8000 feat(sandbox): keep offices on the sandbox image of the running mikan by geminixiang · Pull Request #175 · geminixiang/mikan · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,10 @@ You need Node.js 24.15 or later and a container engine: nerdctl, Podman, or Dock
```bash
npm i -g @geminixiang/mikan pm2
mikan onboard # pick a chat platform, a model, and a sandbox image
docker pull ghcr.io/geminixiang/mikan-sandbox:latest
pm2 start ~/.mikan/ecosystem.config.cjs && pm2 save
```

That's it: `@mention` the bot in a channel or send it a DM. `mikan onboard` prints the exact `docker pull` line for the image you picked; the recommended image is about 900 MB, so the pull takes a few minutes the first time. Run `pm2 startup` once if mikan should start on boot.
That's it: `@mention` the bot in a channel or send it a DM. mikan pulls the sandbox image when it starts, and pulls the matching image again after each upgrade; the recommended image is about 900 MB, so the first start takes a few minutes. Run `pm2 startup` once if mikan should start on boot.

Everything lives in `~/.mikan`: `settings.json` for behavior (model, sandbox limits, reply modes), `mikan.env` for secrets and platform tokens, and `ecosystem.config.cjs` for process supervision. To add another platform later, put its tokens in `mikan.env` and run `pm2 reload ~/.mikan/ecosystem.config.cjs` so pm2 rereads the file; `mikan env` lists every variable mikan reads.

