@kylecheng3146/agent-ops 0.10.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.
Files changed (49) hide show
  1. package/README.md +3 -2
  2. package/dist/packages/cli/src/args.js +10 -10
  3. package/dist/packages/cli/src/cli.js +14 -4
  4. package/dist/packages/cli/src/commands/advance.js +2 -1
  5. package/dist/packages/cli/src/commands/parallel.js +1 -1
  6. package/dist/packages/cli/src/commands/review.js +3 -1
  7. package/dist/packages/cli/src/commands/run.js +10 -3
  8. package/dist/packages/cli/src/context.js +22 -4
  9. package/dist/packages/cli/src/office-entry.js +8 -4
  10. package/dist/packages/cli/src/owned-run-step.js +4 -4
  11. package/dist/packages/cli/src/parallel-deps.js +0 -2
  12. package/dist/packages/cli/src/run-deps.js +12 -9
  13. package/dist/packages/cli/src/run-entry.js +76 -17
  14. package/dist/packages/cli/src/run-setup-entry.js +2 -2
  15. package/dist/runtime/src/install/harness.js +5 -2
  16. package/dist/runtime/src/office/scene.js +1 -1
  17. package/dist/runtime/src/office/server.js +43 -16
  18. package/dist/runtime/src/office/snapshot.js +1 -1
  19. package/dist/runtime/src/parallel/finish.js +139 -65
  20. package/dist/runtime/src/parallel/service.js +5 -1
  21. package/dist/runtime/src/review/execute.js +9 -7
  22. package/dist/runtime/src/review/packet.js +17 -6
  23. package/dist/runtime/src/review/runner.js +2 -0
  24. package/dist/runtime/src/run/controls.js +99 -1
  25. package/dist/runtime/src/run/hosts/codex.js +28 -1
  26. package/dist/runtime/src/run/hosts/protocol.js +5 -2
  27. package/dist/runtime/src/run/integration.js +35 -13
  28. package/dist/runtime/src/run/policy.js +65 -10
  29. package/dist/runtime/src/run/scheduler.js +10 -5
  30. package/dist/runtime/src/run/service.js +68 -21
  31. package/dist/runtime/src/run/supervisor.js +11 -2
  32. package/dist/runtime/src/run/transport.js +1 -0
  33. package/dist/runtime/src/security/permissions.js +85 -0
  34. package/dist/runtime/src/task/debug-state.js +9 -3
  35. package/dist/runtime/src/task/debug-user-event.js +82 -17
  36. package/dist/runtime/src/task/debug.js +35 -15
  37. package/docs/en/guides/configuration.md +8 -4
  38. package/docs/en/guides/task-debug.md +14 -2
  39. package/docs/en/spec/acceptance-and-evidence.md +4 -0
  40. package/docs/en/spec/harness-adapters.md +26 -1
  41. package/docs/en/spec/loop-engineering.md +34 -6
  42. package/docs/en/spec/review.md +6 -0
  43. package/docs/zh-TW/guides/configuration.md +6 -3
  44. package/docs/zh-TW/guides/task-debug.md +9 -2
  45. package/docs/zh-TW/spec/acceptance-and-evidence.md +2 -1
  46. package/docs/zh-TW/spec/harness-adapters.md +21 -1
  47. package/docs/zh-TW/spec/loop-engineering.md +9 -3
  48. package/docs/zh-TW/spec/review.md +5 -0
  49. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { canonicalJson } from "../config/hash.js";
4
4
  import { sha256 } from "../fs/hash.js";
5
5
  import { AgentOpsError } from "../fs/paths.js";
6
6
  import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
7
+ import { parseWorktreeListPorcelain, readWorktreeRecord } from "../parallel/service.js";
7
8
  import { FileRunRepository } from "./service.js";
8
9
  function ledger(commonDir) { return new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir); }
9
10
  function proofPath(commonDir, runId) { return join(commonDir, "agent-ops", "runs", runId, "integration-proof.json"); }
