Skip to content

Commit 71eaa7c

Browse files
authored
Merge pull request #22 from roodriigoooo/refactor/worker-spawn-policy
Refactor/worker spawn policy
2 parents 62ec254 + ef2fc6c commit 71eaa7c

35 files changed

Lines changed: 1827 additions & 594 deletions

docs/architecture.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ Each module owns its data, its interface, and its tests. Adapters at the seam ta
1717
| Bundle Commands | `extensions/checkpoint-commands.ts` | `list` / `delete` support for saved bundles. |
1818
| Bundle Selector | `extensions/checkpoint-selector.ts` | Interactive accept/exclude before optional summarization. |
1919
| Loaded Artifact Context | `extensions/loaded-artifact-context.ts` | Mounted source slots, reference/full chip expansion, consume-on-use queue. |
20-
| Background Work | `extensions/background-work.ts` | Worker state transitions, protocol semantics, pre-flight task docs, synthetic status artifacts, heartbeat dedup. |
20+
| Worker Lifecycle | `extensions/worker-lifecycle.ts` | Pure status transitions and lifecycle selectors: review/respawn/harvest eligibility, dock-terminal age, and prune disposition. |
21+
| Background Work | `extensions/background-work.ts` | Protocol payload shaping, pre-flight task docs, synthetic status artifacts, and heartbeat artifact dedup. |
2122
| Worker Review | `extensions/worker-review.ts` | Shared Worker + Artifact projection: state, result artifact, summary, recommendations, and status-card text. |
2223
| Worker Conflicts | `extensions/worker-conflicts.ts` | Edited-file overlap detection across workers; warning text for dock, dashboard, and promote confirmation. |
2324
| Worker Verdict | `extensions/worker-verdict.ts` | Worker decision lifecycle: candidate ranking, verdict actions, decision-ledger context, and change-set promotion. |
25+
| Worker Change Review | `extensions/worker-change-review.ts` | One review operation over a deterministic change set: built-in diff, Hunk fallback, comment disposition, and worker-only comment delivery. It cannot promote or mount artifacts. |
26+
| Hunk Diff Review | `extensions/worker-diff-review.ts` | Hunk process adapter: availability, exact patch extraction, launch, comment harvesting, and comment formatting. |
2427
| Worker Commands | `extensions/worker-commands.ts` | `spawn` / `tell` / `delete` / `load` / `unload` / completion. |
25-
| Worker Store | `extensions/worker-store.ts` | Shared tmux session topology, `send-keys -l` stdin (single line) and `paste-buffer` (multiline), task doc write, session seeding. |
28+
| Worker Store | `extensions/worker-store.ts` | Shared tmux session topology, status-file locking/atomic transition persistence, `send-keys -l` stdin (single line) and `paste-buffer` (multiline), task doc write, session seeding. |
2629
| Worker Events | `extensions/worker-events.ts` | NDJSON append + tail + rotation. |
2730
| Worker Snapshot Cache | `extensions/worker-dock-cache.ts` | mtime-cached status/artifacts read, `fs.watch`, sticky recent-event ring. |
2831
| Worker Eviction | `extensions/worker-eviction.ts` | Dock idle-hide window, prune-after-hours sweep. |
@@ -31,6 +34,11 @@ Each module owns its data, its interface, and its tests. Adapters at the seam ta
3134
| Extension Surface | `extensions/docket.ts` (via `globalThis.__docket`) | `registerWorkerKind`, `listWorkerKinds`, `onWorkerEvent`. |
3235
| Navigator | `extensions/docket-navigator.ts` | View model, ranking, selection state, mode/source transitions. |
3336
| Command Router | `extensions/docket-command-router.ts` | Routes parsed intents to the modules above. |
37+
| Shared Session Runtime | `extensions/shared-session-runtime.ts` | Parent/worker-neutral registration: `/docket` routing, message rendering, mounted artifact expansion, checkpoint lifecycle, and session cleanup. |
38+
| Parent Runtime | `extensions/parent-runtime.ts` | Parent-only worker watch/dock startup and teardown. The parent owns cache refresh, reconciliation, harvest, tmux status, and dock animation. |
39+
| Worker Runtime | `extensions/worker-runtime.ts` | Worker-only guardrail/protocol registration plus heartbeat lifecycle. The worker owns protocol tools, nudges, shell fallback, event capture, and allowed child spawning. |
40+
| Docket Views | `extensions/docket-views/` | Artifact/file viewers, shared layout primitives, and router/verdict action type boundaries. Runtime state is not imported into views. |
41+
| Docket Keymap | `extensions/docket-keymap.ts` | Normalized physical-key bindings, conflict checking, and shared card/footer/help hint rendering for interactive views. |
3442

3543
## Worker lifecycle
3644

