@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.
- package/README.md +3 -2
- package/dist/packages/cli/src/args.js +10 -10
- package/dist/packages/cli/src/cli.js +14 -4
- package/dist/packages/cli/src/commands/advance.js +2 -1
- package/dist/packages/cli/src/commands/parallel.js +1 -1
- package/dist/packages/cli/src/commands/review.js +3 -1
- package/dist/packages/cli/src/commands/run.js +10 -3
- package/dist/packages/cli/src/context.js +22 -4
- package/dist/packages/cli/src/office-entry.js +8 -4
- package/dist/packages/cli/src/owned-run-step.js +4 -4
- package/dist/packages/cli/src/parallel-deps.js +0 -2
- package/dist/packages/cli/src/run-deps.js +12 -9
- package/dist/packages/cli/src/run-entry.js +76 -17
- package/dist/packages/cli/src/run-setup-entry.js +2 -2
- package/dist/runtime/src/install/harness.js +5 -2
- package/dist/runtime/src/office/scene.js +1 -1
- package/dist/runtime/src/office/server.js +43 -16
- package/dist/runtime/src/office/snapshot.js +1 -1
- package/dist/runtime/src/parallel/finish.js +139 -65
- package/dist/runtime/src/parallel/service.js +5 -1
- package/dist/runtime/src/review/execute.js +9 -7
- package/dist/runtime/src/review/packet.js +17 -6
- package/dist/runtime/src/review/runner.js +2 -0
- package/dist/runtime/src/run/controls.js +99 -1
- package/dist/runtime/src/run/hosts/codex.js +28 -1
- package/dist/runtime/src/run/hosts/protocol.js +5 -2
- package/dist/runtime/src/run/integration.js +35 -13
- package/dist/runtime/src/run/policy.js +65 -10
- package/dist/runtime/src/run/scheduler.js +10 -5
- package/dist/runtime/src/run/service.js +68 -21
- package/dist/runtime/src/run/supervisor.js +11 -2
- package/dist/runtime/src/run/transport.js +1 -0
- package/dist/runtime/src/security/permissions.js +85 -0
- package/dist/runtime/src/task/debug-state.js +9 -3
- package/dist/runtime/src/task/debug-user-event.js +82 -17
- package/dist/runtime/src/task/debug.js +35 -15
- package/docs/en/guides/configuration.md +8 -4
- package/docs/en/guides/task-debug.md +14 -2
- package/docs/en/spec/acceptance-and-evidence.md +4 -0
- package/docs/en/spec/harness-adapters.md +26 -1
- package/docs/en/spec/loop-engineering.md +34 -6
- package/docs/en/spec/review.md +6 -0
- package/docs/zh-TW/guides/configuration.md +6 -3
- package/docs/zh-TW/guides/task-debug.md +9 -2
- package/docs/zh-TW/spec/acceptance-and-evidence.md +2 -1
- package/docs/zh-TW/spec/harness-adapters.md +21 -1
- package/docs/zh-TW/spec/loop-engineering.md +9 -3
- package/docs/zh-TW/spec/review.md +5 -0
- package/package.json +1 -1
|
@@ -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"
|
|
295
|
-
throw
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
908
|
-
|
|
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 {
|
|
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 =
|
|
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.
|
|
344
|
-
"
|
|
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 :
|
|
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
|
-
|
|
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,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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}`,
|