@kylecheng3146/agent-ops 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +3 -2
  2. package/dist/packages/cli/src/args.js +18 -53
  3. package/dist/packages/cli/src/bin.js +28 -29
  4. package/dist/packages/cli/src/cli.js +25 -17
  5. package/dist/packages/cli/src/codex-loop-process.js +0 -6
  6. package/dist/packages/cli/src/commands/advance.js +2 -1
  7. package/dist/packages/cli/src/commands/parallel.js +1 -1
  8. package/dist/packages/cli/src/commands/review.js +8 -11
  9. package/dist/packages/cli/src/commands/run.js +12 -3
  10. package/dist/packages/cli/src/commands/task.js +6 -50
  11. package/dist/packages/cli/src/commands/verify.js +7 -3
  12. package/dist/packages/cli/src/context.js +22 -4
  13. package/dist/packages/cli/src/default-base.js +26 -0
  14. package/dist/packages/cli/src/hook-process.js +19 -83
  15. package/dist/packages/cli/src/office-entry.js +8 -4
  16. package/dist/packages/cli/src/owned-run-step.js +4 -4
  17. package/dist/packages/cli/src/parallel-deps.js +0 -2
  18. package/dist/packages/cli/src/run-deps.js +12 -9
  19. package/dist/packages/cli/src/run-entry.js +123 -19
  20. package/dist/packages/cli/src/run-setup-entry.js +2 -2
  21. package/dist/runtime/src/adapters/agy/config.js +2 -3
  22. package/dist/runtime/src/adapters/claude/config.js +2 -2
  23. package/dist/runtime/src/adapters/codex/config.js +2 -5
  24. package/dist/runtime/src/hooks/completion-gate.js +0 -15
  25. package/dist/runtime/src/install/harness.js +7 -4
  26. package/dist/runtime/src/install/plan.js +3 -3
  27. package/dist/runtime/src/install/profiles.js +0 -2
  28. package/dist/runtime/src/office/scene.js +1 -1
  29. package/dist/runtime/src/office/server.js +43 -16
  30. package/dist/runtime/src/office/snapshot.js +5 -2
  31. package/dist/runtime/src/parallel/finish.js +139 -65
  32. package/dist/runtime/src/parallel/manage.js +0 -3
  33. package/dist/runtime/src/parallel/service.js +5 -1
  34. package/dist/runtime/src/review/execute.js +77 -11
  35. package/dist/runtime/src/review/packet.js +17 -6
  36. package/dist/runtime/src/review/runner.js +2 -0
  37. package/dist/runtime/src/run/controls.js +99 -1
  38. package/dist/runtime/src/run/hosts/codex.js +28 -1
  39. package/dist/runtime/src/run/hosts/protocol.js +5 -2
  40. package/dist/runtime/src/run/integration.js +35 -13
  41. package/dist/runtime/src/run/policy.js +65 -10
  42. package/dist/runtime/src/run/scheduler.js +10 -5
  43. package/dist/runtime/src/run/service.js +68 -21
  44. package/dist/runtime/src/run/supervisor.js +11 -2
  45. package/dist/runtime/src/run/transport.js +1 -0
  46. package/dist/runtime/src/run/verification.js +2 -2
  47. package/dist/runtime/src/schema/validate.js +10 -5
  48. package/dist/runtime/src/security/permissions.js +85 -0
  49. package/dist/runtime/src/task/completion.js +4 -42
  50. package/dist/runtime/src/task/contract.js +11 -0
  51. package/dist/runtime/src/task/service.js +34 -57
  52. package/dist/runtime/src/task/store.js +3 -7
  53. package/dist/runtime/src/verify/acceptance-coverage.js +2 -3
  54. package/dist/runtime/src/verify/acceptance-protocol.js +4 -3
  55. package/dist/runtime/src/verify/acceptance-replay.js +4 -7
  56. package/dist/runtime/src/verify/service.js +6 -23
  57. package/dist/runtime/src/verify/source-fingerprint.js +17 -4
  58. package/docs/en/guides/configuration.md +8 -4
  59. package/docs/en/guides/task-debug.md +39 -121
  60. package/docs/en/spec/acceptance-and-evidence.md +31 -19
  61. package/docs/en/spec/harness-adapters.md +35 -12
  62. package/docs/en/spec/loop-engineering.md +40 -6
  63. package/docs/en/spec/review.md +6 -0
  64. package/docs/zh-TW/guides/configuration.md +6 -3
  65. package/docs/zh-TW/guides/task-debug.md +27 -66
  66. package/docs/zh-TW/spec/acceptance-and-evidence.md +19 -18
  67. package/docs/zh-TW/spec/harness-adapters.md +28 -9
  68. package/docs/zh-TW/spec/loop-engineering.md +14 -3
  69. package/docs/zh-TW/spec/review.md +5 -0
  70. package/package.json +1 -1
  71. package/schemas/config.schema.json +1 -21
  72. package/dist/packages/cli/src/debug-intake.js +0 -245
  73. package/dist/runtime/src/task/debug-intake.js +0 -15
  74. package/dist/runtime/src/task/debug-state.js +0 -129
  75. package/dist/runtime/src/task/debug-user-event.js +0 -95
  76. package/dist/runtime/src/task/debug.js +0 -612
@@ -8,7 +8,7 @@ import { recordNativeRunAuthorization } from "../../../runtime/src/run/authoriza
8
8
  import { observeRunFailure } from "../../../runtime/src/run/verification.js";
9
9
  import { criteriaProgress, recordRunPhase } from "../../../runtime/src/run/phase.js";
10
10
  import { compareFailureObservations } from "../../../runtime/src/run/convergence.js";
11
- import { RunControlService } from "../../../runtime/src/run/controls.js";
11
+ import { RunControlService, nativeResumeTime } from "../../../runtime/src/run/controls.js";
12
12
  import { beginPolicyTransition, markPolicyTransitionStage, completePolicyTransition, discoverPendingPolicyTransition } from "../../../runtime/src/run/policy-transition.js";
13
13
  import { readReviewReportArtifact } from "../../../runtime/src/review/attestation.js";
14
14
  import { execFile, spawn } from "node:child_process";
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { setTimeout as delay } from "node:timers/promises";
19
19
  import { AgentOpsError, resolveContainedPath } from "../../../runtime/src/fs/paths.js";
