@os-eco/overstory-cli 0.9.4 → 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 (110) hide show
  1. package/README.md +47 -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 +54 -0
  26. package/src/commands/coordinator.test.ts +127 -0
  27. package/src/commands/coordinator.ts +203 -5
  28. package/src/commands/dashboard.test.ts +188 -0
  29. package/src/commands/dashboard.ts +13 -3
  30. package/src/commands/doctor.ts +3 -1
  31. package/src/commands/group.test.ts +94 -0
  32. package/src/commands/group.ts +49 -20
  33. package/src/commands/init.test.ts +8 -0
  34. package/src/commands/init.ts +8 -1
  35. package/src/commands/log.test.ts +56 -11
  36. package/src/commands/log.ts +134 -69
  37. package/src/commands/mail.test.ts +162 -0
  38. package/src/commands/mail.ts +64 -9
  39. package/src/commands/merge.test.ts +112 -1
  40. package/src/commands/merge.ts +17 -4
  41. package/src/commands/nudge.test.ts +351 -4
  42. package/src/commands/nudge.ts +356 -34
  43. package/src/commands/run.test.ts +43 -7
  44. package/src/commands/serve/build.test.ts +202 -0
  45. package/src/commands/serve/build.ts +206 -0
  46. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  47. package/src/commands/serve/coordinator-actions.ts +408 -0
  48. package/src/commands/serve/dev.test.ts +168 -0
  49. package/src/commands/serve/dev.ts +117 -0
  50. package/src/commands/serve/mail-actions.test.ts +312 -0
  51. package/src/commands/serve/mail-actions.ts +167 -0
  52. package/src/commands/serve/rest.test.ts +1323 -0
  53. package/src/commands/serve/rest.ts +708 -0
  54. package/src/commands/serve/static.ts +51 -0
  55. package/src/commands/serve/ws.test.ts +361 -0
  56. package/src/commands/serve/ws.ts +332 -0
  57. package/src/commands/serve.test.ts +459 -0
  58. package/src/commands/serve.ts +565 -0
  59. package/src/commands/sling.test.ts +73 -1
  60. package/src/commands/sling.ts +149 -64
  61. package/src/commands/status.test.ts +9 -0
  62. package/src/commands/status.ts +12 -4
  63. package/src/commands/stop.test.ts +174 -1
  64. package/src/commands/stop.ts +107 -8
  65. package/src/commands/watch.test.ts +43 -0
  66. package/src/commands/watch.ts +153 -28
  67. package/src/config.ts +23 -0
  68. package/src/doctor/consistency.test.ts +106 -0
  69. package/src/doctor/consistency.ts +48 -1
  70. package/src/doctor/serve.test.ts +95 -0
  71. package/src/doctor/serve.ts +86 -0
  72. package/src/doctor/types.ts +2 -1
  73. package/src/doctor/watchdog.ts +57 -1
  74. package/src/events/tailer.test.ts +234 -1
  75. package/src/events/tailer.ts +90 -0
  76. package/src/index.ts +53 -6
  77. package/src/json.ts +29 -0
  78. package/src/mail/client.ts +15 -2
  79. package/src/mail/store.test.ts +82 -0
  80. package/src/mail/store.ts +41 -4
  81. package/src/merge/lock.test.ts +149 -0
  82. package/src/merge/lock.ts +140 -0
  83. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  84. package/src/runtimes/claude.test.ts +791 -1
  85. package/src/runtimes/claude.ts +323 -1
  86. package/src/runtimes/connections.test.ts +141 -1
  87. package/src/runtimes/connections.ts +73 -4
  88. package/src/runtimes/headless-connection.test.ts +264 -0
  89. package/src/runtimes/headless-connection.ts +158 -0
  90. package/src/runtimes/types.ts +10 -0
  91. package/src/schema-consistency.test.ts +1 -0
  92. package/src/sessions/store.test.ts +390 -24
  93. package/src/sessions/store.ts +184 -19
  94. package/src/test-setup.test.ts +31 -0
  95. package/src/test-setup.ts +28 -0
  96. package/src/types.ts +56 -1
  97. package/src/utils/pid.test.ts +85 -1
  98. package/src/utils/pid.ts +86 -1
  99. package/src/utils/process-scan.test.ts +53 -0
  100. package/src/utils/process-scan.ts +76 -0
  101. package/src/watchdog/daemon.test.ts +1520 -411
  102. package/src/watchdog/daemon.ts +442 -83
  103. package/src/watchdog/health.test.ts +157 -0
  104. package/src/watchdog/health.ts +92 -25
  105. package/src/worktree/process.test.ts +71 -0
  106. package/src/worktree/process.ts +25 -5
  107. package/src/worktree/tmux.test.ts +3 -0
  108. package/src/worktree/tmux.ts +10 -3
  109. package/templates/CLAUDE.md.tmpl +19 -8
  110. package/templates/overlay.md.tmpl +3 -2
