@kylecheng3146/agent-ops 0.10.2 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/packages/cli/src/args.js +18 -44
- package/dist/packages/cli/src/bin.js +37 -29
- package/dist/packages/cli/src/cli.js +16 -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 +27 -54
- package/dist/packages/cli/src/commands/verify.js +7 -3
- package/dist/packages/cli/src/criteria-review.js +63 -0
- 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/owned-run-step.js +1 -1
- package/dist/packages/cli/src/run-entry.js +203 -115
- 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 +4 -4
- package/dist/runtime/src/install/plan.js +3 -3
- package/dist/runtime/src/install/profiles.js +0 -2
- package/dist/runtime/src/logging/local-log.js +12 -0
- package/dist/runtime/src/office/snapshot.js +4 -1
- package/dist/runtime/src/parallel/manage.js +0 -3
- package/dist/runtime/src/review/criteria.js +252 -0
- package/dist/runtime/src/review/execute.js +68 -4
- package/dist/runtime/src/review/invocation.js +3 -3
- 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/dist/runtime/src/verify/spawn.js +3 -2
- 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/en/spec/review.md +16 -0
- 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/docs/zh-TW/spec/review.md +17 -1
- 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,632 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { canonicalJson, calculateConfigHash } from "../config/hash.js";
|
|
5
|
-
import { sha256 } from "../fs/hash.js";
|
|
6
|
-
import { AgentOpsError } from "../fs/paths.js";
|
|
7
|
-
import { readPrivateFile } from "../security/permissions.js";
|
|
8
|
-
import { validateEvidence } from "../schema/validate.js";
|
|
9
|
-
import { FileEvidenceStore } from "../verify/evidence.js";
|
|
10
|
-
import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
|
|
11
|
-
import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
|
|
12
|
-
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
13
|
-
import { resolveReviewScope } from "../review/scope.js";
|
|
14
|
-
import { runVerificationCommand } from "../verify/spawn.js";
|
|
15
|
-
import { checkTaskVerificationEvidence } from "./completion.js";
|
|
16
|
-
import { taskContractHash, criterionContractHash } from "./contract.js";
|
|
17
|
-
import { DEBUG_EXTENSION_MS, debugSpentMs, debugProofDigest, debugRemainingMs, debugRounds, debugStopCause } from "./debug-state.js";
|
|
18
|
-
import { FileTaskStore } from "./store.js";
|
|
19
|
-
import { debugUserControl, readDebugUserEvent } from "./debug-user-event.js";
|
|
20
|
-
import { readWorktreeRecord } from "../parallel/service.js";
|
|
21
|
-
import { evaluateGuardrail } from "../guardrails/evaluate.js";
|
|
22
|
-
import { sameDebugText } from "./debug-intake.js";
|
|
23
|
-
import { nativeProcessIdentity } from "../run/transport.js";
|
|
24
|
-
import { declareEscape, LEDGER_DIRECTORY, removeEscapeRecord, resolveOrigin } from "../escape/link.js";
|
|
25
|
-
function fail(code, message) { throw new AgentOpsError(code, message); }
|
|
26
|
-
const bounded = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 4096 && !v.includes("\0");
|
|
27
|
-
const session = (v) => bounded(v) && v.length <= 256;
|
|
28
|
-
async function processState(operation) {
|
|
29
|
-
if (operation.processId === undefined || operation.processIdentity === undefined)
|
|
30
|
-
return "unknown";
|
|
31
|
-
try {
|
|
32
|
-
process.kill(operation.processId, 0);
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
const code = error.code;
|
|
36
|
-
if (code === "ESRCH")
|
|
37
|
-
return "dead";
|
|
38
|
-
if (code !== "EPERM")
|
|
39
|
-
return "unknown";
|
|
40
|
-
}
|
|
41
|
-
const identity = await nativeProcessIdentity(operation.processId);
|
|
42
|
-
if (identity === null)
|
|
43
|
-
return "unknown";
|
|
44
|
-
return identity === operation.processIdentity ? "alive" : "dead";
|
|
45
|
-
}
|
|
46
|
-
export class DebugService {
|
|
47
|
-
options;
|
|
48
|
-
constructor(options) {
|
|
49
|
-
this.options = options;
|
|
50
|
-
}
|
|
51
|
-
async root(taskId) {
|
|
52
|
-
const state = await this.options.store.read();
|
|
53
|
-
const task = state.tasks.find(r => r.task.id === taskId);
|
|
54
|
-
let root = state.tasks.find(r => r.task.id === task?.task.debug?.rootTaskId);
|
|
55
|
-
if (root?.debug !== undefined && root.debug.ownerRoot !== this.options.root) {
|
|
56
|
-
const owner = root.debug.ownerRoot;
|
|
57
|
-
root = (await new FileTaskStore(join(owner, ".agent-ops/tasks/state.json"), owner).read()).tasks.find(r => r.task.id === task?.task.debug?.rootTaskId);
|
|
58
|
-
if (root?.debug?.ownerRoot !== owner)
|
|
59
|
-
fail("TASK_DEBUG_ROOT_MISMATCH", "Original issue owner is unavailable; never recreate its counters.");
|
|
60
|
-
}
|
|
61
|
-
if (task === undefined || root?.debug === undefined)
|
|
62
|
-
return fail("TASK_DEBUG_REQUIRED", "This task is not attached to an original debug issue.");
|
|
63
|
-
if (root.debug.rootTaskId !== root.task.id || root.task.goal !== task.task.goal)
|
|
64
|
-
return fail("TASK_DEBUG_ROOT_MISMATCH", "Debug root identity and original goal must match.");
|
|
65
|
-
return root;
|
|
66
|
-
}
|
|
67
|
-
async #mutate(previous, next) {
|
|
68
|
-
const owner = previous.debug.ownerRoot;
|
|
69
|
-
const store = owner === this.options.root ? this.options.store : new FileTaskStore(join(owner, ".agent-ops/tasks/state.json"), owner);
|
|
70
|
-
await store.mutate(state => {
|
|
71
|
-
const index = state.tasks.findIndex(r => r.task.id === previous.task.id);
|
|
72
|
-
if (index < 0 || canonicalJson(state.tasks[index]) !== canonicalJson(previous))
|
|
73
|
-
fail("TASK_DEBUG_STATE_CHANGED", "Concurrent state preserved; reload the debug issue.");
|
|
74
|
-
state.tasks[index] = { ...previous, debug: next, updatedAt: new Date().toISOString() };
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
async #scope(taskId) {
|
|
78
|
-
const task = await this.options.tasks.status({ taskId });
|
|
79
|
-
const config = await this.options.loadConfig();
|
|
80
|
-
const surface = await collectChangeSurface(this.options.gitRunner);
|
|
81
|
-
let scope;
|
|
82
|
-
try {
|
|
83
|
-
scope = surface.paths.length > 0 ? { mode: "worktree", changedFiles: surface.paths } : await resolveReviewScope({ root: this.options.root, runner: this.options.gitRunner,
|
|
84
|
-
...(this.options.base === undefined ? {} : { base: this.options.base }), ...(task.noChangePaths === undefined ? {} : { noChangePaths: task.noChangePaths }) });
|
|
85
|
-
}
|
|
86
|
-
catch (error) {
|
|
87
|
-
if (!(error instanceof AgentOpsError) || error.code !== "REVIEW_NO_CHANGE_SURFACE")
|
|
88
|
-
throw error;
|
|
89
|
-
scope = { mode: "worktree", changedFiles: surface.paths };
|
|
90
|
-
}
|
|
91
|
-
return { task, config, sourceFingerprint: await calculateSourceFingerprint(this.options.root, scope, this.options.gitRunner) };
|
|
92
|
-
}
|
|
93
|
-
async #productDigest(state) {
|
|
94
|
-
const records = await this.options.tasks.list();
|
|
95
|
-
const materials = new Set(records.flatMap(r => r.task.criteria.flatMap(c => c.acceptance?.bindings.flatMap(b => b.materials.map(m => m.path)) ?? [])));
|
|
96
|
-
const paths = new Set([...await collectBaseChangePaths(this.options.gitRunner, state.baselineCommit), ...(await collectChangeSurface(this.options.gitRunner)).paths]);
|
|
97
|
-
// The escape ledger is data about the fix, not a repair candidate.
|
|
98
|
-
const product = [...paths].filter(path => !materials.has(path) && !path.startsWith(LEDGER_DIRECTORY + "/") && !/(?:^|\/)(?:tests?|fixtures)(?:\/|$)|(?:\.test|\.spec)\./u.test(path)).sort();
|
|
99
|
-
const entries = [];
|
|
100
|
-
for (const path of product) {
|
|
101
|
-
try {
|
|
102
|
-
entries.push({ path, hash: sha256(await readFile(join(this.options.root, path))) });
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
if (error.code !== "ENOENT")
|
|
106
|
-
throw error;
|
|
107
|
-
entries.push({ path, hash: sha256("deleted") });
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return sha256(canonicalJson(entries));
|
|
111
|
-
}
|
|
112
|
-
/** Seal an operation only after its persisted process identity proves death. */
|
|
113
|
-
async #recoverInterrupted(root) {
|
|
114
|
-
const operation = root.debug.operation;
|
|
115
|
-
if (operation === null)
|
|
116
|
-
return root;
|
|
117
|
-
const state = await processState(operation);
|
|
118
|
-
if (state === "alive")
|
|
119
|
-
fail("TASK_DEBUG_OPERATION_RUNNING", "The previous debug operation is still alive; preserve its checkpoint and do not take it over.");
|
|
120
|
-
if (state === "unknown")
|
|
121
|
-
fail("TASK_DEBUG_OPERATION_RECOVERY_REQUIRED", "The previous debug operation has no trustworthy process-death proof; preserve it and obtain host reconciliation.");
|
|
122
|
-
const finishedAt = Date.now();
|
|
123
|
-
const durationMs = Math.max(0, finishedAt - operation.startedAt);
|
|
124
|
-
const foreground = root.debug.foreground;
|
|
125
|
-
const foregroundBeforeOperation = foreground === undefined || foreground === null ? 0 : Math.max(0, Math.min(finishedAt, operation.startedAt) - foreground.startedAt);
|
|
126
|
-
const receipt = {
|
|
127
|
-
id: operation.id,
|
|
128
|
-
taskId: operation.taskId,
|
|
129
|
-
kind: operation.kind,
|
|
130
|
-
contractHash: operation.contractHash,
|
|
131
|
-
configHash: operation.configHash,
|
|
132
|
-
sourceFingerprint: operation.sourceFingerprint,
|
|
133
|
-
productDigest: operation.productDigest,
|
|
134
|
-
durationMs,
|
|
135
|
-
finishedAt,
|
|
136
|
-
round: false,
|
|
137
|
-
progress: [],
|
|
138
|
-
references: [`operation:${operation.id}`],
|
|
139
|
-
outcome: "UNKNOWN",
|
|
140
|
-
cause: "environment"
|
|
141
|
-
};
|
|
142
|
-
const next = { ...root.debug, operation: null, foreground: null, consumedMs: root.debug.consumedMs + foregroundBeforeOperation + durationMs,
|
|
143
|
-
receipts: [...root.debug.receipts, receipt], checkpoint: null };
|
|
144
|
-
await this.#mutate(root, next);
|
|
145
|
-
return await this.root(root.task.id);
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* `sealed` admits a strategy switch's citation of this issue's own sealed
|
|
149
|
-
* regression history: a scope revision stales every current reference while
|
|
150
|
-
* verification stays stopped until the switch is recorded. Hypotheses and
|
|
151
|
-
* delivery still need evidence for the current contract and candidate.
|
|
152
|
-
*/
|
|
153
|
-
async #evidence(taskId, reference, sealed = false) {
|
|
154
|
-
const { task, config, sourceFingerprint } = await this.#scope(taskId);
|
|
155
|
-
const store = new FileEvidenceStore(this.options.root, this.options.root);
|
|
156
|
-
const checked = validateEvidence(await store.load(reference));
|
|
157
|
-
if (!checked.ok)
|
|
158
|
-
return fail("TASK_DEBUG_EVIDENCE_INVALID", "Use an actual current acceptance execution reference.");
|
|
159
|
-
const e = checked.value, p = e.acceptance;
|
|
160
|
-
const root = await this.root(taskId);
|
|
161
|
-
const head = await this.options.gitRunner.run(["rev-parse", "HEAD"]);
|
|
162
|
-
const candidate = new TextDecoder().decode(head.stdout).trim();
|
|
163
|
-
const current = p !== undefined && e.taskContractHash === taskContractHash(task.task) && e.configHash === calculateConfigHash(config) &&
|
|
164
|
-
e.sourceFingerprint === sourceFingerprint && p.pairedCommit === (p.phase === "baseline" ? candidate : root.debug.baselineCommit) &&
|
|
165
|
-
p.commit === (p.phase === "baseline" ? root.debug.baselineCommit : candidate);
|
|
166
|
-
const historical = sealed && p !== undefined && (p.phase === "baseline" ? p.commit : p.pairedCommit) === root.debug.baselineCommit &&
|
|
167
|
-
root.debug.receipts.some(r => r.kind === "verify" && r.taskId === taskId && r.references.includes(reference));
|
|
168
|
-
if (head.exitCode !== 0 || e.taskId !== taskId || p === undefined || !(current || historical) ||
|
|
169
|
-
!task.task.criteria.some(c => c.id === e.criterionId && c.acceptance?.mode === "behavioral" && c.acceptance.baselineCommit === root.debug.baselineCommit &&
|
|
170
|
-
(current
|
|
171
|
-
? p.criterionContractHash === criterionContractHash(c) && c.acceptance.bindings.some(b => b.runnerId === e.commandId && sha256(canonicalJson({ runnerId: b.runnerId, materials: b.materials })) === p.bindingHash)
|
|
172
|
-
: c.acceptance.bindings.some(b => b.runnerId === e.commandId))))
|
|
173
|
-
return fail("TASK_DEBUG_EVIDENCE_STALE", "Evidence must discriminate the original issue at its immutable baseline and current candidate.");
|
|
174
|
-
const source = await readPrivateFile(join(this.options.root, p.executionArtifact), this.options.root);
|
|
175
|
-
if (source === null || p.executionArtifact !== ".agent-ops/tasks/acceptance/" + sha256(source) + ".json")
|
|
176
|
-
return fail("TASK_DEBUG_EVIDENCE_INVALID", "Execution artifact is missing or modified.");
|
|
177
|
-
const actual = JSON.parse(source);
|
|
178
|
-
if (actual.run?.completed !== true || actual.aggregate?.status === "UNKNOWN" || actual.commit !== p.commit || actual.pairedCommit !== p.pairedCommit || actual.runner?.id !== e.commandId || actual.phase !== p.phase ||
|
|
179
|
-
actual.materialDigest !== p.materialDigest || actual.executionDigest !== p.executionDigest ||
|
|
180
|
-
p.checks.some(check => !actual.run.results?.some((r) => r.checkId === check.checkId && r.status === check.status && r.attempts === 1 &&
|
|
181
|
-
(r.failureClass === "assertion-failed" ? "assertion" : r.failureClass) === check.failureClass)))
|
|
182
|
-
return fail("TASK_DEBUG_EVIDENCE_INVALID", "Only completed, single-attempt assertion execution can resolve a hypothesis.");
|
|
183
|
-
return e;
|
|
184
|
-
}
|
|
185
|
-
async hypothesis(taskId, input) {
|
|
186
|
-
if (Object.keys(input).some(k => !["id", "statement", "method", "expected", "reason", "reference", "checkId", "expectedStatus", "action"].includes(k)) ||
|
|
187
|
-
![input.id, input.statement, input.method, input.expected, input.checkId].every(bounded) || !["PASS", "FAIL"].includes(input.expectedStatus ?? "") ||
|
|
188
|
-
(input.action !== undefined && (!["investigate", "observe"].includes(input.action) || input.reference !== undefined)))
|
|
189
|
-
fail("TASK_DEBUG_HYPOTHESIS_INVALID", "A hypothesis needs a bounded statement, verification method and expected result; status is runtime-derived.");
|
|
190
|
-
if (evaluateGuardrail({ kind: "content", content: JSON.stringify(input), scope: this.options.root }).action === "block")
|
|
191
|
-
fail("TASK_DEBUG_SENSITIVE_INPUT", "Do not save literal credentials in hypothesis proof.");
|
|
192
|
-
const root = await this.root(taskId), next = structuredClone(root.debug);
|
|
193
|
-
if (root.status !== "active" || next.operation !== null)
|
|
194
|
-
fail("TASK_DEBUG_BUSY", "Hypothesis changes need an active idle issue.");
|
|
195
|
-
let h = next.hypotheses.find(h => h.id === input.id);
|
|
196
|
-
if (h === undefined) {
|
|
197
|
-
const resolved = next.hypotheses.find(o => ["confirmed", "rejected"].includes(o.status) && sameDebugText(o.statement, input.statement) && sameDebugText(o.method, input.method));
|
|
198
|
-
if (resolved !== undefined)
|
|
199
|
-
fail("TASK_DEBUG_REJECTED_CAUSE", `Hypothesis ${resolved.id} already resolved this cause; reopen it with new discriminating evidence instead of a new id.`);
|
|
200
|
-
if (next.hypotheses.length >= 32)
|
|
201
|
-
fail("TASK_DEBUG_HISTORY_LIMIT", "Hypothesis history is full; preserve it and report the blocker.");
|
|
202
|
-
h = { id: input.id, statement: input.statement, method: input.method, expected: input.expected, checkId: input.checkId, expectedStatus: input.expectedStatus, status: "proposed", supporting: [], opposing: [], actual: "", history: [] };
|
|
203
|
-
next.hypotheses.push(h);
|
|
204
|
-
}
|
|
205
|
-
else if (h.statement !== input.statement || h.method !== input.method || h.expected !== input.expected || h.checkId !== input.checkId || h.expectedStatus !== input.expectedStatus)
|
|
206
|
-
fail("TASK_DEBUG_HYPOTHESIS_IMMUTABLE", "Retain the hypothesis and create a distinct falsifiable alternative.");
|
|
207
|
-
if (input.action !== undefined) {
|
|
208
|
-
if (["confirmed", "rejected"].includes(h.status))
|
|
209
|
-
fail("TASK_DEBUG_REJECTED_CAUSE", "Resolved hypotheses need new discriminating evidence, not another identical investigation.");
|
|
210
|
-
const { task, config, sourceFingerprint } = await this.#scope(taskId);
|
|
211
|
-
if (!task.task.criteria.some(c => c.acceptance?.mode === "behavioral" && c.acceptance.bindings.some(b => (b.redCheckIds ?? []).includes(input.checkId))))
|
|
212
|
-
fail("TASK_DEBUG_HYPOTHESIS_UNPROVEN", "Investigation must name a declared original-issue regression check.");
|
|
213
|
-
if (input.action === "investigate") {
|
|
214
|
-
h.status = "investigating";
|
|
215
|
-
h.actual = "Selected original-issue check for investigation; no cause is confirmed.";
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
const receipt = next.receipts.at(-1);
|
|
219
|
-
if (h.status !== "investigating" || receipt?.kind !== "verify" || receipt.outcome !== "UNKNOWN" || receipt.taskId !== taskId ||
|
|
220
|
-
receipt.contractHash !== taskContractHash(task.task) || receipt.configHash !== calculateConfigHash(config) || receipt.sourceFingerprint !== sourceFingerprint)
|
|
221
|
-
fail("TASK_DEBUG_HYPOTHESIS_UNPROVEN", "Inconclusive needs the current runtime-recorded UNKNOWN verification for this investigating hypothesis.");
|
|
222
|
-
h.status = "inconclusive";
|
|
223
|
-
h.actual = "Verification could not establish the hypothesis: UNKNOWN";
|
|
224
|
-
}
|
|
225
|
-
const references = input.action === "observe" ? next.receipts.at(-1).references : [];
|
|
226
|
-
if (h.history.at(-1)?.status !== h.status || h.history.at(-1)?.actual !== h.actual)
|
|
227
|
-
h.history.push({ status: h.status, actual: h.actual, references: [...references], reason: input.reason ?? "Runtime-derived investigation state; unresolved evidence cannot confirm a cause." });
|
|
228
|
-
}
|
|
229
|
-
else if (input.reference !== undefined) {
|
|
230
|
-
const e = await this.#evidence(taskId, input.reference);
|
|
231
|
-
const check = e.acceptance.checks.find(c => c.checkId === input.checkId);
|
|
232
|
-
if (!root.debug.regressionChecks.includes(e.commandId + ":" + input.checkId) || check === undefined || !["PASS", "FAIL"].includes(check.status) || !["PASS", "FAIL"].includes(input.expectedStatus ?? "") ||
|
|
233
|
-
(check.status === "FAIL" && check.failureClass !== "assertion"))
|
|
234
|
-
fail("TASK_DEBUG_HYPOTHESIS_UNPROVEN", "Name a real discriminating check and its expected outcome.");
|
|
235
|
-
if (h.status === "rejected" && (!bounded(input.reason) || [...h.supporting, ...h.opposing].includes(input.reference)))
|
|
236
|
-
fail("TASK_DEBUG_REJECTED_CAUSE", "A rejected cause needs new discriminating evidence and a preserved reopening reason.");
|
|
237
|
-
const matches = check.status === input.expectedStatus;
|
|
238
|
-
if ((matches ? h.supporting : h.opposing).includes(input.reference))
|
|
239
|
-
return structuredClone(h);
|
|
240
|
-
h.actual = `${e.acceptance.phase}:${check.checkId}:${check.status}`;
|
|
241
|
-
h.status = matches ? "confirmed" : "rejected";
|
|
242
|
-
(matches ? h.supporting : h.opposing).push(input.reference);
|
|
243
|
-
h.history.push({ status: h.status, actual: h.actual, references: [input.reference], reason: input.reason ?? "Runtime-reloaded discriminating assertion result." });
|
|
244
|
-
}
|
|
245
|
-
else if (h.status === "rejected")
|
|
246
|
-
fail("TASK_DEBUG_REJECTED_CAUSE", "Rejected hypotheses cannot reopen without new evidence.");
|
|
247
|
-
next.checkpoint = null;
|
|
248
|
-
await this.#mutate(root, next);
|
|
249
|
-
return structuredClone(h);
|
|
250
|
-
}
|
|
251
|
-
async strategy(taskId, input) {
|
|
252
|
-
const root = await this.root(taskId);
|
|
253
|
-
if (Object.keys(input).some(k => !["from", "to", "reason", "reference"].includes(k)) || !Object.values(input).every(bounded) ||
|
|
254
|
-
input.to.trim().length < 20 || input.reason.trim().length < 20 || input.from.replace(/\W/gu, "").toLowerCase() === input.to.replace(/\W/gu, "").toLowerCase())
|
|
255
|
-
fail("TASK_DEBUG_STRATEGY_INVALID", "Document a substantive different verification method and evidence-backed reason.");
|
|
256
|
-
if (debugStopCause(root.debug) !== "strategy-required" || root.debug.strategy !== null || root.debug.operation !== null)
|
|
257
|
-
fail("TASK_DEBUG_STRATEGY_NOT_ALLOWED", "Exactly one strategy switch is allowed after two stagnant repair rounds.");
|
|
258
|
-
await this.#evidence(taskId, input.reference, true);
|
|
259
|
-
await this.#mutate(root, { ...root.debug, strategy: { from: input.from, to: input.to, reason: input.reason, references: [input.reference], afterRound: debugRounds(root.debug) }, checkpoint: null });
|
|
260
|
-
}
|
|
261
|
-
/** Escape Loop: where the confirmed cause came from, bound into the contract before review. */
|
|
262
|
-
async origin(taskId, input) {
|
|
263
|
-
const root = await this.root(taskId);
|
|
264
|
-
if (root.task.id !== taskId)
|
|
265
|
-
fail("ESCAPE_ORIGIN_INVALID", "Declare the origin on the original issue task.");
|
|
266
|
-
if (root.status !== "active" || root.debug.operation !== null)
|
|
267
|
-
fail("TASK_DEBUG_BUSY", "Origin needs an active idle issue.");
|
|
268
|
-
if (debugRemainingMs(root.debug) === 0)
|
|
269
|
-
fail("TASK_DEBUG_BUDGET_EXHAUSTED", "The original issue's active budget is exhausted.");
|
|
270
|
-
if (!root.debug.hypotheses.some(h => h.status === "confirmed"))
|
|
271
|
-
fail("ESCAPE_ORIGIN_EARLY", "Declare the origin only after discriminating evidence confirms a hypothesis.");
|
|
272
|
-
const origin = await resolveOrigin(this.options.gitRunner, input);
|
|
273
|
-
const record = await this.options.tasks.revise(taskId, { expectedContractHash: taskContractHash(root.task), origin, debugOrigin: true,
|
|
274
|
-
reason: `Declare issue origin: ${origin.kind === "escape-of" ? "escape-of " + origin.commit : origin.kind}.` });
|
|
275
|
-
if (origin.kind !== "escape-of") {
|
|
276
|
-
await removeEscapeRecord(this.options.root, taskId);
|
|
277
|
-
return { record };
|
|
278
|
-
}
|
|
279
|
-
return { record, escape: await declareEscape({ root: this.options.root, git: this.options.gitRunner, taskId, origin,
|
|
280
|
-
base: root.debug.baselineCommit, now: new Date().toISOString() }) };
|
|
281
|
-
}
|
|
282
|
-
async remaining(taskId) { return debugRemainingMs((await this.root(taskId)).debug); }
|
|
283
|
-
async #userEvent(sessionId, home, kind = "latest") {
|
|
284
|
-
const record = await readWorktreeRecord(this.options.root);
|
|
285
|
-
const origin = record?.mainRoot ?? this.options.root;
|
|
286
|
-
try {
|
|
287
|
-
return await readDebugUserEvent(origin, sessionId, home, kind, origin === this.options.root ? undefined : this.options.root);
|
|
288
|
-
}
|
|
289
|
-
catch (error) {
|
|
290
|
-
if (origin === this.options.root || !(error instanceof AgentOpsError) || error.code !== "TASK_DEBUG_GRANT_UNSUPPORTED")
|
|
291
|
-
throw error;
|
|
292
|
-
return await readDebugUserEvent(this.options.root, sessionId, home, kind);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
/** Only the native transcript can change the human's investigation scope. */
|
|
296
|
-
async syncNativeControl(taskId, sessionId, home) {
|
|
297
|
-
let event;
|
|
298
|
-
try {
|
|
299
|
-
event = await this.#userEvent(sessionId, home, "control");
|
|
300
|
-
}
|
|
301
|
-
catch (error) {
|
|
302
|
-
if (error instanceof AgentOpsError && error.code === "TASK_DEBUG_GRANT_UNSUPPORTED")
|
|
303
|
-
return;
|
|
304
|
-
throw error;
|
|
305
|
-
}
|
|
306
|
-
let root = await this.root(taskId);
|
|
307
|
-
if (event.timestamp < Date.parse(root.createdAt) || root.debug.control?.eventId === event.id ||
|
|
308
|
-
event.timestamp <= (root.debug.control?.timestamp ?? 0))
|
|
309
|
-
return;
|
|
310
|
-
const action = debugUserControl(event.text);
|
|
311
|
-
if (action === null)
|
|
312
|
-
return;
|
|
313
|
-
const attached = await this.options.tasks.status({ sessionId });
|
|
314
|
-
if (attached.task.debug?.rootTaskId !== root.task.id || root.status !== "active")
|
|
315
|
-
fail("TASK_DEBUG_CONTROL_INVALID", "Human control must belong to the active attached original issue.");
|
|
316
|
-
if (action === "unlimited" || ((action === "resume" || action === "repair") && debugRemainingMs(root.debug) === 0)) {
|
|
317
|
-
try {
|
|
318
|
-
await this.extend(taskId, sessionId, event.id, home);
|
|
319
|
-
}
|
|
320
|
-
catch (error) {
|
|
321
|
-
if (!(error instanceof AgentOpsError) || !["TASK_DEBUG_GRANT_INVALID", "TASK_DEBUG_GRANT_UNSUPPORTED"].includes(error.code))
|
|
322
|
-
throw error;
|
|
323
|
-
}
|
|
324
|
-
root = await this.root(taskId);
|
|
325
|
-
}
|
|
326
|
-
if (action === "pause") {
|
|
327
|
-
await this.foregroundStop(taskId, sessionId);
|
|
328
|
-
root = await this.root(taskId);
|
|
329
|
-
}
|
|
330
|
-
await this.#mutate(root, { ...root.debug, control: {
|
|
331
|
-
mode: action === "diagnose" ? "diagnose" : action === "repair" ? "full" : root.debug.control?.mode ?? "full",
|
|
332
|
-
paused: action === "pause", sessionId, eventId: event.id, sourceDigest: event.sourceDigest, timestamp: event.timestamp
|
|
333
|
-
}, checkpoint: null });
|
|
334
|
-
if (action === "pause" && (await this.root(taskId)).debug.operation === null)
|
|
335
|
-
await this.checkpoint(taskId, sessionId);
|
|
336
|
-
}
|
|
337
|
-
/** A native Interrupt is itself a human pause signal; no CLI actor flag exists. */
|
|
338
|
-
async interrupt(taskId, sessionId, nativeInput) {
|
|
339
|
-
const attached = await this.options.tasks.status({ sessionId });
|
|
340
|
-
let root = await this.root(taskId);
|
|
341
|
-
if (attached.task.debug?.rootTaskId !== root.task.id || root.status !== "active")
|
|
342
|
-
fail("TASK_DEBUG_CONTROL_INVALID", "Interrupt must belong to the attached original issue.");
|
|
343
|
-
await this.foregroundStop(taskId, sessionId);
|
|
344
|
-
root = await this.root(taskId);
|
|
345
|
-
const sourceDigest = sha256(nativeInput);
|
|
346
|
-
await this.#mutate(root, { ...root.debug, control: { mode: root.debug.control?.mode ?? "full", paused: true, sessionId,
|
|
347
|
-
eventId: sha256("Interrupt:" + sessionId + ":" + sourceDigest), sourceDigest, timestamp: Date.now() }, checkpoint: null });
|
|
348
|
-
if ((await this.root(taskId)).debug.operation === null)
|
|
349
|
-
await this.checkpoint(taskId, sessionId);
|
|
350
|
-
}
|
|
351
|
-
async diagnosisProven(taskId) {
|
|
352
|
-
const root = await this.root(taskId);
|
|
353
|
-
if (root.debug.control?.mode !== "diagnose" || root.debug.operation !== null)
|
|
354
|
-
return false;
|
|
355
|
-
for (const h of root.debug.hypotheses.filter(h => h.status === "confirmed" && h.expectedStatus === "FAIL" && h.actual === `baseline:${h.checkId}:FAIL`)) {
|
|
356
|
-
const reference = h.supporting.at(-1);
|
|
357
|
-
if (reference === undefined)
|
|
358
|
-
continue;
|
|
359
|
-
try {
|
|
360
|
-
const evidence = await this.#evidence(taskId, reference);
|
|
361
|
-
if (root.debug.regressionChecks.includes(evidence.commandId + ":" + h.checkId) && evidence.acceptance?.phase === "baseline" &&
|
|
362
|
-
evidence.acceptance.checks.some(c => c.checkId === h.checkId && c.status === "FAIL" && c.failureClass === "assertion"))
|
|
363
|
-
return true;
|
|
364
|
-
}
|
|
365
|
-
catch (error) {
|
|
366
|
-
if (!(error instanceof AgentOpsError) || !["TASK_DEBUG_EVIDENCE_INVALID", "TASK_DEBUG_EVIDENCE_STALE"].includes(error.code))
|
|
367
|
-
throw error;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
return false;
|
|
371
|
-
}
|
|
372
|
-
async prepareStop(taskId, sessionId) {
|
|
373
|
-
await this.syncNativeControl(taskId, sessionId);
|
|
374
|
-
const root = await this.root(taskId);
|
|
375
|
-
if (root.debug.operation === null && (root.debug.control?.paused === true || await this.diagnosisProven(taskId)))
|
|
376
|
-
await this.checkpoint(taskId, sessionId);
|
|
377
|
-
}
|
|
378
|
-
async #begin(taskId, kind) {
|
|
379
|
-
let root = await this.root(taskId);
|
|
380
|
-
root = await this.#recoverInterrupted(root);
|
|
381
|
-
const { task, config, sourceFingerprint } = await this.#scope(taskId);
|
|
382
|
-
if (root.status !== "active" || root.debug.operation !== null)
|
|
383
|
-
fail("TASK_DEBUG_BUSY", "The original issue has a running or interrupted operation; preserve its checkpoint.");
|
|
384
|
-
if (root.debug.receipts.length >= 256)
|
|
385
|
-
fail("TASK_DEBUG_HISTORY_LIMIT", "Execution history is full; retain it and report the blocker.");
|
|
386
|
-
const productDigest = await this.#productDigest(root.debug);
|
|
387
|
-
// EVIDENCE-SURFACE-001: intake may start without a surface, the first repair may not.
|
|
388
|
-
if (kind === "verify" && task.task.surfaceRequired === true && task.task.surface === undefined && productDigest !== sha256(canonicalJson([])))
|
|
389
|
-
fail("SURFACE_MISSING", "Add the surface audit with task revise --surface-file before verifying the first repair.");
|
|
390
|
-
const previousVerify = root.debug.receipts.filter(r => r.kind === "verify" && (r.outcome === "PASS" || r.outcome === "FAIL")).at(-1);
|
|
391
|
-
const previousCandidate = previousVerify?.productDigest ?? sha256("[]");
|
|
392
|
-
const cause = debugStopCause(root.debug);
|
|
393
|
-
// Recovery reruns the original verifier; changed product content still needs fresh proof.
|
|
394
|
-
// At the round limit an unchanged PASS candidate may be re-verified (not a new round), e.g. after an
|
|
395
|
-
// escape-ledger update moved the source fingerprint; a FAIL cannot be re-run into proof at the cap.
|
|
396
|
-
if (cause !== null && !(["environment", "permission"].includes(cause) && (kind === "investigate" || kind === "verify")) &&
|
|
397
|
-
!(cause === "iterations" && productDigest === previousCandidate && (kind === "review" || (kind === "verify" && previousVerify?.outcome === "PASS"))))
|
|
398
|
-
fail("TASK_DEBUG_STOP_REQUIRED", `Runtime stop predicate: ${cause}. Record a checkpoint; resume alone cannot reset it.`);
|
|
399
|
-
const processIdentity = await nativeProcessIdentity(process.pid);
|
|
400
|
-
if (processIdentity === null)
|
|
401
|
-
fail("TASK_DEBUG_PROCESS_IDENTITY_REQUIRED", "The host cannot prove the debug operation process identity; preserve the issue and report the unsupported environment.");
|
|
402
|
-
const operation = { id: randomUUID(), taskId, kind, startedAt: Date.now(), contractHash: taskContractHash(task.task), configHash: calculateConfigHash(config), sourceFingerprint, productDigest, processId: process.pid, processIdentity };
|
|
403
|
-
await this.#mutate(root, { ...root.debug, operation, checkpoint: null });
|
|
404
|
-
return operation;
|
|
405
|
-
}
|
|
406
|
-
async #end(operation, outcome, references, progress, cause) {
|
|
407
|
-
const finishedAt = Date.now();
|
|
408
|
-
const durationMs = Math.max(0, finishedAt - operation.startedAt);
|
|
409
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
410
|
-
try {
|
|
411
|
-
const root = await this.root(operation.taskId);
|
|
412
|
-
if (root.debug.operation?.id !== operation.id)
|
|
413
|
-
fail("TASK_DEBUG_OPERATION_CHANGED", "The timed operation changed; its result cannot be adopted.");
|
|
414
|
-
const previous = root.debug.receipts.filter(r => r.kind === "verify" && (r.outcome === "PASS" || r.outcome === "FAIL")).at(-1)?.productDigest ?? sha256("[]");
|
|
415
|
-
const round = operation.kind === "verify" && (outcome === "PASS" || outcome === "FAIL") && operation.productDigest !== previous;
|
|
416
|
-
const receipt = { id: operation.id, taskId: operation.taskId, kind: operation.kind, contractHash: operation.contractHash, configHash: operation.configHash,
|
|
417
|
-
sourceFingerprint: operation.sourceFingerprint, productDigest: operation.productDigest, durationMs, finishedAt, round, progress, references, outcome, cause };
|
|
418
|
-
const foreground = root.debug.foreground;
|
|
419
|
-
const operationBeforeForeground = foreground === undefined || foreground === null ? durationMs : Math.max(0, Math.min(finishedAt, foreground.startedAt) - operation.startedAt);
|
|
420
|
-
const next = { ...root.debug, operation: null, consumedMs: root.debug.consumedMs + operationBeforeForeground, receipts: [...root.debug.receipts, receipt], checkpoint: null };
|
|
421
|
-
await this.#mutate(root, next);
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
catch (error) {
|
|
425
|
-
if (!(error instanceof AgentOpsError) || error.code !== "TASK_DEBUG_STATE_CHANGED" || attempt === 2)
|
|
426
|
-
throw error;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
/** Called only around the existing verifier; outcomes are reloaded from sealed evidence. */
|
|
431
|
-
async verification(taskId, execute) {
|
|
432
|
-
const initial = await this.root(taskId);
|
|
433
|
-
if (initial.debug.regressionChecks.length === 0) {
|
|
434
|
-
const record = await this.options.tasks.status({ taskId });
|
|
435
|
-
const checks = [...new Set(record.task.criteria.flatMap(c => c.acceptance?.mode === "behavioral" ? c.acceptance.bindings.flatMap(b => (b.redCheckIds ?? []).map(id => b.runnerId + ":" + id)) : []))];
|
|
436
|
-
if (checks.length > 0)
|
|
437
|
-
await this.#mutate(initial, { ...initial.debug, regressionChecks: checks });
|
|
438
|
-
}
|
|
439
|
-
const operation = await this.#begin(taskId, "verify");
|
|
440
|
-
try {
|
|
441
|
-
const result = await execute();
|
|
442
|
-
const { task, config, sourceFingerprint } = await this.#scope(taskId);
|
|
443
|
-
const store = new FileEvidenceStore(this.options.root, this.options.root);
|
|
444
|
-
const coverage = await acceptanceCoverage(task, config, sourceFingerprint, store);
|
|
445
|
-
const problem = await checkTaskVerificationEvidence(task, { root: this.options.root, config, sourceFingerprint, evidenceStore: store });
|
|
446
|
-
const root = await this.root(taskId);
|
|
447
|
-
const previousProgress = new Set(root.debug.receipts.flatMap(r => r.progress));
|
|
448
|
-
const proven = coverage.filter(row => row.mode === "behavioral" && row.status === "proven").flatMap(row => {
|
|
449
|
-
const criterion = task.task.criteria.find(c => c.id === row.criterionId);
|
|
450
|
-
return criterion?.acceptance?.bindings.flatMap(b => (b.redCheckIds ?? []).map(id => b.runnerId + ":" + id).filter(id => root.debug.regressionChecks.includes(id))) ?? [];
|
|
451
|
-
});
|
|
452
|
-
const resolved = root.debug.hypotheses.filter(h => ["confirmed", "rejected"].includes(h.status)).map(h => "hypothesis:" + h.actual);
|
|
453
|
-
const progress = [...proven, ...resolved].filter(id => !previousProgress.has(id));
|
|
454
|
-
const refs = [...new Set(Object.values(task.evidence).flat().filter(r => !r.startsWith("review:")))];
|
|
455
|
-
let environment = false;
|
|
456
|
-
for (const reference of refs) {
|
|
457
|
-
const checked = validateEvidence(await store.load(reference));
|
|
458
|
-
if (checked.ok && checked.value.sourceFingerprint === operation.sourceFingerprint && checked.value.taskContractHash === operation.contractHash && checked.value.status === "UNKNOWN" && checked.value.failureClass !== "debug-budget-exhausted" && !(checked.value.failureClass.includes("timeout") && await this.remaining(taskId) === 0))
|
|
459
|
-
environment = true;
|
|
460
|
-
}
|
|
461
|
-
await this.#end(operation, problem === null ? "PASS" : environment ? "UNKNOWN" : "FAIL", refs, progress, environment ? "environment" : null);
|
|
462
|
-
return result;
|
|
463
|
-
}
|
|
464
|
-
catch (error) {
|
|
465
|
-
if ((await this.root(taskId)).debug.operation?.id === operation.id)
|
|
466
|
-
await this.#end(operation, "UNKNOWN", [], [], error instanceof AgentOpsError && error.code === "TASK_DEBUG_BUDGET_EXHAUSTED" ? null : error instanceof AgentOpsError && /TRUST|DENIED|PERMISSION/u.test(error.code) ? "permission" : "environment");
|
|
467
|
-
throw error;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
async review(taskId, execute) {
|
|
471
|
-
const operation = await this.#begin(taskId, "review");
|
|
472
|
-
try {
|
|
473
|
-
const result = await execute();
|
|
474
|
-
await this.#end(operation, result.status === "PASS" ? "PASS" : result.status === "FAIL" ? "FAIL" : "UNKNOWN", [], [], null);
|
|
475
|
-
return result;
|
|
476
|
-
}
|
|
477
|
-
catch (error) {
|
|
478
|
-
if ((await this.root(taskId)).debug.operation?.id === operation.id)
|
|
479
|
-
await this.#end(operation, "UNKNOWN", [], [], "environment");
|
|
480
|
-
throw error;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
async investigate(taskId, commandId, trusted) {
|
|
484
|
-
const current = await this.root(taskId);
|
|
485
|
-
if (current.debug.receipts.at(-1)?.cause !== null && current.debug.receipts.at(-1)?.cause !== undefined && current.debug.receipts.at(-1)?.references[0] !== "command:" + commandId)
|
|
486
|
-
fail("TASK_DEBUG_RECOVERY_COMMAND_REQUIRED", "Retry the same blocked command or original regression verifier; unrelated green commands cannot clear a blocker.");
|
|
487
|
-
const operation = await this.#begin(taskId, "investigate");
|
|
488
|
-
try {
|
|
489
|
-
const config = await this.options.loadConfig();
|
|
490
|
-
const command = config.verification.commands.find(c => c.id === commandId);
|
|
491
|
-
if (!trusted || command === undefined)
|
|
492
|
-
fail("TASK_DEBUG_COMMAND_DENIED", "Investigation executes only a currently trusted configured command.");
|
|
493
|
-
const result = await runVerificationCommand({ ...command, timeoutMs: Math.min(command.timeoutMs ?? 300_000, await this.remaining(taskId)) }, { cwd: command.cwd === "." ? this.options.root : join(this.options.root, command.cwd) });
|
|
494
|
-
await this.#end(operation, result.status, ["command:" + commandId], [], result.status === "UNKNOWN" && !(result.timedOut && await this.remaining(taskId) === 0) ? "environment" : null);
|
|
495
|
-
return result;
|
|
496
|
-
}
|
|
497
|
-
catch (error) {
|
|
498
|
-
if ((await this.root(taskId)).debug.operation?.id === operation.id)
|
|
499
|
-
await this.#end(operation, "UNKNOWN", [], [], error instanceof AgentOpsError && /DENIED/u.test(error.code) ? "permission" : "environment");
|
|
500
|
-
throw error;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
/** Start measuring one native foreground turn; host hooks are the only callers. */
|
|
504
|
-
async foregroundStart(taskId, sessionId) {
|
|
505
|
-
if (!session(sessionId))
|
|
506
|
-
fail("TASK_DEBUG_SESSION_INVALID", "A native session identity is required for foreground timing.");
|
|
507
|
-
let root = await this.root(taskId);
|
|
508
|
-
root = await this.#recoverInterrupted(root);
|
|
509
|
-
if (root.status !== "active" || root.debug.operation !== null)
|
|
510
|
-
fail("TASK_DEBUG_BUSY", "Foreground timing requires an active idle debug issue.");
|
|
511
|
-
const current = root.debug.foreground;
|
|
512
|
-
if (current !== undefined && current !== null) {
|
|
513
|
-
const cause = debugStopCause(root.debug);
|
|
514
|
-
if (current.sessionId === sessionId && (cause === null || cause === "environment" || cause === "permission"))
|
|
515
|
-
return;
|
|
516
|
-
fail("TASK_DEBUG_CLOCK_CONTESTED", "A different or exhausted foreground turn owns the debug clock; preserve it and wait for its native stop event.");
|
|
517
|
-
}
|
|
518
|
-
const cause = debugStopCause(root.debug);
|
|
519
|
-
if (cause !== null && cause !== "environment" && cause !== "permission")
|
|
520
|
-
fail("TASK_DEBUG_STOP_REQUIRED", `Runtime stop predicate: ${cause}. Obtain the required checkpoint or human grant before starting another turn.`);
|
|
521
|
-
await this.#mutate(root, { ...root.debug, foreground: { sessionId, startedAt: Date.now() }, checkpoint: null });
|
|
522
|
-
}
|
|
523
|
-
/** Close a native foreground turn and account only time outside a running operation. */
|
|
524
|
-
async foregroundStop(taskId, sessionId) {
|
|
525
|
-
if (!session(sessionId))
|
|
526
|
-
return;
|
|
527
|
-
// A native stop is a trusted foreground boundary even when a controlled
|
|
528
|
-
// operation is still running. Recovering first would reject the stop on
|
|
529
|
-
// a live operation and leave the foreground clock running forever. Keep
|
|
530
|
-
// that operation intact; #end will account its complete controlled
|
|
531
|
-
// interval after the foreground has been closed. Dead or unverifiable
|
|
532
|
-
// operations still use the fail-closed recovery path. The short retry
|
|
533
|
-
// handles the expected race where the operation finishes while its host
|
|
534
|
-
// identity is being checked.
|
|
535
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
536
|
-
try {
|
|
537
|
-
let root = await this.root(taskId);
|
|
538
|
-
const operation = root.debug.operation;
|
|
539
|
-
if (operation === null) {
|
|
540
|
-
root = await this.#recoverInterrupted(root);
|
|
541
|
-
}
|
|
542
|
-
else {
|
|
543
|
-
const state = await processState(operation);
|
|
544
|
-
// Reload after the host identity check: an operation can finish
|
|
545
|
-
// while that check is running. Recover only when the same operation
|
|
546
|
-
// is still persisted; otherwise retain the writer's newer state.
|
|
547
|
-
root = await this.root(taskId);
|
|
548
|
-
if (root.debug.operation?.id === operation.id && state !== "alive")
|
|
549
|
-
root = await this.#recoverInterrupted(root);
|
|
550
|
-
}
|
|
551
|
-
const foreground = root.debug.foreground;
|
|
552
|
-
if (foreground === undefined || foreground === null)
|
|
553
|
-
return;
|
|
554
|
-
if (foreground.sessionId !== sessionId)
|
|
555
|
-
fail("TASK_DEBUG_CLOCK_OWNER_MISMATCH", "Only the native session that opened the debug clock may close it.");
|
|
556
|
-
const now = Date.now();
|
|
557
|
-
const end = root.debug.operation?.startedAt ?? now;
|
|
558
|
-
const durationMs = Math.max(0, Math.min(now, end) - foreground.startedAt);
|
|
559
|
-
await this.#mutate(root, { ...root.debug, foreground: null, consumedMs: root.debug.consumedMs + durationMs,
|
|
560
|
-
checkpoint: root.debug.checkpoint?.cause == null ? null : root.debug.checkpoint });
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
catch (error) {
|
|
564
|
-
if (!(error instanceof AgentOpsError) || error.code !== "TASK_DEBUG_STATE_CHANGED" || attempt === 2)
|
|
565
|
-
throw error;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
async checkpoint(taskId, sessionId) {
|
|
570
|
-
const recovered = await this.#recoverInterrupted(await this.root(taskId));
|
|
571
|
-
const root = recovered, { task, config } = await this.#scope(taskId);
|
|
572
|
-
const attached = await this.options.tasks.status({ sessionId });
|
|
573
|
-
if (attached.task.debug?.rootTaskId !== root.task.id || root.debug.operation !== null)
|
|
574
|
-
fail("TASK_DEBUG_CHECKPOINT_INVALID", "A stopped checkpoint must belong to this idle attached issue.");
|
|
575
|
-
const cause = debugStopCause(root.debug) ?? (await this.diagnosisProven(taskId) ? "diagnosed" : null);
|
|
576
|
-
const latest = root.debug.receipts.at(-1);
|
|
577
|
-
if ((cause === "environment" || cause === "permission") && (latest?.contractHash !== taskContractHash(task.task) || latest.configHash !== calculateConfigHash(config) || latest.sourceFingerprint !== (await this.#scope(taskId)).sourceFingerprint))
|
|
578
|
-
fail("TASK_DEBUG_CHECKPOINT_INVALID", "Blocked receipt must still match its current task, source and policy.");
|
|
579
|
-
const receiptId = root.debug.receipts.at(-1)?.id ?? "intake:" + root.task.id;
|
|
580
|
-
const surface = await collectChangeSurface(this.options.gitRunner);
|
|
581
|
-
const sourceFingerprint = await calculateSourceFingerprint(this.options.root, { mode: "worktree", changedFiles: surface.paths }, this.options.gitRunner);
|
|
582
|
-
const next = { ...root.debug, checkpoint: { taskId, sessionId, root: this.options.root, sourceFingerprint, contractHash: taskContractHash(task.task), configHash: calculateConfigHash(config), cause, receiptId } };
|
|
583
|
-
await this.#mutate(root, next);
|
|
584
|
-
return next;
|
|
585
|
-
}
|
|
586
|
-
async validCheckpoint(taskId, sessionId, sourceFingerprint) {
|
|
587
|
-
const root = await this.root(taskId), checkpoint = root.debug.checkpoint;
|
|
588
|
-
const { task, config } = await this.#scope(taskId);
|
|
589
|
-
return root.debug.operation === null && checkpoint !== null && checkpoint.taskId === taskId && checkpoint.sessionId === sessionId &&
|
|
590
|
-
checkpoint.root === this.options.root && checkpoint.sourceFingerprint === sourceFingerprint && checkpoint.contractHash === taskContractHash(task.task) &&
|
|
591
|
-
checkpoint.configHash === calculateConfigHash(config) && checkpoint.cause !== null &&
|
|
592
|
-
(checkpoint.cause === "diagnosed" ? await this.diagnosisProven(taskId) : checkpoint.cause === debugStopCause(root.debug)) &&
|
|
593
|
-
checkpoint.receiptId === (root.debug.receipts.at(-1)?.id ?? "intake:" + root.task.id);
|
|
594
|
-
}
|
|
595
|
-
async proofDigest(taskId) { return debugProofDigest((await this.root(taskId)).debug); }
|
|
596
|
-
async resume(taskId, sessionId) {
|
|
597
|
-
const root = await this.#recoverInterrupted(await this.root(taskId)), checkpoint = root.debug.checkpoint, record = await readWorktreeRecord(this.options.root);
|
|
598
|
-
const task = await this.options.tasks.status({ taskId }), config = await this.options.loadConfig();
|
|
599
|
-
const surface = await collectChangeSurface(this.options.gitRunner);
|
|
600
|
-
const fingerprint = await calculateSourceFingerprint(this.options.root, { mode: "worktree", changedFiles: surface.paths }, this.options.gitRunner);
|
|
601
|
-
if (root.debug.operation !== null || checkpoint === null || record === null || record.path !== this.options.root || record.sessionId !== checkpoint.sessionId ||
|
|
602
|
-
checkpoint.root !== record.path || checkpoint.taskId !== taskId || checkpoint.sourceFingerprint !== fingerprint ||
|
|
603
|
-
checkpoint.contractHash !== taskContractHash(task.task) || checkpoint.configHash !== calculateConfigHash(config))
|
|
604
|
-
fail("TASK_DEBUG_RECOVERY_CONFIRMATION_REQUIRED", "Owned debug checkpoint/path/source/contract/config mismatch. Preserve the checkout and obtain user confirmation before recovery.");
|
|
605
|
-
await this.options.tasks.attach(sessionId, root.task.id);
|
|
606
|
-
}
|
|
607
|
-
async extend(taskId, sessionId, eventId, home) {
|
|
608
|
-
const root = await this.root(taskId), attached = await this.options.tasks.status({ sessionId });
|
|
609
|
-
if (attached.task.debug?.rootTaskId !== root.task.id || root.debug.operation !== null)
|
|
610
|
-
fail("TASK_DEBUG_GRANT_INVALID", "Extension belongs to the original issue and its native session; a running operation cannot be extended.");
|
|
611
|
-
const event = await this.#userEvent(sessionId, home);
|
|
612
|
-
if (eventId !== "latest" && eventId !== event.id)
|
|
613
|
-
fail("TASK_DEBUG_GRANT_INVALID", "The requested event is not the current native human event.");
|
|
614
|
-
if (root.debug.grants.some(g => g.eventId === event.id))
|
|
615
|
-
return;
|
|
616
|
-
const otherCause = debugStopCause({ ...root.debug, consumedMs: 0, foreground: null, operation: null, budgetMs: 86_400_000,
|
|
617
|
-
...(root.debug.control === undefined ? {} : { control: { ...root.debug.control, paused: false } }) });
|
|
618
|
-
const unlimited = debugUserControl(event.text) === "unlimited";
|
|
619
|
-
if (unlimited && (root.status !== "active" || event.timestamp < Date.parse(root.createdAt)))
|
|
620
|
-
fail("TASK_DEBUG_GRANT_INVALID", "Only a current native human event can waive the active original issue time limit.");
|
|
621
|
-
const foregroundActive = root.debug.foreground !== undefined && root.debug.foreground !== null;
|
|
622
|
-
const remainingAtEvent = debugRemainingMs(root.debug, event.timestamp);
|
|
623
|
-
const sealedAtCandidates = [Date.parse(root.updatedAt), root.debug.receipts.at(-1)?.finishedAt ?? Number.NaN].filter(Number.isFinite);
|
|
624
|
-
const lastSealedAt = sealedAtCandidates.length === 0 ? Number.POSITIVE_INFINITY : Math.max(...sealedAtCandidates);
|
|
625
|
-
const exhaustedAtEvent = foregroundActive
|
|
626
|
-
? remainingAtEvent === 0
|
|
627
|
-
: debugRemainingMs(root.debug) === 0 && event.timestamp > lastSealedAt;
|
|
628
|
-
if ((!unlimited && !exhaustedAtEvent) || (otherCause !== null && !["environment", "permission"].includes(otherCause)) || !["resume", "repair", "unlimited"].includes(debugUserControl(event.text) ?? ""))
|
|
629
|
-
fail("TASK_DEBUG_GRANT_INVALID", "Only a contemporaneous human continue event extends exhausted time; other blockers remain authoritative.");
|
|
630
|
-
await this.#mutate(root, { ...root.debug, grants: [...root.debug.grants, { eventId: event.id, sessionId, sourceDigest: event.sourceDigest, grantedMs: unlimited ? null : DEBUG_EXTENSION_MS, spentAtGrantMs: debugSpentMs(root.debug, event.timestamp) }], checkpoint: null });
|
|
631
|
-
}
|
|
632
|
-
}
|