20
+ import { ensureRunPolicy } from "./context.js";
20
21
  import { bindRunPolicy, readRunPolicy, runRuntimeHash, validateRunPolicyChange } from "../../../runtime/src/run/policy.js";
21
22
  import { sha256 } from "../../../runtime/src/fs/hash.js";
22
23
  import { calculateConfigHash, canonicalJson } from "../../../runtime/src/config/hash.js";
@@ -27,7 +28,7 @@ import { RunSupervisor } from "../../../runtime/src/run/supervisor.js";
27
28
  import { NativeRunTransport, nativeProcessIdentity } from "../../../runtime/src/run/transport.js";
28
29
  import { workerPlan } from "../../../runtime/src/run/planning.js";
29
30
  import { RunScheduler } from "../../../runtime/src/run/scheduler.js";
30
- import { activeWallTimeMs } from "../../../runtime/src/run/scheduler.js";
31
+ import { activeWallTimeMs, remainingRunTimeMs } from "../../../runtime/src/run/scheduler.js";
31
32
  import { readBootIdentity, readGuiLoginIdentity, LaunchdController } from "../../../runtime/src/run/macOS.js";
32
33
  import { readPrivateFile, writePrivateFile, withPrivateFileLock } from "../../../runtime/src/security/permissions.js";
33
34
  import { TaskService } from "../../../runtime/src/task/service.js";
@@ -42,6 +43,75 @@ const exec = promisify(execFile);
42
43
  const plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
43
44
  const active = (worker) => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status);
44
45
  const cli = fileURLToPath(new URL("./bin.js", import.meta.url));
46
+ const pendingChildren = (state) => state.tasks.filter(task => task.taskId !== state.rootTaskId && !["delivered", "complete", "blocked"].includes(task.status));
47
+ // Only a coordinator wait stops the coordinator with a handoff reason; it never delivers itself.
48
+ const yielded = (worker) => worker.status === "stopped" && worker.stopIntent?.reason === "handoff";
49
+ /**
50
+ * Stop a coordinator that asked to wait for its children, instead of letting it
51
+ * poll run status. Its native thread is dropped so the wake starts fresh.
52
+ */
53
+ export async function waitForChildren(repository, supervisor, runId, workerId, generation) {
54
+ const current = await repository.read(runId);
55
+ if (current === null || workerId !== current.coordinatorId)
56
+ throw new AgentOpsError("RUN_COORDINATOR_REQUIRED", "Only the coordinator can wait for child deliveries.");
57
+ if (current.rootTaskId === null || pendingChildren(current).length === 0)
58
+ throw new AgentOpsError("RUN_WAIT_NOTHING_PENDING", "Wait requires at least one planned child that has not finished.");
59
+ await yieldCoordinator(repository, supervisor, runId, workerId, generation, current.rootTaskId);
60
+ }
61
+ async function yieldCoordinator(repository, supervisor, runId, workerId, generation, taskId) {
62
+ await supervisor.stopWorker(runId, workerId, generation, "handoff");
63
+ await repository.mutate(runId, saved => ({ ...saved, workers: saved.workers.map(w => w.workerId === workerId && w.generation === generation && w.status === "stopped" ? { ...w, nativeSessionId: null, nativeJobId: null } : w) }));
64
+ await repository.appendEvent(runId, { type: "diagnostic", code: "RUN_COORDINATOR_YIELDED", workerId, taskId,
65
+ detail: "Coordinator waited; process death confirmed and its slot released until every child finishes." });
66
+ }
67
+ /** Finish an unconfirmed wait, then return the stopped coordinator once every child has finished. */
68
+ export async function prepareSerialSchedule(repository, supervisor, runId) {
69
+ const current = await repository.read(runId);
70
+ if (current === null || current.rootTaskId === null || current.status !== "active" || current.disableRestart || current.awaitingResume)
71
+ return null;
72
+ const coordinator = current.workers.find(worker => worker.workerId === current.coordinatorId);
73
+ if (coordinator?.status === "handing-off" && coordinator.stopIntent?.reason === "handoff") {
74
+ try {
75
+ await yieldCoordinator(repository, supervisor, runId, coordinator.workerId, coordinator.generation, current.rootTaskId);
76
+ }
77
+ catch (cause) {
78
+ // The slot stays held until death is confirmed; retry on the next supervisor pass.
79
+ if (cause instanceof AgentOpsError && ["RUN_HANDOFF_PROCESS_ALIVE", "RUN_WRITER_STILL_ALIVE"].includes(cause.code))
80
+ return null;
81
+ throw cause;
82
+ }
83
+ return null;
84
+ }
85
+ if (coordinator === undefined || current.tasks.every(task => task.taskId === current.rootTaskId) || pendingChildren(current).length > 0)
86
+ return null;
87
+ return coordinator.status === "stopped" && !current.workers.some(worker => worker.workerId !== coordinator.workerId && active(worker)) ? coordinator : null;
88
+ }
89
+ /** Recovery leaves a waiting coordinator stopped, and with jobs=1 starts only a child whose dependencies are delivered. */
90
+ export function recoverySkips(state, worker) {
91
+ if (state.rootTaskId === null)
92
+ return false;
93
+ const pending = pendingChildren(state);
94
+ if (pending.length === 0)
95
+ return false;
96
+ if (worker.workerId === state.coordinatorId)
97
+ return yielded(worker);
98
+ const node = pending.find(task => task.taskId === worker.taskId);
99
+ return state.jobs === 1 && (node === undefined ||
100
+ node.dependencies.some(id => !state.tasks.some(task => task.taskId === id && ["delivered", "complete"].includes(task.status))));
101
+ }
102
+ /** Child status handed to a woken coordinator, which may have lost its conversation. */
103
+ export function coordinatorWakeNote(state, coordinator) {
104
+ const children = state.tasks.filter(task => task.taskId !== state.rootTaskId)
105
+ .map(task => `${task.taskId} ${task.status}${task.sourceCommit === null ? "" : " " + task.sourceCommit}`);
106
+ return (coordinator.nativeSessionId === null ? "Fresh native session after wait; the earlier conversation is unavailable. " : "") +
107
+ `Children: ${children.join("; ")}. Review deliveries, then plan follow-up workers and wait again, or request finalization.`;
108
+ }
109
+ /** Retain the structured child failure instead of hiding it behind recovery text. */
110
+ export function runStepError(result, fallback) {
111
+ const details = Array.isArray(result.errors) ? result.errors.filter(plain)
112
+ .map(error => typeof error.message === "string" ? error.message : "").filter(Boolean).join("; ") : "";
113
+ return new AgentOpsError(typeof result.code === "string" ? result.code : "RUN_INTEGRATION_RECOVERY_REQUIRED", `${fallback}${details.length === 0 ? "" : ": " + details.slice(0, 4096)}`);
114
+ }
45
115
  /** Recheck after confirmed death and after verification, before publishing the frozen SHA. */
