@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.
- package/README.md +3 -2
- package/dist/packages/cli/src/args.js +18 -53
- package/dist/packages/cli/src/bin.js +28 -29
- package/dist/packages/cli/src/cli.js +25 -17
- package/dist/packages/cli/src/codex-loop-process.js +0 -6
- 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 +8 -11
- package/dist/packages/cli/src/commands/run.js +12 -3
- package/dist/packages/cli/src/commands/task.js +6 -50
- package/dist/packages/cli/src/commands/verify.js +7 -3
- package/dist/packages/cli/src/context.js +22 -4
- package/dist/packages/cli/src/default-base.js +26 -0
- package/dist/packages/cli/src/hook-process.js +19 -83
- 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 +123 -19
- package/dist/packages/cli/src/run-setup-entry.js +2 -2
- package/dist/runtime/src/adapters/agy/config.js +2 -3
- package/dist/runtime/src/adapters/claude/config.js +2 -2
- package/dist/runtime/src/adapters/codex/config.js +2 -5
- package/dist/runtime/src/hooks/completion-gate.js +0 -15
- package/dist/runtime/src/install/harness.js +7 -4
- package/dist/runtime/src/install/plan.js +3 -3
- package/dist/runtime/src/install/profiles.js +0 -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 +5 -2
- package/dist/runtime/src/parallel/finish.js +139 -65
- package/dist/runtime/src/parallel/manage.js +0 -3
- package/dist/runtime/src/parallel/service.js +5 -1
- package/dist/runtime/src/review/execute.js +77 -11
- 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/run/verification.js +2 -2
- package/dist/runtime/src/schema/validate.js +10 -5
- package/dist/runtime/src/security/permissions.js +85 -0
- package/dist/runtime/src/task/completion.js +4 -42
- package/dist/runtime/src/task/contract.js +11 -0
- package/dist/runtime/src/task/service.js +34 -57
- package/dist/runtime/src/task/store.js +3 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +2 -3
- package/dist/runtime/src/verify/acceptance-protocol.js +4 -3
- package/dist/runtime/src/verify/acceptance-replay.js +4 -7
- package/dist/runtime/src/verify/service.js +6 -23
- package/dist/runtime/src/verify/source-fingerprint.js +17 -4
- package/docs/en/guides/configuration.md +8 -4
- package/docs/en/guides/task-debug.md +39 -121
- package/docs/en/spec/acceptance-and-evidence.md +31 -19
- package/docs/en/spec/harness-adapters.md +35 -12
- package/docs/en/spec/loop-engineering.md +40 -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 +27 -66
- package/docs/zh-TW/spec/acceptance-and-evidence.md +19 -18
- package/docs/zh-TW/spec/harness-adapters.md +28 -9
- package/docs/zh-TW/spec/loop-engineering.md +14 -3
- package/docs/zh-TW/spec/review.md +5 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +1 -21
- package/dist/packages/cli/src/debug-intake.js +0 -245
- package/dist/runtime/src/task/debug-intake.js +0 -15
- package/dist/runtime/src/task/debug-state.js +0 -129
- package/dist/runtime/src/task/debug-user-event.js +0 -95
- 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
|
|
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"
|
|
295
|
-
throw
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
908
|
-
|
|
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 {
|
|
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,
|
|
@@ -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")
|
|
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")
|
|
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("
|
|
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")
|
|
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("
|
|
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.
|
|
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 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
|
|
365
|
-
lines.push("
|
|
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
|
-
|
|
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 :
|
|
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
|
});
|
|
@@ -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 =>
|
|
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}`,
|