omp-conductor 0.15.11 → 0.15.12

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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The model-failover chain (#286): which model the next attempt of one run
3
+ * chain dispatches on, and how the choice is worded so a merged branch built
4
+ * on a different model stays attributable months later.
5
+ *
6
+ * Pure and synchronous: the daemon reads the run rows from the store, hands
7
+ * them here, and does what the returned choice says. Nothing in this module
8
+ * reaches a store or a provider — the same split as `failure-class.ts`, kept
9
+ * here (and NOT there) because `failure-class.ts` owns the *verdict* while
10
+ * this owns the *response to a run of provider verdicts*.
11
+ *
12
+ * The trigger is deliberately counted from the store, never from the
13
+ * harness's `modelFallbackMessage`: that message means the harness already
14
+ * downgraded internally and is evidence about a run, not a decision; keying
15
+ * on it would fire the feature when nothing needed switching and stay silent
16
+ * when a provider returns 402 cleanly.
17
+ */
18
+
19
+ import type { FailureClass, RunRecord } from "./types.ts";
20
+
21
+ /**
22
+ * Failure classes that move a chain onto its next model — and ONLY these: a
23
+ * cap kill means the slice was too big, and switching models in response
24
+ * would hide a decomposition problem behind a provider one (#286, #490).
25
+ */
26
+ export const FAILOVER_CLASSES: readonly FailureClass[] = [
27
+ "provider-transient",
28
+ "provider-credit",
29
+ ];
30
+
31
+ /** The default for a project's `modelFallbackThreshold` when it is absent or
32
+ * unusable. Two consecutive provider-class failures, then the next attempt
33
+ * moves to the next model. */
34
+ export const DEFAULT_MODEL_FALLBACK_THRESHOLD = 2;
35
+
36
+ /** True for the classes {@link FAILOVER_CLASSES} names, so a non-provider
37
+ * failure can never push a chain sideways. */
38
+ export function isProviderFailureClass(cls: string | undefined): cls is FailureClass {
39
+ return cls !== undefined && (FAILOVER_CLASSES as readonly string[]).includes(cls);
40
+ }
41
+
42
+ /** Consecutive provider-class failures at the head of one run chain. */
43
+ export interface ProviderFailureFacts {
44
+ /** How many terminal runs at the head of the chain failed as a provider. */
45
+ streak: number;
46
+ /** Those runs' classes, newest first — the set is either one class or a mix
47
+ * of `provider-transient` and `provider-credit`. */
48
+ classes: FailureClass[];
49
+ /**
50
+ * The model the most recent failure dispatched on, when its row recorded
51
+ * one. Undefined when the rows predate the column or the project has no
52
+ * chain — the log then falls back to the configured primary, or to "harness
53
+ * default" for a project with no `workerModel`.
54
+ */
55
+ previousModel: string | undefined;
56
+ }
57
+
58
+ /**
59
+ * Counts the consecutive provider-class failures at the head of a run chain.
60
+ * `runs` is in store order (oldest first, per `Store.runsForIssue`); the walk
61
+ * starts at the newest row and stops at the first row that is not a
62
+ * provider-class failure — a success, a non-provider failure, an unclassified
63
+ * row, a live row — so "sticky per chain, never global" falls out: a fresh
64
+ * issue has no rows and starts on the primary, and one issue's bad luck can
65
+ * never move another issue's chain.
66
+ */
67
+ export function providerFailureFacts(runs: readonly RunRecord[]): ProviderFailureFacts {
68
+ const facts: ProviderFailureFacts = { streak: 0, classes: [], previousModel: undefined };
69
+ for (let i = runs.length - 1; i >= 0; i--) {
70
+ const run = runs[i]!;
71
+ if (!isProviderFailureClass(run.failureClass)) break;
72
+ facts.streak += 1;
73
+ facts.classes.push(run.failureClass);
74
+ if (facts.previousModel === undefined && run.model !== undefined) {
75
+ facts.previousModel = run.model;
76
+ }
77
+ }
78
+ return facts;
79
+ }
80
+
81
+ /** The model the next attempt should dispatch on, and whether that is a
82
+ * failover at all. */
83
+ export interface ModelChoice {
84
+ /** Model to dispatch on; `undefined` leaves the harness default, exactly as
85
+ * an unconfigured project dispatches today. */
86
+ model: string | undefined;
87
+ /** True when the choice came from the fallback chain — the failover has
88
+ * fired and the tick report must say so. */
89
+ fallback: boolean;
90
+ }
91
+
92
+ /**
93
+ * Where one run chain's next attempt dispatches, given the chain configuration
94
+ * and the store-derived failure streak.
95
+ *
96
+ * With no chain (empty or absent `modelFallbacks`) the primary model is
97
+ * returned unchanged and `fallback` stays false — today's dispatch byte for
98
+ * byte. Once the streak reaches the threshold the chain advances one slot per
99
+ * extra failure, clamped to the last model: the chain is exhausted there, and
100
+ * the existing escalation path (the provider-transient strike cap) settles it,
101
+ * naming every model tried.
102
+ */
103
+ export function resolveDispatchModel(args: {
104
+ workerModel: string | undefined;
105
+ modelFallbacks: readonly string[] | undefined;
106
+ threshold: number;
107
+ streak: number;
108
+ }): ModelChoice {
109
+ const { workerModel, modelFallbacks, threshold, streak } = args;
110
+ if (modelFallbacks === undefined || modelFallbacks.length === 0) {
111
+ return { model: workerModel, fallback: false };
112
+ }
113
+ if (streak < threshold) return { model: workerModel, fallback: false };
114
+ const index = Math.min(streak - threshold, modelFallbacks.length - 1);
115
+ return { model: modelFallbacks[index] ?? workerModel, fallback: true };
116
+ }
117
+
118
+ /**
119
+ * The clause the tick report annexes to a failover dispatch:
120
+ * `on <fallback> after N provider-transient failures on <previous>`, the shape
121
+ * the issue asked for (`#123 attempt 3 on <fallback> after 2
122
+ * provider-transient failures on <primary>`). `undefined` when no failover
123
+ * fired, so the ordinary dispatch log line is untouched.
124
+ */
125
+ export function fallbackClause(
126
+ choice: ModelChoice,
127
+ facts: ProviderFailureFacts,
128
+ workerModel: string | undefined,
129
+ ): string | undefined {
130
+ if (!choice.fallback) return undefined;
131
+ const classWord = new Set(facts.classes).size === 1 ? facts.classes[0]! : "provider";
132
+ const countWord = facts.streak === 1 ? "failure" : "failures";
133
+ const previous = facts.previousModel ?? workerModel ?? "harness default";
134
+ return `on ${choice.model} after ${facts.streak} ${classWord} ${countWord} on ${previous}`;
135
+ }
136
+
137
+ /** One recorded model dispatch in a chain: which model, on which attempt. */
138
+ export interface ModelTried {
139
+ model: string;
140
+ attempt: number;
141
+ }
142
+
143
+ /** Every recorded model of a chain, in chain order — what the exhaustion
144
+ * escalation names when it says "every model tried". Rows without a recorded
145
+ * model (no chain configured, or written before the column existed) are
146
+ * absent: an unnamed primary is the harness default, which the escalation
147
+ * wording covers without pretending to know its name. */
148
+ export function modelsTried(runs: readonly RunRecord[]): ModelTried[] {
149
+ const tried: ModelTried[] = [];
150
+ for (const run of runs) {
151
+ if (run.model === undefined) continue;
152
+ tried.push({ model: run.model, attempt: run.attempt });
153
+ }
154
+ return tried;
155
+ }
156
+
157
+ /**
158
+ * `"acme/a (attempts 1-2), acme/b (attempt 3)"` — consecutive attempts
159
+ * grouped under their model, in chain order.
160
+ */
161
+ export function formatModelsTried(tried: readonly ModelTried[]): string {
162
+ const groups: { model: string; attempts: number[] }[] = [];
163
+ for (const { model, attempt } of tried) {
164
+ const last = groups[groups.length - 1];
165
+ if (last !== undefined && last.model === model) last.attempts.push(attempt);
166
+ else groups.push({ model, attempts: [attempt] });
167
+ }
168
+ return groups
169
+ .map(({ model, attempts }) => {
170
+ const span =
171
+ attempts.length === 1
172
+ ? `attempt ${attempts[0]}`
173
+ : `attempts ${attempts[0]}-${attempts[attempts.length - 1]}`;
174
+ return `${model} (${span})`;
175
+ })
176
+ .join(", ");
177
+ }
package/src/omp.ts CHANGED
@@ -19,14 +19,15 @@ import { tmpdir } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
 
21
21
  import { readOnlySession, worktreeConfinement } from "./confinement.ts";
22
- import { releasePolicyTripwire, type ReleaseBlockContext } from "./release-policy.ts";
22
+ import { releasePolicyTripwire, type GateShape, type ReleaseBlockContext } from "./release-policy.ts";
23
23
  import type {
24
24
  HostToParent,
25
25
  ParentToHost,
26
26
  SessionHostSpec,
27
27
  } from "./session-host.ts";
28
28
  import { decodeFrames, encodeFrame } from "./session-host.ts";
29
- import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
29
+ import type { ResolvedGrants, SessionRole } from "./types.ts";
30
+ import { SESSION_ROLE_ENV } from "./types.ts";
30
31
  import { conductorVerbs } from "./verbs/client.ts";
31
32
 
32
33
  const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
@@ -158,7 +159,7 @@ export async function createLocalSession(opts: {
158
159
  /** Install the release/deploy tool-call gate with these per-shape grants. */
159
160
  releaseGrants?: ResolvedGrants;
160
161
  /** Durable audit callback invoked only when that gate rejects a call. */
161
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
162
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
162
163
  /**
163
164
  * The conductor verb socket this session's mutation tools call (#126).
164
165
  *
@@ -414,7 +415,7 @@ export interface CreateSessionOptions {
414
415
  resume?: boolean;
415
416
  role: SessionRole;
416
417
  releaseGrants?: ResolvedGrants;
417
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
418
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
418
419
 
419
420
  /**
420
421
  * Where the control socket is bound. The daemon puts it beside the run's own
@@ -559,6 +560,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
559
560
  const argv = [process.execPath, opts.hostModule ?? SESSION_HOST, JSON.stringify(spec)];
560
561
  const child = Bun.spawn(argv, {
561
562
  cwd: opts.cwd,
563
+ // Stamp the session's own role on the child, so a process the agent runs —
564
+ // `omp-conductor report` from its sandbox — can tell a worker session from
565
+ // the operator's shell. Direct CLI runs outside a spawned session inherit
566
+ // nothing and stay the orchestrator surface.
567
+ env: opts.role === undefined ? undefined : { ...process.env, [SESSION_ROLE_ENV]: opts.role },
562
568
  stdin: "ignore",
563
569
  // The harness writes progress to stdout; both streams are the daemon's log,
564
570
  // never the protocol. The protocol has its own socket precisely so a chatty
@@ -617,6 +623,67 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
617
623
  return stderrTail.trim();
618
624
  };
619
625
 
626
+ /**
627
+ * The child's own start failure, if it got one out over the socket before it
628
+ * died — the socket counterpart of the pipe drain.
629
+ *
630
+ * A child that cannot start can connect and write `start-error` in the same
631
+ * event-loop turn its exit is dispatched in: the connect already completed
632
+ * in the kernel, the accept is still queued, and closing the listener in
633
+ * that window drops the frame with the reason. So every exit route waits
634
+ * this out BEFORE {@link cleanup} closes the server, then prefers the frame
635
+ * over a bare exit code. Bounded like the pipes: a dead child that never
636
+ * connected has nothing on the wire, and 2s only ever delays an
637
+ * already-failed startup.
638
+ *
639
+ * Memoized: the pre-connect catch and the exit handler both arrive here, and
640
+ * both must read the SAME socket — two independent readers would race on the
641
+ * first `destroy()` and one of them would lose the frame.
642
+ */
643
+ let startErrorReading: Promise<string | undefined> | undefined;
644
+ const startErrorFromSocket = (): Promise<string | undefined> => {
645
+ startErrorReading ??= (async (): Promise<string | undefined> => {
646
+ const socket = await Promise.race([
647
+ attached.catch(() => undefined),
648
+ new Promise<undefined>((resolve) => {
649
+ setTimeout(() => resolve(undefined), DRAIN_GRACE_MS).unref?.();
650
+ }),
651
+ ]);
652
+ if (socket === undefined) return undefined;
653
+ try {
654
+ return await new Promise<string | undefined>((resolve) => {
655
+ let buffer = "";
656
+ let settled = false;
657
+ const finish = (message: string | undefined): void => {
658
+ if (settled) return;
659
+ settled = true;
660
+ clearTimeout(timer);
661
+ resolve(message);
662
+ };
663
+ const timer = setTimeout(() => finish(undefined), DRAIN_GRACE_MS);
664
+ timer.unref?.();
665
+ socket.on("data", (chunk: Buffer) => {
666
+ buffer += chunk.toString("utf8");
667
+ const { frames, rest } = decodeFrames(buffer);
668
+ buffer = rest;
669
+ for (const frame of frames) {
670
+ const message = frame as { t?: unknown; message?: unknown };
671
+ if (message.t === "start-error" && typeof message.message === "string") {
672
+ finish(message.message);
673
+ return;
674
+ }
675
+ }
676
+ });
677
+ socket.once("error", () => finish(undefined));
678
+ socket.once("close", () => finish(undefined));
679
+ });
680
+ } finally {
681
+ socket.destroy();
682
+ }
683
+ })();
684
+ return startErrorReading;
685
+ };
686
+
620
687
  const cleanup = (): void => {
621
688
  server.close();
622
689
  rmSync(socketPath, { force: true });
@@ -649,15 +716,21 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
649
716
 
650
717
  void child.exited.then(async (code) => {
651
718
  onExit();
719
+ // A child that cannot start writes `start-error` over the socket, not the
720
+ // pipes — and its exit can be dispatched before the accept of a connection
721
+ // that already completed in the kernel. Let the socket settle before the
722
+ // listener closes, so the child's own words survive the ordering.
723
+ if (disposing) {
724
+ cleanup();
725
+ return;
726
+ }
727
+ const startError = await startErrorFromSocket();
652
728
  cleanup();
653
- // A child that exits during teardown exited because we asked it to. Only an
654
- // exit while the session is supposed to be live is a failure — and it has
655
- // to reach whoever is awaiting a prompt, or the dispatcher waits forever
656
- // for a turn from a process that is gone.
657
- if (disposing) return;
658
729
  // The tail first, so the message carries the child's own words.
659
730
  await settledTail();
660
- fail(`omp-conductor session child exited ${String(code)} before the session ended`);
731
+ fail(
732
+ startError ?? `omp-conductor session child exited ${String(code)} before the session ended`,
733
+ );
661
734
  });
662
735
 
663
736
  let socket: Socket;
@@ -681,15 +754,20 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
681
754
  ]);
682
755
  } catch (err) {
683
756
  child.kill("SIGKILL");
757
+ // The child's exit can win the race above against the accept of a
758
+ // connection that completed in the kernel — then its `start-error` frame
759
+ // is still queued, and closing the listener now would throw it away.
760
+ // Read the socket first (bounded, like the pipes), and prefer its words
761
+ // over a bare exit code.
762
+ const startError = await startErrorFromSocket();
684
763
  cleanup();
685
764
  // Killed first, so the pipes are already closing, and only then read. This is
686
765
  // the route a child that dies before connecting takes — a missing peer
687
766
  // dependency, say — and reading `stderrTail` synchronously here raced the
688
767
  // drain loops and reported a bare exit code instead of the reason.
689
768
  const tail = await settledTail();
690
- throw new Error(
691
- `${err instanceof Error ? err.message : String(err)}${tail === "" ? "" : `\nchild output:\n${tail}`}`,
692
- );
769
+ const reason = startError ?? (err instanceof Error ? err.message : String(err));
770
+ throw new Error(`${reason}${tail === "" ? "" : `\nchild output:\n${tail}`}`);
693
771
  }
694
772
 
695
773
  let buffer = "";
@@ -83,6 +83,7 @@ import {
83
83
  type FrictionSignal,
84
84
  type HeldNotice,
85
85
  type InterruptCategory,
86
+ type IntakeItem,
86
87
  type MaterialEvent,
87
88
  type ReportScopeChoice,
88
89
  type ReportingPolicy,
@@ -704,8 +705,12 @@ export { TELEGRAM_APPROVAL_TOOL };
704
705
  */
705
706
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
706
707
  `The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
707
- `If you have an amendment to propose, deliver the question with \`omp-conductor message --text "QUESTION: <the question>"\` — it resolves this project's own Telegram chat and topic — and wait for your operator's reply on a later turn. ` +
708
- `A returned telegram_ask answer proves an answer, not Telegram delivery. ` +
708
+ `If you have an amendment to propose, deliver the question with \`omp-conductor message --category decision-needed --text "<the question>"\` — ` +
709
+ `it resolves this project's own Telegram chat and topic, records the question as an open decision row, and applies the ` +
710
+ `same availability policy as the tick: an unanswered yes/no stays pending — re-surfaced in every tick until answered ` +
711
+ `or the seven-day expiry, never recorded as approved. ` +
712
+ `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
713
+ `A returned answer proves an answer, not Telegram delivery. ` +
709
714
  `Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
710
715
 
711
716
  /**
@@ -725,6 +730,32 @@ export const TICK_ASK_RULE =
725
730
  `The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
726
731
  `takes, and an unanswered question must never hold the loop.`;
727
732
 
733
+ /**
734
+ * What a tick says when the bounded ask surface did not actually mount on this
735
+ * session (#520). {@link TICK_ASK_RULE} is appended only to a tick whose live
736
+ * mounted set carries {@link ASK_TOOL}; this is the other half of that
737
+ * agreement, and it has to carry the ask's semantics rather than just its text:
738
+ * the escalation category is declared from the vocabulary that already exists
739
+ * (the `conductor_ask` category is the same one), and the fallback command
740
+ * records the question as an open decision row, so "nobody answered" is a
741
+ * durable pending row re-surfaced in every tick — never "asked once, no reply,
742
+ * dropped". It also names the #524 consequence of a plain send: delivery
743
+ * follows the fleet's reporting policy, so a category the policy defers waits
744
+ * for the scheduled digest, and a *blocking* question must declare a category
745
+ * the fleet interrupts on.
746
+ */
747
+ export const TICK_ASK_UNAVAILABLE_RULE =
748
+ `The ${ASK_TOOL} tool is NOT mounted on this tick, so the bounded ask surface is unavailable on this session. ` +
749
+ `Ask through the CLI fallback instead: run \`omp-conductor message --category <category> --text "<the question>"\` ` +
750
+ `with the escalation category declared from the policy vocabulary — fleet-stopped, tier2 or decision-needed — ` +
751
+ `never smuggled through a "QUESTION:" text prefix. The command records the question as an open decision row before ` +
752
+ `delivering, parked on silence: nobody answering keeps the row open and pending, re-surfaced in every tick until ` +
753
+ `answered or the seven-day expiry, never an approval. Delivery follows the same reporting policy as the tick — a ` +
754
+ `category the fleet's interruptOn list defers waits for the next digest or working-hours catch-up and prints a ` +
755
+ `held-notice id, so a blocking question must declare a category the fleet interrupts on (tier2 or fleet-stopped). ` +
756
+ `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
757
+ `A returned answer proves an answer, not Telegram delivery.`;
758
+
728
759
  /**
729
760
  * The gate's refusal for a raw {@link TELEGRAM_APPROVAL_TOOL} / `write
730
761
  * xd://telegram_ask` call on a locally injected tick (#438). Fixed wording the
@@ -798,6 +829,49 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
798
829
  return lines.join("\n");
799
830
  }
800
831
 
832
+ /** Bounds one pending idea's text in the tick prompt, like the digest ledger. */
833
+ const INTAKE_TEXT_LIMIT = 160;
834
+
835
+ /**
836
+ * Pending intake items, each waiting to become an issue (#300).
837
+ *
838
+ * The one appended block that is a *duty* rather than a read-out: every pending
839
+ * item is listed with its id and text (oldest first, as the store returns
840
+ * them), followed by the standing instruction for grooming one into an issue —
841
+ * file it without the queue label, mark provenance with `intake groomed`, and
842
+ * name the grooming in the digest ledger. Absent when nothing is pending: an
843
+ * empty intake is the common state of a healthy fleet, and a block that said
844
+ * "0 pending" every tick would be permanent noise — the same convention as
845
+ * {@link queueDigestLine}. Project-specific grooming taste (priority scales,
846
+ * template wording) stays in POLICY.md; this block carries only the floor duty.
847
+ */
848
+ export function formatPendingIntake(
849
+ items: readonly IntakeItem[],
850
+ instruction: { tracker: string; queueLabel: string; labelPrefix: string },
851
+ ): string | undefined {
852
+ if (items.length === 0) return undefined;
853
+ const oneLine = (text: string): string => {
854
+ const flat = text.replace(/\s+/g, " ").trim();
855
+ return flat.length <= INTAKE_TEXT_LIMIT ? flat : `${flat.slice(0, INTAKE_TEXT_LIMIT - 1)}…`;
856
+ };
857
+ const lines = [
858
+ `Pending intake — ${items.length} idea(s) captured, waiting to be groomed into issues, oldest first:`,
859
+ ...items.map((item) => `- ${item.id} — ${oneLine(item.text)}`),
860
+ "",
861
+ `Groom each into exactly one issue on ${instruction.tracker}: a title stating the problem; a body ` +
862
+ `carrying the product rationale and the acceptance criteria as a checklist; the routing ` +
863
+ `"${instruction.labelPrefix}<repo>" label and a priority per POLICY.md. File it WITHOUT the ` +
864
+ `"${instruction.queueLabel}" queue label — filing is not promoting, and never add ` +
865
+ `${instruction.queueLabel} to an issue you groomed from intake. Then mark provenance with ` +
866
+ "`omp-conductor intake groomed <id> --issue <url>` — an already-groomed id is a no-op, and an " +
867
+ "idea is filed exactly once — and record the outcome with `omp-conductor event record --category " +
868
+ 'intake --summary "groomed intake <id> → #<issue-number>" --evidence <url>` so the digest names ' +
869
+ "the grooming. An item that is malformed or empty is dismissed with `omp-conductor intake dismiss " +
870
+ "<id>` and noted in the digest rather than filed.",
871
+ ];
872
+ return lines.join("\n");
873
+ }
874
+
801
875
  /**
802
876
  * The scope this tick carries, where the brief actually lives, and — when the
803
877
  * config could not answer — why.
@@ -2258,7 +2332,13 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2258
2332
  // tick — it would wait unbounded — so the tick names the replacement
2259
2333
  // surface up front, custom message included.
2260
2334
  content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
2261
- content = `${content}\n${TICK_ASK_RULE}`;
2335
+ // The ask rule names the surface this session can actually call (#520):
2336
+ // the bounded ask is registered at `session_start`, so the live mounted
2337
+ // set read here — between turns, in the heartbeat timer — is the honest
2338
+ // source for whether the registration took. A tick that mandates a tool
2339
+ // the session does not carry is #114 all over again, so a missing surface
2340
+ // degrades to the CLI path that exists and carries the ask's semantics.
2341
+ content = `${content}\n${pi.getActiveTools().includes(ASK_TOOL) ? TICK_ASK_RULE : TICK_ASK_UNAVAILABLE_RULE}`;
2262
2342
  }
2263
2343
  let frictionStore: Store | undefined;
2264
2344
  let frictionSignals: FrictionSignal[] = [];
@@ -2304,8 +2384,17 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2304
2384
  project.groomBelow ?? DEFAULT_GROOM_BELOW,
2305
2385
  );
2306
2386
  if (queue !== undefined) content = `${content}\n${queue}`;
2387
+ // Pending intake is the same class of standing block as the friction
2388
+ // and decisions read-outs: a store-backed duty the orchestrator must
2389
+ // not derive from memory. The store answers, the prompt instructs.
2390
+ const pendingIntake = formatPendingIntake(frictionStore.pendingIntake(scope.projectName), {
2391
+ tracker: project.tracker.repo,
2392
+ queueLabel: project.queueLabel,
2393
+ labelPrefix: project.routing.labelPrefix,
2394
+ });
2395
+ if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
2307
2396
  } catch {
2308
- // unreadable config: no queue digest this tick
2397
+ // unreadable config: no queue digest or pending-intake block this tick
2309
2398
  }
2310
2399
  } catch (err) {
2311
2400
  frictionStore?.close();
@@ -2749,7 +2838,6 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2749
2838
  */
2750
2839
  const armAskTool = (config: TickConfig, configuredProject: string | undefined): void => {
2751
2840
  if (askToolArmed) return;
2752
- askToolArmed = true;
2753
2841
  pi.registerTool({
2754
2842
  name: ASK_TOOL,
2755
2843
  label: ASK_TOOL,
@@ -2834,6 +2922,14 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2834
2922
  return { content: [{ type: "text", text: result.text }] };
2835
2923
  },
2836
2924
  });
2925
+ // Latched after the registration returns, not before: a throw inside
2926
+ // `registerTool` must not leave the surface permanently unmounted — the
2927
+ // latch would otherwise turn a transient registration failure into a
2928
+ // missing ask surface for the whole session (#520). On a real harness
2929
+ // `session_start` fires once, so this is a retry on the next session
2930
+ // rather than a loop, but it is the difference between a recovery and a
2931
+ // permanent divergence between the prompt and the mounted set.
2932
+ askToolArmed = true;
2837
2933
  };
