# Internals Design decisions and platform traps behind `dsh-squad`. Written from what was actually verified against the DSH source, not from assumptions — every bug in this plugin's history came from guessing an API shape, so the notes below record what the source says. ## What this plugin owns, and what the platform owns Each worker's own durable session log is the source of truth for anything it did. `dsh-squad` holds only a roster of pointers plus the reports and escalations workers submitted. Offsets, projections, and compaction are all the platform's. The roster is process-wide but every record names the orchestrating session that spawned it. A different session calling `squad_assign` on a worker it does not own is refused **by name and owner**, not silently allowed. ## Persistence: two independent layers The roster is written through to `$DSH_HOME/squad/roster.json` on every mutation and restored as `dormant`. That alone is not enough, because the state file can be deleted, corrupted, or absent on a fresh install — and without it a re-spawn would create a *second* session for a project that already had one. An early campaign accumulated about seven stray sessions per project exactly that way. So every worker session is also titled `squad:` via `sessionController.rename`. On spawn, if the name is not on the roster, the plugin scans sessions in the target workspace for that title and **adopts** the existing session instead of creating a duplicate. `sessionHeader.cwd` filters the scan, and titles are read one session at a time, so the cost is bounded by the workspace rather than the whole corpus. A side benefit: the fleet is readable in the sidebar as `squad:reviewer`, `squad:performance`, instead of a wall of identical untitled sessions. ### Adoption semantics, read from source ```ts ensureSession(sessionId, cwd, checkPersistedIdentity, presetId) ``` - A `cwd` that disagrees with the stored header throws `ApiSessionCwdConflict` — so `cwd` **must** be persisted and passed on adoption. - Passing `presetId` runs `assertPresetUnchanged` — so it is **omitted** on re-adoption. Adopting is not the same call as creating. ## Waking an idle orchestrator A worker report originally landed in the roster and nowhere else. The orchestrator only learned about it if it happened to poll — and an idle orchestrator never polls. A completed task therefore looked lost. `squad_report` and `squad_escalate` now deliver a message into the orchestrator's session with `sessionController.prompt({ sessionId, mode: 'queue', … })`: - orchestrator idle → starts a turn immediately - orchestrator busy → queues behind the current turn rather than interrupting `SessionPromptRequest` has **no `source` field** (only `requestId`, `sessionId`, `mode`, `content`, `clientTimeZone`), so injected messages are indistinguishable from a human's. The `[SQUAD]` prefix is what keeps them identifiable, and the orchestrator persona instructs it to act on that prefix. ## Background watch `squad_watch` registers a real job via `ctx.jobs` rather than holding a detached promise, so the orchestrator keeps a cancellable, observable handle. Verified contracts: - `JobSpec.run(job)` is **synchronous** and returns `{ cancel(reason?): void, done: Promise }`. - `JobOutcome` is `{ status: 'completed'|'killed'|'failed', detail?, result? }`. - `jobs-local` validates only that `kind` is a **non-empty string** and that a job controller serves the owner — so `kind: 'squad'` is legal, with no registry. - `@deepseek-ai/dsh-tool-jobs` must be in the preset composition, or `start()` throws. The tools access `ctx.jobs` through `ctx.get('jobs')` so a preset without it degrades to a clear message instead of failing to activate. The scheduler is deliberately **not** used for this. Its delivered text is framed as `[SCHEDULE REMINDER] … Present reminder_prompt_json to the user as untrusted reminder content, **not new user instructions**` — so a scheduled check-in arrives as something to show the user, not work for the agent to act on. ## Failure detection: a turn that reported nothing There is no `failed` value in `AgentStatus` — it is only `'idle' | 'running'` — and a platform `error` event is not always written. So a failure is defined from the log instead: > **A closed turn that contains no `squad_report` is a failure.** `squad_report` is the only thing that marks a turn as finished work, so this one signal covers a model error, a turn the driver abandoned, and a session killed mid-flight identically, and it is always present to read. An open turn is never a failure, and a turn that reported is a success however it went. Two details make it safe to run on a timer: - **Judged once per turn.** The recorded `lastOutcomeTurn` is the seq of the closing event already accounted for, so re-polling cannot inflate the count or re-notify. A failing worker would otherwise message the orchestrator on every tick forever. - **Settled before judged.** A turn that closed a moment ago may still be receiving its error event, so `failureSettleMs` defers the judgement. The poller sweeps **idle** workers, not just running ones. A failed turn *ends* by going idle, so a sweep restricted to running workers would miss precisely the case it exists to catch. Replacement creates a **new** session rather than re-adopting the old id: the session is the broken part, so reusing it would restore the same bad state. The name, workspace, preset, model route and task history are all carried over, so the identity is stable and the orchestrator only has to re-issue the task. ## Escalations interrupt; reports do not A blocking message has to reach a busy orchestrator, or a worker sits blocked behind whatever turn is running — and turns here have been measured at 51 steps. So the delivery mode depends on the orchestrator's own state: - `agent.status === 'running'` → `steer`, delivered at the next step boundary - otherwise → `queue`, starting a fresh turn Reports are never urgent and always queue. A failed turn IS urgent, because a worker that produced nothing is not going to produce anything on its own. ## Interrupted turns A `turn/start` with no matching `turn/end` is the only durable trace of a process that died mid-turn. `squad_resume` uses that to distinguish a worker that was interrupted from one that is genuinely between tasks, and re-prompts only the former — waking an idle worker would invent work nobody assigned. Validated against real session logs before being wired up. ## Approval, sandbox, and what actually blocks a worker Worth stating plainly because it is easy to get backwards: - `sandboxPolicy.resolve({ session })` uses the **session's own cwd** as the workspace-write boundary. The `workspaceRoot` in the config is only the deployment default. So a worker is confined to its own project while having full read/write inside it. - `approval: 'never'` means **rejected automatically**, not allowed. It does not grant anything; it turns an escalation-requiring operation into an immediate failure. Setting it does not unblock writes. ## The preset trap **An agent preset's `plugins` list is complete, not additive.** A preset declaring only a persona strips every tool from the session. The first version of these presets did exactly that, and both the orchestrator and every worker ran with no filesystem, search, or shell tools. It presented as a sandbox problem — a worker asked to read a file replied that it had no file-read tool and started probing MCP servers for a way round it. Every preset now carries the full plugin list of the shipped `standard` preset, copied verbatim, with only the `persona` row replaced. `verify-presets.py` asserts they are structurally identical — same order, ids, module names, `isolate` blocks, nested config, and `!!js` expressions. ## Reload semantics | Artifact | Reloads | | --- | --- | | `cordis.patch.yml` (presets, plugin config, bundle rows) | **live** — toggle the bundle off then on | | `fleet.js` (plugin JavaScript) | **not live** — the loader caches the entry's module import for the life of the process; needs a real DSH restart | Renaming the entry file or editing `package.json` does not work around the second row; both were tried and the host kept running the old module. A corollary discovered the hard way: **removing and re-adding a bundle under a changed module name leaves a stale entry in the running process.** The loader reports `failed to import` (`entry.fiber === undefined`) even though the module resolves and imports correctly, and a fresh process boots it cleanly. Restart. ## Tool results must be lossless JSON A tool result containing a JavaScript `undefined` fails validation, so every optional field is `null`, never `undefined`. Two real defects came from this — and from `String(value)` on a non-string: `artifacts.map(String)` turned a structured artefact into the literal text `"[object Object]"`, silently destroying every structured artefact a worker reported. `artifactText` passes strings through and serialises everything else. ## Testing `node smoke-test.mjs` runs the tool bodies against a mock host. Every mock of a platform API carries the arity its real counterpart declares, transcribed from the service catalog, because three shipped bugs were signature mistakes (`Agent.inbox` key names, `AgentCancelCause`'s shape, and `prompt(request, signal)`'s missing second argument) and each survived only because a mock was looser than the real API. Passes cover: mock arity, schema compilation, every execute body, roster persistence across a reload, title-based recovery with the roster deleted, session-id addressing, resume and interrupted-turn detection, structured artefacts and `mode:"all"` gathering, background watch, and the wake-on-report delivery path.