Expand Down
2 changes: 1 addition & 1 deletion deploy/pm2/ecosystem.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ module.exports = {
name: "mikan",
script: "mikan",

args: "--sandbox=oci:ghcr.io/geminixiang/mikan-sandbox:latest",
args: "--sandbox=oci",

env: loadEnvFile(path.join(os.homedir(), ".mikan", "mikan.env")),

Expand Down
32 changes: 32 additions & 0 deletions docs/adr/0023-mikan-keeps-its-sandbox-image-current.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
status: accepted
---

# mikan keeps its sandbox image current

`--sandbox=oci` runs the image published for the running mikan version, `ghcr.io/geminixiang/mikan-sandbox:<version>`. On start the provisioner pulls its configured image in the background, whatever image that is. Offices move to the pulled image the next time they start after an idle stop (ADR 0014), and the last container replaced away from an older published image removes it.

## Context

ADR 0009 left pulling to deployments: mikan compared containers with the local image and never pulled. Deployments configured the mutable `:latest` tag and pulled by hand, if at all. One production deployment ran mikan 1.3.0 on an image pulled three weeks and seven releases earlier, with some offices on an image from four months before; offices failed when they used a browser that only the newer image contained. Each release also rebuilt the image from scratch, so even an unchanged Dockerfile meant a new 940 MB download.

## Decision

- **One image per release.** A release publishes `:<version>`; stable releases also move `:latest`. A release whose Dockerfile is unchanged and whose image is under 30 days old tags the previous image instead of rebuilding it, so most upgrades download nothing and replace no container, while tools still refresh about monthly.
- **The version picks the image.** `--sandbox=oci` resolves to that release's tag, so mikan and its sandbox upgrade and roll back together. `oci:<image>` keeps any other image.
- **mikan pulls.** A pull at start is a manifest check when nothing changed. A failed 8000 pull is a warning, and offices keep the local image; an image that exists only locally keeps working. Creating a container waits for the same pull rather than starting another.
- **mikan removes only what it stopped using.** After replacing a container for image drift, it runs `rmi` without force on the old container's published reference, or on its image ID otherwise. Engines refuse while any container uses the image, and an image ID while a name still points to it, so an operator's own images and tags stay.

## Considered options

- **Pull only the default image**: deployments pinned to `:latest` would stay stale until an operator edits their configuration.
- **Delete by repository name**: would remove images an operator built or tagged under that name.
- **Replace running containers once the image arrives**: an office could lose a command in flight; replacing at the next start after an idle stop reaches every active office within the idle timeout.

## Consequences

- Upgrading or rolling back mikan is the whole procedure; no `docker pull` step remains in onboarding or the deployment guide.
- A daemon start contacts the registry once. On a host without registry access the warning repeats on each start.
- A version with no published image, such as a build from an unreleased commit, fails to create new offices until it is given `oci:<image>`.
- Offices that never start again keep their container and its image until removed.
- Point 5 of ADR 0009, that mikan never pulls, no longer holds.
56 changes: 56 additions & 0 deletions docs/research/sandbox-image-versions-2026-10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Keeping the sandbox image in step with mikan

Date: 2026-10-11. Question: why do offices keep running an old sandbox image after mikan upgrades, and how can mikan move them to the image of its own release without operator steps?

## What happens today

- The provisioner pulls the configured image only when no local image has that reference (`pulledImageId`), and only while creating a container.
- Image drift compares a stopped container's `mikan.image-id` label with the **local** image ID (`hasImageDrift`). An image that was never pulled is not drift, so the container is started again unchanged.
- onboarding, the pm2 template, and the documentation configure `oci:ghcr.io/geminixiang/mikan-sandbox:latest`, a tag that moves on the registry without the host noticing.

One production deployment, inspected on 2026-10-11, ran mikan 1.3.0 with a local `:latest` pulled on 2026-09-23. Of its 56 office containers, 35 used that image, 16 an image from June, and 5 others; seven releases had published newer images in between. Offices that used the browser in late September failed 23 times because their containers predated the image that added it.

## How releases publish the image

Each release publishes `ghcr.io/geminixiang/mikan-sandbox:<version>`, and stable releases also move `:latest`. Since #174 a release whose Dockerfile is unchanged and whose image is under 30 days old tags the previous image instead of rebuilding it, so consecutive versions usually share one digest: upgrading mikan then downloads nothing and leaves containers alone. Before #174 every release rebuilt the image and 936 of its 1,013 compressed MB were new layers.

## Engine behavior the design relies on

| Behavior | Result |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `pull` of a reference already present and current | completes after a manifest check and changes nothing |
| `pull` of a reference the registry lacks | fails; a local image with that reference stays usable |
| `rmi <reference>` without `-f` while any container, running or stopped, uses the image | refused (nerdctl: "image is being used by stopped container"; Docker when it is the only name) |
| `rmi <image ID>` | Docker refuses while several names point to the image; nerdctl answers "no such image" for any ID |
| a tag moved by a pull or `tag` on nerdctl | leaves no unnamed image behind |

## Design

1. `--sandbox=oci` without an image means `ghcr.io/geminixiang/mikan-sandbox:<mikan version>`; `oci:<image>` stays for any other image.
2. At startup the provisioner pulls the configured image in the background. A failed pull is logged as a warning, and offices keep the local image if there is one. Creating a container waits for that same pull instead of starting a second one.
3. Drift then compares against the freshly pulled image, so each office moves to it the next time it starts after an idle stop; a running office is never replaced.
4. After replacing a container for image drift, the provisioner removes the image the old container used, by ID and without force. The engine refuses while another container still uses it, so the last office to move releases the old image.

Because step 2 applies to any image, deployments pinned to `:latest` also follow the registry from their next restart; switching them to `--sandbox=oci` additionally keeps the image matched to the running mikan.

Rejected:

- **Pull only the default image**: deployments configured with `:latest` would stay stale until an operator edits their configuration.
- **Delete images by repository name**: it would remove images an operator built or tagged under that name; removing by the replaced container's image ID touches only what mikan itself stopped using.
- **Replace running containers at startup**: an office could lose a command in flight; replacement at the next start after an idle stop already reaches every active office within the idle timeout.

## Acceptance eval

`.workspace/versioned-image/upgrade.eval.ts`, against a local `registry:2` holding two images that differ only in `/sandbox-version`, on nerdctl:

| Case | main |
| ------------------------------------------------------------ | --------------------------- |
| C1 `--sandbox=oci` | error: invalid sandbox type | `ghcr.io/geminixiang/mikan-sandbox:1.3.0` |
| C2 stopped office after upgrading to an image not yet pulled | stays on 1.0.0 | moves to 1.1.0 |
| C3 old image after its only office moved | kept | removed |
| C4 old image while a second office still uses it | kept, kept | kept, then removed |
| C5 upgraded to a version the registry lacks | office runs 1.0.0 | office runs 1.0.0 |
| C6 local-only image | office runs | office runs |
| C7 running office during an upgrade | stays on 1.0.0 | stays on 1.0.0 |
| C8 new office after upgrading | 1.1.0 | 1.1.0 |
| C9 `:latest` moved on the registry, daemon restarted | stays on 1.0.0 | moves to 1.1.0 |
2 changes: 1 addition & 1 deletion src/cli/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function bootCommand() {
.description("Multi-platform chat agent daemon")
.helpOption(false)
.argument("[working-directory...]", "Workspace directory (default: ~/.mikan/workspace)")
.option("--sandbox <spec>", "host | container:<name> | oci:<image[:tag]>", nonEmptyValue)
.option("--sandbox <spec>", "host | container:<name> | oci | oci:<image[:tag]>", nonEmptyValue)
.option("--onboard", "Interactive first-run setup (also: mikan onboard)")
.option("-v, --version", "Print version")
.option("-V", "Print version")
Expand Down
29 changes: 16 additions & 13 deletions src/cli/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { atomicWritePrivateFile } from "../file-guards.js";
import {
MINIMAL_SANDBOX_IMAGE,
PM2_ECOSYSTEM_FILENAME,
PUBLISHED_SANDBOX_IMAGE,
renderPm2Ecosystem,
} from "./pm2-ecosystem.js";
import { PUBLISHED_SANDBOX_SPEC } from "../sandbox/registry.js";
import { publishedSandboxImage } from "../sandbox/published-image.js";
import type { OnboardIo } from "./types.js";

class OnboardAborted extends Error {
Expand Down Expand Up @@ -126,20 +127,22 @@ async function askLlm(
return { llm: { provider, model: ids[0]! }, modelsJson };
}

async function askSandboxImage(io: OnboardIo): Promise<string> {
io.print("\nStep 3/3 — sandbox image (one Docker container per conversation; needs Docker)");
async function askSandboxSpec(io: OnboardIo): Promise<string> {
io.print(
"\nStep 3/3 — sandbox image (one container per conversation; needs nerdctl, Podman, or Docker)",
);
const index = await askChoice(
io,
[
`${PUBLISHED_SANDBOX_IMAGE} (recommended: git, gh, node, python, curl)`,
`${publishedSandboxImage()} (recommended: git, gh, gcloud, node, python, browser; follows mikan upgrades)`,
`${MINIMAL_SANDBOX_IMAGE} (minimal: shell only, no git or package installs)`,
"Another image from Docker Hub or a registry…",
],
"Sandbox image",
);
if (index === 0) return PUBLISHED_SANDBOX_IMAGE;
if (index === 1) return MINIMAL_SANDBOX_IMAGE;
return askRequired(io, " Image (e.g. registry/name:tag): ");
if (index === 0) return PUBLISHED_SANDBOX_SPEC;
if (index === 1) return `oci:${MINIMAL_SANDBOX_IMAGE}`;
return `oci:${await askRequired(io, " Image (e.g. registry/name:tag): ")}`;
}

export function renderEnvFile(existing: string | undefined, vars: Record<string, string>): string {
Expand Down Expand Up @@ -177,7 +180,7 @@ export async function runOnboardWizard(
const env: Record<string, string> = {};
const { githubRepos } = await askAdapter(io, env);
const { llm, modelsJson } = await askLlm(io, env);
const image = await askSandboxImage(io);
const sandboxSpec = await askSandboxSpec(io);

const modelsPath = join(stateDir, "models.json");
if (modelsJson && existsSync(modelsPath)) {
Expand All @@ -187,7 +190,7 @@ export async function runOnboardWizard(
return 1;
}
io.print(
`Review settings\nProvider: ${llm.provider}\nModel: ${llm.model}\nSandbox: oci:${image}\nSettings: ${settingsPath}\nCredentials: ${paths?.envFilePath ?? join(stateDir, "mikan.env")}${modelsJson ? `\nModels: ${modelsPath}` : ""}\nSecrets are hidden.`,
`Review settings\nProvider: ${llm.provider}\nModel: ${llm.model}\nSandbox: ${sandboxSpec}\nSettings: ${settingsPath}\nCredentials: ${paths?.envFilePath ?? join(stateDir, "mikan.env")}${modelsJson ? `\nModels: ${modelsPath}` : ""}\nSecrets are hidden.`,
);
if (!(await io.confirm("Save these settings?"))) {
io.print("Onboarding cancelled; nothing was written.");
Expand All @@ -214,16 +217,16 @@ export async function runOnboardWizard(
if (existsSync(ecosystemPath)) {
io.print(`Kept existing ${ecosystemPath}`);
} else {
atomicWritePrivateFile(ecosystemPath, renderPm2Ecosystem(image));
atomicWritePrivateFile(ecosystemPath, renderPm2Ecosystem(sandboxSpec));
io.print(`Wrote ${ecosystemPath}`);
}

io.print("Next steps:");
io.print(` 1. docker pull ${image}`);
io.print(` 2. pm2 start ${ecosystemPath} && pm2 save`);
io.print(` pm2 start ${ecosystemPath} && pm2 save`);
io.print(
` Foreground instead: set -a; source ${envFilePath}; set +a; mikan --sandbox=oci:${image}`,
` Foreground instead: set -a; source ${envFilePath}; set +a; mikan --sandbox=${sandboxSpec}`,
);
io.print(" mikan pulls the sandbox image on start.");
return 0;
}

Expand Down
6 changes: 2 additions & 4 deletions src/cli/pm2-ecosystem.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
export const PUBLISHED_SANDBOX_IMAGE = "ghcr.io/geminixiang/mikan-sandbox:latest";

export const MINIMAL_SANDBOX_IMAGE = "debian:trixie-slim";

export const PM2_ECOSYSTEM_FILENAME = "ecosystem.config.cjs";

export function renderPm2Ecosystem(image: string): string {
export function renderPm2Ecosystem(sandboxSpec: string): string {
return `const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
Expand Down Expand Up @@ -41,7 +39,7 @@ module.exports = {
name: "mikan",
script: "mikan",

args: ${JSON.stringify(`--sandbox=oci:${image}`)},
args: ${JSON.stringify(`--sandbox=${sandboxSpec}`)},

env: loadEnvFile(path.join(os.homedir(), ".mikan", "mikan.env")),

Expand Down
11 changes: 2 additions & 9 deletions src/content/docs/deployment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,15 @@ The published `mikan-sandbox` image is a tool runtime, not a standalone mikan se
that file; `mikan env` lists every variable and what is currently set. An existing
`~/.mikan/ecosystem.config.cjs` is kept as it is.

3. Pull the sandbox image you picked. Onboard prints this line; the published image is about
900 MB:

```bash
docker pull ghcr.io/geminixiang/mikan-sandbox:latest
```

4. Start and persist the process:
3. Start and persist the process:

```bash
pm2 start ~/.mikan/ecosystem.config.cjs
pm2 save
pm2 startup
```

5. Run the command printed by `pm2 startup` to enable boot startup.
4. Run the command printed by `pm2 startup` to enable boot startup.

</Steps>

Expand Down
1 change: 0 additions & 1 deletion src/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ import CTA from "@geminixiang/mikan-starlight-theme/components/CTA.astro";
lines={[
"$ npm i -g @geminixiang/mikan pm2",
"$ mikan onboard",
"$ docker pull ghcr.io/geminixiang/mikan-sandbox:latest",
"$ pm2 start ~/.mikan/ecosystem.config.cjs",
" mikan online — Slack, Telegram, Discord, GitHub",
]}
Expand Down
10 changes: 2 additions & 8 deletions src/content/docs/ja/deployment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,15 @@ import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
この file を編集します。`mikan env` はすべての変数と現在の設定状況を表示します。既存の
`~/.mikan/ecosystem.config.cjs` はそのまま残ります。

3. 選んだ sandbox image を pull します。onboard がこの行を表示します。公開 image は約 900 MB です:

```bash
docker pull ghcr.io/geminixiang/mikan-sandbox:latest
```

4. process を起動し、永続化します:
3. process を起動し、永続化します:

```bash
pm2 start ~/.mikan/ecosystem.config.cjs
pm2 save
pm2 startup
```

5. `pm2 startup` が出力したコマンドを実行し、boot 時の自動起動を有効にします。
4. `pm2 startup` が出力したコマンドを実行し、boot 時の自動起動を有効にします。

</Steps>

Expand Down
7 changes: 3 additions & 4 deletions src/content/docs/ja/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,13 @@ import { Aside, Steps } from "@astrojs/starlight/components";
export TELEGRAM_BOT_TOKEN="123456:ABC-..."
```

4. sandbox image を pull し、mikan をフォアグラウンドで起動します:
4. mikan をフォアグラウンドで起動します。起動時に sandbox image を pull します:

```bash
docker pull ghcr.io/geminixiang/mikan-sandbox:latest
mikan --sandbox=oci:ghcr.io/geminixiang/mikan-sandbox:latest
mikan --sandbox=oci
```

image は約 900 MB なので、初回の pull には数分かかります。working directory は既定で `~/.mikan/workspace` になり、自動的に作成されます。別の場所を使うにはフラグの後にパスを渡します。
image は約 900 MB なので、初回の起動には数分かかります。working directory は既定で `~/.mikan/workspace` になり、自動的に作成されます。別の場所を使うにはフラグの後にパスを渡します。

その workspace の中で、各会話は自分の **office** を持ちます。これは、その会話のメモリー、添付ファイル、セッションログ、skills、作業領域を保持するディレクトリです。office ディレクトリは生のチャット id ではなく office key(`v1-telegram-…`)で命名されるため、どの office がどの会話のものかを確認するには `mikan office list` を使います。

Expand Down
Loading
Loading
0