@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
@@ -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,31 @@ 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
+ /** Release the sole native slot only after confirmed coordinator death. */
47
+ export async function prepareSerialSchedule(repository, supervisor, runId) {
48
+ const current = await repository.read(runId);
49
+ if (current === null || current.jobs !== 1 || current.rootTaskId === null || current.status !== "active" || current.disableRestart || current.awaitingResume)
50
+ return null;
51
+ const children = current.tasks.filter(task => task.taskId !== current.rootTaskId);
52
+ const coordinator = current.workers.find(worker => worker.workerId === current.coordinatorId);
53
+ if (children.length === 0 || coordinator === undefined)
54
+ return null;
55
+ if (children.some(task => !["delivered", "complete", "blocked"].includes(task.status))) {
56
+ if (active(coordinator)) {
57
+ await supervisor.stopWorker(runId, coordinator.workerId, coordinator.generation, "handoff");
58
+ await repository.appendEvent(runId, { type: "diagnostic", code: "RUN_COORDINATOR_YIELDED", workerId: coordinator.workerId,
59
+ taskId: coordinator.taskId, detail: "Coordinator process death confirmed before assigning the sole slot to children." });
60
+ }
61
+ return null;
62
+ }
63
+ return coordinator.status === "stopped" && !current.workers.some(worker => worker.workerId !== coordinator.workerId && active(worker)) ? coordinator : null;
64
+ }
65
+ /** Retain the structured child failure instead of hiding it behind recovery text. */
66
+ export function runStepError(result, fallback) {
67
+ const details = Array.isArray(result.errors) ? result.errors.filter(plain)
68
+ .map(error => typeof error.message === "string" ? error.message : "").filter(Boolean).join("; ") : "";
69
+ return new AgentOpsError(typeof result.code === "string" ? result.code : "RUN_INTEGRATION_RECOVERY_REQUIRED", `${fallback}${details.length === 0 ? "" : ": " + details.slice(0, 4096)}`);
70
+ }
45
71
  /** Recheck after confirmed death and after verification, before publishing the frozen SHA. */
