The array-based FrameFields<T> only checked that listed names were members of
the frame's keys, so a field added to a TS interface (e.g. LogMessage.seq?)
left the existing arrays a valid subset — typecheck passed, and since the
constant and Python both lacked the field the mirror test passed too. The
JSDoc's claim that runtime covered this was false.
Replace it with WIRE_FRAME_FIELD_ROLES, a per-frame map keyed by field name
(`Record<RequiredKeys<T>, 'required'> & Record<OptionalKeys<T>, 'optional'>`),
so every interface key MUST appear with a matching required/optional tag: an
added field, a removed field, a rename, or an optionality flip all fail
typecheck at the roles map (verified). WIRE_FRAME_FIELDS is projected from it
as the sorted arrays the mirror test still compares to the Python TypedDicts.
Also drop the `export` added to the promoted frame interfaces (Namespace,
ErrorClass, RunMessage, DoneErrorField, ReplyOk, ReplyErr) — nothing outside
protocol.ts imports them, so the barrel surface is unchanged and knip stays
clean.
The previous mirror binding (FrameFields<keyof T>) only checked membership: it
could not see a TS-side optionality flip (truncated? -> truncated leaves keyof
unchanged) or a field added on one side, so the "depends on the TS
declaration" claim was overstated.
- Promote the inline frame shapes (Namespace, ErrorClass, DoneErrorField,
RunMessage, and the two Reply variants) to named interfaces so every frame
binds uniformly.
- Derive FrameFields from RequiredKeys<T>/OptionalKeys<T>, so `required` and
`optional` each accept only that side's keys. An optionality flip or a rename
now fails typecheck (verified: flipping LogMessage.truncated to required
errors at the constant).
- Enumerate EVERY public TypedDict in py/protocol.py in the mirror e2e (not a
name list taken from the TS side) and assert both the frame roster and each
frame's required/optional sets by exact equality, so a frame or field present
on only one side of the wire fails the test.
Two gaps from the previous round's fixes:
- checkDoneValue flagged a non-lossless number but skipped counting its encoded
bytes, so a value over budget ONLY through that number classified as
non-lossless instead of over-budget (e.g. [Infinity] at cap 3, whose encoding
is 10 bytes). Count the scalar's bytes even when flagging, so the budget check
wins as the JSDoc promises. Add cap-3 regression cases.
- The mirror e2e compared the Python TypedDict keys against a hand-written
constant, so a field change on the TS side alone would not fail it, and the
reply frames were not probed at all. Introduce WIRE_FRAME_FIELDS in
protocol.ts, bound to each frame interface's key set via `satisfies` (a
renamed/removed field breaks typecheck — verified), and drive the mirror test
from it, now covering ReplyOk/ReplyErr too. The test therefore fails on
one-sided drift from either language.
Address the two standing review suggestions in this layer rather than deferring
them to PR #4:
- Extend tests/protocol-mirror.e2e.ts to read each py/protocol.py TypedDict's
required/optional key set and assert it against the wire field names
src/protocol.ts declares (global included, via functional TypedDict). The
round-12 class of drift — a renamed/dropped field, or one side making a field
optional the other requires — now fails a test instead of relying on review.
Field types remain review-guarded (no mechanical TS/Python equivalent).
- Drop the forward references to PR #4's internal mechanisms from this layer's
prose: the "256 MiB frame ceiling" figure and the "(index.ts)" fd-3 pinning
citation become an abstract "host-side inbound frame-size cap" so the JSDoc,
spec, README, and Agent Note describe only what this layer owns.
Update both README sides and the Agent Note (both languages) to state the
mirror is now executable, and re-record their i18n pairings.
checkDoneValue returned non-lossless the instant it hit a non-finite/negative-
zero number, before finishing the budget metering. A value that is BOTH over-
budget and non-lossless then classified by member order: `["<huge>", 1e400]`
gave non-lossless while `[1e400, "<huge>"]` gave over-budget — the same value,
two verdicts — which would drive the consumer to emit invalid-output vs
output-limit non-deterministically, contradicting the JSDoc promise that an
over-budget value is rejected as over-budget regardless. Record the number
violation in a flag and let metering finish; return non-lossless only once the
whole value is confirmed within budget. Add a regression test asserting both
member orders classify as over-budget.
Both README sides still described checkDoneValue as "one bounded traversal /
一次有界遍历" — the same overclaim already retracted in the code JSDoc and the
Agent Note. Reword both to match: the walk bounds only the incremental
allocation it adds (escaped-string copy, enqueued children, per-key stringify);
the frame's own width is parsed upstream and capped by the host's fd-3 receive
buffer, not re-bounded here. Re-record README.i18n.yaml.
- Replace four raw U+0000 bytes in protocol.spec.ts string literals with the
\0 escape so the source stays plain text (a bare NUL makes text tools treat
the file as binary); the runtime value is unchanged, so the bytes:8 NUL-escape
assertion still holds.
- Sync the Agent Note (both languages) with the corrected checkDoneValue
contract: the walk bounds only the incremental allocation it would add, not
the frame width, which is already parsed and capped upstream by the host's
fd-3 receive buffer. Drop the "prevents a hundreds-of-MB allocation" overclaim
that the code JSDoc already retracted. Re-record the note i18n pairing.
ownValues' JSDoc claimed the generator avoids a "second full-breadth
allocation before a single value is examined", but for...in still materializes
the key-name enumeration when the loop starts — the same JS limitation the
checkDoneValue rewrite now acknowledges. What the generator genuinely saves is
the extra VALUE array Object.values/Object.entries would copy; state that
precisely rather than implying sublinear startup.
checkDoneValue cannot bound object width sublinearly: JS has no lazy own-key
iterator (for...in materializes the key set), and done.value is already
JSON.parse'd before the check runs, so the frame's width is paid upstream. The
genuine width bound is the host's fixed 256 MiB fd-3 receive buffer (a later
stack layer). Reword the JSDoc and branch comments to claim only what holds —
the traversal caps the INCREMENTAL allocation the check would add (escaped
strings, enqueued children, per-key stringify) and refuses over-budget before
those secondary allocations — and drop the mid-count micro-check that JS cannot
honor. Replace the Proxy test (whose ownKeys allocated a 2M array, proving
nothing) with assertions that an over-budget string/array/object is refused
before its escaped copy or child enqueue.
The base seam branch resolved its DUNDER_MEMBER inconsistency by keeping
/^__.+__$/ and asserting `____` (empty middle between two `__` pairs) does not
match. Drop this branch's earlier /^__.*__$/ stopgap so the seam file is
byte-identical to its base: the earlier change only existed because the base
was self-inconsistent, and the base now owns a coherent decision.
The object branch counted every own key before applying the size bound, so a
forged done.value with millions of keys and a small cap forced an O(frame)
walk — contradicting the O(cap) guarantee the comment promised and able to
block the host event loop. Bail mid-count the instant the running minimum
encoding (braces + 4 bytes/entry + commas) crosses maxBytes, and drop the now
-redundant post-count check the loop subsumes. Add a Proxy-based test proving
a 2M-key object enumerates fewer than 1000 keys under a 64-byte cap.
Also correct the checkDoneValue JSDoc: per-scalar byte length is measured via
scalarJson (exact BigInt digits for beyond-safe integers), not JSON.stringify.
- Translate README.zh.md's Model Experience body and KV Cache line, which
were left verbatim in English.
- Convert half-width punctuation to full-width across the README.zh.md
Known Limitations bullets and the entire Agent Note Chinese side, per
docs/i18n translation-rules.md Typography (MUST use ,。:()in Chinese prose).
- Re-record both README and Agent Note i18n.yaml pairing hashes.
- Reword the workspace-constraints extra-files comment: this layer's py/
ships only the wire-protocol mirror; the spawned bootstrap arrives later.
The base seam branch still carries the buggy /^__.+__$/ (rejects `____`,
which its own reserved.spec asserts must match), so this stacked branch must
keep the /^__.*__$/ correction to stay green. Reword the JSDoc to the same
line count as the base so the CodeRuntime class does not shift, leaving the
cordis services catalog anchor identical to the base and confining this
branch's footprint on the seam file to the single regex character.
- Drop a CALL frame whose id is negative zero: it passes Number.isFinite but
the reply re-serializes it as `0`, colliding with a real call id `0`. The
honest child never issues `-0`.
- Document that validateChildFrame preserves a forged done frame's value and
error together on purpose, so consumers must check error before value.
- Cover the log-frame `truncated` rebuild branch: assert a literal-true flag
rides along and any other value (1, string, false) is dropped, closing the
protocol.ts branch the coverage gate flagged.
- Correct encodeJsonPlain's JSDoc: it matches compact JSON.stringify EXCEPT on
a beyond-safe-range integral double, where it emits the exact BigInt digits
(`...846976`) rather than the rounded `...847000` — the divergence the
"emits exact digits" test pins.
- Declare py/protocol.py's `global`-bearing frames (Namespace, CallMessage)
with functional TypedDict syntax so they carry the real wire key instead of
a `global_` attribute the wire never sends, and split optional-field messages
(Namespace/LogMessage/DoneMessage) into a required base plus a total=False
subclass so `type` and other required fields cannot be dropped. Widen
HostToChild to include the boot and run frames the host sends before replies.
- Reword the mirror e2e's py/ directory assertion to describe the source-tree
layout it actually checks.
- Drop the unused @deepseek-ai/dsh-code-runtime dependency: this layer
imports nothing from the seam (protocol.ts has no imports; the invariant
companion uses only cordis and dsh-invariants). The backend-core PR
re-adds it when PythonCodeRuntime consumes the seam. Fixes knip.
- Point the Agent Note's cross-reference to the seam note at the English
target on both language sides, per the bilingual-pairing contract (only
the language switcher flips to .zh.md). Re-record the sidecar.
- Add the Known Limitations section both READMEs require, covering the
cross-language guard's scope and the deferred runtime implementation.
- Regenerate the module graph for the dropped dependency edge.
The seam's dunder-member test (added in the base seam PR) asserts
`DUNDER_MEMBER.test('____')` is true and `test('__')` is false, but the
regex `/^__.+__$/` rejected `____`: the `.+` demanded a non-empty middle,
while `____` is two adjacent `__` pairs with an empty middle. Widen to
`/^__.*__$/` so a name with distinct leading and trailing `__` pairs
matches whether or not it has a middle, and align the JSDoc. Regenerate
the cordis service catalog for the merged seam source line.
Introduce @deepseek-ai/dsh-code-runtime-python with the versionless
JSON-lines protocol between the Node host and the CPython subprocess:
the host-side hostile-frame codec (validateChildFrame, encodeJsonPlain,
checkDoneValue, hasUnsafeIntegerToken, hasNonLosslessNumber,
logTruncationMarker) and the Python-side wire-vocabulary mirror
(py/protocol.py).
This is the protocol layer of the code-runtime-python stack, split from
#436 and based on the multi-language seam extension. The PythonCodeRuntime
implementation and its Python JSON codec land in the backend-core PR on
top of this branch.
Ship the minimal buildable package skeleton (package.json, tsconfig,
tsdown, barrel index, invariant companion, bilingual README) because the
workspace-constraint, coverage, and invariant-topology gates require the
package to exist and build the moment its directory does; the backend-core
PR extends those files rather than creating them.
Align py/protocol.py with src/protocol.ts (the round-12 review of #436
found LogMessage.truncated, DoneMessage.error.kind, and Namespace.errorClass
stale) and guard the two runtime-executed surfaces (PROTOCOL_FD and the log
truncation marker) with a real-python3 cross-language mirror e2e test.
The inline comment called every dunder entry "the Python backend's" slot,
but `__debug__` is a CPython compile-time constant, not a seeded slot;
align the comment with the seam declaration, which is the single home for
why each entry is reserved.
- index.ts: RESERVED_ERROR_MEMBERS JSDoc now says `__x__` (non-empty
middle), matching DUNDER_MEMBER and the derivative docs; and
PORTABLE_RESERVED_WORDS is described as covering portable *target*
languages (Python is a later-PR backend, not yet shipped).
- types.ts + type-equiv docs: CodeBindingNamespace.global points to
RESERVED_BINDING_GLOBALS by name with an `e.g.` sample rather than
enumerating the set (single home), no longer implying `__debug__` is a
seeded slot.
- Agent Note: separate `__debug__` (CPython compile-time constant) from
the seeded bootstrap globals.
- README (en/zh): document the four exported exclusion sets and the
portable identifier/naming contract — no cross-package link needed.
- Regenerate cordis services catalog for the shifted source line.
- Agent Note: correct the stale worker-adoption paragraph — the worker
consumes the seam constants directly by name (no RESERVED_WORDS /
RESERVED_ERROR_PROPERTIES re-alias) — and describe DUNDER_MEMBER as
`__x__` (non-empty middle).
- types.ts: document RESERVED_BINDING_GLOBALS on CodeBindingNamespace.global
(names like `__dsh_main__` pass the identifier rule but are still refused)
and the non-empty-middle dunder rule on memberNameProperty; propagate to
the type-equiv derivative docs (both languages, re-recorded).
`____` has an empty middle between the two `__` pairs and does not match
`/^__.+__$/`; assert that (not a match) and add `__x__` as the shortest
real dunder form that does.
- Worker consumes PORTABLE_RESERVED_WORDS / RESERVED_ERROR_MEMBERS by
their seam names directly, dropping the local re-alias (symmetry with
the other two imported constants).
- Split the reserved-vs-duplicate diagnostics: a backend-owned global now
reports "reserved binding global", not the misleading "duplicate".
- DUNDER_MEMBER uses `__.+__` so a bare `__` (empty middle, not a real
CPython dunder) is not matched; add coverage.
- Worker misuse tests add `a$b` (second-char `$`) and `lambda` (Python
keyword) so the identifier narrowing and reserved-word adoption are
each pinned directly, not only transitively.
- Clarify the seam JSDoc (dunder-vs-explicit-set wording, Python backend
is a later stack PR) and record in the Agent Note the obligation to
widen RESERVED_BINDING_GLOBALS when the bootstrap seeds more globals.
Move the reserved-word, reserved-global, reserved-error-member, and
dunder exclusion sets from the worker backend up to the code-runtime
seam package, and narrow the portable identifier subset to drop the
JS-only `$`. Every backend now imports one contract so a binding
namespace list valid on one backend is valid on all.
Delivers only the seam extension and the worker's adoption; the Python
backend, py-types renderer, and Code Mode language dispatch are later
PRs in the stack that depend on these exports.
`SDK_RENDERERS` and `RUN_CODE_FLAVORS` had to stay in step by review alone:
the `Object.hasOwn` guards catch drift only once a runtime reporting the
half-added language exists, which is the one case that cannot arise. Both
tables are now `satisfies`-checked against a shared `CodeSdkLanguage` union,
so a missing or extra entry fails `typecheck`. The declared `Record<string, …>`
type stays, since `CodeRuntime.language` is an unconstrained `string`.
The code-runtime seam's own README row and `CodeRuntime.language` JSDoc still
named `'typescript'` as the sole well-known value; both now name `'python'`
too and say only `'typescript'` has a published backend.
A description was emitted above the `async def`, where Python treats the
first string as the `Tools` class docstring and every later one as a dead
expression — leaving each method undocumented in the model's only source of
tool semantics. Emit it as the first statement of the method body instead.
Also names the known languages in the run_code flavor guard (the reachable
rejection, symmetric with the SDK_RENDERERS guard) and corrects three doc
claims: the code-runtime group README no longer calls the generated SDK
TypeScript, the base Code Mode note states its serial dispatch in past
tense, and the tools README points at the rationale the language-dispatch
note actually carries.
The load-time range check rejects a maxWallMs above Node's maximum
setTimeout delay, but the constraint appeared only in the README design
section. Deployments reading the Config field JSDoc, the generated config
catalog, or the README config summary saw maxWallMs described as a
positive finite wall-clock ceiling, so an out-of-range value looked valid
until plugin load failed.
`config.maxWallMs` is only checked for positivity, and it is handed to
`setTimeout`, which clamps any delay above 2^31-1 ms to 1 ms. A deployment
configuring a 25-day wall ceiling therefore gets the opposite of what it asked
for: every run times out on the first tick. The runtime now range-checks the
field at load against MAX_TIMER_DELAY_MS from dsh-timeout and throws, so the
misconfiguration fails loud where it is self-contained instead of silently
inverting the budget.
`computeMs` needs no matching bound: it is compared against measured event-loop
utilization rather than fed to a timer.
The test asserts both the rejection and that the boundary value itself loads.
Implements the execa Agent Note's four sub-changes:
- execa (root devDep + loader-smoke dep) replaces the hand-rolled
spawn-collect-timeout choreography in loader-smoke, apps/cli and
cli-demo/acp-demo built-bin e2e, lsp-local and code-runtime-worker
built-lib e2e, the tui pty-harness outer collector, the jsonrpc
keyless smoke, and crash-recovery's child spawn. Genuinely custom
parts stay custom: cli-demo's interrupt-on-marker, jsonrpc's
line-predicate protocol driving, crash-recovery's SIGKILL-at-failpoint.
The two loader-smoke /* v8 ignore */ OS-error branches are gone.
- llm-mock-server CLI tokenizes via node:util parseArgs; numeric
coercion/bounds/cross-option constraints stay manual; pinned
error-message tests updated to the parseArgs texts.
- both loadRootEnv copies in apps/web/tests are deleted: the owning
vitest configs (web unconditionally, snapshot in record mode)
already load the repo-root .env before these files run.
- the four poll loops (acp-snapshot harness waits + crash-recovery
waitForFile) ride vi.waitFor with explicit {interval, timeout}.