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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Apply the rename pass to READMEs and docs the master sweep rewrote, fix
PTC mode anchors and the renamed-note links in the spill READMEs, and
regenerate the doc graphs.
packages/AGENTS.md:18 requires a package-specific "No runtime invariant:"
reason on an empty installer. This one described a process-boundary
implementation and real-subprocess integration tests that the package does
not carry — it ships the wire-protocol codec and its Python mirror, covered
by protocol.spec.ts and protocol-mirror.e2e.ts.
The barrel's module comment described where a later implementation would sit
relative to this seam, which docs/AGENTS.md:38 keeps out of durable prose.
State what the module exports instead.
docs/AGENTS.md:38 keeps PRs, commits, and stack positions out of durable
prose. Both README sides described where this layer sits in a PR stack and
what a later PR would add, which goes stale the moment the backend lands.
Describe what the package owns instead: the wire protocol, with an exported
surface that carries no subprocess execution path.
Re-record README.i18n.yaml.
master added verify-dsh-package-licenses while this branch was open: every
repository-owned DSH package must declare "license": "MIT". This package
carried BSD-3-Clause from its creation, so the gate failed and took the
required "node 24 / static" lane down with it.
Resolutions:
- docs/module-graph.md, docs/config-catalog.md: generated files. Regenerated
with gen-module-graph and gen-config-catalog on the merged tree, then carried
the new package's entries into the Chinese sides and re-recorded both
pairings. Each side now differs from master by exactly the
code-runtime-python rows.
- scripts/verify-package-readme-model-experience.ts, tsconfig.host.json: master
renamed packages/bash -> packages/shell, packages/pty -> packages/terminal,
code-runtime-worker -> code-runtime-worker-thread and agent-tool-mode ->
agent-tool-presentation. Kept master's names and re-added this branch's
code-runtime-python entry.
Adapted the package to conventions master introduced while the branch was open:
version 0.1.0-rc.6 with publishConfig.access "public" (the release-member rule
check-workspace-constraints now enforces), the invariants project reference
moved to packages/runtime-diagnostics/invariants, and the README companion link
retargeted to code-runtime-worker-thread.
master cut 0.0.1-rc.2 while this branch was open. The version bump touched
every existing package but not this new one, so check-workspace-constraints
rejected the mismatch and took the required "all checks passed" job down
with it.
Resolutions:
- docs/module-graph.md, docs/config-catalog.md: both are generated files.
Regenerated with gen-module-graph and gen-config-catalog on the merged
tree instead of hand-merging the conflict hunks.
- scripts/verify-package-readme-model-experience.ts: both sides appended
registry entries; kept all four.
Adapted this package to conventions master introduced while the branch was
open: version 0.0.1-rc.1 with publishConfig and repository metadata, the
workspace: protocol for peer dependencies, and the @deepseek-ai/cordis
rescope in package.json and src/invariant.ts.
Two comments described facts that belong to later layers of the stack:
- The workspace-constraints whitelist comment described a bootstrap the host
spawns by path. This layer's py/ holds only protocol.py, the wire-vocabulary
mirror, and nothing here spawns it. State what the whitelist entry actually
covers: the Python source ships as-is rather than built.
- checkDoneValue's JSDoc claimed maxValueBytes "defaults to 32 KiB". This
package defines no config and no default; maxValueBytes is a required boot
frame field. Name it as the budget instead, so the prose cannot drift when
the owning implementation picks a default.
Comment-only; the bound argument is unchanged.
Rename UnionCoversRoster/RosterCoversUnion to UnionSubsetOfRoster/RosterSubsetOfUnion so
the names read in the same direction as their extends clauses, share the python3 -I -B
flags between the two mirror probes, and align the README and Agent Note prose with the
checkDoneValue JSDoc: the escaped-size scan is the metering itself, not deferred work.
Regenerate docs/module-graph.md, which listed code-runtime-python twice.
- Drop the review-history narrative from checkDoneValue's JSDoc (the "in the
previous implementation … now avoids" clause); state the current contract only.
- Assert encodeJsonPlain on the same 100k-deep value the metering test uses:
its headline contract is stack-safety (JSON.stringify would throw), but no
test exercised the encoder on a deep value.
Address the latest review round:
- WireFrameShapesCoverUnions checked only union ⊆ roster, so removing a frame
from a message union (e.g. dropping ReplyErr from ReplyMessage) left the check
true while the public TS union diverged from the wire. Replace it with a
bidirectional equivalence between MessageFrames and the roster's message-frame
value types (nested Namespace/ErrorClass/DoneErrorField excluded): both a frame
added to a union without a roster entry and a frame removed from a union now
fail typecheck (both verified).
- The mirror e2e's python3 probes imported protocol.py without -B, writing
py/__pycache__/*.pyc into the (un-ignored) source tree. Add -B to both.
- Refresh the metering prose (checkDoneValue JSDoc + README both sides + Agent
Note both sides): the incremental-work list no longer says "per-key
JSON.stringify" now that jsonStringBytesUpTo scans without stringifying;
re-record the README and Agent Note i18n pairings.
Two review findings on checkDoneValue's metering and the wire-mirror binding:
- The string/key byte check used a decoded-length lower bound and then called
JSON.stringify, which materializes the ~6x escaped copy before the over-budget
check — the hundreds-of-MB spike the metered walk exists to avoid. Add
jsonStringBytesUpTo, a non-allocating scan that computes the exact escaped
UTF-8 size (matching JSON.stringify byte for byte, including surrogate pairs
vs lone surrogates) and bails the instant it crosses the remaining budget; use
it for both string values and object keys.
- The frame roster in WIRE_FRAME_FIELD_ROLES was hand-written, so a frame added
to ChildToHost/ReplyMessage without a roster entry slipped past. Introduce
WireFrameShapes (name -> interface) as the canonical roster the roles map is
bound against, plus a WireFrameShapesCoverUnions compile-time assertion that
every message-union member appears in it (verified: adding a frame to a union
without a WireFrameShapes entry fails typecheck).
Address the remaining review findings on the wire-mirror layer:
- Export PROTOCOL_FD from protocol.ts as the TS-side source of truth the host
wires, and assert the Python constant against it in the mirror e2e instead of
a bare literal 3, so an fd drift on either side is caught.
- Correct the FrameFieldRoles JSDoc to point at the actual assertion site
(WIRE_FRAME_FIELD_ROLES's satisfies clause, not WIRE_FRAME_FIELDS).
- Drop the redundant explicit type annotation on WIRE_FRAME_FIELDS (the trailing
`as` cast already types it; Object.fromEntries returns an index signature).
- Refresh the mirror-test comment to describe the roles-map binding (a TS-side
add/remove/rename/optionality-flip fails typecheck; a Python-side change fails
the comparison).
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 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.
- 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.