2838
2934
 
2839
2935
  pi.on("session_start", (_event, ctx) => {
@@ -33,8 +33,8 @@ import { stateDir } from "./config.ts";
33
33
  import { formatEscalation } from "./escalate.ts";
34
34
  import { createSession, disposeSession } from "./omp.ts";
35
35
  import type { AgentSessionLike } from "./omp.ts";
36
- import type { ReleaseBlockContext } from "./release-policy.ts";
37
- import type { Escalation, ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
36
+ import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
37
+ import type { Escalation, ResolvedGrants, SessionRole } from "./types.ts";
38
38
 
39
39
  /**
40
40
  * The session factory {@link startOrchestrator} uses. Named so the test seam
@@ -47,7 +47,7 @@ export type CreateSessionFn = (opts: {
47
47
  resume?: boolean;
48
48
  role: SessionRole;
49
49
  releaseGrants?: ResolvedGrants;
50
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
50
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
51
51
  onSpawn?: (pid: number) => void;
52
52
  socketPath?: string;
53
53
  verbSocketPath?: string;
@@ -85,7 +85,7 @@ export interface OrchestratorOpts {
85
85
  sessionDir?: string;
86
86
  model?: string;
87
87
  releaseGrants?: ResolvedGrants;
88
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
88
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
89
89
  /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
90
90
  onSpawn?: (pid: number) => void;
91
91
  /** Control socket for the session child, beside its own working directory. */
package/src/privileged.ts CHANGED
@@ -90,6 +90,14 @@ export interface RunPrivilegedOptions {
90
90
  title?: string;
91
91
  /** Extra lines shown above the step list — what this batch is for. */
92
92
  preamble?: readonly string[];
93
+ /**
94
+ * Runs after the confirm and the sudo priming, immediately before the first
95
+ * step. A caller that must do something between consent and execution — the
96
+ * pause/drain of a draining restart — does it here, under the same confirm,
97
+ * so the operator authorises the whole batch at once. A throw propagates to
98
+ * the caller with nothing restarted.
99
+ */
100
+ beforeRun?: () => Promise<void>;
93
101
  }
94
102
 
95
103
  /**
@@ -211,6 +219,8 @@ export async function runPrivileged(
211
219
  }
212
220
  }
213
221
 
222
+ await options.beforeRun?.();
223
+
214
224
  const softFailures: { step: PrivilegedStep; stderr: string }[] = [];
215
225
  for (const [index, step] of steps.entries()) {
216
226
  ui.notify(`[${index + 1}/${steps.length}] ${step.title}`, "info");