@@ -84,6 +85,11 @@ export async function readRunIntegrationProof(commonDir, record) {
84
85
  return null;
85
86
  if (state.ownerSessionId !== record.sessionId || state.coordinatorId !== record.workerId)
86
87
  throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "Recovery coordinator does not match the sealed transaction.");
88
+ const coordinator = state.workers.find(w => w.workerId === state.coordinatorId);
89
+ if (record.mainRoot !== state.root || record.ownerSessionId !== state.ownerSessionId ||
90
+ (state.workers.length > 0 && (coordinator === undefined || coordinator.worktree !== record.path ||
91
+ coordinator.generation !== record.workerGeneration || coordinator.ownerSessionId !== record.ownerSessionId)))
92
+ throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "Recovery requires the current coordinator worktree owner and generation.");
87
93
  return await readSealedProof(commonDir, record.runId, state.integration);
88
94
  }
89
95
  /** A failed pre-merge CAS may be retried only after archiving the unused sealed transaction. */
@@ -133,17 +139,15 @@ export async function markRunIntegration(commonDir, record, status, step, receip
133
139
  await ledger(commonDir).mutate(record.runId, current => {
134
140
  if (current.integration === null || current.ownerSessionId !== record.sessionId || current.coordinatorId !== record.workerId)
135
141
  throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "The integration transaction is missing or belongs to another coordinator.");
142
+ if (current.status !== "active" || current.disableRestart || current.awaitingResume)
143
+ throw new AgentOpsError("RUN_RESUME_DISABLED", "A paused, stopped or disabled run cannot advance integration steps.");
136
144
  return { ...current, integration: { ...current.integration, status, updatedAt: new Date().toISOString(),
137
145
  completedSteps: [...new Set([...current.integration.completedSteps, step])],
138
146
  ...(receipt === undefined ? {} : { receiptPath: receipt.path, receiptDigest: receipt.digest }) } };
139
147
  });
140
148
  }
