@kylecheng3146/agent-ops 0.7.3 → 0.8.3
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 +2 -0
- package/dist/packages/cli/src/args.js +72 -1
- package/dist/packages/cli/src/bin.js +69 -10
- package/dist/packages/cli/src/cli.js +11 -3
- package/dist/packages/cli/src/codex-loop-process.js +6 -0
- package/dist/packages/cli/src/commands/batch.js +3 -1
- package/dist/packages/cli/src/commands/hook.js +4 -1
- package/dist/packages/cli/src/commands/review.js +9 -3
- package/dist/packages/cli/src/commands/task.js +44 -2
- package/dist/packages/cli/src/commands/update.js +18 -0
- package/dist/packages/cli/src/commands/verify.js +3 -1
- package/dist/packages/cli/src/debug-intake.js +245 -0
- package/dist/packages/cli/src/hook-process.js +155 -34
- package/dist/packages/cli/src/office-entry.js +34 -6
- package/dist/packages/cli/src/run-deps.js +8 -0
- package/dist/runtime/src/adapters/agy/config.js +3 -2
- package/dist/runtime/src/adapters/claude/config.js +7 -3
- package/dist/runtime/src/adapters/codex/config.js +10 -3
- package/dist/runtime/src/adapters/codex/events.js +2 -1
- package/dist/runtime/src/adapters/codex/input.js +20 -2
- package/dist/runtime/src/adapters/codex/output.js +4 -1
- package/dist/runtime/src/config/merge.js +1 -1
- package/dist/runtime/src/hooks/completion-gate.js +15 -0
- package/dist/runtime/src/install/harness.js +2 -0
- package/dist/runtime/src/install/plan.js +3 -3
- package/dist/runtime/src/install/profiles.js +2 -0
- package/dist/runtime/src/install/update.js +10 -2
- package/dist/runtime/src/office/activity.js +105 -0
- package/dist/runtime/src/office/art.js +1000 -0
- package/dist/runtime/src/office/collect.js +137 -10
- package/dist/runtime/src/office/layout.js +203 -0
- package/dist/runtime/src/office/page.js +883 -644
- package/dist/runtime/src/office/scene.js +38 -33
- package/dist/runtime/src/office/sessions.js +18 -1
- package/dist/runtime/src/office/snapshot.js +74 -1
- package/dist/runtime/src/parallel/finish.js +4 -2
- package/dist/runtime/src/parallel/manage.js +19 -2
- package/dist/runtime/src/review/attestation.js +4 -1
- package/dist/runtime/src/review/batch.js +10 -0
- package/dist/runtime/src/review/execute.js +10 -1
- package/dist/runtime/src/review/slots.js +59 -44
- package/dist/runtime/src/run/hosts/codex.js +1 -1
- package/dist/runtime/src/run/transport.js +3 -0
- package/dist/runtime/src/schema/validate.js +8 -2
- package/dist/runtime/src/security/permissions.js +3 -1
- package/dist/runtime/src/task/completion.js +20 -1
- package/dist/runtime/src/task/contract.js +1 -1
- package/dist/runtime/src/task/debug-intake.js +12 -0
- package/dist/runtime/src/task/debug-state.js +130 -0
- package/dist/runtime/src/task/debug-user-event.js +93 -0
- package/dist/runtime/src/task/debug.js +586 -0
- package/dist/runtime/src/task/service.js +46 -0
- package/dist/runtime/src/task/store.js +8 -1
- package/dist/runtime/src/verify/acceptance-replay.js +9 -4
- package/dist/runtime/src/verify/service.js +21 -2
- package/docs/en/guides/configuration.md +7 -0
- package/docs/en/guides/task-debug.md +111 -0
- package/docs/en/spec/harness-adapters.md +31 -4
- package/docs/zh-TW/guides/configuration.md +6 -0
- package/docs/zh-TW/guides/task-debug.md +66 -0
- package/docs/zh-TW/spec/harness-adapters.md +23 -4
- package/package.json +1 -1
- package/schemas/config.schema.json +30 -2
- package/schemas/manifest.schema.json +1 -0
- package/schemas/task.schema.json +62 -2
|
@@ -35,7 +35,7 @@ export function sceneModel(snapshot) {
|
|
|
35
35
|
return { passed: typeof p.passed === "number" ? p.passed : 0, total: typeof p.total === "number" ? p.total : 0,
|
|
36
36
|
verify: p.verify === "PASS" || p.verify === "FAIL" ? p.verify : null, review: p.review === "PASS" || p.review === "FAIL" ? p.review : null };
|
|
37
37
|
};
|
|
38
|
-
const short = (value, limit = 18) => value.length > limit ? value.slice(0, limit -
|
|
38
|
+
const short = (value, limit = 18) => value.length > limit ? value.slice(0, limit - 6) + "…" + value.slice(-5) : value;
|
|
39
39
|
const optional = (value) => value !== null && typeof value === "object" ? value : {};
|
|
40
40
|
const boardFor = (progress, status, taskId, pending) => ({
|
|
41
41
|
passed: progress?.passed ?? 0, total: progress?.total ?? 0,
|
|
@@ -49,7 +49,7 @@ export function sceneModel(snapshot) {
|
|
|
49
49
|
return typeof repo === "string" && repo.length > 0 ? repo : null;
|
|
50
50
|
};
|
|
51
51
|
const scoped = (repo, key) => repo === null ? key : "repo:" + repo + "|" + key;
|
|
52
|
-
const titled = (repo, title) =>
|
|
52
|
+
const titled = (repo, title) => repo === null ? title : repo + " · " + title;
|
|
53
53
|
const makeAreas = () => PHASES.map(phase => {
|
|
54
54
|
const area = areas[phase];
|
|
55
55
|
return { phase, x: area.x, y: area.y, width: area.width, height: area.height, prop: area.prop, label: labels[phase] };
|
|
@@ -70,7 +70,7 @@ export function sceneModel(snapshot) {
|
|
|
70
70
|
{ kind: "plant", x: 57, y: 34, key: key + ":plant-bottom" },
|
|
71
71
|
{ kind: "door", x: 65, y: 33, key: key + ":door" }
|
|
72
72
|
];
|
|
73
|
-
const makeRun = (run) => {
|
|
73
|
+
const makeRun = (run, index) => {
|
|
74
74
|
const extra = optional(run);
|
|
75
75
|
const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
|
|
76
76
|
const questions = run.questions;
|
|
@@ -95,34 +95,43 @@ export function sceneModel(snapshot) {
|
|
|
95
95
|
total += p?.total ?? 0;
|
|
96
96
|
if (p?.verify === "FAIL")
|
|
97
97
|
verify = "FAIL";
|
|
98
|
-
else if (p?.verify === "PASS" && verify === null)
|
|
99
|
-
verify = "PASS";
|
|
100
98
|
if (p?.review === "FAIL")
|
|
101
99
|
review = "FAIL";
|
|
102
|
-
else if (p?.review === "PASS" && review === null)
|
|
103
|
-
review = "PASS";
|
|
104
100
|
actors.push({ key: key + ":" + agent.id, id: agent.id, kind: agent.role, x: point.x, y: point.y, alert: agent.role === "coordinator" && questions.length > 0,
|
|
105
101
|
label: short(agent.id), status: agent.status, phase, taskId: agent.taskId || "unassigned", progress: p,
|
|
106
102
|
narration: agent.narration, questionCount: agent.role === "coordinator" ? questions.length : 0, host: "unknown" });
|
|
107
103
|
dialogue.push(`${run.title}: ${agent.id} ${agent.narration}.`);
|
|
108
104
|
});
|
|
109
|
-
run.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
105
|
+
if (verify === null && run.agents.length && run.agents.every(agent => agent.progress?.verify === "PASS"))
|
|
106
|
+
verify = "PASS";
|
|
107
|
+
if (review === null && run.agents.length && run.agents.every(agent => agent.progress?.review === "PASS"))
|
|
108
|
+
review = "PASS";
|
|
109
|
+
const rootProgress = Object.prototype.hasOwnProperty.call(extra, "progress") ? progressOf(extra.progress) : { passed, total, verify, review };
|
|
110
|
+
const criteria = (Array.isArray(extra.criteria) ? extra.criteria : []).map(value => {
|
|
111
|
+
const item = optional(value);
|
|
112
|
+
return { id: typeof item.id === "string" ? item.id : "unknown", description: typeof item.description === "string" ? item.description : "",
|
|
113
|
+
status: item.status === "PASS" || item.status === "FAIL" || item.status === "UNKNOWN" ? item.status : null,
|
|
114
|
+
finishedAt: typeof item.finishedAt === "string" ? item.finishedAt : null,
|
|
115
|
+
failureClass: typeof item.failureClass === "string" ? item.failureClass : null,
|
|
116
|
+
exitCode: typeof item.exitCode === "number" ? item.exitCode : null, output: typeof item.output === "string" ? item.output : null };
|
|
116
117
|
});
|
|
117
|
-
const
|
|
118
|
-
|
|
118
|
+
const taskId = typeof extra.taskId === "string" && extra.taskId.length > 0
|
|
119
|
+
? extra.taskId : run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
|
|
119
120
|
if (questions.length > 0)
|
|
120
121
|
dialogue.push(`${run.title}: waiting for your answer — ${questions[0].prompt}`);
|
|
121
|
-
return { key, sourceIndex:
|
|
122
|
-
|
|
122
|
+
return { key, sourceIndex: index, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions, criteria,
|
|
123
|
+
activities: activitiesOf(optional(run).activities),
|
|
124
|
+
board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: rootProgress?.passed ?? 0, total: rootProgress?.total ?? 0 },
|
|
123
125
|
clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 0, phase: run.phase, status: run.status,
|
|
124
126
|
completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
|
|
125
127
|
};
|
|
128
|
+
const activitiesOf = (value) => (Array.isArray(value) ? value : []).map(raw => {
|
|
129
|
+
const item = optional(raw);
|
|
130
|
+
return { kind: item.kind === "verify" || item.kind === "review" || item.kind === "finish" ? item.kind : "verify",
|
|
131
|
+
startedAt: typeof item.startedAt === "string" ? item.startedAt : "",
|
|
132
|
+
targets: Array.isArray(item.targets) ? item.targets.filter((t) => typeof t === "string").slice(0, 2) : [],
|
|
133
|
+
round: item.round === 1 || item.round === 2 ? item.round : null, target: typeof item.target === "string" ? item.target : null };
|
|
134
|
+
});
|
|
126
135
|
const makeDesk = (desk, index) => {
|
|
127
136
|
const extra = optional(desk);
|
|
128
137
|
const phase = extra.phase === "planning" || extra.phase === "implementing" || extra.phase === "verifying" || extra.phase === "reviewing" || extra.phase === "integrating" ? extra.phase : "unknown";
|
|
@@ -135,6 +144,15 @@ export function sceneModel(snapshot) {
|
|
|
135
144
|
return { questionId: typeof item.questionId === "string" ? item.questionId : "unknown", prompt: typeof item.prompt === "string" ? item.prompt : "" };
|
|
136
145
|
});
|
|
137
146
|
const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
|
|
147
|
+
// Inline checks, no named helper: the page embeds sceneModel via toString, so it cannot carry bundler helpers.
|
|
148
|
+
const criteria = (Array.isArray(extra.criteria) ? extra.criteria : []).map(value => {
|
|
149
|
+
const item = optional(value);
|
|
150
|
+
return { id: typeof item.id === "string" ? item.id : "unknown", description: typeof item.description === "string" ? item.description : "",
|
|
151
|
+
status: item.status === "PASS" || item.status === "FAIL" || item.status === "UNKNOWN" ? item.status : null,
|
|
152
|
+
finishedAt: typeof item.finishedAt === "string" ? item.finishedAt : null,
|
|
153
|
+
failureClass: typeof item.failureClass === "string" ? item.failureClass : null,
|
|
154
|
+
exitCode: typeof item.exitCode === "number" ? item.exitCode : null, output: typeof item.output === "string" ? item.output : null };
|
|
155
|
+
});
|
|
138
156
|
const repo = repoOf(desk);
|
|
139
157
|
const key = scoped(repo, "session:" + [desk.sessionId || "unknown", desk.branch || "unknown", desk.name || "session"].join(":"));
|
|
140
158
|
const props = makeProps(key);
|
|
@@ -146,24 +164,11 @@ export function sceneModel(snapshot) {
|
|
|
146
164
|
dialogue.push(`${desk.name}: waiting for your answer.`);
|
|
147
165
|
dialogue.push(`${desk.name}: ${desk.narration}.`);
|
|
148
166
|
return { key, sourceIndex: index, kind: "desk", title: titled(repo, desk.name), top: 0, props, actors: [actor], phaseAreas: makeAreas(),
|
|
149
|
-
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 },
|
|
167
|
+
board: boardFor(p, status, taskId, questions.length), questions, criteria, activities: activitiesOf(extra.activities), papers: { [key + ":implementing"]: Math.min(8, desk.diff.files) }, books: { lit: p?.passed ?? 0, total: p?.total ?? 0 },
|
|
150
168
|
clock: 1, phase, status, completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
|
|
151
169
|
};
|
|
152
|
-
snapshot.runs.forEach(run => floors.push(makeRun(run)));
|
|
170
|
+
snapshot.runs.forEach((run, index) => floors.push(makeRun(run, index)));
|
|
153
171
|
snapshot.lobby.forEach((desk, index) => floors.push(makeDesk(desk, index)));
|
|
154
|
-
snapshot.reviews.forEach((review, index) => {
|
|
155
|
-
const repo = repoOf(review);
|
|
156
|
-
const key = scoped(repo, "review:" + review.slot + ":" + index);
|
|
157
|
-
const props = makeProps(key);
|
|
158
|
-
const point = position("reviewing", 0);
|
|
159
|
-
const actor = { key: key + ":actor", id: "reviewer " + review.slot, kind: "reviewer", x: point.x, y: point.y, alert: false, label: "reviewer " + review.slot,
|
|
160
|
-
status: "reviewing", phase: "reviewing", taskId: review.taskId ?? "unassigned", progress: null,
|
|
161
|
-
narration: "reviewing", questionCount: 0, host: "unknown" };
|
|
162
|
-
dialogue.push(`Review slot ${review.slot}: ${review.taskId ?? "unassigned"}.`);
|
|
163
|
-
floors.push({ key, sourceIndex: index, kind: "review", title: titled(repo, "Review slot " + review.slot), top: 0, props, actors: [actor], phaseAreas: makeAreas(), questions: [],
|
|
164
|
-
board: boardFor(null, "reviewing", review.taskId ?? "unassigned", 0), papers: {}, books: { lit: 0, total: 0 }, clock: 1,
|
|
165
|
-
phase: "reviewing", status: "reviewing", completedAt: null, repo, overview: { x: 0, y: 0, width: 0, height: 0 } });
|
|
166
|
-
});
|
|
167
172
|
// Rooms of one repository sit together; the sort is stable inside each.
|
|
168
173
|
floors.sort((a, b) => (a.repo ?? "").localeCompare(b.repo ?? ""));
|
|
169
174
|
if (dialogue.length === 0)
|
|
@@ -99,7 +99,8 @@ export async function recordOfficeSession(options) {
|
|
|
99
99
|
root: options.projectRoot,
|
|
100
100
|
firstSeenAt: previous?.firstSeenAt ?? at,
|
|
101
101
|
lastSeenAt: at,
|
|
102
|
-
|
|
102
|
+
// A session that only started is waiting at its prompt; work begins with activity.
|
|
103
|
+
status: options.event === "activity" ? "active" : "idle",
|
|
103
104
|
...(options.agentId ?? previous?.agentId ? { agentId: options.agentId ?? previous?.agentId } : {}),
|
|
104
105
|
...(options.runId ?? previous?.runId ? { runId: options.runId ?? previous?.runId } : {}),
|
|
105
106
|
...(options.workerId ?? previous?.workerId ? { workerId: options.workerId ?? previous?.workerId } : {}),
|
|
@@ -116,6 +117,22 @@ export async function recordOfficeSession(options) {
|
|
|
116
117
|
await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, sessions: [...without, next].slice(-MAX_SESSIONS) }) + "\n", options.commonDir);
|
|
117
118
|
});
|
|
118
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* A long command's later step (batch, advance) on the session that runs it,
|
|
122
|
+
* which hooks only see start. Unknown sessions stay unknown.
|
|
123
|
+
*/
|
|
124
|
+
export async function recordOfficeSessionPhase(commonDir, sessionId, phase, now = Date.now()) {
|
|
125
|
+
const path = officeSessionsPath(commonDir);
|
|
126
|
+
await withPrivateFileLock(path, commonDir, async () => {
|
|
127
|
+
const records = prune(await readRecords(commonDir), now);
|
|
128
|
+
if (!records.some(record => record.sessionId === sessionId))
|
|
129
|
+
return;
|
|
130
|
+
const at = new Date(now).toISOString();
|
|
131
|
+
const next = records.map(record => record.sessionId === sessionId
|
|
132
|
+
? { ...record, status: "active", phase, lastSeenAt: at } : record);
|
|
133
|
+
await writePrivateFile(path, JSON.stringify({ schemaVersion: OFFICE_SESSION_SCHEMA_VERSION, sessions: next }) + "\n", commonDir);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
119
136
|
/** Closes a session's room once `worktree finish` merged its work. Unknown sessions stay unknown. */
|
|
120
137
|
export async function markOfficeSessionCompleted(commonDir, sessionId, now = Date.now()) {
|
|
121
138
|
const path = officeSessionsPath(commonDir);
|
|
@@ -29,6 +29,13 @@ export function buildOfficeSnapshot(input) {
|
|
|
29
29
|
const reviewers = input.reviews.filter(r => r.root !== null && roots.has(r.root)).slice(0, 2);
|
|
30
30
|
reviewers.forEach(r => claimed.add(r));
|
|
31
31
|
const usedMs = activeWallTimeMs(run.budget.activeIntervals, input.now);
|
|
32
|
+
const coordinator = run.workers.find(worker => worker.workerId === run.coordinatorId);
|
|
33
|
+
// rootTaskId is the persisted run authority. Only legacy runs without it
|
|
34
|
+
// fall back to the coordinator's task id.
|
|
35
|
+
const rootTaskId = run.rootTaskId ?? (coordinator?.taskId || null);
|
|
36
|
+
const rootTask = rootTaskId === null ? undefined : run.tasks.find(task => task.taskId === rootTaskId);
|
|
37
|
+
const rootTree = rootTaskId === null ? undefined : input.worktrees.find(tree => tree.taskId === rootTaskId &&
|
|
38
|
+
(tree.runId === run.runId || tree.path === coordinator?.worktree));
|
|
32
39
|
const agents = run.workers.filter(w => w.workerId === run.coordinatorId || LIVE.has(w.status)).map((w) => {
|
|
33
40
|
const tree = w.worktree === null ? undefined : byPath.get(w.worktree);
|
|
34
41
|
const phase = w.phase ?? "unknown";
|
|
@@ -39,6 +46,11 @@ export function buildOfficeSnapshot(input) {
|
|
|
39
46
|
});
|
|
40
47
|
const questions = run.questions.filter(q => q.answeredAt === null).map(q => ({ questionId: q.questionId, prompt: q.prompt }));
|
|
41
48
|
return { runId: run.runId, title: run.rootTaskId ?? run.runId,
|
|
49
|
+
...(rootTaskId === null ? {} : { taskId: rootTaskId }),
|
|
50
|
+
// The root task is the only source of run-level proof. A
|
|
51
|
+
// missing proof stays null; worker progress must never become root proof.
|
|
52
|
+
...(rootTaskId === null ? {} : { progress: rootTree?.progress ?? rootTask?.progress ?? null,
|
|
53
|
+
...(rootTree?.criteria === undefined ? {} : { criteria: rootTree.criteria }) }),
|
|
42
54
|
status: run.status, phase: run.phase ?? "unknown",
|
|
43
55
|
budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: Math.max(0, run.budget.limitMs - usedMs) },
|
|
44
56
|
questions, agents, reviewers,
|
|
@@ -57,6 +69,7 @@ export function buildOfficeSnapshot(input) {
|
|
|
57
69
|
runSessionIds.add(worker.nativeSessionId);
|
|
58
70
|
});
|
|
59
71
|
const lobby = [];
|
|
72
|
+
const deskPaths = new Map();
|
|
60
73
|
const represented = new Set();
|
|
61
74
|
for (const worktree of input.worktrees) {
|
|
62
75
|
if (worktree.runId !== undefined)
|
|
@@ -78,9 +91,15 @@ export function buildOfficeSnapshot(input) {
|
|
|
78
91
|
...(worktree.status === undefined && session?.taskStatus === undefined && session?.status === undefined ? {} : { status: worktree.status ?? session?.taskStatus ?? session?.status }),
|
|
79
92
|
...(worktree.taskId === undefined && session?.taskId === undefined ? {} : { taskId: worktree.taskId ?? session?.taskId ?? null }),
|
|
80
93
|
...(worktree.progress === undefined && session?.progress === undefined ? {} : { progress: worktree.progress ?? session?.progress ?? null }),
|
|
94
|
+
...(worktree.title === undefined && session?.title === undefined ? {} : { title: worktree.title ?? session?.title }),
|
|
95
|
+
...(worktree.criteria === undefined && session?.criteria === undefined ? {} : { criteria: worktree.criteria ?? session?.criteria ?? [] }),
|
|
96
|
+
...(worktree.review === undefined && session?.review === undefined ? {} : { review: worktree.review ?? session?.review ?? null }),
|
|
97
|
+
...(worktree.base === undefined ? {} : { base: worktree.base }),
|
|
98
|
+
...(worktree.ahead === undefined ? {} : { ahead: worktree.ahead }),
|
|
81
99
|
...(completedAt === null && session?.completedAt === undefined ? {} : { completedAt }),
|
|
82
100
|
...(worktree.host === undefined && session?.host === undefined ? {} : { host: worktree.host ?? session?.host }),
|
|
83
101
|
});
|
|
102
|
+
deskPaths.set(lobby.length - 1, worktree.path);
|
|
84
103
|
represented.add(worktree.sessionId);
|
|
85
104
|
}
|
|
86
105
|
for (const session of sessions) {
|
|
@@ -100,11 +119,16 @@ export function buildOfficeSnapshot(input) {
|
|
|
100
119
|
status: session.taskStatus ?? session.status,
|
|
101
120
|
...(session.taskId === undefined ? {} : { taskId: session.taskId }),
|
|
102
121
|
...(session.progress === undefined ? {} : { progress: session.progress }),
|
|
122
|
+
...(session.title === undefined ? {} : { title: session.title }),
|
|
123
|
+
...(session.criteria === undefined ? {} : { criteria: session.criteria }),
|
|
124
|
+
...(session.review === undefined ? {} : { review: session.review }),
|
|
103
125
|
...(completedAt === null ? {} : { completedAt }),
|
|
104
126
|
...(session.host === undefined ? {} : { host: session.host })
|
|
105
127
|
});
|
|
106
128
|
}
|
|
107
|
-
|
|
129
|
+
attachActivities(input, runs, lobby, deskPaths);
|
|
130
|
+
// Review slots no longer make rooms: a running review shows up as reviewers in its own room.
|
|
131
|
+
return { generatedAt: new Date(input.now).toISOString(), runs, lobby, reviews: [] };
|
|
108
132
|
}
|
|
109
133
|
/** One building from several repositories' snapshots; every room names its repository. */
|
|
110
134
|
export function mergeOfficeSnapshots(parts, now) {
|
|
@@ -115,3 +139,52 @@ export function mergeOfficeSnapshots(parts, now) {
|
|
|
115
139
|
reviews: parts.flatMap(({ repo, snapshot }) => snapshot.reviews.map(review => ({ ...review, repo })))
|
|
116
140
|
};
|
|
117
141
|
}
|
|
142
|
+
const ACTIVITY_PHASE = { verify: "verifying", review: "reviewing", finish: "integrating" };
|
|
143
|
+
function activityView(activity) {
|
|
144
|
+
return { kind: activity.kind, startedAt: activity.startedAt, ...(activity.targets === undefined ? {} : { targets: activity.targets }),
|
|
145
|
+
...(activity.round === undefined ? {} : { round: activity.round }), ...(activity.target === undefined ? {} : { target: activity.target }) };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Puts each live activity in its room: the same session, else the same task,
|
|
149
|
+
* else the worktree it runs in. One that matches no room gets a room of its
|
|
150
|
+
* own for as long as it runs, so nothing that is running goes unseen.
|
|
151
|
+
*/
|
|
152
|
+
function attachActivities(input, runs, lobby, deskPaths) {
|
|
153
|
+
const runSessions = (run) => new Set(run.workers.flatMap(worker => [worker.ownerSessionId, worker.nativeSessionId].filter((id) => id !== null)));
|
|
154
|
+
for (const activity of input.activities ?? []) {
|
|
155
|
+
const view = activityView(activity);
|
|
156
|
+
const toDesk = (index) => { const desk = lobby[index]; lobby[index] = { ...desk, activities: [...(desk.activities ?? []), view] }; };
|
|
157
|
+
const toRun = (index) => { const run = runs[index]; runs[index] = { ...run, activities: [...(run.activities ?? []), view] }; };
|
|
158
|
+
const sourceRun = (run) => input.runs.find(state => state.runId === run.runId);
|
|
159
|
+
const matchers = [
|
|
160
|
+
[desk => activity.sessionId !== undefined && desk.sessionId === activity.sessionId,
|
|
161
|
+
run => activity.sessionId !== undefined && runSessions(sourceRun(run)).has(activity.sessionId)],
|
|
162
|
+
[desk => activity.taskId !== undefined && desk.taskId === activity.taskId,
|
|
163
|
+
run => activity.taskId !== undefined && run.agents.some(agent => agent.taskId === activity.taskId)],
|
|
164
|
+
[(_, index) => deskPaths.get(index) === activity.root,
|
|
165
|
+
run => sourceRun(run).workers.some(worker => worker.worktree === activity.root)]
|
|
166
|
+
];
|
|
167
|
+
let placed = false;
|
|
168
|
+
for (const [deskMatch, runMatch] of matchers) {
|
|
169
|
+
const desk = lobby.findIndex(deskMatch);
|
|
170
|
+
if (desk >= 0) {
|
|
171
|
+
toDesk(desk);
|
|
172
|
+
placed = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
const run = runs.findIndex(candidate => sourceRun(candidate) !== undefined && runMatch(candidate));
|
|
176
|
+
if (run >= 0) {
|
|
177
|
+
toRun(run);
|
|
178
|
+
placed = true;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (placed)
|
|
183
|
+
continue;
|
|
184
|
+
const name = activity.taskId ?? activity.worktree ?? activity.kind;
|
|
185
|
+
lobby.push({ name, branch: "(no worktree)", sessionId: activity.sessionId ?? `activity-${activity.kind}-${activity.pid}`, sessionActive: true,
|
|
186
|
+
diff: { files: 0, insertions: 0, deletions: 0, paths: [], recent: null }, narration: ACTIVITY_PHASE[activity.kind],
|
|
187
|
+
commands: activity.taskId === undefined ? [] : [`agent-ops task status --task ${quote(activity.taskId)}`],
|
|
188
|
+
phase: ACTIVITY_PHASE[activity.kind], status: "active", ...(activity.taskId === undefined ? {} : { taskId: activity.taskId }), activities: [view] });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withActivity } from "../office/activity.js";
|
|
1
2
|
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -233,7 +234,8 @@ export async function finishWorktree(deps, options) {
|
|
|
233
234
|
if (record === null) {
|
|
234
235
|
throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
|
|
235
236
|
}
|
|
236
|
-
|
|
237
|
+
// Display only: the integrator is at work while the finish runs.
|
|
238
|
+
return await withActivity(commonDir, { kind: "finish", root: record.path, sessionId: record.sessionId, worktree: name }, async () => await withFinishLock(deps, commonDir, async () => {
|
|
237
239
|
const sessionChildren = (await listWorktrees(deps, mainRoot))
|
|
238
240
|
.map(({ record: child }) => child)
|
|
239
241
|
.filter((child) => child.path !== record.path && child.sessionId === record.sessionId &&
|
|
@@ -507,5 +509,5 @@ export async function finishWorktree(deps, options) {
|
|
|
507
509
|
await markRunIntegration(commonDir, record, "cleaned", "coordinator");
|
|
508
510
|
return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
|
|
509
511
|
...(receipt === undefined ? {} : { receipt: receipt.path }) };
|
|
510
|
-
});
|
|
512
|
+
}));
|
|
511
513
|
}
|
|
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { sha256 } from "../fs/hash.js";
|
|
4
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
5
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
6
|
+
import { FileRunRepository } from "../run/service.js";
|
|
6
7
|
import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, assertRunWorktreeOwnership, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
|
|
7
8
|
/**
|
|
8
9
|
* A baseline no source state hashes to. A resumed session inherits whatever
|
|
@@ -141,8 +142,11 @@ export async function resumeWorktree(deps, options) {
|
|
|
141
142
|
const sessionId = assertSessionId(options.sessionId);
|
|
142
143
|
const mainRoot = await mainCheckout(deps, options.cwd);
|
|
143
144
|
const previous = await requireRecord(mainRoot, name);
|
|
144
|
-
|
|
145
|
+
// A base gives the service repository context, which debug resume needs.
|
|
146
|
+
const tasks = deps.tasks(previous.path, previous.base);
|
|
145
147
|
const attached = await tasks.status({ sessionId: previous.sessionId }).catch(() => null);
|
|
148
|
+
if (attached?.task.debug !== undefined)
|
|
149
|
+
await tasks.debugService().resume(attached.task.id, sessionId);
|
|
146
150
|
if (attached !== null)
|
|
147
151
|
await tasks.attach(sessionId, attached.task.id);
|
|
148
152
|
const record = { ...previous, sessionId };
|
|
@@ -206,6 +210,15 @@ export async function removeWorktree(deps, options) {
|
|
|
206
210
|
const name = assertWorktreeName(options.name);
|
|
207
211
|
const mainRoot = await mainCheckout(deps, options.cwd);
|
|
208
212
|
const status = await describe(deps, await requireRecord(mainRoot, name));
|
|
213
|
+
// A running run still writes here; --force discards work, it does not stop the run.
|
|
214
|
+
const runId = status.record.runId;
|
|
215
|
+
if (runId !== undefined) {
|
|
216
|
+
const commonDir = join(mainRoot, ".git");
|
|
217
|
+
const run = await new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir).read(runId).catch(() => null);
|
|
218
|
+
if (run !== null && ["active", "awaiting-input", "stopping"].includes(run.status)) {
|
|
219
|
+
throw manageError("WORKTREE_RUN_ACTIVE", `${name} belongs to run ${runId}, which is ${run.status}. Stop it first: agent-ops run stop ${runId}.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
209
222
|
if ((status.dirty || status.ahead > 0) && !options.force) {
|
|
210
223
|
throw manageError("WORKTREE_UNMERGED", `${name} has ${status.dirty ? "uncommitted changes" : ""}${status.dirty && status.ahead > 0 ? " and " : ""}${status.ahead > 0 ? `${status.ahead} commit(s) not in ${status.record.targetBranch}` : ""}. Finish it, or remove it with --force to discard that work (the user is asked).`);
|
|
211
224
|
}
|
|
@@ -217,7 +230,11 @@ export async function removeWorktree(deps, options) {
|
|
|
217
230
|
? mainGate.redirect(record.sessionId, null)
|
|
218
231
|
: mainGate.removeRoot(record.sessionId, record.path));
|
|
219
232
|
}
|
|
220
|
-
|
|
233
|
+
// A paused or stopped run's worktree has no loadable policy config; its trust
|
|
234
|
+
// was never this checkout's to revoke, so removal must not depend on it.
|
|
235
|
+
const config = await deps.loadConfig(record.path).catch(() => null);
|
|
236
|
+
if (config !== null)
|
|
237
|
+
await deps.trust.revoke(record.path, config).catch(() => undefined);
|
|
221
238
|
await removeCheckout(deps, record, true);
|
|
222
239
|
return status;
|
|
223
240
|
}
|
|
@@ -17,7 +17,9 @@ const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u;
|
|
|
17
17
|
const REPORT_ARTIFACT_MAX_BYTES = 512 * 1024;
|
|
18
18
|
function validContractBinding(value) {
|
|
19
19
|
const hashes = [value.taskContractHash, value.goalHash, value.coverageDigest];
|
|
20
|
-
return
|
|
20
|
+
return (value.debugProofs === undefined || typeof value.debugProofs === "object" && value.debugProofs !== null && !Array.isArray(value.debugProofs) &&
|
|
21
|
+
Object.entries(value.debugProofs).every(([id, hash]) => TASK_ID_PATTERN.test(id) && typeof hash === "string" && DIGEST_PATTERN.test(hash))) &&
|
|
22
|
+
hashes.every(v => typeof v === "string" && DIGEST_PATTERN.test(v)) &&
|
|
21
23
|
(value.treeContractHash === undefined || typeof value.treeContractHash === "string" && DIGEST_PATTERN.test(value.treeContractHash)) &&
|
|
22
24
|
typeof value.candidateCommit === "string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value.candidateCommit) &&
|
|
23
25
|
typeof value.taskContracts === "object" && value.taskContracts !== null && !Array.isArray(value.taskContracts) &&
|
|
@@ -26,6 +28,7 @@ function validContractBinding(value) {
|
|
|
26
28
|
}
|
|
27
29
|
function contractBinding(value) {
|
|
28
30
|
return validContractBinding(value) ? {
|
|
31
|
+
...(value.debugProofs === undefined ? {} : { debugProofs: value.debugProofs }),
|
|
29
32
|
taskContractHash: value.taskContractHash, taskContracts: value.taskContracts,
|
|
30
33
|
...(value.treeContractHash === undefined ? {} : { treeContractHash: value.treeContractHash }),
|
|
31
34
|
goalHash: value.goalHash, candidateCommit: value.candidateCommit, coverageDigest: value.coverageDigest
|
|
@@ -25,6 +25,14 @@ export async function runBatch(ids, deps, options) {
|
|
|
25
25
|
throw new AgentOpsError("BATCH_INVALID_WIDTH", "Batch width must be a positive integer.");
|
|
26
26
|
}
|
|
27
27
|
const progress = options.onProgress ?? (() => { });
|
|
28
|
+
const phase = (value) => {
|
|
29
|
+
try {
|
|
30
|
+
options.onPhase?.(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Display only.
|
|
34
|
+
}
|
|
35
|
+
};
|
|
28
36
|
const controller = new AbortController();
|
|
29
37
|
const outer = options.signal;
|
|
30
38
|
if (outer?.aborted === true) {
|
|
@@ -71,6 +79,7 @@ export async function runBatch(ids, deps, options) {
|
|
|
71
79
|
entry.retried = true;
|
|
72
80
|
}
|
|
73
81
|
let result;
|
|
82
|
+
phase("reviewing");
|
|
74
83
|
try {
|
|
75
84
|
result = await deps.review(id, controller.signal);
|
|
76
85
|
}
|
|
@@ -127,6 +136,7 @@ export async function runBatch(ids, deps, options) {
|
|
|
127
136
|
break;
|
|
128
137
|
}
|
|
129
138
|
let status;
|
|
139
|
+
phase("verifying");
|
|
130
140
|
try {
|
|
131
141
|
status = await deps.verify(id, controller.signal);
|
|
132
142
|
}
|
|
@@ -690,6 +690,13 @@ async function attemptTargetWithRetry(request, options) {
|
|
|
690
690
|
return attemptTarget({ ...request, resumeLostTurn: false }, options);
|
|
691
691
|
}
|
|
692
692
|
/** Builds the `execute` callback with one necessary and one adversarial review. */
|
|
693
|
+
/** Display only: a failing listener never touches the review. */
|
|
694
|
+
function announceRound(options, round, target, targets) {
|
|
695
|
+
try {
|
|
696
|
+
options.onRound?.(round, target, targets);
|
|
697
|
+
}
|
|
698
|
+
catch { /* display only */ }
|
|
699
|
+
}
|
|
693
700
|
export function createReviewExecutor(options) {
|
|
694
701
|
const report = options.onProgress ?? (() => { });
|
|
695
702
|
const host = detectHostTarget(options.env ?? process.env);
|
|
@@ -698,7 +705,7 @@ export function createReviewExecutor(options) {
|
|
|
698
705
|
const clock = options.now ?? (() => Date.now());
|
|
699
706
|
return async (request) => {
|
|
700
707
|
const deadlineAt = clock() +
|
|
701
|
-
(options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS);
|
|
708
|
+
Math.min(options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS, await options.remainingBudgetMs?.() ?? Infinity);
|
|
702
709
|
const remainingMs = () => deadlineAt - clock();
|
|
703
710
|
/**
|
|
704
711
|
* The options one stage runs under: its own timeout, or what is left of
|
|
@@ -802,6 +809,7 @@ export function createReviewExecutor(options) {
|
|
|
802
809
|
report("chain: review deadline reached before the first round");
|
|
803
810
|
return { status: "NOT_RUN", reason: "timeout", attempts: [] };
|
|
804
811
|
}
|
|
812
|
+
announceRound(options, 1, primaryTarget, plannedTargets);
|
|
805
813
|
const primaryOutcome = await attemptTargetWithRetry({
|
|
806
814
|
...shared,
|
|
807
815
|
target: primaryTarget,
|
|
@@ -911,6 +919,7 @@ export function createReviewExecutor(options) {
|
|
|
911
919
|
};
|
|
912
920
|
}
|
|
913
921
|
// The second round draws on what is left, never a fresh full budget.
|
|
922
|
+
announceRound(options, 2, adversarialTarget, plannedTargets);
|
|
914
923
|
const adversarialOutcome = await attemptTargetWithRetry({
|
|
915
924
|
...shared,
|
|
916
925
|
target: adversarialTarget,
|
|
@@ -48,62 +48,77 @@ export async function withReviewSlot(options, action) {
|
|
|
48
48
|
}
|
|
49
49
|
const sleep = options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
|
|
50
50
|
let waiting = false;
|
|
51
|
+
let waitEnded = false;
|
|
51
52
|
let held;
|
|
52
53
|
const token = randomUUID();
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
54
|
+
try {
|
|
55
|
+
while (held === undefined) {
|
|
56
|
+
if (options.signal?.aborted === true)
|
|
57
|
+
throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
|
|
58
|
+
for (let index = 0; index < width && held === undefined; index += 1) {
|
|
59
|
+
const path = join(options.dir, `agent-ops-review-slot-${index}.lock`);
|
|
60
|
+
try {
|
|
61
|
+
const claimed = await withPrivateFileLock(path + ".state", options.dir, async () => {
|
|
62
|
+
if (options.signal?.aborted === true)
|
|
63
|
+
throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
|
|
64
|
+
for (;;) {
|
|
65
|
+
try {
|
|
66
|
+
await mkdir(path);
|
|
67
|
+
await writeFile(join(path, "owner.json"), JSON.stringify({ ...options.holder, pid: process.pid, at: Date.now(), token }));
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== "EEXIST")
|
|
72
|
+
throw error;
|
|
73
|
+
if (!await stale(path, options.staleMs ?? STALE_MS))
|
|
74
|
+
return false;
|
|
75
|
+
await rm(path, { recursive: true, force: true });
|
|
76
|
+
}
|
|
74
77
|
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
});
|
|
79
|
+
if (claimed)
|
|
80
|
+
held = path;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
79
86
|
}
|
|
80
|
-
|
|
81
|
-
if (!
|
|
82
|
-
|
|
87
|
+
if (held === undefined) {
|
|
88
|
+
if (!waiting) {
|
|
89
|
+
waiting = true;
|
|
90
|
+
await options.onWaitStart?.();
|
|
91
|
+
options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
|
|
92
|
+
}
|
|
93
|
+
await sleep(options.pollMs ?? POLL_MS);
|
|
83
94
|
}
|
|
84
95
|
}
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
|
|
89
|
-
}
|
|
90
|
-
await sleep(options.pollMs ?? POLL_MS);
|
|
96
|
+
if (waiting) {
|
|
97
|
+
waitEnded = true;
|
|
98
|
+
await options.onWaitEnd?.(true);
|
|
91
99
|
}
|
|
92
|
-
}
|
|
93
|
-
try {
|
|
94
100
|
if (options.signal?.aborted === true)
|
|
95
101
|
throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
|
|
96
102
|
return await action();
|
|
97
103
|
}
|
|
98
104
|
finally {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
if (waiting && !waitEnded) {
|
|
106
|
+
waitEnded = true;
|
|
107
|
+
try {
|
|
108
|
+
await options.onWaitEnd?.(false);
|
|
109
|
+
}
|
|
110
|
+
catch { /* preserve the original wait/abort failure */ }
|
|
111
|
+
}
|
|
112
|
+
if (held !== undefined) {
|
|
113
|
+
await withPrivateFileLock(held + ".state", options.dir, async () => {
|
|
114
|
+
const owner = await readFile(join(held, "owner.json"), "utf8").catch(error => {
|
|
115
|
+
if (error.code === "ENOENT")
|
|
116
|
+
return null;
|
|
117
|
+
throw error;
|
|
118
|
+
});
|
|
119
|
+
if (owner !== null && JSON.parse(owner)?.token === token)
|
|
120
|
+
await rm(held, { recursive: true, force: true });
|
|
104
121
|
});
|
|
105
|
-
|
|
106
|
-
await rm(held, { recursive: true, force: true });
|
|
107
|
-
});
|
|
122
|
+
}
|
|
108
123
|
}
|
|
109
124
|
}
|
|
@@ -2,10 +2,13 @@ import { nativeProcessGroupAlive, signalNativeProcessGroup } from "./hosts/util.
|
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
|
+
import { readWindowsProcessIdentity } from "../security/permissions.js";
|
|
5
6
|
const exec = promisify(execFile);
|
|
6
7
|
export async function nativeProcessIdentity(pid) {
|
|
7
8
|
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
8
9
|
return null;
|
|
10
|
+
if (process.platform === "win32")
|
|
11
|
+
return await readWindowsProcessIdentity(pid);
|
|
9
12
|
try {
|
|
10
13
|
const { stdout } = await exec("ps", ["-p", String(pid), "-o", "lstart="], { maxBuffer: 8192 });
|
|
11
14
|
return stdout.trim() || null;
|