From ec1b74c85ae34a37334359641834be27ae970995 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 22 Jul 2026 20:19:32 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat(kbc):=20coverage=20v2=20=E2=80=94=20?= =?UTF-8?q?media=20assets=20auto-attach=20via=20citing=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media assets (images under assets/) are document attachments, not first-class sources. Coverage v2 auto-accounts an image against any document that embeds it in its body, once that document is itself cited or excluded — so compiled_from no longer needs a token per image and the exclusion ledger no longer needs a row per image. Motivation - compiled_from bloat: aggregated cf drowned in image tokens (UHI 昇腾.md: 139 entries = 35 docs + 104 images), unreadable to a human. - exclusion-ledger noise: images as first-class sources forced ~563 of UHI's 603 exclusion rows to be mechanical asset entries. - attribution gap: the schema had no way to express "which document embeds this image", so mediaverify and the collapse UI each guessed with heuristics. Math (v2) auto = { a in media_assets | exists d in (cited | excluded): edge(d->a) } unaccounted = sources - cited - excluded - auto - edges are derived from the raw tree (each document's body ![](), [](), and links), resolved relative to the document + posixpath.normpath + URL-decoded; a link to a nonexistent/non-media path yields no edge, no error. - an ORPHAN image (embedded by no accounted document — upload residue) is NOT auto-attached; it stays unaccounted and must be excluded with a reason so a human still sees it (anti fail-open). - a directly-cited asset still counts as cited (v1 compatibility). Monotonicity (old green libraries stay green) v2 only ADDS an accounting path, so unaccounted can only shrink. All pre-existing selfcheck tests pass unchanged — none asserted "unreferenced asset => unaccounted" over an assets/ path (the media-asset predicate requires an assets/ segment, which no existing test used). Sheet-placeholder carve-out assets/sheets/*.md table placeholders are content files, not media (is_media_asset requires an image extension). They stay first-class sources — auto-attaching them would launder "the table data was never compiled" into "covered". Observability coverage adds auto_attached (count) and auto_attached_sample (<=20 asset<-via edges), so auto-accounting is never a silent fail-open. Two-repo fixture kbc/platform/pod/fixtures/asset-provenance/ is the shared, byte-for-byte contract (raw tree + candidate + EXCLUSIONS + expected.json). sicore's adoption ledger will assert the SAME expected.json. Cases: relative link, ../ cross-dir, HTML , URL-encoded path, missing target (no edge), 0-byte download-failed placeholder, assets/sheets/*.md (not media), image shared by a cited and an unaccounted document, orphan image, image inheriting a document's exclusion, directly-cited asset. S1b: box constitution now tells the compile agent to cite documents only — images auto-attach; do not cite or exclude them one-by-one; orphan images still need a reasoned exclusion; assets/sheets/ placeholders handled as usual. --- .../pod/fixtures/asset-provenance/README.md | 17 +++ .../authoring/EXCLUSIONS.json | 10 ++ .../asset-provenance/candidate/detail.md | 7 + .../asset-provenance/candidate/index.md | 8 + .../asset-provenance/candidate/overview.md | 8 + .../asset-provenance/candidate/setup.md | 7 + .../asset-provenance/candidate/tables.md | 7 + .../fixtures/asset-provenance/expected.json | 70 +++++++++ .../fixtures/asset-provenance/raw/archive.md | 4 + .../raw/assets/directcited.png | 1 + .../asset-provenance/raw/assets/hero.png | 0 .../asset-provenance/raw/assets/inherited.png | 1 + .../raw/assets/orphan-excluded.png | 1 + .../asset-provenance/raw/assets/orphan.png | 1 + .../asset-provenance/raw/assets/shared.png | 1 + .../raw/assets/sheets/table1.md | 4 + .../asset-provenance/raw/excluded-doc.md | 3 + .../asset-provenance/raw/guide/assets/a b.png | 1 + .../raw/guide/assets/diagram.png | 1 + .../raw/guide/assets/screenshot.png | 1 + .../asset-provenance/raw/guide/deep/detail.md | 3 + .../asset-provenance/raw/guide/setup.md | 9 ++ .../fixtures/asset-provenance/raw/overview.md | 6 + .../raw/report.assets/fig.png | 1 + kbc/platform/pod/prompts/en/box_role.md | 2 +- kbc/platform/pod/prompts/zh/box_role.md | 2 +- kbc/platform/pod/selfcheck.py | 143 +++++++++++++++++- kbc/platform/pod/test_selfcheck.py | 128 ++++++++++++++++ 28 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 kbc/platform/pod/fixtures/asset-provenance/README.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/authoring/EXCLUSIONS.json create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/detail.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/index.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/overview.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/setup.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/tables.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/expected.json create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/archive.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/directcited.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/hero.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/inherited.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan-excluded.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/shared.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/sheets/table1.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/excluded-doc.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/a b.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/diagram.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/screenshot.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/deep/detail.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/overview.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/report.assets/fig.png diff --git a/kbc/platform/pod/fixtures/asset-provenance/README.md b/kbc/platform/pod/fixtures/asset-provenance/README.md new file mode 100644 index 000000000..65247c4cd --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/README.md @@ -0,0 +1,17 @@ +# asset-provenance fixture (coverage v2) + +Shared, byte-for-byte contract between siclaw `selfcheck.py` and the sicore +adoption ledger (DESIGN-kb-asset-provenance-2026-07-22 §六). Both repos run +their edge extraction + coverage math over this same `raw/` + `candidate/` + +`authoring/EXCLUSIONS.json` and assert the result equals `expected.json`. + +Cases exercised: relative link, `../` cross-directory link, HTML ``, +URL-encoded path (`%20`), a body reference to a nonexistent asset (no edge, +no error), a 0-byte download-failed placeholder, `assets/sheets/*.md` (a +content file, not media), one image shared by a cited and an unaccounted +document (auto via the accounted one), an orphan image (unaccounted unless +excluded), an image inheriting its document's exclusion, and a directly-cited +asset (still counts as cited, v1 compatibility). + +Image files hold placeholder bytes — coverage never decodes them; only their +path and presence in the inventory matter. diff --git a/kbc/platform/pod/fixtures/asset-provenance/authoring/EXCLUSIONS.json b/kbc/platform/pod/fixtures/asset-provenance/authoring/EXCLUSIONS.json new file mode 100644 index 000000000..c0fca86b2 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/authoring/EXCLUSIONS.json @@ -0,0 +1,10 @@ +[ + { + "pattern": "excluded-doc.md", + "reason": "superseded draft, not compiled" + }, + { + "pattern": "assets/orphan-excluded.png", + "reason": "upload residue, embedded by no document" + } +] diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/detail.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/detail.md new file mode 100644 index 000000000..001a2b37a --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/detail.md @@ -0,0 +1,7 @@ +--- +type: Topic +title: Detail +compiled_from: + - guide/deep/detail.md +--- +Detail page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md new file mode 100644 index 000000000..161385a83 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md @@ -0,0 +1,8 @@ +--- +okf_version: "0.1" +--- +# Index +- [Overview](overview.md) +- [Setup](setup.md) +- [Detail](detail.md) +- [Tables](tables.md) diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/overview.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/overview.md new file mode 100644 index 000000000..0bdbecc03 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/overview.md @@ -0,0 +1,8 @@ +--- +type: Topic +title: Overview +compiled_from: + - overview.md + - assets/directcited.png +--- +Overview page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/setup.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/setup.md new file mode 100644 index 000000000..d807d8852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/setup.md @@ -0,0 +1,7 @@ +--- +type: Topic +title: Setup +compiled_from: + - guide/setup.md +--- +Setup page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/tables.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/tables.md new file mode 100644 index 000000000..dea8b97e9 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/tables.md @@ -0,0 +1,7 @@ +--- +type: Topic +title: Tables +compiled_from: + - assets/sheets/table1.md +--- +Tables page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/expected.json b/kbc/platform/pod/fixtures/asset-provenance/expected.json new file mode 100644 index 000000000..eeb276126 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/expected.json @@ -0,0 +1,70 @@ +{ + "attribution_edges": { + "archive.md": [ + "assets/shared.png" + ], + "excluded-doc.md": [ + "assets/inherited.png" + ], + "guide/deep/detail.md": [ + "guide/assets/diagram.png" + ], + "guide/setup.md": [ + "guide/assets/a b.png", + "guide/assets/diagram.png", + "guide/assets/screenshot.png" + ], + "overview.md": [ + "assets/hero.png", + "assets/shared.png", + "report.assets/fig.png" + ] + }, + "coverage": { + "total_sources": 16, + "cited": 5, + "excluded": 2, + "auto_attached": 7, + "auto_attached_sample": [ + { + "asset": "assets/hero.png", + "via": "overview.md" + }, + { + "asset": "assets/inherited.png", + "via": "excluded-doc.md" + }, + { + "asset": "assets/shared.png", + "via": "overview.md" + }, + { + "asset": "guide/assets/a b.png", + "via": "guide/setup.md" + }, + { + "asset": "guide/assets/diagram.png", + "via": "guide/deep/detail.md" + }, + { + "asset": "guide/assets/diagram.png", + "via": "guide/setup.md" + }, + { + "asset": "guide/assets/screenshot.png", + "via": "guide/setup.md" + }, + { + "asset": "report.assets/fig.png", + "via": "overview.md" + } + ], + "unaccounted": [ + "archive.md", + "assets/orphan.png" + ], + "dangling_citations": [], + "noop_exclusions": [], + "closed": false + } +} diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/archive.md b/kbc/platform/pod/fixtures/asset-provenance/raw/archive.md new file mode 100644 index 000000000..a300e6ea8 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/archive.md @@ -0,0 +1,4 @@ +# Archive + +Neither compiled nor excluded, so this document itself is unaccounted. +It also embeds the shared figure: ![shared](assets/shared.png) diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/directcited.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/directcited.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/directcited.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/hero.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/hero.png new file mode 100644 index 000000000..e69de29bb diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/inherited.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/inherited.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/inherited.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan-excluded.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan-excluded.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan-excluded.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/shared.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/shared.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/shared.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/sheets/table1.md b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/sheets/table1.md new file mode 100644 index 000000000..03c8467a9 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/sheets/table1.md @@ -0,0 +1,4 @@ +| col | val | +| --- | --- | +| a | 1 | +| b | 2 | diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/excluded-doc.md b/kbc/platform/pod/fixtures/asset-provenance/raw/excluded-doc.md new file mode 100644 index 000000000..c2ac9d4b8 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/excluded-doc.md @@ -0,0 +1,3 @@ +# Excluded doc + +This document is excluded; the image it embeds inherits the exclusion (auto-attached via an excluded document): ![inherited](assets/inherited.png) diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/a b.png b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/a b.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/a b.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/diagram.png b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/diagram.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/diagram.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/screenshot.png b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/screenshot.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/screenshot.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/deep/detail.md b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/deep/detail.md new file mode 100644 index 000000000..747daf693 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/deep/detail.md @@ -0,0 +1,3 @@ +# Detail + +Cross-directory plain link to the shared diagram: [see diagram](../assets/diagram.png) diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md new file mode 100644 index 000000000..27fcbcc4d --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md @@ -0,0 +1,9 @@ +# Setup + +Relative image: ![diagram](assets/diagram.png) + +HTML table image: shot + +URL-encoded path: ![spaced](assets/a%20b.png) + +Missing target — must not error and yields no edge: ![gone](assets/missing.png) diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/overview.md b/kbc/platform/pod/fixtures/asset-provenance/raw/overview.md new file mode 100644 index 000000000..14064dedc --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/overview.md @@ -0,0 +1,6 @@ +# Overview + +![hero](assets/hero.png) + +The shared figure ![shared](assets/shared.png) and the legacy-form figure ![fig](report.assets/fig.png). +This page also directly cites assets/directcited.png in its candidate cf. diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/report.assets/fig.png b/kbc/platform/pod/fixtures/asset-provenance/raw/report.assets/fig.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/report.assets/fig.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/prompts/en/box_role.md b/kbc/platform/pod/prompts/en/box_role.md index 3c0e32f8c..18d63bbcb 100644 --- a/kbc/platform/pod/prompts/en/box_role.md +++ b/kbc/platform/pod/prompts/en/box_role.md @@ -28,7 +28,7 @@ Structured signal tools available to you: **Applying rulings**: the owner later answers tickets one by one in the contradiction view; you will receive an "apply the following rulings" instruction containing `{ticket_id, affected_pages, correct value}` entries. For each one: open the listed `affected_pages`, change that contradiction's spot to the correct value and remove the `⚠️ 存疑` mark there; if the answer is "accept as uncertain / keep both sources", keep them side by side and do not force a verdict. **Touch only the pages named; leave everything else alone.** **After finishing each ticket (including the accept-as-uncertain kind that changes no value), immediately call `resolve_ticket(ticket_id, applied_value, pages_edited, note, dispatch_nonce)`** (echo each ticket's `nonce:` verbatim if the dispatch carried one) — it is the only action that closes a ticket (there is no manual close button on the owner's side): `applied_value` = the value you actually wrote (for accept-as-uncertain write "kept both sources"); `pages_edited` = the candidate files you actually touched for this ticket and **must cover the ticket's `affected_pages`** (missed pages get auto-flagged for review on the owner's side); `note` = one line on what you changed. **One ticket per call, no batching, no missed pages; never hand-edit `status` in `CONTRADICTIONS.json`** — the tool writes it for you. When all rulings are applied, reply briefly with which pages changed overall. **A ruling dispatch can land while you are mid-compile or mid-task: once the patches are done, return to the interrupted task and finish it — do not stop and wait to be reminded** (no scheduler will resume you; if you stop, a half-finished compile stays half-finished). -**Coverage ledger (mechanically checked by the system, not left to conscience)**: every candidate page's frontmatter `compiled_from` must list the raw relative paths it was actually compiled from (recommended `- " · "`, hash optional). **Images / PDFs and other media are sources too**: whenever their content is digested into a page, register their paths in that page's `compiled_from` just the same; `derived: true` is reserved for pages truly compiled from no raw file at all (e.g. a glossary) — a page compiled from an image must still register `compiled_from`, never use `derived` to escape the ledger. Any raw file you decide **not** to compile (media included) must go into `authoring/EXCLUSIONS.json` (a JSON array of `{"pattern": "path or glob relative to raw", "reason": "one-line reason the owner can understand"}`; globs match SEGMENT-wise: a bare `logs` matches only a file literally named logs, `logs/*` only direct children - a whole subtree needs `logs/**` or the `logs/` prefix form; a pattern matching no source gets called out by the self-check for you to fix) — writing "not included" only in the index prose is invisible to the system and does not count. At the end of every round the system mechanically checks "all raw sources = union of compiled_from + EXCLUSIONS matches": anything unaccounted for comes back as a 【system self-check】 repair instruction, and you must either compile it or explicitly exclude it, one by one — do not leave it hanging. In addition, any file the body cites as `(source: X)` must appear in that page's compiled_from, and every page must be reachable by links from index.md (orphan pages are caught by lint). +**Coverage ledger (mechanically checked by the system, not left to conscience)**: every candidate page's frontmatter `compiled_from` must list the raw relative paths it was actually compiled from (recommended `- " · "`, hash optional). **Cite documents, not the images inside them**: a media asset (an image under an `assets/` directory, embedded in a document's body) attaches to its document automatically — the system reads each raw document's body image links and books every image it embeds against that document, so you do **not** register images in `compiled_from` one-by-one, and you do **not** exclude them one-by-one. Cite only the documents themselves; the image content-quality check is a separate image-audit step, not your ledger job. Two carve-outs: an **orphan image** (under `assets/` but embedded by no document — usually upload residue) still needs an explicit `EXCLUSIONS.json` entry with a reason, since nothing else accounts for it; and `assets/sheets/*.md` table placeholders are **content files, not media** — cite or exclude them like any other source. PDFs and pre-rendered office files (`.pptx`/`.xlsx`/`.docx` → sibling `.md`) are content sources too, cited or excluded as usual. `derived: true` is reserved for pages truly compiled from no raw file at all (e.g. a glossary), never a way to escape the ledger. Any raw file you decide **not** to compile (orphan media included) must go into `authoring/EXCLUSIONS.json` (a JSON array of `{"pattern": "path or glob relative to raw", "reason": "one-line reason the owner can understand"}`; globs match SEGMENT-wise: a bare `logs` matches only a file literally named logs, `logs/*` only direct children - a whole subtree needs `logs/**` or the `logs/` prefix form; a pattern matching no source gets called out by the self-check for you to fix) — writing "not included" only in the index prose is invisible to the system and does not count. At the end of every round the system mechanically checks "all raw sources = union of compiled_from + EXCLUSIONS matches": anything unaccounted for comes back as a 【system self-check】 repair instruction, and you must either compile it or explicitly exclude it, one by one — do not leave it hanging. In addition, any file the body cites as `(source: X)` must appear in that page's compiled_from, and every page must be reachable by links from index.md (orphan pages are caught by lint). **Chart/screenshot transcription discipline**: when extracting numbers from images, screenshots or charts: first identify the chart type, the axes and units, and the legend/series labels — only then transcribe values; **for tabular monitoring screenshots (nvitop / nvidia-smi / top …) every value must be aligned to its COLUMN header before transcribing — a percentage bar belongs to a specific column (a MEM bar is NOT GPU-Util; this exact confusion has really happened), and N/A rows (dead/offline devices) must be recorded as-is, never averaged into "each of the N devices…"**; write only numbers you can actually read, and when series are ambiguous or a value is unreadable, mark it `⚠️ 存疑` + file a ticket rather than guess; when the same data exists in both a chart and text/tables, the text/table wins; do not Read several images in one message (it trips the image-processing limit); **never attach an image's (source:) tag to information the image does not contain** — a conclusion inferred across sources must either cite the source that actually supports it, or be marked `⚠️ 存疑` + ticketed, never disguised as a sourced fact. After compiling, the system blind-transcribes the images and mechanically compares your claims against the transcripts; mismatches come back as a 【系统自检 · 图像复核】 repair directive (claim / transcript value / fix per item) — just follow it item by item. diff --git a/kbc/platform/pod/prompts/zh/box_role.md b/kbc/platform/pod/prompts/zh/box_role.md index 57dabd94f..eadcd6104 100644 --- a/kbc/platform/pod/prompts/zh/box_role.md +++ b/kbc/platform/pod/prompts/zh/box_role.md @@ -28,7 +28,7 @@ **应用裁决**:负责人事后会在「矛盾处理」里逐条给出正确答案,你会收到一条「应用以下裁决」指令,里面给你若干 `{ticket_id, affected_pages, 正确值}`。对每条:打开对应 `affected_pages`,把该矛盾处改成正确值、并去掉那处的 `⚠️ 存疑` 标注;若答案是"接受存疑/保留双源",就保持并列、不强行定论。**只动被点名的页,别的页不碰。** **每处理完一条(包括"接受存疑"那种不改值的),都必须立刻调一次 `resolve_ticket(ticket_id, applied_value, pages_edited, note, dispatch_nonce)`(指令里每条工单若带 `nonce:` 就原样回传)** —— 这是唯一能让工单"解单"的动作(负责人侧没有手动关单按钮、全靠它):`applied_value` = 你实际写进页里的值(接受存疑就写"保留双源");`pages_edited` = 你这条实际改动的 candidate 文件名,**必须覆盖该工单的 `affected_pages`**(漏页负责人侧会被自动标"待核");`note` = 一句话说你改了什么。**一条一个、别批量、别漏页**;**别再手工去改 `CONTRADICTIONS.json` 的 `status`**,该工具会替你写。全部回修完再简短回一句总体动了哪几页。**回修指令可能在你编译/干别的活干到一半时插进来:回修完成后,回到被打断的任务把它干完,不要停下来等人提醒**(没有任何调度器会替你续命,你一停,半截的编译就一直停在半截)。 -**覆盖账本(系统机械核查,不靠自觉)**:每个 candidate 页 frontmatter 的 `compiled_from` 必须列出它实际编自的 raw 相对路径(推荐 `- " · <路径>"`,hash 可省略)。**图片/PDF 等媒体也是源**:内容被你消化进页面的,照样在该页 `compiled_from` 登记其路径;`derived: true` 只留给真正不编自任何 raw 文件的纯综合页(如术语表)——编自图片的页也必须登记 compiled_from,不许用 derived 逃账。raw 里你决定**不编**的文件(含媒体),必须写进 `authoring/EXCLUSIONS.json`(JSON 数组,元素 `{"pattern": "相对 raw 的路径或 glob", "reason": "一句话理由,让负责人看得懂"}`;glob **按路径段匹配**:裸 `logs` 只匹配名字恰为 logs 的文件、`logs/*` 只匹配直接子级、整个子树要写 `logs/**` 或 `logs/` 前缀;没命中任何源的模式自检会点名要你修正)——只在 index 散文里写"未收录"系统看不见,不算数。每轮结束系统会机械核对「raw 全部源 = compiled_from 并集 + EXCLUSIONS 匹配」:有未入账的,你会收到一条【系统自检】回修指令,逐个补编或显式排除,二选一,不许晾着。此外正文里 `(source: X)` 引用的文件必须出现在本页 compiled_from,所有页必须从 index.md 有链可达(孤儿页会被 lint 抓)。 +**覆盖账本(系统机械核查,不靠自觉)**:每个 candidate 页 frontmatter 的 `compiled_from` 必须列出它实际编自的 raw 相对路径(推荐 `- " · <路径>"`,hash 可省略)。**只引用文档,不引用文档里的图片**:媒体资产(`assets/` 目录下、嵌在某文档正文里的图片)由系统自动附属到其文档——系统读每个 raw 文档正文里的图片链接,把它嵌的每张图记在该文档名下,所以图片你**不要**逐张登记进 `compiled_from`,也**不要**逐张排除。只引用文档本身即可;图片内容质量另有独立的图像复核环节负责,不归你这本账。两个例外:**孤儿图片**(在 `assets/` 下但没有任何文档引用它——多半是上传残留)仍需在 `EXCLUSIONS.json` 里带理由显式排除,因为没有别的东西替它入账;`assets/sheets/*.md` 表格占位是**内容件,不是媒体**——照常当普通源引用或带理由排除。PDF 与预渲染的 office 文件(`.pptx`/`.xlsx`/`.docx` → 同名 `.md`)也是内容源,照常引用或排除。`derived: true` 只留给真正不编自任何 raw 文件的纯综合页(如术语表),绝不是逃账的口子。raw 里你决定**不编**的文件(含孤儿媒体),必须写进 `authoring/EXCLUSIONS.json`(JSON 数组,元素 `{"pattern": "相对 raw 的路径或 glob", "reason": "一句话理由,让负责人看得懂"}`;glob **按路径段匹配**:裸 `logs` 只匹配名字恰为 logs 的文件、`logs/*` 只匹配直接子级、整个子树要写 `logs/**` 或 `logs/` 前缀;没命中任何源的模式自检会点名要你修正)——只在 index 散文里写"未收录"系统看不见,不算数。每轮结束系统会机械核对「raw 全部源 = compiled_from 并集 + EXCLUSIONS 匹配」:有未入账的,你会收到一条【系统自检】回修指令,逐个补编或显式排除,二选一,不许晾着。此外正文里 `(source: X)` 引用的文件必须出现在本页 compiled_from,所有页必须从 index.md 有链可达(孤儿页会被 lint 抓)。 **图表/截图转写纪律**:从图片、截图、图表提取数字时:先认清图表类型、坐标轴与单位、图例/系列标签,再逐值转写;**表格型监控截图(nvitop/nvidia-smi/top 等)每个数值必须对准列名——百分比条要先确认它属于哪一列(MEM 显存条 ≠ GPU-Util,这个混淆真实发生过);N/A 行(掉卡/离线)如实记录,不要平均化成「每块都…」**;只写你确实读得清的数值,系列分不清或读不准的,宁可标 ⚠️ 存疑+落工单也绝不猜;同一数据图文并存时以文字/表格为准;一条消息不要连读多张图(会撞图片处理上限);**图里没有的信息不要挂该图的 (source:) 标注**——跨来源推断出的结论要么挂真正支撑它的来源,要么标 ⚠️ 存疑+落工单,绝不伪装成带源事实。编完后系统会对图片做独立盲转写并与你的断言机械比对,不符项会以【系统自检 · 图像复核】回修指令下发(列明断言/转写值/修法),逐条照办即可。 diff --git a/kbc/platform/pod/selfcheck.py b/kbc/platform/pod/selfcheck.py index 4236ea3e6..d19441a7d 100644 --- a/kbc/platform/pod/selfcheck.py +++ b/kbc/platform/pod/selfcheck.py @@ -42,6 +42,16 @@ MEDIA_SOURCE_EXTS = IMAGE_SOURCE_EXTS | {".pdf", ".ppt", ".pptx", ".doc", ".docx", ".xls", ".xlsx"} KNOWN_SOURCE_EXTS = TEXT_SOURCE_EXTS | MEDIA_SOURCE_EXTS +# Media ASSETS (coverage v2, DESIGN-kb-asset-provenance-2026-07-22 §4.1): images +# that live under an `assets/` directory (or the legacy `*.assets/` export form). +# They are DOCUMENT ATTACHMENTS, not first-class sources — a media asset embedded +# in the body of an accounted document is auto-accounted against that document +# (see is_media_asset / asset_attribution_edges / coverage), so cf no longer needs +# a token per image and the exclusion ledger no longer needs a row per image. +# Superset of IMAGE_SOURCE_EXTS by .tiff; kept EXACT and byte-for-byte in sync +# with the sicore ledger mirror via the shared fixture. +MEDIA_ASSET_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".tiff"} + EXCLUSIONS_PATH = "authoring/EXCLUSIONS.json" SELFCHECK_PATH = "authoring/SELFCHECK.json" @@ -86,6 +96,23 @@ def source_inventory(workdir: str) -> list[str]: return sorted(out) +def is_media_asset(rel: str) -> bool: + """A raw source is a media ASSET — a document attachment, auto-accountable in + coverage v2 — when some path SEGMENT is `assets` (or the legacy `*.assets` + export form) AND its extension is a known image type (case-insensitive). + + Deliberately NARROW: sheet placeholders at `assets/sheets/*.md` and every + other `.md`/`.json` are content files (their extension is not an image type), + so they stay first-class sources that must be cited or excluded. Auto-attaching + a sheet placeholder would launder 'the table data was never compiled' into + 'covered' — the exact fail-open §4.1 forbids.""" + ext = posixpath.splitext(rel)[1].lower() + if ext not in MEDIA_ASSET_EXTS: + return False + return any(seg == "assets" or seg.endswith(".assets") + for seg in rel.split("/")) + + def _strip_source_prefix(entry: str) -> str: """Drop a leading raw/ or drop/ so a path compares against the raw-relative inventory. Applied to BOTH compiled_from entries and exclusion patterns, so @@ -252,6 +279,9 @@ def _matches(path: str, pattern: str) -> bool: _MD_LINK_RE = re.compile(r"\]\(\s*(<[^>\n]+>|[^)\n]+)\)") _WIKI_LINK_RE = re.compile(r"\[\[([^\]|#]+)") +# HTML (single OR double quotes) — 07-21 lesson: a feishu doc +# embedded 120 table images as HTML , invisible to a markdown-only scan. +_HTML_IMG_SRC_RE = re.compile(r"]*?\bsrc\s*=\s*(\"[^\"]*\"|'[^']*')", re.IGNORECASE) _BODY_SOURCE_START_RE = re.compile( r"(?P[((])\s*(?:source|src|源|来源)\s*[::]\s*", re.IGNORECASE, ) @@ -589,6 +619,69 @@ def _markdown_link_targets(text: str) -> list[str]: return targets +def document_link_targets(md_text: str) -> list[str]: + """Every link/image destination in one document's prose — the building block + of the doc→asset attribution edge (coverage v2, §4.2). Covers markdown + ``![](...)`` and ``[](...)`` plus HTML ```` (single- OR + double-quoted). Each target is URL-decoded (``%20`` → space) and has its + fragment and optional link title stripped. Code fences/spans are masked so a + path inside example code is not mistaken for a real embed.""" + targets: list[str] = [] + prose = _markdown_prose(md_text) + for captured in _MD_LINK_RE.findall(prose): + destination = captured.strip() + if destination.startswith("<") and destination.endswith(">"): + destination = destination[1:-1].strip() + else: + titled = re.fullmatch(r"(.+?)\s+(?:\"[^\"]*\"|'[^']*')", destination) + if titled: + destination = titled.group(1).strip() + destination = unquote(destination).split("#", 1)[0].strip() + if destination: + targets.append(destination) + for captured in _HTML_IMG_SRC_RE.findall(prose): + destination = unquote(captured[1:-1]).split("#", 1)[0].strip() + if destination: + targets.append(destination) + return targets + + +def asset_attribution_edges( + workdir: str, sources: list[str] | None = None, +) -> dict[str, list[str]]: + """Map each raw ``.md`` document to the media assets it embeds in its body. + + For every document ``d`` under ``raw/``, resolve each body link/img target + relative to ``d``'s directory (``posixpath.normpath``, URL-decoded); an edge + ``d→a`` is recorded only when ``a`` is a media asset present in the raw + inventory. A target that resolves to nothing — or to a non-media file — + yields no edge and never errors: the raw tree is the single source of truth + for what embeds what, so the box (local files) and the server (sync-time + refs) derive the SAME edges. Values are sorted for determinism.""" + sources = source_inventory(workdir) if sources is None else sources + media = {s for s in sources if is_media_asset(s)} + raw = Path(workdir) / "raw" + edges: dict[str, list[str]] = {} + for doc in sources: + if not doc.lower().endswith(".md"): + continue + try: + text = (raw / doc).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + base = posixpath.dirname(doc) + hits: set[str] = set() + for target in document_link_targets(text): + if target.startswith(("http://", "https://", "//", "/")): + continue + resolved = posixpath.normpath(posixpath.join(base, target)) + if resolved in media: + hits.add(resolved) + if hits: + edges[doc] = sorted(hits) + return edges + + def _body_source_payload_spans(text: str) -> list[tuple[int, int, str]]: """Extract source payloads and exact source-text offsets outside code.""" payloads: list[tuple[int, int, str]] = [] @@ -1003,7 +1096,21 @@ def dup_candidates(pages: dict[str, dict], cap: int = 20) -> list[dict]: def coverage(workdir: str, pages: dict[str, dict], exclusions: list[dict]) -> dict: - """The ledger: raw inventory − compiled_from union − exclusions = unaccounted.""" + """The ledger (coverage v2): raw inventory − compiled_from union − exclusions + − auto-attached media = unaccounted. + + v2 adds ONE accounting path (monotonic — it can only SHRINK unaccounted, so + every v1-green library stays green): a media asset (image under `assets/`, + see is_media_asset) embedded in the body of a document that is ITSELF + accounted (cited or excluded) is auto-attached to that document and counts as + accounted. Media assets are the document's attachments, not first-class + sources — so `compiled_from` no longer needs a token per image and the + exclusion ledger no longer needs a row per image. Two things deliberately do + NOT auto-attach: an ORPHAN asset embedded by no accounted document (upload + residue — it stays unaccounted and must be excluded with a reason, so a human + still sees it), and `assets/sheets/*.md` placeholders (content files, not + media — is_media_asset excludes them, so they remain first-class sources). A + directly-cited asset still counts as cited (v1 compatibility).""" sources = source_inventory(workdir) source_set = set(sources) cited: set[str] = set() @@ -1020,12 +1127,34 @@ def coverage(workdir: str, pages: dict[str, dict], exclusions: list[dict]) -> di # glob) — surfaced as a warning so the owner fixes it, but non-blocking (a # stale exclusion for an already-removed file shouldn't wedge the gate). noop_exclusions = sorted({e["pattern"] for e in exclusions} - hit) - unaccounted = sorted(source_set - cited - excluded) + # v2 auto-attach. Edges come from the raw tree (each document's body image + # links), so both repos compute the SAME accounting from the SAME frozen + # source set. `auto` is the NET-NEW set — media assets accounted ONLY via an + # embedding accounted document (not already cited/excluded); the subtraction + # below is identical either way, but reporting the net-new set keeps cited / + # excluded / auto_attached a disjoint, auditable decomposition of accounted. + media_assets = {s for s in source_set if is_media_asset(s)} + accounted_docs = cited | excluded + edges = asset_attribution_edges(workdir, sources) + auto_edges = sorted( + (asset, doc) + for doc, assets in edges.items() if doc in accounted_docs + for asset in assets + if asset in media_assets and asset not in cited and asset not in excluded + ) + auto = {asset for asset, _ in auto_edges} + unaccounted = sorted(source_set - cited - excluded - auto) dangling = sorted(cited - source_set) return { "total_sources": len(sources), "cited": len(cited & source_set), "excluded": len(excluded), + # Auto-attach must be OBSERVABLE, never a silent fail-open (platform + # architecture audit's largest class): report the count and a capped + # sample of the accounting edges (asset ← the document that embeds it). + "auto_attached": len(auto), + "auto_attached_sample": [{"asset": asset, "via": doc} + for asset, doc in auto_edges[:20]], "unaccounted": unaccounted, "dangling_citations": dangling, "noop_exclusions": noop_exclusions, @@ -1244,14 +1373,20 @@ def narration(report: dict, locale: str | None = None) -> str: cov, lint = report["coverage"], report["lint"] en = _is_en(locale) noop = cov.get("noop_exclusions") or [] + auto = cov.get("auto_attached") or 0 warn = "" if noop: warn = (f" ⚠ {len(noop)} exclusion(s) match no source — likely a typo" if en else f" ⚠ {len(noop)} 条排除未命中任何源——疑似写错") + auto_note = "" + if auto: + auto_note = (f"; {auto} media auto-attached" if en + else f";{auto} 张媒体自动附属") if en: if report["state"] == "passed": return (f"Self-check (ledger): closed ✓ — {cov['cited']} sources compiled" - f" / {cov['excluded']} explicitly excluded / {cov['total_sources']} total; lint passed") + warn + f" / {cov['excluded']} explicitly excluded / {cov['total_sources']} total" + f"{auto_note}; lint passed") + warn parts = [] if cov["unaccounted"]: parts.append(f"{len(cov['unaccounted'])} source file(s) unaccounted") @@ -1263,7 +1398,7 @@ def narration(report: dict, locale: str | None = None) -> str: return "Self-check (ledger): " + ", ".join(parts) + " — " + tail + warn if report["state"] == "passed": return (f"自检(账本):闭合 ✓ — {cov['cited']} 源已编 / {cov['excluded']} 显式排除" - f" / 共 {cov['total_sources']};lint 通过") + warn + f" / 共 {cov['total_sources']}{auto_note};lint 通过") + warn parts = [] if cov["unaccounted"]: parts.append(f"{len(cov['unaccounted'])} 个源文件未入账") diff --git a/kbc/platform/pod/test_selfcheck.py b/kbc/platform/pod/test_selfcheck.py index bab837345..21d4db2d9 100644 --- a/kbc/platform/pod/test_selfcheck.py +++ b/kbc/platform/pod/test_selfcheck.py @@ -1601,6 +1601,130 @@ def test_content_hash_shared_formula(): print("OK content_hash: pack == tree == canonical (single shared formula)") +def test_is_media_asset(): + """Media-asset predicate (coverage v2 §4.1): an assets/ (or legacy *.assets) + segment AND an image extension; sheet placeholders and non-images are not.""" + yes = ["assets/a.png", "guide/assets/b.JPG", "report.assets/c.png", + "x/assets/y/d.webp", "assets/photo.tiff"] + no = ["docs/x.png", # no assets segment + "assets/sheets/t.md", # sheet placeholder = content file + "assets/data.json", # json is not an image + "assets/notes.pdf", # pdf is not a media asset + "assetsx/y.png", # segment is not exactly `assets` + "my.assets.bak/y.png"] # segment ends with .bak, not .assets + for p in yes: + assert selfcheck.is_media_asset(p), p + for p in no: + assert not selfcheck.is_media_asset(p), p + print("OK is_media_asset (assets/ + *.assets, image ext, case-insensitive; sheet/.json/.pdf excluded)") + + +def test_document_link_targets(): + """Link/img extraction (coverage v2 §4.2): md image + md link + HTML + (both quote styles), URL-decoded, angle-bracket, title/fragment stripped, + code fences masked; external targets pass through (filtered at edge time).""" + targets = selfcheck.document_link_targets( + "# T\n" + "![a](assets/a.png)\n" + "[b](assets/b.png)\n" + "\n" + "x\n" + "![e](assets/a%20b.png)\n" + "![f]()\n" + "![t](assets/t.png \"caption\")\n" + "![h](assets/i.png#frag)\n" + "```\n![code](assets/nope.png)\n```\n" + "[ext](https://example.test/y.png)\n" + ) + for want in ["assets/a.png", "assets/b.png", "assets/c.png", "assets/d.png", + "assets/a b.png", "assets/g h.png", "assets/t.png", "assets/i.png", + "https://example.test/y.png"]: + assert want in targets, (want, targets) + assert "assets/nope.png" not in targets, targets # masked inside a code fence + print("OK document_link_targets (md/html/url-encoded/angle/title/fragment; code masked)") + + +def test_coverage_v2_auto_attach(): + """Coverage v2 auto-attach semantics, each in isolation.""" + okf_index = "---\nokf_version: \"0.1\"\n---\n# Index\n- [p](p.md)\n" + + # A. image embedded by a cited doc → auto; orphan image → unaccounted; a + # sheet placeholder is a content file, not media (cited, not auto). + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/d.md", "# D\n![x](assets/a.png)\n") + _mk(base, "raw/assets/a.png"); _mk(base, "raw/assets/orphan.png") + _mk(base, "raw/assets/sheets/t.md", "| c |\n| - |\n| 1 |\n") + _mk(base, "candidate/index.md", okf_index) + _mk(base, "candidate/p.md", + "---\ntype: Topic\ncompiled_from:\n - d.md\n - assets/sheets/t.md\n---\nok") + cov = selfcheck.run_layer1(td)["coverage"] + assert cov["unaccounted"] == ["assets/orphan.png"], cov + assert cov["auto_attached"] == 1, cov + assert cov["auto_attached_sample"] == [{"asset": "assets/a.png", "via": "d.md"}], cov + assert not selfcheck.is_media_asset("assets/sheets/t.md") + assert not cov["closed"] + + # B. v1 compatibility + no double count: a directly-cited asset stays cited, + # an explicitly-excluded asset stays excluded, and neither shows up as auto. + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/d.md", "# D\n![a](assets/a.png)\n![b](assets/b.png)\n") + _mk(base, "raw/assets/a.png"); _mk(base, "raw/assets/b.png") + _mk(base, "candidate/index.md", okf_index) + _mk(base, "candidate/p.md", + "---\ntype: Topic\ncompiled_from:\n - d.md\n - assets/a.png\n---\nok") + _mk(base, "authoring/EXCLUSIONS.json", + json.dumps([{"pattern": "assets/b.png", "reason": "not needed"}])) + cov = selfcheck.run_layer1(td)["coverage"] + assert cov["closed"], cov + assert cov["auto_attached"] == 0, cov + assert cov["cited"] == 2 and cov["excluded"] == 1, cov + + # C. an image inherits its document's exclusion (auto via an excluded doc). + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/x.md", "# X\n![c](assets/c.png)\n") + _mk(base, "raw/assets/c.png") + _mk(base, "candidate/index.md", "---\nokf_version: \"0.1\"\n---\n# Index\n") + _mk(base, "authoring/EXCLUSIONS.json", + json.dumps([{"pattern": "x.md", "reason": "draft"}])) + cov = selfcheck.run_layer1(td)["coverage"] + assert cov["closed"], cov + assert cov["auto_attached"] == 1, cov + assert cov["auto_attached_sample"] == [{"asset": "assets/c.png", "via": "x.md"}], cov + + # D. an image shared by a cited AND an unaccounted doc: auto via the cited + # one; the unaccounted document itself stays unaccounted (no fail-open). + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/cited.md", "# C\n![s](assets/s.png)\n") + _mk(base, "raw/loose.md", "# L\n![s](assets/s.png)\n") + _mk(base, "raw/assets/s.png") + _mk(base, "candidate/index.md", okf_index) + _mk(base, "candidate/p.md", "---\ntype: Topic\ncompiled_from:\n - cited.md\n---\nok") + cov = selfcheck.run_layer1(td)["coverage"] + assert cov["unaccounted"] == ["loose.md"], cov + assert cov["auto_attached"] == 1, cov + assert cov["auto_attached_sample"] == [{"asset": "assets/s.png", "via": "cited.md"}], cov + print("OK coverage v2 auto-attach (embed/orphan/sheet, v1 compat, exclusion inherit, shared-any-cited)") + + +def test_asset_provenance_fixture(): + """The shared two-repo fixture: edges + coverage v2 must equal expected.json + byte-for-byte (sicore's adoption ledger asserts the SAME expected.json).""" + fx = Path(__file__).resolve().parent / "fixtures" / "asset-provenance" + expected = json.loads((fx / "expected.json").read_text(encoding="utf-8")) + edges = selfcheck.asset_attribution_edges(str(fx)) + assert edges == expected["attribution_edges"], edges + pages = selfcheck.candidate_pages(str(fx)) + exclusions, errors = selfcheck.load_exclusions(str(fx)) + assert errors == [], errors + cov = selfcheck.coverage(str(fx), pages, exclusions) + assert cov == expected["coverage"], cov + print("OK asset-provenance fixture (edges + coverage v2 == expected.json)") + + def main(): os.environ["KBC_L1_REPAIR_ROUNDS"] = "1" os.environ.setdefault("KBC_PK_MODE", "off") # PK never fires in unrelated wiring tests @@ -1615,6 +1739,10 @@ def main(): test_coverage_and_lint() test_candidate_credential_lint() test_media_ledger_and_new_lint() + test_is_media_asset() + test_document_link_targets() + test_coverage_v2_auto_attach() + test_asset_provenance_fixture() test_body_source_annotations() test_deterministic_body_source_normalization() test_spaced_markdown_links() From 5790bf354a3cf72de169acafe7629544c19dc7f9 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 22 Jul 2026 20:25:31 +0800 Subject: [PATCH 02/18] feat(kbc): media numeric re-verification follows attribution edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to coverage v2. media_citing_pages() gains a third discovery path: a candidate page that cites a DOCUMENT d in its compiled_from now inherits the image numeric-fidelity check of every image d embeds in its body (via asset_attribution_edges), unioned with the existing two paths (directly-cited compiled_from images + body (source: img) citations), which legacy pages keep using. Why: S1b tells the compile agent to stop citing images one-by-one — they auto-attach. media_citing_pages() drove the blind fresh-eyes transcription check off cited images only, so without this the numeric check would silently stop covering embedded charts — a silent fail-open on the exact fidelity risk it exists to catch, in the window before the image-audit dimension lands. Edge assets are intersected with IMAGE_SOURCE_EXTS (the transcription surface), so a media asset like .tiff is accounted by coverage but not numerically re-read. Additive only: no existing media test used an assets/ raw path, so all prior assertions pass unchanged. --- kbc/platform/pod/selfcheck.py | 23 ++++++++++++++++++++--- kbc/platform/pod/test_selfcheck.py | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/kbc/platform/pod/selfcheck.py b/kbc/platform/pod/selfcheck.py index d19441a7d..8cebf843c 100644 --- a/kbc/platform/pod/selfcheck.py +++ b/kbc/platform/pod/selfcheck.py @@ -1592,15 +1592,27 @@ def file_residual_ticket(workdir: str, report: dict, locale: str | None = None) def media_citing_pages(workdir: str) -> dict[str, list[str]]: - """candidate page → sorted raw-relative image paths it digests, gathered - from compiled_from entries AND body (source: …) citations. Basename match - tolerated for body citations (bodies usually cite the bare filename).""" + """candidate page → sorted raw-relative image paths whose numeric fidelity + this page is responsible for. Three discovery paths, UNIONED: + 1. compiled_from image entries — an image cited directly (legacy pages); + 2. body ``(source: …)`` image citations — basename match tolerated; + 3. attribution-edge reverse lookup — a page that cites a DOCUMENT ``d`` in + its compiled_from inherits the numeric check of every image ``d`` + embeds in its body. + Path 3 is what keeps image re-verification alive under coverage v2: agents + now cite DOCUMENTS (images auto-attach, see coverage/asset_attribution_edges) + rather than listing images one-by-one, so without it the fresh-eyes numeric + check would silently stop covering embedded charts — a silent fail-open on the + exact fidelity risk it exists to catch. Only IMAGE_SOURCE_EXTS images go to + transcription, so edge assets are intersected with the raw image set (a media + asset like .tiff is accounted by coverage but not numerically re-read here).""" raw_images = [p for p in source_inventory(workdir) if posixpath.splitext(p)[1].lower() in IMAGE_SOURCE_EXTS] by_basename: dict[str, list[str]] = {} for p in raw_images: by_basename.setdefault(posixpath.basename(p), []).append(p) raw_set = set(raw_images) + edges = asset_attribution_edges(workdir) out: dict[str, list[str]] = {} for rel, page in candidate_pages(workdir).items(): if "error" in page: @@ -1615,6 +1627,11 @@ def media_citing_pages(workdir: str) -> dict[str, list[str]]: matches = by_basename.get(posixpath.basename(entry), []) if len(matches) == 1: hits.add(matches[0]) + # Path 3: images embedded by a document this page cites in compiled_from. + for entry in page["sources"]: + for asset in edges.get(entry, ()): + if asset in raw_set: + hits.add(asset) if hits: out[rel] = sorted(hits) return out diff --git a/kbc/platform/pod/test_selfcheck.py b/kbc/platform/pod/test_selfcheck.py index 21d4db2d9..820ff7d4a 100644 --- a/kbc/platform/pod/test_selfcheck.py +++ b/kbc/platform/pod/test_selfcheck.py @@ -1710,6 +1710,24 @@ def test_coverage_v2_auto_attach(): print("OK coverage v2 auto-attach (embed/orphan/sheet, v1 compat, exclusion inherit, shared-any-cited)") +def test_media_citing_pages_via_attribution_edge(): + """Coverage v2: a page citing only a DOCUMENT still enters the image + numeric-verification surface for every image that document embeds — agents no + longer cite images one-by-one, so the attribution edge is the carrier.""" + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/doc.md", "# D\n![c](assets/chart.png)\n") + _mk(base, "raw/assets/chart.png") + _mk(base, "candidate/index.md", "---\nokf_version: \"0.1\"\n---\n# Index\n- [p](p.md)") + # cites the DOCUMENT only — no image in compiled_from, no (source: img) + _mk(base, "candidate/p.md", + "---\ntype: Topic\ncompiled_from:\n - doc.md\n---\nSummary of the chart.") + citing = selfcheck.media_citing_pages(td) + assert citing == {"p.md": ["assets/chart.png"]}, citing + assert list(selfcheck.pending_media_verification(td)) == ["p.md"] + print("OK media_citing_pages via attribution edge (doc-only citation still verifies embedded images)") + + def test_asset_provenance_fixture(): """The shared two-repo fixture: edges + coverage v2 must equal expected.json byte-for-byte (sicore's adoption ledger asserts the SAME expected.json).""" @@ -1742,6 +1760,7 @@ def main(): test_is_media_asset() test_document_link_targets() test_coverage_v2_auto_attach() + test_media_citing_pages_via_attribution_edge() test_asset_provenance_fixture() test_body_source_annotations() test_deterministic_body_source_normalization() From d581f3731bb03f9af2182e285b1b64ce97f99f6d Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 22 Jul 2026 20:38:09 +0800 Subject: [PATCH 03/18] feat(kbc): align coverage-v2 edge semantics with sicore ledger mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two implementation-level details locked byte-for-byte with the sicore adoption ledger (shared fixture, DESIGN §六), after cross-checking the two implementations: 1. is_media_asset segment match is now case-INSENSITIVE (`Assets/`, `report.ASSETS/` count too). The platform always writes lowercase `assets/`, so this only affects hand-authored trees, but both repos must agree. Locked by a unit test, NOT the shared fixture: an uppercase directory is not portable on a case-insensitive filesystem (it collides with `assets/`). 2. document_link_targets truncates a `#fragment` AND `?query` from the target, and does so on the STILL-ENCODED string BEFORE percent-decoding (new helper _strip_fragment_query). This matches the ledger's parse order (strip angle → truncate #/? → unescape), so an encoded %23/%3F inside a real filename survives while an actual delimiter is removed. Everything else already matched (ext set incl. tiff / no pdf, both quote styles for HTML , resolve-then-membership skip rule, single unquote, Clean(Join) keeping leading ..). The shared fixture gains a `?query`-suffixed target case (raw/extras.md → assets/q.png?v=2). Neither change alters any prior fixture output; expected.json is regenerated from the reference implementation with an in-generator sanity assert on the invariants. All selfcheck + mediaverify tests green. --- .../pod/fixtures/asset-provenance/README.md | 15 ++++++----- .../asset-provenance/candidate/extras.md | 7 ++++++ .../asset-provenance/candidate/index.md | 1 + .../fixtures/asset-provenance/expected.json | 13 +++++++--- .../asset-provenance/raw/assets/q.png | 1 + .../fixtures/asset-provenance/raw/extras.md | 3 +++ kbc/platform/pod/selfcheck.py | 25 ++++++++++++++----- kbc/platform/pod/test_selfcheck.py | 15 +++++++---- 8 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/extras.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/q.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/extras.md diff --git a/kbc/platform/pod/fixtures/asset-provenance/README.md b/kbc/platform/pod/fixtures/asset-provenance/README.md index 65247c4cd..cbf54624d 100644 --- a/kbc/platform/pod/fixtures/asset-provenance/README.md +++ b/kbc/platform/pod/fixtures/asset-provenance/README.md @@ -6,12 +6,15 @@ their edge extraction + coverage math over this same `raw/` + `candidate/` + `authoring/EXCLUSIONS.json` and assert the result equals `expected.json`. Cases exercised: relative link, `../` cross-directory link, HTML ``, -URL-encoded path (`%20`), a body reference to a nonexistent asset (no edge, -no error), a 0-byte download-failed placeholder, `assets/sheets/*.md` (a -content file, not media), one image shared by a cited and an unaccounted -document (auto via the accounted one), an orphan image (unaccounted unless -excluded), an image inheriting its document's exclusion, and a directly-cited -asset (still counts as cited, v1 compatibility). +URL-encoded path (`%20`), a `?query`-suffixed target (truncated before +decoding), a body reference to a nonexistent asset (no edge, no error), a +0-byte download-failed placeholder, `assets/sheets/*.md` (a content file, not +media), one image shared by a cited and an unaccounted document (auto via the +accounted one), an orphan image (unaccounted unless excluded), an image +inheriting its document's exclusion, and a directly-cited asset (still counts +as cited, v1 compatibility). Case-insensitivity of the `assets`/`*.assets` +segment is locked by a unit test (an uppercase dir is not portable on a +case-insensitive filesystem), not by a fixture file. Image files hold placeholder bytes — coverage never decodes them; only their path and presence in the inventory matter. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/extras.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/extras.md new file mode 100644 index 000000000..fc3228962 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/extras.md @@ -0,0 +1,7 @@ +--- +type: Topic +title: Extras +compiled_from: + - extras.md +--- +Extras page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md index 161385a83..c19ae577e 100644 --- a/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md @@ -6,3 +6,4 @@ okf_version: "0.1" - [Setup](setup.md) - [Detail](detail.md) - [Tables](tables.md) +- [Extras](extras.md) diff --git a/kbc/platform/pod/fixtures/asset-provenance/expected.json b/kbc/platform/pod/fixtures/asset-provenance/expected.json index eeb276126..5bf35881d 100644 --- a/kbc/platform/pod/fixtures/asset-provenance/expected.json +++ b/kbc/platform/pod/fixtures/asset-provenance/expected.json @@ -6,6 +6,9 @@ "excluded-doc.md": [ "assets/inherited.png" ], + "extras.md": [ + "assets/q.png" + ], "guide/deep/detail.md": [ "guide/assets/diagram.png" ], @@ -21,10 +24,10 @@ ] }, "coverage": { - "total_sources": 16, - "cited": 5, + "total_sources": 18, + "cited": 6, "excluded": 2, - "auto_attached": 7, + "auto_attached": 8, "auto_attached_sample": [ { "asset": "assets/hero.png", @@ -34,6 +37,10 @@ "asset": "assets/inherited.png", "via": "excluded-doc.md" }, + { + "asset": "assets/q.png", + "via": "extras.md" + }, { "asset": "assets/shared.png", "via": "overview.md" diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/q.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/q.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/q.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/extras.md b/kbc/platform/pod/fixtures/asset-provenance/raw/extras.md new file mode 100644 index 000000000..124d58fed --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/extras.md @@ -0,0 +1,3 @@ +# Extras + +Query-string suffix (truncated): ![q](assets/q.png?v=2) diff --git a/kbc/platform/pod/selfcheck.py b/kbc/platform/pod/selfcheck.py index 8cebf843c..d7287697e 100644 --- a/kbc/platform/pod/selfcheck.py +++ b/kbc/platform/pod/selfcheck.py @@ -109,7 +109,10 @@ def is_media_asset(rel: str) -> bool: ext = posixpath.splitext(rel)[1].lower() if ext not in MEDIA_ASSET_EXTS: return False - return any(seg == "assets" or seg.endswith(".assets") + # Segment match is case-INSENSITIVE, matching the sicore ledger mirror (an + # `Assets/` dir counts too); the platform always writes lowercase `assets/`, + # so this only matters for hand-authored trees, but the two repos must agree. + return any(seg.lower() == "assets" or seg.lower().endswith(".assets") for seg in rel.split("/")) @@ -619,13 +622,23 @@ def _markdown_link_targets(text: str) -> list[str]: return targets +def _strip_fragment_query(target: str) -> str: + """Drop a URL ``#fragment`` / ``?query`` from the STILL-ENCODED target, before + percent-decoding — so an encoded ``%23``/``%3F`` that is part of a real + filename survives while an actual fragment/query delimiter is removed. Order + matches the sicore ledger's parser (strip angle → truncate #/? → unescape) so + the two repos derive byte-identical edges.""" + return target.split("#", 1)[0].split("?", 1)[0] + + def document_link_targets(md_text: str) -> list[str]: """Every link/image destination in one document's prose — the building block of the doc→asset attribution edge (coverage v2, §4.2). Covers markdown ``![](...)`` and ``[](...)`` plus HTML ```` (single- OR - double-quoted). Each target is URL-decoded (``%20`` → space) and has its - fragment and optional link title stripped. Code fences/spans are masked so a - path inside example code is not mistaken for a real embed.""" + double-quoted). Angle brackets are unwrapped and an optional link title + stripped; then the ``#fragment``/``?query`` is truncated and the result is + URL-decoded once (``%20`` → space). Code fences/spans are masked so a path + inside example code is not mistaken for a real embed.""" targets: list[str] = [] prose = _markdown_prose(md_text) for captured in _MD_LINK_RE.findall(prose): @@ -636,11 +649,11 @@ def document_link_targets(md_text: str) -> list[str]: titled = re.fullmatch(r"(.+?)\s+(?:\"[^\"]*\"|'[^']*')", destination) if titled: destination = titled.group(1).strip() - destination = unquote(destination).split("#", 1)[0].strip() + destination = unquote(_strip_fragment_query(destination)).strip() if destination: targets.append(destination) for captured in _HTML_IMG_SRC_RE.findall(prose): - destination = unquote(captured[1:-1]).split("#", 1)[0].strip() + destination = unquote(_strip_fragment_query(captured[1:-1])).strip() if destination: targets.append(destination) return targets diff --git a/kbc/platform/pod/test_selfcheck.py b/kbc/platform/pod/test_selfcheck.py index 820ff7d4a..17cec7b94 100644 --- a/kbc/platform/pod/test_selfcheck.py +++ b/kbc/platform/pod/test_selfcheck.py @@ -1605,7 +1605,10 @@ def test_is_media_asset(): """Media-asset predicate (coverage v2 §4.1): an assets/ (or legacy *.assets) segment AND an image extension; sheet placeholders and non-images are not.""" yes = ["assets/a.png", "guide/assets/b.JPG", "report.assets/c.png", - "x/assets/y/d.webp", "assets/photo.tiff"] + "x/assets/y/d.webp", "assets/photo.tiff", + # case-INSENSITIVE segment (locked here, not in the fixture: an + # uppercase dir is not portable on a case-insensitive filesystem). + "Assets/e.png", "report.ASSETS/f.png", "guide/AsSeTs/g.png"] no = ["docs/x.png", # no assets segment "assets/sheets/t.md", # sheet placeholder = content file "assets/data.json", # json is not an image @@ -1616,7 +1619,7 @@ def test_is_media_asset(): assert selfcheck.is_media_asset(p), p for p in no: assert not selfcheck.is_media_asset(p), p - print("OK is_media_asset (assets/ + *.assets, image ext, case-insensitive; sheet/.json/.pdf excluded)") + print("OK is_media_asset (assets/ + *.assets, case-insensitive seg + image ext; sheet/.json/.pdf excluded)") def test_document_link_targets(): @@ -1633,15 +1636,17 @@ def test_document_link_targets(): "![f]()\n" "![t](assets/t.png \"caption\")\n" "![h](assets/i.png#frag)\n" + "![q](assets/q.png?v=2)\n" "```\n![code](assets/nope.png)\n```\n" "[ext](https://example.test/y.png)\n" ) for want in ["assets/a.png", "assets/b.png", "assets/c.png", "assets/d.png", "assets/a b.png", "assets/g h.png", "assets/t.png", "assets/i.png", - "https://example.test/y.png"]: + "assets/q.png", "https://example.test/y.png"]: assert want in targets, (want, targets) - assert "assets/nope.png" not in targets, targets # masked inside a code fence - print("OK document_link_targets (md/html/url-encoded/angle/title/fragment; code masked)") + assert "assets/nope.png" not in targets, targets # masked inside a code fence + assert "assets/q.png?v=2" not in targets, targets # ?query truncated + print("OK document_link_targets (md/html/url-encoded/angle/title/fragment/query; code masked)") def test_coverage_v2_auto_attach(): From b7074badb90aebb8ca7b7440e6fe98675d7aaff7 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 22 Jul 2026 22:57:15 +0800 Subject: [PATCH 04/18] feat(kbc): rename compile box image to siclaw-kbc-box and pod prefix to kbc-box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation (operations): during production troubleshooting every pod was named `agentbox-`, so a KB compile box and a chat agentbox could only be told apart by label/image filtering — the compile box has bitten on-call more than once. Make the compile box distinguishable at a glance. Changes: - Image rename `kbc-compile-box` -> `siclaw-kbc-box` (Makefile build/push, helm `siclaw.compileBoxImage` derivation, values, README/docs). The explicit `agentbox.compileBoxImage` override semantics are unchanged. - Pod-name prefix declared on the BoxProfile (`podNamePrefix`): `kb-compile` and `kb-compile-codex` spawn as `kbc-box-` instead of `agentbox-`. Derived resources (cert Secret, hostname) follow the prefix. Chat agentboxes and the read-only, ephemeral `kb-test` box keep the `agentbox-` prefix. - Upgrade compatibility: on the first post-upgrade compile spawn 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 old and new pods do not coexist. - Keep AgentBoxManager consistent with the spawner: its pod-name lookup is now profile-aware too, and `stop(runId, profile)` / `getAsync(runId, profile)` receive the run's profile. Without this the capability reap would 404 on the old name and leak the renamed compile pod, and adopt would miss the live box. Transition: - `make push-kbc` double-pushes the same image digest under the legacy name `kbc-compile-box` for one release cycle so runtimes still pinned to it keep resolving. Remove the legacy tag/push next release. - DevOps: add a registry replication rule (ap-southeast -> cn-shanghai) for `siclaw-kbc-box` (the old name was not in the rule and had to be pushed by hand). Until then, push `siclaw-kbc-box` to cn-shanghai manually for prod. Tests: box-profile / k8s-spawner / manager suites green, including new coverage for the kbc-box prefix, the legacy-pod reap, the chat-pod guard, and the profile-aware stop/getAsync lookups. --- CHANGELOG.md | 12 +++ CLAUDE.md | 2 +- Makefile | 17 ++- ...026-07-kb-authoring-message-idempotency.md | 2 +- helm/siclaw/templates/_helpers.tpl | 6 +- helm/siclaw/values.yaml | 4 +- kbc/platform/pod/README.md | 4 +- kbc/platform/pod/destream.py | 2 +- src/gateway/agentbox/box-profile.test.ts | 15 ++- src/gateway/agentbox/box-profile.ts | 15 ++- src/gateway/agentbox/k8s-spawner.test.ts | 101 +++++++++++++++++- src/gateway/agentbox/k8s-spawner.ts | 47 +++++++- src/gateway/agentbox/manager.test.ts | 45 ++++++-- src/gateway/agentbox/manager.ts | 25 +++-- src/gateway/server.ts | 14 ++- 15 files changed, 264 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5efa7a4cc..5a758deee 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 93ae1df54..7c7d45e40 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 683a5b713..21b81fe33 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 138433037..7bae5db10 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 6acd6504b..a61a9c786 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 d3b10478b..445fcf8bf 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 855c2c1ec..95d1ca355 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 c646cfead..0e034df59 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 af902c28f..4b1e950bf 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 597c6ce4c..59a58597e 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 dba443224..64d903ee4 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 d3ec73a14..519037fd7 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 8719d6bd7..07899bbb7 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 b9459ee1d..7a0941bf9 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 036d77d62..701897e49 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 Date: Wed, 22 Jul 2026 23:07:14 +0800 Subject: [PATCH 05/18] feat(sicore-a2a-mcp): support multiple named A2A keys selected by alias One adapter process could previously bind only a single A2A key, so switching Siclaw agents meant editing config and restarting. Passing the key as a tool argument is not an option: credentials must never flow through the model context. Add configuration-side named keys, selected at call time by alias: - New SICLAW_A2A_KEYS env: JSON {alias: key}. Aliases match ^[a-z0-9][a-z0-9_-]{0,31}$. The single-key SICLAW_A2A_KEY / SICLAW_A2A_KEY_FILE form is unchanged and maps to the reserved "default" alias; the two forms may be combined. SICLAW_AGENT_ID still pins the default key and is rejected alongside SICLAW_A2A_KEYS. Every key is self-resolved once at startup and any failure aborts boot (fail-fast). - Every tool gains an optional "agent" alias argument (never a key). Tool descriptions are populated at startup with the configured aliases and their resolved agent ids. With one agent "agent" is optional; with several, a create/list call that omits it errors with the alias list rather than guessing. - task_id -> alias is tracked in process for the server lifetime, so wait/get/cancel auto-route to the creating key. A mismatched "agent" argument is overridden by the recorded creator and noted in the result. siclaw_list_tasks without "agent" aggregates across every key, tags each task with its alias, and rebuilds the map after a restart. Keys stay in configuration only: never a tool parameter, never logged, never present in an error message (errors carry aliases and agent ids). Route the new logic through an AgentRouter; keep SicoreA2aClient per-key. Existing tests updated to the router API; add router unit tests, multi-key routing tool tests, and a two-alias stdio e2e cold-start smoke. --- mcp/sicore-a2a-adapter/README.md | 175 ++++++++-- mcp/sicore-a2a-adapter/src/a2a-client.ts | 7 +- mcp/sicore-a2a-adapter/src/config.test.ts | 91 ++++- mcp/sicore-a2a-adapter/src/config.ts | 103 +++++- mcp/sicore-a2a-adapter/src/index.ts | 36 +- mcp/sicore-a2a-adapter/src/router.test.ts | 71 ++++ mcp/sicore-a2a-adapter/src/router.ts | 111 ++++++ mcp/sicore-a2a-adapter/src/server.test.ts | 7 +- mcp/sicore-a2a-adapter/src/server.ts | 32 +- mcp/sicore-a2a-adapter/src/stdio.e2e.test.ts | 68 ++++ mcp/sicore-a2a-adapter/src/tools.test.ts | 162 ++++++++- mcp/sicore-a2a-adapter/src/tools.ts | 350 +++++++++++++------ 12 files changed, 1025 insertions(+), 188 deletions(-) create mode 100644 mcp/sicore-a2a-adapter/src/router.test.ts create mode 100644 mcp/sicore-a2a-adapter/src/router.ts diff --git a/mcp/sicore-a2a-adapter/README.md b/mcp/sicore-a2a-adapter/README.md index f41e06171..1f190cb48 100644 --- a/mcp/sicore-a2a-adapter/README.md +++ b/mcp/sicore-a2a-adapter/README.md @@ -1,6 +1,7 @@ # Sicore A2A MCP Adapter -A minimal local stdio MCP server that lets Codex or Claude Code use one existing Siclaw agent through Sicore's A2A API. +A minimal local stdio MCP server that lets Codex or Claude Code drive one or more +existing Siclaw agents through Sicore's A2A API. The adapter is intentionally thin: @@ -8,15 +9,88 @@ The adapter is intentionally thin: Codex / Claude Code --stdio MCP--> this adapter --HTTPS A2A--> Sicore --> Siclaw Runtime --> the agent's existing AgentBox ``` -It does not hold cluster credentials, choose a Siclaw agent, or execute infrastructure tools. One adapter process is fixed to one `SICLAW_AGENT_ID` and one Sicore A2A key. +It does not hold cluster credentials, choose what a Siclaw agent does, or execute +infrastructure tools. + +## Multiple agents, one adapter + +Each Sicore A2A key is bound to exactly one Siclaw agent. Historically one adapter +process meant one key meant one agent, so switching agents meant editing config and +restarting. + +This adapter can hold several **named keys** at once. You give each key a short +alias in configuration; the model selects an agent by passing that alias as the +`agent` tool argument. The alias is the only agent selector that ever crosses the +model boundary. + +### Design + +- **Keys live only in configuration.** `SICLAW_A2A_KEYS` is a JSON object of + `{alias: key}`. The key material never appears in a tool schema, a tool + argument, a log line, or an error message. Errors name the *alias* and the + resolved *agent id*, never the key. +- **The model selects by alias, never by key.** Every tool takes an optional + `agent` argument constrained to the alias pattern `^[a-z0-9][a-z0-9_-]{0,31}$`. + This is the fix for the failure mode where a credential would otherwise have to + be pasted into the chat to change which agent answers. +- **Fail fast at startup.** Each configured key is self-resolved once at boot via + `GET /api/v1/a2a/self` (the same mechanism the single-key form already used). If + any key fails to resolve, the process exits with an error that names the failing + alias — no key is ever partially usable and silent. +- **No guessing.** With a single configured agent, `agent` may be omitted. With + several, a create/list call that omits `agent` returns an explicit error listing + the available aliases rather than picking one. +- **Task ownership is tracked, so follow-ups auto-route.** `task_id` and + `context_id` are per-key server-side resources. The adapter records which alias + created each `task_id` (in process memory, for the server's lifetime). + `siclaw_wait_task` / `siclaw_get_task` / `siclaw_cancel_task` therefore route to + the creating key automatically — you usually only pass `agent` on + `siclaw_investigate`. If a task op passes an `agent` that disagrees with the + recorded creator, the recorded creator wins and the result carries a + `routing_note` recording the override. +- **Aggregated recovery.** `siclaw_list_tasks` without `agent` (and with several + agents configured) queries every key and tags each task with its owning alias, + re-establishing the `task_id -> alias` map after a client restart. Passing + `agent` scopes the listing to that one agent and supports paging. + +### Backward compatibility + +The original single-key environment variables keep working unchanged and map to +the reserved alias `default`: + +- `SICLAW_A2A_KEY` / `SICLAW_A2A_KEY_FILE` alone → one agent named `default`. +- `SICLAW_AGENT_ID` still pins the `default` agent (for an older Sicore without + `/self`, or as a cross-check). It applies only to the single-key form; combining + it with `SICLAW_A2A_KEYS` is a configuration error, because each named key + resolves its own agent. +- The single-key form and `SICLAW_A2A_KEYS` may be combined; the single key takes + the `default` slot and a `default` alias inside `SICLAW_A2A_KEYS` is rejected as + a collision. ## Tools -- `siclaw_investigate`: create or continue an investigation and wait up to 50 seconds. Longer investigations continue server-side and are watched with `siclaw_wait_task`. -- `siclaw_wait_task`: wait up to 50 seconds on an existing task without submitting another investigation. Use it repeatedly as the same-turn watchdog. -- `siclaw_get_task`: get one immediate compact snapshot; terminal snapshots include the full result. +- `siclaw_investigate`: create or continue an investigation and wait up to 50 + seconds. Longer investigations continue server-side and are watched with + `siclaw_wait_task`. +- `siclaw_wait_task`: wait up to 50 seconds on an existing task without submitting + another investigation. Use it repeatedly as the same-turn watchdog. +- `siclaw_get_task`: get one immediate compact snapshot; terminal snapshots + include the full result. - `siclaw_cancel_task`: cancel a non-terminal task. -- `siclaw_list_tasks`: recover/list tasks scoped to the same agent and API key. +- `siclaw_list_tasks`: recover/list tasks; aggregates across all agents when + `agent` is omitted. + +Every tool takes an optional `agent` alias. Its description is populated at startup +with the configured aliases and their resolved agent ids, so the model does not +have to guess which alias maps to which agent. + +## Security + +- The key exists only in configuration/env (or a `0600` key file). It is never a + tool parameter, never logged, and never echoed in an error message. +- The model-visible surface carries aliases and resolved agent ids only. +- Diagnostic output on stderr reports the endpoint origin and `alias=agentId` + pairs, never key material. ## Build and test @@ -33,17 +107,26 @@ Requires Node.js 22.19 or newer. Required: - `SICORE_URL`: Sicore base URL, for example `https://sicore.example.com`. -- exactly one of: - - `SICLAW_A2A_KEY_FILE`: path to a file containing the key. On Unix it must have mode `0600` or stricter. - - `SICLAW_A2A_KEY`: direct environment fallback for ephemeral testing. +- at least one key, from any combination of: + - `SICLAW_A2A_KEYS`: JSON object of named keys, e.g. `{"sre":"sk-a","kb":"sk-b"}`. + Alias must match `^[a-z0-9][a-z0-9_-]{0,31}$`. + - `SICLAW_A2A_KEY_FILE`: path to a file containing a single key (the `default` + alias). On Unix it must have mode `0600` or stricter. + - `SICLAW_A2A_KEY`: direct single-key environment fallback for ephemeral testing + (the `default` alias). Optional: -- `SICLAW_AGENT_ID`: the agent UUID bound to the A2A key. Keys are per-agent, so by default the adapter resolves the agent from the key at startup (`GET /api/v1/a2a/self`). Set it explicitly to pin the agent as a cross-check, or when talking to an older Sicore without the `/self` endpoint. -- `SICLAW_A2A_TIMEOUT_MS`: network-operation timeout, default `30000`. Bounded GET retries share this same budget. +- `SICLAW_AGENT_ID`: the agent UUID bound to the single `default` key. By default + the adapter resolves the agent from each key at startup (`GET /api/v1/a2a/self`). + Set it to pin the `default` agent as a cross-check or for an older Sicore without + `/self`. It cannot be combined with `SICLAW_A2A_KEYS`. +- `SICLAW_A2A_TIMEOUT_MS`: network-operation timeout, default `30000`. Bounded GET + retries share this same budget. - `SICLAW_A2A_POLL_INTERVAL_MS`: task polling interval, default `3000`. -The key must never be passed as a command-line argument or committed to a project MCP config. Prefer a private key file outside the repository: +Keys must never be passed as a command-line argument or committed to a project MCP +config. Prefer a private key file outside the repository for the single-key form: ```bash mkdir -p ~/.config/siclaw @@ -54,25 +137,40 @@ unset SICLAW_TEST_KEY chmod 600 ~/.config/siclaw/test-a2a-key ``` +For `SICLAW_A2A_KEYS`, keep the JSON in a private env file the MCP client loads, +not in a shared/committed config. + ## Run directly +Single key: + ```bash SICORE_URL=https://sicore.example.com \ SICLAW_A2A_KEY_FILE=~/.config/siclaw/test-a2a-key \ node dist/index.js ``` -The process speaks MCP over stdout. Diagnostic startup messages go only to stderr and never include the key. +Multiple named keys: + +```bash +SICORE_URL=https://sicore.example.com \ +SICLAW_A2A_KEYS='{"sre":"sk-a","kb":"sk-b"}' \ +node dist/index.js +``` + +The process speaks MCP over stdout. Diagnostic startup messages go only to stderr +and never include a key. ## Codex -Use absolute paths so the MCP process does not depend on the current project directory: +Use absolute paths so the MCP process does not depend on the current project +directory: ```bash codex mcp add \ --env SICORE_URL=https://sicore.example.com \ - --env SICLAW_A2A_KEY_FILE=/absolute/path/to/test-a2a-key \ - siclaw-test -- node /absolute/path/to/sicore-a2a-adapter/dist/index.js + --env SICLAW_A2A_KEYS='{"sre":"sk-a","kb":"sk-b"}' \ + siclaw -- node /absolute/path/to/sicore-a2a-adapter/dist/index.js ``` ## Claude Code @@ -80,19 +178,22 @@ codex mcp add \ ```bash claude mcp add -s user \ -e SICORE_URL=https://sicore.example.com \ - -e SICLAW_A2A_KEY_FILE=/absolute/path/to/test-a2a-key \ - siclaw-test -- node /absolute/path/to/sicore-a2a-adapter/dist/index.js + -e SICLAW_A2A_KEYS='{"sre":"sk-a","kb":"sk-b"}' \ + siclaw -- node /absolute/path/to/sicore-a2a-adapter/dist/index.js ``` -Create one named MCP server per Siclaw agent/key pair. The model-visible tool schemas intentionally contain no `agent_id` parameter, and the adapter derives the agent from the key itself (add `-e SICLAW_AGENT_ID=` to pin it explicitly or for an older Sicore). +The model-visible tool schemas contain no key parameter and no `agent_id` +parameter — only an `agent` alias. With one key you can keep the original +single-key form and omit `agent`; with several, the model passes the alias. ## Watchdog behavior -Sicore owns the durable background task. The local adapter stays stateless and -does not run a persistent daemon. When `siclaw_investigate` returns a working -task, the MCP client should keep the current turn open and call -`siclaw_wait_task` with the same `task_id` until the task is terminal, the user -asks it to stop, or an overall investigation deadline is exhausted. +Sicore owns the durable background task. The local adapter stays stateless across +restarts (the only in-memory state is the `task_id -> alias` map, which +`siclaw_list_tasks` rebuilds). When `siclaw_investigate` returns a working task, +the MCP client should keep the current turn open and call `siclaw_wait_task` with +the same `task_id` until the task is terminal, the user asks it to stop, or an +overall investigation deadline is exhausted. Working responses contain only the latest status, timestamp, and accumulated character count. The growing partial report is deliberately withheld so each @@ -106,9 +207,25 @@ previous conversation. ## Failure behavior -- A2A `message:send` is never automatically retried, avoiding duplicate investigation tasks. -- Idempotent task reads retry transient timeouts, connection failures, malformed responses, HTTP 408/429, and HTTP 500/502/503/504 at most twice with short backoff. Retries stay inside the read or watchdog deadline. +- A2A `message:send` is never automatically retried, avoiding duplicate + investigation tasks. +- Idempotent task reads retry transient timeouts, connection failures, malformed + responses, HTTP 408/429, and HTTP 500/502/503/504 at most twice with short + backoff. Retries stay inside the read or watchdog deadline. - Task cancellation is never automatically retried. -- If a bounded wait expires, the tool returns a compact working task; call `siclaw_wait_task` again instead of resubmitting the question. -- HTTP 401/403/429/503 and A2A error reasons are returned as MCP tool errors without request headers or key material. -- Adapter restarts do not lose server-side tasks; recover them with `siclaw_list_tasks` using the same key. +- If a bounded wait expires, the tool returns a compact working task; call + `siclaw_wait_task` again instead of resubmitting the question. +- HTTP 401/403/429/503 and A2A error reasons are returned as MCP tool errors + without request headers or key material. +- Adapter restarts do not lose server-side tasks; recover them with + `siclaw_list_tasks` using the same keys. + +## Relation to the remote Sicore MCP endpoint + +Sicore also exposes a first-party remote MCP endpoint (`/api/v1/mcp`, stateless +streamable HTTP). That endpoint is out of scope for this adapter: each remote MCP +client configuration carries its own `Authorization` header, so multi-agent use +there is just multiple client configurations, one per agent — no alias multiplexing +is needed. This local stdio adapter exists for clients that inject credentials from +env/files rather than per-request headers, and it is where named-key aliasing +applies. diff --git a/mcp/sicore-a2a-adapter/src/a2a-client.ts b/mcp/sicore-a2a-adapter/src/a2a-client.ts index d2689280d..da9606d12 100644 --- a/mcp/sicore-a2a-adapter/src/a2a-client.ts +++ b/mcp/sicore-a2a-adapter/src/a2a-client.ts @@ -1,5 +1,8 @@ import { setTimeout as delay } from "node:timers/promises"; -import type { AdapterConfig, ResolvedAdapterConfig } from "./config.js"; +import type { ResolvedAdapterConfig } from "./config.js"; + +/** The subset of config resolveAgentId needs; each named key supplies its own. */ +export type AgentResolveInput = Pick; export const TERMINAL_A2A_STATES = new Set([ "TASK_STATE_COMPLETED", @@ -278,7 +281,7 @@ async function retryIdempotentRead( // from copying the agent UUID into client config. Older Sicore deployments // without the endpoint require SICLAW_AGENT_ID to be set explicitly. export async function resolveAgentId( - config: AdapterConfig, + config: AgentResolveInput, fetchImpl: typeof fetch = globalThis.fetch, ): Promise { let payload: { agentId?: unknown } | null; diff --git a/mcp/sicore-a2a-adapter/src/config.test.ts b/mcp/sicore-a2a-adapter/src/config.test.ts index 18e92e353..a9df50fa1 100644 --- a/mcp/sicore-a2a-adapter/src/config.test.ts +++ b/mcp/sicore-a2a-adapter/src/config.test.ts @@ -20,18 +20,97 @@ function baseEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { } describe("loadConfig", () => { - it("loads and normalizes environment configuration", () => { + it("loads and normalizes the single-key form as the default alias", () => { expect(loadConfig(baseEnv())).toEqual({ baseUrl: "https://sicore.example.com", - agentId: "agent-1", - apiKey: "test-key", + keys: [{ alias: "default", apiKey: "test-key", agentId: "agent-1" }], requestTimeoutMs: 30_000, pollIntervalMs: 3_000, }); }); it("treats SICLAW_AGENT_ID as optional for key self-resolution", () => { - expect(loadConfig(baseEnv({ SICLAW_AGENT_ID: undefined })).agentId).toBeUndefined(); + expect(loadConfig(baseEnv({ SICLAW_AGENT_ID: undefined })).keys).toEqual([ + { alias: "default", apiKey: "test-key", agentId: undefined }, + ]); + }); + + it("parses SICLAW_A2A_KEYS into named keys in declaration order", () => { + const env = baseEnv({ + SICLAW_A2A_KEY: undefined, + SICLAW_AGENT_ID: undefined, + SICLAW_A2A_KEYS: '{"sre":"sk-a","kb":"sk-b"}', + }); + expect(loadConfig(env).keys).toEqual([ + { alias: "sre", apiKey: "sk-a" }, + { alias: "kb", apiKey: "sk-b" }, + ]); + }); + + it("merges the single key as default alongside named keys", () => { + const env = baseEnv({ + SICLAW_AGENT_ID: undefined, + SICLAW_A2A_KEYS: '{"kb":"sk-b"}', + }); + expect(loadConfig(env).keys).toEqual([ + { alias: "default", apiKey: "test-key", agentId: undefined }, + { alias: "kb", apiKey: "sk-b" }, + ]); + }); + + it("rejects a named alias colliding with the single-key default", () => { + const env = baseEnv({ + SICLAW_AGENT_ID: undefined, + SICLAW_A2A_KEYS: '{"default":"sk-b"}', + }); + expect(() => loadConfig(env)).toThrow(/collides with the single-key/); + }); + + it("rejects invalid alias names", () => { + const env = baseEnv({ + SICLAW_A2A_KEY: undefined, + SICLAW_AGENT_ID: undefined, + SICLAW_A2A_KEYS: '{"SRE":"sk-a"}', + }); + expect(() => loadConfig(env)).toThrow(/is invalid/); + }); + + it("rejects SICLAW_AGENT_ID combined with SICLAW_A2A_KEYS", () => { + const env = baseEnv({ + SICLAW_A2A_KEY: undefined, + SICLAW_A2A_KEYS: '{"sre":"sk-a"}', + }); + expect(() => loadConfig(env)).toThrow(/cannot be combined with SICLAW_A2A_KEYS/); + }); + + it("rejects malformed SICLAW_A2A_KEYS JSON without echoing values", () => { + const env = baseEnv({ + SICLAW_A2A_KEY: undefined, + SICLAW_AGENT_ID: undefined, + SICLAW_A2A_KEYS: "not-json", + }); + expect(() => loadConfig(env)).toThrow(/must be a JSON object/); + }); + + it("rejects a non-string key value without echoing it", () => { + const env = baseEnv({ + SICLAW_A2A_KEY: undefined, + SICLAW_AGENT_ID: undefined, + SICLAW_A2A_KEYS: '{"sre":123}', + }); + try { + loadConfig(env); + throw new Error("expected ConfigError"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + expect((error as Error).message).toContain("sre"); + expect((error as Error).message).not.toContain("123"); + } + }); + + it("requires at least one key", () => { + const env = baseEnv({ SICLAW_A2A_KEY: undefined, SICLAW_AGENT_ID: undefined }); + expect(() => loadConfig(env)).toThrow(/at least one key/); }); it("allows HTTP only for loopback testing", () => { @@ -47,8 +126,8 @@ describe("loadConfig", () => { const keyFile = join(dir, "key"); writeFileSync(keyFile, "file-key\n", { mode: 0o600 }); chmodSync(keyFile, 0o600); - const env = baseEnv({ SICLAW_A2A_KEY: undefined, SICLAW_A2A_KEY_FILE: keyFile }); - expect(loadConfig(env).apiKey).toBe("file-key"); + const env = baseEnv({ SICLAW_A2A_KEY: undefined, SICLAW_AGENT_ID: undefined, SICLAW_A2A_KEY_FILE: keyFile }); + expect(loadConfig(env).keys).toEqual([{ alias: "default", apiKey: "file-key", agentId: undefined }]); }); it.runIf(process.platform !== "win32")("rejects a group-readable key file", () => { diff --git a/mcp/sicore-a2a-adapter/src/config.ts b/mcp/sicore-a2a-adapter/src/config.ts index 5845dd87a..63b926366 100644 --- a/mcp/sicore-a2a-adapter/src/config.ts +++ b/mcp/sicore-a2a-adapter/src/config.ts @@ -1,15 +1,31 @@ import { readFileSync, statSync } from "node:fs"; +/** One named A2A key. `agentId` is set only when pinned via SICLAW_AGENT_ID on the single-key path. */ +export interface NamedKey { + alias: string; + apiKey: string; + agentId?: string; +} + export interface AdapterConfig { baseUrl: string; - /** Absent when the agent should be resolved from the key via GET /api/v1/a2a/self. */ - agentId?: string; + keys: NamedKey[]; + requestTimeoutMs: number; + pollIntervalMs: number; +} + +/** Per-key config handed to one SicoreA2aClient after its agent is resolved. */ +export interface ResolvedAdapterConfig { + baseUrl: string; + agentId: string; apiKey: string; requestTimeoutMs: number; pollIntervalMs: number; } -export type ResolvedAdapterConfig = AdapterConfig & { agentId: string }; +export const ALIAS_PATTERN = "^[a-z0-9][a-z0-9_-]{0,31}$"; +const ALIAS_RE = new RegExp(ALIAS_PATTERN); +const DEFAULT_ALIAS = "default"; export class ConfigError extends Error { constructor(message: string) { @@ -84,7 +100,10 @@ function loadKeyFromFile(path: string): string { return key; } -function loadApiKey(env: NodeJS.ProcessEnv): string { +// The single-key form (SICLAW_A2A_KEY / SICLAW_A2A_KEY_FILE) is the original, +// backward-compatible way to configure exactly one agent. It maps to the +// reserved "default" alias so old configs keep working untouched. +function loadSingleKey(env: NodeJS.ProcessEnv): string | undefined { const direct = env.SICLAW_A2A_KEY?.trim(); const keyFile = env.SICLAW_A2A_KEY_FILE?.trim(); if (direct && keyFile) { @@ -92,18 +111,82 @@ function loadApiKey(env: NodeJS.ProcessEnv): string { } if (keyFile) return loadKeyFromFile(keyFile); if (direct) return direct; - throw new ConfigError("SICLAW_A2A_KEY or SICLAW_A2A_KEY_FILE is required"); + return undefined; } -export function loadConfig(env: NodeJS.ProcessEnv = process.env): AdapterConfig { - const agentId = env.SICLAW_AGENT_ID?.trim() || undefined; - if (agentId && Buffer.byteLength(agentId, "utf8") > 255) { +function parseKeysJson(raw: string): Array<[string, string]> { + let obj: unknown; + try { + obj = JSON.parse(raw); + } catch { + throw new ConfigError('SICLAW_A2A_KEYS must be a JSON object mapping alias to key, e.g. {"sre":"sk-..."}'); + } + if (obj === null || typeof obj !== "object" || Array.isArray(obj)) { + throw new ConfigError('SICLAW_A2A_KEYS must be a JSON object mapping alias to key, e.g. {"sre":"sk-..."}'); + } + const entries: Array<[string, string]> = []; + for (const [alias, value] of Object.entries(obj)) { + if (typeof value !== "string" || !value.trim()) { + // Do not echo the value; a malformed key must never surface in an error. + throw new ConfigError(`SICLAW_A2A_KEYS["${alias}"] must be a non-empty string key`); + } + entries.push([alias, value.trim()]); + } + if (entries.length === 0) { + throw new ConfigError("SICLAW_A2A_KEYS must contain at least one alias"); + } + return entries; +} + +function loadKeys(env: NodeJS.ProcessEnv): NamedKey[] { + const single = loadSingleKey(env); + const multiRaw = env.SICLAW_A2A_KEYS?.trim(); + const pinnedAgentId = env.SICLAW_AGENT_ID?.trim() || undefined; + if (pinnedAgentId && Buffer.byteLength(pinnedAgentId, "utf8") > 255) { throw new ConfigError("SICLAW_AGENT_ID must be 255 bytes or less"); } + + const byAlias = new Map(); + + if (single !== undefined) { + // SICLAW_AGENT_ID only pins the default single key. Every SICLAW_A2A_KEYS + // entry resolves its own agent at startup, so pinning one id there is + // ambiguous and rejected below. + byAlias.set(DEFAULT_ALIAS, { alias: DEFAULT_ALIAS, apiKey: single, agentId: pinnedAgentId }); + } + + if (multiRaw) { + if (pinnedAgentId) { + throw new ConfigError( + "SICLAW_AGENT_ID cannot be combined with SICLAW_A2A_KEYS; each named key resolves its own agent", + ); + } + for (const [alias, key] of parseKeysJson(multiRaw)) { + if (!ALIAS_RE.test(alias)) { + throw new ConfigError(`SICLAW_A2A_KEYS alias "${alias}" is invalid; must match ${ALIAS_PATTERN}`); + } + if (byAlias.has(alias)) { + // The only possible collision is with the "default" single key. + throw new ConfigError( + `SICLAW_A2A_KEYS alias "${alias}" collides with the single-key SICLAW_A2A_KEY/SICLAW_A2A_KEY_FILE (reserved as "default"); remove one`, + ); + } + byAlias.set(alias, { alias, apiKey: key }); + } + } + + if (byAlias.size === 0) { + throw new ConfigError( + "Configure at least one key via SICLAW_A2A_KEYS, SICLAW_A2A_KEY, or SICLAW_A2A_KEY_FILE", + ); + } + return [...byAlias.values()]; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AdapterConfig { return { baseUrl: normalizeBaseUrl(required(env, "SICORE_URL")), - agentId, - apiKey: loadApiKey(env), + keys: loadKeys(env), requestTimeoutMs: parseBoundedInteger(env, "SICLAW_A2A_TIMEOUT_MS", 30_000, 1_000, 120_000), pollIntervalMs: parseBoundedInteger(env, "SICLAW_A2A_POLL_INTERVAL_MS", 3_000, 500, 5_000), }; diff --git a/mcp/sicore-a2a-adapter/src/index.ts b/mcp/sicore-a2a-adapter/src/index.ts index b6c693276..df3a31366 100644 --- a/mcp/sicore-a2a-adapter/src/index.ts +++ b/mcp/sicore-a2a-adapter/src/index.ts @@ -1,15 +1,41 @@ #!/usr/bin/env node import { resolveAgentId, SicoreA2aClient } from "./a2a-client.js"; -import { loadConfig } from "./config.js"; +import { loadConfig, type AdapterConfig, type NamedKey } from "./config.js"; +import { AgentRouter, type AgentEntry } from "./router.js"; import { serveStdio } from "./server.js"; +async function buildEntry(config: AdapterConfig, key: NamedKey): Promise { + const shared = { + baseUrl: config.baseUrl, + apiKey: key.apiKey, + requestTimeoutMs: config.requestTimeoutMs, + pollIntervalMs: config.pollIntervalMs, + }; + let agentId: string; + try { + agentId = key.agentId ?? await resolveAgentId(shared); + } catch (error) { + // Prefix with the alias (never the key) so the operator knows which entry failed. + throw new Error(`agent "${key.alias}": ${error instanceof Error ? error.message : String(error)}`); + } + const client = new SicoreA2aClient({ ...shared, agentId }); + return { alias: key.alias, agentId, api: client }; +} + async function main(): Promise { const config = loadConfig(); - const agentId = config.agentId ?? await resolveAgentId(config); - const server = await serveStdio(new SicoreA2aClient({ ...config, agentId })); + const entries: AgentEntry[] = []; + for (const key of config.keys) { + entries.push(await buildEntry(config, key)); + } + const router = new AgentRouter(entries); + const server = await serveStdio(router); + + const agentList = entries.map((entry) => `${entry.alias}=${entry.agentId}`).join(", "); + const resolvedFromKey = config.keys.some((key) => !key.agentId); process.stderr.write( - `[sicore-a2a-mcp] ready agent=${agentId} endpoint=${new URL(config.baseUrl).origin}` - + `${config.agentId ? "" : " (agent resolved from key)"}\n`, + `[sicore-a2a-mcp] ready endpoint=${new URL(config.baseUrl).origin} agents=[${agentList}]` + + `${resolvedFromKey ? " (some agents resolved from key)" : ""}\n`, ); const shutdown = async (): Promise => { diff --git a/mcp/sicore-a2a-adapter/src/router.test.ts b/mcp/sicore-a2a-adapter/src/router.test.ts new file mode 100644 index 000000000..3824395e0 --- /dev/null +++ b/mcp/sicore-a2a-adapter/src/router.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SiclawA2aApi } from "./a2a-client.js"; +import { AgentRouter, RoutingError } from "./router.js"; + +function fakeApi(): SiclawA2aApi { + return { + sendMessage: vi.fn(), + getTask: vi.fn(), + cancelTask: vi.fn(), + listTasks: vi.fn(), + waitForTask: vi.fn(), + } as unknown as SiclawA2aApi; +} + +function router(aliases: string[]): AgentRouter { + return new AgentRouter(aliases.map((alias) => ({ alias, agentId: `agent-${alias}`, api: fakeApi() }))); +} + +describe("AgentRouter", () => { + it("rejects an empty or duplicated configuration", () => { + expect(() => new AgentRouter([])).toThrow(RoutingError); + expect(() => router(["sre", "sre"])).toThrow(/Duplicate agent alias/); + }); + + it("preserves alias order and describes agents by id, never a key", () => { + const r = router(["sre", "kb"]); + expect(r.aliases).toEqual(["sre", "kb"]); + expect(r.isSingle).toBe(false); + expect(r.describeAgents()).toBe("sre = agent-sre, kb = agent-kb"); + }); + + it("makes the agent optional for a single configured key", () => { + const r = router(["default"]); + expect(r.selectExplicit(undefined).alias).toBe("default"); + expect(r.selectExplicit("default").alias).toBe("default"); + expect(() => r.selectExplicit("other")).toThrow(/Unknown agent alias "other"/); + }); + + it("refuses to guess an agent for a create call when several exist", () => { + const r = router(["sre", "kb"]); + expect(() => r.selectExplicit(undefined)).toThrow(/Multiple Siclaw agents/); + expect(r.selectExplicit("kb").alias).toBe("kb"); + }); + + it("routes a task to its recorded creator regardless of the argument", () => { + const r = router(["sre", "kb"]); + r.remember("t1", "kb"); + expect(r.selectForTask("t1", undefined)).toEqual({ entry: expect.objectContaining({ alias: "kb" }) }); + + const mismatched = r.selectForTask("t1", "sre"); + expect(mismatched.entry.alias).toBe("kb"); + expect(mismatched.note).toMatch(/Routed to agent "kb".*ignored agent="sre"/); + }); + + it("still validates a bogus argument even when the mapping wins", () => { + const r = router(["sre", "kb"]); + r.remember("t1", "kb"); + expect(() => r.selectForTask("t1", "ghost")).toThrow(/Unknown agent alias "ghost"/); + }); + + it("requires an agent for an untracked task under multiple keys", () => { + const r = router(["sre", "kb"]); + expect(() => r.selectForTask("t9", undefined)).toThrow(/was not created in this session/); + expect(r.selectForTask("t9", "sre").entry.alias).toBe("sre"); + }); + + it("uses the sole agent for an untracked task under a single key", () => { + const r = router(["default"]); + expect(r.selectForTask("t9", undefined).entry.alias).toBe("default"); + }); +}); diff --git a/mcp/sicore-a2a-adapter/src/router.ts b/mcp/sicore-a2a-adapter/src/router.ts new file mode 100644 index 000000000..a70f79357 --- /dev/null +++ b/mcp/sicore-a2a-adapter/src/router.ts @@ -0,0 +1,111 @@ +import type { SiclawA2aApi } from "./a2a-client.js"; + +export interface AgentEntry { + alias: string; + agentId: string; + api: SiclawA2aApi; +} + +export class RoutingError extends Error { + constructor(message: string) { + super(message); + this.name = "RoutingError"; + } +} + +export interface TaskRoute { + entry: AgentEntry; + /** Set when the caller's `agent` argument was overridden by the recorded creator. */ + note?: string; +} + +// AgentRouter maps user-facing aliases to A2A clients and routes tool calls. +// task_id and context_id are per-key server resources, so the router also +// remembers which alias created each task_id (process-local, for the MCP +// server's lifetime). Errors never carry key material, only aliases and the +// agent ids resolved at startup. +export class AgentRouter { + private readonly entries: Map; + private readonly order: string[]; + private readonly taskAlias = new Map(); + + constructor(entries: AgentEntry[]) { + if (entries.length === 0) throw new RoutingError("At least one agent must be configured"); + this.entries = new Map(); + for (const entry of entries) { + if (this.entries.has(entry.alias)) { + throw new RoutingError(`Duplicate agent alias "${entry.alias}"`); + } + this.entries.set(entry.alias, entry); + } + this.order = entries.map((entry) => entry.alias); + } + + get aliases(): string[] { + return [...this.order]; + } + + get isSingle(): boolean { + return this.order.length === 1; + } + + listEntries(): AgentEntry[] { + return this.order.map((alias) => this.entries.get(alias)!); + } + + /** "sre = , kb = " for tool descriptions and error messages. */ + describeAgents(): string { + return this.order.map((alias) => `${alias} = ${this.entries.get(alias)!.agentId}`).join(", "); + } + + requireAlias(alias: string): AgentEntry { + const entry = this.entries.get(alias); + if (!entry) { + throw new RoutingError(`Unknown agent alias "${alias}". Configured agents: ${this.describeAgents()}`); + } + return entry; + } + + /** + * Pick the agent for a create/list call. With a single configured agent the + * argument is optional; with several, an absent argument is an error rather + * than a guess. + */ + selectExplicit(alias: string | undefined): AgentEntry { + if (alias !== undefined) return this.requireAlias(alias); + if (this.isSingle) return this.entries.get(this.order[0])!; + throw new RoutingError( + `Multiple Siclaw agents are configured; pass "agent" to choose one. Configured agents: ${this.describeAgents()}`, + ); + } + + /** + * Pick the agent for a task-scoped call (wait/get/cancel). The recorded + * creator wins over the caller's argument, because task_id belongs to the key + * that created it; when they disagree the returned note records the override. + */ + selectForTask(taskId: string, alias: string | undefined): TaskRoute { + const mapped = this.taskAlias.get(taskId); + if (mapped) { + const entry = this.entries.get(mapped)!; + if (alias !== undefined && alias !== mapped) { + this.requireAlias(alias); // validate for a clean message; mapping still wins + return { + entry, + note: `Routed to agent "${mapped}" that created this task; ignored agent="${alias}".`, + }; + } + return { entry }; + } + if (alias !== undefined) return { entry: this.requireAlias(alias) }; + if (this.isSingle) return { entry: this.entries.get(this.order[0])! }; + throw new RoutingError( + `Task "${taskId}" was not created in this session and multiple agents are configured; ` + + `pass "agent", or run siclaw_list_tasks first. Configured agents: ${this.describeAgents()}`, + ); + } + + remember(taskId: string, alias: string): void { + if (taskId) this.taskAlias.set(taskId, alias); + } +} diff --git a/mcp/sicore-a2a-adapter/src/server.test.ts b/mcp/sicore-a2a-adapter/src/server.test.ts index c847415f6..affaa9877 100644 --- a/mcp/sicore-a2a-adapter/src/server.test.ts +++ b/mcp/sicore-a2a-adapter/src/server.test.ts @@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SiclawA2aApi, SiclawTask } from "./a2a-client.js"; +import { AgentRouter } from "./router.js"; import { createMcpServer } from "./server.js"; const closeAfter: Array<{ close(): Promise }> = []; @@ -34,10 +35,14 @@ function fakeApi(): SiclawA2aApi { }; } +function singleRouter(api: SiclawA2aApi): AgentRouter { + return new AgentRouter([{ alias: "default", agentId: "agent-default", api }]); +} + describe("MCP server", () => { it("completes MCP initialization, lists tools, and calls Siclaw through the adapter", async () => { const api = fakeApi(); - const server = createMcpServer(api); + const server = createMcpServer(singleRouter(api)); const client = new Client({ name: "test-client", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); closeAfter.push(client, server); diff --git a/mcp/sicore-a2a-adapter/src/server.ts b/mcp/sicore-a2a-adapter/src/server.ts index b65ccd666..c122898ba 100644 --- a/mcp/sicore-a2a-adapter/src/server.ts +++ b/mcp/sicore-a2a-adapter/src/server.ts @@ -1,32 +1,44 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import type { SiclawA2aApi } from "./a2a-client.js"; -import { createToolHandler, TOOL_DEFINITIONS } from "./tools.js"; +import type { AgentRouter } from "./router.js"; +import { buildToolDefinitions, createToolHandler } from "./tools.js"; -export const SERVER_INSTRUCTIONS = [ +const BASE_INSTRUCTIONS = [ "Use siclaw_investigate for operational questions that require the configured Siclaw SRE agent.", "When it returns a non-terminal task, keep the current turn open and call siclaw_wait_task with the same task_id until terminal, unless the user requests fire-and-forget, asks to stop, or the overall investigation deadline is exhausted.", "Never resubmit the same question merely because a task is still working.", "Use siclaw_list_tasks to recover server-side tasks after a client restart.", -].join(" "); +]; -export function createMcpServer(api: SiclawA2aApi): Server { +export function buildInstructions(router: AgentRouter): string { + const lines = [...BASE_INSTRUCTIONS]; + if (!router.isSingle) { + lines.push( + `Multiple Siclaw agents are configured; pass the "agent" argument (an alias, never a key) to choose one: ${router.describeAgents()}.`, + "Task waits, snapshots, and cancels auto-route to the agent that created the task, so you usually only need \"agent\" on siclaw_investigate.", + ); + } + return lines.join(" "); +} + +export function createMcpServer(router: AgentRouter): Server { const server = new Server( { name: "sicore-a2a-mcp-adapter", version: "0.1.0" }, - { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }, + { capabilities: { tools: {} }, instructions: buildInstructions(router) }, ); - const handleTool = createToolHandler(api); + const handleTool = createToolHandler(router); + const tools = buildToolDefinitions(router); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...TOOL_DEFINITIONS] })); + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...tools] })); server.setRequestHandler(CallToolRequestSchema, async (request) => ( await handleTool(request.params.name, request.params.arguments ?? {}) as any )); return server; } -export async function serveStdio(api: SiclawA2aApi): Promise { - const server = createMcpServer(api); +export async function serveStdio(router: AgentRouter): Promise { + const server = createMcpServer(router); await server.connect(new StdioServerTransport()); return server; } diff --git a/mcp/sicore-a2a-adapter/src/stdio.e2e.test.ts b/mcp/sicore-a2a-adapter/src/stdio.e2e.test.ts index 6b6dce2d5..73a733698 100644 --- a/mcp/sicore-a2a-adapter/src/stdio.e2e.test.ts +++ b/mcp/sicore-a2a-adapter/src/stdio.e2e.test.ts @@ -121,4 +121,72 @@ describe("stdio process", () => { expect(paths[0]).toBe("/api/v1/a2a/self"); expect(paths).toContain("/api/v1/a2a/agents/agent-resolved/message:send"); }); + + it("resolves several named keys, injects aliases into tool descriptions, and routes by alias", async () => { + const requests: Array<{ path: string; auth: string }> = []; + const mock = createServer(async (request, response) => { + const auth = request.headers.authorization ?? ""; + const path = request.url ?? ""; + requests.push({ path, auth }); + for await (const _chunk of request) { /* drain */ } + response.writeHead(200, { "content-type": "application/a2a+json" }); + if (path === "/api/v1/a2a/self") { + const agentId = auth === "Bearer secret-key-sre" + ? "agent-sre-id" + : auth === "Bearer secret-key-kb" ? "agent-kb-id" : "agent-unknown"; + response.end(JSON.stringify({ agentId })); + return; + } + response.end(JSON.stringify({ task: completedTask() })); + }); + httpServers.push(mock); + const port = await listen(mock); + + const adapterEntrypoint = fileURLToPath(new URL("./index.ts", import.meta.url)); + const transport = new StdioClientTransport({ + command: process.execPath, + args: ["--import", "tsx", adapterEntrypoint], + env: { + SICORE_URL: `http://127.0.0.1:${port}`, + SICLAW_A2A_KEYS: JSON.stringify({ sre: "secret-key-sre", kb: "secret-key-kb" }), + }, + stderr: "pipe", + }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { stderr += String(chunk); }); + const client = new Client({ name: "stdio-e2e-multi", version: "1.0.0" }); + clients.push(client); + await client.connect(transport); + + // Both keys are self-resolved once at startup. + const selfCalls = requests.filter((entry) => entry.path === "/api/v1/a2a/self"); + expect(selfCalls.map((entry) => entry.auth).sort()).toEqual([ + "Bearer secret-key-kb", + "Bearer secret-key-sre", + ]); + + const listed = await client.listTools(); + const investigate = listed.tools.find((tool) => tool.name === "siclaw_investigate")!; + expect(investigate.description).toContain("sre = agent-sre-id"); + expect(investigate.description).toContain("kb = agent-kb-id"); + // The description advertises aliases, never the keys. + expect(investigate.description).not.toContain("secret-key"); + expect(investigate.inputSchema.properties).toHaveProperty("agent"); + expect(investigate.inputSchema.properties).not.toHaveProperty("key"); + + const result = await client.callTool({ + name: "siclaw_investigate", + arguments: { question: "check kb", agent: "kb", wait_seconds: 0 }, + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ task_id: "task-e2e", agent: "kb" }); + const sendCall = requests.find((entry) => entry.path.endsWith("/message:send")); + expect(sendCall?.path).toBe("/api/v1/a2a/agents/agent-kb-id/message:send"); + expect(sendCall?.auth).toBe("Bearer secret-key-kb"); + + // The diagnostic ready line reports agent ids, never key material. + expect(stderr).toContain("agents=["); + expect(stderr).toContain("agent-kb-id"); + expect(stderr).not.toContain("secret-key"); + }); }); diff --git a/mcp/sicore-a2a-adapter/src/tools.test.ts b/mcp/sicore-a2a-adapter/src/tools.test.ts index e0e4fcdfb..fa79f5ef8 100644 --- a/mcp/sicore-a2a-adapter/src/tools.test.ts +++ b/mcp/sicore-a2a-adapter/src/tools.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { SiclawA2aApi, SiclawTask } from "./a2a-client.js"; -import { createToolHandler, TOOL_DEFINITIONS } from "./tools.js"; +import { AgentRouter } from "./router.js"; +import { buildToolDefinitions, createToolHandler } from "./tools.js"; -function task(state: SiclawTask["state"] = "working"): SiclawTask { +function task(state: SiclawTask["state"] = "working", taskId = "task-1"): SiclawTask { return { - task_id: "task-1", + task_id: taskId, context_id: "context-1", state, a2a_state: state === "completed" ? "TASK_STATE_COMPLETED" : "TASK_STATE_WORKING", @@ -16,34 +17,60 @@ function task(state: SiclawTask["state"] = "working"): SiclawTask { }; } -function fakeApi(): SiclawA2aApi { +function fakeApi(overrides: Partial = {}): SiclawA2aApi { return { sendMessage: vi.fn(async () => task()), getTask: vi.fn(async () => task("completed")), cancelTask: vi.fn(async () => ({ ...task(), state: "canceled", a2a_state: "TASK_STATE_CANCELED", is_terminal: true })), listTasks: vi.fn(async () => ({ tasks: [task()], total_size: 1, page_size: 20, next_page_token: null })), waitForTask: vi.fn(async () => task("completed")), + ...overrides, }; } +function router(entries: Array<[string, SiclawA2aApi]>): AgentRouter { + return new AgentRouter(entries.map(([alias, api]) => ({ alias, agentId: `agent-${alias}`, api }))); +} + +function singleRouter(api: SiclawA2aApi = fakeApi()): AgentRouter { + return router([["default", api]]); +} + describe("tool contract", () => { - it("keeps the configured agent out of every model-visible input schema", () => { - expect(TOOL_DEFINITIONS.map((tool) => tool.name)).toEqual([ + it("keeps agent aliases, never a key parameter, in every model-visible input schema", () => { + const defs = buildToolDefinitions(singleRouter()); + expect(defs.map((tool) => tool.name)).toEqual([ "siclaw_investigate", "siclaw_wait_task", "siclaw_get_task", "siclaw_cancel_task", "siclaw_list_tasks", ]); - for (const tool of TOOL_DEFINITIONS) { - expect(tool.inputSchema.properties).not.toHaveProperty("agent_id"); + for (const tool of defs) { + const properties = tool.inputSchema.properties as Record; + expect(properties).not.toHaveProperty("agent_id"); + expect(properties).not.toHaveProperty("key"); + expect(properties).not.toHaveProperty("api_key"); + expect(properties).toHaveProperty("agent"); expect(tool.inputSchema.additionalProperties).toBe(false); + const required = (tool.inputSchema as { required?: string[] }).required ?? []; + expect(required).not.toContain("agent"); } }); - it("submits and waits for a bounded investigation", async () => { + it("injects the configured aliases and agent ids into descriptions", () => { + const defs = buildToolDefinitions(router([["sre", fakeApi()], ["kb", fakeApi()]])); + const investigate = defs.find((tool) => tool.name === "siclaw_investigate")!; + expect(investigate.description).toContain("sre = agent-sre"); + expect(investigate.description).toContain("kb = agent-kb"); + expect(investigate.description).toMatch(/must pass "agent"/); + const list = defs.find((tool) => tool.name === "siclaw_list_tasks")!; + expect(list.description).toMatch(/aggregates tasks from every configured agent/); + }); + + it("submits and waits for a bounded investigation and tags the agent", async () => { const api = fakeApi(); - const handle = createToolHandler(api); + const handle = createToolHandler(singleRouter(api)); const result = await handle("siclaw_investigate", { question: "check node", context_id: "context-1", @@ -52,12 +79,12 @@ describe("tool contract", () => { expect(api.sendMessage).toHaveBeenCalledWith("check node", "context-1"); expect(api.waitForTask).toHaveBeenCalledWith("task-1", 5); expect(result.isError).toBeUndefined(); - expect(result.structuredContent).toMatchObject({ state: "completed", result: "root cause" }); + expect(result.structuredContent).toMatchObject({ state: "completed", result: "root cause", agent: "default" }); }); it("maps simple list status to the A2A enum", async () => { const api = fakeApi(); - const handle = createToolHandler(api); + const handle = createToolHandler(singleRouter(api)); const result = await handle("siclaw_list_tasks", { status: "working", page_size: 10 }); expect(api.listTasks).toHaveBeenCalledWith({ contextId: undefined, @@ -70,14 +97,14 @@ describe("tool contract", () => { it("waits on an existing task without submitting another investigation", async () => { const api = fakeApi(); - const handle = createToolHandler(api); + const handle = createToolHandler(singleRouter(api)); const result = await handle("siclaw_wait_task", { task_id: "task-1", wait_seconds: 45, }); expect(api.waitForTask).toHaveBeenCalledWith("task-1", 45); expect(api.sendMessage).not.toHaveBeenCalled(); - expect(result.structuredContent).toMatchObject({ state: "completed", result: "root cause" }); + expect(result.structuredContent).toMatchObject({ state: "completed", result: "root cause", agent: "default" }); }); it("keeps a working response compact until the terminal result", async () => { @@ -87,7 +114,7 @@ describe("tool contract", () => { result: "partial evidence that should not be repeated", updated_at: "2026-07-18T00:00:00.000Z", }); - const result = await createToolHandler(api)("siclaw_get_task", { task_id: "task-1" }); + const result = await createToolHandler(singleRouter(api))("siclaw_get_task", { task_id: "task-1" }); expect(result.content[0].text).not.toContain("partial evidence that should not be repeated"); expect(result.content[0].text).toContain("siclaw_wait_task"); expect(result.structuredContent).toMatchObject({ @@ -100,7 +127,7 @@ describe("tool contract", () => { it("keeps the submitted task_id when the bounded wait fails after submission", async () => { const api = fakeApi(); vi.mocked(api.waitForTask).mockRejectedValue(new Error("Sicore A2A request timed out")); - const result = await createToolHandler(api)("siclaw_investigate", { question: "check node" }); + const result = await createToolHandler(singleRouter(api))("siclaw_investigate", { question: "check node" }); expect(api.sendMessage).toHaveBeenCalledOnce(); expect(result.isError).toBeUndefined(); @@ -115,20 +142,20 @@ describe("tool contract", () => { }); it("returns MCP tool errors for invalid arguments", async () => { - const result = await createToolHandler(fakeApi())("siclaw_get_task", {}); + const result = await createToolHandler(singleRouter())("siclaw_get_task", {}); expect(result.isError).toBe(true); expect(result.content[0].text).toMatch(/task_id/); }); it("keeps waits below common MCP client request timeouts", async () => { - const result = await createToolHandler(fakeApi())("siclaw_investigate", { + const result = await createToolHandler(singleRouter())("siclaw_investigate", { question: "check node", wait_seconds: 51, }); expect(result.isError).toBe(true); expect(result.content[0].text).toMatch(/between 0 and 50/); - const waitResult = await createToolHandler(fakeApi())("siclaw_wait_task", { + const waitResult = await createToolHandler(singleRouter())("siclaw_wait_task", { task_id: "task-1", wait_seconds: 51, }); @@ -136,3 +163,100 @@ describe("tool contract", () => { expect(waitResult.content[0].text).toMatch(/between 1 and 50/); }); }); + +describe("multi-key routing", () => { + it("refuses to guess when several agents are configured and none is named", async () => { + const handle = createToolHandler(router([["sre", fakeApi()], ["kb", fakeApi()]])); + const result = await handle("siclaw_investigate", { question: "check node" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Multiple Siclaw agents/); + expect(result.content[0].text).toContain("sre = agent-sre"); + expect(result.content[0].text).toContain("kb = agent-kb"); + }); + + it("rejects an unknown alias and lists valid ones without leaking a key", async () => { + const handle = createToolHandler(router([["sre", fakeApi()], ["kb", fakeApi()]])); + const result = await handle("siclaw_investigate", { question: "check node", agent: "nope" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Unknown agent alias "nope"/); + expect(result.content[0].text).toContain("sre = agent-sre"); + }); + + it("routes a named investigation to that agent only", async () => { + const sre = fakeApi(); + const kb = fakeApi(); + const handle = createToolHandler(router([["sre", sre], ["kb", kb]])); + const result = await handle("siclaw_investigate", { question: "check node", agent: "kb", wait_seconds: 0 }); + expect(kb.sendMessage).toHaveBeenCalledWith("check node", undefined); + expect(sre.sendMessage).not.toHaveBeenCalled(); + expect(result.structuredContent).toMatchObject({ agent: "kb" }); + }); + + it("auto-routes a follow-up task op to the agent that created the task", async () => { + const sre = fakeApi(); + const kb = fakeApi(); + const handle = createToolHandler(router([["sre", sre], ["kb", kb]])); + await handle("siclaw_investigate", { question: "check node", agent: "kb", wait_seconds: 0 }); + + const waited = await handle("siclaw_wait_task", { task_id: "task-1", wait_seconds: 5 }); + expect(kb.waitForTask).toHaveBeenCalledWith("task-1", 5); + expect(sre.waitForTask).not.toHaveBeenCalled(); + expect(waited.structuredContent).toMatchObject({ agent: "kb" }); + expect(waited.structuredContent).not.toHaveProperty("routing_note"); + }); + + it("honors the recorded creator over a mismatched agent argument and notes the override", async () => { + const sre = fakeApi(); + const kb = fakeApi(); + const handle = createToolHandler(router([["sre", sre], ["kb", kb]])); + await handle("siclaw_investigate", { question: "check node", agent: "kb", wait_seconds: 0 }); + + const got = await handle("siclaw_get_task", { task_id: "task-1", agent: "sre" }); + expect(kb.getTask).toHaveBeenCalledWith("task-1"); + expect(sre.getTask).not.toHaveBeenCalled(); + expect(got.structuredContent).toMatchObject({ agent: "kb" }); + expect((got.structuredContent as { routing_note?: string }).routing_note) + .toMatch(/Routed to agent "kb".*ignored agent="sre"/); + expect(got.content[0].text).toContain("routing_note:"); + }); + + it("refuses an untracked task op that cannot be attributed to one agent", async () => { + const handle = createToolHandler(router([["sre", fakeApi()], ["kb", fakeApi()]])); + const result = await handle("siclaw_cancel_task", { task_id: "ghost" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/was not created in this session/); + }); + + it("aggregates list_tasks across every agent and tags each row", async () => { + const sre = fakeApi({ + listTasks: vi.fn(async () => ({ tasks: [task("working", "sre-1")], total_size: 1, page_size: 20, next_page_token: 20 })), + }); + const kb = fakeApi({ + listTasks: vi.fn(async () => ({ tasks: [task("completed", "kb-1")], total_size: 1, page_size: 20, next_page_token: null })), + }); + const handle = createToolHandler(router([["sre", sre], ["kb", kb]])); + const result = await handle("siclaw_list_tasks", {}); + expect(result.structuredContent).toMatchObject({ total_size: 2, next_page_token: null, truncated_agents: ["sre"] }); + const tasks = (result.structuredContent as { tasks: Array<{ task_id: string; agent: string }> }).tasks; + expect(tasks).toEqual(expect.arrayContaining([ + expect.objectContaining({ task_id: "sre-1", agent: "sre" }), + expect.objectContaining({ task_id: "kb-1", agent: "kb" }), + ])); + expect(result.content[0].text).toContain("agent=sre"); + expect(result.content[0].text).toContain("More tasks exist for agents: sre"); + }); + + it("recovers and remembers ownership from a per-agent list", async () => { + const sre = fakeApi(); + const kb = fakeApi({ + listTasks: vi.fn(async () => ({ tasks: [task("working", "kb-9")], total_size: 1, page_size: 20, next_page_token: null })), + }); + const handle = createToolHandler(router([["sre", sre], ["kb", kb]])); + await handle("siclaw_list_tasks", { agent: "kb" }); + + const waited = await handle("siclaw_wait_task", { task_id: "kb-9", wait_seconds: 5 }); + expect(kb.waitForTask).toHaveBeenCalledWith("kb-9", 5); + expect(sre.waitForTask).not.toHaveBeenCalled(); + expect(waited.structuredContent).toMatchObject({ agent: "kb" }); + }); +}); diff --git a/mcp/sicore-a2a-adapter/src/tools.ts b/mcp/sicore-a2a-adapter/src/tools.ts index 4ea8677ec..1595b8fb6 100644 --- a/mcp/sicore-a2a-adapter/src/tools.ts +++ b/mcp/sicore-a2a-adapter/src/tools.ts @@ -1,4 +1,6 @@ -import type { SiclawA2aApi, SiclawTask, SiclawTaskList } from "./a2a-client.js"; +import type { SiclawTask } from "./a2a-client.js"; +import { ALIAS_PATTERN } from "./config.js"; +import type { AgentRouter } from "./router.js"; const STATUS_TO_A2A: Record = { submitted: "TASK_STATE_SUBMITTED", @@ -9,97 +11,139 @@ const STATUS_TO_A2A: Record = { rejected: "TASK_STATE_REJECTED", }; -export const TOOL_DEFINITIONS = [ - { - name: "siclaw_investigate", - description: "Ask the configured Siclaw SRE agent to investigate an operational question. This creates an asynchronous Sicore A2A task. Reuse context_id to continue the same investigation. If the returned task is not terminal, do not submit it again: call siclaw_wait_task until it finishes unless the user explicitly requested fire-and-forget. The configured A2A key fixes which Siclaw agent is used.", - inputSchema: { - type: "object", - properties: { - question: { - type: "string", - minLength: 1, - description: "The concrete operational question for Siclaw, including relevant target names and time window when known.", - }, - context_id: { - type: "string", - minLength: 1, - maxLength: 255, - description: "Optional context_id from a prior task to continue the same Siclaw investigation session.", - }, - wait_seconds: { - type: "integer", - minimum: 0, - maximum: 50, - default: 20, - description: "How long to wait for a terminal result before returning the task as working. The 50-second ceiling stays below common MCP client request timeouts; use siclaw_wait_task later when it is still running.", +function agentHint(router: AgentRouter): string { + if (router.isSingle) { + return `Only one Siclaw agent is configured (${router.describeAgents()}); "agent" may be omitted.`; + } + return `Configured Siclaw agents (pass one as "agent"): ${router.describeAgents()}.`; +} + +function agentProperty(router: AgentRouter) { + return { + type: "string", + pattern: ALIAS_PATTERN, + description: + `Which configured Siclaw agent to use, selected by alias (never a key). ${agentHint(router)}`, + } as const; +} + +export function buildToolDefinitions(router: AgentRouter) { + const agent = agentProperty(router); + const multi = !router.isSingle; + const hint = agentHint(router); + const requireAgent = multi ? ` You must pass "agent" because more than one agent is configured.` : ""; + const routeNote = ` If "agent" is omitted, the adapter routes to the agent that created the task.`; + const listNote = multi + ? ` Without "agent" it aggregates tasks from every configured agent and tags each with its alias; with "agent" it lists only that agent's tasks.` + : ` Tasks are tagged with the configured agent's alias.`; + + return [ + { + name: "siclaw_investigate", + description: + "Ask the configured Siclaw SRE agent to investigate an operational question. This creates an asynchronous Sicore A2A task. Reuse context_id to continue the same investigation. If the returned task is not terminal, do not submit it again: call siclaw_wait_task until it finishes unless the user explicitly requested fire-and-forget. The A2A key selected by \"agent\" fixes which Siclaw agent is used." + + ` ${hint}${requireAgent}`, + inputSchema: { + type: "object", + properties: { + question: { + type: "string", + minLength: 1, + description: "The concrete operational question for Siclaw, including relevant target names and time window when known.", + }, + agent, + context_id: { + type: "string", + minLength: 1, + maxLength: 255, + description: "Optional context_id from a prior task to continue the same Siclaw investigation session. It belongs to the same agent that created it.", + }, + wait_seconds: { + type: "integer", + minimum: 0, + maximum: 50, + default: 20, + description: "How long to wait for a terminal result before returning the task as working. The 50-second ceiling stays below common MCP client request timeouts; use siclaw_wait_task later when it is still running.", + }, }, + required: ["question"], + additionalProperties: false, }, - required: ["question"], - additionalProperties: false, }, - }, - { - name: "siclaw_wait_task", - description: "Wait for an existing Siclaw investigation without creating another task. Use this as the same-turn watchdog: call it repeatedly while the task is non-terminal, unless the user asks to stop or the overall investigation deadline is exhausted. Working responses are compact; the full report is returned once the task reaches a terminal state.", - inputSchema: { - type: "object", - properties: { - task_id: { type: "string", minLength: 1, maxLength: 255 }, - wait_seconds: { - type: "integer", - minimum: 1, - maximum: 50, - default: 45, - description: "Bounded watchdog wait. Keep this below the MCP client's request timeout.", + { + name: "siclaw_wait_task", + description: + "Wait for an existing Siclaw investigation without creating another task. Use this as the same-turn watchdog: call it repeatedly while the task is non-terminal, unless the user asks to stop or the overall investigation deadline is exhausted. Working responses are compact; the full report is returned once the task reaches a terminal state." + + ` ${hint}${routeNote}`, + inputSchema: { + type: "object", + properties: { + task_id: { type: "string", minLength: 1, maxLength: 255 }, + agent, + wait_seconds: { + type: "integer", + minimum: 1, + maximum: 50, + default: 45, + description: "Bounded watchdog wait. Keep this below the MCP client's request timeout.", + }, }, + required: ["task_id"], + additionalProperties: false, }, - required: ["task_id"], - additionalProperties: false, }, - }, - { - name: "siclaw_get_task", - description: "Get one immediate snapshot of a Siclaw investigation task created with this same A2A key. Use siclaw_wait_task instead when actively waiting for completion.", - inputSchema: { - type: "object", - properties: { - task_id: { type: "string", minLength: 1, maxLength: 255 }, + { + name: "siclaw_get_task", + description: + "Get one immediate snapshot of a Siclaw investigation task. Use siclaw_wait_task instead when actively waiting for completion." + + ` ${hint}${routeNote}`, + inputSchema: { + type: "object", + properties: { + task_id: { type: "string", minLength: 1, maxLength: 255 }, + agent, + }, + required: ["task_id"], + additionalProperties: false, }, - required: ["task_id"], - additionalProperties: false, }, - }, - { - name: "siclaw_cancel_task", - description: "Cancel a non-terminal Siclaw investigation task created with this same A2A key.", - inputSchema: { - type: "object", - properties: { - task_id: { type: "string", minLength: 1, maxLength: 255 }, + { + name: "siclaw_cancel_task", + description: + "Cancel a non-terminal Siclaw investigation task." + + ` ${hint}${routeNote}`, + inputSchema: { + type: "object", + properties: { + task_id: { type: "string", minLength: 1, maxLength: 255 }, + agent, + }, + required: ["task_id"], + additionalProperties: false, }, - required: ["task_id"], - additionalProperties: false, }, - }, - { - name: "siclaw_list_tasks", - description: "List Siclaw investigation tasks scoped to this configured agent and A2A key. Use this to recover task IDs after a local client restart.", - inputSchema: { - type: "object", - properties: { - context_id: { type: "string", minLength: 1, maxLength: 255 }, - status: { - type: "string", - enum: ["submitted", "working", "completed", "failed", "canceled", "rejected"], + { + name: "siclaw_list_tasks", + description: + "List Siclaw investigation tasks. Use this to recover task IDs after a local client restart." + + ` ${hint}${listNote}`, + inputSchema: { + type: "object", + properties: { + agent, + context_id: { type: "string", minLength: 1, maxLength: 255 }, + status: { + type: "string", + enum: ["submitted", "working", "completed", "failed", "canceled", "rejected"], + }, + page_size: { type: "integer", minimum: 1, maximum: 100, default: 20 }, + page_token: { type: "integer", minimum: 0, default: 0 }, }, - page_size: { type: "integer", minimum: 1, maximum: 100, default: 20 }, - page_token: { type: "integer", minimum: 0, default: 0 }, + additionalProperties: false, }, - additionalProperties: false, }, - }, -] as const; + ] as const; +} type ToolResult = { isError?: boolean; @@ -108,14 +152,17 @@ type ToolResult = { }; type TaskToolView = Omit & { + agent: string; result?: string | null; progress_chars: number; + routing_note?: string; wait_error?: string; }; -function taskView(task: SiclawTask, includeTerminalResult: boolean): TaskToolView { +function taskView(task: SiclawTask, alias: string, includeTerminalResult: boolean): TaskToolView { const { result, ...summary } = task; return { + agent: alias, ...summary, progress_chars: result?.length ?? 0, ...(includeTerminalResult && task.is_terminal ? { result } : {}), @@ -155,14 +202,21 @@ function intArg( return value as number; } -function taskText(task: SiclawTask, waitError?: string): string { +interface TaskAnnotations { + waitError?: string; + routingNote?: string; +} + +function taskText(task: SiclawTask, alias: string, notes: TaskAnnotations = {}): string { const lines = [ `Siclaw task ${task.task_id}: ${task.state}`, + `agent: ${alias}`, `context_id: ${task.context_id}`, ]; if (task.updated_at) lines.push(`updated_at: ${task.updated_at}`); if (task.status_message) lines.push(`status: ${task.status_message}`); - if (waitError) lines.push(`wait_error: ${waitError} (status polling failed, but the task was already created)`); + if (notes.routingNote) lines.push(`routing_note: ${notes.routingNote}`); + if (notes.waitError) lines.push(`wait_error: ${notes.waitError} (status polling failed, but the task was already created)`); if (task.is_terminal && task.result) lines.push("", task.result); if (!task.is_terminal) { const progressChars = task.result?.length ?? 0; @@ -172,70 +226,154 @@ function taskText(task: SiclawTask, waitError?: string): string { return lines.join("\n"); } -function taskResult(task: SiclawTask, waitError?: string): ToolResult { - const view = taskView(task, true); - if (waitError) view.wait_error = waitError; +function taskResult(task: SiclawTask, alias: string, notes: TaskAnnotations = {}): ToolResult { + const view = taskView(task, alias, true); + if (notes.routingNote) view.routing_note = notes.routingNote; + if (notes.waitError) view.wait_error = notes.waitError; return { - content: [{ type: "text", text: taskText(task, waitError) }], + content: [{ type: "text", text: taskText(task, alias, notes) }], structuredContent: view as unknown as Record, }; } -function listText(list: SiclawTaskList): string { - if (list.tasks.length === 0) return "No Siclaw tasks matched this query."; - const rows = list.tasks.map((task) => `${task.task_id}\t${task.state}\tcontext=${task.context_id}`); - return [`Siclaw tasks (${list.tasks.length}/${list.total_size}):`, ...rows].join("\n"); +interface TaggedTask { + task: SiclawTask; + alias: string; +} + +function listResult( + tagged: TaggedTask[], + meta: { totalSize: number; pageSize: number; nextPageToken: number | null; truncatedAgents?: string[] }, +): ToolResult { + const lines: string[] = []; + if (tagged.length === 0) { + lines.push("No Siclaw tasks matched this query."); + } else { + lines.push(`Siclaw tasks (${tagged.length}/${meta.totalSize}):`); + for (const { task, alias } of tagged) { + lines.push(`${task.task_id}\t${task.state}\tagent=${alias}\tcontext=${task.context_id}`); + } + } + if (meta.truncatedAgents && meta.truncatedAgents.length > 0) { + lines.push( + "", + `More tasks exist for agents: ${meta.truncatedAgents.join(", ")}. ` + + "Query siclaw_list_tasks again with that agent and page_token to page through them.", + ); + } + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { + tasks: tagged.map(({ task, alias }) => taskView(task, alias, false)), + total_size: meta.totalSize, + page_size: meta.pageSize, + next_page_token: meta.nextPageToken, + ...(meta.truncatedAgents && meta.truncatedAgents.length > 0 + ? { truncated_agents: meta.truncatedAgents } + : {}), + } as unknown as Record, + }; } -export function createToolHandler(api: SiclawA2aApi) { +export function createToolHandler(router: AgentRouter) { return async (name: string, rawArgs: unknown): Promise => { try { const args = argsRecord(rawArgs ?? {}); + const agentArg = stringArg(args, "agent"); + if (name === "siclaw_investigate") { + const entry = router.selectExplicit(agentArg); const question = stringArg(args, "question", true)!; if (Buffer.byteLength(question, "utf8") > 512 * 1024) { throw new Error("question must be 512 KiB or less"); } const contextId = stringArg(args, "context_id"); const waitSeconds = intArg(args, "wait_seconds", 20, 0, 50); - const submitted = await api.sendMessage(question, contextId); - if (waitSeconds === 0 || submitted.is_terminal) return taskResult(submitted); + const submitted = await entry.api.sendMessage(question, contextId); + router.remember(submitted.task_id, entry.alias); + if (waitSeconds === 0 || submitted.is_terminal) return taskResult(submitted, entry.alias); try { - return taskResult(await api.waitForTask(submitted.task_id, waitSeconds)); + const done = await entry.api.waitForTask(submitted.task_id, waitSeconds); + router.remember(done.task_id, entry.alias); + return taskResult(done, entry.alias); } catch (error) { // The task exists server-side at this point; a plain error would drop the // task_id and invite the client to resubmit a duplicate investigation. - return taskResult(submitted, error instanceof Error ? error.message : String(error)); + return taskResult(submitted, entry.alias, { + waitError: error instanceof Error ? error.message : String(error), + }); } } + if (name === "siclaw_get_task") { - return taskResult(await api.getTask(stringArg(args, "task_id", true)!)); + const taskId = stringArg(args, "task_id", true)!; + const { entry, note } = router.selectForTask(taskId, agentArg); + const task = await entry.api.getTask(taskId); + router.remember(task.task_id, entry.alias); + return taskResult(task, entry.alias, { routingNote: note }); } + if (name === "siclaw_wait_task") { const taskId = stringArg(args, "task_id", true)!; const waitSeconds = intArg(args, "wait_seconds", 45, 1, 50); - return taskResult(await api.waitForTask(taskId, waitSeconds)); + const { entry, note } = router.selectForTask(taskId, agentArg); + const task = await entry.api.waitForTask(taskId, waitSeconds); + router.remember(task.task_id, entry.alias); + return taskResult(task, entry.alias, { routingNote: note }); } + if (name === "siclaw_cancel_task") { - return taskResult(await api.cancelTask(stringArg(args, "task_id", true)!)); + const taskId = stringArg(args, "task_id", true)!; + const { entry, note } = router.selectForTask(taskId, agentArg); + const task = await entry.api.cancelTask(taskId); + router.remember(task.task_id, entry.alias); + return taskResult(task, entry.alias, { routingNote: note }); } + if (name === "siclaw_list_tasks") { const status = stringArg(args, "status"); if (status && !STATUS_TO_A2A[status]) throw new Error("status is invalid"); - const list = await api.listTasks({ + const options = { contextId: stringArg(args, "context_id"), status: status ? STATUS_TO_A2A[status] : undefined, pageSize: intArg(args, "page_size", 20, 1, 100), pageToken: intArg(args, "page_token", 0, 0, Number.MAX_SAFE_INTEGER), - }); - return { - content: [{ type: "text", text: listText(list) }], - structuredContent: { - ...list, - tasks: list.tasks.map((task) => taskView(task, false)), - } as unknown as Record, }; + + // Aggregate across every agent only when none is named and several exist. + if (agentArg === undefined && !router.isSingle) { + const perAgent = await Promise.all( + router.listEntries().map(async (entry) => ({ entry, list: await entry.api.listTasks(options) })), + ); + const tagged: TaggedTask[] = []; + const truncatedAgents: string[] = []; + let totalSize = 0; + for (const { entry, list } of perAgent) { + for (const task of list.tasks) { + router.remember(task.task_id, entry.alias); + tagged.push({ task, alias: entry.alias }); + } + totalSize += list.total_size; + if (list.next_page_token !== null) truncatedAgents.push(entry.alias); + } + // A combined cursor across agents would be meaningless; page per agent instead. + return listResult(tagged, { + totalSize, + pageSize: options.pageSize, + nextPageToken: null, + truncatedAgents, + }); + } + + const entry = router.selectExplicit(agentArg); + const list = await entry.api.listTasks(options); + for (const task of list.tasks) router.remember(task.task_id, entry.alias); + return listResult( + list.tasks.map((task) => ({ task, alias: entry.alias })), + { totalSize: list.total_size, pageSize: list.page_size, nextPageToken: list.next_page_token }, + ); } + throw new Error(`Unknown tool: ${name}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); From 4fcb40b1ee19f0d4344784202479811b4972ad46 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 22 Jul 2026 23:09:01 +0800 Subject: [PATCH 06/18] Add test-session resilience: turn-stall watchdog, idle-TTL reaper, lifecycle logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three production-confirmed defects in the read-only test-session path (kbc/platform/pod/compile_box.py), all box-side: 1. Test turns hung on "thinking" forever. The test-session receive loop (test_session_driver) was a bare `async for msg in receive_messages()` with no model-stall watchdog — unlike the compile path, which runs _consume_turn_stream under _model_stall_watchdog. A black-holed model request (gateway accepts but never responds) left the turn active and the UI spinning indefinitely, with no recovery. Fix: _test_stall_watchdog + _consume_test_turn_stream reuse the compile idle-latency semantics (any SDK event == alive; a pending read tool relaxes to the tool bound). On a stall the turn is reaped once (no retry): interrupt the SDK stream, emit a turn-level error + turn_done so the UI goes idle, and keep the session live for the next question. The receive loop discards the torn-down ResultMessage. Bound defaults to the compile idle bound, overridable via KBC_TEST_MODEL_IDLE_TIMEOUT_S. 2. Orphaned sessions exhausted the concurrency cap. TEST_SESSIONS had no idle TTL and no way to enumerate sessions, so a server timeout or a persistence failure left box-side sessions that nobody closed, filling KBC_MAX_TEST_SESSIONS (3) until pod recreation. Fix: TestRun now tracks created_at / last_activity_at / a monotonic idle marker (touched on open, each turn, and event consumption). A periodic sweep (_test_session_reaper, KBC_TEST_SESSION_IDLE_TTL_S default 1800s) tears down idle sessions and emits a close event. New GET /test-sessions returns {"sessions":[{tid,parent_run_id,created_at,last_activity_at,done}]} (wire contract agreed with the consumer). 3. Zero lifecycle observability. The box only printed its startup line, so pod logs showed nothing for test-session open/close/turn activity. Fix: _print_test_lifecycle emits one stdout line per lifecycle event (test.open / test.close / turn.start / turn.done / turn.stalled / ttl.reap), carrying tid + parent_run_id only — never user content. Constraints held: CompileRun behavior untouched; destream/unrelated areas not touched; test sessions keep faithful streaming (no de-stream shim), so the new watchdog is their only stall guard. Tests (test_compile_box.py, +3): stall reaper frees a wedged turn and keeps the session live for a follow-up question; idle-TTL sweep drops orphans and spares active sessions; GET /test-sessions returns the agreed wire shape. Full suite green (69 passing). --- kbc/platform/pod/compile_box.py | 241 ++++++++++++++++++++++++++- kbc/platform/pod/test_compile_box.py | 168 +++++++++++++++++++ 2 files changed, 404 insertions(+), 5 deletions(-) diff --git a/kbc/platform/pod/compile_box.py b/kbc/platform/pod/compile_box.py index 58163e3ad..f6638aaae 100644 --- a/kbc/platform/pod/compile_box.py +++ b/kbc/platform/pod/compile_box.py @@ -226,6 +226,52 @@ def _test_snapshot_root() -> str: _HIERARCHICAL_MODEL_IDLE_TIMEOUT_S = float(os.environ.get( "KBC_HIERARCHICAL_MODEL_IDLE_TIMEOUT_S", "240")) +# ── Test-session resilience (read-only consumer turns) ─────────────────────── +# A test session streams faithfully (no de-stream shim), so the compile-path +# watchdog does not cover it. It gets its OWN turn-stall guard reusing the same +# idle-latency semantics (SDK event == alive), defaulting to the compile idle +# bound. Unlike compile, a wedged test turn is reaped WITHOUT retry: the UI is +# freed with a turn-level error and the session stays live for the next question. +_TEST_MODEL_IDLE_TIMEOUT_S = float(os.environ.get( + "KBC_TEST_MODEL_IDLE_TIMEOUT_S", str(_MODEL_IDLE_TIMEOUT_S))) +# Orphan reaper: a server timeout / persistence failure can leave a box-side test +# session that nobody closes, holding one of KBC_MAX_TEST_SESSIONS until the pod +# is recreated. A periodic sweep tears down sessions idle past this TTL. +_TEST_SESSION_IDLE_TTL_S = float(os.environ.get("KBC_TEST_SESSION_IDLE_TTL_S", "1800")) +_TEST_SESSION_REAP_POLL_S = float(os.environ.get("KBC_TEST_SESSION_REAP_POLL_S", "60")) + + +def _now_iso() -> str: + """UTC timestamp for the test-session wire contract (created_at/last_activity_at).""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _print_test_lifecycle(label: str, run: "TestRun", extra: str = "") -> None: + """One stdout line per test-session lifecycle event (test.open / test.close / + turn.start / turn.done / turn.stalled / ttl.reap). tid + parent_run_id ONLY — + never user content — so pod logs make orphan/stall triage possible without + leaking the KB or the consumer's questions. Same [compile_box] prefix style.""" + tail = f" {extra}" if extra else "" + print(f"[compile_box] {label} tid={run.tid} parent={run.parent_run_id}{tail}", flush=True) + + +def _touch_test_activity(run: "TestRun") -> None: + """Mark liveness for the idle-TTL reaper (open, each turn, event consumption).""" + run._last_activity_monotonic = time.monotonic() + run.last_activity_at = _now_iso() + + +def _arm_test_turn(run: "TestRun") -> None: + """Arm the turn-stall watchdog for a new test-session turn (the /test-message + analogue of CompileRun._begin_turn, minus compile/selfcheck bookkeeping).""" + run._turn_active = True + run._tool_pending = False + run._stall_reaped = False + run._last_sdk_message_type = "query" + run._last_model_activity = time.monotonic() + _touch_test_activity(run) + + # ── Rate-limit resilience (C2) ─────────────────────────────────────────────── # massapi under concurrency (5-10 boxes × the red-blue PK) can return 429/503/529. # The bundled CLI retries internally a few times; past that the turn ends with @@ -445,6 +491,24 @@ def __init__(self, tid: str, cwd: str, parent_run_id: str, snapshot_hash: str, l self.consumer_model: str | None = None self.consumer_max_turns: int | None = None self.consumer_fingerprint: str = "" + # Lifecycle timestamps for the idle-TTL reaper and GET /test-sessions. + # created_at/last_activity_at are wire (iso8601, agreed with sicore); + # _last_activity_monotonic drives the TTL comparison (clock-skew safe). + now = time.monotonic() + self.created_at: str = _now_iso() + self.last_activity_at: str = self.created_at + self._last_activity_monotonic: float = now + # Turn-stall watchdog state (mirrors CompileRun's, minus retry/compile + # bookkeeping). A turn is active from a /test-message query() until its + # ResultMessage; the watchdog only judges an active turn, against model + # latency, relaxing to the tool bound while a read tool runs. + self._turn_active = False + self._tool_pending = False + self._last_model_activity: float = now + self._last_sdk_message_type = "query" # controlled class name only; never content + # Set by the watchdog when it reaps a wedged turn; the receive loop then + # discards the interrupted ResultMessage instead of announcing it. + self._stall_reaped = False async def emit(self, ev: dict): await self.events.put(ev) @@ -4096,6 +4160,118 @@ async def guard(input_data, tool_use_id, context): return guard +async def _consume_test_turn_stream(run: "TestRun", client) -> None: + """Relay a read-only test session's message stream through _emit_message, + owning the test-path stall reaper. Every inbound SDK message is liveness + (resets the idle clock, updates tool_pending) AND refreshes the TTL. Unlike + the compile path there is NO retry: when the watchdog has reaped the turn, + its ResultMessage is the torn-down attempt — discard it (the UI already got a + turn_done from the watchdog) and stay live for the next /test-message. A real + ResultMessage ends the turn normally.""" + async for msg in client.receive_messages(): + _note_model_activity(run, msg) # SDK event == alive; flips tool_pending + _touch_test_activity(run) # any message is also TTL liveness + if type(msg).__name__ == "ResultMessage": + if run._stall_reaped: + # The watchdog interrupted this turn and already emitted the + # turn-level error + turn_done. This is the reaped attempt's + # empty result; drop its partial text and keep the session live. + run._turn_text = [] + run._stall_reaped = False + run._turn_active = False + continue + was_active = run._turn_active + run._turn_active = False + if was_active: + _print_test_lifecycle("turn.done", run) + await _emit_message(run, msg) + continue + await _emit_message(run, msg) + + +async def _test_stall_watchdog(run: "TestRun") -> None: + """Reap a test-session turn wedged on a black-holed model request. The test + path streams faithfully (no de-stream shim), so this is its ONLY stall guard. + It does not retry: it reaps the turn once (interrupt → the receive loop + discards the torn-down result), emits a turn-level error + turn_done so the + UI stops "thinking", and leaves the session live for the next question. Only + judges an ACTIVE turn; a pending read tool relaxes to the tool bound so a slow + Read/Grep over the snapshot is never false-killed (I4).""" + while not run.done: + await asyncio.sleep(_MODEL_WATCHDOG_POLL_S) + if not run._turn_active or run._stall_reaped: + continue + client = run.client + if client is None: + continue + # destream_floor=0: test sessions do not de-stream, so the model bound + # needs no lift; a pending tool still keeps its own (longer) bound. + bound = _watchdog_idle_bound(run._tool_pending, _TEST_MODEL_IDLE_TIMEOUT_S, 0.0) + idle = time.monotonic() - run._last_model_activity + if idle <= bound: + continue + run._stall_reaped = True + run._turn_active = False + run._turn_text = [] # the wedged attempt produced nothing usable + _print_test_lifecycle( + "turn.stalled", run, extra=f"idle={round(idle, 1)}s bound={round(bound, 1)}s") + await run.emit({ + "type": "turn_stalled", + "idle_s": round(idle, 1), + "bound_s": round(bound, 1), + "tool_pending": run._tool_pending, + "last_sdk_message": run._last_sdk_message_type, + }) + await run.emit({"type": "error", "error": _loc(run, + "The answer timed out — please try again.", + "回答超时,请重试。")}) + # turn_done frees the UI (idle) while the session stays open; the next + # /test-message re-arms a fresh turn on the same live client. + await run.emit({"type": "turn_done", "text": _loc(run, + "The answer timed out — please try again.", + "回答超时,请重试。")}) + try: + await client.interrupt() + except Exception as e: + # A swallowed interrupt cannot un-wedge the receive loop, but the UI + # already recovered above; do not disconnect (spec: keep the session + # live). Surface the interrupt failure for pod-log triage. + await run.emit({"type": "summary", "text": _loc(run, + f"Test session: interrupt after stall failed {e!r}", + f"测试会话:停滞后中断失败 {e!r}")}) + + +async def _reap_idle_test_sessions() -> int: + """One idle-TTL sweep: tear down every test session idle past the TTL and emit + a close event. Split from the loop so a test can drive a single deterministic + pass. Returns the number reaped.""" + now = time.monotonic() + stale = [r for r in list(TEST_SESSIONS.values()) + if not r.done and (now - r._last_activity_monotonic) > _TEST_SESSION_IDLE_TTL_S] + for run in stale: + _print_test_lifecycle( + "ttl.reap", run, extra=f"idle={round(now - run._last_activity_monotonic)}s") + try: + await run.emit({"type": "summary", "text": _loc(run, + "Test session closed after being idle.", + "测试会话空闲超时,已关闭。")}) + except Exception: + pass + await _teardown_test_session(run) # cancels the task → wrapper emits `end` + return len(stale) + + +async def _test_session_reaper() -> None: + """Periodic idle-TTL sweep for orphaned test sessions. Fail-open: a sweep + error must never kill the reaper loop (it protects a shared concurrency slot).""" + while True: + await asyncio.sleep(_TEST_SESSION_REAP_POLL_S) + try: + await _reap_idle_test_sessions() + except Exception as e: + print(f"[compile_box] test-session reaper sweep failed: {e!r}", flush=True) + + async def test_session_driver(run: "TestRun"): """Read-only consumer driver: host a ClaudeSDKClient over the pinned snapshot dir, tools limited to the kb-test profile's whitelist (default Read/Glob/Grep), @@ -4146,8 +4322,10 @@ async def test_session_driver(run: "TestRun"): sid = str(getattr(client, "session_id", sid) or sid) run.session_id = sid await run.emit({"type": "session", "session_id": sid}) - async for msg in client.receive_messages(): - await _emit_message(run, msg) + # Persistent conversational stream with the turn-stall reaper (started by + # _test_session_wrapper). Yields this turn's output then blocks for the + # next /test-message, keeping the session alive until close/idle-TTL. + await _consume_test_turn_stream(run, client) finally: run.connected.set() # unblock any /test-message waiters even if connect failed run.client = None @@ -4621,15 +4799,22 @@ async def assist_reference_answer(parent: "CompileRun", payload: dict) -> dict: async def _test_session_wrapper(run: "TestRun"): - """Lifecycle for a read-only test session: run the driver, turn a crash into an - `error` event, always close with `end`. No syncer / bundle fallback (read-only, - nothing to persist). Cancellation (teardown) skips `error`, still emits `end`.""" + """Lifecycle for a read-only test session: run the driver under a turn-stall + watchdog, turn a crash into an `error` event, always close with `end`. No + syncer / bundle fallback (read-only, nothing to persist). Cancellation + (teardown) skips `error`, still emits `end`.""" + watchdog = asyncio.create_task(_test_stall_watchdog(run)) try: await _TEST_SESSION_IMPL(run) except Exception as e: # top-level boundary; CancelledError (teardown) passes through await run.emit({"type": "error", "error": repr(e)}) finally: run.done = True + watchdog.cancel() + try: + await watchdog + except asyncio.CancelledError: + pass await run.emit({"type": "end"}) @@ -4637,6 +4822,7 @@ async def _teardown_test_session(run: "TestRun"): """Cancel the session task (→ driver finally disconnects the client), drop the snapshot dir, and forget the run. The original RUNS machinery has no GC; test sessions are frequent + ephemeral, so they get explicit teardown.""" + _print_test_lifecycle("test.close", run) if run.task and not run.task.done(): run.task.cancel() try: @@ -5218,6 +5404,7 @@ async def handle_open_test(request: web.Request): ) TEST_SESSIONS[tid] = run run.task = asyncio.create_task(_test_session_wrapper(run)) + _print_test_lifecycle("test.open", run, extra=f"pages={pages}") return web.json_response({ "ok": True, "test_session_id": tid, @@ -5310,6 +5497,8 @@ async def handle_test_message(request: web.Request): text = (body.get("message") or "").strip() if not text: return web.json_response({"error": "message is required"}, status=400) + _arm_test_turn(run) # arm the turn-stall watchdog for this turn + _print_test_lifecycle("turn.start", run) await run.client.query(text) return web.json_response({"ok": True}) @@ -5346,6 +5535,45 @@ async def handle_close_test(request: web.Request): return web.json_response({"ok": True}) +async def handle_list_test_sessions(request: web.Request): + """List the box's live test sessions so the consumer can reconcile orphans + against its own records and drive the idle-reap view. Wire contract agreed + with sicore — the field names (tid/parent_run_id/created_at/last_activity_at/ + done) are load-bearing, do not rename.""" + return web.json_response({"sessions": [ + { + "tid": r.tid, + "parent_run_id": r.parent_run_id, + "created_at": r.created_at, + "last_activity_at": r.last_activity_at, + "done": r.done, + } + for r in TEST_SESSIONS.values() + ]}) + + +_TEST_SESSION_REAPER_TASK: "asyncio.Task | None" = None + + +async def _start_test_session_reaper(_app) -> None: + """aiohttp on_startup: launch the idle-TTL orphan reaper (needs a running loop).""" + global _TEST_SESSION_REAPER_TASK + _TEST_SESSION_REAPER_TASK = asyncio.create_task(_test_session_reaper()) + + +async def _stop_test_session_reaper(_app) -> None: + """aiohttp on_cleanup: stop the reaper so it does not outlive the app.""" + global _TEST_SESSION_REAPER_TASK + task = _TEST_SESSION_REAPER_TASK + _TEST_SESSION_REAPER_TASK = None + if task is not None: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + async def _flush_on_shutdown(_app) -> None: """aiohttp on_shutdown (F3): SIGTERM is stopping the box. Emit a final workspace sync for every active run so the last unsynced work reaches the @@ -5391,7 +5619,9 @@ def build_app() -> web.Application: middlewares=[_client_certificate_middleware], ) app.on_startup.append(destream.start) + app.on_startup.append(_start_test_session_reaper) app.on_shutdown.append(_flush_on_shutdown) + app.on_cleanup.append(_stop_test_session_reaper) app.add_routes([ web.post("/sources", handle_sources), web.post("/sources/begin", handle_sources_begin), @@ -5409,6 +5639,7 @@ def build_app() -> web.Application: web.post("/test-reference-assist/{run_id}", handle_test_reference_assist), web.post("/test-message/{tid}", handle_test_message), web.get("/test-events/{tid}", handle_test_events), + web.get("/test-sessions", handle_list_test_sessions), web.post("/test-session/{tid}/close", handle_close_test), web.get("/health", handle_health), ]) diff --git a/kbc/platform/pod/test_compile_box.py b/kbc/platform/pod/test_compile_box.py index e6af936eb..df7ef3c51 100644 --- a/kbc/platform/pod/test_compile_box.py +++ b/kbc/platform/pod/test_compile_box.py @@ -6,6 +6,7 @@ """ import asyncio import base64 +import contextlib import hashlib import io import json @@ -13,6 +14,7 @@ import shutil import tarfile import tempfile +import time from pathlib import Path from aiohttp.test_utils import TestClient, TestServer @@ -953,6 +955,169 @@ async def drain(run): print("✓ test-session step frames (retrieval trace); compile runs unaffected") +class _TestStallFake: + """First query black-holes (receive blocks on a gate) so the watchdog must + reap it; interrupt() yields the torn-down ResultMessage. A SECOND query + produces a real answer then closes → proves the session stayed live after the + reap (the whole point of defect 1: recover the UI without killing the session).""" + + def __init__(self): + self.queries = [] + self.interrupts = 0 + self._gate = asyncio.Event() + self._mode = "idle" + self._closed = False + + async def connect(self, prompt=None): + pass + + async def query(self, text, session_id="default"): + self.queries.append(text) + if len(self.queries) == 1: + self._mode = "blackhole" # no gate set → receive blocks (the wedge) + else: + self._mode = "produce" + self._gate.set() + + async def interrupt(self): + self.interrupts += 1 + self._mode = "interrupted" + self._gate.set() + + async def receive_messages(self): + while not self._closed: + await self._gate.wait() + self._gate.clear() + if self._mode == "interrupted": + yield ResultMessage() # the reaped attempt ends + elif self._mode == "produce": + yield AssistantMessage("RoCE is a transport") + yield ResultMessage() + self._closed = True + return + + async def disconnect(self): + self._closed = True + + +async def test_test_session_stall_reaps_turn_keeps_session_live(): + """Defect 1: a black-holed test turn used to hang the UI on "思考中" forever — + the test path had no stall watchdog. Now a wedged turn is reaped (interrupt + + turn-level error + turn_done so the UI goes idle), and the session stays live: + a fresh question after the stall still gets a real answer. Also asserts the + turn.stalled / turn.done stdout lines carry tid+parent and NO user content.""" + saved = (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, compile_box._MODEL_WATCHDOG_POLL_S) + compile_box._TEST_MODEL_IDLE_TIMEOUT_S = 0.15 + compile_box._MODEL_WATCHDOG_POLL_S = 0.03 + buf = io.StringIO() + fake = _TestStallFake() + try: + with tempfile.TemporaryDirectory() as snap, contextlib.redirect_stdout(buf): + run = compile_box.TestRun("t-stall", snap, parent_run_id="p-stall", snapshot_hash="h") + run.client = fake + run.connected.set() + wdog = asyncio.create_task(compile_box._test_stall_watchdog(run)) + consume = asyncio.create_task(compile_box._consume_test_turn_stream(run, fake)) + try: + # turn 1 → black-holed; the watchdog must interrupt/reap it + compile_box._arm_test_turn(run) + await fake.query("what is roce?") + deadline = time.monotonic() + 3 + while time.monotonic() < deadline and fake.interrupts == 0: + await asyncio.sleep(0.02) + assert fake.interrupts == 1, fake.interrupts + # turn 2 → the SAME live session answers a fresh question + compile_box._arm_test_turn(run) + await fake.query("again") + await asyncio.wait_for(consume, timeout=3) + finally: + run.done = True + wdog.cancel() + try: + await wdog + except asyncio.CancelledError: + pass + evs = _drain(run) + types = [e["type"] for e in evs] + assert "turn_stalled" in types and "error" in types and "turn_done" in types, types + td = next(e for e in evs if e["type"] == "turn_done") + assert "timed out" in td["text"], td # UI-facing timeout wording (en default) + # the post-stall question produced a real answer → session stayed usable + assert any(e["type"] == "log" and "RoCE" in e.get("text", "") for e in evs), evs + assert fake.queries == ["what is roce?", "again"], fake.queries + out = buf.getvalue() + assert "turn.stalled tid=t-stall parent=p-stall" in out, out + assert "turn.done tid=t-stall parent=p-stall" in out, out + assert "roce" not in out.lower(), out # never leak the question text to stdout + finally: + (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, compile_box._MODEL_WATCHDOG_POLL_S) = saved + print("✓ test-session stall watchdog reaps a wedged turn and keeps the session live") + + +async def test_test_session_idle_ttl_reaper(): + """Defect 2 (TTL): a test session idle past KBC_TEST_SESSION_IDLE_TTL_S is torn + down by the periodic sweep — the snapshot dir + registry slot are freed and a + ttl.reap stdout line fires. A recently-active session is left alone.""" + compile_box.TEST_SESSIONS.clear() + saved = compile_box._TEST_SESSION_IDLE_TTL_S + compile_box._TEST_SESSION_IDLE_TTL_S = 100.0 + buf = io.StringIO() + stale_dir = tempfile.mkdtemp() + fresh_dir = tempfile.mkdtemp() + try: + stale = compile_box.TestRun("t-stale", stale_dir, parent_run_id="p-ttl", snapshot_hash="h") + stale._last_activity_monotonic = time.monotonic() - 500.0 # idle > TTL + fresh = compile_box.TestRun("t-fresh", fresh_dir, parent_run_id="p-ttl", snapshot_hash="h") + compile_box._touch_test_activity(fresh) # just active + compile_box.TEST_SESSIONS["t-stale"] = stale + compile_box.TEST_SESSIONS["t-fresh"] = fresh + with contextlib.redirect_stdout(buf): + reaped = await compile_box._reap_idle_test_sessions() + assert reaped == 1, reaped + assert "t-stale" not in compile_box.TEST_SESSIONS, compile_box.TEST_SESSIONS + assert "t-fresh" in compile_box.TEST_SESSIONS + assert not Path(stale_dir).exists(), "stale snapshot dir not dropped" + # the reaped session got a close (summary) event before teardown emitted end + se = [stale.events.get_nowait() for _ in range(stale.events.qsize())] + assert any(e["type"] == "summary" for e in se), se + out = buf.getvalue() + assert "ttl.reap tid=t-stale parent=p-ttl" in out, out + assert "test.close tid=t-stale parent=p-ttl" in out, out + finally: + compile_box._TEST_SESSION_IDLE_TTL_S = saved + compile_box.TEST_SESSIONS.clear() + shutil.rmtree(stale_dir, ignore_errors=True) + shutil.rmtree(fresh_dir, ignore_errors=True) + print("✓ test-session idle-TTL reaper drops orphans, spares active sessions") + + +async def test_list_test_sessions_endpoint(): + """Defect 2 (list): GET /test-sessions returns the agreed sicore wire contract + exactly — tid / parent_run_id / created_at / last_activity_at / done — one row + per live session.""" + compile_box.TEST_SESSIONS.clear() + client = TestClient(TestServer(compile_box.build_app())) + await client.start_server() + try: + empty = await (await client.get("/test-sessions")).json() + assert empty == {"sessions": []}, empty + + run = compile_box.TestRun("tl1", "/tmp", parent_run_id="pl1", snapshot_hash="h") + run.done = False + compile_box.TEST_SESSIONS["tl1"] = run + body = await (await client.get("/test-sessions")).json() + assert list(body.keys()) == ["sessions"], body + assert len(body["sessions"]) == 1, body + row = body["sessions"][0] + assert set(row.keys()) == {"tid", "parent_run_id", "created_at", "last_activity_at", "done"}, row + assert row["tid"] == "tl1" and row["parent_run_id"] == "pl1" and row["done"] is False, row + assert row["created_at"].endswith("Z") and row["last_activity_at"].endswith("Z"), row + finally: + await client.close() + compile_box.TEST_SESSIONS.clear() + print("✓ GET /test-sessions lists live sessions in the agreed wire contract") + + async def test_explicit_test_recommendation_http(): """The explicit red-team endpoint is stable-draft-only, single-flight, and returns one validated raw-grounded recommendation without mutating files.""" @@ -3907,6 +4072,9 @@ async def main(): await test_open_close_test_session_http() await test_test_message_path() await test_test_session_step_frames() + await test_test_session_stall_reaps_turn_keeps_session_live() + await test_test_session_idle_ttl_reaper() + await test_list_test_sessions_endpoint() await test_explicit_test_recommendation_http() await test_reference_assist_http_and_validation() await test_reference_assist_driver_is_fast_isolated_and_structured() From 651dc321a627f1b17450e4d1c085ee55f9f27679 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 22 Jul 2026 23:24:53 +0800 Subject: [PATCH 07/18] Add capability.testSessions runtime method + structured 429 for test-session limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the box-side test-session resilience through the TS gateway so the consumer can reach it, aligned with the sicore side. 1. capability.testSessions (src/gateway/server.ts): new RPC method proxying the box's GET /test-sessions. Read-only reconciliation — mirrors testClose's box discovery and NEVER spawns/rehydrates a box just to list (absent/dead box → empty list). Box rows pass through verbatim; the `tid` wire field is load-bearing for the consumer and is not renamed. Response: {run_id, sessions:[{tid,parent_run_id,created_at,last_activity_at,done}]}. 2. Stable 429 error code (kbc/platform/pod/compile_box.py): the box's "too many concurrent test sessions" refusal now returns the structured {error:{code,message,retriable:false}} shape (same convention as handle_test_recommendation) with code "test_session_limit". The agentbox error mapping (agentBoxResponseError → wrapRpcError) passes the code + retriable=false through unchanged. Without this a bare 429 mapped to the generic TOO_MANY_REQUESTS with retriable=true — indistinguishable from a model rate-limit 429 and wrongly retriable. Not retriable by design: the caller must close an idle session, not auto-retry. 3. Wire contract (src/gateway/capability/contract.ts): CAPABILITY_TEST_SESSIONS, the request/response/summary interfaces, and TEST_SESSION_LIMIT_ERROR_CODE documented so the box↔runtime↔consumer contract is single-sourced. Tests: new src/gateway/server-capability-test-sessions.test.ts (passthrough with tid preserved / empty on absent box / run_id required); box test asserts the structured 429 body. tsc --noEmit clean; gateway vitest green; box suite 69 passing. --- kbc/platform/pod/compile_box.py | 18 ++- kbc/platform/pod/test_compile_box.py | 8 +- src/gateway/capability/contract.ts | 42 ++++++ .../server-capability-test-sessions.test.ts | 126 ++++++++++++++++++ src/gateway/server.ts | 29 ++++ 5 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 src/gateway/server-capability-test-sessions.test.ts diff --git a/kbc/platform/pod/compile_box.py b/kbc/platform/pod/compile_box.py index f6638aaae..515c4c8ed 100644 --- a/kbc/platform/pod/compile_box.py +++ b/kbc/platform/pod/compile_box.py @@ -239,6 +239,13 @@ def _test_snapshot_root() -> str: # is recreated. A periodic sweep tears down sessions idle past this TTL. _TEST_SESSION_IDLE_TTL_S = float(os.environ.get("KBC_TEST_SESSION_IDLE_TTL_S", "1800")) _TEST_SESSION_REAP_POLL_S = float(os.environ.get("KBC_TEST_SESSION_REAP_POLL_S", "60")) +# Structured error code for the 429 refusal when the pod's concurrent +# test-session cap is full. Emitted in the box's own {error:{code,message, +# retriable}} shape (same convention as handle_test_recommendation) so the +# runtime's agentbox error mapping passes a STABLE code + retriable=false +# through to the consumer — a bare 429 would otherwise map to the generic +# TOO_MANY_REQUESTS with retriable=true. Mirrored in gateway contract.ts. +TEST_SESSION_LIMIT_ERROR_CODE = "test_session_limit" def _now_iso() -> str: @@ -5376,7 +5383,16 @@ async def handle_open_test(request: web.Request): return web.json_response({"error": "unknown run"}, status=404) active = sum(1 for t in TEST_SESSIONS.values() if not t.done) if active >= _max_test_sessions(): - return web.json_response({"error": "too many concurrent test sessions"}, status=429) + # Structured error (same shape as handle_test_recommendation) so the + # runtime maps a STABLE code + retriable=false, not the generic 429. + # Not retriable: the caller must close an idle session, not auto-retry. + return web.json_response( + {"error": { + "code": TEST_SESSION_LIMIT_ERROR_CODE, + "message": "too many concurrent test sessions", + "retriable": False, + }}, + status=429) body = await request.json() if request.body_exists else {} consumer_tools = _effective_test_allowed_tools(body.get("allowed_tools")) consumer_model = _test_model() diff --git a/kbc/platform/pod/test_compile_box.py b/kbc/platform/pod/test_compile_box.py index df7ef3c51..ea661a938 100644 --- a/kbc/platform/pod/test_compile_box.py +++ b/kbc/platform/pod/test_compile_box.py @@ -828,7 +828,13 @@ async def test_open_close_test_session_http(): # concurrency cap → 429 (deterministic: a non-done stub occupies the only slot) os.environ["KBC_MAX_TEST_SESSIONS"] = "1" compile_box.TEST_SESSIONS["busy"] = compile_box.TestRun("busy", "/tmp/x", "p1", "h") - assert (await client.post("/test-session/p1")).status == 429 + capped = await client.post("/test-session/p1") + assert capped.status == 429 + # structured error so the runtime maps a stable code + retriable=false + err = (await capped.json())["error"] + assert err["code"] == compile_box.TEST_SESSION_LIMIT_ERROR_CODE, err + assert err["retriable"] is False, err + assert "too many concurrent test session" in err["message"], err compile_box.TEST_SESSIONS.clear() os.environ.pop("KBC_MAX_TEST_SESSIONS", None) diff --git a/src/gateway/capability/contract.ts b/src/gateway/capability/contract.ts index e35c40569..172dec39e 100644 --- a/src/gateway/capability/contract.ts +++ b/src/gateway/capability/contract.ts @@ -44,6 +44,20 @@ export const CAPABILITY_TEST_MESSAGE = "capability.testMessage" as const; export const CAPABILITY_TEST_CLOSE = "capability.testClose" as const; export const CAPABILITY_TEST_RECOMMEND = "capability.testRecommend" as const; export const CAPABILITY_TEST_REFERENCE_ASSIST = "capability.testReferenceAssist" as const; +/** Enumerate the box's live test sessions for a run (orphan reconciliation). */ +export const CAPABILITY_TEST_SESSIONS = "capability.testSessions" as const; + +/** + * ErrorDetail.code the box emits when testStart is refused because the pod's + * concurrent test-session cap (KBC_MAX_TEST_SESSIONS) is full — HTTP 429 with a + * structured `{error:{code,message,retriable:false}}` body. Sourced in the box + * (compile_box handle_open_test) and passed through unchanged by the agentbox + * error mapping (agentBoxResponseError → wrapRpcError). NOT retriable: the caller + * must close an idle session, not auto-retry. Distinct from the generic + * TOO_MANY_REQUESTS a bare 429 would otherwise map to, so a consumer can tell + * "test-session limit" apart from a model rate-limit 429. + */ +export const TEST_SESSION_LIMIT_ERROR_CODE = "test_session_limit" as const; /** siclaw → consumer: live stream + content sink + input fetch. */ export const CAPABILITY_EVENT = "capability.event" as const; @@ -214,6 +228,34 @@ export interface CapabilityTestCloseRequest { test_session_id: string; } +/** + * Enumerate the box's live test sessions for a run so the consumer can + * reconcile orphans (a lost server ack / persistence failure can leave a + * box-side session nobody closed) against its own records. Read-only: the + * runtime NEVER spawns/rehydrates a box just to list — a dead/absent box has + * no sessions and returns an empty list. + */ +export interface CapabilityTestSessionsRequest { + run_id: string; +} + +export interface CapabilityTestSessionSummary { + /** Box-side test session id. Field name is `tid` (NOT test_session_id) — the + * box's GET /test-sessions rows pass through unchanged; do not rename. */ + tid: string; + parent_run_id: string; + /** iso8601 UTC (e.g. "2026-07-22T09:30:00Z"). */ + created_at: string; + /** iso8601 UTC; refreshed on open, each turn, and event consumption. */ + last_activity_at: string; + done: boolean; +} + +export interface CapabilityTestSessionsResponse { + run_id: string; + sessions: CapabilityTestSessionSummary[]; +} + /** * Explicit, owner-triggered red-team authoring of one regression test. * This is deliberately separate from compilation: the one-shot session can diff --git a/src/gateway/server-capability-test-sessions.test.ts b/src/gateway/server-capability-test-sessions.test.ts new file mode 100644 index 000000000..d747a2c21 --- /dev/null +++ b/src/gateway/server-capability-test-sessions.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getJsonMock = vi.hoisted(() => vi.fn()); + +vi.mock("./chat-repo.js", () => ({ + ensureChatSession: vi.fn(async () => {}), + appendMessage: vi.fn(async () => "msg-id"), + bindMessageTraceId: vi.fn(async () => {}), + updateMessage: vi.fn(async () => {}), + incrementMessageCount: vi.fn(async () => {}), +})); +vi.mock("./output-redactor.js", () => ({ buildRedactionConfigForModelConfig: vi.fn(() => ({})) })); +vi.mock("./sse-consumer.js", () => ({ + consumeAgentSse: vi.fn(async () => ({ resultText: "", taskReportText: "", errorMessage: "", eventCount: 0, durationMs: 0 })), +})); +vi.mock("./agentbox/client.js", () => ({ + AgentBoxClient: class { + endpoint: string; + constructor(endpoint: string) { this.endpoint = endpoint; } + async postJson() { return {}; } + getJson = getJsonMock; + async *streamPath() {} + }, +})); +vi.mock("./capability/materialize.js", () => ({ + materializeCapabilityInputs: vi.fn(async () => ({ locale: undefined, llm: undefined, settings: undefined })), +})); + +const { startRuntime } = await import("./server.js"); + +function fakeFrontendClient() { + return { + request: vi.fn(async () => ({})), + onCommand: vi.fn(), + emitEvent: vi.fn(), + close: vi.fn(), + } as any; +} + +function fakeAgentBoxManager(box: { boxId: string; endpoint: string; agentId: string } | null) { + return { + setCertManager: vi.fn(), + setSpawnEnvResolver: vi.fn(), + setPersistenceResolver: vi.fn(), + getAsync: vi.fn(async () => box), + getOrCreate: vi.fn(async () => box), + stop: vi.fn(async () => {}), + list: vi.fn(() => []), + cleanup: vi.fn(async () => {}), + } as any; +} + +let server: Awaited> | undefined; + +beforeEach(() => { + getJsonMock.mockReset(); +}); + +afterEach(async () => { + if (server) await server.close(); + server = undefined; + vi.clearAllMocks(); +}); + +describe("capability.testSessions", () => { + it("lists the box's sessions verbatim (tid field preserved) without spawning a box", async () => { + const rows = [ + { + tid: "test-1", + parent_run_id: "run-live", + created_at: "2026-07-22T09:30:00Z", + last_activity_at: "2026-07-22T09:31:00Z", + done: false, + }, + ]; + getJsonMock.mockResolvedValue({ sessions: rows }); + const manager = fakeAgentBoxManager({ + boxId: "agentbox-run-live", + endpoint: "https://10.0.0.10:3000", + agentId: "run-live", + }); + server = await startRuntime({ + config: { port: 0, internalPort: 0, host: "127.0.0.1", serverUrl: "", portalSecret: "" } as any, + agentBoxManager: manager, + frontendClient: fakeFrontendClient(), + credentialService: {} as any, + }); + + const list = server.rpcMethods.get("capability.testSessions")!; + await expect(list({ run_id: "run-live" })).resolves.toEqual({ + run_id: "run-live", + sessions: rows, // passed through byte-for-byte: `tid`, not renamed + }); + expect(manager.getAsync).toHaveBeenCalledWith("run-live"); + expect(getJsonMock).toHaveBeenCalledWith("/test-sessions"); + expect(manager.getOrCreate).not.toHaveBeenCalled(); // never spawn to list + }); + + it("returns an empty list when the box is absent — never spawns/rehydrates", async () => { + const manager = fakeAgentBoxManager(null); + server = await startRuntime({ + config: { port: 0, internalPort: 0, host: "127.0.0.1", serverUrl: "", portalSecret: "" } as any, + agentBoxManager: manager, + frontendClient: fakeFrontendClient(), + credentialService: {} as any, + }); + + const list = server.rpcMethods.get("capability.testSessions")!; + await expect(list({ run_id: "run-gone" })).resolves.toEqual({ run_id: "run-gone", sessions: [] }); + expect(getJsonMock).not.toHaveBeenCalled(); + expect(manager.getOrCreate).not.toHaveBeenCalled(); + }); + + it("requires run_id", async () => { + const manager = fakeAgentBoxManager(null); + server = await startRuntime({ + config: { port: 0, internalPort: 0, host: "127.0.0.1", serverUrl: "", portalSecret: "" } as any, + agentBoxManager: manager, + frontendClient: fakeFrontendClient(), + credentialService: {} as any, + }); + + const list = server.rpcMethods.get("capability.testSessions")!; + await expect(list({} as any)).rejects.toThrow("run_id is required"); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 036d77d62..753d3039c 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -44,6 +44,9 @@ import type { CapabilityTestReferenceAssistRequest, CapabilityTestReferenceAssistResponse, CapabilityTestMessageRequest, + CapabilityTestSessionsRequest, + CapabilityTestSessionsResponse, + CapabilityTestSessionSummary, CapabilityTestStartRequest, CapabilityTestStartResponse, } from "./capability/contract.js"; @@ -989,6 +992,32 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise { + const req = params as unknown as CapabilityTestSessionsRequest; + if (!req.run_id) throw new Error("run_id is required"); + // Read-only reconciliation: NEVER spawn/rehydrate a box just to list (mirrors + // testClose's box discovery). An absent/dead box has no live sessions → []. + // The box's GET /test-sessions rows are passed through verbatim (the `tid` + // wire field is load-bearing for the consumer — do not rename). + let client: AgentBoxClient; + if (localCapabilityBoxEndpoint) { + client = new AgentBoxClient(localCapabilityBoxEndpoint, 30000, agentBoxTlsOptions); + } else { + const alive = await agentBoxManager.getAsync(req.run_id); + if (!alive) { + const empty: CapabilityTestSessionsResponse = { run_id: req.run_id, sessions: [] }; + return empty; + } + client = new AgentBoxClient(alive.endpoint, 30000, agentBoxTlsOptions); + } + const listed = await client.getJson<{ sessions: CapabilityTestSessionSummary[] }>("/test-sessions"); + const response: CapabilityTestSessionsResponse = { + run_id: req.run_id, + sessions: listed.sessions ?? [], + }; + return response; + }); + rpcMethods.set("chat.abort", async (params) => { const agentId = params.agentId as string; const sessionId = params.sessionId as string; From 2a0a995e8794bc9623128d82e4129f0ac2157d38 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 23 Jul 2026 12:32:07 +0800 Subject: [PATCH 08/18] Add testStart idempotency key + run-scoped test-session listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2 follow-ups from the sicore MR review, layered on the test-session work. 1. testStart idempotency (kbc/platform/pod/compile_box.py + gateway): handle_open_test accepts an optional `idempotency_key`. A retried open with the same key returns the SAME live session (same tid/snapshot_hash/pages) with `idempotent_replay: true`, instead of opening a second session or consuming a second concurrency slot — so a lost ack / client retry cannot leave a ghost session. Checked before the cap (a replay is never 429'd) and before packing (no snapshot work). The key→tid map is process-local and cleared with the session on teardown, so a torn-down key opens fresh and can never replay a dead tid. Absent key → behavior unchanged (old-consumer compat). The gateway's capability.testStart passes the field through and, crucially, SKIPS starting a second event relay on an idempotent_replay — the box's /test-events is single-consumer, so a duplicate relay would split the stream. 2. Run-scoped listing (gateway): capability.testSessions filters the box's rows to the requested run_id. A shared box (SICLAW_COMPILE_BOX_ENDPOINT) hosts sessions for multiple parent runs; one run must never see or reap another's. Double protection with the consumer's own ParentRunID check. Field name `idempotency_key` (aligned with sicore). Tests: box test asserts same-key replay → same tid + replay flag + unchanged active count, different key → new session, teardown drops the key (fresh afterwards), no-key never deduped; gateway test asserts a shared box's two runs are mutually invisible in the list. tsc --noEmit clean; gateway vitest green; box suite 70 passing. --- kbc/platform/pod/compile_box.py | 45 ++++++++- kbc/platform/pod/test_compile_box.py | 96 +++++++++++++++++++ src/gateway/capability/contract.ts | 8 ++ .../server-capability-test-sessions.test.ts | 30 ++++++ src/gateway/server.ts | 40 +++++--- 5 files changed, 204 insertions(+), 15 deletions(-) diff --git a/kbc/platform/pod/compile_box.py b/kbc/platform/pod/compile_box.py index 515c4c8ed..900661752 100644 --- a/kbc/platform/pod/compile_box.py +++ b/kbc/platform/pod/compile_box.py @@ -101,6 +101,11 @@ # Read-only "test session" runs — ephemeral consumer sessions over a pinned draft # snapshot (test sessions). Parallel to RUNS, torn down on close/idle. See TestRun. TEST_SESSIONS: dict[str, "TestRun"] = {} +# Consumer-minted idempotency key → tid, for open-test-session retries. A retried +# testStart (same key) must return the SAME live session, never a second session +# or concurrency slot. Process-local; entries are cleared on teardown with their +# session (a torn-down key opens fresh, so a stale mapping can never be replayed). +TEST_SESSION_IDEMPOTENCY: dict[str, str] = {} # One explicit red-team read-only review may inspect a run at a time. Test # recommendation and reference-answer assistance share this guard so two owner # actions cannot spend overlapping model calls against the same workspace. @@ -516,6 +521,11 @@ def __init__(self, tid: str, cwd: str, parent_run_id: str, snapshot_hash: str, l # Set by the watchdog when it reaps a wedged turn; the receive loop then # discards the interrupted ResultMessage instead of announcing it. self._stall_reaped = False + # Snapshot page count (returned by open, replayed on an idempotent open). + self.pages: int = 0 + # Consumer-minted idempotency key that opened this session (if any), so + # teardown can drop its TEST_SESSION_IDEMPOTENCY entry. + self.idempotency_key: str | None = None async def emit(self, ev: dict): await self.events.put(ev) @@ -4830,6 +4840,12 @@ async def _teardown_test_session(run: "TestRun"): snapshot dir, and forget the run. The original RUNS machinery has no GC; test sessions are frequent + ephemeral, so they get explicit teardown.""" _print_test_lifecycle("test.close", run) + # Drop the idempotency mapping WITH the session: a torn-down key must open a + # fresh session, never replay a dead tid (guard on tid so a re-minted key for + # a newer session is not clobbered). + key = run.idempotency_key + if key and TEST_SESSION_IDEMPOTENCY.get(key) == run.tid: + TEST_SESSION_IDEMPOTENCY.pop(key, None) if run.task and not run.task.done(): run.task.cancel() try: @@ -5381,6 +5397,29 @@ async def handle_open_test(request: web.Request): parent = RUNS.get(request.match_info["run_id"]) if not parent: return web.json_response({"error": "unknown run"}, status=404) + body = await request.json() if request.body_exists else {} + # Idempotent open: a retried testStart (same consumer-minted key) returns the + # SAME live session. Checked BEFORE the cap (a replay must not be spuriously + # 429'd) and BEFORE packing (a replay does no snapshot work). `idempotent_replay` + # tells the runtime NOT to start a second event relay on the already-relayed + # session (the box's /test-events is single-consumer). Absent key → unchanged. + idem_key = (body.get("idempotency_key") or "").strip() + if len(idem_key) > 128: + return web.json_response({"error": "idempotency_key must be at most 128 characters"}, status=400) + if idem_key: + existing = TEST_SESSIONS.get(TEST_SESSION_IDEMPOTENCY.get(idem_key, "")) + if existing is not None and not existing.done: + _print_test_lifecycle("test.open.idempotent", existing) + return web.json_response({ + "ok": True, + "test_session_id": existing.tid, + "snapshot_hash": existing.snapshot_hash, + "consumer_fingerprint": existing.consumer_fingerprint, + "pages": existing.pages, + "idempotent_replay": True, + }) + # Stale mapping (session closed/reaped): drop it and open fresh below. + TEST_SESSION_IDEMPOTENCY.pop(idem_key, None) active = sum(1 for t in TEST_SESSIONS.values() if not t.done) if active >= _max_test_sessions(): # Structured error (same shape as handle_test_recommendation) so the @@ -5393,7 +5432,6 @@ async def handle_open_test(request: web.Request): "retriable": False, }}, status=429) - body = await request.json() if request.body_exists else {} consumer_tools = _effective_test_allowed_tools(body.get("allowed_tools")) consumer_model = _test_model() consumer_max_turns = _test_max_turns() @@ -5418,6 +5456,10 @@ async def handle_open_test(request: web.Request): run.locale, run.allowed_tools, run.consumer_model, max_turns=run.consumer_max_turns, ) + run.pages = pages + if idem_key: + run.idempotency_key = idem_key + TEST_SESSION_IDEMPOTENCY[idem_key] = tid TEST_SESSIONS[tid] = run run.task = asyncio.create_task(_test_session_wrapper(run)) _print_test_lifecycle("test.open", run, extra=f"pages={pages}") @@ -5427,6 +5469,7 @@ async def handle_open_test(request: web.Request): "snapshot_hash": snapshot_hash, "consumer_fingerprint": run.consumer_fingerprint, "pages": pages, + "idempotent_replay": False, }) diff --git a/kbc/platform/pod/test_compile_box.py b/kbc/platform/pod/test_compile_box.py index ea661a938..54da59dc4 100644 --- a/kbc/platform/pod/test_compile_box.py +++ b/kbc/platform/pod/test_compile_box.py @@ -886,6 +886,101 @@ async def test_open_close_test_session_http(): print("✓ open/close test session over HTTP (snapshot pinned, cap, teardown)") +class _AliveFakeClient: + """A test-session SDK stand-in that connects and then BLOCKS in receive + (emits nothing, session stays live / not-done) until disconnect. The default + _FakeSDKClient self-completes almost immediately, which would race the + idempotency replay against the session going done — this keeps it live so the + replay deterministically hits an active session.""" + + def __init__(self, options=None): + self.options = options + self._closed = asyncio.Event() + + async def connect(self, prompt=None): + pass + + async def query(self, text, session_id="default"): + pass + + async def receive_messages(self): + await self._closed.wait() + return + yield # unreachable; makes this an async generator + + async def disconnect(self): + self._closed.set() + + +async def test_open_test_session_idempotency(): + """A retried testStart (same idempotency_key) returns the SAME live session — + same tid/snapshot_hash/pages, flagged idempotent_replay, no new session, no new + concurrency slot. A different key opens a new session. Teardown drops the key + so a later same-key open starts fresh (never replays a dead tid).""" + orig = compile_box.ClaudeSDKClient + compile_box.ClaudeSDKClient = _AliveFakeClient + compile_box.RUNS.clear() + compile_box.TEST_SESSIONS.clear() + compile_box.TEST_SESSION_IDEMPOTENCY.clear() + snap_root = tempfile.mkdtemp() + os.environ["KBC_TEST_SNAPSHOT_ROOT"] = snap_root + os.environ["KBC_MAX_TEST_SESSIONS"] = "10" # exercise idempotency, not the cap + client = TestClient(TestServer(compile_box.build_app())) + await client.start_server() + + async def active(): + return sum(1 for t in compile_box.TEST_SESSIONS.values() if not t.done) + + try: + wd = tempfile.mkdtemp() + cand = Path(wd) / "candidate" + cand.mkdir() + (cand / "index.md").write_text("# index\n") + compile_box.RUNS["p1"] = compile_box.CompileRun("p1", wd, 1) + + # first open with a key → a fresh session (idempotent_replay False) + b1 = await (await client.post("/test-session/p1", json={"idempotency_key": "k-1"})).json() + tid1 = b1["test_session_id"] + assert b1["idempotent_replay"] is False, b1 + n1 = await active() + + # retry SAME key → same tid + snapshot_hash + pages, replay flagged, count unchanged + b2 = await (await client.post("/test-session/p1", json={"idempotency_key": "k-1"})).json() + assert b2["test_session_id"] == tid1, (b1, b2) + assert b2["snapshot_hash"] == b1["snapshot_hash"] and b2["pages"] == b1["pages"], (b1, b2) + assert b2["idempotent_replay"] is True, b2 + assert await active() == n1, "replay must not consume a new slot" + assert len(compile_box.TEST_SESSIONS) == 1, compile_box.TEST_SESSIONS + + # a DIFFERENT key opens a new session + b3 = await (await client.post("/test-session/p1", json={"idempotency_key": "k-2"})).json() + assert b3["test_session_id"] != tid1 and len(compile_box.TEST_SESSIONS) == 2 + + # teardown drops the key → the same key opens FRESH afterwards (no dead replay) + assert (await client.post(f"/test-session/{tid1}/close")).status == 200 + assert "k-1" not in compile_box.TEST_SESSION_IDEMPOTENCY, compile_box.TEST_SESSION_IDEMPOTENCY + b4 = await (await client.post("/test-session/p1", json={"idempotency_key": "k-1"})).json() + assert b4["test_session_id"] != tid1 and b4["idempotent_replay"] is False, b4 + + # no-key opens are never deduped (old-consumer behavior unchanged) + b5 = await (await client.post("/test-session/p1")).json() + b6 = await (await client.post("/test-session/p1")).json() + assert b5["test_session_id"] != b6["test_session_id"], (b5, b6) + + for t in list(compile_box.TEST_SESSIONS.keys()): + await client.post(f"/test-session/{t}/close") + finally: + await client.close() + compile_box.ClaudeSDKClient = orig + compile_box.RUNS.clear() + compile_box.TEST_SESSIONS.clear() + compile_box.TEST_SESSION_IDEMPOTENCY.clear() + os.environ.pop("KBC_TEST_SNAPSHOT_ROOT", None) + os.environ.pop("KBC_MAX_TEST_SESSIONS", None) + shutil.rmtree(snap_root, ignore_errors=True) + print("✓ test-session open is idempotent per key (same tid, no new slot, replay flagged)") + + async def test_test_message_path(): """/test-message injects a user turn into a LIVE test session (200 + query forwarded); an unknown test session → 404.""" @@ -4076,6 +4171,7 @@ async def main(): await test_test_session_driver_readonly() await test_test_session_driver_uses_captured_contract() await test_open_close_test_session_http() + await test_open_test_session_idempotency() await test_test_message_path() await test_test_session_step_frames() await test_test_session_stall_reaps_turn_keeps_session_live() diff --git a/src/gateway/capability/contract.ts b/src/gateway/capability/contract.ts index 172dec39e..6b4f60cc7 100644 --- a/src/gateway/capability/contract.ts +++ b/src/gateway/capability/contract.ts @@ -204,6 +204,14 @@ export interface CapabilityTestStartRequest { bundle_base64?: string; /** sha256 of the bundle bytes; the box verifies it at install time. */ bundle_sha256?: string; + /** + * Optional consumer-minted idempotency key. A retried testStart with the same + * key returns the SAME live test session (same tid + snapshot_hash) instead of + * opening a second one / consuming a second concurrency slot — so a lost ack + * or a client retry cannot leave a ghost session. Absent → always open fresh + * (old-consumer behavior unchanged). + */ + idempotency_key?: string; } export interface CapabilityTestStartResponse { diff --git a/src/gateway/server-capability-test-sessions.test.ts b/src/gateway/server-capability-test-sessions.test.ts index d747a2c21..1cd97b656 100644 --- a/src/gateway/server-capability-test-sessions.test.ts +++ b/src/gateway/server-capability-test-sessions.test.ts @@ -96,6 +96,36 @@ describe("capability.testSessions", () => { expect(manager.getOrCreate).not.toHaveBeenCalled(); // never spawn to list }); + it("scopes the list to the requested run — a shared box's other runs stay invisible", async () => { + // A shared box (SICLAW_COMPILE_BOX_ENDPOINT) hosts sessions for many runs; + // the box returns them all, and the runtime must filter to the caller's run + // so one run can never see (or reap) another's session. + process.env.SICLAW_COMPILE_BOX_ENDPOINT = "https://10.0.0.20:3000"; + const iso = "2026-07-22T09:30:00Z"; + getJsonMock.mockResolvedValue({ + sessions: [ + { tid: "a-1", parent_run_id: "run-A", created_at: iso, last_activity_at: iso, done: false }, + { tid: "b-1", parent_run_id: "run-B", created_at: iso, last_activity_at: iso, done: false }, + { tid: "a-2", parent_run_id: "run-A", created_at: iso, last_activity_at: iso, done: true }, + ], + }); + server = await startRuntime({ + config: { port: 0, internalPort: 0, host: "127.0.0.1", serverUrl: "", portalSecret: "" } as any, + agentBoxManager: fakeAgentBoxManager(null), + frontendClient: fakeFrontendClient(), + credentialService: {} as any, + }); + try { + const list = server.rpcMethods.get("capability.testSessions")!; + const a = (await list({ run_id: "run-A" })) as any; + expect(a.sessions.map((s: any) => s.tid)).toEqual(["a-1", "a-2"]); // only run-A's + const b = (await list({ run_id: "run-B" })) as any; + expect(b.sessions.map((s: any) => s.tid)).toEqual(["b-1"]); // run-A's stay invisible + } finally { + delete process.env.SICLAW_COMPILE_BOX_ENDPOINT; + } + }); + it("returns an empty list when the box is absent — never spawns/rehydrates", async () => { const manager = fakeAgentBoxManager(null); server = await startRuntime({ diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 753d3039c..e9ccffdbc 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -836,25 +836,34 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise capabilityRunManager.touch(runId), - }).catch((err) => { - // A dead test relay is disposable — log, never fail the authoring run. - console.warn( - `[capability] test relay ended run=${runId} tid=${opened.test_session_id}:`, - err instanceof Error ? err.message : String(err), - ); - }); + // On an idempotent replay the box returned an ALREADY-relayed session; its + // /test-events stream is single-consumer, so a second relay would split the + // frames. Only drive a freshly-opened session. + if (!opened.idempotent_replay) { + driveTestSession({ + client, + runId, + testSessionId: opened.test_session_id, + frontendClient, + touch: () => capabilityRunManager.touch(runId), + }).catch((err) => { + // A dead test relay is disposable — log, never fail the authoring run. + console.warn( + `[capability] test relay ended run=${runId} tid=${opened.test_session_id}:`, + err instanceof Error ? err.message : String(err), + ); + }); + } const res: CapabilityTestStartResponse = { run_id: runId, test_session_id: opened.test_session_id, @@ -1011,9 +1020,12 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise("/test-sessions"); + // Scope to THIS run: a SHARED box (SICLAW_COMPILE_BOX_ENDPOINT) hosts test + // sessions for multiple parent runs, and one run must never see (or reap) + // another's. Double protection with the consumer's own ParentRunID check. const response: CapabilityTestSessionsResponse = { run_id: req.run_id, - sessions: listed.sessions ?? [], + sessions: (listed.sessions ?? []).filter((s) => s.parent_run_id === req.run_id), }; return response; }); From a14548704368ecad39fab9d1b85950c86ac59337 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 23 Jul 2026 12:35:43 +0800 Subject: [PATCH 09/18] Extract shouldRelayTestSession predicate + unit-test both branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the testStart idempotency change: the relay-skip guard only fires on the rare idempotent-replay path, and a future refactor of the testStart handler could silently drop it — splitting the box's single-consumer /test-events stream. Extract the decision into a pure predicate (shouldRelayTestSession) in the relay module and unit-test both branches (replay → no relay; fresh open or flag absent → relay); the handler now calls the predicate instead of an inline check. Behavior unchanged. tsc clean; test-relay vitest green. --- src/gateway/capability/test-relay.test.ts | 16 +++++++++++++++- src/gateway/capability/test-relay.ts | 13 +++++++++++++ src/gateway/server.ts | 9 ++++----- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/gateway/capability/test-relay.test.ts b/src/gateway/capability/test-relay.test.ts index 7b576fdca..b4dae2d5c 100644 --- a/src/gateway/capability/test-relay.test.ts +++ b/src/gateway/capability/test-relay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { driveTestSession } from "./test-relay.js"; +import { driveTestSession, shouldRelayTestSession } from "./test-relay.js"; import type { AgentBoxClient } from "../agentbox/client.js"; import type { FrontendWsClient } from "../frontend-ws-client.js"; import type { CapabilityEventFrame } from "./contract.js"; @@ -87,3 +87,17 @@ describe("driveTestSession", () => { expect(emitted).toHaveLength(2); }); }); + +describe("shouldRelayTestSession", () => { + it("does NOT relay an idempotent replay (already-relayed, single-consumer stream)", () => { + expect(shouldRelayTestSession({ idempotent_replay: true })).toBe(false); + }); + + it("relays a freshly-opened session", () => { + expect(shouldRelayTestSession({ idempotent_replay: false })).toBe(true); + }); + + it("relays when the flag is absent (older box that omits it)", () => { + expect(shouldRelayTestSession({})).toBe(true); + }); +}); diff --git a/src/gateway/capability/test-relay.ts b/src/gateway/capability/test-relay.ts index bcd4b6186..6f00250be 100644 --- a/src/gateway/capability/test-relay.ts +++ b/src/gateway/capability/test-relay.ts @@ -39,6 +39,19 @@ export interface DriveTestSessionOptions { touch?: () => void; } +/** + * Whether the runtime should start a test-event relay for a just-returned + * testStart. An idempotent replay reuses an ALREADY-relayed session — the box's + * /test-events is single-consumer, so a second relay would split the frame + * stream — so a replay must NOT be relayed again. A fresh open (or an older box + * that omits the flag) IS relayed. Extracted as a pure predicate so this + * rare-and-subtle replay branch stays regression-proof if the testStart handler + * is later refactored (a dropped inline guard would silently split streams). + */ +export function shouldRelayTestSession(opened: { idempotent_replay?: boolean }): boolean { + return !opened.idempotent_replay; +} + /** * Relay the test-session event stream until the box closes it (`end`, emitted * on teardown/close). Errors propagate to the caller — which only logs: a dead diff --git a/src/gateway/server.ts b/src/gateway/server.ts index e9ccffdbc..78eefdafc 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -28,7 +28,7 @@ import { getBoxProfile } from "./agentbox/box-profile.js"; import { buildSpawnEnv } from "./agentbox/spawn-env.js"; import { CapabilityRunManager } from "./capability/run-manager.js"; import { driveCapabilitySession } from "./capability/session-driver.js"; -import { driveTestSession } from "./capability/test-relay.js"; +import { driveTestSession, shouldRelayTestSession } from "./capability/test-relay.js"; import { CAPABILITY_GET_RUN, isTerminalCapabilityStatus } from "./capability/contract.js"; import type { CapabilityCancelRequest, @@ -846,10 +846,9 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise Date: Thu, 23 Jul 2026 12:47:45 +0800 Subject: [PATCH 10/18] Rename test-session idempotency field to client_request_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the testStart idempotency key with sicore (MR!696) and the codebase's existing convention: every consumer-minted idempotency key here is a `_id` (message_id for a turn, command_id for a typed command, operation_id for a mutation receipt) — there is no `idempotency_key`. So the field is `client_request_id`, not the `idempotency_key` the first pass used. Behavior is unchanged: same client_request_id → same live session (checked before the cap and before packing), empty/absent → open fresh. Box read + TestRun attribute + teardown cleanup, the gateway request type + passthrough, and the box test's wire bodies all renamed. box suite 70 green; tsc clean; gateway vitest green. --- kbc/platform/pod/compile_box.py | 31 ++++++++++++++-------------- kbc/platform/pod/test_compile_box.py | 10 ++++----- src/gateway/capability/contract.ts | 13 ++++++------ src/gateway/server.ts | 6 +++--- 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/kbc/platform/pod/compile_box.py b/kbc/platform/pod/compile_box.py index 900661752..eee86646d 100644 --- a/kbc/platform/pod/compile_box.py +++ b/kbc/platform/pod/compile_box.py @@ -101,10 +101,10 @@ # Read-only "test session" runs — ephemeral consumer sessions over a pinned draft # snapshot (test sessions). Parallel to RUNS, torn down on close/idle. See TestRun. TEST_SESSIONS: dict[str, "TestRun"] = {} -# Consumer-minted idempotency key → tid, for open-test-session retries. A retried -# testStart (same key) must return the SAME live session, never a second session -# or concurrency slot. Process-local; entries are cleared on teardown with their -# session (a torn-down key opens fresh, so a stale mapping can never be replayed). +# Consumer-minted client_request_id → tid, for open-test-session retries. A +# retried testStart (same key) must return the SAME live session, never a second +# session or concurrency slot. Process-local; entries are cleared on teardown with +# their session (a torn-down key opens fresh, so a stale mapping is never replayed). TEST_SESSION_IDEMPOTENCY: dict[str, str] = {} # One explicit red-team read-only review may inspect a run at a time. Test # recommendation and reference-answer assistance share this guard so two owner @@ -523,9 +523,9 @@ def __init__(self, tid: str, cwd: str, parent_run_id: str, snapshot_hash: str, l self._stall_reaped = False # Snapshot page count (returned by open, replayed on an idempotent open). self.pages: int = 0 - # Consumer-minted idempotency key that opened this session (if any), so + # Consumer-minted client_request_id that opened this session (if any), so # teardown can drop its TEST_SESSION_IDEMPOTENCY entry. - self.idempotency_key: str | None = None + self.client_request_id: str | None = None async def emit(self, ev: dict): await self.events.put(ev) @@ -4843,7 +4843,7 @@ async def _teardown_test_session(run: "TestRun"): # Drop the idempotency mapping WITH the session: a torn-down key must open a # fresh session, never replay a dead tid (guard on tid so a re-minted key for # a newer session is not clobbered). - key = run.idempotency_key + key = run.client_request_id if key and TEST_SESSION_IDEMPOTENCY.get(key) == run.tid: TEST_SESSION_IDEMPOTENCY.pop(key, None) if run.task and not run.task.done(): @@ -5398,14 +5398,15 @@ async def handle_open_test(request: web.Request): if not parent: return web.json_response({"error": "unknown run"}, status=404) body = await request.json() if request.body_exists else {} - # Idempotent open: a retried testStart (same consumer-minted key) returns the - # SAME live session. Checked BEFORE the cap (a replay must not be spuriously - # 429'd) and BEFORE packing (a replay does no snapshot work). `idempotent_replay` - # tells the runtime NOT to start a second event relay on the already-relayed - # session (the box's /test-events is single-consumer). Absent key → unchanged. - idem_key = (body.get("idempotency_key") or "").strip() + # Idempotent open: a retried testStart (same consumer-minted client_request_id) + # returns the SAME live session. Checked BEFORE the cap (a replay must not be + # spuriously 429'd) and BEFORE packing (a replay does no snapshot work). + # `idempotent_replay` tells the runtime NOT to start a second event relay on the + # already-relayed session (the box's /test-events is single-consumer). Absent + # key (empty/omitted) → unchanged old-consumer behavior. + idem_key = (body.get("client_request_id") or "").strip() if len(idem_key) > 128: - return web.json_response({"error": "idempotency_key must be at most 128 characters"}, status=400) + return web.json_response({"error": "client_request_id must be at most 128 characters"}, status=400) if idem_key: existing = TEST_SESSIONS.get(TEST_SESSION_IDEMPOTENCY.get(idem_key, "")) if existing is not None and not existing.done: @@ -5458,7 +5459,7 @@ async def handle_open_test(request: web.Request): ) run.pages = pages if idem_key: - run.idempotency_key = idem_key + run.client_request_id = idem_key TEST_SESSION_IDEMPOTENCY[idem_key] = tid TEST_SESSIONS[tid] = run run.task = asyncio.create_task(_test_session_wrapper(run)) diff --git a/kbc/platform/pod/test_compile_box.py b/kbc/platform/pod/test_compile_box.py index 54da59dc4..bab532028 100644 --- a/kbc/platform/pod/test_compile_box.py +++ b/kbc/platform/pod/test_compile_box.py @@ -913,7 +913,7 @@ async def disconnect(self): async def test_open_test_session_idempotency(): - """A retried testStart (same idempotency_key) returns the SAME live session — + """A retried testStart (same client_request_id) returns the SAME live session — same tid/snapshot_hash/pages, flagged idempotent_replay, no new session, no new concurrency slot. A different key opens a new session. Teardown drops the key so a later same-key open starts fresh (never replays a dead tid).""" @@ -939,13 +939,13 @@ async def active(): compile_box.RUNS["p1"] = compile_box.CompileRun("p1", wd, 1) # first open with a key → a fresh session (idempotent_replay False) - b1 = await (await client.post("/test-session/p1", json={"idempotency_key": "k-1"})).json() + b1 = await (await client.post("/test-session/p1", json={"client_request_id": "k-1"})).json() tid1 = b1["test_session_id"] assert b1["idempotent_replay"] is False, b1 n1 = await active() # retry SAME key → same tid + snapshot_hash + pages, replay flagged, count unchanged - b2 = await (await client.post("/test-session/p1", json={"idempotency_key": "k-1"})).json() + b2 = await (await client.post("/test-session/p1", json={"client_request_id": "k-1"})).json() assert b2["test_session_id"] == tid1, (b1, b2) assert b2["snapshot_hash"] == b1["snapshot_hash"] and b2["pages"] == b1["pages"], (b1, b2) assert b2["idempotent_replay"] is True, b2 @@ -953,13 +953,13 @@ async def active(): assert len(compile_box.TEST_SESSIONS) == 1, compile_box.TEST_SESSIONS # a DIFFERENT key opens a new session - b3 = await (await client.post("/test-session/p1", json={"idempotency_key": "k-2"})).json() + b3 = await (await client.post("/test-session/p1", json={"client_request_id": "k-2"})).json() assert b3["test_session_id"] != tid1 and len(compile_box.TEST_SESSIONS) == 2 # teardown drops the key → the same key opens FRESH afterwards (no dead replay) assert (await client.post(f"/test-session/{tid1}/close")).status == 200 assert "k-1" not in compile_box.TEST_SESSION_IDEMPOTENCY, compile_box.TEST_SESSION_IDEMPOTENCY - b4 = await (await client.post("/test-session/p1", json={"idempotency_key": "k-1"})).json() + b4 = await (await client.post("/test-session/p1", json={"client_request_id": "k-1"})).json() assert b4["test_session_id"] != tid1 and b4["idempotent_replay"] is False, b4 # no-key opens are never deduped (old-consumer behavior unchanged) diff --git a/src/gateway/capability/contract.ts b/src/gateway/capability/contract.ts index 6b4f60cc7..6230699ed 100644 --- a/src/gateway/capability/contract.ts +++ b/src/gateway/capability/contract.ts @@ -205,13 +205,14 @@ export interface CapabilityTestStartRequest { /** sha256 of the bundle bytes; the box verifies it at install time. */ bundle_sha256?: string; /** - * Optional consumer-minted idempotency key. A retried testStart with the same - * key returns the SAME live test session (same tid + snapshot_hash) instead of - * opening a second one / consuming a second concurrency slot — so a lost ack - * or a client retry cannot leave a ghost session. Absent → always open fresh - * (old-consumer behavior unchanged). + * Optional consumer-minted request id used as an idempotency key (naming + * mirrors message_id / command_id). A retried testStart with the same + * client_request_id returns the SAME live test session (same tid + + * snapshot_hash) instead of opening a second one / consuming a second + * concurrency slot — so a lost ack or a client retry cannot leave a ghost + * session. Absent/empty → always open fresh (old-consumer behavior unchanged). */ - idempotency_key?: string; + client_request_id?: string; } export interface CapabilityTestStartResponse { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 78eefdafc..b58e540b4 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -836,9 +836,9 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise Date: Thu, 23 Jul 2026 12:50:47 +0800 Subject: [PATCH 11/18] fix(runtime): apply agent custom system prompt on the web chat path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat.send only read params.systemPrompt when building the box prompt. sicore's web-chat proxy never forwards that param, so a custom-prompt agent silently fell back to AgentBox's built-in default SRE persona on sicore web chat — while channel paths (dingtalk/lark) resolved the agent's own template and worked. Root-cause chain: - sicore sends chat.send without systemPrompt (proxy/handler.go). - runtime chat.send only honored params.systemPrompt -> undefined -> box uses the default template. - resolveAgentSystemPrompt (config.getAgent -> agents.system_prompt) already existed but was wired only into dingtalk/lark/task-coordinator/ delegate paths, not the web chat main path. Fix (runtime-side fallback, sicore unchanged): when the caller does not forward systemPrompt, chat.send falls back to resolveAgentSystemPrompt. An explicitly forwarded prompt (portal-standalone) still wins as-is; a resolve failure / no custom prompt falls back to undefined = built-in default, so a lookup failure never turns a chat into a failure. Session-reuse semantics unchanged: AgentBox applies the template only at session creation, so this affects NEW sessions; a warm multi-turn session keeps the prompt it was created with. chat.send is the only prompt- creating entry point (chat.steer targets an already-running session via steerSession), so no other handler needs the fallback. Adds server-chat-system-prompt.test.ts covering: explicit param wins and skips the lookup; omitted param resolves the agent's custom prompt; no custom prompt / lookup failure -> undefined default (send still acks ok). --- src/gateway/server-chat-system-prompt.test.ts | 178 ++++++++++++++++++ src/gateway/server.ts | 20 +- 2 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/gateway/server-chat-system-prompt.test.ts diff --git a/src/gateway/server-chat-system-prompt.test.ts b/src/gateway/server-chat-system-prompt.test.ts new file mode 100644 index 000000000..4458310db --- /dev/null +++ b/src/gateway/server-chat-system-prompt.test.ts @@ -0,0 +1,178 @@ +/** + * Regression test for the custom-system-prompt fallback in chat.send. + * + * Bug: sicore's web-chat proxy sends chat.send WITHOUT a systemPrompt param, so + * the runtime's handler (which only read params.systemPrompt) handed the box + * `undefined` and a custom-prompt agent silently got AgentBox's built-in default + * SRE persona. Channel paths (dingtalk/lark) already resolved the agent's own + * template via config.getAgent and worked — only the web path was unwired. + * + * The fix: when the caller does not forward systemPrompt, chat.send falls back to + * resolveAgentSystemPrompt(agentId, frontendClient). An explicitly forwarded + * prompt (portal-standalone) still wins as-is; a resolve failure / no custom + * prompt falls back to undefined = built-in default. + * + * This test drives the real chat.send handler from startRuntime (data-layer + + * agentbox mocked) and asserts what systemPromptTemplate the box prompt receives. + */ +import { describe, it, expect, afterEach, vi } from "vitest"; + +vi.mock("./chat-repo.js", () => ({ + ensureChatSession: vi.fn(async () => {}), + appendMessage: vi.fn(async () => "msg-id"), + bindMessageTraceId: vi.fn(async () => {}), + updateMessage: vi.fn(async () => {}), + incrementMessageCount: vi.fn(async () => {}), +})); + +vi.mock("./output-redactor.js", () => ({ + buildRedactionConfigForModelConfig: vi.fn(() => ({})), +})); + +// The mocked consumer hangs (the IIFE never settles) — we only need it to reach +// prompt(), whose opts we capture below. +vi.mock("./sse-consumer.js", () => ({ + consumeAgentSse: vi.fn((opts: { signal?: AbortSignal }) => { + return new Promise((resolve) => { + const done = () => + resolve({ resultText: "", taskReportText: "", errorMessage: "", eventCount: 0, durationMs: 0 }); + if (opts.signal?.aborted) return done(); + opts.signal?.addEventListener("abort", done, { once: true }); + }); + }), +})); + +const promptCalls: Array<{ systemPromptTemplate?: string; sessionId: string }> = []; +vi.mock("./agentbox/client.js", () => ({ + AgentBoxClient: class { + endpoint: string; + constructor(endpoint: string) { + this.endpoint = endpoint; + } + prompt = vi.fn(async (opts: { sessionId: string; systemPromptTemplate?: string }) => { + promptCalls.push(opts); + return { sessionId: opts.sessionId, traceId: "0123456789abcdef0123456789abcdef" }; + }); + abortSession = vi.fn(async () => {}); + steerSession = vi.fn(async () => ({ ok: true, traceId: "fedcba9876543210fedcba9876543210" })); + streamEvents = async function* () {}; + }, +})); + +const { startRuntime } = await import("./server.js"); + +// getAgentResult drives what config.getAgent resolves to (or throws when set to +// an Error), letting each test model "custom prompt present / absent / lookup +// failed". Other RPC methods resolve empty so unrelated wiring stays inert. +let getAgentResult: unknown = undefined; +let getAgentError: Error | undefined; +const getAgentCalls: unknown[] = []; +function fakeFrontendClient() { + return { + request: vi.fn(async (method: string, params: unknown) => { + if (method === "config.getAgent") { + getAgentCalls.push(params); + if (getAgentError) throw getAgentError; + return getAgentResult; + } + return { found: false }; + }), + onCommand: vi.fn(), + emitEvent: vi.fn(), + close: vi.fn(), + } as any; +} + +function fakeAgentBoxManager() { + return { + setCertManager: vi.fn(), + setSpawnEnvResolver: vi.fn(), + setPersistenceResolver: vi.fn(), + getOrCreate: vi.fn(async () => ({ endpoint: "https://fake.internal" })), + list: vi.fn(() => []), + cleanup: vi.fn(async () => {}), + } as any; +} + +async function bootRuntime() { + return startRuntime({ + config: { port: 0, internalPort: 0, host: "127.0.0.1", serverUrl: "", portalSecret: "" } as any, + agentBoxManager: fakeAgentBoxManager(), + frontendClient: fakeFrontendClient(), + credentialService: {} as any, + }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await new Promise((r) => setTimeout(r, 5)); + } +} + +let server: Awaited> | undefined; +afterEach(async () => { + if (server) await server.close(); + server = undefined; + promptCalls.length = 0; + getAgentCalls.length = 0; + getAgentResult = undefined; + getAgentError = undefined; + vi.clearAllMocks(); +}); + +describe("startRuntime — chat.send custom system prompt", () => { + it("uses an explicitly forwarded systemPrompt as-is (portal-standalone) and skips the lookup", async () => { + // config.getAgent would resolve a DIFFERENT prompt; the explicit param must win + // and the fallback lookup must not even run. + getAgentResult = { system_prompt: "should-not-be-used" }; + server = await bootRuntime(); + const send = server.rpcMethods.get("chat.send")!; + + await send( + { agentId: "a", userId: "u", text: "hi", sessionId: "S", systemPrompt: "explicit-portal-prompt" }, + { sendEvent: vi.fn() }, + ); + await waitFor(() => promptCalls.length > 0); + + expect(promptCalls[0].systemPromptTemplate).toBe("explicit-portal-prompt"); + expect(getAgentCalls).toEqual([]); + }); + + it("falls back to the agent's custom prompt when the caller omits systemPrompt (sicore web path)", async () => { + getAgentResult = { system_prompt: "custom agent persona" }; + server = await bootRuntime(); + const send = server.rpcMethods.get("chat.send")!; + + await send({ agentId: "a", userId: "u", text: "hi", sessionId: "S" }, { sendEvent: vi.fn() }); + await waitFor(() => promptCalls.length > 0); + + expect(getAgentCalls).toEqual([{ agentId: "a" }]); + expect(promptCalls[0].systemPromptTemplate).toBe("custom agent persona"); + }); + + it("falls back to undefined (built-in default) when the agent has no custom prompt", async () => { + getAgentResult = { system_prompt: null }; + server = await bootRuntime(); + const send = server.rpcMethods.get("chat.send")!; + + await send({ agentId: "a", userId: "u", text: "hi", sessionId: "S" }, { sendEvent: vi.fn() }); + await waitFor(() => promptCalls.length > 0); + + expect(promptCalls[0].systemPromptTemplate).toBeUndefined(); + }); + + it("falls back to undefined (built-in default) when the prompt lookup fails — never a chat failure", async () => { + getAgentError = new Error("config.getAgent RPC exploded"); + server = await bootRuntime(); + const send = server.rpcMethods.get("chat.send")!; + + const ack = await send({ agentId: "a", userId: "u", text: "hi", sessionId: "S" }, { sendEvent: vi.fn() }); + // The ack still returns ok — a resolve failure must not fail the send. + expect(ack).toMatchObject({ ok: true, sessionId: "S" }); + await waitFor(() => promptCalls.length > 0); + + expect(promptCalls[0].systemPromptTemplate).toBeUndefined(); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 036d77d62..b3fbdacad 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -92,7 +92,7 @@ import { MetricsAggregator } from "./metrics-aggregator.js"; import { PromFederationAggregator } from "./prom-federation-aggregator.js"; import { LocalSpawner } from "./agentbox/local-spawner.js"; import { sessionRegistry } from "./session-registry.js"; -import { resolveAgentModelBinding } from "./agent-model-binding.js"; +import { resolveAgentModelBinding, resolveAgentSystemPrompt } from "./agent-model-binding.js"; function stablePayloadDigest(value: unknown): string { const canonicalize = (input: unknown): unknown => { @@ -319,6 +319,24 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise Date: Thu, 23 Jul 2026 14:59:53 +0800 Subject: [PATCH 12/18] fix(capability): thread run profile into box discovery/stop for compile pods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration fix for the #442 × #444 merge interaction. #442 renamed the compile-box pod prefix (agentbox- → kbc-box-, profile-derived) and updated onReap/onAdopt to pass rec.profile, but left the other capability call sites resolving the pod name with the default "agentbox" prefix. On main that was correct (every box was agentbox-); after the rename those sites silently point at a pod name the compile box no longer uses, so in K8s mode: - capability.testClose reports a LIVE test session as already-closed - capability.testSessions (new in #444, which reused testClose's pattern) lists no sessions for a live compile run - capability.cancel and the ensureCapabilitySession cleanup paths 404 on stop() and leak the pod instead of reaping it (ghost cleanup) Pass the run's profile at every capability-box site so getAsync/stop build the kbc-box- prefix. The profile comes from the in-memory run record, or — for the restart-safe paths (testClose/testSessions/cancel) where the record may be gone — is recovered from the consumer store via adopt(notifyOnAdopt:false), which never reattaches a relay or spawns a box. Unknown profile falls back to the default prefix (the pre-rename behavior). Chat-agent paths (chat.sessionStatus, agent.clearMemory, agent.terminate) are intentionally left on the default prefix — they operate on chat agentIds, not capability runs. Tests: memory-cold testClose/testSessions now assert getAsync is called with the recovered "kb-compile" profile (→ kbc-box-); cancel asserts stop carries the profile for both in-memory and store-only runs. --- src/gateway/server-capability-cancel.test.ts | 6 ++-- .../server-capability-test-close.test.ts | 22 ++++++++++---- .../server-capability-test-sessions.test.ts | 22 ++++++++++---- src/gateway/server.ts | 29 +++++++++++++++---- 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/gateway/server-capability-cancel.test.ts b/src/gateway/server-capability-cancel.test.ts index d85df2bb7..47f2ccb9a 100644 --- a/src/gateway/server-capability-cancel.test.ts +++ b/src/gateway/server-capability-cancel.test.ts @@ -129,7 +129,7 @@ describe("capability.cancel", () => { run_id: started.run_id, stop_confirmed: true, }); - expect(stop).toHaveBeenCalledWith(started.run_id); + expect(stop).toHaveBeenCalledWith(started.run_id, "kb-compile"); }); it("surfaces uncertain cleanup and lets an idempotent retry confirm the stop", async () => { @@ -181,7 +181,9 @@ describe("capability.cancel", () => { expect(frontend.rows.get("run-after-restart")).toMatchObject({ status: "done" }); expect(manager.getAsync).not.toHaveBeenCalled(); expect(manager.getOrCreate).not.toHaveBeenCalled(); - expect(manager.stop).toHaveBeenCalledWith("run-after-restart"); + // Store-only run: cancel adopts it (notifyOnAdopt:false) to recover the + // profile, so stop targets the compile pod prefix (kbc-box-), not agentbox-. + expect(manager.stop).toHaveBeenCalledWith("run-after-restart", "kb-compile"); }); it("rejects a blank run id before touching the run store or box", async () => { diff --git a/src/gateway/server-capability-test-close.test.ts b/src/gateway/server-capability-test-close.test.ts index e4a0309f9..13b727cef 100644 --- a/src/gateway/server-capability-test-close.test.ts +++ b/src/gateway/server-capability-test-close.test.ts @@ -28,9 +28,18 @@ vi.mock("./capability/materialize.js", () => ({ const { startRuntime } = await import("./server.js"); -function fakeFrontendClient() { +// When `runProfile` is given, the fake answers capability.getRun with a live run +// row carrying that profile — this is how a memory-cold testClose (no in-memory +// run after a Runtime restart) recovers the profile it needs to target the right +// pod-name prefix. Omit it to keep the store empty (adopt returns undefined). +function fakeFrontendClient(runProfile?: string) { return { - request: vi.fn(async () => ({})), + request: vi.fn(async (method: string, params: any) => { + if (runProfile && method === "capability.getRun") { + return { id: params?.run_id, status: "running", profile: runProfile }; + } + return {}; + }), onCommand: vi.fn(), emitEvent: vi.fn(), close: vi.fn(), @@ -65,14 +74,15 @@ afterEach(async () => { describe("capability.testClose", () => { it("closes an existing box session even when the Runtime has no in-memory run", async () => { const manager = fakeAgentBoxManager({ - boxId: "agentbox-run-lost", + boxId: "kbc-box-run-lost", endpoint: "https://10.0.0.9:3000", agentId: "run-lost", }); server = await startRuntime({ config: { port: 0, internalPort: 0, host: "127.0.0.1", serverUrl: "", portalSecret: "" } as any, agentBoxManager: manager, - frontendClient: fakeFrontendClient(), + // Memory-cold: the run only exists in the store, with a compile profile. + frontendClient: fakeFrontendClient("kb-compile"), credentialService: {} as any, }); @@ -83,7 +93,9 @@ describe("capability.testClose", () => { run_id: "run-lost", test_session_id: "test-1", }); - expect(manager.getAsync).toHaveBeenCalledWith("run-lost"); + // The profile is recovered from the store so getAsync targets kbc-box-run-lost, + // not agentbox-run-lost — otherwise a live compile box reads as already-closed. + expect(manager.getAsync).toHaveBeenCalledWith("run-lost", "kb-compile"); expect(postJsonMock).toHaveBeenCalledWith("/test-session/test-1/close", {}); expect(manager.getOrCreate).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-capability-test-sessions.test.ts b/src/gateway/server-capability-test-sessions.test.ts index 1cd97b656..f03c11986 100644 --- a/src/gateway/server-capability-test-sessions.test.ts +++ b/src/gateway/server-capability-test-sessions.test.ts @@ -28,9 +28,18 @@ vi.mock("./capability/materialize.js", () => ({ const { startRuntime } = await import("./server.js"); -function fakeFrontendClient() { +// With `runProfile`, the fake answers capability.getRun with a live run row so a +// memory-cold testSessions (no in-memory run after a Runtime restart) can recover +// the profile that getAsync needs to build the right pod-name prefix. Omit it to +// keep the store empty (adopt returns undefined → default prefix). +function fakeFrontendClient(runProfile?: string) { return { - request: vi.fn(async () => ({})), + request: vi.fn(async (method: string, params: any) => { + if (runProfile && method === "capability.getRun") { + return { id: params?.run_id, status: "running", profile: runProfile }; + } + return {}; + }), onCommand: vi.fn(), emitEvent: vi.fn(), close: vi.fn(), @@ -75,14 +84,15 @@ describe("capability.testSessions", () => { ]; getJsonMock.mockResolvedValue({ sessions: rows }); const manager = fakeAgentBoxManager({ - boxId: "agentbox-run-live", + boxId: "kbc-box-run-live", endpoint: "https://10.0.0.10:3000", agentId: "run-live", }); server = await startRuntime({ config: { port: 0, internalPort: 0, host: "127.0.0.1", serverUrl: "", portalSecret: "" } as any, agentBoxManager: manager, - frontendClient: fakeFrontendClient(), + // Memory-cold: the run only lives in the store, with a compile profile. + frontendClient: fakeFrontendClient("kb-compile"), credentialService: {} as any, }); @@ -91,7 +101,9 @@ describe("capability.testSessions", () => { run_id: "run-live", sessions: rows, // passed through byte-for-byte: `tid`, not renamed }); - expect(manager.getAsync).toHaveBeenCalledWith("run-live"); + // Profile recovered from the store so getAsync targets kbc-box-run-live — a + // profile-less lookup would miss the compile pod and report no sessions. + expect(manager.getAsync).toHaveBeenCalledWith("run-live", "kb-compile"); expect(getJsonMock).toHaveBeenCalledWith("/test-sessions"); expect(manager.getOrCreate).not.toHaveBeenCalled(); // never spawn to list }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 03c934a6c..3a78e54ef 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -573,7 +573,7 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise + void agentBoxManager.stop(runId, profile).catch((stopErr) => console.error( `[capability] stop new box after setup failure run=${runId}:`, stopErr instanceof Error ? stopErr.message : String(stopErr), @@ -601,7 +601,7 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise + void agentBoxManager.stop(runId, profile).catch((stopErr) => console.error( `[capability] stop box after relay close run=${runId}:`, stopErr instanceof Error ? stopErr.message : String(stopErr), @@ -820,7 +820,10 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise", + // not "agentbox-". rec was just resolved above (get ?? adopt); an + // absent profile falls back to the default prefix. + await agentBoxManager.stop(runId, rec?.profile); } catch (err) { console.error( `[capability] cancel: stop box run=${runId} failed:`, @@ -1003,7 +1006,16 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise", a chat box "agentbox-"), so it must be told + // the profile or it looks up the wrong pod and reports a live session as + // already-closed. The in-memory run may be gone after a Runtime restart — + // recover its profile from the consumer store WITHOUT reattaching a relay + // (notifyOnAdopt:false) or spawning a box. Unknown profile ⇒ default prefix. + const rec = + capabilityRunManager.get(req.run_id) ?? + (await capabilityRunManager.adopt(req.run_id, { notifyOnAdopt: false })); + const alive = await agentBoxManager.getAsync(req.run_id, rec?.profile); if (!alive) { const response: CapabilityTestCloseResponse = { ok: true, @@ -1037,7 +1049,14 @@ export async function startRuntime(opts: StartRuntimeOptions): Promise for a compile + // box). Recover it from the store when memory-cold — never spawning a box + // or reattaching a relay (notifyOnAdopt:false). Unknown profile ⇒ default. + const rec = + capabilityRunManager.get(req.run_id) ?? + (await capabilityRunManager.adopt(req.run_id, { notifyOnAdopt: false })); + const alive = await agentBoxManager.getAsync(req.run_id, rec?.profile); if (!alive) { const empty: CapabilityTestSessionsResponse = { run_id: req.run_id, sessions: [] }; return empty; From 85e8ce34f4d20640cf6589e3d25359baf43319bc Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 23 Jul 2026 16:51:56 +0800 Subject: [PATCH 13/18] =?UTF-8?q?fix(kbc-box):=20review=20P1s=20in=20test-?= =?UTF-8?q?session=20path=20=E2=80=94=20run-scoped=20idempotency=20+=20per?= =?UTF-8?q?-turn=20generation=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1 (idempotency key not run-isolated): the open-test dedup map was keyed by client_request_id alone, so on a shared box two runs colliding on a request id would replay each other's tid/snapshot/fingerprint. Key it by (parent_run_id, client_request_id) and re-verify the mapped session belongs to THIS parent run before replaying; a mismatch (or stale/closed mapping) falls through to a fresh open. Teardown rebuilds the run-scoped tuple to drop the key. P1-2 (stall-then-immediate-retry frame race): the watchdog emits turn_done and frees the UI before it interrupts, so the next /test-message can arm a new turn before the reaped turn's stragglers drain. With _stall_reaped already cleared by _arm_test_turn, the late ResultMessage terminated the fresh turn (and a late partial contaminated it). Add a per-turn generation bumped each _arm_test_turn; the receive loop tags each frame (frame_gen = _results_seen + 1, results stream in strict turn order) and drops any below the armed generation, logging turn.stale_frame_dropped — never blocking on stragglers that may never arrive. Tests: test_open_test_session_idempotency_is_run_scoped (same key across two runs → distinct sessions; same key+run → replay) and test_test_session_late_reaped_frames_never_terminate_new_turn (drives the real watchdog reap, then injects the reaped turn's late partial + ResultMessage via a new queue-driven fake, asserting the fresh turn survives and completes on its own result while both stragglers are dropped). --- kbc/platform/pod/compile_box.py | 90 ++++++++++---- kbc/platform/pod/test_compile_box.py | 180 +++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 23 deletions(-) diff --git a/kbc/platform/pod/compile_box.py b/kbc/platform/pod/compile_box.py index eee86646d..493103813 100644 --- a/kbc/platform/pod/compile_box.py +++ b/kbc/platform/pod/compile_box.py @@ -101,11 +101,14 @@ # Read-only "test session" runs — ephemeral consumer sessions over a pinned draft # snapshot (test sessions). Parallel to RUNS, torn down on close/idle. See TestRun. TEST_SESSIONS: dict[str, "TestRun"] = {} -# Consumer-minted client_request_id → tid, for open-test-session retries. A -# retried testStart (same key) must return the SAME live session, never a second -# session or concurrency slot. Process-local; entries are cleared on teardown with -# their session (a torn-down key opens fresh, so a stale mapping is never replayed). -TEST_SESSION_IDEMPOTENCY: dict[str, str] = {} +# (parent_run_id, consumer-minted client_request_id) → tid, for open-test-session +# retries. A retried testStart (same run + key) must return the SAME live session, +# never a second session or concurrency slot. The key is RUN-SCOPED: a box hosts +# multiple runs, and two runs that happen to mint the same client_request_id must +# NOT hand each other's tid/snapshot/fingerprint back. Process-local; entries are +# cleared on teardown with their session (a torn-down key opens fresh, so a stale +# mapping is never replayed). +TEST_SESSION_IDEMPOTENCY: dict[tuple[str, str], str] = {} # One explicit red-team read-only review may inspect a run at a time. Test # recommendation and reference-answer assistance share this guard so two owner # actions cannot spend overlapping model calls against the same workspace. @@ -279,6 +282,7 @@ def _arm_test_turn(run: "TestRun") -> None: run._turn_active = True run._tool_pending = False run._stall_reaped = False + run._turn_generation += 1 # supersede any reaped turn's late stragglers run._last_sdk_message_type = "query" run._last_model_activity = time.monotonic() _touch_test_activity(run) @@ -521,6 +525,16 @@ def __init__(self, tid: str, cwd: str, parent_run_id: str, snapshot_hash: str, l # Set by the watchdog when it reaps a wedged turn; the receive loop then # discards the interrupted ResultMessage instead of announcing it. self._stall_reaped = False + # Per-turn generation, bumped every _arm_test_turn. After a stall reap the + # watchdog frees the UI immediately, so a new /test-message can arm the next + # turn BEFORE the reaped turn's straggler frames (partial text + its + # torn-down ResultMessage) drain from the stream. The receive loop tags each + # frame with the generation it belongs to (results stream in strict turn + # order) and drops any whose generation is below the armed one — so a late + # frame can never terminate or contaminate the current turn. _results_seen + # counts consumed ResultMessages; frame generation = _results_seen + 1. + self._turn_generation = 0 + self._results_seen = 0 # Snapshot page count (returned by open, replayed on an idempotent open). self.pages: int = 0 # Consumer-minted client_request_id that opened this session (if any), so @@ -4184,11 +4198,32 @@ async def _consume_test_turn_stream(run: "TestRun", client) -> None: the compile path there is NO retry: when the watchdog has reaped the turn, its ResultMessage is the torn-down attempt — discard it (the UI already got a turn_done from the watchdog) and stay live for the next /test-message. A real - ResultMessage ends the turn normally.""" + ResultMessage ends the turn normally. + + Frames stream in strict turn order (a query's frames — including its lone + ResultMessage — all precede the next query's), so a frame's generation is + `_results_seen + 1`. After a stall reap the watchdog frees the UI at once and + the next /test-message can arm a newer turn before the reaped turn's stragglers + drain; those below the armed generation are dropped so a late ResultMessage can + never terminate — nor a late partial contaminate — the current turn.""" async for msg in client.receive_messages(): + is_result = type(msg).__name__ == "ResultMessage" + frame_gen = run._results_seen + 1 + if frame_gen < run._turn_generation: + # A superseded (reaped) turn's straggler. Drop it without touching the + # live turn's liveness/tool_pending — updating those from a stale frame + # would skew the watchdog's read of the current turn. A ResultMessage + # still advances _results_seen so the stream stays turn-aligned. + if is_result: + run._results_seen += 1 + _print_test_lifecycle( + "turn.stale_frame_dropped", run, + extra=f"frame_gen={frame_gen} armed_gen={run._turn_generation} kind={type(msg).__name__}") + continue _note_model_activity(run, msg) # SDK event == alive; flips tool_pending _touch_test_activity(run) # any message is also TTL liveness - if type(msg).__name__ == "ResultMessage": + if is_result: + run._results_seen += 1 if run._stall_reaped: # The watchdog interrupted this turn and already emitted the # turn-level error + turn_done. This is the reaped attempt's @@ -4842,10 +4877,12 @@ async def _teardown_test_session(run: "TestRun"): _print_test_lifecycle("test.close", run) # Drop the idempotency mapping WITH the session: a torn-down key must open a # fresh session, never replay a dead tid (guard on tid so a re-minted key for - # a newer session is not clobbered). - key = run.client_request_id - if key and TEST_SESSION_IDEMPOTENCY.get(key) == run.tid: - TEST_SESSION_IDEMPOTENCY.pop(key, None) + # a newer session is not clobbered). The key is run-scoped, so rebuild the + # (parent_run_id, client_request_id) tuple that stored it. + if run.client_request_id: + key = (run.parent_run_id, run.client_request_id) + if TEST_SESSION_IDEMPOTENCY.get(key) == run.tid: + TEST_SESSION_IDEMPOTENCY.pop(key, None) if run.task and not run.task.done(): run.task.cancel() try: @@ -5399,17 +5436,23 @@ async def handle_open_test(request: web.Request): return web.json_response({"error": "unknown run"}, status=404) body = await request.json() if request.body_exists else {} # Idempotent open: a retried testStart (same consumer-minted client_request_id) - # returns the SAME live session. Checked BEFORE the cap (a replay must not be - # spuriously 429'd) and BEFORE packing (a replay does no snapshot work). - # `idempotent_replay` tells the runtime NOT to start a second event relay on the - # already-relayed session (the box's /test-events is single-consumer). Absent - # key (empty/omitted) → unchanged old-consumer behavior. - idem_key = (body.get("client_request_id") or "").strip() - if len(idem_key) > 128: + # returns the SAME live session. The dedup key is RUN-SCOPED — (parent_run_id, + # client_request_id) — so on a shared box two runs colliding on a request id + # each open their own session instead of aliasing one another's snapshot. Before + # replaying we re-verify the mapped session still belongs to THIS parent run; a + # mismatch (or a stale/closed mapping) falls through to a fresh open. Checked + # BEFORE the cap (a replay must not be spuriously 429'd) and BEFORE packing (a + # replay does no snapshot work). `idempotent_replay` tells the runtime NOT to + # start a second event relay on the already-relayed session (the box's + # /test-events is single-consumer). Absent key (empty/omitted) → unchanged + # old-consumer behavior. + req_id = (body.get("client_request_id") or "").strip() + if len(req_id) > 128: return web.json_response({"error": "client_request_id must be at most 128 characters"}, status=400) - if idem_key: + idem_key = (parent.run_id, req_id) if req_id else None + if idem_key is not None: existing = TEST_SESSIONS.get(TEST_SESSION_IDEMPOTENCY.get(idem_key, "")) - if existing is not None and not existing.done: + if existing is not None and not existing.done and existing.parent_run_id == parent.run_id: _print_test_lifecycle("test.open.idempotent", existing) return web.json_response({ "ok": True, @@ -5419,7 +5462,8 @@ async def handle_open_test(request: web.Request): "pages": existing.pages, "idempotent_replay": True, }) - # Stale mapping (session closed/reaped): drop it and open fresh below. + # Stale/mismatched mapping (session closed/reaped, or a cross-run entry that + # slipped in): drop it and open fresh below. TEST_SESSION_IDEMPOTENCY.pop(idem_key, None) active = sum(1 for t in TEST_SESSIONS.values() if not t.done) if active >= _max_test_sessions(): @@ -5458,8 +5502,8 @@ async def handle_open_test(request: web.Request): max_turns=run.consumer_max_turns, ) run.pages = pages - if idem_key: - run.client_request_id = idem_key + if idem_key is not None: + run.client_request_id = req_id TEST_SESSION_IDEMPOTENCY[idem_key] = tid TEST_SESSIONS[tid] = run run.task = asyncio.create_task(_test_session_wrapper(run)) diff --git a/kbc/platform/pod/test_compile_box.py b/kbc/platform/pod/test_compile_box.py index bab532028..492666964 100644 --- a/kbc/platform/pod/test_compile_box.py +++ b/kbc/platform/pod/test_compile_box.py @@ -1155,6 +1155,184 @@ async def test_test_session_stall_reaps_turn_keeps_session_live(): print("✓ test-session stall watchdog reaps a wedged turn and keeps the session live") +_STREAM_STOP = object() + + +class _QueuedFrameFake: + """A test-session SDK stand-in whose receive stream is fed one frame at a time + by the test via an asyncio.Queue. Unlike _TestStallFake's single _mode, this + lets a test enqueue a reaped turn's LATE frames (a partial + its torn-down + ResultMessage) AFTER the next turn is armed, reproducing the exact stall→reap→ + immediate-new-query→late-frame race. connect/query record only; interrupt is + counted but injects no frame (the test controls when the reaped result lands).""" + + def __init__(self): + self.queries = [] + self.interrupts = 0 + self._q: asyncio.Queue = asyncio.Queue() + + async def connect(self, prompt=None): + pass + + async def query(self, text, session_id="default"): + self.queries.append(text) + + async def interrupt(self): + self.interrupts += 1 + + def push(self, msg): + self._q.put_nowait(msg) + + def close_stream(self): + self._q.put_nowait(_STREAM_STOP) + + async def receive_messages(self): + while True: + msg = await self._q.get() + if msg is _STREAM_STOP: + return + yield msg + + async def disconnect(self): + self.close_stream() + + +async def test_test_session_late_reaped_frames_never_terminate_new_turn(): + """Race guard (per-turn generation): the stall watchdog frees the UI the instant + it reaps a wedged turn, so a new /test-message can arm the NEXT turn before the + reaped turn's straggler frames (a partial + its interrupted ResultMessage) drain + from the stream. Those stale frames must be dropped (logged), never fold into or + terminate the freshly-armed turn — the new answer must complete on its OWN result. + Drives the real watchdog for the reap, then injects the late frames deterministically.""" + saved = (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, compile_box._MODEL_WATCHDOG_POLL_S) + compile_box._TEST_MODEL_IDLE_TIMEOUT_S = 0.15 + compile_box._MODEL_WATCHDOG_POLL_S = 0.03 + buf = io.StringIO() + fake = _QueuedFrameFake() + try: + with tempfile.TemporaryDirectory() as snap, contextlib.redirect_stdout(buf): + run = compile_box.TestRun("t-race", snap, parent_run_id="p-race", snapshot_hash="h") + run.client = fake + run.connected.set() + wdog = asyncio.create_task(compile_box._test_stall_watchdog(run)) + consume = asyncio.create_task(compile_box._consume_test_turn_stream(run, fake)) + try: + # turn 1 (gen 1) black-holes (no frames pushed) → the watchdog reaps + # it: turn_done frees the UI, _stall_reaped set, interrupt() fired. + compile_box._arm_test_turn(run) + await fake.query("first question (black-holed)") + deadline = time.monotonic() + 3 + while time.monotonic() < deadline and fake.interrupts == 0: + await asyncio.sleep(0.02) + assert fake.interrupts == 1, fake.interrupts + assert run._turn_generation == 1 and run._stall_reaped, run._turn_generation + # the reap is done; stop the watchdog so turn 2's controlled + # injection can't be spuriously reaped by idle timing. + wdog.cancel() + with contextlib.suppress(asyncio.CancelledError): + await wdog + + # UI immediately asks the NEXT question → turn 2 (gen 2). This clears + # _stall_reaped, so ONLY the generation guard can protect turn 2. + compile_box._arm_test_turn(run) + await fake.query("second question (the real one)") + assert run._turn_generation == 2 and not run._stall_reaped + + # NOW turn 1's stragglers arrive late — a partial then its torn-down + # ResultMessage. Both belong to gen 1 < armed gen 2 → dropped. + fake.push(AssistantMessage("stale text from the reaped turn")) + fake.push(ResultMessage()) + drain_deadline = time.monotonic() + 3 + while time.monotonic() < drain_deadline and run._results_seen < 1: + await asyncio.sleep(0.01) + assert run._results_seen == 1, run._results_seen + # the late reaped result must NOT have terminated the live turn 2 + assert run._turn_active is True, "a stale reaped frame terminated the new turn" + + # turn 2's real answer completes the turn on its own result frame + fake.push(AssistantMessage("real answer for the second question")) + fake.push(ResultMessage()) + fake.close_stream() + await asyncio.wait_for(consume, timeout=3) + finally: + run.done = True + wdog.cancel() + with contextlib.suppress(asyncio.CancelledError): + await wdog + evs = _drain(run) + logs = [e["text"] for e in evs if e["type"] == "log"] + # the surviving turn answered with turn 2's text, never the reaped turn's + assert any("real answer for the second question" in t for t in logs), logs + assert not any("stale text from the reaped turn" in t for t in logs), logs + # exactly one clean turn_done carried turn 2's real answer (the reap emitted + # its own timeout turn_done earlier; turn 2 must not double- or mis-terminate) + clean_dones = [e for e in evs if e["type"] == "turn_done" + and "real answer for the second question" in e.get("text", "")] + assert len(clean_dones) == 1, [e for e in evs if e["type"] == "turn_done"] + assert run._turn_active is False, run._turn_active + out = buf.getvalue() + # both reaped stragglers were dropped as stale and logged with tid+parent + assert out.count("turn.stale_frame_dropped tid=t-race parent=p-race") == 2, out + assert "answer" not in out.lower() and "question" not in out.lower(), out # no content leak + finally: + (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, compile_box._MODEL_WATCHDOG_POLL_S) = saved + print("✓ test-session late reaped frames are dropped, never terminate the freshly-armed turn") + + +async def test_open_test_session_idempotency_is_run_scoped(): + """P1: the open-test dedup key is (parent_run_id, client_request_id). On a shared + box two runs that mint the SAME client_request_id must each open their OWN session + (no cross-run aliasing of tid/snapshot/fingerprint); the same key on the SAME run + still replays. Teardown drops only the run-scoped key.""" + orig = compile_box.ClaudeSDKClient + compile_box.ClaudeSDKClient = _AliveFakeClient + compile_box.RUNS.clear() + compile_box.TEST_SESSIONS.clear() + compile_box.TEST_SESSION_IDEMPOTENCY.clear() + snap_root = tempfile.mkdtemp() + os.environ["KBC_TEST_SNAPSHOT_ROOT"] = snap_root + os.environ["KBC_MAX_TEST_SESSIONS"] = "10" # exercise idempotency, not the cap + client = TestClient(TestServer(compile_box.build_app())) + await client.start_server() + try: + for rid in ("p1", "p2"): + wd = tempfile.mkdtemp() + cand = Path(wd) / "candidate" + cand.mkdir() + (cand / "index.md").write_text(f"# index {rid}\n") + compile_box.RUNS[rid] = compile_box.CompileRun(rid, wd, 1) + + # SAME client_request_id on TWO different runs → two DISTINCT fresh sessions + a = await (await client.post("/test-session/p1", json={"client_request_id": "shared"})).json() + b = await (await client.post("/test-session/p2", json={"client_request_id": "shared"})).json() + assert a["test_session_id"] != b["test_session_id"], (a, b) + assert a["idempotent_replay"] is False and b["idempotent_replay"] is False, (a, b) + assert len(compile_box.TEST_SESSIONS) == 2, compile_box.TEST_SESSIONS + # the two run-scoped keys coexist, each pointing at its own run's session + assert compile_box.TEST_SESSION_IDEMPOTENCY[("p1", "shared")] == a["test_session_id"] + assert compile_box.TEST_SESSION_IDEMPOTENCY[("p2", "shared")] == b["test_session_id"] + + # same key + SAME run → replay of THAT run's session (never the other run's) + a2 = await (await client.post("/test-session/p1", json={"client_request_id": "shared"})).json() + assert a2["test_session_id"] == a["test_session_id"] and a2["idempotent_replay"] is True, (a, a2) + assert len(compile_box.TEST_SESSIONS) == 2, "replay must not open a new session" + + for t in list(compile_box.TEST_SESSIONS.keys()): + await client.post(f"/test-session/{t}/close") + # teardown removed both run-scoped keys + assert compile_box.TEST_SESSION_IDEMPOTENCY == {}, compile_box.TEST_SESSION_IDEMPOTENCY + finally: + await client.close() + compile_box.ClaudeSDKClient = orig + compile_box.RUNS.clear() + compile_box.TEST_SESSIONS.clear() + compile_box.TEST_SESSION_IDEMPOTENCY.clear() + os.environ.pop("KBC_TEST_SNAPSHOT_ROOT", None) + os.environ.pop("KBC_MAX_TEST_SESSIONS", None) + shutil.rmtree(snap_root, ignore_errors=True) + print("✓ test-session idempotency is run-scoped (same key, different runs → distinct sessions)") + + async def test_test_session_idle_ttl_reaper(): """Defect 2 (TTL): a test session idle past KBC_TEST_SESSION_IDLE_TTL_S is torn down by the periodic sweep — the snapshot dir + registry slot are freed and a @@ -4172,9 +4350,11 @@ async def main(): await test_test_session_driver_uses_captured_contract() await test_open_close_test_session_http() await test_open_test_session_idempotency() + await test_open_test_session_idempotency_is_run_scoped() await test_test_message_path() await test_test_session_step_frames() await test_test_session_stall_reaps_turn_keeps_session_live() + await test_test_session_late_reaped_frames_never_terminate_new_turn() await test_test_session_idle_ttl_reaper() await test_list_test_sessions_endpoint() await test_explicit_test_recommendation_http() From 35d1860daed4b8412c5327401befb017978129b7 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 23 Jul 2026 18:15:06 +0800 Subject: [PATCH 14/18] fix(kbc-box): rebuild test-session client when a reaped turn's terminator is lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review P1 on the per-turn generation guard. The guard assumes a stall-reaped test turn's torn-down ResultMessage eventually arrives and advances _results_seen. But the reaper's client.interrupt() can be swallowed by the same black hole that wedged the turn, so that terminator may NEVER come. Then the armed generation permanently outruns _results_seen: every later turn's real frames are dropped as stale, its own ResultMessage is dropped too, the turn times out and re-reaps — a permanent miscount avalanche the existing tests never covered (they only exercised a terminator that eventually arrives). Fix: when the terminator cannot realign the stream, reconnect a fresh SDK client instead of drifting forever. - The test-stall watchdog now manages _needs_rebuild. A failed interrupt() flags it immediately. A successful interrupt() arms a bounded post-interrupt window (KBC_TEST_STALL_REBUILD_WINDOW_S, default 10s, timed inside the watchdog loop so it never blocks the handler): if the terminator lands and advances _results_seen the flag stays clear (existing generation flow resumes); if the window elapses, the flag is set. - The next /test-message that sees _needs_rebuild rebuilds the SDK client: test_session_driver becomes an epoch loop that disconnects the dead client and reconnects on the SAME pinned snapshot with generation state reset to zero. The tid / snapshot / UI session row are preserved. The SDK-side conversation history is intentionally discarded — a stalled session's history is already unreliable and the UI transcript is server-persisted; this is the accepted trade-off. A rebuild failure surfaces a retryable error, never silently. Tests (three stall timings, fake supports a raising interrupt and a never-delivered terminator): failed interrupt -> rebuild -> new turn answers; accepted interrupt but terminator never arrives -> window elapses -> rebuild; terminator arrives in-window -> no rebuild, existing drop path (regression guard). Full suite green across three consecutive runs. --- kbc/platform/pod/compile_box.py | 265 ++++++++++++++++++++++----- kbc/platform/pod/test_compile_box.py | 178 ++++++++++++++++++ 2 files changed, 399 insertions(+), 44 deletions(-) diff --git a/kbc/platform/pod/compile_box.py b/kbc/platform/pod/compile_box.py index 493103813..c41272070 100644 --- a/kbc/platform/pod/compile_box.py +++ b/kbc/platform/pod/compile_box.py @@ -36,6 +36,7 @@ """ import asyncio import base64 +import contextlib import hashlib import io import json @@ -247,6 +248,18 @@ def _test_snapshot_root() -> str: # is recreated. A periodic sweep tears down sessions idle past this TTL. _TEST_SESSION_IDLE_TTL_S = float(os.environ.get("KBC_TEST_SESSION_IDLE_TTL_S", "1800")) _TEST_SESSION_REAP_POLL_S = float(os.environ.get("KBC_TEST_SESSION_REAP_POLL_S", "60")) +# After the stall reaper interrupt()s a wedged test turn, the torn-down +# ResultMessage should still arrive and advance the generation stream. But a true +# black-hole can swallow interrupt() too, so that terminator may NEVER come — the +# per-turn generation guard then miscounts every later turn (armed generation +# outruns _results_seen forever). When interrupt() succeeds we give the terminator +# this bounded window to land; if it does not, we flag the session for a client +# rebuild instead of letting the generation stream drift permanently. +_TEST_STALL_REBUILD_WINDOW_S = float(os.environ.get("KBC_TEST_STALL_REBUILD_WINDOW_S", "10")) +# Bound on a /test-message-triggered client rebuild (disconnect + reconnect on the +# same snapshot). A rebuild that overruns this surfaces a retryable error instead +# of hanging the request. +_TEST_REBUILD_DEADLINE_S = float(os.environ.get("KBC_TEST_REBUILD_DEADLINE_S", "30")) # Structured error code for the 429 refusal when the pod's concurrent # test-session cap is full. Emitted in the box's own {error:{code,message, # retriable}} shape (same convention as handle_test_recommendation) so the @@ -535,6 +548,22 @@ def __init__(self, tid: str, cwd: str, parent_run_id: str, snapshot_hash: str, l # counts consumed ResultMessages; frame generation = _results_seen + 1. self._turn_generation = 0 self._results_seen = 0 + # Client-rebuild coordination (review P1). The generation guard above + # silently assumes a reaped turn's terminator eventually arrives. When it + # cannot (interrupt() itself was swallowed, or its result black-holed), the + # generation stream can never realign and every later turn would be dropped. + # The watchdog then flags _needs_rebuild; the next /test-message disconnects + # and reconnects a fresh SDK client on the same pinned snapshot (state reset + # to a clean generation) before arming the turn. _pending_terminator_* time + # the post-interrupt grace window inside the watchdog loop (event-loop timed, + # never blocking the handler). _rebuild_requested/_rebuild_ready/_rebuild_error + # hand the rebuild off to the driver task and report the outcome back. + self._needs_rebuild = False + self._pending_terminator_deadline: float | None = None + self._pending_terminator_seen = 0 + self._rebuild_requested: asyncio.Event = asyncio.Event() + self._rebuild_ready: asyncio.Event = asyncio.Event() + self._rebuild_error: str | None = None # Snapshot page count (returned by open, replayed on an idempotent open). self.pages: int = 0 # Consumer-minted client_request_id that opened this session (if any), so @@ -4251,6 +4280,20 @@ async def _test_stall_watchdog(run: "TestRun") -> None: Read/Grep over the snapshot is never false-killed (I4).""" while not run.done: await asyncio.sleep(_MODEL_WATCHDOG_POLL_S) + # Resolve a pending post-interrupt terminator window (armed below after a + # successful interrupt). If the reaped turn's ResultMessage arrived, the + # consume loop advanced _results_seen and the generation stream is intact — + # clear the window. If the window elapsed with nothing, the terminator is + # lost and the generation guard can never realign: flag a rebuild so the + # next /test-message starts the SDK client fresh. Runs above the active-turn + # guard because a reaped turn is no longer active. + if run._pending_terminator_deadline is not None: + if run._results_seen > run._pending_terminator_seen: + run._pending_terminator_deadline = None + elif time.monotonic() >= run._pending_terminator_deadline: + run._pending_terminator_deadline = None + run._needs_rebuild = True + _print_test_lifecycle("turn.rebuild_armed", run, extra="reason=terminator_lost") if not run._turn_active or run._stall_reaped: continue client = run.client @@ -4285,12 +4328,23 @@ async def _test_stall_watchdog(run: "TestRun") -> None: try: await client.interrupt() except Exception as e: - # A swallowed interrupt cannot un-wedge the receive loop, but the UI - # already recovered above; do not disconnect (spec: keep the session - # live). Surface the interrupt failure for pod-log triage. + # A swallowed interrupt cannot un-wedge the receive loop and its + # torn-down result will never arrive, so the generation stream can + # never realign. Flag a rebuild immediately: the next /test-message + # reconnects a fresh client. The UI already recovered above; do not + # disconnect here (spec: keep the session live). Surface the interrupt + # failure for pod-log triage. + run._needs_rebuild = True + _print_test_lifecycle("turn.rebuild_armed", run, extra="reason=interrupt_failed") await run.emit({"type": "summary", "text": _loc(run, f"Test session: interrupt after stall failed {e!r}", f"测试会话:停滞后中断失败 {e!r}")}) + else: + # interrupt() was accepted; the torn-down ResultMessage should still + # arrive and advance _results_seen. Arm a bounded window (resolved at + # the top of this loop) — if the terminator never lands, flag a rebuild. + run._pending_terminator_seen = run._results_seen + run._pending_terminator_deadline = time.monotonic() + _TEST_STALL_REBUILD_WINDOW_S async def _reap_idle_test_sessions() -> int: @@ -4324,17 +4378,14 @@ async def _test_session_reaper() -> None: print(f"[compile_box] test-session reaper sweep failed: {e!r}", flush=True) -async def test_session_driver(run: "TestRun"): - """Read-only consumer driver: host a ClaudeSDKClient over the pinned snapshot - dir, tools limited to the kb-test profile's whitelist (default Read/Glob/Grep), - persona = test_role pack, no MCP, no kickoff. A conversational session — connects and - waits for the first /test-message; each turn streams log + turn_done over GET - /test-events. Mirrors run_session minus writes/compile-tools/sync/bundle.""" - sid = run.session_id or str(uuid.uuid4()) - run.session_id = sid +def _build_test_client(run: "TestRun", sid: str): + """Construct (but do not connect) a read-only test-session SDK client over the + pinned snapshot dir, tools limited to the kb-test profile's whitelist. Split + out so both the first connect and a post-stall rebuild build an identical + client (same snapshot, same profile) — only the SDK session id differs.""" effective_tools = _effective_test_allowed_tools(run.allowed_tools) if _engine_kind() == "codex_sdk": - client = CodexSDKClient( + return CodexSDKClient( cwd=run.cwd, system_prompt=_prompt("test_role", run.locale), model=run.consumer_model or _test_model(), @@ -4344,44 +4395,136 @@ async def test_session_driver(run: "TestRun"): allowed_read_tools=effective_tools, max_tool_calls=run.consumer_max_turns if run.consumer_max_turns is not None else _test_max_turns(), ) - else: - opts = ClaudeAgentOptions( - cwd=run.cwd, - system_prompt={"type": "preset", "preset": "claude_code", "append": _prompt("test_role", run.locale)}, - tools=effective_tools, # actual context matches the fingerprinted profile contract - allowed_tools=effective_tools, - disallowed_tools=list(READONLY_CONSUMER_DISALLOWED_TOOLS), # belt-and-suspenders under bypass - mcp_servers={}, # no compile signal tools - strict_mcp_config=True, # ignore project/user/plugin MCP configs - skills=[], # no skills for a read-only consumer - permission_mode="bypassPermissions", # the pod itself is the sandbox - # C4: path confinement — absolute/../ reads must not escape the snapshot - # to the live /work draft. Hook, not can_use_tool: hooks fire under bypass. - hooks={"PreToolUse": [HookMatcher(hooks=[_make_test_path_guard(Path(run.cwd), run.locale)])]}, - setting_sources=[], # tenant isolation - # The test session mimics the REAL consumer → the gate/consumer tier - # (sonnet), not the compile tier. Massapi-served id; overridable per-deploy. - model=run.consumer_model or _test_model(), - max_turns=run.consumer_max_turns if run.consumer_max_turns is not None else _test_max_turns(), - session_id=sid, - session_store=InMemorySessionStore(), - ) - client = ClaudeSDKClient(options=opts) - try: - await client.connect() # conversational: wait for the first /test-message + opts = ClaudeAgentOptions( + cwd=run.cwd, + system_prompt={"type": "preset", "preset": "claude_code", "append": _prompt("test_role", run.locale)}, + tools=effective_tools, # actual context matches the fingerprinted profile contract + allowed_tools=effective_tools, + disallowed_tools=list(READONLY_CONSUMER_DISALLOWED_TOOLS), # belt-and-suspenders under bypass + mcp_servers={}, # no compile signal tools + strict_mcp_config=True, # ignore project/user/plugin MCP configs + skills=[], # no skills for a read-only consumer + permission_mode="bypassPermissions", # the pod itself is the sandbox + # C4: path confinement — absolute/../ reads must not escape the snapshot + # to the live /work draft. Hook, not can_use_tool: hooks fire under bypass. + hooks={"PreToolUse": [HookMatcher(hooks=[_make_test_path_guard(Path(run.cwd), run.locale)])]}, + setting_sources=[], # tenant isolation + # The test session mimics the REAL consumer → the gate/consumer tier + # (sonnet), not the compile tier. Massapi-served id; overridable per-deploy. + model=run.consumer_model or _test_model(), + max_turns=run.consumer_max_turns if run.consumer_max_turns is not None else _test_max_turns(), + session_id=sid, + session_store=InMemorySessionStore(), + ) + return ClaudeSDKClient(options=opts) + + +def _reset_test_turn_state(run: "TestRun") -> None: + """Zero the per-turn generation bookkeeping for a freshly (re)connected client. + A rebuild starts a brand-new SDK conversation, so the generation counters, + reap latch, and pending-terminator window must all return to their initial + values — otherwise the guard in _consume_test_turn_stream would keep dropping + the new client's frames against a stale armed generation.""" + run._turn_active = False + run._tool_pending = False + run._stall_reaped = False + run._turn_generation = 0 + run._results_seen = 0 + run._turn_text = [] + run._needs_rebuild = False + run._pending_terminator_deadline = None + run._pending_terminator_seen = 0 + + +async def test_session_driver(run: "TestRun"): + """Read-only consumer driver: host a ClaudeSDKClient over the pinned snapshot + dir, tools limited to the kb-test profile's whitelist (default Read/Glob/Grep), + persona = test_role pack, no MCP, no kickoff. A conversational session — connects and + waits for the first /test-message; each turn streams log + turn_done over GET + /test-events. Mirrors run_session minus writes/compile-tools/sync/bundle. + + Epoch loop: normally one connect drives the session for its whole life. But a + reaped turn whose terminator is lost flags _needs_rebuild; the next + /test-message sets _rebuild_requested, and this loop then disconnects the dead + client and reconnects a fresh one on the SAME snapshot (a new SDK conversation — + the stalled SDK-side history is deliberately discarded; the UI transcript is + server-persisted, and a stalled session's history is already unreliable). The + tid / snapshot / UI session row are unchanged across the rebuild.""" + sid = run.session_id or str(uuid.uuid4()) + run.session_id = sid + rebuilding = False + while True: + client = _build_test_client(run, run.session_id) + try: + await client.connect() # conversational: wait for the first /test-message + except Exception as e: + if not rebuilding: + run.connected.set() # unblock /test-message waiters → they see 409 + raise # first connect failed: hard session failure + # A rebuild's reconnect failed. Keep the session row alive and report a + # retryable error to the waiting handler rather than tearing the session + # down; a later /test-message retries the rebuild. + run._rebuild_error = repr(e) + run._needs_rebuild = True + run.connected.clear() + run.client = None + run._rebuild_ready.set() + await run._rebuild_requested.wait() + run._rebuild_requested.clear() + run._rebuild_ready.clear() + continue run.client = client run.connected.set() - sid = str(getattr(client, "session_id", sid) or sid) + sid = str(getattr(client, "session_id", run.session_id) or run.session_id) run.session_id = sid await run.emit({"type": "session", "session_id": sid}) + if rebuilding: + # The reconnect succeeded; the state was zeroed before this epoch. + run._rebuild_error = None + run._rebuild_ready.set() # Persistent conversational stream with the turn-stall reaper (started by - # _test_session_wrapper). Yields this turn's output then blocks for the - # next /test-message, keeping the session alive until close/idle-TTL. - await _consume_test_turn_stream(run, client) - finally: - run.connected.set() # unblock any /test-message waiters even if connect failed - run.client = None + # _test_session_wrapper). Runs until the stream ends (close/teardown) OR a + # /test-message asks for a rebuild; whichever fires first wins. + consume = asyncio.create_task(_consume_test_turn_stream(run, client)) + rebuild_req = asyncio.create_task(run._rebuild_requested.wait()) + try: + done, _ = await asyncio.wait( + {consume, rebuild_req}, return_when=asyncio.FIRST_COMPLETED) + except BaseException: + # Teardown cancels the wrapper task while we await here; never leak the + # child tasks. Cancel both, drain, then let the cancellation propagate + # (the finally still disconnects the client via the outer flow). + for t in (consume, rebuild_req): + t.cancel() + await asyncio.gather(consume, rebuild_req, return_exceptions=True) + with contextlib.suppress(Exception): + await client.disconnect() + raise + finally: + run.connected.clear() + run.client = None + if rebuild_req in done: + # Rebuild wins even if consume also just finished — a fresh client is + # always safe. Cancel the consume loop, drop the dead client, reset the + # generation state, and reconnect with a new SDK session id. + if not consume.done(): + consume.cancel() + with contextlib.suppress(asyncio.CancelledError): + await consume + await client.disconnect() + run._rebuild_requested.clear() + _reset_test_turn_state(run) + run.session_id = str(uuid.uuid4()) + rebuilding = True + continue + # Stream ended on its own (close / teardown / driver stop): surface any + # consume exception to the wrapper and end the session. + rebuild_req.cancel() + with contextlib.suppress(asyncio.CancelledError): + await rebuild_req await client.disconnect() + consume.result() + return def _validate_test_recommendation(root: Path, args: dict) -> dict: @@ -5588,6 +5731,34 @@ def failure(status: int, code: str, message: str, retriable: bool): RECOMMENDATIONS_ACTIVE.discard(run_id) +async def _rebuild_test_client(run: "TestRun") -> web.Response | None: + """Reconnect a fresh SDK client for a session whose prior turn's terminator was + lost (interrupt() failed or its post-interrupt window elapsed). The generation + stream can never realign on the old client, so hand the rebuild to the driver + task: it disconnects the dead client and reconnects on the SAME pinned snapshot + with reset generation state. The tid / snapshot / UI session row are preserved; + the SDK-side conversation history is intentionally dropped (a stalled session's + history is already unreliable and the UI transcript lives server-side). Bounded — + a rebuild that overruns surfaces a retryable error instead of hanging. Returns + an error Response on failure, else None.""" + run._needs_rebuild = False + run._rebuild_error = None + run._rebuild_ready.clear() + run._rebuild_requested.set() # the driver's asyncio.wait wakes, cancels consume, reconnects + try: + await asyncio.wait_for(run._rebuild_ready.wait(), timeout=_TEST_REBUILD_DEADLINE_S) + except asyncio.TimeoutError: + run._rebuild_error = "test session rebuild timed out" + if run._rebuild_error is not None: + _print_test_lifecycle("turn.rebuild_failed", run, extra=f"err={run._rebuild_error!r}") + await run.emit({"type": "error", "error": _loc(run, + "The session could not be restarted — please try again.", + "会话无法重启,请重试。")}) + return web.json_response({"error": "test session rebuild failed"}, status=503) + _print_test_lifecycle("turn.rebuilt", run) + return None + + async def handle_test_message(request: web.Request): """Inject a user turn into a live read-only test session. Reply streams over GET /test-events/{tid}.""" @@ -5601,6 +5772,12 @@ async def handle_test_message(request: web.Request): text = (body.get("message") or "").strip() if not text: return web.json_response({"error": "message is required"}, status=400) + if run._needs_rebuild: + # The previous turn's terminator was lost; reconnect a clean client before + # arming, or the generation guard would drop this turn's frames forever. + err = await _rebuild_test_client(run) + if err is not None: + return err _arm_test_turn(run) # arm the turn-stall watchdog for this turn _print_test_lifecycle("turn.start", run) await run.client.query(text) diff --git a/kbc/platform/pod/test_compile_box.py b/kbc/platform/pod/test_compile_box.py index 492666964..c8d3a3584 100644 --- a/kbc/platform/pod/test_compile_box.py +++ b/kbc/platform/pod/test_compile_box.py @@ -1279,6 +1279,181 @@ async def test_test_session_late_reaped_frames_never_terminate_new_turn(): print("✓ test-session late reaped frames are dropped, never terminate the freshly-armed turn") +class _RebuildFake: + """Multi-epoch test-session SDK stand-in for the client-rebuild P1. The FIRST + connected instance black-holes its first query (forcing a stall reap); its + interrupt() behaviour is scenario-driven: + - "interrupt_fails": interrupt() RAISES → the torn-down result can never + come, so the watchdog flags a rebuild at once. + - "terminator_never": interrupt() is accepted but no ResultMessage is ever + delivered → the post-interrupt window elapses → rebuild. + - "terminator_in_window": interrupt() delivers the torn-down ResultMessage → + the generation stream realigns → NO rebuild. + A SECOND instance (built by the driver's rebuild) answers the next turn normally. + Instances are tracked class-side so a test asserts how many connects happened + (2 = a rebuild occurred; 1 = the existing drop path handled it).""" + + instances: list = [] + scenario = "interrupt_fails" + + def __init__(self, options=None): + self.options = options + self.idx = len(_RebuildFake.instances) + _RebuildFake.instances.append(self) + self.queries = [] + self.interrupts = 0 + self._q: asyncio.Queue = asyncio.Queue() + self._closed = False + + async def connect(self, prompt=None): + pass + + async def query(self, text, session_id="default"): + self.queries.append(text) + if self.idx == 0 and len(self.queries) == 1: + return # first turn on the first client black-holes (push nothing) + self._q.put_nowait(AssistantMessage("answer: " + text)) + self._q.put_nowait(ResultMessage()) + + async def interrupt(self): + self.interrupts += 1 + if _RebuildFake.scenario == "interrupt_fails": + raise RuntimeError("interrupt swallowed by the black hole") + if _RebuildFake.scenario == "terminator_in_window": + self._q.put_nowait(ResultMessage()) # the torn-down terminator lands + # "terminator_never": accepted, but nothing is ever delivered + + async def receive_messages(self): + while not self._closed: + msg = await self._q.get() + if msg is _STREAM_STOP: + return + yield msg + + async def disconnect(self): + self._closed = True + self._q.put_nowait(_STREAM_STOP) + + +async def _await(cond, *, timeout=3.0, poll=0.02): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if cond(): + return True + await asyncio.sleep(poll) + return False + + +async def _drive_rebuild_scenario(scenario): + """Boot a real test session (driver + watchdog via _test_session_wrapper) over + a _RebuildFake, black-hole turn 1, let the watchdog reap it, then reproduce the + NEXT /test-message exactly as handle_test_message does (rebuild-if-flagged → + arm → query). Returns (run, buf, rebuilt) where rebuilt says a reconnect + happened. Caller drains + asserts, then this restores globals.""" + saved = (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, + compile_box._MODEL_WATCHDOG_POLL_S, + compile_box._TEST_STALL_REBUILD_WINDOW_S, + compile_box.ClaudeSDKClient) + compile_box._TEST_MODEL_IDLE_TIMEOUT_S = 0.15 + compile_box._MODEL_WATCHDOG_POLL_S = 0.03 + compile_box._TEST_STALL_REBUILD_WINDOW_S = 0.2 + _RebuildFake.instances = [] + _RebuildFake.scenario = scenario + compile_box.ClaudeSDKClient = _RebuildFake + buf = io.StringIO() + snap = tempfile.mkdtemp() + run = compile_box.TestRun("t-rb", snap, parent_run_id="p-rb", snapshot_hash="h") + try: + with contextlib.redirect_stdout(buf): + run.task = asyncio.create_task(compile_box._test_session_wrapper(run)) + assert await compile_box._await_session_live(run) is None + # turn 1 → black-holed; the watchdog must reap it + compile_box._arm_test_turn(run) + await run.client.query("first question") + assert await _await(lambda: _RebuildFake.instances[0].interrupts == 1), "turn 1 was never reaped" + # the rebuild decision settles asynchronously (immediately for a failed + # interrupt, after the window for a lost terminator, never in-window) + if scenario == "terminator_in_window": + assert await _await(lambda: run._results_seen >= 1), "the in-window terminator was not consumed" + await asyncio.sleep(compile_box._TEST_STALL_REBUILD_WINDOW_S + 0.1) # let the window pass + assert run._needs_rebuild is False, "an in-window terminator must NOT force a rebuild" + else: + assert await _await(lambda: run._needs_rebuild), f"{scenario}: rebuild was never flagged" + + # the NEXT /test-message: mirror handle_test_message's decision path + rebuilt = run._needs_rebuild + if run._needs_rebuild: + assert await compile_box._rebuild_test_client(run) is None, "rebuild reported an error" + compile_box._arm_test_turn(run) + await run.client.query("second question") + assert await _await(lambda: run._turn_active is False and run._results_seen >= 1), "turn 2 never completed" + evs = _drain(run) + return run, buf, rebuilt, evs + finally: + run.done = True + if run.task and not run.task.done(): + run.task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run.task + shutil.rmtree(snap, ignore_errors=True) + (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, + compile_box._MODEL_WATCHDOG_POLL_S, + compile_box._TEST_STALL_REBUILD_WINDOW_S, + compile_box.ClaudeSDKClient) = saved + + +async def test_test_session_rebuild_after_failed_interrupt(): + """Review P1 (a): the stall reaper's interrupt() can FAIL, so the wedged turn's + torn-down ResultMessage never arrives — _results_seen can never catch up to the + armed generation, and every later turn would be dropped forever. The watchdog + now flags _needs_rebuild the instant interrupt() raises; the next /test-message + reconnects a fresh SDK client (2nd connect) with a clean generation, and the new + turn answers normally on it.""" + run, buf, rebuilt, evs = await _drive_rebuild_scenario("interrupt_fails") + assert rebuilt is True, "a failed interrupt must trigger a client rebuild" + assert len(_RebuildFake.instances) == 2, _RebuildFake.instances # reconnected + logs = [e["text"] for e in evs if e["type"] == "log"] + assert any("answer: second question" in t for t in logs), logs + assert any(e["type"] == "turn_done" and "answer: second question" in e.get("text", "") for e in evs), evs + out = buf.getvalue() + assert "turn.rebuild_armed tid=t-rb parent=p-rb reason=interrupt_failed" in out, out + assert "turn.rebuilt tid=t-rb parent=p-rb" in out, out + assert "question" not in out.lower() and "answer" not in out.lower(), out # no content leak + print("✓ test-session rebuild: a failed stall interrupt reconnects a clean client") + + +async def test_test_session_rebuild_after_lost_terminator(): + """Review P1 (b): interrupt() is ACCEPTED but its torn-down ResultMessage never + lands (a true black-hole swallows it too). The post-interrupt window elapses + with _results_seen unchanged → the watchdog flags a rebuild → the next + /test-message reconnects a fresh client and the new turn answers normally.""" + run, buf, rebuilt, evs = await _drive_rebuild_scenario("terminator_never") + assert rebuilt is True, "a lost terminator must trigger a client rebuild" + assert len(_RebuildFake.instances) == 2, _RebuildFake.instances # reconnected + logs = [e["text"] for e in evs if e["type"] == "log"] + assert any("answer: second question" in t for t in logs), logs + out = buf.getvalue() + assert "turn.rebuild_armed tid=t-rb parent=p-rb reason=terminator_lost" in out, out + assert "turn.rebuilt tid=t-rb parent=p-rb" in out, out + print("✓ test-session rebuild: a lost stall terminator reconnects after the window") + + +async def test_test_session_terminator_in_window_no_rebuild(): + """Review P1 (c) — regression guard: when interrupt() DOES deliver the torn-down + ResultMessage within the window, _results_seen realigns, no rebuild is flagged, + and the next turn is served on the SAME client via the existing generation-drop + path (only ONE connect ever happens).""" + run, buf, rebuilt, evs = await _drive_rebuild_scenario("terminator_in_window") + assert rebuilt is False, "an in-window terminator must NOT rebuild" + assert len(_RebuildFake.instances) == 1, _RebuildFake.instances # no reconnect + logs = [e["text"] for e in evs if e["type"] == "log"] + assert any("answer: second question" in t for t in logs), logs + out = buf.getvalue() + assert "turn.rebuild_armed" not in out, out + assert "turn.rebuilt" not in out, out + print("✓ test-session no rebuild: an in-window stall terminator keeps the same client") + + async def test_open_test_session_idempotency_is_run_scoped(): """P1: the open-test dedup key is (parent_run_id, client_request_id). On a shared box two runs that mint the SAME client_request_id must each open their OWN session @@ -4355,6 +4530,9 @@ async def main(): await test_test_session_step_frames() await test_test_session_stall_reaps_turn_keeps_session_live() await test_test_session_late_reaped_frames_never_terminate_new_turn() + await test_test_session_rebuild_after_failed_interrupt() + await test_test_session_rebuild_after_lost_terminator() + await test_test_session_terminator_in_window_no_rebuild() await test_test_session_idle_ttl_reaper() await test_list_test_sessions_endpoint() await test_explicit_test_recommendation_http() From cf1b9b6096e4719f3eb206cf5fceabd391e93889 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 23 Jul 2026 18:32:13 +0800 Subject: [PATCH 15/18] fix(kbc-box): consult _needs_rebuild before the liveness gate in /test-message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a rebuild whose reconnect failed, the driver clears `connected` and parks until the next rebuild request. But handle_test_message waited on `connected` first, so every retry timed out at the liveness gate (503 "session is still starting") and the rebuild retry path never ran — the session was stuck until the idle TTL (review P1). Reorder the handler: cheap body validation, then rebuild-if-flagged, then the liveness wait. A successful rebuild sets `connected` before signalling ready, so the order is safe in the normal (still-connected) taint case too. New HTTP regression test drives the real handler end to end: first rebuild reconnect fails (immediate retryable 503, flag re-armed), second retry rebuilds on a fresh client and the turn is answered. Verified the test fails on the previous head with exactly the reported symptom. --- kbc/platform/pod/compile_box.py | 16 ++-- kbc/platform/pod/test_compile_box.py | 116 +++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/kbc/platform/pod/compile_box.py b/kbc/platform/pod/compile_box.py index c41272070..d3a4d4933 100644 --- a/kbc/platform/pod/compile_box.py +++ b/kbc/platform/pod/compile_box.py @@ -5765,19 +5765,25 @@ async def handle_test_message(request: web.Request): run = TEST_SESSIONS.get(request.match_info["tid"]) if not run: return web.json_response({"error": "unknown test session"}, status=404) - err = await _await_session_live(run) # duck-typed on .connected/.client - if err is not None: - return err body = await request.json() text = (body.get("message") or "").strip() if not text: return web.json_response({"error": "message is required"}, status=400) if run._needs_rebuild: - # The previous turn's terminator was lost; reconnect a clean client before - # arming, or the generation guard would drop this turn's frames forever. + # The previous turn's terminator was lost (reconnect a clean client before + # arming, or the generation guard would drop this turn's frames forever) OR + # a prior rebuild's reconnect failed. This must run BEFORE the liveness + # wait: after a failed reconnect the driver clears `connected` and parks + # until the next rebuild request, so gating on `connected` first would turn + # every retry into a 25s timeout → 503 and the retry path would never fire. + # A successful rebuild sets `connected` before signalling ready, so the + # liveness wait below is correct in this order too. err = await _rebuild_test_client(run) if err is not None: return err + err = await _await_session_live(run) # duck-typed on .connected/.client + if err is not None: + return err _arm_test_turn(run) # arm the turn-stall watchdog for this turn _print_test_lifecycle("turn.start", run) await run.client.query(text) diff --git a/kbc/platform/pod/test_compile_box.py b/kbc/platform/pod/test_compile_box.py index c8d3a3584..abcb7759c 100644 --- a/kbc/platform/pod/test_compile_box.py +++ b/kbc/platform/pod/test_compile_box.py @@ -1335,6 +1335,48 @@ async def disconnect(self): self._q.put_nowait(_STREAM_STOP) +class _RetryRebuildFake: + """Rebuild-retry stand-in: instance 0 connects fine but black-holes turn 1 and + its interrupt() raises (forcing a rebuild); instance 1 — the rebuild's + reconnect — FAILS to connect (a transient error); instance 2 connects and + answers normally. Exercises the failed-rebuild retry path end to end.""" + + instances: list = [] + + def __init__(self, options=None): + self.options = options + self.idx = len(_RetryRebuildFake.instances) + _RetryRebuildFake.instances.append(self) + self.queries = [] + self._q: asyncio.Queue = asyncio.Queue() + self._closed = False + + async def connect(self, prompt=None): + if self.idx == 1: + raise RuntimeError("transient reconnect failure") + + async def query(self, text, session_id="default"): + self.queries.append(text) + if self.idx == 0: + return # black-hole every turn on the doomed first client + self._q.put_nowait(AssistantMessage("answer: " + text)) + self._q.put_nowait(ResultMessage()) + + async def interrupt(self): + raise RuntimeError("interrupt swallowed by the black hole") + + async def receive_messages(self): + while not self._closed: + msg = await self._q.get() + if msg is _STREAM_STOP: + return + yield msg + + async def disconnect(self): + self._closed = True + self._q.put_nowait(_STREAM_STOP) + + async def _await(cond, *, timeout=3.0, poll=0.02): deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -1454,6 +1496,79 @@ async def test_test_session_terminator_in_window_no_rebuild(): print("✓ test-session no rebuild: an in-window stall terminator keeps the same client") +async def test_test_session_rebuild_retry_after_failed_reconnect(): + """Review P1: a rebuild whose reconnect FAILS must stay retryable over HTTP. + After a failed reconnect the driver clears `connected` and parks until the next + rebuild request, so /test-message must consult _needs_rebuild BEFORE the + liveness gate — gating on `connected` first turns every retry into a connect + timeout → 503 "session is still starting" and the retry logic never runs (the + session is then stuck until the idle TTL). First retry → 503 rebuild failed + (flag re-armed); second retry → fresh client, 200, turn answered on it.""" + saved = (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, + compile_box._MODEL_WATCHDOG_POLL_S, + compile_box._TEST_STALL_REBUILD_WINDOW_S, + compile_box.ClaudeSDKClient) + compile_box._TEST_MODEL_IDLE_TIMEOUT_S = 0.15 + compile_box._MODEL_WATCHDOG_POLL_S = 0.03 + compile_box._TEST_STALL_REBUILD_WINDOW_S = 0.2 + _RetryRebuildFake.instances = [] + compile_box.ClaudeSDKClient = _RetryRebuildFake + # keep the pre-fix failure mode fast: the old ordering would eat this timeout + # on every retry instead of ever reaching the rebuild + os.environ["KBC_CONNECT_TIMEOUT_SECS"] = "1" + compile_box.TEST_SESSIONS.clear() + buf = io.StringIO() + snap = tempfile.mkdtemp() + client = TestClient(TestServer(compile_box.build_app())) + await client.start_server() + run = compile_box.TestRun("t-rr", snap, parent_run_id="p-rr", snapshot_hash="h") + compile_box.TEST_SESSIONS["t-rr"] = run + try: + with contextlib.redirect_stdout(buf): + run.task = asyncio.create_task(compile_box._test_session_wrapper(run)) + assert await compile_box._await_session_live(run) is None + # turn 1 black-holes; the watchdog reaps it and interrupt() raises → rebuild flagged + r1 = await client.post("/test-message/t-rr", json={"message": "first question"}) + assert r1.status == 200, await r1.text() + assert await _await(lambda: run._needs_rebuild), "rebuild was never flagged" + + # retry 1: the rebuild's reconnect fails (instance 1) → an immediate + # retryable 503, NOT a liveness-gate timeout + r2 = await client.post("/test-message/t-rr", json={"message": "second question"}) + assert r2.status == 503, await r2.text() + assert "rebuild failed" in (await r2.json())["error"], await r2.text() + assert run._needs_rebuild is True, "a failed rebuild must re-arm the flag for the next retry" + + # retry 2: the rebuild succeeds (instance 2) and the turn is answered on it + r3 = await client.post("/test-message/t-rr", json={"message": "third question"}) + assert r3.status == 200, await r3.text() + assert await _await(lambda: run._turn_active is False and run._results_seen >= 1), \ + "the retried turn never completed" + evs = _drain(run) + logs = [e["text"] for e in evs if e["type"] == "log"] + assert any("answer: third question" in t for t in logs), logs + assert len(_RetryRebuildFake.instances) == 3, _RetryRebuildFake.instances + out = buf.getvalue() + assert "turn.rebuild_failed tid=t-rr" in out, out + assert "turn.rebuilt tid=t-rr" in out, out + assert "question" not in out.lower() and "answer" not in out.lower(), out # no content leak + finally: + run.done = True + if run.task and not run.task.done(): + run.task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run.task + await client.close() + compile_box.TEST_SESSIONS.clear() + os.environ.pop("KBC_CONNECT_TIMEOUT_SECS", None) + shutil.rmtree(snap, ignore_errors=True) + (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, + compile_box._MODEL_WATCHDOG_POLL_S, + compile_box._TEST_STALL_REBUILD_WINDOW_S, + compile_box.ClaudeSDKClient) = saved + print("✓ test-session rebuild retry: a failed reconnect 503s fast and the next retry rebuilds") + + async def test_open_test_session_idempotency_is_run_scoped(): """P1: the open-test dedup key is (parent_run_id, client_request_id). On a shared box two runs that mint the SAME client_request_id must each open their OWN session @@ -4533,6 +4648,7 @@ async def main(): await test_test_session_rebuild_after_failed_interrupt() await test_test_session_rebuild_after_lost_terminator() await test_test_session_terminator_in_window_no_rebuild() + await test_test_session_rebuild_retry_after_failed_reconnect() await test_test_session_idle_ttl_reaper() await test_list_test_sessions_endpoint() await test_explicit_test_recommendation_http() From 916d85d80b0c03261366aa6e0619881503dfc84f Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 23 Jul 2026 19:06:04 +0800 Subject: [PATCH 16/18] fix(kbc-box): rebuild on retry while the post-interrupt terminator window is unresolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a stall reap whose interrupt() succeeded, the watchdog frees the UI (turn_done) and arms a bounded window waiting for the torn-down turn's ResultMessage. But the UI can retry immediately inside that window, and arming the retry on the old client is ambiguous (review P1): if the terminator never arrives, the retry's frames are counted one generation behind and silently dropped, while its own ResultMessage is mistaken for the lost terminator — cancelling the rebuild decision. The user's valid retry was never displayed and timed out a second time. /test-message now treats an unresolved pending-terminator window exactly like _needs_rebuild: rebuild a fresh client before arming. Deterministic by construction — no new query is ever issued while the old generation stream is in doubt, so the mistaken-terminator attribution can no longer occur. The stalled SDK history was already the accepted loss on this path. _rebuild_test_client also clears the window up front so the watchdog cannot re-flag terminator_lost mid-rebuild. The no-retry paths are unchanged: an in-window terminator with a patient user still resolves on the same client with no rebuild. New HTTP regression test drives the reviewer's exact sequence end to end: interrupt succeeds, terminator lost, retry lands inside the window — asserts exactly ONE stall timeout, the second answer is displayed, the client was rebuilt, and no stale-frame drops occurred. Verified it fails on the previous head with two turn_stalled events and the retry answer eaten. --- kbc/platform/pod/compile_box.py | 34 +++++--- kbc/platform/pod/test_compile_box.py | 112 +++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 9 deletions(-) diff --git a/kbc/platform/pod/compile_box.py b/kbc/platform/pod/compile_box.py index d3a4d4933..17301cfcb 100644 --- a/kbc/platform/pod/compile_box.py +++ b/kbc/platform/pod/compile_box.py @@ -5742,6 +5742,10 @@ async def _rebuild_test_client(run: "TestRun") -> web.Response | None: a rebuild that overruns surfaces a retryable error instead of hanging. Returns an error Response on failure, else None.""" run._needs_rebuild = False + # An unresolved post-interrupt window is moot on a fresh client; clearing it + # here (not just in the driver's post-connect reset) stops the watchdog from + # re-flagging terminator_lost while the rebuild is in flight. + run._pending_terminator_deadline = None run._rebuild_error = None run._rebuild_ready.clear() run._rebuild_requested.set() # the driver's asyncio.wait wakes, cancels consume, reconnects @@ -5769,15 +5773,27 @@ async def handle_test_message(request: web.Request): text = (body.get("message") or "").strip() if not text: return web.json_response({"error": "message is required"}, status=400) - if run._needs_rebuild: - # The previous turn's terminator was lost (reconnect a clean client before - # arming, or the generation guard would drop this turn's frames forever) OR - # a prior rebuild's reconnect failed. This must run BEFORE the liveness - # wait: after a failed reconnect the driver clears `connected` and parks - # until the next rebuild request, so gating on `connected` first would turn - # every retry into a 25s timeout → 503 and the retry path would never fire. - # A successful rebuild sets `connected` before signalling ready, so the - # liveness wait below is correct in this order too. + if run._needs_rebuild or run._pending_terminator_deadline is not None: + # Rebuild before arming whenever the generation stream is broken or in + # doubt: + # (a) _needs_rebuild — the previous turn's terminator is known lost + # (interrupt failed / window elapsed) or a prior rebuild's reconnect + # failed; the guard would drop this turn's frames forever. + # (b) an unresolved post-interrupt terminator window — the reaped turn's + # terminator has NOT landed yet, so arming a new turn now is ambiguous: + # if the terminator never arrives, the new turn's frames are counted + # one generation behind and silently dropped, and its own + # ResultMessage is mistaken for the old turn's terminator, cancelling + # the rebuild decision (review P1: an immediate retry right after the + # stall turn_done lost the user's answer and timed out again). A + # fresh client is deterministic; the stalled SDK history is already + # the accepted loss. + # This must also run BEFORE the liveness wait: after a failed reconnect + # the driver clears `connected` and parks until the next rebuild request, + # so gating on `connected` first would turn every retry into a 25s + # timeout → 503 and the retry path would never fire. A successful rebuild + # sets `connected` before signalling ready, so the liveness wait below is + # correct in this order too. err = await _rebuild_test_client(run) if err is not None: return err diff --git a/kbc/platform/pod/test_compile_box.py b/kbc/platform/pod/test_compile_box.py index abcb7759c..ffa227691 100644 --- a/kbc/platform/pod/test_compile_box.py +++ b/kbc/platform/pod/test_compile_box.py @@ -1377,6 +1377,49 @@ async def disconnect(self): self._q.put_nowait(_STREAM_STOP) +class _ImmediateRetryFake: + """Immediate-retry-within-window stand-in: instance 0 black-holes query 1; + its interrupt() is ACCEPTED but the torn-down ResultMessage never arrives + (terminator lost, window pending). Any LATER query on instance 0 answers + normally — pre-fix, those frames landed while the window was still pending + and were dropped one generation behind, with their ResultMessage mistaken + for the lost terminator. Instance 1 (the rebuild) answers normally.""" + + instances: list = [] + + def __init__(self, options=None): + self.options = options + self.idx = len(_ImmediateRetryFake.instances) + _ImmediateRetryFake.instances.append(self) + self.queries = [] + self._q: asyncio.Queue = asyncio.Queue() + self._closed = False + + async def connect(self, prompt=None): + pass + + async def query(self, text, session_id="default"): + self.queries.append(text) + if self.idx == 0 and len(self.queries) == 1: + return # black-hole the first turn only + self._q.put_nowait(AssistantMessage("answer: " + text)) + self._q.put_nowait(ResultMessage()) + + async def interrupt(self): + pass # accepted — but no torn-down ResultMessage is ever delivered + + async def receive_messages(self): + while not self._closed: + msg = await self._q.get() + if msg is _STREAM_STOP: + return + yield msg + + async def disconnect(self): + self._closed = True + self._q.put_nowait(_STREAM_STOP) + + async def _await(cond, *, timeout=3.0, poll=0.02): deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -1496,6 +1539,74 @@ async def test_test_session_terminator_in_window_no_rebuild(): print("✓ test-session no rebuild: an in-window stall terminator keeps the same client") +async def test_test_session_immediate_retry_within_terminator_window(): + """Review P1: interrupt() succeeds but the reaped turn's terminator is lost, + and the user retries IMMEDIATELY after the stall turn_done — inside the + pending-terminator window. Pre-fix, arming that retry on the old client + counted its frames one generation behind (all silently dropped), and its own + ResultMessage was mistaken for the lost terminator, cancelling the rebuild + decision: the user's valid retry was never displayed and timed out again. + Now /test-message treats an unresolved window like _needs_rebuild: the retry + rebuilds a fresh client, exactly ONE stall timeout is ever surfaced, and the + second question is answered normally.""" + saved = (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, + compile_box._MODEL_WATCHDOG_POLL_S, + compile_box._TEST_STALL_REBUILD_WINDOW_S, + compile_box.ClaudeSDKClient) + compile_box._TEST_MODEL_IDLE_TIMEOUT_S = 0.15 + compile_box._MODEL_WATCHDOG_POLL_S = 0.03 + compile_box._TEST_STALL_REBUILD_WINDOW_S = 5.0 # wide: the retry always lands inside it + _ImmediateRetryFake.instances = [] + compile_box.ClaudeSDKClient = _ImmediateRetryFake + compile_box.TEST_SESSIONS.clear() + buf = io.StringIO() + snap = tempfile.mkdtemp() + client = TestClient(TestServer(compile_box.build_app())) + await client.start_server() + run = compile_box.TestRun("t-ir", snap, parent_run_id="p-ir", snapshot_hash="h") + compile_box.TEST_SESSIONS["t-ir"] = run + try: + with contextlib.redirect_stdout(buf): + run.task = asyncio.create_task(compile_box._test_session_wrapper(run)) + assert await compile_box._await_session_live(run) is None + r1 = await client.post("/test-message/t-ir", json={"message": "first question"}) + assert r1.status == 200, await r1.text() + # the stall reap frees the UI (turn_done) and arms the terminator window + assert await _await(lambda: run._pending_terminator_deadline is not None), \ + "the stall was never reaped / the window never armed" + # the UI retries at once, well inside the window + r2 = await client.post("/test-message/t-ir", json={"message": "second question"}) + assert r2.status == 200, await r2.text() + assert await _await(lambda: run._turn_active is False and run._results_seen >= 1), \ + "the immediate retry never completed" + evs = _drain(run) + stalls = [e for e in evs if e["type"] == "turn_stalled"] + assert len(stalls) == 1, stalls # exactly one timeout ever surfaced + logs = [e["text"] for e in evs if e["type"] == "log"] + assert any("answer: second question" in t for t in logs), logs + assert any(e["type"] == "turn_done" and "answer: second question" in e.get("text", "") + for e in evs), evs + assert len(_ImmediateRetryFake.instances) == 2, _ImmediateRetryFake.instances # rebuilt + out = buf.getvalue() + assert "turn.rebuilt tid=t-ir" in out, out + assert "turn.stale_frame_dropped" not in out, out # the retry's frames were never eaten + assert "question" not in out.lower() and "answer" not in out.lower(), out # no content leak + finally: + run.done = True + if run.task and not run.task.done(): + run.task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run.task + await client.close() + compile_box.TEST_SESSIONS.clear() + shutil.rmtree(snap, ignore_errors=True) + (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, + compile_box._MODEL_WATCHDOG_POLL_S, + compile_box._TEST_STALL_REBUILD_WINDOW_S, + compile_box.ClaudeSDKClient) = saved + print("✓ test-session immediate retry inside the terminator window rebuilds; one timeout, answer shown") + + async def test_test_session_rebuild_retry_after_failed_reconnect(): """Review P1: a rebuild whose reconnect FAILS must stay retryable over HTTP. After a failed reconnect the driver clears `connected` and parks until the next @@ -4649,6 +4760,7 @@ async def main(): await test_test_session_rebuild_after_lost_terminator() await test_test_session_terminator_in_window_no_rebuild() await test_test_session_rebuild_retry_after_failed_reconnect() + await test_test_session_immediate_retry_within_terminator_window() await test_test_session_idle_ttl_reaper() await test_list_test_sessions_endpoint() await test_explicit_test_recommendation_http() From ab3cbb162036650b46c43e0b29723abb9bd3b70c Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 23 Jul 2026 19:27:19 +0800 Subject: [PATCH 17/18] fix(kbc-box): close the reap-to-arm gap and make the terminator window generation-specific MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the stall-reap path: 1. The pending-terminator window was armed only AFTER interrupt() returned, but the stall turn_done frees the UI BEFORE that await — a retry landing while interrupt() was in flight saw neither _needs_rebuild nor a pending window and armed a misaligned turn on the wedged client. The window is now armed at the reap decision itself, before any await, so the /test-message rebuild gate covers the whole post-reap timeline. 2. The window resolver treated ANY advance of _results_seen as "the reaped terminator arrived", letting a later turn's ResultMessage impersonate a lost terminator and cancel the rebuild decision — leaving the session permanently generation-misaligned (every later frame silently dropped until the idle TTL). The window now records the reaped turn's GENERATION and only a ResultMessage advancing _results_seen to that generation resolves it (_pending_terminator_seen -> _pending_terminator_gen). The interrupt-failed branch additionally guards on the client still being current: a retry may have already rebuilt underneath the in-flight interrupt (disconnecting the old client is exactly what makes interrupt() raise then), and re-flagging would force a second, spurious rebuild. New HTTP regression test parks the reaper inside a blocked interrupt(), retries in exactly that gap, and asserts one stall, a rebuild, and the retry's answer displayed. Verified it fails on the previous head with the retry swallowed. --- kbc/platform/pod/compile_box.py | 68 +++++++++------ kbc/platform/pod/test_compile_box.py | 121 +++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 25 deletions(-) diff --git a/kbc/platform/pod/compile_box.py b/kbc/platform/pod/compile_box.py index 17301cfcb..f40395000 100644 --- a/kbc/platform/pod/compile_box.py +++ b/kbc/platform/pod/compile_box.py @@ -560,7 +560,7 @@ def __init__(self, tid: str, cwd: str, parent_run_id: str, snapshot_hash: str, l # hand the rebuild off to the driver task and report the outcome back. self._needs_rebuild = False self._pending_terminator_deadline: float | None = None - self._pending_terminator_seen = 0 + self._pending_terminator_gen = 0 self._rebuild_requested: asyncio.Event = asyncio.Event() self._rebuild_ready: asyncio.Event = asyncio.Event() self._rebuild_error: str | None = None @@ -4280,15 +4280,18 @@ async def _test_stall_watchdog(run: "TestRun") -> None: Read/Grep over the snapshot is never false-killed (I4).""" while not run.done: await asyncio.sleep(_MODEL_WATCHDOG_POLL_S) - # Resolve a pending post-interrupt terminator window (armed below after a - # successful interrupt). If the reaped turn's ResultMessage arrived, the - # consume loop advanced _results_seen and the generation stream is intact — - # clear the window. If the window elapsed with nothing, the terminator is - # lost and the generation guard can never realign: flag a rebuild so the - # next /test-message starts the SDK client fresh. Runs above the active-turn - # guard because a reaped turn is no longer active. + # Resolve a pending post-interrupt terminator window (armed at the reap + # below). The reaped turn's terminator is the ResultMessage that advances + # _results_seen to that turn's recorded GENERATION — only that specific + # evidence clears the window (review: a bare "_results_seen advanced" + # check let any later turn's ResultMessage impersonate the lost + # terminator and cancel the rebuild decision). If the window elapses + # first, the terminator is lost and the generation guard can never + # realign: flag a rebuild so the next /test-message starts the SDK client + # fresh. Runs above the active-turn guard because a reaped turn is no + # longer active. if run._pending_terminator_deadline is not None: - if run._results_seen > run._pending_terminator_seen: + if run._results_seen >= run._pending_terminator_gen: run._pending_terminator_deadline = None elif time.monotonic() >= run._pending_terminator_deadline: run._pending_terminator_deadline = None @@ -4308,6 +4311,18 @@ async def _test_stall_watchdog(run: "TestRun") -> None: run._stall_reaped = True run._turn_active = False run._turn_text = [] # the wedged attempt produced nothing usable + # Arm the pending-terminator guard NOW, before any await: the turn_done + # below frees the UI, and a retry can land while interrupt() is still in + # flight — /test-message must already see the unresolved window and + # rebuild (review: arming only after interrupt() returned left a gap in + # which a retry armed a misaligned turn on the wedged client, and its + # ResultMessage then impersonated the reaped turn's terminator). The + # guard records the reaped turn's GENERATION: its terminator is the + # ResultMessage that advances _results_seen to that generation, so the + # resolver at the top of this loop compares against the generation + # rather than trusting any bare counter advance. + run._pending_terminator_gen = run._turn_generation + run._pending_terminator_deadline = time.monotonic() + _TEST_STALL_REBUILD_WINDOW_S _print_test_lifecycle( "turn.stalled", run, extra=f"idle={round(idle, 1)}s bound={round(bound, 1)}s") await run.emit({ @@ -4330,21 +4345,24 @@ async def _test_stall_watchdog(run: "TestRun") -> None: except Exception as e: # A swallowed interrupt cannot un-wedge the receive loop and its # torn-down result will never arrive, so the generation stream can - # never realign. Flag a rebuild immediately: the next /test-message - # reconnects a fresh client. The UI already recovered above; do not - # disconnect here (spec: keep the session live). Surface the interrupt - # failure for pod-log triage. - run._needs_rebuild = True - _print_test_lifecycle("turn.rebuild_armed", run, extra="reason=interrupt_failed") - await run.emit({"type": "summary", "text": _loc(run, - f"Test session: interrupt after stall failed {e!r}", - f"测试会话:停滞后中断失败 {e!r}")}) - else: - # interrupt() was accepted; the torn-down ResultMessage should still - # arrive and advance _results_seen. Arm a bounded window (resolved at - # the top of this loop) — if the terminator never lands, flag a rebuild. - run._pending_terminator_seen = run._results_seen - run._pending_terminator_deadline = time.monotonic() + _TEST_STALL_REBUILD_WINDOW_S + # never realign. Escalate the armed window to an immediate rebuild + # flag: the next /test-message reconnects a fresh client. The UI + # already recovered above; do not disconnect here (spec: keep the + # session live). Surface the interrupt failure for pod-log triage. + # Guard on the client still being current — a retry may have already + # rebuilt underneath this await (disconnecting `client` is exactly + # what makes interrupt() raise then), and re-flagging would force a + # second, spurious rebuild of the fresh client. + if run.client is client: + run._pending_terminator_deadline = None + run._needs_rebuild = True + _print_test_lifecycle("turn.rebuild_armed", run, extra="reason=interrupt_failed") + await run.emit({"type": "summary", "text": _loc(run, + f"Test session: interrupt after stall failed {e!r}", + f"测试会话:停滞后中断失败 {e!r}")}) + # On success the window armed above stands: the torn-down ResultMessage + # should arrive and advance _results_seen to the reaped generation; the + # resolver clears the window then, or flags a rebuild when it elapses. async def _reap_idle_test_sessions() -> int: @@ -4433,7 +4451,7 @@ def _reset_test_turn_state(run: "TestRun") -> None: run._turn_text = [] run._needs_rebuild = False run._pending_terminator_deadline = None - run._pending_terminator_seen = 0 + run._pending_terminator_gen = 0 async def test_session_driver(run: "TestRun"): diff --git a/kbc/platform/pod/test_compile_box.py b/kbc/platform/pod/test_compile_box.py index ffa227691..4eff8f89c 100644 --- a/kbc/platform/pod/test_compile_box.py +++ b/kbc/platform/pod/test_compile_box.py @@ -1420,6 +1420,54 @@ async def disconnect(self): self._q.put_nowait(_STREAM_STOP) +class _BlockedInterruptFake: + """Reap-to-arm race stand-in: instance 0 black-holes query 1 and its + interrupt() BLOCKS until the test releases it (a slow SDK round-trip) — + modelling the gap in which the UI has already received the stall turn_done + but the terminator window used to be armed only after interrupt() returned. + A retry landing in that gap must still be gated. Later queries on instance 0 + answer normally (pre-fix, they were the frames dropped one generation + behind). Instance 1 (the rebuild) answers normally.""" + + instances: list = [] + interrupt_entered: asyncio.Event + interrupt_release: asyncio.Event + + def __init__(self, options=None): + self.options = options + self.idx = len(_BlockedInterruptFake.instances) + _BlockedInterruptFake.instances.append(self) + self.queries = [] + self._q: asyncio.Queue = asyncio.Queue() + self._closed = False + + async def connect(self, prompt=None): + pass + + async def query(self, text, session_id="default"): + self.queries.append(text) + if self.idx == 0 and len(self.queries) == 1: + return # black-hole the first turn only + self._q.put_nowait(AssistantMessage("answer: " + text)) + self._q.put_nowait(ResultMessage()) + + async def interrupt(self): + _BlockedInterruptFake.interrupt_entered.set() + await _BlockedInterruptFake.interrupt_release.wait() + # released: accepted, but the torn-down ResultMessage never arrives + + async def receive_messages(self): + while not self._closed: + msg = await self._q.get() + if msg is _STREAM_STOP: + return + yield msg + + async def disconnect(self): + self._closed = True + self._q.put_nowait(_STREAM_STOP) + + async def _await(cond, *, timeout=3.0, poll=0.02): deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -1607,6 +1655,78 @@ async def test_test_session_immediate_retry_within_terminator_window(): print("✓ test-session immediate retry inside the terminator window rebuilds; one timeout, answer shown") +async def test_test_session_retry_during_interrupt_in_flight(): + """Review (Medium): the stall reaper frees the UI (turn_done) BEFORE awaiting + interrupt(), so a retry can land while interrupt() is still in flight. The + window used to be armed only after interrupt() returned — a retry in that gap + saw neither _needs_rebuild nor a pending window, armed a misaligned turn on + the wedged client, and (with the terminator lost) every later frame was + silently dropped until the idle TTL, while the retry's own ResultMessage + impersonated the lost terminator and cancelled the rebuild decision. The + window is now armed at the reap itself (before any await) and the resolver + compares against the reaped turn's generation, so the in-flight-interrupt + retry rebuilds a fresh client: exactly one stall, the retry's answer is + displayed.""" + saved = (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, + compile_box._MODEL_WATCHDOG_POLL_S, + compile_box._TEST_STALL_REBUILD_WINDOW_S, + compile_box.ClaudeSDKClient) + compile_box._TEST_MODEL_IDLE_TIMEOUT_S = 0.15 + compile_box._MODEL_WATCHDOG_POLL_S = 0.03 + compile_box._TEST_STALL_REBUILD_WINDOW_S = 5.0 + _BlockedInterruptFake.instances = [] + _BlockedInterruptFake.interrupt_entered = asyncio.Event() + _BlockedInterruptFake.interrupt_release = asyncio.Event() + compile_box.ClaudeSDKClient = _BlockedInterruptFake + compile_box.TEST_SESSIONS.clear() + buf = io.StringIO() + snap = tempfile.mkdtemp() + client = TestClient(TestServer(compile_box.build_app())) + await client.start_server() + run = compile_box.TestRun("t-race2", snap, parent_run_id="p-race2", snapshot_hash="h") + compile_box.TEST_SESSIONS["t-race2"] = run + try: + with contextlib.redirect_stdout(buf): + run.task = asyncio.create_task(compile_box._test_session_wrapper(run)) + assert await compile_box._await_session_live(run) is None + r1 = await client.post("/test-message/t-race2", json={"message": "first question"}) + assert r1.status == 200, await r1.text() + # the reap has emitted turn_done and is now parked INSIDE interrupt() + await asyncio.wait_for(_BlockedInterruptFake.interrupt_entered.wait(), timeout=3) + # the user retries in exactly that gap + r2 = await client.post("/test-message/t-race2", json={"message": "second question"}) + assert r2.status == 200, await r2.text() + assert await _await(lambda: run._turn_active is False and run._results_seen >= 1), \ + "the in-gap retry never completed" + _BlockedInterruptFake.interrupt_release.set() # let the reaper's interrupt return + await asyncio.sleep(0.1) # give the watchdog a settle beat + evs = _drain(run) + stalls = [e for e in evs if e["type"] == "turn_stalled"] + assert len(stalls) == 1, stalls + logs = [e["text"] for e in evs if e["type"] == "log"] + assert any("answer: second question" in t for t in logs), logs + assert len(_BlockedInterruptFake.instances) == 2, _BlockedInterruptFake.instances # rebuilt + out = buf.getvalue() + assert "turn.rebuilt tid=t-race2" in out, out + assert "turn.stale_frame_dropped" not in out, out + assert "question" not in out.lower() and "answer" not in out.lower(), out # no content leak + finally: + _BlockedInterruptFake.interrupt_release.set() + run.done = True + if run.task and not run.task.done(): + run.task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run.task + await client.close() + compile_box.TEST_SESSIONS.clear() + shutil.rmtree(snap, ignore_errors=True) + (compile_box._TEST_MODEL_IDLE_TIMEOUT_S, + compile_box._MODEL_WATCHDOG_POLL_S, + compile_box._TEST_STALL_REBUILD_WINDOW_S, + compile_box.ClaudeSDKClient) = saved + print("✓ test-session retry during an in-flight interrupt rebuilds; one stall, answer shown") + + async def test_test_session_rebuild_retry_after_failed_reconnect(): """Review P1: a rebuild whose reconnect FAILS must stay retryable over HTTP. After a failed reconnect the driver clears `connected` and parks until the next @@ -4761,6 +4881,7 @@ async def main(): await test_test_session_terminator_in_window_no_rebuild() await test_test_session_rebuild_retry_after_failed_reconnect() await test_test_session_immediate_retry_within_terminator_window() + await test_test_session_retry_during_interrupt_in_flight() await test_test_session_idle_ttl_reaper() await test_list_test_sessions_endpoint() await test_explicit_test_recommendation_http() From a1738a60b0d21aea9e239359e06d71da724a1d1f Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 23 Jul 2026 19:27:19 +0800 Subject: [PATCH 18/18] fix(kbc): unwrap angle-bracketed link destinations with a trailing #fragment/?query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An anchored angle destination — ![f](#fig1) — defeated the unwrap: the link regex's angle alternation only matches when `>` is immediately followed by `)`, so the whole `<...>#fig1` fell to the bare branch, endswith(">") was false, the brackets were never stripped, and the doc->asset edge was lost — a good compile was wrongly failed as unaccounted (review finding). Same root cause in _markdown_link_targets misreported an anchored angle-linked page as an orphan. Shared _unwrap_angle_destination now runs after the title strip and before #/? truncation: it unwraps up to the first `>` and re-appends the remainder, so the ordinary fragment/query truncation removes the anchor. Mirrored byte-for-byte by the sicore ledger's unwrapAngleDestination (fixed in lockstep); the shared asset-provenance fixture pins the case with a new `guide/assets/c d.png` referenced only via `#fig1` — the old parser demonstrably drops that edge in both repos. --- .../pod/fixtures/asset-provenance/README.md | 4 +- .../fixtures/asset-provenance/expected.json | 9 +++- .../asset-provenance/raw/guide/assets/c d.png | 1 + .../asset-provenance/raw/guide/setup.md | 2 + kbc/platform/pod/selfcheck.py | 50 ++++++++++++------- 5 files changed, 46 insertions(+), 20 deletions(-) create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/c d.png diff --git a/kbc/platform/pod/fixtures/asset-provenance/README.md b/kbc/platform/pod/fixtures/asset-provenance/README.md index cbf54624d..fc165b2a8 100644 --- a/kbc/platform/pod/fixtures/asset-provenance/README.md +++ b/kbc/platform/pod/fixtures/asset-provenance/README.md @@ -7,7 +7,9 @@ their edge extraction + coverage math over this same `raw/` + `candidate/` + Cases exercised: relative link, `../` cross-directory link, HTML ``, URL-encoded path (`%20`), a `?query`-suffixed target (truncated before -decoding), a body reference to a nonexistent asset (no edge, no error), a +decoding), an angle-bracketed destination with a trailing `#fragment` +(`#fig1` — unwrapped even though the destination does not +*end* in `>`), a body reference to a nonexistent asset (no edge, no error), a 0-byte download-failed placeholder, `assets/sheets/*.md` (a content file, not media), one image shared by a cited and an unaccounted document (auto via the accounted one), an orphan image (unaccounted unless excluded), an image diff --git a/kbc/platform/pod/fixtures/asset-provenance/expected.json b/kbc/platform/pod/fixtures/asset-provenance/expected.json index 5bf35881d..689f2fd49 100644 --- a/kbc/platform/pod/fixtures/asset-provenance/expected.json +++ b/kbc/platform/pod/fixtures/asset-provenance/expected.json @@ -14,6 +14,7 @@ ], "guide/setup.md": [ "guide/assets/a b.png", + "guide/assets/c d.png", "guide/assets/diagram.png", "guide/assets/screenshot.png" ], @@ -24,10 +25,10 @@ ] }, "coverage": { - "total_sources": 18, + "total_sources": 19, "cited": 6, "excluded": 2, - "auto_attached": 8, + "auto_attached": 9, "auto_attached_sample": [ { "asset": "assets/hero.png", @@ -49,6 +50,10 @@ "asset": "guide/assets/a b.png", "via": "guide/setup.md" }, + { + "asset": "guide/assets/c d.png", + "via": "guide/setup.md" + }, { "asset": "guide/assets/diagram.png", "via": "guide/deep/detail.md" diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/c d.png b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/c d.png new file mode 100644 index 000000000..174ac55c0 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/c d.png @@ -0,0 +1 @@ +png-placeholder \ No newline at end of file diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md index 27fcbcc4d..4499cbeb3 100644 --- a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md @@ -7,3 +7,5 @@ HTML table image: shot URL-encoded path: ![spaced](assets/a%20b.png) Missing target — must not error and yields no edge: ![gone](assets/missing.png) + +Angle destination with a trailing anchor: ![angled](#fig1) diff --git a/kbc/platform/pod/selfcheck.py b/kbc/platform/pod/selfcheck.py index d7287697e..6ada281ba 100644 --- a/kbc/platform/pod/selfcheck.py +++ b/kbc/platform/pod/selfcheck.py @@ -605,23 +605,41 @@ def _markdown_link_targets(text: str) -> list[str]: targets: list[str] = [] for captured in _MD_LINK_RE.findall(text): destination = captured.strip() - if destination.startswith("<") and destination.endswith(">"): - destination = destination[1:-1].strip() - else: - # Keep compatibility with the optional Markdown link-title form. - titled = re.fullmatch( - r"(.+?\.md(?:#[^\s\"']*)?)\s+(?:\"[^\"]*\"|'[^']*')", - destination, - re.IGNORECASE, - ) - if titled: - destination = titled.group(1) + # Keep compatibility with the optional Markdown link-title form. + titled = re.fullmatch( + r"(.+?\.md(?:#[^\s\"']*)?)\s+(?:\"[^\"]*\"|'[^']*')", + destination, + re.IGNORECASE, + ) + if titled: + destination = titled.group(1) + # After the title strip so a quoted title is never mistaken for the + # angle destination's trailing fragment (same ordering + rationale as + # document_link_targets — an anchored `<...>#sec` link must not keep + # its brackets and get misreported as an orphaned page). + destination = _unwrap_angle_destination(destination) destination = unquote(destination).split("#", 1)[0].strip() if destination.lower().endswith(".md"): targets.append(destination) return targets +def _unwrap_angle_destination(destination: str) -> str: + """Unwrap a CommonMark angle-bracketed destination, tolerating a trailing + ``#fragment``/``?query`` OUTSIDE the closing bracket (``#fig1``): + the remainder is re-appended so the ordinary #/? truncation removes it. + Previously the wrapper was only stripped when the destination *ended* in + ``>``, so an anchored angle link kept its brackets and lost its edge — a + good compile was wrongly failed (review finding). Runs AFTER the title + strip (a quoted title is not a fragment) and BEFORE #/? truncation. Mirrors + the sicore ledger's unwrapAngleDestination; change only in lockstep.""" + if destination.startswith("<"): + close = destination.find(">") + if close != -1: + return destination[1:close].strip() + destination[close + 1:].strip() + return destination + + def _strip_fragment_query(target: str) -> str: """Drop a URL ``#fragment`` / ``?query`` from the STILL-ENCODED target, before percent-decoding — so an encoded ``%23``/``%3F`` that is part of a real @@ -643,12 +661,10 @@ def document_link_targets(md_text: str) -> list[str]: prose = _markdown_prose(md_text) for captured in _MD_LINK_RE.findall(prose): destination = captured.strip() - if destination.startswith("<") and destination.endswith(">"): - destination = destination[1:-1].strip() - else: - titled = re.fullmatch(r"(.+?)\s+(?:\"[^\"]*\"|'[^']*')", destination) - if titled: - destination = titled.group(1).strip() + titled = re.fullmatch(r"(.+?)\s+(?:\"[^\"]*\"|'[^']*')", destination) + if titled: + destination = titled.group(1).strip() + destination = _unwrap_angle_destination(destination) destination = unquote(_strip_fragment_query(destination)).strip() if destination: targets.append(destination)