omp-conductor 0.18.1 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/REFERENCE.md +1 -1
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +6 -1
- package/src/arm-challenge.ts +250 -57
- package/src/briefs/orchestrator.md +21 -8
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +122 -1
- package/src/command-manifest.ts +16 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/intake.ts +4 -19
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +19 -6
- package/src/config.ts +23 -8
- package/src/daemon.ts +150 -241
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +55 -129
- package/src/escalate.ts +22 -11
- package/src/fleet.ts +93 -218
- package/src/host.ts +7 -332
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +11 -56
- package/src/orchestrator-tick.ts +225 -37
- package/src/session-host.ts +6 -41
- package/src/setup-host.ts +27 -10
- package/src/setup-wizard.ts +141 -1
- package/src/setup.ts +54 -4
- package/src/status-render.ts +138 -1
- package/src/to-spec.ts +23 -2
- package/src/types.ts +62 -6
- package/src/verbs/server.ts +38 -3
- package/src/worker.ts +0 -13
package/src/orchestrator-tick.ts
CHANGED
|
@@ -114,6 +114,7 @@ import {
|
|
|
114
114
|
type AskResult,
|
|
115
115
|
} from "./ask.ts";
|
|
116
116
|
import { deliverOperatorMessage } from "./reports.ts";
|
|
117
|
+
import { effectiveLabels } from "./routing.ts";
|
|
117
118
|
import { readTelegramToken, resolveProjectTopicId, telegramStateDir } from "./escalate.ts";
|
|
118
119
|
import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
119
120
|
import { dbPath, openStore } from "./store.ts";
|
|
@@ -125,7 +126,7 @@ import {
|
|
|
125
126
|
TO_SPEC_SCHEMA,
|
|
126
127
|
} from "./to-spec.ts";
|
|
127
128
|
import { heldNoticeId } from "./notices.ts";
|
|
128
|
-
import {
|
|
129
|
+
import { acknowledgeArmReply } from "./arm-challenge.ts";
|
|
129
130
|
|
|
130
131
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
131
132
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -639,6 +640,21 @@ function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
|
|
|
639
640
|
return [...counts.entries()].map(([verdict, count]) => `${verdict} ${count}`).join(", ");
|
|
640
641
|
}
|
|
641
642
|
|
|
643
|
+
/**
|
|
644
|
+
* The queue-label inventory read fresh from the tracker on one tick, with the
|
|
645
|
+
* pending label ops applied exactly as the dispatch pass judges eligibility
|
|
646
|
+
* (#848). Queue verdicts in the present tense ("empty", "running low") are
|
|
647
|
+
* only ever drawn from this observation; a dispatch summary alone is dated
|
|
648
|
+
* history and must never be rendered as the live queue.
|
|
649
|
+
*/
|
|
650
|
+
export interface QueueObservation {
|
|
651
|
+
/** Open issues carrying the queue label under the *effective* label set —
|
|
652
|
+
* the count dispatch would route against on its next pass. */
|
|
653
|
+
queued: number;
|
|
654
|
+
/** The instant the tracker observation was made, epoch ms. */
|
|
655
|
+
observedAt: number;
|
|
656
|
+
}
|
|
657
|
+
|
|
642
658
|
/**
|
|
643
659
|
* One line telling the orchestrator the routable queue is running dry (#181),
|
|
644
660
|
* or `undefined` when healthy — no dispatch recorded yet, or the routable count
|
|
@@ -651,6 +667,12 @@ function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
|
|
|
651
667
|
* so it is told apart from the mechanical holds: it says "a batch is running",
|
|
652
668
|
* not "the lane cannot move", and counts neither as claimable nor as
|
|
653
669
|
* known-blocked.
|
|
670
|
+
*
|
|
671
|
+
* `queue` is the tracker observation the caller made THIS tick (#848). When
|
|
672
|
+
* present, the queue verdict reads in the present tense from that inventory,
|
|
673
|
+
* and dispatch-derived counts are only ever dated context. When absent the
|
|
674
|
+
* tracker could not be read, and this never asserts that the queue is empty:
|
|
675
|
+
* the dispatch row is rendered explicitly as-of instead.
|
|
654
676
|
*/
|
|
655
677
|
export function queueDigestLine(
|
|
656
678
|
summary: DispatchSummary | undefined,
|
|
@@ -658,10 +680,30 @@ export function queueDigestLine(
|
|
|
658
680
|
labelPrefix: string,
|
|
659
681
|
groomBelow: number,
|
|
660
682
|
grooming: readonly GroomingRecord[] = [],
|
|
683
|
+
queue: QueueObservation | undefined = undefined,
|
|
661
684
|
): string | undefined {
|
|
662
685
|
if (summary === undefined) return undefined;
|
|
686
|
+
if (queue !== undefined) {
|
|
687
|
+
return liveQueueDigestLine(summary, queue, queueLabel, labelPrefix, groomBelow, grooming);
|
|
688
|
+
}
|
|
689
|
+
return datedQueueDigestLine(summary, queueLabel, labelPrefix, groomBelow, grooming);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The dated rendering: the last dispatch row is the only evidence, so every
|
|
693
|
+
* count carries its snapshot date and nothing is asserted as current. */
|
|
694
|
+
function datedQueueDigestLine(
|
|
695
|
+
summary: DispatchSummary,
|
|
696
|
+
queueLabel: string,
|
|
697
|
+
labelPrefix: string,
|
|
698
|
+
groomBelow: number,
|
|
699
|
+
grooming: readonly GroomingRecord[],
|
|
700
|
+
): string | undefined {
|
|
701
|
+
const dated = new Date(summary.completedAt).toISOString();
|
|
663
702
|
if (summary.ready === 0) {
|
|
664
|
-
|
|
703
|
+
if (summary.paused === true) {
|
|
704
|
+
return `Queue: unchecked — the last pass (${dated}) was a hold that counted no queue and admitted nothing; the tracker could not be re-read this tick, so the live queue is NOT claimed empty. Groom only after a fresh read.`;
|
|
705
|
+
}
|
|
706
|
+
return `Queue: empty (as of the last dispatch, ${dated}) — nobody carried "${queueLabel}" on that count, and the live tracker was not re-read this tick, so this is a snapshot, not a present-tense claim. Groom the backlog (Duty 2) once a fresh read confirms it.`;
|
|
665
707
|
}
|
|
666
708
|
if (summary.routed === 0) {
|
|
667
709
|
// `ready` counts claimed (in-flight) issues too; route() drops those with a
|
|
@@ -678,7 +720,7 @@ export function queueDigestLine(
|
|
|
678
720
|
const staleLifecycle = summary.holds
|
|
679
721
|
.filter((h) => h.reason === "stale-lifecycle")
|
|
680
722
|
.reduce((n, h) => n + h.count, 0);
|
|
681
|
-
let line = `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
|
|
723
|
+
let line = `As of the last dispatch (${dated}): Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
|
|
682
724
|
if (unroutable > 0) {
|
|
683
725
|
line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
|
|
684
726
|
}
|
|
@@ -693,6 +735,94 @@ export function queueDigestLine(
|
|
|
693
735
|
return line;
|
|
694
736
|
}
|
|
695
737
|
if (summary.routed >= groomBelow) return undefined;
|
|
738
|
+
return lowQueueTail(summary, groomBelow, grooming, `As of the last dispatch (${dated}): Queue: `);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** The live rendering: the tracker observation is the queue label inventory,
|
|
742
|
+
* the dispatch summary supplies the claimability reading, and the shared
|
|
743
|
+
* low-queue diagnostics are composed underneath (#848 review 2). The raw
|
|
744
|
+
* carrier count appears only as a present-tense inventory fact — never as
|
|
745
|
+
* the grooming threshold, which must be claimability (`summary.routed`, the
|
|
746
|
+
* same count the to-spec offer gates on), or parked/in-flight/state-labelled
|
|
747
|
+
* carriers would silence a queue that dispatch cannot move. */
|
|
748
|
+
function liveQueueDigestLine(
|
|
749
|
+
summary: DispatchSummary,
|
|
750
|
+
queue: QueueObservation,
|
|
751
|
+
queueLabel: string,
|
|
752
|
+
labelPrefix: string,
|
|
753
|
+
groomBelow: number,
|
|
754
|
+
grooming: readonly GroomingRecord[],
|
|
755
|
+
): string | undefined {
|
|
756
|
+
const queued = queue.queued;
|
|
757
|
+
const observed = new Date(queue.observedAt).toISOString();
|
|
758
|
+
// The present-tense empty verdict is the tracker's own word: nothing open
|
|
759
|
+
// carries the queue label right now. No dispatch snapshot may produce a
|
|
760
|
+
// live "empty" claim — that is the whole defect (#848).
|
|
761
|
+
if (queued === 0) {
|
|
762
|
+
return (
|
|
763
|
+
`Queue: empty — nothing open carries "${queueLabel}" right now (tracker ${observed}). ` +
|
|
764
|
+
"Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report " +
|
|
765
|
+
"why there is nothing to do."
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
// The grooming threshold is claimability — summary.routed — never raw
|
|
769
|
+
// carrier volume. Four in-flight/parked/unroutable carriers must not
|
|
770
|
+
// suppress the low-claimable signal, because the to-spec offer gates on
|
|
771
|
+
// the very same routed count and the two would split.
|
|
772
|
+
if (summary.routed >= groomBelow) return undefined;
|
|
773
|
+
const dated = new Date(summary.completedAt).toISOString();
|
|
774
|
+
const inventory = `Queue: ${queued} open issue${queued === 1 ? "" : "s"} carry "${queueLabel}" right now (tracker ${observed}); `;
|
|
775
|
+
if (summary.routed === 0) {
|
|
776
|
+
// Dispatch's own claimability read: `ready` counts claimed (in-flight)
|
|
777
|
+
// issues too, so "0 routable" must not blanket-blame missing `repo:`
|
|
778
|
+
// labels (#228), and a residual lifecycle label is Duty 1 reconciliation
|
|
779
|
+
// work, not spare depth (#611).
|
|
780
|
+
if (summary.paused === true) {
|
|
781
|
+
return (
|
|
782
|
+
inventory +
|
|
783
|
+
`the last dispatch (${dated}) was a hold that admitted nothing — the queued issues above are the work that resumes when claiming reopens.`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
const claimed = summary.claimed ?? 0;
|
|
787
|
+
let line = inventory + `at the last dispatch (${dated}) ${summary.ready} ready, 0 spare — ${claimed} in flight`;
|
|
788
|
+
const unroutable = summary.holds
|
|
789
|
+
.filter((h) => h.reason.startsWith("unroutable:"))
|
|
790
|
+
.reduce((n, h) => n + h.count, 0);
|
|
791
|
+
const staleLifecycle = summary.holds
|
|
792
|
+
.filter((h) => h.reason === "stale-lifecycle")
|
|
793
|
+
.reduce((n, h) => n + h.count, 0);
|
|
794
|
+
if (unroutable > 0) {
|
|
795
|
+
line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
|
|
796
|
+
}
|
|
797
|
+
if (staleLifecycle > 0) {
|
|
798
|
+
line += `, ${staleLifecycle} with a residual lifecycle label (Duty 1: newest run terminal — reconcile the stale agent:in-progress/blocked/failed state)`;
|
|
799
|
+
}
|
|
800
|
+
if (unroutable === 0 && staleLifecycle === 0) {
|
|
801
|
+
line += `. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
|
|
802
|
+
} else {
|
|
803
|
+
line += ".";
|
|
804
|
+
}
|
|
805
|
+
return line;
|
|
806
|
+
}
|
|
807
|
+
// routed > 0 below the trigger: the live inventory leads, then the same
|
|
808
|
+
// low-queue diagnostics the dated path ships — the claimable/known-blocked
|
|
809
|
+
// split, in-flight to-spec batches, the considered backlog and this pass's
|
|
810
|
+
// holds (#735, #777, #679) — so a tracker read never hides them.
|
|
811
|
+
return inventory + lowQueueTail(summary, groomBelow, grooming, "");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** The shared low-queue diagnostic tail (#735/#777/#679): the routable-count →
|
|
815
|
+
* claimable/known-blocked split, in-flight to-spec batches, the considered
|
|
816
|
+
* backlog and this pass's holds. `lead` is the sentence opener the caller
|
|
817
|
+
* chooses — the dated path opens with the dispatch timestamp and "Queue:",
|
|
818
|
+
* the live path with its tracker inventory (#848 review 2). Sharing one tail
|
|
819
|
+
* means a tracker read can never hide these distinctions. */
|
|
820
|
+
function lowQueueTail(
|
|
821
|
+
summary: DispatchSummary,
|
|
822
|
+
groomBelow: number,
|
|
823
|
+
grooming: readonly GroomingRecord[],
|
|
824
|
+
lead: string,
|
|
825
|
+
): string {
|
|
696
826
|
// The durable per-issue verdicts, not this pass's one-shot hold groups: a
|
|
697
827
|
// lane-blocked runway must read as "cannot move" even after a restart, and
|
|
698
828
|
// the orchestrator's own prior verdicts (#679) must not be re-derived.
|
|
@@ -708,22 +838,22 @@ export function queueDigestLine(
|
|
|
708
838
|
const claimable = Math.max(0, summary.routed - knownBlocked.length);
|
|
709
839
|
const inFlightNames = inFlight.map((r) => `#${r.issue}`).join(", ");
|
|
710
840
|
const busy = inFlight.length === 0 ? "" : `, ${inFlight.length} in a to-spec batch (${inFlightNames})`;
|
|
711
|
-
let
|
|
841
|
+
let tail: string;
|
|
712
842
|
if (knownBlocked.length > 0 && claimable === 0) {
|
|
713
|
-
|
|
714
|
-
|
|
843
|
+
tail =
|
|
844
|
+
`${lead}running low — ${summary.routed} routable candidate(s), all known-blocked ` +
|
|
715
845
|
`(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
|
|
716
846
|
`${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
|
|
717
847
|
} else if (knownBlocked.length > 0 || inFlight.length > 0) {
|
|
718
|
-
|
|
719
|
-
|
|
848
|
+
tail =
|
|
849
|
+
`${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
|
|
720
850
|
`${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
|
|
721
851
|
`${busy} — groom only the claimable.`;
|
|
722
852
|
} else {
|
|
723
|
-
|
|
853
|
+
tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
|
|
724
854
|
}
|
|
725
855
|
if (considered.length > 0) {
|
|
726
|
-
|
|
856
|
+
tail +=
|
|
727
857
|
` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
|
|
728
858
|
`promote the promotable or groom new issues, never re-groom these.`;
|
|
729
859
|
}
|
|
@@ -734,9 +864,9 @@ export function queueDigestLine(
|
|
|
734
864
|
return `${h.reason} ${h.count}${first === undefined ? "" : ` (${first})`}`;
|
|
735
865
|
})
|
|
736
866
|
.join(", ");
|
|
737
|
-
|
|
867
|
+
tail += ` All held: ${held}.`;
|
|
738
868
|
}
|
|
739
|
-
return
|
|
869
|
+
return tail;
|
|
740
870
|
}
|
|
741
871
|
|
|
742
872
|
// ================================================================ to-spec
|
|
@@ -1094,21 +1224,28 @@ export async function offerToSpecLaunch(input: {
|
|
|
1094
1224
|
active: readonly { issue: number }[];
|
|
1095
1225
|
project: ProjectConfig;
|
|
1096
1226
|
trackerSeam: ToSpecTrackerSeam | undefined;
|
|
1227
|
+
/** An open-issue snapshot this tick already read from the tracker. Shared
|
|
1228
|
+
* with the queue digest so one tick cannot describe two queues (#848);
|
|
1229
|
+
* absent when that read failed, in which case the launch retries its own
|
|
1230
|
+
* read and fails closed on the same terms as before. */
|
|
1231
|
+
issues?: readonly ReadyIssue[];
|
|
1097
1232
|
now: number;
|
|
1098
1233
|
}): Promise<ToSpecLaunchBlock | undefined> {
|
|
1099
1234
|
if (input.summary === undefined) return undefined;
|
|
1100
1235
|
if (input.summary.routed >= input.groomBelow) return undefined;
|
|
1101
1236
|
const seam = input.trackerSeam;
|
|
1102
1237
|
if (seam === undefined) return undefined;
|
|
1103
|
-
let issues
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1238
|
+
let issues = input.issues;
|
|
1239
|
+
if (issues === undefined) {
|
|
1240
|
+
try {
|
|
1241
|
+
issues = await seam.listOpenIssues(input.project);
|
|
1242
|
+
} catch {
|
|
1243
|
+
// No authoritative snapshot, no launch: a batch offered without one would
|
|
1244
|
+
// make the model the selector, which is exactly the defect this slice
|
|
1245
|
+
// removes. The queue digest still names the grooming duty; the next tick
|
|
1246
|
+
// retries the read.
|
|
1247
|
+
return undefined;
|
|
1248
|
+
}
|
|
1112
1249
|
}
|
|
1113
1250
|
const views = toSpecPoolFromSnapshot(issues, input.project);
|
|
1114
1251
|
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
@@ -3224,12 +3361,59 @@ async function tick(
|
|
|
3224
3361
|
// so the resolved name is in hand and an un-named lookup would refuse
|
|
3225
3362
|
// to guess on a host with a second project.
|
|
3226
3363
|
const project = findProject(loadConfig(), scope.projectName);
|
|
3364
|
+
const groomBelow = project.groomBelow ?? DEFAULT_GROOM_BELOW;
|
|
3365
|
+
const dispatch = frictionStore.latestDispatch(scope.projectName);
|
|
3366
|
+
const grooming = store.groomingVerdicts(projectName);
|
|
3367
|
+
// #848: "the queue is empty / running dry" is the tracker's word, not
|
|
3368
|
+
// the last dispatch pass's echo. One open-issue snapshot per tick,
|
|
3369
|
+
// read live through the same authoritative tracker surface the
|
|
3370
|
+
// to-spec launch uses, overlaid with the label_ops projection
|
|
3371
|
+
// exactly as the dispatch pass judges eligibility — so a promotion,
|
|
3372
|
+
// unblock or projection write after the last dispatch pass is
|
|
3373
|
+
// visible on the very next tick even while a drain holds claiming.
|
|
3374
|
+
// The digest consumes the overlay as the live inventory; the to-spec
|
|
3375
|
+
// offer shares the same raw snapshot so one tick cannot describe two
|
|
3376
|
+
// queues. If the tracker read fails, the digest falls back to
|
|
3377
|
+
// explicitly dated wording (never a present-tense empty claim),
|
|
3378
|
+
// and the offer retries its own read exactly as it did before.
|
|
3379
|
+
//
|
|
3380
|
+
// The read is made only for dispatch rows the digest could render:
|
|
3381
|
+
// a healthy row (routed at/above the grooming threshold) emits no
|
|
3382
|
+
// queue line at all, so no freshness check is spent on it and no
|
|
3383
|
+
// stale claim can leak from it. A zero-ready row (or any row below
|
|
3384
|
+
// the trigger) is exactly where the old wording lied, so every such
|
|
3385
|
+
// tick reads the live queue.
|
|
3386
|
+
const needsQueueRead =
|
|
3387
|
+
dispatch !== undefined &&
|
|
3388
|
+
(dispatch.ready === 0 || dispatch.routed === 0 || dispatch.routed < groomBelow);
|
|
3389
|
+
let queueObservation: QueueObservation | undefined;
|
|
3390
|
+
let openSnapshot: readonly ReadyIssue[] | undefined;
|
|
3391
|
+
if (toSpecTrackerSeam !== undefined && needsQueueRead) {
|
|
3392
|
+
try {
|
|
3393
|
+
const open = await toSpecTrackerSeam.listOpenIssues(project);
|
|
3394
|
+
openSnapshot = open;
|
|
3395
|
+
const effective = open.map((issue) => {
|
|
3396
|
+
const pending = store.pendingLabelOpsFor(projectName, issue.number);
|
|
3397
|
+
return pending.length === 0
|
|
3398
|
+
? issue
|
|
3399
|
+
: { ...issue, labels: effectiveLabels(issue.labels, pending) };
|
|
3400
|
+
});
|
|
3401
|
+
queueObservation = {
|
|
3402
|
+
queued: effective.filter((issue) => issue.labels.includes(project.queueLabel)).length,
|
|
3403
|
+
observedAt: now,
|
|
3404
|
+
};
|
|
3405
|
+
} catch {
|
|
3406
|
+
// No current-queue evidence this tick: the digest below renders
|
|
3407
|
+
// the dispatch row as dated history instead of claiming empty.
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3227
3410
|
const queue = queueDigestLine(
|
|
3228
|
-
|
|
3411
|
+
dispatch,
|
|
3229
3412
|
project.queueLabel,
|
|
3230
3413
|
project.routing.labelPrefix,
|
|
3231
|
-
|
|
3232
|
-
|
|
3414
|
+
groomBelow,
|
|
3415
|
+
grooming,
|
|
3416
|
+
queueObservation,
|
|
3233
3417
|
);
|
|
3234
3418
|
if (queue !== undefined) content = `${content}\n${queue}`;
|
|
3235
3419
|
// #777: the mechanical to-spec launch boundary. The queue digest is
|
|
@@ -3244,10 +3428,11 @@ async function tick(
|
|
|
3244
3428
|
// Every tick owns its authorization fresh — the clears above ran
|
|
3245
3429
|
// before the reads, so the offer below can only mint for THIS tick.
|
|
3246
3430
|
const launch = await offerToSpecLaunch({
|
|
3247
|
-
summary:
|
|
3248
|
-
groomBelow
|
|
3249
|
-
grooming
|
|
3431
|
+
summary: dispatch,
|
|
3432
|
+
groomBelow,
|
|
3433
|
+
grooming,
|
|
3250
3434
|
active: store.activeRuns(projectName),
|
|
3435
|
+
issues: openSnapshot,
|
|
3251
3436
|
project,
|
|
3252
3437
|
trackerSeam: toSpecTrackerSeam,
|
|
3253
3438
|
now,
|
|
@@ -4033,22 +4218,25 @@ export default function orchestratorTickExtension(
|
|
|
4033
4218
|
}
|
|
4034
4219
|
return;
|
|
4035
4220
|
}
|
|
4036
|
-
// The reply to an arming challenge lands here as an ordinary user turn.
|
|
4037
|
-
//
|
|
4038
|
-
//
|
|
4039
|
-
//
|
|
4040
|
-
//
|
|
4041
|
-
// #
|
|
4042
|
-
//
|
|
4043
|
-
// from the `FLEET-` prefix, so
|
|
4044
|
-
// active challenge stays inert
|
|
4221
|
+
// The reply to an arming challenge lands here as an ordinary user turn.
|
|
4222
|
+
// This adapter — not the model, and not any transcript scan — is the
|
|
4223
|
+
// sole producer of the arming acknowledgement: it classifies the turn
|
|
4224
|
+
// against persisted authenticated challenge state and, on a match,
|
|
4225
|
+
// atomically records the challenge-id-specific acknowledgement the
|
|
4226
|
+
// host-side `arm` waits on (conductor #614), all before normal model
|
|
4227
|
+
// handling. The classification derives from the pending challenge (hash
|
|
4228
|
+
// + expiry, keyed by this project), never from the `FLEET-` prefix, so
|
|
4229
|
+
// an unsolicited lookalike that matches no active challenge stays inert
|
|
4230
|
+
// and model behaviour cannot determine whether the host becomes armed.
|
|
4231
|
+
// The turn itself still reaches the transcript exactly as sent, with the
|
|
4232
|
+
// same trusted machine-readable steer as before (#415).
|
|
4045
4233
|
if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
|
|
4046
4234
|
const replyText =
|
|
4047
4235
|
message.content
|
|
4048
4236
|
?.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
4049
4237
|
.map((part) => part.text as string)
|
|
4050
4238
|
.join(" ") ?? "";
|
|
4051
|
-
const proof =
|
|
4239
|
+
const proof = acknowledgeArmReply(configuredProject, replyText, Date.now());
|
|
4052
4240
|
if (session.activeLocalTick !== undefined) {
|
|
4053
4241
|
session.activeLocalTick.humanWaiting = true;
|
|
4054
4242
|
if (proof) session.activeLocalTick.armingProof = true;
|
package/src/session-host.ts
CHANGED
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
|
|
22
22
|
import { connect } from "node:net";
|
|
23
23
|
|
|
24
|
-
import { identityMismatch } from "./host.ts";
|
|
25
24
|
import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
|
|
26
25
|
import type { GraphToolsObservation } from "./types.ts";
|
|
27
26
|
import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
|
|
@@ -36,16 +35,6 @@ export interface SessionHostSpec {
|
|
|
36
35
|
socket: string;
|
|
37
36
|
cwd: string;
|
|
38
37
|
role: SessionRole;
|
|
39
|
-
/**
|
|
40
|
-
* The kernel identity this session was launched under (#798): the uid/gid
|
|
41
|
-
* setpriv dropped to before this process started. The child asserts
|
|
42
|
-
* `process.getuid()/getgid()` against it as its first act, and refuses to
|
|
43
|
-
* build a session when they differ — a worker session whose kernel identity
|
|
44
|
-
* is not the one its parent established must not run worker code. Absent,
|
|
45
|
-
* the session runs as the daemon's own uid, which is what every
|
|
46
|
-
* non-worker surface (orchestrator, tests) wants.
|
|
47
|
-
*/
|
|
48
|
-
identity?: { uid: number; gid: number };
|
|
49
38
|
sessionDir?: string;
|
|
50
39
|
model?: string;
|
|
51
40
|
resume?: boolean;
|
|
@@ -213,43 +202,20 @@ export interface SessionHostDeps {
|
|
|
213
202
|
* peer dependency still reads as a deployment mistake rather than as an
|
|
214
203
|
* unexplained child exit.
|
|
215
204
|
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* and the caller turns it into a non-zero exit so nothing downstream can read
|
|
220
|
-
* it as a clean session.
|
|
205
|
+
* Always resolves `true` now that the worker-identity refusal is gone (#894):
|
|
206
|
+
* the boolean return survives so the entry point's exit-code contract keeps
|
|
207
|
+
* its shape for any wrapper that learned it.
|
|
221
208
|
*/
|
|
222
209
|
export async function runSessionHost(
|
|
223
210
|
spec: SessionHostSpec,
|
|
224
211
|
deps: SessionHostDeps = { createSession: createLocalSession },
|
|
225
212
|
): Promise<boolean> {
|
|
226
|
-
|
|
227
213
|
const socket = connect(spec.socket);
|
|
228
214
|
socket.setNoDelay(true);
|
|
229
215
|
const send = (message: HostToParent): void => {
|
|
230
216
|
if (!socket.writableEnded) socket.write(encodeFrame(message));
|
|
231
217
|
};
|
|
232
218
|
|
|
233
|
-
// The kernel identity check comes as early as there is a socket to report
|
|
234
|
-
// over and before the harness (`createSession`) has run a single byte of
|
|
235
|
-
// worker-controlled code. A child whose uid/gid are not the ones its parent
|
|
236
|
-
// launched it for proves the boundary did not hold — the daemon would be
|
|
237
|
-
// supervising a session that is not what it thinks it is.
|
|
238
|
-
const violation = identityMismatch(spec.identity);
|
|
239
|
-
if (violation !== undefined) {
|
|
240
|
-
send({ t: "start-error", message: violation });
|
|
241
|
-
// The frame must reach the parent before this process exits: `process.exit`
|
|
242
|
-
// does not flush the socket, and a parent that only observes the exit used
|
|
243
|
-
// to report a bare exit code instead of the child's own words. The parent
|
|
244
|
-
// resolves the run as failed with no live child — fail closed.
|
|
245
|
-
await new Promise<void>((resolve) => {
|
|
246
|
-
socket.once("finish", () => resolve());
|
|
247
|
-
socket.once("error", () => resolve());
|
|
248
|
-
socket.end();
|
|
249
|
-
});
|
|
250
|
-
return false;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
219
|
const { promise: connected, resolve: onConnect, reject: onConnectFail } = Promise.withResolvers<void>();
|
|
254
220
|
socket.once("connect", () => {
|
|
255
221
|
onConnect();
|
|
@@ -422,9 +388,8 @@ if (import.meta.main) {
|
|
|
422
388
|
// spec that will not parse fails loudly here rather than as a silent hang.
|
|
423
389
|
const spec = specFromArgv(process.argv);
|
|
424
390
|
const started = await runSessionHost(spec);
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
// boundary refusal, never a clean exit.
|
|
391
|
+
// 91 was the worker-identity boundary refusal; the code stays reserved (and
|
|
392
|
+
// unreachable — runSessionHost always resolves true since #894) so nothing
|
|
393
|
+
// that learned to read it can mistake a future refusal for a clean exit.
|
|
429
394
|
process.exit(started ? 0 : 91);
|
|
430
395
|
}
|
package/src/setup-host.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
} from "./fleet.ts";
|
|
14
14
|
import {
|
|
15
15
|
harnessBindingProblem,
|
|
16
|
-
workerHarnessImportProblem,
|
|
17
16
|
WORKER_ACCOUNT,
|
|
18
17
|
WORKER_HARNESS_DIR,
|
|
19
18
|
WORKER_HARNESS_NODE_MODULES,
|
|
@@ -48,6 +47,16 @@ export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
|
|
|
48
47
|
export const STAGED_SERVICE_NAME = "omp-conductor.service";
|
|
49
48
|
export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
|
|
50
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The system directories every rendered service PATH ends with, in systemd's
|
|
52
|
+
* own default order: git, gh and the other tools the fleet spawns by name
|
|
53
|
+
* live here on every target host. The service PATH as a whole is canonical
|
|
54
|
+
* (#879) — these directories plus the resolved fleet-binary directories — so
|
|
55
|
+
* `setup host` stages and `doctor` compares one value, whichever process
|
|
56
|
+
* renders it and whatever the invoking shell happened to carry.
|
|
57
|
+
*/
|
|
58
|
+
export const SERVICE_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
59
|
+
|
|
51
60
|
/**
|
|
52
61
|
* The one recovery unit every fleet unit's `OnFailure=` points at (#485).
|
|
53
62
|
*
|
|
@@ -476,9 +485,18 @@ export function defaultServiceRuntime(
|
|
|
476
485
|
const globalCli = Bun.which("omp-conductor");
|
|
477
486
|
const herdr = Bun.which("herdr");
|
|
478
487
|
const loginShell = userInfo().shell;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
)
|
|
488
|
+
// #879: the staged PATH is a canonical host-runtime value — the directories
|
|
489
|
+
// of the binaries this runtime resolved plus SERVICE_SYSTEM_PATH — never the
|
|
490
|
+
// invoking shell's PATH. Caller entries (/root/bin, /opt/puppetlabs/bin, …)
|
|
491
|
+
// once leaked into the staged units, so `doctor` re-deriving the render in
|
|
492
|
+
// its own process reported PATH drift against byte-identical installs, and
|
|
493
|
+
// rerunning setup reproduced the finding its own fix text prescribes away.
|
|
494
|
+
const pathParts = [
|
|
495
|
+
dirname(bun),
|
|
496
|
+
...(globalCli === null ? [] : [dirname(globalCli)]),
|
|
497
|
+
...(herdr === null ? [] : [dirname(herdr)]),
|
|
498
|
+
...SERVICE_SYSTEM_PATH.split(":"),
|
|
499
|
+
].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
|
482
500
|
return {
|
|
483
501
|
username: userInfo().username,
|
|
484
502
|
home,
|
|
@@ -1592,12 +1610,11 @@ export function defaultIdentityProbes(runtime: { home: string; bun?: string }):
|
|
|
1592
1610
|
workerGhConfigCurrent: existsSync(join(WORKER_HOME_DIR, ".config", "gh", "hosts.yml")),
|
|
1593
1611
|
gitconfigPresent: existsSync(join(runtime.home, ".gitconfig")),
|
|
1594
1612
|
workerGitconfigCurrent: existsSync(join(WORKER_HOME_DIR, ".gitconfig")),
|
|
1595
|
-
// Setup currentness is the
|
|
1596
|
-
//
|
|
1597
|
-
//
|
|
1598
|
-
//
|
|
1599
|
-
harnessProblem: () =>
|
|
1600
|
-
harnessBindingProblem() ?? workerHarnessImportProblem({ bun: runtime.bun ?? process.execPath }),
|
|
1613
|
+
// Setup currentness is the binding's inode identity (#828). The old
|
|
1614
|
+
// second half — executing the loader through the worker HOME/setpriv
|
|
1615
|
+
// transition — retired with the runtime identity (#894): sessions launch
|
|
1616
|
+
// under the fleet account, so there is no transition left to probe.
|
|
1617
|
+
harnessProblem: () => harnessBindingProblem(),
|
|
1601
1618
|
// The bound tree's reach (#828 review), hardened to the directory's whole
|
|
1602
1619
|
// shape (#831): the verdict is a live ownership/access check read fresh on
|
|
1603
1620
|
// every planning run — root-owned, group the worker's live primary gid,
|