@os-eco/overstory-cli 0.9.4 → 0.11.0

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 (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  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 +219 -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/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -2
@@ -0,0 +1,190 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ detectScopeViolation,
4
+ findScopeViolations,
5
+ hasExpansionReason,
6
+ IMPLEMENTATION_CAPABILITIES,
7
+ parseExpansionReasonsFromGitLog,
8
+ } from "./scope-detect.ts";
9
+
10
+ describe("findScopeViolations", () => {
11
+ test("literal match: in-scope file is not a violation", () => {
12
+ const result = findScopeViolations(["src/foo.ts"], ["src/foo.ts"]);
13
+ expect(result).toEqual([]);
14
+ });
15
+
16
+ test("glob match: src/foo/**/*.ts allows nested file", () => {
17
+ const result = findScopeViolations(
18
+ ["src/foo/bar/baz.ts", "src/foo/qux.ts"],
19
+ ["src/foo/**/*.ts"],
20
+ );
21
+ expect(result).toEqual([]);
22
+ });
23
+
24
+ test("out-of-scope file is reported", () => {
25
+ const result = findScopeViolations(["src/other.ts"], ["src/foo.ts"]);
26
+ expect(result).toEqual(["src/other.ts"]);
27
+ });
28
+
29
+ test("empty fileScope is treated as unrestricted", () => {
30
+ const result = findScopeViolations(["any/file.ts", "another.ts"], []);
31
+ expect(result).toEqual([]);
32
+ });
33
+
34
+ test("partial violations: returns only the out-of-scope subset", () => {
35
+ const result = findScopeViolations(
36
+ ["src/foo.ts", "src/other.ts", "src/bar.ts"],
37
+ ["src/foo.ts", "src/bar.ts"],
38
+ );
39
+ expect(result).toEqual(["src/other.ts"]);
40
+ });
41
+
42
+ test("glob match: literal path equals scope entry without a glob char", () => {
43
+ const result = findScopeViolations(["a.ts"], ["a.ts"]);
44
+ expect(result).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe("hasExpansionReason", () => {
49
+ test("expansion_reason: with value → true", () => {
50
+ expect(hasExpansionReason("expansion_reason: foo")).toBe(true);
51
+ });
52
+
53
+ test("Expansion_Reason: case-insensitive → true", () => {
54
+ expect(hasExpansionReason("Expansion_Reason: bar")).toBe(true);
55
+ });
56
+
57
+ test("EXPANSION_REASON: multi-word value → true", () => {
58
+ expect(hasExpansionReason("EXPANSION_REASON: baz quux")).toBe(true);
59
+ });
60
+
61
+ test("expansion_reason: empty value → false", () => {
62
+ expect(hasExpansionReason("expansion_reason:")).toBe(false);
63
+ });
64
+
65
+ test("expansion_reason: only whitespace → false", () => {
66
+ expect(hasExpansionReason("expansion_reason: ")).toBe(false);
67
+ });
68
+
69
+ test("expansion-reason: hyphen separator → false", () => {
70
+ expect(hasExpansionReason("expansion-reason: foo")).toBe(false);
71
+ });
72
+
73
+ test("no marker → false", () => {
74
+ expect(hasExpansionReason("no reason here")).toBe(false);
75
+ });
76
+
77
+ test("marker embedded in commit body with prefix → true", () => {
78
+ const body = "Refactor the thing\n\nexpansion_reason: had to update shared types\n";
79
+ expect(hasExpansionReason(body)).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe("parseExpansionReasonsFromGitLog", () => {
84
+ test("returns each value across multiple commit bodies", () => {
85
+ const log = [
86
+ "feat: a thing",
87
+ "",
88
+ "expansion_reason: needed shared type",
89
+ "",
90
+ "fix: another thing",
91
+ "",
92
+ "expansion_reason: had to update barrel export",
93
+ ].join("\n");
94
+ const result = parseExpansionReasonsFromGitLog(log);
95
+ expect(result).toEqual(["needed shared type", "had to update barrel export"]);
96
+ });
97
+
98
+ test("trims whitespace around values", () => {
99
+ const log = "expansion_reason: surrounded by spaces \n";
100
+ const result = parseExpansionReasonsFromGitLog(log);
101
+ expect(result).toEqual(["surrounded by spaces"]);
102
+ });
103
+
104
+ test("ignores commits without the marker", () => {
105
+ const log = "feat: regular commit\n\nfix: another\n";
106
+ expect(parseExpansionReasonsFromGitLog(log)).toEqual([]);
107
+ });
108
+
109
+ test("empty log returns empty array", () => {
110
+ expect(parseExpansionReasonsFromGitLog("")).toEqual([]);
111
+ });
112
+
113
+ test("case-insensitive match", () => {
114
+ const log = "Expansion_Reason: capitalized variant\n";
115
+ expect(parseExpansionReasonsFromGitLog(log)).toEqual(["capitalized variant"]);
116
+ });
117
+ });
118
+
119
+ describe("detectScopeViolation", () => {
120
+ test("returns expected violations and expansion reasons via stub", () => {
121
+ const stub = (args: string[]): string => {
122
+ if (args[0] === "diff") return "src/foo.ts\nsrc/other.ts\n";
123
+ if (args[0] === "log") return "feat: change\n\nexpansion_reason: cross-cutting\n";
124
+ return "";
125
+ };
126
+ const result = detectScopeViolation({
127
+ worktreePath: "/tmp/wt",
128
+ baseRef: "main",
129
+ fileScope: ["src/foo.ts"],
130
+ gitRunner: stub,
131
+ });
132
+ expect(result.violations).toEqual(["src/other.ts"]);
133
+ expect(result.expansionReasons).toEqual(["cross-cutting"]);
134
+ });
135
+
136
+ test("returns empty when stub throws", () => {
137
+ const stub = (): string => {
138
+ throw new Error("boom");
139
+ };
140
+ const result = detectScopeViolation({
141
+ worktreePath: "/tmp/wt",
142
+ baseRef: "main",
143
+ fileScope: ["src/foo.ts"],
144
+ gitRunner: stub,
145
+ });
146
+ expect(result.violations).toEqual([]);
147
+ expect(result.expansionReasons).toEqual([]);
148
+ });
149
+
150
+ test("empty fileScope yields no violations regardless of diff", () => {
151
+ const stub = (args: string[]): string => {
152
+ if (args[0] === "diff") return "src/anything.ts\n";
153
+ return "";
154
+ };
155
+ const result = detectScopeViolation({
156
+ worktreePath: "/tmp/wt",
157
+ baseRef: "main",
158
+ fileScope: [],
159
+ gitRunner: stub,
160
+ });
161
+ expect(result.violations).toEqual([]);
162
+ });
163
+
164
+ test("blank diff lines are filtered", () => {
165
+ const stub = (args: string[]): string => {
166
+ if (args[0] === "diff") return "\nsrc/foo.ts\n\n\nsrc/bar.ts\n";
167
+ return "";
168
+ };
169
+ const result = detectScopeViolation({
170
+ worktreePath: "/tmp/wt",
171
+ baseRef: "main",
172
+ fileScope: ["src/foo.ts"],
173
+ gitRunner: stub,
174
+ });
175
+ expect(result.violations).toEqual(["src/bar.ts"]);
176
+ });
177
+ });
178
+
179
+ describe("IMPLEMENTATION_CAPABILITIES", () => {
180
+ test("includes builder and merger", () => {
181
+ expect(IMPLEMENTATION_CAPABILITIES.has("builder")).toBe(true);
182
+ expect(IMPLEMENTATION_CAPABILITIES.has("merger")).toBe(true);
183
+ });
184
+
185
+ test("excludes read-only roles", () => {
186
+ expect(IMPLEMENTATION_CAPABILITIES.has("scout")).toBe(false);
187
+ expect(IMPLEMENTATION_CAPABILITIES.has("reviewer")).toBe(false);
188
+ expect(IMPLEMENTATION_CAPABILITIES.has("lead")).toBe(false);
189
+ });
190
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Scope-violation detection for builder/merger turns (overstory-9f4d).
3
+ *
4
+ * Surfaces a soft, advisory signal when an agent's modified files exceed its
5
+ * declared FILE_SCOPE without an `expansion_reason:` justification. The
6
+ * turn-runner consumes this on terminal-mail observation; the lead reads the
7
+ * `events.db` record during merge verification.
8
+ *
9
+ * This is observability only — never a hard block. All errors are swallowed.
10
+ */
11
+
12
+ /**
13
+ * Capabilities allowed to modify files. Mirrors the set in
14
+ * `src/agents/hooks-deployer.ts`. Read-only roles (scout, reviewer) do not
15
+ * commit work, so scope detection is a no-op for them. Lead is excluded for
16
+ * the same reason — leads delegate, they don't touch files directly.
17
+ */
18
+ export const IMPLEMENTATION_CAPABILITIES: ReadonlySet<string> = new Set(["builder", "merger"]);
19
+
20
+ /**
21
+ * Synchronous git runner contract. Accepts argv (without the `git` prefix) and
22
+ * a working directory; returns combined stdout. Tests inject a stub; production
23
+ * calls `Bun.spawnSync(["git", ...args], { cwd })`.
24
+ */
25
+ export type GitRunner = (args: string[], cwd: string) => string;
26
+
27
+ const defaultGitRunner: GitRunner = (args, cwd) => {
28
+ const proc = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
29
+ if (proc.exitCode !== 0) {
30
+ return "";
31
+ }
32
+ return proc.stdout.toString();
33
+ };
34
+
35
+ /**
36
+ * Return the subset of `modifiedFiles` not covered by any FILE_SCOPE entry.
37
+ *
38
+ * - Empty `fileScope` is treated as unrestricted: returns `[]`.
39
+ * - A file is in scope when any scope entry matches it literally OR when
40
+ * `new Bun.Glob(entry).match(file)` returns true.
41
+ */
42
+ export function findScopeViolations(modifiedFiles: string[], fileScope: string[]): string[] {
43
+ if (fileScope.length === 0) return [];
44
+
45
+ const globs: Array<{ entry: string; glob: Bun.Glob }> = [];
46
+ for (const entry of fileScope) {
47
+ try {
48
+ globs.push({ entry, glob: new Bun.Glob(entry) });
49
+ } catch {
50
+ // malformed glob — fall back to literal-only match for this entry
51
+ globs.push({ entry, glob: new Bun.Glob(entry.replace(/[*?[\]{}]/g, "\\$&")) });
52
+ }
53
+ }
54
+
55
+ const violations: string[] = [];
56
+ for (const file of modifiedFiles) {
57
+ let matched = false;
58
+ for (const { entry, glob } of globs) {
59
+ if (entry === file) {
60
+ matched = true;
61
+ break;
62
+ }
63
+ try {
64
+ if (glob.match(file)) {
65
+ matched = true;
66
+ break;
67
+ }
68
+ } catch {
69
+ // ignore malformed pattern; continue
70
+ }
71
+ }
72
+ if (!matched) violations.push(file);
73
+ }
74
+ return violations;
75
+ }
76
+
77
+ /**
78
+ * Case-insensitive check for `expansion_reason:` followed by at least one
79
+ * non-whitespace character before the next newline.
80
+ *
81
+ * - Matches `expansion_reason: foo`, `Expansion_Reason: bar`, `EXPANSION_REASON: baz quux`.
82
+ * - Rejects `expansion_reason:` (empty value) and `expansion-reason: foo` (different separator).
83
+ */
84
+ export function hasExpansionReason(message: string): boolean {
85
+ return /expansion_reason:[^\S\n]*\S+/i.test(message);
86
+ }
87
+
88
+ /**
89
+ * Parse `expansion_reason:` values from the output of `git log --format=%B <range>`.
90
+ * Returns each value (trimmed) in encounter order. Commits without the marker
91
+ * are ignored.
92
+ */
93
+ export function parseExpansionReasonsFromGitLog(log: string): string[] {
94
+ const reasons: string[] = [];
95
+ const re = /expansion_reason:[^\S\n]*([^\n]*\S)/gi;
96
+ let m: RegExpExecArray | null = re.exec(log);
97
+ while (m !== null) {
98
+ const value = m[1]?.trim();
99
+ if (value && value.length > 0) reasons.push(value);
100
+ m = re.exec(log);
101
+ }
102
+ return reasons;
103
+ }
104
+
105
+ export interface DetectScopeViolationOpts {
106
+ worktreePath: string;
107
+ baseRef: string;
108
+ fileScope: string[];
109
+ /** Test injection — replaces `Bun.spawnSync` for git calls. */
110
+ gitRunner?: GitRunner;
111
+ }
112
+
113
+ export interface ScopeViolationResult {
114
+ violations: string[];
115
+ expansionReasons: string[];
116
+ }
117
+
118
+ /**
119
+ * Detect scope violations for a worktree at HEAD relative to `baseRef`.
120
+ *
121
+ * - Runs `git diff --name-only baseRef...HEAD` to enumerate modified files.
122
+ * - Runs `git log --format=%B baseRef..HEAD` and parses `expansion_reason:`
123
+ * markers from commit bodies.
124
+ *
125
+ * Always returns. Any failure (git error, parse failure) yields
126
+ * `{ violations: [], expansionReasons: [] }` — this is an advisory signal and
127
+ * must never break the runner.
128
+ */
129
+ export function detectScopeViolation(opts: DetectScopeViolationOpts): ScopeViolationResult {
130
+ const runner = opts.gitRunner ?? defaultGitRunner;
131
+ try {
132
+ const diffOut = runner(["diff", "--name-only", `${opts.baseRef}...HEAD`], opts.worktreePath);
133
+ const modifiedFiles = diffOut
134
+ .split("\n")
135
+ .map((line) => line.trim())
136
+ .filter((line) => line.length > 0);
137
+
138
+ const logOut = runner(["log", "--format=%B", `${opts.baseRef}..HEAD`], opts.worktreePath);
139
+ const expansionReasons = parseExpansionReasonsFromGitLog(logOut);
140
+
141
+ const violations = findScopeViolations(modifiedFiles, opts.fileScope);
142
+ return { violations, expansionReasons };
143
+ } catch {
144
+ return { violations: [], expansionReasons: [] };
145
+ }
146
+ }
@@ -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
+ });