@kylecheng3146/agent-ops 0.9.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/packages/cli/src/args.js +58 -14
- package/dist/packages/cli/src/bin.js +10 -3
- package/dist/packages/cli/src/cli.js +30 -6
- package/dist/packages/cli/src/commands/advance.js +2 -1
- package/dist/packages/cli/src/commands/escapes.js +109 -0
- package/dist/packages/cli/src/commands/parallel.js +1 -1
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +63 -11
- package/dist/packages/cli/src/commands/run.js +10 -3
- package/dist/packages/cli/src/commands/task.js +41 -3
- package/dist/packages/cli/src/commands/update.js +16 -4
- package/dist/packages/cli/src/commands/verify.js +4 -1
- package/dist/packages/cli/src/context.js +22 -4
- package/dist/packages/cli/src/debug-intake.js +1 -1
- package/dist/packages/cli/src/office-entry.js +8 -4
- package/dist/packages/cli/src/owned-run-step.js +4 -4
- package/dist/packages/cli/src/parallel-deps.js +0 -2
- package/dist/packages/cli/src/run-deps.js +12 -9
- package/dist/packages/cli/src/run-entry.js +76 -17
- package/dist/packages/cli/src/run-setup-entry.js +2 -2
- package/dist/runtime/src/escape/link.js +317 -0
- package/dist/runtime/src/escape/origin.js +20 -0
- package/dist/runtime/src/escape/rules.js +252 -0
- package/dist/runtime/src/fs/temporary.js +10 -0
- package/dist/runtime/src/install/harness.js +7 -4
- package/dist/runtime/src/logging/local-log.js +44 -1
- package/dist/runtime/src/office/scene.js +1 -1
- package/dist/runtime/src/office/server.js +43 -16
- package/dist/runtime/src/office/snapshot.js +1 -1
- package/dist/runtime/src/parallel/finish.js +139 -65
- package/dist/runtime/src/parallel/manage.js +51 -1
- package/dist/runtime/src/parallel/receipt.js +3 -1
- package/dist/runtime/src/parallel/service.js +5 -1
- package/dist/runtime/src/review/execute.js +20 -12
- package/dist/runtime/src/review/packet.js +20 -7
- package/dist/runtime/src/review/probe.js +3 -2
- package/dist/runtime/src/review/runner.js +26 -3
- package/dist/runtime/src/review/slots.js +1 -1
- 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/schema/validate.js +6 -1
- package/dist/runtime/src/security/permissions.js +85 -0
- package/dist/runtime/src/surface/discovery.js +79 -2
- package/dist/runtime/src/surface/schema.js +43 -2
- package/dist/runtime/src/task/completion.js +20 -0
- package/dist/runtime/src/task/contract.js +3 -1
- package/dist/runtime/src/task/debug-state.js +9 -3
- package/dist/runtime/src/task/debug-user-event.js +82 -17
- package/dist/runtime/src/task/debug.js +59 -16
- package/dist/runtime/src/task/service.js +58 -6
- package/dist/runtime/src/verify/acceptance-replay.js +24 -11
- package/docs/en/guides/configuration.md +24 -5
- package/docs/en/guides/task-debug.md +26 -2
- package/docs/en/spec/acceptance-and-evidence.md +104 -2
- package/docs/en/spec/harness-adapters.md +26 -1
- package/docs/en/spec/loop-engineering.md +34 -6
- package/docs/en/spec/review.md +6 -0
- package/docs/zh-TW/guides/configuration.md +20 -4
- package/docs/zh-TW/guides/task-debug.md +19 -2
- package/docs/zh-TW/spec/acceptance-and-evidence.md +84 -4
- package/docs/zh-TW/spec/harness-adapters.md +21 -1
- package/docs/zh-TW/spec/loop-engineering.md +9 -3
- package/docs/zh-TW/spec/review.md +5 -0
- package/package.json +1 -1
- package/schemas/task.schema.json +8 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, readdir } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { canonicalJson } from "../config/hash.js";
|
|
6
|
+
import { sha256 } from "../fs/hash.js";
|
|
7
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
8
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
9
|
+
import { globMatcher } from "../surface/schema.js";
|
|
10
|
+
import { ESCAPE_LABELS } from "./origin.js";
|
|
11
|
+
/** Escape Loop R2: learned, tracked, data-only rules. They can only add places a task must classify. */
|
|
12
|
+
export const RULES_DIRECTORY = ".agent-ops-escapes/rules";
|
|
13
|
+
export const RULE_LABELS = [...ESCAPE_LABELS, "surface-excluded", "surface-unseen"];
|
|
14
|
+
/** Labels whose proof is a rule replayed at the escaped commit. */
|
|
15
|
+
export const SURFACE_LABELS = ["surface", "surface-excluded", "surface-unseen"];
|
|
16
|
+
/** Labels whose proof is a promoted red-to-green behavioral check of the fixing task. */
|
|
17
|
+
export const CHECK_LABELS = ["criteria", "verify"];
|
|
18
|
+
export const RULE_LIMITS = { backtestCommits: 200, maxFireRate: 0.3, backtestBudgetMs: 60_000, queryTimeoutMs: 30_000 };
|
|
19
|
+
/** argv only: rule strings are repository data and must never meet a shell or a regex engine. */
|
|
20
|
+
export function runGit(root, args, timeoutMs) {
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
execFile("git", [...args], { cwd: root, encoding: "buffer", timeout: Math.max(1, timeoutMs), maxBuffer: 256 * 1024 * 1024, shell: false }, (error, stdout) => {
|
|
23
|
+
const failure = error;
|
|
24
|
+
resolve({ code: failure === null ? 0 : typeof failure.code === "number" ? failure.code : null,
|
|
25
|
+
stdout: Buffer.isBuffer(stdout) ? stdout : Buffer.alloc(0), timedOut: failure?.killed === true });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export const gitRunner = (root) => ({ run: async (args) => {
|
|
30
|
+
const result = await runGit(root, args, RULE_LIMITS.queryTimeoutMs);
|
|
31
|
+
return { exitCode: result.code ?? 128, stdout: result.stdout };
|
|
32
|
+
} });
|
|
33
|
+
const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
34
|
+
const exact = (v, keys) => keys.every(k => Object.hasOwn(v, k)) && Object.keys(v).every(k => keys.includes(k));
|
|
35
|
+
const line = (v, min) => typeof v === "string" && v.trim().length >= min && v.length <= 256 && !/[\0\n\r]/u.test(v);
|
|
36
|
+
const glob = (v) => line(v, 1) && !v.startsWith("/") && !v.includes("\\") &&
|
|
37
|
+
v.split("/").every(part => part !== "" && part !== "." && part !== "..");
|
|
38
|
+
const strings = (v, check) => Array.isArray(v) && v.length <= 32 && v.every(check) && new Set(v).size === v.length;
|
|
39
|
+
function invalid(file, message) {
|
|
40
|
+
throw new AgentOpsError("ESCAPE_RULE_INVALID", `${RULES_DIRECTORY}/${file}: ${message}`);
|
|
41
|
+
}
|
|
42
|
+
/** Strict: there is no field that removes a query, adds an exclusion or relaxes a gate. */
|
|
43
|
+
export function parseRule(value, file) {
|
|
44
|
+
if (!record(value) || !exact(value, ["id", "escape", "label", "trigger", "expand", "noExclude"]))
|
|
45
|
+
invalid(file, "a rule is exactly {id, escape, label, trigger: {paths, tokens}, expand: {paths, queries}, noExclude}.");
|
|
46
|
+
if (typeof value.id !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(value.id) || file !== `${value.id}.json`)
|
|
47
|
+
invalid(file, "id is lowercase kebab-case and names the file <id>.json.");
|
|
48
|
+
if (typeof value.escape !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.escape))
|
|
49
|
+
invalid(file, "escape is the full escaped commit SHA.");
|
|
50
|
+
if (!RULE_LABELS.includes(value.label))
|
|
51
|
+
invalid(file, `label is one of ${RULE_LABELS.join(", ")}.`);
|
|
52
|
+
const { trigger, expand } = value;
|
|
53
|
+
if (!record(trigger) || !exact(trigger, ["paths", "tokens"]) || !strings(trigger.paths, glob) || !strings(trigger.tokens, v => line(v, 3)) ||
|
|
54
|
+
trigger.paths.length + trigger.tokens.length === 0)
|
|
55
|
+
invalid(file, "trigger needs 1-32 path globs and/or fixed-string tokens (3+ characters).");
|
|
56
|
+
if (!record(expand) || !exact(expand, ["paths", "queries"]) || !strings(expand.paths, glob) || !strings(expand.queries, v => line(v, 3)) ||
|
|
57
|
+
expand.paths.length + expand.queries.length === 0)
|
|
58
|
+
invalid(file, "expand needs 1-32 path globs and/or fixed-string queries (3+ characters).");
|
|
59
|
+
if (typeof value.noExclude !== "boolean")
|
|
60
|
+
invalid(file, "noExclude is true or false.");
|
|
61
|
+
return { id: value.id, escape: value.escape, label: value.label,
|
|
62
|
+
trigger: { paths: [...trigger.paths], tokens: [...trigger.tokens] }, expand: { paths: [...expand.paths], queries: [...expand.queries] },
|
|
63
|
+
noExclude: value.noExclude };
|
|
64
|
+
}
|
|
65
|
+
async function kind(path) {
|
|
66
|
+
try {
|
|
67
|
+
const stat = await lstat(path);
|
|
68
|
+
return stat.isSymbolicLink() ? "other" : stat.isDirectory() ? "directory" : stat.isFile() ? "file" : "other";
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code === "ENOENT")
|
|
72
|
+
return "missing";
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const unsafe = () => {
|
|
77
|
+
throw new AgentOpsError("ESCAPE_RULE_UNSAFE", `${RULES_DIRECTORY} must be a real directory of regular <id>.json files.`);
|
|
78
|
+
};
|
|
79
|
+
/** Fails closed: a broken or symlinked rule never silently stops tightening. */
|
|
80
|
+
export async function loadRules(root) {
|
|
81
|
+
for (const directory of [".agent-ops-escapes", RULES_DIRECTORY]) {
|
|
82
|
+
const found = await kind(join(root, directory));
|
|
83
|
+
if (found === "missing")
|
|
84
|
+
return [];
|
|
85
|
+
if (found !== "directory")
|
|
86
|
+
unsafe();
|
|
87
|
+
}
|
|
88
|
+
const rules = [];
|
|
89
|
+
for (const name of (await readdir(join(root, RULES_DIRECTORY))).sort()) {
|
|
90
|
+
const path = join(root, RULES_DIRECTORY, name);
|
|
91
|
+
if (await kind(path) !== "file")
|
|
92
|
+
unsafe();
|
|
93
|
+
if (!name.endsWith(".json"))
|
|
94
|
+
invalid(name, "only <id>.json rule files belong here.");
|
|
95
|
+
// O_NOFOLLOW closes the lstat-to-open race on POSIX; Windows has no such flag.
|
|
96
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
97
|
+
let source;
|
|
98
|
+
try {
|
|
99
|
+
source = await handle.readFile("utf8");
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await handle.close();
|
|
103
|
+
}
|
|
104
|
+
let value;
|
|
105
|
+
try {
|
|
106
|
+
value = JSON.parse(source);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
invalid(name, "not JSON.");
|
|
110
|
+
}
|
|
111
|
+
rules.push({ rule: parseRule(value, name), hash: sha256(source) });
|
|
112
|
+
}
|
|
113
|
+
return rules;
|
|
114
|
+
}
|
|
115
|
+
/** Added and removed lines only: headers and context never trigger. */
|
|
116
|
+
function changedLines(diff) {
|
|
117
|
+
return diff.split("\n").filter(l => /^[+-]/u.test(l) && !l.startsWith("+++") && !l.startsWith("---")).map(l => l.slice(1));
|
|
118
|
+
}
|
|
119
|
+
/** A rule fires when a changed path matches a trigger glob or a changed line contains a trigger token. */
|
|
120
|
+
export function ruleFires(rule, paths, diff) {
|
|
121
|
+
const globs = rule.trigger.paths.map(globMatcher);
|
|
122
|
+
if (paths.some(path => globs.some(match => match(path))))
|
|
123
|
+
return true;
|
|
124
|
+
if (rule.trigger.tokens.length === 0)
|
|
125
|
+
return false;
|
|
126
|
+
const lines = changedLines(diff);
|
|
127
|
+
return rule.trigger.tokens.some(token => lines.some(l => l.includes(token)));
|
|
128
|
+
}
|
|
129
|
+
const zList = (value) => value.toString("utf8").split("\0").filter(Boolean);
|
|
130
|
+
/** Files a fired rule puts in front of the task: path globs over tracked files plus git grep -F queries; ref absent = working tree. */
|
|
131
|
+
export async function ruleHits(root, rule, ref, timeoutMs = RULE_LIMITS.queryTimeoutMs) {
|
|
132
|
+
const hits = new Set();
|
|
133
|
+
const fail = () => { throw new AgentOpsError("SURFACE_BUDGET_EXCEEDED", `Rule ${rule.id} could not list its expand hits in time.`); };
|
|
134
|
+
if (rule.expand.paths.length > 0) {
|
|
135
|
+
const files = await runGit(root, ref === undefined ? ["ls-files", "-z"] : ["ls-tree", "-r", "-z", "--name-only", ref], timeoutMs);
|
|
136
|
+
if (files.code !== 0)
|
|
137
|
+
fail();
|
|
138
|
+
const globs = rule.expand.paths.map(globMatcher);
|
|
139
|
+
for (const path of zList(files.stdout))
|
|
140
|
+
if (globs.some(match => match(path)))
|
|
141
|
+
hits.add(path);
|
|
142
|
+
}
|
|
143
|
+
for (const query of rule.expand.queries) {
|
|
144
|
+
const result = await runGit(root, ["grep", "-F", "-l", "-z", "-I", "--no-color", "-e", query, ...(ref === undefined ? [] : [ref]), "--"], timeoutMs);
|
|
145
|
+
if (result.timedOut || (result.code !== 0 && result.code !== 1))
|
|
146
|
+
fail();
|
|
147
|
+
for (const hit of zList(result.stdout))
|
|
148
|
+
hits.add(ref === undefined ? hit : hit.slice(ref.length + 1));
|
|
149
|
+
}
|
|
150
|
+
// A rule's own text names its queries; the ledger is never a product surface.
|
|
151
|
+
return [...hits].filter(path => !path.startsWith(".agent-ops-escapes/")).sort();
|
|
152
|
+
}
|
|
153
|
+
/** One commit's own change against its first parent (the empty tree for a root commit). */
|
|
154
|
+
export async function commitChange(root, commit) {
|
|
155
|
+
const names = await runGit(root, ["show", "-m", "--first-parent", "--format=", "--name-only", "-z", "--no-renames", commit, "--"], RULE_LIMITS.queryTimeoutMs);
|
|
156
|
+
const diff = await runGit(root, ["show", "-m", "--first-parent", "--format=", "-U0", "--no-color", "--no-ext-diff", "--no-textconv",
|
|
157
|
+
"--no-renames", commit, "--"], RULE_LIMITS.queryTimeoutMs);
|
|
158
|
+
if (names.code !== 0 || diff.code !== 0)
|
|
159
|
+
throw new AgentOpsError("ESCAPE_GIT_FAILED", `git show ${commit} failed.`);
|
|
160
|
+
return { paths: zList(names.stdout), diff: diff.stdout.toString("utf8") };
|
|
161
|
+
}
|
|
162
|
+
/** Product files a past fix commit touched (tests and agent-ops data are not missed surfaces). */
|
|
163
|
+
export async function fixPaths(root, fix) {
|
|
164
|
+
return (await commitChange(root, fix)).paths.filter(path => !path.startsWith(".agent-ops") &&
|
|
165
|
+
!/(?:^|\/)(?:tests?|fixtures)(?:\/|$)|(?:\.test|\.spec)\./u.test(path)).sort().slice(0, 200);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The trigger against the last 200 first-parent commits, in one argv git call.
|
|
169
|
+
* ponytail: one `git log -p` buffered in memory (256 MiB cap); stream it if huge histories need it.
|
|
170
|
+
*/
|
|
171
|
+
export async function backtestRule(root, loaded, budgetMs = RULE_LIMITS.backtestBudgetMs) {
|
|
172
|
+
const head = (await runGit(root, ["rev-parse", "HEAD"], RULE_LIMITS.queryTimeoutMs)).stdout.toString("utf8").trim();
|
|
173
|
+
const cachePath = join(root, ".agent-ops", "tasks", "escape", "backtest", sha256(canonicalJson({ head, rule: loaded.hash, limit: RULE_LIMITS.backtestCommits })) + ".json");
|
|
174
|
+
try {
|
|
175
|
+
const cached = await readPrivateFile(cachePath, root);
|
|
176
|
+
if (cached !== null)
|
|
177
|
+
return JSON.parse(cached);
|
|
178
|
+
}
|
|
179
|
+
catch { /* recompute */ }
|
|
180
|
+
const log = await runGit(root, ["log", "--first-parent", "-m", "-n", String(RULE_LIMITS.backtestCommits), "-p", "-U0", "--no-color",
|
|
181
|
+
"--no-ext-diff", "--no-textconv", "--no-renames", "--format=%x00commit %H", "HEAD", "--"], budgetMs);
|
|
182
|
+
if (log.timedOut)
|
|
183
|
+
throw new AgentOpsError("SURFACE_BUDGET_EXCEEDED", `Backtest of rule ${loaded.rule.id} exceeded its ${budgetMs} ms budget.`);
|
|
184
|
+
if (log.code !== 0)
|
|
185
|
+
throw new AgentOpsError("ESCAPE_GIT_FAILED", "git log failed during the rule backtest.");
|
|
186
|
+
const commits = log.stdout.toString("utf8").split("\0commit ").slice(1);
|
|
187
|
+
let fires = 0;
|
|
188
|
+
for (const chunk of commits) {
|
|
189
|
+
const paths = [...chunk.matchAll(/^diff --git a\/.+? b\/(.+)$/gmu)].map(m => m[1]);
|
|
190
|
+
if (ruleFires(loaded.rule, paths, chunk))
|
|
191
|
+
fires += 1;
|
|
192
|
+
}
|
|
193
|
+
const result = { commits: commits.length, fires, rate: commits.length === 0 ? 0 : Math.round(fires / commits.length * 1000) / 1000 };
|
|
194
|
+
await writePrivateFile(cachePath, JSON.stringify(result), root).catch(() => undefined);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Proof per label. Surface labels need a rule for this escape that fires on the
|
|
199
|
+
* escaped commit's own diff and whose hits at that commit cover every missed
|
|
200
|
+
* path that existed there; no test or review ever runs at the escaped commit.
|
|
201
|
+
*/
|
|
202
|
+
export async function proveEscape(input) {
|
|
203
|
+
if (CHECK_LABELS.includes(input.label))
|
|
204
|
+
return { status: "check", check: input.promote ?? null };
|
|
205
|
+
if (!SURFACE_LABELS.includes(input.label))
|
|
206
|
+
return { status: "lesson", reason: `${input.label}: no mechanical replay exists for this gate; recorded as an unproven lesson.` };
|
|
207
|
+
const rules = (await loadRules(input.root)).filter(r => r.rule.escape === input.escapeOf);
|
|
208
|
+
if (rules.length === 0)
|
|
209
|
+
return { status: "unproven", code: "ESCAPE_RULE_UNPROVEN",
|
|
210
|
+
reason: `No ${RULES_DIRECTORY}/<id>.json rule names escape ${input.escapeOf}.` };
|
|
211
|
+
const change = await commitChange(input.root, input.escapeOf);
|
|
212
|
+
const required = [];
|
|
213
|
+
for (const path of input.missedPaths)
|
|
214
|
+
if ((await runGit(input.root, ["cat-file", "-e", `${input.escapeOf}:${path}`], RULE_LIMITS.queryTimeoutMs)).code === 0)
|
|
215
|
+
required.push(path);
|
|
216
|
+
const reasons = [];
|
|
217
|
+
for (const loaded of rules) {
|
|
218
|
+
if (!ruleFires(loaded.rule, change.paths, change.diff)) {
|
|
219
|
+
reasons.push(`${loaded.rule.id} does not fire on the escaped commit`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const hits = new Set(await ruleHits(input.root, loaded.rule, input.escapeOf));
|
|
223
|
+
const missing = required.filter(path => !hits.has(path));
|
|
224
|
+
if (missing.length > 0) {
|
|
225
|
+
reasons.push(`${loaded.rule.id} misses ${missing.slice(0, 5).join(", ")}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
let backtest;
|
|
229
|
+
try {
|
|
230
|
+
backtest = await backtestRule(input.root, loaded, input.budgetMs);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error instanceof AgentOpsError && error.code === "SURFACE_BUDGET_EXCEEDED")
|
|
234
|
+
return { status: "unproven", code: "SURFACE_BUDGET_EXCEEDED", reason: error.message, rule: loaded.rule.id };
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
if (backtest.rate > RULE_LIMITS.maxFireRate)
|
|
238
|
+
return { status: "unproven", code: "ESCAPE_RULE_TOO_BROAD", rule: loaded.rule.id, backtest,
|
|
239
|
+
reason: `${loaded.rule.id} fires on ${backtest.fires} of the last ${backtest.commits} first-parent commits (> ${RULE_LIMITS.maxFireRate * 100}%).` };
|
|
240
|
+
return { status: "proven", rule: loaded.rule.id, ruleHash: loaded.hash, backtest };
|
|
241
|
+
}
|
|
242
|
+
return { status: "unproven", code: "ESCAPE_RULE_UNPROVEN", reason: reasons.join("; ") };
|
|
243
|
+
}
|
|
244
|
+
/** Printed only: agent-ops never writes .agent-ops/config.json for an executable rule. */
|
|
245
|
+
export function configProposal(promote, config) {
|
|
246
|
+
const [runnerId, checkId] = promote.split(":");
|
|
247
|
+
const runner = config?.verification.acceptanceRunners?.find(r => r.id === runnerId);
|
|
248
|
+
return { note: "Proposal only: review it, then apply it yourself and confirm trust with agent-ops update; agent-ops never writes this config.",
|
|
249
|
+
promote: { runnerId, checkId },
|
|
250
|
+
verification: { commands: [{ id: `escape-${checkId}`.slice(0, 64), command: runner?.command ?? "<runner command>", args: [...runner?.args ?? []],
|
|
251
|
+
cwd: runner?.cwd ?? ".", required: true, evidence: { kind: "exit-code" } }] } };
|
|
252
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Removes a scratch directory without letting cleanup hide the result it served.
|
|
4
|
+
* Windows can hold a just-killed child's files open for a moment (EBUSY), so rm
|
|
5
|
+
* retries; a directory that still cannot be removed stays in the OS temp dir.
|
|
6
|
+
*/
|
|
7
|
+
export async function removeTemporary(path) {
|
|
8
|
+
// ponytail: a leaked temp directory is the ceiling; the OS temp cleaner reclaims it.
|
|
9
|
+
await rm(path, { recursive: true, force: true, maxRetries: 10 }).catch(() => undefined);
|
|
10
|
+
}
|
|
@@ -340,8 +340,11 @@ function autoRunInstruction(descriptor, context) {
|
|
|
340
340
|
"`WORKTREE_NESTED`, say so in one line naming the code and fall back to",
|
|
341
341
|
"the subtask flow (`task create --parent`, then batch or advance).",
|
|
342
342
|
"`RUN_REPO_UNTRUSTED` means stop and ask the user; never work",
|
|
343
|
-
"around trust.
|
|
344
|
-
"
|
|
343
|
+
"around trust. New runs have no total time cap; legacy finite runs upgrade",
|
|
344
|
+
"only on manual resume, preserving accounting and independent blockers.",
|
|
345
|
+
"Command/review timeouts, provider limits and DEBUG budgets remain effective.",
|
|
346
|
+
"If the run ends without completing (blocked, stopped, failing review),",
|
|
347
|
+
"report the `agent-ops run status",
|
|
345
348
|
"<id>` state and code, the last `agent-ops run logs <id>` events, and the",
|
|
346
349
|
"exact `agent-ops run resume <id>` and `agent-ops run stop <id>` commands,",
|
|
347
350
|
"then stop: never resume automatically or take over the run's work.",
|
|
@@ -362,9 +365,9 @@ export function managedRules(descriptor, context) {
|
|
|
362
365
|
""
|
|
363
366
|
];
|
|
364
367
|
if (context.capabilities.includes("task-debug"))
|
|
365
|
-
lines.push("Task DEBUG routing is enabled for investigation/bugfix issues. Native prompt hooks create the issue task when a session identity is available; otherwise use agent-ops task create --debug --goal <verbatim issue> with two to five issue-specific criteria. Explain/read-only, feature and refactor requests use the existing normal task flow.", "For DEBUG: understand the issue, investigate with task debug --debug-action investigate --command-id <trusted verifier>, record falsifiable hypotheses with --debug-action hypothesis --payload-file <json>, reproduce, fix, verify, then obtain two fresh independent reviews and finish automatically. A clean HEAD is the automatic baseline; do not ask permission for it or offer diagnosis-only as a fallback. Failure to reproduce alone is not a stop condition: continue discriminating checks within the budget. Replace provisional intake criteria with relevant behavioral acceptance; baseline setup/skip/timeout/UNKNOWN is never reproduction. Never report success from edits or unrelated green checks.", "DEBUG defaults are 60 cumulative active minutes and 5 material repair candidates across the original issue, revisions, sessions and children. After two stagnant candidates document one substantive strategy switch; two further stagnant candidates block. Save task debug --debug-action checkpoint before stopping. Resume preserves budgets and blockers. A human continue event may authorize --debug-action extend --event-id latest; unsupported native event sources fail closed. Never use allow-stop for DEBUG.", "DEBUG human control comes only from native human events: '只診斷' / 'diagnosis only' keeps investigating without product repairs until current sealed baseline assertion FAIL confirms a hypothesis or runtime limits/blockers permit non-success Stop. '暫停' / 'pause' saves progress and stops immediately. '繼續' / 'continue' resumes the existing scope; '繼續修復' / 'resume repair' restores the full loop. Codex Interrupt is a pause. Preserve the original task, evidence, consumed time and rounds; diagnosis or pause never marks repair complete. CLI actor/text fields and synthetic instructions cannot authorize these changes.", "DEBUG is foreground work. On Claude, agy and native Codex, Stop refuses an active DEBUG issue without a valid stop checkpoint, with or without the completion gate. Codex uses decision:block to continue the original task even on repeated Stop; stop_hook_active does not waive it. Codex's generic completion gate remains unsupported; opencode has no supported foreground lifecycle. DEBUG gate faults get one targeted task/config reload, then an explicit UNKNOWN/incomplete diagnostic and non-success Stop, preserving state. Without reliable native session identity, automatic hook intake is unsupported; the existing native Agent can use the task CLI. Only a matching owned debug worktree checkpoint permits dirty recovery; preserve all original user edits.", "");
|
|
368
|
+
lines.push("Task DEBUG routing is enabled for investigation/bugfix issues. Native prompt hooks create the issue task when a session identity is available; otherwise use agent-ops task create --debug --goal <verbatim issue> with two to five issue-specific criteria. Explain/read-only, feature and refactor requests use the existing normal task flow.", "For DEBUG: understand the issue, investigate with task debug --debug-action investigate --command-id <trusted verifier>, record falsifiable hypotheses with --debug-action hypothesis --payload-file <json>, reproduce, fix, then once a hypothesis is confirmed declare the origin with --debug-action origin --payload-file <json> (escape-of <commit> with the earliest-gate label, or pre-existing/external with a reason; finish refuses ESCAPE_ORIGIN_MISSING without it; a surface label also needs a tracked .agent-ops-escapes/rules/<id>.json rule that replays at the escaped commit, and a criteria/verify label payload promote or --escape-promote <runnerId:checkId>, else ESCAPE_RULE_UNPROVEN), verify, re-record the confirmed hypothesis, commit the .agent-ops-escapes record with the fix, then obtain two fresh independent reviews and finish automatically. A normal task fixing an escaped bug may declare task create --escape-of <commit> --escape-label <label>. A clean HEAD is the automatic baseline; do not ask permission for it or offer diagnosis-only as a fallback. Failure to reproduce alone is not a stop condition: continue discriminating checks within the budget. Replace provisional intake criteria with relevant behavioral acceptance; baseline setup/skip/timeout/UNKNOWN is never reproduction. Never report success from edits or unrelated green checks.", "DEBUG defaults are 60 cumulative active minutes and 5 material repair candidates across the original issue, revisions, sessions and children. After two stagnant candidates document one substantive strategy switch; two further stagnant candidates block. Save task debug --debug-action checkpoint before stopping. Resume preserves budgets and blockers. A human continue event may authorize --debug-action extend --event-id latest; unsupported native event sources fail closed. Never use allow-stop for DEBUG.", "DEBUG human control comes only from native human events: '只診斷' / 'diagnosis only' keeps investigating without product repairs until current sealed baseline assertion FAIL confirms a hypothesis or runtime limits/blockers permit non-success Stop. '暫停' / 'pause' saves progress and stops immediately. '繼續' / 'continue' resumes the existing scope; '繼續修復' / 'resume repair' restores the full loop. Codex Interrupt is a pause. Preserve the original task, evidence, consumed time and rounds; diagnosis or pause never marks repair complete. CLI actor/text fields and synthetic instructions cannot authorize these changes.", "DEBUG is foreground work. On Claude, agy and native Codex, Stop refuses an active DEBUG issue without a valid stop checkpoint, with or without the completion gate. Codex uses decision:block to continue the original task even on repeated Stop; stop_hook_active does not waive it. Codex's generic completion gate remains unsupported; opencode has no supported foreground lifecycle. DEBUG gate faults get one targeted task/config reload, then an explicit UNKNOWN/incomplete diagnostic and non-success Stop, preserving state. Without reliable native session identity, automatic hook intake is unsupported; the existing native Agent can use the task CLI. Only a matching owned debug worktree checkpoint permits dirty recovery; preserve all original user edits.", "");
|
|
366
369
|
if (context.capabilities.includes("rules")) {
|
|
367
|
-
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. 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.", "");
|
|
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
|
}
|
|
369
372
|
if (context.capabilities.includes("task")) {
|
|
370
373
|
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria and verification; final review covers all", "tasks together when their worktrees are integrated;", "completing one never completes its parent.", "", ...autoRunInstruction(descriptor, context), "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "For subagents in separate worktrees, use `task advance` instead.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "", "For a session with writing subagents, record each child task's complete", "modification intent with `task create --intent <text>` before editing.", "Each child commits and runs local `verify --task` in its own worktree.", "From the main checkout run `agent-ops task advance --task <parent-id>", "--session <session-id> --yes` through the trusted outer host runner,", "unsetting `CODEX_SANDBOX_NETWORK_DISABLED` on Codex. Advance integrates", "child commits and intent into the coordinator worktree, verifies every", "task on the final candidate, then runs one two-round tree review.", "Only `NOT_RUN / scope-too-large` falls back to per-task two-round review;", "a substantive FAIL must be fixed. After PASS, advance writes a local", "receipt under the main checkout's Git common directory and calls finish.", "If the target moves, it retries the final gate once. An unresolved", "failure preserves worktrees and cannot be reported as finished.", "");
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
-
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
4
|
+
import { localStatePaths, readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
3
5
|
import { redactSecrets } from "../security/redact.js";
|
|
4
6
|
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
5
7
|
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
@@ -125,6 +127,21 @@ function sanitizeEvent(value) {
|
|
|
125
127
|
residualRisks: value.residualRisks
|
|
126
128
|
};
|
|
127
129
|
}
|
|
130
|
+
if (value.type === "gate-catch") {
|
|
131
|
+
if (!hasExactKeys(value, ["code", "taskId", "type"]) || typeof value.code !== "string" || !ID_PATTERN.test(value.code) ||
|
|
132
|
+
typeof value.taskId !== "string" || !ID_PATTERN.test(value.taskId)) {
|
|
133
|
+
return invalidEvent();
|
|
134
|
+
}
|
|
135
|
+
return { type: "gate-catch", code: value.code, taskId: value.taskId };
|
|
136
|
+
}
|
|
137
|
+
if (value.type === "escape-rule") {
|
|
138
|
+
if (!hasExactKeys(value, ["excluded", "included", "ruleId", "type"]) || typeof value.ruleId !== "string" ||
|
|
139
|
+
!/^[a-z][a-z0-9-]{0,63}$/u.test(value.ruleId) ||
|
|
140
|
+
[value.included, value.excluded].some((n) => !Number.isSafeInteger(n) || n < 0)) {
|
|
141
|
+
return invalidEvent();
|
|
142
|
+
}
|
|
143
|
+
return { type: "escape-rule", ruleId: value.ruleId, included: value.included, excluded: value.excluded };
|
|
144
|
+
}
|
|
128
145
|
return invalidEvent();
|
|
129
146
|
}
|
|
130
147
|
function serialize(stored) {
|
|
@@ -198,3 +215,29 @@ export async function appendLocalLog(path, event, options) {
|
|
|
198
215
|
await writePrivateFile(path, serialized.join(""), options.anchorDirectory);
|
|
199
216
|
});
|
|
200
217
|
}
|
|
218
|
+
/** Advisory and fail-open: a logging fault never changes the gate that caught something. */
|
|
219
|
+
export async function logGateCatch(code, taskId, home = process.env.AGENT_OPS_HOME ?? homedir()) {
|
|
220
|
+
try {
|
|
221
|
+
const state = localStatePaths(home);
|
|
222
|
+
await appendLocalLog(join(state.logDirectory, "events.ndjson"), { type: "gate-catch", code, taskId }, { anchorDirectory: state.anchorDirectory });
|
|
223
|
+
}
|
|
224
|
+
catch { /* advisory only */ }
|
|
225
|
+
}
|
|
226
|
+
/** Read-only and fail-open for reports: unreadable lines are skipped, a missing log is empty. */
|
|
227
|
+
export async function readLocalLog(home = process.env.AGENT_OPS_HOME ?? homedir()) {
|
|
228
|
+
try {
|
|
229
|
+
const state = localStatePaths(home);
|
|
230
|
+
const source = await readPrivateFile(join(state.logDirectory, "events.ndjson"), state.anchorDirectory);
|
|
231
|
+
return (source ?? "").split("\n").filter(Boolean).flatMap((line) => {
|
|
232
|
+
try {
|
|
233
|
+
return [parseStoredLine(line)];
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -122,7 +122,7 @@ export function sceneModel(snapshot) {
|
|
|
122
122
|
return { key, sourceIndex: index, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions, criteria,
|
|
123
123
|
activities: activitiesOf(optional(run).activities),
|
|
124
124
|
board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: rootProgress?.passed ?? 0, total: rootProgress?.total ?? 0 },
|
|
125
|
-
clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs :
|
|
125
|
+
clock: run.budget.limitMs !== null && run.budget.remainingMs !== null && run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 1, phase: run.phase, status: run.status,
|
|
126
126
|
completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
|
|
127
127
|
};
|
|
128
128
|
const activitiesOf = (value) => (Array.isArray(value) ? value : []).map(raw => {
|
|
@@ -25,7 +25,7 @@ function send(res, status, type, body, headers = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
/** Loopback-only, token-gated, GET-only view of the building. */
|
|
27
27
|
export function createOfficeServer(options) {
|
|
28
|
-
|
|
28
|
+
let token = options.token ?? randomBytes(24).toString("base64url");
|
|
29
29
|
const now = options.now ?? Date.now;
|
|
30
30
|
const idleMs = options.idleMs ?? OFFICE_IDLE_MS;
|
|
31
31
|
let lastActive = now();
|
|
@@ -40,6 +40,8 @@ export function createOfficeServer(options) {
|
|
|
40
40
|
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
41
41
|
if (!same(url.searchParams.get("token") ?? "", token))
|
|
42
42
|
return send(res, 403, "text/plain", "Forbidden\n");
|
|
43
|
+
if (url.pathname === "/health")
|
|
44
|
+
return send(res, 200, "text/plain", "ok\n");
|
|
43
45
|
if (url.pathname === "/") {
|
|
44
46
|
const nonce = randomBytes(16).toString("base64");
|
|
45
47
|
return send(res, 200, "text/html; charset=utf-8", officePage(nonce), { "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'` });
|
|
@@ -54,16 +56,25 @@ export function createOfficeServer(options) {
|
|
|
54
56
|
else
|
|
55
57
|
res.end(); });
|
|
56
58
|
});
|
|
59
|
+
const bind = (candidate) => new Promise((resolve, reject) => {
|
|
60
|
+
const onError = (error) => { server.removeListener("listening", onListening); reject(error); };
|
|
61
|
+
const onListening = () => {
|
|
62
|
+
server.removeListener("error", onError);
|
|
63
|
+
const address = server.address();
|
|
64
|
+
port = typeof address === "object" && address !== null ? address.port : 0;
|
|
65
|
+
resolve(port);
|
|
66
|
+
};
|
|
67
|
+
server.once("error", onError);
|
|
68
|
+
server.once("listening", onListening);
|
|
69
|
+
server.listen(candidate, "127.0.0.1");
|
|
70
|
+
});
|
|
57
71
|
return {
|
|
58
|
-
server,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
resolve(port);
|
|
65
|
-
});
|
|
66
|
-
}),
|
|
72
|
+
server,
|
|
73
|
+
get token() { return token; },
|
|
74
|
+
set token(value) { token = value; },
|
|
75
|
+
listen: async (preferredPort = 0) => preferredPort > 0
|
|
76
|
+
? await bind(preferredPort).catch(async () => await bind(0))
|
|
77
|
+
: await bind(0),
|
|
67
78
|
tick: async () => {
|
|
68
79
|
const snapshot = await options.snapshot().catch(() => null);
|
|
69
80
|
const sessionIsPresent = snapshot?.lobby.some(desk => (desk.sessionActive ?? desk.status === "active") && (desk.completedAt === undefined || desk.completedAt === null)) ?? false;
|
|
@@ -86,16 +97,29 @@ function alive(pid) {
|
|
|
86
97
|
return error.code === "EPERM";
|
|
87
98
|
}
|
|
88
99
|
}
|
|
89
|
-
|
|
90
|
-
export async function readLiveOffice(home) {
|
|
100
|
+
export async function readOfficeRecord(home) {
|
|
91
101
|
const source = await readPrivateFile(officeRecordPath(home), home.anchor).catch(() => null);
|
|
92
102
|
if (source === null)
|
|
93
103
|
return null;
|
|
94
104
|
try {
|
|
95
105
|
const record = JSON.parse(source);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
return Number.isSafeInteger(record.pid) && Number.isSafeInteger(record.port) &&
|
|
107
|
+
record.port > 0 && record.port <= 65535 &&
|
|
108
|
+
typeof record.token === "string" && record.token.length > 0
|
|
109
|
+
? record
|
|
110
|
+
: null;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** A recorded server counts only if its process lives and it answers with its own token. */
|
|
117
|
+
export async function readLiveOffice(home) {
|
|
118
|
+
const record = await readOfficeRecord(home);
|
|
119
|
+
if (record === null || !alive(record.pid))
|
|
120
|
+
return null;
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(`http://127.0.0.1:${record.port}/health?token=${encodeURIComponent(record.token)}`, { signal: AbortSignal.timeout(2000) });
|
|
99
123
|
await response.body?.cancel();
|
|
100
124
|
return response.status === 200 ? record : null;
|
|
101
125
|
}
|
|
@@ -111,7 +135,10 @@ export async function claimOffice(home, office, pid = process.pid) {
|
|
|
111
135
|
return await withPrivateFileLock(officeRecordPath(home), home.anchor, async () => {
|
|
112
136
|
if (await readLiveOffice(home) !== null)
|
|
113
137
|
return null;
|
|
114
|
-
const
|
|
138
|
+
const previous = await readOfficeRecord(home);
|
|
139
|
+
if (previous !== null)
|
|
140
|
+
office.token = previous.token;
|
|
141
|
+
const record = { pid, port: await office.listen(previous?.port), token: office.token, startedAt: new Date().toISOString() };
|
|
115
142
|
await writePrivateFile(officeRecordPath(home), JSON.stringify(record) + "\n", home.anchor);
|
|
116
143
|
return record;
|
|
117
144
|
});
|
|
@@ -52,7 +52,7 @@ export function buildOfficeSnapshot(input) {
|
|
|
52
52
|
...(rootTaskId === null ? {} : { progress: rootTree?.progress ?? rootTask?.progress ?? null,
|
|
53
53
|
...(rootTree?.criteria === undefined ? {} : { criteria: rootTree.criteria }) }),
|
|
54
54
|
status: run.status, phase: run.phase ?? "unknown",
|
|
55
|
-
budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: Math.max(0, run.budget.limitMs - usedMs) },
|
|
55
|
+
budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: run.budget.limitMs === null ? null : Math.max(0, run.budget.limitMs - usedMs) },
|
|
56
56
|
questions, agents, reviewers,
|
|
57
57
|
...(run.status === "complete" || run.status === "failed" ? { completedAt: run.updatedAt } : {}),
|
|
58
58
|
commands: [`agent-ops run status ${run.runId}`, `agent-ops run logs ${run.runId}`,
|