46
116
  export async function assertRunDeliverySnapshot(root, taskId, head, contract, tasks) {
47
117
  const runner = gitRunner(root);
@@ -104,6 +174,15 @@ export async function superviseNativeRun(root, runId) {
104
174
  await blockStartup("RUN_RESTART_STORM");
105
175
  return;
106
176
  }
177
+ let manualResumeAt;
178
+ await repository.mutate(runId, current => {
179
+ manualResumeAt = nativeResumeTime(current);
180
+ if (manualResumeAt === undefined)
181
+ return current;
182
+ // Consume the latest control durably; bounded event history is not a one-shot fence.
183
+ return { ...current, controls: current.controls.map((control, index) => index === current.controls.length - 1
184
+ ? { ...control, appliedAt: new Date().toISOString() } : control) };
185
+ });
107
186
  await repository.appendEvent(runId, { type: "status", code: "RUN_SUPERVISOR_START", workerId: null, taskId: null, detail: null });
108
187
  let version;
109
188
  try {
@@ -165,13 +244,13 @@ export async function superviseNativeRun(root, runId) {
165
244
  "Requests require requestId (unique alphanumeric or hyphen, max 64), workerId, generation and current contractHash.",
166
245
  "Plan fields: action=plan,title,intent,criteria,surface (the EVIDENCE-SURFACE-001 audit object, same shape as task create --surface-file). Worker fields: action=worker,title,intent,criteria,dependencies (known worker task IDs; omit all acceptance baselines until dependencies are delivered),covers (parent included surface IDs this worker delivers; every included surface needs one worker).",
167
246
  "Only the coordinator may change execution policy: submit action=policy, configFile (relative proposed JSON), expectedPolicyHash, approvals (each changed capabilityId command:<id>/runner:<id>/setup:<index>, classification=non-dangerous, reason assessing the entire fixed definition including setup/build). Commit source changes and wait until every writer is clean before requesting policy synchronization. Never use init/update/trust grant to create permanent execution authorization. Explicit native or organization denial must not be bypassed.",
168
- "Worker task baselines are frozen after dependency commits enter its checkout. Handoff fields: action=handoff,sourceFiles (explicit existing source paths when unchanged). Question fields: action=question,prompt. Coordinator finalize fields: action=finalize.",
247
+ "Worker task baselines are frozen after dependency commits enter its checkout. Handoff fields: action=handoff,sourceFiles (explicit existing source paths when unchanged). Question fields: action=question,prompt. Coordinator wait fields: action=wait, sent once every worker is planned instead of polling run status; the supervisor stops the coordinator and starts it again, in a fresh session with a child-status diagnostic, after every child is delivered, complete or blocked. Coordinator finalize fields: action=finalize.",
169
248
  "The supervisor, after stopping writers, runs final verification, two fresh reviews and finish. A native completed goal is never final proof."], diagnostic };
170
249
  const path = join(worker.worktree, ".agent-ops", "tasks", "run-instructions.json");
171
250
  await writePrivateFile(path, JSON.stringify(content, null, 2), worker.worktree);
172
- return "Read .agent-ops/tasks/run-instructions.json and carry its immutable original goal through the evidence loop. Submit a plan if rootTaskId is null; otherwise implement and repair the assigned task. A child requests handoff after local green; the coordinator waits for all child deliveries, then requests finalization. Never substitute native goal completion for agent-ops evidence.";
251
+ return "Read .agent-ops/tasks/run-instructions.json and carry its immutable original goal through the evidence loop. Submit a plan if rootTaskId is null; otherwise implement and repair the assigned task. A child requests handoff after local green; the coordinator submits action=wait after planning its children, then requests finalization once all have delivered. Never substitute native goal completion for agent-ops evidence.";
173
252
  }
