diff --git a/CHANGELOG.md b/CHANGELOG.md index 5efa7a4c..5a758dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed + +#### KB compile box: distinct image name + pod prefix (operations) + +Renamed the KB compile box so operators can tell it apart from chat agentboxes at a glance during production troubleshooting (previously every pod was `agentbox-` and the compile box could only be isolated by label/image filter). + +- **Image rename**: `kbc-compile-box` → `siclaw-kbc-box` (Makefile build/push targets, helm `siclaw.compileBoxImage` derivation, Dockerfile/README/docs). `agentbox.compileBoxImage` explicit-override semantics are unchanged. +- **Pod-name prefix**: compile boxes now spawn as `kbc-box-` instead of `agentbox-`, declared on the BoxProfile (`podNamePrefix`) for the `kb-compile` / `kb-compile-codex` profiles. Derived resources (cert Secret, hostname) follow the prefix. Chat agentboxes and the read-only `kb-test` box keep the `agentbox-` prefix. +- **Upgrade behavior**: on the first compile spawn after upgrade, the spawner reaps any pod left under the old `agentbox-` name for that agent (guarded to compile boxes only — a chat box under the same name is never touched), so the old and new pods do not coexist. +- **Transition double-push**: `make push-kbc` republishes the same image digest under the legacy name `kbc-compile-box` for one release cycle so runtimes still pinned to the old name keep resolving. Remove the legacy tag/push next release. +- **DevOps action required**: the registry replication rule (ap-southeast → cn-shanghai) must gain an entry for `siclaw-kbc-box` (the old `kbc-compile-box` was not in the rule and had to be pushed manually). Until the rule is added, push `siclaw-kbc-box` to cn-shanghai manually for production. + ### Added #### Prometheus Observability Layer diff --git a/CLAUDE.md b/CLAUDE.md index 93ae1df5..7c7d45e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ mTLS is **K8s mode only**. Do not add mTLS dependencies to local mode code paths | `src/core/prompt.ts` | **⚠️ REQUIRES HUMAN APPROVAL** | — | Describe intent and wait for OK before editing | | `src/memory/` | invariants.md §7, decisions.md ADR-005 | `npm test` | Requires embedding config; pi-agent only | | `Dockerfile.agentbox` | security.md §3-5 | `docker build` | Dual-user model; capability set; setgid kubectl | -| `kbc/platform/pod/*` (KB compile box, Python) | kbc/platform/pod/README.md | `cd kbc/platform/pod && python test_compile_box.py` (needs `pip install claude-agent-sdk aiohttp`; CI: kbc-ci.yml, path-filtered) | Box↔runtime HTTP+SSE contract is shared with `src/gateway/capability/session-driver.ts` + `server.ts` (`/session` body, event vocabulary) and `agentbox/box-profile.ts` (allowedTools names, ANTHROPIC env forward) — change both sides together. Behavior changes need a `kbc-compile-box` image rebuild + redeploy (runtime env `SICLAW_COMPILE_BOX_IMAGE`); a rolled-out runtime does NOT pick up a new box image for existing live sessions | +| `kbc/platform/pod/*` (KB compile box, Python) | kbc/platform/pod/README.md | `cd kbc/platform/pod && python test_compile_box.py` (needs `pip install claude-agent-sdk aiohttp`; CI: kbc-ci.yml, path-filtered) | Box↔runtime HTTP+SSE contract is shared with `src/gateway/capability/session-driver.ts` + `server.ts` (`/session` body, event vocabulary) and `agentbox/box-profile.ts` (allowedTools names, ANTHROPIC env forward) — change both sides together. Behavior changes need a `siclaw-kbc-box` image rebuild + redeploy (runtime env `SICLAW_COMPILE_BOX_IMAGE`); a rolled-out runtime does NOT pick up a new box image for existing live sessions | | `k8s/` or `helm/` | security.md §5, invariants.md §11 | `helm template` | mTLS K8s-only; container hardening | | `src/agentbox/resource-handlers.ts` | invariants.md §1,6 | `npm test` | `materialize()` safe in K8s, destructive in local mode | | `src/core/job-registry.ts` | tools.md §9 | `npm test` (`job-registry.test.ts`) | `claimNotification` is the single-fire dedup; a completion notice fires exactly once across the process-exit vs `job_stop` race | diff --git a/Makefile b/Makefile index 683a5b71..21b81fe3 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,11 @@ RUNTIME_IMAGE = $(REGISTRY)/siclaw-runtime:$(TAG) AGENTBOX_IMAGE = $(REGISTRY)/siclaw-agentbox:$(TAG) PORTAL_IMAGE = $(REGISTRY)/siclaw-portal:$(TAG) OCR_IMAGE = $(REGISTRY)/siclaw-ocr:$(TAG) -KBC_IMAGE = $(REGISTRY)/kbc-compile-box:$(TAG) +KBC_IMAGE = $(REGISTRY)/siclaw-kbc-box:$(TAG) +# Transition alias (remove next release): the KB compile box's former name. The +# push-kbc target double-pushes this same digest so runtimes still pinned to the +# old name keep resolving during the rename window. +KBC_LEGACY_IMAGE = $(REGISTRY)/kbc-compile-box:$(TAG) # ── OCI labels injected into every image ── DOCKER_LABELS = \ @@ -87,7 +91,7 @@ docker-portal: ## Build portal image docker-ocr: ## Build OCR backend image docker build -f Dockerfile.ocr $(DOCKER_LABELS) -t $(OCR_IMAGE) . -docker-kbc: ## Build KB compile-box image (spawned per compile run; helm agentbox.compileBoxEnabled derives this tag) +docker-kbc: ## Build KB compile-box image siclaw-kbc-box (spawned per compile run; helm agentbox.compileBoxEnabled derives this tag) cd kbc && docker build -f platform/pod/Dockerfile $(DOCKER_LABELS) -t $(KBC_IMAGE) . push: push-runtime push-agentbox push-portal push-ocr push-kbc ## Push all images to registry @@ -104,8 +108,14 @@ push-portal: ## Push portal image push-ocr: ## Push OCR backend image docker push $(OCR_IMAGE) -push-kbc: ## Push KB compile-box image +push-kbc: ## Push KB compile-box image siclaw-kbc-box (+ transition legacy alias) docker push $(KBC_IMAGE) + # Transition double-push (remove next release): republish the SAME digest under + # the legacy name kbc-compile-box so a runtime pinned to it during the rename + # window still resolves. Drop these two lines once all runtimes ship on the + # siclaw-kbc-box name. + docker tag $(KBC_IMAGE) $(KBC_LEGACY_IMAGE) + docker push $(KBC_LEGACY_IMAGE) # ==================== Test ==================== ##@ Test @@ -132,6 +142,7 @@ info: ## Print build variables @echo "PORTAL: $(PORTAL_IMAGE)" @echo "OCR: $(OCR_IMAGE)" @echo "KBC: $(KBC_IMAGE)" + @echo "KBC(legacy): $(KBC_LEGACY_IMAGE)" logs: ## View recent logs (all components) @echo "=== Runtime ===" && \ diff --git a/docs/design/2026-07-kb-authoring-message-idempotency.md b/docs/design/2026-07-kb-authoring-message-idempotency.md index 13843303..7bae5db1 100644 --- a/docs/design/2026-07-kb-authoring-message-idempotency.md +++ b/docs/design/2026-07-kb-authoring-message-idempotency.md @@ -35,4 +35,4 @@ turning the run checkpoint into an unbounded transcript. The field is additive and optional, so Sicore, the runtime, and the compile-box image may roll independently. Full protection is active after all three pieces are deployed; changing `compile_box.py` requires rebuilding the -`kbc-compile-box` image. +`siclaw-kbc-box` image. diff --git a/helm/siclaw/templates/_helpers.tpl b/helm/siclaw/templates/_helpers.tpl index 6acd6504..a61a9c78 100644 --- a/helm/siclaw/templates/_helpers.tpl +++ b/helm/siclaw/templates/_helpers.tpl @@ -82,7 +82,7 @@ Build agentbox image string — same registry/tag as gateway, different componen {{/* KB compile-box image (spawned per compile run by the runtime; NOT a helm-managed pod). Release-coupled by default: agentbox.compileBoxEnabled=true derives -{registry}/kbc-compile-box:{image.tag} — the image ships with every release +{registry}/siclaw-kbc-box:{image.tag} — the image ships with every release (`make docker` builds it). agentbox.compileBoxImage overrides the full string (hot-fix the compile brain independently of a release) and implies enabled. Empty result ⇒ KB stays dark (fail-closed). @@ -93,9 +93,9 @@ Empty result ⇒ KB stays dark (fail-closed). {{- $ab.compileBoxImage -}} {{- else if $ab.compileBoxEnabled -}} {{- if .Values.image.registry -}} -{{- printf "%s/kbc-compile-box:%s" .Values.image.registry .Values.image.tag -}} +{{- printf "%s/siclaw-kbc-box:%s" .Values.image.registry .Values.image.tag -}} {{- else -}} -{{- printf "kbc-compile-box:%s" .Values.image.tag -}} +{{- printf "siclaw-kbc-box:%s" .Values.image.tag -}} {{- end -}} {{- end -}} {{- end }} diff --git a/helm/siclaw/values.yaml b/helm/siclaw/values.yaml index d3b10478..445fcf8b 100644 --- a/helm/siclaw/values.yaml +++ b/helm/siclaw/values.yaml @@ -196,13 +196,13 @@ agentbox: debugImage: "" # KB compile capability (kb-compile / kb-test boxes; built from this repo's # kbc/, ships with every release via `make docker`). The RELEASE-COUPLED - # switch: true ⇒ the runtime spawns {image.registry}/kbc-compile-box:{image.tag} + # switch: true ⇒ the runtime spawns {image.registry}/siclaw-kbc-box:{image.tag} # — same registry/tag convention as every other component. false + no # override ⇒ KB stays dark (fail-closed; compile attempts error, chat # unaffected). compileBoxEnabled: false # Full-image override (implies enabled): hot-fix the compile brain - # independently of a release, e.g. "registry/.../kbc-compile-box:main-". + # independently of a release, e.g. "registry/.../siclaw-kbc-box:main-". compileBoxImage: "" # Node selector applied to every spawned AgentBox pod. Constrains pods to # nodes carrying all of these labels (e.g. { disktype: ssd, pool: agents }). diff --git a/kbc/platform/pod/README.md b/kbc/platform/pod/README.md index 855c2c1e..95d1ca35 100644 --- a/kbc/platform/pod/README.md +++ b/kbc/platform/pod/README.md @@ -48,11 +48,11 @@ platform/pod/.venv/bin/python platform/pod/test_compile_box.py ## Run (container, production form) ```bash -docker build -f platform/pod/Dockerfile -t kbc-compile-box . +docker build -f platform/pod/Dockerfile -t siclaw-kbc-box . docker run --rm -p 3000:3000 \ -e ANTHROPIC_BASE_URL=https:/// \ # model goes through the company massapi (key injected on the proxy side) -v /tmp/wd:/work \ - kbc-compile-box + siclaw-kbc-box # then: # POST :3000/sources {"run_id":"r1","bundle_base64":"...","bundle_sha256":"..."} # or for Source Snapshot v2: diff --git a/kbc/platform/pod/destream.py b/kbc/platform/pod/destream.py index c646cfea..0e034df5 100644 --- a/kbc/platform/pod/destream.py +++ b/kbc/platform/pod/destream.py @@ -20,7 +20,7 @@ codex/OpenAI route is untouched, and KBC_DESTREAM=0/off is the operator escape hatch (e.g. once the gateway ships cross-chunk incremental decoding). Blast radius is this container image only: the shim binds 127.0.0.1 inside -the kbc-compile-box pod; runtime/agentbox/sicore LLM callers never see it. +the siclaw-kbc-box pod; runtime/agentbox/sicore LLM callers never see it. """ from __future__ import annotations diff --git a/src/gateway/agentbox/box-profile.test.ts b/src/gateway/agentbox/box-profile.test.ts index af902c28..4b1e950b 100644 --- a/src/gateway/agentbox/box-profile.test.ts +++ b/src/gateway/agentbox/box-profile.test.ts @@ -24,9 +24,9 @@ describe("getBoxProfile", () => { }); it("kb-compile → dedicated image + LLM env + writable /work + box-owned restricted tools", () => { - process.env.SICLAW_COMPILE_BOX_IMAGE = "kbc-compile-box:test-tag"; + process.env.SICLAW_COMPILE_BOX_IMAGE = "siclaw-kbc-box:test-tag"; const p = getBoxProfile("kb-compile"); - expect(p.image).toBe("kbc-compile-box:test-tag"); + expect(p.image).toBe("siclaw-kbc-box:test-tag"); expect(p.home).toBe("/work"); expect(p.nestedSandbox).toBeUndefined(); expect(p.volumes).toEqual([{ name: "work", mountPath: "/work", sizeLimit: "4Gi" }]); @@ -60,6 +60,17 @@ describe("getBoxProfile", () => { expect(test.allowedTools).not.toContain("Bash"); }); + it("compile profiles declare the kbc-box pod prefix; agent/test keep the default", () => { + // Operational distinguishability: a compile box's pod name must read as a KB + // box, not a chat agentbox, when an operator scans `kubectl get pods`. + expect(getBoxProfile("kb-compile").podNamePrefix).toBe("kbc-box"); + expect(getBoxProfile("kb-compile-codex").podNamePrefix).toBe("kbc-box"); + // The default agent and the read-only kb-test box keep the agentbox prefix + // (kb-test is short-lived and out of scope for this rename). + expect(getBoxProfile("agent").podNamePrefix).toBeUndefined(); + expect(getBoxProfile("kb-test").podNamePrefix).toBeUndefined(); + }); + it("fail-closed: an unknown profile throws (no silent downgrade to the all-tools agent)", () => { expect(() => getBoxProfile("kb-tset")).toThrow(/unknown BoxProfile/); }); diff --git a/src/gateway/agentbox/box-profile.ts b/src/gateway/agentbox/box-profile.ts index 597c6ce4..59a58597 100644 --- a/src/gateway/agentbox/box-profile.ts +++ b/src/gateway/agentbox/box-profile.ts @@ -22,6 +22,14 @@ export interface BoxProfileVolume { export interface BoxProfile { /** Profile name; also the box reuse-key discriminator (A.5) and a pod label. */ name: string; + /** + * Pod-name prefix for boxes of this profile (default "agentbox"). The compile + * profiles override it to "kbc-box" so an operator scanning `kubectl get pods` + * during a production incident can tell a KB compile box from a chat agentbox + * by name alone, not just by label/image filtering. All resources derived from + * the pod name (cert Secret, hostname) follow this prefix automatically. + */ + podNamePrefix?: string; /** * Container image. undefined → the spawner's default agentbox image * (this.config.image / $SICLAW_AGENTBOX_IMAGE). @@ -71,7 +79,7 @@ export const AGENT_PROFILE: BoxProfile = { * explicit compile-box image (helm `agentbox.compileBoxEnabled` ⇒ the * SICLAW_COMPILE_BOX_IMAGE env). This is the single source of truth the Runtime * advertises to its consumer on connect so the consumer can route compile runs - * here WITHOUT any consumer-side config. The bare `kbc-compile-box:latest` + * here WITHOUT any consumer-side config. The bare `siclaw-kbc-box:latest` * fallback in the profiles above is deliberately NOT treated as capable: an * unset env means KB stays dark (fail-closed), so we must not claim capability. */ @@ -89,7 +97,8 @@ export function isCompileCapable(): boolean { function kbCompileProfile(): BoxProfile { return { name: "kb-compile", - image: process.env.SICLAW_COMPILE_BOX_IMAGE || "kbc-compile-box:latest", + podNamePrefix: "kbc-box", + image: process.env.SICLAW_COMPILE_BOX_IMAGE || "siclaw-kbc-box:latest", // LLM credentials arrive in /session after fail-closed input materialization: // consumer block first, Runtime Helm fallback only when that block is absent. // Never duplicate a Runtime secret into the pod spec; only the non-secret @@ -133,7 +142,7 @@ function kbCompileCodexProfile(): BoxProfile { function kbTestProfile(): BoxProfile { return { name: "kb-test", - image: process.env.SICLAW_COMPILE_BOX_IMAGE || "kbc-compile-box:latest", + image: process.env.SICLAW_COMPILE_BOX_IMAGE || "siclaw-kbc-box:latest", envForward: ["ANTHROPIC_BASE_URL", "KBC_*"], home: "/work", // Same cap as kb-compile — the profiles' documented invariant is "identical diff --git a/src/gateway/agentbox/k8s-spawner.test.ts b/src/gateway/agentbox/k8s-spawner.test.ts index dba44322..64d903ee 100644 --- a/src/gateway/agentbox/k8s-spawner.test.ts +++ b/src/gateway/agentbox/k8s-spawner.test.ts @@ -266,7 +266,8 @@ describe("K8sSpawner — spawn branches", () => { let reads = 0; readPodImpl.fn = async () => { reads++; - if (reads === 1) throw Object.assign(new Error("nf"), { code: 404 }); + // reads 1 (legacy agentbox-name probe) + 2 (new kbc-box name) both absent → create. + if (reads <= 2) throw Object.assign(new Error("nf"), { code: 404 }); return { status: { phase: "Running", podIP: "10.0.0.21", conditions: [{ type: "Ready", status: "True" }] }, metadata: { labels: {} } }; }; @@ -362,7 +363,8 @@ describe("K8sSpawner — spawn branches", () => { let reads = 0; readPodImpl.fn = async () => { reads++; - if (reads === 1) throw Object.assign(new Error("nf"), { code: 404 }); + // reads 1 (legacy agentbox-name probe) + 2 (new kbc-box name) both absent → create. + if (reads <= 2) throw Object.assign(new Error("nf"), { code: 404 }); return { status: { phase: "Running", podIP: "10.0.0.23", conditions: [{ type: "Ready", status: "True" }] }, metadata: { labels: {} } }; }; @@ -389,7 +391,8 @@ describe("K8sSpawner — spawn branches", () => { let reads = 0; readPodImpl.fn = async () => { reads++; - if (reads === 1) throw Object.assign(new Error("nf"), { code: 404 }); + // reads 1 (legacy agentbox-name probe) + 2 (new kbc-box name) both absent → create. + if (reads <= 2) throw Object.assign(new Error("nf"), { code: 404 }); return { status: { phase: "Running", podIP: "10.0.0.22", conditions: [{ type: "Ready", status: "True" }] }, metadata: { labels: {} } }; }; @@ -669,6 +672,93 @@ describe("K8sSpawner — spawn branches", () => { }); }); +describe("K8sSpawner — pod-name prefix (compile boxes vs chat) + upgrade migration", () => { + // First read of any given pod name → 404 (absent), subsequent reads → Running. + // Keyed per-name so a legacy lookup and a fresh spawn can interleave. + function perNameFirst404ThenRunning(podIP = "10.0.0.30") { + const seen = new Map(); + return async (args: any) => { + const n = (seen.get(args.name) ?? 0) + 1; + seen.set(args.name, n); + if (n === 1) throw Object.assign(new Error("nf"), { code: 404 }); + return { status: { phase: "Running", podIP, conditions: [{ type: "Ready", status: "True" }] }, metadata: { labels: {} } }; + }; + } + + it("names compile pods with the kbc-box prefix while chat pods keep agentbox", async () => { + const cm = new FakeCertManager(); + const s = new K8sSpawner(); + s.setCertManager(cm as any); + readPodImpl.fn = perNameFirst404ThenRunning(); + + await s.spawn({ agentId: "chatty" }); + await s.spawn({ agentId: "kbrun", profile: "kb-compile" }); + + const bodies = calls.createNamespacedPod.map((c: any) => c.body); + const names = bodies.map((b: any) => b.metadata.name); + expect(names).toContain("agentbox-chatty"); + expect(names).toContain("kbc-box-kbrun"); + // Resources derived from the pod name follow the prefix. + const compilePod = bodies.find((b: any) => b.metadata.name === "kbc-box-kbrun"); + expect(compilePod.spec.hostname).toBe("kbc-box-kbrun"); + const secretNames = calls.createNamespacedSecret.map((c: any) => c.body.metadata.name); + expect(secretNames).toContain("kbc-box-kbrun-cert"); + }); + + it("reaps the legacy agentbox-named compile pod (+ its cert Secret) when spawning under kbc-box", async () => { + const cm = new FakeCertManager(); + const s = new K8sSpawner(); + s.setCertManager(cm as any); + + let legacyDeleted = false; + deletePodImpl.fn = async (args: any) => { + if (args.name === "agentbox-migrated") legacyDeleted = true; + return {}; + }; + const newName = perNameFirst404ThenRunning(); + readPodImpl.fn = async (args: any) => { + if (args.name === "agentbox-migrated") { + if (legacyDeleted) throw Object.assign(new Error("nf"), { code: 404 }); // waitForPodDeleted sees it gone + return { + status: { phase: "Running", podIP: "10.0.0.31", conditions: [{ type: "Ready", status: "True" }] }, + metadata: { labels: { "siclaw.io/boxType": "kb-compile" } }, + }; + } + return newName(args); + }; + + await s.spawn({ agentId: "migrated", profile: "kb-compile" }); + + expect(calls.deleteNamespacedPod.some((c: any) => c.name === "agentbox-migrated")).toBe(true); + expect(calls.deleteNamespacedSecret.some((c: any) => c.name === "agentbox-migrated-cert")).toBe(true); + // The new box is created under the renamed prefix, not the old one. + expect(calls.createNamespacedPod.map((c: any) => c.body.metadata.name)).toEqual(["kbc-box-migrated"]); + }); + + it("never reaps a legacy agentbox-named CHAT pod that happens to share the agentId", async () => { + const cm = new FakeCertManager(); + const s = new K8sSpawner(); + s.setCertManager(cm as any); + + const newName = perNameFirst404ThenRunning(); + readPodImpl.fn = async (args: any) => { + if (args.name === "agentbox-shared") { + // A chat box under this name owns its own idle-destruct lifecycle. + return { + status: { phase: "Running", podIP: "10.0.0.32", conditions: [{ type: "Ready", status: "True" }] }, + metadata: { labels: { "siclaw.io/boxType": "agent" } }, + }; + } + return newName(args); + }; + + await s.spawn({ agentId: "shared", profile: "kb-compile" }); + + expect(calls.deleteNamespacedPod.some((c: any) => c.name === "agentbox-shared")).toBe(false); + expect(calls.createNamespacedPod.map((c: any) => c.body.metadata.name)).toEqual(["kbc-box-shared"]); + }); +}); + describe("K8sSpawner — stop", () => { it("deletes pod + cert Secret", async () => { const s = new K8sSpawner(); @@ -1058,10 +1148,11 @@ describe("K8sSpawner — capability-box orphan sweep + burstable resources (audi let reads = 0; g.__k8sImpls.readNamespacedPod = async () => { reads++; - if (reads === 1) throw Object.assign(new Error("nf"), { code: 404 }); + // reads 1 (legacy agentbox-name probe) + 2 (new kbc-box name) both absent → create. + if (reads <= 2) throw Object.assign(new Error("nf"), { code: 404 }); return { status: { phase: "Running", podIP: "10.0.0.9", conditions: [{ type: "Ready", status: "True" }] }, metadata: { labels: {} } }; }; - process.env.SICLAW_COMPILE_BOX_IMAGE = "kbc-compile-box:test"; + process.env.SICLAW_COMPILE_BOX_IMAGE = "siclaw-kbc-box:test"; try { await s.spawn({ agentId: "res-test", profile: "kb-compile" }); } finally { diff --git a/src/gateway/agentbox/k8s-spawner.ts b/src/gateway/agentbox/k8s-spawner.ts index d3ec73a1..519037fd 100644 --- a/src/gateway/agentbox/k8s-spawner.ts +++ b/src/gateway/agentbox/k8s-spawner.ts @@ -104,10 +104,40 @@ export class K8sSpawner implements BoxSpawner { * Generate Pod name — keyed on agentId only (one pod per agent, shared * across callers). Sanitized to the K8s name charset and capped so the * full name stays under 63 chars. + * + * The prefix comes from the BoxProfile (default "agentbox"; compile boxes use + * "kbc-box"). Both prefixes are ≤ 8 chars, so the 50-char agentId cap keeps the + * full name well under 63. */ - private podName(agentId: string): string { + private podName(agentId: string, prefix = "agentbox"): string { const sanitized = agentId.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 50); - return `agentbox-${sanitized}`; + return `${prefix}-${sanitized}`; + } + + /** + * Upgrade migration: reap a pod left under the OLD "agentbox-" name for a + * profile that now spawns under a different prefix (e.g. "kbc-box-"). Deletes + * the pod and its cert Secret, then waits for it to disappear so the agent + * never has two live boxes across the rename. + * + * Guard: only a compile box (boxType label "kb-compile*") is reaped. A chat + * box (boxType "agent") keeps the "agentbox-" name and owns its own lifecycle; + * it must never be touched even if it happens to share this agentId. A missing + * legacy pod (404) is the normal post-migration steady state → no-op. + */ + private async reapRenamedLegacyPod(legacyName: string, namespace: string, labelPrefix: string): Promise { + let existing: k8s.V1Pod; + try { + existing = await this.coreApi.readNamespacedPod({ name: legacyName, namespace }); + } catch (err: any) { + if (err.code === 404 || err.statusCode === 404) return; + throw err; + } + const boxType = existing.metadata?.labels?.[`${labelPrefix}/boxType`] || "agent"; + if (!boxType.startsWith("kb-compile")) return; + console.log(`[k8s-spawner] Reaping legacy-named pod ${legacyName} (boxType=${boxType}) superseded by renamed prefix`); + await this.stop(legacyName); // deletes pod + cert Secret, 404-tolerant + await this.waitForPodDeleted(legacyName, namespace); } private gatewayUrl(namespace: string): string { @@ -137,11 +167,22 @@ export class K8sSpawner implements BoxSpawner { const image = boxConfig.image ?? profile.image ?? this.config.image; const agentId = boxConfig.agentId; if (!agentId) throw new Error("K8sSpawner.spawn requires a non-empty agentId"); - const podName = this.podName(agentId); + const podPrefix = profile.podNamePrefix ?? "agentbox"; + const podName = this.podName(agentId, podPrefix); const orgId = boxConfig.orgId || ""; console.log(`[k8s-spawner] Creating pod: ${podName} for agent: ${agentId}`); + // Upgrade compatibility: a profile that carries a non-default pod prefix used + // to spawn under the "agentbox-" name. After the rename, the spawner looks up + // (and reuses) the NEW name only, so a pre-upgrade pod under the old name would + // leak — two boxes for one agent, the old one never reaped. Reap the stale + // old-named pod (+ its cert Secret) first, but only when it is a compile box + // (boxType "kb-compile*") — never a chat box that merely shares the agentId. + if (podPrefix !== "agentbox") { + await this.reapRenamedLegacyPod(this.podName(agentId, "agentbox"), namespace, labelPrefix); + } + // Stamp the pod + its cert Secret with the CA fingerprint. The runtime uses // it to detect pods whose mTLS cert was signed by a rotated CA (those can no // longer complete mTLS in either direction) and recycle them — see the reuse diff --git a/src/gateway/agentbox/manager.test.ts b/src/gateway/agentbox/manager.test.ts index 8719d6bd..07899bbb 100644 --- a/src/gateway/agentbox/manager.test.ts +++ b/src/gateway/agentbox/manager.test.ts @@ -178,15 +178,16 @@ describe("AgentBoxManager — K8s mode", () => { const created = await mgr.getOrCreateWithDisposition("new-run", { profile: "kb-compile" }); expect(created.created).toBe(true); - spawner.getReturns.set("agentbox-live-run", { - boxId: "agentbox-live-run", agentId: "live-run", status: "running", + // A kb-compile box spawns under the "kbc-box-" prefix, not "agentbox-". + spawner.getReturns.set("kbc-box-live-run", { + boxId: "kbc-box-live-run", agentId: "live-run", status: "running", endpoint: "https://10.0.0.9:3000", createdAt: new Date(), lastActiveAt: new Date(), profile: "kb-compile", }); const adopted = await mgr.getOrCreateWithDisposition("live-run", { profile: "kb-compile" }); expect(adopted).toMatchObject({ created: false, - handle: { boxId: "agentbox-live-run", endpoint: "https://10.0.0.9:3000" }, + handle: { boxId: "kbc-box-live-run", endpoint: "https://10.0.0.9:3000" }, }); }); @@ -201,8 +202,8 @@ describe("AgentBoxManager — K8s mode", () => { it("reuses a running pod when the requested profile matches", async () => { const spawner = new FakeSpawner("k8s"); const mgr = new AgentBoxManager(spawner); - spawner.getReturns.set("agentbox-run-1", { - boxId: "agentbox-run-1", agentId: "run-1", status: "running", + spawner.getReturns.set("kbc-box-run-1", { + boxId: "kbc-box-run-1", agentId: "run-1", status: "running", endpoint: "https://10.0.0.9:3000", createdAt: new Date(), lastActiveAt: new Date(), profile: "kb-compile", }); @@ -215,17 +216,18 @@ describe("AgentBoxManager — K8s mode", () => { it("stops and respawns when the running pod's profile no longer matches", async () => { const spawner = new FakeSpawner("k8s"); const mgr = new AgentBoxManager(spawner); - // A pod is running as kb-compile, but the same id is now requested as kb-test. - spawner.getReturns.set("agentbox-run-1", { - boxId: "agentbox-run-1", agentId: "run-1", status: "running", + // A pod is running as kb-compile, but the same id is now requested as + // kb-compile-codex — a realistic same-prefix ("kbc-box-") profile switch. + spawner.getReturns.set("kbc-box-run-1", { + boxId: "kbc-box-run-1", agentId: "run-1", status: "running", endpoint: "https://10.0.0.9:3000", createdAt: new Date(), lastActiveAt: new Date(), profile: "kb-compile", }); - const handle = await mgr.getOrCreate("run-1", { profile: "kb-test" }); + const handle = await mgr.getOrCreate("run-1", { profile: "kb-compile-codex" }); // Old-shaped pod stopped; a fresh box spawned with the requested profile. - expect(spawner.stopCalls).toEqual(["agentbox-run-1"]); + expect(spawner.stopCalls).toEqual(["kbc-box-run-1"]); expect(spawner.spawnCalls).toHaveLength(1); - expect(spawner.spawnCalls[0].profile).toBe("kb-test"); + expect(spawner.spawnCalls[0].profile).toBe("kb-compile-codex"); expect(handle.boxId).toBe("box-run-1"); }); @@ -276,6 +278,27 @@ describe("AgentBoxManager — K8s mode", () => { await mgr.stop("agent-a"); expect(spawner.stopCalls).toEqual(["agentbox-agent-a"]); }); + + it("stop(runId, 'kb-compile') targets the kbc-box-prefixed pod (reap must not leak it)", async () => { + const spawner = new FakeSpawner("k8s"); + const mgr = new AgentBoxManager(spawner); + await mgr.stop("run-1", "kb-compile"); + expect(spawner.stopCalls).toEqual(["kbc-box-run-1"]); + }); + + it("getAsync(runId, 'kb-compile') finds the kbc-box-prefixed live box (adopt re-attach)", async () => { + const spawner = new FakeSpawner("k8s"); + const mgr = new AgentBoxManager(spawner); + spawner.getReturns.set("kbc-box-run-1", { + boxId: "kbc-box-run-1", agentId: "run-1", status: "running", + endpoint: "https://10.0.0.9:3000", createdAt: new Date(), lastActiveAt: new Date(), + profile: "kb-compile", + }); + const handle = await mgr.getAsync("run-1", "kb-compile"); + expect(handle?.boxId).toBe("kbc-box-run-1"); + // Without the profile it would look under "agentbox-run-1" and miss. + expect(await mgr.getAsync("run-1")).toBeUndefined(); + }); }); // ── Per-agent persistence is anchored at cold spawn ──────────────────── diff --git a/src/gateway/agentbox/manager.ts b/src/gateway/agentbox/manager.ts index b9459ee1..7a0941bf 100644 --- a/src/gateway/agentbox/manager.ts +++ b/src/gateway/agentbox/manager.ts @@ -11,6 +11,7 @@ import type { BoxSpawner } from "./spawner.js"; import type { AgentBoxConfig, AgentBoxHandle, AgentBoxInfo } from "./types.js"; +import { getBoxProfile } from "./box-profile.js"; export interface AgentBoxManagerConfig { /** Health check interval (ms) — local dev only */ @@ -130,10 +131,20 @@ export class AgentBoxManager { /** * Pod / box name. One pod per agent — we trim agentId to keep under the 63-char * K8s name limit and only sanitize forbidden characters. + * + * The prefix is profile-derived and MUST match K8sSpawner.podName (compile + * boxes are "kbc-box-", everything else "agentbox-"): the manager looks a pod + * up by this computed name for warm reuse, liveness and stop, so a mismatch + * would miss the real pod (a leaked box on stop, a missed re-attach on adopt). */ - private podName(agentId: string): string { + private podName(agentId: string, prefix = "agentbox"): string { const sanitized = agentId.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 50); - return `agentbox-${sanitized}`; + return `${prefix}-${sanitized}`; + } + + /** Pod-name prefix a profile spawns under (see K8sSpawner / BoxProfile.podNamePrefix). */ + private prefixForProfile(profile: string | undefined): string { + return getBoxProfile(profile).podNamePrefix ?? "agentbox"; } private async runHealthCheck(): Promise { @@ -187,8 +198,8 @@ export class AgentBoxManager { agentId: string, config?: Partial, ): Promise { - const name = this.podName(agentId); const wantProfile = config?.profile ?? "agent"; + const name = this.podName(agentId, this.prefixForProfile(wantProfile)); const info = await this.spawner.get(name); if (info && info.status === "running" && info.endpoint && this.isCertFresh(info)) { @@ -303,9 +314,9 @@ export class AgentBoxManager { return undefined; } - async getAsync(agentId: string): Promise { + async getAsync(agentId: string, profile?: string): Promise { if (this.isK8s) { - const name = this.podName(agentId); + const name = this.podName(agentId, this.prefixForProfile(profile)); const info = await this.spawner.get(name); if (info && info.status === "running" && info.endpoint) { return { boxId: name, endpoint: info.endpoint, agentId }; @@ -315,9 +326,9 @@ export class AgentBoxManager { return this.get(agentId); } - async stop(agentId: string): Promise { + async stop(agentId: string, profile?: string): Promise { if (this.isK8s) { - const name = this.podName(agentId); + const name = this.podName(agentId, this.prefixForProfile(profile)); console.log(`[agentbox-manager] Stopping AgentBox ${name}`); await this.spawner.stop(name); return; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 036d77d6..701897e4 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -474,7 +474,10 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise { - await agentBoxManager.stop(rec.runId).catch((err) => { + // Pass the run's profile so the manager targets the right pod name — a + // compile box is "kbc-box-", not "agentbox-"; a mismatched name + // would 404 and leak the pod instead of reaping it. + await agentBoxManager.stop(rec.runId, rec.profile).catch((err) => { console.warn(`[capability] reap: stopping box ${rec.runId} failed:`, err instanceof Error ? err.message : String(err)); }); }, @@ -487,7 +490,7 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise { void (async () => { try { - const alive = await agentBoxManager.getAsync(rec.runId); + const alive = await agentBoxManager.getAsync(rec.runId, rec.profile); if (!alive) return; await ensureCapabilitySession(rec.runId, rec.profile, rec.orgId || undefined, undefined); console.log(`[capability] re-attached relay to live box for recovered run ${rec.runId}`); @@ -604,7 +607,12 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise