omp-conductor 0.16.2 → 0.17.1

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.
Files changed (52) hide show
  1. package/README.md +38 -4
  2. package/REFERENCE.md +18 -12
  3. package/package.json +2 -1
  4. package/schema/config.schema.json +16 -0
  5. package/src/admission.ts +159 -43
  6. package/src/availability.ts +27 -1
  7. package/src/briefs/worker.md +2 -0
  8. package/src/clack-ui.ts +83 -0
  9. package/src/command-manifest.ts +16 -7
  10. package/src/commands/arm.ts +11 -3
  11. package/src/commands/decision.ts +17 -7
  12. package/src/commands/doctor.ts +18 -1
  13. package/src/commands/hold.ts +9 -7
  14. package/src/commands/ledger.ts +25 -4
  15. package/src/commands/message.ts +32 -4
  16. package/src/commands/setup.ts +61 -10
  17. package/src/commands/stats.ts +9 -5
  18. package/src/commands/status.ts +32 -5
  19. package/src/commands/tail.ts +13 -1
  20. package/src/commands/watch.ts +16 -7
  21. package/src/config-schema.ts +20 -0
  22. package/src/config.ts +37 -0
  23. package/src/daemon.ts +1240 -18
  24. package/src/doctor.ts +310 -22
  25. package/src/escalate.ts +560 -57
  26. package/src/failure-class.ts +56 -13
  27. package/src/fleet.ts +224 -47
  28. package/src/gitops.ts +103 -24
  29. package/src/lifecycle.ts +7 -2
  30. package/src/orchestrator-tick.ts +372 -157
  31. package/src/privileged.ts +3 -0
  32. package/src/release-policy.ts +177 -5
  33. package/src/setup-answers.ts +135 -0
  34. package/src/setup-host.ts +193 -4
  35. package/src/setup-install.ts +2 -0
  36. package/src/setup-probe.ts +1 -0
  37. package/src/setup-wizard.ts +1296 -101
  38. package/src/setup.ts +60 -3
  39. package/src/status-render.ts +11 -1
  40. package/src/store.ts +333 -12
  41. package/src/tracker/github.ts +562 -13
  42. package/src/types.ts +204 -2
  43. package/src/ui/progress.ts +32 -0
  44. package/src/ui/style.ts +11 -0
  45. package/src/upgrade.ts +50 -19
  46. package/src/verbs/actions.ts +66 -18
  47. package/src/verbs/protocol.ts +45 -0
  48. package/src/verbs/server.ts +212 -11
  49. package/src/wizard-ui.ts +14 -5
  50. package/src/worker.ts +26 -0
  51. package/systemd/omp-conductor-recover.sh +73 -0
  52. package/systemd/recover-unit-test.sh +61 -0
@@ -103,7 +103,7 @@ import {
103
103
  type AskResult,
104
104
  } from "./ask.ts";
105
105
  import { deliverOperatorMessage } from "./reports.ts";
106
- import type { RunRecord } from "./types.ts";
106
+ import type { ProjectConfig, RecoveryAction, RunRecord } from "./types.ts";
107
107
  import { dbPath, openStore } from "./store.ts";
108
108
  import { digestDue, localDayKey } from "./digest-schedule.ts";
109
109
  import { heldNoticeId } from "./notices.ts";
