@kylecheng3146/agent-ops 0.10.2 → 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/dist/packages/cli/src/args.js +8 -43
- package/dist/packages/cli/src/bin.js +28 -29
- package/dist/packages/cli/src/cli.js +12 -14
- package/dist/packages/cli/src/codex-loop-process.js +0 -6
- package/dist/packages/cli/src/commands/review.js +5 -10
- package/dist/packages/cli/src/commands/run.js +3 -1
- package/dist/packages/cli/src/commands/task.js +6 -50
- package/dist/packages/cli/src/commands/verify.js +7 -3
- package/dist/packages/cli/src/default-base.js +26 -0
- package/dist/packages/cli/src/hook-process.js +19 -83
- package/dist/packages/cli/src/run-entry.js +66 -21
- package/dist/runtime/src/adapters/agy/config.js +2 -3
- package/dist/runtime/src/adapters/claude/config.js +2 -2
- package/dist/runtime/src/adapters/codex/config.js +2 -5
- package/dist/runtime/src/hooks/completion-gate.js +0 -15
- package/dist/runtime/src/install/harness.js +3 -3
- package/dist/runtime/src/install/plan.js +3 -3
- package/dist/runtime/src/install/profiles.js +0 -2
- package/dist/runtime/src/office/snapshot.js +4 -1
- package/dist/runtime/src/parallel/manage.js +0 -3
- package/dist/runtime/src/review/execute.js +68 -4
- package/dist/runtime/src/run/verification.js +2 -2
- package/dist/runtime/src/schema/validate.js +10 -5
- package/dist/runtime/src/task/completion.js +4 -42
- package/dist/runtime/src/task/contract.js +11 -0
- package/dist/runtime/src/task/service.js +34 -57
- package/dist/runtime/src/task/store.js +3 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +2 -3
- package/dist/runtime/src/verify/acceptance-protocol.js +4 -3
- package/dist/runtime/src/verify/acceptance-replay.js +4 -7
- package/dist/runtime/src/verify/service.js +6 -23
- package/dist/runtime/src/verify/source-fingerprint.js +17 -4
- package/docs/en/guides/task-debug.md +39 -133
- package/docs/en/spec/acceptance-and-evidence.md +31 -23
- package/docs/en/spec/harness-adapters.md +9 -11
- package/docs/en/spec/loop-engineering.md +11 -5
- package/docs/zh-TW/guides/task-debug.md +27 -73
- package/docs/zh-TW/spec/acceptance-and-evidence.md +18 -18
- package/docs/zh-TW/spec/harness-adapters.md +7 -8
- package/docs/zh-TW/spec/loop-engineering.md +9 -4
- package/package.json +1 -1
- package/schemas/config.schema.json +1 -21
- package/dist/packages/cli/src/debug-intake.js +0 -245
- package/dist/runtime/src/task/debug-intake.js +0 -15
- package/dist/runtime/src/task/debug-state.js +0 -135
- package/dist/runtime/src/task/debug-user-event.js +0 -160
- package/dist/runtime/src/task/debug.js +0 -632
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { runVerificationCommand } from "../verify/spawn.js";
|
|
@@ -37,6 +37,14 @@ export const DEFAULT_STALL_IDLE_MS = 90_000;
|
|
|
37
37
|
* round timeout or caller cancellation may stop a silent agy review.
|
|
38
38
|
*/
|
|
39
39
|
export const AGY_STALL_IDLE_MS = Number.POSITIVE_INFINITY;
|
|
40
|
+
/**
|
|
41
|
+
* agy logs this once its turn is over and the answer is on stdout. Some runs
|
|
42
|
+
* then never exit (the language server shutdown hangs), which would hold a
|
|
43
|
+
* finished round until the full round timeout and report it as NOT_RUN.
|
|
44
|
+
*/
|
|
45
|
+
const AGY_TURN_FINISHED = /Stream goroutine exited for \S+, sending completion signal/u;
|
|
46
|
+
/** How long a finished agy turn may take to exit before it is stopped. */
|
|
47
|
+
export const AGY_EXIT_GRACE_MS = 30_000;
|
|
40
48
|
/**
|
|
41
49
|
* A call the service dropped before answering. It produced no verdict, so
|
|
42
50
|
* one fresh retry is not review shopping.
|
|
@@ -238,6 +246,55 @@ function watchForStall(logFile, parent, idleMs) {
|
|
|
238
246
|
}
|
|
239
247
|
};
|
|
240
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Stops an agy round that finished its turn but did not exit. Only the log's
|
|
251
|
+
* completion line starts the grace window, so a long silent generation is
|
|
252
|
+
* never cut off; the round timeout still caps a reviewer that never finishes.
|
|
253
|
+
*/
|
|
254
|
+
function watchForFinishedTurn(logFile, graceMs) {
|
|
255
|
+
const controller = new AbortController();
|
|
256
|
+
let fired = false;
|
|
257
|
+
let grace;
|
|
258
|
+
const check = async () => {
|
|
259
|
+
if (logFile === undefined || grace !== undefined)
|
|
260
|
+
return;
|
|
261
|
+
try {
|
|
262
|
+
if (!AGY_TURN_FINISHED.test(await readFile(logFile, "utf8")))
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (grace !== undefined)
|
|
269
|
+
return;
|
|
270
|
+
clearInterval(poll);
|
|
271
|
+
grace = setTimeout(() => {
|
|
272
|
+
fired = true;
|
|
273
|
+
controller.abort("finished-without-exit");
|
|
274
|
+
}, graceMs);
|
|
275
|
+
grace.unref();
|
|
276
|
+
};
|
|
277
|
+
const poll = setInterval(() => {
|
|
278
|
+
void check();
|
|
279
|
+
}, Math.max(20, Math.min(5_000, Math.floor(graceMs / 3))));
|
|
280
|
+
poll.unref();
|
|
281
|
+
return {
|
|
282
|
+
signal: controller.signal,
|
|
283
|
+
fired: () => fired,
|
|
284
|
+
stop: () => {
|
|
285
|
+
clearInterval(poll);
|
|
286
|
+
if (grace !== undefined)
|
|
287
|
+
clearTimeout(grace);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** A finished agy turn that was stopped for not exiting counts as completed. */
|
|
292
|
+
function finishedTurnCompletion(target, spawned, watch, graceMs, options) {
|
|
293
|
+
if (!watch.fired() || spawned.failureClass !== "aborted" || options.signal?.aborted === true)
|
|
294
|
+
return spawned;
|
|
295
|
+
options.onProgress?.(`${target}: finished its turn but did not exit within ${Math.max(1, Math.round(graceMs / 1_000))}s → stopped it and read its answer`);
|
|
296
|
+
return { ...spawned, failureClass: "none" };
|
|
297
|
+
}
|
|
241
298
|
function throwIfInterrupted(target, options, failureClass) {
|
|
242
299
|
if (failureClass !== "aborted" && options.signal?.aborted !== true) {
|
|
243
300
|
return;
|
|
@@ -507,6 +564,8 @@ async function attemptTarget(request, options) {
|
|
|
507
564
|
const stallIdleMs = options.stallIdleMs ??
|
|
508
565
|
(target === "agy" ? AGY_STALL_IDLE_MS : DEFAULT_STALL_IDLE_MS);
|
|
509
566
|
const stallWatch = watchForStall(heartbeatLog, options.signal, stallIdleMs);
|
|
567
|
+
const exitGraceMs = options.agyExitGraceMs ?? AGY_EXIT_GRACE_MS;
|
|
568
|
+
const finishedWatch = watchForFinishedTurn(agyLog, exitGraceMs);
|
|
510
569
|
let spawned;
|
|
511
570
|
try {
|
|
512
571
|
spawned = await runVerificationCommand({
|
|
@@ -526,13 +585,15 @@ async function attemptTarget(request, options) {
|
|
|
526
585
|
stdin: invocation.stdin,
|
|
527
586
|
env: environment,
|
|
528
587
|
replaceEnv: true,
|
|
529
|
-
signal: stallWatch.signal,
|
|
588
|
+
signal: AbortSignal.any([stallWatch.signal, finishedWatch.signal]),
|
|
530
589
|
onActivity: stallWatch.beat
|
|
531
590
|
});
|
|
532
591
|
}
|
|
533
592
|
finally {
|
|
534
593
|
stallWatch.stop();
|
|
594
|
+
finishedWatch.stop();
|
|
535
595
|
}
|
|
596
|
+
spawned = finishedTurnCompletion(target, spawned, finishedWatch, exitGraceMs, options);
|
|
536
597
|
// Ahead of `throwIfInterrupted`, which reads the same `aborted` failure
|
|
537
598
|
// class as a user interrupt. A stall is this executor's own abort, and
|
|
538
599
|
// ends one target rather than the whole review.
|
|
@@ -607,6 +668,7 @@ async function attemptTarget(request, options) {
|
|
|
607
668
|
if (nudge !== undefined) {
|
|
608
669
|
options.onProgress?.(`${target}: turn ended without answering${denied.length > 0 ? ` (denied ${denied.join(", ")})` : ""} → resuming the conversation once`);
|
|
609
670
|
const nudgeWatch = watchForStall(nudgeLog, options.signal, stallIdleMs);
|
|
671
|
+
const nudgeFinished = watchForFinishedTurn(nudgeLog, exitGraceMs);
|
|
610
672
|
let second;
|
|
611
673
|
try {
|
|
612
674
|
second = await runVerificationCommand({
|
|
@@ -626,13 +688,15 @@ async function attemptTarget(request, options) {
|
|
|
626
688
|
stdin: nudge.stdin,
|
|
627
689
|
env: environment,
|
|
628
690
|
replaceEnv: true,
|
|
629
|
-
signal: nudgeWatch.signal,
|
|
691
|
+
signal: AbortSignal.any([nudgeWatch.signal, nudgeFinished.signal]),
|
|
630
692
|
onActivity: nudgeWatch.beat
|
|
631
693
|
});
|
|
632
694
|
}
|
|
633
695
|
finally {
|
|
634
696
|
nudgeWatch.stop();
|
|
697
|
+
nudgeFinished.stop();
|
|
635
698
|
}
|
|
699
|
+
second = finishedTurnCompletion(target, second, nudgeFinished, exitGraceMs, options);
|
|
636
700
|
usage = sumUsage(usage, extractUsage(target, second.stdout, second.stderr));
|
|
637
701
|
throwIfInterrupted(target, options, second.failureClass);
|
|
638
702
|
nudged = { log: nudgeLog, stdout: second.stdout };
|
|
@@ -712,7 +776,7 @@ export function createReviewExecutor(options) {
|
|
|
712
776
|
const clock = options.now ?? (() => Date.now());
|
|
713
777
|
return async (request) => {
|
|
714
778
|
const deadlineAt = clock() +
|
|
715
|
-
|
|
779
|
+
(options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS);
|
|
716
780
|
const remainingMs = () => deadlineAt - clock();
|
|
717
781
|
/**
|
|
718
782
|
* The options one stage runs under: its own timeout, or what is left of
|
|
@@ -4,7 +4,7 @@ import { sha256 } from "../fs/hash.js";
|
|
|
4
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
5
|
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
6
6
|
import { redactSecrets } from "../security/redact.js";
|
|
7
|
-
import { taskContractHash } from "../task/contract.js";
|
|
7
|
+
import { evidenceMatchesContract, taskContractHash } from "../task/contract.js";
|
|
8
8
|
import { FileEvidenceStore } from "../verify/evidence.js";
|
|
9
9
|
import { deriveFailureObservation } from "./convergence.js";
|
|
10
10
|
/** Preserve actual failed command reports, which are not completion evidence. */
|
|
@@ -50,7 +50,7 @@ export async function observeRunFailure(state, root, records, taskId, code, pend
|
|
|
50
50
|
for (const phase of saved.report.acceptance ?? []) {
|
|
51
51
|
const proof = await evidence.load(phase.reference);
|
|
52
52
|
const criterion = record.task.criteria.find(c => c.id === phase.criterionId);
|
|
53
|
-
if (proof?.acceptance === undefined || proof.taskContractHash
|
|
53
|
+
if (proof?.acceptance === undefined || !evidenceMatchesContract(proof.taskContractHash, record.task) || criterion?.acceptance === undefined || criterion.acceptance.mode === "review-only")
|
|
54
54
|
continue;
|
|
55
55
|
const bindings = criterion.acceptance.bindings.filter(b => b.runnerId === phase.runnerId);
|
|
56
56
|
acceptance.push({ ...phase, criterionId: record.task.id + ":" + phase.criterionId, checks: proof.acceptance.checks,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { validDebugSettings, validDebugIntake } from "../task/debug-state.js";
|
|
2
1
|
import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
3
2
|
import { acceptanceError, runnerError, commitIdentity } from "./acceptance.js";
|
|
4
3
|
import { parseSurface } from "../surface/schema.js";
|
|
@@ -339,8 +338,9 @@ export function validateConfig(value) {
|
|
|
339
338
|
if (featuresUnknown !== undefined) {
|
|
340
339
|
return featuresUnknown;
|
|
341
340
|
}
|
|
342
|
-
|
|
343
|
-
|
|
341
|
+
// Pre-0.11 DEBUG budget settings: still accepted, now ignored.
|
|
342
|
+
if (root.features.debug !== undefined && !isRecord(root.features.debug))
|
|
343
|
+
return failure("INVALID_FEATURE", "$.features.debug", "features.debug must be an object.");
|
|
344
344
|
if (root.features.office !== undefined) {
|
|
345
345
|
if (!isRecord(root.features.office)) {
|
|
346
346
|
return failure("INVALID_TYPE", "$.features.office", "office must be an object.");
|
|
@@ -571,12 +571,17 @@ export function validateCriterion(value, path) {
|
|
|
571
571
|
}
|
|
572
572
|
return success(value);
|
|
573
573
|
}
|
|
574
|
+
/** The DEBUG tag a task keeps: its root and the issue's recorded facts and unknowns. */
|
|
575
|
+
function validDebugTag(value) {
|
|
576
|
+
return isRecord(value) && Object.keys(value).every(k => ["rootTaskId", "facts", "unknowns"].includes(k)) &&
|
|
577
|
+
typeof value.rootTaskId === "string" && value.rootTaskId.length > 0 && isStringArray(value.facts) && isStringArray(value.unknowns);
|
|
578
|
+
}
|
|
574
579
|
export function validateTask(value) {
|
|
575
580
|
const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title", "debug", "surface", "surfaceRequired", "origin", "originRequired"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
|
|
576
581
|
if (isFailure(root)) {
|
|
577
582
|
return root;
|
|
578
583
|
}
|
|
579
|
-
if (root.debug !== undefined && (!
|
|
584
|
+
if (root.debug !== undefined && (!validDebugTag(root.debug) || root.schemaVersion !== TASK_SCHEMA_VERSION || root.goal === undefined))
|
|
580
585
|
return failure("INVALID_DEBUG_TASK", "$.debug", "Debug tasks require immutable goal and bounded root intake.");
|
|
581
586
|
if (root.surfaceRequired !== undefined && root.surfaceRequired !== true)
|
|
582
587
|
return failure("INVALID_SURFACE", "$.surfaceRequired", "surfaceRequired is either absent or true.");
|
|
@@ -591,7 +596,7 @@ export function validateTask(value) {
|
|
|
591
596
|
if (root.origin !== undefined && !validOrigin(root.origin))
|
|
592
597
|
return failure("INVALID_ORIGIN", "$.origin", "origin is escape-of {commit, label, receipt?} or pre-existing/external {reason}.");
|
|
593
598
|
if (root.originRequired !== undefined && (root.originRequired !== true || root.debug === undefined))
|
|
594
|
-
return failure("INVALID_ORIGIN", "$.originRequired", "originRequired is either absent or true on a DEBUG task.");
|
|
599
|
+
return failure("INVALID_ORIGIN", "$.originRequired", "originRequired (pre-0.11, now ignored) is either absent or true on a DEBUG task.");
|
|
595
600
|
if (root.contractRevision !== undefined && (!Number.isSafeInteger(root.contractRevision) || root.contractRevision < 0))
|
|
596
601
|
return failure("INVALID_CONTRACT_REVISION", "$.contractRevision", "Invalid contract revision.");
|
|
597
602
|
if (root.goal !== undefined && (!isNonEmptyString(root.goal) || Buffer.byteLength(root.goal, "utf8") > 65536))
|
|
@@ -2,52 +2,14 @@ import { calculateConfigHash } from "../config/hash.js";
|
|
|
2
2
|
import { findReviewAttestation } from "../review/attestation.js";
|
|
3
3
|
import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
|
|
4
4
|
import { isPassingVerificationEvidence } from "../verify/evidence.js";
|
|
5
|
-
import { taskContractHash } from "./contract.js";
|
|
6
|
-
import { acceptanceCoverage
|
|
7
|
-
import { loadDebugProof, debugProofDigest, debugRemainingMs } from "./debug-state.js";
|
|
8
|
-
import { escapeCompletionProblem } from "../escape/link.js";
|
|
9
|
-
import { RULES_DIRECTORY } from "../escape/rules.js";
|
|
5
|
+
import { evidenceMatchesContract, taskContractHash } from "./contract.js";
|
|
6
|
+
import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
|
|
10
7
|
export async function checkTaskCompletionEvidence(stored, options) {
|
|
11
|
-
const debug = stored.task.debug === undefined ? undefined : await loadDebugProof(options.root, stored.task.debug.rootTaskId);
|
|
12
|
-
if (debug?.control?.paused === true || debug?.control?.mode === "diagnose")
|
|
13
|
-
return { status: "FAIL", code: "TASK_DEBUG_SCOPE_RESTRICTED", remedy: "The human restricted this issue to pause or diagnosis. Preserve the active task; only a native human resume repair event authorizes repair completion." };
|
|
14
8
|
const problem = await checkTaskVerificationEvidence(stored, options);
|
|
15
9
|
if (problem !== null)
|
|
16
10
|
return problem;
|
|
17
11
|
const attestation = await findReviewAttestation(options.root, options.sourceFingerprint, stored.task.id);
|
|
18
|
-
|
|
19
|
-
const coverage = await acceptanceCoverage(stored, options.config, options.sourceFingerprint, options.evidenceStore);
|
|
20
|
-
const { FileTaskStore } = await import("./store.js");
|
|
21
|
-
const { join } = await import("node:path");
|
|
22
|
-
const reviewedRecords = (await new FileTaskStore(join(options.root, ".agent-ops/tasks/state.json"), options.root).read()).tasks.filter(r => attestation?.taskContracts?.[r.task.id] !== undefined);
|
|
23
|
-
const currentCoverage = [];
|
|
24
|
-
for (const record of reviewedRecords) {
|
|
25
|
-
const rows = await acceptanceCoverage(record, options.config, options.sourceFingerprint, options.evidenceStore);
|
|
26
|
-
currentCoverage.push(...rows.map(row => ({ ...row, criterionId: attestation?.tree === undefined ? row.criterionId : record.task.id + ":" + row.criterionId })));
|
|
27
|
-
}
|
|
28
|
-
const refs = new Set(coverage.filter(c => c.mode === "behavioral" && c.status === "proven").flatMap(c => c.evidenceReferences));
|
|
29
|
-
if (attestation?.coverageDigest !== coverageDigest(currentCoverage) || debug.operation !== null || debugRemainingMs(debug) === 0 || !debug.hypotheses.some(h => h.status === "confirmed" && h.supporting.some(ref => refs.has(ref))) ||
|
|
30
|
-
attestation?.debugProofs?.[stored.task.id] !== debugProofDigest(debug))
|
|
31
|
-
return { status: "FAIL", code: "TASK_DEBUG_PROOF_REQUIRED", remedy: "Confirm the original cause with current discriminating regression proof, then obtain two fresh independent reviews bound to that debug proof." };
|
|
32
|
-
}
|
|
33
|
-
// Fails closed: the Escape Loop needs every DEBUG fix to say where the bug came from.
|
|
34
|
-
if (stored.task.originRequired === true && stored.task.origin === undefined)
|
|
35
|
-
return { status: "FAIL", code: "ESCAPE_ORIGIN_MISSING", remedy: "Declare the issue origin with task debug --debug-action origin --payload-file <json> (escape-of <commit> + label, pre-existing or external + reason) after the hypothesis is confirmed, then re-verify, re-record the hypothesis and obtain review." };
|
|
36
|
-
// Escape Loop R2: a declared escape finishes only with its proof (rule replay + backtest, or a promoted red-to-green check).
|
|
37
|
-
const origin = stored.task.origin, base = debug?.baselineCommit ?? stored.createdSourceCommit;
|
|
38
|
-
if (origin?.kind === "escape-of" && base !== undefined) {
|
|
39
|
-
const escape = await escapeCompletionProblem({ root: options.root, taskId: stored.task.id, origin, base });
|
|
40
|
-
if (escape !== null && "code" in escape)
|
|
41
|
-
return { status: "FAIL", code: escape.code, remedy: `${escape.reason} Add or fix a tracked ${RULES_DIRECTORY}/<id>.json rule, re-declare the origin and commit the record.` };
|
|
42
|
-
if (escape !== null) {
|
|
43
|
-
const [runnerId, checkId] = escape.check.split(":");
|
|
44
|
-
const proven = new Set((await acceptanceCoverage(stored, options.config, options.sourceFingerprint, options.evidenceStore))
|
|
45
|
-
.filter(row => row.status === "proven").map(row => row.criterionId));
|
|
46
|
-
if (!stored.task.criteria.some(c => proven.has(c.id) && c.acceptance?.mode === "behavioral" &&
|
|
47
|
-
c.acceptance.bindings.some(b => b.runnerId === runnerId && (b.redCheckIds ?? []).includes(checkId))))
|
|
48
|
-
return { status: "FAIL", code: "ESCAPE_RULE_UNPROVEN", remedy: `The promoted check ${escape.check} must be a redCheckId of a proven behavioral criterion of this task (red baseline, green candidate).` };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
12
|
+
// Escape origin and its rule proof are advisory: task create/revise --escape-of report them, completion does not wait on them.
|
|
51
13
|
if (attestation === null || attestation.taskId !== stored.task.id ||
|
|
52
14
|
((stored.task.criteria.some(c => c.acceptance !== undefined) || (stored.task.contractRevision ?? 0) > 0 || stored.task.goal !== undefined) &&
|
|
53
15
|
(attestation.schemaVersion !== 3 || attestation.taskContracts?.[stored.task.id] !== taskContractHash(stored.task))) ||
|
|
@@ -86,7 +48,7 @@ export async function checkTaskVerificationEvidence(stored, options) {
|
|
|
86
48
|
if (evidence.taskId === stored.task.id && evidence.criterionId === criterion.id &&
|
|
87
49
|
evidence.commandId === command.id && evidence.configHash === configHash &&
|
|
88
50
|
evidence.sourceFingerprint === sourceFingerprint &&
|
|
89
|
-
((stored.task.goal === undefined && (stored.task.contractRevision ?? 0) === 0 && stored.task.criteria.every(c => c.acceptance === undefined)) || evidence.taskContractHash
|
|
51
|
+
((stored.task.goal === undefined && (stored.task.contractRevision ?? 0) === 0 && stored.task.criteria.every(c => c.acceptance === undefined)) || evidenceMatchesContract(evidence.taskContractHash, stored.task)) &&
|
|
90
52
|
(latest === undefined || Date.parse(evidence.finishedAt) > Date.parse(latest.finishedAt) ||
|
|
91
53
|
(Date.parse(evidence.finishedAt) === Date.parse(latest.finishedAt) && !isPassingVerificationEvidence(command, evidence)))) {
|
|
92
54
|
latest = evidence;
|
|
@@ -15,6 +15,17 @@ export function taskContractHash(task) {
|
|
|
15
15
|
...(task.origin === undefined ? {} : { origin: task.origin }),
|
|
16
16
|
...(task.originRequired === undefined ? {} : { originRequired: true }) }));
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* What verification actually ran: the criteria. Surface, origin, goal and revision
|
|
20
|
+
* changes leave it alone, so they need a fresh review but not a fresh verify.
|
|
21
|
+
*/
|
|
22
|
+
export function executionContractHash(task) {
|
|
23
|
+
return sha256(canonicalJson({ domain: "agent-ops-execution-v1", criteria: task.criteria.map(criterionContractHash) }));
|
|
24
|
+
}
|
|
25
|
+
/** Verification evidence binds the execution hash; evidence from before 0.11 bound the full contract. */
|
|
26
|
+
export function evidenceMatchesContract(hash, task) {
|
|
27
|
+
return hash !== undefined && (hash === executionContractHash(task) || hash === taskContractHash(task));
|
|
28
|
+
}
|
|
18
29
|
export function treeContractHash(tasks) {
|
|
19
30
|
return sha256(canonicalJson(tasks.map(task => ({ id: task.id, hash: taskContractHash(task) }))
|
|
20
31
|
.sort((a, b) => a.id.localeCompare(b.id))));
|
|
@@ -6,18 +6,14 @@ import { validateEvidence, validateTask, validateTaskAgainstConfig } from "../sc
|
|
|
6
6
|
import { renderTaskMarkdown } from "./render.js";
|
|
7
7
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "./completion.js";
|
|
8
8
|
import { calculateConfigHash, FileEvidenceStore, isPassingVerificationEvidence } from "../verify/evidence.js";
|
|
9
|
-
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
10
9
|
import { resolveReviewScope } from "../review/scope.js";
|
|
11
10
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
12
11
|
import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
|
|
13
12
|
import { loadFindingPin } from "./pin-finding.js";
|
|
14
13
|
import { findReviewAttestation } from "../review/attestation.js";
|
|
15
14
|
import { advanceFailureFingerprint } from "../verify/fingerprint.js";
|
|
16
|
-
import { taskContractHash, treeContractHash } from "./contract.js";
|
|
15
|
+
import { evidenceMatchesContract, executionContractHash, taskContractHash, treeContractHash } from "./contract.js";
|
|
17
16
|
import { commitIdentity } from "../schema/acceptance.js";
|
|
18
|
-
import { createDebugState, DEBUG_DEFAULTS } from "./debug-state.js";
|
|
19
|
-
import { DebugService } from "./debug.js";
|
|
20
|
-
import { sameDebugText } from "./debug-intake.js";
|
|
21
17
|
import { isCovers, parseSurface } from "../surface/schema.js";
|
|
22
18
|
import { declareEscape, resolveOrigin } from "../escape/link.js";
|
|
23
19
|
import { configProposal } from "../escape/rules.js";
|
|
@@ -92,15 +88,6 @@ export class TaskService {
|
|
|
92
88
|
this.#now = options.now ?? (() => new Date().toISOString());
|
|
93
89
|
this.#completion = options.completion;
|
|
94
90
|
}
|
|
95
|
-
debugService() {
|
|
96
|
-
if (this.#completion === undefined)
|
|
97
|
-
throw taskError("TASK_DEBUG_CONTEXT_REQUIRED", "Debug operations require repository context.");
|
|
98
|
-
return new DebugService({ store: this.#store, tasks: this, ...this.#completion });
|
|
99
|
-
}
|
|
100
|
-
async assertDebugBaseline() {
|
|
101
|
-
if (this.#completion === undefined || (await collectChangeSurface(this.#completion.gitRunner)).paths.length > 0)
|
|
102
|
-
throw taskError("TASK_DEBUG_BASELINE_DIRTY", "Preserve user edits; debug intake needs a clean committed baseline before worktree allocation.");
|
|
103
|
-
}
|
|
104
91
|
async create(input) {
|
|
105
92
|
if (input.policyConfigHash !== undefined &&
|
|
106
93
|
!/^[a-f0-9]{64}$/u.test(input.policyConfigHash)) {
|
|
@@ -109,12 +96,6 @@ export class TaskService {
|
|
|
109
96
|
if (input.sessionId !== undefined) {
|
|
110
97
|
assertSessionId(input.sessionId);
|
|
111
98
|
}
|
|
112
|
-
// A new session must not restart an active issue's budget and rounds.
|
|
113
|
-
if (input.debug !== undefined && input.parentTaskId === undefined && input.goal !== undefined) {
|
|
114
|
-
const existing = await this.activeDebugRoot(input.goal);
|
|
115
|
-
if (existing !== null)
|
|
116
|
-
return input.sessionId === undefined ? existing : await this.attach(input.sessionId, existing.task.id);
|
|
117
|
-
}
|
|
118
99
|
let createdSourceCommit = input.createdSourceCommit;
|
|
119
100
|
if (createdSourceCommit === undefined && this.#completion !== undefined) {
|
|
120
101
|
const head = await this.#completion.gitRunner.run(["rev-parse", "--verify", "HEAD"]);
|
|
@@ -142,21 +123,11 @@ export class TaskService {
|
|
|
142
123
|
? {}
|
|
143
124
|
: { parentTaskId: input.parentTaskId })
|
|
144
125
|
};
|
|
145
|
-
|
|
146
|
-
if (input.debug !== undefined) {
|
|
147
|
-
if (
|
|
148
|
-
throw taskError("
|
|
149
|
-
if ((await collectChangeSurface(this.#completion.gitRunner)).paths.length > 0)
|
|
150
|
-
throw taskError("TASK_DEBUG_BASELINE_DIRTY", "Preserve original user edits; investigate read-only or ask for an explicit committed baseline.");
|
|
151
|
-
const config = await this.#completion.loadConfig();
|
|
152
|
-
if (config.features.debug?.enabled === false)
|
|
153
|
-
throw taskError("TASK_DEBUG_DISABLED", "Task debug routing is explicitly disabled.");
|
|
154
|
-
debugSettings = config.features.debug ?? DEBUG_DEFAULTS;
|
|
126
|
+
// DEBUG = an ordinary task whose original regression is proven red at the baseline and green on the candidate.
|
|
127
|
+
if (input.debug !== undefined && input.parentTaskId === undefined) {
|
|
128
|
+
if (!criteria.some(c => c.acceptance?.mode === "behavioral" && c.acceptance.bindings.some(b => (b.redCheckIds ?? []).length > 0)))
|
|
129
|
+
throw taskError("TASK_DEBUG_RED_CHECK_REQUIRED", "A DEBUG task needs a behavioral criterion whose redCheckIds fail at the baseline and pass on the candidate.");
|
|
155
130
|
task.debug = { rootTaskId: task.id, facts: [...(input.debug.facts ?? [])], unknowns: [...(input.debug.unknowns ?? [])] };
|
|
156
|
-
if (input.origin !== undefined)
|
|
157
|
-
throw taskError("ESCAPE_ORIGIN_DEBUG_ACTION", "A DEBUG issue declares its origin with task debug --debug-action origin after a confirmed hypothesis.");
|
|
158
|
-
if (input.parentTaskId === undefined)
|
|
159
|
-
task.originRequired = true;
|
|
160
131
|
}
|
|
161
132
|
const validation = validateTask(task);
|
|
162
133
|
if (!validation.ok) {
|
|
@@ -192,13 +163,10 @@ export class TaskService {
|
|
|
192
163
|
throw taskError("TASK_GOAL_IMMUTABLE", "Subtasks must retain their parent original goal.");
|
|
193
164
|
validation.value.goal = parent.task.goal;
|
|
194
165
|
}
|
|
195
|
-
if (parent.task.debug !== undefined)
|
|
196
|
-
if (input.debug !== undefined)
|
|
197
|
-
throw taskError("TASK_DEBUG_ROOT_IMMUTABLE", "A child cannot create a new issue budget.");
|
|
166
|
+
if (parent.task.debug !== undefined)
|
|
198
167
|
validation.value.debug = structuredClone(parent.task.debug);
|
|
199
|
-
}
|
|
200
168
|
}
|
|
201
|
-
if (input.requireSurface === true
|
|
169
|
+
if (input.requireSurface === true) {
|
|
202
170
|
const parentSurface = state.tasks.find(record => record.task.id === input.parentTaskId)?.task.surface;
|
|
203
171
|
const surfacedParent = parentSurface !== undefined && !isCovers(parentSurface);
|
|
204
172
|
if (surface === undefined || isCovers(surface) !== surfacedParent)
|
|
@@ -208,7 +176,6 @@ export class TaskService {
|
|
|
208
176
|
}
|
|
209
177
|
const record = {
|
|
210
178
|
task: validation.value,
|
|
211
|
-
...(input.debug === undefined ? {} : { debug: createDebugState(task.id, createdSourceCommit, debugSettings, this.#completion.root) }),
|
|
212
179
|
...(createdSourceCommit === undefined ? {} : { createdSourceCommit }),
|
|
213
180
|
status: "active",
|
|
214
181
|
evidence: {},
|
|
@@ -271,26 +238,28 @@ export class TaskService {
|
|
|
271
238
|
if (taskContractHash(previous.task) !== input.expectedContractHash)
|
|
272
239
|
throw taskError("TASK_CONTRACT_CHANGED", "Task contract changed.");
|
|
273
240
|
// With none of them, a revision only rebinds the task to the current configuration (checked below).
|
|
274
|
-
const rebindOnly = input.criteria === undefined && input.surface === undefined && input.origin === undefined;
|
|
241
|
+
const rebindOnly = input.criteria === undefined && input.surface === undefined && input.origin === undefined && input.baseline === undefined;
|
|
275
242
|
if (rebindOnly && this.#completion === undefined)
|
|
276
243
|
throw taskError("TASK_REVISION_INVALID", "Revision requires criteria, a surface, an origin, or a combination.");
|
|
277
|
-
if (input.origin !== undefined && previous.task.debug !== undefined && input.debugOrigin !== true)
|
|
278
|
-
throw taskError("ESCAPE_ORIGIN_DEBUG_ACTION", "A DEBUG issue declares its origin with task debug --debug-action origin after a confirmed hypothesis.");
|
|
279
244
|
// Re-declaring the same origin only refreshes its ledger record; the contract and its proof stand.
|
|
280
245
|
if (input.criteria === undefined && input.surface === undefined && input.origin !== undefined &&
|
|
281
246
|
canonicalJson(input.origin) === canonicalJson(previous.task.origin ?? null))
|
|
282
247
|
return previous;
|
|
283
248
|
const surface = input.surface === undefined ? previous.task.surface : parseSurface(input.surface);
|
|
284
|
-
|
|
285
|
-
const
|
|
249
|
+
// An explicit --baseline moves every typed criterion (e.g. a task opened on the wrong branch); its evidence is then void.
|
|
250
|
+
const moved = input.baseline === undefined ? undefined : await this.#resolveCommit(input.baseline);
|
|
251
|
+
const baseline = moved ?? previous.createdSourceCommit;
|
|
252
|
+
const criteria = (input.criteria === undefined ? structuredClone(previous.task.criteria) : structuredClone(input.criteria)).map(criterion => {
|
|
286
253
|
if (criterion.acceptance === undefined)
|
|
287
254
|
return criterion;
|
|
288
|
-
|
|
255
|
+
if (input.criteria === undefined && moved === undefined)
|
|
256
|
+
return criterion;
|
|
257
|
+
const sha = moved ?? criterion.acceptance.baselineCommit ?? baseline;
|
|
289
258
|
if (sha === undefined)
|
|
290
259
|
throw taskError("BASELINE_REQUIRED", "Legacy tasks need an explicit baseline before typed acceptance.");
|
|
291
260
|
const old = previous.task.criteria.find(c => c.id === criterion.id)?.acceptance;
|
|
292
|
-
if (old !== undefined && sha !== old.baselineCommit)
|
|
293
|
-
throw taskError("BASELINE_IMMUTABLE", "Create a replacement criterion
|
|
261
|
+
if (moved === undefined && old !== undefined && sha !== old.baselineCommit)
|
|
262
|
+
throw taskError("BASELINE_IMMUTABLE", "Create a replacement criterion, or move every baseline with --baseline.");
|
|
294
263
|
return { ...criterion, acceptance: { ...criterion.acceptance, baselineCommit: sha } };
|
|
295
264
|
});
|
|
296
265
|
const next = { ...previous.task, schemaVersion: TASK_SCHEMA_VERSION,
|
|
@@ -319,7 +288,13 @@ export class TaskService {
|
|
|
319
288
|
const current = findTask(state, taskId);
|
|
320
289
|
if (current.status !== "active" || JSON.stringify(current) !== JSON.stringify(previous))
|
|
321
290
|
throw taskError("TASK_CONTRACT_CHANGED", "Concurrent revision preserved; retry from current task.");
|
|
322
|
-
|
|
291
|
+
// Surface, origin or goal-only revisions keep what verification ran; review is still voided by the new contract hash.
|
|
292
|
+
const sameExecution = executionContractHash(next) === executionContractHash(current.task) && policyConfigHash === current.policyConfigHash;
|
|
293
|
+
// Review references never carry over: the new contract needs fresh review.
|
|
294
|
+
const verified = Object.fromEntries(Object.entries(current.evidence)
|
|
295
|
+
.map(([id, refs]) => [id, refs.filter(ref => !ref.startsWith("review:"))]).filter(([, refs]) => refs.length > 0));
|
|
296
|
+
const result = { ...current, task: next, evidence: sameExecution ? verified : {},
|
|
297
|
+
failureFingerprint: sameExecution ? current.failureFingerprint : null, ...(moved === undefined ? {} : { createdSourceCommit: moved }),
|
|
323
298
|
policyConfigHash, completionBase: null, updatedAt: now,
|
|
324
299
|
revisions: [...(current.revisions ?? []), { previousTask: current.task, previousEvidence: current.evidence,
|
|
325
300
|
previousFailure: current.failureFingerprint, previousHash: input.expectedContractHash,
|
|
@@ -329,6 +304,15 @@ export class TaskService {
|
|
|
329
304
|
return cloneRecord(withoutReview);
|
|
330
305
|
});
|
|
331
306
|
}
|
|
307
|
+
/** A moved baseline must be a real commit in the candidate's history, so red-to-green still proves this change. */
|
|
308
|
+
async #resolveCommit(ref) {
|
|
309
|
+
const git = this.#completion?.gitRunner;
|
|
310
|
+
const resolved = git === undefined ? undefined : await git.run(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
|
|
311
|
+
const sha = resolved?.exitCode === 0 ? resolved.stdout.toString().trim() : "";
|
|
312
|
+
if (!commitIdentity(sha) || (await git.run(["merge-base", "--is-ancestor", sha, "HEAD"])).exitCode !== 0)
|
|
313
|
+
throw taskError("TASK_BASELINE_INVALID", "--baseline must resolve to a commit that is an ancestor of HEAD.");
|
|
314
|
+
return sha;
|
|
315
|
+
}
|
|
332
316
|
async replan(taskId, input) {
|
|
333
317
|
if (input.reason.trim() === "" || input.reason.includes("\0") || input.reason.length > 8192 ||
|
|
334
318
|
input.tasks.length === 0 || input.tasks.length > 128)
|
|
@@ -461,8 +445,6 @@ export class TaskService {
|
|
|
461
445
|
if (record.policyConfigHash !== parent.policyConfigHash || !validateTask(task).ok) {
|
|
462
446
|
throw taskError("TASK_DELIVERY_INVALID", `Task ${task.id} has an incompatible policy or definition.`);
|
|
463
447
|
}
|
|
464
|
-
if (parent.task.debug !== undefined && (task.goal !== parent.task.goal || JSON.stringify(task.debug) !== JSON.stringify(parent.task.debug) || record.debug !== undefined))
|
|
465
|
-
throw taskError("TASK_DEBUG_ROOT_MISMATCH", "A delivery must retain the original issue identity and cannot import an independent debug budget.");
|
|
466
448
|
const existing = state.tasks.find((item) => item.task.id === task.id);
|
|
467
449
|
if (existing !== undefined) {
|
|
468
450
|
if (JSON.stringify(existing.task) !== JSON.stringify(task)) {
|
|
@@ -488,11 +470,6 @@ export class TaskService {
|
|
|
488
470
|
}
|
|
489
471
|
});
|
|
490
472
|
}
|
|
491
|
-
/** The active DEBUG root already holding this original issue, if any. */
|
|
492
|
-
async activeDebugRoot(goal) {
|
|
493
|
-
return (await this.list()).find((record) => record.status === "active" && record.task.debug?.rootTaskId === record.task.id &&
|
|
494
|
-
record.task.goal !== undefined && sameDebugText(record.task.goal, goal)) ?? null;
|
|
495
|
-
}
|
|
496
473
|
async list(filter = {}) {
|
|
497
474
|
const state = await this.#store.read();
|
|
498
475
|
return state.tasks
|
|
@@ -721,7 +698,7 @@ export class TaskService {
|
|
|
721
698
|
validation.value.criterionId === criterionId && validation.value.commandId === commandId &&
|
|
722
699
|
validation.value.configHash === configHash && validation.value.sourceFingerprint === sourceFingerprint &&
|
|
723
700
|
(!(snapshot.task.goal !== undefined || (snapshot.task.contractRevision ?? 0) > 0 || snapshot.task.criteria.some(c => c.acceptance !== undefined)) ||
|
|
724
|
-
validation.value.taskContractHash
|
|
701
|
+
evidenceMatchesContract(validation.value.taskContractHash, snapshot.task))
|
|
725
702
|
? validation.value : null;
|
|
726
703
|
};
|
|
727
704
|
for (const criterion of snapshot.task.criteria) {
|
|
@@ -4,7 +4,6 @@ import { AgentOpsError } from "../fs/paths.js";
|
|
|
4
4
|
import { validateTask } from "../schema/validate.js";
|
|
5
5
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
6
6
|
import { taskContractHash } from "./contract.js";
|
|
7
|
-
import { assertDebugState } from "./debug-state.js";
|
|
8
7
|
import { commitIdentity, acceptanceSourcePath } from "../schema/acceptance.js";
|
|
9
8
|
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
10
9
|
const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
|
|
@@ -78,11 +77,9 @@ function parseTaskRecord(value) {
|
|
|
78
77
|
if (!task.ok) {
|
|
79
78
|
return invalidState("Task state contains an invalid task.");
|
|
80
79
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return invalidState("Debug counters belong only to the original issue root.");
|
|
85
|
-
}
|
|
80
|
+
// Pre-0.11 DEBUG budget/hypothesis state: accepted, then dropped on load.
|
|
81
|
+
if (value.debug !== undefined && !isRecord(value.debug))
|
|
82
|
+
return invalidState("Task state contains an invalid task record.");
|
|
86
83
|
if (!["active", "archived", "complete"].includes(String(value.status)) ||
|
|
87
84
|
!isTimestamp(value.createdAt) ||
|
|
88
85
|
!isTimestamp(value.updatedAt) ||
|
|
@@ -188,7 +185,6 @@ function parseTaskRecord(value) {
|
|
|
188
185
|
}
|
|
189
186
|
return {
|
|
190
187
|
task: task.value,
|
|
191
|
-
...(value.debug === undefined ? {} : { debug: value.debug }),
|
|
192
188
|
...(value.noChangePaths === undefined ? {} : { noChangePaths: value.noChangePaths }),
|
|
193
189
|
...(value.createdSourceCommit === undefined ? {} : { createdSourceCommit: value.createdSourceCommit }),
|
|
194
190
|
...(value.revisions === undefined ? {} : { revisions: value.revisions }),
|
|
@@ -2,10 +2,9 @@ import { calculateConfigHash, canonicalJson } from "../config/hash.js";
|
|
|
2
2
|
import { readPrivateFile } from "../security/permissions.js";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { sha256 } from "../fs/hash.js";
|
|
5
|
-
import { criterionContractHash,
|
|
5
|
+
import { criterionContractHash, evidenceMatchesContract } from "../task/contract.js";
|
|
6
6
|
/** Only current, paired check evidence discharges a mechanical contract. */
|
|
7
7
|
export async function acceptanceCoverage(record, config, sourceFingerprint, store) {
|
|
8
|
-
const contract = taskContractHash(record.task);
|
|
9
8
|
const configHash = calculateConfigHash(config);
|
|
10
9
|
const rows = [];
|
|
11
10
|
for (const criterion of record.task.criteria) {
|
|
@@ -18,7 +17,7 @@ export async function acceptanceCoverage(record, config, sourceFingerprint, stor
|
|
|
18
17
|
const evidence = await store.load(reference);
|
|
19
18
|
if (evidence?.schemaVersion === 4 && evidence.taskId === record.task.id &&
|
|
20
19
|
evidence.criterionId === criterion.id && evidence.configHash === configHash &&
|
|
21
|
-
evidence.sourceFingerprint === sourceFingerprint && evidence.taskContractHash
|
|
20
|
+
evidence.sourceFingerprint === sourceFingerprint && evidenceMatchesContract(evidence.taskContractHash, record.task) &&
|
|
22
21
|
evidence.acceptance?.criterionContractHash === criterionContractHash(criterion)) {
|
|
23
22
|
matching.push({ reference, evidence });
|
|
24
23
|
}
|
|
@@ -167,9 +167,10 @@ export function aggregateAcceptanceRun(run, requestedCheckIds) {
|
|
|
167
167
|
const duplicateCheckIds = sortedUnique([...counts.entries()].filter(([, count]) => count > 1).map(([id]) => id));
|
|
168
168
|
const unexpectedCheckIds = sortedUnique(run.results.filter((result) => !expected.has(result.checkId)).map((result) => result.checkId));
|
|
169
169
|
const missingCheckIds = requested.filter((id) => !counts.has(id));
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
// Checks the runner emits beyond the requested ones are ignored: a binding may list only its red checks.
|
|
171
|
+
// An unrequested failure still surfaces through the runner's exit code, which replay reconciles.
|
|
172
|
+
if (duplicateCheckIds.length > 0 || missingCheckIds.length > 0) {
|
|
173
|
+
return unknown(run, run.results, duplicateCheckIds.length > 0 ? ACCEPTANCE_FAILURE.duplicate : ACCEPTANCE_FAILURE.missing, ["The observed checks do not include every requested check exactly once."], missingCheckIds, duplicateCheckIds, unexpectedCheckIds);
|
|
173
174
|
}
|
|
174
175
|
const requestedResults = requested.map((id) => run.results.find((result) => result.checkId === id));
|
|
175
176
|
if (run.diagnostics.length > 0) {
|
|
@@ -8,7 +8,7 @@ import { canonicalJson } from "../config/hash.js";
|
|
|
8
8
|
import { sha256 } from "../fs/hash.js";
|
|
9
9
|
import { removeTemporary } from "../fs/temporary.js";
|
|
10
10
|
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
11
|
-
import { criterionContractHash,
|
|
11
|
+
import { criterionContractHash, executionContractHash } from "../task/contract.js";
|
|
12
12
|
import { aggregateAcceptanceRun, makeAcceptanceCheck, makeAcceptanceRun } from "./acceptance-protocol.js";
|
|
13
13
|
import { collectNodeAcceptance, collectJestAcceptance, collectVitestAcceptance, collectPytestAcceptance, collectRustAcceptance } from "./adapters/index.js";
|
|
14
14
|
import { buildVerificationEvidence } from "./evidence.js";
|
|
@@ -118,12 +118,9 @@ export async function replayAcceptance(options) {
|
|
|
118
118
|
const env = replayEnvironment(checkout, phase, executionId);
|
|
119
119
|
let timedOut;
|
|
120
120
|
const command = async (executable, args, cwd, stage, own) => {
|
|
121
|
-
const remaining = await options.remainingMs?.();
|
|
122
|
-
if (remaining === 0)
|
|
123
|
-
throw new AgentOpsError("TASK_DEBUG_BUDGET_EXHAUSTED", "The original issue's active budget is exhausted.");
|
|
124
121
|
const limit = own ?? ACCEPTANCE_TIMEOUTS[stage];
|
|
125
122
|
const result = await runVerificationCommand({ id: group.runner.id, command: executable, args: [...args], cwd: ".", required: true,
|
|
126
|
-
evidence: { kind: "exit-code" }, timeoutMs:
|
|
123
|
+
evidence: { kind: "exit-code" }, timeoutMs: limit }, { cwd, runner: options.processRunner, env, replaceEnv: true,
|
|
127
124
|
outputLimitBytes: 1024 * 1024, ...(options.signal === undefined ? {} : { signal: options.signal }) });
|
|
128
125
|
if (result.failureClass === "timeout" && timedOut === undefined)
|
|
129
126
|
timedOut = stage === "git"
|
|
@@ -217,7 +214,7 @@ export async function replayAcceptance(options) {
|
|
|
217
214
|
failure = aggregate?.failureClass ?? output.failureClass;
|
|
218
215
|
}
|
|
219
216
|
catch (error) {
|
|
220
|
-
failure = error instanceof
|
|
217
|
+
failure = error instanceof Error && /^[a-z-]+$/u.test(error.message) ? error.message : "replay-unavailable";
|
|
221
218
|
}
|
|
222
219
|
finally {
|
|
223
220
|
await removeTemporary(temp);
|
|
@@ -245,7 +242,7 @@ export async function replayAcceptance(options) {
|
|
|
245
242
|
scope: options.scope, startedAt, finishedAt, exitCode: output?.exitCode ?? null, testCount: checks.length,
|
|
246
243
|
status, failureClass: status === "PASS" ? "none" : phase === "baseline" && aggregate?.status === "PASS" ? "non-discriminating" : failure,
|
|
247
244
|
sourceFingerprint: options.sourceFingerprint, toolVersions: { node: process.version }, config: options.config,
|
|
248
|
-
taskContractHash:
|
|
245
|
+
taskContractHash: executionContractHash(options.task), acceptance: { criterionContractHash: criterionContractHash(criterion),
|
|
249
246
|
bindingHash: sha256(canonicalJson({ runnerId: group.runner.id, materials: group.binding.materials })), phase, commit, pairedCommit: phase === "baseline" ? candidate : group.baseline, materialDigest, executionDigest, executionArtifact, checks } });
|
|
250
247
|
const reference = await options.evidenceStore.save(evidence);
|
|
251
248
|
reports.push({ criterionId: criterion.id, runnerId: group.runner.id, phase, status, failureClass: evidence.failureClass, reference,
|