@kylecheng3146/agent-ops 0.9.0 → 0.10.2
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 +3 -2
- package/dist/packages/cli/src/args.js +58 -14
- package/dist/packages/cli/src/bin.js +10 -3
- package/dist/packages/cli/src/cli.js +30 -6
- package/dist/packages/cli/src/commands/advance.js +2 -1
- package/dist/packages/cli/src/commands/escapes.js +109 -0
- package/dist/packages/cli/src/commands/parallel.js +1 -1
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +63 -11
- package/dist/packages/cli/src/commands/run.js +10 -3
- package/dist/packages/cli/src/commands/task.js +41 -3
- package/dist/packages/cli/src/commands/update.js +16 -4
- package/dist/packages/cli/src/commands/verify.js +4 -1
- package/dist/packages/cli/src/context.js +22 -4
- package/dist/packages/cli/src/debug-intake.js +1 -1
- package/dist/packages/cli/src/office-entry.js +8 -4
- package/dist/packages/cli/src/owned-run-step.js +4 -4
- package/dist/packages/cli/src/parallel-deps.js +0 -2
- package/dist/packages/cli/src/run-deps.js +12 -9
- package/dist/packages/cli/src/run-entry.js +76 -17
- package/dist/packages/cli/src/run-setup-entry.js +2 -2
- package/dist/runtime/src/escape/link.js +317 -0
- package/dist/runtime/src/escape/origin.js +20 -0
- package/dist/runtime/src/escape/rules.js +252 -0
- package/dist/runtime/src/fs/temporary.js +10 -0
- package/dist/runtime/src/install/harness.js +7 -4
- package/dist/runtime/src/logging/local-log.js +44 -1
- package/dist/runtime/src/office/scene.js +1 -1
- package/dist/runtime/src/office/server.js +43 -16
- package/dist/runtime/src/office/snapshot.js +1 -1
- package/dist/runtime/src/parallel/finish.js +139 -65
- package/dist/runtime/src/parallel/manage.js +51 -1
- package/dist/runtime/src/parallel/receipt.js +3 -1
- package/dist/runtime/src/parallel/service.js +5 -1
- package/dist/runtime/src/review/execute.js +20 -12
- package/dist/runtime/src/review/packet.js +20 -7
- package/dist/runtime/src/review/probe.js +3 -2
- package/dist/runtime/src/review/runner.js +26 -3
- package/dist/runtime/src/review/slots.js +1 -1
- package/dist/runtime/src/run/controls.js +99 -1
- package/dist/runtime/src/run/hosts/codex.js +28 -1
- package/dist/runtime/src/run/hosts/protocol.js +5 -2
- package/dist/runtime/src/run/integration.js +35 -13
- package/dist/runtime/src/run/policy.js +65 -10
- package/dist/runtime/src/run/scheduler.js +10 -5
- package/dist/runtime/src/run/service.js +68 -21
- package/dist/runtime/src/run/supervisor.js +11 -2
- package/dist/runtime/src/run/transport.js +1 -0
- package/dist/runtime/src/schema/validate.js +6 -1
- package/dist/runtime/src/security/permissions.js +85 -0
- package/dist/runtime/src/surface/discovery.js +79 -2
- package/dist/runtime/src/surface/schema.js +43 -2
- package/dist/runtime/src/task/completion.js +20 -0
- package/dist/runtime/src/task/contract.js +3 -1
- package/dist/runtime/src/task/debug-state.js +9 -3
- package/dist/runtime/src/task/debug-user-event.js +82 -17
- package/dist/runtime/src/task/debug.js +59 -16
- package/dist/runtime/src/task/service.js +58 -6
- package/dist/runtime/src/verify/acceptance-replay.js +24 -11
- package/docs/en/guides/configuration.md +24 -5
- package/docs/en/guides/task-debug.md +26 -2
- package/docs/en/spec/acceptance-and-evidence.md +104 -2
- package/docs/en/spec/harness-adapters.md +26 -1
- package/docs/en/spec/loop-engineering.md +34 -6
- package/docs/en/spec/review.md +6 -0
- package/docs/zh-TW/guides/configuration.md +20 -4
- package/docs/zh-TW/guides/task-debug.md +19 -2
- package/docs/zh-TW/spec/acceptance-and-evidence.md +84 -4
- package/docs/zh-TW/spec/harness-adapters.md +21 -1
- package/docs/zh-TW/spec/loop-engineering.md +9 -3
- package/docs/zh-TW/spec/review.md +5 -0
- package/package.json +1 -1
- package/schemas/task.schema.json +8 -1
|
@@ -5,6 +5,8 @@ import { sha256 } from "../fs/hash.js";
|
|
|
5
5
|
import { AgentOpsError } from "../fs/paths.js";
|
|
6
6
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
7
7
|
import { budgetExpired } from "./scheduler.js";
|
|
8
|
+
import { approvalRetryEvents } from "./controls.js";
|
|
9
|
+
import { redactSecrets } from "../security/redact.js";
|
|
8
10
|
export const RUN_PHASES = ["planning", "implementing", "verifying", "reviewing", "integrating"];
|
|
9
11
|
/** Writer slots are deliberately bounded; the coordinator may occupy one. */
|
|
10
12
|
export const MAX_ACTIVE_WORKERS = 2;
|
|
@@ -13,7 +15,7 @@ const ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
|
|
|
13
15
|
const SHA = /^[a-f0-9]{40,64}$/u;
|
|
14
16
|
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
15
17
|
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
|
16
|
-
const MAX_EVENTS = 2_000;
|
|
18
|
+
export const MAX_EVENTS = 2_000;
|
|
17
19
|
const MAX_GOAL_LENGTH = 64_000;
|
|
18
20
|
const MAX_TIME_BUDGET_MS = 24 * 60 * 60 * 1_000;
|
|
19
21
|
function invalid(message) {
|
|
@@ -101,8 +103,10 @@ export function assertRunState(value) {
|
|
|
101
103
|
state.budget.activeIntervals.some(interval => !plain(interval)))
|
|
102
104
|
return invalid("Run state contains malformed nested records.");
|
|
103
105
|
if (!RUN_ID.test(state.runId) || !SHA.test(state.goalHash) || state.goal.length > MAX_GOAL_LENGTH ||
|
|
104
|
-
sha256(state.goal) !== state.goalHash || !Number.isSafeInteger(state.budget.limitMs) ||
|
|
105
|
-
state.budget.limitMs <= 0 || state.budget.limitMs > MAX_TIME_BUDGET_MS ||
|
|
106
|
+
sha256(state.goal) !== state.goalHash || (state.budget.limitMs !== null && (!Number.isSafeInteger(state.budget.limitMs) ||
|
|
107
|
+
state.budget.limitMs <= 0 || state.budget.limitMs > MAX_TIME_BUDGET_MS)) ||
|
|
108
|
+
(state.budget.legacyLimitMs !== undefined && (!Number.isSafeInteger(state.budget.legacyLimitMs) || state.budget.legacyLimitMs <= 0 || state.budget.legacyLimitMs > MAX_TIME_BUDGET_MS)) ||
|
|
109
|
+
(state.budget.upgradedAt !== undefined && !Number.isFinite(Date.parse(state.budget.upgradedAt))) ||
|
|
106
110
|
!Number.isSafeInteger(state.budget.accumulatedMs) || state.budget.accumulatedMs < 0 ||
|
|
107
111
|
!Array.isArray(state.budget.activeIntervals) || state.budget.activeIntervals.some((interval) => !Number.isSafeInteger(interval.startMs) || interval.startMs < 0 ||
|
|
108
112
|
(interval.endMs !== null && (!Number.isSafeInteger(interval.endMs) || interval.endMs < interval.startMs))) ||
|
|
@@ -154,10 +158,8 @@ export function createRunState(input) {
|
|
|
154
158
|
if (input.jobs !== undefined && (!Number.isSafeInteger(input.jobs) || input.jobs < 1 || input.jobs > 2)) {
|
|
155
159
|
throw new AgentOpsError("RUN_JOBS_INVALID", "A run supports one or two implementation workers.");
|
|
156
160
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
throw new AgentOpsError("RUN_BUDGET_INVALID", "Run time budget must be a positive value no greater than 24 hours.");
|
|
160
|
-
}
|
|
161
|
+
if (input.timeBudgetMs !== undefined)
|
|
162
|
+
throw new AgentOpsError("RUN_TIME_BUDGET_UNSUPPORTED", "New runs have no total time cap; omit --time-budget. Command and review timeouts remain effective.");
|
|
161
163
|
const runId = input.runId ?? `run-${randomUUID().replaceAll("-", "").slice(0, 16)}`;
|
|
162
164
|
if (!RUN_ID.test(runId))
|
|
163
165
|
throw new AgentOpsError("RUN_ID_INVALID", "Run id must contain 8-64 lowercase letters, digits, and hyphens.");
|
|
@@ -186,7 +188,7 @@ export function createRunState(input) {
|
|
|
186
188
|
awaitingResume: false,
|
|
187
189
|
disableRestart: false,
|
|
188
190
|
budget: {
|
|
189
|
-
limitMs,
|
|
191
|
+
limitMs: null,
|
|
190
192
|
activeIntervals: [],
|
|
191
193
|
accumulatedMs: 0,
|
|
192
194
|
startedAt: iso(now, "budget.startedAt"),
|
|
@@ -315,6 +317,48 @@ export class FileRunRepository {
|
|
|
315
317
|
});
|
|
316
318
|
}
|
|
317
319
|
}
|
|
320
|
+
const RECOVERABLE = new Set(["RUN_RECOVERY_DIRTY", "RUN_NATIVE_VERSION_CHANGED", "RUN_RUNTIME_CHANGED", "RUN_REPO_UNTRUSTED",
|
|
321
|
+
"RUN_NATIVE_UNAVAILABLE", "RUN_RESTART_STORM", "RUN_POLICY_RECOVERY_REQUIRED", "RUN_POLICY_EXPIRED", "RUN_POLICY_DISABLED", "NATIVE_GOAL_NOT_ACTIVE", "RUN_WORKER_STALE", "RUN_IDENTITY_INVALID", "RUN_TIME_BUDGET_EXCEEDED", "RUN_SETUP_RECOVERY_REQUIRED", "RUN_NATIVE_START_FAILED"]);
|
|
322
|
+
function resumeBlocker(current, acknowledged = new Set()) {
|
|
323
|
+
return [...current.events].reverse().find(e => !acknowledged.has(e.id) && (RECOVERABLE.has(e.code) || ["RUN_NO_PROGRESS_REPEAT", "RUN_COMMAND_DENIED"].includes(e.code)));
|
|
324
|
+
}
|
|
325
|
+
function cannotResume(current, acknowledged = new Set()) {
|
|
326
|
+
return current.status === "complete" || current.events.some(e => e.code === "RUN_COMMAND_DENIED" && !acknowledged.has(e.id)) ||
|
|
327
|
+
(current.integration?.proofDigest === undefined && (current.workers.some(w => w.workerId === current.coordinatorId && w.status === "blocked" && w.lastFailure !== null && w.lastFailure.failureClass !== "native-start") ||
|
|
328
|
+
(current.status === "blocked" && !RECOVERABLE.has(resumeBlocker(current, acknowledged)?.code ?? ""))));
|
|
329
|
+
}
|
|
330
|
+
/** Observations are not proof validation; resume still reconciles every authority and sealed artifact. */
|
|
331
|
+
export function describeRunRecovery(state, now = Date.now()) {
|
|
332
|
+
const diagnostics = [...state.events].reverse().filter(e => e.type === "diagnostic");
|
|
333
|
+
const event = diagnostics[0]?.code === "RUN_CLEANUP_UNCHANGED"
|
|
334
|
+
? diagnostics.find(e => e.code === "RUN_CLEANUP_BLOCKED") ?? diagnostics[0] : diagnostics[0];
|
|
335
|
+
let cause = event === undefined ? null : { code: event.code, detail: redactSecrets(event.detail ?? "").slice(0, 4096) };
|
|
336
|
+
if (event?.code === "RUN_CLEANUP_BLOCKED") {
|
|
337
|
+
try {
|
|
338
|
+
const saved = JSON.parse(event.detail ?? "{}");
|
|
339
|
+
if (typeof saved.code === "string" && typeof saved.message === "string")
|
|
340
|
+
cause = { code: saved.code, detail: redactSecrets(saved.message).slice(0, 4096) };
|
|
341
|
+
}
|
|
342
|
+
catch { /* Retain the recorded diagnostic when old metadata cannot be decoded. */ }
|
|
343
|
+
}
|
|
344
|
+
const sealedProof = state.integration?.proofDigest !== undefined;
|
|
345
|
+
const cleanup = state.integration?.status === "cleaned" ? "complete" :
|
|
346
|
+
state.integration?.status === "receipt-written" ? "pending" : "not-ready";
|
|
347
|
+
const resumable = !cannotResume(state);
|
|
348
|
+
const code = cause?.code ?? "";
|
|
349
|
+
const remediation = state.status === "complete" ? "No action: the completed run and its receipt are immutable." :
|
|
350
|
+
state.events.some(e => e.code === "RUN_COMMAND_DENIED") ? "Obtain genuine new authorization for the recorded denial; resume cannot erase it." :
|
|
351
|
+
/TRUST/u.test(code) ? "Restore repository trust through the supported authorization process, then manually resume; trust is revalidated." :
|
|
352
|
+
/IDENTITY|GENERATION|STALE/u.test(code) ? "Confirm the old writer is dead and reconcile its owned worktree, native identity and current generation; never replay an old request." :
|
|
353
|
+
/PROOF|RECEIPT|TARGET_CHANGED|OWNERSHIP/u.test(code) ? "Reconcile the sealed proof, receipt, target and owner mismatch; preserve the checkout and never replace sealed artifacts." :
|
|
354
|
+
/NO_PROGRESS/u.test(code) ? "Inspect the saved failure history; ordinary resume cannot reset no-progress counters." :
|
|
355
|
+
cleanup === "pending" ? "Resolve the recorded cleanup blocker, then manually resume the original run. Only missing steps run after proof, receipt, target and ownership validation; unchanged cleanup inputs are refused." :
|
|
356
|
+
RECOVERABLE.has(code) ? `Resolve ${code}, then manually resume the original run; runtime, policy, identity and worktree checks still apply.` :
|
|
357
|
+
state.status === "awaiting-input" ? "Answer the pending question with agent-ops run respond." :
|
|
358
|
+
!resumable ? "Inspect agent-ops run logs and repair the recorded blocker before requesting recovery; do not blindly resume." :
|
|
359
|
+
"Inspect agent-ops run logs before a manual resume; reconciliation precedes any writer restart.";
|
|
360
|
+
return { phase: state.phase ?? "unknown", cause, sealedProof: sealedProof ? "recorded-unchecked" : "none", cleanup, resumable, remediation };
|
|
361
|
+
}
|
|
318
362
|
/**
|
|
319
363
|
* Small service facade used by the CLI and supervisor. Native host execution is
|
|
320
364
|
* injected; this layer owns persistent run identity and never treats a host's
|
|
@@ -374,29 +418,32 @@ export class RunService {
|
|
|
374
418
|
}));
|
|
375
419
|
return { state, message: `Run ${runId} is paused.` };
|
|
376
420
|
}
|
|
377
|
-
async resume(runId) {
|
|
378
|
-
const state = await this.#repository.mutate(runId, (current) => {
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
const blocker =
|
|
382
|
-
|
|
383
|
-
current.workers.some(w => w.workerId === current.coordinatorId && w.status === "blocked" && w.lastFailure !== null && w.lastFailure.failureClass !== "native-start");
|
|
384
|
-
if (current.status === "complete" || (current.integration?.proofDigest === undefined &&
|
|
385
|
-
(terminalBlocker || (current.status === "blocked" && !recoverable.has(blocker?.code ?? ""))))) {
|
|
421
|
+
async resume(runId, retryApprovalIds = [], upgradeBudget = true) {
|
|
422
|
+
const state = await this.#repository.mutate(runId, async (current) => {
|
|
423
|
+
const acknowledged = await approvalRetryEvents(current, retryApprovalIds);
|
|
424
|
+
const recoverable = RECOVERABLE;
|
|
425
|
+
const blocker = resumeBlocker(current, acknowledged);
|
|
426
|
+
if (cannotResume(current, acknowledged)) {
|
|
386
427
|
throw new AgentOpsError("RUN_NOT_RESUMABLE", `Run ${runId} is ${current.status}.`);
|
|
387
428
|
}
|
|
388
|
-
if (current.integration?.proofDigest === undefined && budgetExpired(current, Date.parse(this.#now())))
|
|
429
|
+
if (current.integration?.proofDigest === undefined && !upgradeBudget && budgetExpired(current, Date.parse(this.#now())))
|
|
389
430
|
throw new AgentOpsError("RUN_BUDGET_EXHAUSTED", "Explicit resume preserves the whole-run budget; no execution allowance remains.");
|
|
390
|
-
const recover = ["blocked", "paused"].includes(current.status) && recoverable.has(blocker?.code ?? "");
|
|
431
|
+
const recover = ["blocked", "paused", "budget-limited"].includes(current.status) && recoverable.has(blocker?.code ?? "");
|
|
391
432
|
return {
|
|
392
433
|
...current,
|
|
393
|
-
...(
|
|
434
|
+
...(upgradeBudget && current.budget.limitMs !== null ? {
|
|
435
|
+
budget: { ...current.budget, limitMs: null, legacyLimitMs: current.budget.limitMs, upgradedAt: this.#now() },
|
|
436
|
+
events: [...current.events, event({ type: "diagnostic", code: "RUN_BUDGET_UPGRADED", workerId: null, taskId: null,
|
|
437
|
+
detail: `Manual resume removed legacy total cap ${current.budget.limitMs}ms; accounting and independent blockers remain.` }, this.#now())].slice(-MAX_EVENTS)
|
|
438
|
+
} : {}),
|
|
439
|
+
...(recover ? { workers: current.workers.map(w => w.status === "blocked" && (w.lastFailure === null || w.lastFailure.failureClass === "native-start") &&
|
|
440
|
+
!current.tasks.some(t => t.taskId === w.taskId && t.status === "blocked" && !recoverable.has(t.blockedReason ?? "")) ? { ...w, status: "stopped" } : w),
|
|
394
441
|
tasks: current.tasks.map(t => t.status === "blocked" && recoverable.has(t.blockedReason ?? "") ? { ...t, status: "ready", blockedReason: null } : t) } : {}),
|
|
395
442
|
status: "active",
|
|
396
443
|
awaitingResume: false,
|
|
397
444
|
disableRestart: false,
|
|
398
445
|
controls: [...current.controls, {
|
|
399
|
-
action: "resume", actor: "user", reason: null, requestedAt: this.#now(), expiresAt: null, appliedAt: null
|
|
446
|
+
action: "resume", actor: "user", reason: retryApprovalIds.length === 0 ? null : "retry-approval:" + JSON.stringify(retryApprovalIds), requestedAt: this.#now(), expiresAt: null, appliedAt: null
|
|
400
447
|
}]
|
|
401
448
|
};
|
|
402
449
|
});
|
|
@@ -109,13 +109,17 @@ export class RunSupervisor {
|
|
|
109
109
|
throw error("RUN_WORKER_NOT_FOUND", `Registered worker disappeared: ${workerId}`);
|
|
110
110
|
return { workerId, taskId: input.taskId, ownerSessionId: input.ownerSessionId, generation: worker.generation, worktree: worker.worktree };
|
|
111
111
|
}
|
|
112
|
-
async startWorker(runId, workerId, generation, goal) {
|
|
112
|
+
async startWorker(runId, workerId, generation, goal, manualResumeAt) {
|
|
113
113
|
assertId(workerId, "workerId");
|
|
114
114
|
if (!Number.isSafeInteger(generation) || generation < 1)
|
|
115
115
|
throw error("RUN_GENERATION_INVALID", "Worker generation is invalid.");
|
|
116
116
|
const before = await this.#repository.read(runId);
|
|
117
117
|
if (before === null)
|
|
118
118
|
throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
119
|
+
const control = before.controls.at(-1);
|
|
120
|
+
if (manualResumeAt !== undefined && (control?.actor !== "user" || control.action !== "resume" ||
|
|
121
|
+
control.requestedAt !== manualResumeAt || !Number.isFinite(Date.parse(manualResumeAt)) || Date.parse(manualResumeAt) > Date.parse(this.#now())))
|
|
122
|
+
throw error("RUN_WORKER_STALE", "Native recovery requires the current explicit manual resume.");
|
|
119
123
|
if (before.host !== this.#host.host)
|
|
120
124
|
throw error("RUN_HOST_MISMATCH", `Run ${runId} is assigned to ${before.host}, not ${this.#host.host}.`);
|
|
121
125
|
const worker = before.workers.find((candidate) => candidate.workerId === workerId);
|
|
@@ -126,12 +130,16 @@ export class RunSupervisor {
|
|
|
126
130
|
const starting = await this.#repository.mutate(runId, (current) => {
|
|
127
131
|
if (current.status !== "active" || current.disableRestart || current.awaitingResume)
|
|
128
132
|
throw error("RUN_START_DISABLED", "Run state no longer permits starting a writer.");
|
|
133
|
+
if (manualResumeAt !== undefined && current.controls.at(-1)?.requestedAt !== manualResumeAt)
|
|
134
|
+
throw error("RUN_WORKER_STALE", "Manual recovery changed before native start.");
|
|
129
135
|
if (budgetExpired(current, Date.parse(this.#now())))
|
|
130
136
|
throw error("RUN_BUDGET_EXHAUSTED", "The cumulative run budget cannot be reset by starting another worker.");
|
|
131
137
|
const currentWorker = current.workers.find((candidate) => candidate.workerId === workerId);
|
|
132
138
|
if (currentWorker === undefined || currentWorker.generation !== generation || currentWorker.status !== "assigned") {
|
|
133
139
|
throw error("RUN_WORKER_STALE", `Worker ${workerId} changed before native start.`);
|
|
134
140
|
}
|
|
141
|
+
if (current.workers.filter(candidate => candidate.workerId !== workerId && activeWorker(candidate)).length >= current.jobs)
|
|
142
|
+
throw error("RUN_WORKER_LIMIT", "No native execution slot is available.");
|
|
135
143
|
return replaceWorker(replaceTask(current, currentWorker.taskId, { status: "running" }), {
|
|
136
144
|
...currentWorker,
|
|
137
145
|
status: "starting",
|
|
@@ -150,7 +158,8 @@ export class RunSupervisor {
|
|
|
150
158
|
ownerSessionId: startingWorker.ownerSessionId,
|
|
151
159
|
generation,
|
|
152
160
|
worktree: startingWorker.worktree,
|
|
153
|
-
contractHash: before.currentContractHash
|
|
161
|
+
contractHash: before.currentContractHash,
|
|
162
|
+
...(manualResumeAt === undefined ? {} : { manualResumeAt })
|
|
154
163
|
});
|
|
155
164
|
}
|
|
156
165
|
catch (cause) {
|
|
@@ -38,6 +38,7 @@ export class NativeRunTransport {
|
|
|
38
38
|
const saved = (await this.repository.read(input.runId))?.workers.find(w => w.workerId === input.workerId);
|
|
39
39
|
const request = { runId: input.runId, workerId: input.workerId, generation: input.generation,
|
|
40
40
|
contractHash: input.contractHash, cwd: input.worktree,
|
|
41
|
+
...(input.manualResumeAt === undefined ? {} : { manualResumeAt: input.manualResumeAt }),
|
|
41
42
|
env: { AGENT_OPS_SESSION_ID: input.ownerSessionId, AGENT_OPS_HOST: this.host,
|
|
42
43
|
CODEX_THREAD_ID: undefined, AGENT_OPS_AGENT_ID: undefined,
|
|
43
44
|
AGENT_OPS_RUN_ID: input.runId, AGENT_OPS_WORKER_ID: input.workerId,
|
|
@@ -2,6 +2,7 @@ import { validDebugSettings, validDebugIntake } from "../task/debug-state.js";
|
|
|
2
2
|
import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
3
3
|
import { acceptanceError, runnerError, commitIdentity } from "./acceptance.js";
|
|
4
4
|
import { parseSurface } from "../surface/schema.js";
|
|
5
|
+
import { validOrigin } from "../escape/origin.js";
|
|
5
6
|
const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
|
|
6
7
|
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
7
8
|
const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)?$/i;
|
|
@@ -571,7 +572,7 @@ export function validateCriterion(value, path) {
|
|
|
571
572
|
return success(value);
|
|
572
573
|
}
|
|
573
574
|
export function validateTask(value) {
|
|
574
|
-
const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title", "debug", "surface", "surfaceRequired"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
|
|
575
|
+
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);
|
|
575
576
|
if (isFailure(root)) {
|
|
576
577
|
return root;
|
|
577
578
|
}
|
|
@@ -587,6 +588,10 @@ export function validateTask(value) {
|
|
|
587
588
|
return failure("INVALID_SURFACE", "$.surface", "Task surface is invalid.");
|
|
588
589
|
}
|
|
589
590
|
}
|
|
591
|
+
if (root.origin !== undefined && !validOrigin(root.origin))
|
|
592
|
+
return failure("INVALID_ORIGIN", "$.origin", "origin is escape-of {commit, label, receipt?} or pre-existing/external {reason}.");
|
|
593
|
+
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.");
|
|
590
595
|
if (root.contractRevision !== undefined && (!Number.isSafeInteger(root.contractRevision) || root.contractRevision < 0))
|
|
591
596
|
return failure("INVALID_CONTRACT_REVISION", "$.contractRevision", "Invalid contract revision.");
|
|
592
597
|
if (root.goal !== undefined && (!isNonEmptyString(root.goal) || Buffer.byteLength(root.goal, "utf8") > 65536))
|
|
@@ -37,6 +37,7 @@ const execFile = promisify(execFileCallback);
|
|
|
37
37
|
const lockQueues = new Map();
|
|
38
38
|
const heartbeatFailures = new Map();
|
|
39
39
|
const heartbeatPaths = new Set();
|
|
40
|
+
const heartbeatReleases = new Map();
|
|
40
41
|
// A heartbeat lives as long as the process. Its handle is held here, not only
|
|
41
42
|
// by the interval below, so a reset or mocked timer can never let garbage
|
|
42
43
|
// collection close it (an uncaught error since Node 26).
|
|
@@ -172,6 +173,14 @@ async function ensureProcessHeartbeat(parent, anchorDirectory) {
|
|
|
172
173
|
});
|
|
173
174
|
});
|
|
174
175
|
}, HEARTBEAT_INTERVAL_MS);
|
|
176
|
+
heartbeatReleases.set(heartbeatPath, async () => {
|
|
177
|
+
clearInterval(timer);
|
|
178
|
+
await handle.close();
|
|
179
|
+
heartbeatHandles.delete(handle);
|
|
180
|
+
heartbeatPaths.delete(heartbeatPath);
|
|
181
|
+
processHeartbeats.delete(cacheKey);
|
|
182
|
+
heartbeatReleases.delete(heartbeatPath);
|
|
183
|
+
});
|
|
175
184
|
timer.unref();
|
|
176
185
|
return heartbeat;
|
|
177
186
|
})();
|
|
@@ -184,6 +193,82 @@ async function ensureProcessHeartbeat(parent, anchorDirectory) {
|
|
|
184
193
|
throw error;
|
|
185
194
|
}
|
|
186
195
|
}
|
|
196
|
+
async function fileIdentity(path) {
|
|
197
|
+
try {
|
|
198
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
199
|
+
try {
|
|
200
|
+
const { dev, ino } = await handle.stat({ bigint: true });
|
|
201
|
+
return { dev, ino };
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await handle.close();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Retire authenticated runtime markers without weakening Git's dirty-file guard. */
|
|
212
|
+
export async function cleanupWorktreeHeartbeats(root, paths) {
|
|
213
|
+
const candidates = [];
|
|
214
|
+
for (const relativePath of paths) {
|
|
215
|
+
const match = /^\.(?:claude|codex)\/\.agent-ops-process-([1-9]\d*)-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/u.exec(relativePath);
|
|
216
|
+
if (match === null)
|
|
217
|
+
continue;
|
|
218
|
+
const path = join(root, relativePath), pid = Number(match[1]);
|
|
219
|
+
const unsafe = () => new AgentOpsError("WORKTREE_HEARTBEAT_UNSAFE", `Preserve unverified heartbeat ${relativePath} (PID ${pid}).`);
|
|
220
|
+
const blocked = () => new AgentOpsError("WORKTREE_HEARTBEAT_ACTIVE", `Preserve heartbeat ${relativePath}: PID ${pid} is active or its death cannot be confirmed.`);
|
|
221
|
+
if (!Number.isSafeInteger(pid) || pid > 2147483647)
|
|
222
|
+
throw unsafe();
|
|
223
|
+
const parent = await lstat(dirname(path));
|
|
224
|
+
if (parent.isSymbolicLink() || !parent.isDirectory())
|
|
225
|
+
throw unsafe();
|
|
226
|
+
let handle;
|
|
227
|
+
try {
|
|
228
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
229
|
+
const status = await handle.stat({ bigint: true });
|
|
230
|
+
if (!status.isFile() || status.nlink !== 1n || status.size > 128n ||
|
|
231
|
+
await handle.readFile("utf8") !== `runtime:${match[2]}\n`)
|
|
232
|
+
throw unsafe();
|
|
233
|
+
const own = pid === process.pid && match[2] === PROCESS_INSTANCE_TOKEN && heartbeatReleases.has(path);
|
|
234
|
+
if (own) {
|
|
235
|
+
if ([...lockQueues.keys()].some(key => key.startsWith(`${resolve(root)}\0`)))
|
|
236
|
+
throw blocked();
|
|
237
|
+
}
|
|
238
|
+
else if (isProcessActive(pid))
|
|
239
|
+
throw blocked();
|
|
240
|
+
candidates.push({ path, relative: relativePath, pid, own, dev: status.dev, ino: status.ino });
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (isMissing(error))
|
|
244
|
+
continue;
|
|
245
|
+
if (error instanceof AgentOpsError)
|
|
246
|
+
throw error;
|
|
247
|
+
throw unsafe();
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await handle?.close();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Validate the complete set before removing anything; foreign owners fail closed.
|
|
254
|
+
for (const candidate of candidates) {
|
|
255
|
+
const status = await lstat(candidate.path, { bigint: true });
|
|
256
|
+
// Identity is compared handle to handle, as it was captured: on Windows, Node 22.14.0
|
|
257
|
+
// reports different dev/ino for lstat and fstat of the same file.
|
|
258
|
+
const identity = await fileIdentity(candidate.path);
|
|
259
|
+
if (!status.isFile() || status.isSymbolicLink() || identity === undefined || identity.dev !== candidate.dev || identity.ino !== candidate.ino)
|
|
260
|
+
throw new AgentOpsError("WORKTREE_HEARTBEAT_UNSAFE", `Heartbeat changed before cleanup: ${candidate.relative}.`);
|
|
261
|
+
if (!candidate.own && isProcessActive(candidate.pid))
|
|
262
|
+
throw new AgentOpsError("WORKTREE_HEARTBEAT_ACTIVE", `Preserve heartbeat ${candidate.relative}: PID ${candidate.pid} is active or its death cannot be confirmed.`);
|
|
263
|
+
if (candidate.own) {
|
|
264
|
+
const release = heartbeatReleases.get(candidate.path);
|
|
265
|
+
if (release === undefined || [...lockQueues.keys()].some(key => key.startsWith(`${resolve(root)}\0`)))
|
|
266
|
+
throw new AgentOpsError("WORKTREE_HEARTBEAT_ACTIVE", `Current-process locks still protect ${candidate.relative} (PID ${candidate.pid}).`);
|
|
267
|
+
await release();
|
|
268
|
+
}
|
|
269
|
+
await rm(candidate.path);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
187
272
|
async function assertProcessHeartbeat(parent, heartbeat) {
|
|
188
273
|
const heartbeatPath = join(parent, heartbeat.file);
|
|
189
274
|
try {
|
|
@@ -3,13 +3,16 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { canonicalJson } from "../config/hash.js";
|
|
5
5
|
import { sha256 } from "../fs/hash.js";
|
|
6
|
+
import { loadRules, ruleFires, ruleHits } from "../escape/rules.js";
|
|
7
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
8
|
import { appendLocalLog } from "../logging/local-log.js";
|
|
7
9
|
import { localStatePaths, readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
8
10
|
import { globMatcher, isCovers, missingScenarios, parseSurface, SURFACE_AXES } from "./schema.js";
|
|
9
11
|
/** Cheapest first: a budget overrun stops before the long tail of diff tokens. */
|
|
10
12
|
export const DISCOVERY_SOURCES = ["agent", "hunk-header", "template-fragment", "diff-token"];
|
|
11
13
|
export const SURFACE_LIMITS = {
|
|
12
|
-
|
|
14
|
+
// ponytail: derived queries over 10 files are skipped as generic; incidental ones under that are dismissed by query.
|
|
15
|
+
agentQueryFiles: 200, derivedQueryFiles: 10, exclusionFiles: 10, dismissals: 128, dismissMinFiles: 4, budgetMs: 120_000, queryTimeoutMs: 30_000,
|
|
13
16
|
tokenLength: 6, fragmentLength: 4, derivedQueries: 400
|
|
14
17
|
};
|
|
15
18
|
/** argv only: queries are task text and must never meet a shell or a regex engine. */
|
|
@@ -84,6 +87,14 @@ async function logMetrics(home, rows) {
|
|
|
84
87
|
}
|
|
85
88
|
catch { /* advisory only */ }
|
|
86
89
|
}
|
|
90
|
+
async function logRuleMetrics(home, rows) {
|
|
91
|
+
try {
|
|
92
|
+
const state = localStatePaths(home);
|
|
93
|
+
for (const row of rows)
|
|
94
|
+
await appendLocalLog(join(state.logDirectory, "events.ndjson"), { type: "escape-rule", ...row }, { anchorDirectory: state.anchorDirectory });
|
|
95
|
+
}
|
|
96
|
+
catch { /* advisory only */ }
|
|
97
|
+
}
|
|
87
98
|
export async function logSurfaceResidualRisks(count, home = process.env.AGENT_OPS_HOME ?? homedir()) {
|
|
88
99
|
await logMetrics(home, [{ source: "review", hits: 0, included: 0, excluded: 0, postBlockExclusions: 0, residualRisks: count }]);
|
|
89
100
|
}
|
|
@@ -113,6 +124,16 @@ export async function runSurfaceGate(input) {
|
|
|
113
124
|
if (uncovered.length > 0 || unknown.length > 0)
|
|
114
125
|
return notRun("SURFACE_UNASSIGNED", "Children covers must equal the parent included surfaces.", [{ uncovered, unknown }]);
|
|
115
126
|
}
|
|
127
|
+
// Escape Loop R2: learned rules fail closed before any discovery.
|
|
128
|
+
let rules;
|
|
129
|
+
try {
|
|
130
|
+
rules = await loadRules(input.root);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error instanceof AgentOpsError && (error.code === "ESCAPE_RULE_INVALID" || error.code === "ESCAPE_RULE_UNSAFE"))
|
|
134
|
+
return notRun(error.code, error.message, []);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
116
137
|
const now = input.now ?? Date.now;
|
|
117
138
|
const started = now();
|
|
118
139
|
const budget = input.budgetMs ?? SURFACE_LIMITS.budgetMs;
|
|
@@ -186,6 +207,22 @@ export async function runSurfaceGate(input) {
|
|
|
186
207
|
const skipped = [];
|
|
187
208
|
const unclassified = new Map();
|
|
188
209
|
const metrics = new Map(DISCOVERY_SOURCES.map(source => [source, { hits: new Set(), included: new Set(), excluded: new Set() }]));
|
|
210
|
+
const dismissals = new Map((surface.dismissed ?? []).map(row => [row.query, row.reason]));
|
|
211
|
+
if (dismissals.size > SURFACE_LIMITS.dismissals)
|
|
212
|
+
return notRun("SURFACE_DISMISSAL_TOO_BROAD", `At most ${SURFACE_LIMITS.dismissals} queries may be dismissed; split the change.`, [{ dismissed: dismissals.size }]);
|
|
213
|
+
const recorded = new Set(agent);
|
|
214
|
+
const dismissed = [];
|
|
215
|
+
for (const query of dismissals.keys()) {
|
|
216
|
+
if (recorded.has(query))
|
|
217
|
+
return notRun("SURFACE_DISMISSAL_INVALID", "A recorded axis query cannot be dismissed; classify its hits.", [{ query }]);
|
|
218
|
+
const hit = hits.find(row => row.query === query && row.source !== "agent");
|
|
219
|
+
const outside = hit === undefined ? [] : hit.files.filter(path => !changed.has(path));
|
|
220
|
+
if (hit !== undefined && outside.length < SURFACE_LIMITS.dismissMinFiles && hit.files.length <= SURFACE_LIMITS.derivedQueryFiles)
|
|
221
|
+
return notRun("SURFACE_DISMISSAL_INVALID", `A query reaching fewer than ${SURFACE_LIMITS.dismissMinFiles} files outside the diff ` +
|
|
222
|
+
"is the likeliest sibling signal and cannot be dismissed; classify its files.", [{ query, files: outside }]);
|
|
223
|
+
if (hit !== undefined)
|
|
224
|
+
dismissed.push({ query, files: outside.length, postBlock: outside.some(path => blocked.includes(path)) });
|
|
225
|
+
}
|
|
189
226
|
for (const hit of hits) {
|
|
190
227
|
sources[hit.source].queries += 1;
|
|
191
228
|
if (hit.source === "agent" && hit.files.length > SURFACE_LIMITS.agentQueryFiles)
|
|
@@ -194,6 +231,8 @@ export async function runSurfaceGate(input) {
|
|
|
194
231
|
skipped.push({ source: hit.source, query: hit.query, files: hit.files.length });
|
|
195
232
|
continue;
|
|
196
233
|
}
|
|
234
|
+
if (hit.source !== "agent" && dismissals.has(hit.query))
|
|
235
|
+
continue;
|
|
197
236
|
const metric = metrics.get(hit.source);
|
|
198
237
|
for (const path of hit.files) {
|
|
199
238
|
metric.hits.add(path);
|
|
@@ -205,9 +244,47 @@ export async function runSurfaceGate(input) {
|
|
|
205
244
|
unclassified.set(path, [...(unclassified.get(path) ?? []), { source: hit.source, query: hit.query }]);
|
|
206
245
|
}
|
|
207
246
|
}
|
|
247
|
+
// A fired rule's hits must be classified like any other; noExclude hits cannot be excluded.
|
|
248
|
+
const ruleRows = [];
|
|
249
|
+
const noExclude = [];
|
|
250
|
+
for (const { rule } of rules) {
|
|
251
|
+
if (!ruleFires(rule, [...changed], diff))
|
|
252
|
+
continue;
|
|
253
|
+
if (remaining() <= 0)
|
|
254
|
+
return notRun("SURFACE_BUDGET_EXCEEDED", "Discovery budget exhausted before escape rules ran.", [{ rule: rule.id }]);
|
|
255
|
+
let paths;
|
|
256
|
+
try {
|
|
257
|
+
paths = await ruleHits(input.root, rule, undefined, Math.min(SURFACE_LIMITS.queryTimeoutMs, remaining()));
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (error instanceof AgentOpsError)
|
|
261
|
+
return notRun("SURFACE_BUDGET_EXCEEDED", error.message, [{ rule: rule.id }]);
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
const row = { ruleId: rule.id, included: 0, excluded: 0 };
|
|
265
|
+
for (const path of paths) {
|
|
266
|
+
if (changed.has(path) || included.some(match => match(path)) || unknownPaths.some(match => match(path))) {
|
|
267
|
+
row.included += 1;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const exclusion = exclusions.find(e => e.match(path));
|
|
271
|
+
if (exclusion !== undefined) {
|
|
272
|
+
row.excluded += 1;
|
|
273
|
+
if (rule.noExclude)
|
|
274
|
+
noExclude.push({ ruleId: rule.id, path, glob: exclusion.glob });
|
|
275
|
+
}
|
|
276
|
+
else
|
|
277
|
+
unclassified.set(path, [...(unclassified.get(path) ?? []), { source: "escape-rule", query: `rule:${rule.id}` }]);
|
|
278
|
+
}
|
|
279
|
+
ruleRows.push(row);
|
|
280
|
+
}
|
|
281
|
+
if (input.metrics === true)
|
|
282
|
+
await logRuleMetrics(input.logHome ?? process.env.AGENT_OPS_HOME ?? homedir(), ruleRows);
|
|
283
|
+
if (noExclude.length > 0)
|
|
284
|
+
return notRun("ESCAPE_RULE_NO_EXCLUDE", "A learned escape rule marks these places noExclude: include them or mark them UNKNOWN.", noExclude);
|
|
208
285
|
for (const source of DISCOVERY_SOURCES)
|
|
209
286
|
sources[source].files = metrics.get(source).hits.size;
|
|
210
|
-
const discovery = { cached, sources, skipped: skipped.slice(0, 50), postBlockExclusions };
|
|
287
|
+
const discovery = { cached, sources, skipped: skipped.slice(0, 50), postBlockExclusions, dismissed };
|
|
211
288
|
if (input.metrics === true)
|
|
212
289
|
await logMetrics(input.logHome ?? process.env.AGENT_OPS_HOME ?? homedir(), DISCOVERY_SOURCES.map(source => ({ source,
|
|
213
290
|
hits: metrics.get(source).hits.size, included: metrics.get(source).included.size, excluded: metrics.get(source).excluded.size,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
+
import { canonicalJson } from "../config/hash.js";
|
|
2
3
|
/** EVIDENCE-SURFACE-001: where else the change lands, recorded before criteria. */
|
|
3
4
|
export const SURFACE_AXES = ["dependents", "duplicates", "otherPaths", "sharedState"];
|
|
4
5
|
export const SCENARIO_DIMENSIONS = ["initialState", "redo", "timeBoundary", "identitySwitch", "concurrency"];
|
|
@@ -34,7 +35,7 @@ export function parseSurface(value) {
|
|
|
34
35
|
invalid("covers must list unique parent surface IDs.");
|
|
35
36
|
return { covers: [...value.covers] };
|
|
36
37
|
}
|
|
37
|
-
if (!record(value) || !exact(value, ["axes", "included", "excluded", "unknown", "contracts", "valueChanges", "scenarios"]))
|
|
38
|
+
if (!record(value) || !exact(value, ["axes", "included", "excluded", "unknown", "contracts", "valueChanges", "scenarios"], ["dismissed"]))
|
|
38
39
|
invalid("Surface file needs axes, included, excluded, unknown, contracts, valueChanges and scenarios (or covers for a child).");
|
|
39
40
|
const axes = value.axes;
|
|
40
41
|
if (!record(axes) || !exact(axes, [...SURFACE_AXES]))
|
|
@@ -97,9 +98,23 @@ export function parseSurface(value) {
|
|
|
97
98
|
else
|
|
98
99
|
invalid(`Scenario ${key} needs {applies: true, behavior} or {na: reason}.`);
|
|
99
100
|
}
|
|
101
|
+
let dismissed;
|
|
102
|
+
if (value.dismissed !== undefined) {
|
|
103
|
+
if (!list(value.dismissed, 256))
|
|
104
|
+
invalid("dismissed must be a list.");
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
dismissed = value.dismissed.map(row => {
|
|
107
|
+
if (!record(row) || !exact(row, ["query", "reason"]) || !text(row.query, 256) || /[\n\r]/u.test(row.query) || seen.has(row.query))
|
|
108
|
+
invalid("Each dismissal needs a unique single-line query and a reason.");
|
|
109
|
+
seen.add(row.query);
|
|
110
|
+
if (!reason(row.reason) || row.reason.length > 256)
|
|
111
|
+
invalid(`Dismissal ${row.query}: say in at most 256 characters why its hits are incidental; "out of scope" is not a reason.`);
|
|
112
|
+
return { query: row.query, reason: row.reason };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
100
115
|
return {
|
|
101
116
|
axes: Object.fromEntries(SURFACE_AXES.map(name => [name, axis(axes[name], name)])),
|
|
102
|
-
included, excluded, unknown, contracts, valueChanges, scenarios: parsedScenarios
|
|
117
|
+
included, excluded, unknown, contracts, valueChanges, scenarios: parsedScenarios, ...(dismissed === undefined ? {} : { dismissed })
|
|
103
118
|
};
|
|
104
119
|
}
|
|
105
120
|
export function missingScenarios(surface) {
|
|
@@ -125,3 +140,29 @@ export function globMatcher(pattern) {
|
|
|
125
140
|
const expression = new RegExp(`^${source}$`, "u");
|
|
126
141
|
return path => expression.test(path);
|
|
127
142
|
}
|
|
143
|
+
/** What one revision did to the surface: list rows added/removed, other fields before/after. Size follows the edit, not the audit. */
|
|
144
|
+
export function surfaceDelta(before, after) {
|
|
145
|
+
const key = (value) => canonicalJson(value ?? null);
|
|
146
|
+
if (key(before) === key(after))
|
|
147
|
+
return "unchanged";
|
|
148
|
+
const from = (before ?? {}), to = (after ?? {});
|
|
149
|
+
const added = {}, removed = {};
|
|
150
|
+
const changed = {};
|
|
151
|
+
for (const field of [...new Set([...Object.keys(from), ...Object.keys(to)])].sort()) {
|
|
152
|
+
const a = from[field], b = to[field];
|
|
153
|
+
if (key(a) === key(b))
|
|
154
|
+
continue;
|
|
155
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
156
|
+
const old = new Set(a.map(key)), next = new Set(b.map(key));
|
|
157
|
+
const plus = b.filter(row => !old.has(key(row))), minus = a.filter(row => !next.has(key(row)));
|
|
158
|
+
if (plus.length > 0)
|
|
159
|
+
added[field] = plus;
|
|
160
|
+
if (minus.length > 0)
|
|
161
|
+
removed[field] = minus;
|
|
162
|
+
}
|
|
163
|
+
else
|
|
164
|
+
changed[field] = { before: a ?? null, after: b ?? null };
|
|
165
|
+
}
|
|
166
|
+
return { ...(Object.keys(added).length > 0 ? { added } : {}), ...(Object.keys(removed).length > 0 ? { removed } : {}),
|
|
167
|
+
...(Object.keys(changed).length > 0 ? { changed } : {}) };
|
|
168
|
+
}
|
|
@@ -5,6 +5,8 @@ import { isPassingVerificationEvidence } from "../verify/evidence.js";
|
|
|
5
5
|
import { taskContractHash } from "./contract.js";
|
|
6
6
|
import { acceptanceCoverage, coverageDigest } from "../verify/acceptance-coverage.js";
|
|
7
7
|
import { loadDebugProof, debugProofDigest, debugRemainingMs } from "./debug-state.js";
|
|
8
|
+
import { escapeCompletionProblem } from "../escape/link.js";
|
|
9
|
+
import { RULES_DIRECTORY } from "../escape/rules.js";
|
|
8
10
|
export async function checkTaskCompletionEvidence(stored, options) {
|
|
9
11
|
const debug = stored.task.debug === undefined ? undefined : await loadDebugProof(options.root, stored.task.debug.rootTaskId);
|
|
10
12
|
if (debug?.control?.paused === true || debug?.control?.mode === "diagnose")
|
|
@@ -28,6 +30,24 @@ export async function checkTaskCompletionEvidence(stored, options) {
|
|
|
28
30
|
attestation?.debugProofs?.[stored.task.id] !== debugProofDigest(debug))
|
|
29
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." };
|
|
30
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
|
+
}
|
|
31
51
|
if (attestation === null || attestation.taskId !== stored.task.id ||
|
|
32
52
|
((stored.task.criteria.some(c => c.acceptance !== undefined) || (stored.task.contractRevision ?? 0) > 0 || stored.task.goal !== undefined) &&
|
|
33
53
|
(attestation.schemaVersion !== 3 || attestation.taskContracts?.[stored.task.id] !== taskContractHash(stored.task))) ||
|
|
@@ -11,7 +11,9 @@ export function taskContractHash(task) {
|
|
|
11
11
|
intent: task.intent ?? null, revision: task.contractRevision ?? 0,
|
|
12
12
|
criteria: task.criteria.map(criterionContractHash), ...(task.debug === undefined ? {} : { debug: task.debug }),
|
|
13
13
|
...(task.surface === undefined ? {} : { surface: sha256(canonicalJson(task.surface)) }),
|
|
14
|
-
...(task.surfaceRequired === undefined ? {} : { surfaceRequired: true })
|
|
14
|
+
...(task.surfaceRequired === undefined ? {} : { surfaceRequired: true }),
|
|
15
|
+
...(task.origin === undefined ? {} : { origin: task.origin }),
|
|
16
|
+
...(task.originRequired === undefined ? {} : { originRequired: true }) }));
|
|
15
17
|
}
|
|
16
18
|
export function treeContractHash(tasks) {
|
|
17
19
|
return sha256(canonicalJson(tasks.map(task => ({ id: task.id, hash: taskContractHash(task) }))
|
|
@@ -50,7 +50,7 @@ export function validDebugState(v) {
|
|
|
50
50
|
if (new Set(v.receipts.map(r => r.id)).size !== v.receipts.length || v.receipts.filter(r => r.round).length > v.maxRounds)
|
|
51
51
|
return false;
|
|
52
52
|
for (const g of v.grants)
|
|
53
|
-
if (!exact(g, ["eventId", "sessionId", "sourceDigest", "grantedMs"]) || !text(g.eventId) || !text(g.sessionId) || !hash(g.sourceDigest) || g.grantedMs !== DEBUG_EXTENSION_MS)
|
|
53
|
+
if (!(exact(g, ["eventId", "sessionId", "sourceDigest", "grantedMs"]) || (exact(g, ["eventId", "sessionId", "sourceDigest", "grantedMs", "spentAtGrantMs"]) && integer(g.spentAtGrantMs))) || !text(g.eventId) || !text(g.sessionId) || !hash(g.sourceDigest) || (g.grantedMs !== DEBUG_EXTENSION_MS && g.grantedMs !== null))
|
|
54
54
|
return false;
|
|
55
55
|
if (new Set(v.grants.map(g => g.eventId)).size !== v.grants.length)
|
|
56
56
|
return false;
|
|
@@ -79,10 +79,16 @@ export function createDebugState(rootTaskId, baselineCommit, settings = DEBUG_DE
|
|
|
79
79
|
hypotheses: [], receipts: [], strategy: null, grants: [], foreground: null, operation: null, checkpoint: null };
|
|
80
80
|
}
|
|
81
81
|
export function debugRounds(state) { return state.receipts.filter(r => r.round).length; }
|
|
82
|
-
export function
|
|
82
|
+
export function debugSpentMs(state, now = Date.now()) {
|
|
83
83
|
const starts = [state.foreground?.startedAt, state.operation?.startedAt].filter((startedAt) => startedAt !== undefined);
|
|
84
84
|
const active = starts.length === 0 ? 0 : Math.max(0, now - Math.min(...starts));
|
|
85
|
-
return
|
|
85
|
+
return state.consumedMs + active;
|
|
86
|
+
}
|
|
87
|
+
export function debugRemainingMs(state, now = Date.now()) {
|
|
88
|
+
if (state.grants.some(g => g.grantedMs === null))
|
|
89
|
+
return Number.POSITIVE_INFINITY;
|
|
90
|
+
const limit = state.grants.reduce((n, g) => Math.max(n, g.spentAtGrantMs ?? n) + (g.grantedMs ?? 0), state.budgetMs);
|
|
91
|
+
return Math.max(0, limit - debugSpentMs(state, now));
|
|
86
92
|
}
|
|
87
93
|
export function debugStopCause(state, now = Date.now()) {
|
|
88
94
|
if (state.control?.paused === true)
|