@bastani/atomic 0.6.3-0 → 0.6.4-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/.agents/skills/ast-grep/SKILL.md +323 -0
- package/.agents/skills/ast-grep/references/rule_reference.md +297 -0
- package/.agents/skills/ripgrep/SKILL.md +382 -0
- package/.mcp.json +5 -6
- package/dist/commands/cli/claude-inflight-hook.d.ts +100 -0
- package/dist/commands/cli/claude-inflight-hook.d.ts.map +1 -0
- package/dist/commands/cli/claude-stop-hook.d.ts +2 -0
- package/dist/commands/cli/claude-stop-hook.d.ts.map +1 -1
- package/dist/lib/spawn.d.ts +1 -1
- package/dist/lib/spawn.d.ts.map +1 -1
- package/dist/sdk/providers/claude.d.ts +36 -0
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/providers/copilot.d.ts +17 -1
- package/dist/sdk/providers/copilot.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +49 -34
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +18 -16
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/batching.d.ts +43 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/batching.d.ts.map +1 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +30 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +2 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +18 -16
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -1
- package/dist/services/config/additional-instructions.d.ts +67 -0
- package/dist/services/config/additional-instructions.d.ts.map +1 -0
- package/package.json +3 -1
- package/src/cli.ts +18 -1
- package/src/commands/cli/chat/index.ts +52 -2
- package/src/commands/cli/claude-inflight-hook.test.ts +598 -0
- package/src/commands/cli/claude-inflight-hook.ts +359 -0
- package/src/commands/cli/claude-stop-hook.ts +40 -4
- package/src/commands/cli/init/index.ts +9 -0
- package/src/lib/spawn.ts +6 -2
- package/src/sdk/providers/claude.ts +131 -0
- package/src/sdk/providers/copilot.ts +30 -1
- package/src/sdk/runtime/executor.ts +43 -2
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +318 -158
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +253 -129
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/batching.ts +65 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/ignore-by-default.d.ts +8 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +203 -12
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +248 -78
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +258 -146
- package/src/services/config/additional-instructions.ts +273 -0
- package/src/services/system/auto-sync.ts +10 -1
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for claudeInflightHookCommand and its helpers.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: monkey-patch `Bun.stdin.text` to feed payloads to the handler
|
|
5
|
+
* directly, then assert against real filesystem state. No mocks of fs or of
|
|
6
|
+
* the hook internals — the contract is "marker files appear/disappear
|
|
7
|
+
* correctly under the per-root inflight dir," and we verify exactly that.
|
|
8
|
+
*
|
|
9
|
+
* Each test uses `crypto.randomUUID()` for unique session/agent ids and
|
|
10
|
+
* cleans up in `afterEach` so runs never collide with each other or with
|
|
11
|
+
* real workflow runs that share `~/.atomic/claude-inflight/`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, test, expect, afterEach } from "bun:test";
|
|
15
|
+
import { access, mkdir, rm, stat, writeFile, utimes } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
claudeInflightHookCommand,
|
|
19
|
+
inflightDirIsEmpty,
|
|
20
|
+
sweepStaleInflight,
|
|
21
|
+
waitForInflightDrained,
|
|
22
|
+
clearInflightTracking,
|
|
23
|
+
} from "./claude-inflight-hook.ts";
|
|
24
|
+
import { claudeHookDirs, claudeStopHookCommand } from "./claude-stop-hook.ts";
|
|
25
|
+
|
|
26
|
+
const dirs = claudeHookDirs();
|
|
27
|
+
|
|
28
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
await access(filePath);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mockStdin(text: string): void {
|
|
38
|
+
(Bun.stdin as { text: () => Promise<string> }).text = () =>
|
|
39
|
+
Promise.resolve(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sessionsToClean: string[] = [];
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
for (const id of sessionsToClean) {
|
|
46
|
+
await Promise.all([
|
|
47
|
+
rm(join(dirs.inflight, id), { recursive: true, force: true }),
|
|
48
|
+
rm(join(dirs.inflightRoots, id), { force: true }),
|
|
49
|
+
rm(join(dirs.marker, id), { force: true }),
|
|
50
|
+
rm(join(dirs.queue, id), { force: true }),
|
|
51
|
+
rm(join(dirs.release, id), { force: true }),
|
|
52
|
+
rm(join(dirs.pid, id), { force: true }),
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
sessionsToClean.length = 0;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("claudeInflightHookCommand — start mode", () => {
|
|
59
|
+
test("SubagentStart writes a marker file under <inflight>/<session>/<agent_id>", async () => {
|
|
60
|
+
const sessionId = crypto.randomUUID();
|
|
61
|
+
const agentId = crypto.randomUUID();
|
|
62
|
+
sessionsToClean.push(sessionId, agentId);
|
|
63
|
+
|
|
64
|
+
mockStdin(JSON.stringify({
|
|
65
|
+
session_id: sessionId,
|
|
66
|
+
hook_event_name: "SubagentStart",
|
|
67
|
+
agent_id: agentId,
|
|
68
|
+
agent_type: "general-purpose",
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const code = await claudeInflightHookCommand("start");
|
|
72
|
+
|
|
73
|
+
expect(code).toBe(0);
|
|
74
|
+
expect(await fileExists(join(dirs.inflight, sessionId, agentId))).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("marker payload captures parent_session_id, root, and a numeric ts", async () => {
|
|
78
|
+
const sessionId = crypto.randomUUID();
|
|
79
|
+
const agentId = crypto.randomUUID();
|
|
80
|
+
sessionsToClean.push(sessionId, agentId);
|
|
81
|
+
|
|
82
|
+
mockStdin(JSON.stringify({
|
|
83
|
+
session_id: sessionId,
|
|
84
|
+
hook_event_name: "SubagentStart",
|
|
85
|
+
agent_id: agentId,
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
await claudeInflightHookCommand("start");
|
|
89
|
+
|
|
90
|
+
const body = await Bun.file(join(dirs.inflight, sessionId, agentId)).text();
|
|
91
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
92
|
+
|
|
93
|
+
expect(parsed["parent_session_id"]).toBe(sessionId);
|
|
94
|
+
expect(parsed["root"]).toBe(sessionId);
|
|
95
|
+
expect(parsed["kind"]).toBe("SubagentStart");
|
|
96
|
+
expect(typeof parsed["ts"]).toBe("number");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("nested SubagentStart resolves to the original root via .session-roots mapping", async () => {
|
|
100
|
+
const rootSession = crypto.randomUUID();
|
|
101
|
+
const childAgent = crypto.randomUUID();
|
|
102
|
+
const grandchildAgent = crypto.randomUUID();
|
|
103
|
+
sessionsToClean.push(rootSession, childAgent, grandchildAgent);
|
|
104
|
+
|
|
105
|
+
// Direct child: SubagentStart fires under root's session.
|
|
106
|
+
mockStdin(JSON.stringify({
|
|
107
|
+
session_id: rootSession,
|
|
108
|
+
hook_event_name: "SubagentStart",
|
|
109
|
+
agent_id: childAgent,
|
|
110
|
+
}));
|
|
111
|
+
await claudeInflightHookCommand("start");
|
|
112
|
+
|
|
113
|
+
// Grandchild: SubagentStart fires under the child's session_id (the
|
|
114
|
+
// child is itself a Claude session). The handler must resolve the root
|
|
115
|
+
// via the .session-roots mapping written for childAgent above.
|
|
116
|
+
mockStdin(JSON.stringify({
|
|
117
|
+
session_id: childAgent,
|
|
118
|
+
hook_event_name: "SubagentStart",
|
|
119
|
+
agent_id: grandchildAgent,
|
|
120
|
+
}));
|
|
121
|
+
await claudeInflightHookCommand("start");
|
|
122
|
+
|
|
123
|
+
// Both markers should land under <inflight>/<rootSession>/.
|
|
124
|
+
expect(await fileExists(join(dirs.inflight, rootSession, childAgent))).toBe(true);
|
|
125
|
+
expect(await fileExists(join(dirs.inflight, rootSession, grandchildAgent))).toBe(true);
|
|
126
|
+
// Grandchild must NOT also be under <inflight>/<childAgent>/ — that
|
|
127
|
+
// would mean the wait-for-drain on rootSession could empty out before
|
|
128
|
+
// the grandchild finishes.
|
|
129
|
+
expect(await fileExists(join(dirs.inflight, childAgent, grandchildAgent))).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("payload missing agent_id is a no-op", async () => {
|
|
133
|
+
const sessionId = crypto.randomUUID();
|
|
134
|
+
sessionsToClean.push(sessionId);
|
|
135
|
+
|
|
136
|
+
mockStdin(JSON.stringify({ session_id: sessionId, hook_event_name: "SubagentStart" }));
|
|
137
|
+
|
|
138
|
+
const code = await claudeInflightHookCommand("start");
|
|
139
|
+
|
|
140
|
+
expect(code).toBe(0);
|
|
141
|
+
// No marker dir should have been created.
|
|
142
|
+
expect(await fileExists(join(dirs.inflight, sessionId))).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("malformed JSON is a no-op and returns 0", async () => {
|
|
146
|
+
mockStdin("not json {");
|
|
147
|
+
const code = await claudeInflightHookCommand("start");
|
|
148
|
+
expect(code).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("missing session_id is a no-op and returns 0", async () => {
|
|
152
|
+
mockStdin(JSON.stringify({ agent_id: "orphan" }));
|
|
153
|
+
const code = await claudeInflightHookCommand("start");
|
|
154
|
+
expect(code).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("claudeInflightHookCommand — stop mode", () => {
|
|
159
|
+
test("SubagentStop removes the marker for the given agent_id", async () => {
|
|
160
|
+
const sessionId = crypto.randomUUID();
|
|
161
|
+
const agentId = crypto.randomUUID();
|
|
162
|
+
sessionsToClean.push(sessionId, agentId);
|
|
163
|
+
|
|
164
|
+
// Seed via start.
|
|
165
|
+
mockStdin(JSON.stringify({
|
|
166
|
+
session_id: sessionId,
|
|
167
|
+
hook_event_name: "SubagentStart",
|
|
168
|
+
agent_id: agentId,
|
|
169
|
+
}));
|
|
170
|
+
await claudeInflightHookCommand("start");
|
|
171
|
+
expect(await fileExists(join(dirs.inflight, sessionId, agentId))).toBe(true);
|
|
172
|
+
|
|
173
|
+
// Stop should remove it.
|
|
174
|
+
mockStdin(JSON.stringify({
|
|
175
|
+
session_id: sessionId,
|
|
176
|
+
hook_event_name: "SubagentStop",
|
|
177
|
+
agent_id: agentId,
|
|
178
|
+
}));
|
|
179
|
+
const code = await claudeInflightHookCommand("stop");
|
|
180
|
+
|
|
181
|
+
expect(code).toBe(0);
|
|
182
|
+
expect(await fileExists(join(dirs.inflight, sessionId, agentId))).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("stop on an unknown id is a no-op and returns 0", async () => {
|
|
186
|
+
const sessionId = crypto.randomUUID();
|
|
187
|
+
const agentId = crypto.randomUUID();
|
|
188
|
+
sessionsToClean.push(sessionId);
|
|
189
|
+
|
|
190
|
+
mockStdin(JSON.stringify({
|
|
191
|
+
session_id: sessionId,
|
|
192
|
+
hook_event_name: "SubagentStop",
|
|
193
|
+
agent_id: agentId,
|
|
194
|
+
}));
|
|
195
|
+
const code = await claudeInflightHookCommand("stop");
|
|
196
|
+
|
|
197
|
+
expect(code).toBe(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("stop resolves the same root as start for nested subagents", async () => {
|
|
201
|
+
const rootSession = crypto.randomUUID();
|
|
202
|
+
const childAgent = crypto.randomUUID();
|
|
203
|
+
const grandchildAgent = crypto.randomUUID();
|
|
204
|
+
sessionsToClean.push(rootSession, childAgent, grandchildAgent);
|
|
205
|
+
|
|
206
|
+
// Start child + grandchild.
|
|
207
|
+
mockStdin(JSON.stringify({ session_id: rootSession, agent_id: childAgent }));
|
|
208
|
+
await claudeInflightHookCommand("start");
|
|
209
|
+
mockStdin(JSON.stringify({ session_id: childAgent, agent_id: grandchildAgent }));
|
|
210
|
+
await claudeInflightHookCommand("start");
|
|
211
|
+
|
|
212
|
+
// Stop the grandchild — its `session_id` field is the child's session,
|
|
213
|
+
// but the marker lives under root. The handler must resolve correctly.
|
|
214
|
+
mockStdin(JSON.stringify({ session_id: childAgent, agent_id: grandchildAgent }));
|
|
215
|
+
await claudeInflightHookCommand("stop");
|
|
216
|
+
|
|
217
|
+
expect(await fileExists(join(dirs.inflight, rootSession, grandchildAgent))).toBe(false);
|
|
218
|
+
expect(await fileExists(join(dirs.inflight, rootSession, childAgent))).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("claudeInflightHookCommand — wait mode (TeammateIdle)", () => {
|
|
223
|
+
test("returns 0 immediately when the in-flight dir is empty", async () => {
|
|
224
|
+
const sessionId = crypto.randomUUID();
|
|
225
|
+
sessionsToClean.push(sessionId);
|
|
226
|
+
|
|
227
|
+
mockStdin(JSON.stringify({ session_id: sessionId, hook_event_name: "TeammateIdle" }));
|
|
228
|
+
|
|
229
|
+
const start = Date.now();
|
|
230
|
+
const code = await claudeInflightHookCommand("wait");
|
|
231
|
+
const elapsed = Date.now() - start;
|
|
232
|
+
|
|
233
|
+
expect(code).toBe(0);
|
|
234
|
+
expect(elapsed).toBeLessThan(100);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("waits for the in-flight dir to drain before returning", async () => {
|
|
238
|
+
const sessionId = crypto.randomUUID();
|
|
239
|
+
const agentId = crypto.randomUUID();
|
|
240
|
+
sessionsToClean.push(sessionId, agentId);
|
|
241
|
+
|
|
242
|
+
// Seed an in-flight subagent on this root.
|
|
243
|
+
mockStdin(JSON.stringify({ session_id: sessionId, agent_id: agentId }));
|
|
244
|
+
await claudeInflightHookCommand("start");
|
|
245
|
+
|
|
246
|
+
// Drain after 80 ms.
|
|
247
|
+
setTimeout(() => {
|
|
248
|
+
void rm(join(dirs.inflight, sessionId, agentId), { force: true });
|
|
249
|
+
}, 80);
|
|
250
|
+
|
|
251
|
+
mockStdin(JSON.stringify({ session_id: sessionId, hook_event_name: "TeammateIdle" }));
|
|
252
|
+
|
|
253
|
+
const start = Date.now();
|
|
254
|
+
const code = await claudeInflightHookCommand("wait");
|
|
255
|
+
const elapsed = Date.now() - start;
|
|
256
|
+
|
|
257
|
+
expect(code).toBe(0);
|
|
258
|
+
expect(elapsed).toBeGreaterThanOrEqual(80);
|
|
259
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("malformed JSON is a no-op and returns 0", async () => {
|
|
263
|
+
mockStdin("not json {");
|
|
264
|
+
const code = await claudeInflightHookCommand("wait");
|
|
265
|
+
expect(code).toBe(0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("resolves the root via .session-roots when the event fires under a teammate's session", async () => {
|
|
269
|
+
const rootSession = crypto.randomUUID();
|
|
270
|
+
const teammateSession = crypto.randomUUID();
|
|
271
|
+
const grandchildAgent = crypto.randomUUID();
|
|
272
|
+
sessionsToClean.push(rootSession, teammateSession, grandchildAgent);
|
|
273
|
+
|
|
274
|
+
// Set up: rootSession spawned teammateSession (which writes a roots-mapping
|
|
275
|
+
// entry pointing at rootSession), then teammateSession spawned grandchildAgent.
|
|
276
|
+
mockStdin(JSON.stringify({ session_id: rootSession, agent_id: teammateSession }));
|
|
277
|
+
await claudeInflightHookCommand("start");
|
|
278
|
+
mockStdin(JSON.stringify({ session_id: teammateSession, agent_id: grandchildAgent }));
|
|
279
|
+
await claudeInflightHookCommand("start");
|
|
280
|
+
|
|
281
|
+
// TeammateIdle fires under teammateSession's session_id. The wait must
|
|
282
|
+
// resolve the root and gate on rootSession's in-flight dir.
|
|
283
|
+
mockStdin(JSON.stringify({ session_id: teammateSession, hook_event_name: "TeammateIdle" }));
|
|
284
|
+
|
|
285
|
+
// Drain the root's markers after 80 ms.
|
|
286
|
+
setTimeout(() => {
|
|
287
|
+
void rm(join(dirs.inflight, rootSession), { recursive: true, force: true });
|
|
288
|
+
}, 80);
|
|
289
|
+
|
|
290
|
+
const start = Date.now();
|
|
291
|
+
const code = await claudeInflightHookCommand("wait");
|
|
292
|
+
const elapsed = Date.now() - start;
|
|
293
|
+
|
|
294
|
+
expect(code).toBe(0);
|
|
295
|
+
expect(elapsed).toBeGreaterThanOrEqual(80);
|
|
296
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("inflightDirIsEmpty", () => {
|
|
301
|
+
test("returns true when the dir is missing", async () => {
|
|
302
|
+
const sessionId = crypto.randomUUID();
|
|
303
|
+
sessionsToClean.push(sessionId);
|
|
304
|
+
expect(await inflightDirIsEmpty(sessionId)).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("returns false when the dir contains a marker", async () => {
|
|
308
|
+
const sessionId = crypto.randomUUID();
|
|
309
|
+
sessionsToClean.push(sessionId);
|
|
310
|
+
|
|
311
|
+
mockStdin(JSON.stringify({
|
|
312
|
+
session_id: sessionId,
|
|
313
|
+
agent_id: crypto.randomUUID(),
|
|
314
|
+
}));
|
|
315
|
+
await claudeInflightHookCommand("start");
|
|
316
|
+
|
|
317
|
+
expect(await inflightDirIsEmpty(sessionId)).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("returns true when the dir exists but is empty", async () => {
|
|
321
|
+
const sessionId = crypto.randomUUID();
|
|
322
|
+
sessionsToClean.push(sessionId);
|
|
323
|
+
await mkdir(join(dirs.inflight, sessionId), { recursive: true });
|
|
324
|
+
expect(await inflightDirIsEmpty(sessionId)).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("sweepStaleInflight", () => {
|
|
329
|
+
test("removes markers older than threshold and leaves fresh ones alone", async () => {
|
|
330
|
+
const sessionId = crypto.randomUUID();
|
|
331
|
+
sessionsToClean.push(sessionId);
|
|
332
|
+
|
|
333
|
+
const dir = join(dirs.inflight, sessionId);
|
|
334
|
+
await mkdir(dir, { recursive: true });
|
|
335
|
+
const stale = join(dir, "stale");
|
|
336
|
+
const fresh = join(dir, "fresh");
|
|
337
|
+
await writeFile(stale, "stale");
|
|
338
|
+
await writeFile(fresh, "fresh");
|
|
339
|
+
// Backdate the stale marker to 3 hours ago.
|
|
340
|
+
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
|
341
|
+
await utimes(stale, threeHoursAgo, threeHoursAgo);
|
|
342
|
+
|
|
343
|
+
const reaped = await sweepStaleInflight(sessionId);
|
|
344
|
+
|
|
345
|
+
expect(reaped).toBe(1);
|
|
346
|
+
expect(await fileExists(stale)).toBe(false);
|
|
347
|
+
expect(await fileExists(fresh)).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("returns 0 when the dir is missing", async () => {
|
|
351
|
+
const sessionId = crypto.randomUUID();
|
|
352
|
+
sessionsToClean.push(sessionId);
|
|
353
|
+
expect(await sweepStaleInflight(sessionId)).toBe(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("custom threshold is honored", async () => {
|
|
357
|
+
const sessionId = crypto.randomUUID();
|
|
358
|
+
sessionsToClean.push(sessionId);
|
|
359
|
+
const dir = join(dirs.inflight, sessionId);
|
|
360
|
+
await mkdir(dir, { recursive: true });
|
|
361
|
+
const marker = join(dir, "id");
|
|
362
|
+
await writeFile(marker, "x");
|
|
363
|
+
// Backdate 200 ms.
|
|
364
|
+
const past = new Date(Date.now() - 200);
|
|
365
|
+
await utimes(marker, past, past);
|
|
366
|
+
|
|
367
|
+
// Threshold of 50 ms → should sweep.
|
|
368
|
+
const reaped = await sweepStaleInflight(sessionId, 50);
|
|
369
|
+
expect(reaped).toBe(1);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("waitForInflightDrained", () => {
|
|
374
|
+
test("resolves immediately when the dir is empty/missing", async () => {
|
|
375
|
+
const sessionId = crypto.randomUUID();
|
|
376
|
+
sessionsToClean.push(sessionId);
|
|
377
|
+
|
|
378
|
+
const start = Date.now();
|
|
379
|
+
await waitForInflightDrained(sessionId, { timeoutMs: 500, pollIntervalMs: 50 });
|
|
380
|
+
const elapsed = Date.now() - start;
|
|
381
|
+
|
|
382
|
+
// Should be effectively instant (well under one poll interval).
|
|
383
|
+
expect(elapsed).toBeLessThan(100);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("resolves once the marker is removed externally", async () => {
|
|
387
|
+
const sessionId = crypto.randomUUID();
|
|
388
|
+
const agentId = crypto.randomUUID();
|
|
389
|
+
sessionsToClean.push(sessionId, agentId);
|
|
390
|
+
|
|
391
|
+
mockStdin(JSON.stringify({ session_id: sessionId, agent_id: agentId }));
|
|
392
|
+
await claudeInflightHookCommand("start");
|
|
393
|
+
|
|
394
|
+
// Remove the marker after 80 ms — the wait should resolve shortly after.
|
|
395
|
+
setTimeout(() => {
|
|
396
|
+
void rm(join(dirs.inflight, sessionId, agentId), { force: true });
|
|
397
|
+
}, 80);
|
|
398
|
+
|
|
399
|
+
const start = Date.now();
|
|
400
|
+
await waitForInflightDrained(sessionId, {
|
|
401
|
+
timeoutMs: 2_000,
|
|
402
|
+
pollIntervalMs: 25,
|
|
403
|
+
});
|
|
404
|
+
const elapsed = Date.now() - start;
|
|
405
|
+
|
|
406
|
+
expect(elapsed).toBeGreaterThanOrEqual(80);
|
|
407
|
+
expect(elapsed).toBeLessThan(1_000);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("resolves on timeout even when the marker never drains", async () => {
|
|
411
|
+
const sessionId = crypto.randomUUID();
|
|
412
|
+
const agentId = crypto.randomUUID();
|
|
413
|
+
sessionsToClean.push(sessionId, agentId);
|
|
414
|
+
|
|
415
|
+
mockStdin(JSON.stringify({ session_id: sessionId, agent_id: agentId }));
|
|
416
|
+
await claudeInflightHookCommand("start");
|
|
417
|
+
|
|
418
|
+
const start = Date.now();
|
|
419
|
+
await waitForInflightDrained(sessionId, {
|
|
420
|
+
timeoutMs: 200,
|
|
421
|
+
pollIntervalMs: 25,
|
|
422
|
+
// Extremely high stale threshold so the sweep doesn't reap our marker.
|
|
423
|
+
staleMs: 24 * 60 * 60 * 1000,
|
|
424
|
+
});
|
|
425
|
+
const elapsed = Date.now() - start;
|
|
426
|
+
|
|
427
|
+
expect(elapsed).toBeGreaterThanOrEqual(200);
|
|
428
|
+
expect(elapsed).toBeLessThan(800);
|
|
429
|
+
// Marker is still on disk — the wait gave up, didn't reap.
|
|
430
|
+
expect(await fileExists(join(dirs.inflight, sessionId, agentId))).toBe(true);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("uses the in-loop stale sweep to recover from a wedged marker", async () => {
|
|
434
|
+
const sessionId = crypto.randomUUID();
|
|
435
|
+
const agentId = crypto.randomUUID();
|
|
436
|
+
sessionsToClean.push(sessionId, agentId);
|
|
437
|
+
|
|
438
|
+
// Seed a marker and immediately backdate it past the staleMs threshold.
|
|
439
|
+
mockStdin(JSON.stringify({ session_id: sessionId, agent_id: agentId }));
|
|
440
|
+
await claudeInflightHookCommand("start");
|
|
441
|
+
const path = join(dirs.inflight, sessionId, agentId);
|
|
442
|
+
const past = new Date(Date.now() - 60_000);
|
|
443
|
+
await utimes(path, past, past);
|
|
444
|
+
|
|
445
|
+
// staleMs = 30s → the marker is stale → first sweep tick reaps it.
|
|
446
|
+
const start = Date.now();
|
|
447
|
+
await waitForInflightDrained(sessionId, {
|
|
448
|
+
timeoutMs: 5_000,
|
|
449
|
+
pollIntervalMs: 25,
|
|
450
|
+
staleMs: 30_000,
|
|
451
|
+
});
|
|
452
|
+
const elapsed = Date.now() - start;
|
|
453
|
+
|
|
454
|
+
expect(elapsed).toBeLessThan(500);
|
|
455
|
+
expect(await fileExists(path)).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe("clearInflightTracking", () => {
|
|
460
|
+
test("removes the marker dir and any roots-mapping entries pointing at it", async () => {
|
|
461
|
+
const rootSession = crypto.randomUUID();
|
|
462
|
+
const childAgent = crypto.randomUUID();
|
|
463
|
+
sessionsToClean.push(rootSession, childAgent);
|
|
464
|
+
|
|
465
|
+
mockStdin(JSON.stringify({ session_id: rootSession, agent_id: childAgent }));
|
|
466
|
+
await claudeInflightHookCommand("start");
|
|
467
|
+
|
|
468
|
+
expect(await fileExists(join(dirs.inflight, rootSession, childAgent))).toBe(true);
|
|
469
|
+
expect(await fileExists(join(dirs.inflightRoots, childAgent))).toBe(true);
|
|
470
|
+
|
|
471
|
+
await clearInflightTracking(rootSession);
|
|
472
|
+
|
|
473
|
+
expect(await fileExists(join(dirs.inflight, rootSession))).toBe(false);
|
|
474
|
+
expect(await fileExists(join(dirs.inflightRoots, childAgent))).toBe(false);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("is idempotent / safe when nothing exists yet", async () => {
|
|
478
|
+
const sessionId = crypto.randomUUID();
|
|
479
|
+
sessionsToClean.push(sessionId);
|
|
480
|
+
// Should not throw.
|
|
481
|
+
await clearInflightTracking(sessionId);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe("Stop hook gates release on inflight drain", () => {
|
|
486
|
+
test("with markers present, the Stop hook does NOT consume the release marker", async () => {
|
|
487
|
+
const sessionId = crypto.randomUUID();
|
|
488
|
+
const agentId = crypto.randomUUID();
|
|
489
|
+
sessionsToClean.push(sessionId, agentId);
|
|
490
|
+
|
|
491
|
+
// Seed an in-flight subagent for this session.
|
|
492
|
+
mockStdin(JSON.stringify({ session_id: sessionId, agent_id: agentId }));
|
|
493
|
+
await claudeInflightHookCommand("start");
|
|
494
|
+
|
|
495
|
+
// Pre-write the release marker so the Stop hook would normally consume
|
|
496
|
+
// it on the first poll tick.
|
|
497
|
+
await mkdir(dirs.release, { recursive: true });
|
|
498
|
+
await writeFile(join(dirs.release, sessionId), "");
|
|
499
|
+
|
|
500
|
+
// Run the Stop hook with a short timeout so we don't actually wait 24 days.
|
|
501
|
+
mockStdin(JSON.stringify({ session_id: sessionId }));
|
|
502
|
+
const code = await claudeStopHookCommand({
|
|
503
|
+
waitTimeoutMs: 250,
|
|
504
|
+
pollIntervalMs: 25,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Hook exits 0 (timeout path), but crucially the release marker was NOT
|
|
508
|
+
// consumed because inflight was non-empty.
|
|
509
|
+
expect(code).toBe(0);
|
|
510
|
+
expect(await fileExists(join(dirs.release, sessionId))).toBe(true);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("with no markers, the Stop hook consumes the release marker promptly", async () => {
|
|
514
|
+
const sessionId = crypto.randomUUID();
|
|
515
|
+
sessionsToClean.push(sessionId);
|
|
516
|
+
|
|
517
|
+
await mkdir(dirs.release, { recursive: true });
|
|
518
|
+
await writeFile(join(dirs.release, sessionId), "");
|
|
519
|
+
|
|
520
|
+
mockStdin(JSON.stringify({ session_id: sessionId }));
|
|
521
|
+
const code = await claudeStopHookCommand({
|
|
522
|
+
waitTimeoutMs: 5_000,
|
|
523
|
+
pollIntervalMs: 25,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
expect(code).toBe(0);
|
|
527
|
+
expect(await fileExists(join(dirs.release, sessionId))).toBe(false);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("release is consumed once markers drain mid-poll", async () => {
|
|
531
|
+
const sessionId = crypto.randomUUID();
|
|
532
|
+
const agentId = crypto.randomUUID();
|
|
533
|
+
sessionsToClean.push(sessionId, agentId);
|
|
534
|
+
|
|
535
|
+
mockStdin(JSON.stringify({ session_id: sessionId, agent_id: agentId }));
|
|
536
|
+
await claudeInflightHookCommand("start");
|
|
537
|
+
|
|
538
|
+
await mkdir(dirs.release, { recursive: true });
|
|
539
|
+
await writeFile(join(dirs.release, sessionId), "");
|
|
540
|
+
|
|
541
|
+
// Drain the marker after 100 ms.
|
|
542
|
+
setTimeout(() => {
|
|
543
|
+
void rm(join(dirs.inflight, sessionId, agentId), { force: true });
|
|
544
|
+
}, 100);
|
|
545
|
+
|
|
546
|
+
mockStdin(JSON.stringify({ session_id: sessionId }));
|
|
547
|
+
const code = await claudeStopHookCommand({
|
|
548
|
+
waitTimeoutMs: 5_000,
|
|
549
|
+
pollIntervalMs: 25,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
expect(code).toBe(0);
|
|
553
|
+
expect(await fileExists(join(dirs.release, sessionId))).toBe(false);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("queue marker still takes priority even with inflight markers present", async () => {
|
|
557
|
+
const sessionId = crypto.randomUUID();
|
|
558
|
+
const agentId = crypto.randomUUID();
|
|
559
|
+
sessionsToClean.push(sessionId, agentId);
|
|
560
|
+
|
|
561
|
+
mockStdin(JSON.stringify({ session_id: sessionId, agent_id: agentId }));
|
|
562
|
+
await claudeInflightHookCommand("start");
|
|
563
|
+
|
|
564
|
+
await mkdir(dirs.queue, { recursive: true });
|
|
565
|
+
await writeFile(join(dirs.queue, sessionId), "follow up", "utf-8");
|
|
566
|
+
|
|
567
|
+
const stdoutChunks: string[] = [];
|
|
568
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
569
|
+
(process.stdout as unknown as { write: (s: unknown) => boolean }).write = (
|
|
570
|
+
s: unknown,
|
|
571
|
+
) => {
|
|
572
|
+
stdoutChunks.push(String(s));
|
|
573
|
+
return true;
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
mockStdin(JSON.stringify({ session_id: sessionId }));
|
|
578
|
+
const code = await claudeStopHookCommand({
|
|
579
|
+
waitTimeoutMs: 250,
|
|
580
|
+
pollIntervalMs: 25,
|
|
581
|
+
});
|
|
582
|
+
expect(code).toBe(0);
|
|
583
|
+
} finally {
|
|
584
|
+
(process.stdout as unknown as { write: typeof origWrite }).write = origWrite;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Queue was consumed and a block decision was emitted with the prompt.
|
|
588
|
+
expect(await fileExists(join(dirs.queue, sessionId))).toBe(false);
|
|
589
|
+
const out = stdoutChunks.join("");
|
|
590
|
+
expect(out).toContain('"decision":"block"');
|
|
591
|
+
expect(out).toContain("follow up");
|
|
592
|
+
|
|
593
|
+
// Stat the marker via fs to confirm it stayed put. The queue path takes
|
|
594
|
+
// priority and doesn't touch inflight at all.
|
|
595
|
+
const markerStat = await stat(join(dirs.inflight, sessionId, agentId));
|
|
596
|
+
expect(markerStat.isFile()).toBe(true);
|
|
597
|
+
});
|
|
598
|
+
});
|