@os-eco/overstory-cli 0.9.3 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Per-agent serialization lock for the spawn-per-turn engine.
3
+ *
4
+ * Ensures that two `runTurn` calls for the same agent never overlap. The lock
5
+ * is layered:
6
+ *
7
+ * 1. **In-process layer** — a module-level `Map<agentName, Promise<void>>`
8
+ * chained via `.then`. Concurrent calls inside the same Bun process queue up.
9
+ *
10
+ * 2. **Cross-process layer** — a SQLite-backed lease at
11
+ * `{overstoryDir}/turn-locks.db`. Each agent has at most one row in
12
+ * `turn_locks(agent_name, held_by_pid, acquired_at)`. Acquire wraps the
13
+ * state change in `BEGIN IMMEDIATE` so two processes cannot claim the same
14
+ * row in the same instant. The transaction itself is short — it does not
15
+ * span the whole turn — so other agents' acquires are not blocked.
16
+ *
17
+ * Stale leases (where `held_by_pid` is no longer alive) are stolen on the
18
+ * next acquire attempt. Both layers are released when `release()` is called.
19
+ */
20
+
21
+ import { Database } from "bun:sqlite";
22
+ import { join } from "node:path";
23
+
24
+ /** In-process serialization tail per agent. Holds the latest queued promise. */
25
+ const inProcessTails = new Map<string, Promise<void>>();
26
+
27
+ export interface TurnLockHandle {
28
+ readonly agentName: string;
29
+ /** Release both layers. Idempotent. */
30
+ release(): void;
31
+ }
32
+
33
+ export interface AcquireTurnLockOpts {
34
+ agentName: string;
35
+ overstoryDir: string;
36
+ /** Process id recorded as the holder. Defaults to `process.pid`. */
37
+ ownerPid?: number;
38
+ /** Maximum time to wait for cross-process acquisition, in ms. Default 60_000. */
39
+ timeoutMs?: number;
40
+ /** Polling interval between cross-process retries when contended. Default 50ms. */
41
+ pollMs?: number;
42
+ /** Test injection: time source. */
43
+ _now?: () => number;
44
+ /** Test injection: liveness check (default uses `process.kill(pid, 0)`). */
45
+ _isProcessAlive?: (pid: number) => boolean;
46
+ /** Test injection: explicit DB path (overrides `{overstoryDir}/turn-locks.db`). */
47
+ _dbPath?: string;
48
+ }
49
+
50
+ const CREATE_TABLE = `
51
+ CREATE TABLE IF NOT EXISTS turn_locks (
52
+ agent_name TEXT PRIMARY KEY,
53
+ held_by_pid INTEGER,
54
+ acquired_at TEXT
55
+ )`;
56
+
57
+ /** Default cross-process database path. */
58
+ export function turnLockDbPath(overstoryDir: string): string {
59
+ return join(overstoryDir, "turn-locks.db");
60
+ }
61
+
62
+ function defaultIsProcessAlive(pid: number): boolean {
63
+ if (!Number.isFinite(pid) || pid <= 0) return false;
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ } catch (err) {
68
+ // EPERM means the process exists but we lack permission to signal it.
69
+ // Treat as alive so we don't steal an active lock.
70
+ const code = (err as NodeJS.ErrnoException).code;
71
+ return code === "EPERM";
72
+ }
73
+ }
74
+
75
+ function openDb(path: string): Database {
76
+ const db = new Database(path);
77
+ db.exec("PRAGMA journal_mode = WAL");
78
+ db.exec("PRAGMA synchronous = NORMAL");
79
+ db.exec("PRAGMA busy_timeout = 5000");
80
+ db.exec(CREATE_TABLE);
81
+ return db;
82
+ }
83
+
84
+ /**
85
+ * Acquire the per-agent turn lock. Resolves once both layers are held.
86
+ *
87
+ * The returned handle MUST be released — failure to do so leaves a stale row
88
+ * that future acquires will treat as held until the holder's pid expires.
89
+ */
90
+ export async function acquireTurnLock(opts: AcquireTurnLockOpts): Promise<TurnLockHandle> {
91
+ const { agentName, overstoryDir } = opts;
92
+ const ownerPid = opts.ownerPid ?? process.pid;
93
+ const now = opts._now ?? (() => Date.now());
94
+ const isProcessAlive = opts._isProcessAlive ?? defaultIsProcessAlive;
95
+ const dbPath = opts._dbPath ?? turnLockDbPath(overstoryDir);
96
+ const timeoutMs = opts.timeoutMs ?? 60_000;
97
+ const pollMs = opts.pollMs ?? 50;
98
+
99
+ // === Layer 1: in-process serialization ===
100
+ const previous = inProcessTails.get(agentName) ?? Promise.resolve();
101
+ let inProcessRelease!: () => void;
102
+ const current = new Promise<void>((resolve) => {
103
+ inProcessRelease = resolve;
104
+ });
105
+ inProcessTails.set(
106
+ agentName,
107
+ previous.then(() => current),
108
+ );
109
+ await previous;
110
+
111
+ // === Layer 2: cross-process SQLite lease ===
112
+ const db = openDb(dbPath);
113
+ const ensureRowStmt = db.prepare<void, { $n: string }>(
114
+ "INSERT OR IGNORE INTO turn_locks (agent_name, held_by_pid, acquired_at) VALUES ($n, NULL, NULL)",
115
+ );
116
+ const selectStmt = db.prepare<
117
+ { held_by_pid: number | null; acquired_at: string | null },
118
+ { $n: string }
119
+ >("SELECT held_by_pid, acquired_at FROM turn_locks WHERE agent_name = $n");
120
+ const claimStmt = db.prepare<void, { $n: string; $p: number; $a: string }>(
121
+ "UPDATE turn_locks SET held_by_pid = $p, acquired_at = $a WHERE agent_name = $n",
122
+ );
123
+ const releaseStmt = db.prepare<void, { $n: string; $p: number }>(
124
+ "UPDATE turn_locks SET held_by_pid = NULL, acquired_at = NULL WHERE agent_name = $n AND held_by_pid = $p",
125
+ );
126
+
127
+ const tearDown = (): void => {
128
+ try {
129
+ db.close();
130
+ } catch {
131
+ // best-effort
132
+ }
133
+ inProcessRelease();
134
+ };
135
+
136
+ const deadline = now() + timeoutMs;
137
+ let acquired = false;
138
+
139
+ while (!acquired) {
140
+ try {
141
+ db.exec("BEGIN IMMEDIATE");
142
+ } catch (err) {
143
+ // busy_timeout exhausted — fall through to retry until our own deadline.
144
+ if (now() >= deadline) {
145
+ tearDown();
146
+ throw err;
147
+ }
148
+ await Bun.sleep(pollMs);
149
+ continue;
150
+ }
151
+
152
+ try {
153
+ ensureRowStmt.run({ $n: agentName });
154
+ const row = selectStmt.get({ $n: agentName });
155
+ const held = row?.held_by_pid ?? null;
156
+ const stale = held !== null && !isProcessAlive(held);
157
+ if (held === null || stale || held === ownerPid) {
158
+ claimStmt.run({
159
+ $n: agentName,
160
+ $p: ownerPid,
161
+ $a: new Date(now()).toISOString(),
162
+ });
163
+ db.exec("COMMIT");
164
+ acquired = true;
165
+ } else {
166
+ db.exec("ROLLBACK");
167
+ }
168
+ } catch (err) {
169
+ try {
170
+ db.exec("ROLLBACK");
171
+ } catch {
172
+ // ignore
173
+ }
174
+ tearDown();
175
+ throw err;
176
+ }
177
+
178
+ if (!acquired) {
179
+ if (now() >= deadline) {
180
+ tearDown();
181
+ throw new Error(
182
+ `turn-lock: timed out after ${timeoutMs}ms acquiring lock for "${agentName}"`,
183
+ );
184
+ }
185
+ await Bun.sleep(pollMs);
186
+ }
187
+ }
188
+
189
+ let released = false;
190
+ return {
191
+ agentName,
192
+ release(): void {
193
+ if (released) return;
194
+ released = true;
195
+ try {
196
+ releaseStmt.run({ $n: agentName, $p: ownerPid });
197
+ } catch {
198
+ // best-effort: SQL failure must not block in-process release.
199
+ }
200
+ try {
201
+ db.close();
202
+ } catch {
203
+ // best-effort
204
+ }
205
+ inProcessRelease();
206
+ },
207
+ };
208
+ }
209
+
210
+ /** Inspect the persisted lock state. Used by tests and diagnostics. */
211
+ export function readTurnLock(
212
+ overstoryDir: string,
213
+ agentName: string,
214
+ dbPath?: string,
215
+ ): { heldByPid: number | null; acquiredAt: string | null } {
216
+ const db = openDb(dbPath ?? turnLockDbPath(overstoryDir));
217
+ try {
218
+ const stmt = db.prepare<
219
+ { held_by_pid: number | null; acquired_at: string | null },
220
+ { $n: string }
221
+ >("SELECT held_by_pid, acquired_at FROM turn_locks WHERE agent_name = $n");
222
+ const row = stmt.get({ $n: agentName });
223
+ return {
224
+ heldByPid: row?.held_by_pid ?? null,
225
+ acquiredAt: row?.acquired_at ?? null,
226
+ };
227
+ } finally {
228
+ db.close();
229
+ }
230
+ }
231
+
232
+ /** Reset the in-process tail map. Used by tests; not exported through index. */
233
+ export function _resetInProcessLocks(): void {
234
+ inProcessTails.clear();
235
+ }
@@ -0,0 +1,182 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AgentRuntime } from "../runtimes/types.ts";
3
+ import type { AgentManifest, AgentSession, OverstoryConfig } from "../types.ts";
4
+ import { buildRunTurnOptsFactory, isSpawnPerTurnAgent } from "./turn-runner-dispatch.ts";
5
+
6
+ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
7
+ return {
8
+ id: "session-1",
9
+ agentName: "build-agent-1",
10
+ capability: "builder",
11
+ worktreePath: "/tmp/wt-build",
12
+ branchName: "overstory/b/task-1",
13
+ taskId: "task-1",
14
+ tmuxSession: "",
15
+ state: "working",
16
+ pid: 12345,
17
+ parentAgent: "lead-1",
18
+ depth: 1,
19
+ runId: "run-abc",
20
+ startedAt: new Date().toISOString(),
21
+ lastActivity: new Date().toISOString(),
22
+ escalationLevel: 0,
23
+ stalledSince: null,
24
+ transcriptPath: null,
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ function makeConfig(overrides: Partial<OverstoryConfig> = {}): OverstoryConfig {
30
+ const base: OverstoryConfig = {
31
+ project: {
32
+ name: "test",
33
+ root: "/tmp/proj",
34
+ canonicalBranch: "main",
35
+ },
36
+ agents: {
37
+ baseDir: "agents",
38
+ manifestPath: ".overstory/agent-manifest.json",
39
+ maxConcurrent: 5,
40
+ maxSessionsPerRun: 0,
41
+ maxAgentsPerLead: 5,
42
+ maxDepth: 2,
43
+ staggerDelayMs: 0,
44
+ autoNudgeOnMail: false,
45
+ },
46
+ worktrees: { baseDir: ".overstory/worktrees" },
47
+ merge: { mode: "manual" },
48
+ mulch: { enabled: false, domains: {} },
49
+ canopy: { enabled: false },
50
+ taskTracker: { backend: "seeds", enabled: true },
51
+ watchdog: {
52
+ tier0Enabled: false,
53
+ tier0IntervalMs: 30_000,
54
+ tier1Enabled: false,
55
+ maxEscalationLevel: 3,
56
+ },
57
+ models: {},
58
+ logging: { verbose: false, redactSecrets: true },
59
+ runtime: {
60
+ default: "claude",
61
+ },
62
+ providers: {},
63
+ } as unknown as OverstoryConfig;
64
+ return { ...base, ...overrides } as OverstoryConfig;
65
+ }
66
+
67
+ function makeManifest(): AgentManifest {
68
+ return {
69
+ version: "1",
70
+ agents: {
71
+ builder: {
72
+ file: "builder.md",
73
+ model: "claude-sonnet",
74
+ tools: [],
75
+ capabilities: ["build"],
76
+ canSpawn: false,
77
+ constraints: [],
78
+ },
79
+ },
80
+ capabilityIndex: { build: ["builder"] },
81
+ } as AgentManifest;
82
+ }
83
+
84
+ function fakeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
85
+ return {
86
+ id: "claude",
87
+ stability: "stable",
88
+ instructionPath: ".claude/CLAUDE.md",
89
+ buildSpawnCommand: () => "",
90
+ buildPrintCommand: () => [],
91
+ deployConfig: async () => {},
92
+ buildEnv: () => ({}),
93
+ buildDirectSpawn: () => ["claude"],
94
+ parseEvents: async function* () {},
95
+ ...overrides,
96
+ } as unknown as AgentRuntime;
97
+ }
98
+
99
+ describe("buildRunTurnOptsFactory", () => {
100
+ test("threads session metadata + project paths into runTurn opts", () => {
101
+ const session = makeSession();
102
+ const config = makeConfig();
103
+ const manifest = makeManifest();
104
+ const runtime = fakeRuntime();
105
+
106
+ const factory = buildRunTurnOptsFactory({
107
+ session,
108
+ config,
109
+ manifest,
110
+ overstoryDir: "/tmp/proj/.overstory",
111
+ _getRuntime: () => runtime,
112
+ _resolveModel: () => ({ model: "claude-sonnet", isExplicitOverride: false }),
113
+ });
114
+
115
+ const opts = factory.build('{"type":"user"}\n');
116
+ expect(opts.agentName).toBe("build-agent-1");
117
+ expect(opts.capability).toBe("builder");
118
+ expect(opts.worktreePath).toBe("/tmp/wt-build");
119
+ expect(opts.taskId).toBe("task-1");
120
+ expect(opts.runId).toBe("run-abc");
121
+ expect(opts.projectRoot).toBe("/tmp/proj");
122
+ expect(opts.mailDbPath).toBe("/tmp/proj/.overstory/mail.db");
123
+ expect(opts.eventsDbPath).toBe("/tmp/proj/.overstory/events.db");
124
+ expect(opts.sessionsDbPath).toBe("/tmp/proj/.overstory/sessions.db");
125
+ expect(opts.userTurnNdjson).toBe('{"type":"user"}\n');
126
+ expect(opts.runtime).toBe(runtime);
127
+ });
128
+
129
+ test("threads merger capability through to runTurn opts", () => {
130
+ const session = makeSession({ capability: "merger", agentName: "merge-1" });
131
+ const factory = buildRunTurnOptsFactory({
132
+ session,
133
+ config: makeConfig(),
134
+ manifest: makeManifest(),
135
+ overstoryDir: "/tmp/proj/.overstory",
136
+ _getRuntime: () => fakeRuntime(),
137
+ _resolveModel: () => ({ model: "claude-sonnet", isExplicitOverride: false }),
138
+ });
139
+ expect(factory.build("x").capability).toBe("merger");
140
+ });
141
+ });
142
+
143
+ describe("isSpawnPerTurnAgent", () => {
144
+ const runtime = fakeRuntime();
145
+ const config = makeConfig();
146
+
147
+ test("returns true for a builder in non-terminal state on a claude-like runtime", () => {
148
+ const ok = isSpawnPerTurnAgent(
149
+ makeSession({ capability: "builder", state: "working" }),
150
+ config,
151
+ runtime,
152
+ );
153
+ expect(ok).toBe(true);
154
+ });
155
+
156
+ test("admits all task-scoped capabilities (scout, reviewer, merger, lead, builder)", () => {
157
+ for (const cap of ["builder", "scout", "reviewer", "merger", "lead"]) {
158
+ expect(isSpawnPerTurnAgent(makeSession({ capability: cap }), config, runtime)).toBe(true);
159
+ }
160
+ });
161
+
162
+ test("rejects persistent capabilities (coordinator, orchestrator, monitor)", () => {
163
+ for (const cap of ["coordinator", "orchestrator", "monitor"]) {
164
+ expect(isSpawnPerTurnAgent(makeSession({ capability: cap }), config, runtime)).toBe(false);
165
+ }
166
+ });
167
+
168
+ test("returns false for terminal states (completed, zombie)", () => {
169
+ expect(isSpawnPerTurnAgent(makeSession({ state: "completed" }), config, runtime)).toBe(false);
170
+ expect(isSpawnPerTurnAgent(makeSession({ state: "zombie" }), config, runtime)).toBe(false);
171
+ });
172
+
173
+ test("returns false when runtime cannot direct-spawn", () => {
174
+ const noDirectSpawn = fakeRuntime({ buildDirectSpawn: undefined });
175
+ expect(isSpawnPerTurnAgent(makeSession(), config, noDirectSpawn)).toBe(false);
176
+ });
177
+
178
+ test("returns false when runtime cannot parseEvents", () => {
179
+ const noParser = fakeRuntime({ parseEvents: undefined });
180
+ expect(isSpawnPerTurnAgent(makeSession(), config, noParser)).toBe(false);
181
+ });
182
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Helpers for dispatching user turns through `runTurn` (Phase 2 spawn-per-turn).
3
+ *
4
+ * `runTurn` (src/agents/turn-runner.ts) needs ~12 inputs to drive one builder
5
+ * turn. Most of those derive from the agent's `AgentSession` row plus
6
+ * project-level paths. Consumers that route mail or nudges through the
7
+ * spawn-per-turn engine — `ov serve`, `ov nudge` — need the same setup
8
+ * boilerplate, so it lives here.
9
+ *
10
+ * The exported `buildRunTurnOptsFactory()` resolves the runtime adapter and
11
+ * model once per agent and returns a `(userTurnNdjson) => RunTurnOpts`
12
+ * closure. The closure is small and disposable; callers re-resolve it when
13
+ * the underlying session metadata changes (e.g. on rescan).
14
+ */
15
+
16
+ import { join } from "node:path";
17
+ import { getRuntime } from "../runtimes/registry.ts";
18
+ import type { AgentRuntime } from "../runtimes/types.ts";
19
+ import type { AgentManifest, AgentSession, OverstoryConfig, ResolvedModel } from "../types.ts";
20
+ import { isTaskScopedCapability } from "./capabilities.ts";
21
+ import { resolveModel } from "./manifest.ts";
22
+ import type { RunTurnOpts } from "./turn-runner.ts";
23
+
24
+ export interface BuildOptsFactoryInput {
25
+ session: AgentSession;
26
+ config: OverstoryConfig;
27
+ manifest: AgentManifest;
28
+ overstoryDir: string;
29
+ /** Optional override (test injection). Defaults to `getRuntime`. */
30
+ _getRuntime?: typeof getRuntime;
31
+ /** Optional override (test injection). Defaults to `resolveModel`. */
32
+ _resolveModel?: typeof resolveModel;
33
+ }
34
+
35
+ export interface BuiltOptsFactory {
36
+ runtime: AgentRuntime;
37
+ resolvedModel: ResolvedModel;
38
+ /** Produces a fresh `RunTurnOpts` for the given user turn payload. */
39
+ build: (userTurnNdjson: string) => RunTurnOpts;
40
+ }
41
+
42
+ /**
43
+ * Resolve runtime + model for a session and return a factory that produces
44
+ * `RunTurnOpts` given a user-turn NDJSON payload.
45
+ *
46
+ * The factory closes over the per-agent metadata (worktreePath, capability,
47
+ * runId, etc.) so callers only have to supply the dynamic payload. The
48
+ * factory's outputs are otherwise identical run-to-run.
49
+ */
50
+ export function buildRunTurnOptsFactory(input: BuildOptsFactoryInput): BuiltOptsFactory {
51
+ const { session, config, manifest, overstoryDir } = input;
52
+ const _getRuntime = input._getRuntime ?? getRuntime;
53
+ const _resolveModel = input._resolveModel ?? resolveModel;
54
+
55
+ const runtime = _getRuntime(undefined, config, session.capability);
56
+ const fallback = manifest.agents[session.capability]?.model ?? "claude-sonnet";
57
+ const resolvedModel = _resolveModel(config, manifest, session.capability, fallback);
58
+
59
+ const mailDbPath = join(overstoryDir, "mail.db");
60
+ const eventsDbPath = join(overstoryDir, "events.db");
61
+ const sessionsDbPath = join(overstoryDir, "sessions.db");
62
+
63
+ const build = (userTurnNdjson: string): RunTurnOpts => ({
64
+ agentName: session.agentName,
65
+ capability: session.capability,
66
+ overstoryDir,
67
+ worktreePath: session.worktreePath,
68
+ projectRoot: config.project.root,
69
+ taskId: session.taskId,
70
+ userTurnNdjson,
71
+ runtime,
72
+ resolvedModel,
73
+ runId: session.runId,
74
+ mailDbPath,
75
+ eventsDbPath,
76
+ sessionsDbPath,
77
+ });
78
+
79
+ return { runtime, resolvedModel, build };
80
+ }
81
+
82
+ /**
83
+ * Predicate: is this agent eligible for spawn-per-turn dispatch?
84
+ *
85
+ * Capability gate: any task-scoped worker (builder, scout, reviewer, merger,
86
+ * lead — see {@link isTaskScopedCapability}) running on a runtime that
87
+ * implements `buildDirectSpawn`/`parseEvents` (today: claude). Non-terminal
88
+ * state required — completed/zombie sessions are never re-spawned.
89
+ *
90
+ * `_config` is unused at this layer (the project-level gate flag was removed in
91
+ * Phase 3 once spawn-per-turn became the only path for task-scoped Claude) but
92
+ * the parameter is preserved so call sites that already thread config in don't
93
+ * need to change.
94
+ */
95
+ export function isSpawnPerTurnAgent(
96
+ session: AgentSession,
97
+ _config: OverstoryConfig,
98
+ runtime: AgentRuntime,
99
+ ): boolean {
100
+ if (!isTaskScopedCapability(session.capability)) return false;
101
+ if (session.state === "completed" || session.state === "zombie") return false;
102
+ if (typeof runtime.buildDirectSpawn !== "function") return false;
103
+ if (typeof runtime.parseEvents !== "function") return false;
104
+ return true;
105
+ }