@@ -42,6 +50,8 @@ Each module owns its data, its interface, and its tests. Adapters at the seam ta
4250
6. Worker calls `docket_done` / `docket_fail` → state goes terminal → row enters `ready` / `failed` until evicted (`worker.dockIdleHideMinutes`) or pruned (`worker.pruneAfterHours`). When the prune sweep removes a terminal worker that never got a verdict (its id is absent from the decision ledger), it records a `worker_evicted_unreviewed` event first so the debt is counted before the record is gone.
4351
7. If the worker *process* dies, `remain-on-exit` keeps the dead pane. The dock's harvest sweep (`isPaneHarvestCandidate``WorkerStore.harvestPaneTail`) captures the last 200 lines to `pane-tail.txt`, kills the window, and stamps `paneCapturedAt` on the status so the probe never repeats. The tail surfaces as a `terminal tail` artifact in review and as the last lines on the failed verdict card. Workers in a terminal state whose pane is still alive (a protocol `docket_fail` with pi still running) are left untouched so you can keep chatting with them.
4452

53+
For a ready worker, the verdict card can open the deterministic change-set artifact directly or ask Hunk to annotate its exact patch. `Worker Change Review` owns the fallback to the built-in diff and comment send/copy/ignore handling. Only a successful send returns `comments-sent`; `Worker Verdict` then records the chat decision and advances the queue.
54+
4555
## Worker protocol
4656

4757
One contract for every kind. The MD body of a kind extends the universal guardrails; it does not replace them.

docs/configuration.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ Evidence bundles are bundle-first: by default `/docket save` writes a determinis
7373
| `worker.pruneAfterHours` | 24 | auto-prune `ended` worker dirs after this many hours; 0 disables. |
7474
| `worker.tmuxStatusLine` | false | write a compact summary to `docket-workers`' `status-right`. |
7575
| `worker.captureTerminal` | false | enable `tmux pipe-pane` to `<worker-dir>/pane.log` per worker. |
76-
| `worker.autoRespawn` | false | reserved; today `/docket respawn` is manual. |
7776
| `worker.autoEmbedSummary` | false | when true, append a short summary (outcome + 1-line summary + up to 5 recommended bullets) to the parent session as a worker reaches `ready`. Default false keeps the parent JSONL fully manual — the inbox card still surfaces the ready worker; nothing is auto-injected. |
7877
| `worker.parentSeedPolicy` | `none` | default parent-seed policy for `/docket spawn` when neither `--seed`/`--fresh` nor the kind sets one. `"none"` (default) spawns fresh workers with no parent context; `"full"` seeds the worker with the parent session JSONL (reuses prompt cache prefix but inherits full parent context). Use as a project-wide escape hatch when most workers need parent context. |
7978
| `worker.guardrailsPath` | bundled | absolute or cwd-relative path to a guardrail file appended to every worker prompt. |
@@ -96,7 +95,7 @@ Bundled kinds (`default`, `scout`, `patcher`) live in `extensions/worker-kinds/`
9695
| `name` || required; kebab-case slug used by `--as` |
9796
| `description` || one-line shown in `/docket kinds` |
9897
| `model` | parent | optional model override (`provider/model` string) |
99-
| `thinking` | `medium` | `off` / `low` / `medium` / `high` |
98+
| `thinking` | parent/default | optional Pi thinking override: `off` / `minimal` / `low` / `medium` / `high` / `xhigh` |
10099
| `read_only` | false | when true, appendix tells the worker not to edit files |
101100
| `default_worktree` | true | spawn this kind in a detached worktree by default |
102101
| `parent_seed` | `none` | `none` for a fresh worker; `full` to seed the worker session with the parent's JSONL (reuses prompt cache prefix but inherits full parent context — use only when the worker needs it). Per-spawn `--seed`/`--fresh` flags override. |

extensions/background-work.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { gitSnapshotLabel } from "./git-context.js";
22
import type { Artifact, GitSnapshot } from "./types.js";
3+
import { deriveWorkerLifecycleState, isPaneHarvestEligible } from "./worker-lifecycle.js";
34

