omp-conductor 0.3.23 → 0.3.25
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 +74 -32
- package/package.json +1 -1
- package/src/approval-surface.ts +253 -0
- package/src/board.ts +6 -1
- package/src/briefs/orchestrator.md +8 -0
- package/src/cli.ts +9 -2
- package/src/daemon.ts +258 -70
- package/src/fleet.ts +16 -56
- package/src/orchestrator-tick.ts +117 -40
- package/src/store.ts +51 -2
- package/src/types.ts +16 -0
- package/src/unblock.ts +53 -3
- package/src/worktree.ts +26 -12
package/src/daemon.ts
CHANGED
|
@@ -387,16 +387,30 @@ async function safeEscalate(d: Pick<Deps, "escalate">, e: Escalation): Promise<b
|
|
|
387
387
|
* `checkIntegrity` is — this wording is the whole thing a human acts on, so it
|
|
388
388
|
* is worth a test holding it, and the sha in it is the only pointer to work
|
|
389
389
|
* that no longer has any other copy.
|
|
390
|
+
*
|
|
391
|
+
* `retained` is not cosmetic. These lines used to promise a tree "kept for
|
|
392
|
+
* inspection" unconditionally, which was true only because salvage ran only on
|
|
393
|
+
* the paths that keep one. A blocked run's tree is removed the moment its work
|
|
394
|
+
* is safely on the branch, and sending an operator to a path this process just
|
|
395
|
+
* deleted is the same class of mistake as #118 itself.
|
|
390
396
|
*/
|
|
391
|
-
export function salvageLines(
|
|
392
|
-
|
|
397
|
+
export function salvageLines(
|
|
398
|
+
outcome: SalvageOutcome,
|
|
399
|
+
worktree: string,
|
|
400
|
+
retained: boolean,
|
|
401
|
+
): string[] {
|
|
402
|
+
const fate = retained
|
|
403
|
+
? `Worktree kept for inspection: ${worktree}`
|
|
404
|
+
: `Worktree removed: ${worktree}`;
|
|
393
405
|
|
|
394
|
-
if (outcome.kind === "nothing") return [`${
|
|
406
|
+
if (outcome.kind === "nothing") return [`${fate} — nothing uncommitted to salvage`];
|
|
395
407
|
|
|
396
408
|
if (outcome.kind === "failed") {
|
|
397
409
|
return [
|
|
398
410
|
`WIP SALVAGE FAILED: ${outcome.error}`,
|
|
399
|
-
`Uncommitted work in ${worktree} is the only copy of it,
|
|
411
|
+
`Uncommitted work in ${worktree} is the only copy of it, so the tree was kept.`,
|
|
412
|
+
"This issue is held out of dispatch until the tree is recovered by hand and",
|
|
413
|
+
"`omp-conductor unblock <n> --force` records that you accepted it.",
|
|
400
414
|
];
|
|
401
415
|
}
|
|
402
416
|
|
|
@@ -415,35 +429,80 @@ export function salvageLines(outcome: SalvageOutcome, worktree: string): string[
|
|
|
415
429
|
: `${count}; new: ${outcome.newPaths.slice(0, 12).join(", ")}${
|
|
416
430
|
outcome.newPaths.length > 12 ? `, … +${outcome.newPaths.length - 12} more` : ""
|
|
417
431
|
}`;
|
|
418
|
-
return [where, manifest,
|
|
432
|
+
return [where, manifest, fate];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Everything a settled run has to record and say about its worktree. */
|
|
436
|
+
export interface WorktreeSettlement {
|
|
437
|
+
outcome: SalvageOutcome;
|
|
438
|
+
/** Whether the tree still exists now the run is over. */
|
|
439
|
+
retained: boolean;
|
|
440
|
+
/** Escalation lines naming where the work went. */
|
|
441
|
+
lines: string[];
|
|
442
|
+
/** Row fields recording the durable ref, or the failure that blocks a re-claim. */
|
|
443
|
+
patch: Pick<RunRecord, "salvageSha" | "salvageError">;
|
|
419
444
|
}
|
|
420
445
|
|
|
421
446
|
/**
|
|
422
|
-
*
|
|
423
|
-
*
|
|
447
|
+
* Decides what becomes of a finished run's worktree: save the work, then keep
|
|
448
|
+
* or remove the tree, then say which.
|
|
424
449
|
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
450
|
+
* One function because the two halves are one decision and splitting them is
|
|
451
|
+
* how #118 happened — the removal at the end of dispatch had no idea whether
|
|
452
|
+
* anything had been saved, and the salvage at the top of the failure branch had
|
|
453
|
+
* no idea the blocked branch fell through to a `--force` removal.
|
|
454
|
+
*
|
|
455
|
+
* A salvage that *fails* retains the tree whatever the caller asked for. There
|
|
456
|
+
* was real work, git refused to commit it, and the tree is now the only copy in
|
|
457
|
+
* existence: deleting it on schedule would be the data loss this whole path
|
|
458
|
+
* exists to prevent. The issue is held out of dispatch until an operator says
|
|
459
|
+
* otherwise, because the next attempt's `worktree remove --force` would finish
|
|
460
|
+
* the job (see `admitCandidates`).
|
|
461
|
+
*
|
|
462
|
+
* Exported so a test can drive the real decision against a real git tree.
|
|
430
463
|
*/
|
|
431
|
-
async function
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
464
|
+
export async function settleWorktree(
|
|
465
|
+
args: {
|
|
466
|
+
issue: number;
|
|
467
|
+
attempt: number;
|
|
468
|
+
/** Clause for the commit subject: "killed by the turns cap", "blocked …". */
|
|
469
|
+
ending: string;
|
|
470
|
+
worktree: string;
|
|
471
|
+
} & (
|
|
472
|
+
| /** Terminal-failure and orphan trees are evidence, and are kept even when clean. */
|
|
473
|
+
{ tree: "keep" }
|
|
474
|
+
| { tree: "remove"; mirrorPath: string }
|
|
475
|
+
),
|
|
476
|
+
): Promise<WorktreeSettlement> {
|
|
477
|
+
const { issue, attempt, ending, worktree } = args;
|
|
478
|
+
const outcome = await salvageWip(worktree, issue, attempt, ending);
|
|
479
|
+
const retained = args.tree === "keep" || outcome.kind === "failed";
|
|
480
|
+
if (!retained && args.tree === "remove") await removeWorktree(args.mirrorPath, worktree);
|
|
481
|
+
|
|
482
|
+
const lines = salvageLines(outcome, worktree, retained);
|
|
438
483
|
log(`#${issue} salvage: ${lines.join(" ")}`);
|
|
439
|
-
return
|
|
484
|
+
return {
|
|
485
|
+
outcome,
|
|
486
|
+
retained,
|
|
487
|
+
lines,
|
|
488
|
+
patch:
|
|
489
|
+
outcome.kind === "salvaged"
|
|
490
|
+
? { salvageSha: outcome.sha }
|
|
491
|
+
: outcome.kind === "failed"
|
|
492
|
+
? { salvageError: outcome.error }
|
|
493
|
+
: {},
|
|
494
|
+
};
|
|
440
495
|
}
|
|
441
496
|
|
|
442
|
-
/**
|
|
497
|
+
/**
|
|
498
|
+
* How a run's end is named — in the salvage commit, and to whoever reads it.
|
|
499
|
+
* The whole clause, not a bare reason: a graceful block was not killed by
|
|
500
|
+
* anything, and the commit subject is read during recovery.
|
|
501
|
+
*/
|
|
443
502
|
function endedBy(killedBy: KilledBy | undefined): string {
|
|
444
|
-
if (killedBy === "turns") return "the turns cap";
|
|
445
|
-
if (killedBy === "wallclock") return "the wall-clock cap";
|
|
446
|
-
return "a failed run";
|
|
503
|
+
if (killedBy === "turns") return "killed by the turns cap";
|
|
504
|
+
if (killedBy === "wallclock") return "killed by the wall-clock cap";
|
|
505
|
+
return "killed by a failed run";
|
|
447
506
|
}
|
|
448
507
|
|
|
449
508
|
/**
|
|
@@ -460,7 +519,7 @@ export async function buildBrief(
|
|
|
460
519
|
r: Routed,
|
|
461
520
|
branch: string,
|
|
462
521
|
worktree: string,
|
|
463
|
-
opts: { continuation?: boolean; defaultBranch?: string } = {},
|
|
522
|
+
opts: { continuation?: boolean; defaultBranch?: string; salvagedSha?: string } = {},
|
|
464
523
|
): Promise<string> {
|
|
465
524
|
// Read per dispatch rather than caching: editing the brief then takes effect
|
|
466
525
|
// on the next issue instead of needing a daemon restart.
|
|
@@ -474,6 +533,14 @@ export async function buildBrief(
|
|
|
474
533
|
"",
|
|
475
534
|
`You are **resuming** issue #${r.issue.number}. Branch \`${branch}\` already exists`,
|
|
476
535
|
"and was reattached with prior commits (and possibly a salvaged WIP tip).",
|
|
536
|
+
...(opts.salvagedSha === undefined
|
|
537
|
+
? []
|
|
538
|
+
: [
|
|
539
|
+
"",
|
|
540
|
+
`The previous attempt's uncommitted work was preserved for you as commit`,
|
|
541
|
+
`\`${opts.salvagedSha}\` on this branch. It is the tip you are continuing from,`,
|
|
542
|
+
"and it is the only copy of that work — do not reset past it or force-push over it.",
|
|
543
|
+
]),
|
|
477
544
|
"Before writing anything:",
|
|
478
545
|
"",
|
|
479
546
|
"```bash",
|
|
@@ -599,9 +666,10 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
599
666
|
|
|
600
667
|
let claimed = false;
|
|
601
668
|
let run: RunRecord | undefined;
|
|
602
|
-
// Hoisted out of the try so the catch path can still name the tree:
|
|
603
|
-
// mid-dispatch is one of the
|
|
604
|
-
//
|
|
669
|
+
// Hoisted out of the try so the catch path can still name and save the tree:
|
|
670
|
+
// a crash mid-dispatch is one of the ends whose uncommitted work has to be
|
|
671
|
+
// salvaged too, and it is the path least likely to have committed first.
|
|
672
|
+
const mirrorPath = mirrorPathFor(r.repo, project.mirrorRoot);
|
|
605
673
|
let worktreePath: string | undefined;
|
|
606
674
|
let turnLimit: TurnLimitController | undefined;
|
|
607
675
|
|
|
@@ -612,6 +680,10 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
612
680
|
// issue out, and a human decides what to do with the orphan.
|
|
613
681
|
await tracker.addLabel(issue, inProgress);
|
|
614
682
|
claimed = true;
|
|
683
|
+
// Read before this attempt's own row exists, so `latestRun` still means the
|
|
684
|
+
// attempt whose work this one inherits.
|
|
685
|
+
const priorSalvage = store.latestRun(project.name, issue)?.salvageSha;
|
|
686
|
+
|
|
615
687
|
|
|
616
688
|
run = store.createRun({
|
|
617
689
|
project: project.name,
|
|
@@ -635,7 +707,6 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
635
707
|
// tolerates a mirror or tree that is not there yet, so this is safe on a
|
|
636
708
|
// first attempt. addWorktree does its own ensureMirror; calling it here too
|
|
637
709
|
// would cost a second network fetch per attempt.
|
|
638
|
-
const mirrorPath = mirrorPathFor(r.repo, project.mirrorRoot);
|
|
639
710
|
await removeWorktree(mirrorPath, worktreePathFor(project.workspaceRoot, issue));
|
|
640
711
|
const provisioned = await addWorktree(
|
|
641
712
|
r.repo,
|
|
@@ -664,6 +735,9 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
664
735
|
brief: await buildBrief(project, r, branch, worktreePath, {
|
|
665
736
|
continuation: provisioned.reattached,
|
|
666
737
|
defaultBranch: r.repo.defaultBranch,
|
|
738
|
+
...(provisioned.reattached && priorSalvage !== undefined
|
|
739
|
+
? { salvagedSha: priorSalvage }
|
|
740
|
+
: {}),
|
|
667
741
|
}),
|
|
668
742
|
cwd: worktreePath,
|
|
669
743
|
caps,
|
|
@@ -706,6 +780,27 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
706
780
|
const finalReport =
|
|
707
781
|
verified.reason === undefined ? result.report : `${verified.reason}\n\n${result.report}`;
|
|
708
782
|
|
|
783
|
+
// What becomes of the tree, decided once, before any label or page. A
|
|
784
|
+
// `pushed-*` run is the only end that does not salvage: its deliverable is
|
|
785
|
+
// already on a remote branch, whatever is left loose in the tree is by the
|
|
786
|
+
// worker's own account not part of it, and appending a WIP commit would
|
|
787
|
+
// turn the green PR this daemon just verified red. Every other end is
|
|
788
|
+
// continuable, so its tree is treated as work.
|
|
789
|
+
const settlement =
|
|
790
|
+
state === "pushed-green" || state === "pushed-pending" || state === "merged"
|
|
791
|
+
? undefined
|
|
792
|
+
: await settleWorktree({
|
|
793
|
+
issue,
|
|
794
|
+
attempt,
|
|
795
|
+
ending:
|
|
796
|
+
state === "blocked" ? "blocked for an operator decision" : endedBy(result.killedBy),
|
|
797
|
+
worktree: worktreePath,
|
|
798
|
+
...(state === "failed" || state === "killed"
|
|
799
|
+
? ({ tree: "keep" } as const)
|
|
800
|
+
: ({ tree: "remove", mirrorPath } as const)),
|
|
801
|
+
});
|
|
802
|
+
if (settlement === undefined) await removeWorktree(mirrorPath, worktreePath);
|
|
803
|
+
|
|
709
804
|
store.updateRun(runId, {
|
|
710
805
|
state,
|
|
711
806
|
endedAt: Date.now(),
|
|
@@ -715,8 +810,11 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
715
810
|
headSha: result.headSha,
|
|
716
811
|
sessionFile: result.sessionFile,
|
|
717
812
|
...(verified.reason === undefined ? {} : { lastError: verified.reason }),
|
|
813
|
+
...settlement?.patch,
|
|
718
814
|
});
|
|
719
815
|
|
|
816
|
+
const salvaged = settlement?.lines ?? [];
|
|
817
|
+
|
|
720
818
|
if (state === "blocked") {
|
|
721
819
|
await swapLabel(tracker, issue, inProgress, project.stateLabels.blocked);
|
|
722
820
|
await safeEscalate(d, {
|
|
@@ -725,7 +823,7 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
725
823
|
issue,
|
|
726
824
|
runId,
|
|
727
825
|
summary: `#${issue} is blocked on attempt ${attempt} and needs a decision`,
|
|
728
|
-
detail: [`${r.issue.title}`, r.issue.url, "", result.report].join("\n"),
|
|
826
|
+
detail: [`${r.issue.title}`, r.issue.url, "", ...salvaged, "", result.report].join("\n"),
|
|
729
827
|
});
|
|
730
828
|
} else if (state === "failed" || state === "killed") {
|
|
731
829
|
// A turns cap consumes the independent continuation budget, not an
|
|
@@ -735,7 +833,6 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
735
833
|
const continueTurns =
|
|
736
834
|
result.killedBy === "turns" &&
|
|
737
835
|
hasContinuationBudget(continuation, caps.maxContinuationsPerIssue);
|
|
738
|
-
const salvaged = await salvage(issue, attempt, endedBy(result.killedBy), worktreePath);
|
|
739
836
|
|
|
740
837
|
if (continueTurns) {
|
|
741
838
|
await tracker.removeLabel(issue, inProgress);
|
|
@@ -790,13 +887,6 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
790
887
|
// checks or merge settle, preventing another worker from duplicating it.
|
|
791
888
|
log(`#${issue} ${state}${result.prUrl ? ` ${result.prUrl}` : ""}`);
|
|
792
889
|
}
|
|
793
|
-
|
|
794
|
-
// A failed or killed tree is evidence — keep it. Anything else is just
|
|
795
|
-
// disk, and the mirror means re-provisioning is cheap. (The kept tree is
|
|
796
|
-
// wiped by the next attempt, not left to accumulate forever.)
|
|
797
|
-
if (state !== "failed" && state !== "killed") {
|
|
798
|
-
await removeWorktree(mirrorPath, worktreePath);
|
|
799
|
-
}
|
|
800
890
|
} catch (err) {
|
|
801
891
|
// Dispatch setup can fail after the controller opens but before runWorker's
|
|
802
892
|
// inner settlement guard exists. Latch it before any terminal write or await.
|
|
@@ -804,8 +894,25 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
804
894
|
turnLimit = undefined;
|
|
805
895
|
const detail = errText(err);
|
|
806
896
|
log(`#${issue} errored: ${detail}`);
|
|
897
|
+
// A crash lands anywhere, including mid-edit in a tree holding the only
|
|
898
|
+
// copy of real work. Nothing else on this path so much as looks at it.
|
|
899
|
+
const settlement =
|
|
900
|
+
worktreePath === undefined
|
|
901
|
+
? undefined
|
|
902
|
+
: await settleWorktree({
|
|
903
|
+
issue,
|
|
904
|
+
attempt,
|
|
905
|
+
ending: "killed by a dispatch error",
|
|
906
|
+
worktree: worktreePath,
|
|
907
|
+
tree: "keep",
|
|
908
|
+
});
|
|
807
909
|
if (run) {
|
|
808
|
-
store.updateRun(run.id, {
|
|
910
|
+
store.updateRun(run.id, {
|
|
911
|
+
state: "failed",
|
|
912
|
+
endedAt: Date.now(),
|
|
913
|
+
lastError: detail,
|
|
914
|
+
...settlement?.patch,
|
|
915
|
+
});
|
|
809
916
|
}
|
|
810
917
|
if (claimed) {
|
|
811
918
|
// Leaving the issue stuck as in-progress would hide it from both the
|
|
@@ -816,13 +923,8 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
816
923
|
log(`#${issue} could not be relabelled: ${errText(relabelErr)}`);
|
|
817
924
|
}
|
|
818
925
|
}
|
|
819
|
-
// A crash lands anywhere, including mid-edit in a tree holding the only
|
|
820
|
-
// copy of real work. Nothing else on this path so much as looks at it.
|
|
821
|
-
const salvaged =
|
|
822
|
-
worktreePath === undefined
|
|
823
|
-
? []
|
|
824
|
-
: await salvage(issue, attempt, "a dispatch error", worktreePath);
|
|
825
926
|
|
|
927
|
+
const salvaged = settlement?.lines ?? [];
|
|
826
928
|
await safeEscalate(d, {
|
|
827
929
|
tier: 1,
|
|
828
930
|
project: project.name,
|
|
@@ -893,26 +995,33 @@ export function settlementFor(pr: PrState | undefined, prUrl: string): Settlemen
|
|
|
893
995
|
* hand-editing one, and `unblock` refused to clear that particular label — so
|
|
894
996
|
* both issues were permanently unclaimable with no supported way back (#18).
|
|
895
997
|
*
|
|
896
|
-
* Never throws, and
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
* not
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
998
|
+
* Never throws, and reports whether the label is provably gone, because the
|
|
999
|
+
* caller has to decide what to write to the store on the strength of it. A
|
|
1000
|
+
* sweep must not lose the rest of its rows to one unreachable tracker, and it
|
|
1001
|
+
* must not terminalise a row whose label it failed to drop: the settlement
|
|
1002
|
+
* sweep only ever revisits `pushed-*` rows, so a row written terminal is a row
|
|
1003
|
+
* nothing asks about again, and swallowing the failure under it would recreate
|
|
1004
|
+
* the exact permanent-`agent:in-progress` state of #18 in the one case that
|
|
1005
|
+
* still reaches it. Answering false instead leaves the row where the next tick
|
|
1006
|
+
* will find it.
|
|
1007
|
+
*
|
|
1008
|
+
* Removing a label the issue does not carry is success, not failure: the GitHub
|
|
1009
|
+
* adapter treats an absent label as a no-op, so false means the tracker could
|
|
1010
|
+
* not be reached or refused — a condition that passes.
|
|
904
1011
|
*/
|
|
905
1012
|
export async function releaseInProgress(
|
|
906
1013
|
d: Pick<Deps, "project" | "tracker">,
|
|
907
1014
|
issue: number,
|
|
908
1015
|
why: string,
|
|
909
|
-
): Promise<
|
|
1016
|
+
): Promise<boolean> {
|
|
910
1017
|
const label = d.project.stateLabels.inProgress;
|
|
911
1018
|
try {
|
|
912
1019
|
await d.tracker.removeLabel(issue, label);
|
|
913
1020
|
log(`#${issue} released ${label}: ${why}`);
|
|
1021
|
+
return true;
|
|
914
1022
|
} catch (err) {
|
|
915
|
-
log(`#${issue} could not release ${label} (${errText(err)}) — ${why};
|
|
1023
|
+
log(`#${issue} could not release ${label} (${errText(err)}) — ${why}; retrying next tick`);
|
|
1024
|
+
return false;
|
|
916
1025
|
}
|
|
917
1026
|
}
|
|
918
1027
|
|
|
@@ -946,6 +1055,15 @@ export async function releaseInProgress(
|
|
|
946
1055
|
* the same Tracker port the dispatcher claimed the issue with — orphan detection
|
|
947
1056
|
* is only trustworthy while every state label on the tracker came from this
|
|
948
1057
|
* package.
|
|
1058
|
+
*
|
|
1059
|
+
* The two writes are ordered label-then-row, and the order is load-bearing. This
|
|
1060
|
+
* sweep is the only thing that revisits a `pushed-*` row, so the terminal state
|
|
1061
|
+
* is also the row's exit from it: written first, a tracker that then failed on
|
|
1062
|
+
* the label would leave `agent:in-progress` with nothing left to retry it — #18
|
|
1063
|
+
* exactly, in the last window able to reach it. Writing the label first makes
|
|
1064
|
+
* failure cost a repeated `gh` call on the next tick instead, and the row stays
|
|
1065
|
+
* in the busy set throughout, so no second worker can be sent at the issue while
|
|
1066
|
+
* it waits.
|
|
949
1067
|
*/
|
|
950
1068
|
export async function settlePushedGreen(
|
|
951
1069
|
d: Pick<Deps, "project" | "tracker" | "store">,
|
|
@@ -978,14 +1096,19 @@ export async function settlePushedGreen(
|
|
|
978
1096
|
|
|
979
1097
|
const settlement = settlementFor(pr, run.prUrl);
|
|
980
1098
|
if (settlement !== undefined) {
|
|
1099
|
+
// Label first, row second, and the order is the whole safety argument.
|
|
1100
|
+
// The sweep only ever revisits `pushed-*` rows, so writing the terminal
|
|
1101
|
+
// state first would put this row beyond every later tick — and a tracker
|
|
1102
|
+
// that failed on the label in that instant would strand
|
|
1103
|
+
// `agent:in-progress` permanently, which is #18 again in the one window
|
|
1104
|
+
// still able to reach it. Leaving the row `pushed-*` costs a stale active
|
|
1105
|
+
// row until the tracker answers, and the busy set keeps the issue
|
|
1106
|
+
// occupied meanwhile, so nothing can be dispatched onto it in between.
|
|
1107
|
+
if (!(await releaseInProgress(d, run.issue, settlement.reason))) continue;
|
|
981
1108
|
const patch: Partial<RunRecord> = { state: settlement.state, endedAt: Date.now() };
|
|
982
1109
|
if (settlement.state === "failed") patch.lastError = settlement.reason;
|
|
983
1110
|
store.updateRun(run.id, patch);
|
|
984
1111
|
log(`#${run.issue} settled: ${settlement.reason}`);
|
|
985
|
-
// Store and tracker in the same breath, for the reason above: the row is
|
|
986
|
-
// terminal, so the label's interlock is spent. `releaseInProgress` never
|
|
987
|
-
// throws, so a tracker hiccup costs this label and not the later rows.
|
|
988
|
-
await releaseInProgress(d, run.issue, settlement.reason);
|
|
989
1112
|
continue;
|
|
990
1113
|
}
|
|
991
1114
|
|
|
@@ -1002,13 +1125,13 @@ export async function settlePushedGreen(
|
|
|
1002
1125
|
store.updateRun(run.id, { state: "pushed-green", lastError: undefined });
|
|
1003
1126
|
log(`#${run.issue} checks settled: ${verification.reason}`);
|
|
1004
1127
|
} else if (verification.status === "failed") {
|
|
1128
|
+
// Equally terminal, so the same label-first order for the same reason:
|
|
1129
|
+
// this row is about to leave the sweep's reach. The green branch above
|
|
1130
|
+
// releases nothing — that row is still awaiting a merge, and its live PR
|
|
1131
|
+
// is exactly the work the label must keep guarding.
|
|
1132
|
+
if (!(await releaseInProgress(d, run.issue, verification.reason))) continue;
|
|
1005
1133
|
store.updateRun(run.id, { state: "failed", lastError: verification.reason });
|
|
1006
1134
|
log(`#${run.issue} checks failed: ${verification.reason}`);
|
|
1007
|
-
// Equally terminal: a red check on a pushed row ends the attempt, so the
|
|
1008
|
-
// same release applies. The green branch above deliberately does not —
|
|
1009
|
-
// that row is still awaiting a merge, and its live PR is exactly the work
|
|
1010
|
-
// the label must keep guarding.
|
|
1011
|
-
await releaseInProgress(d, run.issue, verification.reason);
|
|
1012
1135
|
} else {
|
|
1013
1136
|
store.updateRun(run.id, { lastError: verification.reason });
|
|
1014
1137
|
}
|
|
@@ -1257,6 +1380,32 @@ export async function admitCandidates(
|
|
|
1257
1380
|
continue;
|
|
1258
1381
|
}
|
|
1259
1382
|
|
|
1383
|
+
// Fail closed on work that exists only in a worktree. `addWorktree` clears
|
|
1384
|
+
// the tree at <workspaceRoot>/<issue> before it provisions, so admitting
|
|
1385
|
+
// this issue is what finally destroys the copy the salvage could not save
|
|
1386
|
+
// (#118). Nothing here can recover it — git already refused once — so the
|
|
1387
|
+
// only safe move is to refuse the claim and keep saying why until an
|
|
1388
|
+
// operator has looked and run `unblock --force`.
|
|
1389
|
+
const newest = store.latestRun(project.name, issue);
|
|
1390
|
+
if (newest?.salvageError !== undefined && newest.salvageAckAt === undefined) {
|
|
1391
|
+
hold(issue, "unsalvaged-wip");
|
|
1392
|
+
await safeEscalate(d, {
|
|
1393
|
+
tier: 1,
|
|
1394
|
+
project: project.name,
|
|
1395
|
+
issue,
|
|
1396
|
+
summary: `#${issue} is holding unsalvaged work and will not be re-claimed`,
|
|
1397
|
+
detail: [
|
|
1398
|
+
r.issue.title,
|
|
1399
|
+
r.issue.url,
|
|
1400
|
+
`Attempt ${newest.attempt} could not commit its uncommitted changes: ${newest.salvageError}`,
|
|
1401
|
+
`The only copy is the worktree ${newest.worktree === "" ? "(path not recorded)" : newest.worktree}.`,
|
|
1402
|
+
"Dispatch is held because claiming this issue removes that tree.",
|
|
1403
|
+
"Recover it by hand, then `omp-conductor unblock <n> --force` to release the hold.",
|
|
1404
|
+
].join("\n"),
|
|
1405
|
+
});
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1260
1409
|
// Soft concurrency per epic: at most one in-flight child of a given parent.
|
|
1261
1410
|
// No parent means today's concurrent admission. Cheap local filters already
|
|
1262
1411
|
// ran; this sits before the open-PR API call so a held sibling frees the
|
|
@@ -1653,6 +1802,9 @@ export interface StatusSnapshot {
|
|
|
1653
1802
|
caps: Caps;
|
|
1654
1803
|
/** Occupied issues: live workers plus green PRs awaiting a human merge. */
|
|
1655
1804
|
activeRuns: RunRecord[];
|
|
1805
|
+
/** Newest attempts holding a preserved WIP tip, or a tree that is still the
|
|
1806
|
+
* only copy of work the daemon could not save. */
|
|
1807
|
+
salvagedRuns: RunRecord[];
|
|
1656
1808
|
/** Runs backed by a worker process — the number capacity compares against. */
|
|
1657
1809
|
liveWorkers: number;
|
|
1658
1810
|
runsToday: number;
|
|
@@ -1673,6 +1825,7 @@ export function statusSnapshotFromStore(p: ProjectConfig, caps: Caps, store: Sto
|
|
|
1673
1825
|
paused: isPaused(),
|
|
1674
1826
|
caps,
|
|
1675
1827
|
activeRuns: store.activeRuns(p.name),
|
|
1828
|
+
salvagedRuns: store.salvagedRuns(p.name),
|
|
1676
1829
|
liveWorkers: store.liveRuns(p.name).length,
|
|
1677
1830
|
runsToday: store.runsStartedSince(p.name, since),
|
|
1678
1831
|
spendTodayUsd: store.spendSince(p.name, since),
|
|
@@ -1716,6 +1869,32 @@ export function formatDispatchSummary(summary?: DispatchSummary): string {
|
|
|
1716
1869
|
return lines.join("\n");
|
|
1717
1870
|
}
|
|
1718
1871
|
|
|
1872
|
+
/**
|
|
1873
|
+
* The WIP block: every issue whose newest attempt left work behind, and
|
|
1874
|
+
* whether that work is safe.
|
|
1875
|
+
*
|
|
1876
|
+
* Blocked runs used to be invisible here, which is exactly how #118 stayed
|
|
1877
|
+
* invisible for a full attempt cycle — the operator saw a blocked issue and had
|
|
1878
|
+
* no way to tell "stopped with 34 uncommitted files" from "stopped clean".
|
|
1879
|
+
* A preserved line is informational; an UNSALVAGED line is an alarm, and it
|
|
1880
|
+
* names the directory because that directory is the work.
|
|
1881
|
+
*/
|
|
1882
|
+
export function formatSalvagedRuns(runs: readonly RunRecord[]): string[] {
|
|
1883
|
+
if (runs.length === 0) return [];
|
|
1884
|
+
const lines = ["", "wip"];
|
|
1885
|
+
for (const r of runs) {
|
|
1886
|
+
lines.push(
|
|
1887
|
+
r.salvageError !== undefined && r.salvageAckAt === undefined
|
|
1888
|
+
? ` #${r.issue} UNSALVAGED ${r.worktree === "" ? "(path not recorded)" : r.worktree} — ` +
|
|
1889
|
+
`only copy, dispatch held (${r.salvageError})`
|
|
1890
|
+
: r.salvageError !== undefined
|
|
1891
|
+
? ` #${r.issue} accepted as lost attempt ${r.attempt} (${r.salvageError})`
|
|
1892
|
+
: ` #${r.issue} preserved ${r.salvageSha ?? "?"} on ${r.branch} (attempt ${r.attempt}, ${r.state})`,
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1895
|
+
return lines;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1719
1898
|
export function formatStatus(s: StatusSnapshot): string {
|
|
1720
1899
|
const lines = [
|
|
1721
1900
|
`project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
|
|
@@ -1748,6 +1927,7 @@ export function formatStatus(s: StatusSnapshot): string {
|
|
|
1748
1927
|
);
|
|
1749
1928
|
}
|
|
1750
1929
|
}
|
|
1930
|
+
lines.push(...formatSalvagedRuns(s.salvagedRuns));
|
|
1751
1931
|
// Deploy hint: a restart while workers are live orphans them (salvage runs
|
|
1752
1932
|
// first — #35). Prefer pause + drain to zero live workers when you can wait.
|
|
1753
1933
|
if (s.liveWorkers > 0) {
|
|
@@ -1852,12 +2032,20 @@ export async function reconcileOrphanedRuns(
|
|
|
1852
2032
|
const endedAt = Date.now();
|
|
1853
2033
|
for (const r of stale) {
|
|
1854
2034
|
// Salvage before the row flips: the worktree path is on the record, and
|
|
1855
|
-
// salvageWip is a no-op for a missing/clean tree.
|
|
1856
|
-
//
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
2035
|
+
// salvageWip is a no-op for a missing/clean tree. The clause matches the
|
|
2036
|
+
// cap-kill wording so triage reads the same either way, and the tree is
|
|
2037
|
+
// kept because an orphan's remains are the orchestrator's drain-duty call.
|
|
2038
|
+
const settlement =
|
|
2039
|
+
r.worktree === ""
|
|
2040
|
+
? undefined
|
|
2041
|
+
: await settleWorktree({
|
|
2042
|
+
issue: r.issue,
|
|
2043
|
+
attempt: r.attempt,
|
|
2044
|
+
ending: "killed by a daemon restart",
|
|
2045
|
+
worktree: r.worktree,
|
|
2046
|
+
tree: "keep",
|
|
2047
|
+
});
|
|
2048
|
+
store.updateRun(r.id, { state: "orphaned", endedAt, ...settlement?.patch });
|
|
1861
2049
|
}
|
|
1862
2050
|
return stale;
|
|
1863
2051
|
}
|
package/src/fleet.ts
CHANGED
|
@@ -28,8 +28,16 @@ import { createInterface } from "node:readline";
|
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { dirname, join } from "node:path";
|
|
30
30
|
import { findProject, loadConfig, stateDir } from "./config.ts";
|
|
31
|
+
import { readApprovalSurface } from "./approval-surface.ts";
|
|
31
32
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
32
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
formatDispatchSummary,
|
|
35
|
+
formatSalvagedRuns,
|
|
36
|
+
isPaused,
|
|
37
|
+
setPaused,
|
|
38
|
+
statusSnapshot,
|
|
39
|
+
type StatusSnapshot,
|
|
40
|
+
} from "./daemon.ts";
|
|
33
41
|
import {
|
|
34
42
|
healthCheck,
|
|
35
43
|
isAlive,
|
|
@@ -1028,6 +1036,7 @@ function formatProjectBody(s: StatusSnapshot): string {
|
|
|
1028
1036
|
);
|
|
1029
1037
|
}
|
|
1030
1038
|
}
|
|
1039
|
+
lines.push(...formatSalvagedRuns(s.salvagedRuns));
|
|
1031
1040
|
if (s.liveWorkers > 0) {
|
|
1032
1041
|
lines.push(
|
|
1033
1042
|
"",
|
|
@@ -1082,62 +1091,11 @@ function readPairedChannel(path: string): Channel {
|
|
|
1082
1091
|
}
|
|
1083
1092
|
|
|
1084
1093
|
/**
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
* for a turn whose prompt resolves a notify target. A Telegram-originated turn
|
|
1090
|
-
* resolves one from its own `<telegram-message>` wrapper; a locally injected
|
|
1091
|
-
* orchestrator tick has no wrapper, so it resolves one only through
|
|
1092
|
-
* `notifyTarget()` — which needs `notifyMode` set to "away" or "always" *and* a
|
|
1093
|
-
* destination, this session's forum topic under `topicsChat` or the flat
|
|
1094
|
-
* `notifyChat`. With neither, the tool is simply absent from the tick.
|
|
1095
|
-
*
|
|
1096
|
-
* That is exactly what happened on 2026-08-09 06:17Z: the fleet's access.json
|
|
1097
|
-
* had no `notifyMode`, so the locally injected tick could not ask the
|
|
1098
|
-
* Learning-loop yes/no question the package floor requires — while this very
|
|
1099
|
-
* status line reported `telegram ok (@tbcoder_bot; inbound configured)`
|
|
1100
|
-
* throughout (#114). A health row that stays green through a broken contract is
|
|
1101
|
-
* worse than no row, so the approval surface is now part of it.
|
|
1102
|
-
*
|
|
1103
|
-
* The legacy `away: true` boolean counts: `loadAccess()` migrates it to
|
|
1104
|
-
* `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
|
|
1105
|
-
* and must not be reported as broken.
|
|
1094
|
+
* The approval half of the Telegram surface is a different question from
|
|
1095
|
+
* whether inbound works, and it is answered by {@link readApprovalSurface} —
|
|
1096
|
+
* shared with the orchestrator tick so this row and the tick's own warning can
|
|
1097
|
+
* never disagree about whether an amendment can be asked.
|
|
1106
1098
|
*/
|
|
1107
|
-
type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
|
|
1108
|
-
|
|
1109
|
-
function readApprovalSurface(path: string): ApprovalSurface {
|
|
1110
|
-
let parsed: unknown;
|
|
1111
|
-
try {
|
|
1112
|
-
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1113
|
-
} catch {
|
|
1114
|
-
return { kind: "missing", reason: `telegram_ask unavailable on local ticks: cannot read ${path}` };
|
|
1115
|
-
}
|
|
1116
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1117
|
-
return { kind: "missing", reason: `telegram_ask unavailable on local ticks: ${path} is not an object` };
|
|
1118
|
-
}
|
|
1119
|
-
const access = parsed as { readonly [key: string]: unknown };
|
|
1120
|
-
const mode = access["notifyMode"];
|
|
1121
|
-
const active = mode === "away" || mode === "always" || access["away"] === true;
|
|
1122
|
-
if (!active) {
|
|
1123
|
-
return {
|
|
1124
|
-
kind: "missing",
|
|
1125
|
-
reason:
|
|
1126
|
-
`telegram_ask unavailable on local ticks: no notifyMode in ${path} — ` +
|
|
1127
|
-
`set it to "always" and give it a destination (notifyChat, or topicsChat for a forum)`,
|
|
1128
|
-
};
|
|
1129
|
-
}
|
|
1130
|
-
const destination = access["notifyChat"] ?? access["topicsChat"];
|
|
1131
|
-
if (typeof destination !== "string" || destination.length === 0) {
|
|
1132
|
-
return {
|
|
1133
|
-
kind: "missing",
|
|
1134
|
-
reason:
|
|
1135
|
-
`telegram_ask unavailable on local ticks: notifyMode is set but ${path} names no destination — ` +
|
|
1136
|
-
"set notifyChat to the paired owner id (or topicsChat for a forum)",
|
|
1137
|
-
};
|
|
1138
|
-
}
|
|
1139
|
-
return { kind: "ready" };
|
|
1140
|
-
}
|
|
1141
1099
|
|
|
1142
1100
|
function readBotToken(): string | undefined {
|
|
1143
1101
|
const env = process.env["TELEGRAM_BOT_TOKEN"];
|
|
@@ -1205,6 +1163,8 @@ export async function probeTelegramHealth(
|
|
|
1205
1163
|
// Inbound first: a bridge that is down says nothing about the approval
|
|
1206
1164
|
// surface, and stacking two remedies on one row buries the one to act on.
|
|
1207
1165
|
if (channel.kind === "down") return { kind: "degraded", detail: `${username}; inbound ${channel.reason}` };
|
|
1166
|
+
// The token is already proven: `getMe` succeeded above. What is left is
|
|
1167
|
+
// whether an answer could come back, which is the access file's business.
|
|
1208
1168
|
const approval = readApprovalSurface(accessPath);
|
|
1209
1169
|
if (approval.kind === "missing") {
|
|
1210
1170
|
return { kind: "degraded", detail: `${username}; inbound configured; ${approval.reason}` };
|