omp-conductor 0.17.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +11 -7
- package/package.json +1 -1
- package/schema/config.schema.json +24 -1
- package/src/admission.ts +104 -1
- package/src/ask.ts +39 -3
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +36 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +33 -0
- package/src/config.ts +60 -1
- package/src/daemon.ts +253 -1479
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/failure-class.ts +15 -2
- package/src/fleet.ts +20 -2
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +36 -5
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +104 -12
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +75 -0
- package/src/setup.ts +93 -3
- package/src/status-render.ts +31 -9
- package/src/store.ts +78 -5
- package/src/tracker/github.ts +46 -0
- package/src/types.ts +127 -3
- package/src/verbs/server.ts +58 -2
- package/src/worker.ts +214 -7
- package/src/worktree.ts +115 -8
package/src/worker.ts
CHANGED
|
@@ -30,6 +30,77 @@ const GITHUB_PR_URL_PATTERN = /https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/
|
|
|
30
30
|
/** `{{KEY}}` placeholders in a brief template. */
|
|
31
31
|
const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The structured settlement a worker yields at the end of a run (#540).
|
|
35
|
+
*
|
|
36
|
+
* Fields decided here, not per project. The daemon stays the authority on
|
|
37
|
+
* green — it verifies the PR itself — so this is the worker's *claim*,
|
|
38
|
+
* cross-checked exactly as the prose report it replaces: a `green` claim
|
|
39
|
+
* must carry the PR URL and the head SHA the worker actually watched go
|
|
40
|
+
* green, and anything less fails closed.
|
|
41
|
+
*/
|
|
42
|
+
export interface WorkerSettlement {
|
|
43
|
+
status: "green" | "blocked" | "failed";
|
|
44
|
+
/** The run's pull request URL. Required with `status: "green"`. */
|
|
45
|
+
prUrl?: string;
|
|
46
|
+
/** The branch the PR was pushed from. */
|
|
47
|
+
branch?: string;
|
|
48
|
+
/** The 40-hex head SHA the green CI verdict was observed at. Required with `status: "green"`. */
|
|
49
|
+
headSha?: string;
|
|
50
|
+
/** What changed and why — the narrative a reviewer reads. */
|
|
51
|
+
summary: string;
|
|
52
|
+
/** What a decision or credential this run needed (blocked runs). */
|
|
53
|
+
blockers?: string[];
|
|
54
|
+
/** Commands run as evidence, exactly as executed. */
|
|
55
|
+
proof?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The JSON Schema form of {@link WorkerSettlement}, handed to the harness as
|
|
60
|
+
* the session's `outputSchema` (#540).
|
|
61
|
+
*
|
|
62
|
+
* Used two ways, and both must stay honest to the same contract:
|
|
63
|
+
* `requireYieldTool` puts the `yield` tool in front of the worker with this
|
|
64
|
+
* schema as its description, and `outputSchemaMode: "permissive"` means a
|
|
65
|
+
* violation is a fallback, never a lost report — the worker's text path must
|
|
66
|
+
* still settle a run whose yield does not parse.
|
|
67
|
+
*/
|
|
68
|
+
export const WORKER_SETTLEMENT_SCHEMA = {
|
|
69
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
70
|
+
type: "object",
|
|
71
|
+
description:
|
|
72
|
+
"The run's settlement: what this worker claims it achieved. The daemon verifies the PR itself; " +
|
|
73
|
+
"this is the claim, cross-checked like the settlement report it replaces.",
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
required: ["status", "summary"],
|
|
76
|
+
properties: {
|
|
77
|
+
status: {
|
|
78
|
+
type: "string",
|
|
79
|
+
enum: ["green", "blocked", "failed"],
|
|
80
|
+
description:
|
|
81
|
+
"green: pushed and the checks you watched are green. blocked: a decision, credential or " +
|
|
82
|
+
"repo fact is missing. failed: the run could not complete.",
|
|
83
|
+
},
|
|
84
|
+
prUrl: { type: "string", description: "The run's pull request URL — required with status green." },
|
|
85
|
+
branch: { type: "string", description: "The branch the PR was pushed from." },
|
|
86
|
+
headSha: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "The 40-character head SHA you observed green — required with status green.",
|
|
89
|
+
},
|
|
90
|
+
summary: { type: "string", description: "One short paragraph: what changed and why." },
|
|
91
|
+
blockers: {
|
|
92
|
+
type: "array",
|
|
93
|
+
items: { type: "string" },
|
|
94
|
+
description: "What is missing or uncertain, one item per blocker (blocked runs).",
|
|
95
|
+
},
|
|
96
|
+
proof: {
|
|
97
|
+
type: "array",
|
|
98
|
+
items: { type: "string" },
|
|
99
|
+
description: "The commands you ran as evidence, exactly as executed.",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
33
104
|
function scheduleWallClock(callback: () => void, delayMs: number): () => void {
|
|
34
105
|
const timer = setTimeout(callback, delayMs);
|
|
35
106
|
return () => clearTimeout(timer);
|
|
@@ -88,7 +159,7 @@ export const REVIEW_REVISION_PROMPT =
|
|
|
88
159
|
"The orchestrator reviewed your green pull request and found blocking findings. Continue this same session " +
|
|
89
160
|
"on this same run: same branch, same pull request — do not close or reopen it, and do not open another. " +
|
|
90
161
|
"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 (
|
|
162
|
+
"and finish with the same final-report contract as before (yield your settlement: status green, prUrl, headSha). " +
|
|
92
163
|
"Your original brief is already in this transcript; redo only what the findings implicate.";
|
|
93
164
|
|
|
94
165
|
/**
|
|
@@ -205,6 +276,13 @@ export interface WorkerResult {
|
|
|
205
276
|
headSha?: string;
|
|
206
277
|
turns: number;
|
|
207
278
|
spendUsd: number;
|
|
279
|
+
/**
|
|
280
|
+
* The run's settlement text. Today this is the worker's own last words; a
|
|
281
|
+
* run that yielded a structured {@link WorkerSettlement} instead carries the
|
|
282
|
+
* canonical rendering of that parsed object (#540) — the narrative lives in
|
|
283
|
+
* its `summary`, transcribed here field for field rather than regexed out of
|
|
284
|
+
* prose.
|
|
285
|
+
*/
|
|
208
286
|
report: string;
|
|
209
287
|
/** In-session HTTP 429 responses the session recorded (stopReason "error",
|
|
210
288
|
* errorStatus 429), counted as the messages streamed in. A healthy run
|
|
@@ -277,12 +355,34 @@ export function renderBrief(template: string, vars: Record<string, string>): str
|
|
|
277
355
|
* must carry both a PR URL and the head SHA observed after CI; the daemon then
|
|
278
356
|
* asks the tracker to verify those facts independently. Missing or malformed
|
|
279
357
|
* evidence fails closed.
|
|
358
|
+
*
|
|
359
|
+
* A parsed {@link WorkerSettlement} wins over the text whenever it is present
|
|
360
|
+
* (#540): the schema is the contract now, and a misleadingly regular-looking
|
|
361
|
+
* prose block must not out-vote the object the worker actually yielded. The
|
|
362
|
+
* text path is unchanged underneath, so a run that never yields settles
|
|
363
|
+
* exactly as it did before.
|
|
280
364
|
*/
|
|
281
|
-
export function deriveResult(
|
|
365
|
+
export function deriveResult(
|
|
366
|
+
report: string,
|
|
367
|
+
repoSlug?: string,
|
|
368
|
+
structured?: WorkerSettlement,
|
|
369
|
+
): {
|
|
282
370
|
state: RunState;
|
|
283
371
|
prUrl?: string;
|
|
284
372
|
headSha?: string;
|
|
285
373
|
} {
|
|
374
|
+
if (structured !== undefined && (structured.status === "green" || structured.status === "blocked" || structured.status === "failed")) {
|
|
375
|
+
const prUrl = structured.prUrl;
|
|
376
|
+
const headSha = structured.headSha?.toLowerCase();
|
|
377
|
+
if (structured.status === "green" && prUrl !== undefined && headSha !== undefined) {
|
|
378
|
+
return { state: "pushed-green", prUrl, headSha };
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
state: structured.status === "blocked" ? "blocked" : "failed",
|
|
382
|
+
...(prUrl === undefined ? {} : { prUrl }),
|
|
383
|
+
...(headSha === undefined ? {} : { headSha }),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
286
386
|
const structuredPrUrl = PR_URL_PATTERN.exec(report)?.[1];
|
|
287
387
|
const headSha = HEAD_SHA_PATTERN.exec(report)?.[1]?.toLowerCase();
|
|
288
388
|
if (PUSHED_GREEN_PATTERN.test(report) && structuredPrUrl !== undefined && headSha !== undefined) {
|
|
@@ -305,6 +405,81 @@ export function deriveResult(report: string, repoSlug?: string): {
|
|
|
305
405
|
};
|
|
306
406
|
}
|
|
307
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Parse a worker's structured settlement out of one assistant message's
|
|
410
|
+
* content (#540).
|
|
411
|
+
*
|
|
412
|
+
* The worker calls the `yield` tool with `{ result: { data: <settlement> } }`
|
|
413
|
+
* and the harness records that call as a `toolCall` content block on the
|
|
414
|
+
* assistant message — the same content `reportText` flattens. Parsed
|
|
415
|
+
* permissively on purpose: `outputSchemaMode` is permissive, so a yield the
|
|
416
|
+
* schema rejected must still fall back to the text path rather than vanish.
|
|
417
|
+
* The acceptance bar mirrors the schema's own `required`: a recognizable
|
|
418
|
+
* `status` and a string `summary`; every other field is adopted when it has
|
|
419
|
+
* the right type and dropped otherwise. Exported so the run loop's precedence
|
|
420
|
+
* (yield over prose) is pinned by a unit test.
|
|
421
|
+
*/
|
|
422
|
+
export function structuredSettlement(content: unknown): WorkerSettlement | undefined {
|
|
423
|
+
if (!Array.isArray(content)) return undefined;
|
|
424
|
+
let parsed: WorkerSettlement | undefined;
|
|
425
|
+
for (const block of content) {
|
|
426
|
+
if (field(block, "type") !== "toolCall") continue;
|
|
427
|
+
if (field(block, "name") !== "yield") continue;
|
|
428
|
+
const args = field(block, "arguments");
|
|
429
|
+
if (args === null || typeof args !== "object") continue;
|
|
430
|
+
const result = field(args, "result");
|
|
431
|
+
if (result === null || typeof result !== "object") continue;
|
|
432
|
+
const data = field(result, "data");
|
|
433
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) continue;
|
|
434
|
+
const record = data as Record<string, unknown>;
|
|
435
|
+
const status = record.status;
|
|
436
|
+
if (status !== "green" && status !== "blocked" && status !== "failed") continue;
|
|
437
|
+
const summary = record.summary;
|
|
438
|
+
if (typeof summary !== "string") continue;
|
|
439
|
+
const settlement: WorkerSettlement = { status, summary };
|
|
440
|
+
for (const key of ["prUrl", "branch", "headSha"] as const) {
|
|
441
|
+
const value = record[key];
|
|
442
|
+
if (typeof value === "string" && value !== "") settlement[key] = value;
|
|
443
|
+
}
|
|
444
|
+
for (const key of ["blockers", "proof"] as const) {
|
|
445
|
+
const value = record[key];
|
|
446
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
|
447
|
+
settlement[key] = value;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
parsed = settlement;
|
|
451
|
+
}
|
|
452
|
+
return parsed;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Render a parsed {@link WorkerSettlement} as the run's stored report (#540).
|
|
457
|
+
*
|
|
458
|
+
* A worker that yields structured output may write little or no prose after
|
|
459
|
+
* the yield call, and the prose shape is no longer the contract — so the
|
|
460
|
+
* report the store keeps for such a run is this canonical rendering of the
|
|
461
|
+
* parsed object itself: the same fields a reviewer would have had to regex
|
|
462
|
+
* out of prose, derived from the object, field for field. The daemon's own
|
|
463
|
+
* postfixes — the PR-diff `changed:` line and the reliability sentence — are
|
|
464
|
+
* appended to it exactly as they are to a prose report.
|
|
465
|
+
*/
|
|
466
|
+
export function renderSettlement(settlement: WorkerSettlement): string {
|
|
467
|
+
const lines: string[] = [`status: ${settlement.status}`];
|
|
468
|
+
if (settlement.prUrl !== undefined) lines.push(`pr: ${settlement.prUrl}`);
|
|
469
|
+
if (settlement.branch !== undefined) lines.push(`branch: ${settlement.branch}`);
|
|
470
|
+
if (settlement.headSha !== undefined) lines.push(`head: ${settlement.headSha}`);
|
|
471
|
+
if (settlement.summary !== "") lines.push("", settlement.summary);
|
|
472
|
+
if (settlement.blockers !== undefined && settlement.blockers.length > 0) {
|
|
473
|
+
lines.push("", "blockers:");
|
|
474
|
+
for (const blocker of settlement.blockers) lines.push(` - ${blocker}`);
|
|
475
|
+
}
|
|
476
|
+
if (settlement.proof !== undefined && settlement.proof.length > 0) {
|
|
477
|
+
lines.push("", "proof:");
|
|
478
|
+
for (const item of settlement.proof) lines.push(` - ${item}`);
|
|
479
|
+
}
|
|
480
|
+
return lines.join("\n");
|
|
481
|
+
}
|
|
482
|
+
|
|
308
483
|
/**
|
|
309
484
|
* Did this report state a verdict at all?
|
|
310
485
|
*
|
|
@@ -380,6 +555,13 @@ export async function runWorker(
|
|
|
380
555
|
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
381
556
|
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
|
382
557
|
...(o.maySpawn === undefined ? {} : { maySpawn: o.maySpawn }),
|
|
558
|
+
// The structured settlement contract (#540): the worker's `yield` tool
|
|
559
|
+
// validates its `data` payload against this schema, permissively — an
|
|
560
|
+
// invalid or absent yield falls back to the text path, never a lost
|
|
561
|
+
// report. Always-on for workers: one schema, and no per-project shape.
|
|
562
|
+
outputSchema: WORKER_SETTLEMENT_SCHEMA,
|
|
563
|
+
outputSchemaMode: "permissive",
|
|
564
|
+
requireYieldTool: true,
|
|
383
565
|
});
|
|
384
566
|
} catch (err) {
|
|
385
567
|
// The pre-spawn gate closed (#374): a daemon stop landed while the session
|
|
@@ -473,6 +655,10 @@ export async function runWorker(
|
|
|
473
655
|
let spendUsd = 0;
|
|
474
656
|
let provider429Count = 0;
|
|
475
657
|
let report = "";
|
|
658
|
+
// The newest structured settlement the worker yielded (#540). A terminal
|
|
659
|
+
// `yield` ends the run, so anything parsed here is the run's own claim, and
|
|
660
|
+
// any later message is stale-polling chatter, never a newer verdict.
|
|
661
|
+
let structured: WorkerSettlement | undefined;
|
|
476
662
|
// Which model/provider actually wrote the newest assistant message. Last
|
|
477
663
|
// assistant message wins: that is the durable answer even for a run that
|
|
478
664
|
// never failed over (#535 slice 1, read off the message field which is where
|
|
@@ -629,15 +815,22 @@ export async function runWorker(
|
|
|
629
815
|
session.on("message_end", (event) => {
|
|
630
816
|
const message = field(event, "message");
|
|
631
817
|
if (field(message, "role") !== "assistant") return;
|
|
818
|
+
const content = field(message, "content");
|
|
632
819
|
// Keep the newest non-empty assistant text: whatever the worker said last
|
|
633
820
|
// is its report, whether it finished cleanly or was cut off.
|
|
634
|
-
const text = reportText(
|
|
821
|
+
const text = reportText(content);
|
|
635
822
|
if (text !== "") {
|
|
636
823
|
report = text;
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
824
|
+
}
|
|
825
|
+
// The structured settlement (#540): the `yield` tool call rides the same
|
|
826
|
+
// message's content as a `toolCall` block. Newest yield wins; the
|
|
827
|
+
// derivation itself prefers it over prose, so a yield and a misleadingly
|
|
828
|
+
// regular-looking text block on the same message agree in its favour.
|
|
829
|
+
const yielded = structuredSettlement(content);
|
|
830
|
+
if (yielded !== undefined) structured = yielded;
|
|
831
|
+
const stated = deriveResult(text, o.repoSlug, yielded);
|
|
832
|
+
if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
|
|
833
|
+
claim = { prUrl: stated.prUrl, headSha: stated.headSha };
|
|
641
834
|
}
|
|
642
835
|
|
|
643
836
|
// Real cost lives on assistant messages as `usage.cost.total` (live hermes
|
|
@@ -802,6 +995,20 @@ export async function runWorker(
|
|
|
802
995
|
}));
|
|
803
996
|
}
|
|
804
997
|
|
|
998
|
+
// A structured yield is the run's settlement: it wins over the last text —
|
|
999
|
+
// the worker may have written nothing after the yield call, and the stored
|
|
1000
|
+
// report is the canonical rendering of the parsed object, not prose the
|
|
1001
|
+
// regex has to recover (#540).
|
|
1002
|
+
if (structured !== undefined) {
|
|
1003
|
+
return withMetrics(withSessionFacts({
|
|
1004
|
+
...deriveResult(report, o.repoSlug, structured),
|
|
1005
|
+
turns,
|
|
1006
|
+
spendUsd,
|
|
1007
|
+
provider429Count,
|
|
1008
|
+
report: renderSettlement(structured),
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
805
1012
|
// An explicit later verdict always wins: a worker that pushed green and then
|
|
806
1013
|
// stopped to ask a question means the question. The earlier claim is only
|
|
807
1014
|
// restored when the last thing said was not a verdict at all.
|
package/src/worktree.ts
CHANGED
|
@@ -759,12 +759,87 @@ export async function removeWorktree(
|
|
|
759
759
|
|
|
760
760
|
export type RetainedWorktreeCleanup =
|
|
761
761
|
| { kind: "removed" }
|
|
762
|
-
| {
|
|
762
|
+
| {
|
|
763
|
+
kind: "retained";
|
|
764
|
+
reason: "dirty" | "unpushed" | "unknown" | "quarantined";
|
|
765
|
+
detail: string;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Repairs a run repository whose `objects/info/alternates` names a path that no
|
|
770
|
+
* longer exists, re-pointing the dangling entry at this project's current
|
|
771
|
+
* mirror. Worktrees created before mirrors moved into per-project roots keep
|
|
772
|
+
* the old flat path (`<home>/mirrors/<repo>.git`); the store they borrow from
|
|
773
|
+
* is gone, so any fetch against them dies on unresolved deltas (#728). The
|
|
774
|
+
* repair belongs here — in the code that discovers the stale entry — rather
|
|
775
|
+
* than in a one-off host edit, so the next layout migration heals itself too.
|
|
776
|
+
*
|
|
777
|
+
* Only missing entries are rewritten: a valid alternate is left alone and a
|
|
778
|
+
* worktree with no alternates file is already sound. When the store cannot be
|
|
779
|
+
* made sound — the git dir is unresolvable, the file cannot be read or
|
|
780
|
+
* written, or a missing path survives the rewrite — the caller must not fetch
|
|
781
|
+
* into the broken state; a quarantine detail is returned instead.
|
|
782
|
+
*/
|
|
783
|
+
async function repairAlternates(
|
|
784
|
+
worktreePath: string,
|
|
785
|
+
mirrorPath: string,
|
|
786
|
+
): Promise<{ kind: "ok" } | { kind: "quarantine"; detail: string }> {
|
|
787
|
+
const common = await runGit(["rev-parse", "--git-common-dir"], worktreePath);
|
|
788
|
+
if (common.code !== 0) {
|
|
789
|
+
return {
|
|
790
|
+
kind: "quarantine",
|
|
791
|
+
detail: `cannot resolve the git dir: ${common.stderr.trim() || common.stdout.trim() || "no output"}`,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const raw = common.stdout.trim();
|
|
795
|
+
if (raw === "") {
|
|
796
|
+
return { kind: "quarantine", detail: "cannot resolve the git dir: git printed nothing" };
|
|
797
|
+
}
|
|
798
|
+
// `--git-common-dir` is relative for linked worktrees; resolve against the
|
|
799
|
+
// tree so bare-mirror layouts and plain clones both land on the alternates.
|
|
800
|
+
const commonDir = raw.startsWith("/") ? raw : join(worktreePath, raw);
|
|
801
|
+
const alternates = join(commonDir, "objects", "info", "alternates");
|
|
802
|
+
if (!existsSync(alternates)) return { kind: "ok" };
|
|
803
|
+
|
|
804
|
+
const current = join(mirrorPath, "objects");
|
|
805
|
+
let content: string;
|
|
806
|
+
try {
|
|
807
|
+
content = readFileSync(alternates, "utf8");
|
|
808
|
+
} catch (err) {
|
|
809
|
+
return { kind: "quarantine", detail: `${alternates} cannot be read: ${err instanceof Error ? err.message : String(err)}` };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
let changed = false;
|
|
813
|
+
const rewritten = content.split("\n").map((line) => {
|
|
814
|
+
const candidate = line.trim();
|
|
815
|
+
if (candidate === "" || candidate === current || existsSync(candidate)) return line;
|
|
816
|
+
changed = true;
|
|
817
|
+
return current;
|
|
818
|
+
});
|
|
819
|
+
if (changed) {
|
|
820
|
+
try {
|
|
821
|
+
writeFileSync(alternates, rewritten.join("\n"));
|
|
822
|
+
} catch (err) {
|
|
823
|
+
return { kind: "quarantine", detail: `${alternates} cannot be rewritten: ${err instanceof Error ? err.message : String(err)}` };
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const dangling = rewritten.map((line) => line.trim()).filter((path) => path !== "" && !existsSync(path));
|
|
828
|
+
if (dangling.length > 0) {
|
|
829
|
+
return {
|
|
830
|
+
kind: "quarantine",
|
|
831
|
+
detail: `${alternates} still names missing path(s): ${dangling.join(", ")}`,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
return { kind: "ok" };
|
|
835
|
+
}
|
|
763
836
|
|
|
764
837
|
/**
|
|
765
838
|
* Reap a terminal run's tree and local mirror branch without deleting the only
|
|
766
839
|
* 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
|
|
840
|
+
* local half after refreshing remote refs. Any ambiguity retains everything;
|
|
841
|
+
* a remote read that failed is ambiguity, never a pushed/unpushed claim, and
|
|
842
|
+
* the unpushed verdicts below are reachable only from reads that succeeded.
|
|
768
843
|
*/
|
|
769
844
|
export async function cleanupRetainedWorktree(
|
|
770
845
|
mirrorPath: string,
|
|
@@ -779,6 +854,20 @@ export async function cleanupRetainedWorktree(
|
|
|
779
854
|
|
|
780
855
|
try {
|
|
781
856
|
if (existsSync(worktreePath)) {
|
|
857
|
+
// A run repo created before mirrors moved into per-project roots may
|
|
858
|
+
// borrow objects from a path that no longer exists. Fetching into it is
|
|
859
|
+
// a guaranteed failure, so repair the alternates first — and when the
|
|
860
|
+
// store cannot be made sound, quarantine the tree instead of fetching
|
|
861
|
+
// into the broken state.
|
|
862
|
+
const repair = await repairAlternates(worktreePath, mirrorPath);
|
|
863
|
+
if (repair.kind === "quarantine") {
|
|
864
|
+
return {
|
|
865
|
+
kind: "retained",
|
|
866
|
+
reason: "quarantined",
|
|
867
|
+
detail: `worktree quarantined: ${repair.detail}`,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
782
871
|
const dirty = await git(["status", "--porcelain"], worktreePath);
|
|
783
872
|
if (dirty !== "") {
|
|
784
873
|
return { kind: "retained", reason: "dirty", detail: "worktree has uncommitted changes" };
|
|
@@ -794,8 +883,18 @@ export async function cleanupRetainedWorktree(
|
|
|
794
883
|
}
|
|
795
884
|
|
|
796
885
|
// A deleted remote branch can make a pushed commit look local-only until
|
|
797
|
-
// the default branch is fetched.
|
|
798
|
-
|
|
886
|
+
// the default branch is fetched. A failed fetch is ambiguity, never
|
|
887
|
+
// permission: without fresh remote refs the unpushed verdicts below are
|
|
888
|
+
// unreachable and the run is retained on an explicit unknown.
|
|
889
|
+
try {
|
|
890
|
+
await git(["fetch", "--prune", "origin"], mirrorPath);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
return {
|
|
893
|
+
kind: "retained",
|
|
894
|
+
reason: "unknown",
|
|
895
|
+
detail: `remote refs could not be refreshed: ${err instanceof Error ? err.message : String(err)}`,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
799
898
|
|
|
800
899
|
const ref = `refs/heads/${branch}`;
|
|
801
900
|
|
|
@@ -806,10 +905,18 @@ export async function cleanupRetainedWorktree(
|
|
|
806
905
|
// data loss #121 exists to prevent, reintroduced by the move to per-run
|
|
807
906
|
// repositories, so it is checked where the objects actually are.
|
|
808
907
|
if (existsSync(worktreePath)) {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
908
|
+
try {
|
|
909
|
+
await git(
|
|
910
|
+
["fetch", "--no-tags", mirrorPath, "+refs/remotes/origin/*:refs/remotes/origin/*"],
|
|
911
|
+
worktreePath,
|
|
912
|
+
);
|
|
913
|
+
} catch (err) {
|
|
914
|
+
return {
|
|
915
|
+
kind: "retained",
|
|
916
|
+
reason: "unknown",
|
|
917
|
+
detail: `the run repository's remote refs could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
813
920
|
const runUnique = await git(["rev-list", ref, "--not", "--remotes"], worktreePath);
|
|
814
921
|
if (runUnique !== "") {
|
|
815
922
|
return {
|