@@ -339,6 +339,114 @@ export function getTrackerCloseGuards(): HookEntry[] {
339
339
  ];
340
340
  }
341
341
 
342
+ /**
343
+ * Build a PreToolUse guard script that enforces the merge_ready gate on lead
344
+ * agents (overstory-3899, overstory-da9b): a lead may not run
345
+ * `sd/bd close $OVERSTORY_TASK_ID` unless (a) it has sent at least one
346
+ * `merge_ready` mail AND has sent at least one `merge_ready` per `worker_done`
347
+ * it has received, AND (b) the lead's branch (worktree HEAD) is reachable
348
+ * from the merge target (session-branch.txt > "main") via
349
+ * `git merge-base --is-ancestor`. (a) proves the lead reported completion;
350
+ * (b) proves the coordinator actually merged the work.
351
+ *
352
+ * Counts are derived by querying `ov mail list --json` and grep-counting
353
+ * `"id":"` occurrences in the JSON response (no jq dependency). The gate
354
+ * is a no-op for non-lead agents because it is only deployed to leads via
355
+ * `getLeadCloseGateGuards()`, but it still self-protects: the script
356
+ * exits early when OVERSTORY_AGENT_NAME or OVERSTORY_TASK_ID is unset.
357
+ * The merge-ancestor check fails open when OVERSTORY_WORKTREE_PATH is unset
358
+ * or the target ref cannot be resolved locally — in those cases we cannot
359
+ * make a definitive claim, so we don't block.
360
+ *
361
+ * Foreign-task closes are caught earlier by `buildTrackerCloseGuardScript`,
362
+ * so this gate only fires when the issue ID matches OVERSTORY_TASK_ID.
363
+ */
364
+ export function buildLeadCloseGateScript(): string {
365
+ const blockNoMergeReady = JSON.stringify({
366
+ decision: "block",
367
+ reason:
368
+ 'merge_ready gate: cannot close your task — you have not sent a merge_ready mail to coordinator. Required: ov mail send --to coordinator --subject "merge_ready: <task>" --body "<branch + files>" --type merge_ready --from $OVERSTORY_AGENT_NAME. Then retry the close.',
369
+ });
370
+ const blockUnderCount = JSON.stringify({
371
+ decision: "block",
372
+ reason:
373
+ "merge_ready gate: cannot close your task — merge_ready count is less than worker_done received. Send one merge_ready per worker_done before closing.",
374
+ });
375
+ const blockNotMerged = JSON.stringify({
376
+ decision: "block",
377
+ reason:
378
+ "merge_ready gate: cannot close your task — your branch is not yet merged into the target (session-branch.txt or main). Wait for the coordinator to merge before closing. The merge step is what makes the work real.",
379
+ });
380
+
381
+ const script = [
382
+ // Only enforce for overstory agent sessions
383
+ ENV_GUARD,
384
+ // Skip if task ID is not set (coordinator/monitor have no task)
385
+ '[ -z "$OVERSTORY_TASK_ID" ] && exit 0;',
386
+ "read -r INPUT;",
387
+ // Extract command value from JSON
388
+ 'CMD=$(echo "$INPUT" | sed \'s/.*"command": *"\\([^"]*\\)".*/\\1/\');',
389
+ // Only inspect sd/bd close commands
390
+ "if ! echo \"$CMD\" | grep -qE '^\\s*(sd|bd)\\s+close\\s'; then exit 0; fi;",
391
+ // Extract the issue ID being closed
392
+ "ISSUE_ID=$(echo \"$CMD\" | sed -E 's/^[[:space:]]*(sd|bd)[[:space:]]+close[[:space:]]+([^ ]+).*/\\2/');",
393
+ // Only gate when the lead is closing its own task. Foreign closes are blocked by buildTrackerCloseGuardScript.
394
+ '[ "$ISSUE_ID" != "$OVERSTORY_TASK_ID" ] && exit 0;',
395
+ // Count merge_ready mails sent by this agent
396
+ 'MR=$(ov mail list --json --from "$OVERSTORY_AGENT_NAME" --type merge_ready 2>/dev/null | grep -o \'"id":"\' | wc -l | tr -d \' \');',
397
+ // Count worker_done mails received by this agent
398
+ 'WD=$(ov mail list --json --to "$OVERSTORY_AGENT_NAME" --type worker_done 2>/dev/null | grep -o \'"id":"\' | wc -l | tr -d \' \');',
399
+ // Default to 0 if the count failed for any reason.
400
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
401
+ "MR=${MR:-0}; WD=${WD:-0};",
402
+ // Block if no merge_ready was ever sent
403
+ 'if [ "$MR" -eq 0 ]; then',
404
+ ` echo '${escapeForSingleQuotedShell(blockNoMergeReady)}';`,
405
+ " exit 0;",
406
+ "fi;",
407
+ // Block if not enough merge_ready for the worker_done count
408
+ 'if [ "$MR" -lt "$WD" ]; then',
409
+ ` echo '${escapeForSingleQuotedShell(blockUnderCount)}';`,
410
+ " exit 0;",
411
+ "fi;",
412
+ // Verify the lead's branch is actually merged into the target (overstory-da9b).
413
+ // merge_ready alone doesn't prove the work landed — the coordinator may still be
414
+ // verifying or the merge may have failed.
415
+ // Skip if worktree path is missing (test envs etc.) — fail open.
416
+ '[ -z "$OVERSTORY_WORKTREE_PATH" ] && exit 0;',
417
+ // Resolve target branch: $OVERSTORY_PROJECT_ROOT/.overstory/session-branch.txt > "main"
418
+ 'TARGET="";',
419
+ 'if [ -n "$OVERSTORY_PROJECT_ROOT" ] && [ -f "$OVERSTORY_PROJECT_ROOT/.overstory/session-branch.txt" ]; then',
420
+ ' TARGET=$(tr -d "[:space:]" < "$OVERSTORY_PROJECT_ROOT/.overstory/session-branch.txt" 2>/dev/null);',
421
+ "fi;",
422
+ '[ -z "$TARGET" ] && TARGET=main;',
423
+ // If the target ref doesn't exist locally, we can't verify — fail open.
424
+ 'if ! git -C "$OVERSTORY_WORKTREE_PATH" rev-parse --verify "$TARGET" >/dev/null 2>&1; then exit 0; fi;',
425
+ // Block if HEAD is not yet an ancestor of the target.
426
+ 'if ! git -C "$OVERSTORY_WORKTREE_PATH" merge-base --is-ancestor HEAD "$TARGET" >/dev/null 2>&1; then',
427
+ ` echo '${escapeForSingleQuotedShell(blockNotMerged)}';`,
428
+ " exit 0;",
429
+ "fi;",
430
+ ].join(" ");
431
+ return script;
432
+ }
433
+
434
+ /**
435
+ * Generate the lead-only PreToolUse guard that gates `sd/bd close <own-task>`
436
+ * on merge_ready emission. Wraps `buildLeadCloseGateScript` with the standard
437
+ * PATH_PREFIX so `ov` resolves under Claude Code's minimal hook PATH.
438
+ *
439
+ * Only deployed to lead agents (see getCapabilityGuards).
440
+ */
441
+ export function getLeadCloseGateGuards(): HookEntry[] {
442
+ return [
443
+ {
444
+ matcher: "Bash",
445
+ hooks: [{ type: "command", command: `${PATH_PREFIX} ${buildLeadCloseGateScript()}` }],
446
+ },
447
+ ];
448
+ }
449
+
342
450
  /**
343
451
  * Capabilities that are allowed to modify files via Bash commands.
344
452
  * These get the Bash path boundary guard instead of a blanket file-modification block.
@@ -507,6 +615,13 @@ export function getCapabilityGuards(capability: string, qualityGates?: QualityGa
507
615
  guards.push(...getBashPathBoundaryGuards());
508
616
  }
509
617
 
618
+ // Lead agents get the merge_ready gate on sd/bd close (overstory-3899).
619
+ // Blocks closing the lead's own task unless at least one merge_ready mail
620
+ // has been sent and the count covers all worker_done received.
621
+ if (capability === "lead") {
622
+ guards.push(...getLeadCloseGateGuards());
623
+ }
624
+
510
625
  return guards;
511
626
  }
512
627
 
@@ -538,9 +653,23 @@ export function isOverstoryHookEntry(entry: HookEntry): boolean {
538
653
  * Overstory hooks are placed before user hooks per event type so security
539
654
  * guards run first.
540
655
  *
656
+ * In `headlessOnly` mode, only PreToolUse hooks are deployed (overstory-e24b).
657
+ * Headless Claude Code (`-p --output-format stream-json`) DOES dispatch hooks
658
+ * from settings.local.json, so PreToolUse security guards (path boundary,
659
+ * capability blocks, bash danger patterns, tracker close, lead close gate)
660
+ * are required to keep parity with tmux mode. The other hook types are dropped
661
+ * because they have headless equivalents already wired up:
662
+ * - SessionStart → buildInitialHeadlessPrompt() in sling.ts
663
+ * - UserPromptSubmit → mail injection loop owned by `ov serve`
664
+ * - PostToolUse → stream-json parser captures tool_use/tool_result
665
+ * - Stop → stream-json parser captures the `result` event
666
+ * - PreCompact → deferred (tracked separately)
667
+ *
541
668
  * @param worktreePath - Absolute path to the agent's git worktree (or project root)
542
669
  * @param agentName - The unique name of the agent
543
670
  * @param capability - Agent capability (builder, scout, reviewer, lead, merger)
671
+ * @param qualityGates - Quality gates whose commands are whitelisted as safe Bash prefixes
672
+ * @param headlessOnly - When true, deploy only PreToolUse entries (overstory-e24b)
544
673
  * @throws {AgentError} If the template is not found or the write fails
545
674
  */
