agent-dealer 1.2.8 → 1.2.10
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/bundle/server/dist/adapters/github.js +93 -4
- package/bundle/server/dist/adapters/github.test.js +96 -1
- package/bundle/server/dist/adapters/worktree-deps.js +96 -0
- package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
- package/bundle/server/dist/coordinator/auto-merge.js +205 -1
- package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
- package/bundle/server/dist/coordinator/commands.js +341 -18
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/developer-effect.js +237 -3
- package/bundle/server/dist/coordinator/developer-effect.test.js +460 -3
- package/bundle/server/dist/coordinator/human-resolution.js +19 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +100 -12
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +190 -0
- package/bundle/server/dist/coordinator/muse-spawn.js +22 -4
- package/bundle/server/dist/coordinator/muse-spawn.test.js +166 -10
- package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
- package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
- package/bundle/server/dist/coordinator/projection.js +10 -4
- package/bundle/server/dist/coordinator/projection.test.js +13 -0
- package/bundle/server/dist/coordinator/prompts.js +134 -1
- package/bundle/server/dist/coordinator/prompts.test.js +148 -0
- package/bundle/server/dist/coordinator/recovery.js +57 -3
- package/bundle/server/dist/coordinator/recovery.test.js +67 -8
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
- package/bundle/server/dist/coordinator/routing.js +55 -6
- package/bundle/server/dist/coordinator/routing.test.js +62 -15
- package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
- package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
- package/bundle/server/dist/db/index.js +13 -0
- package/bundle/server/dist/db/schema.sql +2 -0
- package/bundle/server/dist/repository/issues.js +39 -3
- package/bundle/server/dist/repository/work-items.js +7 -0
- package/bundle/server/dist/runners/muse-code-args.js +25 -0
- package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
- package/bundle/server/dist/runners/muse-code-jsonl.js +61 -0
- package/bundle/server/dist/runners/muse-code.test.js +13 -1
- package/bundle/server/dist/runners/spawn-cli.js +93 -20
- package/bundle/server/dist/runners/spawn-cli.test.js +38 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +8 -0
- package/bundle/shared/dist/issues.d.ts +30 -0
- package/bundle/shared/dist/issues.js +14 -0
- package/bundle/shared/dist/issues.test.js +2 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +6 -0
- package/bundle/shared/dist/workflow.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.js +4 -1
- package/dist/action.test.js +42 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
|
@@ -2,30 +2,36 @@
|
|
|
2
2
|
// arrival-stamped normalized events, and the five-field stall metadata object.
|
|
3
3
|
// The full `runMuseDeveloperSession` needs a Muse binary + credentials, so these
|
|
4
4
|
// test the pure pieces it wires together (plus the parser contract it relies on).
|
|
5
|
-
import { test } from "node:test";
|
|
5
|
+
import { test, after } from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
7
8
|
import fs from "node:fs";
|
|
8
9
|
import os from "node:os";
|
|
9
10
|
import path from "node:path";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { MUSE_CODE_CONTRIBUTOR_MODEL } from "@agent-dealer/shared";
|
|
13
|
+
import { parseMuseRun, isMuseTerminalStdoutLine, createMusePrimaryTerminalMatcher, } from "../runners/muse-code-jsonl.js";
|
|
14
|
+
import { museIdleTimeoutMs, museTerminalGraceMs, DEFAULT_MUSE_IDLE_TIMEOUT_MS, DEFAULT_MUSE_TERMINAL_GRACE_MS, } from "./session-timeouts.js";
|
|
15
|
+
import { museIdleMinutes, museStallMetadata, runMuseDeveloperSession, sessionLogActivityMs, writeMuseNormalizedLog, } from "./muse-spawn.js";
|
|
13
16
|
const MODEL = "muse-spark-1.3-contributor";
|
|
14
17
|
// ── MUSE_IDLE_TIMEOUT_MS parsing ─────────────────────────────────────────────
|
|
15
18
|
function withIdleEnv(raw, fn) {
|
|
16
|
-
|
|
19
|
+
withEnv("MUSE_IDLE_TIMEOUT_MS", raw, fn);
|
|
20
|
+
}
|
|
21
|
+
function withEnv(name, raw, fn) {
|
|
22
|
+
const prev = process.env[name];
|
|
17
23
|
try {
|
|
18
24
|
if (raw === undefined)
|
|
19
|
-
delete process.env
|
|
25
|
+
delete process.env[name];
|
|
20
26
|
else
|
|
21
|
-
process.env
|
|
27
|
+
process.env[name] = raw;
|
|
22
28
|
fn();
|
|
23
29
|
}
|
|
24
30
|
finally {
|
|
25
31
|
if (prev === undefined)
|
|
26
|
-
delete process.env
|
|
32
|
+
delete process.env[name];
|
|
27
33
|
else
|
|
28
|
-
process.env
|
|
34
|
+
process.env[name] = prev;
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
37
|
test("museIdleTimeoutMs defaults to 30 minutes when unset or blank", () => {
|
|
@@ -97,19 +103,21 @@ test("museIdleMinutes measures silence from last activity, else the bound", () =
|
|
|
97
103
|
assert.equal(museIdleMinutes({ lastActivityAt: null, idleTimeoutMs: undefined }), null, "disabled watchdog has no idle kill to explain");
|
|
98
104
|
});
|
|
99
105
|
// ── stall metadata object ────────────────────────────────────────────────────
|
|
100
|
-
test("museStallMetadata carries
|
|
106
|
+
test("museStallMetadata carries stall-evidence fields including lingeredAfterTerminal", () => {
|
|
101
107
|
assert.deepEqual(museStallMetadata({
|
|
102
108
|
lastActivityAt: "2026-10-01T19:00:00.000Z",
|
|
103
109
|
toolCallCount: 24,
|
|
104
110
|
lastToolName: "npm_test",
|
|
105
111
|
firstOutputMs: 1234,
|
|
106
112
|
idleTimedOut: true,
|
|
113
|
+
lingeredAfterTerminal: false,
|
|
107
114
|
}), {
|
|
108
115
|
lastActivityAt: "2026-10-01T19:00:00.000Z",
|
|
109
116
|
toolCallCount: 24,
|
|
110
117
|
lastToolName: "npm_test",
|
|
111
118
|
firstOutputMs: 1234,
|
|
112
119
|
idleTimedOut: true,
|
|
120
|
+
lingeredAfterTerminal: false,
|
|
113
121
|
});
|
|
114
122
|
});
|
|
115
123
|
// ── arrival-stamped normalized events ────────────────────────────────────────
|
|
@@ -185,6 +193,7 @@ test("the written ndjson carries ts on every event and durationMs on completed t
|
|
|
185
193
|
firstOutputMs: 900,
|
|
186
194
|
lastActivityAt: new Date(t0 + 63_000).toISOString(),
|
|
187
195
|
idleTimedOut: false,
|
|
196
|
+
lingeredAfterTerminal: false,
|
|
188
197
|
toolCallCount: r.tools.length,
|
|
189
198
|
lastToolName: "npm_test",
|
|
190
199
|
};
|
|
@@ -206,5 +215,152 @@ test("the written ndjson carries ts on every event and durationMs on completed t
|
|
|
206
215
|
assert.equal(muse.toolCallCount, 2);
|
|
207
216
|
assert.equal(muse.lastToolName, "npm_test");
|
|
208
217
|
assert.equal(muse.idleTimedOut, false);
|
|
218
|
+
assert.equal(muse.lingeredAfterTerminal, false);
|
|
209
219
|
assert.equal(muse.lastActivityAt, new Date(t0 + 63_000).toISOString());
|
|
210
220
|
});
|
|
221
|
+
// ── MUSE_TERMINAL_GRACE_MS parsing ───────────────────────────────────────────
|
|
222
|
+
test("museTerminalGraceMs defaults to 30 seconds when unset or blank", () => {
|
|
223
|
+
withEnv("MUSE_TERMINAL_GRACE_MS", undefined, () => assert.equal(museTerminalGraceMs(), DEFAULT_MUSE_TERMINAL_GRACE_MS));
|
|
224
|
+
withEnv("MUSE_TERMINAL_GRACE_MS", "", () => assert.equal(museTerminalGraceMs(), DEFAULT_MUSE_TERMINAL_GRACE_MS));
|
|
225
|
+
withEnv("MUSE_TERMINAL_GRACE_MS", " ", () => assert.equal(museTerminalGraceMs(), DEFAULT_MUSE_TERMINAL_GRACE_MS));
|
|
226
|
+
assert.equal(DEFAULT_MUSE_TERMINAL_GRACE_MS, 30_000);
|
|
227
|
+
});
|
|
228
|
+
test("MUSE_TERMINAL_GRACE_MS=0 disables the post-terminal kill", () => {
|
|
229
|
+
withEnv("MUSE_TERMINAL_GRACE_MS", "0", () => assert.equal(museTerminalGraceMs(), undefined));
|
|
230
|
+
});
|
|
231
|
+
test("isMuseTerminalStdoutLine matches completed and failed envelopes only", () => {
|
|
232
|
+
assert.equal(isMuseTerminalStdoutLine(JSON.stringify({ payload_type: "run.terminal.failed", payload: { terminal: "failed", reason: "x" } })), true);
|
|
233
|
+
assert.equal(isMuseTerminalStdoutLine(JSON.stringify({ payload_type: "run.terminal.completed", payload: {} })), true);
|
|
234
|
+
assert.equal(isMuseTerminalStdoutLine(JSON.stringify({ payload_type: "tool.result", payload: {} })), false);
|
|
235
|
+
assert.equal(isMuseTerminalStdoutLine("not json"), false);
|
|
236
|
+
});
|
|
237
|
+
// ── NOT-342: linger after terminal, through the Muse spawn ───────────────────
|
|
238
|
+
const FAKE_MUSE = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "fake-muse.mjs");
|
|
239
|
+
const LINGER_HOME = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-linger-"));
|
|
240
|
+
after(() => {
|
|
241
|
+
fs.rmSync(LINGER_HOME, { recursive: true, force: true });
|
|
242
|
+
});
|
|
243
|
+
function lingerScratchWorktree() {
|
|
244
|
+
const wt = fs.mkdtempSync(path.join(LINGER_HOME, "wt-"));
|
|
245
|
+
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: wt });
|
|
246
|
+
execFileSync("git", ["config", "user.email", "t@example.com"], { cwd: wt });
|
|
247
|
+
execFileSync("git", ["config", "user.name", "T"], { cwd: wt });
|
|
248
|
+
fs.writeFileSync(path.join(wt, "a.txt"), "a\n");
|
|
249
|
+
execFileSync("git", ["add", "."], { cwd: wt });
|
|
250
|
+
execFileSync("git", ["-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "init"], {
|
|
251
|
+
cwd: wt,
|
|
252
|
+
});
|
|
253
|
+
return wt;
|
|
254
|
+
}
|
|
255
|
+
async function runLingeringMuse(opts) {
|
|
256
|
+
const keys = [
|
|
257
|
+
"MUSE_CLI",
|
|
258
|
+
"FAKE_MUSE_SCENARIO",
|
|
259
|
+
"META_API_KEY",
|
|
260
|
+
"AGENT_DEALER_HOME",
|
|
261
|
+
"MUSE_TERMINAL_GRACE_MS",
|
|
262
|
+
"MUSE_IDLE_TIMEOUT_MS",
|
|
263
|
+
];
|
|
264
|
+
const prev = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
265
|
+
const wt = lingerScratchWorktree();
|
|
266
|
+
const dealerHome = fs.mkdtempSync(path.join(LINGER_HOME, "home-"));
|
|
267
|
+
const startedAt = Date.now();
|
|
268
|
+
try {
|
|
269
|
+
process.env.MUSE_CLI = FAKE_MUSE;
|
|
270
|
+
process.env.FAKE_MUSE_SCENARIO = opts.scenario;
|
|
271
|
+
process.env.META_API_KEY = "mk-test-linger-key";
|
|
272
|
+
process.env.AGENT_DEALER_HOME = dealerHome;
|
|
273
|
+
process.env.MUSE_TERMINAL_GRACE_MS = opts.graceMs;
|
|
274
|
+
process.env.MUSE_IDLE_TIMEOUT_MS = "0";
|
|
275
|
+
const result = await runMuseDeveloperSession({
|
|
276
|
+
sessionId: opts.sessionId,
|
|
277
|
+
runtime: "muse_code",
|
|
278
|
+
policy: { worktreeWrite: true },
|
|
279
|
+
model: MUSE_CODE_CONTRIBUTOR_MODEL,
|
|
280
|
+
deckId: "00000000-0000-4000-a000-000000000099",
|
|
281
|
+
agentDeckUrl: "http://127.0.0.1:1110/mcp",
|
|
282
|
+
prompt: "Implement it",
|
|
283
|
+
cwd: wt,
|
|
284
|
+
timeoutMs: opts.timeoutMs,
|
|
285
|
+
logPath: path.join(dealerHome, `muse-${opts.sessionId}.ndjson`),
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
timedOut: result.timedOut,
|
|
289
|
+
// Assert top-level and muse summary separately — a regression that drops
|
|
290
|
+
// either field must fail (do not coalesce with ??).
|
|
291
|
+
lingeredAfterTerminal: result.lingeredAfterTerminal,
|
|
292
|
+
museLingeredAfterTerminal: result.muse?.lingeredAfterTerminal,
|
|
293
|
+
failureMessage: result.muse?.failure?.message ?? null,
|
|
294
|
+
failureKind: result.muse?.failure?.kind ?? null,
|
|
295
|
+
elapsedMs: Date.now() - startedAt,
|
|
296
|
+
logPath: result.logPath,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
for (const k of keys) {
|
|
301
|
+
if (prev[k] === undefined)
|
|
302
|
+
delete process.env[k];
|
|
303
|
+
else
|
|
304
|
+
process.env[k] = prev[k];
|
|
305
|
+
}
|
|
306
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
307
|
+
fs.rmSync(dealerHome, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
test("a Muse transport-error that lingers is recorded as a failure, not a timeout", { timeout: 15_000 }, async () => {
|
|
311
|
+
const out = await runLingeringMuse({
|
|
312
|
+
scenario: "transport-linger",
|
|
313
|
+
graceMs: "200",
|
|
314
|
+
timeoutMs: 20_000,
|
|
315
|
+
sessionId: "00000000-0000-4000-8000-000000000342",
|
|
316
|
+
});
|
|
317
|
+
assert.ok(out.elapsedMs < 5_000, `settled on the grace, took ${out.elapsedMs}ms`);
|
|
318
|
+
assert.equal(out.timedOut, false);
|
|
319
|
+
assert.equal(out.lingeredAfterTerminal, true);
|
|
320
|
+
assert.equal(out.museLingeredAfterTerminal, true);
|
|
321
|
+
assert.match(out.failureMessage ?? "", /transport error/);
|
|
322
|
+
});
|
|
323
|
+
test("a completed Muse run that lingers is still a completion, not a timeout", { timeout: 15_000 }, async () => {
|
|
324
|
+
const out = await runLingeringMuse({
|
|
325
|
+
scenario: "completed-linger",
|
|
326
|
+
graceMs: "200",
|
|
327
|
+
timeoutMs: 20_000,
|
|
328
|
+
sessionId: "00000000-0000-4000-8000-000000000344",
|
|
329
|
+
});
|
|
330
|
+
assert.equal(out.timedOut, false);
|
|
331
|
+
assert.equal(out.lingeredAfterTerminal, true);
|
|
332
|
+
assert.equal(out.museLingeredAfterTerminal, true);
|
|
333
|
+
assert.equal(out.failureMessage, null);
|
|
334
|
+
});
|
|
335
|
+
test("MUSE_TERMINAL_GRACE_MS=0 leaves a lingering Muse child to the wall clock", { timeout: 15_000 }, async () => {
|
|
336
|
+
const out = await runLingeringMuse({
|
|
337
|
+
scenario: "transport-linger",
|
|
338
|
+
graceMs: "0",
|
|
339
|
+
timeoutMs: 500,
|
|
340
|
+
sessionId: "00000000-0000-4000-8000-000000000343",
|
|
341
|
+
});
|
|
342
|
+
assert.equal(out.lingeredAfterTerminal, false);
|
|
343
|
+
assert.equal(out.museLingeredAfterTerminal, false);
|
|
344
|
+
assert.equal(out.timedOut, true, "without the grace, the wall clock still kills");
|
|
345
|
+
});
|
|
346
|
+
// NOT-342 repair: a cron run's terminal must not arm the 30s grace and SIGTERM the
|
|
347
|
+
// still-working primary. The child has no primary terminal — only the wall clock kills.
|
|
348
|
+
test("a cron terminal before the primary does not arm terminal grace", { timeout: 15_000 }, async () => {
|
|
349
|
+
const out = await runLingeringMuse({
|
|
350
|
+
scenario: "cron-terminal-then-primary-linger",
|
|
351
|
+
graceMs: "200",
|
|
352
|
+
timeoutMs: 800,
|
|
353
|
+
sessionId: "00000000-0000-4000-8000-000000000345",
|
|
354
|
+
});
|
|
355
|
+
assert.equal(out.lingeredAfterTerminal, false, "cron terminal must not linger-kill");
|
|
356
|
+
assert.equal(out.museLingeredAfterTerminal, false);
|
|
357
|
+
assert.equal(out.timedOut, true, "wall clock still owns the kill when primary never terminals");
|
|
358
|
+
assert.notEqual(out.failureKind, "malformed_stream");
|
|
359
|
+
});
|
|
360
|
+
test("createMusePrimaryTerminalMatcher is what Muse wires for terminalGrace", () => {
|
|
361
|
+
// Sanity: the factory exists and matches the shape spawnCli expects.
|
|
362
|
+
const m = createMusePrimaryTerminalMatcher();
|
|
363
|
+
assert.equal(typeof m.isTerminalLine, "function");
|
|
364
|
+
assert.equal(typeof m.sawPrimaryTerminal, "function");
|
|
365
|
+
assert.equal(m.sawPrimaryTerminal(), false);
|
|
366
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// packages/server/src/coordinator/operator-criteria.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-314: `[operator]`-tagged acceptance criteria are a first-class merge gate.
|
|
4
|
+
// Some ACs can only be proven by a human with real credentials, a real login, or
|
|
5
|
+
// a paid session — the builder discovers this mid-run and today burns rounds
|
|
6
|
+
// before raising a product-scope escalation. Instead the tag is deliberate: the
|
|
7
|
+
// frozen snapshot's operator criteria block auto-merge until a human records the
|
|
8
|
+
// result (verified / waive) or sends the work back (repair).
|
|
9
|
+
//
|
|
10
|
+
// This module is pure except for the frozen-snapshot + verification-record
|
|
11
|
+
// readers at the bottom. The gate itself lives in auto-merge.ts
|
|
12
|
+
// (finalizeAutoMergeOnce, after reviewer approve, before the `gh` merge);
|
|
13
|
+
// resolution lives in commands.ts's resolveHumanActionAndAdvance.
|
|
14
|
+
import { latestIssueArtifact } from "../repository/artifacts.js";
|
|
15
|
+
import { listArtifactsForIssueByKind } from "../repository/artifacts-for-issue.js";
|
|
16
|
+
import { listWorkflowEventsForIssue } from "../repository/workflow-events.js";
|
|
17
|
+
/** The frozen snapshot artifact kind — same string as commands.ts's
|
|
18
|
+
* TASK_SNAPSHOT_ARTIFACT_KIND, repeated here so this module never imports the
|
|
19
|
+
* coordinator kernel (commands.ts imports auto-merge.ts, which imports this). */
|
|
20
|
+
const TASK_SNAPSHOT_KIND = "task_snapshot";
|
|
21
|
+
/** Issue artifact kind holding a recorded `verified` result (author human). */
|
|
22
|
+
export const OPERATOR_VERIFICATION_ARTIFACT_KIND = "operator_verification";
|
|
23
|
+
/**
|
|
24
|
+
* NOT-314: the stored response options for an `operator_verification` action.
|
|
25
|
+
* Shared by auto-merge.ts (which raises it) and commands.ts's responseOptionsFor
|
|
26
|
+
* so the two can never drift apart — same pattern as MERGE_FAILURE_RESPONSE_OPTIONS.
|
|
27
|
+
*/
|
|
28
|
+
export const OPERATOR_VERIFICATION_RESPONSE_OPTIONS = [
|
|
29
|
+
{ choice: "verified", label: "Verified — merge" },
|
|
30
|
+
{ choice: "waive", label: "Waive — merge without it" },
|
|
31
|
+
{ choice: "repair", label: "Another repair round" },
|
|
32
|
+
];
|
|
33
|
+
/** Dedupe key for the gate's action: one open action per issue + head SHA. */
|
|
34
|
+
export function operatorVerificationRequestId(headSha) {
|
|
35
|
+
return `operator-verification:${headSha}`;
|
|
36
|
+
}
|
|
37
|
+
const OPERATOR_TAG_RE = /\[operator\]/i;
|
|
38
|
+
/** An indented checkbox (a nested sub-task) — never a command, never a gate. */
|
|
39
|
+
const INDENTED_CHECKBOX_RE = /^\s+[-*]\s*\[[ xX]?\]/;
|
|
40
|
+
/** An indented plain bullet — an evidence/command sub-bullet. */
|
|
41
|
+
const INDENTED_BULLET_RE = /^\s+[-*]\s+(.*)$/;
|
|
42
|
+
/**
|
|
43
|
+
* Returns each top-level (unindented) checkbox AC whose text contains the token
|
|
44
|
+
* `[operator]` (case-insensitive, anywhere in the line), together with the
|
|
45
|
+
* indented bullet lines directly under it (the command to run).
|
|
46
|
+
*
|
|
47
|
+
* "Top-level" means the checkbox marker starts at column 0; indented
|
|
48
|
+
* checkboxes (sub-tasks) never open the gate. Collection of a criterion's
|
|
49
|
+
* commands stops at the next column-0 checkbox, a non-bullet line, or EOF —
|
|
50
|
+
* blank lines inside the sub-bullet block are skipped, not terminators.
|
|
51
|
+
*/
|
|
52
|
+
export function extractOperatorCriteria(acceptanceCriteria) {
|
|
53
|
+
const found = [];
|
|
54
|
+
const lines = acceptanceCriteria.split("\n");
|
|
55
|
+
let i = 0;
|
|
56
|
+
while (i < lines.length) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
const top = line.match(/^[-*]\s*\[[ xX]?\]\s*(.*)$/);
|
|
59
|
+
if (top && OPERATOR_TAG_RE.test(top[1] ?? "")) {
|
|
60
|
+
const criterion = { text: (top[1] ?? "").trim(), commands: [] };
|
|
61
|
+
i += 1;
|
|
62
|
+
while (i < lines.length) {
|
|
63
|
+
const sub = lines[i];
|
|
64
|
+
if (/^\s*$/.test(sub)) {
|
|
65
|
+
i += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// A column-0 checkbox ends this criterion's block (operator or not —
|
|
69
|
+
// the outer loop classifies it).
|
|
70
|
+
if (/^[-*]\s*\[[ xX]?\]\s*/.test(sub))
|
|
71
|
+
break;
|
|
72
|
+
// A nested (indented) checkbox is a sub-task, not a command — it ends
|
|
73
|
+
// the block without being collected.
|
|
74
|
+
if (INDENTED_CHECKBOX_RE.test(sub))
|
|
75
|
+
break;
|
|
76
|
+
const bullet = sub.match(INDENTED_BULLET_RE);
|
|
77
|
+
if (bullet) {
|
|
78
|
+
criterion.commands.push((bullet[1] ?? "").trim());
|
|
79
|
+
i += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
found.push(criterion);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
/** The frozen snapshot's acceptanceCriteria, falling back to the live issue
|
|
92
|
+
* fields for items queued before snapshots existed (same fallback as
|
|
93
|
+
* commands.ts's getTaskSnapshot). */
|
|
94
|
+
export function snapshotAcceptanceCriteria(issue) {
|
|
95
|
+
try {
|
|
96
|
+
const artifact = latestIssueArtifact(issue.id, TASK_SNAPSHOT_KIND);
|
|
97
|
+
if (artifact?.contentJson) {
|
|
98
|
+
const parsed = JSON.parse(artifact.contentJson);
|
|
99
|
+
if (typeof parsed.acceptanceCriteria === "string")
|
|
100
|
+
return parsed.acceptanceCriteria;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// fall through to the live-field fallback below
|
|
105
|
+
}
|
|
106
|
+
return issue.acceptanceCriteria ?? "";
|
|
107
|
+
}
|
|
108
|
+
/** The frozen snapshot's `[operator]` criteria for this issue ([] when none). */
|
|
109
|
+
export function getOperatorCriteriaForIssue(issue) {
|
|
110
|
+
return extractOperatorCriteria(snapshotAcceptanceCriteria(issue));
|
|
111
|
+
}
|
|
112
|
+
function recordHeadSha(record) {
|
|
113
|
+
return record && typeof record.headSha === "string" && record.headSha ? record.headSha : null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* True when the human already recorded a result for exactly `headSha`: either a
|
|
117
|
+
* `verified` artifact (kind `operator_verification`, author human) or a
|
|
118
|
+
* `operator_verification.waived` workflow event. A result pins the head it was
|
|
119
|
+
* recorded at — new commits re-block the gate.
|
|
120
|
+
*/
|
|
121
|
+
export function hasOperatorVerificationForHead(issueId, headSha) {
|
|
122
|
+
if (!headSha)
|
|
123
|
+
return false;
|
|
124
|
+
try {
|
|
125
|
+
for (const artifact of listArtifactsForIssueByKind(issueId, OPERATOR_VERIFICATION_ARTIFACT_KIND)) {
|
|
126
|
+
if (!artifact.contentJson)
|
|
127
|
+
continue;
|
|
128
|
+
try {
|
|
129
|
+
const record = JSON.parse(artifact.contentJson);
|
|
130
|
+
if (recordHeadSha(record) === headSha)
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// unreadable artifact content cannot prove verification
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// a read failure fails closed below (no verification found → gate blocks)
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
for (const event of listWorkflowEventsForIssue(issueId)) {
|
|
143
|
+
if (event.type !== "operator_verification.waived" || !event.payloadJson)
|
|
144
|
+
continue;
|
|
145
|
+
try {
|
|
146
|
+
const record = JSON.parse(event.payloadJson);
|
|
147
|
+
if (recordHeadSha(record) === headSha)
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// unreadable payload cannot prove a waiver
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// same fail-closed reasoning as above
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
/** One numbered criterion + its commands, for the gate's reason/question. */
|
|
161
|
+
export function formatOperatorCriteria(criteria) {
|
|
162
|
+
return criteria.flatMap((criterion, index) => {
|
|
163
|
+
const lines = [`${index + 1}. ${criterion.text}`];
|
|
164
|
+
for (const command of criterion.commands)
|
|
165
|
+
lines.push(` - ${command}`);
|
|
166
|
+
return lines;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// NOT-314: `[operator]` criterion parser — pure cases (no DB).
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { extractOperatorCriteria } from "./operator-criteria.js";
|
|
5
|
+
test("two [operator] ACs return both; the indented command sub-bullet is collected", () => {
|
|
6
|
+
const criteria = extractOperatorCriteria([
|
|
7
|
+
"- [ ] Agent can list sessions from the registry",
|
|
8
|
+
"- [ ] Operator can sign in with SSO and see the org dashboard [operator]",
|
|
9
|
+
" - `npm run probe:sso -- --env staging`",
|
|
10
|
+
"- [ ] Sessions expire after 30 days of inactivity",
|
|
11
|
+
"- [ ] Operator can complete a paid checkout in the sandbox [operator]",
|
|
12
|
+
"",
|
|
13
|
+
"- [ ] Docs describe the probe",
|
|
14
|
+
].join("\n"));
|
|
15
|
+
assert.equal(criteria.length, 2);
|
|
16
|
+
assert.equal(criteria[0].text, "Operator can sign in with SSO and see the org dashboard [operator]");
|
|
17
|
+
assert.deepEqual(criteria[0].commands, ["`npm run probe:sso -- --env staging`"]);
|
|
18
|
+
assert.equal(criteria[1].text, "Operator can complete a paid checkout in the sandbox [operator]");
|
|
19
|
+
assert.deepEqual(criteria[1].commands, []);
|
|
20
|
+
});
|
|
21
|
+
test("a snapshot with no [operator] AC returns an empty list", () => {
|
|
22
|
+
assert.deepEqual(extractOperatorCriteria("- [ ] Agent lists sessions\n- [ ] Sessions expire after 30d\n"), []);
|
|
23
|
+
assert.deepEqual(extractOperatorCriteria(""), []);
|
|
24
|
+
});
|
|
25
|
+
test("tag case variants match: [OPERATOR], [Operator], [oPeRaToR]", () => {
|
|
26
|
+
const criteria = extractOperatorCriteria(["- [ ] First [OPERATOR]", "- [ ] Second [Operator]", "- [ ] Third [oPeRaToR]"].join("\n"));
|
|
27
|
+
assert.equal(criteria.length, 3);
|
|
28
|
+
});
|
|
29
|
+
test("tag position variants match: leading, middle, trailing, and bare", () => {
|
|
30
|
+
const criteria = extractOperatorCriteria([
|
|
31
|
+
"- [ ] [operator] Leading tag proves login",
|
|
32
|
+
"- [ ] Middle [operator] tag proves login",
|
|
33
|
+
"- [ ] Trailing tag proves login [operator]",
|
|
34
|
+
"- [ ] [operator]",
|
|
35
|
+
].join("\n"));
|
|
36
|
+
assert.equal(criteria.length, 4);
|
|
37
|
+
assert.equal(criteria[0].text, "[operator] Leading tag proves login");
|
|
38
|
+
});
|
|
39
|
+
test("multiple indented sub-bullets all collect; blank lines inside the block are skipped", () => {
|
|
40
|
+
const criteria = extractOperatorCriteria([
|
|
41
|
+
"- [ ] Paid checkout works end to end [operator]",
|
|
42
|
+
" - `npm run probe:checkout -- --env staging`",
|
|
43
|
+
"",
|
|
44
|
+
" - see docs/probes/checkout.md for the test card",
|
|
45
|
+
].join("\n"));
|
|
46
|
+
assert.equal(criteria.length, 1);
|
|
47
|
+
assert.deepEqual(criteria[0].commands, [
|
|
48
|
+
"`npm run probe:checkout -- --env staging`",
|
|
49
|
+
"see docs/probes/checkout.md for the test card",
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
test("indented (nested) checkboxes never open the gate, even with the tag", () => {
|
|
53
|
+
const criteria = extractOperatorCriteria(["- [ ] Parent task", " - [ ] Nested subtask [operator]", "- [ ] Other"].join("\n"));
|
|
54
|
+
assert.deepEqual(criteria, []);
|
|
55
|
+
});
|
|
56
|
+
test("a checked box with the tag still matches, and `*` markers work", () => {
|
|
57
|
+
const criteria = extractOperatorCriteria(["* [x] Already proven by hand [operator]", "- [ ] Plain task"].join("\n"));
|
|
58
|
+
assert.equal(criteria.length, 1);
|
|
59
|
+
assert.equal(criteria[0].text, "Already proven by hand [operator]");
|
|
60
|
+
});
|
|
61
|
+
test("the next top-level checkbox ends the command block; prose ends it too", () => {
|
|
62
|
+
const criteria = extractOperatorCriteria([
|
|
63
|
+
"- [ ] First [operator]",
|
|
64
|
+
" - `cmd-one`",
|
|
65
|
+
"- [ ] Second without tag",
|
|
66
|
+
"- [ ] Third [operator]",
|
|
67
|
+
" - `cmd-three`",
|
|
68
|
+
"Trailing prose, not a bullet.",
|
|
69
|
+
"- [ ] Fourth [operator]",
|
|
70
|
+
].join("\n"));
|
|
71
|
+
assert.equal(criteria.length, 3);
|
|
72
|
+
assert.deepEqual(criteria[0].commands, ["`cmd-one`"]);
|
|
73
|
+
assert.deepEqual(criteria[1].commands, ["`cmd-three`"]);
|
|
74
|
+
assert.deepEqual(criteria[2].commands, []);
|
|
75
|
+
});
|
|
76
|
+
test("[agent] in AC text is not an operator criterion", () => {
|
|
77
|
+
assert.deepEqual(extractOperatorCriteria("- [ ] Agent lists sessions [agent]\n"), []);
|
|
78
|
+
});
|