@kylecheng3146/agent-ops 0.6.0 → 0.7.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 +55 -1
- package/dist/packages/cli/src/args.js +19 -2
- package/dist/packages/cli/src/bin.js +14 -4
- package/dist/packages/cli/src/cli.js +20 -2
- package/dist/packages/cli/src/codex-loop-process.js +19 -2
- package/dist/packages/cli/src/commands/advance.js +19 -2
- package/dist/packages/cli/src/commands/init.js +2 -0
- package/dist/packages/cli/src/commands/office.js +20 -0
- package/dist/packages/cli/src/commands/run.js +6 -2
- package/dist/packages/cli/src/commands/update.js +8 -0
- package/dist/packages/cli/src/context.js +1 -0
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +23 -2
- package/dist/packages/cli/src/loop-entry.js +2 -1
- package/dist/packages/cli/src/office-entry.js +230 -0
- package/dist/packages/cli/src/run-deps.js +9 -2
- package/dist/packages/cli/src/run-entry.js +9 -0
- package/dist/packages/cli/src/wizard.js +14 -2
- package/dist/runtime/src/adapters/agy/config.js +4 -3
- package/dist/runtime/src/adapters/agy/events.js +9 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -3
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -3
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/opencode/config.js +5 -4
- package/dist/runtime/src/adapters/opencode/events.js +8 -0
- package/dist/runtime/src/config/explain.js +5 -0
- package/dist/runtime/src/hooks/codex-loop.js +4 -0
- package/dist/runtime/src/install/harness.js +44 -1
- package/dist/runtime/src/install/plan.js +16 -11
- package/dist/runtime/src/install/preauth.js +44 -18
- package/dist/runtime/src/install/profiles.js +8 -2
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/office/collect.js +233 -0
- package/dist/runtime/src/office/page.js +873 -0
- package/dist/runtime/src/office/scene.js +168 -0
- package/dist/runtime/src/office/server.js +139 -0
- package/dist/runtime/src/office/sessions.js +115 -0
- package/dist/runtime/src/office/snapshot.js +107 -0
- package/dist/runtime/src/parallel/guard.js +7 -1
- package/dist/runtime/src/review/slots.js +1 -1
- package/dist/runtime/src/run/phase.js +37 -0
- package/dist/runtime/src/run/service.js +8 -0
- package/dist/runtime/src/schema/validate.js +13 -2
- package/docs/en/spec/harness-adapters.md +113 -1
- package/docs/zh-TW/spec/harness-adapters.md +93 -1
- package/package.json +1 -1
- package/schemas/config.schema.json +10 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure snapshot -> scene mapping in tile units. Self-contained on purpose: the
|
|
3
|
+
* page embeds this exact function with Function.prototype.toString, so it may
|
|
4
|
+
* not reference imports or module-level values.
|
|
5
|
+
*/
|
|
6
|
+
export function sceneModel(snapshot) {
|
|
7
|
+
const ROOM_COLS = 72, ROOM_ROWS = 40, OVERVIEW_COLS = 80, OVERVIEW_ROWS = 38;
|
|
8
|
+
const PHASES = ["planning", "implementing", "verifying", "reviewing", "integrating"];
|
|
9
|
+
const labels = {
|
|
10
|
+
planning: "Planning", implementing: "Implementing", verifying: "Verifying", reviewing: "Reviewing", integrating: "Integrating", unknown: "Unassigned"
|
|
11
|
+
};
|
|
12
|
+
const areas = {
|
|
13
|
+
planning: { x: 3, y: 8, width: 17, height: 12, prop: "whiteboard" },
|
|
14
|
+
implementing: { x: 3, y: 23, width: 29, height: 11, prop: "desk" },
|
|
15
|
+
verifying: { x: 49, y: 8, width: 20, height: 12, prop: "bench" },
|
|
16
|
+
reviewing: { x: 38, y: 23, width: 30, height: 11, prop: "table" },
|
|
17
|
+
integrating: { x: 22, y: 34, width: 25, height: 5, prop: "door" }
|
|
18
|
+
};
|
|
19
|
+
const position = (phase, index, count = 1) => {
|
|
20
|
+
const area = areas[phase] ?? areas.implementing;
|
|
21
|
+
// Keep every actor inside the phase area, even when a run has many
|
|
22
|
+
// workers in one phase. Fractional spacing compresses a crowd without
|
|
23
|
+
// dropping actors or sending them through the room walls.
|
|
24
|
+
const inset = 2, columns = Math.max(1, Math.min(count, Math.floor((area.width - inset * 2) / 3)));
|
|
25
|
+
const rows = Math.max(1, Math.ceil(count / columns));
|
|
26
|
+
const xSpan = Math.max(0, area.width - inset * 2), ySpan = Math.max(0, area.height - inset * 2);
|
|
27
|
+
const column = index % columns, row = Math.floor(index / columns);
|
|
28
|
+
return { x: area.x + inset + (columns === 1 ? xSpan * .35 : column * xSpan / (columns - 1)),
|
|
29
|
+
y: area.y + inset + (rows === 1 ? ySpan * .55 : row * ySpan / (rows - 1)) };
|
|
30
|
+
};
|
|
31
|
+
const progressOf = (value) => {
|
|
32
|
+
if (value === null || typeof value !== "object")
|
|
33
|
+
return null;
|
|
34
|
+
const p = value;
|
|
35
|
+
return { passed: typeof p.passed === "number" ? p.passed : 0, total: typeof p.total === "number" ? p.total : 0,
|
|
36
|
+
verify: p.verify === "PASS" || p.verify === "FAIL" ? p.verify : null, review: p.review === "PASS" || p.review === "FAIL" ? p.review : null };
|
|
37
|
+
};
|
|
38
|
+
const short = (value, limit = 18) => value.length > limit ? value.slice(0, limit - 1) + "…" : value;
|
|
39
|
+
const optional = (value) => value !== null && typeof value === "object" ? value : {};
|
|
40
|
+
const boardFor = (progress, status, taskId, pending) => ({
|
|
41
|
+
passed: progress?.passed ?? 0, total: progress?.total ?? 0,
|
|
42
|
+
verify: progress?.verify ?? "pending", review: progress?.review ?? "pending", pending, status, taskId
|
|
43
|
+
});
|
|
44
|
+
const floors = [];
|
|
45
|
+
const dialogue = [];
|
|
46
|
+
const makeAreas = () => PHASES.map(phase => {
|
|
47
|
+
const area = areas[phase];
|
|
48
|
+
return { phase, x: area.x, y: area.y, width: area.width, height: area.height, prop: area.prop, label: labels[phase] };
|
|
49
|
+
});
|
|
50
|
+
const makeProps = (key) => [
|
|
51
|
+
{ kind: "window", x: 56, y: 1, key: key + ":window" },
|
|
52
|
+
{ kind: "whiteboard", x: 3, y: 10, key: key + ":planning", phase: "planning" },
|
|
53
|
+
{ kind: "desk", x: 5, y: 25, key: key + ":implementing", phase: "implementing" },
|
|
54
|
+
{ kind: "bench", x: 49, y: 10, key: key + ":verifying", phase: "verifying" },
|
|
55
|
+
{ kind: "table", x: 40, y: 25, key: key + ":reviewing", phase: "reviewing" },
|
|
56
|
+
{ kind: "desk", x: 24, y: 35, key: key + ":integrating", phase: "integrating" },
|
|
57
|
+
{ kind: "chair", x: 13, y: 30, key: key + ":chair" },
|
|
58
|
+
{ kind: "shelf", x: 2, y: 29, key: key + ":shelf" },
|
|
59
|
+
{ kind: "shelf", x: 34, y: 20, key: key + ":shelf-middle" },
|
|
60
|
+
{ kind: "plant", x: 2, y: 6, key: key + ":plant-left" },
|
|
61
|
+
{ kind: "plant", x: 22, y: 16, key: key + ":plant-middle" },
|
|
62
|
+
{ kind: "plant", x: 65, y: 6, key: key + ":plant-right" },
|
|
63
|
+
{ kind: "plant", x: 57, y: 34, key: key + ":plant-bottom" },
|
|
64
|
+
{ kind: "door", x: 65, y: 33, key: key + ":door" }
|
|
65
|
+
];
|
|
66
|
+
const makeRun = (run) => {
|
|
67
|
+
const extra = optional(run);
|
|
68
|
+
const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
|
|
69
|
+
const questions = run.questions;
|
|
70
|
+
const props = makeProps(run.runId);
|
|
71
|
+
const actors = [];
|
|
72
|
+
const papers = { [run.runId + ":implementing"]: Math.min(8, run.agents.reduce((sum, agent) => sum + (agent.diff?.files ?? 0), 0)) };
|
|
73
|
+
let passed = 0, total = 0, verify = null, review = null;
|
|
74
|
+
run.agents.forEach((agent, index) => {
|
|
75
|
+
const phase = agent.phase;
|
|
76
|
+
const p = progressOf(agent.progress);
|
|
77
|
+
const phaseIndex = actors.filter(actor => actor.phase === phase).length;
|
|
78
|
+
const phaseCount = run.agents.filter(candidate => candidate.phase === phase).length;
|
|
79
|
+
const point = position(phase, phaseIndex, phaseCount);
|
|
80
|
+
if ((phase === "implementing" || phase === "unknown") && phaseIndex > 0) {
|
|
81
|
+
const deskKey = run.runId + ":desk:" + agent.id;
|
|
82
|
+
props.push({ kind: "desk", x: point.x - 2, y: point.y - 5, key: deskKey, phase: "implementing", label: short(agent.id) });
|
|
83
|
+
papers[deskKey] = Math.min(8, agent.diff?.files ?? 0);
|
|
84
|
+
}
|
|
85
|
+
passed += p?.passed ?? 0;
|
|
86
|
+
total += p?.total ?? 0;
|
|
87
|
+
if (p?.verify === "FAIL")
|
|
88
|
+
verify = "FAIL";
|
|
89
|
+
else if (p?.verify === "PASS" && verify === null)
|
|
90
|
+
verify = "PASS";
|
|
91
|
+
if (p?.review === "FAIL")
|
|
92
|
+
review = "FAIL";
|
|
93
|
+
else if (p?.review === "PASS" && review === null)
|
|
94
|
+
review = "PASS";
|
|
95
|
+
actors.push({ key: run.runId + ":" + agent.id, id: agent.id, kind: agent.role, x: point.x, y: point.y, alert: agent.role === "coordinator" && questions.length > 0,
|
|
96
|
+
label: short(agent.id), status: agent.status, phase, taskId: agent.taskId || "unassigned", progress: p,
|
|
97
|
+
narration: agent.narration, questionCount: agent.role === "coordinator" ? questions.length : 0, host: "unknown" });
|
|
98
|
+
dialogue.push(`${run.title}: ${agent.id} ${agent.narration}.`);
|
|
99
|
+
});
|
|
100
|
+
run.reviewers.forEach((reviewer, index) => {
|
|
101
|
+
const reviewerIndex = actors.filter(actor => actor.phase === "reviewing").length;
|
|
102
|
+
const reviewerCount = run.agents.filter(agent => agent.phase === "reviewing").length + run.reviewers.length;
|
|
103
|
+
const point = position("reviewing", reviewerIndex, reviewerCount);
|
|
104
|
+
actors.push({ key: run.runId + ":review:" + reviewer.slot, id: "reviewer " + reviewer.slot, kind: "reviewer", x: point.x, y: point.y, alert: false,
|
|
105
|
+
label: "reviewer " + reviewer.slot, status: "reviewing", phase: "reviewing", taskId: reviewer.taskId ?? "unassigned",
|
|
106
|
+
progress: null, narration: "reviewing", questionCount: 0, host: "unknown" });
|
|
107
|
+
});
|
|
108
|
+
const rootProgress = { passed, total, verify, review };
|
|
109
|
+
const taskId = run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
|
|
110
|
+
if (questions.length > 0)
|
|
111
|
+
dialogue.push(`${run.title}: waiting for your answer — ${questions[0].prompt}`);
|
|
112
|
+
return { key: run.runId, sourceIndex: null, kind: "run", title: short(run.title, 34), top: 0, props, actors, phaseAreas: makeAreas(), questions,
|
|
113
|
+
board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: passed, total },
|
|
114
|
+
clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 0, phase: run.phase, status: run.status,
|
|
115
|
+
completedAt, overview: { x: 0, y: 0, width: 0, height: 0 } };
|
|
116
|
+
};
|
|
117
|
+
const makeDesk = (desk, index) => {
|
|
118
|
+
const extra = optional(desk);
|
|
119
|
+
const phase = extra.phase === "planning" || extra.phase === "implementing" || extra.phase === "verifying" || extra.phase === "reviewing" || extra.phase === "integrating" ? extra.phase : "unknown";
|
|
120
|
+
const status = typeof extra.status === "string" ? extra.status : desk.narration;
|
|
121
|
+
const taskId = typeof extra.taskId === "string" && extra.taskId.length > 0 ? extra.taskId : "unassigned";
|
|
122
|
+
const p = progressOf(extra.progress);
|
|
123
|
+
const questionsValue = Array.isArray(extra.questions) ? extra.questions : [];
|
|
124
|
+
const questions = questionsValue.map(value => {
|
|
125
|
+
const item = optional(value);
|
|
126
|
+
return { questionId: typeof item.questionId === "string" ? item.questionId : "unknown", prompt: typeof item.prompt === "string" ? item.prompt : "" };
|
|
127
|
+
});
|
|
128
|
+
const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
|
|
129
|
+
const key = "session:" + [desk.sessionId || "unknown", desk.branch || "unknown", desk.name || "session"].join(":");
|
|
130
|
+
const props = makeProps(key);
|
|
131
|
+
const point = position(phase, 0, 1);
|
|
132
|
+
const host = typeof extra.host === "string" ? extra.host : "unknown";
|
|
133
|
+
const actor = { key: key + ":actor", id: desk.name, kind: "worker", x: point.x, y: point.y, alert: questions.length > 0, label: short(desk.name), status,
|
|
134
|
+
phase, taskId, progress: p, narration: desk.narration, questionCount: questions.length, host };
|
|
135
|
+
if (questions.length > 0)
|
|
136
|
+
dialogue.push(`${desk.name}: waiting for your answer.`);
|
|
137
|
+
dialogue.push(`${desk.name}: ${desk.narration}.`);
|
|
138
|
+
return { key, sourceIndex: index, kind: "desk", title: short(desk.name, 34), top: 0, props, actors: [actor], phaseAreas: makeAreas(),
|
|
139
|
+
board: boardFor(p, status, taskId, questions.length), questions, papers: { [key + ":implementing"]: Math.min(8, desk.diff.files) }, books: { lit: p?.passed ?? 0, total: p?.total ?? 0 },
|
|
140
|
+
clock: 1, phase, status, completedAt, overview: { x: 0, y: 0, width: 0, height: 0 } };
|
|
141
|
+
};
|
|
142
|
+
snapshot.runs.forEach(run => floors.push(makeRun(run)));
|
|
143
|
+
snapshot.lobby.forEach((desk, index) => floors.push(makeDesk(desk, index)));
|
|
144
|
+
snapshot.reviews.forEach((review, index) => {
|
|
145
|
+
const key = "review:" + review.slot + ":" + index;
|
|
146
|
+
const props = makeProps(key);
|
|
147
|
+
const point = position("reviewing", 0);
|
|
148
|
+
const actor = { key: key + ":actor", id: "reviewer " + review.slot, kind: "reviewer", x: point.x, y: point.y, alert: false, label: "reviewer " + review.slot,
|
|
149
|
+
status: "reviewing", phase: "reviewing", taskId: review.taskId ?? "unassigned", progress: null,
|
|
150
|
+
narration: "reviewing", questionCount: 0, host: "unknown" };
|
|
151
|
+
dialogue.push(`Review slot ${review.slot}: ${review.taskId ?? "unassigned"}.`);
|
|
152
|
+
floors.push({ key, sourceIndex: index, kind: "review", title: "Review slot " + review.slot, top: 0, props, actors: [actor], phaseAreas: makeAreas(), questions: [],
|
|
153
|
+
board: boardFor(null, "reviewing", review.taskId ?? "unassigned", 0), papers: {}, books: { lit: 0, total: 0 }, clock: 1,
|
|
154
|
+
phase: "reviewing", status: "reviewing", completedAt: null, overview: { x: 0, y: 0, width: 0, height: 0 } });
|
|
155
|
+
});
|
|
156
|
+
if (dialogue.length === 0)
|
|
157
|
+
dialogue.push("The office is quiet. No agent is at work.");
|
|
158
|
+
const columns = floors.length === 0 ? 1 : Math.min(floors.length, Math.max(1, Math.ceil(Math.sqrt(floors.length * OVERVIEW_COLS / OVERVIEW_ROWS))));
|
|
159
|
+
const rows = Math.max(1, Math.ceil(floors.length / columns));
|
|
160
|
+
const gap = Math.max(0, Math.min(1, (OVERVIEW_COLS - columns) / (columns + 1), (OVERVIEW_ROWS - rows) / (rows + 1)));
|
|
161
|
+
const cardWidth = Math.max(0.25, (OVERVIEW_COLS - gap * (columns + 1)) / columns);
|
|
162
|
+
const cardHeight = Math.max(0.25, (OVERVIEW_ROWS - gap * (rows + 1)) / rows);
|
|
163
|
+
floors.forEach((floor, index) => {
|
|
164
|
+
const x = gap + (index % columns) * (cardWidth + gap), y = gap + Math.floor(index / columns) * (cardHeight + gap);
|
|
165
|
+
floor.overview = { x, y, width: cardWidth, height: cardHeight };
|
|
166
|
+
});
|
|
167
|
+
return { cols: OVERVIEW_COLS, rows: OVERVIEW_ROWS, roomCols: ROOM_COLS, roomRows: ROOM_ROWS, floors, dialogue };
|
|
168
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
|
+
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
7
|
+
import { officePage } from "./page.js";
|
|
8
|
+
export const OFFICE_IDLE_MS = 10 * 60 * 1000;
|
|
9
|
+
const ACTIVE = new Set(["active", "awaiting-input", "stopping"]);
|
|
10
|
+
export const officeRecordPath = (commonDir) => join(commonDir, "agent-ops", "office.json");
|
|
11
|
+
const officeStartLockPath = (commonDir) => join(commonDir, "agent-ops", "office-start.lock");
|
|
12
|
+
export const officeUrl = (record) => `http://127.0.0.1:${record.port}/?token=${record.token}`;
|
|
13
|
+
function same(a, b) {
|
|
14
|
+
const left = Buffer.from(a), right = Buffer.from(b);
|
|
15
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
16
|
+
}
|
|
17
|
+
function send(res, status, type, body, headers = {}) {
|
|
18
|
+
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
|
|
19
|
+
"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", ...headers });
|
|
20
|
+
res.end(body);
|
|
21
|
+
}
|
|
22
|
+
/** Loopback-only, token-gated, GET-only view of the building. */
|
|
23
|
+
export function createOfficeServer(options) {
|
|
24
|
+
const token = options.token ?? randomBytes(24).toString("base64url");
|
|
25
|
+
const now = options.now ?? Date.now;
|
|
26
|
+
const idleMs = options.idleMs ?? OFFICE_IDLE_MS;
|
|
27
|
+
let lastActive = now();
|
|
28
|
+
let port = 0;
|
|
29
|
+
let idle = false;
|
|
30
|
+
const handle = async (req, res) => {
|
|
31
|
+
// DNS-rebinding guard: a foreign name resolving to loopback still carries its own Host.
|
|
32
|
+
if (req.headers.host !== `127.0.0.1:${port}`)
|
|
33
|
+
return send(res, 421, "text/plain", "Misdirected request\n");
|
|
34
|
+
if (req.method !== "GET")
|
|
35
|
+
return send(res, 405, "text/plain", "Method not allowed\n", { Allow: "GET" });
|
|
36
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
37
|
+
if (!same(url.searchParams.get("token") ?? "", token))
|
|
38
|
+
return send(res, 403, "text/plain", "Forbidden\n");
|
|
39
|
+
if (url.pathname === "/") {
|
|
40
|
+
const nonce = randomBytes(16).toString("base64");
|
|
41
|
+
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'` });
|
|
42
|
+
}
|
|
43
|
+
if (url.pathname === "/snapshot.json")
|
|
44
|
+
return send(res, 200, "application/json", JSON.stringify(await options.snapshot()));
|
|
45
|
+
return send(res, 404, "text/plain", "Not found\n");
|
|
46
|
+
};
|
|
47
|
+
const server = createServer((req, res) => {
|
|
48
|
+
handle(req, res).catch(() => { if (!res.headersSent)
|
|
49
|
+
send(res, 500, "text/plain", "Snapshot unavailable\n");
|
|
50
|
+
else
|
|
51
|
+
res.end(); });
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
server, token,
|
|
55
|
+
listen: async () => await new Promise((resolve, reject) => {
|
|
56
|
+
server.once("error", reject);
|
|
57
|
+
server.listen(0, "127.0.0.1", () => {
|
|
58
|
+
const address = server.address();
|
|
59
|
+
port = typeof address === "object" && address !== null ? address.port : 0;
|
|
60
|
+
resolve(port);
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
tick: async () => {
|
|
64
|
+
const snapshot = await options.snapshot().catch(() => null);
|
|
65
|
+
const sessionIsPresent = snapshot?.lobby.some(desk => (desk.sessionActive ?? desk.status === "active") && (desk.completedAt === undefined || desk.completedAt === null)) ?? false;
|
|
66
|
+
if (snapshot === null || snapshot.runs.some(run => ACTIVE.has(run.status)) || sessionIsPresent)
|
|
67
|
+
lastActive = now();
|
|
68
|
+
if (!idle && now() - lastActive >= idleMs) {
|
|
69
|
+
idle = true;
|
|
70
|
+
options.onIdle();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
close: async () => await new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()); })
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function alive(pid) {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return error.code === "EPERM";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** A recorded server counts only if its process lives and it answers with its own token. */
|
|
86
|
+
export async function readLiveOffice(commonDir) {
|
|
87
|
+
const source = await readPrivateFile(officeRecordPath(commonDir), commonDir).catch(() => null);
|
|
88
|
+
if (source === null)
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
const record = JSON.parse(source);
|
|
92
|
+
if (!Number.isSafeInteger(record.pid) || !Number.isSafeInteger(record.port) || typeof record.token !== "string" || !alive(record.pid))
|
|
93
|
+
return null;
|
|
94
|
+
const response = await fetch(`http://127.0.0.1:${record.port}/snapshot.json?token=${encodeURIComponent(record.token)}`, { signal: AbortSignal.timeout(2000) });
|
|
95
|
+
await response.body?.cancel();
|
|
96
|
+
return response.status === 200 ? record : null;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Starts serving unless another live server already owns the repository.
|
|
104
|
+
* Returns null when this process should exit because one is already running.
|
|
105
|
+
*/
|
|
106
|
+
export async function claimOffice(commonDir, office, pid = process.pid) {
|
|
107
|
+
return await withPrivateFileLock(officeRecordPath(commonDir), commonDir, async () => {
|
|
108
|
+
if (await readLiveOffice(commonDir) !== null)
|
|
109
|
+
return null;
|
|
110
|
+
const record = { pid, port: await office.listen(), token: office.token, startedAt: new Date().toISOString() };
|
|
111
|
+
await writePrivateFile(officeRecordPath(commonDir), JSON.stringify(record) + "\n", commonDir);
|
|
112
|
+
return record;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Reuse the live server, or start one and wait for it to record itself. */
|
|
116
|
+
export async function ensureOffice(commonDir, start, options = {}) {
|
|
117
|
+
return await withPrivateFileLock(officeStartLockPath(commonDir), commonDir, async () => {
|
|
118
|
+
const live = await readLiveOffice(commonDir);
|
|
119
|
+
if (live !== null)
|
|
120
|
+
return officeUrl(live);
|
|
121
|
+
await start();
|
|
122
|
+
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
|
|
123
|
+
while (Date.now() < deadline) {
|
|
124
|
+
await new Promise(resolve => setTimeout(resolve, options.pollMs ?? 200));
|
|
125
|
+
const started = await readLiveOffice(commonDir);
|
|
126
|
+
if (started !== null)
|
|
127
|
+
return officeUrl(started);
|
|
128
|
+
}
|
|
129
|
+
throw new AgentOpsError("OFFICE_START_FAILED", "The office server did not record itself in time.");
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Drop the record on exit, unless another server has replaced it. */
|
|
133
|
+
export async function releaseOffice(commonDir, record) {
|
|
134
|
+
await withPrivateFileLock(officeRecordPath(commonDir), commonDir, async () => {
|
|
135
|
+
const source = await readPrivateFile(officeRecordPath(commonDir), commonDir).catch(() => null);
|
|
136
|
+
if (source !== null && JSON.parse(source).token === record.token)
|
|
137
|
+
await rm(officeRecordPath(commonDir), { force: true });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
3
|
+
export const OFFICE_SESSION_STALE_MS = 30 * 60 * 1000;
|
|
4
|
+
export const OFFICE_SESSION_RETENTION_MS = 2 * 60 * 60 * 1000;
|
|
5
|
+
const OFFICE_SESSION_SCHEMA_VERSION = 1;
|
|
6
|
+
const MAX_SESSIONS = 512;
|
|
7
|
+
const MAX_TEXT = 256;
|
|
8
|
+
const MAX_ROOT = 4_096;
|
|
9
|
+
export const officeSessionsPath = (commonDir) => join(commonDir, "agent-ops", "office-sessions.json");
|
|
10
|
+
function bounded(value, max) {
|
|
11
|
+
return typeof value === "string" && value.length > 0 && value.length <= max &&
|
|
12
|
+
!/[\0\r\n]/u.test(value);
|
|
13
|
+
}
|
|
14
|
+
function optional(value, max = MAX_TEXT) {
|
|
15
|
+
return value === undefined ? undefined : bounded(value, max) ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
function timestamp(value) {
|
|
18
|
+
return bounded(value, 64) && Number.isFinite(Date.parse(value));
|
|
19
|
+
}
|
|
20
|
+
function parseRecord(value) {
|
|
21
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
22
|
+
return null;
|
|
23
|
+
const item = value;
|
|
24
|
+
if (item.schemaVersion !== OFFICE_SESSION_SCHEMA_VERSION ||
|
|
25
|
+
!bounded(item.sessionId, MAX_TEXT) || !bounded(item.harness, MAX_TEXT) ||
|
|
26
|
+
!bounded(item.root, MAX_ROOT) || !timestamp(item.firstSeenAt) ||
|
|
27
|
+
!timestamp(item.lastSeenAt) || !["active", "idle"].includes(String(item.status))) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (item.completedAt !== undefined && item.completedAt !== null && !timestamp(item.completedAt))
|
|
31
|
+
return null;
|
|
32
|
+
const record = {
|
|
33
|
+
schemaVersion: 1,
|
|
34
|
+
sessionId: item.sessionId,
|
|
35
|
+
harness: item.harness,
|
|
36
|
+
root: item.root,
|
|
37
|
+
firstSeenAt: item.firstSeenAt,
|
|
38
|
+
lastSeenAt: item.lastSeenAt,
|
|
39
|
+
status: item.status,
|
|
40
|
+
...(optional(item.agentId) === undefined ? {} : { agentId: optional(item.agentId) }),
|
|
41
|
+
...(optional(item.runId) === undefined ? {} : { runId: optional(item.runId) }),
|
|
42
|
+
...(optional(item.workerId) === undefined ? {} : { workerId: optional(item.workerId) }),
|
|
43
|
+
...(optional(item.taskId) === undefined ? {} : { taskId: optional(item.taskId) }),
|
|
44
|
+
...(optional(item.phase) === undefined ? {} : { phase: optional(item.phase) }),
|
|
45
|
+
...(optional(item.host) === undefined ? {} : { host: optional(item.host) }),
|
|
46
|
+
...(item.completedAt === undefined ? {} : { completedAt: item.completedAt })
|
|
47
|
+
};
|
|
48
|
+
return record;
|
|
49
|
+
}
|
|
50
|
+
async function readRecords(commonDir) {
|
|
51
|
+
const source = await readPrivateFile(officeSessionsPath(commonDir), commonDir).catch(() => null);
|
|
52
|
+
if (source === null)
|
|
53
|
+
return [];
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(source);
|
|
56
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
57
|
+
return [];
|
|
58
|
+
const sessions = value.sessions;
|
|
59
|
+
if (!Array.isArray(sessions))
|
|
60
|
+
return [];
|
|
61
|
+
return sessions.slice(0, MAX_SESSIONS).map(parseRecord).filter((record) => record !== null);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function retained(record, now) {
|
|
68
|
+
const lastSeen = Date.parse(record.lastSeenAt);
|
|
69
|
+
if (!Number.isFinite(lastSeen))
|
|
70
|
+
return false;
|
|
71
|
+
if (record.status === "active") {
|
|
72
|
+
// ponytail: one TTL handles crashed hook clients; a persistent host heartbeat can replace it if needed.
|
|
73
|
+
return now - lastSeen <= OFFICE_SESSION_STALE_MS;
|
|
74
|
+
}
|
|
75
|
+
const completed = record.completedAt === null || record.completedAt === undefined
|
|
76
|
+
? lastSeen
|
|
77
|
+
: Date.parse(record.completedAt);
|
|
78
|
+
return Number.isFinite(completed) && now - completed < OFFICE_SESSION_RETENTION_MS;
|
|
79
|
+
}
|
|
80
|
+
function prune(records, now) {
|
|
81
|
+
return records.filter(record => retained(record, now)).slice(-MAX_SESSIONS);
|
|
82
|
+
}
|
|
83
|
+
export async function readOfficeSessions(commonDir, now = Date.now()) {
|
|
84
|
+
return prune(await readRecords(commonDir), now);
|
|
85
|
+
}
|
|
86
|
+
export async function recordOfficeSession(options) {
|
|
87
|
+
if (!bounded(options.sessionId, MAX_TEXT) || !bounded(options.harness, MAX_TEXT) ||
|
|
88
|
+
!bounded(options.projectRoot, MAX_ROOT) || !bounded(options.commonDir, MAX_ROOT))
|
|
89
|
+
return;
|
|
90
|
+
const now = options.now ?? Date.now();
|
|
91
|
+
const at = new Date(now).toISOString();
|
|
92
|
+
const path = officeSessionsPath(options.commonDir);
|
|
93
|
+
await withPrivateFileLock(path, options.commonDir, async () => {
|
|
94
|
+
const records = prune(await readRecords(options.commonDir), now);
|
|
95
|
+
const previous = records.find(record => record.sessionId === options.sessionId);
|
|
96
|
+
const next = {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
sessionId: options.sessionId,
|
|
99
|
+
harness: options.harness,
|
|
100
|
+
root: options.projectRoot,
|
|
101
|
+
firstSeenAt: previous?.firstSeenAt ?? at,
|
|
102
|
+
lastSeenAt: at,
|
|
103
|
+
status: options.event === "stop" ? "idle" : "active",
|
|
104
|
+
...(options.agentId ?? previous?.agentId ? { agentId: options.agentId ?? previous?.agentId } : {}),
|
|
105
|
+
...(options.runId ?? previous?.runId ? { runId: options.runId ?? previous?.runId } : {}),
|
|
106
|
+
...(options.workerId ?? previous?.workerId ? { workerId: options.workerId ?? previous?.workerId } : {}),
|
|
107
|
+
...(options.taskId ?? previous?.taskId ? { taskId: options.taskId ?? previous?.taskId } : {}),
|
|
108
|
+
...(options.phase ?? previous?.phase ? { phase: options.phase ?? previous?.phase } : {}),
|
|
109
|
+
...(options.host ?? previous?.host ? { host: options.host ?? previous?.host } : {}),
|
|
110
|
+
...(options.event === "stop" && previous?.completedAt !== undefined ? { completedAt: previous.completedAt } : {})
|
|
111
|
+
};
|
|
112
|
+
const without = records.filter(record => record.sessionId !== options.sessionId);
|
|
113
|
+
await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, sessions: [...without, next].slice(-MAX_SESSIONS) }) + "\n", options.commonDir);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { activeWallTimeMs } from "../run/scheduler.js";
|
|
2
|
+
const COMPLETED_RETENTION_MS = 2 * 60 * 60 * 1000;
|
|
3
|
+
const LIVE = new Set(["assigned", "starting", "running", "idle", "handing-off", "fenced", "delivered", "blocked"]);
|
|
4
|
+
const PHASES = new Set(["planning", "implementing", "verifying", "reviewing", "integrating", "unknown"]);
|
|
5
|
+
function phaseOf(value) {
|
|
6
|
+
return value !== undefined && PHASES.has(value) ? value : "unknown";
|
|
7
|
+
}
|
|
8
|
+
/** Decision 8: narration comes from changed paths only. */
|
|
9
|
+
export function narrate(diff) {
|
|
10
|
+
const path = diff?.recent ?? diff?.paths[0] ?? null;
|
|
11
|
+
if (path === null)
|
|
12
|
+
return null;
|
|
13
|
+
if (path.startsWith("docs/"))
|
|
14
|
+
return "writing docs";
|
|
15
|
+
if (path.startsWith("tests/"))
|
|
16
|
+
return "writing tests";
|
|
17
|
+
return "editing " + path;
|
|
18
|
+
}
|
|
19
|
+
function quote(text) {
|
|
20
|
+
return "'" + text.replaceAll("'", "'\\''") + "'";
|
|
21
|
+
}
|
|
22
|
+
/** Pure aggregation of runs, worktrees and review slots into one building. */
|
|
23
|
+
export function buildOfficeSnapshot(input) {
|
|
24
|
+
const byPath = new Map(input.worktrees.map(w => [w.path, w]));
|
|
25
|
+
const claimed = new Set();
|
|
26
|
+
const runs = input.runs.filter(run => !["complete", "failed"].includes(run.status) || input.now - Date.parse(run.updatedAt) < COMPLETED_RETENTION_MS)
|
|
27
|
+
.map((run) => {
|
|
28
|
+
const roots = new Set(run.workers.map(w => w.worktree).filter((p) => p !== null));
|
|
29
|
+
const reviewers = input.reviews.filter(r => r.root !== null && roots.has(r.root)).slice(0, 2);
|
|
30
|
+
reviewers.forEach(r => claimed.add(r));
|
|
31
|
+
const usedMs = activeWallTimeMs(run.budget.activeIntervals, input.now);
|
|
32
|
+
const agents = run.workers.filter(w => w.workerId === run.coordinatorId || LIVE.has(w.status)).map((w) => {
|
|
33
|
+
const tree = w.worktree === null ? undefined : byPath.get(w.worktree);
|
|
34
|
+
const phase = w.phase ?? "unknown";
|
|
35
|
+
return { id: w.workerId, role: w.workerId === run.coordinatorId ? "coordinator" : "worker", status: w.status, phase,
|
|
36
|
+
taskId: w.taskId, progress: run.tasks.find(t => t.taskId === w.taskId)?.progress ?? null,
|
|
37
|
+
worktree: tree?.name ?? null, diff: tree?.diff ?? null,
|
|
38
|
+
narration: narrate(tree?.diff ?? null) ?? (phase === "unknown" ? w.status : phase) };
|
|
39
|
+
});
|
|
40
|
+
const questions = run.questions.filter(q => q.answeredAt === null).map(q => ({ questionId: q.questionId, prompt: q.prompt }));
|
|
41
|
+
return { runId: run.runId, title: run.rootTaskId ?? run.runId,
|
|
42
|
+
status: run.status, phase: run.phase ?? "unknown",
|
|
43
|
+
budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: Math.max(0, run.budget.limitMs - usedMs) },
|
|
44
|
+
questions, agents, reviewers,
|
|
45
|
+
...(run.status === "complete" || run.status === "failed" ? { completedAt: run.updatedAt } : {}),
|
|
46
|
+
commands: [`agent-ops run status ${run.runId}`, `agent-ops run logs ${run.runId}`,
|
|
47
|
+
...questions.map(q => `agent-ops run respond ${run.runId} --question-id ${quote(q.questionId)} --answer "..."`),
|
|
48
|
+
`agent-ops run resume ${run.runId}`, `agent-ops run stop ${run.runId}`] };
|
|
49
|
+
});
|
|
50
|
+
const sessions = input.sessions ?? [];
|
|
51
|
+
const bySession = new Map(sessions.map(session => [session.sessionId, session]));
|
|
52
|
+
const runIds = new Set(input.runs.map(run => run.runId));
|
|
53
|
+
const runSessionIds = new Set(input.worktrees.filter(worktree => worktree.runId !== undefined).map(worktree => worktree.sessionId));
|
|
54
|
+
input.runs.flatMap(run => run.workers).forEach(worker => {
|
|
55
|
+
runSessionIds.add(worker.ownerSessionId);
|
|
56
|
+
if (worker.nativeSessionId !== null)
|
|
57
|
+
runSessionIds.add(worker.nativeSessionId);
|
|
58
|
+
});
|
|
59
|
+
const lobby = [];
|
|
60
|
+
const represented = new Set();
|
|
61
|
+
for (const worktree of input.worktrees) {
|
|
62
|
+
if (worktree.runId !== undefined)
|
|
63
|
+
continue;
|
|
64
|
+
const session = bySession.get(worktree.sessionId);
|
|
65
|
+
const completedAt = worktree.completedAt ?? session?.completedAt ?? null;
|
|
66
|
+
if (completedAt !== null && input.now - Date.parse(completedAt) >= COMPLETED_RETENTION_MS)
|
|
67
|
+
continue;
|
|
68
|
+
lobby.push({
|
|
69
|
+
name: worktree.name,
|
|
70
|
+
branch: worktree.branch,
|
|
71
|
+
sessionId: worktree.sessionId,
|
|
72
|
+
sessionActive: session?.status === "active" && completedAt === null,
|
|
73
|
+
diff: worktree.diff,
|
|
74
|
+
narration: narrate(worktree.diff) ?? worktree.phase ?? session?.phase ?? worktree.status ?? session?.status ?? "idle",
|
|
75
|
+
commands: ["agent-ops worktree list", `agent-ops worktree finish ${quote(worktree.name)}`],
|
|
76
|
+
...(worktree.phase === undefined && session?.phase === undefined ? {} : { phase: phaseOf(worktree.phase ?? session?.phase) }),
|
|
77
|
+
...(worktree.status === undefined && session?.taskStatus === undefined && session?.status === undefined ? {} : { status: worktree.status ?? session?.taskStatus ?? session?.status }),
|
|
78
|
+
...(worktree.taskId === undefined && session?.taskId === undefined ? {} : { taskId: worktree.taskId ?? session?.taskId ?? null }),
|
|
79
|
+
...(worktree.progress === undefined && session?.progress === undefined ? {} : { progress: worktree.progress ?? session?.progress ?? null }),
|
|
80
|
+
...(completedAt === null && session?.completedAt === undefined ? {} : { completedAt: completedAt ?? session?.completedAt ?? null }),
|
|
81
|
+
...(worktree.host === undefined && session?.host === undefined ? {} : { host: worktree.host ?? session?.host }),
|
|
82
|
+
});
|
|
83
|
+
represented.add(worktree.sessionId);
|
|
84
|
+
}
|
|
85
|
+
for (const session of sessions) {
|
|
86
|
+
if (represented.has(session.sessionId) || runSessionIds.has(session.sessionId) || (session.runId !== undefined && runIds.has(session.runId)))
|
|
87
|
+
continue;
|
|
88
|
+
represented.add(session.sessionId);
|
|
89
|
+
const completedAt = session.completedAt ?? null;
|
|
90
|
+
lobby.push({
|
|
91
|
+
name: session.sessionId,
|
|
92
|
+
branch: "(no worktree)",
|
|
93
|
+
sessionId: session.sessionId,
|
|
94
|
+
sessionActive: session.status === "active" && completedAt === null,
|
|
95
|
+
diff: { files: 0, insertions: 0, deletions: 0, paths: [], recent: null },
|
|
96
|
+
narration: session.phase === undefined ? session.taskStatus ?? session.status ?? "idle" : phaseOf(session.phase),
|
|
97
|
+
commands: [`agent-ops task status --session ${quote(session.sessionId)}`],
|
|
98
|
+
...(session.phase === undefined ? {} : { phase: phaseOf(session.phase) }),
|
|
99
|
+
status: session.taskStatus ?? session.status,
|
|
100
|
+
...(session.taskId === undefined ? {} : { taskId: session.taskId }),
|
|
101
|
+
...(session.progress === undefined ? {} : { progress: session.progress }),
|
|
102
|
+
...(completedAt === null ? {} : { completedAt }),
|
|
103
|
+
...(session.host === undefined ? {} : { host: session.host })
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return { generatedAt: new Date(input.now).toISOString(), runs, lobby, reviews: input.reviews.filter(r => !claimed.has(r)) };
|
|
107
|
+
}
|
|
@@ -36,6 +36,8 @@ export async function canonicalPath(path) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
/** The gitignored goal file the `run` profile writes in the main checkout. */
|
|
40
|
+
export const RUN_GOAL_FILE = join(".agent-ops", "state", "run-goal.md");
|
|
39
41
|
/**
|
|
40
42
|
* In worktree auto mode the main checkout is shared by every session, so no
|
|
41
43
|
* session writes it directly: each edit belongs in that session's own
|
|
@@ -46,9 +48,13 @@ export async function evaluateWorktreeWrite(mainRoot, paths, sessionId,
|
|
|
46
48
|
/** Creates or reuses this writer's worktree and returns its path. */
|
|
47
49
|
ensureWorktree,
|
|
48
50
|
/** The subagent making the write, when there is one: it has a worktree of its own. */
|
|
49
|
-
agentId
|
|
51
|
+
agentId,
|
|
52
|
+
/** The `auto-run` capability: `agent-ops run` starts from the main checkout. */
|
|
53
|
+
autoRun = false) {
|
|
50
54
|
for (const path of paths) {
|
|
51
55
|
const target = await canonicalPath(path);
|
|
56
|
+
if (autoRun && target === join(mainRoot, RUN_GOAL_FILE))
|
|
57
|
+
continue;
|
|
52
58
|
const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
|
|
53
59
|
if (inMain && insideWorktreeDirectory(mainRoot, target) && sessionId !== undefined) {
|
|
54
60
|
// A worktree belongs to the session that made it: writing into another
|
|
@@ -62,7 +62,7 @@ export async function withReviewSlot(options, action) {
|
|
|
62
62
|
for (;;) {
|
|
63
63
|
try {
|
|
64
64
|
await mkdir(path);
|
|
65
|
-
await writeFile(join(path, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now(), token }));
|
|
65
|
+
await writeFile(join(path, "owner.json"), JSON.stringify({ ...options.holder, pid: process.pid, at: Date.now(), token }));
|
|
66
66
|
return true;
|
|
67
67
|
}
|
|
68
68
|
catch (error) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Move one worker, and the run with it, into a phase. Unknown workers leave the state unchanged. */
|
|
2
|
+
export function withRunPhase(state, workerId, phase) {
|
|
3
|
+
if (!state.workers.some(w => w.workerId === workerId))
|
|
4
|
+
return state;
|
|
5
|
+
return { ...state, phase, workers: state.workers.map(w => w.workerId === workerId ? { ...w, phase } : w) };
|
|
6
|
+
}
|
|
7
|
+
/** Merge the latest verify or review observation into one task node. */
|
|
8
|
+
export function withTaskProgress(state, taskId, update) {
|
|
9
|
+
return { ...state, tasks: state.tasks.map(t => t.taskId !== taskId ? t :
|
|
10
|
+
{ ...t, progress: { verify: null, review: null, passed: 0, total: 0, ...t.progress, ...update } }) };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Passed/total criteria from one verify report: a command-backed criterion
|
|
14
|
+
* passes when every command result passed, a typed one when every replayed
|
|
15
|
+
* acceptance phase for it passed.
|
|
16
|
+
*/
|
|
17
|
+
export function criteriaProgress(criteria, report) {
|
|
18
|
+
const results = report.results ?? [];
|
|
19
|
+
const commandsPass = results.length > 0 && results.every(r => r.status === "PASS");
|
|
20
|
+
const passed = criteria.filter(c => {
|
|
21
|
+
if (c.acceptance === undefined)
|
|
22
|
+
return commandsPass;
|
|
23
|
+
const rows = (report.acceptance ?? []).filter(r => r.criterionId === c.id);
|
|
24
|
+
return rows.length > 0 && rows.every(r => r.status === "PASS");
|
|
25
|
+
}).length;
|
|
26
|
+
return { passed, total: criteria.length };
|
|
27
|
+
}
|
|
28
|
+
/** Phase writes are display data: a failed write must never fail the proof it describes. */
|
|
29
|
+
export async function recordRunPhase(repository, runId, workerId, phase, progress) {
|
|
30
|
+
try {
|
|
31
|
+
await repository.mutate(runId, state => {
|
|
32
|
+
const moved = withRunPhase(state, workerId, phase);
|
|
33
|
+
return progress === undefined ? moved : withTaskProgress(moved, progress.taskId, progress.update);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch { /* ponytail: display-only; the next transition rewrites it */ }
|
|
37
|
+
}
|
|
@@ -5,6 +5,7 @@ import { sha256 } from "../fs/hash.js";
|
|
|
5
5
|
import { AgentOpsError } from "../fs/paths.js";
|
|
6
6
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
7
7
|
import { budgetExpired } from "./scheduler.js";
|
|
8
|
+
export const RUN_PHASES = ["planning", "implementing", "verifying", "reviewing", "integrating"];
|
|
8
9
|
/** Writer slots are deliberately bounded; the coordinator may occupy one. */
|
|
9
10
|
export const MAX_ACTIVE_WORKERS = 2;
|
|
10
11
|
const RUN_ID = /^[a-z][a-z0-9-]{7,63}$/u;
|
|
@@ -113,6 +114,13 @@ export function assertRunState(value) {
|
|
|
113
114
|
return invalid("Persisted run state failed identity, budget, or ownership validation.");
|
|
114
115
|
}
|
|
115
116
|
validUsage(state.budget.usage);
|
|
117
|
+
const phase = (value) => value === undefined || RUN_PHASES.includes(value);
|
|
118
|
+
const count = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 64;
|
|
119
|
+
const result = (value) => value === null || value === "PASS" || value === "FAIL";
|
|
120
|
+
if (!phase(state.phase) || state.workers.some(w => !phase(w.phase)) || state.tasks.some(t => t.progress !== undefined &&
|
|
121
|
+
(!plain(t.progress) || !result(t.progress.verify) || !result(t.progress.review) || !count(t.progress.passed) ||
|
|
122
|
+
!count(t.progress.total) || t.progress.passed > t.progress.total)))
|
|
123
|
+
return invalid("Run phase or task progress is invalid.");
|
|
116
124
|
if (state.policyBinding !== undefined && !validPolicyBinding(state.policyBinding))
|
|
117
125
|
return invalid("Invalid run policy binding.");
|
|
118
126
|
if (state.proofProcess != null && (!plain(state.proofProcess) || !Number.isSafeInteger(state.proofProcess.processId) ||
|