45
export type WorkerState = "starting" | "active" | "idle" | "needs_input" | "ready" | "failed" | "error" | "ended";
56
export type WorkerDerivedState = "starting" | "thinking" | "stale" | "needs_input" | "ready_open_todos" | "ready" | "empty" | "failed" | "idle" | "reviewed";
@@ -88,6 +89,8 @@ export type WorkerStatus = {
8889
createdAt: string;
8990
updatedAt: string;
9091
state: WorkerState;
92+
/** Unique launch generation; prevents an old process-exit hook from changing a respawned worker. */
93+
runToken?: string;
9194
pid?: number;
9295
sessionFile?: string;
9396
model?: string;
@@ -423,22 +426,7 @@ export function workerQuestions(worker: WorkerStatus): WorkerQuestion[] {
423426
}
424427

425428
export function deriveWorkerState(worker: WorkerStatus, now = Date.now()): WorkerDerivedState {
426-
if (worker.state === "needs_input") return "needs_input";
427-
// Reviewed workers stay dim until new protocol activity or parent input clears reviewedAt.
428-
// Heartbeats do NOT clear it (a done worker still ticks updatedAt but should stay reviewed).
429-
if (worker.reviewedAt && (worker.state === "ready" || worker.state === "ended" || worker.state === "failed" || worker.state === "error")) return "reviewed";
430-
if (worker.state === "failed" || worker.state === "error") return "failed";
431-
if (worker.state === "ready") return "ready";
432-
if (worker.state === "ended") {
433-
if ((worker.artifactCount ?? 0) === 0) return "empty";
434-
return "ready";
435-
}
436-
const ageMs = now - Date.parse(worker.updatedAt);
437-
if (Number.isFinite(ageMs) && ageMs > 90_000) return "stale";
438-
if (worker.state === "active") return "thinking";
439-
if (worker.state === "starting") return "starting";
440-
if (worker.state === "idle") return "idle";
441-
return "idle";
429+
return deriveWorkerLifecycleState(worker, now);
442430
}
443431

444432
export function workerStateRank(worker: WorkerStatus, now = Date.now()): number {
@@ -578,11 +566,9 @@ export function workerStatusArtifact(worker: WorkerStatus, now = Date.now()): Ar
578566
};
579567
}
580568

581-
const PANE_HARVEST_STATES: ReadonlySet<WorkerState> = new Set(["failed", "error", "ended"]);
582-
583569
/** True when the parent's dock sweep should probe this worker's tmux pane for a post-mortem capture. */
584570
export function isPaneHarvestCandidate(worker: WorkerStatus): boolean {
585-
return PANE_HARVEST_STATES.has(worker.state) && !worker.paneCapturedAt;
571+
return isPaneHarvestEligible(worker);
586572
}
587573

588574
export const PANE_TAIL_MAX_LINES = 200;

extensions/checkpoint-selector.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2-
import { Box, Key, Text, matchesKey, truncateToWidth, type Component, type TUI } from "@mariozechner/pi-tui";
2+
import { Box, Text, truncateToWidth, type Component, type TUI } from "@mariozechner/pi-tui";
3+
import { createEvidenceBundleKeymap, formatKeyHints } from "./docket-keymap.js";
34
import type { Artifact, ArtifactKind, CheckpointMode } from "./types.js";
45

