omp-conductor 0.17.1 → 0.18.1
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 +34 -0
- package/REFERENCE.md +71 -17
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +53 -1
- package/src/admission.ts +308 -76
- package/src/ask.ts +307 -10
- package/src/backups.ts +2 -2
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +43 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +37 -19
- package/src/cli.ts +2 -0
- package/src/command-help.ts +19 -1
- package/src/command-manifest.ts +27 -2
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +110 -3
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +57 -0
- package/src/config.ts +102 -2
- package/src/daemon.ts +1220 -1517
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +279 -16
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +425 -1
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +43 -4
- package/src/fleet.ts +166 -24
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +55 -8
- package/src/graph.ts +379 -69
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +158 -6
- package/src/omp.ts +269 -20
- package/src/orchestrator-tick.ts +1489 -26
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/routing.ts +11 -3
- package/src/session-host.ts +115 -5
- package/src/settlement.ts +1780 -0
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +119 -30
- package/src/setup-wizard.ts +88 -2
- package/src/setup.ts +119 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +100 -11
- package/src/store.ts +519 -45
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +150 -14
- package/src/types.ts +470 -16
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +770 -40
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +239 -9
- package/src/worktree.ts +142 -18
package/src/verbs/socket.ts
CHANGED
|
@@ -215,21 +215,40 @@ export function unlinkStaleSocket(path: string): void {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
/**
|
|
218
|
-
* Restrict a bound socket to the daemon's own uid
|
|
218
|
+
* Restrict a bound socket to the daemon's own uid, or to the worker identity
|
|
219
|
+
* that owns the channel when `owner` is given.
|
|
219
220
|
*
|
|
220
221
|
* Returns the guarantee in force, because #126 asks the daemon to *state* its
|
|
221
222
|
* mechanism at startup rather than guess: the socket is `0600` under a `0711`
|
|
222
223
|
* daemon-owned parent, so it is one-user, and the unguessable suffix plus that
|
|
223
|
-
* parent are the whole story.
|
|
224
|
-
*
|
|
224
|
+
* parent are the whole story. Under the worker identity the one user is the
|
|
225
|
+
* worker account — the socket is chowned to it, because the child that must
|
|
226
|
+
* connect to it runs as that uid and nothing else does. Saying so is the
|
|
227
|
+
* difference between an audited boundary and a hopeful one.
|
|
225
228
|
*/
|
|
226
|
-
export type SocketOwnership = "daemon-user";
|
|
229
|
+
export type SocketOwnership = "daemon-user" | "worker-user";
|
|
230
|
+
|
|
231
|
+
export interface SecureBoundSocketOptions {
|
|
232
|
+
/** The chmod call; injected so tests pin the mode without a real socket. */
|
|
233
|
+
chmod?: (p: string, mode: number) => void;
|
|
234
|
+
/**
|
|
235
|
+
* The uid/gid the socket belongs to (the worker identity for a run
|
|
236
|
+
* channel). Absent, the socket stays daemon-owned and only the daemon's
|
|
237
|
+
* uid can connect.
|
|
238
|
+
*/
|
|
239
|
+
owner?: { uid: number; gid: number };
|
|
240
|
+
}
|
|
227
241
|
|
|
228
242
|
export function secureBoundSocket(
|
|
229
243
|
path: string,
|
|
230
|
-
|
|
244
|
+
options: SecureBoundSocketOptions = {},
|
|
231
245
|
): SocketOwnership {
|
|
246
|
+
const chmod = options.chmod ?? chmodSync;
|
|
232
247
|
chmod(path, VERB_SOCKET_MODE);
|
|
248
|
+
if (options.owner !== undefined) {
|
|
249
|
+
chownSync(path, options.owner.uid, options.owner.gid);
|
|
250
|
+
return "worker-user";
|
|
251
|
+
}
|
|
233
252
|
return "daemon-user";
|
|
234
253
|
}
|
|
235
254
|
|
package/src/worker.ts
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
import type { WorkerIdentity } from "./host.ts";
|
|
15
16
|
import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
|
|
16
17
|
import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
|
|
17
|
-
import type { Caps, ResolvedGrants, RunState } from "./types.ts";
|
|
18
|
+
import type { Caps, GraphToolsObservation, ResolvedGrants, RunState } from "./types.ts";
|
|
18
19
|
|
|
19
20
|
/** Structured evidence fields from the worker's final report. */
|
|
20
21
|
const PR_URL_PATTERN = /^pr:\s*(https:\/\/github\.com\/\S+\/pull\/\d+)\s*$/im;
|
|
@@ -30,6 +31,77 @@ const GITHUB_PR_URL_PATTERN = /https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/
|
|
|
30
31
|
/** `{{KEY}}` placeholders in a brief template. */
|
|
31
32
|
const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The structured settlement a worker yields at the end of a run (#540).
|
|
36
|
+
*
|
|
37
|
+
* Fields decided here, not per project. The daemon stays the authority on
|
|
38
|
+
* green — it verifies the PR itself — so this is the worker's *claim*,
|
|
39
|
+
* cross-checked exactly as the prose report it replaces: a `green` claim
|
|
40
|
+
* must carry the PR URL and the head SHA the worker actually watched go
|
|
41
|
+
* green, and anything less fails closed.
|
|
42
|
+
*/
|
|
43
|
+
export interface WorkerSettlement {
|
|
44
|
+
status: "green" | "blocked" | "failed";
|
|
45
|
+
/** The run's pull request URL. Required with `status: "green"`. */
|
|
46
|
+
prUrl?: string;
|
|
47
|
+
/** The branch the PR was pushed from. */
|
|
48
|
+
branch?: string;
|
|
49
|
+
/** The 40-hex head SHA the green CI verdict was observed at. Required with `status: "green"`. */
|
|
50
|
+
headSha?: string;
|
|
51
|
+
/** What changed and why — the narrative a reviewer reads. */
|
|
52
|
+
summary: string;
|
|
53
|
+
/** What a decision or credential this run needed (blocked runs). */
|
|
54
|
+
blockers?: string[];
|
|
55
|
+
/** Commands run as evidence, exactly as executed. */
|
|
56
|
+
proof?: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The JSON Schema form of {@link WorkerSettlement}, handed to the harness as
|
|
61
|
+
* the session's `outputSchema` (#540).
|
|
62
|
+
*
|
|
63
|
+
* Used two ways, and both must stay honest to the same contract:
|
|
64
|
+
* `requireYieldTool` puts the `yield` tool in front of the worker with this
|
|
65
|
+
* schema as its description, and `outputSchemaMode: "permissive"` means a
|
|
66
|
+
* violation is a fallback, never a lost report — the worker's text path must
|
|
67
|
+
* still settle a run whose yield does not parse.
|
|
68
|
+
*/
|
|
69
|
+
export const WORKER_SETTLEMENT_SCHEMA = {
|
|
70
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
71
|
+
type: "object",
|
|
72
|
+
description:
|
|
73
|
+
"The run's settlement: what this worker claims it achieved. The daemon verifies the PR itself; " +
|
|
74
|
+
"this is the claim, cross-checked like the settlement report it replaces.",
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
required: ["status", "summary"],
|
|
77
|
+
properties: {
|
|
78
|
+
status: {
|
|
79
|
+
type: "string",
|
|
80
|
+
enum: ["green", "blocked", "failed"],
|
|
81
|
+
description:
|
|
82
|
+
"green: pushed and the checks you watched are green. blocked: a decision, credential or " +
|
|
83
|
+
"repo fact is missing. failed: the run could not complete.",
|
|
84
|
+
},
|
|
85
|
+
prUrl: { type: "string", description: "The run's pull request URL — required with status green." },
|
|
86
|
+
branch: { type: "string", description: "The branch the PR was pushed from." },
|
|
87
|
+
headSha: {
|
|
88
|
+
type: "string",
|
|
89
|
+
description: "The 40-character head SHA you observed green — required with status green.",
|
|
90
|
+
},
|
|
91
|
+
summary: { type: "string", description: "One short paragraph: what changed and why." },
|
|
92
|
+
blockers: {
|
|
93
|
+
type: "array",
|
|
94
|
+
items: { type: "string" },
|
|
95
|
+
description: "What is missing or uncertain, one item per blocker (blocked runs).",
|
|
96
|
+
},
|
|
97
|
+
proof: {
|
|
98
|
+
type: "array",
|
|
99
|
+
items: { type: "string" },
|
|
100
|
+
description: "The commands you ran as evidence, exactly as executed.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
33
105
|
function scheduleWallClock(callback: () => void, delayMs: number): () => void {
|
|
34
106
|
const timer = setTimeout(callback, delayMs);
|
|
35
107
|
return () => clearTimeout(timer);
|
|
@@ -88,7 +160,7 @@ export const REVIEW_REVISION_PROMPT =
|
|
|
88
160
|
"The orchestrator reviewed your green pull request and found blocking findings. Continue this same session " +
|
|
89
161
|
"on this same run: same branch, same pull request — do not close or reopen it, and do not open another. " +
|
|
90
162
|
"Address exactly the findings below, push with conductor_push, verify the checks with conductor_pr_status, " +
|
|
91
|
-
"and finish with the same final-report contract as before (
|
|
163
|
+
"and finish with the same final-report contract as before (yield your settlement: status green, prUrl, headSha). " +
|
|
92
164
|
"Your original brief is already in this transcript; redo only what the findings implicate.";
|
|
93
165
|
|
|
94
166
|
/**
|
|
@@ -146,6 +218,17 @@ export interface WorkerOpts {
|
|
|
146
218
|
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
147
219
|
/** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
|
|
148
220
|
onSpawn?: (pid: number) => void;
|
|
221
|
+
/**
|
|
222
|
+
* The worker identity this session runs under (#798): the dedicated
|
|
223
|
+
* least-privilege account the daemon resolved for worker launch. Forwarded
|
|
224
|
+
* to `createSession`, which launches the child through the identity
|
|
225
|
+
* transition (setpriv), re-points its environment at the worker's own home,
|
|
226
|
+
* grants it the control socket, and refuses the session unless its kernel
|
|
227
|
+
* uid/gid match. The daemon supplies it for every worker launch and refuses
|
|
228
|
+
* to dispatch a worker without it; absent, the session runs as the
|
|
229
|
+
* caller's own identity (orchestrator, tests).
|
|
230
|
+
*/
|
|
231
|
+
workerIdentity?: WorkerIdentity;
|
|
149
232
|
/** Control socket for that child, beside the run's own session directory. */
|
|
150
233
|
socketPath?: string;
|
|
151
234
|
/**
|
|
@@ -205,6 +288,13 @@ export interface WorkerResult {
|
|
|
205
288
|
headSha?: string;
|
|
206
289
|
turns: number;
|
|
207
290
|
spendUsd: number;
|
|
291
|
+
/**
|
|
292
|
+
* The run's settlement text. Today this is the worker's own last words; a
|
|
293
|
+
* run that yielded a structured {@link WorkerSettlement} instead carries the
|
|
294
|
+
* canonical rendering of that parsed object (#540) — the narrative lives in
|
|
295
|
+
* its `summary`, transcribed here field for field rather than regexed out of
|
|
296
|
+
* prose.
|
|
297
|
+
*/
|
|
208
298
|
report: string;
|
|
209
299
|
/** In-session HTTP 429 responses the session recorded (stopReason "error",
|
|
210
300
|
* errorStatus 429), counted as the messages streamed in. A healthy run
|
|
@@ -252,6 +342,15 @@ export interface WorkerResult {
|
|
|
252
342
|
* is otherwise indistinguishable from a run that was merely unlucky.
|
|
253
343
|
*/
|
|
254
344
|
modelFallbackMessage?: string;
|
|
345
|
+
/**
|
|
346
|
+
* The code-graph session observation (#726): what the session's own registry
|
|
347
|
+
* held at start, read off the session exactly like `modelFallbackMessage`.
|
|
348
|
+
* Absent means the session surface did not record one — never "graph tools
|
|
349
|
+
* absent", which is the `present: false` truth value. A dispatched run that
|
|
350
|
+
* records it lets the doctor tell "the model ignored a tool it had" from
|
|
351
|
+
* "the tool was missing" apart.
|
|
352
|
+
*/
|
|
353
|
+
graphTools?: GraphToolsObservation;
|
|
255
354
|
}
|
|
256
355
|
|
|
257
356
|
/**
|
|
@@ -277,12 +376,34 @@ export function renderBrief(template: string, vars: Record<string, string>): str
|
|
|
277
376
|
* must carry both a PR URL and the head SHA observed after CI; the daemon then
|
|
278
377
|
* asks the tracker to verify those facts independently. Missing or malformed
|
|
279
378
|
* evidence fails closed.
|
|
379
|
+
*
|
|
380
|
+
* A parsed {@link WorkerSettlement} wins over the text whenever it is present
|
|
381
|
+
* (#540): the schema is the contract now, and a misleadingly regular-looking
|
|
382
|
+
* prose block must not out-vote the object the worker actually yielded. The
|
|
383
|
+
* text path is unchanged underneath, so a run that never yields settles
|
|
384
|
+
* exactly as it did before.
|
|
280
385
|
*/
|
|
281
|
-
export function deriveResult(
|
|
386
|
+
export function deriveResult(
|
|
387
|
+
report: string,
|
|
388
|
+
repoSlug?: string,
|
|
389
|
+
structured?: WorkerSettlement,
|
|
390
|
+
): {
|
|
282
391
|
state: RunState;
|
|
283
392
|
prUrl?: string;
|
|
284
393
|
headSha?: string;
|
|
285
394
|
} {
|
|
395
|
+
if (structured !== undefined && (structured.status === "green" || structured.status === "blocked" || structured.status === "failed")) {
|
|
396
|
+
const prUrl = structured.prUrl;
|
|
397
|
+
const headSha = structured.headSha?.toLowerCase();
|
|
398
|
+
if (structured.status === "green" && prUrl !== undefined && headSha !== undefined) {
|
|
399
|
+
return { state: "pushed-green", prUrl, headSha };
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
state: structured.status === "blocked" ? "blocked" : "failed",
|
|
403
|
+
...(prUrl === undefined ? {} : { prUrl }),
|
|
404
|
+
...(headSha === undefined ? {} : { headSha }),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
286
407
|
const structuredPrUrl = PR_URL_PATTERN.exec(report)?.[1];
|
|
287
408
|
const headSha = HEAD_SHA_PATTERN.exec(report)?.[1]?.toLowerCase();
|
|
288
409
|
if (PUSHED_GREEN_PATTERN.test(report) && structuredPrUrl !== undefined && headSha !== undefined) {
|
|
@@ -305,6 +426,81 @@ export function deriveResult(report: string, repoSlug?: string): {
|
|
|
305
426
|
};
|
|
306
427
|
}
|
|
307
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Parse a worker's structured settlement out of one assistant message's
|
|
431
|
+
* content (#540).
|
|
432
|
+
*
|
|
433
|
+
* The worker calls the `yield` tool with `{ result: { data: <settlement> } }`
|
|
434
|
+
* and the harness records that call as a `toolCall` content block on the
|
|
435
|
+
* assistant message — the same content `reportText` flattens. Parsed
|
|
436
|
+
* permissively on purpose: `outputSchemaMode` is permissive, so a yield the
|
|
437
|
+
* schema rejected must still fall back to the text path rather than vanish.
|
|
438
|
+
* The acceptance bar mirrors the schema's own `required`: a recognizable
|
|
439
|
+
* `status` and a string `summary`; every other field is adopted when it has
|
|
440
|
+
* the right type and dropped otherwise. Exported so the run loop's precedence
|
|
441
|
+
* (yield over prose) is pinned by a unit test.
|
|
442
|
+
*/
|
|
443
|
+
export function structuredSettlement(content: unknown): WorkerSettlement | undefined {
|
|
444
|
+
if (!Array.isArray(content)) return undefined;
|
|
445
|
+
let parsed: WorkerSettlement | undefined;
|
|
446
|
+
for (const block of content) {
|
|
447
|
+
if (field(block, "type") !== "toolCall") continue;
|
|
448
|
+
if (field(block, "name") !== "yield") continue;
|
|
449
|
+
const args = field(block, "arguments");
|
|
450
|
+
if (args === null || typeof args !== "object") continue;
|
|
451
|
+
const result = field(args, "result");
|
|
452
|
+
if (result === null || typeof result !== "object") continue;
|
|
453
|
+
const data = field(result, "data");
|
|
454
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) continue;
|
|
455
|
+
const record = data as Record<string, unknown>;
|
|
456
|
+
const status = record.status;
|
|
457
|
+
if (status !== "green" && status !== "blocked" && status !== "failed") continue;
|
|
458
|
+
const summary = record.summary;
|
|
459
|
+
if (typeof summary !== "string") continue;
|
|
460
|
+
const settlement: WorkerSettlement = { status, summary };
|
|
461
|
+
for (const key of ["prUrl", "branch", "headSha"] as const) {
|
|
462
|
+
const value = record[key];
|
|
463
|
+
if (typeof value === "string" && value !== "") settlement[key] = value;
|
|
464
|
+
}
|
|
465
|
+
for (const key of ["blockers", "proof"] as const) {
|
|
466
|
+
const value = record[key];
|
|
467
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
|
468
|
+
settlement[key] = value;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
parsed = settlement;
|
|
472
|
+
}
|
|
473
|
+
return parsed;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Render a parsed {@link WorkerSettlement} as the run's stored report (#540).
|
|
478
|
+
*
|
|
479
|
+
* A worker that yields structured output may write little or no prose after
|
|
480
|
+
* the yield call, and the prose shape is no longer the contract — so the
|
|
481
|
+
* report the store keeps for such a run is this canonical rendering of the
|
|
482
|
+
* parsed object itself: the same fields a reviewer would have had to regex
|
|
483
|
+
* out of prose, derived from the object, field for field. The daemon's own
|
|
484
|
+
* postfixes — the PR-diff `changed:` line and the reliability sentence — are
|
|
485
|
+
* appended to it exactly as they are to a prose report.
|
|
486
|
+
*/
|
|
487
|
+
export function renderSettlement(settlement: WorkerSettlement): string {
|
|
488
|
+
const lines: string[] = [`status: ${settlement.status}`];
|
|
489
|
+
if (settlement.prUrl !== undefined) lines.push(`pr: ${settlement.prUrl}`);
|
|
490
|
+
if (settlement.branch !== undefined) lines.push(`branch: ${settlement.branch}`);
|
|
491
|
+
if (settlement.headSha !== undefined) lines.push(`head: ${settlement.headSha}`);
|
|
492
|
+
if (settlement.summary !== "") lines.push("", settlement.summary);
|
|
493
|
+
if (settlement.blockers !== undefined && settlement.blockers.length > 0) {
|
|
494
|
+
lines.push("", "blockers:");
|
|
495
|
+
for (const blocker of settlement.blockers) lines.push(` - ${blocker}`);
|
|
496
|
+
}
|
|
497
|
+
if (settlement.proof !== undefined && settlement.proof.length > 0) {
|
|
498
|
+
lines.push("", "proof:");
|
|
499
|
+
for (const item of settlement.proof) lines.push(` - ${item}`);
|
|
500
|
+
}
|
|
501
|
+
return lines.join("\n");
|
|
502
|
+
}
|
|
503
|
+
|
|
308
504
|
/**
|
|
309
505
|
* Did this report state a verdict at all?
|
|
310
506
|
*
|
|
@@ -375,11 +571,19 @@ export async function runWorker(
|
|
|
375
571
|
role: "worker",
|
|
376
572
|
...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
|
|
377
573
|
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
574
|
+
...(o.workerIdentity === undefined ? {} : { identity: o.workerIdentity }),
|
|
378
575
|
...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
|
|
379
576
|
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
380
577
|
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
381
578
|
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
|
382
579
|
...(o.maySpawn === undefined ? {} : { maySpawn: o.maySpawn }),
|
|
580
|
+
// The structured settlement contract (#540): the worker's `yield` tool
|
|
581
|
+
// validates its `data` payload against this schema, permissively — an
|
|
582
|
+
// invalid or absent yield falls back to the text path, never a lost
|
|
583
|
+
// report. Always-on for workers: one schema, and no per-project shape.
|
|
584
|
+
outputSchema: WORKER_SETTLEMENT_SCHEMA,
|
|
585
|
+
outputSchemaMode: "permissive",
|
|
586
|
+
requireYieldTool: true,
|
|
383
587
|
});
|
|
384
588
|
} catch (err) {
|
|
385
589
|
// The pre-spawn gate closed (#374): a daemon stop landed while the session
|
|
@@ -431,7 +635,7 @@ export async function runWorker(
|
|
|
431
635
|
const withSessionFacts = (
|
|
432
636
|
result: Omit<WorkerResult, ReliabilityKeys>,
|
|
433
637
|
): Omit<WorkerResult, ReliabilityKeys> => {
|
|
434
|
-
const { sessionFile, modelFallbackMessage } = session;
|
|
638
|
+
const { sessionFile, modelFallbackMessage, graphTools } = session;
|
|
435
639
|
if (!advisorSpendFolded) {
|
|
436
640
|
advisorSpendFolded = true;
|
|
437
641
|
// Advisor turns live in their own `__advisor*.jsonl` and never surface
|
|
@@ -452,6 +656,7 @@ export async function runWorker(
|
|
|
452
656
|
spendUsd,
|
|
453
657
|
...(sessionFile === undefined ? {} : { sessionFile }),
|
|
454
658
|
...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
|
|
659
|
+
...(graphTools === undefined ? {} : { graphTools }),
|
|
455
660
|
};
|
|
456
661
|
};
|
|
457
662
|
|
|
@@ -473,6 +678,10 @@ export async function runWorker(
|
|
|
473
678
|
let spendUsd = 0;
|
|
474
679
|
let provider429Count = 0;
|
|
475
680
|
let report = "";
|
|
681
|
+
// The newest structured settlement the worker yielded (#540). A terminal
|
|
682
|
+
// `yield` ends the run, so anything parsed here is the run's own claim, and
|
|
683
|
+
// any later message is stale-polling chatter, never a newer verdict.
|
|
684
|
+
let structured: WorkerSettlement | undefined;
|
|
476
685
|
// Which model/provider actually wrote the newest assistant message. Last
|
|
477
686
|
// assistant message wins: that is the durable answer even for a run that
|
|
478
687
|
// never failed over (#535 slice 1, read off the message field which is where
|
|
@@ -629,15 +838,22 @@ export async function runWorker(
|
|
|
629
838
|
session.on("message_end", (event) => {
|
|
630
839
|
const message = field(event, "message");
|
|
631
840
|
if (field(message, "role") !== "assistant") return;
|
|
841
|
+
const content = field(message, "content");
|
|
632
842
|
// Keep the newest non-empty assistant text: whatever the worker said last
|
|
633
843
|
// is its report, whether it finished cleanly or was cut off.
|
|
634
|
-
const text = reportText(
|
|
844
|
+
const text = reportText(content);
|
|
635
845
|
if (text !== "") {
|
|
636
846
|
report = text;
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
847
|
+
}
|
|
848
|
+
// The structured settlement (#540): the `yield` tool call rides the same
|
|
849
|
+
// message's content as a `toolCall` block. Newest yield wins; the
|
|
850
|
+
// derivation itself prefers it over prose, so a yield and a misleadingly
|
|
851
|
+
// regular-looking text block on the same message agree in its favour.
|
|
852
|
+
const yielded = structuredSettlement(content);
|
|
853
|
+
if (yielded !== undefined) structured = yielded;
|
|
854
|
+
const stated = deriveResult(text, o.repoSlug, yielded);
|
|
855
|
+
if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
|
|
856
|
+
claim = { prUrl: stated.prUrl, headSha: stated.headSha };
|
|
641
857
|
}
|
|
642
858
|
|
|
643
859
|
// Real cost lives on assistant messages as `usage.cost.total` (live hermes
|
|
@@ -802,6 +1018,20 @@ export async function runWorker(
|
|
|
802
1018
|
}));
|
|
803
1019
|
}
|
|
804
1020
|
|
|
1021
|
+
// A structured yield is the run's settlement: it wins over the last text —
|
|
1022
|
+
// the worker may have written nothing after the yield call, and the stored
|
|
1023
|
+
// report is the canonical rendering of the parsed object, not prose the
|
|
1024
|
+
// regex has to recover (#540).
|
|
1025
|
+
if (structured !== undefined) {
|
|
1026
|
+
return withMetrics(withSessionFacts({
|
|
1027
|
+
...deriveResult(report, o.repoSlug, structured),
|
|
1028
|
+
turns,
|
|
1029
|
+
spendUsd,
|
|
1030
|
+
provider429Count,
|
|
1031
|
+
report: renderSettlement(structured),
|
|
1032
|
+
}));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
805
1035
|
// An explicit later verdict always wins: a worker that pushed green and then
|
|
806
1036
|
// stopped to ask a question means the question. The earlier claim is only
|
|
807
1037
|
// restored when the last thing said was not a verdict at all.
|
package/src/worktree.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
28
28
|
import { dirname, join } from "node:path";
|
|
29
29
|
|
|
30
|
-
import { credentialedEnv, scrubUserinfo } from "./gitops.ts";
|
|
30
|
+
import { credentialedEnv, runRepoSafeDirectoryExemption, scrubUserinfo } from "./gitops.ts";
|
|
31
31
|
import type { RepoTarget } from "./types.ts";
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -255,7 +255,9 @@ function refreshManagedExclude(worktree: string): void {
|
|
|
255
255
|
try {
|
|
256
256
|
// `--git-common-dir` is relative for linked worktrees; resolve against the
|
|
257
257
|
// tree so bare-mirror layouts and plain clones both land on info/exclude.
|
|
258
|
-
|
|
258
|
+
// The tree is worker-owned by the time salvage runs (#798), so the
|
|
259
|
+
// exact-path exemption rides on the argv (#816).
|
|
260
|
+
const common = Bun.spawnSync(["git", ...runRepoSafeDirectoryExemption(worktree), "rev-parse", "--git-common-dir"], {
|
|
259
261
|
cwd: worktree,
|
|
260
262
|
stdin: "ignore",
|
|
261
263
|
stdout: "pipe",
|
|
@@ -640,11 +642,16 @@ export async function salvageWip(
|
|
|
640
642
|
// and dies with the next `worktree remove --force` (#44).
|
|
641
643
|
refreshManagedExclude(worktree);
|
|
642
644
|
|
|
643
|
-
|
|
645
|
+
// The tree is worker-owned by the time salvage runs (#798): every daemon
|
|
646
|
+
// git call against it carries the exact per-run ownership exemption —
|
|
647
|
+
// never a wildcard, never a global config entry (#816).
|
|
648
|
+
const exempt = runRepoSafeDirectoryExemption(worktree);
|
|
649
|
+
|
|
650
|
+
if ((await git([...exempt, "status", "--porcelain"], worktree)) === "") return { kind: "nothing" };
|
|
644
651
|
|
|
645
652
|
// The tree's own branch, not one the caller believes it should be on: this
|
|
646
653
|
// string ends up in an escalation as the place to go looking.
|
|
647
|
-
const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktree);
|
|
654
|
+
const branch = await git([...exempt, "rev-parse", "--abbrev-ref", "HEAD"], worktree);
|
|
648
655
|
|
|
649
656
|
// `-A` on purpose: the losses this exists for were mostly *new* files.
|
|
650
657
|
//
|
|
@@ -660,13 +667,13 @@ export async function salvageWip(
|
|
|
660
667
|
// A literal `:(exclude)<path>` was also an outright bug: git counts it as
|
|
661
668
|
// naming the path, so an already-ignored file made `add` exit 1 and every
|
|
662
669
|
// cap-kill would have reported a salvage *failure*.
|
|
663
|
-
await git(["add", "-A"], worktree);
|
|
670
|
+
await git([...exempt, "add", "-A"], worktree);
|
|
664
671
|
|
|
665
672
|
// The dirty check above ran before git applied its ignores, so a tree whose
|
|
666
673
|
// only changes were ignored scratch had work by that test and none by this.
|
|
667
674
|
// Without this, `commit` exits non-zero on an empty index and a tree
|
|
668
675
|
// holding nothing worth keeping gets reported as a salvage *failure*.
|
|
669
|
-
const cached = await git(["diff", "--cached", "--name-status"], worktree);
|
|
676
|
+
const cached = await git([...exempt, "diff", "--cached", "--name-status"], worktree);
|
|
670
677
|
if (cached === "") {
|
|
671
678
|
return { kind: "nothing" };
|
|
672
679
|
}
|
|
@@ -674,6 +681,7 @@ export async function salvageWip(
|
|
|
674
681
|
const msg = salvageCommitMessage(issue, attempt, ending, files, newPaths);
|
|
675
682
|
await git(
|
|
676
683
|
[
|
|
684
|
+
...exempt,
|
|
677
685
|
...SALVAGE_COMMIT_CONFIG,
|
|
678
686
|
"commit",
|
|
679
687
|
"--no-verify",
|
|
@@ -684,7 +692,7 @@ export async function salvageWip(
|
|
|
684
692
|
],
|
|
685
693
|
worktree,
|
|
686
694
|
);
|
|
687
|
-
const sha = await git(["rev-parse", "HEAD"], worktree);
|
|
695
|
+
const sha = await git([...exempt, "rev-parse", "HEAD"], worktree);
|
|
688
696
|
const salvaged = { kind: "salvaged" as const, sha, branch, files, newPaths };
|
|
689
697
|
|
|
690
698
|
if (branch === "HEAD") {
|
|
@@ -759,12 +767,92 @@ export async function removeWorktree(
|
|
|
759
767
|
|
|
760
768
|
export type RetainedWorktreeCleanup =
|
|
761
769
|
| { kind: "removed" }
|
|
762
|
-
| {
|
|
770
|
+
| {
|
|
771
|
+
kind: "retained";
|
|
772
|
+
reason: "dirty" | "unpushed" | "unknown" | "quarantined";
|
|
773
|
+
detail: string;
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Repairs a run repository whose `objects/info/alternates` names a path that no
|
|
778
|
+
* longer exists, re-pointing the dangling entry at this project's current
|
|
779
|
+
* mirror. Worktrees created before mirrors moved into per-project roots keep
|
|
780
|
+
* the old flat path (`<home>/mirrors/<repo>.git`); the store they borrow from
|
|
781
|
+
* is gone, so any fetch against them dies on unresolved deltas (#728). The
|
|
782
|
+
* repair belongs here — in the code that discovers the stale entry — rather
|
|
783
|
+
* than in a one-off host edit, so the next layout migration heals itself too.
|
|
784
|
+
*
|
|
785
|
+
* Only missing entries are rewritten: a valid alternate is left alone and a
|
|
786
|
+
* worktree with no alternates file is already sound. When the store cannot be
|
|
787
|
+
* made sound — the git dir is unresolvable, the file cannot be read or
|
|
788
|
+
* written, or a missing path survives the rewrite — the caller must not fetch
|
|
789
|
+
* into the broken state; a quarantine detail is returned instead.
|
|
790
|
+
*/
|
|
791
|
+
async function repairAlternates(
|
|
792
|
+
worktreePath: string,
|
|
793
|
+
mirrorPath: string,
|
|
794
|
+
): Promise<{ kind: "ok" } | { kind: "quarantine"; detail: string }> {
|
|
795
|
+
// The tree is worker-owned by cleanup time (#798); the exact-path exemption
|
|
796
|
+
// makes this read the daemon's own (#816).
|
|
797
|
+
const common = await runGit(
|
|
798
|
+
[...runRepoSafeDirectoryExemption(worktreePath), "rev-parse", "--git-common-dir"],
|
|
799
|
+
worktreePath,
|
|
800
|
+
);
|
|
801
|
+
if (common.code !== 0) {
|
|
802
|
+
return {
|
|
803
|
+
kind: "quarantine",
|
|
804
|
+
detail: `cannot resolve the git dir: ${common.stderr.trim() || common.stdout.trim() || "no output"}`,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
const raw = common.stdout.trim();
|
|
808
|
+
if (raw === "") {
|
|
809
|
+
return { kind: "quarantine", detail: "cannot resolve the git dir: git printed nothing" };
|
|
810
|
+
}
|
|
811
|
+
// `--git-common-dir` is relative for linked worktrees; resolve against the
|
|
812
|
+
// tree so bare-mirror layouts and plain clones both land on the alternates.
|
|
813
|
+
const commonDir = raw.startsWith("/") ? raw : join(worktreePath, raw);
|
|
814
|
+
const alternates = join(commonDir, "objects", "info", "alternates");
|
|
815
|
+
if (!existsSync(alternates)) return { kind: "ok" };
|
|
816
|
+
|
|
817
|
+
const current = join(mirrorPath, "objects");
|
|
818
|
+
let content: string;
|
|
819
|
+
try {
|
|
820
|
+
content = readFileSync(alternates, "utf8");
|
|
821
|
+
} catch (err) {
|
|
822
|
+
return { kind: "quarantine", detail: `${alternates} cannot be read: ${err instanceof Error ? err.message : String(err)}` };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let changed = false;
|
|
826
|
+
const rewritten = content.split("\n").map((line) => {
|
|
827
|
+
const candidate = line.trim();
|
|
828
|
+
if (candidate === "" || candidate === current || existsSync(candidate)) return line;
|
|
829
|
+
changed = true;
|
|
830
|
+
return current;
|
|
831
|
+
});
|
|
832
|
+
if (changed) {
|
|
833
|
+
try {
|
|
834
|
+
writeFileSync(alternates, rewritten.join("\n"));
|
|
835
|
+
} catch (err) {
|
|
836
|
+
return { kind: "quarantine", detail: `${alternates} cannot be rewritten: ${err instanceof Error ? err.message : String(err)}` };
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const dangling = rewritten.map((line) => line.trim()).filter((path) => path !== "" && !existsSync(path));
|
|
841
|
+
if (dangling.length > 0) {
|
|
842
|
+
return {
|
|
843
|
+
kind: "quarantine",
|
|
844
|
+
detail: `${alternates} still names missing path(s): ${dangling.join(", ")}`,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
return { kind: "ok" };
|
|
848
|
+
}
|
|
763
849
|
|
|
764
850
|
/**
|
|
765
851
|
* Reap a terminal run's tree and local mirror branch without deleting the only
|
|
766
852
|
* copy of work. Tracker state is proved by the caller; this function proves the
|
|
767
|
-
* local half after refreshing remote refs. Any ambiguity retains everything
|
|
853
|
+
* local half after refreshing remote refs. Any ambiguity retains everything;
|
|
854
|
+
* a remote read that failed is ambiguity, never a pushed/unpushed claim, and
|
|
855
|
+
* the unpushed verdicts below are reachable only from reads that succeeded.
|
|
768
856
|
*/
|
|
769
857
|
export async function cleanupRetainedWorktree(
|
|
770
858
|
mirrorPath: string,
|
|
@@ -779,11 +867,29 @@ export async function cleanupRetainedWorktree(
|
|
|
779
867
|
|
|
780
868
|
try {
|
|
781
869
|
if (existsSync(worktreePath)) {
|
|
782
|
-
|
|
870
|
+
// A run repo created before mirrors moved into per-project roots may
|
|
871
|
+
// borrow objects from a path that no longer exists. Fetching into it is
|
|
872
|
+
// a guaranteed failure, so repair the alternates first — and when the
|
|
873
|
+
// store cannot be made sound, quarantine the tree instead of fetching
|
|
874
|
+
// into the broken state.
|
|
875
|
+
const repair = await repairAlternates(worktreePath, mirrorPath);
|
|
876
|
+
if (repair.kind === "quarantine") {
|
|
877
|
+
return {
|
|
878
|
+
kind: "retained",
|
|
879
|
+
reason: "quarantined",
|
|
880
|
+
detail: `worktree quarantined: ${repair.detail}`,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// The tree is worker-owned by cleanup time (#798): every daemon git call
|
|
885
|
+
// against it carries the exact per-run exemption (#816).
|
|
886
|
+
const exempt = runRepoSafeDirectoryExemption(worktreePath);
|
|
887
|
+
|
|
888
|
+
const dirty = await git([...exempt, "status", "--porcelain"], worktreePath);
|
|
783
889
|
if (dirty !== "") {
|
|
784
890
|
return { kind: "retained", reason: "dirty", detail: "worktree has uncommitted changes" };
|
|
785
891
|
}
|
|
786
|
-
const actual = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
|
|
892
|
+
const actual = await git([...exempt, "rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
|
|
787
893
|
if (actual !== branch) {
|
|
788
894
|
return {
|
|
789
895
|
kind: "retained",
|
|
@@ -794,8 +900,18 @@ export async function cleanupRetainedWorktree(
|
|
|
794
900
|
}
|
|
795
901
|
|
|
796
902
|
// A deleted remote branch can make a pushed commit look local-only until
|
|
797
|
-
// the default branch is fetched.
|
|
798
|
-
|
|
903
|
+
// the default branch is fetched. A failed fetch is ambiguity, never
|
|
904
|
+
// permission: without fresh remote refs the unpushed verdicts below are
|
|
905
|
+
// unreachable and the run is retained on an explicit unknown.
|
|
906
|
+
try {
|
|
907
|
+
await git(["fetch", "--prune", "origin"], mirrorPath);
|
|
908
|
+
} catch (err) {
|
|
909
|
+
return {
|
|
910
|
+
kind: "retained",
|
|
911
|
+
reason: "unknown",
|
|
912
|
+
detail: `remote refs could not be refreshed: ${err instanceof Error ? err.message : String(err)}`,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
799
915
|
|
|
800
916
|
const ref = `refs/heads/${branch}`;
|
|
801
917
|
|
|
@@ -806,11 +922,19 @@ export async function cleanupRetainedWorktree(
|
|
|
806
922
|
// data loss #121 exists to prevent, reintroduced by the move to per-run
|
|
807
923
|
// repositories, so it is checked where the objects actually are.
|
|
808
924
|
if (existsSync(worktreePath)) {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
925
|
+
try {
|
|
926
|
+
await git(
|
|
927
|
+
[...runRepoSafeDirectoryExemption(worktreePath), "fetch", "--no-tags", mirrorPath, "+refs/remotes/origin/*:refs/remotes/origin/*"],
|
|
928
|
+
worktreePath,
|
|
929
|
+
);
|
|
930
|
+
} catch (err) {
|
|
931
|
+
return {
|
|
932
|
+
kind: "retained",
|
|
933
|
+
reason: "unknown",
|
|
934
|
+
detail: `the run repository's remote refs could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
const runUnique = await git([...runRepoSafeDirectoryExemption(worktreePath), "rev-list", ref, "--not", "--remotes"], worktreePath);
|
|
814
938
|
if (runUnique !== "") {
|
|
815
939
|
return {
|
|
816
940
|
kind: "retained",
|