546
675
  export async function deployHooks(
@@ -548,6 +677,7 @@ export async function deployHooks(
548
677
  agentName: string,
549
678
  capability = "builder",
550
679
  qualityGates?: QualityGate[],
680
+ headlessOnly = false,
551
681
  ): Promise<void> {
552
682
  const templatePath = getTemplatePath();
553
683
  const file = Bun.file(templatePath);
@@ -578,6 +708,17 @@ export async function deployHooks(
578
708
  // Parse the base config from the template
579
709
  const config = JSON.parse(content) as { hooks: Record<string, HookEntry[]> };
580
710
 
711
+ // Headless mode: drop all template-derived hook entries.
712
+ // Under spawn-per-turn (Phase 3, overstory-2cf9), the turn-runner provides
713
+ // the user prompt and emits its own observability events for every turn;
714
+ // the template's SessionStart/UserPromptSubmit/PostToolUse/Stop/PreCompact
715
+ // hooks would either double-deliver mail (UserPromptSubmit re-injects on top
716
+ // of the runner's prompt) or duplicate session_end / per-tool events.
717
+ // Only the dynamic PreToolUse security guards added below are retained.
718
+ if (headlessOnly) {
719
+ config.hooks = {};
720
+ }
721
+
581
722
  // Extend PATH in all template hook commands.
582
723
  // Claude Code invokes hooks with PATH=/usr/bin:/bin:/usr/sbin:/sbin — ~/.bun/bin
583
724
  // (where ov, ml, sd, etc. live) is not included. Prepend PATH_PREFIX so CLIs resolve.
@@ -523,7 +523,7 @@ describe("generateOverlay", () => {
523
523
  expect(output).toContain("3");
524
524
  });
525
525
 
526
- test("dispatch overrides: maxAgentsOverride of 1 enables combined lead/worker guidance", async () => {
526
+ test("dispatch overrides: maxAgentsOverride of 1 directs the lead to spend the slot on a single builder", async () => {
527
527
  const config = makeConfig({
528
528
  capability: "lead",
529
529
  maxAgentsOverride: 1,
@@ -532,8 +532,8 @@ describe("generateOverlay", () => {
532
532
  const output = await generateOverlay(config);
533
533
 
534
534
  expect(output).toContain("MAX AGENTS");
535
- expect(output).toContain("combined **lead/worker**");
536
- expect(output).toContain("only slot");
535
+ expect(output).toContain("single builder");
536
+ expect(output).toContain("Leads cannot implement directly");
537
537
  });
538
538
 
539
539
  test("dispatch overrides: maxAgentsOverride of 2 enables compressed-mode guidance", async () => {
@@ -546,7 +546,7 @@ describe("generateOverlay", () => {
546
546
 
547
547
  expect(output).toContain("MAX AGENTS");
548
548
  expect(output).toContain("compressed mode");
549
- expect(output).toContain("self-verification");
549
+ expect(output).toContain("Leads do not implement");
550
550
  });
551
551
 
552
552
  test("dispatch overrides: both skipReview and maxAgentsOverride together", async () => {
@@ -3,6 +3,26 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { DEFAULT_QUALITY_GATES } from "../config.ts";
4
4
  import { AgentError } from "../errors.ts";
5
5
  import type { OverlayConfig, QualityGate } from "../types.ts";
6
+ import { terminalMailTypesFor } from "./capabilities.ts";
7
+
8
+ /**
9
+ * Capability-specific completion-mail guidance for the dynamic overlay.
10
+ *
11
+ * Returns the terminal mail-type name and a one-line example fragment so the
12
+ * overlay can render: "ov mail send ... --type <terminalType> ...".
13
+ *
14
+ * Crucial: this MUST stay in sync with `terminalMailTypesFor()` — overstory-1a4c
15
+ * found that overlay text saying `--type result` while the runner watched only
16
+ * for `worker_done` left worker sessions stuck in `working`.
17
+ */
18
+ function completionMailTypeFor(capability: string): string {
19
+ const types = terminalMailTypesFor(capability);
20
+ // `terminalMailTypesFor` returns the canonical type first
21
+ // (worker_done for workers, merged for mergers). Use that for prose;
22
+ // agents may also use the secondary types (`merge_failed`, etc.) where
23
+ // applicable per their base prompt.
24
+ return types[0] ?? "worker_done";
25
+ }
6
26
 
7
27
  /**
8
28
  * Resolve the path to the overlay template file.
@@ -105,14 +125,14 @@ function formatDispatchOverrides(config: OverlayConfig): string {
105
125
  if (config.maxAgentsOverride === 1) {
106
126
  sections.push(
107
127
  "- **MAX AGENTS**: Your per-lead agent ceiling has been set to **1**. " +
108
- "Operate as a combined **lead/worker**: implement the task yourself unless a single specialist is absolutely necessary. " +
109
- "Do not spend your only slot on a scout or reviewer unless that specialist work is the real bottleneck.",
128
+ "Spend that slot on a single builder for the whole task skip scouts and reviewers and self-verify the builder's diff yourself. " +
129
+ "Leads cannot implement directly (Write/Edit/`git add`/`git commit` are blocked by the harness), so the one slot must be a builder.",
110
130
  );
111
131
  } else if (config.maxAgentsOverride === 2) {
112
132
  sections.push(
113
133
  "- **MAX AGENTS**: Your per-lead agent ceiling has been set to **2**. " +
114
- "Operate in compressed mode: use at most one helper at a time when possible, then complete the remaining implementation and verification yourself. " +
115
- "Prefer self-verification over spawning a separate reviewer.",
134
+ "Operate in compressed mode: spend the slots on builders (one or two), skip scouts and reviewers, and self-verify each diff yourself. " +
135
+ "Leads do not implement; every change requires a builder spawn.",
116
136
  );
117
137
  } else {
118
138
  sections.push(
@@ -202,14 +222,15 @@ export function formatQualityGatesCapabilities(gates: QualityGate[] | undefined)
202
222
 
203
223
  function formatQualityGates(config: OverlayConfig): string {
204
224
  if (READ_ONLY_CAPABILITIES.has(config.capability)) {
225
+ const completionType = completionMailTypeFor(config.capability);
205
226
  return [
206
227
  "## Completion",
207
228
  "",
208
229
  "Before reporting completion:",
209
230
  "",
210
231
  `1. **Record mulch learnings:** \`ml record <domain> --type <convention|pattern|reference> --description "..."\` — capture reusable knowledge from your work`,
211
- `2. **Close issue:** \`${config.trackerCli ?? "sd"} close ${config.taskId} --reason "summary of findings"\``,
212
- `3. **Send results:** \`ov mail send --to ${config.parentAgent ?? "coordinator"} --subject "done" --body "Summary" --type result --agent ${config.agentName}\``,
232
+ `2. **Signal completion:** send \`${completionType}\` mail to ${config.parentAgent ?? "coordinator"}: \`ov mail send --to ${config.parentAgent ?? "coordinator"} --subject "Worker done: ${config.taskId}" --body "Summary of findings" --type ${completionType} --agent ${config.agentName}\``,
233
+ `3. **Close issue:** \`${config.trackerCli ?? "sd"} close ${config.taskId} --reason "summary of findings"\``,
213
234
  "",
214
235
  "You are a read-only agent. Do NOT commit, modify files, or run quality gates.",
215
236
  ].join("\n");
@@ -245,13 +266,14 @@ function formatQualityGates(config: OverlayConfig): string {
245
266
  * Writable agents get file-scope and branch constraints.
246
267
  */
247
268
  function formatConstraints(config: OverlayConfig): string {
269
+ const completionType = completionMailTypeFor(config.capability);
248
270
  if (READ_ONLY_CAPABILITIES.has(config.capability)) {
249
271
  return [
250
272
  "## Constraints",
251
273
  "",
252
274
  "- You are **read-only**: do NOT modify, create, or delete any files",
253
275
  "- Do NOT commit, push, or make any git state changes",
254
- `- Report completion via \`${config.trackerCli ?? "sd"} close\` AND \`ov mail send --type result\``,
276
+ `- Report completion via \`${config.trackerCli ?? "sd"} close\` AND \`ov mail send --type ${completionType}\``,
255
277
  "- If you encounter a blocking issue, send mail with `--priority urgent --type error`",
256
278
  ].join("\n");
257
279
  }
@@ -264,7 +286,7 @@ function formatConstraints(config: OverlayConfig): string {
264
286
  "- Only modify files in your File Scope",
265
287
  `- Commit only to your branch: ${config.branchName}`,
266
288
  "- Never push to the canonical branch",
267
- `- Report completion via \`${config.trackerCli ?? "sd"} close\` AND \`ov mail send --type result\``,
289
+ `- Report completion via \`${config.trackerCli ?? "sd"} close\` AND \`ov mail send --type ${completionType}\``,
268
290
  "- If you encounter a blocking issue, send mail with `--priority urgent --type error`",
269
291
  ].join("\n");
270
292
  }
@@ -0,0 +1,181 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ _resetInProcessLocks,
7
+ acquireTurnLock,
8
+ readTurnLock,
9
+ turnLockDbPath,
10
+ } from "./turn-lock.ts";
11
+
12
+ describe("turn-lock", () => {
13
+ let overstoryDir: string;
14
+
15
+ beforeEach(async () => {
16
+ overstoryDir = await mkdtemp(join(tmpdir(), "overstory-turnlock-test-"));
17
+ _resetInProcessLocks();
18
+ });
19
+
20
+ afterEach(async () => {
21
+ _resetInProcessLocks();
22
+ await rm(overstoryDir, { recursive: true, force: true });
23
+ });
24
+
25
+ test("turnLockDbPath joins overstory dir + turn-locks.db", () => {
26
+ expect(turnLockDbPath("/tmp/overstory")).toBe("/tmp/overstory/turn-locks.db");
27
+ });
28
+
29
+ test("acquire creates the row and records holder pid", async () => {
30
+ const handle = await acquireTurnLock({ agentName: "alpha", overstoryDir });
31
+ try {
32
+ const state = readTurnLock(overstoryDir, "alpha");
33
+ expect(state.heldByPid).toBe(process.pid);
34
+ expect(state.acquiredAt).toBeTruthy();
35
+ } finally {
36
+ handle.release();
37
+ }
38
+ });
39
+
40
+ test("release clears the row and is idempotent", async () => {
41
+ const handle = await acquireTurnLock({ agentName: "alpha", overstoryDir });
42
+ handle.release();
43
+ // Calling release a second time must not throw.
44
+ handle.release();
45
+ const state = readTurnLock(overstoryDir, "alpha");
46
+ expect(state.heldByPid).toBeNull();
47
+ expect(state.acquiredAt).toBeNull();
48
+ });
49
+
50
+ test("two acquires for the same agent serialize via in-process queue", async () => {
51
+ // Track entry/exit windows via timestamps. The second call must start
52
+ // AFTER the first releases, never overlap.
53
+ const events: Array<{ id: number; phase: "enter" | "exit"; ts: number }> = [];
54
+
55
+ const work = async (id: number, holdMs: number): Promise<void> => {
56
+ const handle = await acquireTurnLock({ agentName: "shared", overstoryDir });
57
+ events.push({ id, phase: "enter", ts: Date.now() });
58
+ await Bun.sleep(holdMs);
59
+ events.push({ id, phase: "exit", ts: Date.now() });
60
+ handle.release();
61
+ };
62
+
63
+ await Promise.all([work(1, 100), work(2, 50)]);
64
+
65
+ // Sort events by timestamp; verify each acquire's enter follows the
66
+ // previous holder's exit.
67
+ const ordered = [...events].sort((a, b) => a.ts - b.ts);
68
+ expect(ordered.length).toBe(4);
69
+ expect(ordered[0]?.phase).toBe("enter");
70
+ expect(ordered[1]?.phase).toBe("exit");
71
+ expect(ordered[1]?.id).toBe(ordered[0]?.id);
72
+ expect(ordered[2]?.phase).toBe("enter");
73
+ expect(ordered[3]?.phase).toBe("exit");
74
+ expect(ordered[3]?.id).toBe(ordered[2]?.id);
75
+ });
76
+
77
+ test("acquires for different agents proceed concurrently", async () => {
78
+ // Both calls should overlap because the in-process map is keyed per agent.
79
+ let active = 0;
80
+ let maxActive = 0;
81
+ const work = async (name: string): Promise<void> => {
82
+ const handle = await acquireTurnLock({ agentName: name, overstoryDir });
83
+ active++;
84
+ maxActive = Math.max(maxActive, active);
85
+ await Bun.sleep(80);
86
+ active--;
87
+ handle.release();
88
+ };
89
+
90
+ await Promise.all([work("alpha"), work("beta"), work("gamma")]);
91
+ expect(maxActive).toBeGreaterThan(1);
92
+ });
93
+
94
+ test("stale lock (dead pid) is taken over by next acquirer", async () => {
95
+ // Inject _isProcessAlive that says the recorded holder is gone.
96
+ const handle = await acquireTurnLock({
97
+ agentName: "stale",
98
+ overstoryDir,
99
+ ownerPid: 99999, // pretend we are this dead pid
100
+ _isProcessAlive: () => true, // claim alive to plant the lock
101
+ });
102
+ // Don't call release() — we want the row to look orphaned.
103
+
104
+ // Reset in-process locks so the next call is not blocked by the queue
105
+ // from the same Bun process. Cross-process is what we are exercising.
106
+ _resetInProcessLocks();
107
+
108
+ const stolen = await acquireTurnLock({
109
+ agentName: "stale",
110
+ overstoryDir,
111
+ ownerPid: 12345,
112
+ _isProcessAlive: () => false, // prior holder reported dead
113
+ });
114
+ try {
115
+ const state = readTurnLock(overstoryDir, "stale");
116
+ expect(state.heldByPid).toBe(12345);
117
+ } finally {
118
+ stolen.release();
119
+ // release() of the original handle would still be safe because the
120
+ // row pid no longer matches its ownerPid (99999).
121
+ handle.release();
122
+ }
123
+ });
124
+
125
+ test("acquire times out when the lock is held by a live foreign pid", async () => {
126
+ // Plant a lock owned by a different live pid (we say always-alive).
127
+ const planted = await acquireTurnLock({
128
+ agentName: "blocked",
129
+ overstoryDir,
130
+ ownerPid: 77777,
131
+ _isProcessAlive: () => true,
132
+ });
133
+ // Intentionally do NOT release planted — keep the row active.
134
+
135
+ _resetInProcessLocks();
136
+
137
+ const start = Date.now();
138
+ await expect(
139
+ acquireTurnLock({
140
+ agentName: "blocked",
141
+ overstoryDir,
142
+ ownerPid: 88888,
143
+ _isProcessAlive: () => true,
144
+ timeoutMs: 200,
145
+ pollMs: 25,
146
+ }),
147
+ ).rejects.toThrow(/timed out/);
148
+ const elapsed = Date.now() - start;
149
+ expect(elapsed).toBeGreaterThanOrEqual(150);
150
+
151
+ planted.release();
152
+ });
153
+
154
+ test("re-acquire by the same owner pid is allowed (re-entrant by pid)", async () => {
155
+ // First acquire records pid X. A subsequent acquire by the same pid
156
+ // (after in-process queue clears) should succeed without timing out
157
+ // even if the row still names X — this models recovery from a crashed
158
+ // in-process holder where the SQLite row was never released.
159
+ const first = await acquireTurnLock({
160
+ agentName: "reentry",
161
+ overstoryDir,
162
+ ownerPid: 4242,
163
+ });
164
+ // Simulate an in-process crash that lost the in-process tail.
165
+ _resetInProcessLocks();
166
+
167
+ const second = await acquireTurnLock({
168
+ agentName: "reentry",
169
+ overstoryDir,
170
+ ownerPid: 4242,
171
+ timeoutMs: 500,
172
+ });
173
+ try {
174
+ const state = readTurnLock(overstoryDir, "reentry");
175
+ expect(state.heldByPid).toBe(4242);
176
+ } finally {
177
+ second.release();
178
+ first.release();
179
+ }
180
+ });
181
+ });