The loop logs the assistant/message (carrying tool-call blocks) BEFORE running
the tools, so a crash mid-tool leaves durable tool calls with no matching
tool/result. interruptedTurnClosers only added step/end + turn/end, so a resumed
session's deriveMessages() replayed a dangling assistant tool-call — which every
provider rejects as an invalid transcript on the next request.
interruptedTurnClosers now scans the interrupted turn for tool-call blocks
without a matching tool/result and synthesizes an error tool/result for each
(before the step/end), so the rehydrated history is a valid transcript. Adds a
dedicated repair.spec.ts and a shared-contract case proving both backends pair
every orphaned call with a result. Docs (ADR 0018, both persistence READMEs,
load() JSDoc) updated.
Also fixes the echo-agent README session-cleanup path: demo:echo runs from the
repo root, so sessions land in <repo-root>/.sessions/_no-cwd/, not
examples/echo-agent/.sessions/ (review #33).
Per review feedback (use a real markdown parser with an AST linked to
source positions), rewrite verify-md-wrap to parse each file with
mdast-util-from-markdown (the CommonMark parser behind remark) + the GFM
extension, then flag any `paragraph` node whose source span covers more
than one line.
Why a parser over the hand-rolled line scanner:
- It is a checker, not a formatter — it reports and never rewrites, so
zero cosmetic churn (no emphasis-marker or table-delimiter
normalization, which is why Prettier was rejected for this).
- The AST owns every structural exemption (fenced code of any fence
length, tables, lists, blockquotes, HTML, headings, reference defs),
fixing both bugs the regex version had: it now catches wrapped
list-item / blockquote prose (a `paragraph` inside those nodes) and no
longer false-positives on a longer ```` fence wrapping an inner ```.
Also unwrap two pre-existing hard-wrapped blockquotes (architecture.md,
adding-a-tool.md) that the stricter AST check correctly surfaced.
Mirror the JSONL backend's crash-recovery contract change: load() now PRESERVES
the real events of an interrupted final turn (a turn can be huge — truncating it
would destroy work) and durably CLOSES the orphaned turn with synthetic boundary
events (step/end if open, then turn/end {interrupted}) inside one transaction
that also deletes any torn tail row. load() is therefore mutating; the deferred
truncation-repair on the next append is gone. Replaces cutAtLastTurnEnd with
scanRows (longest preserved prefix + torn-tail offset). Updates the sqlite tests
and README to the preserve-and-close semantics; the shared runPersistenceContract
suite now holds both backends to identical interrupted-turn behavior.
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.
- AGENTS.md: require merging PRs with a merge commit (gh pr merge
--merge), never squash/rebase — the per-PR commit history (review-fix
and regression-test commits) is intentional record.
- Add scripts/verify-md-wrap.ts: a doc-sync gate that fails on
hard-wrapped prose paragraphs (one physical line per paragraph), with
smart exemptions for fenced code, tables, lists, blockquotes, headings,
HTML comments, hrs, and reference defs. Scope covers README.md,
docs/**/*.md, packages/*/README.md, plus AGENTS.md / packages/AGENTS.md
(the files doc-sync did not previously cover). Folded into doc-sync so
it rides the existing pre-push and CI gates.
- Sync AGENTS.md and docs/development.md doc-sync descriptions and
command lists to include verify-md-wrap.
A crash can leave a durable log whose final turn never closed. The old
behavior truncated everything after the last turn/end as a "crash tail".
But a single turn can be HUGE in a long-horizon task (many steps, large
tool output), so truncating it silently destroys real, durably-written
work — truncating a turn is wrong.
New crash recovery (ADR 0018): load() PRESERVES the interrupted turn's
events and CLOSES the orphaned turn by durably appending synthetic
boundary events — a step/end if a step was open, then a turn/end carrying
the new merge-extensible TurnEndReason {kind:'interrupted'}. load()
returns the balanced log, so a resumed session is immediately usable. Only
a never-fully-written TORN tail fragment is discarded; corruption in the
committed region is still unloadable.
- dsh-session: TurnEndReason {kind:'interrupted'} + shared
interruptedTurnClosers() repair helper.
- JSONL backend: scanLog preserves the longest contiguous prefix
(including a partial final turn); loadCore truncates a torn fragment and
durably writes the closers, returning the balanced log.
- runPersistenceContract gains a crash-recovery test (both backends + mock).
- Docs: ADR 0018/0017, architecture.md, package READMEs.
Also (review #33): RFC 013 records the "move event vocabulary to Zod"
question (merge-extensible maps → runtime schema registry) + blast radius;
deferred, not done here.
Codex review follow-up:
- Update docs/development.md: add verify-module-graph to the pre-push and
CI gate lists and gen/verify-module-graph to the daily commands, so the
contributor guide matches the new freshness gate.
- Emit the module-graph.md intro paragraph as a single line (repo Markdown
convention: one line per paragraph), since regenerating would otherwise
reintroduce hard wrapping.
Replace the alphabetical package ordering with a Kahn-style topological
sort (alphabetical tiebreak within each layer to stay deterministic), so
the table and graph list leaf interfaces first and dependents last.
Add scripts/gen-module-graph.ts, which derives the inter-package
dependency graph from each package's @deepseek-ai/dsh-* peerDependencies
and renders docs/module-graph.md (a GitHub-native Mermaid graph plus a
dependency table). Output is deterministic so a regenerate-and-diff
check is stable.
Wire a freshness gate the same way doc-sync is wired (ADR 0007: hooks
and CI run the same package.json scripts): verify-module-graph runs in
pre-push (lefthook) and as a CI step. It fails if the committed file
drifts from what the generator would produce.
The `materialized` INTEGER column was redundant: create()/update() already
keep a lazy session in memory and write no row, so a `sessions` row is
written only by the first append. Its EXISTENCE is the materialization
signal — has()/list() now report exactly the sessions that have a row,
matching the JSONL backend's "file exists ⇔ materialized".
The column only existed to force has()/list() to FALSE for an all-tail
crash (a partial first turn, zero committed events). That actually
DIVERGED from the JSONL backend, whose file (and thus has()=true) survives
a first append that never reached turn/end. Removing the column drops that
special case: an all-tail session keeps its row and stays present, the
same as JSONL. The orphaned tail rows are still removed by the deferred
truncation-repair on the next append, and load() stays non-mutating.
Also: add a TODO to route through a cordis db service if one is adopted,
and correct the README's Node-version framing to the repo's engines (>=24).
Lifts the RFC 010 single-session-per-connection cap: the bridge now runs N
concurrent sessions over one connection, each mapped to its own LoopAgent.
- packages/acp: live sessions held in a Map<sessionId, SessionRecord> with an
agent→sessionId reverse WeakMap so agent/* events (which carry only the
Agent) demux in O(1). Every session/event and agent/status is routed strictly
to its owning record — concurrent sessions never cross-settle or interleave
their session/update notifications. Per-session state: one in-flight prompt
each, session/cancel aborts+settles only its own agent/prompt, session/load
reserves a per-id load slot (distinct ids load concurrently; re-loading a live
id is rejected), and disposal drains every live session in parallel to
quiescence.
- packages/tool-bash: record each background task's owning agent at spawn and
keep it for the executor's lifetime (NOT cleared on completion).
bash_output/bash_kill reject a task owned by a different agent (a task with no
owner is open; a no-agent caller can't access an owned task). Task ids are
global and predictable, so this is the fence that stops one session's agent
from reading/killing another session's background task.
- Per-session permission ownership and a per-agent disposer seam stay deferred
(depend on the deferred permission gate); the reverse map the gate will route
through is in place. RFC 011 stays `proposed`.
- Tests: two sessions stream concurrently without interleave; cross-session
cancel isolation; per-session in-flight enforcement; dispose-all-to-quiescence;
bash cross-session read/kill rejected (+ no-agent and unowned-task cases).
- Docs: RFC 011 implementation-status note; acp + tool-bash READMEs; example
MVP-limitations updated. 100% per-file coverage maintained.
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).
master's pnpm migration claimed ADR 0016 (0016-pnpm-over-yarn), which
collides with this stack's session-persistence ADR. Renumbering the
session-persistence ADR to 0018 (turn-enclosure stays 0017); update the
json.ts module-doc reference accordingly.
@google/genai and protobufjs are pulled in transitively by the
dsh-llm-pi-ai adapter and consumed prebuilt; their lifecycle scripts are
no-ops we don't need. Set them to false in allowBuilds (was true) so no
unnecessary install-time code runs, and document why each entry is the
way it is.
Records the why behind the Yarn 4 → pnpm move (PR #39): ecosystem
alignment, strict-linker phantom-dependency safety, build-script
allowlisting, and the package-manager-independent constraints script.
Captures migration-time install benchmarks and notes the gate suite
passes unchanged on pnpm.
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.
materialize() removed the temp hard link in a finally that ran BEFORE
syncDir() and before state.materialized/cursor advanced. If link()
succeeded (log published) but the temp rm then threw, materialize()
rejected after publishing — leaving state.materialized false, so the
buffered events stayed unpersisted and every retry wedged on the
"already exists" exists() backstop.
Restructured to the robust shape: track link() success; on link failure
remove the temp (the only reference) before propagating; on success
fsync the directory, mark materialized, THEN best-effort remove the
now-redundant temp link (a leftover *.tmp is harmless and never read).
A temp-rm failure can no longer reject a session whose log published.
The resume seam intentionally throws a plain Error (the JSDoc and #34
were aligned to "clear error"). Match the ADR 0016 prose, which still
said "typed error". Docs-only; no behavior change. (Also carries the
#32 finalizer-containment fixes via the forward merge.)
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.
- load() no longer DELETEs the crash tail — it stays non-mutating w.r.t.
the event log and records a repair point (repairFrom). The next append
runs the DELETE inside its own transaction before inserting. This makes
the SQLite backend honor the SAME public contract as JSONL (load returns
the committed prefix; the subsequent append performs the one-time
physical truncation-repair), instead of mutating during load.
- All-tail load: when the discarded crash tail was the session's only
committed content (committed.length === 0), the metadata row still read
materialized = 1 from the prior append, so has()/list() reported a
session load() had just emptied. load() now flips the row's materialized
flag to 0 (metadata only — the orphaned event rows are still removed by
the deferred repair), so has()/list() are immediately consistent.
- Schema version: openDatabase now stores SCHEMA_VERSION in PRAGMA
user_version on a fresh database and rejects opening one whose
user_version is newer than this build supports, protecting against a
future incompatible layout.
Regression tests: load is non-mutating (tail rows survive until the next
append), all-tail load makes has()/list() false, and a newer-schema
database is rejected on open.