@@ -305,11 +305,20 @@ interface TickApi {
305
305
  * `@oh-my-pi/pi-coding-agent/src/extensibility/extensions/types.ts:1267-1268`.
306
306
  */
307
307
  getActiveTools(): string[];
308
+ /**
309
+ * Replace the session's active tool set by name. The bounded ask surface
310
+ * ({@link ASK_TOOL}) is registered with `defaultInactive` (#594) and brought
311
+ * into the live set here only when the current tick config resolves a
312
+ * project — so an unusable tool schema is never model-visible on a degraded
313
+ * heartbeat. Signature matches the SDK: `setActiveTools(toolNames)`.
314
+ */
315
+ setActiveTools(toolNames: string[]): Promise<void>;
308
316
  /**
309
317
  * Register one tool on this session — the same shape the verb client uses
310
318
  * (`verbs/client.ts`). The bounded ask surface ({@link ASK_TOOL}) is mounted
311
319
  * with it, so the orchestrator's only waiting ask can never outlive its
312
- * ceiling.
320
+ * ceiling. `defaultInactive` has the SDK meaning: registered but not in the
321
+ * initial active set, activated later via {@link setActiveTools}.
313
322
  */
314
323
  registerTool(tool: {
315
324
  name: string;
@@ -317,6 +326,7 @@ interface TickApi {
317
326
  description: string;
318
327
  parameters: unknown;
319
328
  approval?: "read" | "write" | "exec";
329
+ defaultInactive?: boolean;
320
330
  execute(
321
331
  toolCallId: string,
322
332
  params: Record<string, unknown>,
@@ -471,6 +481,34 @@ export function defaultTickMessage(
471
481
  * `REPORT_SCOPES` fails to compile here instead of resolving to `undefined` at
472
482
  * the point of use.
473
483
  */
484
+ /**
485
+ * Which recovery actions resolve a run without leaving human work behind.
486
+ * Exhaustive over {@link RecoveryAction} so adding an action to the vocabulary
487
+ * forces a decision here: a recovered row carrying `true` is genuinely done and
488
+ * may be summarised as "already handled"; `false` — `escalate`, `hold`, `none` —
489
+ * still needs the orchestrator's Duty 1 attention, so it must never inherit the
490
+ * suppressive "do not re-triage" sentence (#610).
491
+ */
492
+ const AUTONOMOUS_RECOVERY_ACTIONS: Record<RecoveryAction, boolean> = {
493
+ requeue: true,
494
+ continue: true,
495
+ "rerun-checks": true,
496
+ settle: true,
497
+ escalate: false,
498
+ hold: false,
499
+ none: false,
500
+ };
501
+
502
+ /** Bounded name-and-count summary shared by the two recovered-row groups. */
503
+ function recoveredSummary(group: readonly RunRecord[]): string {
504
+ const named = group
505
+ .slice(0, 5)
506
+ .map((r) => `${r.failureClass ?? "unknown"} #${r.issue}`)
507
+ .join(", ");
508
+ const rest = group.length > 5 ? `, +${group.length - 5} more` : "";
509
+ return `${group.length} (${named}${rest})`;
510
+ }
511
+
474
512
  /**
475
513
  * One line naming what the daemon recovered without asking (#132).
476
514
  *
@@ -480,12 +518,23 @@ export function defaultTickMessage(
480
518
  */
481
519
  export function recoveryDigestLine(recovered: readonly RunRecord[]): string | undefined {
482
520
  if (recovered.length === 0) return undefined;
483
- const named = recovered
484
- .slice(0, 5)
485
- .map((r) => `${r.failureClass ?? "unknown"} #${r.issue}`)
486
- .join(", ");
487
- const rest = recovered.length > 5 ? `, +${recovered.length - 5} more` : "";
488
- return `Auto-recovered since last tick: ${recovered.length} (${named}${rest}) — already handled, do not re-triage these.`;
521
+ const handled: RunRecord[] = [];
522
+ const triage: RunRecord[] = [];
523
+ for (const r of recovered) {
524
+ if (r.recoveryAction !== undefined && AUTONOMOUS_RECOVERY_ACTIONS[r.recoveryAction]) {
525
+ handled.push(r);
526
+ } else {
527
+ triage.push(r);
528
+ }
529
+ }
530
+ const lines: string[] = [];
531
+ if (handled.length > 0) {
532
+ lines.push(`Auto-recovered since last tick: ${recoveredSummary(handled)} — already handled, do not re-triage these.`);
533
+ }
534
+ if (triage.length > 0) {
535
+ lines.push(`Recovered but needs Duty 1 triage: ${recoveredSummary(triage)} — inspect these runs.`);
536
+ }
537
+ return lines.join("\n");
489
538
  }
490
539
 
491
540
  /**
@@ -737,32 +786,6 @@ export const TICK_ASK_RULE =
737
786
  `The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
738
787
  `takes, and an unanswered question must never hold the loop.`;
739
788
 
740
- /**
741
- * What a tick says when the bounded ask surface did not actually mount on this
742
- * session (#520). {@link TICK_ASK_RULE} is appended only to a tick whose live
743
- * mounted set carries {@link ASK_TOOL}; this is the other half of that
744
- * agreement, and it has to carry the ask's semantics rather than just its text:
745
- * the escalation category is declared from the vocabulary that already exists
746
- * (the `conductor_ask` category is the same one), and the fallback command
747
- * records the question as an open decision row, so "nobody answered" is a
748
- * durable pending row re-surfaced in every tick — never "asked once, no reply,
749
- * dropped". It also names the #524 consequence of a plain send: delivery
750
- * follows the fleet's reporting policy, so a category the policy defers waits
751
- * for the scheduled digest, and a *blocking* question must declare a category
752
- * the fleet interrupts on.
753
- */
754
- export const TICK_ASK_UNAVAILABLE_RULE =
755
- `The ${ASK_TOOL} tool is NOT mounted on this tick, so the bounded ask surface is unavailable on this session. ` +
756
- `Ask through the CLI fallback instead: run \`omp-conductor message --category <category> --text "<the question>"\` ` +
757
- `with the escalation category declared from the policy vocabulary — fleet-stopped, tier2 or decision-needed — ` +
758
- `never smuggled through a "QUESTION:" text prefix. The command records the question as an open decision row before ` +
759
- `delivering, parked on silence: nobody answering keeps the row open and pending, re-surfaced in every tick until ` +
760
- `answered or the seven-day expiry, never an approval. Delivery follows the same reporting policy as the tick — a ` +
761
- `category the fleet's interruptOn list defers waits for the next digest or working-hours catch-up and prints a ` +
762
- `held-notice id, so a blocking question must declare a category the fleet interrupts on (tier2 or fleet-stopped). ` +
763
- `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
764
- `A returned answer proves an answer, not Telegram delivery.`;
765
-
766
789
  /**
767
790
  * The gate's refusal for a raw {@link TELEGRAM_APPROVAL_TOOL} / `write
768
791
  * xd://telegram_ask` call on a locally injected tick (#438). Fixed wording the
@@ -2181,12 +2204,78 @@ interface TickSession {
2181
2204
  activeLocalTick?: ActiveLocalTick;
2182
2205
  }
2183
2206
 
2207
+ /**
2208
+ * Resolve the project an {@link ASK_TOOL} call routes to, re-reading the live
2209
+ * tick config so a restamp (un-stamped → stamped, or project A → B) binds the
2210
+ * current routing rather than the session-start stamp the ask session closed
2211
+ * over. Only the routing is live; the ceiling and turn budget stay startup-only
2212
+ * (the same contract `budgetSeconds` already has). Mirrors
2213
+ * {@link resolveTickScope}'s decision about when a heartbeat may advertise the
2214
+ * ask surface: a project resolves exactly when a tick would append
2215
+ * {@link TICK_ASK_RULE}, so the model-visible tool and the prompt line never
2216
+ * disagree. Returns the resolved project for delivery, or the failure the tool
2217
+ * reports verbatim.
2218
+ */
2219
+ function resolveAskProject(
2220
+ cwd: string,
2221
+ startup: TickConfig,
2222
+ ): { kind: "ok"; project: ProjectConfig } | { kind: "error"; problem: string } {
2223
+ const live = currentConfig(cwd, startup);
2224
+ try {
2225
+ return { kind: "ok", project: findProject(loadConfig(), live.project) };
2226
+ } catch (err) {
2227
+ return { kind: "error", problem: err instanceof Error ? err.message : String(err) };
2228
+ }
2229
+ }
2230
+
2231
+ /**
2232
+ * Reconcile the bounded ask's model-visible presentation with the current
2233
+ * routing contract. Registered at extension-factory time with
2234
+ * `defaultInactive` (#594), {@link ASK_TOOL} is NOT in the initial active set
2235
+ * — this activates it only once (and for as long as) the live tick config
2236
+ * resolves a project, and deactivates it when resolution degrades (unreadable
2237
+ * config, un-stamped multi-project). That keeps an unusable tool schema out of
2238
+ * the model's view rather than merely omitting the prompt line that names it —
2239
+ * and a restamp (un-stamped → stamped) brings it back on the next resolved
2240
+ * beat. Latched by membership: `setActiveTools` fires only on a state flip,
2241
+ * never per tick.
2242
+ *
2243
+ * Returns whether the requested state now actually holds in the live set
2244
+ * (#683). `true` only after the `setActiveTools` reconciliation is awaited AND
2245
+ * the live membership read confirms it — an already-reconciled beat is
2246
+ * confirmed by the membership read alone. A call that rejected, or one that
2247
+ * resolved without the tool landing in the live set, returns `false`; the
2248
+ * caller must then treat the beat as degraded and never advertise the ask
2249
+ * surface. A flip failure is logged, never fatal — the tool then stays in its
2250
+ * prior state, and executing it still fails closed when it cannot route.
2251
+ */
2252
+ async function ensureAskSurface(pi: TickApi, resolvable: boolean): Promise<boolean> {
2253
+ const current = pi.getActiveTools();
2254
+ const present = current.includes(ASK_TOOL);
2255
+ if (resolvable === present) return true;
2256
+ try {
2257
+ await pi.setActiveTools(
2258
+ resolvable ? [...current, ASK_TOOL] : current.filter((name) => name !== ASK_TOOL),
2259
+ );
2260
+ } catch (err) {
2261
+ pi.logger.error(
2262
+ `[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${ASK_TOOL}: ${
2263
+ err instanceof Error ? err.message : String(err)
2264
+ }`,
2265
+ );
2266
+ return false;
2267
+ }
2268
+ // An awaited call is not yet confirmation: the live membership read after the
2269
+ // reconciliation is what proves the tool is really model-visible.
2270
+ return pi.getActiveTools().includes(ASK_TOOL) === resolvable;
2271
+ }
2272
+
2184
2273
  /**
2185
2274
  * One tick: gather the three facts, ask `tickDecision`, log the reason either
2186
2275
  * way. Skips are deliberately silent in the UI — a disarmed fleet would
2187
2276
  * otherwise emit a notification every interval, forever.
2188
2277
  */
2189
- function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
2278
+ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): Promise<void> {
2190
2279
  const live = currentConfig(ctx.cwd, config);
2191
2280
  const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
2192
2281
  if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
@@ -2339,13 +2428,44 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2339
2428
  // tick — it would wait unbounded — so the tick names the replacement
2340
2429
  // surface up front, custom message included.
2341
2430
  content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
2342
- // The ask rule names the surface this session can actually call (#520):
2343
- // the bounded ask is registered at `session_start`, so the live mounted
2344
- // set read here between turns, in the heartbeat timer is the honest
2345
- // source for whether the registration took. A tick that mandates a tool
2346
- // the session does not carry is #114 all over again, so a missing surface
2347
- // degrades to the CLI path that exists and carries the ask's semantics.
2348
- content = `${content}\n${pi.getActiveTools().includes(ASK_TOOL) ? TICK_ASK_RULE : TICK_ASK_UNAVAILABLE_RULE}`;
2431
+ // The ask rule names the bounded surface this session can call. It is
2432
+ // guarded on a resolved project for the same reason {@link ASK_TOOL} is:
2433
+ // the tool needs the project to file the decision row against and resolve
2434
+ // the delivery target. On a degraded heartbeat (`resolveTickScope` falls
2435
+ // back to DEFAULT_REPORT_SCOPE with no projectName an unreadable config
2436
+ // or a legacy un-stamped multi-project tick) the tool cannot route, so the
2437
+ // prompt must not advertise it. There is no prose to fall back to — the CLI
2438
+ // `omp-conductor message` fallback is deleted (#594), and a registration
2439
+ // failure is a bug to fix, not a surface to name. So the degraded beat
2440
+ // simply names nothing about asking, rather than naming a tool that would
2441
+ // fail closed. The `telegram_ask` refusal stays: it would wait for the
2442
+ // operator for as long as the answer takes (#438).
2443
+ //
2444
+ // The rule is appended only after the ask surface is CONFIRMED
2445
+ // model-visible (#683): a `defaultInactive` registration is not in the
2446
+ // live set until this beat's `setActiveTools` reconciliation lands, so
2447
+ // appending the rule and then firing the reconciliation off in the
2448
+ // background rendered a promise of a tool the next turn might not carry.
2449
+ // The reconciliation is awaited and verified by live membership before the
2450
+ // rule is appended; when it cannot be confirmed, this beat takes the same
2451
+ // degraded branch as an unresolvable project — no rule, tool kept out of
2452
+ // the live set, never the deleted CLI prose fallback — so a tick can never
2453
+ // advertise an ask surface that may still be inactive.
2454
+ const activated = await ensureAskSurface(pi, true);
2455
+ if (activated) {
2456
+ content = `${content}\n${TICK_ASK_RULE}`;
2457
+ } else {
2458
+ // Keeps the tool out of the model-visible set on this beat, matching the
2459
+ // degraded-project branch below: 'not named in the prompt' must not
2460
+ // diverge from 'not callable' (#644).
2461
+ await ensureAskSurface(pi, false);
2462
+ }
2463
+ } else {
2464
+ // Degraded heartbeat: no project to route to, so the bounded tool must not
2465
+ // be model-visible (omitting the prompt line is not enough while the
2466
+ // registration still exposed the schema). A later restamp brings it back on
2467
+ // the next resolved beat.
2468
+ await ensureAskSurface(pi, false);
2349
2469
  }
2350
2470
  let frictionStore: Store | undefined;
2351
2471
  let frictionSignals: FrictionSignal[] = [];
@@ -2559,12 +2679,20 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
2559
2679
  * drift: both must fire the same "do not wait a full interval after a poke"
2560
2680
  * behaviour. Every path still runs {@link tick} → {@link tickDecision}, so a
2561
2681
  * live turn coalesces rather than stacking concurrent ticks.
2682
+ *
2683
+ * The two fire-and-forget sites below (mid-interval poll, arm-time poke)
2684
+ * attach their own `.catch`: `tick` is async, the poll callback returns
2685
+ * nothing for ManagedTimers to route, and the arm-time call's handler
2686
+ * dispatch cannot see a rejection — an escaped one reaches the process-level
2687
+ * unhandledRejection handler and takes the session down. The primary
2688
+ * heartbeat interval keeps handing its promise to the harness, which routes
2689
+ * rejections to the extension error channel on its own.
2562
2690
  */
2563
2691
  function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
2564
2692
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
2565
- const runScheduledTick = (): void => {
2693
+ const runScheduledTick = async (): Promise<void> => {
2566
2694
  try {
2567
- tick(pi, ctx, config, session);
2695
+ await tick(pi, ctx, config, session);
2568
2696
  } finally {
2569
2697
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
2570
2698
  }
@@ -2601,13 +2729,30 @@ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, ses
2601
2729
  pi.logger.info(
2602
2730
  `[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`,
2603
2731
  );
2604
- runScheduledTick();
2732
+ // Fire-and-forget like the arm-time poke, with the same containment: the
2733
+ // callback is not async, so the harness has no promise to attach a catch
2734
+ // to — a rejected tick would escape to the process-level
2735
+ // unhandledRejection handler. Log through the same channel instead, and
2736
+ // the fixed heartbeat stays the fallback for this request line.
2737
+ runScheduledTick().catch((err) => {
2738
+ pi.logger.error(
2739
+ `[omp-conductor] poked tick failed: ${err instanceof Error ? err.message : String(err)}`,
2740
+ );
2741
+ });
2605
2742
  }, pollMs);
2606
2743
  }
2607
2744
  if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
2608
2745
  const reason = readTickRequestReason(ctx.cwd) ?? "wake";
2609
2746
  pi.logger.info(`[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`);
2610
- tick(pi, ctx, config, session);
2747
+ // Fire-and-forget with the same containment as the mid-interval poll: the
2748
+ // call runs inside the session_start handler dispatch, which cannot see an
2749
+ // async rejection — an escaped one reaches the process-level
2750
+ // unhandledRejection handler and takes the session down.
2751
+ void tick(pi, ctx, config, session).catch((err) => {
2752
+ pi.logger.error(
2753
+ `[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
2754
+ );
2755
+ });
2611
2756
  }
2612
2757
 
2613
2758
  /**
@@ -2647,7 +2792,21 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
2647
2792
  });
2648
2793
  }
2649
2794
 
2650
- export default function orchestratorTickExtension(pi: TickApi): void {
2795
+ /**
2796
+ * Factory-time seams for the extension, used by tests. Production runs
2797
+ * `orchestratorTickExtension(pi)` with no options and gets the module's own
2798
+ * real-time defaults; the `ask` seam hands the registered {@link ASK_TOOL} an
2799
+ * injected clock (`wait`/`now`) so the two timeout outcomes can be proven
2800
+ * through the real tool deterministically, without real timers (#683).
2801
+ */
2802
+ export interface OrchestratorTickExtensionOptions {
2803
+ ask?: { wait?: (ms: number) => Promise<void>; now?: () => number };
2804
+ }
2805
+
2806
+ export default function orchestratorTickExtension(
2807
+ pi: TickApi,
2808
+ options: OrchestratorTickExtensionOptions = {},
2809
+ ): void {
2651
2810
  // Scoped to this registration rather than the module, so a second
2652
2811
  // `session_start` can neither install a second heartbeat on the same session
2653
2812
  // nor repeat the ownership decline — which is logged exactly once, because it
@@ -2667,8 +2826,21 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2667
2826
  };
2668
2827
  let releaseGateArmed = false;
2669
2828
  let availabilityGateArmed = false;
2670
- let askToolArmed = false;
2671
2829
  let guardArmed = false;
2830
+ // The bounded ask tool's session state. The tool itself is registered at
2831
+ // extension-factory time, before any session exists, because OMP snapshots
2832
+ // the extension's active tool set before it emits `session_start` — a tool
2833
+ // registered there never reaches the session's model-visible set. So the
2834
+ // registration is unconditional, and the state it needs to act is filled
2835
+ // here — by `session_start`, and only for a session whose ownership is
2836
+ // accepted (the fleet agent) — because a non-owner or unresolved session in
2837
+ // the fleet cwd must not be able to execute the model-visible tool and
2838
+ // create or deliver decisions. `config` is the startup tick config that
2839
+ // owns the ceiling (timeout/budget is startup-only); `cwd` lets the tool
2840
+ // re-read the *live* tick config at execution so routing follows the current
2841
+ // project post-restamp. A call before that — or on a session that never
2842
+ // composed a tick — finds `undefined` and fails closed.
2843
+ let askSession: { cwd: string; config: TickConfig } | undefined;
2672
2844
  // An activation file makes this a fleet directory before Herdr can prove
2673
2845
  // which pane owns it. The gate therefore starts closed and only honours a
2674
2846
  // configured grant after ownership is accepted.
@@ -2835,115 +3007,143 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2835
3007
  );
2836
3008
  };
2837
3009
 
2838
- /**
2839
- * Mount the bounded ask surface (#438). One registration per session, next to
2840
- * the gates it complements: raw {@link TELEGRAM_APPROVAL_TOOL} is refused on
2841
- * autonomous ticks, so the session must own the tool that asks instead. The
2842
- * ceiling comes from the tick config the heartbeat started with the same
2843
- * startup-only contract `budgetSeconds` already has and is capped at the
2844
- * turn budget by the tool itself, so no combination of config and arguments
2845
- * produces an unbounded wait.
2846
- *
2847
- * The tool is inert unless the config resolves this fleet's project: it needs
2848
- * the project to file the decision row against and to resolve the delivery
2849
- * target. An unreadable/ambiguous config makes the tool say so and record
2850
- * nothing, which is the same fail-closed posture the autonomous gate takes.
2851
- */
2852
- const armAskTool = (config: TickConfig, configuredProject: string | undefined): void => {
2853
- if (askToolArmed) return;
2854
- pi.registerTool({
2855
- name: ASK_TOOL,
2856
- label: ASK_TOOL,
2857
- description:
2858
- `Ask your operator one question and wait up to a bounded ceiling for the answer. ` +
2859
- `Records the question durably (decision row + the same delivery path as \`omp-conductor message\`), ` +
2860
- `delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
2861
- `(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
2862
- `shorten or extend within ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}s an ask issued without ` +
2863
- `one still gets the default). When nobody answers, the declared "on-timeout" decides: ` +
2864
- `"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
2865
- `auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
2866
- `pending — re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
2867
- `take the blocked work out of the claimable queue and record its state. A timeout is "nobody ` +
2868
- `answered yet", never a cancellation, an error, or an operator "no".`,
2869
- parameters: askParameterSchema(),
2870
- approval: "write",
2871
- execute: async (_toolCallId, params) => {
2872
- const parsed = parseAskRequest(params);
2873
- if (!parsed.ok) {
2874
- return { content: [{ type: "text", text: parsed.problem }], isError: true };
2875
- }
2876
- let projectConfig;
2877
- try {
2878
- projectConfig = findProject(loadConfig(), configuredProject);
2879
- } catch (err) {
2880
- return {
2881
- content: [
2882
- {
2883
- type: "text",
2884
- text:
2885
- `${ASK_TOOL}: conductor config unreadable (${err instanceof Error ? err.message : String(err)}); ` +
2886
- "nothing was asked or recorded. Repair the config, do not ask through another path.",
2887
- },
2888
- ],
2889
- isError: true,
2890
- };
2891
- }
2892
- const store = openStore(dbPath());
2893
- let result: AskResult;
2894
- try {
2895
- result = await performAsk(parsed.request, {
2896
- store,
2897
- project: projectConfig.name,
2898
- configuredCeilingSeconds: config.askTimeoutSeconds,
2899
- turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
2900
- deliver: async (text, category) => {
2901
- const at = Date.now();
2902
- const noticeId = randomUUID();
2903
- try {
2904
- const delivered = await deliverOperatorMessage(projectConfig, text, {
2905
- store,
2906
- at,
2907
- noticeId,
2908
- category,
2909
- });
2910
- return delivered.kind === "sent"
2911
- ? { kind: "sent", category: delivered.category }
2912
- : { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
2913
- } catch (err) {
2914
- // A failed immediate send must not drop the question: fall back
2915
- // to the durable hold exactly like the gate's own hold path, and
2916
- // let the daemon retry with the digest.
2917
- store.addHeldNotice({
2918
- id: noticeId,
2919
- project: projectConfig.name,
2920
- category,
2921
- summary: text.split("\n", 1)[0]!.slice(0, 240),
2922
- detail: text,
2923
- createdAt: at,
2924
- });
2925
- pi.logger.error(
2926
- `[omp-conductor] ${ASK_TOOL} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
2927
- );
2928
- return { kind: "held", category, noticeId };
2929
- }
3010
+ // Mount the bounded ask surface (#438) at extension-factory time, not in
3011
+ // `session_start`: OMP 17.2.9 snapshots the extension's active tool set
3012
+ // before it emits `session_start`, so a tool registered there mutates the
3013
+ // registry but never reaches this session's model-visible set. Registering
3014
+ // here is the only seam that lands the tool in the real snapshot. Next to
3015
+ // the gates it complements: raw {@link TELEGRAM_APPROVAL_TOOL} is refused on
3016
+ // autonomous ticks, so the session must own the tool that asks instead.
3017
+ //
3018
+ // It registers `defaultInactive`: the OMP snapshot auto-includes extension
3019
+ // tools in the initial active set unless the definition opts out, so without
3020
+ // this flag an unreadable or un-stamped multi-project session would expose
3021
+ // the tool's schema even though it cannot route. Inactive by default, the
3022
+ // surface is brought into the live set by {@link tick} and only for as
3023
+ // long as the current tick config resolves a project.
3024
+ //
3025
+ // The ceiling comes from the tick config the heartbeat started with — the
3026
+ // same startup-only contract `budgetSeconds` already has — and is capped at
3027
+ // the turn budget by the tool itself, so no combination of config and
3028
+ // arguments produces an unbounded wait. That session state is read lazily
3029
+ // from {@link askSession}, filled by `session_start` only once ownership is
3030
+ // accepted for a session that goes on to compose a tick; a call on any
3031
+ // other session fails closed.
3032
+ //
3033
+ // The tool is inert unless the config resolves this fleet's project: it needs
3034
+ // the project to file the decision row against and to resolve the delivery
3035
+ // target. An unreadable/ambiguous config makes the tool say so and record
3036
+ // nothing, which is the same fail-closed posture the autonomous gate takes.
3037
+ pi.registerTool({
3038
+ name: ASK_TOOL,
3039
+ label: ASK_TOOL,
3040
+ defaultInactive: true,
3041
+ description:
3042
+ `Ask your operator one question and wait up to a bounded ceiling for the answer. ` +
3043
+ `Records the question durably (decision row + the same delivery path as \`omp-conductor message\`), ` +
3044
+ `delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
3045
+ `(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
3046
+ `shorten or extend within ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}s an ask issued without ` +
3047
+ `one still gets the default). When nobody answers, the declared "on-timeout" decides: ` +
3048
+ `"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
3049
+ `auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
3050
+ `pending re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
3051
+ `take the blocked work out of the claimable queue and record its state. A timeout is "nobody ` +
3052
+ `answered yet", never a cancellation, an error, or an operator "no".`,
3053
+ parameters: askParameterSchema(),
3054
+ approval: "write",
3055
+ execute: async (_toolCallId, params) => {
3056
+ const parsed = parseAskRequest(params);
3057
+ if (!parsed.ok) {
3058
+ return { content: [{ type: "text", text: parsed.problem }], isError: true };
3059
+ }
3060
+ const session = askSession;
3061
+ if (session === undefined) {
3062
+ // Not a conductor tick session (subagent, or a session that never
3063
+ // composed a tick). Same fail-closed posture as an unresolvable config:
3064
+ // say so, record nothing, and never route the question elsewhere.
3065
+ return {
3066
+ content: [
3067
+ {
3068
+ type: "text",
3069
+ text: `${ASK_TOOL}: not available in this session (no orchestrator tick); nothing was asked or recorded.`,
2930
3070
  },
2931
- });
2932
- } finally {
2933
- store.close();
2934
- }
2935
- return { content: [{ type: "text", text: result.text }] };
2936
- },
2937
- });
2938
- // Latched after the registration returns, not before: a throw inside
2939
- // `registerTool` must not leave the surface permanently unmounted — the
2940
- // latch would otherwise turn a transient registration failure into a
2941
- // missing ask surface for the whole session (#520). On a real harness
2942
- // `session_start` fires once, so this is a retry on the next session
2943
- // rather than a loop, but it is the difference between a recovery and a
2944
- // permanent divergence between the prompt and the mounted set.
2945
- askToolArmed = true;
2946
- };
3071
+ ],
3072
+ isError: true,
3073
+ };
3074
+ }
3075
+ // Routing follows the *live* tick config, not the session-start stamp:
3076
+ // a restamp (un-stamped → stamped, or project A → B) must make the next
3077
+ // tick's toolbox land on the project the turn actually ticks for, and an
3078
+ // abandoned stamp must not keep recording against a project that is no
3079
+ // longer this fleet's. Only the ceiling stays startup-only (from
3080
+ // `config` below) routing is re-read every call.
3081
+ const { cwd, config } = session;
3082
+ const routed = resolveAskProject(cwd, config);
3083
+ if (routed.kind === "error") {
3084
+ return {
3085
+ content: [
3086
+ {
3087
+ type: "text",
3088
+ text:
3089
+ `${ASK_TOOL}: conductor config unreadable (${routed.problem}); ` +
3090
+ "nothing was asked or recorded. Repair the config, do not ask through another path.",
3091
+ },
3092
+ ],
3093
+ isError: true,
3094
+ };
3095
+ }
3096
+ const projectConfig = routed.project;
3097
+ const store = openStore(dbPath());
3098
+ let result: AskResult;
3099
+ try {
3100
+ result = await performAsk(parsed.request, {
3101
+ store,
3102
+ project: projectConfig.name,
3103
+ configuredCeilingSeconds: config.askTimeoutSeconds,
3104
+ turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
3105
+ // Test seam (#683): production omits `wait`/`now` and `performAsk`
3106
+ // falls back to the module's real-time defaults; a test that wants
3107
+ // the timeout outcomes deterministically hands both in.
3108
+ ...(options.ask === undefined ? {} : options.ask),
3109
+ deliver: async (text, category) => {
3110
+ const at = Date.now();
3111
+ const noticeId = randomUUID();
3112
+ try {
3113
+ const delivered = await deliverOperatorMessage(projectConfig, text, {
3114
+ store,
3115
+ at,
3116
+ noticeId,
3117
+ category,
3118
+ });
3119
+ return delivered.kind === "sent"
3120
+ ? { kind: "sent", category: delivered.category }
3121
+ : { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
3122
+ } catch (err) {
3123
+ // A failed immediate send must not drop the question: fall back
3124
+ // to the durable hold exactly like the gate's own hold path, and
3125
+ // let the daemon retry with the digest.
3126
+ store.addHeldNotice({
3127
+ id: noticeId,
3128
+ project: projectConfig.name,
3129
+ category,
3130
+ summary: text.split("\n", 1)[0]!.slice(0, 240),
3131
+ detail: text,
3132
+ createdAt: at,
3133
+ });
3134
+ pi.logger.error(
3135
+ `[omp-conductor] ${ASK_TOOL} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
3136
+ );
3137
+ return { kind: "held", category, noticeId };
3138
+ }
3139
+ },
3140
+ });
3141
+ } finally {
3142
+ store.close();
3143
+ }
3144
+ return { content: [{ type: "text", text: result.text }] };
3145
+ },
3146
+ });
2947
3147
 
2948
3148
  pi.on("session_start", (_event, ctx) => {
2949
3149
  if (decided) return;
@@ -2977,7 +3177,13 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2977
3177
  const configuredProject = result.kind === "ok" ? result.config.project : undefined;
2978
3178
  armReleaseGate(configuredProject);
2979
3179
  armAvailabilityGate(configuredProject);
2980
- if (result.kind === "ok") armAskTool(result.config, configuredProject);
3180
+ // The tool is already registered (extension-factory time); only the session
3181
+ // state it acts on is filled, and only once ownership is accepted below.
3182
+ // `configuredProject` isn't closed over at all — routing is re-read from
3183
+ // the live tick config at execution, so a restamp binds the current
3184
+ // project. Ownership gating is the point: a declined or unresolved session
3185
+ // in the fleet cwd must never be able to execute the model-visible tool
3186
+ // and create or deliver decisions.
2981
3187
 
2982
3188
  if (result.kind === "invalid") {
2983
3189
  const detail = `${result.path}: ${result.problem}`;
@@ -3055,6 +3261,11 @@ export default function orchestratorTickExtension(pi: TickApi): void {
3055
3261
  }
3056
3262
  if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
3057
3263
  releaseAuthorityAccepted = true;
3264
+ // Ownership finally resolved to the fleet agent — only now may the
3265
+ // model-visible ask be executable (defect: non-owner sessions must
3266
+ // not create or deliver decisions). `cwd` is for re-reading the live
3267
+ // tick config at execution, `config` for the startup-only ceiling.
3268
+ askSession = { cwd: ctx.cwd, config };
3058
3269
  armTickHeartbeat(pi, ctx, config, session);
3059
3270
  if (!guardArmed) {
3060
3271
  guardArmed = true;
@@ -3073,6 +3284,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
3073
3284
  }
3074
3285
  if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
3075
3286
  releaseAuthorityAccepted = true;
3287
+ // Only the fleet agent may execute the bounded ask (see the retry path for
3288
+ // why): a declined or unresolved session keeps `askSession` undefined and
3289
+ // the tool fails closed.
3290
+ askSession = { cwd: ctx.cwd, config };
3076
3291
  armTickHeartbeat(pi, ctx, config, session);
3077
3292
  if (!guardArmed) {
3078
3293
  guardArmed = true;