174
- async function start(worker, diagnostic) {
253
+ async function start(worker, diagnostic, resumeAt) {
175
254
  if (await discoverPendingPolicyTransition(repository, runId) !== null)
176
255
  throw new AgentOpsError("RUN_POLICY_TRANSITION_PENDING", "Finish policy synchronization before acquiring a new writer generation.");
177
256
  if (worker.worktree === null)
@@ -195,7 +274,7 @@ export async function superviseNativeRun(root, runId) {
195
274
  throw new AgentOpsError("RUN_WORKTREE_OWNERSHIP", "The worker checkout is not registered to this run.");
196
275
  await writeWorktreeRecord({ ...record, workerGeneration: worker.generation });
197
276
  const goal = await instructions(worker, diagnostic);
198
- const running = await supervisor.startWorker(runId, worker.workerId, worker.generation, goal);
277
+ const running = await supervisor.startWorker(runId, worker.workerId, worker.generation, goal, resumeAt);
199
278
  await recordRunPhase(repository, runId, worker.workerId, (await repository.read(runId)).rootTaskId === null ? "planning" : "implementing");
200
279
  try {
201
280
  await transport.activateRegistered(runId, running, goal);
@@ -206,14 +285,22 @@ export async function superviseNativeRun(root, runId) {
206
285
  }
207
286
  }
208
287
  async function schedule() {
288
+ const coordinator = await prepareSerialSchedule(repository, supervisor, runId);
289
+ if (coordinator !== null) {
290
+ await start(coordinator, coordinatorWakeNote((await repository.read(runId)), coordinator));
291
+ return;
292
+ }
209
293
  const current = (await repository.read(runId));
210
294
  for (const node of await scheduler.next(runId)) {
211
295
  if (node.planDigest === undefined || node.taskId === current.rootTaskId)
212
296
  continue;
213
297
  const existing = current.workers.find(worker => worker.taskId === node.taskId);
214
298
  if (existing !== undefined && existing.worktree !== null &&
215
- (await deps.tasks(existing.worktree).list()).some(r => r.task.id === node.taskId))
299
+ (await deps.tasks(existing.worktree).list()).some(r => r.task.id === node.taskId)) {
300
+ if (existing.status === "stopped")
301
+ await start(existing);
216
302
  continue;
303
+ }
217
304
  const planPath = join(directory, runId, "plans", node.taskId + ".json");
218
305
  const source = await readPrivateFile(planPath, commonDir);
219
306
  if (source === null || sha256(source) !== node.planDigest)
@@ -291,12 +378,14 @@ export async function superviseNativeRun(root, runId) {
291
378
  if (absent)
292
379
  return (await recoverRunIntegrationAfterCleanup(commonDir, runId, deps.git)).receiptPath;
293
380
  const result = await advance(saved);
294
- if (result.status !== "ok" || !plain(result.data) || typeof result.data.receipt !== "string")
295
- throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Recover the sealed transaction before allowing any writer to resume.");
381
+ if (result.status !== "ok")
382
+ throw runStepError(result, "Recover the sealed transaction before allowing any writer to resume");
383
+ if (!plain(result.data) || typeof result.data.receipt !== "string")
384
+ throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Recovery did not return the sealed receipt.");
296
385
  return result.data.receipt;
297
386
  }
298
387
  async function step(cwd, args, saved) {
299
- const remaining = saved.budget.limitMs - activeWallTimeMs(saved.budget.activeIntervals, Date.now());
388
+ const remaining = remainingRunTimeMs(saved);
300
389
  if (remaining <= 0)
301
390
  throw new AgentOpsError("RUN_BUDGET_EXHAUSTED", "No active time remains for final proof.");
302
391
  return await new Promise((resolve, reject) => {
@@ -327,7 +416,7 @@ export async function superviseNativeRun(root, runId) {
327
416
  catch { }
328
417
  }, 3000);
329
418
  };
330
- const timeout = setTimeout(terminate, remaining);
419
+ const timeout = Number.isFinite(remaining) ? setTimeout(terminate, remaining) : undefined;
331
420
  const registration = (async () => {
332
421
  const identity = child.pid === undefined ? null : await nativeProcessIdentity(child.pid);
333
422
  if (identity === null || closed) {
@@ -637,11 +726,15 @@ export async function superviseNativeRun(root, runId) {
637
726
  await synchronizePolicy();
638
727
  state = (await repository.read(runId));
639
728
  for (const worker of state.workers.filter(w => active(w) || w.status === "stopped")) {
729
+ if (recoverySkips(state, worker))
730
+ continue;
640
731
  if (worker.workerId !== state.coordinatorId && (worker.worktree === null ||
641
732
  !(await deps.tasks(worker.worktree).list()).some(r => r.task.id === worker.taskId)))
642
733
  continue;
643
734
  try {
644
- await start(worker);
735
+ await start(worker, worker.workerId === state.coordinatorId && yielded(worker) ? coordinatorWakeNote(state, worker) : undefined, manualResumeAt);
736
+ if (state.jobs === 1)
737
+ break;
645
738
  }
646
739
  catch (cause) {
647
740
  await repository.appendEvent(runId, { type: "diagnostic", code: cause instanceof AgentOpsError ? cause.code : "RUN_RECOVERY_FAILED",
@@ -677,8 +770,9 @@ export async function superviseNativeRun(root, runId) {
677
770
  await stopAll(state.status === "budget-limited" ? "budget" : "stop");
678
771
  return;
679
772
  }
773
+ state = await ensureRunPolicy(repository, state);
680
774
  const elapsed = activeWallTimeMs(state.budget.activeIntervals, Date.now());
681
- if (elapsed >= state.budget.limitMs) {
775
+ if (state.budget.limitMs !== null && elapsed >= state.budget.limitMs) {
682
776
  await repository.mutate(runId, current => ({ ...current, status: "budget-limited", disableRestart: true }));
683
777
  await launchd.disableRestart(runDescriptor(state), "active run time budget exhausted");
684
778
  await stopAll("budget");
@@ -688,7 +782,12 @@ export async function superviseNativeRun(root, runId) {
688
782
  accumulatedMs: activeWallTimeMs(current.budget.activeIntervals, Date.now()), lastObservedAt: new Date().toISOString() } }));
689
783
  await schedule();
690
784
  state = (await repository.read(runId));
691
- for (const worker of state.workers.filter(w => ["running", "idle"].includes(w.status))) {
785
+ for (const candidateWorker of state.workers.filter(w => ["running", "idle"].includes(w.status))) {
786
+ const freshState = (await repository.read(runId));
787
+ const currentWorker = freshState.workers.find(w => w.workerId === candidateWorker.workerId);
788
+ if (currentWorker === undefined || !["running", "idle"].includes(currentWorker.status))
789
+ continue;
790
+ const worker = currentWorker;
692
791
  await supervisor.heartbeat(runId, worker.workerId, worker.generation);
693
792
  const observation = await supervisor.observeWorker(runId, worker.workerId, worker.generation);
694
793
  if (!observation.processAlive) {
@@ -867,6 +966,12 @@ export async function superviseNativeRun(root, runId) {
867
966
  { questionId: request.requestId, prompt: request.prompt, askedAt: new Date().toISOString(), answeredAt: null, answerDigest: null }] }));
868
967
  response = { requestId: request.requestId, code: "RUN_AWAITING_INPUT" };
869
968
  }
969
+ else if (request.action === "wait") {
970
+ if ((await collectChangeSurface(gitRunner(worker.worktree))).paths.length > 0)
971
+ throw new AgentOpsError("RUN_WAIT_DIRTY", "Commit coordinator changes before waiting; a stopped checkout must be clean to restart.");
972
+ await waitForChildren(repository, supervisor, runId, worker.workerId, worker.generation);
973
+ response = { requestId: request.requestId, code: "RUN_COORDINATOR_WAITING" };
974
+ }
870
975
  else if (request.action === "finalize") {
871
976
  if (worker.workerId !== current.coordinatorId || current.tasks.some(task => task.taskId !== current.rootTaskId && task.status !== "delivered"))
872
977
  throw new AgentOpsError("RUN_DELIVERIES_PENDING", "The coordinator must wait for every planned child delivery before final integration.");
@@ -888,7 +993,7 @@ export async function superviseNativeRun(root, runId) {
888
993
  await recordRunPhase(repository, runId, worker.workerId, "verifying");
889
994
  const result = await advance(current);
890
995
  if (result.status !== "ok")
891
- throw new AgentOpsError(result.code, "Final proof failed; inspect saved verifier and fresh review reports before repair.");
996
+ throw runStepError(result, "Final proof failed; inspect saved verifier and fresh review reports before repair");
892
997
  const data = result.data;
893
998
  if (!plain(data) || typeof data.receipt !== "string")
894
999
  throw new AgentOpsError("RUN_RECEIPT_REQUIRED", "Finalization requires the receipt returned by the existing finish gate.");
@@ -904,9 +1009,8 @@ export async function superviseNativeRun(root, runId) {
904
1009
  const stopped = latest.workers.find(w => w.workerId === worker.workerId);
905
1010
  if (request.action === "finalize" && latest.integration !== null && (latest.integration.status !== "prepared" ||
906
1011
  (await deps.git(root, ["rev-parse", latest.targetBranch + "^{commit}"])).stdout.trim() === latest.integration.candidate)) {
907
- const recovered = await recoverIntegration(latest);
908
- await acceptReceipt(latest, recovered);
909
- response = { requestId: request.requestId, code: "RUN_COMPLETE", receipt: recovered };
1012
+ // Preserve the failed step; a manual resume reconciles the sealed transaction after remediation.
1013
+ throw cause;
910
1014
  }
911
1015
  else if (request.action === "policy" && await discoverPendingPolicyTransition(repository, runId) !== null) {
912
1016
  await repository.appendEvent(runId, { type: "diagnostic", code: "RUN_POLICY_RECOVERY_REQUIRED", workerId: worker.workerId, taskId: worker.taskId,
@@ -936,7 +1040,7 @@ export async function superviseNativeRun(root, runId) {
936
1040
  }
937
1041
  catch (cause) {
938
1042
  await repository.appendEvent(runId, { type: "diagnostic", code: cause instanceof AgentOpsError ? cause.code : "RUN_SUPERVISOR_FAILED",
939
- workerId: null, taskId: null, detail: "Supervisor stopped; worktree and proof artifacts are preserved." });
1043
+ workerId: null, taskId: null, detail: cause instanceof AgentOpsError ? cause.message : "Supervisor stopped; worktree and proof artifacts are preserved." });
940
1044
  try {
941
1045
  await stopAll("crash");
942
1046
  }
@@ -1,6 +1,6 @@
1
1
  import { FileRunRepository } from "../../../runtime/src/run/service.js";
2
2
  import { readRunPolicy, runRuntimeHash } from "../../../runtime/src/run/policy.js";
3
- import { activeWallTimeMs } from "../../../runtime/src/run/scheduler.js";
3
+ import { remainingRunTimeMs } from "../../../runtime/src/run/scheduler.js";
4
4
  import { runVerificationCommand, NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
5
5
  import { redactSecrets } from "../../../runtime/src/security/redact.js";
6
6
  import { join } from "node:path";
@@ -21,7 +21,7 @@ const policy = await readRunPolicy(state, await runRuntimeHash());
21
21
  const step = /^(?:0|[1-9][0-9]*)$/u.test(index) ? policy?.config.worktree?.setup?.[Number(index)] : undefined;
22
22
  if (step === undefined)
23
23
  throw new Error("Setup must select an authorized fixed policy command.");
24
- const remaining = state.budget.limitMs - activeWallTimeMs(state.budget.activeIntervals, Date.now());
24
+ const remaining = remainingRunTimeMs(state);
25
25
  if (remaining <= 0)
26
26
  throw new Error("Run budget is exhausted.");
27
27
  const result = await runVerificationCommand({ id: "run-setup", ...step, cwd: ".", required: true,
@@ -54,7 +54,7 @@ export function buildAgyHookSettings(capabilities, runtimePath, platform = proce
54
54
  throw new AgentOpsError("AGY_HOOK_PATH_INVALID", "agy hook runtime path is invalid.");
55
55
  }
56
56
  const hooks = {};
57
- if ((capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence") || capabilities.includes("task-debug")) || capabilities.includes("project-loop")) {
57
+ if ((capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence")) || capabilities.includes("project-loop")) {
58
58
  hooks.PreInvocation = [handler(runtimePath, "SessionStart", platform)];
59
59
  }
60
60
  if ((capabilities.includes("command-policy") || capabilities.includes("office-presence")) || capabilities.includes("project-loop")) {
@@ -62,8 +62,7 @@ export function buildAgyHookSettings(capabilities, runtimePath, platform = proce
62
62
  }
63
63
  if (capabilities.includes("office-presence") ||
64
64
  capabilities.includes("optional-stop-verify") ||
65
- capabilities.includes("completion-gate") ||
66
- capabilities.includes("task-debug")) {
65
+ capabilities.includes("completion-gate")) {
67
66
  hooks.Stop = [handler(runtimePath, "Stop", platform, capabilities.includes("completion-gate"))];
68
67
  }
69
68
  return { hooks };
@@ -115,7 +115,7 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
115
115
  }
116
116
  // The loop has its own prompt hook. Outside it, a prompt is both the
117
117
  // turn's start for Office and content for command policy.
118
- if (capabilities.includes("command-policy") || capabilities.includes("office-presence") || capabilities.includes("task-debug")) {
118
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence")) {
119
119
  hooks.UserPromptSubmit = [matcherGroup("UserPromptSubmit", runtimePath)];
120
120
  if (capabilities.includes("command-policy") || capabilities.includes("office-presence"))
121
121
  hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
@@ -126,7 +126,7 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
126
126
  if (capabilities.includes("completion-gate")) {
127
127
  hooks.Stop = [matcherGroup("Stop", runtimePath, true)];
128
128
  }
129
- else if (capabilities.includes("task-debug") || capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
129
+ else if (capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
130
130
  hooks.Stop = [matcherGroup("Stop", runtimePath)];
131
131
  }
132
132
  // Stop only ends a turn. SessionEnd is the one signal that the conversation
@@ -84,18 +84,15 @@ export function buildCodexHookConfig(capabilities, runtimePath) {
84
84
  }
85
85
  // The loop has its own prompt hook. Outside it, a prompt is both the
86
86
  // turn's start for Office and content for command policy.
87
- if (capabilities.includes("command-policy") || capabilities.includes("office-presence") || capabilities.includes("task-debug")) {
87
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence")) {
88
88
  hooks.UserPromptSubmit = [matcherGroup("UserPromptSubmit", runtimePath)];
89
89
  if (capabilities.includes("command-policy") || capabilities.includes("office-presence"))
90
90
  hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
91
91
  }
92
92
  }
93
- if (capabilities.includes("task-debug") || capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
93
+ if (capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
94
94
  hooks.Stop = [matcherGroup("Stop", runtimePath)];
95
95
  }
96
- if (capabilities.includes("task-debug")) {
97
- hooks.Interrupt = [{ hooks: [{ ...commandHook("Interrupt", runtimePath), timeout: 3 }] }];
98
- }
99
96
  if (capabilities.includes("office-presence") && hooks.PreToolUse !== undefined) {
100
97
  hooks.PreToolUse = hooks.PreToolUse.map(group => ({ ...group, matcher: "^(Bash|apply_patch)$" }));
101
98
  }
@@ -260,9 +260,6 @@ export class CompletionGateService {
260
260
  }
261
261
  }
262
262
  async grantPermit(sessionId) {
263
- const attached = await this.#options.taskService.status({ sessionId }).catch(() => null);
264
- if (attached?.task.debug !== undefined && attached.status === "active")
265
- throw new AgentOpsError("TASK_DEBUG_CHECKPOINT_REQUIRED", "Active debug issues require a runtime stop checkpoint; allow-stop does not grant budget or success.");
266
263
  const fingerprint = await this.#fingerprint();
267
264
  const granted = await this.#store.mutate(sessionId, (state) => {
268
265
  if (state === null) {
@@ -488,18 +485,6 @@ export class CompletionGateService {
488
485
  }
489
486
  return last;
490
487
  }
491
- const debugTask = await this.#options.taskService.status({ sessionId }).catch(() => null);
492
- if (debugTask?.task.debug !== undefined) {
493
- if (debugTask.status === "active") {
494
- await this.#options.taskService.debugService().prepareStop(debugTask.task.id, sessionId);
495
- const valid = await this.#options.taskService.debugService().validCheckpoint(debugTask.task.id, sessionId, fingerprint);
496
- return valid ? gateResult("continue", "UNKNOWN", "COMPLETION_GATE_TASK_DEBUG_STOPPED", "Original issue remains active with a runtime-bound non-success checkpoint.") :
497
- gateResult("block", "FAIL", "COMPLETION_GATE_TASK_DEBUG_ACTIVE", "Continue the investigation/evidence loop or record a current runtime stop checkpoint, even when Git has no net diff.");
498
- }
499
- const failure = await this.#validateTask(sessionId);
500
- if (failure !== null)
501
- return failure;
502
- }
503
488
  if (changed) {
504
489
  const failure = await this.#validateTask(sessionId);
505
490
  if (failure !== null)
@@ -340,8 +340,11 @@ function autoRunInstruction(descriptor, context) {
340
340
  "`WORKTREE_NESTED`, say so in one line naming the code and fall back to",
341
341
  "the subtask flow (`task create --parent`, then batch or advance).",
342
342
  "`RUN_REPO_UNTRUSTED` means stop and ask the user; never work",
343
- "around trust. If the run ends without completing (blocked, budget",
344
- "exhausted, stopped, failing review), report the `agent-ops run status",
343
+ "around trust. New runs have no total time cap; legacy finite runs upgrade",
344
+ "only on manual resume, preserving accounting and independent blockers.",
345
+ "Command/review timeouts and provider limits remain effective.",
346
+ "If the run ends without completing (blocked, stopped, failing review),",
347
+ "report the `agent-ops run status",
345
348
  "<id>` state and code, the last `agent-ops run logs <id>` events, and the",
346
349
  "exact `agent-ops run resume <id>` and `agent-ops run stop <id>` commands,",
347
350
  "then stop: never resume automatically or take over the run's work.",
@@ -361,8 +364,8 @@ export function managedRules(descriptor, context) {
361
364
  : [`Toolkit version: ${context.toolkitVersion}`]),
362
365
  ""
363
366
  ];
364
- if (context.capabilities.includes("task-debug"))
365
- lines.push("Task DEBUG routing is enabled for investigation/bugfix issues. Native prompt hooks create the issue task when a session identity is available; otherwise use agent-ops task create --debug --goal <verbatim issue> with two to five issue-specific criteria. Explain/read-only, feature and refactor requests use the existing normal task flow.", "For DEBUG: understand the issue, investigate with task debug --debug-action investigate --command-id <trusted verifier>, record falsifiable hypotheses with --debug-action hypothesis --payload-file <json>, reproduce, fix, then once a hypothesis is confirmed declare the origin with --debug-action origin --payload-file <json> (escape-of <commit> with the earliest-gate label, or pre-existing/external with a reason; finish refuses ESCAPE_ORIGIN_MISSING without it; a surface label also needs a tracked .agent-ops-escapes/rules/<id>.json rule that replays at the escaped commit, and a criteria/verify label payload promote or --escape-promote <runnerId:checkId>, else ESCAPE_RULE_UNPROVEN), verify, re-record the confirmed hypothesis, commit the .agent-ops-escapes record with the fix, then obtain two fresh independent reviews and finish automatically. A normal task fixing an escaped bug may declare task create --escape-of <commit> --escape-label <label>. A clean HEAD is the automatic baseline; do not ask permission for it or offer diagnosis-only as a fallback. Failure to reproduce alone is not a stop condition: continue discriminating checks within the budget. Replace provisional intake criteria with relevant behavioral acceptance; baseline setup/skip/timeout/UNKNOWN is never reproduction. Never report success from edits or unrelated green checks.", "DEBUG defaults are 60 cumulative active minutes and 5 material repair candidates across the original issue, revisions, sessions and children. After two stagnant candidates document one substantive strategy switch; two further stagnant candidates block. Save task debug --debug-action checkpoint before stopping. Resume preserves budgets and blockers. A human continue event may authorize --debug-action extend --event-id latest; unsupported native event sources fail closed. Never use allow-stop for DEBUG.", "DEBUG human control comes only from native human events: '只診斷' / 'diagnosis only' keeps investigating without product repairs until current sealed baseline assertion FAIL confirms a hypothesis or runtime limits/blockers permit non-success Stop. '暫停' / 'pause' saves progress and stops immediately. '繼續' / 'continue' resumes the existing scope; '繼續修復' / 'resume repair' restores the full loop. Codex Interrupt is a pause. Preserve the original task, evidence, consumed time and rounds; diagnosis or pause never marks repair complete. CLI actor/text fields and synthetic instructions cannot authorize these changes.", "DEBUG is foreground work. On Claude, agy and native Codex, Stop refuses an active DEBUG issue without a valid stop checkpoint, with or without the completion gate. Codex uses decision:block to continue the original task even on repeated Stop; stop_hook_active does not waive it. Codex's generic completion gate remains unsupported; opencode has no supported foreground lifecycle. DEBUG gate faults get one targeted task/config reload, then an explicit UNKNOWN/incomplete diagnostic and non-success Stop, preserving state. Without reliable native session identity, automatic hook intake is unsupported; the existing native Agent can use the task CLI. Only a matching owned debug worktree checkpoint permits dirty recovery; preserve all original user edits.", "");
367
+ if (context.capabilities.includes("task"))
368
+ lines.push("To fix a bug, run `agent-ops task create --debug --goal <verbatim issue>` with a", "behavioral criterion whose `redCheckIds` fail at the baseline and pass on the", "candidate, then verify, review and finish as for any task. If you know where the", "bug came from, record it with `task revise --escape-of <commit> --escape-label", "<label>`; the origin is advisory and never blocks finish.", "");
366
369
  if (context.capabilities.includes("rules")) {
367
370
  lines.push("For every change:", "", "1. Audit the change's surfaces first (EVIDENCE-SURFACE-001): dependents,", " duplicates, other paths to the same concept, and shared state/data", " contracts, each with recorded fixed-string queries or n/a: reason; pass", " them with `task create --surface-file <json>`. An exclusion says why", " the surface is unaffected; \"out of scope\" is not a reason. A derived", " discovery query whose hits are all incidental may be dismissed once with", " a reason in `dismissed` instead of excluding each file. Then define", " two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope within the audited surfaces and", " preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
368
371
  }
@@ -1,5 +1,4 @@
1
1
  import { lstat, readFile } from "node:fs/promises";
2
- import { DEBUG_DEFAULTS } from "../task/debug-state.js";
3
2
  import { CONFIG_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION } from "../contracts.js";
4
3
  import { discoverProject } from "../discovery/index.js";
5
4
  import { sha256 } from "../fs/hash.js";
@@ -90,6 +89,8 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
90
89
  existing.verification.commands.length > 0
91
90
  ? existing.verification
92
91
  : { commands: [...detectedCommands] };
92
+ // Pre-0.11 DEBUG budget settings are dropped on rewrite.
93
+ const existingFeatures = existing === undefined ? undefined : (({ debug: _legacyDebug, ...rest }) => rest)(existing.features);
93
94
  const effectiveWorktree = worktree !== undefined
94
95
  ? (worktree === null ? undefined : structuredClone(worktree))
95
96
  : (existing?.worktree === undefined ? undefined : structuredClone(existing.worktree));
@@ -98,8 +99,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
98
99
  profiles: [...profiles],
99
100
  verification,
100
101
  features: {
101
- debug: existing?.features.debug ?? { ...DEBUG_DEFAULTS },
102
- ...(existing?.features ?? {
102
+ ...(existingFeatures ?? {
103
103
  stopVerification: { enabled: false },
104
104
  completionGate: { enabled: completionGateEnabled }
105
105
  }),
@@ -34,8 +34,6 @@ export function resolveProfiles(inputProfiles) {
34
34
  }
35
35
  export function resolveCapabilities(config) {
36
36
  const resolved = resolveProfiles(config.profiles);
37
- if (config.features.debug?.enabled === true)
38
- resolved.capabilities.push("task-debug");
39
37
  if (config.features.office?.enabled === true)
40
38
  resolved.capabilities.push("office-presence");
41
39
  if (config.features.stopVerification.enabled) {
@@ -122,7 +122,7 @@ export function sceneModel(snapshot) {
122
122
  return { key, sourceIndex: index, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions, criteria,
123
123
  activities: activitiesOf(optional(run).activities),
124
124
  board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: rootProgress?.passed ?? 0, total: rootProgress?.total ?? 0 },
125
- clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 0, phase: run.phase, status: run.status,
125
+ clock: run.budget.limitMs !== null && run.budget.remainingMs !== null && run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 1, phase: run.phase, status: run.status,
126
126
  completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
127
127
  };
128
128
  const activitiesOf = (value) => (Array.isArray(value) ? value : []).map(raw => {
@@ -25,7 +25,7 @@ function send(res, status, type, body, headers = {}) {
25
25
  }
26
26
  /** Loopback-only, token-gated, GET-only view of the building. */
27
27
  export function createOfficeServer(options) {
28
- const token = options.token ?? randomBytes(24).toString("base64url");
28
+ let token = options.token ?? randomBytes(24).toString("base64url");
29
29
  const now = options.now ?? Date.now;
30
30
  const idleMs = options.idleMs ?? OFFICE_IDLE_MS;
31
31
  let lastActive = now();
@@ -40,6 +40,8 @@ export function createOfficeServer(options) {
40
40
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
41
41
  if (!same(url.searchParams.get("token") ?? "", token))
42
42
  return send(res, 403, "text/plain", "Forbidden\n");
43
+ if (url.pathname === "/health")
44
+ return send(res, 200, "text/plain", "ok\n");
43
45
  if (url.pathname === "/") {
44
46
  const nonce = randomBytes(16).toString("base64");
45
47
  return send(res, 200, "text/html; charset=utf-8", officePage(nonce), { "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'` });
@@ -54,16 +56,25 @@ export function createOfficeServer(options) {
54
56
  else
55
57
  res.end(); });
56
58
  });
59
+ const bind = (candidate) => new Promise((resolve, reject) => {
60
+ const onError = (error) => { server.removeListener("listening", onListening); reject(error); };
61
+ const onListening = () => {
62
+ server.removeListener("error", onError);
63
+ const address = server.address();
64
+ port = typeof address === "object" && address !== null ? address.port : 0;
65
+ resolve(port);
66
+ };
67
+ server.once("error", onError);
68
+ server.once("listening", onListening);
69
+ server.listen(candidate, "127.0.0.1");
70
+ });
57
71
  return {
58
- server, token,
59
- listen: async () => await new Promise((resolve, reject) => {
60
- server.once("error", reject);
61
- server.listen(0, "127.0.0.1", () => {
62
- const address = server.address();
63
- port = typeof address === "object" && address !== null ? address.port : 0;
64
- resolve(port);
65
- });
66
- }),
72
+ server,
73
+ get token() { return token; },
74
+ set token(value) { token = value; },
75
+ listen: async (preferredPort = 0) => preferredPort > 0
76
+ ? await bind(preferredPort).catch(async () => await bind(0))
77
+ : await bind(0),
67
78
  tick: async () => {
68
79
  const snapshot = await options.snapshot().catch(() => null);
69
80
  const sessionIsPresent = snapshot?.lobby.some(desk => (desk.sessionActive ?? desk.status === "active") && (desk.completedAt === undefined || desk.completedAt === null)) ?? false;
@@ -86,16 +97,29 @@ function alive(pid) {
86
97
  return error.code === "EPERM";
87
98
  }
88
99
  }
89
- /** A recorded server counts only if its process lives and it answers with its own token. */
90
- export async function readLiveOffice(home) {
100
+ export async function readOfficeRecord(home) {
91
101
  const source = await readPrivateFile(officeRecordPath(home), home.anchor).catch(() => null);
92
102
  if (source === null)
93
103
  return null;
94
104
  try {
95
105
  const record = JSON.parse(source);
96
- if (!Number.isSafeInteger(record.pid) || !Number.isSafeInteger(record.port) || typeof record.token !== "string" || !alive(record.pid))
97
- return null;
98
- const response = await fetch(`http://127.0.0.1:${record.port}/snapshot.json?token=${encodeURIComponent(record.token)}`, { signal: AbortSignal.timeout(2000) });
106
+ return Number.isSafeInteger(record.pid) && Number.isSafeInteger(record.port) &&
107
+ record.port > 0 && record.port <= 65535 &&
108
+ typeof record.token === "string" && record.token.length > 0
109
+ ? record
110
+ : null;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ /** A recorded server counts only if its process lives and it answers with its own token. */
117
+ export async function readLiveOffice(home) {
118
+ const record = await readOfficeRecord(home);
119
+ if (record === null || !alive(record.pid))
120
+ return null;
121
+ try {
122
+ const response = await fetch(`http://127.0.0.1:${record.port}/health?token=${encodeURIComponent(record.token)}`, { signal: AbortSignal.timeout(2000) });
99
123
  await response.body?.cancel();
100
124
  return response.status === 200 ? record : null;
101
125
  }
@@ -111,7 +135,10 @@ export async function claimOffice(home, office, pid = process.pid) {
111
135
  return await withPrivateFileLock(officeRecordPath(home), home.anchor, async () => {
112
136
  if (await readLiveOffice(home) !== null)
113
137
  return null;
114
- const record = { pid, port: await office.listen(), token: office.token, startedAt: new Date().toISOString() };
138
+ const previous = await readOfficeRecord(home);
139
+ if (previous !== null)
140
+ office.token = previous.token;
141
+ const record = { pid, port: await office.listen(previous?.port), token: office.token, startedAt: new Date().toISOString() };
115
142
  await writePrivateFile(officeRecordPath(home), JSON.stringify(record) + "\n", home.anchor);
116
143
  return record;
117
144
  });
@@ -1,6 +1,9 @@
1
1
  import { activeWallTimeMs } from "../run/scheduler.js";
2
2
  const COMPLETED_RETENTION_MS = 2 * 60 * 60 * 1000;
3
3
  const LIVE = new Set(["assigned", "starting", "running", "idle", "handing-off", "fenced", "delivered", "blocked"]);
4
+ // Every other run status is idle: nothing moves a paused, stopping, blocked or
5
+ // budget-limited run on, so it leaves the Office like a finished one.
6
+ const RUNNING = new Set(["active", "awaiting-input"]);
4
7
  const PHASES = new Set(["planning", "implementing", "verifying", "reviewing", "integrating", "unknown"]);
5
8
  function phaseOf(value) {
6
9
  return value !== undefined && PHASES.has(value) ? value : "unknown";
@@ -23,7 +26,7 @@ function quote(text) {
23
26
  export function buildOfficeSnapshot(input) {
24
27
  const byPath = new Map(input.worktrees.map(w => [w.path, w]));
25
28
  const claimed = new Set();
26
- const runs = input.runs.filter(run => !["complete", "failed"].includes(run.status) || input.now - Date.parse(run.updatedAt) < COMPLETED_RETENTION_MS)
29
+ const runs = input.runs.filter(run => RUNNING.has(run.status) || input.now - Date.parse(run.updatedAt) < COMPLETED_RETENTION_MS)
27
30
  .map((run) => {
28
31
  const roots = new Set(run.workers.map(w => w.worktree).filter((p) => p !== null));
29
32
  const reviewers = input.reviews.filter(r => r.root !== null && roots.has(r.root)).slice(0, 2);
@@ -52,7 +55,7 @@ export function buildOfficeSnapshot(input) {
52
55
  ...(rootTaskId === null ? {} : { progress: rootTree?.progress ?? rootTask?.progress ?? null,
53
56
  ...(rootTree?.criteria === undefined ? {} : { criteria: rootTree.criteria }) }),
54
57
  status: run.status, phase: run.phase ?? "unknown",
55
- budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: Math.max(0, run.budget.limitMs - usedMs) },
58
+ budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: run.budget.limitMs === null ? null : Math.max(0, run.budget.limitMs - usedMs) },
56
59
  questions, agents, reviewers,
57
60
  ...(run.status === "complete" || run.status === "failed" ? { completedAt: run.updatedAt } : {}),
58
61
  commands: [`agent-ops run status ${run.runId}`, `agent-ops run logs ${run.runId}`,