141
- /**
142
- * Finish bookkeeping after a coordinator checkout vanished. This only verifies
143
- * sealed artifacts and advances the ledger; it never recreates a checkout or
144
- * starts a native writer.
145
- */
146
- export async function recoverRunIntegrationAfterCleanup(commonDir, runId, git) {
149
+ /** Validate the sealed transaction before any remaining cleanup or final bookkeeping. */
150
+ export async function validateRunIntegrationCleanup(commonDir, runId, git, allowMissingNote = false) {
147
151
  const repository = ledger(commonDir);
148
152
  const state = await repository.read(runId);
149
153
  if (state === null || state.integration === null)
@@ -154,7 +158,7 @@ export async function recoverRunIntegrationAfterCleanup(commonDir, runId, git) {
154
158
  await readSealedProof(commonDir, runId, integration);
155
159
  if (integration.status !== "cleaned" &&
156
160
  (integration.status !== "receipt-written" || !integration.completedSteps.includes("receipt") ||
157
- !integration.completedSteps.includes("note"))) {
161
+ !integration.completedSteps.includes("target") || !integration.completedSteps.includes("tasks"))) {
158
162
  throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Recovery requires a recorded receipt and Git note before cleanup can be completed.");
159
163
  }
160
164
  if (integration.receiptPath === null || integration.receiptDigest === null)
@@ -177,9 +181,10 @@ export async function recoverRunIntegrationAfterCleanup(commonDir, runId, git) {
177
181
  validateRunIntegrationReceipt(integration, receipt);
178
182
  const note = await git(state.root, ["notes", "--ref=agent-ops", "show", integration.candidate]);
179
183
  const noteText = note.stdout;
180
- if (note.exitCode !== 0 || !noteText.includes(`session: ${state.ownerSessionId}`) ||
181
- !noteText.includes(`receipt: ${integration.receiptPath}`) ||
182
- !noteText.includes(`receipt-sha256: ${integration.receiptDigest}`)) {
184
+ const noteBound = note.exitCode === 0 && noteText.split("\n").includes(`session: ${state.ownerSessionId}`) &&
185
+ noteText.split("\n").includes(`receipt: ${integration.receiptPath}`) &&
186
+ noteText.split("\n").includes(`receipt-sha256: ${integration.receiptDigest}`);
187
+ if (!noteBound && !(allowMissingNote && !integration.completedSteps.includes("note") && note.exitCode === 1)) {
183
188
  throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The sealed receipt is not bound by the Git note for the candidate.");
184
189
  }
185
190
  const target = await git(state.root, ["rev-parse", state.targetBranch + "^{commit}"]);
@@ -189,10 +194,27 @@ export async function recoverRunIntegrationAfterCleanup(commonDir, runId, git) {
189
194
  (await git(state.root, ["merge-base", "--is-ancestor", integration.candidate, target.stdout.trim()])).exitCode === 0;
190
195
  if (!exact && !historic)
191
196
  throw new AgentOpsError("RUN_INTEGRATION_TARGET_CHANGED", "The target is inconsistent with the sealed, receipt-bound integration history.");
192
- await repository.mutate(runId, current => {
193
- if (current.integration === null || current.integration.transactionId !== integration.transactionId ||
194
- current.integration.candidate !== integration.candidate || current.integration.expectedTarget !== integration.expectedTarget)
197
+ return { state, integration, receipt: receipt, noteBound, observedTarget: target.stdout.trim() };
198
+ }
199
+ /** After owned checkouts vanish, advance bookkeeping without executing product proof or native writers. */
200
+ export async function recoverRunIntegrationAfterCleanup(commonDir, runId, git) {
201
+ const { state, integration, observedTarget } = await validateRunIntegrationCleanup(commonDir, runId, git);
202
+ const listing = await git(state.root, ["worktree", "list", "--porcelain"]);
203
+ if (listing.exitCode !== 0)
204
+ throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Cannot confirm that integration checkouts were cleaned.");
205
+ for (const checkout of parseWorktreeListPorcelain(listing.stdout)) {
206
+ const record = await readWorktreeRecord(checkout.path);
207
+ if (record?.runId === runId || state.workers.some(worker => worker.worktree === checkout.path))
208
+ throw new AgentOpsError("RUN_INTEGRATION_CLEANUP_PENDING", "An owned integration checkout still exists; finish its missing cleanup steps before marking the run cleaned.");
209
+ }
210
+ const repository = ledger(commonDir);
211
+ await repository.mutate(runId, async (current) => {
212
+ const target = await git(state.root, ["rev-parse", state.targetBranch + "^{commit}"]);
213
+ if (current.revision !== state.revision || canonicalJson(current.integration) !== canonicalJson(integration) ||
214
+ target.exitCode !== 0 || target.stdout.trim() !== observedTarget)
195
215
  throw new AgentOpsError("RUN_INTEGRATION_STALE", "The integration changed during recovery.");
216
+ if (current.integration === null)
217
+ throw new AgentOpsError("RUN_INTEGRATION_REQUIRED", "The integration disappeared during recovery.");
196
218
  if (current.integration.status === "cleaned")
197
219
  return current;
198
220
  if (current.integration.status !== "receipt-written")
@@ -9,7 +9,9 @@ import { evaluateGuardrail } from "../guardrails/evaluate.js";
9
9
  import { validateConfig } from "../schema/validate.js";
10
10
  import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
11
11
  import { redactSecrets } from "../security/redact.js";
12
- import { activeWallTimeMs } from "./scheduler.js";
12
+ import { remainingRunTimeMs } from "./scheduler.js";
13
+ import { MAX_EVENTS } from "./service.js";
14
+ export const RUN_POLICY_TTL_MS = 60 * 60 * 1000;
13
15
  function fail(code, message) { throw new AgentOpsError(code, message); }
14
16
  const plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15
17
  function validApprovals(value) {
@@ -62,13 +64,14 @@ export async function runPolicyAssessment(state, policy, capabilityId) {
62
64
  const assessment = current.approvals.find(a => a.capabilityId === capabilityId);
63
65
  if (assessment !== undefined && canonicalJson(capabilities(current.config).get(capabilityId) ?? null) === canonicalJson(definition))
64
66
  return assessment;
65
- if (current.previousDigest === null)
67
+ const digest = current.assessmentDigest ?? current.previousDigest;
68
+ if (digest === null)
66
69
  break;
67
- const source = await readPrivateFile(join(state.commonDir, "agent-ops/runs", state.runId, "policies", current.previousDigest + ".json"), state.commonDir);
68
- if (source === null || sha256(source) !== current.previousDigest)
70
+ const source = await readPrivateFile(join(state.commonDir, "agent-ops/runs", state.runId, "policies", digest + ".json"), state.commonDir);
71
+ if (source === null || sha256(source) !== digest)
69
72
  fail("RUN_POLICY_CHANGED", "Historical capability assessment is missing or changed.");
70
73
  const previous = JSON.parse(source);
71
- current = (await readRunPolicy({ ...state, policyBinding: { artifactDigest: current.previousDigest, configHash: calculateConfigHash(previous.config),
74
+ current = (await readRunPolicy({ ...state, policyBinding: { artifactDigest: digest, configHash: calculateConfigHash(previous.config),
72
75
  runtimeHash: previous.runtimeHash, expiresAt: previous.expiresAt } }, previous.runtimeHash, Date.now(), true));
73
76
  }
74
77
  return fail("RUN_POLICY_APPROVAL_REQUIRED", "The current capability lacks a matching historical run assessment.");
@@ -107,6 +110,8 @@ export function validateRunPolicyChange(previous, candidate, approvals) {
107
110
  return config;
108
111
  }
109
112
  export async function readRunPolicy(state, runtimeHash, now = Date.now(), allowExpired = false) {
113
+ if (!Number.isSafeInteger(now) || now < 0 || now > 8.64e15 - RUN_POLICY_TTL_MS)
114
+ fail("RUN_CLOCK_INVALID", "Policy clock must be a valid nonnegative timestamp with room for a finite grant.");
110
115
  const binding = state.policyBinding;
111
116
  if (binding === undefined)
112
117
  return null;
@@ -121,11 +126,19 @@ export async function readRunPolicy(state, runtimeHash, now = Date.now(), allowE
121
126
  catch {
122
127
  return fail("RUN_POLICY_INVALID", "Run policy JSON is invalid.");
123
128
  }
124
- if (!plain(parsed) || Object.keys(parsed).some(key => !["schemaVersion", "runId", "config", "baseConfig", "baseTrustBinding", "runtimeHash", "expiresAt", "approvals", "previousDigest"].includes(key)) ||
129
+ if (!plain(parsed) || Object.keys(parsed).some(key => !["schemaVersion", "runId", "config", "baseConfig", "baseTrustBinding", "runtimeHash", "expiresAt", "issuedAt", "approvals", "previousDigest", "assessmentDigest"].includes(key)) ||
125
130
  !validBaseBinding(parsed.baseTrustBinding, state.root) || !validApprovals(parsed.approvals) ||
126
- !(parsed.previousDigest === null || (typeof parsed.previousDigest === "string" && /^[a-f0-9]{64}$/u.test(parsed.previousDigest))))
131
+ !(parsed.previousDigest === null || (typeof parsed.previousDigest === "string" && /^[a-f0-9]{64}$/u.test(parsed.previousDigest))) ||
132
+ (parsed.assessmentDigest !== undefined && (typeof parsed.assessmentDigest !== "string" || !/^[a-f0-9]{64}$/u.test(parsed.assessmentDigest))))
127
133
  fail("RUN_POLICY_INVALID", "Run policy artifact has an invalid structure or trust origin.");
128
134
  const artifact = parsed;
135
+ const expires = Date.parse(artifact.expiresAt), issued = Date.parse(artifact.issuedAt ?? state.createdAt);
136
+ if (typeof artifact.expiresAt !== "string" || (artifact.issuedAt !== undefined && typeof artifact.issuedAt !== "string") ||
137
+ !Number.isFinite(expires) || !Number.isFinite(issued) || expires <= issued ||
138
+ (artifact.issuedAt !== undefined && expires - issued > RUN_POLICY_TTL_MS))
139
+ fail("RUN_POLICY_INVALID", "Policy requires a valid finite authorization window.");
140
+ if (now < issued)
141
+ fail("RUN_CLOCK_INVALID", "Policy clock moved behind its issuance; manual reconciliation is required.");
129
142
  if (artifact.schemaVersion !== 1 || artifact.runId !== state.runId || !validateConfig(artifact.config).ok ||
130
143
  !validateConfig(artifact.baseConfig).ok || calculateConfigHash(artifact.config) !== binding.configHash ||
131
144
  artifact.runtimeHash !== binding.runtimeHash || artifact.expiresAt !== binding.expiresAt ||
@@ -143,7 +156,7 @@ export async function bindRunPolicy(repository, runId, input) {
143
156
  if (state === null)
144
157
  return fail("RUN_NOT_FOUND", "Run does not exist.");
145
158
  const now = input.now ?? Date.now();
146
- if (!Number.isFinite(now) || !validBaseBinding(input.baseTrustBinding, state.root))
159
+ if (!Number.isSafeInteger(now) || now < 0 || !validBaseBinding(input.baseTrustBinding, state.root))
147
160
  fail("RUN_POLICY_INVALID", "Policy requires the original canonical repository trust binding and a valid clock.");
148
161
  const previous = await readRunPolicy(state, input.resumeRuntime === true ? state.policyBinding?.runtimeHash ?? input.runtimeHash : input.runtimeHash, now, true);
149
162
  if ((state.policyBinding?.artifactDigest ?? undefined) !== input.expectedDigest)
@@ -159,12 +172,13 @@ export async function bindRunPolicy(repository, runId, input) {
159
172
  if (!validateConfig(config).ok || !/^[a-f0-9]{64}$/u.test(input.runtimeHash) ||
160
173
  (previous === null && input.baseTrustBinding.configHash !== calculateConfigHash(config)))
161
174
  fail("RUN_POLICY_INVALID", "Run policy or runtime identity is invalid.");
162
- const remaining = state.budget.limitMs - activeWallTimeMs(state.budget.activeIntervals, now);
175
+ const remaining = remainingRunTimeMs(state, now);
163
176
  if (remaining <= 0)
164
177
  fail("RUN_BUDGET_EXHAUSTED", "No active execution budget remains.");
165
178
  const artifact = { schemaVersion: 1, runId, config, baseConfig: previous?.baseConfig ?? config,
166
179
  baseTrustBinding: previous?.baseTrustBinding ?? input.baseTrustBinding, runtimeHash: input.runtimeHash,
167
- expiresAt: new Date(now + remaining).toISOString(), approvals: (input.approvals ?? []).map(a => ({ ...a, reason: redactSecrets(a.reason) })), previousDigest: previous === null ? null : state.policyBinding.artifactDigest };
180
+ issuedAt: new Date(now).toISOString(), expiresAt: new Date(now + Math.min(remaining, RUN_POLICY_TTL_MS)).toISOString(),
181
+ approvals: (input.approvals ?? []).map(a => ({ ...a, reason: redactSecrets(a.reason) })), previousDigest: previous === null ? null : state.policyBinding.artifactDigest };
168
182
  const content = canonicalJson(artifact), digest = sha256(content);
169
183
  await writePrivateFile(join(state.commonDir, "agent-ops", "runs", runId, "policies", digest + ".json"), content, state.commonDir);
170
184
  return await repository.mutate(runId, current => {
@@ -174,3 +188,44 @@ export async function bindRunPolicy(repository, runId, input) {
174
188
  artifactDigest: digest, expiresAt: artifact.expiresAt } };
175
189
  });
176
190
  }
191
+ /** Renew the same finite grant without changing native identities, scope or trust. Reads never renew. */
192
+ export async function renewRunPolicy(repository, runId, input) {
193
+ const state = await repository.read(runId);
194
+ if (state === null)
195
+ return fail("RUN_NOT_FOUND", "Run does not exist.");
196
+ if (state.status !== "active" || state.disableRestart || state.awaitingResume || state.events.some(e => e.code === "RUN_COMMAND_DENIED"))
197
+ return fail("RUN_POLICY_DISABLED", "Renewal cannot awaken a stopped run or erase a denial.");
198
+ const now = input.now ?? Date.now();
199
+ if (now < Date.parse(state.budget.lastObservedAt))
200
+ return fail("RUN_CLOCK_INVALID", "Policy clock moved behind the last observed accounting time.");
201
+ const authority = await input.authority();
202
+ const previous = await readRunPolicy(state, authority.runtimeHash, now, true);
203
+ if (previous === null)
204
+ return fail("RUN_POLICY_INVALID", "Renewal requires an existing bound authorization.");
205
+ const assertAuthority = (current) => {
206
+ if (!current.trusted || current.runtimeHash !== previous.runtimeHash ||
207
+ canonicalJson(current.baseTrustBinding) !== canonicalJson(previous.baseTrustBinding) ||
208
+ calculateConfigHash(current.config) !== calculateConfigHash(previous.baseConfig))
209
+ fail("RUN_POLICY_AUTHORITY_CHANGED", "Original trust, repository configuration or executable runtime changed; obtain genuine authorization before resume.");
210
+ };
211
+ assertAuthority(authority);
212
+ if (Date.parse(previous.expiresAt) > now)
213
+ return state;
214
+ if (remainingRunTimeMs(state, now) <= 0)
215
+ return fail("RUN_BUDGET_EXHAUSTED", "Legacy finite runs require manual resume before renewal.");
216
+ const artifact = { ...previous, issuedAt: new Date(now).toISOString(),
217
+ expiresAt: new Date(now + Math.min(RUN_POLICY_TTL_MS, remainingRunTimeMs(state, now))).toISOString(),
218
+ assessmentDigest: previous.assessmentDigest ?? state.policyBinding.artifactDigest,
219
+ previousDigest: state.policyBinding.artifactDigest };
220
+ const content = canonicalJson(artifact), digest = sha256(content);
221
+ await writePrivateFile(join(state.commonDir, "agent-ops", "runs", runId, "policies", digest + ".json"), content, state.commonDir);
222
+ assertAuthority(await input.authority());
223
+ return await repository.mutate(runId, current => {
224
+ if (current.revision !== state.revision || current.status !== "active" || current.disableRestart || current.awaitingResume ||
225
+ current.policyBinding?.artifactDigest !== state.policyBinding.artifactDigest || current.events.some(e => e.code === "RUN_COMMAND_DENIED"))
226
+ return fail("RUN_POLICY_STALE", "Run stop, denial or scope changed during renewal.");
227
+ return { ...current, policyBinding: { ...state.policyBinding, artifactDigest: digest, expiresAt: artifact.expiresAt },
228
+ events: [...current.events, { id: `renewal-${digest.slice(0, 32)}`, at: artifact.issuedAt, type: "diagnostic",
229
+ code: "RUN_POLICY_RENEWED", workerId: null, taskId: null, detail: `Same-authority finite grant renewed from ${state.policyBinding.artifactDigest}.` }].slice(-MAX_EVENTS) };
230
+ });
231
+ }
@@ -63,16 +63,17 @@ export function planSchedule(state) {
63
63
  const running = state.tasks.filter((task) => task.status === "running" || task.status === "awaiting-delivery");
64
64
  const blocked = state.tasks.filter((task) => task.status === "blocked");
65
65
  const ready = state.tasks.filter((task) => ["planned", "ready"].includes(task.status) && dependencyState(state.tasks, task) === "ready");
66
+ const slotConsumers = state.workers.filter(worker => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status));
66
67
  return {
67
- ready,
68
+ ready: ready.filter(task => task.taskId !== state.rootTaskId),
68
69
  running,
69
70
  blocked,
70
- availableSlots: Math.max(0, Math.min(state.jobs, MAX_ACTIVE_WORKERS) - running.length)
71
+ availableSlots: Math.max(0, Math.min(state.jobs, MAX_ACTIVE_WORKERS) - slotConsumers.length)
71
72
  };
72
73
  }
73
74
  /**
74
75
  * Compute whole-run active wall time as the union of intervals. This prevents
75
- * two parallel writers from doubling the 60-minute run budget.
76
+ * two parallel writers from doubling measured consumption.
76
77
  */
77
78
  export function activeWallTimeMs(intervals, nowMs) {
78
79
  if (!Number.isFinite(nowMs) || nowMs < 0)
@@ -101,7 +102,11 @@ export function activeWallTimeMs(intervals, nowMs) {
101
102
  return start === null ? 0 : total + end - start;
102
103
  }
103
104
  export function budgetExpired(state, now = Date.now()) {
104
- return activeWallTimeMs(state.budget.activeIntervals, now) >= state.budget.limitMs;
105
+ return state.budget.limitMs !== null && activeWallTimeMs(state.budget.activeIntervals, now) >= state.budget.limitMs;
106
+ }
107
+ /** Infinity is internal only: callers retain their finite command/review timeouts. */
108
+ export function remainingRunTimeMs(state, now = Date.now()) {
109
+ return state.budget.limitMs === null ? Infinity : state.budget.limitMs - activeWallTimeMs(state.budget.activeIntervals, now);
105
110
  }
106
111
  /** Two consecutive identical failure rounds without a useful change stop one child. */
107
112
  export function noProgressForTwoRounds(rounds) {
@@ -249,7 +254,7 @@ export class RunScheduler {
249
254
  throw error("RUN_CLOCK_INVALID", "Budget clock is invalid.");
250
255
  return await this.#repository.mutate(runId, (current) => {
251
256
  const accumulatedMs = activeWallTimeMs(current.budget.activeIntervals, nowMs);
252
- const expired = accumulatedMs >= current.budget.limitMs;
257
+ const expired = current.budget.limitMs !== null && accumulatedMs >= current.budget.limitMs;
253
258
  return {
254
259
  ...current,
255
260
  status: expired && ["active", "awaiting-input"].includes(current.status) ? "budget-limited" : current.status,
@@ -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
- const limitMs = input.timeBudgetMs ?? 60 * 60 * 1_000;
158
- if (!Number.isSafeInteger(limitMs) || limitMs <= 0 || limitMs > MAX_TIME_BUDGET_MS) {
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 recoverable = new Set(["RUN_RECOVERY_DIRTY", "RUN_NATIVE_VERSION_CHANGED", "RUN_RUNTIME_CHANGED", "RUN_REPO_UNTRUSTED",
380
- "RUN_NATIVE_UNAVAILABLE", "RUN_RESTART_STORM", "RUN_POLICY_RECOVERY_REQUIRED", "RUN_POLICY_EXPIRED", "RUN_SETUP_RECOVERY_REQUIRED", "RUN_NATIVE_START_FAILED"]);
381
- const blocker = [...current.events].reverse().find(e => recoverable.has(e.code) || ["RUN_NO_PROGRESS_REPEAT", "RUN_COMMAND_DENIED"].includes(e.code));
382
- const terminalBlocker = current.events.some(e => e.code === "RUN_COMMAND_DENIED") ||
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
- ...(recover ? { workers: current.workers.map(w => w.status === "blocked" && (w.lastFailure === null || w.lastFailure.failureClass === "native-start") ? { ...w, status: "stopped" } : w),
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,
@@ -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 {
@@ -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 debugRemainingMs(state, now = Date.now()) {
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 Math.max(0, state.budgetMs + state.grants.reduce((n, g) => n + g.grantedMs, 0) - state.consumedMs - active);
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)