A reviewer found that `cancel(reason)` only preserved the caller's reason when
an active AbortController observed it (the mid-step path, via
`abort.signal.reason`). The marker-only windows (step-start at loop.ts and the
continuation gate) hardcoded `reason: 'cancelled'`, so the logged `turn/end`
reason was race-dependent on WHERE the cancel landed and the public
`cancel(reason?)` parameter was half-effective.
Capture the resolved reason (`reason ?? 'cancelled'`) on the agent when the
marker is armed, expose it on the LoopHandle as `cancelReason()`, and use it in
both marker branches so a turn dropped without a live controller records the
SAME `{kind:'aborted', reason}` the mid-step path produces.
The two existing window tests asserted `reason: 'cancelled'` while passing
`'from turn-start'` / `'from continuation'` — they documented the bug. Updated
both to assert the caller's reason (behavior + test changed together, per
AGENTS.md "tests document behavior, not golden truth").
Also fixes two stale docs the PR's contract change left behind: the
module-level ACP mapping comment and `codec.ts` both still said `session/cancel
-> agent.abort()`.
A stronger durability test (dispose MID-turn, then re-load from disk) caught
that the original two-sibling-effect design dropped the loop's closing
`turn/end` on the bare fiber-dispose path: a fiber unload disposes sibling
effects CONCURRENTLY (`Promise.all`, vendor/cordis/fiber.ts), so the
session-create effect detached `onAppend` racing the loop's final
`session/flush` — the re-loaded log showed crash-recovery's synthetic
`interrupted` closer instead of the real `disposed` reason. The disconnect
path happened to work (only `quiesce()` ran), but the contract must hold
uniformly.
Fix: fold the session lifecycle INTO the agent's single composite effect.
`SessionStore` now exposes `prepare` (validate + construct, no store entry),
`enter` (attach onAppend + store, returns detach), and `announce` (emit
session/created), replacing the sibling-effect `createOwned`. `AgentLoop.start`
builds ONE effect that yields, in order: session-detach, register, then
stop-and-`await agent.done`. LIFO disposal runs them as an ORDERED chain (the
runtime awaits each disposer's promise before the next), so the loop is
stopped and awaited to exit — its closing flush captured through the still-
attached onAppend — BEFORE the session detaches, whether the trigger is the
handle's dispose() OR a fiber unload. The config path uses prepare()+start
too, so it gets the same ordered teardown. All three factory entrypoints now
funnel through the one composite builder.
The mid-turn durability test asserts the REAL `disposed` reason lands on disk
(not a recovered `interrupted` substitute), proving the closing event was
captured rather than reconstructed.
The bridge now holds each session's `AgentHandle` disposer in its
`SessionRecord` and runs it on teardown (client disconnect or fiber dispose)
instead of the old `abort()` + `whenIdle()` drain that left agents
registered. A bare client disconnect now leaves NO registered agent and NO
session-store entry — not an idled-but-still-registered one. The queue-aware
`cancel()` inside the disposer also closes the former pre-step best-effort
window (a turn about to start is dropped), so teardown reaches true
quiescence.
The `session/load`-races-teardown leak is fixed: if the bridge closed while
`resume()` was pending, the just-resumed handle is disposed before throwing,
so it leaves no orphan (it has no SessionRecord, so quiesce() never sees it).
Tests: the disconnect test now asserts (through the SAME memoized teardown)
that the agent is unregistered AND its session removed; a durability test
re-loads the persisted log after dispose and asserts the closing turn/end is
on disk (guards the teardown-order contract); a sibling-isolation test proves
one handle's dispose() leaves other agents untouched. Docs: agent /
agent-loop / acp READMEs, architecture.md, and the stale in-code quiesce()
ownership comment updated to the per-agent disposal model; the now-resolved
TODO(rfc010-agent-disposal) / TODO(rfc010-cancel-prestep) teardown notes
removed.
The agent factory (`ctx.agents.create`/`resume`, the `AgentFactory` seam)
now returns `AgentHandle = { agent; dispose(): Promise<void> }` instead of a
bare `Agent`. The disposer is a capability: only the holder can tear down
exactly this agent — stop its loop, await the loop's exit (true quiescence,
not just the `disposed` status flip), unregister it, and remove its session
from the store.
The teardown ORDER is load-bearing for durability. The loop appends its
final `turn/end` + runs `session/flush` AFTER an abort, delivered through
`session.onAppend` → `session/event`; if the session-store effect (which
detaches `onAppend`) were torn down first, those closing events would never
reach persistence. So `dispose()`:
1. runs the register+start effect disposer (sync: request loop stop),
2. `await agent.done` (loop exits, final flush captured), THEN
3. runs the session disposer (detach onAppend + delete store entry).
`SessionStore.createOwned()` exposes the session-create effect's disposer
(plain `create()` discards it — fiber-owned). `AgentLoop` funnels both
factory entrypoints (`createAgent`, `resumeWith`) through a shared
`startOwned` that composes the ordered teardown; the config path keeps a
fiber-owned agent by discarding the handle.
`ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for
the owner that created it.
Codex's converge pass found a quiescence-contract violation: a whenIdle() waiter
registered for prompt A, then cancel() clears A, then prompt B is queued BEFORE
the loop resumes from the idle wait. The window-1 cancel branch called
settleIdle() UNCONDITIONALLY, resolving the waiter while B was still
queued-and-unrun — whenIdle() resolved with zero events, then B ran afterward.
Fix: in window 1, only settleIdle() + re-park when NO new work is queued. If a
send() raced in after the cancel, the marker was for the cancelled work only —
clear it and fall through to run the new prompt's turn, letting THAT turn's
running→idle settle the waiter (so whenIdle() waits for B to actually run).
Adds a regression test reproducing the exact interleaving (send A → whenIdle →
cancel → send B): whenIdle() now resolves only after B's turn ran (B's user
message + a turn/end in the log), and A was dropped.
abort() only kills the in-flight step, so a queued-but-not-yet-started prompt
ran to completion after a cancel and a prompt accepted right after could be
batched into the cancelled turn (the loop merges queued messages into one turn).
This closes TODO(rfc010-cancel-prestep) with a distinct cancel() verb.
cancel() clears the queued + steering FIFOs, aborts the in-flight step, and
drives a turn-scoped marker on the LoopHandle that the driver checks at EVERY
point a turn could start or continue:
- right after the idle wait (window 1): drop the about-to-run turn and settle
whenIdle() waiters directly (no running→idle transition fires, and no
agent/status is emitted, so an ACP listener can't see a spurious idle that
resolves a freshly-queued prompt as cancelled);
- after the synchronous setStatus('running') emit (window 2): a running listener
can cancel in the gap before runTurn;
- in the step-start window (before runStep, after setAbort): a synchronous
turn-start/step-start listener can cancel before any AbortController exists;
- at the continuation gate: a cancel during the continuation waterfall (the
finished step's controller already cleared) ends the turn aborted.
The marker is ARMED only when there is something to cancel (running, an
in-flight step, or queued/steering work) — an idle no-op cancel cannot leave it
set to drop a later prompt — and RESET unconditionally once per loop iteration,
so it governs exactly one turn and never leaks onto the next prompt (even when a
send() lands in the cancelled turn's flush window).
ACP session/cancel now maps to agent.cancel() (keeping the synchronous
settlePrompt). Teardown/disconnect still use abort('disposed') until PR D, so
the ACP README narrows the remaining best-effort window to teardown only.
Tests (agent-loop/cancel.spec.ts) cover every window unit-level (the F1 hang
guard: a whenIdle() waiter registered before a pre-step cancel resolves; the F2
leak guard: idle cancel then a prompt runs; mid-step, continuation, both
pre-step windows, turn-start-listener, steering-cleared, marker-reset). ACP
turns.spec.ts adds the through-bridge tests with NO intervening whenIdle (idle
cancel→prompt runs; mid-stream cancel→immediate next prompt runs) and updates
the stale pre-step test to the queue-aware guarantee. The existing cancel
snapshot golden is byte-identical (it drives the new cancel() path end-to-end
through the real subprocess), so no new golden is needed. 100% coverage.
Rename the concrete Agent class to make its ReAct-style reasoning loop
explicit in the name. Package name, default-export plugin (`AgentLoop`),
and the `ctx.agentLoop` service key are unchanged.
- AgentLoop.resume uses `this.ctx.get('sessionPersistence')` (strict) instead
of the `, false` overload: still topology-independent, but an inactive/
absent backend reads as undefined (rejected by the existing guard) rather
than being handed back mid-teardown.
- Correct the bridge teardown comment: an ACP-created agent's registry entry
binds to the BRIDGE fiber (the factory is reached through the bridge's
traceable proxy, so AgentLoop.start's `this.ctx.effect` registration uses the
caller context), not the AgentLoop fiber — so an ACP-only HMR dispose
reclaims it. Add a regression test pinning that ownership.
- Sync the ctx.get guidance in the post-mortem, packages/AGENTS.md, and the
dsh-code-review skill to the strict form.
Two independent bugs made the ACP server crash the moment an editor (Zed)
connected, despite 178 green unit tests at 100% coverage:
1. `session/new` threw `cannot get property "agents" without inject`. Root
cause: a stray `export default apply` made the cordis Loader's
`unwrapExports` (`exports.default ?? exports`) collapse the module to the
bare `apply` function, discarding the sibling `inject`/`name`/`Config`
named exports. The plugin fiber was built with empty `inject`, so every
`ctx.<service>` read in `apply` threw at load. Fix: remove the default
export so the Loader uses the namespace.
2. `session/load` threw `cannot get property "sessionPersistence" without
inject`. `AgentLoop.resume` read `this.ctx.sessionPersistence` (a service
it deliberately does NOT inject); the property proxy's ancestor-only fiber
walk fails through the bridge's traceable shadow. Fix: read it via
`this.ctx.get('sessionPersistence', false)`, the topology-independent
global-store lookup.
Why the suite missed both: every test mounted the plugin by hand
(`ctx.plugin({name,inject,apply})`), bypassing `unwrapExports` entirely, and
the only test driving these RPCs was key-gated (skipped in CI). Added a no-key
`session/new` e2e that boots the real example through the real Loader — it
fails loudly on bug #1 without an API key. Set `TSX_TSCONFIG_PATH` in the e2e
spawn so the subprocess resolves workspace `paths` from a temp cwd (it was
silently falling back to a stale built `lib/`).
Docs: post-mortem 0001; AGENTS.md "line coverage is not behavior coverage" +
with-key/smoke-test philosophy; packages/AGENTS.md plugin-export-shape and
ctx.get rules; dsh-code-review SKILL checks.
Collapse docs/adr/ and docs/rfc/ into a single docs/rfc/ with proposed/,
implemented/, and rejected/ subfolders. Every file is renamed to
yyyy-mm-dd-topic-title.md, where the date is when the topic was first
proposed (from git history). ADRs and RFCs that covered exactly the same
topic are merged (property-based testing, session persistence); the
umbrella RFC 005 stays split across its three implemented decisions, and
RFC 006's deferred part-3 (API extractor reports) splits into its own
proposed RFC. All cross-references become machine-checkable relative
links instead of bare "ADR NNNN" / "RFC NNN" prose.
Add a verify-md-links doc-sync gate (scripts/verify-md-links.ts) that
checks every relative Markdown cross-link resolves, wired into doc-sync
alongside verify-md-wrap. This makes the reorganization self-verifying:
the same change that rewrote ~forty inter-doc links adds the check that
proves none dangle. Document the cross-link convention in a new
docs/AGENTS.md and record the gate as an implemented RFC.
doc-sync, typecheck, lint, and the full test suite (667) all pass.
A config agent with `resumeSessionId` set continues a persisted session
instead of starting a fresh `${id}-session-<uuid>`. The id is sourced from
an env var in cordis.yml, so the coding-agent demo can resume a prior
conversation without code changes. The resume is deferred until the
`sessionPersistence` backend loads (via ctx.inject) and is contained: a
missing/unreadable id logs a warning and starts no agent. Adds a real-API
resume e2e proving cross-process continuity through the JSONL backend.
Implements the RFC 010 MVP: a new `@deepseek-ai/dsh-acp` package bridges the
harness agent to the Agent Client Protocol (JSON-RPC 2.0 over newline-delimited
stdio), so Zed and other ACP editors can drive the coding agent — streaming
render, tool-call display, and resumable sessions via `session/load`.
- packages/acp: AgentSideConnection wiring; initialize/newSession/loadSession/
prompt/cancel; a total TurnEndReason→StopReason codec; settle-once with a
fallback chain (agent/turn-end → logged turn/end → idle); single-session
guard; cwd-must-equal-launch-dir validation; load replays from the persisted
event log (assistant/chunk→agent_message_chunk, tool/call/result→tool_call*).
- agent: add Agent.whenIdle() quiescence signal to the interface; LoopAgent
implements it (resolves on the first running→idle/disposed transition). The
bridge awaits it on disposal so teardown reaches quiescence, not just abort.
- examples: extract the shared provider/tool core into examples/base.yml;
coding-agent nest-includes it; new examples/acp-agent serves the agent over
ACP with JSONL persistence and no stdout logger (stdout is the protocol).
- Permission gate deferred (TODO(rfc010-permission-gate)): tools run with the
executor's full authority; only the Agent→sessionId ownership seam is laid
down. Cancel is best-effort for a not-yet-started queued turn
(TODO(rfc010-cancel-prestep)). RFC 010 stays `proposed`.
- Docs: package README + Zed snippet; client-driver cookbook section; root and
packages layout/commands; RFC 010 implementation-status note.
48 bridge tests + whenIdle coverage; 100% per-file coverage; e2e boots the
example as a subprocess and verifies a written file on disk (key-gated, with a
no-key stdout-purity check).
Add a `max-tokens` variant to `TurnEndReasonMap` and carry the model
finish reason up from `runStep` to `runTurn`, applying the rule "any
max-tokens step in the turn surfaces as max-tokens" (disposed/aborted/
error still take precedence). This lets consumers distinguish a clean
stop from a truncated one — the contract RFC 010's ACP bridge maps to
the `max_tokens` stop reason.
Also add an AGENTS.md rule: write an ADR when (and only when) a PR makes
a durable, contested, surprising decision.
The prior fix handled a throwing session/event listener on the turn/start
append, but the SAME push-before-notify hazard remained on the three
FINALIZER appends. Session.append pushes the event before notifying, so a
throwing listener on a finalizer event left the event logged but aborted
the rest of finalization — stranding the turn open.
- failTurn(): set `reason` BEFORE appending the `error` event, and contain
a throwing session/event listener on it (the event is already logged
either way). Otherwise reason stayed unset, agent/error was skipped, and
the caller's closeTurn(false) never ran → open turn.
- closeStep(): the try/catch wrapped only the agent/step-end EMIT, not the
step/end APPEND. A throwing session/event listener on step/end escaped —
fatal when closeStep runs from the outer catch during finalization
(turn/start + step/end but no turn/end). Now both the append and the emit
are contained and surface as a turn error via failTurn.
- closeTurn(): contain a throwing session/event listener on the turn/end
append (it would propagate to the runLoop backstop from closeTurn(false),
or skip the turn-end emit from closeTurn(true)). turn/end is logged
either way, so the turn stays balanced.
Regressions: a throwing session/event listener on the error event, on
step/end during finalization (driven by a throwing agent/step-start), and
on turn/end — each leaves a balanced turn and the loop survives.
- agent/README: the inject() line said "without triggering a turn",
regressing the #32 turn-enclosure model. Restored the running-vs-idle
wording (idle inject wraps a one-shot injection turn; ADR 0017) to match
the interface JSDoc.
- agent-loop resume() JSDoc said "throws a typed error" but the code
throws a plain Error (consistent with the sibling assertAgentIdFree
throw). Softened to "rejects with a clear error" — no behavior change;
plain Error is intentional (no consumer needs a structured code here).
Session.append pushes the event BEFORE notifying session/event listeners,
so a throwing listener leaves the event in the log while the line after
the append (a boolean flag) never runs. Both turn-balance decisions were
gated on such flags, so a throwing listener could strand an open turn or
skip a durability checkpoint.
- loop.ts: the outer catch decided "turn/end owed" from `turnStarted`.
A throwing listener on the turn/start append left turn/start logged but
the flag false → catch rethrew and skipped turn/end → permanently open
turn (violating ADR 0017). Now decided from the log (this turn's
turn/start present), so the turn is always balanced; only a genuine
pre-push failure (non-serializable trigger — turn/start never logged) is
rethrown to the runLoop backstop. Removed the now-dead `turnStarted`.
- agent.ts inject(): the idle one-shot-turn flush was gated on a
`turnRecorded` flag set after append('turn/end'); a throwing turn/end
listener skipped the flush, losing the balanced in-memory injection turn
on crash. Now the flush decision is read from the log, the synthetic
turn/end append contains a throwing listener (turn stays balanced), and
a failing idle flush is reported via agent/error (step 0 convention) AND
the logger — mirroring the loop's post-turn/end flush path — with a
throwing agent/error listener contained.
Rewrote the test that encoded the old (buggy) "turn/start listener throw
is rethrown, no turn/end" semantics to assert the balanced-turn contract,
and added regressions for the throwing-turn/end-listener flush and the
agent/error report. Updated Agent.inject JSDoc.
Add the agent-creation factory seam on ctx.agents (AgentRegistry):
setFactory/create/resume plus the AgentFactory interface and
CreateAgentOptions/ResumeAgentOptions. AgentLoop implements AgentFactory
and registers itself via ctx.agents.setFactory(this), so plugins
create/resume agents through the interface without depending on the
concrete loop package.
- create({ agentId, sessionId, meta?, agentOptions? }) — programmatic
create on a caller-supplied session id (e.g. an ACP-generated id).
- resume({ agentId, resumeSessionId, agentOptions? }) — load a persisted
session via ctx.sessionPersistence (RFC 009) and resume an agent on it;
the live session id is the resumed id, turn numbering and derived
history continue from the loaded log. sessionPersistence is NOT
hard-injected (non-persistent demos still work); resume rejects with a
typed error when it is absent. assertAgentIdFree runs before any
session is created (and again after the load await) so a duplicate id
never leaves an orphaned live session.
Adds the runtime dsh-session-persistence dependency to agent-loop.
Add the durable session-persistence capability seam (ADR 0016): an
abstract SessionPersistence service (dsh-session-persistence,
ctx.sessionPersistence) defining create/append/load/list/has/delete/
update over the existing SessionEvent — no parallel persisted type — and
a first implementation (dsh-session-persistence-jsonl): an append-only
JSONL log per session with crash-safe atomic writes, truncation-repair
of a never-committed crash tail, and a read/replay path. SessionMeta
(format version, cwd, lineage) travels out-of-log via session.header.
A shared runPersistenceContract suite holds every backend to the same
append-only / contiguous-seq / lazy-materialization / serializability
semantics.
Config-driven create() now uses a per-run ${id}-session-<uuid> session
id so a fixed name no longer collides with an on-disk log once a durable
backend is loaded; each run is a new session (a demo simplification). The
examples drop their hand-rolled session-jsonl.ts and load the JSONL
backend via cordis.yml; CI smoke-loads it too.
The agent-facing create/resume factory that consumes load() is a
separate seam, deferred to a follow-up; this change stops at the load
primitive and does not reach into the loop.
Every session event now lives inside a turn (between turn/start and its
turn/end). The loop records queued user/message events AFTER turn/start;
an idle agent.inject() wraps its context/message in a one-shot injection
turn. This makes the turn the single durability/replay boundary so a
persistence backend can treat anything after the last turn/end as a
crash tail without dropping legitimate between-turn context.
A failure once the turn is already closed (rejecting session/flush, a
throwing agent/turn-end listener) has no in-turn position for a session
error event, so it is reported via agent/error + logger only; the turn
stays balanced. failTurn appends an error event only while the turn is
open.
The dsh-invariants plugin enforces turn-enclosure via a default case:
every non-boundary event type — including plugin-added merge-extensible
keys — must sit inside an open turn or it throws.
Documented in ADR 0017 + architecture.md.
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
closeStep() previously caught and silently swallowed a throw from
agent/step-end emit. In the normal no-tool/no-steering path, this
caused runTurn to reach closeTurn(true) with reason still
{kind:completed}, so the session recorded a completed turn with
zero error events — even though a plugin had failed at a loop
boundary. This violates the contract that a throwing plugin is
contained as a turn error, not a silent success.
Now the catch calls failTurn(toError(error)), which appends the
single error event and sets reason={kind:error,…}. failTurn is
idempotent (errorReported guard), so existing error paths that
call failTurn after closeStep are unaffected.
Add regression test: a throwing agent/step-end listener during a
successful step now produces exactly one error event, a turn/end
with reason error, balanced boundaries (step/end before turn/end),
and a surviving loop.
The loop passed the authoritative call.id into ctx.tools.execute() but then
appended tool/result using result.callId — the value a tools/execute waterfall
listener returns — with no check. A listener returning a mismatched id silently
recorded the result under the wrong call. callId is the model-transcript
correlation id: deriveMessages() turns it into the tool-result block's
toolCallId, which must pair with the assistant tool-call block; a wrong id
orphans that pairing in the next model request.
Append tool/result with callId: call.id (the loop's authoritative id). A
listener-internal id, if ever worth keeping, belongs in a separate diagnostic
field — never overloaded onto callId.
Test: a tools/execute listener returns a wrong callId; assert the logged
tool/result.callId equals call.id AND deriveMessages() yields a tool-result
block whose toolCallId equals call.id (not the wrong returned id). Verified the
test fails on the pre-fix result.callId behavior.
After turn/start was appended, nothing guaranteed a matching turn/end: a throw
from a boundary emit (agent/turn-start, agent/step-start, the normal-path
agent/turn-end) escaped runTurn, and the outer runLoop backstop logged an
error but never appended turn/end — leaving an unbalanced turn that replay,
telemetry, and the invariants plugin all assume is impossible.
runTurn is restructured around idempotent finalizers that satisfy the four
traps a naive finally would hit:
- closeStep()/closeTurn(emit) are guarded (stepOpen/turnEnded) so they run at
most once; the agent/step-end and agent/error emits are contained so a
throwing listener can't strand the turn open.
- failTurn() records the single error event + reason and emits agent/error
exactly once (errorReported guard) — no double-logging when the outer catch
also runs (e.g. a step error followed by a throwing turn-end listener).
- the catch closes an open step BEFORE turn/end (invariants reject turn/end
while a step is open), and rethrows ONLY pre-turn throws (turnStarted false),
where no turn/end is owed, so the backstop still nets them.
- disposal precedence: reason stays disposed only when disposed AND no error
was reported; otherwise the error reason wins.
Tests (with the invariants plugin loaded as a balance oracle): throwing
turn-start (one error, one turn/end, no step), throwing step-start (step/end
before turn/end), throwing agent/error on a step-error path (balanced, loop
survives), disposal mid-turn (reason disposed, no error event), a pre-turn
turn/start-append throw (rethrown to the backstop, no turn/end owed), and a
step error + throwing turn-end listener (error logged exactly once). Verified
all six fail against a simulated finalizer bypass. dsh-invariants added as an
agent-loop devDependency (test-only oracle; no package cycle).
Every loop boundary appends the session event before emitting the Cordis
event (ADR 0003's append-before-emit rule) — except step/start, which was
inverted. A listener on agent/step-start that inspected session.events could
not see the step it was just told had started.
- Swap the two lines so session.append('step/start') precedes the emit.
- Fix the two stale pseudo-code copies (the runLoop JSDoc STEP-loop block and
docs/architecture.md) so neither shows step-start emitted before the append.
- Regression test: a step-start listener observes the matching step/start
event already at the tail of session.events. Verified the test fails on the
pre-fix (emit-first) order.
Introduce HarnessError in dsh-llm (the leaf package): a stable machine-routable
code distinct from the message, cause chaining, name from the subclass, plus
isHarnessError. LlmError, ToolArgsError, and InvariantError now extend it.
Tool failures carry the structure end-to-end: ToolExecutionResult gains
error: { name, code } (populated from a thrown HarnessError), and the loop
forwards it onto the tool/result session event (which gained the same optional
field) for retry/sandbox plugins and replay. The loop's toError wraps non-Error
throws in a HarnessError(code: UNKNOWN, cause) instead of a bare Error.
Landed last and in isolation so it's a pure upgrade over the plain Error+code
the earlier PRs used — independently revertible. Graduates RFC 005 pt 2 ->
ADR 0015; RFC 005 now fully implemented.
Two tsx CI gates make doc/code drift fail fast:
- doc-typecheck extracts every fenced ts block from README/docs/package READMEs,
compiles them with tsc --noEmit against a temp project (vendor->lib, harness->src
paths from tsconfig.typecheck.json), and fails on errors. Deliberate sketches opt
out with ```ts ignore-check; the opt-out ratio is reported and capped.
- verify-event-taxonomy asserts the docs/architecture.md taxonomy table names
exactly the events declared in the interface Events blocks. This surfaced three
events the table had been missing (tools/change, llm/adapter-change,
system-prompt/change), now added.
Doc snippets made compilable with stub imports/declares (1 genuine sketch ignored).
Wired into CI after typecheck. API reports (RFC 006 pt 3) deferred. Graduates RFC
006 pts 1-2 -> ADR 0014.
- llm: generator now emits finish chunks (the finish-defaults property was
vacuously green); add a property asserting streaming and one-shot assembly
agree on usage and finish
- agent-loop: assert the synchronous burst batches into exactly one turn; add
a mixed-schedule property (send/settle interleavings); recordStatus returns
its disposer; per-run timeouts so a hang loses no seed
- session: randomize the noise/message interleaving (was a fixed alternation)
- tools: exclude non-finite doubles from generated numeric args (JSON-real)
Adds fast-check + one tests/properties.spec.ts per protocol-shaped package
(llm/BlockAssembler, session, tools/schema DSL, agent-loop scheduling). The
tools suite includes the RFC 001<->005 composition property (generated args
satisfying a spec pass validateArgs), closing the validator/InferArgs drift
risk from ADR 0011. Loop properties are deterministic (settle on agent/status,
no sleeps).
The BlockAssembler suite found a real bug on first run: a duplicate block-end
at the same index overwrote an already-flushed block, so the streamed prefix
disagreed with final blocks(). Fixed (first close wins, matching the existing
straggler rule) + regression test. Graduates RFC 001 -> ADR 0013.