@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.
- package/README.md +50 -19
- package/agents/builder.md +19 -9
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +204 -87
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +219 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/overlay.test.ts +60 -4
- package/src/agents/overlay.ts +63 -8
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/commands/agents.ts +9 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +254 -0
- package/src/commands/coordinator.ts +273 -8
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +14 -4
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +187 -11
- package/src/commands/log.ts +171 -71
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +230 -1
- package/src/commands/merge.ts +68 -12
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +177 -1
- package/src/commands/sling.ts +243 -71
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +255 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +57 -6
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.ts +29 -0
- package/src/logging/theme.ts +4 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.ts +3 -3
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +657 -29
- package/src/sessions/store.ts +286 -23
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +107 -2
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1607 -376
- package/src/watchdog/daemon.ts +462 -88
- package/src/watchdog/health.test.ts +282 -0
- package/src/watchdog/health.ts +126 -27
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +28 -0
- package/src/worktree/tmux.ts +27 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- 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
|
+
});
|