@jaybeeuu/agent-cortex 2.7.0 → 2.8.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/CHANGELOG.md +239 -0
- package/README.md +61 -5
- package/agents/README.md +2 -2
- package/agents/ralph/agent.md +17 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- package/bin/agent-cortex.mjs +20 -1
- package/extensions/post-merge-watch/README.md +133 -0
- package/extensions/post-merge-watch/index.test.ts +247 -0
- package/extensions/post-merge-watch/index.ts +123 -0
- package/extensions/post-merge-watch/package.json +16 -0
- package/extensions/post-merge-watch/watch.test.ts +469 -0
- package/extensions/post-merge-watch/watch.ts +431 -0
- package/extensions/session-clear/README.md +74 -0
- package/extensions/session-clear/index.test.ts +524 -0
- package/extensions/session-clear/index.ts +159 -0
- package/extensions/session-clear/package.json +16 -0
- package/lib/cli.mjs +42 -1
- package/lib/self-update.mjs +553 -0
- package/package.json +1 -1
- package/skills/engineering/implement-task/SKILL.md +124 -0
- package/skills/engineering/tdd/SKILL.md +2 -0
- package/skills/planning/create-task/REFERENCE.md +15 -6
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +8 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
- package/skills/planning/create-task/templates/code.md +8 -3
- package/skills/planning/create-task/templates/document.md +8 -3
- package/skills/planning/create-task/templates/fix.md +8 -3
- package/skills/planning/create-task/templates/housekeeping.md +8 -4
- package/skills/planning/create-task/templates/post-merge.md +67 -0
- package/skills/planning/create-task/templates/review.md +8 -3
- package/skills/planning/create-task/templates/verify.md +8 -3
- package/skills/productivity/bd-tool/SKILL.md +55 -0
- package/skills/review/review-defects/EXAMPLES.md +122 -0
- package/skills/review/review-defects/SKILL.md +91 -0
- package/skills/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- package/skills/workflow/plan/SKILL.md +14 -3
- package/skills/workflow/ralph/REFERENCE.md +124 -27
- package/skills/workflow/ralph/SKILL.md +24 -15
- package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
- package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
- package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
- package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
- package/skills/workflow/using-agent-skills/SKILL.md +15 -7
- package/token-map.README.md +27 -0
- package/token-map.json +10 -4
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { createTempRoot } from "../../test/helpers/temp-root.mjs";
|
|
7
|
+
import {
|
|
8
|
+
PostMergeWatch,
|
|
9
|
+
buildWake,
|
|
10
|
+
readVerdict,
|
|
11
|
+
stateFileFor,
|
|
12
|
+
verdictOf,
|
|
13
|
+
WAKE_CUSTOM_TYPE,
|
|
14
|
+
} from "./watch.ts";
|
|
15
|
+
import type { CheckRun, Scheduler, WakeMessage, WatchState } from "./watch.ts";
|
|
16
|
+
|
|
17
|
+
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const tempRoot = createTempRoot("post-merge-watch-");
|
|
20
|
+
|
|
21
|
+
/** The JSON the real check script prints, cut down to the fields the watch reads. */
|
|
22
|
+
function report({ reason, runUrl }: { reason: string; runUrl?: string }): string {
|
|
23
|
+
return JSON.stringify({
|
|
24
|
+
outcome: reason,
|
|
25
|
+
reason,
|
|
26
|
+
runs: runUrl === undefined ? [] : [{ databaseId: 1, url: runUrl, status: "completed" }],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function run(code: number, stdout = ""): CheckRun {
|
|
31
|
+
return { code, stdout, stderr: "", killed: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A scheduler the test steps by hand: nothing fires on its own, and `fire()`
|
|
36
|
+
* awaits the poll it triggers, so no test sleeps or polls for async work.
|
|
37
|
+
*/
|
|
38
|
+
function createStepScheduler() {
|
|
39
|
+
let pending: Array<() => void | Promise<void>> = [];
|
|
40
|
+
let clock = Date.parse("2026-01-01T00:00:00.000Z");
|
|
41
|
+
|
|
42
|
+
const scheduler: Scheduler = {
|
|
43
|
+
now: () => new Date(clock),
|
|
44
|
+
schedule: (callback) => {
|
|
45
|
+
pending.push(callback);
|
|
46
|
+
return () => {
|
|
47
|
+
pending = pending.filter((queued) => queued !== callback);
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
scheduler,
|
|
54
|
+
/** How many timers are waiting. Zero means no orphan. */
|
|
55
|
+
pending: () => pending.length,
|
|
56
|
+
/** Run the waiting timer and await the poll it starts. */
|
|
57
|
+
async fire(): Promise<void> {
|
|
58
|
+
const [next, ...rest] = pending;
|
|
59
|
+
pending = rest;
|
|
60
|
+
await next?.();
|
|
61
|
+
clock += 15_000;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A stepped scheduler that runs `onClockRead` before it returns the time.
|
|
68
|
+
*
|
|
69
|
+
* Both `start()` and `#writeState` read the clock before the file write reaches
|
|
70
|
+
* the filesystem, so a watch stopped from `onClockRead` stops with its write
|
|
71
|
+
* still to run: the remove lands inside the write window on every run, with no
|
|
72
|
+
* sleep and no dependence on I/O timing.
|
|
73
|
+
*/
|
|
74
|
+
function createWriteWindowScheduler(onClockRead: () => void) {
|
|
75
|
+
const step = createStepScheduler();
|
|
76
|
+
const scheduler: Scheduler = {
|
|
77
|
+
now: () => {
|
|
78
|
+
onClockRead();
|
|
79
|
+
return step.scheduler.now();
|
|
80
|
+
},
|
|
81
|
+
schedule: (callback, delayMs) => step.scheduler.schedule(callback, delayMs),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return { scheduler, step };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A watch over one project and bead, with only the values a test cares about set. */
|
|
88
|
+
function createWatch(overrides: {
|
|
89
|
+
runs: CheckRun[];
|
|
90
|
+
stateDir: string;
|
|
91
|
+
project?: string;
|
|
92
|
+
bead?: string;
|
|
93
|
+
maxAttempts?: number;
|
|
94
|
+
}) {
|
|
95
|
+
const step = createStepScheduler();
|
|
96
|
+
const deliveries: WakeMessage[] = [];
|
|
97
|
+
const warnings: string[] = [];
|
|
98
|
+
const scripted = [...overrides.runs];
|
|
99
|
+
|
|
100
|
+
const watch = new PostMergeWatch({
|
|
101
|
+
bead: overrides.bead ?? "agnt-ctx-86u4.2",
|
|
102
|
+
project: overrides.project ?? "/projects/one",
|
|
103
|
+
script: "/agent-cortex/skills/workflow/ralph/scripts/post-merge-check.mjs",
|
|
104
|
+
stateDir: overrides.stateDir,
|
|
105
|
+
maxAttempts: overrides.maxAttempts,
|
|
106
|
+
scheduler: step.scheduler,
|
|
107
|
+
runCheck: async () => {
|
|
108
|
+
const next = scripted.shift();
|
|
109
|
+
if (next === undefined) throw new Error("no scripted run left");
|
|
110
|
+
return next;
|
|
111
|
+
},
|
|
112
|
+
deliver: (message) => deliveries.push(message),
|
|
113
|
+
warn: (message) => warnings.push(message),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return { watch, step, deliveries, warnings };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read a state file the watch wrote, as data. */
|
|
120
|
+
async function readState(path: string): Promise<WatchState> {
|
|
121
|
+
return JSON.parse(await readFile(path, "utf-8")) as WatchState;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Verdict ─────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe("readVerdict", () => {
|
|
127
|
+
it("takes the verdict from the exit code, not from the report body", () => {
|
|
128
|
+
const verdict = readVerdict(run(1, report({ reason: "run-failed", runUrl: "https://gh/run/9" })));
|
|
129
|
+
|
|
130
|
+
assert.equal(verdict.verdict, "failed");
|
|
131
|
+
assert.equal(verdict.exitCode, 1);
|
|
132
|
+
assert.equal(verdict.runUrl, "https://gh/run/9");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("reads a killed run as inconclusive, whatever its partial output says", () => {
|
|
136
|
+
const verdict = readVerdict({ code: 143, stdout: "", stderr: "", killed: true });
|
|
137
|
+
|
|
138
|
+
assert.equal(verdict.verdict, "inconclusive");
|
|
139
|
+
assert.equal(verdict.reason, "check-timed-out");
|
|
140
|
+
assert.equal(verdict.runUrl, undefined);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("reads an unreadable report as inconclusive without inventing a run", () => {
|
|
144
|
+
const verdict = readVerdict(run(2, "gh: not logged in"));
|
|
145
|
+
|
|
146
|
+
assert.deepEqual(
|
|
147
|
+
{ verdict: verdict.verdict, reason: verdict.reason, runUrl: verdict.runUrl },
|
|
148
|
+
{ verdict: "inconclusive", reason: "unreadable-report", runUrl: undefined },
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("maps every non-zero, non-one exit code to inconclusive", () => {
|
|
153
|
+
assert.deepEqual([0, 1, 2, 127, 143].map(verdictOf), [
|
|
154
|
+
"passed",
|
|
155
|
+
"failed",
|
|
156
|
+
"inconclusive",
|
|
157
|
+
"inconclusive",
|
|
158
|
+
"inconclusive",
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─── Wake payload ────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
describe("buildWake", () => {
|
|
166
|
+
it("names the bead, the verdict and the run URL", () => {
|
|
167
|
+
const wake = buildWake({
|
|
168
|
+
bead: "agnt-ctx-86u4.2",
|
|
169
|
+
verdict: "failed",
|
|
170
|
+
runUrl: "https://github.com/o/r/actions/runs/9",
|
|
171
|
+
exitCode: 1,
|
|
172
|
+
reason: "run-failed",
|
|
173
|
+
attempts: 3,
|
|
174
|
+
project: "/projects/one",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
assert.equal(wake.customType, WAKE_CUSTOM_TYPE);
|
|
178
|
+
assert.equal(wake.display, true);
|
|
179
|
+
assert.match(wake.content, /agnt-ctx-86u4\.2/);
|
|
180
|
+
assert.match(wake.content, /Verdict: failed/);
|
|
181
|
+
assert.match(wake.content, /https:\/\/github\.com\/o\/r\/actions\/runs\/9/);
|
|
182
|
+
assert.deepEqual(wake.details, {
|
|
183
|
+
bead: "agnt-ctx-86u4.2",
|
|
184
|
+
verdict: "failed",
|
|
185
|
+
runUrl: "https://github.com/o/r/actions/runs/9",
|
|
186
|
+
exitCode: 1,
|
|
187
|
+
reason: "run-failed",
|
|
188
|
+
attempts: 3,
|
|
189
|
+
project: "/projects/one",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("says no run was reported rather than leaving the line empty", () => {
|
|
194
|
+
const wake = buildWake({
|
|
195
|
+
bead: "agnt-ctx-86u4.2",
|
|
196
|
+
verdict: "inconclusive",
|
|
197
|
+
runUrl: null,
|
|
198
|
+
exitCode: 2,
|
|
199
|
+
reason: "no-run",
|
|
200
|
+
attempts: 40,
|
|
201
|
+
project: "/projects/one",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
assert.match(wake.content, /Run: none reported/);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ─── Watch lifecycle ─────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe("PostMergeWatch", () => {
|
|
211
|
+
it("polls while the run is in progress and wakes once when the verdict lands", async () => {
|
|
212
|
+
const stateDir = join(await tempRoot.createWorkspace(), "state");
|
|
213
|
+
const { watch, step, deliveries } = createWatch({
|
|
214
|
+
stateDir,
|
|
215
|
+
runs: [
|
|
216
|
+
run(2, report({ reason: "still-running" })),
|
|
217
|
+
run(2, report({ reason: "still-running" })),
|
|
218
|
+
run(1, report({ reason: "run-failed", runUrl: "https://gh/run/9" })),
|
|
219
|
+
],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await watch.start();
|
|
223
|
+
await step.fire();
|
|
224
|
+
await step.fire();
|
|
225
|
+
|
|
226
|
+
assert.equal(deliveries.length, 1);
|
|
227
|
+
assert.deepEqual(deliveries[0].details, {
|
|
228
|
+
bead: "agnt-ctx-86u4.2",
|
|
229
|
+
verdict: "failed",
|
|
230
|
+
runUrl: "https://gh/run/9",
|
|
231
|
+
exitCode: 1,
|
|
232
|
+
reason: "run-failed",
|
|
233
|
+
attempts: 3,
|
|
234
|
+
project: "/projects/one",
|
|
235
|
+
});
|
|
236
|
+
assert.equal(step.pending(), 0);
|
|
237
|
+
assert.equal(watch.active, false);
|
|
238
|
+
await assert.rejects(stat(watch.stateFile), "a watch that settled on a verdict leaves no state file");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("gives up at the attempt cap and reports an inconclusive verdict", async () => {
|
|
242
|
+
const stateDir = join(await tempRoot.createWorkspace(), "state");
|
|
243
|
+
const inProgress = run(2, report({ reason: "still-running" }));
|
|
244
|
+
const { watch, step, deliveries } = createWatch({
|
|
245
|
+
stateDir,
|
|
246
|
+
maxAttempts: 3,
|
|
247
|
+
runs: [inProgress, inProgress, inProgress, inProgress],
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await watch.start();
|
|
251
|
+
await step.fire();
|
|
252
|
+
await step.fire();
|
|
253
|
+
|
|
254
|
+
assert.equal(deliveries.length, 1);
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
{
|
|
257
|
+
verdict: deliveries[0].details.verdict,
|
|
258
|
+
reason: deliveries[0].details.reason,
|
|
259
|
+
runUrl: deliveries[0].details.runUrl,
|
|
260
|
+
attempts: deliveries[0].details.attempts,
|
|
261
|
+
},
|
|
262
|
+
{ verdict: "inconclusive", reason: "still-running", runUrl: null, attempts: 3 },
|
|
263
|
+
);
|
|
264
|
+
assert.equal(step.pending(), 0);
|
|
265
|
+
await assert.rejects(
|
|
266
|
+
stat(watch.stateFile),
|
|
267
|
+
"a watch that settled on the attempt cap leaves no state file",
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("keeps polling when the check script cannot run, then reports inconclusive", async () => {
|
|
272
|
+
const stateDir = join(await tempRoot.createWorkspace(), "state");
|
|
273
|
+
const step = createStepScheduler();
|
|
274
|
+
const deliveries: WakeMessage[] = [];
|
|
275
|
+
const warnings: string[] = [];
|
|
276
|
+
|
|
277
|
+
const watch = new PostMergeWatch({
|
|
278
|
+
bead: "agnt-ctx-86u4.2",
|
|
279
|
+
project: "/projects/one",
|
|
280
|
+
script: "/missing/post-merge-check.mjs",
|
|
281
|
+
stateDir,
|
|
282
|
+
maxAttempts: 2,
|
|
283
|
+
scheduler: step.scheduler,
|
|
284
|
+
runCheck: async () => {
|
|
285
|
+
throw new Error("spawn ENOENT");
|
|
286
|
+
},
|
|
287
|
+
deliver: (message) => deliveries.push(message),
|
|
288
|
+
warn: (message) => warnings.push(message),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await watch.start();
|
|
292
|
+
await step.fire();
|
|
293
|
+
|
|
294
|
+
assert.equal(deliveries.length, 1);
|
|
295
|
+
assert.equal(deliveries[0].details.verdict, "inconclusive");
|
|
296
|
+
assert.equal(deliveries[0].details.reason, "no-report");
|
|
297
|
+
assert.match(warnings.join("\n"), /spawn ENOENT/);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("records one state file per project and bead, and removes only the stopped watch's", async () => {
|
|
301
|
+
const stateDir = join(await tempRoot.createWorkspace(), "state");
|
|
302
|
+
const inProgress = () => [run(2, report({ reason: "still-running" }))];
|
|
303
|
+
const sameBeadOtherProject = createWatch({ stateDir, project: "/projects/two", runs: inProgress() });
|
|
304
|
+
const otherBeadSameProject = createWatch({ stateDir, bead: "agnt-ctx-other.1", runs: inProgress() });
|
|
305
|
+
|
|
306
|
+
const first = createWatch({ stateDir, runs: inProgress() });
|
|
307
|
+
await first.watch.start();
|
|
308
|
+
await sameBeadOtherProject.watch.start();
|
|
309
|
+
await otherBeadSameProject.watch.start();
|
|
310
|
+
|
|
311
|
+
const paths = [
|
|
312
|
+
first.watch.stateFile,
|
|
313
|
+
sameBeadOtherProject.watch.stateFile,
|
|
314
|
+
otherBeadSameProject.watch.stateFile,
|
|
315
|
+
];
|
|
316
|
+
assert.equal(new Set(paths).size, 3, "one file per project and bead");
|
|
317
|
+
assert.deepEqual(
|
|
318
|
+
{ bead: (await readState(paths[0])).bead, project: (await readState(paths[0])).project },
|
|
319
|
+
{ bead: "agnt-ctx-86u4.2", project: "/projects/one" },
|
|
320
|
+
);
|
|
321
|
+
assert.deepEqual(
|
|
322
|
+
{ bead: (await readState(paths[1])).bead, project: (await readState(paths[1])).project },
|
|
323
|
+
{ bead: "agnt-ctx-86u4.2", project: "/projects/two" },
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
await first.watch.stop();
|
|
327
|
+
|
|
328
|
+
await assert.rejects(stat(paths[0]), "a settled watch leaves no state file");
|
|
329
|
+
assert.equal((await stat(paths[1])).isFile(), true, "another project's watch is untouched");
|
|
330
|
+
|
|
331
|
+
await Promise.all([sameBeadOtherProject.watch.stop(), otherBeadSameProject.watch.stop()]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("stops polling and removes its state file when the session shuts down", async () => {
|
|
335
|
+
const stateDir = join(await tempRoot.createWorkspace(), "state");
|
|
336
|
+
const { watch, step, deliveries } = createWatch({
|
|
337
|
+
stateDir,
|
|
338
|
+
runs: [run(2, report({ reason: "still-running" }))],
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await watch.start();
|
|
342
|
+
const stateFile = watch.stateFile;
|
|
343
|
+
assert.equal((await readState(stateFile)).attempts, 1);
|
|
344
|
+
|
|
345
|
+
await watch.stop();
|
|
346
|
+
|
|
347
|
+
await assert.rejects(stat(stateFile));
|
|
348
|
+
assert.equal(step.pending(), 0);
|
|
349
|
+
assert.equal(watch.active, false);
|
|
350
|
+
assert.deepEqual(deliveries, []);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("removes the state file when the session stops before the first write lands", async () => {
|
|
354
|
+
const stateDir = join(await tempRoot.createWorkspace(), "state");
|
|
355
|
+
const deliveries: WakeMessage[] = [];
|
|
356
|
+
let armed = true;
|
|
357
|
+
let stopping: Promise<void> = Promise.resolve();
|
|
358
|
+
let watch: PostMergeWatch;
|
|
359
|
+
|
|
360
|
+
const { scheduler, step } = createWriteWindowScheduler(() => {
|
|
361
|
+
if (!armed) return;
|
|
362
|
+
armed = false;
|
|
363
|
+
stopping = watch.stop();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
watch = new PostMergeWatch({
|
|
367
|
+
bead: "agnt-ctx-86u4.2",
|
|
368
|
+
project: "/projects/one",
|
|
369
|
+
script: "/agent-cortex/skills/workflow/ralph/scripts/post-merge-check.mjs",
|
|
370
|
+
stateDir,
|
|
371
|
+
scheduler,
|
|
372
|
+
runCheck: async () => run(2, report({ reason: "still-running" })),
|
|
373
|
+
deliver: (message) => deliveries.push(message),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const starting = watch.start();
|
|
377
|
+
await stopping; // the remove lands before the write it is racing with
|
|
378
|
+
await starting;
|
|
379
|
+
|
|
380
|
+
await assert.rejects(
|
|
381
|
+
stat(watch.stateFile),
|
|
382
|
+
"a watch stopped before its first state write lands leaves no state file",
|
|
383
|
+
);
|
|
384
|
+
assert.equal(step.pending(), 0);
|
|
385
|
+
assert.deepEqual(deliveries, []);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("removes the state file and schedules nothing when the session stops during a poll's write", async () => {
|
|
389
|
+
const stateDir = join(await tempRoot.createWorkspace(), "state");
|
|
390
|
+
const deliveries: WakeMessage[] = [];
|
|
391
|
+
let armed = false;
|
|
392
|
+
let stopping: Promise<void> = Promise.resolve();
|
|
393
|
+
let watch: PostMergeWatch;
|
|
394
|
+
|
|
395
|
+
const { scheduler, step } = createWriteWindowScheduler(() => {
|
|
396
|
+
if (!armed) return;
|
|
397
|
+
armed = false;
|
|
398
|
+
stopping = watch.stop();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
watch = new PostMergeWatch({
|
|
402
|
+
bead: "agnt-ctx-86u4.2",
|
|
403
|
+
project: "/projects/one",
|
|
404
|
+
script: "/agent-cortex/skills/workflow/ralph/scripts/post-merge-check.mjs",
|
|
405
|
+
stateDir,
|
|
406
|
+
scheduler,
|
|
407
|
+
runCheck: async () => run(2, report({ reason: "still-running" })),
|
|
408
|
+
deliver: (message) => deliveries.push(message),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
await watch.start();
|
|
412
|
+
armed = true;
|
|
413
|
+
await step.fire(); // the poll's state write reads the clock, so stop() lands inside it
|
|
414
|
+
await stopping;
|
|
415
|
+
|
|
416
|
+
await assert.rejects(
|
|
417
|
+
stat(watch.stateFile),
|
|
418
|
+
"a watch stopped during a poll's state write leaves no state file",
|
|
419
|
+
);
|
|
420
|
+
assert.equal(step.pending(), 0, "a stopped watch schedules no further check");
|
|
421
|
+
assert.deepEqual(deliveries, []);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("does not wake a session that stopped while a check was in flight", async () => {
|
|
425
|
+
const stateDir = join(await tempRoot.createWorkspace(), "state");
|
|
426
|
+
const step = createStepScheduler();
|
|
427
|
+
const deliveries: WakeMessage[] = [];
|
|
428
|
+
const gate = createDeferred<CheckRun>();
|
|
429
|
+
|
|
430
|
+
const watch = new PostMergeWatch({
|
|
431
|
+
bead: "agnt-ctx-86u4.2",
|
|
432
|
+
project: "/projects/one",
|
|
433
|
+
script: "/agent-cortex/skills/workflow/ralph/scripts/post-merge-check.mjs",
|
|
434
|
+
stateDir,
|
|
435
|
+
scheduler: step.scheduler,
|
|
436
|
+
runCheck: () => gate.promise,
|
|
437
|
+
deliver: (message) => deliveries.push(message),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const starting = watch.start();
|
|
441
|
+
await watch.stop();
|
|
442
|
+
gate.resolve(run(1, report({ reason: "run-failed", runUrl: "https://gh/run/9" })));
|
|
443
|
+
await starting;
|
|
444
|
+
|
|
445
|
+
assert.deepEqual(deliveries, []);
|
|
446
|
+
await assert.rejects(stat(watch.stateFile));
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe("stateFileFor", () => {
|
|
451
|
+
it("keeps two projects that share a directory name apart", () => {
|
|
452
|
+
const stateDir = "/state";
|
|
453
|
+
const left = stateFileFor(stateDir, "/work/one/project", "agnt-ctx-86u4.2");
|
|
454
|
+
const right = stateFileFor(stateDir, "/work/two/project", "agnt-ctx-86u4.2");
|
|
455
|
+
|
|
456
|
+
assert.notEqual(left, right);
|
|
457
|
+
assert.match(left, /agnt-ctx-86u4\.2/);
|
|
458
|
+
assert.equal(stateFileFor(stateDir, "/work/one/project", "agnt-ctx-86u4.2"), left);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
/** A promise the test settles itself, so an in-flight check can be held open. */
|
|
463
|
+
function createDeferred<T>() {
|
|
464
|
+
let settle: (value: T) => void = () => {};
|
|
465
|
+
const promise = new Promise<T>((resolve) => {
|
|
466
|
+
settle = resolve;
|
|
467
|
+
});
|
|
468
|
+
return { promise, resolve: settle };
|
|
469
|
+
}
|