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.
Files changed (65) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
@@ -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. Saying so is the difference between an audited
224
- * boundary and a hopeful one.
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
- chmod: (p: string, mode: number) => void = chmodSync,
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 (state: pushed-green, pr:, head:). " +
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(report: string, repoSlug?: string): {
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(field(message, "content"));
844
+ const text = reportText(content);
635
845
  if (text !== "") {
636
846
  report = text;
637
- const stated = deriveResult(text, o.repoSlug);
638
- if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
639
- claim = { prUrl: stated.prUrl, headSha: stated.headSha };
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
- const common = Bun.spawnSync(["git", "rev-parse", "--git-common-dir"], {
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
- if ((await git(["status", "--porcelain"], worktree)) === "") return { kind: "nothing" };
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
- | { kind: "retained"; reason: "dirty" | "unpushed" | "unknown"; detail: string };
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
- const dirty = await git(["status", "--porcelain"], worktreePath);
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. Failure is ambiguity, never permission.
798
- await git(["fetch", "--prune", "origin"], mirrorPath);
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
- await git(
810
- ["fetch", "--no-tags", mirrorPath, "+refs/remotes/origin/*:refs/remotes/origin/*"],
811
- worktreePath,
812
- );
813
- const runUnique = await git(["rev-list", ref, "--not", "--remotes"], worktreePath);
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",