46
72
  export async function assertRunDeliverySnapshot(root, taskId, head, contract, tasks) {
47
73
  const runner = gitRunner(root);
@@ -104,6 +130,15 @@ export async function superviseNativeRun(root, runId) {
104
130
  await blockStartup("RUN_RESTART_STORM");
105
131
  return;
106
132
  }
133
+ let manualResumeAt;
134
+ await repository.mutate(runId, current => {
135
+ manualResumeAt = nativeResumeTime(current);
136
+ if (manualResumeAt === undefined)
137
+ return current;
138
+ // Consume the latest control durably; bounded event history is not a one-shot fence.
139
+ return { ...current, controls: current.controls.map((control, index) => index === current.controls.length - 1
140
+ ? { ...control, appliedAt: new Date().toISOString() } : control) };
141
+ });
107
142
  await repository.appendEvent(runId, { type: "status", code: "RUN_SUPERVISOR_START", workerId: null, taskId: null, detail: null });
108
143
  let version;
109
144
  try {
@@ -171,7 +206,7 @@ export async function superviseNativeRun(root, runId) {
171
206
  await writePrivateFile(path, JSON.stringify(content, null, 2), worker.worktree);
172
207
  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.";
173
208
  }
174
- async function start(worker, diagnostic) {
209
+ async function start(worker, diagnostic, resumeAt) {
175
210
  if (await discoverPendingPolicyTransition(repository, runId) !== null)
176
211
  throw new AgentOpsError("RUN_POLICY_TRANSITION_PENDING", "Finish policy synchronization before acquiring a new writer generation.");
177
212
  if (worker.worktree === null)
@@ -195,7 +230,7 @@ export async function superviseNativeRun(root, runId) {
195
230
  throw new AgentOpsError("RUN_WORKTREE_OWNERSHIP", "The worker checkout is not registered to this run.");
196
231
  await writeWorktreeRecord({ ...record, workerGeneration: worker.generation });
197
232
  const goal = await instructions(worker, diagnostic);
198
- const running = await supervisor.startWorker(runId, worker.workerId, worker.generation, goal);
233
+ const running = await supervisor.startWorker(runId, worker.workerId, worker.generation, goal, resumeAt);
199
234
  await recordRunPhase(repository, runId, worker.workerId, (await repository.read(runId)).rootTaskId === null ? "planning" : "implementing");
200
235
  try {
201
236
  await transport.activateRegistered(runId, running, goal);
@@ -206,14 +241,22 @@ export async function superviseNativeRun(root, runId) {
206
241
  }
207
242
  }
208
243
  async function schedule() {
244
+ const coordinator = await prepareSerialSchedule(repository, supervisor, runId);
245
+ if (coordinator !== null) {
246
+ await start(coordinator);
247
+ return;
248
+ }
209
249
  const current = (await repository.read(runId));
210
250
  for (const node of await scheduler.next(runId)) {
211
251
  if (node.planDigest === undefined || node.taskId === current.rootTaskId)
212
252
  continue;
213
253
  const existing = current.workers.find(worker => worker.taskId === node.taskId);
214
254
  if (existing !== undefined && existing.worktree !== null &&
215
- (await deps.tasks(existing.worktree).list()).some(r => r.task.id === node.taskId))
255
+ (await deps.tasks(existing.worktree).list()).some(r => r.task.id === node.taskId)) {
256
+ if (existing.status === "stopped")
257
+ await start(existing);
216
258
  continue;
259
+ }
217
260
  const planPath = join(directory, runId, "plans", node.taskId + ".json");
218
261
  const source = await readPrivateFile(planPath, commonDir);
219
262
  if (source === null || sha256(source) !== node.planDigest)
@@ -291,12 +334,14 @@ export async function superviseNativeRun(root, runId) {
291
334
  if (absent)
292
335
  return (await recoverRunIntegrationAfterCleanup(commonDir, runId, deps.git)).receiptPath;
293
336
  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.");
337
+ if (result.status !== "ok")
338
+ throw runStepError(result, "Recover the sealed transaction before allowing any writer to resume");
339
+ if (!plain(result.data) || typeof result.data.receipt !== "string")
340
+ throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Recovery did not return the sealed receipt.");
296
341
  return result.data.receipt;
297
342
  }
298
343
  async function step(cwd, args, saved) {
299
- const remaining = saved.budget.limitMs - activeWallTimeMs(saved.budget.activeIntervals, Date.now());
344
+ const remaining = remainingRunTimeMs(saved);
300
345
  if (remaining <= 0)
301
346
  throw new AgentOpsError("RUN_BUDGET_EXHAUSTED", "No active time remains for final proof.");
302
347
  return await new Promise((resolve, reject) => {
@@ -327,7 +372,7 @@ export async function superviseNativeRun(root, runId) {
327
372
  catch { }
328
373
  }, 3000);
329
374
  };
330
- const timeout = setTimeout(terminate, remaining);
375
+ const timeout = Number.isFinite(remaining) ? setTimeout(terminate, remaining) : undefined;
331
376
  const registration = (async () => {
332
377
  const identity = child.pid === undefined ? null : await nativeProcessIdentity(child.pid);
333
378
  if (identity === null || closed) {
@@ -637,11 +682,20 @@ export async function superviseNativeRun(root, runId) {
637
682
  await synchronizePolicy();
638
683
  state = (await repository.read(runId));
639
684
  for (const worker of state.workers.filter(w => active(w) || w.status === "stopped")) {
685
+ if (state.jobs === 1 && state.rootTaskId !== null) {
686
+ const pending = state.tasks.filter(task => task.taskId !== state.rootTaskId && !["delivered", "complete", "blocked"].includes(task.status));
687
+ const node = pending.find(task => task.taskId === worker.taskId);
688
+ if (pending.length > 0 && (worker.workerId === state.coordinatorId || node === undefined ||
689
+ node.dependencies.some(id => !state.tasks.some(task => task.taskId === id && ["delivered", "complete"].includes(task.status)))))
690
+ continue;
691
+ }
640
692
  if (worker.workerId !== state.coordinatorId && (worker.worktree === null ||
641
693
  !(await deps.tasks(worker.worktree).list()).some(r => r.task.id === worker.taskId)))
642
694
  continue;
643
695
  try {
644
- await start(worker);
696
+ await start(worker, undefined, manualResumeAt);
697
+ if (state.jobs === 1)
698
+ break;
645
699
  }
646
700
  catch (cause) {
647
701
  await repository.appendEvent(runId, { type: "diagnostic", code: cause instanceof AgentOpsError ? cause.code : "RUN_RECOVERY_FAILED",
@@ -677,8 +731,9 @@ export async function superviseNativeRun(root, runId) {
677
731
  await stopAll(state.status === "budget-limited" ? "budget" : "stop");
678
732
  return;
679
733
  }
734
+ state = await ensureRunPolicy(repository, state);
680
735
  const elapsed = activeWallTimeMs(state.budget.activeIntervals, Date.now());
681
- if (elapsed >= state.budget.limitMs) {
736
+ if (state.budget.limitMs !== null && elapsed >= state.budget.limitMs) {
682
737
  await repository.mutate(runId, current => ({ ...current, status: "budget-limited", disableRestart: true }));
683
738
  await launchd.disableRestart(runDescriptor(state), "active run time budget exhausted");
684
739
  await stopAll("budget");
@@ -688,7 +743,12 @@ export async function superviseNativeRun(root, runId) {
688
743
  accumulatedMs: activeWallTimeMs(current.budget.activeIntervals, Date.now()), lastObservedAt: new Date().toISOString() } }));
689
744
  await schedule();
690
745
  state = (await repository.read(runId));
691
- for (const worker of state.workers.filter(w => ["running", "idle"].includes(w.status))) {
746
+ for (const candidateWorker of state.workers.filter(w => ["running", "idle"].includes(w.status))) {
747
+ const freshState = (await repository.read(runId));
748
+ const currentWorker = freshState.workers.find(w => w.workerId === candidateWorker.workerId);
749
+ if (currentWorker === undefined || !["running", "idle"].includes(currentWorker.status))
750
+ continue;
751
+ const worker = currentWorker;
692
752
  await supervisor.heartbeat(runId, worker.workerId, worker.generation);
693
753
  const observation = await supervisor.observeWorker(runId, worker.workerId, worker.generation);
694
754
  if (!observation.processAlive) {
@@ -888,7 +948,7 @@ export async function superviseNativeRun(root, runId) {
888
948
  await recordRunPhase(repository, runId, worker.workerId, "verifying");
889
949
  const result = await advance(current);
890
950
  if (result.status !== "ok")
891
- throw new AgentOpsError(result.code, "Final proof failed; inspect saved verifier and fresh review reports before repair.");
951
+ throw runStepError(result, "Final proof failed; inspect saved verifier and fresh review reports before repair");
892
952
  const data = result.data;
893
953
  if (!plain(data) || typeof data.receipt !== "string")
894
954
  throw new AgentOpsError("RUN_RECEIPT_REQUIRED", "Finalization requires the receipt returned by the existing finish gate.");
@@ -904,9 +964,8 @@ export async function superviseNativeRun(root, runId) {
904
964
  const stopped = latest.workers.find(w => w.workerId === worker.workerId);
905
965
  if (request.action === "finalize" && latest.integration !== null && (latest.integration.status !== "prepared" ||
906
966
  (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 };
967
+ // Preserve the failed step; a manual resume reconciles the sealed transaction after remediation.
968
+ throw cause;
910
969
  }
911
970
  else if (request.action === "policy" && await discoverPendingPolicyTransition(repository, runId) !== null) {
912
971
  await repository.appendEvent(runId, { type: "diagnostic", code: "RUN_POLICY_RECOVERY_REQUIRED", workerId: worker.workerId, taskId: worker.taskId,
@@ -936,7 +995,7 @@ export async function superviseNativeRun(root, runId) {
936
995
  }
937
996
  catch (cause) {
938
997
  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." });
998
+ workerId: null, taskId: null, detail: cause instanceof AgentOpsError ? cause.message : "Supervisor stopped; worktree and proof artifacts are preserved." });
940
999
  try {
941
1000
  await stopAll("crash");
942
1001
  }
@@ -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,
@@ -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, provider limits and DEBUG budgets 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.",
@@ -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
  });
@@ -52,7 +52,7 @@ export function buildOfficeSnapshot(input) {
52
52
  ...(rootTaskId === null ? {} : { progress: rootTree?.progress ?? rootTask?.progress ?? null,
53
53
  ...(rootTree?.criteria === undefined ? {} : { criteria: rootTree.criteria }) }),
54
54
  status: run.status, phase: run.phase ?? "unknown",
55
- budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: Math.max(0, run.budget.limitMs - usedMs) },
55
+ budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: run.budget.limitMs === null ? null : Math.max(0, run.budget.limitMs - usedMs) },
56
56
  questions, agents, reviewers,
57
57
  ...(run.status === "complete" || run.status === "failed" ? { completedAt: run.updatedAt } : {}),
58
58
  commands: [`agent-ops run status ${run.runId}`, `agent-ops run logs ${run.runId}`,