Author SHA1 Message Date
Yichen Jiang d347e70390 Merge pull request #3554 from deepseek-harness/release/dsh-0.1.3-alpha.1
release: dsh@0.1.3-alpha.1
2026-09-04 17:16:23 +08:00
Yichen Jiang 5aff05a92f Merge branch 'master' into release/dsh-0.1.3-alpha.1 2026-09-04 16:58:08 +08:00
Yichen Jiang 2f77acb9ab Merge pull request #3558 from deepseek-harness/fix/web-clickable-links-gallery-golden
test(web): refresh the clickable-links gallery golden for the attachment button
2026-09-04 16:58:00 +08:00
Yichen Jiang c42ab9e14e test(web): refresh the clickable-links gallery golden for the attachment button 2026-09-04 16:38:22 +08:00
Yichen Jiang 6a82a3b3e2 Merge branch 'master' into release/dsh-0.1.3-alpha.1 2026-09-04 16:24:04 +08:00
ihsiang 0180c5cdea Merge pull request #3547 from deepseek-harness/ihsiangzhang/tool-call-clickable-styles
feat(client): 统一回答中可点击链接样式——链接别名、hover 点状虚线、分类图标
2026-09-04 16:23:14 +08:00
Yichen Jiang e94f938c05 release(dsh): align file-upload version 2026-09-04 16:21:09 +08:00
Yichen Jiang 14befe7ff7 Merge branch 'master' into release/dsh-0.1.3-alpha.1 2026-09-04 16:16:15 +08:00
Yichen Jiang 9c13703708 Merge pull request #3556 from deepseek-harness/fix/web-skill-chip-rebuilt-message
fix(web): keep skill chips on rebuilt message nodes
2026-09-04 16:16:08 +08:00
CreatixChu 53f6590f76 Merge pull request #3109 from deepseek-harness/worktree/2984-generic-file-upload
feat(attachment): 支持通用文件上传
2026-09-04 16:06:32 +08:00
Yichen Jiang 75d3402b84 fix(web): keep skill chips on rebuilt message nodes 2026-09-04 15:59:33 +08:00
yx.zhang 8ffdee4fe5 feat(client): unify clickable-link language with link alias and category glyphs
Clickable artifact links across the transcript (markdown anchors, prose
file mentions, web source/fetch links, produced-file chips, workflow
member links) now share one language: a dedicated --dsw-alias-link color
(light deepseek-500, dark deepseek-400) decoupled from
state-business-primary, font-weight 500, no underline at rest, and a
dotted 3px-offset underline on hover/focus. A new ui-primitives LinkIcon
leads each link with a currentColor category glyph (url / folder / code /
image / document / other) derived from the path extension by
classifyLinkPath. Produced-file chips drop the grey pill and 96px cap and
render at natural width, shrinking with ellipsis only on row overflow.
ToolRow's grey dotted file links and the grey show-in-folder action stay
as they were. The inline-code chip tint moves to neutral-50 (dark
neutral-800) with its 0.5px border kept.

Coverage: LinkIcon unit spec, refreshed markdown-dom fixtures, and the
clickable-links-gallery web e2e registered in the host compiler face.

Closes #3546
2026-09-04 15:55:49 +08:00
creatixchu e28b58f8c0 Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload
# Conflicts:
#	packages/client/ui-chat/src/client/chat/MessageItem.tsx
2026-09-04 15:41:57 +08:00
creatixchu 58ec1f0867 refactor(file-upload): keep storage helpers and the HTTP route package-private
Stop re-exporting the verbatim file-store helpers from attachment-local, move
the raw-byte upload route handler into a non-entry module of file-upload, and
drop the redundant hook type re-exports from the Client entry. The route test
moves next to the module it exercises.
2026-09-04 15:41:25 +08:00
Yichen Jiang ed67c22496 release(dsh): 0.1.3-alpha.1 2026-09-04 15:38:55 +08:00
Yichen Jiang 267d755bee Merge pull request #3552 from deepseek-harness/worktree/skill-fuzzy-search-53e21d
feat(web): fuzzy skill discovery and fact-based slash reference chips
2026-09-04 15:31:46 +08:00
Yichen Jiang 1b470211ad fix(web): address review: index skill-name batches, bound slash tokens at whitespace, chip only the leading goal token 2026-09-04 15:15:51 +08:00
Yichen Jiang d976849a6b fix(web): expect the goal command bubble to share the body face 2026-09-04 14:56:46 +08:00
creatixchu b4517feed5 Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload 2026-09-04 14:46:36 +08:00
Turtle 7acc038beb Merge pull request #3362 from deepseek-harness/refactor/session-persistence-write-lease
feat(session-persistence-jsonl): cross-process write lock (flock / named semaphore)
2026-09-04 14:45:03 +08:00
Yichen Jiang 9d947bd9cf Merge branch 'master' into worktree/skill-fuzzy-search-53e21d 2026-09-04 14:43:20 +08:00
Yichen Jiang d0da8ccf40 fix(web): decorate slash tokens in bubbles from logged skill and command facts 2026-09-04 14:38:30 +08:00
Turtle 2099 645c68daf2 Merge pull request #3530 from deepseek-harness/turtle/pr-3520-review-followup
docs(fs): record normalized unread diagnostics
2026-09-04 14:35:52 +08:00
Yichen Jiang 46d20f8bee feat(web): rank skill candidates with the shared fuzzy name ranker 2026-09-04 14:31:54 +08:00
Turtle c58097a826 feat(session-persistence-jsonl): cross-process write-ownership lease
Write handles now hold a durable lease (session.lock.json beside the
log): a random owner token, diagnostic pid, and an expiry. Acquisition
wins by exclusive create; a second process's create or write open rejects
while the record is unrenewed for less than leaseTtlMs (default 5 min),
and takes over after that — a crashed holder is waited out, never
reclaimed by pid. The holder renews every leaseRenewIntervalMs (default
4 min); a renewal that finds a foreign, vanished, or expired record — or
fails outright — marks the lease lost permanently, so every later
append/flush rejects with SessionOwnershipLostError while reads continue.
Close releases the record; read handles never touch it. Takeover of an
expired record is eventually exclusive: a replaced holder stops within
one renewal interval.

Refs #3245
2026-09-04 14:12:14 +08:00
creatixchu 268cf0101e Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload 2026-09-04 13:08:41 +08:00
creatixchu f0e52c2731 refactor(file-upload): tighten service boundaries 2026-09-04 13:08:25 +08:00
Dudu-0223 b6b2ec4a90 Merge pull request #3480 from deepseek-harness/fix/search-result-reveal-session
Fix session search result navigation
2026-09-04 12:18:21 +08:00
Chinesezjc bee0c13f28 Merge pull request #3513 from deepseek-harness/fix/test-tmp-teardown-self-clean
test: remove dsh-* temp dirs created by unit tests at teardown
2026-09-04 12:11:53 +08:00
Chinesezjc 1f0a87eb06 Merge remote-tracking branch 'origin/master' into fix/test-tmp-teardown-self-clean
# Conflicts:
#	packages/subprocess/subprocess-local/tests/spawn.spec.ts
2026-09-04 11:58:16 +08:00
Dudu-0223 470ea85286 Merge origin/master into fix/search-result-reveal-session 2026-09-04 11:57:07 +08:00
Chinesezjc d8a995e00e docs: align the changed-spec count with the merged-base diff (36) 2026-09-04 11:56:31 +08:00
Turtle df07f37827 Merge pull request #3516 from deepseek-harness/turtle/issue-2390-windows-hide-subprocess
fix(subprocess): hide Windows child windows
2026-09-04 11:52:34 +08:00
Chinesezjc 3f07443702 Merge branch 'master' into fix/test-tmp-teardown-self-clean 2026-09-04 11:15:31 +08:00
Turtle 9656a1742b Merge pull request #3521 from deepseek-harness/turtle/issue-2349-windows-root-workspace
fix(workspace): preserve Windows drive roots
2026-09-04 11:10:27 +08:00
creatixchu 705cd220d4 test(web): migrate file upload snapshot to v2 2026-09-04 10:29:41 +08:00
creatixchu 48656b4594 test(session-controller): restore Session type import 2026-09-04 10:14:15 +08:00
creatixchu fb743759a7 Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload
# Conflicts:
#	apps/web/tests/scaffold.ts
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/api/session-controller/README.i18n.yaml
#	packages/api/session-controller/README.md
#	packages/api/session-controller/README.zh.md
#	packages/extensions/tool-cordis/src/api-catalog.ts
#	packages/llm/token-meter/src/index.ts
#	packages/session-query/session-log-export/README.i18n.yaml
#	packages/session-query/session-log-export/README.md
#	packages/session-query/session-log-export/README.zh.md
#	packages/session-query/session-log-export/src/archive.ts
#	scripts/package-dependency-policy.ts
2026-09-04 10:12:19 +08:00
Tianyi Cui b4ea3efcf6 Merge pull request #3400 from deepseek-harness/worktree/session-format-06-v2-snapshot-rollout
test(snapshot): roll corpus forward to format v2
2026-09-03 23:12:28 +08:00
Tianyi Cui 1d35bcfc8a Merge pull request #3533 from deepseek-harness/gate/package-version-drift
gate(constraints): enforce the dsh family version rule across apps and packages
2026-09-03 22:49:35 +08:00
Tianyi Cui a931bcf226 fix(constraints): require the shared dsh version in every workspace manifest
The dsh-family version rule lived only in the packages/ block of
checkWorkspaceManifest, so apps/ members and the root-named CLI carried the
shared version with no static check of their own. The name-based rule now
covers every scanned manifest; regression coverage names the stale-version
merge that shipped packages/util/http-proxy at 0.1.2-alpha.5 behind a
0.1.2-rc.1 family.
2026-09-03 22:41:44 +08:00
Tianyi Cui 8cb1d6a7eb Merge commit '85c23a2df3' into worktree/session-format-06-v2-snapshot-rollout 2026-09-03 22:37:26 +08:00
Tianyi Cui 86241a442d fix(session): keep independent version fields off the format generation
`SESSION_FORMAT_VERSION` is the only value that names the Session format
generation. Two other version fields had moved to 2 alongside it:

- The `dsh_session_log` request extension's outer schema `version` returns
  to 1 and its Session header projection keeps `seedLength`, derived from
  the logical inherited cut, so the external wire is unchanged in this PR;
  `sessionFormatVersion` alone identifies the embedded generation.
- The `sessionStats` projection unit's `stateVersion` returns to 1: the
  projection cache binds every checkpoint to the format generation, so a
  generation change discards old rows without a unit version bump.

Test doubles and the standalone Client fixture that build a current
header now spell `SESSION_FORMAT_VERSION` instead of the literal.
2026-09-03 22:36:51 +08:00
Tianyi Cui 6c7603aacd Merge commit '51f9d5f6c1' into worktree/session-format-06-v2-snapshot-rollout 2026-09-03 22:24:50 +08:00
Tianyi Cui 9fb0678f0b Merge commit '22472e4092' into worktree/session-format-05-v1-v2-chunk-migration 2026-09-03 22:24:36 +08:00
Tianyi Cui 721ff67ca1 Merge commit '1b6fdd0e1b' into worktree/session-format-04-live-assistant-stream 2026-09-03 22:24:22 +08:00
Tianyi Cui 6c419795f6 docs(session): name the frozen v0/v1 vocabulary in the v0-to-v1 README
The README file table listed four of the six source modules and omitted
the two largest, so the package read as an oversized identity conversion.
The summary now states that most of the source is the frozen released
v0/v1 event vocabulary, why the edge refuses a malformed historical log
before the installed current restorer runs, and that later edges reuse
those shapes; the table gains `payload-validation.ts` and
`relationships.ts` rows.
2026-09-03 22:23:53 +08:00
Tianyi Cui 17fb719138 Merge commit 'cf8f75faaf' into worktree/session-format-06-v2-snapshot-rollout 2026-09-03 22:10:02 +08:00
Tianyi Cui d58964a010 Merge commit 'be5db297c7' into worktree/session-format-05-v1-v2-chunk-migration
# Conflicts:
#	.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml
#	.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md
#	.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/api/session-controller/README.i18n.yaml
#	packages/api/session-controller/README.md
#	packages/api/session-controller/README.zh.md
#	packages/api/session-controller/src/assistant-stream.ts
#	packages/api/session-controller/src/client/sessions/assistant-stream.ts
#	packages/api/session-controller/src/types.ts
#	packages/api/session-controller/tests/assistant-stream.client.spec.ts
#	packages/api/session-controller/tests/session-history-journal.host.spec.ts
#	packages/api/session-controller/tests/sessions-service.client.spec.ts
#	packages/api/session-controller/tests/transport.client.spec.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/extensions/tool-cordis/src/api-catalog.ts
#	scripts/package-dependency-policy.ts
2026-09-03 22:09:31 +08:00
Tianyi Cui 8a0c91be7f Merge commit 'c347bfcfa6' into worktree/session-format-04-live-assistant-stream 2026-09-03 21:59:19 +08:00
Tianyi Cui fe9c2d7053 refactor(agent, session-controller): drop the unread startedTime frame field
The `start` frame and the reconnect baseline attempt carried a wall-clock
`startedTime` that no Host or Client consumer read: the Host accumulator
and the Client reconciler copied it into their attempt maps and nothing
looked at it again. Presentation frames now carry only the coordinates
consumers use (attempt, revision, turn, step, chunk index, and v1 seq
provenance); the type-equivalence block, event graph, READMEs, and the
live-stream Agent Note follow the type.
2026-09-03 21:58:34 +08:00
Tianyi Cui c97df989ff refactor(session): share the canonical log basename and JSON snapshots
`session[.vN].jsonl` was assembled independently by the JSONL provider
(twice), the session-log export archive, and the recorded-session fixture
helpers. `dsh-session-format` now owns `sessionFormatLogFilename` and
`parseSessionFormatLogFilename`; the three consumers append only their
compression suffix, and the migration note names the owner.

`snapshotSessionFormatJson` re-implemented the lossless JSON walk that
`dsh-util-values` already publishes as `snapshotJsonValue` + `deepFreeze`,
and the frozen v0 relationship validator carried a third structural JSON
comparison next to `deepEqualJson`. Both now delegate; the format package
reports one `is not lossless JSON` diagnostic per labelled subject instead
of ten member-specific ones, and enumerable accessors whose values survive
a JSON round trip are accepted like `JSON.stringify` accepts them.
2026-09-03 21:48:09 +08:00
creatixchu de022388ea test(ci): stabilize attachment fixtures 2026-09-03 21:25:45 +08:00
Dudu-0223 d18770f50a fix(web): address latest session reveal review 2026-09-03 21:19:09 +08:00
creatixchu 0516edb037 test(file-upload): restore host composition fixtures 2026-09-03 21:12:50 +08:00
creatixchu 2a826a93c3 Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload 2026-09-03 20:55:12 +08:00
creatixchu ea669428be refactor(attachment): unify prompt content admission 2026-09-03 20:53:29 +08:00
creatixchu 3eb9736eb6 refactor(file-upload): scope prompt binding rollback 2026-09-03 20:15:18 +08:00
Tianyi Cui 71089f8df7 Merge commit '8f7250f904' into worktree/session-format-06-v2-snapshot-rollout
# Conflicts:
#	apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl
2026-09-03 19:41:58 +08:00
Tianyi Cui 874b174fd6 test(web): compare fixture inventories by Session role and pin the v2 upload
`assertFixtureInventory` folds every retained generation of one parent or
child fixture into a single role before comparing, so an older generation kept
beside the current one is not an inventory drift. The generation spec writes
headers that match each filename, since fixture selection validates the chosen
generation, and asserts that an absent parent resolves only for an
override-only replay. The `dsh_session_log` composition expectation follows the
extension's version 2 payload.
2026-09-03 19:41:16 +08:00
Tianyi Cui d5044d5675 Merge commit 'ee4bad5cde' into worktree/session-format-05-v1-v2-chunk-migration 2026-09-03 19:25:29 +08:00
Tianyi Cui bb771b133c Merge commit '09d3a185ab' into worktree/session-format-04-live-assistant-stream 2026-09-03 19:24:54 +08:00
Tianyi Cui d455ad99e3 test(session): record the v2 SDK expectations and cover review-fix branches
The continuable-child headless expectation and the Python `advanced` and
`restart` scenarios pin the current runtime, whose subagent children carry no
`session/end-seed` marker and whose assistant messages embed their streams:
the child fixture drops the marker and renumbers its references, `advanced/`
re-records `result.json`, and both scenarios gain their v2 Session fixtures.
Unit tests cover the `tool-call-delta` id and name validation, embedded
stream members without timing arrays, and the versionless-header refusal.
2026-09-03 19:22:37 +08:00
Chinesezjc dde27f5163 Merge remote-tracking branch 'origin/master' into fix/test-tmp-teardown-self-clean 2026-09-03 19:04:31 +08:00
Turtle 6e7c3f37c1 docs(fs): record normalized unread diagnostics 2026-09-03 19:01:30 +08:00
Turtle a05b5fbe79 fix(subprocess): hide Windows cleanup helpers 2026-09-03 19:01:30 +08:00
Turtle e609fd73a3 fix(workspace): harden qualified path handling 2026-09-03 19:01:30 +08:00
Tianyi Cui 778e0866c3 test(session): cover the migration read faults and the versioned export name
The JSONL backend rethrows storage errno failures and abort signals from the
source read of a pending migration unwrapped; a test now drives both through
the stat seam so the classification stays covered. The navigation-panes Web
e2e derives the exported archive entry from `SESSION_FORMAT_VERSION`, which
`session-log-export` uses for its `session.v<N>.jsonl` name. The module graph
records the `session-log-export` → `session` peer edge.
2026-09-03 19:00:16 +08:00
Tianyi Cui 91904d7c10 Merge commit 'fef079e5c9' into worktree/session-format-06-v2-snapshot-rollout
# Conflicts:
#	snapshots/session/fs-policy-reject/session.jsonl
2026-09-03 18:51:05 +08:00
Tianyi Cui 156429e86d chore(session): satisfy the master gates on the v1-to-v2 branch
`session-format-v1-to-v2` takes the `0.1.2-rc.1` root version that master
requires, and `defineReleasedPayloadDisposition` drops the `@param` tag for a
parameter it no longer has.
2026-09-03 18:43:24 +08:00
Tianyi Cui 28b16c011a Merge commit 'a123cb0055' into worktree/session-format-05-v1-v2-chunk-migration
# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
2026-09-03 18:39:44 +08:00
Tianyi Cui 13074e4aa2 Merge commit '308c1c69a6' into worktree/session-format-04-live-assistant-stream 2026-09-03 18:36:02 +08:00
Tianyi Cui 3c3ad02b30 chore(session): satisfy the master hygiene gates
`session-log-export` names its archive entry after `SESSION_FORMAT_VERSION`,
a runtime value of the shared `dsh-session` instance, so `dsh-session` becomes
a peer dependency and the export is classified as peer-required in the package
dependency policy. The three Session format packages take the `0.1.2-rc.1`
root version that master now requires.
2026-09-03 18:35:14 +08:00
Tianyi Cui 3aef64797d Merge master into session format migration
# Conflicts:
#	packages/test-support/session-snapshot/src/harness.ts
#	scripts/smoke-python-runtime.py
2026-09-03 18:31:54 +08:00
creatixchu b2c4483c9d refactor(attachment): own prompt admission on service 2026-09-03 18:18:32 +08:00
Chinesezjc cf4cfd9e4a docs: correct the changed-spec count in the Agent Note verification section 2026-09-03 18:17:09 +08:00
Turtle 818c4f344e Merge pull request #3520 from deepseek-harness/turtle/issue-2353-fs-error-copy
fix(fs): normalize unread mutation diagnostics
2026-09-03 18:15:00 +08:00
Tianyi Cui d89427ba6c Merge commit '12dabcc9d2' into worktree/session-format-06-v2-snapshot-rollout 2026-09-03 17:54:10 +08:00
Tianyi Cui 5fa5fea11d Merge commit '1f9d5f72e9' into worktree/session-format-05-v1-v2-chunk-migration
# Conflicts:
#	packages/session/session-format/README.i18n.yaml
#	packages/session/session-format/README.md
#	packages/session/session-format/README.zh.md
#	packages/session/session-format/src/catalog.ts
#	packages/session/session-format/src/types.ts
#	packages/test-support/llm-replay/tests/llm-replay.spec.ts
2026-09-03 17:53:18 +08:00
Tianyi Cui 51adfbf52c chore(session): retire the v1-to-v2 benchmark script
The repository-only benchmark, its unit-suite spec, the root script, and the
README section are removed; the accepted acceptance run's figures stay in the
embedded-assistant-streams note as recorded facts. The note also records why
the migration edge reuses the `dsh-llm` stream helpers instead of frozen
copies and why its target validation re-checks message/stream agreement
itself. A validation test that recomputed `RELEASED_V2_EVENT_TYPES` from the
same expression as its definition is dropped.
2026-09-03 17:50:09 +08:00
Tianyi Cui 7c169a04b3 Merge commit '1a244059c3' into worktree/session-format-04-live-assistant-stream 2026-09-03 17:48:54 +08:00
Tianyi Cui 13dee20db6 refactor(session): drop dead migration plumbing
`ensureJsonlGenerationCurrent` returned a live Zstandard body iterator that
every production caller disposed unread before decoding the same bytes again;
the header read now decodes only the first frame and owns no decoder past its
return. The JSONL backend reuses the generation module's stable-revision read
instead of keeping a second copy, `encodeCurrent` encodes the validated
current artifact it is documented to receive instead of re-running the
migration chain on it, and the test-only `inspectVersion` catalog method and
the `migrateSessionSnapshotFixture` duplicate of
`prepareSessionSnapshotFixtureForComparison` are removed.
2026-09-03 17:39:12 +08:00
Turtle af63a56078 test(workspace): make relative path regression portable 2026-09-03 17:30:14 +08:00
Chinesezjc c8fc38541f test: own the default spill dir in subprocess specs; correct retention wording
Second review round (ds-review-bot v4p/v5/v6): the exit cleanup claim that
collectors unlink their spill files on dispose was wrong — completed spill
files are retained (seal() only closes, discardSpill() is the only unlink),
so the exit removal only ever applies to directories that never spilled.
Reword the JSDoc, README, and Agent Note accordingly, drop the redundant
readdirSync precheck (rmdirSync ENOTEMPTY is caught anyway), and recount the
v8-ignore window. The spawn.spec default-spill test now records the default
per-process directory it created and removes it in afterAll, so a completed
spill file is never left behind by the unit suite.
2026-09-03 17:29:21 +08:00
Turtle d33fe767c1 fix(workspace): preserve Windows drive roots 2026-09-03 17:14:02 +08:00
Turtle 18635905aa fix(fs): normalize unread mutation diagnostics 2026-09-03 17:13:13 +08:00
Chinesezjc 1bc330ce01 docs: cross-link the spill retention decision from the teardown note
The Agent Note now links the 2026-07-17 local-spill-startup-cleanup note it
builds on, and the privateSpillDir JSDoc documents the empty-dir exit
removal alongside the directory's creation contract.
2026-09-03 17:07:43 +08:00
Chinesezjc 29e6669e3c test: narrow process-exit spill cleanup to empty dirs; drop spill-store deletion
Review (ds-review-bot) found the exit-time deletion of per-process spill
roots conflicts with the documented retention decision: spill artifacts are
model-visible locators that resumed or forked sessions may still reference
(2026-07-17-local-spill-startup-cleanup), and an exception thrown from an
'exit' listener can change the process exit code.

- dsh-spill-local: revert the exit handler entirely; its default root is
  already owned by the package's 30-day startup sweep.
- dsh-subprocess-local/spawn: remove the per-process spill dir at a
  JavaScript-observable exit only while it is EMPTY (collectors unlink
  their spill files on dispose), best-effort with a named swallow so a
  Windows-held handle never changes the exit code; dirs holding spill
  files keep them for external cleanup.
- Document the empty-dir exit removal in the package README and the Agent
  Note, and re-record both bilingual pairs.
2026-09-03 17:06:48 +08:00
Turtle cc8099dc5f fix(subprocess): hide Windows child windows 2026-09-03 17:05:18 +08:00
creatixchu f989df7c8e test(file-upload): cover extensible request identifiers 2026-09-03 17:04:29 +08:00
creatixchu 8a84c1edff test(file-upload): cover isolated service paths 2026-09-03 17:03:14 +08:00
creatixchu 33b7123e96 test(remotes): declare built smoke schema dependency 2026-09-03 16:49:08 +08:00
creatixchu 3b8245afc3 fix(build): align file upload project faces 2026-09-03 16:41:45 +08:00
creatixchu 7158227650 fix(file-upload): declare host runtime dependencies 2026-09-03 16:35:50 +08:00
Chinesezjc 6d9776a3b0 test: exempt process-exit spill cleanup from the per-file coverage gate
The exit listener runs after the coverage dump, so its body can never be
measured by the unit coverage lane; mark it v8-ignore with the reason, as
with other process-exit-only code paths.
2026-09-03 16:35:45 +08:00
Chinesezjc 1107ff5fe1 test: remove remaining dsh-* spill/temp dirs in shell and fs specs
Full-template residue histogram on the CI host surfaced four more normal-exit
leaks below the earlier cutoff: tool-bash/tool-pwsh tools.spec (module spill
dir / per-test homes), bash-sandbox sandbox.spec (module spill dir and the
read-only denial root), and fs-sandbox fs-sandbox.spec (inline tmp dir). All
four now remove what they create at the same teardown points as their
neighbors.
2026-09-03 16:26:22 +08:00
creatixchu 9fd72003e6 chore(test): remove obsolete lint suppression 2026-09-03 16:20:23 +08:00
creatixchu dd37061c2b test(client): provide file upload fixture service 2026-09-03 16:20:08 +08:00
Chinesezjc 0364343a7e test: remove dsh-* temp dirs created by unit tests at teardown
Spec files that create /tmp/dsh-* directories via mkdtemp now track and
delete them in afterEach/afterAll; module-scope fixture dirs (executor
spill dirs) are removed in afterAll. The file list came from the
observed-residue inventory on the self-hosted CI host: only specs whose
dirs actually accumulated were leak sources (issue #3134), superseding
the kept-but-unmerged CI sweep branch per the #3233 review decision.

Product per-process spill roots (dsh-subprocess-local spawn,
dsh-spill-local store) register a process-exit handler that removes the
memoized dir, so processes that used the spawn/spill path clean up on
normal exit. A SIGKILLed process cannot run in-process teardown; the
machine-side timer remains the backstop for that path.

Agent Note: .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md
2026-09-03 16:19:18 +08:00
Dudu-0223 a22f2105e0 fix(test): stabilize trajectory history pagination 2026-09-03 16:17:43 +08:00
Tianyi Cui 1762d63e6c test(session): cover observed migration shapes 2026-09-03 16:16:09 +08:00
creatixchu 97fe39920a Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload 2026-09-03 16:10:40 +08:00
creatixchu 8fc9a11c5e refactor(attachment): isolate file upload service 2026-09-03 16:10:24 +08:00
Dudu-0223 29e20b0a9f Merge branch 'master' into fix/search-result-reveal-session 2026-09-03 16:00:19 +08:00
Chinesezjc 100fcb286f Merge pull request #3432 from deepseek-harness/fix/pwsh-loader-composition-timeout
test(pwsh): restore product-default deadline in loader composition
2026-09-03 15:59:41 +08:00
Tianyi Cui e4f46f8e4d Merge commit '4ec9519539' into worktree/session-format-06-v2-snapshot-rollout 2026-09-03 15:46:19 +08:00
Tianyi Cui cf97e3e455 Merge commit 'ad1b192078' into worktree/session-format-05-v1-v2-chunk-migration 2026-09-03 15:45:27 +08:00
Tianyi Cui e7a17eb7ac Merge commit '7931edb2eb' into worktree/session-format-04-live-assistant-stream 2026-09-03 15:44:44 +08:00
Tianyi Cui f9870325b2 fix(session): validate compaction spans by surface order 2026-09-03 15:44:04 +08:00
Tianyi Cui 7442cedc82 Merge commit '13f179cd60' into worktree/session-format-06-v2-snapshot-rollout 2026-09-03 15:28:51 +08:00
Tianyi Cui 463f8e9221 Merge commit '0d8fcd3a0e' into worktree/session-format-05-v1-v2-chunk-migration 2026-09-03 15:27:50 +08:00
Tianyi Cui 9a5611865f Merge commit 'c0c4aebaa9' into worktree/session-format-04-live-assistant-stream 2026-09-03 15:27:11 +08:00
Tianyi Cui b5f1539733 fix(session): validate surface replacement endpoints 2026-09-03 15:26:35 +08:00
Chinesezjc 91f7e08136 Merge branch 'master' into fix/pwsh-loader-composition-timeout 2026-09-03 15:05:54 +08:00
Dudu-0223 f3dab48118 Merge remote-tracking branch 'origin/master' into fix/search-result-reveal-session 2026-09-03 15:03:50 +08:00
Dudu-0223 4f0706a335 fix(web): address session reveal review 2026-09-03 15:03:38 +08:00
creatixchu 1967bedd9f Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload 2026-09-03 14:43:25 +08:00
creatixchu ee23db68cf test(client): cover background upload seams 2026-09-03 14:43:07 +08:00
fz 9fe2112a63 Merge pull request #3473 from deepseek-harness/feat/python-sdk-macos-x64
feat(python): support macOS x64 runtime wheels
2026-09-03 14:36:59 +08:00
Yichen Jiang 0c99ae8557 Merge pull request #3474 from deepseek-harness/worktree/lark-docs-optimization-c86275
docs(user):  reasoning levels and model discovery in the provider guide
2026-09-03 14:30:42 +08:00
creatixchu b7e5d0b90e docs(client): refresh generated catalogs 2026-09-03 14:25:05 +08:00
creatixchu 4ed33aa581 chore(client): refresh slot source locations 2026-09-03 14:20:25 +08:00
fz 44855d1054 fix(release): align http proxy package version 2026-09-03 14:15:32 +08:00
Yichen Jiang a51474c436 docs(user): align provider guide wording with the effort menu, config button, and off semantics 2026-09-03 14:15:32 +08:00
fz 23c1052f5a docs(python): refresh runtime target notes 2026-09-03 14:15:12 +08:00
creatixchu caa4bbb474 Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload 2026-09-03 14:12:15 +08:00
creatixchu 7a99e70372 fix(client): satisfy upload package gates 2026-09-03 14:11:41 +08:00
Dudu-0223 987fde9515 Merge remote-tracking branch 'origin/master' into fix/search-result-reveal-session 2026-09-03 14:10:18 +08:00
Yichen Jiang 74c38ba9c7 Merge remote-tracking branch 'origin/master' into worktree/lark-docs-optimization-c86275 2026-09-03 14:07:26 +08:00
creatixchu 6acc828f81 test(client): widen upload carrier fixtures 2026-09-03 14:03:53 +08:00
Yichen Jiang 76fda72979 Merge pull request #3481 from deepseek-harness/fix/http-proxy-rc-version
fix(http-proxy): match the workspace version to the 0.1.2-rc.1 release
2026-09-03 14:02:58 +08:00
fz 61e4829ed7 Merge remote-tracking branch 'origin/master' into feat/python-sdk-macos-x64 2026-09-03 14:02:09 +08:00
creatixchu 4647dbf8ff fix(ui-approval): type pending approval discriminator 2026-09-03 14:01:37 +08:00
Dudu-0223 b09d3a2c0d fix(ui-workspace): reveal opened search result 2026-09-03 14:00:21 +08:00
creatixchu 6cf7b1e34f chore(client): refresh generated API catalog 2026-09-03 14:00:14 +08:00
creatixchu b8512fa5e5 Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload 2026-09-03 13:53:47 +08:00
creatixchu bbb2ca7c9b refactor(client): extract background file upload service 2026-09-03 13:53:25 +08:00
Yichen Jiang 136b67cdec fix(http-proxy): match the workspace version to the 0.1.2-rc.1 release 2026-09-03 13:50:13 +08:00
Yichen Jiang 34b33c2273 docs(user): simplify discovery, protocol, and OAuth wording in the provider guide 2026-09-03 13:31:47 +08:00
Yichen Jiang 7169660d33 Merge pull request #3198 from deepseek-harness/worktree/deepseek-harness-proxy-config-2f5b4a
feat(net): route every outbound request through the configured proxy
2026-09-03 13:19:19 +08:00
Tianyi Cui 7299d5e093 Merge commit '060de4c547f020e1953c88b98b7ab749fd9ef20f' into worktree/session-format-06-v2-snapshot-rollout 2026-09-03 13:18:35 +08:00
Tianyi Cui 4fb03d0bda fix(session): preserve v2 migration compatibility 2026-09-03 13:17:48 +08:00
Tianyi Cui d7e9115df9 Merge commit 'a4a34b2a71f6bdb92c723840dca4d7fbd71c3148' into worktree/session-format-05-v1-v2-chunk-migration 2026-09-03 13:09:15 +08:00
Tianyi Cui 401216477c Merge commit 'e6b9e90886088ffd344110d45bb406880e554a8a' into worktree/session-format-04-live-assistant-stream 2026-09-03 13:08:25 +08:00
Tianyi Cui 618a7a46e8 fix(session): accept released migration data 2026-09-03 13:07:26 +08:00
Chinesezjc ecd75e6dd6 test(pwsh): clarify deadline comment and keep case budget independent
Review feedback: the dsh-terminal-bash product default is 30s, not 300s;
the raised value bounds one send plus the complete startup sequence, so it
must cover the same cold start the tool deadline does. The vitest case
budget stays at its pre-existing 120s: the case-level timeout overrides
the lane --testTimeout, so syncing it to the plugin deadline would make a
stalled partition wait 300s instead of 120s.
2026-09-03 13:05:22 +08:00
Yichen Jiang 13f467b15b docs(user): gather settings.yaml fields under advanced configuration ahead of troubleshooting 2026-09-03 12:36:39 +08:00
Yichen Jiang be1777084d docs(user): call catalog providers built-in providers in the provider guide 2026-09-03 12:33:24 +08:00
Yichen Jiang 254c9b8b7f docs(user): guide model discovery for third-party providers 2026-09-03 11:51:46 +08:00
Yichen Jiang 410e912893 Merge remote-tracking branch 'origin/master' into worktree/lark-docs-optimization-c86275 2026-09-03 11:49:24 +08:00
Yichen Jiang 454d92dc34 docs(user): route reasoning levels and settings.yaml fields through the provider guide 2026-09-03 11:49:22 +08:00
imccyu 48d84990a0 Merge pull request #3471 from deepseek-harness/release/dsh-0.1.2-rc.1-version-to-master
sync: merge 0.1.2-rc.1 to master
2026-09-03 11:21:56 +08:00
fz 7dcdcc2965 feat(python): support macOS x64 runtime wheels 2026-09-03 11:16:34 +08:00
Yichen Jiang 427d210b5e Merge pull request #3451 from deepseek-harness/worktree/fix-model-listing-discovery
fix(llm): expand model listing discovery
2026-09-03 11:12:32 +08:00
Yichen Jiang 777d46cf81 Merge branch 'master' into worktree/fix-model-listing-discovery 2026-09-03 10:59:28 +08:00
imccyu 3225ec2d94 Merge remote-tracking branch 'origin/master' into release/dsh-0.1.2-rc.1-version-to-master 2026-09-03 10:40:28 +08:00
imccyu cea08311a0 Merge pull request #3470 from deepseek-harness/release/dsh-0.1.2-rc.1
release: dsh@0.1.2-rc.1
2026-09-03 10:35:15 +08:00
Tianyi Cui 06b93748eb fix(snapshot): address v2 rollout review 2026-09-03 05:15:04 +08:00
Tianyi Cui e10f671167 Merge remote-tracking branch 'origin/worktree/session-format-05-v1-v2-chunk-migration' into worktree/session-format-06-v2-snapshot-rollout
# Conflicts:
#	apps/web/tests/scaffold.ts
#	apps/web/tests/subagent-conversation.e2e.ts
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.zh.md
#	packages/test-support/llm-replay/README.i18n.yaml
#	packages/test-support/llm-replay/README.md
#	packages/test-support/llm-replay/README.zh.md
#	packages/test-support/llm-replay/tests/llm-replay.spec.ts
#	snapshots/sdk/subagent-continuable-inheritance/session.1.v2.jsonl
#	snapshots/sdk/subagent-continuable/session.1.v2.jsonl
#	snapshots/sdk/subagent-dsh-sdk-diagnostic/session.v2.jsonl
#	snapshots/sdk/subagent-list-agents/session.1.v2.jsonl
#	snapshots/sdk/subagent-send-message/session.1.v2.jsonl
#	snapshots/session/agent-instructions/session.v2.jsonl
#	snapshots/session/headless.snapshot.ts
#	snapshots/web/schedule-catalog/session.v2.jsonl
2026-09-03 05:09:32 +08:00
Tianyi Cui 67c8a58854 fix(session): address v2 migration review 2026-09-03 04:45:41 +08:00
Tianyi Cui a88ef734cc Merge remote-tracking branch 'origin/worktree/session-format-04-live-assistant-stream' into worktree/session-format-05-v1-v2-chunk-migration
# Conflicts:
#	.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml
#	.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md
#	.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md
#	.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml
#	.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md
#	.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md
#	apps/web/tests/scaffold.ts
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	packages/api/session-controller/README.i18n.yaml
#	packages/api/session-controller/README.md
#	packages/api/session-controller/README.zh.md
#	packages/api/session-controller/src/client/sessions/assistant-stream.ts
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent-loop/src/assistant-stream.ts
#	packages/core/agent-loop/tests/loop.spec.ts
#	packages/core/agent/src/runtime-types.ts
#	packages/session/session-telemetry/tests/telemetry.spec.ts
#	packages/test-support/llm-replay/README.i18n.yaml
#	packages/test-support/llm-replay/README.md
#	packages/test-support/llm-replay/README.zh.md
#	packages/test-support/llm-replay/src/alpha-refusal-fixtures.ts
#	packages/test-support/llm-replay/src/index.ts
#	packages/test-support/llm-replay/tests/llm-replay.spec.ts
#	packages/test-support/session-snapshot/README.i18n.yaml
#	packages/test-support/session-snapshot/README.md
#	packages/test-support/session-snapshot/README.zh.md
#	packages/test-support/session-snapshot/src/suite.ts
#	packages/test-support/session-snapshot/tests/suite.spec.ts
#	snapshots/sdk/sdk.snapshot.ts
#	snapshots/session/headless.snapshot.ts
2026-09-03 03:10:05 +08:00
imccyu a66e470204 release(dsh): 0.1.2-rc.1 2026-09-03 02:27:19 +08:00
Tianyi Cui a65edc4810 fix(agent): terminate live assistant attempts 2026-09-03 02:25:25 +08:00
Tianyi Cui a5c24f204d Merge remote-tracking branch 'origin/worktree/session-format-03-v0-v1-migration' into worktree/session-format-04-live-assistant-stream 2026-09-03 02:02:47 +08:00
Tianyi Cui 731f1268d1 docs(snapshot): correct child replay ordering 2026-09-03 02:00:11 +08:00
Tianyi Cui f044d3fad2 fix(session): address v0 migration review 2026-09-03 01:52:25 +08:00
Turtle 0e6a397464 Merge pull request #3444 from deepseek-harness/turtle/migrate-issue-fields-to-project
fix(issue-management): use Project-local Priority
2026-09-03 00:58:00 +08:00
Turtle 692b9b59f7 fix(issue-management): complete policy token guards 2026-09-03 00:50:13 +08:00
Tianyi Cui a8e33b62e3 Merge pull request #3434 from deepseek-harness/worktree-pkgnode
fix: Stop the packaged Python SDK runtime from hijacking spawned node commands
2026-09-02 22:47:27 +08:00
Tianyi Cui c2fa93f567 Merge remote-tracking branch 'origin/worktree/session-format-05-v1-v2-chunk-migration' into worktree/session-format-06-v2-snapshot-rollout
# Conflicts:
#	apps/web/tests/scaffold.ts
2026-09-02 22:21:50 +08:00
Tianyi Cui ac8b58241d Merge remote-tracking branch 'origin/worktree/session-format-04-live-assistant-stream' into worktree/session-format-05-v1-v2-chunk-migration
# Conflicts:
#	apps/web/tests/scaffold.ts
2026-09-02 22:19:50 +08:00
Tianyi Cui 08ef483c7a Merge remote-tracking branch 'origin/worktree/session-format-03-v0-v1-migration' into worktree/session-format-04-live-assistant-stream 2026-09-02 22:17:47 +08:00
Tianyi Cui 99567996bd test(session): cover body-free listing races 2026-09-02 22:16:06 +08:00
Tianyi Cui c0d05349e9 Merge remote-tracking branch 'origin/worktree/session-format-05-v1-v2-chunk-migration' into worktree/session-format-06-v2-snapshot-rollout 2026-09-02 21:59:19 +08:00
Yichen Jiang 8de7518c89 test(llm): replay Anthropic's documented model-listing example
The List Models reference reply joins the recorded listings so the
anthropic-messages path is pinned to the published response fields.
2026-09-02 21:58:58 +08:00
Yichen Jiang 31065cf1ab test(llm): archive recorded provider model listings
Replies captured on 2026-09-02 from OpenRouter, models.dev, and DeepSeek
replay through the discovery parser, and OpenRouter's nested
top_provider.max_completion_tokens now feeds the output-token cap.
2026-09-02 21:55:06 +08:00
Tianyi Cui 2087fd3f2c Merge remote-tracking branch 'origin/worktree/session-format-04-live-assistant-stream' into worktree/session-format-05-v1-v2-chunk-migration 2026-09-02 21:52:34 +08:00
Tianyi Cui e3e9bd5909 Merge remote-tracking branch 'origin/worktree/session-format-03-v0-v1-migration' into worktree/session-format-04-live-assistant-stream
# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/core/agent/src/runtime-types.ts
2026-09-02 21:51:42 +08:00
Tianyi Cui fe06dccea4 Merge remote-tracking branch 'origin/master' into worktree/session-format-03-v0-v1-migration
# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/persistence-catalog.i18n.yaml
#	docs/persistence-catalog.md
#	packages/api/session-controller/README.i18n.yaml
#	packages/api/session-controller/README.md
#	packages/api/session-controller/README.zh.md
2026-09-02 21:40:29 +08:00
Yichen Jiang 8179d929ab fix(llm): scope Anthropic /v1 handling to model discovery
Model requests hand the configured baseURL to pi-ai unchanged. Only the
discovery listing URL accepts the Anthropic API root with or without a
trailing /v1, because gateway documentation publishes both spellings.
2026-09-02 21:39:53 +08:00
Yichen Jiang b6427a899e Merge remote-tracking branch 'origin/master' into worktree/deepseek-harness-proxy-config-2f5b4a 2026-09-02 21:38:19 +08:00
imccyu 45a4868c88 test(python): pin packaged shell node resolution in the runtime smoke
The new keyless sdk-spawn-node scenario drives the platform shell tool
through a command starting with node and requires the machine's own Node
version in the tool result with no PKG_EXECPATH in the child environment,
so a pkg upgrade that re-records the child-process patch cannot silently
restore the hijack. Runs on every target through --scenario all.
2026-09-02 21:36:23 +08:00
imccyu 7187cddfb3 docs(notes): record the pkg child_process patch in the single-exe note 2026-09-02 21:36:23 +08:00
imccyu c732dedc69 fix(python): stop the packaged runtime from hijacking spawned node commands
@yao-pkg/pkg's SEA bootstrap rewrites child_process commands named node --
including the string after a -c flag, exactly the Bash tool's bash -c form --
to the executable itself and stamps PKG_EXECPATH into every child
environment, so a model-issued 'node --version' silently booted the dsh CLI
instead of the machine's Node. Pin the packager as an exact root
devDependency invoked through pnpm exec and patch out the single
patchChildProcess call from the SEA bootstrap bundle; packaged children now
resolve node through PATH like any other process. The third-party notices
drop the build-time tools section: the packager is now a declared, patched
devDependency, so the manifest and patch tiers disclose it.
2026-09-02 21:36:23 +08:00
Tianyi Cui 17f49f6191 fix(session): keep cold listing body-free 2026-09-02 21:29:43 +08:00
Tianyi Cui 9d3bbd46ee Merge pull request #3459 from deepseek-harness/revert-3452-feat/session-message-edit
Revert "feat(session, agent, web): support same-session message editing"
2026-09-02 21:21:14 +08:00
Tianyi Cui e974a655a0 Revert "feat(session, agent, web): support same-session message editing" 2026-09-02 21:20:07 +08:00
Yichen Jiang 6f7e30ed3b chore(http-proxy): follow the 0.1.2-alpha.5 release
master released 0.1.2-alpha.5 after this package was created, so the
release bump never reached it; the workspace constraints gate requires
every package version to match the root.
2026-09-02 21:19:00 +08:00
Yichen Jiang 5eb499ed35 Merge remote-tracking branch 'origin/master' into worktree/deepseek-harness-proxy-config-2f5b4a 2026-09-02 21:14:16 +08:00
Tianyi Cui d88c258dc7 Merge remote-tracking branch 'origin/worktree/session-format-03-v0-v1-migration' into worktree/session-format-04-live-assistant-stream
# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/core/agent/src/runtime-types.ts
2026-09-02 20:58:27 +08:00
Tianyi Cui 5a9353adda Merge remote-tracking branch 'origin/master' into worktree/session-format-03-v0-v1-migration
# Conflicts:
#	docs/persistence-catalog.i18n.yaml
#	docs/persistence-catalog.md
2026-09-02 20:48:26 +08:00
lsdsjy f98bfbda85 Merge pull request #3452 from deepseek-harness/feat/session-message-edit
feat(session, agent, web): support same-session message editing
2026-09-02 20:41:36 +08:00
Tianyi Cui 06f29df392 Merge remote-tracking branch 'origin/worktree/session-format-05-v1-v2-chunk-migration' into worktree/session-format-06-v2-snapshot-rollout 2026-09-02 20:10:00 +08:00
Tianyi Cui 3ed91ab9d8 Merge remote-tracking branch 'origin/worktree/session-format-04-live-assistant-stream' into worktree/session-format-05-v1-v2-chunk-migration 2026-09-02 20:09:13 +08:00
Tianyi Cui cd708659b5 Merge remote-tracking branch 'origin/worktree/session-format-03-v0-v1-migration' into worktree/session-format-04-live-assistant-stream 2026-09-02 20:08:34 +08:00
Tianyi Cui 086b52f986 test(session): cover generation scan failures 2026-09-02 20:07:48 +08:00
Tianyi Cui 4dbd107568 Merge remote-tracking branch 'origin/worktree/session-format-05-v1-v2-chunk-migration' into worktree/session-format-06-v2-snapshot-rollout 2026-09-02 19:41:24 +08:00
Tianyi Cui 58f5e0b278 Merge remote-tracking branch 'origin/worktree/session-format-04-live-assistant-stream' into worktree/session-format-05-v1-v2-chunk-migration 2026-09-02 19:40:50 +08:00
Tianyi Cui fe9d8b552c Merge remote-tracking branch 'origin/worktree/session-format-03-v0-v1-migration' into worktree/session-format-04-live-assistant-stream 2026-09-02 19:40:13 +08:00
Tianyi Cui 17f784c936 test(subagent): scope SIGKILL grace proof to POSIX 2026-09-02 19:34:52 +08:00
Tianyi Cui 68e24ddef9 fix(test): keep v2 fixture parsing content-only 2026-09-02 19:24:32 +08:00
Tianyi Cui 4a80818ddc Merge embedded stream migration into v2 snapshot rollout 2026-09-02 19:23:45 +08:00
Tianyi Cui af68f9e10c fix(test): qualify selected replay fixture 2026-09-02 19:22:57 +08:00
Tianyi Cui bc0e57a480 Merge live assistant stream into embedded stream migration
# Conflicts:
#	apps/web/tests/scaffold.ts
2026-09-02 19:21:52 +08:00
Tianyi Cui de3ee872d6 Merge session format migration into live assistant stream 2026-09-02 19:18:16 +08:00
Tianyi Cui aa97372fe4 fix(test): repair alpha fixture and Windows gates 2026-09-02 19:17:32 +08:00
Tianyi Cui 9e371c220d Merge embedded stream migration into v2 snapshot rollout 2026-09-02 19:05:54 +08:00
Tianyi Cui 23b9dc9175 Merge live assistant stream into embedded stream migration
# Conflicts:
#	apps/web/tests/scaffold.ts
#	packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts
2026-09-02 19:05:23 +08:00
Tianyi Cui 42542e9a3d Merge session format migration into live assistant stream 2026-09-02 19:02:32 +08:00
Tianyi Cui 0850261706 fix(test): align current session fixture headers 2026-09-02 19:01:41 +08:00
Tianyi Cui b5b4ef74a0 Merge embedded stream migration into v2 snapshot rollout 2026-09-02 18:50:56 +08:00
Tianyi Cui df7d1b6ee2 chore(release): align v2 format package with alpha.5 2026-09-02 18:50:24 +08:00
Tianyi Cui e12258e2b7 Merge live assistant stream into embedded stream migration 2026-09-02 18:49:39 +08:00
Tianyi Cui d8cc3e035e Merge session format migration into live assistant stream 2026-09-02 18:48:50 +08:00
Tianyi Cui e0604df362 chore(release): align session format packages with alpha.5 2026-09-02 18:47:59 +08:00
Tianyi Cui 2ca559dee3 Merge master into session format migration 2026-09-02 18:46:49 +08:00
Tianyi Cui 8de41bbe99 Merge embedded stream migration into v2 snapshot rollout
# Conflicts:
#	docs/testing.i18n.yaml
#	docs/testing.md
#	docs/testing.zh.md
#	packages/test-support/session-snapshot/README.i18n.yaml
#	packages/test-support/session-snapshot/README.md
#	packages/test-support/session-snapshot/README.zh.md
#	snapshots/session/read-image-attachment-path/session.v2.jsonl
#	snapshots/session/read-image-dimension/session.v2.jsonl
#	snapshots/session/read-image-reencode/session.v2.jsonl
#	snapshots/session/read-image/session.v2.jsonl
#	snapshots/web/navigation-panes/session.jsonl
#	snapshots/web/ptc-round/session.v2.jsonl
#	snapshots/web/pwsh-terminal/session.jsonl
#	snapshots/web/seeded-history/session.jsonl
2026-09-02 18:45:14 +08:00
lsdsjy ef88756f13 feat(session, agent, web): support same-session message editing 2026-09-02 18:26:55 +08:00
Tianyi Cui c024c53d34 Merge pull request #3458 from deepseek-harness/xtr/record-browser-gif-gh-attach
docs(record-browser-gif): publish PR GIFs with gh --attach
2026-09-02 18:24:51 +08:00
Yichen Jiang ccecf4db04 fix(llm): address model discovery review 2026-09-02 18:00:43 +08:00
Tianyi Cui 2f091ee8e6 docs(record-browser-gif): publish PR GIFs with gh --attach
Prefer gh v2.99.0's repeatable --attach flag for publishing demonstration
GIFs: it uploads the artifact and rewrites the body's local-path
reference in place, keeping media out of git history without an orphan
assets branch. The assets-branch workflow remains the fallback when the
GIF exceeds 10 MB, gh is older than v2.99.0, or the repository is not on
github.com.
2026-09-02 17:58:36 +08:00
Tianyi Cui 042bd8d01a Merge live assistant stream into embedded stream migration
# Conflicts:
#	packages/session/session-projection-cache/src/spec.ts
2026-09-02 17:52:32 +08:00
imccyu 49a606bc5b Merge pull request #3456 from deepseek-harness/release/dsh-0.1.2-alpha.5-version-to-master
sync: merge 0.1.2-alpha.5 to master
2026-09-02 17:47:12 +08:00
Tianyi Cui aa2616c570 Merge session format migration into live assistant stream 2026-09-02 17:45:59 +08:00
Tianyi Cui 3bdc1b37e5 Merge master into session format migration 2026-09-02 17:43:06 +08:00
Tianyi Cui cf9948b147 Merge master into session format migration
# Conflicts:
#	packages/session/session-projection-cache/tests/cache.spec.ts
2026-09-02 17:41:58 +08:00
creatixchu a1956fa3e2 Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload 2026-09-02 17:39:48 +08:00
Dudu-0223 f7cee2c888 Merge pull request #3333 from deepseek-harness/feat/3330-team-send-message-steer
feat(agent-team): unify messages on steer
2026-09-02 17:27:55 +08:00
imccyu cf126d8699 Merge remote-tracking branch 'origin/merge/projcache-v6-compat-into-master' into release/dsh-0.1.2-alpha.5-version-to-master 2026-09-02 17:25:23 +08:00
Tianyi Cui 25098647e8 Merge live assistant stream into embedded stream migration
# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
#	.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
#	.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml
#	.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md
#	.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md
#	.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml
#	.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
#	.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md
#	apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
#	apps/web/tests/message-actions.e2e.ts
#	apps/web/tests/scaffold-generation.spec.ts
#	apps/web/tests/scaffold.ts
#	apps/web/tests/seeded-history.e2e.ts
#	apps/web/tests/subagent-conversation.e2e.ts
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/persistence-catalog.i18n.yaml
#	docs/persistence-catalog.md
#	docs/persistence-catalog.zh.md
#	docs/subsystems/persistence.i18n.yaml
#	docs/subsystems/persistence.md
#	docs/subsystems/persistence.zh.md
#	docs/subsystems/session.i18n.yaml
#	docs/subsystems/session.md
#	docs/subsystems/session.zh.md
#	packages/api/session-controller/tests/session-projections.host.spec.ts
#	packages/core/agent-loop/tests/resume.spec.ts
#	packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
#	packages/session-query/session-query/tests/tracing.spec.ts
#	packages/session/session-persistence-jsonl/README.i18n.yaml
#	packages/session/session-persistence-jsonl/README.md
#	packages/session/session-persistence-jsonl/README.zh.md
#	packages/session/session-persistence-jsonl/src/index.ts
#	packages/session/session-persistence-jsonl/tests/jsonl.spec.ts
#	packages/session/session-persistence-jsonl/tests/zstd.spec.ts
#	packages/session/session-persistence/tests/contract.ts
#	packages/session/session-persistence/tests/coordinator-contract.ts
#	packages/session/session-persistence/tests/persistence.spec.ts
#	packages/subagent/subagent/src/continuation.ts
#	packages/subagent/subagent/tests/list-children.spec.ts
2026-09-02 17:20:51 +08:00
imccyu 0a1efddd40 Merge pull request #3455 from deepseek-harness/merge/projcache-v6-compat-into-master
sync: merge projection cache fix from 0.1.2-alpha.5 to master
2026-09-02 17:18:44 +08:00
Dudu-0223 eeddd457cd fix(agent-team): preserve mailbox order on cold resume 2026-09-02 17:14:44 +08:00
Dudu-0223 1180707084 Merge remote-tracking branch 'origin/master' into feat/3330-team-send-message-steer
# Conflicts:
#	packages/experimental/agent-team/tests/team.spec.ts
2026-09-02 16:58:09 +08:00
Yichen Jiang bfdebf1d22 test(llm): expect discovery name fallback 2026-09-02 16:56:06 +08:00
imccyu c917fe6d46 Merge remote-tracking branch 'origin/master' into merge/projcache-v6-compat-into-master
# Conflicts:
#	packages/session/session-projection-cache/src/spec.ts
#	packages/storage/storage-json/src/per-record-unit.ts
#	packages/storage/storage-json/tests/json-backend.spec.ts
2026-09-02 16:55:00 +08:00
Yichen Jiang a6ed19b541 fix(llm): expand model listing discovery 2026-09-02 16:25:20 +08:00
Yichen Jiang 51c65c790d Merge remote-tracking branch 'origin/master' into worktree/deepseek-harness-proxy-config-2f5b4a 2026-09-02 16:11:28 +08:00
Turtle 9ea2329084 fix(issue-management): grant policy Project read access 2026-09-02 16:07:02 +08:00
imccyu 5a69ba1cdd Merge pull request #3445 from deepseek-harness/release/dsh-0.1.2-alpha.5
release: dsh@0.1.2-alpha.5
2026-09-02 15:57:43 +08:00
imccyu db6bdc3576 release(dsh): 0.1.2-alpha.5 2026-09-02 15:48:33 +08:00
imccyu 1915665e1e Merge pull request #3438 from deepseek-harness/fix/projcache-cross-version-read-compat
fix(session-projection-cache): survive upgrades across projcache domain versions
2026-09-02 15:47:14 +08:00
Turtle 39b151bda3 fix(issue-management): use Project-local Priority 2026-09-02 15:43:30 +08:00
imccyu db2dd2f840 docs(session-projection-cache): land the read-compat note as implemented and state fixture provenance in place
Review follow-ups: the Agent Note triplet moves to implemented/ rewritten as
shipped state (Decision/Consequences/Testing, present tense), cross-linked
both ways with the 2026-07-28 storage recovery proposal whose projcache
reset/destroy path it supersedes (that proposal stays live for authoritative
and whole-medium damage). The fixtures spec header and the note state the
fixture provenance as recorded facts of the released builds instead of
citing local tooling, and the spec JSDoc points at the note's final home.
2026-09-02 15:25:07 +08:00
imccyu bef26396e5 docs(session-projection-cache): cross-version read-compat note and schema-change fixture rule
The proposed Agent Note records the three shipped on-disk generations of
session_projcache, the read-compat and backup-and-skip decisions, the
upgrade matrix, and the rejected alternatives. The package README documents
the upgrade guarantees and requires every future schema or domain-version
change to land with archived fixtures and tests proving its upgrade story.
The storage subsystem page and the generated cordis catalog pick up the new
DomainSpec fields.
2026-09-02 15:25:07 +08:00
imccyu 49df707c86 fix(session-projection-cache): keep upgraded caches readable and boots safe across domain versions
The session_projcache domain declares compatibleVersions: [3, 4] and
invalidRecords: 'backup-and-skip'. The two lineage identity fields become
optional — records admitted from older versions predate them, and the single
reader (identityMatches) interprets absence as the unseeded lineage: exact
for unseeded sessions, while a seeded caller fails the match and refolds
cold, so the lineage binding keeps its protection. Upgraded homes therefore
boot and serve their cached listing titles immediately, including homes
whose new tree already holds current-stamped documents without lineage
fields, and a record failing validation anyway is backed up and skipped
instead of refusing the plugin tree.

tests/fixtures/ archives the real on-disk media of every shipped generation
(v3 whole-unit file, v4 and v5 per-record documents, and the lineage-less
current-stamped shape); fixtures.spec.ts proves each recovers through the
real storage stack, rewrites to the current format on the next live write,
and that a hopeless record is salvaged without costing the boot.
2026-09-02 15:25:07 +08:00
imccyu fcd109d29a feat(storage): version read compatibility and backup-and-skip salvage for per-record units
A DomainSpec may declare compatibleVersions: older domain versions whose
stored records the current record schemas still accept. The json backend's
per-record reads admit documents stamped with a declared version (writes
always stamp the current one), and the legacy whole-unit bootstrap migrates
only a file whose stored version is in the accepted set — previously it
migrated any version and stamped the records current, turning a discardable
stale cache into invalid-record failures that refused the whole domain at
open and permanently poisoned the new tree on first boot.

A DomainSpec may also declare invalidRecords: 'backup-and-skip' for domains
whose records are disposable derived data: a stored record failing its zod
schema is moved aside through the new optional KvUnit.backupRecord
(<key>.json.bak.<YYYYMMDDHHmm> under the json backend), logged with its
cause, and skipped, instead of rejecting the open. The default stays
fail-loud, and so do backends without backupRecord.
2026-09-02 15:25:07 +08:00
creatixchu 80b376f0ff Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload 2026-09-02 15:24:07 +08:00
Turtle 66ca93c3dc Merge pull request #3441 from deepseek-harness/turtle/fix-project-date-fields
fix(issue-management): restore Project date fields
2026-09-02 15:20:46 +08:00
Turtle 070b46e1ef fix(issue-management): restore Project date fields 2026-09-02 15:06:25 +08:00
creatixchu 8d1289420b Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload
# Conflicts:
#	packages/client/ui-attachment/README.i18n.yaml
#	packages/client/ui-attachment/README.md
#	packages/client/ui-attachment/README.zh.md
2026-09-02 14:05:03 +08:00
Tianyi Cui 9007b56360 Merge session format migration lint fix 2026-09-02 13:54:42 +08:00
Tianyi Cui 2372f84206 Merge session format migration into live assistant stream
# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
2026-09-02 13:54:23 +08:00
Tianyi Cui 2b529bc2aa test(session-persistence-jsonl): keep migration assertions typed 2026-09-02 13:50:00 +08:00
Tianyi Cui 91e5f8838e Merge master into session format migration 2026-09-02 13:42:50 +08:00
Turtle be70505f9e Merge pull request #3417 from deepseek-harness/turtle/fix-issue-field-start-date
fix(issue-management): write Start date through Issue fields
2026-09-02 13:41:29 +08:00
Tianyi Cui 723980ac78 Merge master into session format migration
# Conflicts:
#	packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts
2026-09-02 13:41:24 +08:00
Yichen Jiang bbae7318f0 Merge pull request #3424 from deepseek-harness/worktree/github-issue-725-verification-7e80bd
fix(llm): keep streamed tool-call identity across empty deltas
2026-09-02 13:35:55 +08:00
Tianyi Cui 7145acfe07 Merge master into session format migration
# Conflicts:
#	.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
#	.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
#	.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md
#	.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md
#	.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml
#	.agents/notes/implemented/architecture/2026-08-05-session-preparation.md
#	.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md
#	.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml
#	.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md
#	.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md
#	.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml
#	.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md
#	.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md
#	.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md
#	.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md
#	.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml
#	.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml
#	.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
#	.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md
#	apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
#	apps/web/tests/cold-blank-session.e2e.ts
#	apps/web/tests/message-actions.e2e.ts
#	apps/web/tests/scaffold.ts
#	apps/web/tests/workspace-management.e2e.ts
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/persistence-catalog.i18n.yaml
#	docs/persistence-catalog.md
#	docs/subsystems/feedback.i18n.yaml
#	docs/subsystems/feedback.md
#	docs/subsystems/feedback.zh.md
#	docs/subsystems/persistence.i18n.yaml
#	docs/subsystems/persistence.md
#	docs/subsystems/persistence.zh.md
#	packages/acp/acp/src/index.ts
#	packages/acp/acp/tests/bridge.spec.ts
#	packages/api/session-controller/src/list.ts
#	packages/api/session-controller/tests/agent.host.spec.ts
#	packages/api/session-controller/tests/session-cold.host.spec.ts
#	packages/api/session-controller/tests/session-projections.host.spec.ts
#	packages/api/session-controller/tests/session-search.host.spec.ts
#	packages/api/session-controller/tests/test-remote.ts
#	packages/api/session-controller/tests/transport.host.spec.ts
#	packages/core/agent-loop/src/index.ts
#	packages/core/agent-loop/tests/config-session-id.spec.ts
#	packages/core/agent-loop/tests/resume.spec.ts
#	packages/extensions/tool-cordis/src/api-catalog.ts
#	packages/feedback/message-feedback/src/index.ts
#	packages/feedback/message-feedback/tests/helpers.ts
#	packages/feedback/message-feedback/tests/message-feedback.spec.ts
#	packages/session-query/session-log-export/src/archive.ts
#	packages/session-query/session-log-export/tests/archive.host.spec.ts
#	packages/session-query/session-log-export/tests/route.host.spec.ts
#	packages/session-query/session-query-sqlite/README.i18n.yaml
#	packages/session-query/session-query-sqlite/README.md
#	packages/session-query/session-query-sqlite/README.zh.md
#	packages/session-query/session-query-sqlite/src/index.ts
#	packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
#	packages/session-query/session-query/README.i18n.yaml
#	packages/session-query/session-query/README.md
#	packages/session-query/session-query/README.zh.md
#	packages/session-query/session-query/src/corpus.ts
#	packages/session-query/session-query/tests/observation.spec.ts
#	packages/session-query/session-query/tests/session-query.spec.ts
#	packages/session-query/session-query/tests/tracing.spec.ts
#	packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts
#	packages/session/session-persistence-jsonl/README.i18n.yaml
#	packages/session/session-persistence-jsonl/README.md
#	packages/session/session-persistence-jsonl/README.zh.md
#	packages/session/session-persistence-jsonl/src/index.ts
#	packages/session/session-persistence-jsonl/tests/jsonl.spec.ts
#	packages/session/session-persistence-jsonl/tests/zstd.spec.ts
#	packages/session/session-persistence/README.i18n.yaml
#	packages/session/session-persistence/README.md
#	packages/session/session-persistence/README.zh.md
#	packages/session/session-persistence/src/coordinator.ts
#	packages/session/session-persistence/src/index.ts
#	packages/session/session-persistence/tests/contract.ts
#	packages/session/session-persistence/tests/coordinator-contract.ts
#	packages/session/session-persistence/tests/persistence.spec.ts
#	packages/session/session-telemetry/package.json
#	packages/shell/tool-bash/tests/tools.spec.ts
#	packages/subagent/subagent/src/continuation.ts
#	packages/subagent/subagent/tests/list-children.spec.ts
#	packages/workspace/workspace/src/index.ts
#	packages/workspace/workspace/tests/workspace.spec.ts
#	scripts/gen-cordis-catalog.ts
2026-09-02 13:34:40 +08:00
Chinesezjc a631115597 Merge pull request #2828 from deepseek-harness/feat/toolcard-image-result
feat(ui-tool): render read_image results as the image
2026-09-02 12:45:39 +08:00
Magolor d921d4b357 fix(storage-json): reject cross-version legacy bootstrap (#3431)
* fix(storage-json): reject cross-version legacy bootstrap

* test(webworker): sync projection cache fixture version

* docs(storage): record legacy bootstrap version ownership
2026-09-02 04:37:15 +00:00
Chinesezjc 3648331b11 Merge remote-tracking branch 'origin/master' into feat/toolcard-image-result
# Conflicts:
#	packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
#	packages/client/ui-chat/src/client/chat/ChatView.tsx
#	packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
2026-09-02 12:02:25 +08:00
Chinesezjc e4c822544c test(pwsh): restore product-default deadline in loader composition
The first call pays the full pwsh cold-start latency (spawn + .NET +
PSReadLine + Defender) inside the tool deadline. A 60s bound on the fully
loaded self-hosted Windows pool is exceeded often enough to reset the
session mid-test: two master CI runs (2026-09-01, runs 33524764567 and
33534262413) each failed at ~62s with the second call observing a fresh
session (cwd back at root, env empty). 300s matches the product default
so cold start no longer races the budget.
2026-09-02 11:50:18 +08:00
Xu Hanxiang d3ab4ce53d Merge pull request #3401 from deepseek-harness/issue-1424-goal-pause-stop-turn
fix(goal): abort the live turn on host-initiated pause
2026-09-02 11:32:12 +08:00
Turtle d76c974fbb Merge branch 'master' into turtle/fix-issue-field-start-date 2026-09-02 11:13:10 +08:00
creatixchu 2217ba42d7 fix(ui): align attachment cards with theme constraints 2026-09-02 11:01:45 +08:00
Yichen Jiang 6d261ecdee test(app-boot): assert the home .env proxy contract without pinning a casing
Windows folds `https_proxy` and `HTTPS_PROXY` into one variable, so the
exported spelling shadowed the file's lowercase one and the case failed
there. Each spelling now gets its own name: the file supplies a name the
shell did not export, and the shell outranks the file for the one it did.
2026-09-02 10:51:20 +08:00
Yichen Jiang 1fc6016f85 chore(http-proxy): match the workspace version bumped on master
The 0.1.2-alpha.4 release on master touched every manifest that existed
there; this package did not, so the merge left it at alpha.3 and
`check-workspace-constraints` refused the tree.
2026-09-02 10:36:10 +08:00
Yichen Jiang b03261caad fix(llm): narrow the fix to identity acceptance
Refusing a response whose tool call never receives an identity needed a new
failure code, a change to the default retryable set, and a `[DONE]` gate that
overrode the finish reason a provider had already sent — turning a safe
`max-tokens` truncation into up to five retries. The lenient wire it guarded
against is hypothetical: no report describes a stream that omits identity
entirely, and the pre-existing test for it is labelled as such.

Only `acceptIdentity` and the widened wire types remain. They close the
reported erasure and cannot reach a worse outcome than the previous
assignment, because the set of inputs that assign only narrows.
2026-09-02 10:34:09 +08:00
creatixchu 3b9ac476fb Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload
# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	docs/persistence-catalog.i18n.yaml
#	docs/persistence-catalog.md
#	docs/persistence-catalog.zh.md
#	packages/api/session-controller/src/commands.ts
#	packages/api/session-controller/src/index.ts
#	packages/api/session-controller/tests/client-contract.client.spec.ts
#	packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
#	packages/client/ui-attachment/src/AttachmentRail.module.css
#	packages/client/ui-chat/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-trajectory/README.i18n.yaml
#	packages/client/ui-trajectory/README.md
#	packages/client/ui-trajectory/README.zh.md
#	packages/extensions/cordis-client-runner/src/client/api-catalog.ts
#	packages/llm/token-meter/src/surface-fold.ts
#	packages/session-query/session-log-export/src/archive.ts
#	packages/session-query/session-log-export/tests/archive.host.spec.ts
2026-09-02 10:29:53 +08:00
Yichen Jiang e7170b05a1 Merge remote-tracking branch 'origin/master' into worktree/deepseek-harness-proxy-config-2f5b4a 2026-09-02 10:28:17 +08:00
Yichen Jiang 93bba8ef67 fix(http-proxy): withhold NODE_USE_ENV_PROXY when the child receives a refused proxy value
`proxyEnvironmentForChild()` hands a child the proxy values the user
exported, including one this package refused — a SOCKS URL kept because
`curl` reads it — and sets `NODE_USE_ENV_PROXY=1` so a child Node honors
them. Node parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before
running the program and exits on any scheme other than `http:` or
`https:`. So a user with a usable `HTTP_PROXY` and `HTTPS_PROXY=socks4://…`
lost every Node child — stdio MCP servers, subagent CLIs, `npm` in the
bash tool — before its first line, while this process had reported only
that the scheme stayed direct. Measured on Node 24.17: `socks4://`,
`ftp://`, and a malformed value all exit 1; `socks5://` is accepted there
and only there.

The flag is now withheld whenever a value under the names Node parses is
one `isSupportedProxyUrl` refuses. Such a child connects directly, which
is what this process already said about that scheme, and `curl` still
reads the value it was kept for. Node does not read `ALL_PROXY`, so a
refused value there alone changes nothing.

The socks5 case in `install.spec.ts` now asserts the flag absent; the
ALL_PROXY fill case asserts it present; a new case spawns a real child
Node under the overlay for each refused shape and asserts it starts.
2026-09-02 10:27:36 +08:00
Yichen Jiang 6ecdc93901 fix(http-proxy): give children the user's environment under a layered direct policy
A direct policy installed over a proxied one swapped the global
dispatcher but left `process.env` holding the outer install's published
normalization. `proxyEnvironmentForChild()` returns nothing under a
direct policy, and `scrubbedParentEnv()` copies `process.env` as it is,
so a child spawned in that window inherited values no active policy
stood behind: an `HTTPS_PROXY` derived from `HTTP_PROXY` the user never
set, or the loss of a SOCKS value they set for `curl` and this package
had refused.

The direct branch now writes the user's own values — the record the
outermost install keeps — back into `process.env` for the window, and
re-applies the outer install's published values when it ends. An
install underneath that proxied nothing published nothing, so there is
nothing to put back. Publishing and restoring share one `writeProxyEnv`;
the empty-string special case it replaced was unreachable, since a
resolved bypass list always carries the loopback entries and an
accepted proxy URL is never empty.

The nesting is reachable only from tests since the plugin was removed;
the fix keeps the disposer symmetric for whoever layers installs next.
2026-09-02 10:19:23 +08:00
Yichen Jiang 83931a5f3e fix(llm): correct the refusal's recorded rationale
`LlmRuntime.adapterStream` normalizes a thrown `LlmError` into the same error
`finish` the loop routes to `agent/request-error`, so throwing would reach
retry too. The Note claimed otherwise. Yielding is chosen because it reports
the attempt's billed usage first and matches the neighbouring `EMPTY_RESPONSE`
refusal.

The rejection comment repeats the corrected durability wording, and the
assembler's delta-only fallback carries a TODO for the empty name it still
invents for adapters that never close a tool-call block.
2026-09-02 10:08:42 +08:00
Yichen Jiang c44dcb7b85 fix(app-boot): accept the proxy names from the Harness-home .env alone
The proxy guide told users a proxy could live in a project or
`$DSH_HOME` `.env`. It could not: `loadLayeredEnv` refuses the four
proxy names from any discovered file, as it refuses `PATH` and
`NODE_OPTIONS`, and the launch fails with a pointer to `export`.

That refusal is right for the invoking directory's file — it arrives
with a clone, and a repository must not choose where the harness sends
its traffic — and wrong for the user's own `$DSH_HOME/.env`, which
already holds their API key. `readEnvLayer` now accepts `HTTP_PROXY`,
`HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` from the directory that is
the Harness home, and nowhere else. `DSH_HOME` is itself bootstrap-only,
so no `.env` can relocate the exemption; the CA and TLS names in the
same group stay refused everywhere, since they change what is trusted
rather than where traffic goes. A project `.env` that sets a proxy name
still fails the launch, and its message now names the home file as the
second way out. Launching from inside the home directory reads that one
file as the project layer; the exemption follows the directory.

The seven existing refusal cases all write to the project layer and
pass unchanged. Four new cases cover the home layer accepting both
casings below an exported value, the home layer still refusing
`SSL_CERT_FILE`, the project layer's new message, and the same-directory
launch. The guide, both package READMEs, and the two Agent Notes that
stated the old rule now state this one.
2026-09-02 10:04:59 +08:00
Yichen Jiang e91c28d3fd fix(llm): cover the malformed tool-call path and correct its records
The `MALFORMED_TOOL_CALL` JSDoc claimed nothing durable is written, but the
loop appends an `assistant/chunk` for every yielded chunk; only the assistant
message and tool result are withheld. The bounded-recovery Note still listed
a five-code transient set, and neither Note linked the other.

A keyless `malformed-tool-call-retry` scenario now records the refusal, the
retry, and the absence of a `tool/call` for the failed attempt. A translator
case pins that an already closable block also withholds its `block-end`.
2026-09-02 10:01:56 +08:00
Yichen Jiang 2795940323 docs(http-proxy): state the shipped library, not the retired plugin
Review found the package's prose still describing a plugin that an
earlier revision removed, and three factual slips about behavior.

- policy.ts, install.ts, install.spec.ts, and the Agent Note named a
  `Config` surface, a `cordis.yml` source, a mountable plugin, and a
  `plugin.spec.ts` that no longer exist; each now describes the
  environment-only resolution the launcher actually runs.
- `NO_PROXY=example.com` bypasses `api.example.com` as well — the matcher
  accepts the host and every subdomain under it, and a leading `.` or
  `*.` means the same thing. The guide, README, and JSDoc claimed a bare
  entry matched only the exact host, which would let a reader believe a
  subdomain was proxied when it went direct.
- A rejection diagnostic names the variable and never its value, so no
  username is shown; the guide said the username was shown with the rest
  masked. The README's source map claimed a "redaction" step that does
  not exist.
- The `node:https` worker placeholder was added for a `node:http` agent
  factory this PR later removed; nothing imports `node:https` now, so the
  stub, its VFS mapping, and its test return to their state on master.
2026-09-02 09:43:47 +08:00
Yichen Jiang 96cbd8d9e6 fix(llm): refresh web and packed-fixture expectations for the new retry code
Adding `MALFORMED_TOOL_CALL` to the default retryable set changes two
expected outputs the session snapshot lane does not own: the shipped Web
composition's inline snapshot, and the canonical packed layout of the
refreshed `empty-response-retry` fixture.
2026-09-02 09:36:54 +08:00
Tianyi Cui 9424841d68 test(snapshot): retain failed reasoning projection 2026-09-02 04:46:35 +08:00
Tianyi Cui 343a20fb6f refactor(snapshot): remove dead fixture path plumbing 2026-09-02 04:46:02 +08:00
Tianyi Cui f693946509 test(snapshot): roll corpus forward to format v2 2026-09-02 04:18:52 +08:00
Tianyi Cui eb7c290b22 fix(session): retain reachable v2 stream settlements 2026-09-02 04:17:44 +08:00
Tianyi Cui 0670239db2 chore(deps): classify session stream helpers 2026-09-02 04:00:33 +08:00
Tianyi Cui f30896394c test(web): restore HMR build artifacts 2026-09-02 04:00:33 +08:00
Tianyi Cui 7e5b72ceee fix(session): harden embedded assistant streams 2026-09-02 04:00:33 +08:00
Tianyi Cui f99b06eaed feat(session)!: embed assistant streams in format v2 2026-09-02 04:00:01 +08:00
Tianyi Cui 0bb7bba015 fix(session): close live assistant framing races 2026-09-02 03:49:17 +08:00
Tianyi Cui 30e045dfad feat(agent): emit live assistant stream frames 2026-09-02 03:36:13 +08:00
Tianyi Cui eb56627f36 test(snapshot): retire vanished generation roles 2026-09-02 03:34:52 +08:00
Tianyi Cui 79f27df0dd fix(session): harden migration artifact ownership 2026-09-02 03:34:38 +08:00
Turtle 8b799cd7ac Merge pull request #3266 from deepseek-harness/refactor/session-persistence-handle-seam
refactor(session-persistence)!: handle-based seam with a lifecycle-owned write path
2026-09-02 00:50:53 +08:00
imccyu 4e84901e64 Merge pull request #3427 from deepseek-harness/release/dsh-0.1.2-alpha.4
release: dsh@0.1.2-alpha.4
2026-09-01 23:37:26 +08:00
imccyu a9e185f205 release(dsh): 0.1.2-alpha.4 2026-09-01 23:19:57 +08:00
Turtle bec6805d6a refactor(session-persistence)!: handle-based seam with a lifecycle-owned write path
The persistence seam is now create/open/stat/list returning per-session
SessionHandles (read/append/flush/close); every log read and write flows
through the owning handle. The seam package exports only the service and
handle contracts, consumer-visible errors, and pure durable-data
validation helpers; each backend owns its complete storage runtime, and
the shared contract suites pin equivalent observable behavior. The
backend routes published sessions' live events by id into the active
write handle; agent-loop only acquires, seeds, and closes the handle.
Resume appends interruptedTurnClosers through its write handle;
session-query owns the revision-keyed cold cache. Legacy-only surfaces
are removed in the same swap: locate/readRaw/supportsRawArtifacts, the
legacy event-shape read migration, zstd torn-frame salvage,
DSH_SESSION_JSONL, and hook transcript_path population; a torn final
zstd frame is discarded whole; the session-list cold blank probe returns
on stat metadata (eventCount derived from the last physical row,
sizeBytes). The WebUI ZIP export serializes the logical log from a read
handle, so both backends export identically.

Refs #3245
2026-09-01 23:19:02 +08:00
Tianyi Cui 3c5b7097ae Merge pull request #3425 from deepseek-harness/feat/ptc-disable-workflow-plugin
feat(presets): omit workflow from Web PTC mode
2026-09-01 23:18:39 +08:00
imccyu 1f694c88ab Merge pull request #3391 from deepseek-harness/worktree-chatperf
perf(web): reduce conversation rendering and layout overhead
2026-09-01 23:16:50 +08:00
imccyu e32437d18b perf(chat): throttle scroll geometry sampling 2026-09-01 23:06:52 +08:00
imccyu 4e4733c50b test(web): await turn-tail stream publication 2026-09-01 22:55:17 +08:00
fz 0cdcc9c3c5 feat(presets): omit workflow from PTC mode 2026-09-01 22:45:09 +08:00
imccyu e427d5746e fix: ci 2026-09-01 22:41:42 +08:00
Tianyi Cui c3e5bd7dae Merge pull request #3418 from deepseek-harness/fix/ptc-note-cloudflare-link
docs: restore Cloudflare's Code Mode name and blog link in PTC Agent Note
2026-09-01 22:36:56 +08:00
imccyu 9ef0e28000 test(web): await streamed text before snapshot 2026-09-01 22:36:05 +08:00
imccyu 0e90d47d19 perf(chat): skip stable node list mapping 2026-09-01 22:29:46 +08:00
Yichen Jiang a1271a4903 fix(llm): keep streamed tool-call identity across empty deltas
A continuation SSE delta that repeats a tool call's `id` or `name` as an
empty string — or as `null`, which some OpenAI-compatible gateways send —
erased the identity established by the call's first delta. The assembled
block reached the loop with an empty name and failed as `unknown tool ""`,
and the empty `callId` persisted into `tool/result`, which the session
reader refuses on reopen.

`acceptIdentity` accepts only a non-empty string, so a repeated empty or
null field means "no update". A tool call still missing `id` or `name` at
`[DONE]` ends the response with the new retryable `MALFORMED_TOOL_CALL`
code instead of closing an unusable block.
2026-09-01 22:22:51 +08:00
imccyu 577f0cf7d9 refactor(client): bind keyed chat sources in renderer 2026-09-01 22:22:25 +08:00
Tianyi Cui d1521ea783 feat(session)!: add released format migration 2026-09-01 22:12:21 +08:00
imccyu b8a19413e9 fix(client): satisfy strict observable contracts 2026-09-01 22:01:18 +08:00
Yichen Jiang 0d99bd0489 docs: regenerate the module graph without the telemetry proxy edge
Reverting telemetry to its own transport dropped the package's dependency
on the proxy library, and the generated graph still recorded that edge.

The gate that catches this is `verify-module-graph`, which runs in
`check:ci:static` and not in `doc-sync`; the revert was checked with the
latter alone.
2026-09-01 21:59:50 +08:00
Yichen Jiang 13daefe073 revert(session-telemetry-otel): leave telemetry on its own transport
Telemetry was the only call site this PR could not cover without changing
the SDK transport underneath it, and both ways of doing that cost more
than the channel is worth.

Routing an `http.Agent` needs Node's `proxyEnv`, added in 22.21 and 24.5
— inside the engines range, so three supported runtimes stayed direct
anyway, and the proxy package had to keep a `createNodeHttpAgent` export
for a path that only sometimes worked. Replacing the transport with the
SDK's `fetch` delegate covered every runtime but has no compression,
while the shipped `base` bundle enables gzip and a realistic OTLP batch
is 6.4x smaller with it; keeping both meant gzipping at the serializer,
which put transport code inside a telemetry plugin.

Telemetry is the one outbound channel whose loss costs the user nothing:
no tool, model request, or session depends on it, and an export that
cannot connect is already dropped silently. A user behind a mandatory
proxy is left where they were rather than regressed.

`src/index.ts`, `otel.spec.ts`, and `tsconfig.json` return to their state
on master; the package keeps only a dev dependency on the proxy library.
`egress.spec.ts` inverts: it installs a policy and asserts the fake proxy
saw nothing, so an SDK upgrade that moved the exporter onto `fetch` would
surface as a failing test rather than silently routing telemetry.
2026-09-01 21:51:29 +08:00
imccyu de07b4c05b chore(ui-chat): document local keyed sources 2026-09-01 21:46:08 +08:00
Yichen Jiang b518286735 fix(session-telemetry-otel): keep gzip on the fetch transport
The previous commit moved the OTLP exporter to the SDK's `fetch` delegate
and refused `exporter.compression` at load, on the belief that nothing
shipped enabled it. `packages/bundle/base/cordis.patch.yml` does, so the
refusal broke every test that boots the shipped bundle — the snapshot,
e2e, and Windows observational jobs all failed on that one load error.

Dropping gzip was the wrong trade anyway: a realistic OTLP batch measures
6.4x smaller with it, so trading it for proxy support would have charged
every deployment to fix one. The `fetch` transport has no compression
hook, but serialization is the seam before the body reaches it — the
plugin now gzips there and declares `Content-Encoding` itself.

`keepAlive` and `httpAgentOptions` have no such seam, since they
configure a connection pool `fetch` does not expose, so those two stay
refused at load rather than accepted and ignored. `compression` is typed
as the two values this package can actually apply rather than the SDK's
wider enum, and a third value fails loud at load.
2026-09-01 21:42:09 +08:00
imccyu a718d1f0a1 docs(client): record conversation performance boundaries 2026-09-01 21:31:04 +08:00
Tianyi Cui b74486ab29 docs: restore Cloudflare's Code Mode name and blog link in PTC note
The code-mode → ptc rename rewrote Cloudflare's product name in the
link text and the external URL path, leaving "PTC mode" pointing at
https://blog.cloudflare.com/ptc/ (404). Restore Cloudflare's own name
and the working https://blog.cloudflare.com/code-mode/ link in both
the English and Chinese notes.
2026-09-01 21:27:44 +08:00
Yichen Jiang bdd294dc47 Merge remote-tracking branch 'origin/master' into worktree/deepseek-harness-proxy-config-2f5b4a 2026-09-01 21:21:22 +08:00
imccyu 2e21d210a5 fix(trajectory): reanchor replaced history windows 2026-09-01 21:17:47 +08:00
Yichen Jiang 8470ddef1d refactor(http-proxy): converge the proxy API on four functions
The package exported six functions, four of them shaped by one SDK's
transport each: a dispatcher factory, a `node:http` agent factory, a
proxy-URL lookup, and a policy accessor. Review asked whether the call
sites could converge instead of the package growing an export per SDK.

They could, and each removal took a whole shape with it:

- The OTLP exporter moves to the SDK's `fetch` delegate, retiring
  `createNodeHttpAgent`. Its Node-version floor goes too: `proxyEnv` on
  an `http.Agent` needs 22.21 or 24.5, inside the engines range, so
  telemetry was direct on 22.19, 22.20, and 24.0-24.4. The cost is
  `compression`, a Node-transport option; the plugin now refuses it,
  `keepAlive`, and `httpAgentOptions` at load instead of ignoring them.
- `web-fetch-http` builds its own address-pinning agent under an
  annotated `proxy-exempt:` exemption, retiring `createDispatcher`.
  Pinning is per-request state a process-wide dispatcher cannot hold.
- E2B reads `route.proxy`, retiring `proxyUrlFor`.

What remains is `installProxyFromEnvironment`, `proxyRouteFor`,
`proxyEnvironmentForChild`, and `clearedProxyEnv` — one per way a caller
can need the policy. Installation absorbs resolution and diagnostic
reporting, which no caller needed apart.

`proxyRouteFor` also closes a defect the old accessor made expressible:
`web-fetch-http` read the policy to decide whether to pin, then read it
again to build a transport, so an unmount between the two returned a
direct, unpinned agent for a URL the first read had cleared as proxied.
A route carries the answer and the transport that answer assumed.

Every egress spec now installs through `installProxyFromEnvironment`, so
no test asserts a policy object a real launch could not produce.
2026-09-01 21:15:04 +08:00
imccyu 7db2bab853 test(conversation): brand fixture sequences 2026-09-01 21:14:19 +08:00
imccyu 443efeeba3 chore(client): refresh slot catalog 2026-09-01 21:09:48 +08:00
imccyu 5934201109 perf(conversation): publish streaming updates every three frames 2026-09-01 21:09:48 +08:00
imccyu ebe9f50b44 perf(ui-chat): contain collapsed reasoning layout 2026-09-01 21:09:48 +08:00
imccyu aad1ce0c68 perf(ui-chat): retain the stats resize observer 2026-09-01 21:09:48 +08:00
imccyu f808112ec8 perf(ui-chat): derive user action reveal in CSS 2026-09-01 21:09:48 +08:00
imccyu 56684331c2 test(ui-chat): compare keyed snapshot values 2026-09-01 21:09:48 +08:00
imccyu 56a4d51d2c perf(ui-chat): scope turn process updates 2026-09-01 21:09:47 +08:00
imccyu 5f1eca58ea perf: InputBar use immutable props 2026-09-01 21:09:47 +08:00
imccyu a731536ecc perf: ChatNodeSeat use seperated source 2026-09-01 21:09:47 +08:00
imccyu 6401a64e20 test(web): cover optimized streaming paths 2026-09-01 21:09:47 +08:00
imccyu 2ab37e9558 perf(trajectory): page resident history before rendering 2026-09-01 21:09:47 +08:00
imccyu c809098b06 perf(conversation): publish streaming updates every two frames 2026-09-01 21:09:47 +08:00
imccyu 81431381d6 perf(conversation): reuse unchanged location projections 2026-09-01 21:09:47 +08:00
imccyu 203e2440ac perf(ui-chat): move reasoning tail alignment to CSS 2026-09-01 21:09:47 +08:00
imccyu e5bbee893b perf(ui-deliverables): move overflow sizing to CSS 2026-09-01 21:09:47 +08:00
imccyu c11c3f98ad docs(ui-deliverables): record CSS overflow policy 2026-09-01 21:09:46 +08:00
Yichen Jiang 3efd4b51e0 Merge pull request #3415 from deepseek-harness/worktree/3414-turn-rail-preview-layer
fix(web): keep turn previews above code banners
2026-09-01 21:05:36 +08:00
Turtle 6ce0b6cce8 fix(issue-management): write Start date through Issue fields 2026-09-01 21:01:36 +08:00
Tianyi Cui 876a3e0414 Merge pull request #3346 from deepseek-harness/worktree/session-format-02-seq-brands
refactor(session)!: distinguish event seqs from log offsets
2026-09-01 20:56:20 +08:00
Tianyi Cui 27bf1039db refactor(session)!: distinguish event seqs from log offsets 2026-09-01 20:36:00 +08:00
Yichen Jiang 515eca7dc7 fix(web): keep turn previews above code banners 2026-09-01 20:21:11 +08:00
ihsiang 4bc0b000f5 Merge pull request #3411 from deepseek-harness/feat/3287-smooth-corners
feat(web): superellipse corners and hairline elevation strokes
2026-09-01 20:17:16 +08:00
mektpoy b57cc33421 docs(goal): re-record README bilingual pairing 2026-09-01 20:04:06 +08:00
yx.zhang 8a97e817b5 docs(agents): describe the corner and elevation prior art generically
The two styling notes name the surveyed product; the mechanism facts
(superellipse token, guard, full-round opt-out, stroke-in-shadow
elevation, 0.5px hairline) stand alone, so the notes now state them
without the product reference. Pairing records re-recorded.
2026-09-01 20:01:23 +08:00
mektpoy 33fa98b3c2 fix(goal): fence pause to the dropped attempt ref 2026-09-01 19:58:52 +08:00
yx.zhang 3ce5604a71 feat(web): deepen composer stroke to l2, widen menu radii to 20px
The composer hairline moves one step up from the menus' l1; the seven
menu-fill dropdown cards grow from 16px to 20px corners. Notes and the
token comment record the new layering.
2026-09-01 19:25:59 +08:00
yx.zhang b873b9321e fix(web): per-element elevation tokens and review sync
Re-declare the derived elevation tokens on body * so per-surface
--dsw-elevation-stroke-color rebinds reach the consuming shadow (custom
properties inherit with var() already substituted); pin that mechanism
and add synthetic rejection cases to the stylesheet scans; take the
ring-track basenames through node:path so the exemption matches on
Windows; update the ModelsSection row-card spec to the hairline recipe;
align the elevation note with the shipped l1 menu rebind, refresh the
feedback-popover note's surface recipe, and document the soft tier.
2026-09-01 19:15:56 +08:00
yx.zhang 7020c7e122 feat(web): superellipse corners and hairline elevation strokes
Apply global visual polish across the web client: corner-shape:
superellipse(1.5) with corner-shape: round pairing for full circles,
elevation tokens that draw 0.5px stroke outlines inside box-shadow for
floating surfaces, 0.5px hairline borders and divider lines for
neutral-token strokes, and tuned stroke contrast plus larger radii for
menus, settings panels, and cards. Stylesheet-scan specs in ui-theme
reject unpaired circles, border+shadow mixes, and 1px neutral hairlines
repo-wide.

Closes #3287
2026-09-01 18:25:20 +08:00
creatixchu 590a8e220a docs(attachment): record generic file behavior 2026-09-01 16:11:43 +08:00
creatixchu e9b7ed0aab test: cover generic file submission flows 2026-09-01 16:11:43 +08:00
creatixchu a1144c4950 feat(ui-attachment): unify mixed attachment presentation 2026-09-01 16:11:43 +08:00
creatixchu bafa6ae11d feat(conversation): stream files through submission lifecycle 2026-09-01 16:11:43 +08:00
creatixchu 8a0ff3aff8 feat(session-controller): admit files across command flows 2026-09-01 16:11:43 +08:00
creatixchu 65d2015b09 feat(attachment): store and project generic files 2026-09-01 16:11:43 +08:00
fz dead2b2324 Merge pull request #3382 from deepseek-harness/feat/sdk-default-web-fetch
feat(base): expose web fetch by default
2026-09-01 15:59:35 +08:00
Yichen Jiang 68488c552a Merge pull request #3403 from deepseek-harness/fix/model-discovery-profile-headers
fix(llm): reuse profile headers for model discovery
2026-09-01 15:46:34 +08:00
Yichen Jiang 8fa464890c Merge remote-tracking branch 'origin/master' into fix/model-discovery-profile-headers 2026-09-01 15:26:09 +08:00
Chinesezjc d2954806de fix(snapshots): project read-image-attachment-path fixture into canonical packed layout 2026-09-01 15:19:37 +08:00
fz 0a0f9e59ff feat(base): expose web fetch by default 2026-09-01 15:18:18 +08:00
Yichen Jiang 25e4527f5e fix(llm): validate configured provider headers 2026-09-01 15:09:40 +08:00
Chinesezjc a23c3dd64e Merge branch 'origin/master' into feat/toolcard-image-result 2026-09-01 14:45:24 +08:00
Yichen Jiang 5257c75092 fix(llm): reuse profile headers for model discovery 2026-09-01 14:37:16 +08:00
Dudu-0223 1149d47e9a test(tools): update team catalog expectation 2026-09-01 14:22:50 +08:00
Dudu-0223 040d73871b feat(agent-team): unify messages on steer 2026-09-01 14:22:50 +08:00
fz aefbee95e2 test(acp): refresh adjacent messaging schema 2026-09-01 14:22:49 +08:00
fz ab9dcd5a2a Merge remote-tracking branch 'origin/master' into feat/sdk-default-web-fetch 2026-09-01 14:17:15 +08:00
Dudu-0223 52af48f808 Merge pull request #3250 from deepseek-harness/feat/3220-steer-service
Unify adjacent Agent delivery on Steer
2026-09-01 14:14:38 +08:00
Dudu-0223 11719fd83c test(web): await goal composer settlement 2026-09-01 14:00:07 +08:00
mektpoy 29ce849738 fix(goal): abort the live turn on host-initiated pause 2026-09-01 13:56:36 +08:00
Dudu-0223 d960d90a98 test(snapshot): refresh Python PTC prompt 2026-09-01 13:46:36 +08:00
Dudu-0223 bfdede9d9e test(subagent): adapt session reads after rebase 2026-09-01 13:46:36 +08:00
Dudu-0223 22b08a9b9b test(subagent): update parent id expectation 2026-09-01 13:46:36 +08:00
Dudu-0223 4093ce465b Merge remote-tracking branch 'origin/master' into feat/3220-steer-service 2026-09-01 13:46:35 +08:00
fz 1bd880c587 Merge remote-tracking branch 'origin/master' into feat/sdk-default-web-fetch
# Conflicts:
#	packages/bundle/headless/README.i18n.yaml
#	packages/bundle/headless/README.md
#	packages/bundle/headless/README.zh.md
2026-09-01 13:45:44 +08:00
Yichen Jiang f878545358 Merge remote-tracking branch 'origin/master' into worktree/deepseek-harness-proxy-config-2f5b4a
Master moved 212 commits. Conflicts were eight `package.json` and nine
`tsconfig.json` files, all dependency-and-reference unions: master dropped the
`runtime-diagnostics/invariants` reference across packages while this branch
added `util/http-proxy`. The lockfile, `tsconfig.base.json`, and the module
graph were regenerated rather than merged by hand.

Master also brought `verify-package-invariants`, which rejects an empty
invariant companion. This package's companion was empty by design, so it is
gone with its publication wiring — the export, the `files` entry, the project
reference, the `dsh-invariants` peer, and the test. Master did the same across
`util/`. The README carries the reason sentence that gate requires.

Carried in the same commit, because the review arrived while the merge was open:
`e2bApiUrl` moves out of the `dsh-e2b` entry into `src/api-url.ts`. Code Entropy
flagged it as a public name with no consumer outside its own package; the seam
keeps its URL-precedence test, which is worth having directly — getting that
order wrong hands a proxy the control-plane traffic and its API key.
2026-09-01 13:39:50 +08:00
Turtle 714bec1316 Merge pull request #3367 from deepseek-harness/omit-unneeded-invariants
cleanup: omit unneeded invariant companions
2026-09-01 12:59:03 +08:00
fz 036abf8c6c test(snapshots): refresh remaining headless web headers 2026-09-01 12:02:29 +08:00
Turtle d7811225dc Merge remote-tracking branch 'origin/master' into turtle/omit-unneeded-invariants
# Conflicts:
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/code-runtime/code-runtime-python/README.md
#	packages/code-runtime/code-runtime-python/README.zh.md
#	packages/experimental/code-runtime-python/README.i18n.yaml
#	packages/experimental/code-runtime-python/package.json
#	packages/experimental/code-runtime-python/src/invariant.ts
#	packages/experimental/code-runtime-python/tsconfig.json
#	pnpm-lock.yaml
#	tsconfig.base.json
2026-09-01 11:54:10 +08:00
Chinesezjc d13d0a4b86 ci: re-trigger workflow after dropped push event 2026-09-01 11:53:19 +08:00
fz aaf10753a1 test(snapshots): separate ACP web headers 2026-09-01 11:49:55 +08:00
Chinesezjc 9d15938073 Merge pull request #1148 from deepseek-harness/feat/code-runtime-python-backend
feat(code-runtime-python): add the CPython subprocess backend
2026-09-01 11:44:16 +08:00
Chinesezjc 6e5ed52aed fix(ui-tool): address review wording and JSDoc accuracy on the image card 2026-09-01 11:43:26 +08:00
Chinesezjc 666bd48eae fix(ui-tool): scope the image-card path fallback to nested calls; repair merge-broken docs and snapshot fixtures 2026-09-01 11:40:21 +08:00
fz cf7b0bd5a4 feat(headless): expose web fetch by default 2026-09-01 11:40:10 +08:00
Chinesezjc a0c0b55c8a Merge remote-tracking branch 'origin/master' into feat/code-runtime-python-backend
# Conflicts:
#	packages/experimental/code-runtime-python/package.json
2026-09-01 11:29:10 +08:00
Turtle d68ff7e66f Merge remote-tracking branch 'origin/master' into turtle/omit-unneeded-invariants
# Conflicts:
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/session/session-projection/src/invariant.ts
#	tsconfig.base.json
2026-09-01 11:18:11 +08:00
Yichen Jiang b9153cbc19 Merge remote-tracking branch 'origin/master' into worktree/deepseek-harness-proxy-config-2f5b4a
# Conflicts:
#	packages/session/session-telemetry-otel/package.json
2026-09-01 11:15:18 +08:00
fz b5c61b613a Merge remote-tracking branch 'origin/master' into feat/sdk-default-web-fetch 2026-09-01 11:13:00 +08:00
Yichen Jiang c62d6f3a44 refactor(net): make the proxy a util library with six functions
Review asked why this is a plugin and why it exports so much. The design note
this branch shipped answered the second question itself — "a pure resolution
function plus an installation function" — and the code drifted to seventeen
exports and a Cordis plugin nobody approved or mounted.

The plugin is gone. Transport policy has one answer per process: nothing to
swap, and no scope narrower than the process to give one. Its `Config` was also
the only supplier of a configuration branch, so resolution now reads the
environment and nothing else — `mode`, the config-sourced fields, and the
`config` policy source were unreachable the moment the plugin left.

Four exports nothing outside the package used are internal again, and
`currentProxyPolicy` answers with the direct policy instead of `undefined`, so
`DIRECT_POLICY` no longer needs a public face. Nine functions remain, one per
way a caller can need the policy; two is not reachable with six consumer seams.

The package moves to `util/`. The note claimed a dependency on `undici`
disqualified it from that group; the charter governs harness dependencies, not
external ones, and the process note that says so predates this branch. The real
blocker was the harness dependency: resolution needed one method of
`LaunchEnvironmentSnapshot`, so it names a structural `EnvLookup` and the
launcher passes its snapshot unchanged. `net/` is dissolved.

Dropping the group's line from the repository layout also returns `AGENTS.md`
to its original ceiling, so the raise the merge needed is reverted.
2026-09-01 11:01:05 +08:00
_Kerman 5dd876025d Merge pull request #2907 from deepseek-harness/xtr/session-log-read-api
perf(session): separate indexed and snapshot log reads
2026-09-01 10:49:54 +08:00
Chinesezjc 6ce131c4f4 Merge remote-tracking branch 'origin/master' into feat/code-runtime-python-backend 2026-08-31 21:41:18 +08:00
_Kerman ad02fa37b4 Merge remote-tracking branch 'github/master' into xtr/session-log-read-api 2026-08-31 20:50:19 +08:00
fz ca723d9273 feat(sdk): expose web fetch by default 2026-08-31 20:18:50 +08:00
Chinesezjc a8d8b8cddd docs(code-runtime-python): sync hostFrameParseCeiling example numbers to the 16x multiple
The hostFrameParseCeiling JSDoc and one load-gate test comment still quoted
the pre-16x derivation (~29 MiB for a ~300 MiB heap, ~14 MiB for a 128 MiB
old space). With HOST_PARSE_WORST_CASE_MULTIPLE = 16 the same hosts derive
~14 MiB and ~7 MiB (floor((176-64)/16)); protocol.spec.ts pins the 304 MiB
case at 15 MiB. Comment-only correction, no behavior change.
2026-08-31 18:57:34 +08:00
Chinesezjc 56ca8af0ee feat(ui-tool): render the image card for nested read_image calls 2026-08-31 18:14:08 +08:00
Chinesezjc 974fca9f5a fix(code-runtime-python): restore oxlint suppressions lost in the #3289 merge-forward and re-pack the ptc-python fixture
The #3289 merge-forward dropped four typescript/no-unnecessary-condition
suppressions from index.ts (boot-write-failure fake child stdin, the
admit()-closure logsTruncated recheck, and both settled rechecks whose
guards flip mid-wait), turning the lint:contracts-ready gate red. Re-add
them with their reasons. The merge also carried a ptc-python-turn session
fixture that was not in canonical packed layout; migrate-packed-session-fixtures
re-writes it so session-fixture-layout passes.
2026-08-31 17:52:25 +08:00
Turtle 79e388547c Merge remote-tracking branch 'origin/master' into turtle/omit-unneeded-invariants 2026-08-31 17:45:29 +08:00
_Kerman febafc7ee0 fix(test): cover indexed session reads 2026-08-31 17:43:17 +08:00
Tianyi Cui 50b6be997d Merge pull request #3289 from deepseek-harness/worktree/pr1148-runtime-contract-fixes
fix(code-runtime): settle Python provider contracts
2026-08-31 17:30:15 +08:00
_Kerman 687ae5c9c0 Merge remote-tracking branch 'github/master' into xtr/session-log-read-api
# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
#	.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
#	.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml
#	.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md
#	.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md
#	packages/examples/agent-spine-demo/tests/agent-core.spec.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
2026-08-31 17:19:59 +08:00
Turtle c6e6f4f460 fix: retain invariant host lint suppression 2026-08-31 17:16:58 +08:00
Tianyi Cui 01141e619a Merge latest #1148 into #3289 2026-08-31 17:11:37 +08:00
Chinesezjc 5bb2c46453 Merge branch 'master' into feat/toolcard-image-result 2026-08-31 17:11:15 +08:00
Tianyi Cui d87b755e37 Merge origin/master into feat/code-runtime-python-backend 2026-08-31 17:11:11 +08:00
Turtle 01a8882601 fix: complete invariant omission cleanup 2026-08-31 17:10:53 +08:00
Turtle a7f5b6e638 Merge remote-tracking branch 'origin/master' into turtle/omit-unneeded-invariants 2026-08-31 17:01:06 +08:00
Tianyi Cui d15610a66b test(snapshot): refresh Python PTC fixture for latest master 2026-08-31 17:00:18 +08:00
Tianyi Cui dc5cc0d575 test(code-runtime-python): align PATH rejection diagnostic 2026-08-31 16:47:26 +08:00
Tianyi Cui ba0609571f Merge #1148 validation corrections into #3289
# Conflicts:
#	packages/experimental/code-runtime-python/tests/runtime.spec.ts
2026-08-31 16:29:52 +08:00
Tianyi Cui f14189cf8c chore(deps): refresh Python runtime lock importer 2026-08-31 16:27:40 +08:00
Tianyi Cui 04aee12cc2 test(code-runtime-python): align macOS runtime expectations 2026-08-31 16:26:39 +08:00
Tianyi Cui 4e2c568efe Merge corrected #1148 checkpoint into #3289
# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.zh.md
#	packages/experimental/code-runtime-python/README.i18n.yaml
#	packages/experimental/code-runtime-python/README.md
#	packages/experimental/code-runtime-python/README.zh.md
#	packages/experimental/code-runtime-python/src/index.ts
#	packages/experimental/code-runtime-python/tests/runtime.spec.ts
2026-08-31 16:10:13 +08:00
Tianyi Cui 61b3e06e97 fix(code-runtime-python): preserve post-merge hardening 2026-08-31 16:03:32 +08:00
Tianyi Cui d6bd5eb973 fix(code-runtime): bound interpreter version probe 2026-08-31 15:55:05 +08:00
Tianyi Cui 711ec7ffac test(snapshot): canonicalize Python PTC fixture 2026-08-31 15:50:45 +08:00
Tianyi Cui 7f84a825c9 fix(code-runtime): settle Python provider contracts 2026-08-31 15:50:45 +08:00
Chinesezjc 8e9d5467b0 fix(code-runtime-python): bound reply and call backlogs, snapshot binding metadata, and compact the reply queue
Review findings on the CPython backend: a child that never reads fd 3 leaves
the reply pipe full forever, so the drain loop waits on 'drain' while every
call frame it keeps sending resolves a binding and queues another reply —
the backlog (and the binding results it pins) would grow until the wall
clock. sendReply now caps the pending backlog at MAX_PENDING_REPLIES and
settles the run as worker-exit past it, mirroring the frame cap; a child
flooding calls against a binding that never settles would otherwise bypass
that cap (pendingReplies grows only after the await), so the dispatcher
counts in-flight binding calls before dispatch and releases the slot in the
async body's finally, capping outstanding closures at the same bound. The
drain also compacts its consumed prefix (replyQueue.splice(0, head)) once
head reaches the bound, so a drain that stays alive without emptying cannot
grow the backing store linearly with cumulative throughput.

The completion-value meter counted lone surrogates with
_SURROGATE.findall(folded), materializing one single-character string per
surrogate: a surrogate-dense value near the budget (millions of surrogates,
each serializing to six bytes) allocated millions of objects before the meter
returned, defeating the meter's counting-without-building contract. The count
is now the length difference between folded and the without string the meter
already computes; a standalone equivalence check confirms it matches findall
across lone-high, lone-low, paired, astral, and mixed cases.

validateBindings read namespace.global/errorClass.name/memberNameProperty
several times and retained the original errorClass object for the boot
frame, whose JSON.stringify re-read it after validation: a stateful getter
could throw or change between the two stages, turning the seam-misuse
rejection into a worker-exit or injecting an unvalidated name. Each field is
now read once into a plain value and the bindings map stores a plain
{ name, memberNameProperty } copy, so validation and the boot frame see
identical values.

Regression tests: a hostile child floods 5000 sequential valid calls without
reading fd 3 and the run settles worker-exit with the reply-queue message
before maxWallMs; a 3,000,000-surrogate completion succeeds at an
18,000,002-byte budget and reports output-limit one byte under; a 5000-call
flood against a never-settling binding settles worker-exit with the
call-backlog message; getter-backed namespace metadata that throws or
changes on a second read boots and runs with each field read exactly once; a
two-wave flood whose replies exceed the writable high-water mark drives the
drain past the compaction bound mid-delivery and verifies all 1524 replies
arrive. README Known Limitations gains the reply-backlog and call-backlog
bounds (en/zh, pairing re-recorded); a new Agent Note registers the findings.
2026-08-31 15:25:26 +08:00
Chinesezjc b75eec0967 docs(doc-graphs): list the experimental Python backend as a codeRuntime implementation
The capability-seams graph derives its implementation lists from
SERVICE_ROLES in scripts/gen-doc-graphs.ts, which still listed only the
worker-thread backend. Add experimental-code-runtime-python so the
generated graph and table match the registered ctx.codeRuntime
implementations; regenerate docs/capability-seams.md, sync the zh pair,
and re-record the i18n pairing.
2026-08-31 15:25:26 +08:00
Chinesezjc 2df28fd249 fix(code-runtime-python): validate explicit pythonBin at load, snapshot bindings, and settle the reply drain
Review findings on the CPython backend: an explicit pythonBin path bypassed
the load-time checks (missing/non-executable/directory paths surfaced only
as a run-time worker-exit); a throwing binding member accessor escaped the
fd-3 data callback and terminated the host; the reply drain waited on
'drain' alone, so a pipe destroyed under the wait hung forever; and two
staging-leak assertions diffed a global tmpdir that parallel workers can
perturb.

resolvePythonBin now applies the same accessSync(X_OK) + isFile check to
explicit paths (resolved against the host CWD), and the load error message
distinguishes 'is not an executable regular file' from 'does not resolve on
PATH'. validateBindings snapshots callables into a plain record during run()'s
synchronous validation, turning an accessor throw into the seam-misuse
rejection and fixing the key set the boot frame and dispatch share. The reply
drain waits on drain/close/error together and short-circuits on
proto.destroyed. The staging-leak assertions check the exact paths this test
file staged (recorded by the mocked mkdtempSync) instead of a tmpdir diff.

docs(code-runtime-python): add the alternatives section to the hardening note

docs(config-catalog): refresh the code-runtime-python Config source line

test(code-runtime-python): cover the async spawn-error worker-exit path
2026-08-31 15:25:26 +08:00
Chinesezjc 2c8d545524 docs(experimental): list code-runtime-python in the group README; state the portable note as current fact
The review's items: the experimental group README's Packages table and Summary
now list code-runtime-python (CPython subprocess backend, ctx.codeRuntime),
paired; the portable-identifier note's Scope drops the 'has since shipped …
now lists' change narration in favor of the current state, removing the
apparent contradiction with 'the worker is the only shipped backend'.
2026-08-31 15:20:50 +08:00
Chinesezjc c435170a93 docs(code-runtime): drop the remaining shipped claims for the private experimental backend
The review's final wording items: the portable-identifier note's Scope said the
backend 'has since shipped' without noting it is experimental/private; the
RESERVED_WORDS JSDoc said backends 'ship for both languages'. Both now name the
TypeScript backend as released and the CPython backend as experimental and
private. The package README also records that the truncation-marker text and
tempdir prefix keep the pre-rename short names (byte-anchored by tests,
independent of the npm name).
2026-08-31 15:19:22 +08:00
Chinesezjc 12d0bdb274 docs(code-runtime): stop calling the private experimental Python backend published
The review's carry-over: 'each has a published backend' in the CodeRuntime
JSDoc and its projections (tool-cordis api-catalog, subsystems page) plus
'both shipped'/'backends ship' in the code-runtime README all claimed the
Python backend is released; it is private and experimental, excluded from the
release family. The wording now states the TypeScript backend is released and
the Python backend is experimental and private (not published), in the JSDoc
(api-catalog regenerated to match), the READMEs (paired), and the subsystems
page (paired).
2026-08-31 15:17:56 +08:00
Chinesezjc 732a54f85b docs(code-runtime-python): fix note status grammar and paragraph wrap
The fd-3 note's Status line moved off line 3 when the experimental-location
fact was added; it is back as the sole line-3 status. The zh portable-identifier
note's merged Scope paragraph lost its blank-line separator, which the
md-wrap gate read as one hard-wrapped paragraph — the blank line is restored.
2026-08-31 15:16:24 +08:00
Chinesezjc 2504d0a501 fix(code-runtime-python): complete the experimental move across configs and docs
The review's move-follow-ups: the Windows test exclude now points at
packages/experimental/code-runtime-python (the constructor throws by design on
Windows, so the suite must stay excluded); the invariant companion and
@module annotations use the new npm name; the truncation marker text and
tmpdir prefix stay as-is (tests anchor them); the package JSDoc and READMEs no
longer call the private experimental backend 'published'/'shipped'; the
code-runtime README row describes the package as protocol AND runtime; the
fd-3 note records the package's experimental location.
2026-08-31 15:15:08 +08:00
Chinesezjc d7eb7f4418 fix(code-runtime-python): finish the experimental move — invariant name and tsconfig aliases
The move broke two generated/derived surfaces: (1) the package invariant
companion still registered the old name
@deepseek-ai/dsh-code-runtime-python, so the exhaustive-topology test found
the new name unreserved — it now registers
@deepseek-ai/dsh-experimental-code-runtime-python; (2) the tsconfig.base.json
alias for the renamed package sat inside the generated region, so
gen-tsconfig-paths dropped it (a package named after something other than its
directory needs a hand-written alias before the BEGIN marker) — the alias is
moved out and the config is current again.
2026-08-31 15:13:55 +08:00
Chinesezjc 053d17f6a1 refactor(code-runtime-python): move the package into packages/experimental
The CPython code runtime's complete public contract is experimental, so it
moves to packages/experimental per the experimental-packages rules: npm name
@deepseek-ai/dsh-experimental-code-runtime-python, private: true, no
publishConfig. All references updated (code-runtime READMEs, config-catalog
and module-graph regenerated with zh alignment, tsconfig paths, doc-standard
and workspace-constraints scripts, the fd-3 and settlement Agent Notes, and
the package README links); md-links and translation pairing pass, and the
suite still runs green.
2026-08-31 15:13:55 +08:00
Turtle 49f1d1bd9a Merge remote-tracking branch 'origin/master' into turtle/pr-864-master-port 2026-08-31 15:12:15 +08:00
Chinesezjc 45cfc9cfaf docs(code-runtime-python): sync the Consequences enumeration to eleven no-fail-before fixes
The review's item: the Problem section counts eleven no-fail-before fixes, but
the Consequences section still said 'the ten called out in the Problem section'
(zh: '那十处') and omitted the new unknown-binding preview cap. Both sides now
say eleven and name the cap, paired and re-recorded.
2026-08-31 15:09:28 +08:00
Chinesezjc 200703a20d docs(code-runtime-python): list the unknown-binding preview cap as the eleventh no-fail-before fix
The review's warning: the settlement note's Problem paragraph says ten fixes
have no fail-before test, but the unknown-binding preview cap (a transient
whole-target JSON.stringify peak, unmeasurable through the seam) is the
eleventh. The count and the item are now recorded, paired.
2026-08-31 15:09:28 +08:00
Chinesezjc 391e29ec8a docs(code-runtime-python): register the unknown-binding preview cap in the settlement note
The review's suggestion: the settlement note enumerates each review fix in this
PR, so the unknown-binding preview cap (escaped from a 1 KiB prefix,
capMessage still enforces the reply budget) gets its own short section, paired
and re-recorded.
2026-08-31 15:07:40 +08:00
Chinesezjc a6678610b8 fix(code-runtime-python): cap the unknown-binding preview before JSON.stringify
The reviewer's standing item: the unknown-binding reply ran JSON.stringify on
the WHOLE capped target (global + '.' + name, each up to maxValueBytes code
units), allocating the escaped form — up to ~6x under control-heavy input, a
multi-hundred-MB spike near the maxValueBytes ceiling that no hostile-peer
bound would have admitted. The escaped preview is now built from a 1 KiB
prefix of the target (enough to identify the binding); capMessage still
enforces the reply budget. A forged huge-name case drives the path.
2026-08-31 15:06:50 +08:00
Chinesezjc 6719e287de docs(code-runtime-python): state the layer-5 deferral as current fact, not PR history
The review's wording item: the layer-5 bullet ended with 'not by this PR'
(zh: 'not borne by this PR'), which references PR context in durable prose.
The sentence now ends with the current-state fact ('not by this package's
suite'), paired and re-recorded.
2026-08-31 15:06:50 +08:00
Chinesezjc 80ceb4ec5f docs(code-runtime-python): register the layer-5 assembly snapshot deferral
The review's open suggestion: the Known Limitations now records that the
real-Loader assembly snapshot is deferred to issue #1182 layer 5 (this package
is exercised through ctx.plugin and real-subprocess tests; the full application
composition is covered by a tracked assembly test in that layer), paired.
2026-08-31 15:06:50 +08:00
Chinesezjc 771872a73d test(code-runtime-python): cover the finish-residual sealed side; use a block array for the open seal
The review's two remaining non-blocking items: (1) a case where the run ends
with a SEALED open hold (past MAX_PENDING_CHUNKS) — finish() must commit the
sealed prefix, verified to fail if finish drops openSealed. (2) openSealed is
now a block ARRAY (one joined block per seal) matching the fd-3 reader's
blocks and the stray capture's seal, instead of one repeated string concat
that leaned on V8 ConsString amortization.
2026-08-31 15:06:50 +08:00
Chinesezjc 74e9d97e37 docs(code-runtime-python): register the host-side open seal; note the empty-first-frame billing
The review's follow-ups on the open-seal fix: (1) the settlement note's seal
section now records the HOST-side open hold seal (openParts -> openSealed,
mirroring the child _LogStream and stray-capture seals), paired. (2) the
first-fragment guard comment notes the empty-first-frame case (bills cost + 1 =
3, establishes no hold, bounded over-charge in the safe direction). (3) a
regression case commits a SEALED open hold before the truncation marker —
verified to fail if truncateLogs drops openSealed.
2026-08-31 15:06:50 +08:00
Chinesezjc bca392e6d1 fix(code-runtime-python): seal the open hold past MAX_PENDING_CHUNKS
The review's warning: each held open fragment is a distinct array slot plus
string object header (~30x overhead the byte cap cannot see), and a
budget-sized single-character open flood is honest-child reachable
(print('x', end='', flush=True) in a loop). With maxLogBytes near its ~67 MB
load ceiling that was up to ~2 GB of host auxiliary heap. The hold now seals
into one block past MAX_PENDING_CHUNKS, mirroring the fd-3 reader's blocks and
the stray capture's seal; the merge, truncateLogs, and the finish residual all
read sealed + current fragments, and a within-budget flood regression asserts
the merged entry is byte-identical.
2026-08-31 15:06:05 +08:00
Chinesezjc 953dd2d9b8 docs(code-runtime-python): align three stale comments with the shipped code
The review's wording items: the load-check comment still referenced the
resolvePythonBin JSDoc's old ENOENT promise; the spawn-site comment called the
type assertion a non-null assertion; and two comments claimed the
'logs serialize to maxLogBytes + marker + envelope' bound is recorded in the
README's Known Limitations, which has no such entry — the cross-references are
dropped, the bound stays stated inline.
2026-08-31 15:06:05 +08:00
Chinesezjc fcf4e5463a docs(code-runtime-python): declare detachResidual in the public surface
The review's carry-over: detachResidual (a test seam for the settled run's
resource cleanup) is re-exported from the '.' entry but was not in the README's
declared public surface; the list now names it alongside resolvePythonBin and
readProcessStart, paired.
2026-08-31 15:06:05 +08:00
Chinesezjc ca0e3e573e docs(code-runtime-python): drop the fixed empty-open limitation; declare the test helpers
The empty-open continuation skip (5b61a8fe6) made the Known Limitations entry
stale — the held fragment array no longer grows per empty frame — so the entry
is removed on both sides. The public-surface list now declares
resolvePythonBin and readProcessStart, which the '.' entry re-exports for the
test suite.
2026-08-31 15:06:05 +08:00
Chinesezjc 716060a04a test(code-runtime-python): cover the zero-content open-continuation skip
The skip branch (an empty open continuation is not pushed into the hold) needs
coverage; a case drives an empty continuation between a first fragment and the
closing frame and asserts the merged entry is unchanged.
2026-08-31 15:06:05 +08:00
Chinesezjc d9ed44d62c fix(code-runtime-python): skip zero-content open continuations in the hold; correct the spawn comment
The review's items: a zero-content open continuation bills 0 but still pushed
'' into the held fragment array, so a forged empty-open flood grew host memory
without touching the ledger — the push is now skipped (an empty fragment
contributes nothing to the merged entry). The spawn-site comment said a PATH
change between load and run would fail with ENOENT; it actually makes spawn
throw synchronously, which the surrounding try settles as worker-exit.
2026-08-31 15:06:05 +08:00
Chinesezjc 3151ecb848 docs(code-runtime-python): state the pythonBin load rejection in the README and the load-check comment
The review's warning: the pythonBin load-rejection is a product-visible change
(unresolvable basename now fails at load instead of a run-time worker-exit),
but the READMEs (en + zh) only said the basename is resolved against PATH, and
the load-check comment still described the old fallback. The README pythonBin
entries and the load-check comment now state the rejection; pairing
re-recorded.
2026-08-31 15:06:05 +08:00
Chinesezjc f931c2128a docs(code-runtime-python): register the pythonBin load-rejection change; harden PYABS
The review's follow-ups: (1) the product-visible change (an unresolvable
basename pythonBin now fails at load instead of a run-time ENOENT worker-exit)
is registered in the settlement note, paired. (2) PYABS falls back to the bare
name when python3 is not resolvable, instead of interpolating the literal
'undefined' into the wrappers.
2026-08-31 15:06:05 +08:00
Chinesezjc 80e13b3446 fix(code-runtime-python): align the resolvePythonBin docs and the exception-group guard
The review's follow-ups on the pythonBin change: (1) the JSDoc and the two
call-site comments still described the old fallback-to-bare-name contract;
they now state the load-rejection behavior. (2) the ExceptionGroup case's
version guard raised a skip message on Python < 3.11 but the assertion still
required the truncation marker unconditionally — the assertion now matches
either the truncation marker (3.11+) or the skip message (3.10). (3) the shell
wrappers quote the resolved interpreter path.
2026-08-31 15:05:23 +08:00
Chinesezjc 0b980bdfd1 fix(code-runtime-python): reject an unresolvable pythonBin at load; guard the ExceptionGroup case
The review's two non-blocking items: (1) resolvePythonBin returned the bare
basename when PATH had no hit, and spawn (env:{}) would silently fall to
execvp's platform default PATH and could start a system interpreter the caller
never asked for. It now returns undefined for an unresolvable basename and the
load check rejects it (absolute paths pass through), so the failure is loud at
configuration time instead of silent at spawn; the case that expected a
run-time worker-exit now asserts the load rejection, consistent with the
empty/NUL pythonBin cases. (2) the over-cap exception-group case skipped on
Python < 3.11 (ExceptionGroup is a 3.11+ builtin), matching the TaskGroup
case's version guard.
2026-08-31 15:05:23 +08:00
Chinesezjc 60b8fc00c4 test(code-runtime-python): resolve the interpreter path in the shell wrappers
The review's portability warning: the six shell wrappers exec'd a bare
'python3', which /bin/sh resolves against its compiled-in default PATH while
the runtime spawns with env:{} — in environments where python3 is reachable
only through the caller's PATH (Nix, pyenv) every wrapper run would fail as
worker-exit. The wrappers now bake the resolved absolute interpreter path
(module-level resolvePythonBin, which the product spawn already uses), and
resolvePythonBin is exported for the tests.
2026-08-31 15:05:23 +08:00
Chinesezjc 27b8f97150 fix(code-runtime-python): normalize the inherited SIGXCPU state before any CPU-consuming setup
The reviewer's residual timing item: the inherited-SIGXCPU reset ran AFTER
setrlimit(RLIMIT_CPU) and the boot-namespace construction, so a huge namespace
under an inherited ignore/block could burn past the soft limit inside that
window and be misclassified as worker-exit. The reset now happens at the very
top of _run, before the resource-limit setup and namespace construction.
2026-08-31 15:05:23 +08:00
Chinesezjc c4fe0320fb test(code-runtime-python): drive the inherited SIGXCPU path with a wrapper; fix the zh outer wire sentence
The review's two follow-ups on the inherited-SIGXCPU fix: (1) a discriminating
case — pythonBin points at a wrapper that ignores SIGXCPU before exec'ing
python3, so the child genuinely inherits the ignore; with cpuSeconds: 1 the
busy loop must end as timeout (the bootstrap reset restored SIG_DFL), and
reverting the reset leaves it running to the wall — verified red. (2) The zh
README's OUTER wire section now carries the truncation-exception sentence
(the previous commit had duplicated it in the inner section instead); the
duplicate is removed, and the settlement note registers the inherited-SIGXCPU
reset.
2026-08-31 15:05:23 +08:00
Chinesezjc 7b9db83f86 fix(code-runtime-python): reset the inherited SIGXCPU disposition and mask at startup
The reviewer's standing issue: the child inherits the host's SIGXCPU
disposition and signal mask — if the host ignores or blocks SIGXCPU, the soft
RLIMIT_CPU fires but cannot stop the child, and the hard limit's SIGKILL then
classifies a definite CPU overrun as worker-exit instead of a timeout. The
bootstrap now resets SIGXCPU to SIG_DFL and unblocks it before any model code
runs (the settle-time enforcer already restores SIG_DFL for a program that
traps or masks the signal mid-run; this closes the inherited-state gap). The
zh README's outer wire section also gains the truncation-exception sentence to
match the en side.
2026-08-31 15:04:43 +08:00
Chinesezjc fa0565032f docs(code-runtime-python): register the truncation exception to open merging; clean a case comment
The review's warning: the READMEs (outer and inner wire sections, en + zh) and
the fd-3 protocol note still claimed the next log frame always merges into an
open entry, while truncateLogs commits the already-billed prefix as its own
entry before the marker. The one exception (truncation) is now stated in both
READMEs and the owning note, paired and re-recorded. The prefix-commit case's
parenthetical describing the pre-fix implementation is removed per the
comment-does-not-record-review-history rule.
2026-08-31 15:04:43 +08:00
Chinesezjc 6bdbe71092 fix(code-runtime-python): drop fd-3 frames with illegal UTF-8 instead of mangling them
The reviewer's standing issue: line.toString('utf8') silently replaces illegal
bytes with U+FFFD, so a forged frame could land a corrupted completion value
(the honest child's lossless encoder never emits non-UTF-8, so such a frame is
hostile traffic). The fd-3 frame decode now uses a fatal UTF-8 decoder: an
illegal byte throws and the frame is dropped, same treatment as the
unsafe-integer check. A forged illegal-UTF-8 done frame is verified to be
dropped (the run settles on the program's real return), and reverting to
toString makes the case fail.
2026-08-31 15:04:43 +08:00
Chinesezjc 89fbe54cb1 fix(code-runtime-python): commit a flushed open prefix before the truncation marker
The review's warning: a flushed unterminated line is billed and committed
(README wire contract says so), but every truncation arm — the child truncated
frame, an over-budget open frame, an over-budget closing frame, and admit's two
budget arms — pushed only the marker, dropping the held prefix: the ledger
charged for output that vanished. All arms now funnel through truncateLogs(),
which pushes the (already billed) held prefix before the marker and clears
openParts, so the prefix survives and only the marker stays last; the finish()
guard drops the now-dead !logsTruncated check (a truncated run has an empty
hold). A regression case asserts [prefix, marker]; the forged-flood and
closing-overflow cases now expect the committed prefix plus the marker.
2026-08-31 15:04:43 +08:00
Chinesezjc e7ac747e2d docs(code-runtime-python): register the zero-billed empty open-frame hold as a known limitation
The review's suggestion: an empty open continuation frame bills zero and holds
one host slot, so a forged empty-open flood grows the held fragment array
without touching logBudget. Accepted as a residual (per-frame host cost far
below its ~30-byte fd-3 wire cost, bounded by pipe throughput, model-code trust
level equal to bash) and now registered in the README's Known Limitations on
both sides, paired and re-recorded.
2026-08-31 15:04:43 +08:00
Chinesezjc 909d5c334b docs(code-runtime-python): restate the buffered-chunks pre-check comment as invariant-preserving
The review's revision: the buffered-chunks pre-check's open-aware overhead is
observationally inert — when the +3 form trips and the open-aware form does not
(pending + newline in [remaining - 2, remaining]), _push_bounded_prefix
re-slices the same newline-free line text and _push_locked admits it under the
same open-aware billing, byte for byte. The comment now states that the
open-aware form keeps _push_bounded_prefix's 'certain to reject' precondition
true, contrasting with the scan pre-check whose slice carries the newline and
therefore genuinely truncates.
2026-08-31 15:04:43 +08:00
Chinesezjc fdcfec4977 docs(code-runtime-python): align the en wire-contract section with the open flag; fix the case comment
The review's warning: the en README's inner 'Wire contract' section still
described only the truncated flag while the zh counterpart (and the outer 'The
wire' section) described open. The inner en section now matches. The exact-fit
closing-line case comment described the buffered-chunks pre-check recipe while
the program actually drives the scan pre-check; the comment now states the
actual arithmetic and path (and the buffered variant was dropped — its writes
coalesce into one call in the test environment, so it did not discriminate).
2026-08-31 15:04:43 +08:00
Chinesezjc 9194dfebc8 fix(code-runtime-python): make the write-path pre-checks open-aware; document the open flag in zh
The review's warning: while an open entry accumulates, the newline pre-checks in
the write path still charged a NEW entry's +3 cheap-bound overhead (quotes +
separator), so an exact-fit merged TAIL was truncated (or the pre-check
over-rejected it and flushed a truncated prefix). Both pre-checks now charge
the overhead only when no open entry is in progress, matching _push_locked's
open-aware bound. A regression case (the review's recipe: flush an open
fragment, then write one exact-fit newline-terminated line) is verified to
truncate when the +3 is restored.

The zh README's wire-contract section now describes the open flag like the en
side (the fd-3 Agent Note holds the split-billing arithmetic; a cross-doc link
was omitted to keep the bilingual link sequence aligned).
2026-08-31 15:04:43 +08:00
Chinesezjc 371303822f test(code-runtime-python): give the first-fragment cap a discriminating case; dedupe the note
The review's warning: the one-byte overflow case ran through the CHILD ledger
(print path), so the host's first-fragment cap (logBudget - 1) never executed,
and the sub-2-byte guard test does not discriminate logBudget from
logBudget - 1 (a reverted cap still trips the guard). The frame is now forged
on fd 3, so a reverted cap of logBudget admits it and flushes it at settlement
— verified to turn the test red.

The review's dedupe suggestion: the split-billing arithmetic was stated in both
notes; the settlement note's Decision paragraph now links to the fd-3 protocol
note's wire-contract section (one home per fact), paired and re-recorded.
2026-08-31 15:04:43 +08:00
Chinesezjc c7d2d4b8b5 docs(code-runtime-python): register the open-merge split billing in the settlement note
The review's suggestion: the settlement note's Decision section now states the
shipped split-billing fact (first fragment pays quotes+separator, continuations
and the closing frame pay content only; host caps logBudget-1 / logBudget+2;
child keys off _open_started), paired and re-recorded.
2026-08-31 15:04:03 +08:00
Chinesezjc 1097bd3c3c docs(code-runtime-python): register the open-merge split billing in the fd-3 note
The review's suggestion: the open-merge mechanism (incremental split billing on
both sides, host caps logBudget-1/logBudget+2, the sub-2-byte walk guard, the
child's _open_started-keyed billing) lived only in code comments. The wire
contract section of the note now states it, paired and re-recorded.
2026-08-31 15:03:21 +08:00
Chinesezjc 22e2dc454d test(code-runtime-python): cover the sub-2-byte guard of the exact-cost walk
The new jsonStringCostUpTo guard (returns undefined below a 2-byte cap) was
uncovered: forged open frames drive the host ledger down to one byte, and a new
open entry's first-fragment cap (logBudget - 1 = 0) trips the guard and
truncates to the marker, asserted as the merged entry plus the marker.
2026-08-31 15:03:21 +08:00
Chinesezjc 3001cc23be fix(code-runtime-python): correct the open-merge cap arithmetic on both sides
The review's arithmetic checks: the closing-frame walk used cap
logBudget - openCost, so a compliant merged entry (58-byte wire cost under a
64-byte budget) could see a negative cap and truncate; the first-fragment cap
used logBudget instead of the ledger's logBudget - 1, so an open frame costing
63 was admitted with a bill of 64, pushing the ledger negative and letting a
subsequent empty frame ride in one byte past the configured cap; and the child
billed a closing frame as a fresh entry (quotes+separator again) instead of the
merged tail, truncating an exact-fit 30+30 entry.

Fixes: first-fragment cap logBudget - 1 (matching admit), continuation and
closing-frame cap logBudget + 2 (billed without quotes), jsonStringCostUpTo
returns undefined below 2 bytes, and the child's split billing keys off
_open_started alone (a closing frame pays content only) with the cheaper bound
len(text) while a merge is open. Regression cases cover all three arithmetic
paths.
2026-08-31 15:03:21 +08:00
Chinesezjc 4fd0068fb7 fix(code-runtime-python): bill a merged open entry incrementally on both sides
The review's critical: the open-merge branch re-joined and re-walked the whole
held text per frame, so k tiny open frames cost O(k * budget) (thousands of
1-byte frames against a near-64 MiB budget would re-traverse hundreds of GB and
block the host event loop). The host now holds a fragment ARRAY with an
incrementally billed cost — each fragment's jsonStringCostUpTo walks only its
own text — and the closing frame bills only its own content, so the merged
entry's wire cost is charged exactly once, split across the fragments. The
child bills symmetrically: the first open fragment pays quotes+separator, each
continuation pays only its content, matching the host ledger (the review's
warning: per-fragment full billing truncated a 16-char merged entry under
maxLogBytes: 64 that costs only 19 bytes as one entry).

Regression cases: 16 single-character flushes merge to one whole entry; a
closing frame that overflows the remaining budget truncates to the marker; a
closing frame after an open flood already truncated the ledger is a no-op; and
a forged open-frame flood stays bounded by the ledger. The closing-frame
post-truncation guard is an invariant-false branch (an open frame that would
trip the ledger resets openParts, so a non-empty hold implies no truncation)
and carries a v8 ignore with that reason.
2026-08-31 15:03:20 +08:00
Chinesezjc ea1d28a068 fix(code-runtime-python): bound the open-merge hold by the ledger budget
The review's critical: the open-merge branch accumulated the held fragment
before any ledger check, so a forged open flood could grow host memory without
touching logBudget. The held fragment is now bounded by the exact-cost walk
(jsonStringCostUpTo against the remaining budget; the closing frame's admit()
still bills the merged entry once), and the open field is registered in the
README wire-contract section and the fd-3 protocol note (en + zh). A forged
open-flood case asserts truncation to the marker under a 64-byte budget.
2026-08-31 15:03:20 +08:00
Chinesezjc 72691455e9 fix(code-runtime-python): merge a flushed unterminated line into the next log entry
The review's remaining warning: an explicit flush of an unterminated line
(print(..., end='', flush=True)) pushed a full log frame, so the following
print() landed in a second entry and logs.join('\n') rendered 'a\nb' for what
the program printed as one line — a model-visible output defect. The flush
frame now carries an  flag (LogMessage gains the optional field on both
sides and in the mirror test), the host holds it and appends the next log frame
to the same entry, and finish() admits the residual if the run ends with it
still open. The settlement note registers the decimal-context fix from the
previous commit.
2026-08-31 15:03:20 +08:00
Chinesezjc 35de0682c7 fix(code-runtime-python): make the float encoder context-independent; correct the binding-reply README entry
The review's critical: Decimal(repr(value)).normalize() read the process-global
decimal context, so a legitimate program setting getcontext().prec = 2 silently
rounded the completion value's digits and traps[Inexact] = True made the encode
raise, misclassifying a successful run as an exception. A fixed module-level
Context(prec=28) makes the spelling decision context-independent; a regression
case mutates both context knobs and asserts the float round-trips exactly.

The binding-reply README entry now states the fact (no seam-level cap;
maxValueBytes meters only the done frame; a wide reply is rebuilt and encoded
whole, bounded by process memory), matching the earlier reviewer wording.
2026-08-31 15:02:38 +08:00
Chinesezjc 666ff2855e docs(code-runtime-python): drop the placebo dispose test and finish the remaining doc drift
The review showed the added dispose case was a placebo (dispose in the same
tick as run means SIGTERM hits the group before the program body runs; the
group-emptied arm is already deterministically covered by the same-group
survivor case, which this removes the v8 ignore for). The test is deleted; the
stale silently-discards comment in the boundary test now says rejects; the
README Known Limitations gains the late-log-frame-drop and host-side
binding-value-memory entries. Pairing re-recorded.
2026-08-31 15:02:38 +08:00
Chinesezjc bc08f405cc test(code-runtime-python): pin the reap-poll group-emptied arm with a dispose-timing case
The review's premise that the group-emptied arm could not be pinned was
incorrect; the same-group reap case already exercises it. This adds the missing
seam-observable case: dispose() while a setsid orphan holds the pipes and the
run is unresolved — settle kills the child, the group empties (the orphan is in
its own session), and the poll finalizes promptly instead of waiting out the
60 s grace. The v8 ignore on that arm is removed.
2026-08-31 15:02:38 +08:00
Chinesezjc 9b7b5489be fix(code-runtime-python): cover the poll-group arm and align the last frame-cap comments
The review's remaining coverage gap: the group-emptied arm of pollGroup depends
on the close-driven settle winning the race against the grace SIGKILL, a timing
interleaving no seam-observable test pins deterministically (the same-group
cases assert the settle and the reap, not this exact interleaving) — the arm
now carries a v8 ignore with that reason. The load-check comment and the
FRAME_ENVELOPE_BYTES JSDoc say rejects-as-worker-exit instead of drops.
2026-08-31 15:02:38 +08:00
Chinesezjc d98965fbcc docs(code-runtime-python): remove the ack-gate v8 ignore and align the remaining doc drift
The forged-second-boot-ack regression makes the re-entry guard covered, so its
v8 ignore is removed. Doc drift: the python README and run() JSDoc state the
resolve-with-value/resolve-with-error contract without inversion; the README
Known Limitations gains the setsid-escaped-orphan entry (the settlement note
referenced it); the settlement note drops the stale drops/discard phrasing and
the two 256 MiB references; the fd-3 protocol zh note no longer claims the
codec is undelivered; the code-runtime seam README (en + zh) says both
backends ship. Pairings re-recorded.
2026-08-31 15:02:38 +08:00
Chinesezjc 3e0055edaf test(code-runtime-python): cover the boot-ack gate's re-entry guard and run-write failure
The review rejected the v8-ignore defense for the ack gate: a forged second
boot-ack is deterministically constructible (one os.write on fd 3) and the
run-write failure is deterministically constructible with the boot-write-failure
mock pattern. A program that forges an extra boot-ack asserts the run still
completes once (the gate does not re-send the run frame); a mocked child whose
fd-3 pipe accepts the boot frame but rejects the run write resolves a
worker-exit.
2026-08-31 15:00:33 +08:00
Chinesezjc cb26dd3804 test(code-runtime-python): pin the directory-skip in pythonBin resolution; cover the ack gate defenses
The resolvePythonBin directory branch now has a regression: a PATH whose first
entry is an executable DIRECTORY named python3 is skipped for a later real
interpreter (fail-before: without the isFile guard the directory would be
chosen and spawn would fail). The boot-ack gate's forged-second-ack re-entry
guard and its write-failure branch are covered by v8 ignore comments (the
honest child sends exactly one ack; the write failure needs the child to exit
between ack and write).
2026-08-31 15:00:32 +08:00
Chinesezjc 2b3b7f87dc fix(code-runtime-python): send the run frame after boot-ack; reject directories in pythonBin resolution
The review's two behavior items: the run frame was written back-to-back with
the boot frame (the seam contract puts run after boot-ack, which confirms the
namespaces were accepted); it now goes out from the boot-ack handler, so a
boot failure cannot race the run frame. resolvePythonBin now requires the
candidate to be a regular file — a directory passes X_OK and would otherwise
shadow a later real interpreter. Doc spots: the load-time overflow message
says worker-exit (not stranding to the wall clock), the run JSDoc spells out
the resolve-with-error contract, the PATH-stub test removes the stale v8
ignore, and the README's binding-value bullet names serialization cost.
2026-08-31 14:59:01 +08:00
Chinesezjc 4943524278 chore: commit the master third-party notices (SDK 0.3.241)
The local machine's node_modules still links claude-agent-sdk 0.3.220, so a
local gen-third-party-notices run rewrites the file to that version; CI's
fresh install resolves the lockfile's 0.3.241 and gen expects it. The branch
adds no third-party dependencies (the schemastery workspace link is already
covered), so the notices file adopts master's 0.3.241 content.
2026-08-31 14:59:01 +08:00
Chinesezjc 981ade7611 fix(code-runtime-python): restore the runtime's cross-package dependency declarations
The earlier merge had adopted master's protocol-only package.json (peer/dev
limited to invariants and cordis, no dependencies), but src/index.ts imports
@deepseek-ai/dsh-code-runtime, dsh-session, dsh-timeout, and schemastery at
runtime — a published lib/index.js could not resolve those bare specifiers.
The manifest now mirrors code-runtime-worker-thread (the five peers, the
schemastery dependency, and the matching dev set); the lockfile, module graph,
and third-party notices are regenerated, and the module-graph zh pair is
re-synced.
2026-08-31 14:59:01 +08:00
Chinesezjc aa123becf1 chore: regenerate the module graph after the sdk-runtime manifest change
Dropping the code-runtime-python peer from sdk-runtime changed the dependency
graph; gen-module-graph refreshes docs/module-graph.md.
2026-08-31 14:57:12 +08:00
Chinesezjc 1eacccc6e4 chore(sdk-runtime): adopt master's manifest (drop the code-runtime-python peer)
The branch's sdk-runtime manifest had carried a code-runtime-python workspace
peer that master's lockfile does not record, so a frozen install failed on the
mismatched specifier. The branch changes no sdk-runtime code, so it adopts
master's manifest verbatim.
2026-08-31 14:56:11 +08:00
Chinesezjc 65a4d64786 chore(code-runtime-python): adopt master's package.json peer dependencies
The earlier merge had kept the branch's older package.json while taking
master's lockfile, so a frozen install failed on mismatched specifiers for the
code-runtime-python package (master added dsh-code-runtime, dsh-session, and
dsh-timeout peers). The branch changes no dependencies, so it adopts master's
manifest verbatim.
2026-08-31 14:56:11 +08:00
Chinesezjc 5b90f4e2ac chore: adopt master's lockfile and third-party notices after the merge
The merge conflict on pnpm-lock.yaml had kept the branch's older dependency
resolutions; the coverage gate's notices check then failed because CI's frozen
install resolved the master lockfile's versions while the committed notices
still named the branch's older ones. The branch adds no dependencies, so it
adopts master's lockfile and notices verbatim.
2026-08-31 14:56:11 +08:00
Chinesezjc 6e8cc96351 docs(code-runtime-python): fix the doc-standard registry and README kind
The audited library registry still listed dsh-code-runtime-python as a plain
protocol library, but the shipped package's src/index.ts has a plugin default
export; the entry is removed from PACKAGE_LIBRARIES and both READMEs declare
kind: package-reference. The zh README heading is 概述 per the standard.
2026-08-31 14:56:11 +08:00
Chinesezjc af72ad4409 docs(code-runtime-python): rewrite the README to the repo documentation standard
The merge pulled master's README rewrite (front-matter, Summary, TOC, section
anchors, details-folding); its content described the pre-delivery protocol-only
package, contradicting the shipped backend. The README (en + zh) now follows
that structure with the delivered facts: PythonCodeRuntime, the fd-3 wire, the
load-validated caps, the 64 MiB frame parse cap (worker-exit settlement), and
the known limitations. Pairing re-recorded.
2026-08-31 14:56:11 +08:00
Chinesezjc 7fbf370d87 docs(code-runtime-python): drop the orphan receive-cap JSDoc and correct the frame comments
The review's three stale-comment items in index.ts: the orphan JSDoc above
FRAME_PARSE_CAP_BYTES (left over from the deleted receive ceiling), the
pre-join comment's change narration and its reference to a no-longer-existing
higher ceiling, and the first-frame comment's mention of a per-line cap check
that no longer exists. Test comments for the pythonBin and sealing-threshold
cases are weakened to their observable claims (both orders reject an over-cap
frame; the pythonBin case pins the contract, not a worker-exit distinction).
2026-08-31 14:55:02 +08:00
Chinesezjc ff87d9a00f docs(code-runtime-python): finish aligning the notes with the delivered runtime
The fd-3 protocol note (en + zh) drops the 'future provider/runtime' staging
language (the runtime is delivered and its real-subprocess suite owns the
field-type gap), and the settlement note's Testing paragraph records the
frame-cap, multi-frame, sealing-threshold, and pythonBin resolution cases now
in the suite. Pairings re-recorded.
2026-08-31 14:55:02 +08:00
Chinesezjc 4903f7da1f docs(code-runtime-python): align stale frame-ceiling prose with the 64 MiB parse cap; pin pythonBin resolution
The review's doc drift items: the orphan receive-ceiling JSDoc, the frame-ceiling
references in index.ts/bootstrap.py/tests, and the README's 'dropped, stranding
to the wall clock' phrasing (the run now settles as a worker-exit) are all
updated to the 64 MiB FRAME_PARSE_CAP_BYTES semantics; the README notes the
>64 MiB binding-argument residual as a worker-exit trip of the same cap. A
regression case resolves a basename pythonBin against a PATH whose first entry
is relative ('.') and asserts the absolute entry is used.
2026-08-31 14:54:18 +08:00
Chinesezjc d62b63d529 fix(code-runtime-python): skip relative PATH entries in pythonBin resolution; pin the sealing-threshold rejection
The review's remaining code items:
- resolvePythonBin now skips RELATIVE PATH segments (a bare 'bin' or '.'): the
  returned candidate must be absolute, because spawn() resolves a relative
  pythonBin against the host CWD, outside the seam contract.
- A deterministic-ish regression pins the sealing-threshold corner: 64 MiB of
  4 KiB (<= PIPE_BUF, atomic) newline-free writes plus 12289 more A's before
  the first newline make the first frame exceed FRAME_PARSE_CAP_BYTES; the
  newline-bearing chunk reaches the first-frame check (sealing is the ELSE
  half of the newline branch), so the run reports worker-exit with the
  protocol-frame-exceeded message.
2026-08-31 14:53:33 +08:00
Chinesezjc a715bfd111 fix(code-runtime-python): seal only newline-free runs so the first-frame check cannot be skipped
The review's sealing corner: the fragment-count seal ran before the newline
branch and did not exclude a newline-bearing chunk, so the 1024th chunk (the
first to carry a newline) was concatenated into a sealed block, pendingChunks
was emptied, sawNewline stayed false, and the first-frame check was skipped for
a join that then contained the newline. Sealing now runs as the ELSE half of
the newline branch, so a newline-bearing chunk always reaches the join and its
first-frame check, and the invariant 'sealed blocks hold newline-free prefixes
only' is true — which is what makes the removed per-line check genuinely dead.
2026-08-31 14:53:33 +08:00
Chinesezjc 4c3e453080 fix(code-runtime-python): drop the now-dead per-line cap check again
The pre-join counter (single unframed line) and the first-frame check
(newline-bearing chunk) reject any frame past FRAME_PARSE_CAP_BYTES before the
join, so every line reaching this loop is within the cap by construction — the
per-line check was dead code and its continue branch could never fire, failing
the per-file 100% coverage gate.
2026-08-31 14:53:33 +08:00
Chinesezjc 295e020ea4 fix(code-runtime-python): reject only an oversized FIRST frame before the join, not a multi-frame buffer
The pre-join check charged the whole unframed buffer, which legitimately holds
several frames each within FRAME_PARSE_CAP_BYTES: a first frame of exactly the
cap followed by a second frame crossed the counter and was misreported as a
worker-exit. The pre-join rejection now fires only while the held bytes are a
single unframed line (this chunk carries no newline); once a newline arrives,
a FIRST-FRAME check measures the bytes up to the first newline across the held
chunks (including sealed blocks) and rejects only that frame before the join —
keeping the peak at one copy of its wire bytes — while later frames in the
same buffer are handled by the restored per-line check. Regression cases: a
72 MiB newline-free buffer is rejected pre-join (fail-before: joining would
have doubled it); two within-cap frames whose combined buffer crosses the cap
both survive (fail-before: the unconditional counter check turns it red).
2026-08-31 14:53:33 +08:00
Chinesezjc abf81a0905 fix(code-runtime-python): drop the now-dead per-line parse cap check
The unframed-buffer counter guard runs before every join and guarantees each
line is within FRAME_PARSE_CAP_BYTES, so the line-loop cap check was dead code
(its continue branch could never fire, failing the per-file 100% coverage gate
on index.ts). Removed with a comment explaining the invariant.
2026-08-31 14:53:33 +08:00
Chinesezjc 219d216c54 test(code-runtime-python): move the frame-overflow cases to the 64 MiB parse cap
The pendingBytes guard now trips at FRAME_PARSE_CAP_BYTES (64 MiB) instead of
the 256 MiB wire ceiling, so the three tests that flood/pin frames against the
guard assert the 67108864 message and write a 64 MiB-based workload.
2026-08-31 14:53:33 +08:00
Chinesezjc c8139589d2 fix(code-runtime-python): reject an oversized unframed frame before the join; cap the rejection diagnostic
The review's remaining critical: the fd-3 data handler checked the unframed
counter against the 256 MiB wire ceiling, so a single 64-256 MiB frame was
fully Buffer.concat-joined (a second copy) and only then dropped in the line
loop — the peak-memory doubling the pre-join check exists to prevent, for a
frame the parser is guaranteed to discard. The counter is now checked against
FRAME_PARSE_CAP_BYTES before the join; the regression case asserts a worker-exit
with 'protocol frame exceeded' (fail-before: reverting to the ceiling turns it
green, proving the join path). FRAME_CEILING_BYTES is removed.

The rejection-cap fix now has its regression: a completion value whose class
name is 70 MiB of Ns asserts invalid-output, not worker-exit (fail-before:
uncapping the diagnostic turns it red).

The settlement note (en + zh) updates the remaining stale bound text, and the
fd-3 protocol note (en + zh) no longer claims protocol-only exports or a
missing Python codec. Pairings re-recorded.
2026-08-31 14:53:33 +08:00
Chinesezjc 3f8b45f9bb fix(code-runtime-python): cap the done-frame rejection diagnostic and sync stale docs
The review's remaining items:
- _done_with_value's rejection branch now caps the _check_done_value diagnostic
  through _cap_message (a reason embedding a hostile class name could otherwise
  push the done frame past the host's 64 MiB parse cap, misreporting an
  invalid-output run as a worker-exit).
- The settlement note (en + zh) updates three stale facts (load bound is now
  parse-cap minus envelope at 67108800; the sink goes directly through the
  bound primitives); the fd-3 protocol note (en + zh) no longer claims the
  package ships protocol without the runtime; FRAME_ENVELOPE_BYTES' JSDoc and
  _cap_message's docstring follow the new bound.
Pairings re-recorded.
2026-08-31 14:52:57 +08:00
Chinesezjc d90155714b docs(code-runtime-python): correct the sink comment and register the frame parse cap
The review's remaining warning: the _run binding comment claimed the log sink
went 'through the bound send', contradicting the sink's actual direct use of the
bound encode+write primitives. The comment now states that; the settlement note
(en + zh) registers FRAME_PARSE_CAP_BYTES and the 65 MiB-frame regression case.
Pairing re-recorded.
2026-08-31 14:52:01 +08:00
Chinesezjc fca41b78ea fix(code-runtime-python): bound the load-time budget to the frame parser cap
The review found the 64 MiB parse cap contradicted the load-time budget bound:
maxLogBytes/maxValueBytes could be configured up to ceiling - envelope (~256 MiB),
but the receive path silently dropped any frame past the 64 MiB parser cap, so an
honest child's budget-internal done frame under such a config would be discarded
and the run stranded to the wall clock. The load bound is now parse-cap -
envelope, so a configured budget always fits through the parser; the boundary
test moves to 64 MiB - 64. The >64 MiB model-constructed binding-argument drop
is registered as an accepted residual in the README (en + zh).
2026-08-31 14:51:20 +08:00
Chinesezjc ab40136b02 fix(code-runtime-python): cap the raw frame length before JSON.parse and bound the log sink
Addresses the review's remaining two items:
- FRAME_PARSE_CAP_BYTES (64 MiB) drops an fd-3 frame whose raw length exceeds
  it BEFORE toString/JSON.parse: the 256 MiB wire ceiling bounds the bytes, not
  the decoded structure, and a compact wide frame near that ceiling could decode
  to far more host memory. A regression test writes a 65 MiB log frame plus a
  normal one and asserts the oversized frame is dropped while the trailing frame
  still lands in logs (fail-before: without the cap the oversized text is parsed
  and admitted, truncating the ledger so the trailing frame is dropped). The
  forged-oversized lower-bound test's frame is reduced to stay under the cap
  while still exercising the truncation path.
- The log sink writes through the def-time bound encode+write primitives (not
  send_sync, whose body resolves _encode_json_plain and self.write_encoded at
  call time), so a rebind cannot break a log frame.
2026-08-31 14:50:40 +08:00
Chinesezjc 125306324f fix(code-runtime-python): write dispatch frames through def-time bound primitives
The review's remaining functional item: send_sync's body resolves
_encode_json_plain (module global) and self.write_encoded (class attribute) at
call time, so a program rebinding either before the first binding call could
turn a legitimate call into an exception. dispatch now writes the call frame
through def-time bound write_encoded+_encode_json_plain, and the log sink goes
through the bound send; the dispatch rebind test also rebinds those two names
(verified fail-before by reverting to send_sync). The annotation test title
matches its assertion direction, and the note (en + zh) registers the
error-class constructor, dispatch primitives, and dont_inherit mechanisms.
Pairing re-recorded.
2026-08-31 14:50:39 +08:00
Chinesezjc c4c79f094d test(code-runtime-python): pin the error-class constructor and dispatch primitives with rebind cases
The review required regression cases for the two cfb35bef6 fixes:
- Rebinding __main__.Exception/__main__.setattr must not break the minted
  error class: a host rejection still surfaces as ToolCallError with the member
  property readable.
- Rebinding __main__._lossless_json_violation/__main__.asyncio/
  __main__.ProtocolChannel.send_sync must not break dispatch: a legitimate
  binding call still round-trips.
2026-08-31 14:49:54 +08:00
Chinesezjc 44205c4949 fix(code-runtime-python): stop the program's compile from inheriting the module's future annotations
bootstrap.py imports from __future__ import annotations; compile(wrapped) was
inheriting that PEP 563 flag, stringifying the program's type annotations and
changing the semantics of a legal program that reads f.__annotations__ at
runtime. compile(..., dont_inherit=True) stops the leak; a regression test
defines an annotated function and asserts the annotation is the live int class,
verified fail-before by removing dont_inherit (the test turns red).
2026-08-31 14:49:54 +08:00
Chinesezjc 6a659df999 fix(code-runtime-python): capture the error-class constructor and dispatch primitives
The review's remaining items:
- _make_error_class captures Exception and setattr as def-time defaults, so a
  rebind of __main__.Exception/__main__.setattr cannot break the rejection
  constructor.
- dispatch binds _lossless_json_violation, asyncio.get_event_loop, and the
  channel's send method into _run locals before the program runs, so a rebind
  cannot turn a legitimate binding call into an exception or a wall-clock
  timeout.
- The note (en + zh) corrects the stdin coverage phrasing: d3f9f57f5's direct
  EOF-observing case is the in-tree pin, not an approximation.
- Collapse two stray double blank lines in the test file.
Pairing re-recorded.
2026-08-31 14:49:54 +08:00
Chinesezjc c4d6c25ffc test(code-runtime-python): pin the pump reader against a class-attribute rebind; correct the staging comment
The review's three remaining items:
- A regression test rebinds __main__.ProtocolChannel.read_frame_async and asserts
  a binding reply still round-trips (the pump's reader is a bound method
  captured by _run before the program runs).
- The settlement note (en + zh) records that send_done's frame-shape check uses
  _run's bound _str/_isinstance.
- The staging-removal comment no longer claims teardown retries tracked state:
  teardown deliberately does not sweep staging, so a removal failure is the one
  case the gone-by-settlement contract degrades on.
Pairing re-recorded.
2026-08-31 14:49:15 +08:00
Chinesezjc 8ed96b1560 docs(code-runtime-python): register the frame-reader capture extensions in the note
The review flagged that the implemented note's capture-family enumeration had
not followed c0ca236b5: read_frame/read_frame_async now also capture len (and
asyncio.get_event_loop on the async reader), _decode_json_plain captures
isinstance/str/list, and the reply pump's frame reader is injected as a bound
method captured by _run before the program runs. Note (en + zh) updated;
pairing re-recorded.
2026-08-31 14:48:36 +08:00
Chinesezjc aa685028a7 test(code-runtime-python): pin the stdin-close behavior with an EOF-observing case
The stdin destroy (child.stdin?.destroy() right after spawn) previously had no
in-tree coverage. A program that reads fd 0 now sees EOF immediately; without
the destroy it blocks and the run would hang to maxWallMs as a timeout —
verified fail-before by disabling the destroy (the test turns red at the wall
ceiling) and restoring it (green). The _str rebind regression was attempted but
is not viable: the success path's done-frame serialization reaches str
transitively through _encode_json_plain, which the README Known Limitations
already records as the accepted success-to-exception residual, so any rebind
test trips that documented residual before send_done's bound _str.
2026-08-31 14:47:57 +08:00
Chinesezjc 302bbb0f8f fix(code-runtime-python): close the remaining call-time lookup gaps in the reply and settlement paths
The review's completeness check found the def-time capture pattern was not yet
applied to every name the reply/settlement paths resolve at call time:
- _decode_json_plain now also captures isinstance/str/list.
- read_frame/read_frame_async capture len; read_frame_async captures
  asyncio.get_event_loop.
- send_done uses _run's bound _str/_isinstance for its frame-shape check.
- The reply pump's frame reader is a bound method captured by _run BEFORE the
  program runs and passed into _pump_replies, so a rebind of the class
  attribute cannot redirect it.
The decode-rebind regression test still pins the _decode_json_plain rebind;
rebinding builtins (len/isinstance/list/str) in a test is not viable because
the Python runtime itself resolves them implicitly.
2026-08-31 14:47:57 +08:00
Chinesezjc aa5e8fc345 fix(code-runtime-python): suppress the unnecessary-optional-chain lint for the stdin destroy
The boot-write-failure fake child carries no stdin at runtime, so the optional
call is the documented guard; the static type (ChildProcessWithoutNullStreams)
says stdin is non-null, which trips the no-unnecessary-condition lint.
2026-08-31 14:47:57 +08:00
Chinesezjc aecdec3f80 fix(code-runtime-python): guard the stdin destroy against a spawn-failure child
The boot-write-failure path's fake child carries no stdin handle, so the
unconditional destroy threw inside the spawn error handler and mislabeled the
worker-exit. Use the optional-call form; the no-stdin branch is exercised by
that same test.
2026-08-31 14:47:57 +08:00
Chinesezjc 40fbf92290 fix(code-runtime-python): close the child stdin handle and def-time capture the frame decode primitives
Addresses the review's two remaining items:
- The host closes the child's stdin write handle immediately after spawn. The
  program is an async body that reads nothing from fd 0; a live pipe would hold
  a host-side handle open past the run, so a setsid-escaped descendant
  inheriting fd 0 could keep the host process from exiting even after the
  closeDeadline forced settlement. The child (and any descendant) reads EOF on
  fd 0 and no host handle survives.
- read_frame/read_frame_async bind their decode primitives (_decode_json_plain,
  os.read, _READ_CHUNK_BYTES, bytes) as def-time default arguments, and
  _decode_json_plain itself captures json.loads, its two regexes, and len the
  same way, so a __main__ rebind cannot kill the reply pump and strand every
  pending Future to the wall clock. _decode_json_plain and its regexes moved
  before the ProtocolChannel class so the defaults resolve at class-definition
  time. A regression test rebinds _decode_json_plain and asserts a binding reply
  still round-trips.
Note (en + zh) registers both mechanisms; pairings re-recorded.
2026-08-31 14:47:57 +08:00
Chinesezjc ac64039843 fix(code-runtime-python): bind str for dispatch's rejection message conversion
The review's remaining non-blocking suggestion: dispatch's call_failure(str(exc))
resolved the builtin str at call time, so a program rebinding __main__.str could
run a hostile callable when the binding-rejection message is formatted. Bind
_str into _run locals and use it in dispatch.
2026-08-31 14:47:18 +08:00
Chinesezjc 937ada4837 fix(code-runtime-python): bind RuntimeError and _BindingRejection for dispatch's rejection path
dispatch's call_failure and its except clause resolved the module globals at
call time, so a program rebinding __main__._BindingRejection = ValueError let
the internal marker type leak into model code. Bind _RuntimeError_cls and
_BindingRejection_cls into _run locals before the program runs (names distinct
from the module globals so the assignment RHS resolves the global, not an
unbound local); dispatch now uses the locals. A regression test rebinds
_BindingRejection and asserts a host rejection still surfaces as RuntimeError.

The sys.__stdout__ flush test now reconfigures the streams back to block
buffering (write_through=False) so the settlement drain path is what the case
pins — verified fail-before: binding the stream objects instead of their flush
methods turns the test red.
2026-08-31 14:47:18 +08:00
Chinesezjc 1efb0094c8 fix(code-runtime-python): bind the original std streams' flush methods, not the stream objects
The settlement drain iterated the bound stream OBJECTS, which are not
callable — every _flush() raised TypeError and was swallowed by the loop's
except, so the drain never ran and only the -u flag carried the behavior.
Bind sys.__stdout__.flush/sys.__stderr__.flush (bound methods, capturing the
stream at binding time, immune to a later sys.__stdout__ rebind; None-guarded).
Verified by removing -u temporarily: the sys.__stdout__ regression test still
passes, so the drain is a genuine backstop, not a documented-but-dead layer.
2026-08-31 14:47:18 +08:00
Chinesezjc 43a0879ad1 fix(code-runtime-python): clear stray buffers on truncation and drain the original std streams
Addresses the review's two carried warnings and the comment suggestion:
- Once the ledger truncates, every arm that marks it (admit()'s two ceilings and
  the child-marker frame arm) now clears both stray pipes' buffered output
  wholesale, so the end-path flushStray sees empty buffers instead of
  concat+decoding doomed data near a 256 MiB maxLogBytes; captureStray's newline
  loop re-checks the flag before re-retaining the residual.
- The child runs with -u (unbuffered), so sys.__stdout__/sys.__stderr__ writes
  are visible to stray capture immediately; the settlement flush still drains
  the original std streams before the done frame as a guard. A regression test
  writes through sys.__stdout__/sys.__stderr__ without an explicit flush and
  asserts both bytes land in logs. C-ext stdio remains an accepted residual,
  recorded in the README Known Limitations (en + zh).
- The ledger-comment arithmetic now states the exact boundary (serializes to
  exactly maxLogBytes; without the reserved byte it would be maxLogBytes + 1)
  in both host and child.
Note (en + zh) registers the stray-clear and -u/settlement-drain mechanisms and
the new test; pairings re-recorded; corpus passes 1029.
2026-08-31 14:47:18 +08:00
Chinesezjc 4c7811812d docs(code-runtime-python): state the macOS killGroup behavior directly and complete the residual sentence
The review flagged the change-narrative wording 'degrades to the pre-existing
behavior' (prohibited by docs/AGENTS.md) in four spots — README en/zh, the
readProcessStart JSDoc, and the test comment — and the incomplete :77 residual
sentence ('can still' with no verb complement). Reword the four to a direct
statement of current behavior (killGroup signals the pgid without the identity
re-check on macOS), complete the residual sentence with the actual consequence,
and re-record both pairings. Corpus-wide verify-translation-pairing passes 1029.
2026-08-31 14:46:31 +08:00
Chinesezjc e0d552fa86 docs: regenerate config-catalog with the python backend config and align the zh side
The master merge brought a stale generated config-catalog that omitted the
dsh-code-runtime-python config section and mislisted the package. Regenerate
docs/config-catalog.md (verify-config-catalog passes), translate the python
config section into zh, keep the ts config-catalog code blocks verbatim
(untranslated, per the pairing rule), and drop the stray zh Library-packages
line. Corpus-wide verify-translation-pairing passes 1029.
2026-08-31 14:45:57 +08:00
Chinesezjc 203110a90c chore: re-trigger pull_request synchronize for CI 2026-08-31 14:44:19 +08:00
Chinesezjc 9af1e5e9f0 fix(code-runtime-python): correct the log-budget floor to 64 and record the marker envelope bound
The review found the 62 floor off by two (the marker's fixed prefix is 51
characters counting both square brackets, so marker(62) serializes to 63) and
the constructor error over-claiming a bound the marker-as-envelope design does
not deliver. Fixes:
- MIN_LOG_BYTES is 64 (marker-only serialization fits with one byte of room);
  the JSDoc arithmetic counts the brackets; the rejection test pins 63; the
  forged-frame test uses 11 NULs (69 escaped) at 64.
- The constructor error now states the marker-only guarantee, and the README
  Known Limitations (en + zh) records the real bound: a truncated run with
  admitted entries serializes its logs to maxLogBytes + marker + envelope.
- The SIGXCPU-mask tests burn with time.process_time() instead of wall-clock
  perf_counter, so a contended CI runner cannot under-burn the budget.
- The settlement note (en + zh) records the 64 floor and the marker envelope
  bound, including the zh pre-encode section that the earlier pass missed.
- The README constructor-rejection list names the maxLogBytes floor.
Pairings re-recorded; corpus-wide verify-translation-pairing passes 1004.
2026-08-31 14:44:19 +08:00
Chinesezjc 51d57cca03 docs(code-runtime-python): register the log-envelope reservation, SIGXCPU unblock, budget floor, and syntax label
The review flagged four mechanism changes shipped without note registration:
- Log ledgers start one byte below the budget (outer-array envelope reservation)
  and the constructor floors maxLogBytes at 62 (the smallest budget that can
  serialize its own truncation marker plus the envelope).
- die_if_cpu_exhausted restores SIG_DFL before unblocking a program-masked
  SIGXCPU, so a trap+mask program cannot run a re-masking handler at the unblock.
- ast.parse passes filename="<model>" so parse-time syntax diagnostics share the
  compile/runtime source label.
Decision and Testing (en + zh) now record all four with their fail-before cases
(exact-limit, budget rejection, syntax label, SIGXCPU-mask, trap+mask); pairing
re-recorded and consistent.
2026-08-31 14:42:28 +08:00
Chinesezjc 4a8c49f78c fix(code-runtime-python): restore SIGXCPU disposition before unblocking and floor the budgets
Addresses the review's two code warnings and one suggestion:
- die_if_cpu_exhausted now restores SIG_DFL BEFORE unblocking SIGXCPU: a program
  that installed a custom handler AND masked the signal would otherwise have
  that pending handler run at the unblock (in model code, re-masking or raising)
  and escape the re-raise; with SIG_DFL first the pending signal kills inside
  the kernel with no bytecode window. A trap+mask combined regression test pins
  it (the mask-only case was already covered).
- The constructor rejects budgets too small to honor: maxLogBytes must fit the
  truncation marker plus the serialized outer-array envelope (floor 64), and
  maxValueBytes must at least represent the smallest JSON completion (floor 4,
  matching the worker backend). The exact-limit test moves to the 64 floor and
  a rejection test pins the floors.
- The pthread_sigmask None-guard comment cites the real rationale (defensive
  against stripped CPython builds; win32 is refused at construction), not the
  unreachable Windows path.
2026-08-31 14:41:49 +08:00
Chinesezjc a2eda792e3 docs(code-runtime-python): align the accepted-residual dep list across README and note
The residual bullets listed the encoder's transitive deps as an exhaustive set
but disagreed with each other and omitted io. Mark the list as a non-exhaustive
example (e.g. _dump_scalar/_dump_string/json/io) in the README (en + zh) and the
settlement note (en + zh); pairings re-recorded and consistent.
2026-08-31 14:41:49 +08:00
Chinesezjc 4e0d77c1d6 fix(code-runtime-python): reserve the log array envelope byte, unblock SIGXCPU before re-raise
Addresses the review's two remaining code warnings and the three suggestions:
- Log ledgers (host and child) start one byte below the budget, reserving the
  serialized outer-array envelope (two brackets and n-1 commas over n entries'
  separators); the exact-zero test moves to maxLogBytes 104 and a new exact-limit
  case pins that maxLogBytes 5 admits ['a'] (5 bytes) while 4 truncates to the
  marker alone.
- die_if_cpu_exhausted unblocks SIGXCPU (pthread_sigmask SIG_UNBLOCK, captured at
  import, None-guarded for Windows) before re-delivering it, so a program that
  masks SIGXCPU, burns past the soft limit, and returns is still classified as a
  timeout; a regression test pins the masked path.
- ast.parse passes filename="<model>" so parse-time syntax diagnostics carry the
  same source label as compile and runtime tracebacks; the syntax-error test
  asserts the label.
- The NUL-escape test comments use the true six-byte JSON escape \u0000 instead
  of the caret notation; the README Known Limitations (en + zh) records that
  PID-reuse protection is inert on macOS; a combined-rebind regression test pins
  BaseException plus the traceback reporter rebinding together.
2026-08-31 14:41:08 +08:00
Chinesezjc 96597c5ed8 fix(code-runtime-python): bind the _done_with_value entry name and correct the residual documentation
Addresses the review's registration-text accuracy findings:
- _run binds _done_with_value into a local (done_with_value_bound) before the
  program runs, closing the __main__._done_with_value = boom success-rewrite
  vector; a regression test rebinds it and returns a legitimate value, asserting
  the success survives.
- README (en + zh): the CPU-recheck bullet now states the recheck runs
  unconditionally after the program returns (a pre-return overrun dies there as
  a timeout) and the false-success window is only a trap-SIGXCPU program that
  passes the recheck and overruns during the settlement flush/encode; the
  encoder-deps residual rationale is replaced with the actual one (bash-equivalent
  trust, verdict still delivered via the send_done fallback frame) and names the
  now-bound entry; the t.join() deadlock bullet fixes the subject/object (the
  main coroutine joins the worker, blocking the pump's main event loop).
- The portable-identifier-seam architecture note no longer claims the Python
  backend does not exist.
- Settlement note (en + zh) registers the entry-name binding and the new test.
- All pairings re-recorded; corpus-wide verify-translation-pairing passes 1004.
2026-08-31 14:40:35 +08:00
Turtle 0e7c769540 Merge remote-tracking branch 'origin/master' into turtle/pr-864-master-port 2026-08-31 14:39:13 +08:00
Chinesezjc e6b23e829b docs(code-runtime-python): split the deadlock into its own bullet and qualify the done_value claim
Addresses the review's two registration-text accuracy findings:
- The cross-thread t.join() deadlock is a process-isolation-backend property (the
  pump runs on the child's main event loop), so it is split out of the wide-binding
  REPLY bullet into its own Known Limitations entry with the correct attribution
  (fix belongs in this backend, not packages/core/session); the zh half-width
  space is removed.
- The settlement note's _done_with_value def-time default-arg sentence is
  qualified: it guards a rebind of _check_done_value/_encode_json_plain, while a
  transitive encoder dep (_dump_scalar/io) rebind can still downgrade, which is
  registered as an accepted residual in the package README.
Pairing re-recorded; corpus-wide verify-translation-pairing passes 1004.
2026-08-31 14:39:07 +08:00
Turtle b8e3b32fcf Merge master into codex/omit-unneeded-invariants 2026-08-31 14:38:55 +08:00
Chinesezjc c8bc96007b docs(code-runtime-python): register the CPU-recheck and encoder-deps accepted residuals
Document the two remaining keep-current residuals in the python package README
Known Limitations (en + zh), per the review's accepted-resolution path:
- A trap-SIGXCPU program can exceed the soft CPU limit during settlement encoding
  and still report success (containment holds via hard +1s and wall clock; only
  the classification is degraded, because the recheck cannot meter mid-encode).
- The encoder's direct deps (_dump_scalar/_dump_string/json) resolve at call
  time, so a __main__ rebind after a legit return can downgrade success to
  exception; the value path's top-level deps are def-time bound, the transitive
  ones are an accepted residual.
Pairing re-recorded and consistent.
2026-08-31 14:37:52 +08:00
Chinesezjc f79e53e74c docs(code-runtime-python): align the note consequences, register the deadlock and default-arg mechanisms
Addresses the bot's keep-current findings:
- The settlement note distinguishes the BaseException (lost done frame) and
  RuntimeError (pump killed -> replies stranded to the wall clock) consequences;
  registers the _done_with_value def-time default-arg capture and the new
  RuntimeError-rebind closed-loop test; zh:95 half-width space fixed.
- The python package README Known Limitations records the cross-thread binding +
  sync t.join() deadlock (en + zh).
- The code-runtime Service Definition README no longer claims only the
  worker-thread backend ships: the Python (process) backend is acknowledged,
  with 'container' as future work (en + zh).
- All pairings re-recorded; corpus-wide verify-translation-pairing passes 1002.
2026-08-31 14:36:31 +08:00
Chinesezjc 923fb56128 fix(code-runtime-python): bind the reply-pump exception names as def-time default arguments
A body-local X = X binding in _pump_replies is too late: _run reaches the
model's top-level statements (which run first, since there is no suspension
point between create_task and await __dsh_main__) before the pump's first step,
so a __main__.RuntimeError rebind there would be captured by the body local and
a closed-loop failure would escape the except, killing the pump. Bind
_RuntimeError, _BindingRejection, str, and bool as DEF-TIME default arguments of
_pump_replies (evaluated at import, before any model code runs). Add a regression
test that rebinds __main__.RuntimeError as the first program statement and drives
the closed-loop worker pattern, asserting the pump survives and delivers the
later binding. Update the settlement note (en + zh) to describe the default-arg
capture; pairing re-recorded and consistent.
2026-08-31 14:34:09 +08:00
Chinesezjc b018abf405 fix(code-runtime-python): bind the pump RuntimeError after its docstring and _done_with_value deps as defaults
- The reply pump's _RuntimeError binding is placed after the function docstring
  (so the docstring remains the __doc__) and the dead _run-side binding is
  removed. _done_with_value binds _check_done_value/_encode_json_plain as
  default arguments so a __main__ rebind after model execution cannot rewrite a
  success into an exception.

The _str/_bool/_BindingRejection pump bindings were attempted but break the
closed-loop pump test (the self-referential _BindingRejection local interferes
with the closure), so they are left unbound; rebinding those names (builtins and
one internal class) is outside the practical threat model.
2026-08-31 14:34:09 +08:00
Chinesezjc 2d82b658ba fix(code-runtime-python): bind RuntimeError inside the module-level _pump_replies
The previous commit bound _RuntimeError in _run, but _pump_replies is a separate
module-level function, so its except _RuntimeError referenced an out-of-scope
local and raised NameError instead of catching the closed-loop failure — killing
the pump and timing out the run. Bind _RuntimeError at the top of _pump_replies
too. The closed-loop pump test now passes.
2026-08-31 14:34:09 +08:00
Chinesezjc bcc11f1235 fix(code-runtime-python): bind RuntimeError for the reply pump catch and note the exception-class locals
The reply pump's except RuntimeError resolved the module global at runtime, so a
__main__.RuntimeError rebind could make a closed-loop scheduling failure escape
the catch, killing the pump and stranding every later reply. Bind RuntimeError
into a _run local alongside BaseException and catch the local. The settlement
note Decision now records that the exception classes the settlement-path except
clauses catch are bound into locals / a closure cell before model code runs
(en + zh); pairing re-recorded and consistent.
2026-08-31 14:34:09 +08:00
Chinesezjc 69dc17c906 fix(code-runtime-python): bind BaseException into every settlement-path except clause
The rebindable-BaseException vector the bot flagged existed in every except
clause of the settlement path, not just the _run outer catch: safe_model_traceback
(three guards) and the post-done flush swallow resolved the module-global
BaseException at runtime, so a __main__.BaseException rebind plus a throwing
__str__ could let a render-time exception escape and lose the done frame. Bind
BaseException into a _run local (at the top) and a closure cell in
_make_failure_reporter, and change every such except clause to catch the local
— immune to a one-line rebind.
2026-08-31 14:34:09 +08:00
Chinesezjc d5945546c7 docs(code-runtime-python): complete the zh no-fail-before enumeration and unify the seal naming
The zh Consequences section counted ten but enumerated only nine; add the
log-fragment seal as the 10th no-fail-before item. Also unify the term to
'封存' (matching the Decision/Testing sections) instead of '封口'. Pairing
re-recorded and consistent.
2026-08-31 14:34:09 +08:00
Chinesezjc 0102cd95bf fix(code-runtime-python): catch the model exception with a pre-program local exception class
The _run outer try/except used the module-global BaseException, which the
program (running as __main__) can rebind: __main__.BaseException = RuntimeError
made the except resolve to RuntimeError, so a subsequent ValueError escaped _run
with no done frame and misreported the run as worker-exit. Bind BaseException
into a _run local before the program runs so the catch is immune; a regression
test rebinds BaseException and raises, asserting an exception, not a worker-exit.

Also correct the NUL-escape comment text: the JSON escape-result side is \^@ (6
bytes, the valid JSON NUL escape), not \x00, so the 6x-budget arithmetic in the
comments is self-consistent. Register the BaseException-rebind case in the
settlement note Testing (en + zh) and re-record the pairing.
2026-08-31 14:34:09 +08:00
Chinesezjc 202c428137 docs(code-runtime-python): correct the fallback-mechanism wording and the no-fail-before count
Addresses the bot's keep-current review findings:
- The module-level fallback comment now states the mechanism truthfully: the
  module globals are RAW primitives bound into _run LOCALS before the program
  runs (the immunity lives in the frame-local binding, not the module global);
  and the fallback literal <unrenderable> is distinguished from the failure
  reporter's _UNRENDERABLE_DIAGNOSTIC text.
- The settlement note's fallback mechanism wording, the transitive-name rebind
  case (now listing the three fallback primitives), and the no-fail-before count
  are aligned en/zh; the zh Problem paste damage is fixed and the Consequences
  count is ten with the 10th item.
- Pairing re-recorded and consistent.
2026-08-31 14:34:09 +08:00
Chinesezjc d3e34d5612 test(code-runtime-python): replace literal NUL bytes in comments with the escape text
The comments describing NUL serialization contained literal NUL bytes, which
interfere with source tooling. Use the \x00 escape text instead.
2026-08-31 14:34:09 +08:00
Chinesezjc fe3ba24057 docs(code-runtime-python): update the no-fail-before count to ten and document the hard==1 CPU blind spot
Addresses the keep-current review findings:
- The settlement note's Problem/Consequences count is nine -> ten, adding the
  log-fragment seal to the no-fail-before enumeration (its 25 M-scale OOM is not
  deterministically constructible in CI); the new Decision section title now
  names all four mechanisms and the double blank line is removed.
- README Known Limitations (en + zh) documents the 1-second dual-limit
  ulimit -t 1 CPU overrun being reported as worker-exit (the hard >= 2 guard
  cannot lower a 1-second soft to 0); pairings re-recorded and consistent.
2026-08-31 14:34:09 +08:00
Chinesezjc 4ff050de71 fix(code-runtime-python): bind the send_done fallback primitives into locals and use a bare except
The done-frame fallback read _os_write/_memoryview/_FALLBACK_DONE_FRAME as module
globals at call time, so a single-line rebind of any of them reopened the
rebind hole the fallback exists to close. Bind them into _run locals before the
program runs, and use a bare except (which catches everything without naming
BaseException, so a rebind of that name cannot defeat the handler). The
transitive-name rebind test now also rebinds _os_write/_memoryview/
_FALLBACK_DONE_FRAME to pin the fallback's immunity.
2026-08-31 14:33:31 +08:00
Chinesezjc 31c3b425bf docs(code-runtime-python): register the fragment-seal, CPU soft-lowering, and done-send fallback fixes
Keep the settlement note current with the latest code-review fixes:
- New Decision section for the _LogStream fragment seal, the _clamped
  RLIMIT_CPU soft-lowering (and its hard==1 blind spot), the send_done
  fallback frame, and the reply-queue slot release.
- Testing registers the fragment-cap drip (no-fail-before), the dual-limit CPU
  overrun, and the transitive-name rebind cases.
- zh mirrored; settlement-fixes.i18n.yaml re-recorded and consistent.
2026-08-31 14:33:31 +08:00
Chinesezjc 9b29d0226e fix(code-runtime-python): make the log seal incremental, scope the soft-lowering to RLIMIT_CPU, and capture memoryview
Addresses the bot's follow-up review findings on the settlement-path fixes:
- The _LogStream seal joined the WHOLE accumulated buffer past the fragment cap,
  re-copying the growing block O(B^2/cap) times for a large drip. It now seals
  only the current fragments into a _pending_blocks entry (character count
  unchanged), so a 25 M single-character drip stays O(B); the newline/flush/
  _push_bounded_prefix consumers join blocks + fragments once.
- The _clamped soft==hard lowering is scoped to RLIMIT_CPU: for RLIMIT_AS a
  one-byte soft differential would only misalign the child's applied limit with
  the host-side budget gate, with no signal to preserve. The hard == 1 blind
  spot is documented.
- send_done's fallback captures memoryview at import (_memoryview) alongside
  os.write, so a one-line rebind of the name cannot change the fallback write;
  the comment now states the module-level-captured mechanism.
2026-08-31 14:33:31 +08:00
Chinesezjc 72241b9f06 test(code-runtime-python): correct the dual-limit CPU overrun assertion and use a hard limit >= 2
The dual-limit CPU test used ulimit -t 1 (hard == 1), which the _clamped
soft-lowering guard (hard >= 2) intentionally does not lower, and trapped
SIGXCPU (which defeats the fix). Use ulimit -t 2 (hard == 2, so the soft is
lowered to 1) and leave SIGXCPU unhandled; the run then classifies as a timeout.
The message is the CPU-time-exhausted diagnostic, not the literal 'SIGXCPU'.
2026-08-31 14:33:31 +08:00
Chinesezjc dcbce50ec2 fix(code-runtime-python): close the log-fragment OOM, CPU classification, and done-send transitive-dependency findings
Addresses the bot's v16 review on the settlement-path code:
- critical: _LogStream._pending now seals the fragment list past a chunk cap
  (like the host captureStray seal), so a newline-free single-character drip no
  longer accumulates one list slot per write and OOMs on its own accounting.
- _clamped lowers a soft==hard result by one unit (when hard >= 2) so a
  dual-limit ulimit -t leaves SIGXCPU a window to fire and a definite CPU
  overrun is reported as a timeout, not a worker-exit.
- send_done wraps its encode+write in a try and, on any throw from a rebound
  transitive name (_dump_scalar/os), writes a fixed pre-encoded done frame via
  the import-time captured os.write, so a settled exception verdict is never
  downgraded to worker-exit.
- drainReplies clears the consumed replyQueue slot so a wide written payload is
  released immediately, bounding host memory to the current backlog under
  sustained fd-3 backpressure.
Tests added for each (fragment cap drip, dual-limit CPU overrun, transitive-name
rebind done frame).
2026-08-31 14:33:31 +08:00
Chinesezjc add4a2fb6f docs(code-runtime-python): clarify that the binding-all-names case is the fixture that rebinds the send names
The Testing sentence's subject attached the three rebinds to 'the fix' rather than
to the fixture that performs them; reword to 'pinned by a case that rebinds' and
mirror zh ('由一个…用例钉住'), re-recording the pairing.
2026-08-31 14:33:31 +08:00
Chinesezjc 7198234a82 test(code-runtime-python): pin send_done against rebinding write_encoded and _encode_json_plain
The rebinds-every-name fixture previously only rebound ProtocolChannel.send_sync,
which a bound method object ignores and the shipped send_done no longer calls —
so it did not actually guard the call-time-lookup shape. Rebind write_encoded
and _encode_json_plain too (the names send_done would resolve late if it looked
them up at call time) and state that in the settlement note's Testing section
(en + zh), re-recording the pairing.
2026-08-31 14:33:31 +08:00
Chinesezjc 093a6217ff docs(code-runtime-python): register the pre-encode, stray-flush, and late-rejection fixes in the settlement note
Keep the agent note current with the recently landed code-review fixes:
- six -> nine no-fail-before cases, adding the done-value TOCTOU pre-encoding,
  the stray-UTF-8 budget-flush retention, and the late-rejection settled guard,
  each with its reason for not carrying a fail-before test.
- New Decision sections for the pre-encode + send_done binding and the stray
  flush retention; Testing lists the binding-all-names case as a tested fix.
- zh mirrored; settlement-fixes.i18n.yaml re-recorded and consistent.
2026-08-31 14:33:31 +08:00
Chinesezjc da38c16912 fix(code-runtime-python): drain the reply queue by head cursor, not shift()
Each shift() re-slices the remaining array, so draining a large gather of
wide bindings awaiting fd 3's drain was O(n^2). Reading by a head index into
the array keeps the drain linear; the finally still discards everything.
2026-08-31 14:33:31 +08:00
Chinesezjc e0e1aa307d fix(code-runtime-python): bind encode/write for send_done and correct stray-flush retention
Addresses the follow-up review findings on the settlement-path fixes:
- send_done now routes both the pre-encoded VALUE frame and the dict ERROR
  frame through a bound _encode_json_plain + bound write_encoded, never through
  channel.send_sync (whose body re-resolves self.write_encoded and the module
  _encode_json_plain at call time) — a program rebinding ProtocolChannel.
  write_encoded or __main__._encode_json_plain no longer skips the done frame.
- flushStray retention re-accrues the withheld multibyte tail from a FRESH
  utf8 state (previously metering the carried lead against the post-flush
  expected>0 state charged it as an illegal continuation), and skips admitting
  when the whole residual drained into the retained tail so no bogus empty
  entry is pushed.
2026-08-31 14:33:31 +08:00
Chinesezjc be0551f52f fix(code-runtime-python): suppress no-unnecessary-condition on the late-rejection settled guard 2026-08-31 14:33:31 +08:00
Chinesezjc 9e6f279040 fix(code-runtime-python): drop the sealed-blocks ternary in the stray flush to hold 100% branch coverage 2026-08-31 14:33:31 +08:00
Chinesezjc 6634d4800c fix(code-runtime-python): bind done-send callables and cover the stray-flush retention
Corrections to the settlement-path review fixes:
- send_done was invoking channel.send_sync / channel.write_encoded via a late
  method look-up, which a program running as __main__ could rebind through
  __main__.ProtocolChannel.send_sync before the failure path ran — a rebound
  send that raises then skipped the done frame and downgraded a settled
  exception to worker-exit. Bind both channel methods into locals before the
  program runs, mirroring the pre-existing binding of flush_out/flush_err/
  safe_model_traceback.
- Restructure flushStray so the mid-sequence budget-flush retention arm is a
  self-contained v8-ignored branch and the covered default path decodes the
  full residual (not schedulable-through-the-seam boundary).
2026-08-31 14:33:31 +08:00
Chinesezjc f71914ceea fix(code-runtime-python): close four settlement-path review findings
Pace-free completion framing, stray UTF-8 flush, and late-rejection guards:
- Pre-encode the completion value at its validation point so send_done never
  re-walks a live value a mutating daemon thread could change (TOCTOU); a
  mutation-induced encode throw is then classified as 'exception', not a
  host-side worker-exit.
- Budget-triggered stray flush retains an incomplete multibyte UTF-8 tail
  (<=3 bytes) as residual instead of decoding a legal, split character to
  U+FFFD in an admitted entry; the end/closeDeadline paths still full-decode.
- Check 'settled' before formatting a late binding rejection's message, so a
  hostile message getter cannot stall or exhaust a run that already settled.
- Document _check_done_value's first-to-trip ruling in its docstring.
- Rewrite ProtocolChannel.send_sync around a shared write_encoded that the
  done frame's pre-encoded string path uses.
2026-08-31 14:33:31 +08:00
Chinesezjc 06e47b299e docs(code-runtime-python): drop the dangling list-conjunction in the six-item note enumeration 2026-08-31 14:33:31 +08:00
Chinesezjc 117ca8cb67 docs(code-runtime-python): register paced-replies and late-drop in the settlement note 2026-08-31 14:33:31 +08:00
Chinesezjc 441ebd0433 test(code-runtime-python): exempt the mid-drain settle branch from coverage
The drain loop's `if (settled) break` needs the run to settle in the window
between two queued frames. A file probe on the concurrent-replies case shows the
queue does reach depth 11, but the wall clock never lands inside that window, so
the branch is not schedulable from a test; a case written to force it passed
without ever executing the line, so it is removed rather than left as coverage it
does not provide. The branch carries a v8 ignore naming what is unreachable.
2026-08-31 14:33:31 +08:00
Chinesezjc 6f58f9c336 fix(code-runtime-python): pace concurrent binding replies against fd 3
`sendReply` ignored `proto.write`'s `false` return, so a program resolving
several large values in one `asyncio.gather` round encoded every reply in the
same turn and queued all of them in fd 3's writable buffer. Binding resolution
carries no seam-level byte cap to bound that, and the failure kills the host
process rather than failing the run: measured on a 64 KiB-highWaterMark pipe,
eight 4 MiB replies buffered 32.0 MiB at once against 0.0 MiB once paced.

Replies now go through a queue that encodes and writes one frame at a time,
awaiting `drain` when the pipe is full. The encode happens inside the loop, so a
queued reply the run no longer needs is dropped by the `settled` check without
ever being serialized.

This was previously deferred on the grounds that serializing would narrow the
seam's concurrency contract. That reasoning was wrong: the child matches each
reply to its `call` by id from a pump that reads fd 3 continuously, so arrival
order was never observable, and the bindings still run concurrently. Only the
host's peak memory and the flush timing change. The README entry recording the
deferral is removed and the Agent Note records the mechanism instead.
2026-08-31 14:33:31 +08:00
Chinesezjc 2a9a917853 fix(code-runtime-python): drop a late binding resolution before snapshotting it
`sendReply` already refuses to write after the run settled, but only after
`snapshotJsonValue` walked and copied the resolution. Binding resolution carries
no seam-level byte cap, so a binding resolving a wide value after `maxWallMs`,
an abort, or dispose settled the run spent host heap building a frame that was
then discarded. The check moves ahead of the snapshot.

Also in this change:

- `readProcessStart` moved after `messageOf`. Inserting it between `messageOf`'s
  JSDoc and its body left that function undocumented and the orphaned block
  reading as a second doc for the reader; `verify-export-jsdoc` does not catch it
  because `messageOf` is not exported.
- The README pair adds the disposed-runtime rejection to `run()`'s public
  contract, which `src/index.ts` has enforced all along.
- Known Limitations records three deferred constraints that until now existed
  only in review discussion: the combined log-and-value peak the load gate does
  not model, the host-side per-member expansion of a wide binding reply (owned by
  `packages/core/session`, and shared with the worker-thread backend), and the
  absence of fd-3 backpressure for concurrent replies.
- The Agent Note's same-group section records the teardown identity guard and its
  two rulings, including why an ABSENT start-time reading proceeds rather than
  withholding the signal, and that reading it as a mismatch is what turned the
  three same-group heartbeat cases red on Linux.
2026-08-31 14:31:48 +08:00
Chinesezjc 0a46bb3414 style(code-runtime-python): keep the teardown v8-ignore under the line limit
The directive carried its whole justification inline at 203 characters, past the
140 the @stylistic/max-len rule allows (imports and template-literal messages
are exempt; a line comment is not). The reasoning moves to the lines above and
the directive keeps a short pointer, since a v8 ignore must stay on one line.
2026-08-31 14:30:02 +08:00
Chinesezjc 68f61b2e2f test(code-runtime-python): exempt the two single-platform teardown arms from coverage
The PID-reuse guard has two arms no single OS can execute: the non-Linux early
return in readProcessStart (the Linux coverage lane always takes the read path)
and the refusal arm, which needs a real pid recycled into a new group leader
between spawn and teardown -- no test can schedule that. The coverage lane
reported 99.53% statements / 99.14% branches on src/index.ts for exactly these
two.

Both carry a v8 ignore naming what cannot be reached and why, the convention
this file and subprocess-local already use for platform defenses. The reader
itself stays covered by the process-identity test rather than being exempted
wholesale.
2026-08-31 14:30:02 +08:00
Chinesezjc 2ad93da755 fix(code-runtime-python): treat an absent start-time reading as reaped, not recycled
The PID-reuse guard refused to signal whenever the current reading differed
from the one taken at spawn, including when it was ABSENT. On Linux a reaped
leader has no /proc/<pid>/stat, so every teardown after the leader exited
skipped SIGTERM/SIGKILL while the group it led still held survivors -- the
exact case the process-group teardown exists to reap. Three same-group survivor
tests went red on the coverage lane; they pass on Darwin because the reader
always returns undefined there, leaving the guard inert.

Only a present-and-different reading now blocks the signal. Verified on the
self-hosted Linux box: a reaped leader with live survivors allows the signal, a
pid whose start time differs still blocks it, and a live matching process is
signalled.
2026-08-31 14:30:02 +08:00
Chinesezjc 33318a5767 docs(code-runtime-python): state the real load-time rejections and finish the zh README
The README pair described `run()` as rejecting "a malformed binding namespace or
non-positive config", which understated and misplaced the configuration
failures: a non-Unix platform, a non-integer budget, a timer value setTimeout
would clamp, a budget larger than one fd-3 frame, and an incompatible
addressSpaceMb/output-budget pair all throw from the CONSTRUCTOR, so they fail
when the plugin loads rather than on a later run. Both sides now separate the
load-time platform/configuration errors from the run-result contract.

The Chinese README's Model Experience and KV Cache effect sections were still
untranslated English; the pairing record only tracks hashes, so it could not
show that. Both are now translated.
2026-08-31 14:30:02 +08:00
Chinesezjc 2e3cf144d5 docs(code-runtime-python): correct the claims the new backend invalidated
Adding a published Python backend and reordering `flush_line` left several
owning documents stating things that are no longer true.

`src/invariant.ts` justified its empty installer with "ships only the fd-3
wire-protocol codec", which the subprocess execution path contradicts. The
reason now states the actual one: every relation this backend maintains lives
in the CPython child or on the fd-3 wire, so no same-process event sequence is
observable from a listener -- the same shape the sibling worker-thread backend
uses.

The seam's `PORTABLE_RESERVED_WORDS` and `language` JSDoc, the code-runtime
README pair, and docs/subsystems/code-runtime both said only TypeScript has a
published backend. Corrected in all four, with the generated cordis catalog
regenerated for the `language` change.

The note attributed the 12x multiple to the settlement flush holding three
copies. That stopped being true when `flush_line` was reordered to drop the
pending chunks before its push: the binding worst case is the newline path's
single near-budget write. Corrected in the note (both sides) and in the test
comment that repeated it.

The note's Testing section now registers the cases this stack added, and the
Chinese side receives the O(depth) entry it never got plus the new ones -- it
had drifted from the English.

`INTERPRETER_BASELINE_BYTES` argued 64 MiB from a RESIDENT set while RLIMIT_AS
bounds address space. It now cites the bootstrap's own measurement (30.23 MiB
of mappings for `python3 -I`), making 64 MiB roughly twice the measured
baseline.

Also: a hardcoded `(:232-235)` comment reference becomes a reference by name,
a "which now walks in O(depth) too" change narrative becomes a current-state
statement, and a stray double blank line is removed.
2026-08-31 14:28:26 +08:00
Chinesezjc e6b547bef4 fix(code-runtime-python): guard teardown, log prefix, and settlement flush
Four independent corrections in the run lifecycle.

`killGroup` signalled `-child.pid` with a raw `process.kill`. Node keeps the
numeric `child.pid` after the leader is reaped and only clears its internal
handle, so `child.kill()` refuses while the raw call does not; `close` can
trail `exit` by seconds when a pipe-holding descendant keeps the streams open.
A recycled pgid could therefore receive this run's SIGTERM and armed SIGKILL.
`groupEmpty()` does not cover it: it reports whether the group has members, not
whether they are ours, and it first runs after the signal. The leader's start
time is now read at spawn and re-checked before each signal, matching the
position packages/subprocess/subprocess-local already states
("ProcessIdentity ... preventing teardown escalation after PID reuse"). Kept
local rather than depending on that package, which would add an architectural
edge. Linux reads /proc; Darwin has no /proc, so the reader reports undefined
and the guard degrades to the previous behavior instead of forking `ps` on a
teardown path.

`_push_bounded_prefix` built `(*self._pending, extra)`, copying every pending
reference into a same-size tuple before the bounded loop. For a
single-character drip that is a second pointer array as large as the list:
measured +80 MiB of tuple over a 40 MiB list for 5.2M chunks, the allocation
the bounded prefix exists to avoid. It now iterates the list in place and
handles `extra` in the loop's `else`; 4000 randomized inputs produce byte-identical
prefixes.

The settlement `flush_out()`/`flush_err()` ran outside any guard while `done`
was already decided, so a flush raising under memory pressure skipped
`send_done` and downgraded a child-classified `exception` into a host-side
`worker-exit`. Both are now wrapped, swallowing only the log tail.

The boot re-check's `if effective_soft != RLIM_INFINITY` was dead: `_clamped`
is asked for a finite `addr_bytes` on both sides and each branch returns that
value or a `min` with an inherited bound, so RLIM_INFINITY is unreachable. The
guard could only ever have skipped the re-check it claimed to protect.
2026-08-31 14:26:23 +08:00
Chinesezjc 8f7d9121d1 fix(code-runtime-python): bound three child-side walks by depth, not width
Three separate paths in the CPython child allocated state proportional to a
value's width or a string's length, so a legitimate input the byte budgets
admit could die as the program's own MemoryError.

`_lossless_json_violation` enqueued one traversal tuple per member while
running, in `dispatch`, over MODEL-CONSTRUCTED binding arguments that no
child-side byte budget bounds first. It now uses the same (kind, container,
iterator) cursor the other two walks already had, checking dict keys as the
cursor pulls each entry. Measured over `[0] * 6_000_000` (~17 MB of JSON):
459.1 MiB of traversal tuples before, 0.0 MiB after.

`_decode_json_plain` matched JSON strings with a `(?:[^"\\]|\\.)*` repetition,
which makes CPython's engine retain backtracking state proportional to the
string's width: 146 MiB for a 1 MiB string, 557.8 MiB for 4 MiB. A legitimate
multi-megabyte binding reply raised MemoryError inside `_pump_replies`, and
because that pump is the only settler of the call's future, the run stranded
until the wall clock reported `timeout`. Strings now scan chunk-to-chunk over a
character class, which the engine matches without backtracking state; the same
4 MiB decode peaks at the 4.0 MiB result.

`_check_done_value` charged strings and dict keys what
`_dump_string(...).encode()` returned, building the escaped copy plus its
encode to MEASURE it -- ~6x the original each for control-heavy text, so
metering a value the budget then rejects could itself breach RLIMIT_AS and
report `exception` where the seam promises `output-limit`. The new
`_json_str_cost` counts instead, reusing `_json_string_cost`'s C-level passes
and reproducing `_dump_string`'s exact surrogate rules (fold spelled-out pairs,
charge six ASCII bytes per lone surrogate). Identical values, 228.9 MiB -> 19.1
MiB of peak on a 20M-NUL string.

Each fix ships a regression test. The two RLIMIT_AS repros are Linux-only:
Darwin does not apply the limit, so the peaks above are measured directly and
recorded in the test comments.
2026-08-31 14:24:59 +08:00
Chinesezjc 86674ed21e test(code-runtime-python): budget the wide-value walk for an instrumented lane
The O(depth) wide-value regression test ran under `maxWallMs: 20_000`, but the
cursor pulls 6M elements one at a time through Python-level frames: ~11s on an
idle machine, and more under the coverage lane's V8 instrumentation with several
workers sharing a runner. CI reported `timeout` instead of the round-trip.

Raise the run's ceiling to 60s inside a 90s vitest timeout, so the runtime's own
wall clock still fires first on a genuine hang. The assertion is unchanged and
still discriminates: restoring the O(width) `stack.extend` enqueue fails the test
with a child-side MemoryError in ~2.6s.
2026-08-31 14:24:59 +08:00
Chinesezjc bca73068f6 fix(code-runtime-python): walk the completion value in O(depth), not O(width)
`_check_done_value` and `_encode_json_plain` pushed one stack entry per child
(plus a separator marker, and `dict.items()` materialized as a list), so the
bookkeeping scaled with the value's WIDTH rather than its depth. A value the
byte meter admits could then die on the walk's own frames: a flat
`[0] * 2_000_000` serializes to 4.0 MB, but measured peaks were 145.2 MB in the
meter and 114.7 MB in the encoder — 28.7x the serialized size, far past the 12x
the load-time address-space gate reserves.

Each container now pushes ONE cursor frame that pulls its children one at a
time and writes into a shared `io.StringIO`, so the output string is the only
width-proportional allocation and the caller already metered its size. Measured
on the same value: 0.0 MB in the meter and 9.0 MB in the encoder (2.3x), with
identical verdicts.
2026-08-31 14:24:59 +08:00
Chinesezjc 9a8663cc4c fix(code-runtime-python): flush logs before framing the completion value
The load gate bounds maxLogBytes and maxValueBytes independently against the
address space, but the child framed the completion value (materializing its
escaped form to meter it, then encoding the frame) while a newline-free log tail
still sat unflushed in _pending. Those two peaks added, so two budgets each
admitted alone could together breach RLIMIT_AS and die as worker-exit instead of
settling. The success path now flushes both log streams before _done_with_value
runs; the trailing flush stays for the exception path and is an idempotent no-op
after a successful settle. A combined-peak regression test (32 MiB each against
512 MiB) asserts the over-budget value reports output-limit rather than OOMing.

Also corrects the worst-case-multiple JSDoc and Agent Note: after 1088d6f03d
made flush_line drop pending before its push, the settlement-flush path holds
two copies, not three, so the newline path is the sole 12x worst case. The
reorder is recorded as a called-out untested fix (the 12x gate already admits
only configs safe under both flush orders).
2026-08-31 14:24:59 +08:00
Chinesezjc 9d9525549d fix(code-runtime-python): raise the output-budget worst-case multiple to 12 and reject the boundary
The load-time output-budget/addressSpaceMb gate used a worst-case multiple of 8,
assuming two simultaneous ~4x astral copies (the built string and its encode).
Three are live at the peak: on the newline path a single write holds the caller's
text argument, the line slice handed to push, and push's encode copy; the
settlement flush_line path held the pending chunks, their join, and that encode
copy. A budget admitted at 8x (e.g. maxLogBytes 48 MiB against addressSpaceMb 512)
could still OOM the child. The multiple is now 12, the strict `>` is `>=` so a
budget whose peak exactly equals the room left after the interpreter baseline is
rejected (that peak plus the baseline is the whole address space), and flush_line
drops the pending chunks before its push to match the newline path's
join-clear-push order. The child re-check mirror and both note sides move in step;
config-catalog is regenerated from the updated field JSDoc.
2026-08-31 14:22:37 +08:00
Chinesezjc ce91c70f9a test(code-runtime-python): assert the inherited-RLIMIT_AS boot re-check reports exception
The boot re-check raises inside bootstrap's setrlimit-phase handler, which
classifies every resource-limit-application failure as kind 'exception'. The
test asserted 'worker-exit'; align it to the actual class and keep the message
assertion so the case still discriminates a config rejection from a generic
setrlimit error. The Agent Note's two references to the reported kind are
corrected on both language sides and the pair re-recorded.
2026-08-31 14:22:37 +08:00
Chinesezjc 436a97a12d fix(code-runtime-python): reserve the interpreter baseline in the budget gate and re-check against the clamped RLIMIT_AS
The output-budget/address-space gate's 8x multiple had no room for the
interpreter's own footprint, so a budget sized right at addressSpaceMb/8 was
admitted while its worst-case peak plus the interpreter overran RLIMIT_AS
(e.g. 15 MiB maxLogBytes against 128 MiB). Reserve a fixed
INTERPRETER_BASELINE_BYTES (64 MiB) before the multiple claims the rest, so each
budget times 8 must fit the room LEFT after the baseline.

The host gate validates against the CONFIGURED addressSpaceMb, but a launch
environment can inherit a stricter RLIMIT_AS (a ulimit -v wrapper below
addressSpaceMb) that _clamped lowers the effective limit to, leaving the budgets
sized for a ceiling the child never gets. bootstrap.py now re-checks both budgets
against the effective clamped soft limit after applying it, mirroring the host
gate's multiple and baseline, and raises at boot rather than letting a
near-budget output OOM mid-run.

Add regression tests for both (the load gate against a 256 MiB address space
covering both budgets, and a ulimit -v wrapper for the inherited-limit re-check);
register the tail-copy test in the note Testing section; sync the zh pair. Merges
origin/feat/code-runtime-python-protocol to resolve the DIRTY base.
2026-08-31 14:22:37 +08:00
Chinesezjc 86c6d9345e test(code-runtime-python): size the tail-copy repro so the model can build its own string
The tail-copy regression built `"first\n" + "A" * 200 MiB`, whose construction
alone peaks near 400 MiB (the string plus the concat temporary) and OOMs under
the 384 MiB addressSpaceMb before the log path under test runs — a MemoryError in
the model, not the defect. Build the tail in a variable and concatenate only the
newline (peak ~2x150 MiB = 300 MiB, under the address space), so the model's own
allocation fits; the pre-fix code then buffered the whole 150 MiB tail again,
pushing past 384 MiB, while the sliced prefix does not.
2026-08-31 14:22:37 +08:00
Chinesezjc d9307ae2a4 fix(code-runtime-python): size the output-budget/address-space gate by worst-case Unicode and gate both budgets
The load-time addressSpaceMb gate used a 1/8 fraction derived for ASCII, but the
child ledgers trigger on character count against a serialized-byte budget: an
astral character is one character yet ~4 bytes stored and ~4 encoded, live at
once, so the true worst-case peak is ~8x the budget, not ~2x. Replace the
fraction with an explicit OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE (8)
and a strict `>`, and gate maxValueBytes the same way as maxLogBytes — the value
path builds and encodes a near-budget completion under the same RLIMIT_AS, so
the incompatible pair was previously admitted there too.

Slice the newline branch's unterminated tail to a budget-sized prefix: it
buffered the whole text[pos:] before the flush trigger could bound it, so an
early newline plus a huge tail made a second full copy of the model's string —
an RLIMIT_AS death the config gate cannot cover since the tail can far exceed
maxLogBytes.

Disclose the cross-field constraint in the maxLogBytes/maxValueBytes/addressSpaceMb
JSDoc (regenerating config-catalog); refresh the note's stale
Buffer.byteLength(JSON.stringify) reference; reconcile the arrival-order rebuttal
with the seam's "in order" logs JSDoc (within-stream, cross-stream best-effort).
Extend the load-rejection test to both budgets and add a tail-copy regression;
sync the zh pair.
2026-08-31 14:22:37 +08:00
Chinesezjc 2df88b5bbe fix(code-runtime-python): reject an oversized maxLogBytes at load instead of metering log capture at runtime
The child log ledger encodes an admitted entry to UTF-8 once to charge its
serialized cost, so a maxLogBytes approaching addressSpaceMb lets a legitimate
near-budget log entry breach RLIMIT_AS and die as worker-exit instead of
truncating. Two runtime fixes were tried and both traded one resource bound for
another: an exact serialized-cost check is either a full encode (the allocation
being avoided) or a per-character Python loop that burns the CPU budget (a 10 MB
write hits SIGXCPU under cpuSeconds:1). The breach is a property of the
maxLogBytes/addressSpaceMb pair, not any write, so reject the incompatible pair
at load — maxLogBytes must stay within one eighth of the addressSpaceMb byte
count — and revert _LogStream to its original character-count buffering, which
is memory-safe once the budget fits the address space. The check runs on every
platform since the incompatibility is a config-value property, not a runtime one.

Replace the child-flood regression tests (which asserted the reverted runtime
behavior) with a load-rejection test. The host-side accrueStrayCost UTF-8
per-lead validation and its tests are unaffected. Update the note and zh pair.
2026-08-31 14:22:37 +08:00
Chinesezjc c24e1e991b fix(code-runtime-python): charge structurally-valid-but-illegal UTF-8 and newline-path logs by decoded cost
accrueStrayCost accepted any 0x80-0xBF continuation, so a CESU-8 surrogate
(ED A0 80) or overlong (E0 80 80) — structurally well-formed but illegal, and
as cheap to flood as 0xFF — was charged its structural width 3 while
toString('utf8') renders each byte as its own U+FFFD (cost 9). Validate each
lead's first-continuation range (WHATWG E0/ED/F0/F4 bounds) and charge 3 per
byte of any sequence outside it, folding a broken prefix to one U+FFFD.

The child _LogStream newline path had the same char-vs-serialized gap the
newline-free trigger had: its per-line fit checks (first reconstructed line and
each subsequent line) compared character count against the serialized-byte
budget, so a control-char line passed and _logs.push encoded it whole, breaching
RLIMIT_AS. Route every check through _fragment_cost_upto, which sums per-char
costs from _json_char_cost over a start/end sub-range without slicing or
encoding and stops at the budget.

Decline arrival-order stray flushing: the two pipes' data events interleave
nondeterministically and logs carries no cross-pipe ordering guarantee, so a
fixed drain order is as valid as any and an arrival-tick branch could not be
covered without a flaky test.

Add CESU-8/overlong, newline-path-flood, and all-lead-class reassembly
regression tests; fix the note's now-inaccurate CESU/illegal-byte claims and a
fixture byte-count comment; sync the zh pair.
2026-08-31 14:22:37 +08:00
Chinesezjc 5c43621ed2 fix(code-runtime-python): weigh the child log flush by per-fragment serialized cost, allocation-free
The prior child-flush fix measured each fragment with chunk.encode('utf-8'),
which copies the whole write — under a tight addressSpaceMb a single 340 MiB
write died on that encode (the exact allocation _push_bounded_prefix exists to
avoid), and re-scanning the whole pending list per write was quadratic under a
daemon-thread flood (the concurrent-write test timed out at 28s). Compute each
fragment's serialized cost with _fragment_cost_upto, which walks the str via a
new _json_char_cost (code point to escaped width, no encode) and stops once the
running total passes the budget, and accumulate it into _pending_cost once per
write. The early-flush trigger reads that accumulator: still charges control
chars their full serialized width (a NUL is 6 bytes), but never encodes a whole
write and never re-scans the buffer, so the 340 MiB single-write and
daemon-thread tests pass alongside the NUL-flood one.

Rework the child NUL-flood regression to write in 1 MiB chunks under a 512 MiB
address space so its own argument construction is not the allocation under test.
2026-08-31 14:22:37 +08:00
Chinesezjc d9f2fa1b04 test(code-runtime-python): drive the child NUL-flood test without a single huge argument
The child-log-flood regression built one 30M-char argument string, which under
the 64 MB addressSpaceMb died on RLIMIT_AS during construction (exit 120) before
the flush trigger under test could run, so it failed on Linux CI. Write the
flood in 1 MiB chunks under a 512 MiB address space instead: the argument str is
never itself the allocation under test, the fixed serialized-cost trigger keeps
the pending tail bounded to a few MiB, and the run completes at the marker; the
pre-fix char-count trigger accumulates the whole ~200 MiB and its ~1.2 GiB
settlement encode breaches RLIMIT_AS. Mirrors the addressSpaceMb budget the
existing oversized-completion tests use.
2026-08-31 14:22:37 +08:00
Chinesezjc dbff8ffba3 fix(code-runtime-python): charge illegal UTF-8 by its U+FFFD width on both log paths
The host stray-capture cost function charged illegal UTF-8 bytes (0x80-0xC1,
0xF5-0xFF, and orphaned multibyte leads) the raw 1, but toString('utf8')
renders each as U+FFFD (3 serialized bytes). A b"\xff" flood was undercounted
threefold, so the residual grew to a full budget's worth of raw bytes before
flushing and, near a large maxLogBytes, expanded toward a ~1 GiB peak in the
flush's concat plus toString. Replace serializedBufferCost with accrueStrayCost,
a cross-chunk UTF-8 walker that charges each byte its decoded serialized width;
carry its sequence state on each StrayBuffer.

The child _LogStream had the same-family bug: its early-flush trigger compared
_pending_chars (character count) against remaining (a serialized-byte budget),
so a 30M-NUL newline-free flood stayed under a 50 MB char trigger yet encoded to
~180 MB at settlement, breaching RLIMIT_AS as worker-exit. Track _pending_cost
via the _JSON_BYTE_COST table and trigger on it; keep _pending_chars for the
char-based slice bounds.

Correct the note's surrogate claim (only the string-walking jsonStringCostUpTo
charges a lone surrogate six bytes; the byte walker never sees one). Shrink the
post-truncation fixture below PIPE_BUF for a deterministic single callback. List
the shared stdout/stderr budget as a third honest fail-before exception
(cross-pipe arrival timing is nondeterministic). Add illegal-UTF-8,
broken-multibyte, and child-log-flood regression tests; sync the zh pair.
2026-08-31 14:22:37 +08:00
Chinesezjc 45814baf26 test(code-runtime-python): make the stray-seal copy-volume bound discriminate
The stray-sealing regression test asserted copied < 2 MiB — about 4x the
defended sealed shape, so reverting the seal to a re-merge (or removing it)
left the test green. Measured both shapes as the fd-3 sibling does: the sealed
shape copies ~120 KB, the re-merge shape ~538 KB. Tighten the bound to 256 KiB,
which sits between them, and record the measurements in the comment and the
Agent Note so the fail-before claim holds.
2026-08-31 14:22:37 +08:00
Chinesezjc e76b3baf9e fix(code-runtime-python): meter stdout and stderr stray residual against one shared budget
stdout and stderr each checked their pending serialized cost against the full
logBudget independently, so both could retain nearly a budget's worth of
newline-free residual at once — double the intended peak, up to ~512 MiB near
the ceiling. The flush threshold now reads the COMBINED cost of both pipes and
flushes both when it crosses, since they share one ledger.

Remove the post-truncation admit() v8-ignore: captureStray's per-line loop
makes that branch deterministically reachable within one data callback (a chunk
whose first newline-terminated line exhausts the budget hits it on the second),
so it is measured by a new regression test rather than ignored.

Refresh two stray-output test comments that still named the removed
StringDecoder; the raw-chunk buffer reassembles a split multibyte sequence by
concatenating before it decodes, and the end flush renders a stranded partial
as U+FFFD via toString('utf8').
2026-08-31 14:22:37 +08:00
Chinesezjc f29b4b1cb9 fix(code-runtime-python): seal stray fragments, flush by serialized cost, charge lone surrogates fully
Three follow-ups the review caught in the stray-capture rewrite, plus a cost
undercount shared with the log ledger.

Seal the stray fragment list into blocks past MAX_PENDING_CHUNKS, mirroring the
fd-3 reader: a program pacing single-byte os.write(1, ...) calls otherwise
accumulates one live Buffer per write, and the per-object overhead no byte
count sees exhausts the host heap far below the budget.

Flush the residual by its running SERIALIZED cost (serializedBufferCost, a
per-byte lower bound) rather than raw byte count: a control-char-dense
newline-free flood serializes several-fold, so a raw-byte threshold let it grow
to a full budget's worth of raw bytes — up to ~6x what the ledger admits —
before flushStray concat/decoded the whole ~256 MiB residual at once.

Charge a lone surrogate its full six escaped bytes (\uXXXX under ES2019
well-formed JSON.stringify) in both jsonStringCostUpTo and serializedBufferCost,
not the three bytes Buffer.byteLength reports for U+FFFD: a forged log frame
flooding \ud800 escapes was undercharged by half and admitted ~2x maxLogBytes.

Key the sync-spawn leak assertion off the exact bootstrap path from the mocked
spawn's argv, immune to a sibling worker's concurrent staging. Refresh the
stale load-check comment that named the replaced JSON.stringify mechanism.

Add lone-surrogate, stray-sealing, and companion regression tests (per-file
100% coverage); update the Agent Note and zh pair.
2026-08-31 14:22:37 +08:00
Chinesezjc a9c480bf39 docs(config-catalog): refresh the code-runtime-python Config source line
Removing the now-unused StringDecoder import shifted the Config interface
down by one line; regenerate the embedded source reference.
2026-08-31 14:22:36 +08:00
Chinesezjc 8093d22164 fix(code-runtime-python): bound stray capture by serialized cost, chunk-scan, and flush on destroy
The line-aggregating stray capture from the previous round regressed three
ways the review caught. Rewrite it on the fd-3 reader's raw-Buffer-chunk
shape: accumulate chunks with a byte counter and split on the raw 0x0a byte,
so a large newline-free write no longer re-copies the residual and re-scans
from index 0 per chunk (both O(N^2)). Meter each admitted entry by serialized
cost through a new jsonStringCostUpTo that walks to the cap and stops, so a
near-budget control-char-dense line never allocates the sixfold-inflated
JSON.stringify result the old ledger did (the critical: ~1.6 GiB transient
under a large maxLogBytes). Flush the residual explicitly in the closeDeadline
handler before it destroys the streams, so a setsid escapee's path (which
fires no end) does not drop a leader's final newline-free diagnostic.

Harden the sync-spawn leak assertion to a set difference against a pre-run
snapshot, immune to a parallel worker's concurrent tmpdir create/delete.

Decline the round-2 request to enforce the fd-3 ceiling per-frame: the counter
check must precede Buffer.concat to prevent ~2x memory doubling (two
regression tests assert this), and the batch-edge false reject it would fix is
reachable only at a maxLogBytes/maxValueBytes configured within one pipe read
of the 256 MiB ceiling, far past the defaults. Documented at the check and in
the note Alternatives.

Add flood, NUL-flood, short-escape, and closeDeadline-flush regression tests
(restoring per-file 100% coverage); update the Agent Note and zh pair.
2026-08-31 14:21:57 +08:00
Chinesezjc c8bf75cbe4 test(code-runtime-python): cover the newline-free stray-flood ledger bound
The line-aggregating stray capture added two branches — the post-truncation
early return and the residual-overflow admit — that the aggregation and
split tests did not exercise, so per-file coverage dropped below 100%. A
2 MB newline-free native write under a 4 KiB maxLogBytes drives the residual
across the budget (admit-and-truncate) and then short-circuits later chunks,
asserting the captured output ends at the truncation marker and stays under
budget rather than buffering the whole flood.
2026-08-31 14:21:57 +08:00
Chinesezjc 44203f3fa7 fix(code-runtime-python): resolve worker-exit on sync spawn failure; aggregate stray output by line
Wrap spawn and the fd-3 narrowing so a synchronous throw (ENAMETOOLONG on
an over-PATH_MAX pythonBin, EMFILE) removes the run's staging directory and
resolves the same worker-exit class as the async error event, instead of
rejecting run() and leaking the directory.

Aggregate native stdout/stderr by real newline rather than by Node data
chunk: logs entries are joined with "\n" downstream, so a newline-free
write larger than one pipe read no longer reads back with spurious breaks.
The ledger still bounds a newline-free flood.

Track a running scan offset in both frame readers so a large frame
accumulated across chunks is scanned once, not re-scanned from 0 per chunk.

Reword the deadline hard-bound v8-ignore to state its real environment
dependence (PID-1-doesn't-reap container, zombie survivor) and cross-ref
the note's rejected signal-0 alternative; fix settle comments that quoted
the pre-qualification teardown contract; document the capMessage vs
_cap_message billing split on both sides; guard the dispose-after-resolve
heartbeat assertion against a vacuous 0===0 pass; reuse
_TRUNCATION_MARKER_BYTES; note the abandoned-call pending-entry bound.

Update the Agent Note Decision/Testing/Alternatives/Consequences for the
above and record the confirmed-empty finalize as a second honest
fail-before exception; sync the zh pair.
2026-08-31 14:21:57 +08:00
Chinesezjc 1103e36c22 fix(code-runtime-python): confirm group death at the deadline; chunk the frame read
The reap-poll deadline arm sent SIGKILL then finalized immediately, declaring
quiescence on mere signal delivery while the group was still dying. It now keeps
polling for the group to actually empty (bounded by one more reap margin) after
its self-sent SIGKILL, so `finished` resolves only on a confirmed-empty group.

ProtocolChannel.read_frame read the boot/run handshake frames through
FileIO.readline() on the unbuffered fd — one os.read(1) per byte, so a
multi-megabyte program burned CPU (RLIMIT_CPU already in force for the run frame)
in millions of syscalls before ast.parse. It now reads in chunks into the same
_pending buffer the async reader uses; the wrapping os.fdopen is gone. read_frame
is this PR's own code (e7f22ed3), not the protocol layer. The chunked read is a
syscall-count improvement with no cross-platform-deterministic failure to assert,
noted as such in the Agent Note.
2026-08-31 14:21:57 +08:00
Chinesezjc 28f747d775 test(code-runtime-python): cover the reply-pump closed-loop guard; align setsid docs
The closed-loop reply-pump guard now ships with a deterministic regression test:
a worker thread abandons a binding so its loop closes, the host answers that call
before a later binding, and the pump must survive the closed-loop
call_soon_threadsafe to deliver the later reply (host-gated ordering makes it
deterministic; unguarding the pump hangs the later binding to the wall clock).

Align the quiescence self-description with the shipped setsid limitation:
teardown()'s JSDoc and the Agent Note's Problem line now qualify "no subprocess
outlives the fiber" to subprocesses that stay in the child's process group, with
a setsid()-escape exception pointing at the README. Tighten the setsid-orphan
fixture's self-timeout to 5s and its upper-bound assertion to <4000ms so a failed
deadline backstop is a sharper red. Register the new regression tests in the note.
2026-08-31 14:21:57 +08:00
Chinesezjc bea8708b5d fix(code-runtime-python): reject a non-integer maxLogBytes/maxValueBytes at load
The child reads these byte budgets through int(...), which silently floors a
float, so maxLogBytes: 3.5 would truncate at 3 bytes child-side while the host
meters and marks at 3.5 — the two sides enforcing different public config. Gate
them to integers at load, as the worker backend does; correct the stale comment
that claimed the int()-truncated caps needed no gate. Adds a regression test.
2026-08-31 14:21:57 +08:00
Chinesezjc db5f890c7f test(code-runtime-python): update CPU-timeout assertions to the reworded message
The SIGXCPU timeout message changed from "CPU budget (Ns) exhausted" to name the
configured value as a ceiling; two existing timeout tests asserted the old text.
Assert "CPU time exhausted" to match.
2026-08-31 14:21:57 +08:00
Chinesezjc 63c49c8a90 fix(code-runtime-python): meter the exception diagnostic by serialized cost
Raising maxValueBytes' load bound to ceiling-envelope assumed both budgets are
metered in serialized (JSON-escaped) bytes, which held for completion values and
logs but not the diagnostic: _cap_message capped by raw UTF-8, so a control-heavy
message near maxValueBytes could serialize sixfold and breach the fd-3 frame
ceiling — the silent worker-exit inversion the load check prevents. _cap_message
now accumulates per-byte serialized cost (new _JSON_BYTE_COST table) and cuts the
prefix that fits. Also reword the host SIGXCPU timeout message to name cpuSeconds
as the configured ceiling rather than a budget a stricter inherited RLIMIT_CPU
soft may undercut. Adds a control-heavy-diagnostic regression test.
2026-08-31 14:21:57 +08:00
Chinesezjc e0d5d8d097 fix(code-runtime-python): send SIGKILL at the reap-poll deadline, not cancel it
The group-reap poll folded its deadline arm into the empty-group arm, so a host
event loop blocked past graceMs + CLOSE_REAP_MARGIN_MS would run the overdue
poll before the grace SIGKILL timer: the group is still non-empty, the deadline
has passed, and the shared arm cancelled the never-fired SIGKILL and finalized —
releasing a SIGTERM-ignoring same-group survivor for good. Split the arms: empty
group cancels the moot timer and finalizes; deadline-with-non-empty-group sends
SIGKILL itself (idempotent if the timer already ran) before finalizing. Adds a
regression test that busy-blocks the loop past both timers and asserts the
survivor's heartbeat freezes.
2026-08-31 14:21:57 +08:00
Chinesezjc b1ce014035 fix(code-runtime-python): restore per-file branch coverage on the reap poll
The group-reap poll's deadline arm (Date.now() >= deadline) is a backstop that
SIGKILL emptying the reachable group never reaches, leaving one uncovered branch
under the per-file 100% gate. Mark it v8-ignore with the reason and drop the
always-true graceTimer-defined guard inside pollGroup (it runs only when killing
is set, so kill() has armed the timer).
2026-08-31 14:21:57 +08:00
Chinesezjc ecdb79824b fix(code-runtime-python): keep a completed run in live until its group is reaped
settle() dropped the run from `live` eagerly, before the grace-window SIGKILL
reaped a same-group survivor. A dispose() racing a just-resolved run() then
snapshotted an empty `live` and returned while the descendant was still alive,
so teardown's "no subprocess outlives the fiber" (and its JSDoc) was false for
that window. The run now stays in `live` until the process-group poll confirms
the group empty, at which point it is both dropped from `live` and its finished
promise resolved. Adds a regression test asserting dispose() of a completed run
with a same-group survivor returns only after the survivor stops executing.
2026-08-31 14:21:57 +08:00
Chinesezjc 9f449a79a6 docs(code-runtime-python): correct the ProtocolChannel serialization docstring
The class docstring still credited the GIL plus per-frame PIPE_BUF atomicity for
serializing writes, which _write_lock's full-write loop already superseded. State
the current contract (writers serialized by _write_lock around a full-write loop)
and drop the double blank line under the binding-replies note heading.
2026-08-31 14:21:57 +08:00
Chinesezjc a8e47dae37 docs(code-runtime-python): note the settlement CPU recheck uses the clamped soft
Record that die_if_cpu_exhausted compares against the effective clamped cpu_soft
in the rlimit section, and add the recheck-timeout test to Testing; re-record pair.
2026-08-31 14:21:57 +08:00
Chinesezjc 6141f0062d fix(code-runtime-python): recheck CPU against the effective clamped soft limit
The settlement-time CPU recheck compared spent CPU against the configured
cpuSeconds, but _clamped may have lowered the effective soft limit to a stricter
inherited value. A program that traps SIGXCPU, burns past the inherited soft,
and returns inside the soft-to-hard gap was checked against the configured value
and falsely reported successful, bypassing the inherited limit. The recheck now
uses the clamped cpu_soft. Adds a regression test that inherits a 1s soft CPU
limit and asserts a SIGXCPU-trapping over-burn is a timeout, not a success.
2026-08-31 14:21:57 +08:00
Chinesezjc d432603b81 docs(code-runtime-python): note the closed-loop reply-pump guard
Record the call_soon_threadsafe-onto-a-closed-loop guard in the binding-reply
section of the settlement-fixes Agent Note; re-record the bilingual pair.
2026-08-31 14:21:57 +08:00
Chinesezjc a30b460b37 fix(code-runtime-python): keep the reply pump alive past a closed thread loop
A binding called from a worker thread records that thread's loop for its reply.
If the thread finished and closed its loop before the host reply arrived,
_pump_replies' call_soon_threadsafe onto the closed loop raises RuntimeError;
unguarded, that ends the pump task and strands every later reply. Wrap the
schedule in a try/except that drops the moot reply (nothing awaits it) and keeps
the pump serving.
2026-08-31 14:21:57 +08:00
Chinesezjc 3a560d37a6 docs(code-runtime-python): document the setsid-escape teardown limitation
A descendant that calls setsid()/start_new_session leaves the child's process
group, so kill(-pid) teardown cannot reach it; if it also releases the inherited
pipes the run still settles and the fiber goes quiescent while the orphan runs.
This is the containment boundary (model code has bash-equivalent trust), not a
guarantee; reaching such an orphan needs descendant-pid tracking and is deferred.
2026-08-31 14:21:57 +08:00
Chinesezjc ff604dc876 fix(code-runtime-python): clear stale SIGKILL timer and clamp inherited soft rlimit
Two further review findings on the CPython backend:
- The grace-window SIGKILL timer was left armed after settlement, so on a
  normal completion a kill(-pid) could fire up to graceMs later and strike a
  recycled pgid once the kernel reused the leader's pid. settle() now clears
  the timer the moment the process group is confirmed empty (the normal path
  and when the poll sees the survivor gone), bounding the reuse window to the
  genuine-survivor case where the group cannot be empty to reuse.
- _clamped bounded rlimits by the inherited hard limit only, silently raising
  an inherited soft limit stricter than the request (loosening RLIMIT_AS or
  deferring RLIMIT_CPU SIGXCPU). It now clamps each side against its own
  inherited counterpart and pins soft under hard, keeping the strictest of
  configured and inherited. Adds an inherited-soft-limit regression test.

Agent Note expanded to seven fixes with the two new rejected alternatives;
zh pair re-recorded.
2026-08-31 14:21:19 +08:00
Chinesezjc 6cb70e6e69 fix(code-runtime-python): reap same-group survivors and fix cross-loop bindings
Two review findings on the CPython backend:
- Disposal could return while a same-group descendant that ignores SIGTERM
  but releases the inherited pipes was still alive: the leader's close fired
  and the previous fix relied on an unref'd SIGKILL timer that a short-lived
  host never fires, reparenting the survivor to init. settle() now withholds
  the run's finished promise on a ref'd process-group poll until the SIGKILL
  has emptied the group (bounded by graceMs + margin, zero-cost when already
  empty), so teardown's "await each child's exit" holds.
- A binding called from a model worker thread via asyncio.run created its
  reply Future on that thread's loop, but _pump_replies completed it directly
  from the main loop; asyncio.Future is not thread-safe across loops, so the
  call hung to the wall clock. Replies now complete via the owning loop's
  call_soon_threadsafe, and a lock serializes the id claim/write/advance.

Tests: the same-group reap case now asserts a heartbeat file stops (robust
whether the killed descendant is reaped or a zombie, so it holds where PID 1
does not wait() orphans); a cross-loop case runs a binding from a worker
thread and asserts the reply round-trips instead of timing out. Agent Note
expanded to all six fixes with rejected alternatives; zh pair re-recorded.
2026-08-31 14:21:19 +08:00
Chinesezjc 46db9e2ad4 test(code-runtime-python): update output-cap bound to ceiling-envelope
The frame-ceiling cap test asserted the old (ceiling-envelope)/6 bound and
its 44739232 message. The load bound is now ceiling-envelope because both
budgets are metered in already-escaped bytes; assert 268435392.
2026-08-31 14:21:19 +08:00
Chinesezjc 9a05c0075f fix(code-runtime-python): reap same-group children and correct log-budget bound
Address review findings on the CPython backend:
- CRITICAL: a model program could leave a descendant in the child's own
  process group that ignores SIGTERM but releases the inherited pipes, so
  the leader's `close` fired and settle() cancelled the pending SIGKILL
  before it escalated — run()/dispose() returned while that child lived.
  kill() now unrefs the grace timer and settle() no longer clears it, so
  the SIGKILL reaches the whole group; killGroup swallows ESRCH when the
  group is already gone (the normal case). Adds a real-subprocess
  regression test.
- WARNING: the maxLogBytes/maxValueBytes load bound divided the frame
  ceiling by 6 for escape expansion, but both budgets are metered in
  already-escaped serialized bytes, so a payload occupies at most
  cap+envelope on the wire. Bound is now ceiling-envelope; drop the unused
  escape constant.
- Narrow the runtime.spec.ts header to "no subprocess mocks" (it mocks
  node:fs.copyFileSync for staging-failure cases).
- Use full-width punctuation in the README.zh.md prose per translation
  rules; re-record the pair.
2026-08-31 14:21:19 +08:00
Chinesezjc 538ad4d3dc docs(code-runtime-python): sync README with the shipped backend
The package README (both languages) still described this layer as
protocol-only with the PythonCodeRuntime implementation deferred to a
later PR, contradicting the shipped code. Rewrite the intro to describe
the registered runtime, add a Configuration section for every Config cap,
and drop the "implementation not in this layer" limitation. Also pin the
residual-detach fixture's size invariant: the byteLength assertion only
holds above Node's Buffer pool threshold.
2026-08-31 14:20:00 +08:00
Chinesezjc e576ceb913 docs: regenerate module graph for the code-runtime-python dependency
Adding @deepseek-ai/dsh-code-runtime to the backend manifest introduces a
new edge the generated graph must reflect.
2026-08-31 14:16:47 +08:00
Chinesezjc 27901c547f fix(code-runtime-python): declare the dsh-code-runtime dependency
The manifest omitted @deepseek-ai/dsh-code-runtime although src/index.ts
imports CodeRuntime and the portable-identifier constants from it and the
tsconfig references ../code-runtime. A three-way package.json merge over
the protocol-layer stub dropped the entry; restore it in peer and dev
dependencies so the declaration matches the import.
2026-08-31 14:16:02 +08:00
Chinesezjc 7b4b8df2dd docs(code-runtime-python): fix settlement-fixes note wrap and cross-link
Unwrap the English note to one physical line per paragraph (verify-md-wrap)
and retarget the backend link to the fd-3 protocol architecture note that
this stack actually ships (verify-md-links); re-record the bilingual pair.
2026-08-31 14:14:33 +08:00
Chinesezjc c388169cff feat(code-runtime-python): add the CPython subprocess backend
Land the PythonCodeRuntime implementation on top of the fd-3 protocol
seam: python3 -I per run, binding namespace over fd 3, RLIMIT_CPU/AS,
wall-clock timer, and SIGTERM->grace->SIGKILL process-group teardown,
with the real-subprocess integration suite.

Fixes three defects surfaced on the source PR's review before they ship:
- boot-write failure resolved a worker-exit through finish()/settle()
  that read wallTimer/onAbort/live in their TDZ, rejecting run() instead;
  the boot write now runs after those bindings and the v8-ignore that hid
  the branch is removed.
- log capture serialized against settlement with no lock while model
  daemon threads keep writing; LogBuffer now owns one shared re-entrant
  lock taken by write/flush_line/push.
- the fd-3 line residual was a subarray view pinning the whole joined
  frame; it is copied into a right-sized Buffer via detachResidual so
  pendingBytes measures what is retained.
2026-08-31 14:14:32 +08:00
Yichen Jiang 43afef8576 fix: settle what the master merge broke
`AGENTS.md` sat at exactly its 1950-word ceiling on master, so the one line this
branch adds to the repository layout — a new top-level package group — cannot
fit at any length: even a one-word description overflows. The description is
condensed to five words and the ceiling raised by ten, the smallest change that
keeps every package group listed. Omitting only `net/` from a list that names
every other group was the alternative.

One new test drove an IPv4-mapped literal through a full fetch. An IPv6 literal
sends `resolvePublicAddresses` looking for a NAT64 prefix before it refuses
anything, and that is a real DNS query — 5s under load, 6ms here, which is why
it passed alone and timed out in the full suite. The three IPv4 cases already
prove the branch end to end without touching the network, so the mapped form is
asserted on the predicate instead.
2026-08-31 13:07:21 +08:00
Yichen Jiang 44a9687bbe Merge remote-tracking branch 'origin/master' into worktree/deepseek-harness-proxy-config-2f5b4a
# Conflicts:
#	apps/cli/package.json
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/e2b/e2b/package.json
#	packages/llm/llm-deepseek/package.json
#	packages/llm/llm-pi-ai/package.json
#	packages/test-support/session-snapshot/package.json
#	packages/workflow/workflow-worker-thread/package.json
#	pnpm-lock.yaml
2026-08-31 12:58:59 +08:00
Yichen Jiang 6de470e61b fix(net): keep this machine off the proxy, and refuse a literal the checks reject
Review found `127.0.0.2` routed through the proxy. The bypass list carries four
literal loopback entries because that is all a consumer reading an environment
can match, and `proxyForUrl` matched only those — leaving the rest of
`127.0.0.0/8`, `0.0.0.0`, and the IPv4-mapped spellings routed through a proxy
that could then reach them. Loopback is now recognised structurally, which a
list entry cannot express; the published entries stay for the environment
readers.

The same review case exposed a wider one. `web_fetch` skips its address checks
on a proxied hop, because the proxy resolves the origin — but a literal needs no
resolution, so the skip bought nothing and let a proxy on this machine reach
every private range those checks refuse, `169.254.169.254` included. A literal
the checks would refuse now takes the validated path, where the existing
refusal already covers it.

Tests that proved a tunnelled hop used a loopback origin, which no policy can
route through a proxy any more. They name a host only the proxy can answer for
instead — closer to what a proxied request actually looks like.

The user guide promised the proxy carried every outbound request including
telemetry. It carries neither on an older Node, nor anything a model-authored
script sends, so the promise is narrowed and the exceptions listed. A password
in a proxy URL reaching every tool DSH runs is documented there too: it is how
the variable already behaves, and worth knowing before putting one in.
2026-08-31 11:57:40 +08:00
Dudu-0223 43840d6ece fix(subagent): harden adjacent message guidance 2026-08-31 11:16:00 +08:00
_Kerman 6c63e708b8 Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-2907
# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/persistence-catalog.i18n.yaml
#	docs/subsystems/session.i18n.yaml
#	packages/api/session-controller/tests/transport.host.spec.ts
#	packages/bundle/headless/src/index.ts
#	packages/core/session/README.i18n.yaml
#	packages/schedule/schedule/README.i18n.yaml
#	packages/schedule/schedule/README.md
#	packages/schedule/schedule/README.zh.md
#	packages/session-query/tool-session-query/src/workspace-access.ts
#	packages/session/session-log-deepseek/src/index.ts
2026-08-31 10:56:53 +08:00
Dudu-0223 ff34b6c28e test(web): pin expanded snapshots to bottom 2026-08-30 16:12:43 +08:00
Dudu-0223 5b91cdbf8b Merge remote-tracking branch 'origin/master' into feat/3220-steer-service 2026-08-30 16:08:31 +08:00
Dudu-0223 3091bdc257 fix(subagent): address steer review findings 2026-08-30 16:08:20 +08:00
Dudu-0223 9f86f31411 test: align catalogs and packed session fixtures 2026-08-30 14:44:12 +08:00
Dudu-0223 b957733bce docs: confirm config catalog pairing 2026-08-30 14:36:02 +08:00
Dudu-0223 487d61bd14 docs: refresh subagent config source link 2026-08-30 14:31:32 +08:00
Dudu-0223 4fce895468 test(subagent): cover unified messaging setup 2026-08-30 14:27:37 +08:00
Dudu-0223 575dc58a07 Merge remote-tracking branch 'origin/master' into feat/3220-steer-service 2026-08-30 14:16:37 +08:00
Dudu-0223 b91e7ce366 Unify adjacent Agent messaging 2026-08-30 14:14:46 +08:00
Chinesezjc a4d4404708 feat(ui-tool): render read_image results as the image
A settled top-level `read_image` call printed its raw attachment object as
literal text in the tool card — `{"type":"image","attachment":{…}}` —
instead of the image, because no presentation metadata told a client card
how to present the reference and the tool-card layer had no image concept.

Host: `read_image` declares an `output.presentationMeta` persisting
`{ path }` only. The attachment reference deliberately lives in the
settled result content — the single record a `tools/post-execute`
replacement rewrites — not in `meta`; the id is opaque and
provider-owned, checked for existence only.

Client: `imageCardModel` derives the card from the call head, the meta
path, the result's own image block, and a shape-matched envelope. ToolRow
gains an `image` card slot; the `read_image` toolview declares the
Tool-owned `tool.call.images` slot as its child and dispatches the
gallery through it. ui-chat down-threads the session-authorized loader
(`ChatNodeOwnerProps.loadImage`), so the tool layer supplies only derived
references plus the loader and never imports an attachment
implementation; ui-attachment fills the slot with its message gallery
renderer. The card keeps the envelope text below the gallery for the
no-attachment-plugin deployment. An image-bearing tool registers a keyed
toolview; the generic fallback keeps its flattened text. `read_image`
joins the read variant with its own locale title key; both rows share
`read-family-row.tsx`.

Verification: `read-image.spec.ts` (metadata projection, envelope by
shape, reference narrowing, real-execution round trip, rejection
branches incl. non-digest ids), `image-card.client.spec.tsx` (derivation,
row render site dispatching the slot, keyed registration with the
child-slot declaration, empty-slot fallbacks, media-type enum), keyless
snapshots (`read-image-gif` added; read-image/-dimension/-reencode
updated to the `{path}` meta), five injected-defect negative controls,
and a demo GIF recorded from this PR's head through the official
image-capable model.
2026-08-29 17:41:05 +08:00
Yichen Jiang b239db6aeb test(net): assert the proxy-name contract on a platform that folds case
The Windows coverage lane failed on four cases that assume `http_proxy` and
`HTTP_PROXY` are separate variables. They are one variable there: `process.env`
is case-insensitive, and the launch snapshot folds names for the same reason. A
scenario built on "the user set only the lowercase name" cannot exist.

Two of them assert a contract rather than a spelling, so they now hold either
way: what reaches a child for a scheme the user named is the user's own value
and never the derived one, and a nested install carries the active policy's
value rather than the outer install's published one. Both read over the pair of
names instead of one.

The other two are about the case distinction itself. Neither can hold on a
folded environment, so each asserts what that platform does instead of skipping:
the later entry wins where a preference cannot be expressed, and a diagnostic
names the spelling resolution asked for. Both were verified against the folding
arm rather than reasoned about.
2026-08-29 13:37:29 +08:00
Yichen Jiang 03d2c54db1 test: start every suite from an environment with no proxy
A developer's Clash and a CI runner's squid both export HTTP_PROXY and its
siblings. Now that the harness honors them, an ambient value decides test
outcomes: this PR has already recorded a proxy's 502 page as a snapshot's
expected output, and let a runner's own export stand in for "what the user
exported" in an assertion about inherited names.

A Vitest setup file clears the proxy names before any suite runs, wired into
every configuration that declares a setup. Real-API e2e is cleared too: before
proxy support existed every request connected directly and that suite passed, so
direct is the environment it is known to work in.

`NODE_USE_ENV_PROXY` cannot be cleared this way — Node samples the proxy
environment at process start — and the module says so. A proxy application never
exports it, and the eight names one does export are fully handled: with all of
them set, the affected suites pass.

The wiring is what regresses, so that is what the test pins. Configurations are
discovered rather than listed, because the web suites carry no setup today and a
hand-written list would let one of them gain a setup without gaining this one.

`plugin.spec.ts` kept its own copy of the eight names to guard against the
machine; it never sets a proxy variable itself, so the setup replaces that
entirely. `install.spec.ts` had one assertion waiting on a DNS miss with no
deadline, which timed out once under load.
2026-08-29 13:17:12 +08:00
Yichen Jiang 2a73128d78 docs(net): describe the worker seam as it is, not as a mechanism that exists
`installGlobalProxy` claimed each worker thread calls it with a policy its host
passed through `workerData`. Nothing does: the two workers this repository ships
evaluate model-authored scripts and are deliberately left without a proxy URL
that may carry credentials.
2026-08-29 13:17:12 +08:00
Yichen Jiang 4623c68e70 perf(scripts): parse only the files the dispatcher gate can find a violation in
`agentBindings` now walks the whole tree to reach a dynamic `import('undici')`,
which pushed the repository-wide scan past the 5s default under the coverage
lane's instrumentation.

Both violations name one of two words in source: an agent construction needs a
binding from the undici module, and the option is a property called
`dispatcher`. Skipping a file that mentions neither leaves 21 of 1597 files to
parse, so the scan runs in ~60ms instead of ~500ms — well clear of the timeout
even instrumented.
2026-08-29 13:17:12 +08:00
Yichen Jiang cfc9b3bdef fix(net): route by the policy, and give a child the routing its parent has
Second review pass on the outbound proxy work.

The installed dispatcher was undici's EnvHttpProxyAgent, which reuses the HTTP
proxy for `https:` whenever no HTTPS proxy is present. That is exactly the state
this package resolves after refusing a SOCKS or malformed URL the user named for
`https:`, so the scheme the diagnostic reported as direct was tunnelled anyway.
The dispatcher is now an Agent whose per-origin factory calls `proxyForUrl`, so
routing and `proxyForUrl` cannot disagree by parsing the same list twice.

`childProxyEnv` returned only the names the user exported, which left a child
Node direct whenever the proxy came from `ALL_PROXY` or from cordis.yml — Node's
`NODE_USE_ENV_PROXY` reads neither — and stripped the merged loopback bypass so
the child sent its own localhost traffic to the proxy. A scheme the user named
in either casing still reaches the child exactly as written; one they named in
neither now carries the resolved value, and the bypass list is always the merged
one.

A nested install (the plugin mounted over the launcher's policy) recorded the
outer policy's published values as the user's, then cleared the record on
disposal, so every later child inherited the normalization instead. The record
now belongs to the outermost install and is restored, not dropped.

`web_fetch` read the active policy twice — once to skip address pinning, again
inside the transport — so a disposal landing between the two reads produced an
unpinned direct connection to a host nothing validated. One snapshot now decides
both.

Also: the node:http proxy test asserted a route the engines range does not always
have, and the gate could not see undici bound through `await import('undici')`,
the form this repository actually uses.
2026-08-29 13:17:12 +08:00
Yichen Jiang 52e39cd783 test(net): clear the ambient proxy before asserting what a child inherits
The case builds a user environment and asserts a child receives it verbatim,
so a runner that exports its own proxy supplied half the "user" values and
decided the assertion.
2026-08-29 13:17:12 +08:00
Yichen Jiang e6dbf85f6c fix(net): address review — containment, opt-out, and syntax-aware discovery
The workflow worker no longer receives proxy configuration: it executes the
model-authored script body, and a proxy URL may carry credentials. A child
process now inherits the values the user exported rather than this process's
normalization, so a SOCKS proxy set for curl survives and no HTTPS_PROXY is
invented. `mode: 'off'` installs a direct dispatcher instead of recording a
policy the global dispatcher ignores, and the environment snapshot is taken
before any write so Windows restores the user's values.

E2B picks its proxy from the control-plane URL the SDK will really call, the
OTLP agent honors `exporter.keepAlive`, and a scheme whose own value was
refused stays direct instead of borrowing another scheme's proxy.

verify-no-bare-dispatcher parses the TypeScript AST as scripts/AGENTS.md
requires; it immediately found the `{ dispatcher }` shorthand the regex missed.
2026-08-29 13:17:12 +08:00
Yichen Jiang aa0ed35ebc test(snapshot): isolate a replayed dsh from the machine's proxy environment
A replay must not depend on the runner's network policy, the same reason it
pins its home and sessions root. The harness now honors the proxy environment,
so a runner exporting one sent the web-fetch scenario's fixture request to a
proxy that could not resolve the fixture host and recorded that proxy's error
page. Clear the proxy names in both test-support spawners, from the one list
dsh-http-proxy owns.
2026-08-29 13:17:12 +08:00
Yichen Jiang ca20b9af11 test(llm): cover the DeepSeek and pi-ai request paths with egress tests
Both adapters' inference requests were argued from a code read and, for
DeepSeek, from one product smoke. Drive each shipping adapter at an
unresolvable endpoint through a fake proxy instead, and cover pi-ai's provider
stream rather than only its model discovery.
2026-08-29 13:17:11 +08:00
Yichen Jiang 35bc2d1d45 fix(webworker): register a node:https placeholder for the proxy agent factory
The VFS packer sweeps module requests statically, so dsh-http-proxy's agent
factory made the preview image unpackable: it names node:https for the SDKs
that post through Node's core HTTP modules, a path the worker never takes.
Mock it the way node:net is mocked rather than hiding the request.
2026-08-29 13:17:11 +08:00
Yichen Jiang ec82e3e3ee test(net): assert the child and worker proxy seam by Node version
NODE_USE_ENV_PROXY reaches Node 24.0+ and 22.21+, while engines admits 22.19.
Assert the direct connection on an older runtime instead of only the proxied
one, so the seam is executable rather than prose.
2026-08-29 13:17:11 +08:00
Yichen Jiang 545e2ad914 feat(net): route every outbound request through the configured proxy
Node's built-in fetch ignores HTTP_PROXY, so every harness request connected
directly regardless of what the user exported. Resolve one policy from the
launch environment and install it as undici's global dispatcher, then wire the
four surfaces a global dispatcher cannot reach: web_fetch's pinned transport,
the OTLP exporter's node:http agent, the E2B SDK's own proxy option, and the
environment a child process or worker thread is given.

Each outbound call site carries an egress test that drives its real code path
through a fake proxy; that measurement is what found the OTLP and E2B gaps.
2026-08-29 13:17:11 +08:00
_Kerman 4096b0b408 refactor(time-context): scan turn messages in reverse 2026-08-28 15:02:12 +08:00
_Kerman 1b6f9a1b51 refactor(session-telemetry): simplify capture replay 2026-08-28 14:58:11 +08:00
_Kerman 32d681f023 fix(subagent): preserve empty restored model selection
Detect explicit empty seeds through the existing end-seed marker and remove the redundant Session.seeded API.
2026-08-28 14:55:46 +08:00
_Kerman 47e6448e23 perf(session): avoid unnecessary event snapshots 2026-08-28 14:17:21 +08:00
_Kerman bcfec8d1c3 perf(session): reuse immutable event snapshots 2026-08-28 13:43:16 +08:00
_Kerman 5660f44d29 perf(session): separate indexed and snapshot log reads 2026-08-28 13:25:58 +08:00
Turtle 15f2997bcb cleanup: omit unneeded invariant companions 2026-08-28 13:12:52 +08:00
Dudu-0223 ec493c2db8 feat(subagent): unify adjacent agent delivery on steer 2026-08-28 01:04:46 +08:00
3624 changed files with 88246 additions and 45720 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
2026-06-18-shared-persistence-write-coordinator.md: a61ceb9b2197a6dd8ed86c1c971373a2706607aa
2026-06-18-shared-persistence-write-coordinator.zh.md: 777d5f5972ac1096c2e3434f9e0ac5aec27e8c26
2026-06-18-shared-persistence-write-coordinator.md: 5c324f2c0c2b951f664bcf92725a36c4975fd3a1
2026-06-18-shared-persistence-write-coordinator.zh.md: 51ef76189cb9276214bf05bbd1dbd8e3755daa35
@@ -1,6 +1,7 @@
# Agent Note: Shared persistence write coordinator
Status: implemented
Archived: 2026-08-31
English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: 共享持久化写入协调器
Status: implemented
Archived: 2026-08-31
[English](2026-06-18-shared-persistence-write-coordinator.md) | 中文
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.md
2026-07-26-packed-chunk-rows-by-default.md: c230c1f1faf5e597321654ebd01d60fae725f518
2026-07-26-packed-chunk-rows-by-default.zh.md: e354efdf6bb68c02f30dc17c8d4ba17a495b61bd
@@ -1,6 +1,7 @@
# Agent Note: Make packed chunk rows the default JSONL layout
Status: implemented
Archived: 2026-09-01
English | [中文](2026-07-26-packed-chunk-rows-by-default.zh.md)
@@ -18,7 +19,7 @@ Reading is unconditional and layout-blind. Packed, unpacked, and mixed files loa
### Logical events and physical rows
The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters `Session.events` or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics.
The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters the Session log or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics.
The JSONL backend packs each durable append batch. Raw `compression: 'none'` and default Zstandard framing carry the same logical storage records; selecting raw mode for reviewable fixtures does not disable packing. Repository replay readers and normalizers decode the shared row format instead of maintaining snapshot-specific codecs.
@@ -1,6 +1,7 @@
# Agent Note: 将打包分片行设为默认 JSONL 布局
Status: implemented
Archived: 2026-09-01
[English](2026-07-26-packed-chunk-rows-by-default.md) | 中文
@@ -18,7 +19,7 @@ JSONL 存储 seam 可以在不改变逻辑日志的情况下减少这部分封
### 逻辑事件与物理行
JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()``decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 `Session.events`,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。
JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()``decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 Session 日志,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。
JSONL 后端会打包每个持久追加批次。原始模式 `compression: 'none'` 与默认 Zstandard 帧承载相同的逻辑存储记录;为使 fixture 便于评审而选择原始模式,不会禁用打包。仓库中的回放读取器和规范化器会解码共享行格式,而不维护快照专用编解码器。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.md
2026-08-15-packed-session-history-transport.md: 1fe8c78a89a0541475d10fae9ad643203f144c30
2026-08-15-packed-session-history-transport.zh.md: b2aa5bf0bc0bc4952df29674f0b9ffe836368f73
@@ -1,6 +1,7 @@
# Agent Note: Carry packed chunk rows through session history
Status: implemented
Archived: 2026-09-01
English | [中文](2026-08-15-packed-session-history-transport.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: 在会话历史中传输打包分片行
Status: implemented
Archived: 2026-09-01
[English](2026-08-15-packed-session-history-transport.md) | 中文
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md
2026-08-04-load-pre-react-loop-sessions.md: e95817ee60647ca002060a4f90c2263d4fe7ce42
2026-08-04-load-pre-react-loop-sessions.zh.md: 98fb1f530f5168fc02b312775d1bb8e6d305b8f8
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.md
2026-09-03-session-search-result-reveal.md: dc43d5e06febf61852d4b8dcdd0b5ca290f623e0
2026-09-03-session-search-result-reveal.zh.md: 0982498f5602ee18b20368d2b0dc04ab1ffae7f0
@@ -0,0 +1,36 @@
# Agent Note: Session search result reveal
Status: implemented
Archived: 2026-09-03
English | [中文](2026-09-03-session-search-result-reveal.zh.md)
## Problem
Selecting a Session search result opened its conversation while leaving the sidebar in the filtered search view. The user could not see where the Session belonged in the normal Workspace hierarchy. Clearing search alone was insufficient because the owning Workspace could be closed, the Session could be hidden beyond the five-row fold, and either grouped or flat navigation could place the row outside the scrollport.
## Decision
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) treats result selection as a transition back to normal browsing. It records the target Session id, clears the query, collapses search, and opens the Session. In grouped browsing, `SessionTree` waits until the current Workspace stream has a complete Host baseline, derives and opens the owning Workspace or Ungrouped group, and transiently reveals the hidden remainder only when the target is behind the five-row fold. Flat browsing needs no fold override.
The normal Session row owns completion of the one-shot reveal. A matching mounted row scrolls itself into the nearest visible position and acknowledges the target id, preventing later renders from repeating the scroll. Starting another non-empty search cancels an unacknowledged reveal. Metadata and content matches use the same transition because both result kinds resolve to a Session id.
## Alternatives considered
**Preserve the query after opening the Session.** This keeps the discovery context but leaves the user in the temporary result list and does not identify the Session's normal location.
**Clear search without opening or unfolding the owning group.** The conversation would open while its selected row could remain hidden, reproducing the missing-location problem in a different sidebar state.
**Persist an expanded-all preference for the group.** One navigation would permanently replace the bounded five-row presentation. The reveal instead expands the remainder only for the current tree mount.
**Scroll from the browser parent.** The parent cannot complete the operation before a folded target row mounts. The row that owns the DOM element performs and acknowledges the scroll.
## Consequences
Selecting a result discards the current query and returns the sidebar to normal browsing. The owning group stays open, and its hidden remainder is visible for that tree mount when required, so the selected row supplies both hierarchy context and an on-screen location. Waiting for the current Workspace baseline prevents a reconnect's retained membership from acknowledging the reveal before replacement state arrives. A later search or ordinary render does not repeat the scroll after acknowledgment.
The target row is the only completion signal. If another client archives or moves the Session between result selection and row mount, the reveal remains armed; the row will scroll if it mounts later, unless a new non-empty search cancels the reveal or the browser unmounts.
## Testing
UI tests cover a content-only hit in the sixth position of a closed Workspace, pending and reconnecting Workspace baselines, cancellation by a new search, transient group expansion and scroll acknowledgment, and the same one-shot scroll in flat mode. The assembled Web navigation test verifies that one click clears search and leaves exactly one selected Session row in the normal tree. The long-conversation browser test opens its seeded Session with that single-click transition.
@@ -0,0 +1,36 @@
# Agent Note: Session 搜索结果显露
Status: implemented
Archived: 2026-09-03
[English](2026-09-03-session-search-result-reveal.md) | 中文
## 问题
选择 Session 搜索结果会打开其对话,但侧边栏仍停留在筛选后的搜索视图。用户无法在常规 Workspace 层级中看到该 Session 的所属位置。仅清空搜索还不够,因为所属 Workspace 可能处于关闭状态,Session 可能隐藏在五行折叠之后,而分组或单列表导航都可能把该行放在滚动区域之外。
## 决策
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 将结果选择视为返回常规浏览的状态切换。它会记录目标 Session id、清空查询、收起搜索并打开 Session。在分组浏览中,`SessionTree` 会等待当前 Workspace stream 取得完整 Host 基线,再解析并打开所属 Workspace 或 Ungrouped 分组;仅当目标位于五行折叠之后时,才会临时显露其余隐藏条目。单列表浏览不需要覆盖折叠状态。
常规 Session 行负责完成一次性显露。匹配的行挂载后会将自身滚动到最近的可见位置,再确认目标 id 已处理,避免后续渲染重复滚动。开始另一项非空搜索会取消尚未确认的显露。元数据命中与内容命中都会解析为 Session id,因此共用同一套状态切换。
## 考虑过的替代方案
**打开 Session 后保留查询。** 这会保留发现上下文,却仍让用户停留在临时结果列表中,无法识别 Session 在常规层级中的位置。
**只清空搜索,不打开或展开所属分组。** 对话会打开,但选中行仍可能隐藏,只是把缺失位置的问题带到另一种侧边栏状态。
**为分组持久保存全部展开偏好。** 一次导航会永久取代有界的五行呈现。本次显露只在当前树挂载期间展开其余条目。
**由浏览器父组件执行滚动。** 折叠的目标行挂载前,父组件无法完成操作。持有 DOM 元素的行负责滚动并确认完成。
## 后果
选择结果会丢弃当前查询,并让侧边栏返回常规浏览。所属分组保持打开,并在需要时于该次树挂载期间显示隐藏条目,因此选中行同时提供层级上下文与屏幕内位置。等待当前 Workspace 基线可避免重连期间保留的旧归属信息在替换状态到达前确认显露。确认完成后,后续搜索或普通渲染不会重复滚动。
目标行是唯一的完成信号。如果另一客户端在结果选择与行挂载之间归档或移动了 Session,显露会保持待处理;除非新的非空搜索取消显露或浏览器卸载,否则该行以后挂载时仍会触发滚动。
## 测试
UI 测试覆盖关闭 Workspace 中位于第六位的纯内容命中、待定与重连中的 Workspace 基线、新搜索取消显露、临时分组展开与滚动确认,以及单列表模式下的同类一次性滚动。组装层 Web 导航测试验证一次点击会清空搜索,并在常规树中只留下一个选中的 Session 行。长对话浏览器测试通过这次单击状态切换打开预置 Session。
+15
View File
@@ -10,6 +10,9 @@
"architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml": "sha256:7eb471a53b7bef104c57e9343b80d672763f062ecb086b01b318b65b488d3c02",
"architecture/2026-06-15-turn-enclosure-invariant.md": "sha256:afefa3a268c84f26cf5461e08933245352a9e63cff688d3c398c8064a4ac6e85",
"architecture/2026-06-15-turn-enclosure-invariant.zh.md": "sha256:c54fdac980abc922cdc252a8fef59e4bdd7567316c7fbb6f7dbc035e470d95fa",
"architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml": "sha256:3c5c22e9e6a63598ba648cad46d783af322cf3afd6021426a2d738f4b026bf65",
"architecture/2026-06-18-shared-persistence-write-coordinator.md": "sha256:d5242c770101086b6f0a0c40eab500d405ef4a98cae07e28d9ec21e89d94f90e",
"architecture/2026-06-18-shared-persistence-write-coordinator.zh.md": "sha256:3dce52e302600a0eea29b4821a2718b6bbc1ebe4c6c2ae372cd0cbe66cb05519",
"architecture/2026-06-20-extract-example-app-packages.i18n.yaml": "sha256:d99b612cc1051c86d883d74737c72e921735e7a28e0b5e6351d3870c664bdcc4",
"architecture/2026-06-20-extract-example-app-packages.md": "sha256:9c7aca3a1e9a1ccc3729961663bc649b90076e671cae23e3db8203305983ccce",
"architecture/2026-06-20-extract-example-app-packages.zh.md": "sha256:19bd50232d9f25d35aa3f9dc72d9af0df457dd0eaca8b982d5aa625e5b95bcff",
@@ -37,6 +40,9 @@
"architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml": "sha256:cf99eda0e58b49630d5f95792459d7095666fafbef61f614165d5cdd031b7118",
"architecture/2026-07-24-dsh-commander-argument-adapter.md": "sha256:705654c8a43bcd199f72c21a77d24ca8bfa02447aff1c7f3e4e820be61dcd562",
"architecture/2026-07-24-dsh-commander-argument-adapter.zh.md": "sha256:3844f02d7659d18caf5d39e1131ed775c789cbf92dc44b4a446c7d6468aa5d00",
"architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml": "sha256:41aa86c65f78e125ca2178295d02d5994d5e0f2e9359b1465777db1aba4a105b",
"architecture/2026-07-26-packed-chunk-rows-by-default.md": "sha256:2b4e14675d12a1fc07eb373ab8566c3e63c7c15e2b5a0b391e6acbc896c7359d",
"architecture/2026-07-26-packed-chunk-rows-by-default.zh.md": "sha256:e0b0e8a4ab529a3461c22f434cbbe1e7b0ea381592871caf7e3fe5157ef844ba",
"architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml": "sha256:7b9dbe8b4a340640610abe7e54fb29492d77a187c176996a53d0e1fc7c8e1945",
"architecture/2026-07-27-tui-chat-channel-module-split.md": "sha256:3e2cd43f306a18b3eaf9bac23e6bdc3a5dbdc7388b7c399ce71e4f71b8f71d2a",
"architecture/2026-07-27-tui-chat-channel-module-split.zh.md": "sha256:d6b84fdcd91a2693b72cf6884b3a0c39e56e571b2b694f630805d894a6ba292f",
@@ -52,6 +58,9 @@
"architecture/2026-08-11-plugin-settings-tabs.i18n.yaml": "sha256:0365da2b317fc5f94dd190064198565f4c624afc91d2e62161ab9170f79d11bc",
"architecture/2026-08-11-plugin-settings-tabs.md": "sha256:fdd92cfe55b6c4cd31b3f768dd46a2ecf129a04c9818249cbdd33857cf722bbf",
"architecture/2026-08-11-plugin-settings-tabs.zh.md": "sha256:8993df1a0178aba1ea35c460ee67c522900344a4b386287bba9dfac2bfb87efa",
"architecture/2026-08-15-packed-session-history-transport.i18n.yaml": "sha256:547b89497b009593db5acfae2a3b989f17b8392f5df73ef631b38f8f68f629f1",
"architecture/2026-08-15-packed-session-history-transport.md": "sha256:ec7f84d59eea95668a8cb6c92ae433a2b7e7b76446a27e57dd5dba05856f563f",
"architecture/2026-08-15-packed-session-history-transport.zh.md": "sha256:0d8eb5444557a18f76c68ce5ae9f0779c580eaddb921ee8651464baea34e0e53",
"architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml": "sha256:42bce930799cb511e9fb245dec5e26efd78bdab4c9b75f7393e37b40fbee4d10",
"architecture/2026-08-18-sqlite-physical-chunk-row-compression.md": "sha256:4fe241f1b272278d9f3ca1a4431971220e1fa54411df043826ef6f59225bf949",
"architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md": "sha256:73178c9ec5abf571680d8facfb145cbadc1efbb2e67e3f039747c2f9cf4bb730",
@@ -136,6 +145,9 @@
"bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d",
"bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23",
"bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16",
"bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5",
"bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9",
"bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe",
"feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
"feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
"feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",
@@ -388,6 +400,9 @@
"process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml": "sha256:dde0041399b253e3758045f0858488db8178ffc563ce889c8b396c87af6c3730",
"process/2026-08-12-documentation-site-navigation-and-chrome.md": "sha256:56cb836ed862378afd33eb5c1a9dc159958b35a0aed3bf4336fcf26ab0b84b8b",
"process/2026-08-12-documentation-site-navigation-and-chrome.zh.md": "sha256:f2dd4adde38a09fe312866a1e6dad0f465684d809287862f40f1a488acd4fe18",
"process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml": "sha256:c522daca5e126bf64227d6259f447648a589831125113dcf49814817d4b28f17",
"process/2026-08-31-pr-opened-issue-start-dates.md": "sha256:749f343576006b0d4950b9c67f434cea0bb9ae21a5fd8d74fefe77b59289ffbf",
"process/2026-08-31-pr-opened-issue-start-dates.zh.md": "sha256:a227dc6cba6e62d08a15b1c6b69b5941297c2d10035d257d6f3bc95d6ea54591",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04",
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md
2026-08-31-pr-opened-issue-start-dates.md: f8976b8b0499aa5c68c9637e8571805b78ce6d48
2026-08-31-pr-opened-issue-start-dates.zh.md: 3142bad5007cbbdd27e1f564bc3ccec9101d3d79
2026-08-31-pr-opened-issue-start-dates.md: f17e1bf0dcfdc541952312d1b504bdaa2e2817cc
2026-08-31-pr-opened-issue-start-dates.zh.md: 3aab8f5528ad125210fbb3839fd334d99ad49bb0
@@ -1,31 +1,34 @@
# Agent Note: PR-opened Issue start dates
Status: implemented
Archived: 2026-09-02
English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md)
## Problem
The Issue Project records planned work in a `Start date` field, but adding or linking an Issue does not provide a date value. A pull request can identify both Issues it resolves and Issues that supply related implementation context, and either relationship marks the start of repository work.
The organization-level `Start date` Issue field records when work begins, but adding an Issue to the Issue Project or linking it from a pull request does not provide a date value. A pull request can identify both Issues it resolves and Issues that supply related implementation context, and either relationship marks the start of repository work.
Updating the field on every pull-request event would assign dates to existing work after edits, pushes, or reopenings. Replacing an existing date would also discard a manually planned date or a date recorded by an earlier pull request.
## Decision
The Issue lifecycle workflow initializes `Start date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the configured Date field only when the current value is empty.
The Issue lifecycle workflow initializes `Start date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the configured organization Issue Date field only when the current value is empty.
The configuration names the Project field and time zone. Missing configuration fails when the policy module loads; a missing field, a non-Date field, an invalid timestamp, or a failed API request fails the workflow at the first relevant pull request.
The configuration names the field exposed in the Project and the time zone. The Project field must resolve to an organization Issue Date field; the workflow reads its Issue value and updates it through `updateIssueFieldValue`. Missing configuration fails when the policy module loads; a missing field, a non-Date or Project-local field, an invalid timestamp, or a failed API request fails the workflow at the first relevant pull request.
[Event-directed PR review status commands](2026-08-10-event-directed-pr-review-status.md) continue to own Status transitions. Date initialization includes resolving and informational Issue references, runs for Draft and automated pull requests, and does not depend on PR policy enforcement.
## Verification
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) cover the Shanghai date boundary, opened-only dispatch, all retained Issue references, empty-value writes, existing-value preservation, missing Project items, invalid field configuration, and the GraphQL mutation variables. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) require the `pull_request.opened` subscription.
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) cover the Shanghai date boundary, opened-only dispatch, all retained Issue references, Issue-field discovery, empty-value writes, existing-value preservation, missing Project items, invalid field configuration, and the `updateIssueFieldValue` variables. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) require the `pull_request.opened` subscription.
## Alternatives considered
**Use a built-in Project workflow.** The built-in workflows own fixed Project item and Status transitions; the repository workflow already owns authenticated GraphQL mutations and can supply the PR creation date.
**Use a Project-local Date field.** A Project field would allow different dates for the same Issue in different Projects and would not appear on the Issue itself. Work begins for the Issue rather than for one Project membership, so the organization Issue field owns the value.
**Process every subscribed PR event or run a reconciler.** Later events would fill dates for existing pull requests and references added after creation, but they would make the field a repair projection instead of a record created with the pull request and would add repeated Project reads.
**Update only resolving Issue references.** Informational references also identify Issues whose implementation work begins with the pull request, so the date initializer uses the existing all-reference set while Status transitions retain resolving-only semantics.
@@ -34,6 +37,6 @@ The configuration names the Project field and time zone. Missing configuration f
## Consequences
Only pull requests opened after the workflow ships initialize dates. References added after creation and existing open pull requests remain unchanged, and the workflow does not scan existing Project items or pull requests.
Only pull requests opened after the workflow ships initialize dates. References added after creation and existing open pull requests remain unchanged, and the workflow does not scan existing Project items or pull requests. The date follows the Issue across organization Projects that expose the field.
The empty-value read makes retries idempotent in ordinary operation. ProjectV2 has no conditional field update, so simultaneous pull requests that reference the same empty Issue can both write; per-PR concurrency does not serialize that Issue, and the last mutation can win.
The empty-value read makes retries idempotent in ordinary operation. The Issue-field mutation has no compare-and-set precondition, so simultaneous pull requests that reference the same empty Issue can both write; per-PR concurrency does not serialize that Issue, and the last mutation can win.
@@ -1,31 +1,34 @@
# Agent Note: 在 PR 创建时设置 Issue 开始日期
Status: implemented
Archived: 2026-09-02
[English](2026-08-31-pr-opened-issue-start-dates.md) | 中文
## 问题
Issue Project 使用 `Start date` 字段记录已规划工作开始日期,但加入或关联 Issue 不会提供日期值。PR 可以同时标识它所解决的 Issue 和提供相关实现上下文的 Issue;两种关系都表示仓库工作已经开始。
组织级 `Start date` Issue 字段记录工作开始时间,但把 Issue 加入 Issue Project 或从 PR 关联它都不会提供日期值。PR 可以同时标识它所解决的 Issue 和提供相关实现上下文的 Issue;两种关系都表示仓库工作已经开始。
如果每个 PR 事件都更新该字段,编辑、推送或重新打开 PR 会为已有工作补上日期。覆盖已有日期还会丢弃人工规划的日期或较早 PR 记录的日期。
## 决策
Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前值为空时写入配置的 Date 字段。
Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前值为空时写入配置的组织级 Issue Date 字段。
配置指定 Project 字段和时区。配置缺失会在策略模块加载时失败;字段缺失、字段不是 Date 类型、时间戳无效或 API 请求失败会让首个相关 PR 的工作流失败。
配置指定 Project 中显示的字段和时区。该 Project 字段必须解析为组织级 Issue Date 字段;工作流读取它的 Issue 值并通过 `updateIssueFieldValue` 更新。配置缺失会在策略模块加载时失败;字段缺失、字段不是 Date 类型或是 Project 局部字段、时间戳无效或 API 请求失败会让首个相关 PR 的工作流失败。
[由事件直接指定的 PR 评审状态命令](2026-08-10-event-directed-pr-review-status.zh.md)继续负责 Status 转换。日期初始化同时包含解决型和信息型 Issue 引用,对 Draft PR 和自动化 PR 同样运行,也不依赖 PR 策略检查是否生效。
## 验证
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)覆盖上海时区日期边界、仅 opened 分派、全部保留的 Issue 引用、空值写入、已有值保留、Project item 缺失、字段配置无效和 GraphQL mutation 变量。[工作流测试](../../../../scripts/ci-workflow.spec.ts)要求保留 `pull_request.opened` 订阅。
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)覆盖上海时区日期边界、仅 opened 分派、全部保留的 Issue 引用、Issue 字段发现、空值写入、已有值保留、Project item 缺失、字段配置无效和 `updateIssueFieldValue` 变量。[工作流测试](../../../../scripts/ci-workflow.spec.ts)要求保留 `pull_request.opened` 订阅。
## 考虑过的替代方案
**使用 Project 内置工作流。** 内置工作流负责固定的 Project item 和 Status 转换;仓库工作流已经负责经过身份验证的 GraphQL mutation,并且能够提供 PR 创建日期。
**使用 Project 局部 Date 字段。** Project 字段允许同一个 Issue 在不同 Project 中使用不同日期,并且不会显示在 Issue 自身。工作是针对 Issue 开始,而不是针对某次 Project 归属开始,因此由组织级 Issue 字段持有该值。
**处理每个已订阅 PR 事件或运行协调器。** 后续事件可以为已有 PR 和创建后新增的引用补上日期,但这会让该字段成为修复型投影,而不是随 PR 创建的记录,并且会增加重复 Project 读取。
**仅更新解决型 Issue 引用。** 信息型引用同样标识随该 PR 开始实现工作的 Issue,因此日期初始化使用现有的全部引用集合,Status 转换仍只处理解决型引用。
@@ -34,6 +37,6 @@ Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date
## 后果
只有工作流发布后新建的 PR 会初始化日期。创建后新增的引用和现有开放 PR 保持不变,工作流不会扫描已有 Project item 或 PR。
只有工作流发布后新建的 PR 会初始化日期。创建后新增的引用和现有开放 PR 保持不变,工作流不会扫描已有 Project item 或 PR。日期会随 Issue 出现在组织内显示该字段的各个 Project 中。
空值读取使重试在通常情况下保持幂等。ProjectV2 没有条件字段更新,因此同时引用同一个空日期 Issue 的 PR 可能都会写入;按 PR 设置的并发控制不会串行化该 Issue,最后一次 mutation 可能胜出。
空值读取使重试在通常情况下保持幂等。Issue 字段 mutation 没有比较并设置前提,因此同时引用同一个空日期 Issue 的 PR 可能都会写入;按 PR 设置的并发控制不会串行化该 Issue,最后一次 mutation 可能胜出。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md
2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c
2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff
2026-06-11-dev-invariants-over-deep-readonly.md: 7e5f55e8910797bd46674050ea5eb8abbca4aef7
2026-06-11-dev-invariants-over-deep-readonly.zh.md: c1413e9c89284312af41b6c115b7e50a99d1b5e3
@@ -22,7 +22,7 @@ Responsibility is split between an always-on storage boundary and optional devel
`Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references.
The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, `session/event` observers receive the same record, and `session.events` returns a frozen array snapshot. A previously returned array does not grow after a later append. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` and `eventAt()` avoid array materialization when a caller needs only the current length or one event. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered.
@@ -32,7 +32,7 @@ This guarantee belongs in `Session`, not in an optional listener, because every
### Package-owned invariant companions check relationships
`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Every package publishes a `./invariant` ownership companion; `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` currently add the rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)).
`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. A package publishes a `./invariant` ownership companion only for an independently observable runtime relationship; packages without one omit the companion and record the reason in their README. `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` provide the initial rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md); [omission decision](../simplification/2026-08-28-omit-unneeded-invariant-companions.md)).
When the session companion attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. The service gives each contribution a disposable child fiber, so hot reload is safe in the middle of a turn without giving diagnostics ownership of session storage.
@@ -48,12 +48,12 @@ Freezing history only when an invariants plugin is installed would make the core
### Clone only when deriving messages
Detaching `deriveMessages()` would protect the most common request path but leave other readers of `session.events`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute.
Detaching `deriveMessages()` would protect the most common request path but leave other readers of `snapshotEvents()`, `eventAt()`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute.
## Consequences
- Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it.
- `session.events` exposes stable immutable snapshots instead of the private growing array.
- `snapshotEvents()` exposes stable immutable snapshots instead of the private growing array; `seq` and `eventAt()` serve scalar reads without copying that array.
- Request-side mutation cannot reach stored history through derived messages.
- Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability.
- `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package.
@@ -22,7 +22,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
`Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的已分离的确切记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者接收同一记录`session.events` 返回冻结的数组快照先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。
被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者`eventAt(seq)` 接收同一记录`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照先前返回的数组不会因后续 append 而增长。调用方只需要当前长度或单个事件时,`seq``eventAt()` 不会物化数组。种子记录在构造成功前经过相同的验证、快照与冻结边界。
此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
@@ -32,7 +32,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
### 包拥有的不变式配套插件检查关系
`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session``dsh-agent``dsh-scope``dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.zh.md))。
`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。只有拥有可独立观察的运行时关系时,包才发布 `./invariant` 所有权配套插件;没有该关系的包会省略 companion 并在 README 中记录原因。`dsh-session``dsh-agent``dsh-scope``dsh-agent-loop` 提供首批需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.zh.md)与[省略决策](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md))。
当会话配套插件附加到已有会话或以种子记录初始化的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。
@@ -48,12 +48,12 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
### 仅在派生消息时克隆
分离 `deriveMessages()` 能保护最常见的请求路径,但 `session.events` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。
分离 `deriveMessages()` 能保护最常见的请求路径,但 `snapshotEvents()``eventAt()` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。
## 后果
- 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。
- `session.events` 暴露稳定的不可变快照,而非持续增长的私有数组。
- `snapshotEvents()` 暴露稳定的不可变快照,而非持续增长的私有数组`seq``eventAt()` 为标量读取提供无需复制数组的路径
- 请求侧的修改无法通过派生消息触及已存储的历史。
- 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。
- `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
2026-06-14-session-persistence.md: 50ec79de83f0cef4a3ec94b689cc25937e334016
2026-06-14-session-persistence.zh.md: 7b66aed6f077ac484802cfa1e23e1ba7ac3ae985
2026-06-14-session-persistence.md: 8f6c8753007e1998f9b072974e154c5a76ef116b
2026-06-14-session-persistence.zh.md: b6d8e16377e0c294f19c69334c0770a66add756e
@@ -14,23 +14,23 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append
Persistence is a **capability seam** with an abstract Service Definition ([capability seams](2026-06-13-capability-seams.md), the `dsh-shell` template), not loop or core logic:
1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type.
2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Eligible `assistant/chunk` delta runs use packed rows by default; [checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`open`/`stat`/`list`/`flush`, with `create`/`open` returning per-session `SessionHandle`s that carry `read`/`append`/`flush`/`close` ([handle-based seam](2026-08-27-handle-based-session-persistence.md)). Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type.
2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Current v2 writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. [Checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
Key durable, contested choices:
- **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and validation of `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service and coordinator remain available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut.
- **The canonical durable log persists every current `SessionEvent` losslessly.** In v2, one `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log.
- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, persistence returns its contiguous, parseable events unmodified; the reader owns balancing — resume computes risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` (`interruptedTurnClosers`) and appends them through its write handle, while read-only observers add the same closers in memory. The synthetic results keep resumed provider transcripts valid. Only the incomplete fragment of a torn final append is discarded — complete records recovered from it are durably rewritten by the write path before its first new append; a parse error or sequence gap in the committed prefix is corruption and makes the session unloadable.
- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service remains available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut.
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, and JSONL validates the decoded header. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` obtains the exact unpublished Session through `ctx.sessionPersistence.prepare()`, publishes it under the persisted id, and continues its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns reuse between history inspection and resume. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` opens the session's write handle, reads the stored log, and publishes the prepared Session under the persisted id, continuing its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns the unpublished-Session ownership window. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
## Alternatives considered
Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
Each key choice above records its rejected alternative where the choice is stated: a **stream-filtered canonical log** — loses attempt evidence, while removing events without an explicit migration breaks contiguous sequence numbers; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes tolerated during cold preparation; a future provider or write-ahead log needs its own power-loss and recovery contract.
Format versioning: the header carries a `version`; handles expose only `SESSION_FORMAT_VERSION = 2`. JSONL event-body reads compose the static v0-to-v1 and v1-to-v2 adjacent migration chain before returning a handle; the first edge owns bounded legacy normalization, while the second owns Assistant stream embedding and dense reference remapping. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract.
## Consequences
The Service Definition, JSONL provider, and metadata contract in `dsh-session` (`session.header`, the `create(id?, options?)` signature) buy durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log. The reusable `runPersistenceContract` suite holds the provider and future implementations to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every `assistant/chunk` survives exactly even when JSONL packs several into one storage row.
The Service Definition, JSONL provider, and metadata contract in `dsh-session` (`session.header`, the `create(header, options?)` signature) buy durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log. The reusable `runPersistenceContract` suite holds the provider and future implementations to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every Assistant attempt retains its exact compact timed stream in one durable settlement.
@@ -14,23 +14,23 @@ Status: implemented
持久化是一个具有抽象 Service Definition 的**能力 seam**[能力 seam](2026-06-13-capability-seams.zh.md)`dsh-shell` 模板),而非循环或核心逻辑:
1. **接口**`dsh-session-persistence``ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent``{ type, seq, time, data }`),原样复用,无转换类型。
2. **实现**`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
1. **接口**`dsh-session-persistence``ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`open`/`stat`/`list`/`flush`,其中 `create`/`open` 返回逐会话的 `SessionHandle`,句柄承载 `read`/`append`/`flush`/`close`[基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md)。其持久化单元就是现有的 `SessionEvent``{ type, seq, time, data }`),原样复用,无转换类型。
2. **实现**`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。当前 v2 每个事件写一行;冻结的 v0 与 v1 reader 保留其历史 packed-delta 表示。[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
长期有效、存在争议的关键选择:
- **规范持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }``turn/end``prepare``load` 在返回可恢复视图前提交这些收尾事件合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务与 coordinator 继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。
- **规范持久日志无损保留每个当前 `SessionEvent`。** 在 v2 中,一个 `assistant/message``assistant/attempt` 会嵌入该 attempt 的精确带时间 provider stream`deriveMessages()` 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 `seq = log.length` `events[i].seq === i`[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该改写,而不是过滤规范日志。
- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,持久化会原样返回其连续、可解析的事件;配平是读方的职责——resume 会为未应答的 assistant 调用计算按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }``turn/end``interruptedTurnClosers`),并通过其写句柄追加它们,而只读观察方仅在内存中添加同样的收尾事件合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有撕裂的最终 append 中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在第一次新 append 之前持久重写;已提交前缀中的解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()``createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。)
- **`ctx.agents.create()``ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。
- **`ctx.agents.create()``ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 打开该会话的写句柄,读取已存储的日志,并以持久化 id 发布准备好的 Session,继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义未发布 Session 的所有权窗口。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。
## 曾考虑的替代方案
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 stream 的规范日志**会丢失 attempt 证据,而未通过显式迁移移除事件会破坏连续序号;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
格式版本控制:header 携带一个 `version`冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写能承受冷准备时可容忍的尾部不完整写入;未来 provider 或 write-ahead log 需要自有的断电与恢复约定。
格式版本控制:header 携带 `version`句柄只暴露 `SESSION_FORMAT_VERSION = 2`。JSONL 的事件正文读取会在返回句柄前组合静态 v0-to-v1 与 v1-to-v2 相邻迁移链;第一条边负责有界 legacy normalization,第二条边负责 Assistant stream 嵌入与密集引用重映射。V0 保留无后缀的 `session.jsonl[.zstd]`,正版本则使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。
## 后果
Service Definition、JSONL provider 与 `dsh-session` 中的元数据约定(`session.header``create(id?, options?)` 签名)带来持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问。可复用 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束该 provider 与未来实现。持久化完整逻辑日志确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留
Service Definition、JSONL provider 与 `dsh-session` 中的元数据约定(`session.header``create(header, options?)` 签名)带来持久恢复/fork、读取/replay 路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧 Session 访问。可复用 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束该 provider 与未来实现。持久化完整逻辑日志确定了事件保真度:每个 Assistant attempt 都在一个持久 settlement 中保留其精确紧凑带时间 stream
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-session-surface.md
2026-06-18-session-surface.md: 95298da0e4bd16e822cb5960718d23ecda7a1b5c
2026-06-18-session-surface.zh.md: 7dd05d79f635b193b2c11cb3599264ebf2424d79
2026-06-18-session-surface.md: 0139cc4beba766e4e8b936594899649304234eaa
2026-06-18-session-surface.zh.md: 0596d2a0425890924276265dd9cc6c32fcffb974
@@ -16,7 +16,7 @@ Add a **surface** — a derived, cached order of event sequences (the subset of
Every `SessionEvent` gains two optional fields (structural metadata, like `seq`/`time`):
- **`sourceEventSeqs?: number[]`** — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs that built an `assistant/message`, or the surface nodes shadowed by a compaction marker). A present `[]` is valid only on `assistant/message` and records a known empty provider stream; when the field is absent, a legacy or foreign event does not record which earlier events produced the message. Other surface events require a non-empty list when the field is present. Without these cited seqs, replay cannot validate that a replace-range operation names every event it removed.
- **`sourceEventSeqs?: number[]`** — seq numbers of earlier events cited as sources, such as a `tool/call` cited by its result or surface nodes shadowed by a compaction marker. A present list is non-empty, unique, earlier, and known. V2 `assistant/message` embeds its provider stream and cannot carry this field. Without cited seqs, replay cannot validate that a replace-range operation names every event it removed.
- **`surfaceOp?: SurfaceOp`** — how this event entered the surface. Absent for non-surface events.
### SurfaceOp: two operations
@@ -27,7 +27,7 @@ export type SurfaceOp =
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
```
1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: every successful `assistant/message` records its complete `assistant/chunk` source set, including `[]`, while `tool/result` records its `tool/call` source.
1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: `tool/result` records its `tool/call` source, while `assistant/message` owns its embedded stream directly.
2. **Replace** — remove entries from `start` through `end` (both inclusive) and insert the new event seq in their place. Both `start` and `end` must be present in the current surface; `start === end` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface.
@@ -41,7 +41,7 @@ Delta processing is O(1) when no new events and O(new events) when new events ar
### Persistence
The new fields are serialized as top-level JSON properties. JSONL storage requires no separate column mapping: its lossless JSON boundary preserves both values. The session format `version` is pinned at `SESSION_FORMAT_VERSION = 0`; the optional surface fields are absorbed without bumping it.
The new fields are serialized as top-level JSON properties. JSONL storage requires no separate column mapping: its lossless JSON boundary preserves both values. Released v0 and v1 share this surface representation, and the identity v0-to-v1 edge preserves it exactly; a future structural representation change increments `SESSION_FORMAT_VERSION` and owns an adjacent migration.
### Crash recovery
@@ -49,9 +49,9 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls
### Invariants
`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: only `assistant/message` may use an empty source-event list; references are unique, earlier, and known; replacement endpoints exist in surface order; and `sourceEventSeqs` covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: source lists are non-empty, unique, earlier, and known; `assistant/message` carries no source list; replacement endpoints exist in surface order; and `sourceEventSeqs` covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy.
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and current loaded logs. Historical v0 validation and normalization belong to the v0-to-v1 edge rather than generic Session code.
## Alternatives considered
@@ -16,7 +16,7 @@ Status: implemented
每个 `SessionEvent` 获得两个可选字段(结构性元数据,与 `seq`/`time` 同级):
- **`sourceEventSeqs?: number[]`**:被引用为数据来源的早期事件 seq 编号例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点。出现的 `[]` 只在 `assistant/message` 上有效,表示已知为空的提供方流;旧格式或外部事件缺少该字段时,没有记录这条消息由哪些早期事件产生。其他 surface 事件一旦出现此字段,就必须是非空列表。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。
- **`sourceEventSeqs?: number[]`**:被引用为数据来源的早期事件 seq 编号例如 result 引用的 `tool/call`,或被 compaction marker 遮蔽的 surface 节点。出现的列表必须非空、唯一、更早且已知。V2 `assistant/message` 嵌入其 provider stream,不能携带该字段。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。
- **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。
### SurfaceOp:两种操作
@@ -27,7 +27,7 @@ export type SurfaceOp =
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
```
1. **Append**:在尾部追加新事件的 seq。`user/message``assistant/message``tool/result``context/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`每个成功的 `assistant/message` 都记录完整的 `assistant/chunk` 来源集合(包括 `[]`),而 `tool/result` 记录其 `tool/call` 来源。
1. **Append**:在尾部追加新事件的 seq。`user/message``assistant/message``tool/result``context/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs``tool/result` 记录其 `tool/call` 来源`assistant/message` 则直接拥有其嵌入式 stream
2. **Replace**:移除从 `start``end`(两端包含)的条目,并在其位置插入新事件的 seq。`start``end` 都必须存在于当前 surface`start === end` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。
@@ -41,7 +41,7 @@ export type SurfaceOp =
### 持久化
新字段作为顶层 JSON 属性序列化。JSONL 存储无需单独列映射:其无损 JSON 边界会保留两个值。会话格式 `version` 固定为 `SESSION_FORMAT_VERSION = 0`;可选 surface 字段被吸收而不递增版本号
新字段作为顶层 JSON 属性序列化。JSONL 存储无需单独列映射:其无损 JSON 边界会保留两个值。已发布 v0 与 v1 共享该 surface 表示,恒等的 v0-to-v1 边会精确保留它;未来结构性表示变更会递增 `SESSION_FORMAT_VERSION` 并拥有一项相邻迁移
### 崩溃恢复
@@ -49,9 +49,9 @@ export type SurfaceOp =
### 不变式
`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs``surfaceOp`只有 `assistant/message` 可以使用空的源事件列表;引用必须唯一、更早且已知;替换端点必须存在于 surface 顺序中;`sourceEventSeqs` 必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选的不变式服务提供的规则。
`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs``surfaceOp`source list 必须非空、唯一、更早且已知;`assistant/message` 不携带 source listreplacement endpoint 必须存在于 surface 顺序中;`sourceEventSeqs` 必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选 invariant service 提供的规则。
每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和加载日志。按照预发布格式策略,无效的种子被拒绝而非升级
每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和当前已加载日志。历史 v0 的校验与规范化属于 v0-to-v1 边,而不属于通用 Session 代码
## 曾考虑的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-branded-ids.md
2026-06-20-branded-ids.md: 954fd89aa229ba587cd1293973b4038cfeb20473
2026-06-20-branded-ids.zh.md: 0dd761da2e5b5fc3e864fe03c250b9781be9ee59
2026-06-20-branded-ids.md: 1f579a7afb7ac5f7facd6c5e8040d5df719c4bed
2026-06-20-branded-ids.zh.md: eaf032027f2f61bec9e4ab624622a5012c97e9b9
@@ -51,7 +51,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o
- **`ModelId`** (`GenerateOptions.model`, the `LlmRuntime` adapter-registry key) — a real cross-package lookup key (config → agent → llm → adapter); a reasonable next brand, left out only to keep this decision's blast radius focused.
- **`ToolName`** (the `ToolRuntime` key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand.
- **`ErrorCode`** (`HarnessError.code`) — a closed vocabulary (`ABORTED`, `NO_ADAPTER`, …), not a per-instance id; better served by a string-literal union than a brand, if anything.
- **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded<string>` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low.
- **Other numeric ordinals** — the [Session sequence and log-offset decision](2026-08-31-session-sequence-and-log-offset-brands.md) brands event identities and log gaps because they cross persistence and reference seams. Turn and step numbers remain plain numbers: they are payload-local ordinals and are not interchangeable with Session event positions.
- **Validated construction** — `brandString<T>()` performs no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a runtime-behavior change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision.
## Verification
@@ -51,7 +51,7 @@ const owner = brandString<OwnerToken>('session-1')
- **`ModelId`**`GenerateOptions.model``LlmRuntime` 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → 适配器);合理的下一个 brand,仅为控制本决策的影响范围而暂不纳入。
- **`ToolName`**`ToolRuntime` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。
- **`ErrorCode`**`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。
- **数值序号**轮次号、步骤号和事件 `seq` 是 `number` 而非 `string``Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低
- **其他数值序号**[Session 序列号与日志偏移决策](2026-08-31-session-sequence-and-log-offset-brands.zh.md)会为事件身份与日志间隙加 brand,因为它们跨越 persistence 与引用 seam。turn 与 step number 保持普通 number:它们是 payload-local ordinal,不会与 Session 事件位置互换
- **带校验的构造**`brandString<T>()` 不执行运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它属于运行时行为变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理。
## 验证
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
2026-06-21-bounded-llm-request-recovery.md: 42bf460e52133b2a5471479fa3d7647e70092b48
2026-06-21-bounded-llm-request-recovery.zh.md: 2a13f0a740348a5f74bd3d90120a148b25f2e870
2026-06-21-bounded-llm-request-recovery.md: 6ae3915462c00eadcc550c80165fab7247a9170c
2026-06-21-bounded-llm-request-recovery.zh.md: 82e2e783fffaedcb5bfaabe72f56f7c4176d3784
@@ -10,7 +10,7 @@ The [per-provider request retry policy](../feature/2026-07-24-provider-retry-pol
Provider adapters can fail by throwing during dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary normalizes thrown values to that terminal finish protocol before `dsh-agent-loop` receives them; middleware and result-processing defects remain thrown. The loop offers a terminal model-request failure to `agent/request-error`. An unhandled failure is terminal; a handling listener repairs policy-owned state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns this return contract.
That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry reconstructs its next attempt from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
That boundary is already safe for another request attempt. Each failed stream commits one log-only `assistant/attempt` with its exact compact stream, message derivation ignores it, tool calls are dispatched only after a successful terminal finish and assembled `assistant/message`, and a retry reconstructs its next attempt from the durable surface. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
The prior boundary left three narrower gaps.
@@ -38,7 +38,7 @@ interface LlmFailure {
}
```
`code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is derived from the failed step's `assistant/chunk` events.
`code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is preserved by the failed attempt's embedded stream.
`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. The final adapter boundary detaches those facts from adapter-thrown values and emits the appropriate terminal finish; unknown SDK exceptions receive an `UNKNOWN` payload. Exact thrown-object identity does not cross the LLM stream seam.
@@ -82,7 +82,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada
### Keep attempts separate in the existing log
A failed attempt may leave `assistant/chunk` events in its step, but it never appends `assistant/message` and never dispatches a tool. A retry continues inside the failing turn and step, reconstructs the request from the durable surface, and produces its own chunks; only the final outcome closes the turn. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed attempt or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects each producer-correlated `retryId` chain into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from `llm/retry-started` and the owning turn and step boundaries' closure. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows.
A failed attempt appends `assistant/attempt` with its embedded stream, but never appends a surface `assistant/message` or dispatches a tool. A retry continues inside the failing turn and step, reconstructs the request from the durable surface, and produces its own settlement; only the final outcome closes the turn. UIs may render transient `assistant/live-chunk` updates while a step is open, then settle the failed attempt when `llm/retry` identifies it or `turn/end` records failure. Web validates the complete retry payload contract, projects each producer-correlated `retryId` chain into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from `llm/retry-started` and the owning turn and step boundaries' closure. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no surface Assistant node. Message derivation ignores `assistant/attempt`, and Web applies the same projection during history rebuild so refreshing cannot promote failed partials into model history or duplicate retry rows.
If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. Web derives one `turn-error` node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. While transient recovery continues, `llm/retry` is the durable home for each intermediate failure and delay; the terminal row exists only once `turn/end` records the error, and because exhausted recovery shares the failing turn, the turn's retry history never suppresses that row — the settled retry chain and the terminal error render side by side. No standalone final-error event or response-id vocabulary is added.
@@ -10,7 +10,7 @@ Status: implemented
提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回约定。
该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn``step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功组装完成后,系统才会分发工具调用重试则从持久日志重建下一次尝试。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试
该边界已能安全地再次发起请求。每个失败 stream 会提交一个包含精确紧凑 stream 的仅日志 `assistant/attempt`message derivation 会忽略它;系统只会在 terminal finish 成功组装 `assistant/message` 后分派工具调用重试则从持久 surface 重建下一次 attempt。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次 attempt
此前的边界还留有三个较窄的缺口。
@@ -38,7 +38,7 @@ interface LlmFailure {
}
```
`code` 仍是 `HarnessError` 建立的提供方无关机器路由分类体系;新字段是在提供方边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后为提供方发放的字符串。该载荷有意不包含 `retryable`、`failover`、`partialOutput`、提供方、模型、阶段或路由 id 字段。是否可重试属于策略,提供方/模型已位于持久请求头中,部分输出则从失败步骤的 `assistant/chunk` 事件派生
`code` 仍是 `HarnessError` 建立的 provider-neutral 机器路由分类;新字段是在 provider 边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后是 provider 发放的字符串。该 payload 有意不包含 `retryable`、`failover`、`partialOutput`、provider、model、phase 或 route id。是否可重试属于 policyprovider/model 已位于持久 request header 中,部分输出由失败 attempt 的嵌入式 stream 保留
`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。最终适配器边界会从适配器抛出值中分离这些事实,并发出相应的终止 finish;未知 SDK 异常会获得 `UNKNOWN` 载荷。精确的抛出对象身份不会跨越 LLM 流 seam。
@@ -82,7 +82,7 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen
### 在现有日志中分隔尝试
一次失败尝试可以在其步骤中留下 `assistant/chunk` 事件,但绝不追加 `assistant/message`,也不会分发工具。重试在失败的轮次与步骤内继续,从持久表层重建请求,并生自己的分片;只有最终结果才会关闭该轮次。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败尝试,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷约定,在 `llm/retry` 到达时清除失败的部分输出,将每条生产方关联的 `retryId` 重试链投影为稳定一行,并用最新一次尝试更新该行,再从 `llm/retry-started` 与所属轮次、步骤边界的关闭派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行
失败 attempt 会追加带嵌入式 stream 的 `assistant/attempt`,但绝不追加 surface `assistant/message` 或分派工具。重试在失败 turn 与 step 内继续,从持久 surface 重建请求,并生自己的 settlement;只有最终结果才会关闭 turn。step 打开时,UI 可以渲染瞬态 `assistant/live-chunk` update;当 `llm/retry` 标识失败 attempt 或 `turn/end` 记录失败时,UI 再结算它。Web 会校验完整 retry payload contract,把每条 producer-correlated `retryId` chain 投影为稳定一行并更新到最新 attempt,再从 `llm/retry-started` 与所属 turn、step boundary 的关闭派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到 event 的时刻为计划延迟起点,而不是 Host event clock;它按向上取整且不低于 1 秒的秒数显示,只在未结算时动画,并把最新失败详情折叠在该行后。即使失败 attempt 没有 surface Assistant noderetry node 也会锚定自己的 trajectory turn。Message derivation 会忽略 `assistant/attempt`,Web 在历史重建时应用同一投影,因此刷新不会把失败 partial 提升进模型历史,也不会生成重复 retry row
如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。暂时性恢复继续期间,`llm/retry` 是每次中间失败与延迟的持久归属位置;终态错误行只在 `turn/end` 记录错误后才存在,而由于耗尽的恢复与失败共享同一轮次,该轮次的重试历史绝不会抑制这一行——定格的重试链与终态错误并列渲染。本决策不增加独立的最终错误事件或响应 id 词汇。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md
2026-06-30-event-domain-semantics.md: 70da718b5471ce309a090c8aade3e7290cc949dc
2026-06-30-event-domain-semantics.zh.md: c3b12a167da0a41b792914d82a675a98b3a0b860
2026-06-30-event-domain-semantics.md: f3e4686a1e9e14c9284de5a4bff16501c50aa907
2026-06-30-event-domain-semantics.zh.md: f4bc92111666b0c30586222e14ee1aeab49ebb3f
@@ -21,7 +21,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/
**Three domains, one job each, with a single boundary rule.**
- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and replay projections share one path.
- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, and errors. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, as are the token stream (`assistant/chunk`) and mid-turn steering (a `user/message`).
- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, errors, and process-local `agent/assistant-stream` frames. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`; Assistant stream evidence becomes durable only inside one `assistant/message` or `assistant/attempt` settlement, and mid-turn steering is a durable `user/message`.
- **`tools/*` — the tool registry and execution pipeline.**
**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit.
@@ -21,7 +21,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)
**三个域,各司其职,以一条边界规则统一。**
- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与回放投影共享同一路径。
- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。拦截 waterfall(瀑布式事件)(`agent/pre-step``agent/request``agent/request-error`)负责变换、拒绝或恢复;awaited `agent/turn-stopping` 观察停止边界;瞬态 emit 报告生命周期、状态、inbox 插入、领取丢弃,以及错误。轮次和步骤边界不在此处——它们是持久的会话事件,`session/event` 读取token 流(`assistant/chunk`)和轮次中途以 `user/message` 呈现的 steering(中途引导)同理
- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。拦截 waterfall(瀑布式事件)(`agent/pre-step``agent/request``agent/request-error`)负责变换、拒绝或恢复;awaited `agent/turn-stopping` 观察停止边界;瞬态 emit 报告生命周期、状态、inbox 插入、领取丢弃、错误,以及进程本地 `agent/assistant-stream` frame。轮次和步骤边界不在此处——它们是从 `session/event` 读取的持久 Session eventAssistant stream 证据只在一个 `assistant/message``assistant/attempt` settlement 内变为持久事实,轮次中途 steering 则是持久 `user/message`
- **`tools/*`——工具注册表与执行流水线。**
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
2026-07-05-reconstructable-requests.md: 3786de02d06c0b6c094297ae89ac3f84053e408d
2026-07-05-reconstructable-requests.zh.md: 851045aca7dababd0da859f3b04b721c65382fc3
2026-07-05-reconstructable-requests.md: bc8ba640c400b18598f18aa303f2bd1b5c5b9cdc
2026-07-05-reconstructable-requests.zh.md: de1802aac83f1e0980172d2d541a093f2d729e4a
@@ -53,5 +53,5 @@ Like MiniCode, the conversation advances append-only and resets only when model-
- `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel.
- Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
- Unreadable referenced attachment objects still fail model requests; [automatic attachment quarantine](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) records the proposed recovery without weakening byte-exact reconstruction.
- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full system prompt and tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated.
- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full system prompt and tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. Current v1 retains this single representation; the frozen v0-to-v1 edge explicitly refuses legacy delta events before current Session construction.
- Snapshot fixtures include each repeated series header. Keyless refresh owns those deterministic log changes, while the snapshot harness pins prompt and tool sidecars only for the initial and actual change revisions and reuses the current revision for `series` snapshots. Filesystem-writing fixtures remain in normalized authored form with cwd-relative tool arguments because replay only round-trips cwd-independent argument paths.
@@ -53,5 +53,5 @@ Status: implemented
- `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。
- 工具结果裁剪无需新机制:一个已记录的单条目 surface replace`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。
- 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)记录了不削弱字节精确重建的拟议恢复方案。
- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整系统提示词与工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移
- 快照 fixture 包含每个重复的 series header。无密钥 refresh 负责这些确定性日志变化;快照 harness 只为 initial 与真实 change 修订固定提示词和工具 sidecar,并让 `series` 快照复用当前修订。写入文件系统的 fixture 继续以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整系统提示词与工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。当前 v1 保留这一种表示;冻结的 v0-to-v1 迁移边会在构造当前 Session 前显式拒绝旧版 delta 事件。
- 快照 fixture 包含每个重复的 series header。无密钥 refresh 负责这些确定性日志变化;快照 harness 只为 initial 与真实 change 修订固定提示词和工具 sidecar,并让 `series` 快照复用当前修订。写入文件系统的 fixture 继续以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
2026-07-10-single-file-executable-sdk-runtime-distribution.md: c152345772826ec4e2dbfd238726c429418c7897
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ea5e457afd761cb5071f8b584ef10fa7ffaa8210
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 558358167a5e7d37bc79c42003a183b0c4c40d17
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 0f7daec5b79e6f526dde7a01c5ef85b07e955707
@@ -21,6 +21,8 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h
`--sea` requires target ≥ node22; the exe uniformly targets node24. One pkg invocation packages exactly one target; multi-platform builds invoke it once per platform.
`@yao-pkg/pkg` is an exact-pinned root `devDependency` invoked as `pnpm exec pkg`, with [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) removing the SEA bootstrap's `patchChildProcess` call. Unpatched, pkg rewrites spawned commands named `node` — including the string after a `-c`/`/c` flag, exactly the Bash tool's `bash -c` form — to the executable itself and stamps `PKG_EXECPATH` into every child environment, so a model-issued `node --version` silently boots the dsh CLI; Node's own SEA layer performs no such rewrite, and a SEA binary cannot impersonate plain Node because it always boots its embedded app. With the call removed, children resolve `node` through PATH like any other process (a machine without Node reports command-not-found honestly), no `PKG_EXECPATH` reaches children, absolute `process.execPath` spawns still re-enter the app, worker threads never applied the hook, and `process.pkg` sidecar selection is untouched.
Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former.
### The serving interface is a plugin inside the dsh application
@@ -44,13 +46,13 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime-<platform>-<arch>` under `dist-exe/` and copy it into the runtime directory. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry.
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64.
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all five targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the Windows target and the explicit exclusion of Windows arm64.
### Python SDK distribution: two carriers, exe for production, node for development
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`.
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v<repository-version>` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms.
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v<repository-version>` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published.
The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback.
@@ -64,7 +66,7 @@ The Python client launches the packaged `dsh` command with the selected profile
## Testing
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ.
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ.
Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends.
@@ -84,4 +86,4 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp
**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern.
**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial).
**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (`@yao-pkg/pkg` is an exact-pinned, pnpm-patched root devDependency; upgrading re-records the patch and is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial).
@@ -21,6 +21,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)vercel/pkg 归档后
`--sea` 要求构建目标 ≥ node22,exe 统一以 node24 为构建目标;每次 pkg 调用只打包一个构建目标,多平台各调用一次。
`@yao-pkg/pkg` 是精确钉版的根 `devDependency`,经 `pnpm exec pkg` 调用,并以 [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) 移除 SEA bootstrap 中的 `patchChildProcess` 调用。未打补丁时,pkg 会把 spawn 的 `node` 命令——包括 `-c`/`/c` 标志后的命令串,恰是 Bash 工具的 `bash -c` 形态——改写为 exe 自身,并向每个子进程环境注入 `PKG_EXECPATH`,模型下发的 `node --version` 会静默启动 dsh CLI;Node 自身的 SEA 层没有这种改写,且 SEA 二进制永远启动内嵌应用、无法充当纯 Node。移除该调用后,子进程像普通进程一样经 PATH 解析 `node`(无 Node 的机器如实报 command not found),子进程环境不再出现 `PKG_EXECPATH`,以 `process.execPath` 绝对路径 spawn 的重入不受影响,worker 线程本来就未应用该钩子,`process.pkg` 侧车选择也不受影响。
术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACPAgent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。
### 对外服务接口是 dsh 应用中的插件
@@ -44,13 +46,13 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `dist-exe/` 并拷回运行时目录。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy``hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit``@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml)[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64`ubuntu-24.04-arm`)、macos-arm64 与 win-x64`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml)[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64`ubuntu-24.04-arm`)、macos-arm64、macos-x64`macos-15-intel`与 win-x64`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标符合 wheel 包标签。完整构建个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
Python SDK 位于 [`python/`](../../../../python/README.zh.md)`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64``py3-none-manylinux_2_28_aarch64`针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签,`py3-none-win_amd64`Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64``py3-none-manylinux_2_28_aarch64``py3-none-macosx_14_0_arm64``py3-none-macosx_14_0_x86_64``py3-none-win_amd64`Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。
Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。
@@ -64,7 +66,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread`
## 测试
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob``grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob``grep`spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
@@ -85,4 +87,4 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread`
**买到的**:目标平台零依赖的单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖 manifest 这两个真源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。
**付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级需要显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。
**付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(`@yao-pkg/pkg` 为精确钉版、带 pnpm 补丁的根 devDependency,升级需重录补丁,属显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
2026-07-14-provider-routed-llm-adapters.md: 78c8d6788006c503b532ff2bbddd30342415f0a4
2026-07-14-provider-routed-llm-adapters.zh.md: af8bc4fe27a50d47d7b49b51eada67afe889fc13
2026-07-14-provider-routed-llm-adapters.md: ab4c485b79f37d9360da9a9a32f38063cae99755
2026-07-14-provider-routed-llm-adapters.zh.md: c431d710af95352a2ecfc7b76b6393428ebedfec
@@ -54,7 +54,7 @@ Compaction configuration gains `summarizationProvider` beside `summarizationMode
The JSON-RPC runtime receives provider and model explicitly. Its convenience fallback mounts `dsh-llm-deepseek` only for provider `deepseek` when that provider has no registered owner; other missing providers fail without guessing an adapter.
The on-disk session format remains the pre-release pinned version `0`, with no compatibility promise. Seed/load validation rejects request headers and assistant messages that omit required provider/model fields instead of accepting an old shape that can no longer reconstruct the request.
Current v1 seed/load validation rejects request headers and assistant messages that omit required provider/model fields. The frozen v0-to-v1 edge requires the same reconstructable routing identity before migration; it never guesses a missing provider or model, and malformed shapes refuse before publication.
## Alternatives considered
@@ -78,7 +78,7 @@ The on-disk session format remains the pre-release pinned version `0`, with no c
- pi-ai credentials, transport knobs, SDK timeouts, and the five-minute-default `streamIdleTimeoutMs` watchdog are scoped per provider profile. Hidden provider retries are disabled; bounded retries belong to the separately composed agent recovery policy.
- `dsh-llm-pi-ai` rejects stop sequences because pi-ai's common stream API cannot express them; the native DeepSeek adapter retains its stop support.
- Replay state is portable only within the adapter instance that owns both the historical and target providers. Cross-provider and cross-model restoration is an adapter responsibility, and another adapter receives provider-neutral history without the opaque state.
- Current pre-release session JSONL requires provider/model on request headers and assistant messages. Older shapes remain version `0` but are rejected rather than migrated.
- Current v1 Session JSONL requires provider/model on request headers and assistant messages. The v0 edge migrates only frozen shapes that already carry reconstructable request identity.
## Testing
@@ -88,4 +88,4 @@ The on-disk session format remains the pre-release pinned version `0`, with no c
## Risks
This is a repo-wide pre-release API break: model-only request construction, adapter registration, app protocols, fixtures, and persisted version-0 event shapes all change together, with no compatibility aliases. The provider exclusivity rule deliberately prevents two implementations of the same upstream from coexisting in one context. A pi-ai dependency update can change the accepted provider/model catalog, so the lockfile and adapter e2e matrix define the tested set. Custom `baseURL` endpoints inherit the chosen catalog model's protocol assumptions and cannot repair an incompatible proxy. Catalog-external model descriptors and multimodal content remain unsupported. pi-ai replay state may contain opaque encrypted reasoning signatures; it is persisted because the provider requires it for continuity, but it is never rendered or logged outside the existing session record.
This was a repo-wide API break when introduced: model-only request construction, adapter registration, app protocols, fixtures, and persisted v0 event shapes changed together, with no compatibility aliases. Released historical recovery now belongs to the adjacent Session-format edge. The provider exclusivity rule deliberately prevents two implementations of the same upstream from coexisting in one context. A pi-ai dependency update can change the accepted provider/model catalog, so the lockfile and adapter e2e matrix define the tested set. Custom `baseURL` endpoints inherit the chosen catalog model's protocol assumptions and cannot repair an incompatible proxy. Catalog-external model descriptors and multimodal content remain unsupported. pi-ai replay state may contain opaque encrypted reasoning signatures; it is persisted because the provider requires it for continuity, but it is never rendered or logged outside the existing session record.
@@ -54,7 +54,7 @@ pi-ai 回放状态用其成功 `AssistantMessage` 的带版本最小投影填充
JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方没有注册所有者时,其便利回退才会挂载 `dsh-llm-deepseek`;其他缺失的提供方会直接失败,不会猜测适配器。
磁盘会话格式仍使用预发布阶段固定的版本 `0`,且不承诺兼容性。seed/load 验证会拒绝省略必需提供方/模型字段的请求头和助手消息,不会接受已无法重建请求的旧格式
当前 v1 的 seed/load 验证会拒绝省略必需提供方模型字段的请求头和助手消息。冻结的 v0-to-v1 迁移边要求迁移前已具备同一套可重建路由身份;它绝不会猜测缺失的提供方或模型,畸形结构会在发布前被拒绝
## 考虑过的替代方案
@@ -78,7 +78,7 @@ JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方
- pi-ai 凭据、传输选项、SDK 超时,以及默认五分钟的 `streamIdleTimeoutMs` 空闲超时机制均按提供方配置隔离。系统禁用隐藏的提供方重试;有界重试由单独组合的 agent 恢复策略负责。
- pi-ai 的通用流 API 无法表达停止序列,因此 `dsh-llm-pi-ai` 会拒绝停止序列;原生 DeepSeek 适配器仍支持停止序列。
- 仅当历史提供方与目标提供方归同一个适配器实例所有时,回放状态才可移植。适配器负责跨提供方和跨模型恢复;其他适配器只接收不含不透明状态的提供方无关历史。
- 当前预发布会话 JSONL 要求请求头和助手消息都包含提供方/模型。旧格式仍使用版本 `0`,但会被拒绝,不执行迁移
- 当前 v1 Session JSONL 要求请求头和助手消息都包含提供方模型。v0 边只迁移已经携带可重建请求身份的冻结结构
## 测试
@@ -88,4 +88,4 @@ JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方
## 风险
这是一次覆盖全仓库的预发布 API 破坏性变更:仅模型的请求构造、适配器注册、应用协议、fixture,以及持久化版本 0 事件格式会同时变化,不提供兼容别名。提供方排他规则有意禁止同一上游的两个实现共存于同一上下文。pi-ai 依赖升级可能改变可接受的提供方/模型目录,因此锁文件与适配器 e2e 矩阵定义已验证集合。自定义 `baseURL` 端点会继承所选目录模型的协议假设,无法修复不兼容的代理。目录外模型描述符与多模态内容仍不受支持。pi-ai 回放状态可能包含不透明的加密推理签名;提供方需要该信息维持连续性,因此系统会持久化该状态,但不会在现有会话记录之外渲染或记录它。
项变更在引入时是覆盖全仓库的 API 破坏性变更:仅模型的请求构造、适配器注册、应用协议、fixture,以及持久化 v0 事件结构同时变化,不提供兼容别名。已发布历史恢复现在属于相邻 Session 格式边。提供方排他规则有意禁止同一上游的两个实现共存于同一上下文。pi-ai 依赖升级可能改变可接受的提供方模型目录,因此锁文件与适配器 e2e 矩阵定义已验证集合。自定义 `baseURL` 端点会继承所选目录模型的协议假设,无法修复不兼容的代理。目录外模型描述符与多模态内容仍不受支持。pi-ai 回放状态可能包含不透明的加密推理签名;提供方需要该信息维持连续性,因此系统会持久化该状态,但不会在现有会话记录之外渲染或记录它。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md
2026-07-19-package-invariant-runtime-contracts.md: b5799a37a61244193b46db6ea4ae15f306d144b2
2026-07-19-package-invariant-runtime-contracts.zh.md: e6035bbabba7188017746c57c5b6a48761710658
2026-07-19-package-invariant-runtime-contracts.md: a1b635cc40844f1846c04e203dbb842d1c7328ed
2026-07-19-package-invariant-runtime-contracts.zh.md: d315c440f1c100911386f57d6f82d5f16be631ca
@@ -14,20 +14,20 @@ Some packages genuinely own no continuously observable relation. Pure utilities,
## Decision
### Registration is exhaustive; assertions must be meaningful
### Published assertions must be meaningful
Every workspace package publishes a separately built `./invariant` companion and registers its exact npm package name. A companion does one of two things:
A workspace package publishes a separately built `./invariant` companion only when it owns an independently observable runtime relationship. A published companion:
- installs a package-owned check over an event stream or relevant mutable data structure and reports violations through its bound `fail(message)` reporter; or
- uses an empty installer whose declaration has an owner-specific `No runtime invariant:` comment explaining why the package has no plausible runtime relation to observe.
- installs a package-owned check over an event stream or relevant mutable data structure and reports violations through its bound `fail(message)` reporter; and
- registers the package's exact npm name while keeping diagnostics outside the root entrypoint.
The empty form is an explicit architectural conclusion, not a generated placeholder. A future package change that introduces mutable state or an event protocol must replace the explanation with the corresponding check.
When no plausible relationship exists, the package omits the companion and publication wiring and records its package-specific reason in the README. A future change that introduces an independently observable relationship must replace the explanation with the corresponding check. The omission mechanics and current audit are owned by the [omit-unneeded-companions decision](../simplification/2026-08-28-omit-unneeded-invariant-companions.md).
The central `dsh-invariants` service owns only configuration, registration uniqueness, child-fiber lifecycle, rollback, disposal, and package-attributed failure. It exposes no generic plugin-shape, service-shape, or startup-assertion helpers and imports no product package.
### Implemented checks
### Representative implemented checks
The current 103-package workspace has 21 executable companions and 82 justified empty companions.
Published companions are enumerated mechanically by `verify-package-invariants`; the current audit count is recorded in the [omit-unneeded-companions decision](../simplification/2026-08-28-omit-unneeded-invariant-companions.md). The table below samples representative runtime relationships rather than listing every companion.
| Owner | Runtime relationship |
|---|---|
@@ -57,13 +57,13 @@ Session-backed companions validate existing durable events when they load, using
### Repository gate and tests
`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls.
`verify-package-invariants` discovers every workspace package. It accepts clean omission, rejects stale or partial companion wiring, and enforces exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries for published companions. Its AST rule rejects generated markers, default exports, and empty installers. Every installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls.
Vitest mounts `InvariantRegistry` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. After the structural gate validates each publication map, an artifact gate stages its manifest-declared `lib/` files, imports the compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so a companion that imports an undeclared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
Vitest mounts `InvariantRegistry` with `{ enabled: true }` for every package test topology and loads the owning companion when one is published. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every published companion's valid and invalid observations, and the exhaustive topology runs every source companion through real Loader namespace normalization. After the structural gate validates each publication map, an artifact gate stages its manifest-declared `lib/` files, imports the compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so a companion that imports an undeclared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
## Alternatives considered
- **Keep generated empty companions.** Rejected because an unexplained placeholder can survive after a package gains a meaningful runtime relation.
- **Keep explained empty companions.** Rejected because source, publication, dependency, and test wiring are disproportionate machinery for a negative conclusion that belongs in the package README.
- **Require an assertion from every package.** Rejected because method-presence, plugin-shape, and fixed-example assertions duplicate stronger type, load, and unit-test contracts without checking runtime consistency.
- **Keep generic shape helpers in the service.** Rejected because they blur compile-time API validation with runtime invariants and encourage centrally defined product assumptions.
- **Move the product checks into the service.** Rejected because product vocabulary, dependencies, tests, and change ownership belong with the package that emits the data.
@@ -71,8 +71,8 @@ Vitest mounts `InvariantRegistry` with `{ enabled: true }` for every package tes
## Consequences
- Every package has visible ownership and publication wiring, but only packages with a plausible runtime relation add listeners or trace state.
- Empty companions remain reviewable decisions with package-specific explanations and fail the gate if the explanation is removed.
- Packages with a plausible runtime relation have visible ownership and publication wiring; packages without one record the omission reason in their README.
- Empty companions fail the gate, and partial omission wiring fails before build or release.
- Type declarations, Cordis loadability, plugin metadata, service method APIs, and pure algebra remain covered by their owning compile, load, unit, or integration gates.
- Runtime failures identify the owning npm package and point to an inconsistent observation rather than restating a required API shape.
- The original selection, blocklist precedence, duplicate ownership, rollback, disposal, and HMR service contracts remain unchanged.
@@ -14,20 +14,20 @@ Status: implemented
## 决策
### 注册必须全覆盖;断言必须有意义
### 已发布的断言必须有意义
每个 workspace 包发布单独构建的 `./invariant` companion,并用完整 npm 包名注册。companion 只能采用以下两种形式之一
只有拥有可独立观察的运行时关系时,workspace 包发布单独构建的 `./invariant` companion。已发布 companion 必须
- 安装包自有的事件流或相关可变数据结构检查,并通过绑定的 `fail(message)` 报告器报告违规;
- 使用空安装器,并在其声明前写一条该包专属的 `No runtime invariant:` 注释,说明为什么该包没有合理的运行时关系可供观测
- 安装包自有的事件流或相关可变数据结构检查,并通过绑定的 `fail(message)` 报告器报告违规;并且
- 用该包的准确 npm 包名注册,同时保持诊断逻辑不进入根入口
空形式是明确的架构结论,不是生成占位符。如果后续变更引入可变状态或事件协议,就必须用相应检查替换该说明。
没有合理关系时,包会省略 companion 与发布接线,并在 README 中记录该包的具体原因。如果后续变更引入可独立观察的关系,就必须用相应检查替换该说明。省略机制与当前审计由[省略不必要 companion 的决策](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)负责。
中央 `dsh-invariants` 服务只负责配置、注册唯一性、子 fiber 生命周期、回滚、dispose(资源释放)和归属到包的失败。它不暴露通用插件形状、服务形状或启动断言 helper,也不导入产品包。
### 已实施检查
### 已实施检查示例
当前 103 个包的 workspace 包含 21 个可执行 companion 和 82 个有理由的空 companion。
已发布 companion 由 `verify-package-invariants` 机械枚举;当前审计数量记录在[省略不必要 companion 的决策](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)中。下表仅展示有代表性的运行时关系,不会逐项列出所有 companion。
| 所有者 | 运行时关系 |
|---|---|
@@ -57,13 +57,13 @@ Status: implemented
### 仓库门禁与测试
`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。
`verify-package-invariants` 发现每个 workspace 包。它接受完整省略,拒绝陈旧或不完整的 companion 接线,并对已发布 companion 强制完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和空 installer。每个 installer 都必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantRegistry`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。结构门禁验证每个包的发布映射后,产物门禁会暂存其 manifest(元数据清单)声明的 `lib/` 文件,在 plain Node 下导入已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,若 companion 导入未声明的运行时分片,门禁就会在发布前失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantRegistry`,并所有者发布 companion 时加载它。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个已发布 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。结构门禁验证每个包的发布映射后,产物门禁会暂存其 manifest(元数据清单)声明的 `lib/` 文件,在 plain Node 下导入已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,若 companion 导入未声明的运行时分片,门禁就会在发布前失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
## 考虑过的替代方案
- **保留生成的空 companion。** 拒绝,因为包获得有意义的运行时关系后,没有解释的占位符仍可能继续存在
- **保留带说明的空 companion。** 拒绝,因为只为表达 README 可以直接记录的否定结论而保留源码、发布、依赖与测试接线,成本过高
- **要求每个包都执行断言。** 拒绝,因为方法存在性、插件形状和固定示例断言会重复更强的类型、加载和单元测试约定,却没有检查运行时一致性。
- **在服务中保留通用形状 helper。** 拒绝,因为这会混淆编译期 API 验证和运行时不变量,并鼓励在中央定义产品假设。
- **把产品检查移入服务。** 拒绝,因为产品词汇、依赖、测试和变更所有权应归属于产生这些数据的包。
@@ -71,8 +71,8 @@ Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantRegis
## 后果
- 每个包都有可见的所有权与发布 wiring,但只有具备合理运行时关系的包才会增加 listener 或 trace 状态
- 空 companion 是带包专属说明、可评审的决策;删除说明后门禁会失败。
- 拥有合理运行时关系的包具有可见的所有权与发布 wiring;没有该关系的包会在 README 中记录省略原因
- 空 companion 会让门禁失败,不完整的省略接线也会在构建或发布前失败。
- 类型声明、Cordis 可加载性、插件 metadata、服务方法 API 和纯代数继续由所属的编译、加载、单元或集成门禁覆盖。
- 运行时失败会标明所属 npm 包,并指出不一致的观测,而不是复述必要的 API 形状。
- 原有 selection、blocklist 优先级、重复所有权、回滚、dispose 和 HMR(热模块替换)服务约定保持不变。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md
2026-07-19-package-owned-invariant-service.md: 88fb870eac6f72307fa2bfeaa714fe59a6a9130d
2026-07-19-package-owned-invariant-service.zh.md: 46b7789034c4a359fed95744e403f0a3a4dee6de
2026-07-19-package-owned-invariant-service.md: b955c99a2576b6b2f8208a16ad2792af181c3468
2026-07-19-package-owned-invariant-service.zh.md: 4fc0fb5d615753c0c057e927f59359847fc1328f
@@ -10,7 +10,7 @@ Runtime invariant checks span session traces, agent state, scoped dispatch, and
Deployments that opt into diagnostics need more than presence or absence of one plugin. Such a composition carries the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently.
Package ownership must also be exhaustive. Without a mechanical repository rule, a new package can omit the companion, dependency, or publication wiring and remain invisible to diagnostics until a maintainer notices the gap.
Published ownership must be mechanically complete. Without a repository rule, a package can expose a partial companion, dependency, or publication map and remain broken until a maintainer notices the gap; packages that publish none must keep their reason reviewable in the README.
## Decision
@@ -18,7 +18,7 @@ Package ownership must also be exhaustive. Without a mechanical repository rule,
`@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks.
Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A companion checks a meaningful event or mutable-data relationship when its owner has one; otherwise it carries an owner-specific explanation for its empty installer. Generated ownership placeholders and synthetic API-shape assertions are forbidden by the follow-up [runtime-contract Agent Note](2026-07-19-package-invariant-runtime-contracts.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service.
A workspace package publishes a `./invariant` companion plugin only when it owns an independently observable event or mutable-data relationship. The companion registers its exact full npm name. Packages without such a relationship omit the companion and publication wiring and record the reason in their README; generated placeholders, empty installers, and synthetic API-shape assertions are forbidden by the [runtime-contract Agent Note](2026-07-19-package-invariant-runtime-contracts.md) and [omission decision](../simplification/2026-08-28-omit-unneeded-invariant-companions.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service.
### Configuration and selection
@@ -64,9 +64,9 @@ The former functional-plugin entry point and one-argument `InvariantError` const
| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency |
| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction |
These four owners supplied the initial stateful checks. The follow-up runtime-contract decision adds checks for seventeen more owners with real event or mutable-data relationships and records justified empty companions for the rest. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency.
These four owners supplied the initial stateful checks. Later owners add companions for real event or mutable-data relationships, while packages without one omit the companion and document why. Every published companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape.
`verify-package-invariants` discovers every workspace package and rejects missing companion source, generated markers, unexplained empty installers, non-empty installers that omit or ignore the reporter, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry.
`verify-package-invariants` discovers every workspace package, accepts clean omission, and rejects partial companion wiring, generated markers, empty installers, installers that omit or ignore the reporter, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit a published companion entry.
### Scoped-event semantic map
@@ -84,7 +84,7 @@ Service tests cover defaults, global disablement, allow/block selection, blockli
Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis.
Every Vitest configuration loads a test host that mounts an explicitly enabled service before an ordinary Cordis root's first plugin and adds the current test package's companion. One exhaustive topology mounts all package companions once; focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone.
Every Vitest configuration loads a test host that mounts an explicitly enabled service before an ordinary Cordis root's first plugin and adds the current test package's companion when one exists. One exhaustive topology mounts all published companions once; focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every published companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone.
## Alternatives considered
@@ -96,10 +96,10 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s
## Consequences
- Product packages own and test their relational assertions while the service stays product-independent.
- Every package pays the publication and dependency cost of a companion; only owners with a meaningful runtime relationship add listener or trace-state cost.
- Only owners with a meaningful runtime relationship pay the publication, dependency, listener, or trace-state cost of a companion; other packages record the omission reason in their README.
- Compositions that mount the diagnostics can disable all checks or select package names without changing their plugin tree.
- Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports.
- One selected executable contribution adds one child fiber and its listener/state cost; a selected empty contribution has no listener or trace-state cost, while filtered registrations retain only name ownership.
- One selected contribution adds one child fiber and its listener/state cost, while filtered registrations retain only name ownership.
- Regex sources are deployment configuration and remain fixed until the service reloads.
- Ordinary Vitest roots install the owning test package's selected companion; one exhaustive topology pays the full child-fiber cost once for repository-wide registration coverage.
- Ordinary Vitest roots install the owning test package's selected companion when published; one exhaustive topology pays the full child-fiber cost once for repository-wide registration coverage.
- Session storage validation, snapshotting, freezing, cited source-event validation, and surface acceptance remain always on and are not affected by invariant selection.
@@ -10,7 +10,7 @@ Status: implemented
选择启用诊断的部署还需要比“是否加载一个插件”更细的控制。这类组合会携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR(热模块替换)下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。
包所有权必须覆盖完整。若没有机械化的仓库规则,包可能遗漏伴随插件、依赖或发布配置,并一直不会进入诊断范围,直到维护者发现这一缺口
已发布的包所有权必须机械完整。若没有仓库规则,包可能暴露不完整的 companion、依赖或发布映射,并一直保持损坏,直到维护者发现;不发布 companion 的包则必须在 README 中保留可评审的原因
## 决策
@@ -18,7 +18,7 @@ Status: implemented
`@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。
工作区内的每个包都发布 `./invariant` 伴随插件注册自己完整且准确的 npm 包名。如果所有者具备有意义的事件或可变数据关系,companion 就检查该关系;否则空 installer 必须携带该所有者专属的说明。后续的[运行时约定 Agent Note](2026-07-19-package-invariant-runtime-contracts.zh.md) 禁止生成的所有权占位符和合成 API 形状断言。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。
只有拥有可独立观察的事件或可变数据关系时,工作区包才发布 `./invariant` 伴随插件;该 companion 会注册自己完整且准确的 npm 包名。没有该关系的包会省略 companion 与发布接线,并在 README 中记录原因;[运行时约定 Agent Note](2026-07-19-package-invariant-runtime-contracts.zh.md) 与[省略决策](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)禁止生成占位符、空 installer 和合成 API 形状断言。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。
### 配置与选择
@@ -64,9 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写
| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | 作用域事件载体的存在性与主体一致性 |
| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 |
这四个所有者提供了首批有状态检查。后续运行时约定决策为另外十七个确有事件或可变数据关系的所有者增加检查,并为其余包记录有理由的空 companion。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖
这四个所有者提供了首批有状态检查。后续所有者会为真实事件或可变数据关系增加 companion,没有该关系的包则省略 companion 并记录原因。每个已发布伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态。
`verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、没有解释的空 installer、缺少或不使用失败报告器的非空 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。
`verify-package-invariants` 会发现每个工作区包,接受完整省略,并拒绝不完整的 companion 接线、生成标记、空 installer、缺少或不使用失败报告器的 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏已发布伴随入口的自定义构建配置。
### 作用域事件语义映射
@@ -84,7 +84,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、
组合测试覆盖标准主干转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node 冒烟测试覆盖编译子路径 export。作用域事件新鲜度门禁会重新执行语义 Program 分析。
每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务,并添加当前测试包伴随插件。一个完整拓扑会一次挂载所有包的伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用 manifest(元数据清单)中的包名,而不是只检查源码文本。
每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务,并当前测试包存在伴随插件时添加它。一个完整拓扑会一次挂载所有已发布伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个已发布伴随插件的 `apply` 函数,并验证它调用 `register` 时使用 manifest(元数据清单)中的包名,而不是只检查源码文本。
## 考虑过的替代方案
@@ -96,10 +96,10 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、
## 后果
- 产品包拥有并测试自己的关系断言,服务保持与产品无关。
- 每个包都承担 companion 的发布依赖成本;只有具备有意义运行时关系的所有者才增加 listener 或 trace 状态成本。
- 只有具备有意义运行时关系的所有者才承担 companion 的发布依赖listener 或 trace 状态成本;其他包在 README 中记录省略原因
- 挂载诊断的组合无需改变插件树即可关闭全部检查或按包名选择。
- 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。
- 每个选中的可执行贡献增加一个子 fiber 及其 listener/状态成本;选中的空贡献不增加 listener 或 trace 状态成本,被过滤注册则只保留包名占用。
- 每个选中贡献增加一个子 fiber 及其 listener/状态成本,被过滤注册则只保留包名占用。
- 正则表达式源属于部署配置,在服务重载前保持固定。
- 普通 Vitest 根上下文会安装当前测试包中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。
- 当前测试包发布伴随插件时,普通 Vitest 根上下文会安装中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。
- 会话存储验证、快照、冻结、引用的源事件验证与 surface 接受规则始终启用,不受不变式选择影响。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md
2026-07-19-zstandard-jsonl-session-logs.md: 93fc20f931c75552352834b9340e7d38680d4254
2026-07-19-zstandard-jsonl-session-logs.zh.md: d58f89430ab91de6beabba83c2a31f43e4a7d275
2026-07-19-zstandard-jsonl-session-logs.md: a893a79f063842dfa93e57cb3972ea151421b05d
2026-07-19-zstandard-jsonl-session-logs.zh.md: bdaeab701359acbd02f47a2a3d83fe4913d607e7
@@ -6,7 +6,7 @@ English | [中文](2026-07-19-zstandard-jsonl-session-logs.zh.md)
## Problem
The JSONL persistence backend keeps every `SessionEvent` verbatim, including high-volume `assistant/chunk` records. Raw text makes logs inspectable but spends storage and I/O on repeated JSON keys and model text. Compression must retain the existing append/fsync commit boundary, collision-safe first materialization, crash repair, and metadata-only listing; rewriting a whole compressed file after every turn would discard those properties.
The JSONL persistence backend keeps every `SessionEvent` verbatim, including Assistant settlements with embedded model streams. Raw text makes logs inspectable but spends storage and I/O on repeated JSON keys and model text. Compression must retain the existing append/fsync commit boundary, collision-safe first materialization, crash repair, and metadata-only listing; rewriting a whole compressed file after every turn would discard those properties.
The encoding also has to remain explicit at the deployment boundary. Snapshot fixtures and external line readers require raw JSONL, while a backend cannot safely guess between compressed and raw artifacts in one root or silently migrate pre-release session data.
@@ -14,9 +14,9 @@ The encoding also has to remain explicit at the deployment boundary. Snapshot fi
### Configuration and suffix ownership
`dsh-session-persistence-jsonl` accepts `compression?: 'zstd' | 'none'` and explicitly resolves omission to `'zstd'`. Zstandard artifacts end in `.jsonl.zstd`; `'none'` retains the original newline-delimited UTF-8 `.jsonl` representation. `SessionLocation.kind` remains `'jsonl'`, because both encodings carry the same logical record format, and `SESSION_FORMAT_VERSION` remains `0` under the repository's pre-release reject-without-migration policy.
`dsh-session-persistence-jsonl` accepts `compression?: 'zstd' | 'none'` and explicitly resolves omission to `'zstd'`. Zstandard artifacts end in `.jsonl.zstd`; `'none'` retains the newline-delimited UTF-8 `.jsonl` representation. Within either configured suffix, v0 uses suffixless `session.jsonl[.zstd]` and every positive format generation uses lowercase `session.vN.jsonl[.zstd]`. `SessionLocation.kind` remains `'jsonl'`, because both encodings carry the same logical record format. Session-format migration uses the configured full suffix and one shared logical chain, so compression does not branch generation selection or publication.
Each persistence root belongs to one encoding. A one-time discovery preflight rejects any opposite suffix, and targeted load, live-adoption, listing, and materialization paths repeat the relevant suffix check after an initially empty preflight. The error names the incompatible artifact and directs the deployment to the matching configuration or a separate root. There is no migration, dual read, dual write, or extension-based fallback.
Each persistence root belongs to one encoding. A one-time discovery preflight rejects any opposite suffix, and targeted load, live-adoption, listing, and materialization paths repeat the relevant suffix check after an initially empty preflight. The error names the incompatible artifact and directs the deployment to the matching configuration or a separate root. There is no compression conversion, dual read, dual write, or extension-based fallback; logical version migration stays within the configured suffix, preserves the source generation, and exclusively publishes the final version-named successor.
### Frame and write path
@@ -32,7 +32,7 @@ A frame-boundary scanner reads the standard magic, variable header fields, block
Listing reads in bounded chunks only until the first complete frame is available, validates and decompresses that header frame, and never reads an event frame. The dedicated header frame therefore preserves metadata-only listing even for very large session logs.
EOF inside the final frame is a recoverable torn tail. After the scanner establishes that boundary, a dedicated prefix decoder uses `finishFlush: ZSTD_e_flush` so Node emits available plaintext without requiring frame or checksum completion; every complete newline-terminated event it emits is retained. Repair truncates from that frame's starting byte and appends one new checksummed frame containing the recovered complete events followed by the coordinator's synthetic tool, step, and turn closers. If the tear occurs before any complete event is decodable, repair drops the partial frame and retains all prior complete frames.
EOF inside the final frame is a torn tail. The frame belongs to an append that never resolved, so none of its records were acknowledged durable: repair truncates from that frame's starting byte, retains all prior complete frames, and appends the coordinator's synthetic tool, step, and turn closers as one new checksummed frame ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) owns dropping the earlier partial-plaintext salvage).
### Consumers and verification
@@ -6,7 +6,7 @@ Status: implemented
## 问题
JSONL 持久化后端会逐字保留每个 `SessionEvent`其中包括数量庞大的 `assistant/chunk` 记录。原始文本便于检查,但重复的 JSON 和模型文本会增加存储与 I/O 开销。压缩编码必须保留既有 append/fsync 提交边界、首次物化时的无冲突发布、崩溃修复以及仅元数据列举;如果每轮都重写整个压缩文件,就会失去这些属性。
JSONL 持久化后端会逐字保留每个 `SessionEvent`包括嵌入模型 stream 的 Assistant settlement。原始文本便于检查,但重复的 JSON key 和模型文本会增加存储与 I/O 开销。压缩编码必须保留既有 append/fsync 提交边界、首次物化时的无冲突发布、崩溃修复仅元数据列举;如果每轮都重写整个压缩文件,就会失去这些属性。
编码还必须在部署边界上保持显式。快照 fixture(测试前置数据)与外部逐行读取器需要原始 JSONL,而后端无法在同一根目录中安全猜测压缩产物与原始产物,也不能静默迁移预发布会话数据。
@@ -14,9 +14,9 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量
### 配置与后缀归属
`dsh-session-persistence-jsonl` 接受 `compression?: 'zstd' | 'none'`,并将省略值显式解析为 `'zstd'`。Zstandard 产物使用 `.jsonl.zstd` 后缀;`'none'` 保留原有的换行分隔 UTF-8 `.jsonl` 表示。`SessionLocation.kind` 仍为 `'jsonl'`,因为两种编码承载同一逻辑记录格式;按照仓库的预发布拒绝且不迁移策略,`SESSION_FORMAT_VERSION` 仍为 `0`
`dsh-session-persistence-jsonl` 接受 `compression?: 'zstd' | 'none'`,并将省略值显式解析为 `'zstd'`。Zstandard 产物使用 `.jsonl.zstd` 后缀;`'none'` 保留换行分隔 UTF-8 `.jsonl` 表示。在任一已配置后缀内,v0 使用无版本后缀 `session.jsonl[.zstd]`,每个正格式 generation 使用小写 `session.vN.jsonl[.zstd]``SessionLocation.kind` 仍为 `'jsonl'`,因为两种编码承载同一逻辑记录格式。Session 格式迁移使用配置后的完整后缀和同一条逻辑链,因此压缩不会分叉 generation 选择或发布
每个持久化根目录只归属于一种编码。一次性的发现预检会拒绝任何相反后缀,而针对性的加载、活跃采用、列举与物化路径会在最初空目录预检之后再次执行对应后缀检查。错误会指出不兼容产物,并要求部署选择匹配配置或单独根目录。系统不提供迁移、双重读取、双重写入或基于扩展名的兜底
每个持久化根目录只归属于一种编码。一次性的发现预检会拒绝任何相反后缀,而针对性的加载、活跃采用、列举与物化路径会在最初空目录预检之后再次执行对应后缀检查。错误会指出不兼容产物,并要求部署选择匹配配置或单独根目录。系统不提供压缩转换、双重读取、双重写入或基于扩展名的 fallback;逻辑版本迁移始终留在配置后缀内,保留源 generation,并排他发布最终具名版本后继
### 帧与写入路径
@@ -32,7 +32,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量
列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。
最终帧内部遇到 EOF 属于可恢复的撕裂尾部。扫描器确定该边界后,专用前缀解码器会使用 `finishFlush: ZSTD_e_flush`,使 Node 不必等到帧结束或读到完整校验和就能产出已有明文;其中每个完整且以换行结束的事件都会保留。修复从该帧起始字节截断,再追加一个新的带校验和帧,其中依次包含恢复出的完整事件,以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件,修复会丢弃该不完整帧并保留此前全部完整帧
最终帧内部遇到 EOF 属于撕裂尾部。该帧属于一次从未完成结算的追加,因此其中没有任何记录被确认为持久:修复从该帧起始字节截断,保留此前全部完整帧,并把协调器生成的工具、步骤与轮次闭合事件作为一个新的带校验和帧追加(对早先部分明文抢救路径的移除由[导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)负责)
### 消费方与验证
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-project-session-directories.md
2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2
2026-07-24-project-session-directories.zh.md: 932b1d29c41d2a854abfc0bab0e47a0ff8c96fe9
2026-07-24-project-session-directories.md: a37f9231167822e409308f8da60f6c1e837c74d5
2026-07-24-project-session-directories.zh.md: 469567764219d7baabea89bd94aecd81bd0e5ab3
@@ -29,7 +29,7 @@ Case-insensitive filesystems can also make differently cased project keys refer
The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure.
The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change.
The encoded session id names an ownership directory rather than the transcript itself. The backend's diagnostics-only `locate` hook resolves the fixed transcript path inside it for format-refusal messages ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) owns removing the consumer-facing path query). Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change.
Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `<project>/<id>.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration.
@@ -29,7 +29,7 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立
根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。
编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path``DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。
编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。后端仅供诊断的 `locate` 钩子在其中解析固定的 transcript 路径,供格式拒绝消息使用(移除面向消费者的路径查询由[导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)负责)。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。
延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O,首次追加会先创建项目目录和会话目录,再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `<project>/<id>.jsonl*` 产物;预发布格式不提供自动数据迁移。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: b4566d70c79607bbf736ee02e3e37a79c2391232
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1d1acf00fa6a1efc868c3613715a5ff781e0323a
2026-07-25-web-client-session-scope-and-provide-channel.md: feffb1ac0c5bb91c33e61ea18583202e96bad34d
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5d368b4fd810264cb1b451d583de51e1d4cb232b
@@ -61,7 +61,7 @@ Session instances share the scope's lifecycle; liveness eligibility = host-liste
A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable):
- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the JSONL provider's lazy-create contract guarantees a never-appended Session never enters `persistence.list()`, so blank never touches disk.
- The host criterion: `session.seq === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the JSONL provider's lazy-create contract guarantees a never-appended session never enters `persistence.list()`, so blank never touches disk.
- The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
@@ -61,7 +61,7 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判
「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变):
- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——JSONL provider 的 lazy-create 约定保证 never-appended Session 不进入 `persistence.list()`,所以 blank 从不落盘。
- host 判据:`session.seq === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——JSONL provider 的 lazy-create 约定保证 never-appended 会话不进入 `persistence.list()`,所以 blank 从不落盘。
- wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。
- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
2026-07-25-web-input-machine-and-slash-pipeline.md: 69899efcda42eb1087aaa68d1eba8c08dd14f361
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7e37dd67a2d5a7943a8c601a890d2de7489b227d
2026-07-25-web-input-machine-and-slash-pipeline.md: 1b9e9d95b5a30efbf297be5fc5f788f9a1ac77c4
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: c9bee217da1dbffaeff69dfe5a2dcf8f0e8e3cb0
@@ -55,7 +55,7 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
- The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-input-trigger or the command surfaces, input still sends and receives normally — graceful degradation.
- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears.
- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the composer surface DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout.
- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the composer surface DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. The memoized InputBar renders its overlay, left, right, and dock child slots after the renderer has bound their standard props; `ConversationRoot` passes only scalar data and callbacks, so an unrelated shell render does not create fresh ReactNode owner props or invalidate the bar.
- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted.
- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists.
- When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current.
@@ -67,8 +67,8 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
- PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
- Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace or the draft end — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path and `/plan。` is prose; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
- Sending is the literal text (no more `<skill>` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape.
- Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
### Per-session provide contributions and the private keyboard surface
@@ -105,6 +105,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ
| Dual draft persistence {text, occurrences} | The mirror writing the clipboard projection adds zero new concepts; chip degradation across refresh is acceptable |
| The native textarea undo stack | Unreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed — both sides retired with the textarea itself; Lexical's history owns undo now |
| The InputBar receiving a 16-member wiring-callback bundle | The consumption matrix proved 11 members InputBar-exclusive and 1 a dead member; the standard-kit channel lets components fetch their own, with the keyboard surface passed privately in-package |
| `ConversationRoot` rendering InputBar's child slots into owner props | Fresh React elements defeat the bar's memo boundary; the bar already receives `renderSlot` and owns the exact positions |
| Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only |
| A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning |
| A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth |
@@ -55,7 +55,7 @@ Status: implemented
- hubtrigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-input-trigger/命令面时输入正常收发,优雅降级。
- 每个实体会话只有一个 `SessionInputShell`(facade),随会话作用域创建和拆除;无会话时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;会话出现后,彼此独立的严格会话 header 和 body outlet 只填入这些固定区域。
- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——编辑器表面 DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。
- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——编辑器表面 DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。memoized InputBar 在 renderer 绑定各 child slot 的标准 props 后自行渲染 overlay、left、right 与 dock`ConversationRoot` 只传标量数据和回调,因此无关 shell render 不会制造新的 ReactNode owner prop 或使 bar 失效。
- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`:summary 已证实为空的会话在任何 open state 下都保持 Hero,未经证实的会话则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Herosidebar 的 blank 位只在提示词成功受理后翻 false。
- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
- blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标会话不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank 会话留存但不再 current。
@@ -67,8 +67,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
- PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。
- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name``@name``x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token
- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name``@name``x/name` 永不命中`/name` token 还必须止于空白或 draft 末尾——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径、`/plan。` 是普通文本;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
- 发送即原文(不再 `<skill>` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip`@name` token 仍按形状装饰
- 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
### 每会话供数贡献与键盘私面
@@ -105,6 +105,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
| draft 双持久化 {text, occurrences} | mirror 写剪贴板投影零新概念;chip 跨刷新降级可接受 |
| 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管——两侧都随 textarea 一并退役;undo 现归 Lexical history |
| InputBar 收 16 员 wiring 回调包 | 消费矩阵实证 11 员 InputBar 独占、1 员死成员;标准件通道让组件自取,键盘面包内私递 |
| 由 `ConversationRoot` 把 InputBar child slot 渲染为 owner prop | 新 React element 会击穿 bar 的 memo 边界;bar 已收到 `renderSlot`,也拥有这些位置 |
| 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通提示词;不可逆副作用只留显式入口 |
| 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
| 占位 select 常驻工具行 | 具名 slot 在注册前保持为空;占位件与真实现冲突时是两个真源 |
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
2026-07-29-projected-token-usage-and-request-context.md: 75a05e5a0e8f0183fef1e7d80701ce6d81041cd6
2026-07-29-projected-token-usage-and-request-context.zh.md: 7cce5989d719156f1d66c48937780ff8aed02a42
2026-07-29-projected-token-usage-and-request-context.md: d62f7dccd544a342da64fc35c24d32d53bf56231
2026-07-29-projected-token-usage-and-request-context.zh.md: 6d2bb624ac11dbcdac30695913d3c16513bfdf9e
@@ -14,7 +14,7 @@ Context occupancy needs a numerator and a denominator that no existing surface c
Both values are ordinary durable session-projection state. `@deepseek-ai/dsh-token-meter` registers two units when `ctx.sessionProjections` is present.
`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value replaces the earlier sample from the same model attempt instead of double-counting it. A matching `llm/retry-started` boundary ends that replacement scope, so a retry with the same `(turn, step)` contributes a new attempt. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. It expands each `assistant/message` or `assistant/attempt` stream and takes the last usage sample; a message's top-level usage takes precedence over its embedded sample instead of double-counting it. `assistant/attempt` therefore preserves usage from failed requests. A matching `llm/retry-started` boundary opens a new attempt, so a retry with the same `(turn, step)` contributes separately. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
Token-meter also owns the shared pure attempt/Turn fold over durable events. It applies the same retry boundary while adding the stricter completeness and exact-total checks required by an exact per-Turn disclosure. A presentation consumer may select a complete Turn window and invoke that fold, but does not own or duplicate the accounting semantics.
@@ -26,7 +26,7 @@ Capacity deliberately stays out of `EpochHeader`. That type is the reconstructio
Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence.
The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known.
The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. The exact-overflow tooltip mounts its measuring child only for a non-empty line and retains one `ResizeObserver` while values change; text changes perform one direct measurement without replacing the observer.
## Context occupancy is approximate, and that is the decision
@@ -58,4 +58,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec
Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary.
Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute.
Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry; streaming text deltas do not force the stats line to recompute or churn layout-observer subscriptions.
@@ -14,7 +14,7 @@ Web 统计行原先从当前已加载的会话节点推导 token 总量。该窗
这两个值都是普通的持久会话投影状态。当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册两个单元。
`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;`assistant/message` 用量值会替换同一次模型 attempt 的先前样本,不会重复计数。匹配的 `llm/retry-started` 边界会结束该替换作用域,因此复用同一 `(turn, step)` 的重试会贡献一次新的 attempt。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前计费用量
`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。它会展开每个 `assistant/message``assistant/attempt` stream 并采用最后一个 usage samplemessage 顶层 usage 优先于其嵌入式 sample,因此不会重复计数。`assistant/attempt` 由此保留失败请求的 usage。匹配的 `llm/retry-started` 边界会打开新 attempt,因此复用同一 `(turn, step)` 的重试会单独贡献用量。推理(reasoning)仍是输出的细分项。compaction 和 surface replacement 不会抹除先前计费。
token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。它采用相同的重试边界,并增加精确单轮次 disclosure 所需的更严格完整性与精确总量检查。展示消费方可以选择完整 Turn 窗口并调用该 fold,但不拥有或复制记账语义。
@@ -26,7 +26,7 @@ token-meter 还拥有在持久事件上运行的共享纯 attemptTurn fold。
两个单元都沿用标准投影生命周期:历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。
Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。
Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。精确 overflow tooltip 只在统计行非空时挂载测量子组件,并在值变化期间保留同一个 `ResizeObserver`;文本变化只直接测量一次,不替换 observer。
## 上下文占用率是近似值,而这正是决策本身
@@ -58,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定,
占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。
每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attemptTurn foldWeb Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量不会迫使统计行重新计算。
每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attemptTurn foldWeb Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量不会迫使统计行重新计算或反复替换布局 observer 订阅
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
2026-07-30-session-end-seed-log-boundary.md: 1c5a8097a6b901f133205dcd52d674a8e3594b28
2026-07-30-session-end-seed-log-boundary.zh.md: ea3549543229a15d0fba7ad0316a8683c674a558
2026-07-30-session-end-seed-log-boundary.md: aeec2a36d0b1e498591ef509e2e9164f586ed60c
2026-07-30-session-end-seed-log-boundary.zh.md: ceba46a474c402230dbf215a2d53a41d3c027fc2
@@ -40,7 +40,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si
**A boundary appended at loop start.** The loop calls `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary.
**Reusing `header.seedLength`.** It is the durable *fork-lineage* boundary and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both.
**Reusing `Session.inheritedEventCount`.** It is the durable *fork-lineage* cut and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both.
**Crash repair closing `compaction/*` alongside turn boundaries.** Rejected: it moves every plugin's bracket semantics into core's repair pass, and core cannot know what closing another package's bracket should record.
@@ -48,8 +48,8 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si
Bought: one boundary, written in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary.
Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property.
Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert that capture begins with the current lifecycle's newly appended boundary and excludes the constructor seed, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property.
`session/end-seed` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly classifies nothing as constructor-seed history.
`session/end-seed` joins the on-disk vocabulary. Current v1 requires the validated marker semantics owned by Session; the frozen v0 codec and migration edge own which historical v0 seed layouts remain admissible. The exact inherited cut stays separate from the logical header and is available after a body read.
The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) now supplies the first consumer. Its tail scan independently finds the unmatched `compaction/start` and newest end-seed, treats only a start after that boundary as live, and clears the invariant trace on the same replay transition. The predicate remains in the compaction package rather than becoming a generic core helper.
@@ -40,7 +40,7 @@ Status: implemented
**在 loop 启动时追加边界。** loop 调用 `resumeWith`,因此覆盖恢复路径,但完全漏掉 `fork()``adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。
**复用 `header.seedLength`。** 它是持久的 *fork 血缘*边界,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。
**复用 `Session.inheritedEventCount`。** 它是持久的 *fork 血缘* cut,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。
**让崩溃修复连同轮次边界一起关闭 `compaction/*`。** 否决:这会把每个插件的括号语义搬进核心的修复流程,而核心无法知道关闭另一个包的括号应该记录什么。
@@ -48,8 +48,8 @@ Status: implemented
买到的:一条边界,在一处写入,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。
代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的:telemetry 的接管测试断言该边界*会*被导出,因为它是本生命周期的自有写入;属性测试套件的回放不变式则是「种子逐字节复现,外加一个仅日志边界」,并把幂等性作为独立属性。
代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的:telemetry 的接管测试断言捕获从当前生命周期新追加的边界开始,并排除 constructor seed;属性测试套件的回放不变式则是「种子逐字节复现,外加一个仅日志边界」,并把幂等性作为独立属性。
`session/end-seed` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地判定没有任何内容属于构造种子历史
`session/end-seed` 加入了落盘词汇表。当前 v1 要求由 Session 拥有的已校验 marker 语义;冻结的 v0 codec 与迁移边负责哪些历史 v0 seed 布局仍可接受。精确继承 cut 与逻辑 header 分离,并在读取正文后可用
[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.zh.md)如今提供了第一个消费方。其尾部扫描会分别查找未匹配的 `compaction/start` 与最新 end-seed,只把位于该边界之后的 start 视为存活,并在同一个回放转换上清除不变量追踪状态。该谓词仍位于压缩功能所在的包中,不会成为通用核心辅助函数。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
2026-07-30-web-config-plane.md: 81b501db529bf1b2974fd4541045991c5a8bf087
2026-07-30-web-config-plane.zh.md: 3f02a17e4826bb35ecfd45da25c4b0170be270cb
2026-07-30-web-config-plane.md: a919487ba48cd7735a9f7fbc65a548bc5bfb7114
2026-07-30-web-config-plane.zh.md: ca9e9f4427bba80a63865e891e42656aeb1b5c64
@@ -22,7 +22,7 @@ The request-level configuration seam made LLM adapter configuration restart-free
**A hand-written editor over a schema model layer.** `ctx.settingsSchema`, provided by `dsh-client-ui-settings`, rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The hand-written direction won over adding a hint/grouping system, and a further simplification removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry only declared token spellings: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so naming them resolves to the light-mode literals in their fallback slots. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface.
**The Models page is a three-domain join with service-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation only when a key is entered), so `settings.yaml` never carries a key value; a blank pi-ai key materializes a reference-free profile and preserves provider-native authentication. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized confirmation dialog whose row actions, title, description, and final action identify the same provider; confirmation removes an exact configured+writable derived credential before the profile, while custom, environment, and unidentified targets remain untouched. Both stages are idempotent and a partial failure stays in the dialog for retry. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. `llm.discoverModels` results stay in picker-local state until **Add selected**; configured ids start unchecked, while **Select all** / **Deselect all** changes only that local set, so bulk selection preserves the same capacity-protection rule. The partial-commit and credential-ownership rationale lives in the [provider credential lifecycle note](../bug-fix/2026-08-06-provider-credential-lifecycle.md).
**The Models page is a three-domain join with service-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation only when a key is entered), so `settings.yaml` never carries a key value; a blank pi-ai key materializes a reference-free profile and preserves provider-native authentication. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized confirmation dialog whose row actions, title, description, and final action identify the same provider; confirmation removes an exact configured+writable derived credential before the profile, while custom, environment, and unidentified targets remain untouched. Both stages are idempotent and a partial failure stays in the dialog for retry. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. `llm.discoverModels` results stay in picker-local state until **Add selected**; configured ids start unchecked, and a localized search filters ids and optional display names without changing hidden selections. **Select all** / **Deselect all** changes only the visible candidates in that local set, so filtered bulk selection preserves the same capacity-protection rule. The partial-commit and credential-ownership rationale lives in the [provider credential lifecycle note](../bug-fix/2026-08-06-provider-credential-lifecycle.md).
## Alternatives considered
@@ -36,4 +36,4 @@ The request-level configuration seam made LLM adapter configuration restart-free
## Consequences
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, model-picker, and identified delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The component suite pins configured-id exclusions and both directions of the bulk toggle. The settings-shell scenario intercepts the pathless native intent; Service Definition, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves both profile and key intact, then confirmation removes both the profile and its identified managed credential. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) with no compatibility alias. The renderer replacement needed no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models) and a page address for live routes that never declared configurability.
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, model-picker, and identified delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The model-picker path filters the catalog, clears only the visible selection, restores hidden picks when the query clears, and records the localized search control in its ARIA golden; the component suite also pins matching by id and optional name, the no-results state, and visible-only bulk selection. The settings-shell scenario intercepts the pathless native intent; Service Definition, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves both profile and key intact, then confirmation removes both the profile and its identified managed credential. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) with no compatibility alias. The renderer replacement needed no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models) and a page address for live routes that never declared configurability.
@@ -22,7 +22,7 @@ Status: implemented
**架在 schema 模型层之上的手写编辑器。**`dsh-client-ui-settings` 提供的 `ctx.settingsSchema` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。手写方向胜过了再加一套提示/分组系统,进一步的简化又把引用输入框整个移除:卡片的主字段是一个 **API 密钥** 输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`deepseek 有 `reasoningEffort`pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id``name``contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border``--surface``--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K``M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名只承载已声明的 token 写法:`--dsw-alias-border-subtle``--dsw-alias-text-tertiary``--dsw-alias-text-primary` 均未声明,写出它们就会解析为各自回退槽位中的亮色模式字面值。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。
**Models 页是一次三领域联接,应用语义与服务同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`(仅在输入密钥时,pi-ai profile 才会记录该派生),因此 `settings.yaml` 从不携带密钥值;留空 pi-ai 密钥会具化一个不带引用的 profile,并保留提供方原生认证。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化确认对话框,其行操作、标题、说明和最终操作都会点名同一个提供方;确认后会先清除与派生目标精确匹配且已配置、可写的凭据,再删除 profile,自定义目标、环境目标和无法识别的目标则保持不变。两个阶段都具备幂等性,部分失败会留在对话框中供重试。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。`llm.discoverModels` 的结果会留在选择框的本地状态中,直至点击**添加所选**;已配置的 ID 默认不勾选,**全选**/**取消全选**只会改变这一本地集合,因此批量选择仍遵守同一条容量保护规则。部分提交与凭据所有权的理由记录在[提供方凭据生命周期 note](../bug-fix/2026-08-06-provider-credential-lifecycle.zh.md)中。
**Models 页是一次三领域联接,应用语义与服务同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`(仅在输入密钥时,pi-ai profile 才会记录该派生),因此 `settings.yaml` 从不携带密钥值;留空 pi-ai 密钥会具化一个不带引用的 profile,并保留提供方原生认证。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化确认对话框,其行操作、标题、说明和最终操作都会点名同一个提供方;确认后会先清除与派生目标精确匹配且已配置、可写的凭据,再删除 profile,自定义目标、环境目标和无法识别的目标则保持不变。两个阶段都具备幂等性,部分失败会留在对话框中供重试。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。`llm.discoverModels` 的结果会留在选择框的本地状态中,直至点击**添加所选**;已配置的 ID 默认不勾选,本地化搜索会过滤 id 和可选显示名称而不改变隐藏项的勾选状态。**全选**/**取消全选**只会改变本地集合中的可见候选,因此经过过滤的批量选择仍遵守同一条容量保护规则。部分提交与凭据所有权的理由记录在[提供方凭据生命周期 note](../bug-fix/2026-08-06-provider-credential-lifecycle.zh.md)中。
## 曾考虑的替代方案
@@ -36,4 +36,4 @@ Status: implemented
## 后果
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态、模型选择框态与已点名目标的删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。组件测试固定了已配置 ID 的排除与批量切换的两个方向。设置外壳场景会截获无路径参数的原生意图;Service Definition、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明,取消会保留 profile 和密钥,随后的确认会同时删除 profile 及其已识别的受管凭据。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器不需要任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)和为从未声明可配置性的存活路由提供页面地址。
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态、模型选择框态与已点名目标的删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。模型选择器路径会过滤 catalog、只清除可见项的选择、在查询清空后恢复隐藏勾选,并在 ARIA golden 中记录本地化搜索控件;组件测试固定了按 id 和可选名称匹配、无结果状态以及仅作用于可见项的批量选择。设置外壳场景会截获无路径参数的原生意图;Service Definition、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明,取消会保留 profile 和密钥,随后的确认会同时删除 profile 及其已识别的受管凭据。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器不需要任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)和为从未声明可配置性的存活路由提供页面地址。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md
2026-07-31-code-runtime-portable-identifier-seam.md: 2011b0f6bc8209e628227ddf486aa1143a63688a
2026-07-31-code-runtime-portable-identifier-seam.zh.md: 36af33366d004fedc6b1077a937d6519de743638
2026-07-31-code-runtime-portable-identifier-seam.md: e4cf236f62407c9fda42a3e2cdcc5d3ef02a1f92
2026-07-31-code-runtime-portable-identifier-seam.zh.md: 63fc89eb0d674a381ce7a5a626bd51d5f8b234d3
@@ -25,7 +25,7 @@ The constants live in the Service Definition even though the worker is the only
## Scope
This decision delivers only the Service Definition extension and the worker's adoption of it. The `py-types` renderer and PTC mode language dispatch are owned by the [language-dispatch note](../feature/2026-07-31-ptc-language-dispatch.md); a Python backend does not exist yet. The Service Definition README keeps its worker-only wording for that reason: linking to a `dsh-code-runtime-python` README that does not exist would break the dead-link gate.
This decision delivers the Service Definition extension and the worker-thread backend's adoption of it. The `py-types` renderer and PTC mode language dispatch are owned by the [language-dispatch note](../feature/2026-07-31-ptc-language-dispatch.md). The private experimental CPython subprocess backend (`dsh-experimental-code-runtime-python`) adopts the same portable-identifier contract.
`RESERVED_BINDING_GLOBALS` encodes the Python bootstrap's concrete design ahead of the backend itself: it seeds exactly `__builtins__`/`__name__` and wraps the program under `__dsh_main__`. A Python backend that seeds any additional module global (`__doc__`, `__loader__`, `__spec__`, `__file__`, `__package__`, …) MUST widen this set in the same change, exactly as adding a language widens `PORTABLE_RESERVED_WORDS` — a name the bootstrap seeds but the set omits is the portability split this contract exists to prevent.
@@ -25,7 +25,7 @@ Service Definition 同时把可移植标识符子集收窄为 `[A-Za-z_][A-Za-z0
## Scope
本决策交付 Service Definition 扩展与 worker 对它的采用。`py-types` 渲染器与 PTC mode 的语言分发归[语言分发 note](../feature/2026-07-31-ptc-language-dispatch.zh.md) 所有Python 后端尚不存在。Service Definition README 因此保留仅描述 worker 的措辞:链接到一个不存在的 `dsh-code-runtime-python` README 会破坏死链 gate
本决策交付 Service Definition 扩展与 worker-thread 后端对它的采用。`py-types` 渲染器与 PTC mode 的语言分发归[语言分发 note](../feature/2026-07-31-ptc-language-dispatch.zh.md)所有。私有的实验性 CPython 子进程后端(`dsh-experimental-code-runtime-python`)采用同一 portable-identifier 契约
`RESERVED_BINDING_GLOBALS` 先于后端本身编码了 Python bootstrap 的具体设计:它恰好 seed `__builtins__`/`__name__`,并把程序包装在 `__dsh_main__` 之下。任何 seed 额外模块 global`__doc__``__loader__``__spec__``__file__``__package__` 等)的 Python 后端必须在同一改动中扩宽此集合,正如新增一门语言即扩宽 `PORTABLE_RESERVED_WORDS`——bootstrap 会 seed 却不在集合中的名称,正是本约定要防止的可移植性分裂。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md
2026-07-31-code-runtime-python-fd3-protocol.md: 5572fe58cb1dd8832ff9405670afc7f80a20362c
2026-07-31-code-runtime-python-fd3-protocol.zh.md: 6254e94a7b48b38edfbe23a6ea0b994d04ac21f4
2026-07-31-code-runtime-python-fd3-protocol.md: cd8a42b509598d4782fc7c0637839e0dfd06f289
2026-07-31-code-runtime-python-fd3-protocol.zh.md: a6454c17dc23e3f6385fe2dc3b46eabdb241faff
@@ -2,13 +2,15 @@
Status: implemented
The CPython code runtime now lives at `packages/experimental/code-runtime-python` (private, npm name `@deepseek-ai/dsh-experimental-code-runtime-python`); promotion to a released package follows the experimental-packages decision.
English | [中文](2026-07-31-code-runtime-python-fd3-protocol.zh.md)
## Problem
`@deepseek-ai/dsh-code-runtime-python` owns the wire protocol intended for a CPython code-runtime provider. Such a provider runs each model program in a fresh `python3 -I` subprocess and bridges binding calls and completion values over the child's fd 3. The host cannot trust that channel: model code has full access to fd 3 and can forge any frame, so every inbound frame is hostile input that the host must validate and rebuild before reading. The protocol also has to carry lossless JSON without the depth limit `JSON.stringify` and `json.dumps` impose, because the seam's `CodeJsonValue` is depth-unbounded.
`@deepseek-ai/dsh-experimental-code-runtime-python` owns the wire protocol intended for a CPython code-runtime provider. Such a provider runs each model program in a fresh `python3 -I` subprocess and bridges binding calls and completion values over the child's fd 3. The host cannot trust that channel: model code has full access to fd 3 and can forge any frame, so every inbound frame is hostile input that the host must validate and rebuild before reading. The protocol also has to carry lossless JSON without the depth limit `JSON.stringify` and `json.dumps` impose, because the seam's `CodeJsonValue` is depth-unbounded.
The package ships the protocol independently from a runtime implementation. It exports no `PythonCodeRuntime`, subprocess path, or Python-side JSON codec; those remain work for a future provider. The protocol builds on the [portable identifier seam](2026-07-31-code-runtime-portable-identifier-seam.md).
The private experimental package contains both the protocol and runtime implementation: `PythonCodeRuntime` (the plugin's default export), the `python3 -I` subprocess path, and the Python-side JSON codec all live in `@deepseek-ai/dsh-experimental-code-runtime-python`. The protocol builds on the [portable identifier seam](2026-07-31-code-runtime-portable-identifier-seam.md).
## Decision
@@ -20,24 +22,24 @@ The package ships the protocol independently from a runtime implementation. It e
`py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text.
The package remains independently buildable with protocol-only exports. `check-workspace-constraints` reads every `packages/<group>/<pkg>/package.json` unconditionally, while the coverage and invariant-topology checks exercise the package as soon as its directory exists.
The package ships the runtime alongside the protocol; it remains independently buildable. `check-workspace-constraints` reads every `packages/<group>/<pkg>/package.json` unconditionally, while the coverage and invariant-topology checks exercise the package as soon as its directory exists.
## Wire contract
Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame), `run` (after `boot-ack`), and one `reply` per `call`. The `log` frame's `truncated` flag marks the frame that IS the child ledger's own truncation marker, so the host stops capturing at the same point the child did instead of inferring it from its own budget. `done.error.kind` is one of `exception`, `invalid-output`, `output-limit`; wall/CPU budgets, aborts, and substrate death are observed host-side, not carried as frames.
Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame), `run` (after `boot-ack`), and one `reply` per `call`. The `log` frame's `truncated` flag marks the frame that IS the child ledger's own truncation marker, so the host stops capturing at the same point the child did instead of inferring it from its own budget. The `log` frame's `open` flag marks an unterminated line committed by an explicit flush: the host holds it and appends the next frame to the same entry, so an explicit flush followed by more text reads back as one line rather than a fake newline. The one exception is truncation: when a later over-budget frame trips the ledger, the already-billed prefix is committed as its own entry and the truncation marker follows it (marker last, no re-charge). The merged entry's wire cost is billed exactly once, split incrementally across its fragments on both sides (O(k) for k fragments, never a re-walk of the whole hold): the FIRST fragment pays the full JSON-string cost plus the separator, each continuation and the closing frame pay only their content; the host's exact-cost caps are `logBudget - 1` for a first fragment (the ledger's reserved byte, matching `admit`) and `logBudget + 2` for a continuation or closing frame (billed without the two quotes), and `jsonStringCostUpTo` returns `undefined` below a 2-byte cap; the child keys its split billing off `_open_started` alone, so a closing frame bills as the merged tail. `done.error.kind` is one of `exception`, `invalid-output`, `output-limit`; wall/CPU budgets, aborts, and substrate death are observed host-side, not carried as frames.
## Mirror alignment
`py/protocol.py` and `src/protocol.ts` agree that `LogMessage` carries `truncated`, `DoneMessage.error` carries `kind`, and `Namespace` may carry `errorClass`. `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts `PROTOCOL_FD`, `log_truncation_marker`, and each `TypedDict`'s required and optional wire field sets against `src/protocol.ts`. A renamed or dropped field, or a required/optional mismatch, fails the test. Field *types* are not compared across the language boundary; review and a future provider's real-subprocess suite own that gap.
`py/protocol.py` and `src/protocol.ts` agree that `LogMessage` carries `truncated`, `DoneMessage.error` carries `kind`, and `Namespace` may carry `errorClass`. `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts `PROTOCOL_FD`, `log_truncation_marker`, and each `TypedDict`'s required and optional wire field sets against `src/protocol.ts`. A renamed or dropped field, or a required/optional mismatch, fails the test. Field *types* are not compared across the language boundary; review and the runtime's real-subprocess suite (`runtime.spec.ts`) own that gap.
## Alternatives considered
**Require a future Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) to live in `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates hostile input and is self-contained. A child-side codec would produce trusted output and belong with bootstrap-owned emission and cost accounting; forcing only its entry points into `protocol.py` would couple the vocabulary mirror to runtime internals or create an import cycle. `protocol.py` remains a pure wire-vocabulary mirror. No Python codec ships in this package.
**Require a future Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) to live in `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates hostile input and is self-contained. A child-side codec would produce trusted output and belong with bootstrap-owned emission and cost accounting; forcing only its entry points into `protocol.py` would couple the vocabulary mirror to runtime internals or create an import cycle. `protocol.py` remains a pure wire-vocabulary mirror; the codec (`_encode_json_plain` / `_decode_json_plain`) lives in `bootstrap.py` with the runtime it serves.
**Keep the protocol files outside a buildable package until a runtime ships.** Rejected: the workspace-constraint, coverage, and invariant-topology checks require every directory under `packages/<group>/<pkg>` to be a buildable package, and the protocol has independent tests and a public wire vocabulary.
## Consequences
Bought: the fd-3 protocol and its hostile-input codec form a self-contained, fully unit-covered layer, with an executing guard against TypeScript/Python field-set drift. A future runtime can consume a reviewed wire contract.
Bought: the fd-3 protocol and its hostile-input codec form a self-contained, fully unit-covered layer, with an executing guard against TypeScript/Python field-set drift. The runtime built on it (`bootstrap.py`) consumes the reviewed wire contract.
Cost: the package name denotes a Python runtime family while `src/index.ts` exports only the protocol vocabulary. The mirror e2e compares field names and required/optional status across the two sides but not field types; comparing type declarations across TypeScript and Python has no mechanical equivalent, so review and the future runtime's real-subprocess suite retain that responsibility.
Cost: the package name denotes a Python runtime family and `src/index.ts` exports the full `PythonCodeRuntime` implementation, so the protocol vocabulary is only one part of the package surface. The mirror e2e compares field names and required/optional status across the two sides but not field types; comparing type declarations across TypeScript and Python has no mechanical equivalent, so review and the runtime's real-subprocess suite retain that responsibility.
@@ -2,13 +2,15 @@
Status: implemented
CPython 代码运行时现在位于 `packages/experimental/code-runtime-python`(私有,npm 名 `@deepseek-ai/dsh-experimental-code-runtime-python`);提升为发布包遵循 experimental-packages 决策。
[English](2026-07-31-code-runtime-python-fd3-protocol.md) | 中文
## Problem
`@deepseek-ai/dsh-code-runtime-python` 负责供 CPython code-runtime 提供方使用的 wire protocol。这样的提供方会在全新的 `python3 -I` 子进程中运行每个模型程序,并通过子进程 fd 3 桥接 binding 调用与完成值。Host 不能信任这条通道:模型代码可以完全访问 fd 3 并伪造任意帧,因此 host 必须把每个入站帧视为敌意输入,先校验并重建后才能读取。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify``json.dumps` 都有递归深度限制。
`@deepseek-ai/dsh-experimental-code-runtime-python` 负责供 CPython code-runtime 提供方使用的 wire protocol。这样的提供方会在全新的 `python3 -I` 子进程中运行每个模型程序,并通过子进程 fd 3 桥接 binding 调用与完成值。Host 不能信任这条通道:模型代码可以完全访问 fd 3 并伪造任意帧,因此 host 必须把每个入站帧视为敌意输入,先校验并重建后才能读取。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify``json.dumps` 都有递归深度限制。
该包独立交付协议,不包含 runtime 实现。它不导出 `PythonCodeRuntime`子进程路径 Python 侧 JSON codec;这些属于未来提供方。协议建立在[可移植标识符 seam](2026-07-31-code-runtime-portable-identifier-seam.zh.md)之上。
这个私有实验包同时包含协议与 runtime 实现`PythonCodeRuntime`(插件的默认导出)、`python3 -I` 子进程路径 Python 侧 JSON codec 都在 `@deepseek-ai/dsh-experimental-code-runtime-python`。协议建立在[可移植标识符 seam](2026-07-31-code-runtime-portable-identifier-seam.zh.md)之上。
## Decision
@@ -20,24 +22,24 @@ Status: implemented
`py/protocol.py``TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3``log_truncation_marker`——文本逐字节一致。
该包只导出协议,同时保持独立可构建。`check-workspace-constraints` 会无条件读取每个 `packages/<group>/<pkg>/package.json`coverage 与 invariant-topology 检查则会在包目录存在时立即覆盖该包。
该包随协议一起交付 runtime,同时保持独立可构建。`check-workspace-constraints` 会无条件读取每个 `packages/<group>/<pkg>/package.json`coverage 与 invariant-topology 检查则会在包目录存在时立即覆盖该包。
## Wire contract
帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack``call``log``done`。Host → child`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply``log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`done.error.kind``exception``invalid-output``output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。
帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack``call``log``done`。Host → child`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply``log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`log` 帧的 `open` 标志标记由显式 flush 提交的未结束行:宿主持有它并把下一个帧追加到同一条目,因此显式 flush 后接更多文本读回为一行而不是假换行。唯一例外是截断:当后续超预算帧触发账本时,已计费的前缀作为独立条目先提交,截断 marker 跟在后面(marker 保持末位,无重复计费)。合并条目的线上成本恰好计费一次,在两侧按片段增量分摊(k 个片段 O(k),绝不对整个持有重走):首片段付完整 JSON 字符串成本加分隔符,每个续接与闭合帧只付内容;宿主精确成本 cap 是首片段 `logBudget - 1`(账本预留字节,与 `admit` 一致)、续接或闭合帧 `logBudget + 2`(不含两个引号计费),且 `jsonStringCostUpTo` 在低于 2 字节 cap 时返回 `undefined`;子进程按 `_open_started` 单独键控拆分计费,因此闭合帧按合并尾部计费。`done.error.kind``exception``invalid-output``output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。
## Mirror alignment
`py/protocol.py``src/protocol.ts` 一致规定:`LogMessage` 携带 `truncated``DoneMessage.error` 携带 `kind``Namespace` 可以携带 `errorClass``tests/protocol-mirror.e2e.ts` 启动真实 `python3`,对照 `src/protocol.ts` 断言 `PROTOCOL_FD``log_truncation_marker` 以及每个 `TypedDict` 的必填和可选 wire 字段集。字段改名、删除或必填/可选性不一致都会使测试失败。字段*类型*不跨语言边界比较;这项缺口由评审和未来提供方的真实子进程套件负责。
`py/protocol.py``src/protocol.ts` 一致规定:`LogMessage` 携带 `truncated``DoneMessage.error` 携带 `kind``Namespace` 可以携带 `errorClass``tests/protocol-mirror.e2e.ts` 启动真实 `python3`,对照 `src/protocol.ts` 断言 `PROTOCOL_FD``log_truncation_marker` 以及每个 `TypedDict` 的必填和可选 wire 字段集。字段改名、删除或必填/可选性不一致都会使测试失败。字段*类型*不跨语言边界比较;这项缺口由评审和 runtime 的真实子进程套件(`runtime.spec.ts`负责。
## Alternatives considered
**要求未来的 Python JSON codec`_encode_json_plain` / `_decode_json_plain`)放进 `py/protocol.py`,以便与 `protocol.ts` 跨侧对称。**拒绝。仓库的 “prefer symmetry for parallel values” 规则指向真正平行的值;这两者不是。`protocol.ts` 中的 host 侧 codec 校验敌意输入且自包含。Child 侧 codec 会产出受信任输出,应与 bootstrap 拥有的发出逻辑和成本核算放在一起;只把入口强塞进 `protocol.py` 会让 vocabulary 镜像耦合 runtime 内部实现,或制造 import 环。`protocol.py` 保持纯 wire-vocabulary 镜像。本包尚未交付 Python codec
**要求未来的 Python JSON codec`_encode_json_plain` / `_decode_json_plain`)放进 `py/protocol.py`,以便与 `protocol.ts` 跨侧对称。**拒绝。仓库的 “prefer symmetry for parallel values” 规则指向真正平行的值;这两者不是。`protocol.ts` 中的 host 侧 codec 校验敌意输入且自包含。Child 侧 codec 会产出受信任输出,应与 bootstrap 拥有的发出逻辑和成本核算放在一起;只把入口强塞进 `protocol.py` 会让 vocabulary 镜像耦合 runtime 内部实现,或制造 import 环。`protocol.py` 保持纯 wire-vocabulary 镜像codec`_encode_json_plain``_decode_json_plain`)与它所服务的 runtime 一起位于 `bootstrap.py`
**在 runtime 交付前把协议文件放在不可构建的包外。**拒绝:workspace-constraint、coverage 与 invariant-topology 检查要求 `packages/<group>/<pkg>` 下的每个目录都是可构建包,而协议本身拥有独立测试与公开 wire vocabulary。
## Consequences
收获:fd-3 协议及其敌意输入 codec 构成自包含、unit 全覆盖的一层,并由执行中的 guard 防止 TypeScriptPython 字段集漂移。未来 runtime 可以直接消费经过评审的 wire contract。
收获:fd-3 协议及其敌意输入 codec 构成自包含、unit 全覆盖的一层,并由执行中的 guard 防止 TypeScriptPython 字段集漂移。基于它构建的 runtime`bootstrap.py`消费经过评审的 wire contract。
代价:包名表示 Python runtime 家族,而 `src/index.ts` 导出协议 vocabulary。mirror e2e 会比较两侧字段名与必填/可选状态,但不比较字段类型;跨 TypeScript 与 Python 比较类型声明没有机械等价物,因此评审与未来 runtime 的真实子进程套件继续负责这项检查。
代价:包名表示 Python runtime 家族,而 `src/index.ts` 导出完整的 `PythonCodeRuntime` 实现,协议 vocabulary 只是包表面的一部分。mirror e2e 会比较两侧字段名与必填/可选状态,但不比较字段类型;跨 TypeScript 与 Python 比较类型声明没有机械等价物,因此评审与 runtime 的真实子进程套件继续负责这项检查。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md
2026-08-04-configuration-source-ownership.md: 1fe5908ab77632732996bd1d5c1eed9c8ab048e6
2026-08-04-configuration-source-ownership.zh.md: 197cb936cdff303e23425d008c7a2cb738500ae0
2026-08-04-configuration-source-ownership.md: 29dd5fd623d38532502af8c4e4afd972236fc139
2026-08-04-configuration-source-ownership.zh.md: e5cbf70826b9314daccb7a62e5da4603e38158de
@@ -42,7 +42,7 @@ The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret,
**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for decisions where a layer must be unreachable; this decision includes the project layer.
**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. One exemption, recorded in [the proxy policy note](2026-08-27-outbound-proxy-policy.md): the four proxy names are accepted from `$DSH_HOME/.env`, which no `.env` can relocate, and still refused from the invoking directory's file.
The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself.
@@ -53,7 +53,7 @@ The line is that these take effect with no user action, before any turn, outside
## Consequences
- The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so.
- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
- Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree.
- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation.
- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work.
@@ -43,7 +43,7 @@ inherited process environment (read-only, wins)
**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制供要求某一层不可达的决策使用;本决策包含项目层。
**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH``SHELL``NODE_OPTIONS``LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR``PAGER``BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV``PERL5OPT``PYTHONSTARTUP``RUBYOPT``JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME``XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH``SHELL``NODE_OPTIONS``LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR``PAGER``BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV``PERL5OPT``PYTHONSTARTUP``RUBYOPT``JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME``XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。唯一的豁免记录在[代理策略笔记](2026-08-27-outbound-proxy-policy.zh.md)中:四个代理名可从 `$DSH_HOME/.env` 接受——没有任何 `.env` 能挪动该文件——但仍拒绝来自调用目录文件的同名变量。
这条界线在于:它们无需任何用户动作、在任何轮次开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具每次发出 `bash -c` 时执行项目指定的文件——项目的代码在 agent(智能体)的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。
@@ -54,7 +54,7 @@ inherited process environment (read-only, wins)
## Consequences
- Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。
- 含 `DSH_*``PATH``BROWSER` proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
- 含 `DSH_*``PATH``BROWSER`(在调用目录中)proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
- composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。
- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。
- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
2026-08-04-draft-provider-endpoint-interrogation.md: 502d9bab15dcb91a59deb26443d869a36b028b48
2026-08-04-draft-provider-endpoint-interrogation.zh.md: e0605369c7f4707eb682cc1c32d11123140b449a
2026-08-04-draft-provider-endpoint-interrogation.md: a6d8d32f12711744cb1380d2ab2767dcd3dd63f6
2026-08-04-draft-provider-endpoint-interrogation.zh.md: e162ddb30706e871a2a42b5bbe7e09a451296289
@@ -6,7 +6,7 @@ English | [中文](2026-08-04-draft-provider-endpoint-interrogation.zh.md)
## Problem
Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding an OpenAI-compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and most of these endpoints do publish that list at `GET /models`.
Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding a compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and OpenAI- and Anthropic-compatible endpoints publish that list through protocol-specific model-listing routes.
The obvious answer, a dynamic runtime catalog refreshed in the background, was rejected with the layer below it: it makes a route's model list external mutable state needing a cache, an invalidation story, and an offline path, while the product need is narrower. What is needed is a *question asked once*, whose answer the user adopts into `settings.yaml` — so `settings.yaml` remains the only thing deciding what a route serves.
@@ -17,11 +17,11 @@ The awkward part is that the question is about something that does not exist yet
Interrogation is keyed by **settings namespace**, not by provider route:
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name.
- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. The one read is the credential of a route the request names: a configuration surface holds a redacted descriptor rather than the stored secret, so the draft's `apiKey` is present only while the user is typing one, and without that read an already-configured route would be interrogated unauthenticated and answer 401. The typed key wins, being the one under test.
- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. A named configured route reads its stored credential and deployment-owned profile `headers` inside the Host: the credential is write-only and the curated Models page does not edit headers, so neither can be reconstructed from that page's draft. The typed key wins over the stored credential, while the profile headers still accompany the request.
- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.
- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
`dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs.
`dsh-llm-pi-ai` applies the protocol-specific listing routes, authentication, URL normalization, response formats, and metadata rules recorded by [protocol-specific model listing discovery](2026-09-02-protocol-specific-model-listing-discovery.md). Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; fixed protocol headers, a typed-or-stored protocol credential, and Harness attribution then win their case-insensitive collisions. A protocol without a documented listing contract answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting guessed response fields as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so deployment path segments remain intact. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage pattern for its own caller-supplied URLs.
### Why not pi-ai's own refresh machinery
@@ -33,18 +33,18 @@ pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `
**Put the capability on `LlmAdapter`.** Adapters are reached through a route registration, so this has the same problem, plus it would make an adapter instance answer questions about endpoints it does not serve.
**Have the host read the stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. Accepting the draft keeps what the user sees and what is asked identical — with the credential as the one exception, because it is the one field a surface is never shown and so can never put in the draft.
**Have the host read the entire stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. The draft remains authoritative for the endpoint and protocol. The narrow Host-side exceptions are the stored credential, which is write-only, and profile headers, which remain deployment configuration rather than Models-page fields.
**Interrogate every pi-ai protocol.** Anthropic's listing happens to share OpenAI's envelope, and Google's does not. Supporting the ones that are easy would make coverage arbitrary and, worse, make a wrong guess at a response shape indistinguishable from a provider with no models. A protocol that says it cannot be interrogated sends the user to hand-entry, which is the documented fallback.
**Interrogate every pi-ai protocol.** Coverage based on convenient response similarities would be arbitrary and would make a wrong guess indistinguishable from a provider with no models. Anthropic is included only through its documented native listing contract, as the [protocol-specific extension](2026-09-02-protocol-specific-model-listing-discovery.md) records; Google's field set and Azure's request contract differ, while Codex uses OAuth. An unsupported protocol sends the user to hand-entry, which remains the documented fallback.
**Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed.
## Consequences
A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber.
A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. When an endpoint discloses richer metadata, adopting a candidate fills its id, name, context window, and output-token cap into the editable Web row. Search preserves hidden selections, selecting all adds the visible results, and deselecting all clears every result so a filtered picker cannot submit hidden models accidentally. An already-configured enterprise gateway uses the same deployment headers and Harness `User-Agent` for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber.
What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage is protocol-shaped rather than provider-shaped — an Anthropic-compatible gateway must be filled in by hand even though its listing would parse. And because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately.
What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage remains protocol-shaped rather than provider-shaped, and an endpoint using an unsupported request contract must be filled in by hand. Because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately.
## Testing
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while `packages/client/ui-settings-models/tests/provider-form.client.spec.tsx` verifies that the draft reaches the Remote whole, absent fields stay absent, and no settings namespace or credential is written before selection.
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — standard arrays and enriched objects with every accepted metadata spelling, Anthropic's native path, headers, and capacity fields, route keys that differ from nested canonical ids, name fallback, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` boots settings and credentials through the Loader and proves settings-only headers reach `GET /models` with request-owned headers winning collisions. `packages/llm/llm-pi-ai/tests/adapter.spec.ts` rejects profile headers Fetch cannot represent, and `packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` proves a settings write reports that configuration error while its last good routes keep serving. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while the component and built-Web settings tests verify that the complete draft reaches the Remote, absent fields stay absent, selected metadata fills all four editable model fields, tuned rows win over rediscovery, filtered deselection clears hidden candidates, and no settings namespace or credential is written before selection.
@@ -6,7 +6,7 @@ Status: implemented
## Problem
当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.zh.md)之后,要接入一个 OpenAI 兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而这类端点大多在 `GET /models`公布这份列表
当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.zh.md)之后,要接入一个兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而 OpenAI 与 Anthropic 兼容端点会通过各自协议的模型列表路由公布这份信息
显而易见的答案——后台刷新的运行时动态 catalog——已随下层一并被拒绝:它会把路由的模型列表变成需要缓存、失效语义与离线路径的外部可变状态,而产品需求要窄得多。真正需要的是*一次性询问*,其答案由用户采纳进 `settings.yaml`,从而让 `settings.yaml` 始终是决定路由服务内容的唯一真源。
@@ -17,11 +17,11 @@ Status: implemented
询问以 **settings namespace** 为键,而不是提供方路由:
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。
- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider``baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。唯一的读取是请求所点名路由的凭据:配置界面拿到的是脱敏描述符而非已存的机密,因此草稿里的 `apiKey` 只在用户正键入时才存在;没有这次读取,已配置好的路由就会被不带认证地询问,只换回一个 401。键入的密钥优先,因为那正是被测试的那一把
- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider``baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。已配置且具名的路由会在 Host 内读取已存凭据和部署方持有的 profile `headers`:凭据只写,而精选的 Models 页面不编辑 headers,因此页面草稿无法重建两者。键入的密钥优先于已存凭据,profile headers 则仍随请求发送
- `LlmDiscoveredModel``id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。
- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate``credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。
`dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。其余协议一律`DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。
`dsh-llm-pi-ai` 会应用[协议特定模型列表发现](2026-09-02-protocol-specific-model-listing-discovery.zh.md)记录的列表路由、认证、URL 归一化、响应格式与元数据规则。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定协议 headers、键入或已存的协议凭据以及 Harness attribution 随后分别以大小写不敏感方式赢得相应冲突。没有文档化列表约定的协议会`DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应字段报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此部署路径会保持不变。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式模式一致。
### 为什么不用 pi-ai 自己的 refresh 机制
@@ -33,18 +33,18 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与
**把能力挂在 `LlmAdapter` 上。** 适配器要经由路由注册才能抵达,因此问题相同;而且这会让一个适配器实例去回答它并不服务的端点的问题。
**让 host 读已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有机密跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。接受草稿让用户看见的与被询问的保持一致——凭据是唯一的例外,因为它是从不向界面展示、因而永远无法放进草稿的那个字段
**让 Host 读取整个已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有机密跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。草稿仍是端点和协议的权威来源。Host 侧的狭窄例外是只写的已存凭据,以及仍属部署配置、而非 Models 页面字段的 profile headers
**询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「提供方没有模型」无法区分。一个明说自己无法被询问的协议会把用户送去手工填写——那正是既定回退路径。
**询问每一种 pi-ai 协议。** 根据便利的响应相似性选择覆盖范围会显得武断,也会让猜错的响应与「提供方没有模型」无法区分。Anthropic 仅通过其文档化原生列表约定纳入支持,具体由[协议特定扩展](2026-09-02-protocol-specific-model-listing-discovery.zh.md)记录;Google 的字段集合与 Azure 的请求约定不同,Codex 则使用 OAuth。不支持的协议会把用户送去手工填写,这仍是既定回退路径。
**用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。
## Consequences
接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。
接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。当端点公布了更丰富的元数据时,采纳候选会把 id、名称、上下文窗口与最大输出 token 数填进 Web 的可编辑行。搜索会保留隐藏项的勾选状态,全选会加入可见结果,而取消全选会清空全部结果,因此筛选后的选择器不会意外提交隐藏模型。已配置的企业网关会为询问与模型请求使用同一组部署 headers 和 Harness `User-Agent`,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。
代价是:协议层多了第三个承载机密的载荷,配置面的只写接口从两个方法变成三个。发现覆盖范围按协议而非按提供方划分——一个 Anthropic 兼容网关即便其列表能被解析,也仍须手工填写。而且由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。
代价是:协议层多了第三个承载机密的载荷,配置面的只写接口从两个方法变成三个。发现覆盖范围按协议而非按提供方划分,使用不受支持请求约定的端点仍须手工填写。由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。
## Testing
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels``/api` 承载注册,`packages/client/ui-settings-models/tests/provider-form.client.spec.tsx` 则验证草稿完整抵达 Remote、缺席字段保持缺席,以及选择前没有 settings namespace 或凭据被写入。
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——包括采用每种受支持元数据拼写的标准数组与富信息对象、Anthropic 原生路径、headers 与容量字段、不同于嵌套规范 id 的路由键、名称回退、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 `GET /models`,且请求所持有的 headers 赢得冲突。`packages/llm/llm-pi-ai/tests/adapter.spec.ts` 拒绝 Fetch 无法表示的 profile headers`packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels``/api` 承载注册,而设置页的组件测试和构建后 Web 测试则验证完整草稿抵达 Remote、缺席字段保持缺席、所选元数据填满四个可编辑模型字段、用户调整过的行优先于重新发现结果、筛选后的取消选择会清除隐藏候选项,以及选择前没有 settings namespace 或凭据被写入。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md
2026-08-05-large-session-jsonl-restore-pipeline.md: eab53c683880ef7095233ed8122e532eb5add547
2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 2cd0d2ca20074d6adb0735db08071638ae2ced88
2026-08-05-large-session-jsonl-restore-pipeline.md: e87777cc407e50a0e4808b77c3a7d554659d62e7
2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 32762bd19914423ef38c6472f0a8087e3e7e45fa
@@ -30,7 +30,7 @@ The scanner stops retaining events at the first unparsable row or sequence gap b
### Restore admission
Persistence transfers freshly materialized JSON values to `Session.fromRestore`. These values are detached, acyclic trees, and packed chunk rows expand into newly allocated events, so the restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice.
Persistence transfers freshly materialized current JSON values to `Session.fromRestore`. These values are detached, acyclic trees; historical packed rows and adjacent migrations have already produced newly allocated v2 settlements. The restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice.
Borrowed seeds used by ordinary creation and fork paths still take a JSON snapshot and use the generic cycle-safe deep freeze. The specialization therefore changes only durable restoration; it does not weaken acceptance for caller-owned values.
@@ -41,7 +41,7 @@ Borrowed seeds used by ordinary creation and fork paths still take a JSON snapsh
- **Concatenate all plaintext before scanning** — rejected because it retains the compressed input, complete plaintext, whole-log UTF-8 string, line metadata, and parsed rows at the same time, and it rescans a torn-frame prefix.
- **Implement a streaming JSON parser** — rejected because JSONL already provides record boundaries; native newline search plus `JSON.parse` removes the large intermediates without owning another parser or changing JSON semantics.
- **Use a shared `WeakSet` while freezing restored events** — rejected because JSON materialization cannot produce cycles, and the set adds a lookup per object while retaining the complete graph during traversal.
- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and `Session.events` promises immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them.
- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and Session read methods promise immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them.
## Consequences
@@ -30,7 +30,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解
### 恢复准入
持久化把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in``switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并通过显式 `pending` 数组迭代冻结所拥有的对象图,不使用循环跟踪集合`surface` 校验记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次
持久化把刚物化的当前 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的 tree;历史 packed row 与相邻 migration 已经生成新分配的 v2 settlement。restore-only path 使用一次 `for...in``switch` 校验固定 event envelope,按 event discriminant 执行当前表示检查,并通过显式 `pending` array 迭代冻结 owned object graph,不使用 cycle-tracking set`surface` 校验记录一次 transition plan,并在同一个 candidate event 进入 log 时提交该 plan
普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照,并使用支持循环检测的通用深度冻结。因此,这项特化仅改变持久恢复,不会放宽调用方所有值的准入要求。
@@ -41,7 +41,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解
- **扫描前拼接全部明文**:不予采纳,因为该方案会同时保留压缩输入、完整明文、整份日志的 UTF-8 字符串、行元数据和解析记录,并会重新扫描撕裂帧前缀。
- **实现流式 JSON 解析器**:不予采纳,因为 JSONL 已提供记录边界;使用原生换行搜索与 `JSON.parse` 就能移除大型中间结构,无需自行维护另一套解析器或改变 JSON 语义。
- **冻结恢复事件时共享一个 `WeakSet`**:不予采纳,因为 JSON 物化不可能产生循环引用,而该集合会对每个对象增加一次查找,并在遍历期间保留完整对象图。
- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 `Session.events` 承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。
- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 Session 读取方法承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。
## 后果
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-session-preparation.md
2026-08-05-session-preparation.md: 50f1ea38e671c6aa7b0f4adaf2fecbf83decc23c
2026-08-05-session-preparation.zh.md: cd918d126b56b081bcc1b6aa43a10d662668102d
2026-08-05-session-preparation.md: 040c9f788173a7cedd91be33cbe7ced3ca758a06
2026-08-05-session-preparation.zh.md: 44b609d488c6f8bb406370f9097eb2a085f7cc2b
@@ -6,63 +6,40 @@ English | [中文](2026-08-05-session-preparation.zh.md)
## Problem
Cold history inspection and Agent resume independently materialized the same persisted session log. For a large compressed log, each operation repeated the full read, decompression, parse, validation, freezing, and Session construction. Pagination could therefore pay the cold-read cost again, while making a history query activate an Agent would couple a read lifecycle to a live Agent with no natural retirement point.
Fresh creation and persisted resume reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together.
Fresh creation and persisted resume also reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together.
Cold history inspection and Agent resume also independently materialized the same persisted session log, which this note originally answered with a persistence-side prepared-Session cache; that half is superseded below.
## Decision
`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume obtains a preparation from `SessionPersistence.prepare()`.
`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume reads the stored log through the session's write handle, appends `interruptedTurnClosers`, and wraps `SessionStore.prepare(id, { seed, meta, seedSource: 'persistence' })` — the restoration branch that validates and freezes the transferred graphs in place.
The Agent loop consumes both forms through one setup-and-publication pipeline: it acquires the preparation, builds the private Agent context around `preparation.session`, awaits optional setup, publishes that exact Session and Agent, and disposes the preparation on every exit. Publication transfers the live lifecycle to the existing Session and Agent stores; `SessionPreparation` itself owns no Agent behavior.
This refines the publication boundary from the [Agent lifecycle and ownership decision](2026-06-18-agent-lifecycle-and-ownership-contracts.md) without replacing its ownership model.
## Persisted preparation lifecycle
## Superseded: the persistence-side preparation lifecycle
A coordinator-backed persistence implementation loads one cold source into a prepared Session. The backend transfers fresh, mutually unaliased metadata and events together with the source-qualified revision that identifies those exact values; the Session restore path validates and freezes the graphs in place instead of cloning them. The coordinator computes interrupted-turn closers and constructs the exact unpublished Session once. Its immutable header and balanced logical event log form the `SessionInspection` borrowed by readers, while the revision remains internal to persistence.
`inspect(id, signal?)` does not mutate storage. Synthetic closers exist only in the prepared in-memory view, and a torn physical tail remains untouched. Same-id callers share an in-flight cold read. Once ready, the preparation may remain in a per-coordinator LRU whose capacity defaults to five and is configurable by first-party backends. Before reusing a retained source, the coordinator reads that id's current revision; a mismatch evicts a ready source and repeats the cold materialization. A source already committing or reserved for resume remains exclusively owned, so concurrent inspection borrows that immutable view until publication or release.
`prepare(id, signal?)` exclusively reserves the prepared Session. It confirms the retained revision before committing any torn-tail and interrupted-turn repair, establishes the durable cursor, then returns a disposable preparation. A stale source is discarded and reloaded instead of being repaired or published. A successful repair also discards the pre-repair source and materializes the committed log again before reservation, so a newer revision is never associated with an older event graph. Another same-id preparation waits until the reservation is published or released. Publication accepts only the exact reserved Session and attaches the committed cursor without rebuilding its history. Failed setup or cancellation returns an unchanged unpublished Session to the LRU; mutation or attachment consumes the reservation.
The legacy `load(id)` API uses the same preparation and repair machinery, then discards its reservation and returns the immutable logical view. It remains a compatibility API, not the history-to-resume reuse path. This lifecycle extends the [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) while preserving the storage and recovery rules owned by the [session persistence decision](2026-06-14-session-persistence.md).
## History and resume reuse
History reads use `inspect()`, so repeated pages borrow the same immutable prepared state without activating an Agent. A later resume uses `prepare()` and receives the exact Session retained by inspection; it does not read, decompress, parse, clone, validate, or freeze the complete log again.
If the durable log changes after inspection, its revision changes. The next history read or resume discards a retained ready Session and materializes the new log, so an old event graph cannot be associated with a newer snapshot revision. A source already claimed by an in-flight resume is not evicted: its exclusive owner keeps it through publication or release, and concurrent history may borrow the same immutable view.
Cold continuable-subagent access follows the same path. Descriptor authorization first inspects the child, then `ctx.agents.resume()` reserves and publishes the retained Session. This preserves the lifecycle and authorization rules in the [continuable subagent conversation decision](../feature/2026-07-28-continuable-subagent-conversations.md) while removing its duplicate cold read.
This note originally also gave persistence a `prepare(id)`/`inspect(id)` lifecycle: a coordinator-backed bounded LRU of cold unpublished Sessions with exclusive reservations, revision-checked reuse, and repair committed inside `prepare`/`load`, so history pagination and a later resume shared one cold materialization. The [handle-based persistence seam](2026-08-27-handle-based-session-persistence.md) deletes all of it: persistence exposes handles only, resume reads the log through its write handle and owns repair, and read-only observers (session-query) own their cold-Session cache keyed by the `stat().revision` change token. The read-reuse goal survives in that cache; the exclusive-reservation machinery does not, because the write handle's single-writer ownership is the exclusion resume actually needs. Resume pays one whole-log read through the handle where the prepared cache sometimes served a warm Session — an accepted cost recorded in the handle note.
## Boundaries
- `readFrom()` remains a detached physical-suffix API. It neither creates nor consumes a preparation, synthesizes logical closers, or joins the LRU.
- HMR adoption keeps the live Session authoritative and reads the stored prefix directly. It may truncate a torn physical fragment but never closes the live open turn as interrupted.
- The cache belongs to one persistence coordinator, not a process-global Session map. Live Sessions are owned by the existing stores and never occupy preparation capacity.
- A fresh create never claims a cold persisted preparation with the same id. Persistence collisions continue to reject.
- Third-party persistence implementations retain the abstract `prepare()` fallback through `load()`. They receive the same publication interface but gain exact-object reuse only when they override preparation.
- Revision validation establishes freshness at the reuse and repair-commit points; it does not add cross-process writer exclusion to a backend. Retries converge after the durable log remains unchanged for one read/check round trip, so continuous external writers can delay preparation.
- The preparation is one disposable ownership window, not a cache: disposal is synchronous and idempotent, and publication accepts only the exact prepared Session.
- A fresh create never claims a persisted identity implicitly. Persistence collisions continue to reject (`SessionAlreadyExistsError`, `SessionAlreadyOwnedError`).
- Live Sessions are owned by the existing stores; preparations hold only unpublished ones.
## Verification
The shared persistence contract pins non-mutating balanced cold inspection and later repair. `persistence.spec.ts` and `preparations.spec.ts` pin same-id in-flight sharing, exact Session reuse across inspect and prepare, revision-triggered refresh before history and resume, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Backend tests pin that full and lightweight reads use the same revision identity. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown.
Agent-loop tests pin the common publication pipeline across create, `createAgent`, and resume, including rollback on setup failure, cancellation, and teardown, and that disposal releases the write handle (reopening for write succeeds). Session-store tests pin the restoration branch's validate-and-freeze-in-place transfer.
## Alternatives considered
**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle.
**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle. This rationale still guards the session-query cold cache: observation never creates an Agent.
**Cache only `{ meta, events }`.** Rejected because resume would still reconstruct, validate, freeze, and copy a Session from the cached values. The exact unpublished Session is the reusable unit.
**Cache only `{ meta, events }`.** Rejected at the time because resume would still reconstruct a Session from the cached values. Under the handle seam this is exactly what the read side does — session-query caches a cold Session per revision for reads only — while resume rebuilds from the handle read, trading the warm-Session reuse for a single write-ownership door.
**Keep a process-global Session map.** Rejected because it would cross backend and runtime ownership boundaries, retain unbounded identities, and duplicate the live Session store.
**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading, repair, reservation, and cursor attachment are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary.
**Turn `readFrom()` into logical preparation.** Rejected because watermark consumers need a detached physical suffix and, on seek-capable backends, a bounded read. Recovery balancing and whole-Session reuse have different semantics.
**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading and Session construction are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary; the handle seam kept that split while moving repair to the loop's resume path.
## Consequences
One cold materialization can serve history pagination, subagent descriptor inspection, and a later resume. Ownership transfer removes redundant restoration clones, while the bounded per-coordinator LRU limits memory and avoids creating live Agents for queries. Create and resume share one publication protocol without merging Agent and Session responsibilities.
The first cold inspection now pays the complete validation and Session-construction cost and may retain that unpublished Session until eviction. Persistence must coordinate reservation, append, repair, and publication, and callers must treat inspection values as immutable borrowed state. Backends that rely on the default `prepare()` remain correct but do not receive the reuse optimization.
Create and resume share one publication protocol without merging Agent and Session responsibilities, and every exit path disposes exactly one preparation. The persistence-side reuse consequences originally recorded here (shared cold materialization, LRU bounds, reservation coordination) now belong to the [handle note](2026-08-27-handle-based-session-persistence.md) and the session-query cache that replaced them.
@@ -6,63 +6,40 @@ Status: implemented
## 问题
冷历史检查和 agent(智能体)恢复会分别实体化同一份持久会话日志。对于大型压缩日志,每次操作都会重新完整读取、解压、解析、验证、冻结并构造 Session。因此,历史分页可能反复承担冷读成本;如果改为由历史查询激活 agent,读取生命周期又会与缺少自然退出时机的实时 agent 耦合
新建和持久化恢复通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent。
新建和持久化恢复也通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent
冷历史检查和 agent(智能体)恢复也曾分别实体化同一份持久会话日志,本 Note 最初以持久化侧的已准备 Session 缓存回答了这一半问题;那一半已在下文中被取代
## 决策
`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复则从 `SessionPersistence.prepare()` 取得准备对象
`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复通过该会话的写句柄读取已存储的日志、追加 `interruptedTurnClosers`,再包装 `SessionStore.prepare(id, { seed, meta, seedSource: 'persistence' })`——即就地验证并冻结转移对象图的恢复分支
agent loop(智能体循环)通过同一条设置与发布流水线消费这两种形式:先取得准备对象,围绕 `preparation.session` 构建私有 agent 上下文,等待可选设置完成,再发布该精确 Session 和 agent,并在所有退出路径上对准备对象执行 dispose(资源释放)。发布后,实时生命周期由现有 Session 与 agent 存储接管;`SessionPreparation` 本身不负责任何 agent 行为。
该机制细化了 [agent 生命周期与所有权决策](2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md)中的发布边界,但不替换其所有权模型。
## 持久化准备生命周期
## 已被取代:持久化侧的准备生命周期
使用协调器的持久化实现会将一个冷源加载为准备完成的 Session。后端转移新鲜、彼此无别名的元数据和事件,以及标识这些精确值的来源限定 revision;Session 恢复路径直接验证并冻结这些对象图,不再复制。协调器计算中断轮次的 closer,并且只构造一次精确的未发布 Session。其不可变 header 与已配平的逻辑事件日志构成读取方借用的 `SessionInspection`revision 则保留在持久化内部
`inspect(id, signal?)` 不修改存储。合成 closer 只存在于准备完成的内存视图中,撕裂的物理尾部保持不变。同 id 调用方共享进行中的冷读。准备完成后,该对象可以进入每个协调器自己的 LRU;第一方后端可配置容量,默认保留五个。协调器复用保留源之前会读取该 id 的当前 revision;如果不匹配,就淘汰处于就绪阶段的源并重新完成冷实体化。已经进入提交或为恢复而预留的源仍由其所有者独占,因此并发检查会借用该不可变视图,直至发布或释放。
`prepare(id, signal?)` 独占预留准备完成的 Session。它先确认保留的 revision,再提交撕裂尾部和中断轮次修复、建立持久游标,最后返回可 dispose 的准备对象。陈旧源会被丢弃并重新读取,不会参与修复或发布。修复成功后也会丢弃修复前的源,并在预留前重新实体化已提交日志,以免把较新的 revision 关联到较旧的事件对象图。同 id 的另一个准备请求会等待当前预留发布或释放。发布只接受精确的预留 Session,并直接附接已提交游标,无需重建历史。设置失败或取消时,未发生变化的未发布 Session 会返回 LRU;发生变更或完成附接后,系统会消费该预留。
存量 `load(id)` API 使用相同的准备和修复机制,随后丢弃其预留并返回不可变逻辑视图。它保留为兼容 API,不承担历史到恢复的复用路径。该生命周期扩展了[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.zh.md),同时继续遵循[会话持久化决策](2026-06-14-session-persistence.zh.md)所规定的存储与恢复规则。
## 历史与恢复复用
历史读取使用 `inspect()`,因此重复分页可以借用同一份不可变准备状态,而不会激活 agent。后续恢复调用 `prepare()`,直接取得检查阶段保留的精确 Session;系统不会再次完整读取、解压、解析、复制、验证或冻结日志。
如果持久日志在检查后发生变化,其 revision 也会变化。下一次历史读取或恢复会丢弃保留且处于就绪阶段的 Session,并实体化新日志,因此旧事件对象图不会被关联到较新的快照 revision。已经由进行中恢复操作取得的源不会被淘汰:其独占所有者会持有它直至发布或释放,并发历史读取可以借用同一个不可变视图。
冷 continuable subagent 访问沿用同一路径。系统先检查子会话并完成 descriptor 授权,再由 `ctx.agents.resume()` 预留并发布保留的 Session。这样既遵循 [continuable subagent 会话决策](../feature/2026-07-28-continuable-subagent-conversations.zh.md)中的生命周期与授权规则,也消除了重复冷读。
本 Note 最初还赋予持久化一个 `prepare(id)`/`inspect(id)` 生命周期:由协调器支撑的、装有冷未发布 Session 的有界 LRU,带独占预留、按 revision 校验的复用,以及在 `prepare`/`load` 内部提交的修复,使历史分页与后续恢复共享一次冷实体化。[基于句柄的持久化 seam](2026-08-27-handle-based-session-persistence.zh.md) 删除了这一切:持久化只暴露句柄,恢复通过其写句柄读取日志并自行负责修复,只读观察方(session-query)拥有自己的冷 Session 缓存,以 `stat().revision` 变更令牌为键。读取复用的目标在该缓存中得以延续;独占预留机制则没有延续,因为写句柄的单写者所有权正是恢复真正需要的排他手段。在已准备缓存有时能提供温 Session 的场景下,恢复要为通过句柄的一次全日志读取付出代价——这是句柄 Note 中记录的、已被接受的成本
## 边界
- `readFrom()` 仍是脱离的物理后缀 API。它不会创建或消费准备对象,不会合成逻辑 closer,也不会进入 LRU
- HMR(热模块替换)接管继续以实时 Session 为权威,并直接读取已存储前缀。它可以截断撕裂的物理碎片,但绝不把实时开放轮次关闭为中断状态
- 缓存属于单个持久化协调器,而不是进程全局 Session map。实时 Session 由现有存储持有,绝不占用准备容量
- 新建流程绝不认领相同 id 的冷持久化准备对象。持久化冲突仍会被拒绝。
- 第三方持久化实现继续获得通过 `load()` 实现的抽象 `prepare()` 回退。它们使用相同发布接口,但只有覆盖准备流程后才能复用精确对象。
- Revision 校验在复用点和修复提交点建立新鲜度,但不会为后端增加跨进程 writer 排他。持久日志在一次读取与复核往返内保持不变后,重试才能收敛,因此持续的外部写入可能延迟准备。
- 准备对象是一个可 dispose 的所有权窗口,而不是缓存:dispose 同步且幂等,发布只接受精确的已准备 Session
- 新建流程绝不隐式认领持久化身份。持久化冲突仍会被拒绝(`SessionAlreadyExistsError``SessionAlreadyOwnedError`
- 实时 Session 由现有存储持有;准备对象只持有未发布的 Session
## 验证
共享持久化约定规定冷检查不得修改存储且须保持配平,并覆盖后续修复。`persistence.spec.ts``preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、在历史读取与恢复前由 revision 触发刷新、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append,以及只允许发布预留 Session。后端测试覆盖完整读取与轻量读取使用同一 revision 身份。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和清理期间从检查到恢复的路径
agent loop 测试覆盖 create、`createAgent` 与 resume 之间的统一发布流水线,包括设置失败时的回滚、取消与清理,以及 dispose 会释放写句柄(重新以写模式打开可以成功)。Session store 测试覆盖恢复分支的就地验证并冻结的所有权转移
## 考虑过的替代方案
**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。
**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。该理由仍然守护着 session-query 冷缓存:观察绝不创建 agent。
**只缓存 `{ meta, events }`。** 不采用,因为恢复仍需从缓存值重新构造、验证、冻结并复制 Session。真正可复用的单元是精确的未发布 Session
**只缓存 `{ meta, events }`。** 当时不采用,因为恢复仍需从缓存值重新构造 Session。在句柄 seam 下,这恰好是读取侧的做法——session-query 按 revision 为只读用途缓存一个冷 Session——而恢复则从句柄读取重建,以温 Session 复用换取唯一的写所有权之门
**维护进程全局 Session map。** 不采用,因为它会跨越后端和运行时所有权边界,无界保留身份,并与实时 Session 存储重复
**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读、修复、预留和游标附接都属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界。
**把 `readFrom()` 改成逻辑准备流程。** 不采用,因为水位消费方需要脱离的物理后缀;对于可寻址后端,还需要限制实际读取范围。恢复平衡与完整 Session 复用具有不同语义。
**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读与 Session 构造属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界;句柄 seam 保留了这一分工,同时把修复移入循环的恢复路径
## 后果
一次冷实体化可以同时服务历史分页、subagent descriptor 检查和后续恢复。所有权转移去除了恢复阶段的冗余复制;每个协调器的有界 LRU 限制内存占用,也避免查询创建实时 agent。新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离。
首次冷检查需要承担完整验证与 Session 构造成本,并可能保留该未发布 Session 直至淘汰。持久化层必须协调预留、append、修复和发布;调用方必须把检查结果视为借用的不可变状态。依赖默认 `prepare()` 的后端仍然正确,但无法获得复用优化。
新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离,且每条退出路径恰好 dispose 一个准备对象。本 Note 最初记录的持久化侧复用后果(共享冷实体化、LRU 上限、预留协调)如今归属于[句柄 Note](2026-08-27-handle-based-session-persistence.zh.md) 以及取代它们的 session-query 缓存
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md
2026-08-06-subagent-list-identity-projection.md: aeed828530f615b1bb4958a360b5ba4db543f714
2026-08-06-subagent-list-identity-projection.zh.md: b2b64eaa7c06b738734a7b975adb5948704465bd
2026-08-06-subagent-list-identity-projection.md: cbb15696314930acfaf20ba8651699c53c5dbde2
2026-08-06-subagent-list-identity-projection.zh.md: dbb62dbfc6bb6ca3ab63504de1ba8dd35327bbbf
@@ -14,16 +14,16 @@ The root cause is that the [durable-subagent-catalog decision](../feature/2026-0
## Decision
mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served identity that passes the seq gate is final; otherwise it pays one full `persistence.inspect` read plus a fold through the registered `subagent` unit. No index, no cache of its own, no write-back.
mode and label are folded by the `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules. Enumeration uses the shared Session query corpus, while value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); an unseeded cold child may use the optional `sessionProjectionCache` checkpoint because its exact inherited cut is known to be zero; every seeded child and every cache miss pays one body-bearing Session observation plus a fold through the registered `subagent` unit. No index, no cache of its own, no list-side write-back.
There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered.
Key points:
- **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual.
- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot(session, ['subagent'])` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, using the non-null identity directly when it passes the seq gate (`seq >= seedLength ?? 0`); otherwise it pays one full Session observation plus a fold through the registered `subagent` unit; beyond that, absent is absent — no cache of its own, no write-back, no index.
- **The subagent list uses the Session query corpus for enumeration and body-bearing observations**: mode/label still comes through `ctx.sessionProjections`, and the list owns no descriptor parser or domain index.
- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot(session, ['subagent'])` (the registry's existing watermark cache, zero log reads); an unseeded cold child may read `sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])`; a seeded child or cache miss pays one Session observation carrying `inheritedEventCount` plus a fold through the registered `subagent` unit. Beyond that, absent is absent — no cache of its own, no list-side write-back, no index.
- **The `subagent` projection unit is the sole authority over the fold rules**: live and cold snapshots both run the one registered unit; no second copy of descriptor-interpretation logic exists.
- **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration.
- **The descriptor (v2) remains untouched**. Session, persistence, projection cache, and query now carry the exact inherited cut separately from the logical header; pre-existing data acquires exact values through one body-bearing observation when listing cannot prove a zero cut — no degraded unknown state and no durable format migration.
Relationship to existing notes:
@@ -36,8 +36,8 @@ It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packa
```ts ignore-check
export type SubagentIdentityProjection =
| { mode: 'one-shot'; label?: string; seq: number }
| { mode: 'continuable'; label: string; seq: number }
| { mode: 'one-shot'; label?: string; seq: SessionSeq }
| { mode: 'continuable'; label: string; seq: SessionSeq }
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
@@ -52,19 +52,19 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all. The host checkpoint state is the serializable wrapper `{ identity?: SubagentIdentityProjection }`; absence is `{}`. Its client view is the non-optional `SubagentIdentityProjection | null` entry. `null` passes JSON losslessly, so a pushed reset replaces a stale identity instead of being dropped by stringify. The judging discipline: consuming surfaces treat null and an absent client key alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below).
- Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; the mode/label discriminant matches the child row's strong contract below exactly (the row carries no `seq` — it is the projection's internal own-suffix proof).
- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel`seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The unit maps the wrapper's validated identity to its client wire view and is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 2, bumped when `seq` was added. Existing older checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold.
- The identity carries branded `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel. A live Session checks it through `isOwnSeq()`; a cold body-bearing observation compares it with `inheritedEventCount`. Header-only seeded candidates skip the cache because the header intentionally exposes no integer cut; unseeded candidates know the cut is zero. The unit maps the wrapper's validated identity to its client wire view and is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 2, bumped when `seq` was added. Existing older checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold.
- Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up.
### Enumeration: subagent-owned live-preferred merge
### Enumeration: query corpus with live preference
`listChildren`'s ([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts)) enumeration goes through no query service: the two sources `ctx.sessions.list()` and `ctx.get('sessionPersistence')?.list()` merge by id, with a live record overriding the same-id persisted record wholesale and no header consistency check. Everything enumeration needs is header facts:
`listChildren` ([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts)) asks `sessionQuery.listSessions()` for the canonical live-preferred corpus, then pairs each listed id with `ctx.sessions.get(id)` when a live Session exists. The live header overrides the listed header for that id. Everything enumeration needs is header facts:
- Filtering: `header.origin === 'subagent' && header.parentSession === parentSessionId`.
- `hasChildren`: the same merged material, looked at one level down — a direct descendant exists with `origin === 'subagent'` whose `parentSession` is that child.
- `activity`: a live record is `running`; one present only in persistence is `inactive`.
- Ordering: `createdAt` ascending, then child id ascending (matching the old contract).
- **Absent persistence degrades to live-only enumeration, not an error**: in a deployment without persistence, a cold child could not be resumed anyway, and listing live children remains meaningful. (Contrast: the old implementation rejected wholesale when sessionQuery was missing.)
- A persistence listing failure fails the whole enumeration; per-child isolation applies only to the per-child cold reads.
- An absent `sessionQuery` service fails with `SUBAGENT_CONTROL_QUERY_UNAVAILABLE`; the shared query corpus owns whether a deployment can enumerate live-only or persisted Sessions.
- A query-corpus failure fails the whole enumeration; per-child isolation applies only to per-child cold observations.
### Value retrieval: the three-rung compute-and-discard ladder
@@ -73,20 +73,20 @@ For each enumerated child, mode/label retrieval walks a three-rung ladder — co
| Rung | Read | Cost |
| --- | --- | --- |
| 1: live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | Zero log reads — the registry's existing watermark cache, synchronous retrieval |
| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, used directly only when the non-null identity satisfies `identity.seq >= header.seedLength ?? 0` — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermark | Zero log reads |
| 3: cold child, fallback | One full `persistence.inspect(id)` read + a fold through the registered `subagent` unit | One full read computed per listing |
| 2: unseeded cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])`; every valid seq is owned when the exact cut is zero | Zero log reads |
| 3: seeded child or cold fallback | One body-bearing `sessionQuery.observeSession(id)` + the registered `subagent` projection, with `inheritedEventCount` available for the own-suffix check | One full read computed per listing |
- Error contract: `sessionProjections` is a required injection — `SubagentRuntime` declares it in its inject set, so a deployment without the registry never activates the service (or the loop), and `listChildren` is unreachable rather than served degraded rows ([mandatory-seam note](2026-08-19-session-projection-mandatory-seam.md)); the loud runtime check and `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` are deleted with it. The session store keeps the explicit posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. apiproxy's dedicated `PROJECTIONS_UNAVAILABLE` wire face is deleted along with the code; `SESSION_STORE_UNAVAILABLE` goes through the generic internal fallback — apiproxy's composition injects `sessions` itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency.
- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`, a required injection). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. A count/interval checkpoint inside the creation window can land a fork seed's replayed ancestor identity in the row — the ancestor's seq falls inside the seed range, the seq gate rejects it, and it likewise falls to the third rung's verdict.
- Error contract: `sessionProjections`, the Session store, and `sessionQuery` are required runtime services for listing. Their explicit failures are `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`, and `SUBAGENT_CONTROL_QUERY_UNAVAILABLE`; no empty result disguises a missing classification or corpus capability.
- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`, a required injection). A seeded header skips this rung because it cannot supply the cache identity's exact cut without a body read. For an unseeded child, anything the second rung throws (including a poisoned unit row detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor, an absent key, or a null sentinel likewise falls through.
- Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping).
- The cold path's lifecycle witness: preparation's result must still point at the lifecycle that was enumerated — the witness field set is the same seven fields as the old SOURCE_CONFLICT check (version, id, createdAt, cwd, parentSession, seedLength, delegationDepth); a session deleted and republished under the same id degrades to a `corrupt` row in the old parent's catalog, leaking nothing of the new owner's child.
- The cold path's lifecycle witness: the observation must still point at the lifecycle that was enumerated. The witness fields are version, id, createdAt, cwd, parentSession, isSeeded, delegationDepth, origin, and agentPreset; a Session deleted and republished under the same id degrades to a `corrupt` row in the old parent's catalog, leaking nothing of the new owner's child.
- Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field.
- The cold-read cost, recorded honestly: only with the cache unmounted or missed does a cold child pay one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout.
- The cold-read cost, recorded honestly: every seeded child and every unseeded cache miss pays one full query observation per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The observation may reuse the query/persistence preparation layer, but listing does not depend on that optimization. A live child reads zero log throughout.
- Cancellation: the caller's signal is checked before and after each persistence read, and a read that settles only after abort is rejected, normalized to the stable error code `CANCELLED`.
### Authority model
- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints of its own, no in-process memo; the `sessionProjectionCache` checkpoint the second rung reads is an existing composition item's derived data, which this design only reads and never writes. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read (an own descriptor is immutable once appended — a cached identity past the seq gate has no staleness problem; the gate guards against seed-replayed ancestor identities).
- The session log is the sole authority; this design adds no domain index, checkpoint of its own, or in-process memo. The `sessionProjectionCache` checkpoint the second rung reads is an existing composition item's derived data, which the list only reads. Values are computed on read and discarded. Seeded candidates use a body-bearing observation to classify the identity against the exact cut; unseeded cached identities need no seq gate because every valid seq is owned.
- The Session and persistence write paths are entirely unaware of listing and projection consumption: no event-listener write-back, no fold-on-write.
- Enumeration and value retrieval constitute no second authorization source and make no unpublished child visible — the two sources see only published live records and durably written persisted records, consistent with the rule the durable-subagent-catalog note laid down for derived read surfaces.
@@ -127,25 +127,24 @@ For each enumerated child, the ladder's result maps to a row through four states
Known boundary deviations (deliberately accepted, recorded with this note):
- A fork child that died in its publication window, with an ancestor descriptor in its seed, gets the ancestor identity from last-wins and wrongly surfaces as a child row; resume still fails against the own-suffix fold authority (`NOT_RESUMABLE`). The old implementation omitted it via `seedLength` filtering; the projection unit cannot see the header, and this debris-grade deviation is accepted (`subagentTiming` has the same kind of pre-existing exposure).
- Multiple descriptors in the own suffix: the old implementation judged corrupt; last-wins now takes the final one (the provider contract guarantees exactly one anyway).
- A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row.
- A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart).
- An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted.
- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live snapshot, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine.
- Rung 2's later-event window applies only to an unseeded child: a cache row lands right after the first descriptor, the log then appends a second descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint. Cold listing can keep serving the old identity until a live run or cache write replaces the row. The precondition violates the provider's append-exactly-once contract and also requires missing every mandatory checkpoint; healthy children are unaffected. Seeded children never take rung 2 without the body-owned cut.
Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin's load requirement changes — `sessionQuery` dropped from inject, `sessionProjections` added as a required injection). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm).
Consuming surfaces keep the same row and diagnostic wire shape. `list_agents` reaches the required query corpus plus projection registry; live identities come from the registry snapshot and cold identities from cache or query observation. Host ownership still uses `header.origin`, and history uses the shared live/cold Session query sources; no consumer parses descriptor events independently.
### Change footprint
| Area | Files | Change |
| --- | --- | --- |
| subagent | projection.ts, projection-types.ts, index.ts | New client-visible `subagent` unit and its registration |
| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` deleted, and `sessionProjections` becomes a required injection (no projection error code remains); new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) |
| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms and the dedicated `PROJECTIONS_UNAVAILABLE` wire face retired with it |
| tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged |
| subagent | list-children.ts and its types | Query-corpus enumeration plus the projection-ladder four-state mapping; required projections/query services and optional projection-cache acceleration |
| host/apiproxy | Session controller/query integration | Owner checks use `header.origin`; live/cold history and listing consume the shared query and projection sources |
| tool | tool-subagent-control/list-agents.ts | Model-visible schema, description, and rendering remain unchanged |
| wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | Types, row shape, and diagnostic handling **unchanged**; api/subagents.ts only reworded the `history` JSDoc to the dual arm |
| core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | — | **Zero changes** |
| core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | body-bearing cut and branded seq plumbing | Logical headers expose `isSeeded`; Session, persistence observations, cache identity, and query records carry exact `inheritedEventCount` separately |
## Alternatives considered
@@ -169,20 +168,20 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir
## Verification
`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; without the registry the service never activates (the mandatory seam — a `setup` variant asserting `ctx.get('subagents')` stays undefined); a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes (`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` for an absent store). Second-rung cases: an own-seq identity used directly with zero `inspect`, a fork seed's ancestor identity (seq inside the seed range) rejected by the gate and falling through, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold; cold-path lifecycle tampering degrades to `corrupt` field by witness field (`it.each` over the seven). The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, `subagent-diagnostic` (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row.
`packages/subagent/subagent/tests/list-children.spec.ts` pins this contract: live identity checks use `Session.isOwnSeq()`; an unseeded cold identity may use the cache at cut zero; seeded candidates skip that cache rung and use an observation carrying `inheritedEventCount`; ancestor identities fail the own-suffix check; absent, null, poisoned, and unavailable cache/observation cases fall through or produce the documented diagnostic; lifecycle tampering degrades to `corrupt` across the complete witness field set. The existing keyless snapshots keep the healthy wire and model-visible surfaces fixed, while `subagent-diagnostic` pins diagnostic classification.
## Consequences
- Listing a live child reads zero log throughout; with the cache unmounted or missed, a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache of its own is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it.
- The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, loading the `list_agents` plugin no longer requires `sessionQuery`, and `sessionProjections` becomes a required injection of `SubagentRuntime` — a deployment without the projection registry never activates the service (the mandatory seam).
- The subagent list requires the Session query corpus and projection registry; missing services fail explicitly instead of producing incomplete rows. The optional projection cache changes only the number of body reads.
- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read use its live, cached, or observed wire snapshots, and no hand-written bypass fold exists; if some future consuming surface bypasses the unit with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee.
- Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration.
- The diagnostic and enumeration semantics leaves six boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, an unknown parent yielding an empty list instead of not-found, and rung 2's later-event window); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data, the unknown-parent one is a silent query-semantics change, and the rung-2 window is a self-healing cache-serving divergence under the double condition of corruption plus a crash; resume authorization is unaffected throughout, all explicitly accepted.
- The diagnostic and enumeration semantics leaves five boundary deviations (multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures changing classification, an unknown parent yielding an empty list instead of not-found, and the unseeded rung-2 later-event window). Seeded ancestor identities are no longer a deviation because body-bearing reads compare them with `inheritedEventCount`; resume authorization remains unaffected.
- Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise.
## Related
- [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while the list's enumeration and value retrieval move to the subagent-owned merge plus the projection ladder.
- [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while value retrieval moves to the projection ladder over the shared query corpus.
- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit and consumes its live and cold wire snapshots.
- [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md) — the state/client split; both `subagent` and `subagentTiming` provide client wire views.
- [Session projections as a required seam](2026-08-19-session-projection-mandatory-seam.md) — `sessionProjections` becomes a required injection; the list's error contract follows it (registry absence is an activation-time failure, and the projection error code is deleted).
@@ -14,16 +14,16 @@ Status: implemented
## 决策
mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级「算完即止」阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选 `sessionProjectionCache` checkpoint取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加经注册的 `subagent` unit 折叠。无索引、不自建缓存、无回写。
mode 与 label 由 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威。枚举使用共享 Session query corpus,取值走三级「算完即止」阶梯:live child 同步读注册表的既有水位缓存(零日志读);unseeded cold child 可以使用可选 `sessionProjectionCache` checkpoint因为其精确 inherited cut 已知为零;每个 seeded child 与每次 cache miss 都执行一次含正文的 Session observation,再经注册的 `subagent` unit 折叠。无索引、不自建缓存、列表侧无回写。
消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。「值随查询索引落库」已整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被「算完即止」显式接受。前两条与退役理由详见考虑过的替代方案一节。
要点:
- **subagent 列表不依赖 session-query**枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表
- **取值三级「算完即止」阶梯**live child 读 `sessionProjections.snapshot(session, ['subagent'])`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份通过 seq 门(`seq >= seedLength ?? 0`)即直接使用;否则执行一次完整 Session 观察,再经注册的 `subagent` unit 折叠再没有就没有——不自建缓存、无回写、无索引。
- **subagent 列表使用 Session query corpus 完成枚举与含正文 observation**mode/label `ctx.sessionProjections` 获取,列表不拥有 descriptor parser 或领域索引
- **取值三级「算完即止」阶梯**live child 读 `sessionProjections.snapshot(session, ['subagent'])`(注册表既有水位缓存,零日志读);unseeded cold child 可读 `sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])`seeded child 或 cache miss 执行一次携带 `inheritedEventCount` 的 Session observation,再经注册的 `subagent` unit 折叠再没有就没有——不自建缓存、列表侧无回写、无索引。
- **`subagent` projection unit 是折叠规则唯一权威**:live 与 cold 快照都运行同一份已注册 unit,不存在第二份描述符解释逻辑。
- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值无 unknown 降级态、无迁移。
- **描述符(v2)保持不变**。Sessionpersistence、projection cache 与 query 在 logical header 之外单独携带精确 inherited cutlisting 无法证明 cut 为零时,存量数据经一次含正文 observation 获得精确值——无 unknown 降级态,也无持久格式迁移。
与既有记录的关系:
@@ -36,8 +36,8 @@ mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠
```ts ignore-check
export type SubagentIdentityProjection =
| { mode: 'one-shot'; label?: string; seq: number }
| { mode: 'continuable'; label: string; seq: number }
| { mode: 'one-shot'; label?: string; seq: SessionSeq }
| { mode: 'continuable'; label: string; seq: SessionSeq }
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
@@ -52,19 +52,19 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样。host checkpoint 状态使用可序列化的包装 `{ identity?: SubagentIdentityProjection }`,缺席为 `{}`;客户端 view 则是非可选的 `SubagentIdentityProjection | null` 条目。`null` 完好通过 JSON,因此推送 reset 会替换旧身份,而不会被 stringify 丢掉。判定纪律:消费面把 null 与客户端 key 缺席一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。
- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强约定完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。
- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。unit 把包装状态中校验后的身份映射为客户端 wire view,并与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 为 2,在增加 `seq` 时升版。更早的 checkpoint 行按 registry 约定版本失配失效、落权威重折。
- 身份携带品牌化 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无。live Session 通过 `isOwnSeq()` 检查它;cold 含正文 observation 则与 `inheritedEventCount` 比较。仅 headerseeded candidate 会跳过 cache,因为 header 有意不暴露整数 cutunseeded candidate 知道 cut 为零。unit 把包装状态中校验后的身份映射为客户端 wire view,并与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 为 2,在增加 `seq` 时升版。更早的 checkpoint 行按 registry 约定版本失配失效、落权威重折。
- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。
### 枚举:subagent 自管 live-preferred 合并
### 枚举:query corpus 与 live preference
`listChildren`[list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts)的枚举不经任何查询服务:`ctx.sessions.list()` `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 记录整条覆盖同 id 持久化记录、不做 header 一致性校验。枚举所需全部是 header 事实:
`listChildren`[list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts)通过 `sessionQuery.listSessions()` 取得 canonical live-preferred corpus,再把每个 listed id 与可能存在的 `ctx.sessions.get(id)` 配对;同 id 存在 live Session 时使用 live header。枚举所需全部是 header 事实:
- 过滤:`header.origin === 'subagent' && header.parentSession === parentSessionId`
- `hasChildren`:同一份合并材料向下看一层——存在 `origin === 'subagent'``parentSession` 为该 child 的直接后代。
- `activity`live 记录为 `running`,仅存在于持久化的为 `inactive`
- 排序:`createdAt` 升序、再按 child id 升序(与旧约定一致)。
- **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。)
- persistence 列表失败使整次枚举失败;per-child 隔离只用于逐 child 的冷读
- `sessionQuery` 服务缺席时以 `SUBAGENT_CONTROL_QUERY_UNAVAILABLE` 失败;共享 query corpus 负责决定部署能枚举 live-only 还是持久化 Session。
- query corpus 失败使整次枚举失败;per-child 隔离只用于逐 child cold observation
### 取值:三级「算完即止」阶梯
@@ -73,20 +73,20 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
| 级 | 读法 | 成本 |
| --- | --- | --- |
| 1live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | 零日志读——注册表既有水位缓存,同步取值 |
| 2cold childcache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份满足 `identity.seq >= header.seedLength ?? 0` 才直接使用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 |
| 3cold child兜底 | `persistence.inspect(id)` 整读 + 经注册的 `subagent` unit 折叠 | 每次列表一次整读现算 |
| 2unseeded cold childcache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])`;精确 cut 为零时,每个合法 seq 都归 child 自有 | 零日志读 |
| 3seeded child 或 cold 兜底 | 一次含正文 `sessionQuery.observeSession(id)` 加已注册的 `subagent` projection,使用 `inheritedEventCount` 做 own-suffix 检查 | 每次列表一次整读现算 |
- 错误约定:`sessionProjections` 是必需注入——`SubagentRuntime` 在 inject 集里声明它,没有 registry 的部署根本无法激活服务(与 loop),`listChildren` 不可达,而不是供出降级行([mandatory-seam 记录](2026-08-19-session-projection-mandatory-seam.zh.md));响亮运行时检查与 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 随之删除。会话存储保留显式姿态:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。apiproxy 为 `PROJECTIONS_UNAVAILABLE` 设的专门 wire 脸随码删除;`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除
- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的必需注入相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决
- 错误约定:`sessionProjections`、Session store 与 `sessionQuery` 都是 listing 所需的 runtime service。三者分别以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE``SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE``SUBAGENT_CONTROL_QUERY_UNAVAILABLE` 显式失败;缺失分类或 corpus 能力不会伪装成空结果
- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的必需注入相对)。seeded header 会跳过该级,因为不读取正文就无法提供 cache identity 所需的精确 cut。对 unseeded child第二级任何抛错(包括中毒 unit 行引爆 `viewCheckpoint`都会静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 早于 descriptor、key 缺席或 null 哨兵也都会落底
- per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。
- 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth;同 id 删除后重新发布的会话对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。
- 冷路径的生命周期见证:observation 必须仍指向枚举时的那个生命周期见证字段version、id、createdAt、cwd、parentSession、isSeeded、delegationDepth、origin 与 agentPreset;同 id 删除后重新发布的 Session 对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。
- 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。
- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表付一次整读,成本与其 transcript 大小成正比;定案「算完即止」,不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.zh.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖。live child 全程零日志读。
- 冷读成本如实记录:每个 seeded child 与每次 unseeded cache miss 都会在每次列表时支付一次完整 query observation,成本与其 transcript 大小成正比;定案「算完即止」,不自建缓存。observation 可以复用 querypersistence preparation 层,但列表不依赖该优化。live child 全程零日志读。
- 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`
### 权威模型
- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revisionown descriptor 一经追加不可变——缓存身份过 seq 门后无陈旧性问题,门防的是种子回放的祖先身份)
- session log 是唯一权威;本方案不新增领域索引、自有 checkpoint进程 memo第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,列表只读。取值现算现弃;seeded candidate 用含正文 observation 按精确 cut 分类,unseeded cached identity 无需 seq 门,因为每个合法 seq 都归自身所有
- Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。
- 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。
@@ -127,25 +127,24 @@ export type SubagentListEntry =
已知边界偏差(有意接受,随本记录留档):
- 死于发布窗口的 fork childseed 里若有祖先描述符,last-wins 会给出祖先身份,误现为 child 行;恢复仍按 own-suffix 折叠权威失败(`NOT_RESUMABLE`)。旧实现靠 `seedLength` 过滤将其 omitprojection unit 看不到 header,接受此残骸级偏差(`subagentTiming` 有同类既有暴露)。
- own suffix 出现多个描述符,旧实现判 corrupt,现 last-wins 取末者(提供方约定本就保证恰一)。
- live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。
- 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。
- 未知 parent,旧实现经 session-query 抛 not-found(「parent session … was not found」);现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。
- rung 2 的更晚事件窗口cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反建档提供方「恰追加一次」约定,属损坏类数据,与多描述符偏差同族同源;②需「损坏 + 崩溃错过 checkpointturn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)」双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按「cache 永不为权威」总纲归档为接受项
- rung 2 的更晚事件窗口只适用于 unseeded childcache 行恰在首个 descriptor 后落盘,日志随后追加第二个 descriptor(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃。cold listing 可能持续供出旧身份,直到一次 live 运行或 cache write 替换该行。其前提违反 provider 的「恰追加一次」约定,并且还需错过所有 mandatory checkpoint;健康 child 不受影响。seeded child 没有 body-owned cut 时绝不进入 rung 2
消费面wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件的加载要求变化——inject 去掉 `sessionQuery`、新增必需注入 `sessionProjections`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history``session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)
消费面保持相同的 row 与 diagnostic wire 形状。`list_agents` 使用必需的 query corpus 与 projection registrylive identity 来自 registry snapshotcold identity 来自 cache 或 query observation。Host ownership 仍使用 `header.origin`history 使用共享的 livecold Session query source;没有消费方独立解析 descriptor event
### 改动落点
| 区域 | 文件 | 改动 |
| --- | --- | --- |
| subagent | projection.ts、projection-types.ts、index.ts | 新客户端可见 `subagent` unit 与注册 |
| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 删除,`sessionProjections` 转为必需注入(不再存在投影错误码);新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) |
| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin``subagents.history``session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂与 `PROJECTIONS_UNAVAILABLE` 专门 wire 脸随之退役 |
| tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 |
| subagent | list-children.ts 及类型 | query-corpus 枚举加 projection 阶梯四态映射;必需 projectionsquery service 与可选 projection-cache 加速 |
| host/apiproxy | Session controllerquery integration | owner 检查使用 `header.origin`livecold history 与 listing 消费共享 query 和 projection source |
| tool | tool-subagent-control/list-agents.ts | model-visible schema、描述与渲染保持不变 |
| wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | 类型、行形状与 diagnostic 处理**零改动**api/subagents.ts 仅 `history` 的 JSDoc 措辞改为双臂 |
| core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** |
| core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | 含正文 cut 与品牌化 seq 传递 | Logical header 暴露 `isSeeded`Session、persistence observation、cache identity 与 query record 单独携带精确 `inheritedEventCount` |
## 考虑过的替代方案
@@ -169,20 +168,20 @@ export type SubagentListEntry =
## 验证
`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本约定:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时服务根本不激活(mandatory seam——`setup` 变体断言 `ctx.get('subagents')` 保持 undefined);live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren``createdAt`→id 排序;提供方未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码(存储缺席时 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证字段逐一(`it.each`降级为 `corrupt``tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化
`packages/subagent/subagent/tests/list-children.spec.ts` 固定本约定:live identity 通过 `Session.isOwnSeq()` 检查;unseeded cold identity 可在 cut 零时使用 cacheseeded candidate 跳过该 cache rung,转而使用携带 `inheritedEventCount` 的 observation;祖先 identity 无法通过 own-suffix 检查;缺席、null、中毒与不可用的 cacheobservation 会按约定落底或产生 diagnostic;lifecycle 篡改按完整见证字段降级为 `corrupt`既有无密钥快照保持健康 wire 与 model-visible 面不变`subagent-diagnostic` 则固定诊断分类
## 后果
- live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案「算完即止」,不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。
- subagent 列表不再要求 query backend:纯 live persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`,而 `sessionProjections` 转为 `SubagentRuntime` 的必需注入——没有投影 registry 的部署根本不会激活服务(mandatory seam
- subagent 列表要求 Session query corpus 与 projection registry;服务缺失会显式失败,而不是供出不完整 row。可选 projection cache 只改变正文读取次数
- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读使用其 live、cached 或 observed wire 快照,不存在手写旁路折叠;若未来某消费面绕开该 unit 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。
- per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。
- 诊断与枚举语义留下处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败`corrupt``unavailable`、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权不受影响,显式接受
- 诊断与枚举语义留下处边界偏差(多描述符取末者、header 冲突不再被察觉、损坏源读失败改变分类、未知 parent 由 not-found 改为空列表、unseeded rung 2 更晚事件窗口)。seeded 祖先 identity 已不再构成偏差,因为含正文读取会把它与 `inheritedEventCount` 比较;恢复鉴权始终不受影响。
- pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。
## 相关
- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。
- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,取值改为共享 query corpus 上的 projection 阶梯。
- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)——registry 约定的权威;本记录为其新增 `subagent` 身份 unit,并消费其 live 与 cold wire 快照。
- [session projection 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.zh.md)——state/client 拆分;`subagent``subagentTiming` 都提供客户端 wire view。
- [session projections 作为必需接缝](2026-08-19-session-projection-mandatory-seam.zh.md)——`sessionProjections` 转为必需注入;列表的错误约定随其变化(registry 缺席是激活期失败,投影错误码删除)。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md
2026-08-08-bounded-session-persistence-write-batching.md: 20c16991b0be30ffe546a94c257bc65f86cb57eb
2026-08-08-bounded-session-persistence-write-batching.zh.md: ac0384f4e28175922f84d23296dfb13848cf5dd3
2026-08-08-bounded-session-persistence-write-batching.md: 350a4dd7a4af64156b6d68939fbc10ea8f06b7b1
2026-08-08-bounded-session-persistence-write-batching.zh.md: 495d7a7824fb2c622e37b5e233e4cb8fe45755de
@@ -6,13 +6,13 @@ English | [中文](2026-08-08-bounded-session-persistence-write-batching.zh.md)
## Problem
Streaming responses can emit many `assistant/chunk` events in a short interval. The persistence coordinator previously scheduled a provider append as soon as an idle queue received one event. Events arriving while that append was active shared a follow-up batch, but a fast provider could still produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix.
One agent step can emit several durable events in a short interval: request metadata, one Assistant settlement, tool lifecycles, plugin facts, and execution boundaries. Scheduling a provider append as soon as an idle queue receives one event can therefore produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix.
Dropping chunk events or replacing them with assembled messages would reduce logical storage, but it would also change the event log, replay, sequence numbers, timestamps, and the chunk seqs cited by assistant messages. The write-amplification problem does not require that larger semantic change.
Assistant stream embedding reduces one high-volume event family, but write cadence remains a provider-neutral lifecycle concern for every other burst and for historical generations. The batching decision does not change event semantics or storage encoding.
### Quantified baseline
Repository fixtures make the logical volume concrete. Decoding the current packed rows in [`goal-multi-turn-actions`](../../../../snapshots/web/goal-multi-turn-actions/session.jsonl) yields 2,098 events: 2,017 chunks (96.1%). Their unpacked JSONL lines occupy 332,647 of 379,225 event bytes (87.7%), while chunk packing reduces the committed file to 89,176 bytes and 182 storage rows, including 23 packed chunk rows. [`permission-policy-context`](../../../../snapshots/web/permission-policy-context/session.jsonl) yields 813 events: 746 chunks (91.8%) and 118,935 of 184,821 unpacked event bytes (64.4%); its packed file is 84,917 bytes and 123 storage rows, including 14 packed rows. These are tracked deterministic fixtures, not a production workload distribution, but they demonstrate why deleting chunks would reduce logical volume and why the existing packed-row layout already removes much of their JSON envelope cost.
Released-v1 repository fixtures established the original logical volume. Decoding the packed `goal-multi-turn-actions` generation yielded 2,098 events, including 2,017 chunks (96.1%); unpacked chunk lines occupied 332,647 of 379,225 event bytes, while the packed file used 89,176 bytes and 182 rows. The packed `permission-policy-context` generation yielded 813 events, including 746 chunks (91.8%); unpacked chunk lines occupied 118,935 of 184,821 event bytes, while the packed file used 84,917 bytes and 123 rows. These deterministic historical measurements explain why v2 embeds streams, but they are not a production workload distribution or a current-format size claim.
JSONL writes one Zstandard frame and fsync per durable append batch. Runtime files do not record former append boundaries, so fixture row counts cannot honestly be presented as fsync counts.
@@ -20,40 +20,40 @@ The scheduling bound is deterministic. With an immediately resolving sink, the f
## Decision
The JSONL provider exposes `writeBatchMaxDelayMs`, a positive integer no greater than Node's timer limit. Its default is `200`. The provider resolves the value at load and passes it to `PersistenceCoordinator`; the coordinator remains the single owner of batching behavior.
The fixed window is the JSONL provider's constant `LIVE_WRITE_BATCH_MAX_DELAY_MS` (200 ms), an internal scheduling policy rather than configuration: the backend's own session listeners route live events by id into the active write handle's buffer, so batching never crosses the package boundary ([handle note](2026-08-27-handle-based-session-persistence.md)).
Each live Session receives a package-private `SessionWriteBehind`. When its pending queue changes from empty to non-empty, the controller starts one fixed window. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, the controller hands the complete pending prefix to the existing per-id serialization and `appendBatch` path. At most one write for a Session is active. Events admitted during that write form a new pending prefix with their own fixed deadline; if that deadline expires before the active write completes, the new prefix starts immediately after it.
Each active write handle owns its buffer directly. A routed event lands in the handle's pending array, and the first event of an idle buffer arms one fixed timer. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, a single-flight drain persists the pending prefix through the handle's mutation chain, which already serializes it against explicit appends. Events admitted during a drain pass coalesce into the next chained batch, in order.
`writeBatchMaxDelayMs` bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA.
The window bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA.
`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement and backend disposal use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects.
`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement (`session/disposed`), the handle's close, and backend teardown's close sweep use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects.
Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame without changing its on-disk format.
Every admitted event remains durable in its original order and representation. The controller copies each event on admission; batching removes or rewrites no sequence, timestamp, surface metadata, embedded Assistant stream, or storage record. JSONL can therefore encode more events in one append frame without changing the Session format.
A failed background append restores its complete batch before any newer pending events, reports the failure once, and pauses automatic retry. The next newly admitted event opens a fresh fixed window; an explicit flush, retirement, or disposal retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary.
A failed background drain retains its complete batch in order ahead of newer pending events, reports the failure once, and pauses the automatic timer. The next explicit drain — a `session/flush` barrier, service-level `flush()`, or close — retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary.
This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one controller per live Session, retained failed batches, per-id serialization, retirement, and quiescent disposal. The [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) remains the owner of the backend hook boundary.
This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one buffer owner per live Session, retained failed batches, retirement, and quiescent disposal. The coordinator and the separate write-behind controller that first hosted this behavior are deleted; the buffer, timer, and drain live on the provider's handle, and the [handle-based seam](2026-08-27-handle-based-session-persistence.md) owns the storage boundary they write through.
## Alternatives considered
**Do not persist streaming chunk events.** Rejected here: it changes the event-sourced authority and recovery semantics rather than only physical write cadence. The existing [assembled-message rejection](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) remains the guardrail until a no-information-loss replacement defines replay, fork, cited source-event links, sequence, and crash behavior independently. The [packed-row decision](2026-07-26-packed-chunk-rows-by-default.md) remains the complementary JSONL storage-size optimization.
**Use one settlement per Assistant attempt instead of batching writes.** The [v2 Assistant stream decision](2026-09-01-v2-embedded-assistant-streams.md) provides that no-information-loss event model and reduces Assistant event cardinality. It does not replace bounded batching for other adjacent events, historical-generation publication, or providers with the same append interface.
**Write only at semantic checkpoints.** Rejected: it maximizes batching but makes the ordinary crash-loss window depend on a separately mounted policy. Bounded background writes preserve progress between checkpoints while mandatory flushes keep their stronger ordering contract.
**Debounce from the latest event.** Rejected: a continuously streaming response could postpone its first write indefinitely. A fixed window from the first pending event provides a real upper bound on intentional coalescing wait.
**Implement the timer inside JSONL.** Rejected: scheduling, failure retention, flush races, and teardown are provider-neutral lifecycle concerns that belong in `PersistenceCoordinator`; an out-of-tree provider can reuse the same behavior.
**A shared provider-neutral controller component.** Rejected after one iteration shipped it: the handle's mutation chain already serializes writes, so a separate controller duplicated that ordering machinery. Each provider implements the buffer on its own handle, and the shared live-write contract suite pins the equivalent observable behavior for any provider.
## Verification
The controller tests use a fake clock to prove the fixed, non-resetting 200 ms window; immediate and shared flush barriers; events admitted during a barrier; an over-budget tail behind an active write; ordered failure retention; paused automatic retry; and explicit retry of an overlapping background failure. Coordinator tests run the controller through Session notifications, retirement, collision reclamation, and teardown. The JSONL suite retains storage-format, recovery, and shared persistence-contract coverage.
The shared live-write contract suite (`runLiveWritePathContract`) uses a fake clock to prove the fixed, non-resetting 200 ms window; the `session/flush` barrier and its loud failure surfacing; ordered failure retention with exactly-once recovery; the service-level `flush()` sweep with per-session failure aggregation; and the disposed/close/teardown drains. The JSONL suite retains its storage-format, recovery, and shared persistence-contract coverage.
## Consequences
High-frequency event bursts normally produce fewer durable append operations while preserving the exact logical event count. The reduction depends on arrival rate and backend latency: a burst inside one 200 ms window becomes one batch, while mandatory flushes and sparse events can still produce small batches.
High-frequency event bursts normally produce fewer durable append operations while preserving the exact admitted event sequence. The reduction depends on arrival rate and backend latency: a burst inside one 200 ms window becomes one batch, while mandatory flushes and sparse events can still produce small batches.
This decision does not cap pending event count or bytes behind a slow provider, and it does not reduce the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule.
An admitted event can remain only in memory during the configured window, and then while scheduling or backend work is outstanding. Deployments choose a smaller value for a narrower ordinary loss window or a larger value for stronger batching. Explicit durability boundaries remain unchanged and bypass the wait.
An admitted event can remain only in memory during the fixed window, and then while scheduling or backend work is outstanding. Explicit durability boundaries remain unchanged and bypass the wait.
The deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; the provider retains only durable storage primitives. `SESSION_FORMAT_VERSION` remains unchanged.
The handle gives the timer, active drain, pending prefix, retry pause, and barrier one owner; the backend's listeners own routing and lifecycle-driven drains. Batching itself never changes `SESSION_FORMAT_VERSION`.
@@ -6,13 +6,13 @@ Status: implemented
## 问题
流式响应可能会在短时间内发出大量 `assistant/chunk` 事件。此前,只要空闲队列收到一个事件,持久化协调器就会立即调度一次 provider 追加。该追加仍在进行时到达的事件会共用一个后续批次,但如果 provider 速度很快,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀。
一个 agent 步骤可以在短时间内发出多个持久事件:请求元数据、一个 Assistant settlement、工具生命周期、插件事实与执行边界。只要空闲队列收到一个事件立即调度 provider 追加,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀。
丢弃分片事件或用组装后的消息替代它们可以减少逻辑存储量,但也会改变事件日志、回放、序列号、时间戳,以及助手消息引用的分片 seq。写放大问题不要求采取这项语义变化更大的方案
Assistant stream 嵌入会减少一个高频事件 family,但对于其他事件突发与历史 generation,写入节奏仍是 provider-neutral 生命周期问题。批处理决策不会改变事件语义或存储编码
### 量化基线
仓库 fixture(测试前置数据)让逻辑数据量有了具体依据。对当前 [`goal-multi-turn-actions`](../../../../snapshots/web/goal-multi-turn-actions/session.jsonl) 中的打包行进行解码,可得到 2,098 个事件,其中 2,017 个是分片96.1%。这些分片解包JSONL 行共 332,647 字节,占全部事件 379,225 字节的 87.7%;分片打包则把仓库中的已提交文件缩小到 89,176 字节 182 个存储行,其中包括 23 个打包分片行。[`permission-policy-context`](../../../../snapshots/web/permission-policy-context/session.jsonl) 可得到 813 个事件,其中 746 个是分片91.8%);这些分片解包JSONL 行共 118,935 字节,占全部事件 184,821 字节的 64.4%。其打包文件为 84,917 字节,共 123 个存储行,其中包括 14 个打包行。这些是纳入版本控制的确定性 fixture,不代表生产工作负载分布;但它们说明了删除分片为何会降低逻辑数据量,也说明现有打包行布局已经消除了大量 JSON 包装开销
已发布 v1 仓库 fixture 建立了原始逻辑数据量。解码 packed `goal-multi-turn-actions` generation 得到 2,098 个事件,其中 2,017 个是 chunk96.1%解包的 chunk 行占 379,225 个事件字节332,647 字节,而 packed 文件使用 89,176 字节 182 行。packed `permission-policy-context` generation 得到 813 个事件,其中 746 个是 chunk91.8%);解包的 chunk 行占 184,821 个事件字节中的 118,935 字节,而 packed 文件使用 84,917 字节 123 行。这些确定性历史测量解释了 v2 为何嵌入 stream,但不代表生产工作负载分布或当前格式大小
JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fsync。运行时文件不记录原有追加边界,因此不能把 fixture 的存储行数当作 fsync 次数。
@@ -20,40 +20,40 @@ JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fs
## 决策
JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 Node 计时器上限的正整数,默认值为 `200`。provider 在加载时解析该值,再传给 `PersistenceCoordinator`;批处理行为仍只由协调器负责
固定窗口是 JSONL provider 的常量 `LIVE_WRITE_BATCH_MAX_DELAY_MS`(200 ms),它是内部调度策略而非配置:后端自己的会话监听器按 id 把实时事件路由进活跃写句柄的缓冲,因此批处理绝不跨越包边界([句柄 Note](2026-08-27-handle-based-session-persistence.zh.md)
每个活跃的会话都有一个包私有 `SessionWriteBehind`。当其待处理队列从空变为非空时,控制器会启动一个固定窗口。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,控制器会把完整的待处理前缀交给现有的按 id 串行化机制,并沿 `appendBatch` 路径写入。同一会话同时最多有一个活跃写入。该写入期间接纳的事件会形成新的待处理前缀,并拥有自己的固定截止时间;如果该截止时间在活跃写入完成前到期,新前缀会在前一次写入完成后立即开始写入
每个活跃写句柄直接拥有自己的缓冲。被路由的事件落入句柄的待处理数组,空闲缓冲收到的第一个事件会启动一个固定计时器。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,一次 single-flight 排空会把待处理前缀经由句柄的修改链持久化,该链本就将其与显式 append 串行化。排空进行期间接纳的事件会按顺序合并进下一个链上的批次
`writeBatchMaxDelayMs` 只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。
该窗口只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。
`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役与后端 dispose(资源释放)共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。
`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役`session/disposed`)、句柄的 close 与后端 teardown 的关闭清扫共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。
每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk``seq``time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加中编码更多事件,而无需改变其磁盘格式。
每个已接纳事件仍会按原有顺序和表示持久化。控制器会在接纳时复制每个事件;批处理不会删除或改写任何序号、时间戳、surface 元数据、嵌入式 Assistant stream 或存储记录。因此,JSONL 可以在一个追加 frame 中编码更多事件,而无需改变 Session 格式。
后台追加失败后,控制器会把完整批次恢复到所有较新的待处理事件之前,报告一次该失败,并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口;显式 flush、退役或 dispose 会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。
后台排空失败后,其完整批次会按顺序保留在所有较新的待处理事件之前,该失败被报告一次,自动计时器随之暂停。下一次显式排空——`session/flush` 屏障、服务级 `flush()` 或 close——会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。
本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)中的即时调度节奏。对于每个活跃会话使用一个控制器、保留失败批次、按 id 串行化、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。后端钩子边界由[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.zh.md)定义。
本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)中的即时调度节奏。对于每个活跃会话使用一个缓冲所有者、保留失败批次、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。最初承载该行为的协调器与独立的 write-behind 控制器均已删除;缓冲、计时器和排空落在 provider 的句柄上,它们写入所经过的存储边界由[基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md) 定义。
## 备选方案
**不持久化流式分片事件。** 这里不采纳:这会改变事件溯源的权威地位及恢复语义,而不只是改变物理写入节奏。在无信息损失的替代方案独立定义回放、fork、引用源事件的关联、序列和崩溃行为之前,现有的[拒绝仅保留组装消息的决策](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md)仍是防护规则。[打包行决策](2026-07-26-packed-chunk-rows-by-default.zh.md)仍是配套的 JSONL 存储体积优化
**使用每个 Assistant attempt 一个 settlement 代替批处理写入。** [v2 Assistant stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)提供该无信息损失事件模型,并减少 Assistant 事件基数。它不能替代其他相邻事件、历史 generation 发布或使用同一 append 接口的 provider 所需的有界批处理
**仅在语义检查点写入。** 不采纳:此方案会最大化批处理,却让普通的崩溃丢失窗口取决于另行挂载的策略。有界后台写入会在检查点之间持久化进度,而强制 flush 继续提供更强的顺序约定。
**按最新事件重置防抖窗口。** 不采纳:持续不断的流式响应可能无限期推迟首次写入。由第一个待处理事件启动的固定窗口,为主动合并等待提供了真正的上界。
**在 JSONL 内实现计时器。** 不采纳:调度、失败保留、flush 竞态和 teardown 都是 provider 无关的生命周期问题,属于 `PersistenceCoordinator`;仓库外 provider 可以复用同一行为。
**共享的 provider 无关控制器组件。** 曾在一次迭代中交付,随后不采纳:句柄的修改链本就串行化写入,独立控制器重复了这套排序机制。每个 provider 在自己的句柄上实现该缓冲,共享的实时写入约定测试套件为任何 provider 钉住等价的可观察行为。
## 验证
控制器测试使用假时钟证明固定且不会重置的 200 ms 窗口、即时且可共享的 flush 屏障、屏障运行期间接纳的事件、在活跃写入之后已超过窗口时限的尾部批次、有序保留失败批次、暂停自动重试,以及对重叠发生的后台失败进行显式重试。协调器测试会在会话通知、退役、冲突回收和 teardown 路径中验证该控制器。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。
共享的实时写入约定测试套件(`runLiveWritePathContract`使用假时钟证明固定且不会重置的 200 ms 窗口、`session/flush` 屏障及其失败的响亮暴露、有序保留失败批次并恰好恢复一次、带逐会话失败聚合的服务级 `flush()` 清扫,以及 disposed/close/teardown 的排空。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。
## 后果
高频事件突发通常会减少持久化追加操作,同时保持逻辑事件数量完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。
高频事件突发通常会减少持久化追加操作,同时保持已接纳事件序列完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。
本决策不会限制因 provider 缓慢而积压的待处理事件数量或字节数,也不会减少解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放约定,而不是再引入一条隐式计时器规则。
接纳后的事件在配置窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口,也可以选择较大的值以加强批处理。显式持久性边界保持不变,并会绕过等待。
接纳后的事件在固定窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。显式持久性边界保持不变,并会绕过等待。
deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障`PersistenceCoordinator` 继续负责初始化和按标识串行化;provider 仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 保持不变
句柄统一负责计时器、活跃排空、待处理前缀、重试暂停和屏障;后端的监听器负责路由和生命周期驱动的排空。批处理本身绝不改变 `SESSION_FORMAT_VERSION`
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
2026-08-09-client-conversation-node-assembly.md: 4831c2261791749804b6d0bd555423b7d4894520
2026-08-09-client-conversation-node-assembly.zh.md: 37463d0543bbabc5d236f662827b932b55bbb11d
2026-08-09-client-conversation-node-assembly.md: 425975944f7b23575c4f716dee8754dc6b3ab54c
2026-08-09-client-conversation-node-assembly.zh.md: 96439fa951cdc1d6abe397ee37661291c0f954a7
@@ -51,7 +51,7 @@ Each `(kind, id)` has at most one start Match. A second start fails immediately;
#### `match(event)`
`match(event)` reads only the current `SessionEventLike` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope. A `chunkrow/*` event can only be an update; the Assembler rejects it as a start, and `start()` receives a `ConversationStartMatch` containing a standard `SessionEvent`.
`match(event)` reads only the current `SessionEventLike` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope. A Client-only `assistant/live-chunk` event can only be an update; the Assembler rejects every transient start, and `start()` receives a `ConversationStartMatch` containing a durable `SessionEvent`.
This restriction makes one scalar event or packed run's routing cost depend only on the number of registered Definitions. The Assembler never scans a Definition's historical Contexts to decide which one owns an update.
@@ -110,7 +110,7 @@ Dependencies point strictly from earlier starts to later starts, so transitive r
#### `update(context, match)`
`update()` handles a post-start scalar or packed Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the input. A Definition that consumes Assistant deltas folds each matching `chunkrow/*` value as one batch without constructing member events.
`update()` handles a post-start durable or transient Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the input. An Assistant Definition folds each `assistant/live-chunk` update directly and expands an embedded `assistant/message` or `assistant/attempt` stream during history replay.
The Assembler invokes `update()` in ascending `seq` order. A live tail update can apply incrementally; any non-tail insertion, newly loaded start, or invalidated dependency causes a complete replay from `start()`.
@@ -125,16 +125,16 @@ The Assembler does not use State reference equality to decide publication or pro
| Return value | Behavior |
|---|---|
| `immediate` | Request a notification and flush in the current microtask |
| `animation-frame` | Coalesce high-frequency updates into materialization on the next frame |
| `animation-frame` | Coalesce high-frequency updates into materialization after three browser animation frames |
| `none` | Do not schedule a flush for this Match; retain its State and dirty marker |
Omitting `publication()` means `immediate`. Assistant token deltas and packed runs use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path.
Every live delta within a frame still executes `update()`, while one historical packed run executes one batch `update()`. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost.
Every live delta during the three-frame interval still executes `update()`, while one historical packed run executes one batch `update()`. Location-data publication, `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost. An immediate publication cancels a pending frame interval and flushes the latest State without delay.
#### `buildLocationData(context, scope)`
`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready.
`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler passes the preceding publication back to its owner, which returns that exact value when its business data is unchanged. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready.
A Definition receives the `step` and `turn` scopes separately and may return one value or `null` in either phase. A value must identify the exact turn/step coordinates and use the Definition's `kind` as its key. The Assembler owns replacement and removal and rejects another Context that claims the same Location key.
@@ -262,7 +262,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Append message IDs to persistent splice state; materialize once per claim and expose the shared current claimed batch to Message |
| Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering |
| Request Prompt / `request-prompt` | Header Event seq | Each `request/header` | None | Read the preceding Request Prompt through Reader, retain the full prompt state, and classify system/tool changes |
| Assistant / `assistant-step` | `turn:step` | `step/start` | Scalar or packed `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
| Assistant / `assistant-step` | `turn:step` | `step/start` | Live `assistant/live-chunk`, durable `assistant/message` or `assistant/attempt`, and same-step Retry | Aggregate blocks, usage, first-token time, settlement evidence, and retry-hidden state, then publish same-key Step data |
| Tool / `tool-call` | Root call ID | Root `tool/call` | Root result and Code Dispatch start/result | Aggregate the root, children, and parent Map; Dispatch Events route exactly through `rootCallId` |
| Command / `command` | Command ID | `command/run` | `command/done` and compact lifecycle/checkpoint Events carrying a source command ID | Aggregate command outcome and manual-compaction evidence |
| Automatic Compaction / `compaction` | Compaction ID | `compaction/start` without a source command ID | Summary, end, and replacement checkpoint | Aggregate summary/checkpoint; sufficient checkpoint evidence supports fallback without a start |
@@ -315,21 +315,21 @@ The shell synchronously resolves the persisted selection when a Session binding
Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it.
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store with identity-stable Node and Turn-process sources, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value.
Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces that key's value and publishes only its source. The Turn-process projector recalculates cross-Node presentation only for a Turn whose structure, specification, or status changed, then publishes only that Turn's process sources.
For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys.
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`.
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order` and resolves the two stable sources for each key. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key, subscribes only to its Node and Turn-process sources, and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`.
[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins live in `ui-chat`, and this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher.
The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes only the stable Node key as `hookContext`; the Slot renderer combines that key with `useSession` from the official standard props to construct `useTurnData(businessKey)`. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority.
The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes the Node's stable Turn data store as `hookContext`; the Slot renderer binds `useTurnData(businessKey)` directly to that store. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority.
Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent paths. The latter continues to bind only registration-owned Observables. The former caches definitions by stable slot-inject-face identity and binds its factory and Hook per stable render occurrence. The selector inside `useTurnData()` returns only the current Node's `turn.data.get(key)`, so selector equality filters unrelated Session publications.
Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent paths. The latter continues to bind only registration-owned Observables. The former caches definitions by stable slot-inject-face identity and binds its factory and Hook per stable render occurrence. `useTurnData()` subscribes to `turn.data.source(key)`, so another Location-data key or Session snapshot publication does not notify it.
The standard `useSession` remains available to every session-scoped slot renderer. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data.
The standard `useSession` remains available to every session-scoped slot renderer, although `ChatNodeSeat` needs neither it nor aggregate `useChat`. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data.
Assistant streaming to final and Tool running to settled stay in one Seat while updating its data and necessary ordering properties. Settlement therefore does not reset component-local State through a parent move.
@@ -382,7 +382,7 @@ History-path tests cover complete replace, non-overlapping prepend, complete-ran
**Define a reverse State fold for backward history scanning.** Rejected: every business would maintain two inverse algorithms, and deletion, non-invertible aggregation, and cross-Context dependencies would be difficult to keep equivalent. Ordered Matches followed by forward replay from start preserve one business meaning.
**Add a separate chunk-run matcher and update lifecycle.** Rejected: a second Definition path would duplicate dispatch, replay, publication, and Context types. `ChunkRowEvent` uses the existing `match(event)` and `update(context, match)` lifecycle while making packed handling explicit through its `chunkrow/*` discriminant.
**Add a separate live-stream matcher and update lifecycle.** Rejected: a second Definition path would duplicate dispatch, replay, publication, and Context types. Client-only `assistant/live-chunk` and durable settlements use the existing `match(event)` and `update(context, match)` lifecycle; only the event discriminator and stream expansion differ.
**Make Inbox a first-class engine concept or one window-wide Context.** Rejected: Inbox is ordinary business State and does not belong in the generic engine. Per-splice instantaneous State plus a strictly backward Reader supports prepend, append, and Message lookup together.
@@ -390,6 +390,8 @@ History-path tests cover complete replace, non-overlapping prepend, complete-ran
**Let a Location-data consumer read the provider's Context State directly.** Rejected: the consumer would depend on another business's mutable internal shape and could not express which Turn/Step owns the value. Declaration-merged data maps expose only the provider-selected read-only value and engine-owned coordinates.
**Cache every Definition's Location data by State identity.** Rejected because a Definition may mutate and return the same State object, and its Location data may also depend on Match Locations or values published by another Definition. Each Definition instead decides whether its business value changed and returns the preceding publication unchanged when it did not.
**Add generic `end()`, prepared, or window-reset lifecycles.** Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation.
**Reuse one Event Definition across Chat and Trajectory by branching in `buildViewNode(target)`.** Rejected: the views require different business State and intermediate records, so a shared Definition would make each package carry the other's conditions and payloads. Separate target-owned Definitions keep those choices local while sharing the Assembler's ingestion and lifecycle contracts.
@@ -412,11 +414,11 @@ Initial tail, older prepend, and live append share one set of Context invariants
Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity.
Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State.
Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per three animation frames. The Assistant view reads the same projection that the preceding Step Location phase installed. Turn Process returns its existing open data and Node for continuing Assistant chunks without deriving or encoding them again, and Turn Tail defers its complete-Match scan until `turn/end`. Step or Turn close and final Events immediately publish the latest State.
An inactive target retains Definition State and a target Context index but no builder, materialized Nodes, or snapshot. The mounted built-in or third-party View activates its own target through normal subscription; previously opened targets continue receiving incremental updates.
Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates.
Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn, and keyed Location sources isolate unrelated updates.
Inbox Context retention grows with splice count and claimed message count rather than their cumulative prefixes. This removes duplicate state growth but does not deduplicate message content in durable Session events or bound the loaded event window.
@@ -51,7 +51,7 @@ Assembler 使用 `conversationContextKey(kind, id)` 组合无碰撞 key;不同
#### `match(event)`
`match(event)` 只读取当前 `SessionEventLike`,返回 `{ id, role: 'start' | 'update' }``null`。它拿不到 Context、历史、Reader、Location 或 view envelope。`chunkrow/*` event 只能作为 updateAssembler 会拒绝 packed start`start()` 接收的 `ConversationStartMatch` 只包含标准 `SessionEvent`
`match(event)` 只读取当前 `SessionEventLike`,返回 `{ id, role: 'start' | 'update' }``null`。它拿不到 Context、history、Reader、Location 或 view envelope。Client-only `assistant/live-chunk` event 只能作为 updateAssembler 会拒绝每个 transient start`start()` 接收的 `ConversationStartMatch` 只包含持久 `SessionEvent`
这项限制使单条 scalar event 或 packed run 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。
@@ -110,7 +110,7 @@ Reader 每次查询都记录 `{ key, revision, windowGap }` 依赖。命中前
#### `update(context, match)`
`update()` 只处理已`match()` 精确路由到当前 `(kind, id)` 的 post-start scalar 或 packed Match。它不判断 input 属于哪个 Context。消费 Assistant delta 的 Definition 会把每个匹配的 `chunkrow/*` 值作为一个 batch fold,而不构造成员 event
`update()` 只处理已由 `match()` 精确路由到当前 `(kind, id)` 的 post-start durable 或 transient Match。它不判断 input 属于哪个 Context。Assistant Definition 会直接 fold 每个 `assistant/live-chunk` update,并在 history replay 期间展开嵌入式 `assistant/message``assistant/attempt` stream
Assembler 按 `seq` 升序调用 `update()`。实时尾部 update 可以直接增量应用;任何非尾部证据插入、start 补齐或依赖失效都会从 `start()` 完整 replay。
@@ -125,16 +125,16 @@ Assembler 不以 State 引用相等判断是否需要发布或传播。每次成
| 返回值 | 行为 |
|---|---|
| `immediate` | 请求当前 microtask 通知与 flush |
| `animation-frame` | 把多条高频更新合并到下一帧 materialize |
| `animation-frame` | 跨过三个浏览器 animation frame 后,把多条高频更新合并为一次 materialization |
| `none` | 本 Match 不主动安排 flushState 和 dirty 标记仍被保留 |
省略 `publication()` 等于 `immediate`。Assistant token delta 与 packed run 使用 `animation-frame`,不可见 Inbox Context 使用 `none`final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。
一帧内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`合并的只是 `buildViewNode()`、View Builder React snapshot 通知,不会丢失 fragment。
三帧间隔内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`Location-data publication、`buildViewNode()`、View Builder React snapshot 通知会合并执行,不会丢失 fragment。immediate publication 会取消等待中的帧间隔,并立即发布最新 State。
#### `buildLocationData(context, scope)`
`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`
`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 会把前一次 publication 传回它的 owner;业务数据未变时,owner 原样返回该值。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`
Definition 分别收到 `step``turn` scope,可以在任一阶段返回一个值或 `null`。返回值必须声明准确的 turn/step 坐标,并使用与 Definition `kind` 相同的 key;Assembler 拥有替换和移除,并拒绝另一个 Context 占用同一 Location key。
@@ -262,7 +262,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 把消息 ID 追加到持久 splice state;每次 claim 只 materialize 一次,并向 Message 暴露共享的当前 claimed batch |
| Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering |
| Request Prompt / `request-prompt` | header Event seq | 每条 `request/header` | 无 | 通过 Reader 读取前一条 Request Prompt,保留完整 prompt 状态,并判定 system/tool 变化 |
| Assistant / `assistant-step` | `turn:step` | `step/start` | scalar 或 packed `assistant/chunk`final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
| Assistant / `assistant-step` | `turn:step` | `step/start` | Live `assistant/live-chunk`持久 `assistant/message``assistant/attempt`、同 step Retry | 聚合 block、usage、首 token 时间、settlement 证据与 retry-hidden state,再发布同 key Step data |
| Tool / `tool-call` | root call ID | root `tool/call` | root result、Code Dispatch start/result | 聚合 root、children 和 parent MapDispatch Event 用 `rootCallId` 精确路由 |
| Command / `command` | command ID | `command/run` | `command/done`、带 source command ID 的 compact lifecycle/checkpoint | 聚合 command outcome 和手动压缩证据 |
| Automatic Compaction / `compaction` | compaction ID | 无 source command ID 的 `compaction/start` | summary、end、replacement checkpoint | 聚合 summary/checkpointcheckpoint 足够时可在缺 start 下 fallback |
@@ -315,21 +315,21 @@ Session binding 可用、缓存的 binding 成为 current 或 View roster 变化
普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`带身份稳定 Node 与 Turn-process source 的 keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`keyed Node store 只替换该 key 的 value。
Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`keyed Node store 只替换该 key 的 value 并发布其 source。Turn-process projector 仅为结构、规格或状态发生变化的 Turn 重算跨 Node 呈现,再只发布该 Turn 的 process source
Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`,并为每个 key 解析两份稳定 source。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,只订阅自身的 Node 与 Turn-process source并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。
[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer`registerConversationNodes()``registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现位于 `ui-chat`,且该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。
`conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected``ChatNodeSeat` 只把稳定 Node key 作为 `hookContext` 传给 slotSlot renderer 用官方 standard props 中的 `useSession` 和该 key 构造 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 dataAssistant renderer 不拥有特殊注入权限。
`conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected``ChatNodeSeat` Node 所属 Turn 的稳定 data store 作为 `hookContext` 传给 slotSlot renderer 直接在该 store 上绑定 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 dataAssistant renderer 不拥有特殊注入权限。
Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径。后者继续只绑定 registration-owned Observable;前者按稳定 slot inject face 缓存定义,并按稳定 render occurrence 绑定 factory 和 Hook。`useTurnData()` 内部 selector 只返回当前 Node 的 `turn.data.get(key)`无关 Session publication 会被 selector equality 截断
Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径。后者继续只绑定 registration-owned Observable;前者按稳定 slot inject face 缓存定义,并按稳定 render occurrence 绑定 factory 和 Hook。`useTurnData()` 订阅 `turn.data.source(key)`其他 Location-data key 或 Session snapshot 的发布不会通知它
标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,`useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。
标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,`ChatNodeSeat` 不再需要它或聚合 `useChat``useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。
Assistant streaming 到 final、Tool running 到 settled 始终留在同一个 Seat,只更新 data 和必要的排序属性。结算不会因跨 parent 移动而重置组件内部 State。
@@ -382,7 +382,7 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
**为历史反扫定义逆向 State fold。** 拒绝:每个业务都要维护互为逆运算的两套逻辑,删除、非可逆聚合和跨 Context 依赖很难保持一致。统一 Matches 后从 start 正序 replay 只有一套业务语义。
**增加独立的 chunk-run matcher 与 update lifecycle。** 拒绝:第二条 Definition 路径会重复 dispatch、replay、publication 与 Context 类型。`ChunkRowEvent` 使用既有 `match(event)` `update(context, match)` lifecycle,并通过 `chunkrow/*` discriminator 明确标记 packed 处理
**增加独立 live-stream matcher 与 update lifecycle。** 拒绝:第二条 Definition path 会重复 dispatch、replay、publication 与 Context type。Client-only `assistant/live-chunk` 与持久 settlement 使用既有 `match(event)` `update(context, match)` lifecycle;只有 event discriminator 与 stream expansion 不同
**把 Inbox 做成引擎一级公民或一个窗口级 Context。** 拒绝:Inbox 是普通业务状态,不应污染通用引擎;逐 splice 瞬间态加严格前序 Reader 同时支持 prepend、append 和 Message 查询。
@@ -390,6 +390,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
**让 Location data 消费者直接读取提供方 Context State。** 拒绝:消费者会依赖另一个业务的可变内部形状,也无法表达值属于哪个 Turn/Step。declaration-merged data map 只公开提供方选择发布的只读值和 Engine-owned 坐标。
**按 State identity 缓存每个 Definition 的 Location data。** 拒绝:Definition 可以原地修改并返回同一个 State 对象,其 Location data 也可能依赖 Match Location 或其他 Definition 发布的 value。各 Definition 改为自行判断业务值是否变化;未变化时原样返回前一次 publication。
**增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 StateLocation close 触发 replay/buildReader dependency 负责补页失效。
**在同一个 Event Definition 内通过 `buildViewNode(target)` 为 Chat 与 Trajectory 分支。** 拒绝:两种视图需要不同的业务 State 与中间记录,共用 Definition 会迫使每个 package 携带另一边的条件与 payload。target 自有的 Definition 把这些选择留在本地,同时复用 Assembler 的摄入与生命周期约定。
@@ -412,11 +414,11 @@ Host 业务 package 把自己的持久 Event 成员 declaration-merge 到 `@deep
Append 不扫描历史 Contextprepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。
State 更新与发布频率分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每 animation frame 最多 materialize 一次。step/turn close final 立即发布最新 State。
State update 与 publication cadence 分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每三个 animation frame 最多 materialize 一次。Assistant view 读取前置 Step Location 阶段刚写入的同一 projection。Turn Process 对持续 Assistant chunk 直接返回已有 open data 和 Node,不再重复派生或编码;Turn Tail 到 `turn/end` 才执行完整 Match 扫描。Step/Turn close final Event 会立即发布最新 State。
inactive target 会保留 Definition State 和 target Context 索引,但不保留 builder、已物化 Node 或 snapshot。已挂载的内建或第三方 View 通过正常订阅激活自己的 target;已经打开的 target 则继续接收增量更新。
Step/Turn 是业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。
Step/Turn 是业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 keyed Location source 隔离无关更新。
Inbox Context 的保留量随 splice 数和已 claim 消息数增长,不再随其累计前缀增长。该结构消除了重复 state 增长,但不会对持久 Session event 中的消息正文去重,也不会限制已加载 event window。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
2026-08-10-cancelled-stream-prefix-finalize.md: fd397a02663908f5984b4e1798d1b1759b140c79
2026-08-10-cancelled-stream-prefix-finalize.zh.md: 44adb2ff4163cd1904a9a93895c99519bae2f234
2026-08-10-cancelled-stream-prefix-finalize.md: 1e6fe59bcc1b323941b69d0e9232f0bcf9e56f53
2026-08-10-cancelled-stream-prefix-finalize.zh.md: 7aa81f179b80925ef47a8820f8a80c1ef37ec5ea
@@ -6,23 +6,23 @@ English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md)
## Problem
A cancelled stream can leave `assistant/chunk` events that clients continue rendering while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap.
A cancelled stream can leave transient chunks that clients have rendered while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap.
The model history must contain assistant content that remains visible to the user after cancellation.
## Decision
`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`.
`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, compact stream accumulator, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and the exact embedded timed stream. The append precedes the committed `agent/assistant-stream` end frame, `step/end`, and the aborted `turn/end`.
`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request.
`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends `assistant/attempt` instead of a surface message. Provider `error` and `aborted` finishes also commit `assistant/attempt` before `agent/request-error`, so their streams remain durable without contributing failed-request content to model history.
Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results.
Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provider information. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results.
## Alternatives considered
**Always discard the prefix.** This avoids a new durable marker but makes every cancel-then-follow-up and fork omit assistant content that remains visible to the user.
**Assemble the prefix from chunks during projection.** `deriveMessages()` and client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative assistant message for the prefix. This also expands model history beyond the three `SurfaceEventType` events.
**Assemble the prefix from the embedded attempt during projection.** `deriveMessages()` and Client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative surface message for the prefix. This also expands model history beyond the three `SurfaceEventType` events.
**Retain complete tool calls with synthetic aborted results.** These calls never dispatched, so synthetic results would claim an execution outcome that did not occur and add content the user did not receive as a tool result.
@@ -32,8 +32,8 @@ Chat and Trajectory Conversation Definitions read `interrupted` from the durable
Post-cancel follow-ups and forks include the delivered prefix. The ACP bridge drains ordered assistant output before settling the prompt, so the final `agent_message_chunk` update precedes the cancelled stop reason.
Terminal provider errors still discard their streamed prefix. That asymmetry remains because an error turn ends without the user's cancellation decision and requires its own retention policy.
Terminal provider errors retain their stream in `assistant/attempt` but keep its content out of model history. Only the user's cancellation decision turns visible delivered text into an interrupted surface message.
## Testing
`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications.
`packages/core/agent-loop/tests/cancel.spec.ts` covers content, embedded streams, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix attempt. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both Client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications.
@@ -6,23 +6,23 @@ Status: implemented
## Problem
被取消的流可能留下客户端继续渲染的 `assistant/chunk` 事件,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。
被取消的流可能留下 Client 已经渲染的瞬态 chunk,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。
模型历史必须包含取消后仍对用户可见的 assistant 内容。
## Decision
`ReactLoopAgent.step()` 在消费模型期间捕捉取消,此时 `BlockAssembler`已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true``surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`
`ReactLoopAgent.step()` 在消费模型 stream 期间捕捉取消,此时 `BlockAssembler`紧凑 stream accumulator 与 provider route 可以确定已送达前缀。loop 把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true``surfaceOp: 'append'` 与精确嵌入式带时间 stream。该追加先于 committed `agent/assistant-stream` end frame、`step/end` 和记录 aborted 的 `turn/end`
`BlockAssembler.interruptedBlocks()`顺序返回内容非空白的已闭合和未闭合 `text``reasoning` 。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时追加 assistant 消息。提供方的 `error` `aborted` finish 会在 `agent/request-error`离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求内容。
`BlockAssembler.interruptedBlocks()` stream 顺序返回内容非空白的已闭合和未闭合 `text``reasoning` block。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空 block 和未闭合的未知 block 类型。返回结果为空时追加 `assistant/attempt`,而不是 surface message。Provider `error` `aborted` finish 会在 `agent/request-error`提交 `assistant/attempt`,因此其 stream 保持持久,但失败请求内容不会进入模型历史
Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。
Chat 和 Trajectory Conversation Definition 从持久 message 读取 `interrupted`。Chat 渲染 Stopped markerTrajectory 则在 `step/end` 后把 provider request 保持在 error 生命周期,并保留持久 result seq 与 provider 信息。工具执行期间的取消遵循工具调度器约定,因为 assistant message 已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。
## Alternatives considered
**始终丢弃前缀。** 这能避免新增持久标记,但每次取消后的追问和分支都会缺少仍对用户可见的 assistant 内容。
**在投影时从分片组装前缀。** `deriveMessages()` 和客户端 Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 assistant 消息。这还会让模型历史超出三类 `SurfaceEventType` 事件。
**在投影时从嵌入式 attempt 组装前缀。** `deriveMessages()` 与 Client Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 surface message。这还会让模型历史超出三类 `SurfaceEventType` 事件。
**保留完整工具调用并合成 aborted 结果。** 这些调用从未分派,合成结果会声称一个并未发生的执行结果,还会增加用户未收到的工具结果内容。
@@ -32,8 +32,8 @@ Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`
取消后的追问和分支会包含已送达前缀。ACP 桥会在结算 prompt 前排空按序传送的 assistant 输出,因此最后一条 `agent_message_chunk` 更新先于 cancelled stop reason。
终局提供方错误仍会丢弃已流出前缀。该不对称保留,因为 error 轮次的结束不来自用户的取消决定,需要独立的保留策略
终局 provider error 会在 `assistant/attempt` 中保留其 stream,但不让内容进入模型历史。只有用户的取消决策会把可见的已送达文本变成 interrupted surface message
## Testing
`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()``packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。
`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖 content、嵌入式 stream、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀 attempt`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()``packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种 Client 投影。keyless `cancel` ACP snapshot 与 `goal-round-driver` goal snapshot 覆盖组装应用。

Some files were not shown because too many files have changed in this diff Show More