56
export type CheckpointSelectionState = {
@@ -86,18 +87,19 @@ class CheckpointSelectorView implements Component {
8687
const selected = clampSelected(this.state.selected, this.artifacts);
8788
this.state = selected === this.state.selected ? this.state : { ...this.state, selected };
8889

89-
if (matchesKey(data, Key.escape) || data === "q" || matchesKey(data, Key.ctrl("c"))) {
90+
const action = createEvidenceBundleKeymap().resolve(data);
91+
if (action === "close") {
9092
this.done(null);
9193
return;
9294
}
93-
if (data === "j" || matchesKey(data, Key.down)) this.state = { ...this.state, selected: clampSelected(selected + 1, this.artifacts) };
94-
else if (data === "k" || matchesKey(data, Key.up)) this.state = { ...this.state, selected: Math.max(0, selected - 1) };
95-
else if (data === "g") this.state = { ...this.state, selected: 0 };
96-
else if (data === "G") this.state = { ...this.state, selected: Math.max(0, this.artifacts.length - 1) };
97-
else if (data === " ") this.state = toggleCheckpointSelection(this.state);
98-
else if (data === "a") this.state = selectAllCheckpointArtifacts(this.state);
99-
else if (data === "n") this.state = selectNoCheckpointArtifacts(this.state);
100-
else if (matchesKey(data, Key.enter)) {
95+
if (action === "down") this.state = { ...this.state, selected: clampSelected(selected + 1, this.artifacts) };
96+
else if (action === "up") this.state = { ...this.state, selected: Math.max(0, selected - 1) };
97+
else if (action === "top") this.state = { ...this.state, selected: 0 };
98+
else if (action === "bottom") this.state = { ...this.state, selected: Math.max(0, this.artifacts.length - 1) };
99+
else if (action === "toggle") this.state = toggleCheckpointSelection(this.state);
100+
else if (action === "all") this.state = selectAllCheckpointArtifacts(this.state);
101+
else if (action === "none") this.state = selectNoCheckpointArtifacts(this.state);
102+
else if (action === "save") {
101103
const selectedArtifacts = selectedCheckpointArtifacts(this.artifacts, this.state);
102104
if (selectedArtifacts.length === 0) this.message = "select at least one artifact or q cancel";
103105
else this.done(selectedArtifacts);
@@ -124,7 +126,7 @@ class CheckpointSelectorView implements Component {
124126
const muted = (s: string) => this.theme.fg("muted", s);
125127
const warning = (s: string) => this.theme.fg("warning", s);
126128
const stats = checkpointSelectionStats(this.artifacts, this.state);
127-
const header = `${accent(this.theme.bold("docket · checkpoint"))} ${dim(this.mode)} ${dim("·")} ${stats.selected}/${stats.total} selected ${dim("·")} ~${stats.estimatedTokens} tok`;
129+
const header = `${accent(this.theme.bold("docket · evidence bundle"))} ${dim(this.mode)} ${dim("·")} ${stats.selected}/${stats.total} selected ${dim("·")} ~${stats.estimatedTokens} tok`;
128130
container.addChild(new Text(truncateToWidth(header, innerWidth - 2), 1, 0));
129131

130132
const windowSize = 14;
@@ -146,7 +148,7 @@ class CheckpointSelectorView implements Component {
146148
for (let i = visible.length; i < windowSize; i++) container.addChild(new Text("", 1, 0));
147149

148150
if (this.message) container.addChild(new Text(warning(this.message), 1, 0));
149-
else container.addChild(new Text(dim("space toggle · a all · n none · enter create · q cancel"), 1, 0));
151+
else container.addChild(new Text(dim(formatKeyHints(createEvidenceBundleKeymap(), "footer")), 1, 0));
150152

151153
this.cachedLines = container.render(width);
152154
this.cachedWidth = width;

extensions/docket-command-router.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { Artifact, CheckpointIndexEntry } from "./types.js";
1313
import type { WorkerCommands } from "./worker-commands.js";
1414
import type { WorkerStore } from "./worker-store.js";
1515
import { findVerdictWorker, runWorkerVerdict, runWorkerVerdictQueue, type DocketVerdictAction } from "./worker-verdict.js";
16-
import type { HunkReviewAction, HunkReviewComment, HunkReviewResult } from "./worker-diff-review.js";
16+
import type { WorkerChangeReviewOutcome, WorkerChangeReviewPreference } from "./worker-change-review.js";
1717

1818
export type DocketBrowserAction = { action: "inspect" | "openFile" | "promoteWorker" | "reference" | "injectFull" | "copy" | "save" | "search" | "tellWorker" | "verdict"; artifact?: Artifact };
1919

@@ -64,8 +64,7 @@ export type DocketCommandRouterDeps = {
6464
markWorkerUnloaded(worker: WorkerStatus): void;
6565
markAllWorkersUnloaded(): void;
6666
promoteWorkerChangeSet(artifact: Artifact): Promise<boolean>;
67-
reviewWorkerChangeSetInHunk(worker: WorkerStatus, changeSet: Artifact): Promise<HunkReviewResult>;
68-
chooseHunkReviewAction(worker: WorkerStatus, comments: HunkReviewComment[]): Promise<HunkReviewAction>;
67+
reviewWorkerChangeSet(worker: WorkerStatus, changeSet: Artifact, options: { preferred: WorkerChangeReviewPreference }): Promise<WorkerChangeReviewOutcome>;
6968
applyWorkerState(state: "needs_input" | "ready" | "failed", text?: string): Promise<void>;
7069
createCheckpoint(options: CheckpointCreateOptions): Promise<void>;
7170
createHandoffCheckpoint(): Promise<void>;

extensions/docket-config.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ export type DocketWorkerConfig = {
2020
tmuxStatusLine?: boolean;
2121
/** When true, every spawned worker also runs tmux pipe-pane to <worker-dir>/pane.log for post-hoc debug. */
2222
captureTerminal?: boolean;
23-
/** When true, /docket offers to re-window orphan workers when the shared tmux session is gone but their dirs are still live. */
24-
autoRespawn?: boolean;
2523
/** Default parent-seed policy when neither `--seed`/`--fresh` nor the kind sets one. `"none"` (default) spawns fresh workers; `"full"` seeds the worker with the parent session JSONL. */
2624
parentSeedPolicy?: "full" | "none";
2725
/** When true, a short summary message is appended to the parent session when a worker reaches ready. Default false: nothing enters the parent JSONL automatically — the inbox card still surfaces the ready worker. */
@@ -58,7 +56,6 @@ export const DEFAULT_CONFIG: DocketConfig = {
5856
maxSpawnDepth: 2,
5957
tmuxStatusLine: false,
6058
captureTerminal: false,
61-
autoRespawn: false,
6259
autoEmbedSummary: false,
6360
parentSeedPolicy: "none",
6461
},

0 commit comments

Comments
 (0)