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.
@@ -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 { isActiveArmProof } from "./arm-challenge.ts";
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
- return `Queue: empty — nothing carries "${queueLabel}". Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report why there is nothing to do.`;
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 line: string;
841
+ let tail: string;
712
842
  if (knownBlocked.length > 0 && claimable === 0) {
713
- line =
714
- `Queue: running low — ${summary.routed} routable candidate(s), all known-blocked ` +
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
- line =
719
- `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
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
- line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
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
- line +=
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
- line += ` All held: ${held}.`;
867
+ tail += ` All held: ${held}.`;
738
868
  }
739
- return line;
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: ReadyIssue[];
1104
- try {
1105
- issues = await seam.listOpenIssues(input.project);
1106
- } catch {
1107
- // No authoritative snapshot, no launch: a batch offered without one would
1108
- // make the model the selector, which is exactly the defect this slice
1109
- // removes. The queue digest still names the grooming duty; the next tick
1110
- // retries the read.
1111
- return undefined;
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
- frictionStore.latestDispatch(scope.projectName),
3411
+ dispatch,
3229
3412
  project.queueLabel,
3230
3413
  project.routing.labelPrefix,
3231
- project.groomBelow ?? DEFAULT_GROOM_BELOW,
3232
- store.groomingVerdicts(projectName),
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: frictionStore.latestDispatch(scope.projectName),
3248
- groomBelow: project.groomBelow ?? DEFAULT_GROOM_BELOW,
3249
- grooming: store.groomingVerdicts(projectName),
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. It
4037
- // must reach the transcript exactly as sent the host's transcript scan
4038
- // (`armTicks`) is the backend proof, so it is left untouched — while the
4039
- // model gets a trusted, machine-readable steer about it, so it stops
4040
- // ad-libbing pairing-safety prose at a bare `FLEET-…` token (conductor
4041
- // #415). The classification derives from persisted authenticated
4042
- // pending-challenge state (hash + expiry, keyed by this project), never
4043
- // from the `FLEET-` prefix, so an unsolicited lookalike that matches no
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 = isActiveArmProof(configuredProject, replyText, Date.now());
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;
@@ -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
- * Returns `false` when the session refused to start because the identity it
217
- * was launched for does not match the kernel identity this process actually
218
- * runs under that refusal is the fail-closed arm of the worker boundary,
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
- // A refused start the launched identity did not match this process's
426
- // kernel identity exits 91 (the same code the rejected cgroup-boundary
427
- // launcher used) so an operator reading `ps` or the exit status sees a
428
- // boundary refusal, never a clean exit.
391
+ // 91 was the worker-identity boundary refusal; the code stays reserved (and
392
+ // unreachablerunSessionHost 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
- const pathParts = [dirname(bun), ...(process.env["PATH"] ?? "").split(":")].filter(
480
- (value, index, all) => value.length > 0 && all.indexOf(value) === index,
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 effective import contract, not just mount
1596
- // metadata: after the inode check passes, execute the same anchored loader
1597
- // with --no-install through the real HOME/setpriv transition. A mounted
1598
- // tree whose Bun resolution still reaches the worker cache is pending.
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,