omp-conductor 0.17.0 → 0.18.0

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 (51) hide show
  1. package/REFERENCE.md +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. 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 { RecoveryAction, RunRecord } from "./types.ts";
106
+ import type { FailureClass, 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>,
@@ -489,11 +499,47 @@ const AUTONOMOUS_RECOVERY_ACTIONS: Record<RecoveryAction, boolean> = {
489
499
  none: false,
490
500
  };
491
501
 
492
- /** Bounded name-and-count summary shared by the two recovered-row groups. */
493
- function recoveredSummary(group: readonly RunRecord[]): string {
502
+ /** The failure-class family that names a turns/wall-clock cap kill. Only these
503
+ * rows carry an ordinal: a second cap kill is a decomposition verdict rather
504
+ * than a retry candidate, so the prompt must say which attempt it was without
505
+ * the orchestrator re-querying the runs table (#711). */
506
+ const CAP_KILL_CLASSES: Record<string, true> = {
507
+ "turn-cap-progress": true,
508
+ "turn-cap-spinning": true,
509
+ "wall-clock-cap-progress": true,
510
+ "wall-clock-cap-spinning": true,
511
+ };
512
+
513
+ /** Issue-history lookups answering the two questions about a recovered row:
514
+ * has the issue moved on since that row, and how many attempts has it spent.
515
+ * Lazy callbacks so the tick can hand the store straight through and the
516
+ * digest only pays for the rows it actually reports.
517
+ */
518
+ export interface RecoveryDigestContext {
519
+ /** Every attempt of the issue, oldest first — exactly what
520
+ * `Store.runsForIssue` returns. The recovered row itself is among the
521
+ * answers, so "has the issue moved on" is "is the last element this row". */
522
+ runsForIssue(issue: number): readonly RunRecord[];
523
+ }
524
+
525
+ /** Bounded name-and-count summary shared by the two recovered-row groups. A
526
+ * cap-kill row also names which attempt it was of the issue's total, taken
527
+ * from the issue's own attempt history — never from its position in this
528
+ * digest's list (#711).
529
+ */
530
+ function recoveredSummary(
531
+ group: readonly RunRecord[],
532
+ runsForIssue: (issue: number) => readonly RunRecord[],
533
+ ): string {
494
534
  const named = group
495
535
  .slice(0, 5)
496
- .map((r) => `${r.failureClass ?? "unknown"} #${r.issue}`)
536
+ .map((r) => {
537
+ const label = `${r.failureClass ?? "unknown"} #${r.issue}`;
538
+ if (r.failureClass === undefined || CAP_KILL_CLASSES[r.failureClass] !== true) return label;
539
+ const attempts = runsForIssue(r.issue).length;
540
+ if (attempts === 0) return label;
541
+ return `${label} (attempt ${r.attempt} of ${attempts})`;
542
+ })
497
543
  .join(", ");
498
544
  const rest = group.length > 5 ? `, +${group.length - 5} more` : "";
499
545
  return `${group.length} (${named}${rest})`;
@@ -505,12 +551,42 @@ function recoveredSummary(group: readonly RunRecord[]): string {
505
551
  * The orchestrator used to write this paragraph by re-deriving it from the run
506
552
  * rows every tick. Undefined when nothing was recovered: a line reading "0" is
507
553
  * one nobody reads on the day it says 4.
554
+ *
555
+ * With {@link RecoveryDigestContext} supplied, a recovered row whose issue has
556
+ * since gained a newer run — live, pushed-green, or settled to any terminal
557
+ * state — is history, not news, and is dropped before rendering, so a finished
558
+ * recovery is never re-reported as this tick's own (#711).
508
559
  */
509
- export function recoveryDigestLine(recovered: readonly RunRecord[]): string | undefined {
560
+ export function recoveryDigestLine(
561
+ recovered: readonly RunRecord[],
562
+ ctx?: RecoveryDigestContext,
563
+ ): string | undefined {
510
564
  if (recovered.length === 0) return undefined;
565
+ // Memoized per issue: a window can hold two recovered attempts of one issue,
566
+ // and each row must not re-query the store for the same history.
567
+ const runsByIssue = new Map<number, readonly RunRecord[]>();
568
+ const runsForIssue = (issue: number): readonly RunRecord[] => {
569
+ let runs = runsByIssue.get(issue);
570
+ if (runs === undefined) {
571
+ runs = ctx?.runsForIssue(issue) ?? [];
572
+ runsByIssue.set(issue, runs);
573
+ }
574
+ return runs;
575
+ };
576
+ const fresh =
577
+ ctx === undefined
578
+ ? recovered
579
+ : recovered.filter((r) => {
580
+ const runs = runsForIssue(r.issue);
581
+ // The recovered row is still its issue's newest attempt. Any newer
582
+ // run means the issue moved on after this row recovered.
583
+ const latest = runs[runs.length - 1];
584
+ return latest === undefined || latest.id === r.id;
585
+ });
586
+ if (fresh.length === 0) return undefined;
511
587
  const handled: RunRecord[] = [];
512
588
  const triage: RunRecord[] = [];
513
- for (const r of recovered) {
589
+ for (const r of fresh) {
514
590
  if (r.recoveryAction !== undefined && AUTONOMOUS_RECOVERY_ACTIONS[r.recoveryAction]) {
515
591
  handled.push(r);
516
592
  } else {
@@ -519,10 +595,10 @@ export function recoveryDigestLine(recovered: readonly RunRecord[]): string | un
519
595
  }
520
596
  const lines: string[] = [];
521
597
  if (handled.length > 0) {
522
- lines.push(`Auto-recovered since last tick: ${recoveredSummary(handled)} — already handled, do not re-triage these.`);
598
+ lines.push(`Auto-recovered since last tick: ${recoveredSummary(handled, runsForIssue)} — already handled, do not re-triage these.`);
523
599
  }
524
600
  if (triage.length > 0) {
525
- lines.push(`Recovered but needs Duty 1 triage: ${recoveredSummary(triage)} — inspect these runs.`);
601
+ lines.push(`Recovered but needs Duty 1 triage: ${recoveredSummary(triage, runsForIssue)} — inspect these runs.`);
526
602
  }
527
603
  return lines.join("\n");
528
604
  }
@@ -550,10 +626,26 @@ export function queueDigestLine(
550
626
  const unroutable = summary.holds
551
627
  .filter((h) => h.reason.startsWith("unroutable:"))
552
628
  .reduce((n, h) => n + h.count, 0);
553
- if (unroutable === 0) {
554
- return `Queue: ${summary.ready} ready, 0 spare ${claimed} in flight. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
629
+ // A residual lifecycle label — a settled run whose state label was never
630
+ // cleared is neither spare nor in flight, and `claimed` counts only
631
+ // live/settling ownership, so name it as Duty 1 reconciliation work rather
632
+ // than folding it into the grooming instruction (#611).
633
+ const staleLifecycle = summary.holds
634
+ .filter((h) => h.reason === "stale-lifecycle")
635
+ .reduce((n, h) => n + h.count, 0);
636
+ let line = `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
637
+ if (unroutable > 0) {
638
+ line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
639
+ }
640
+ if (staleLifecycle > 0) {
641
+ line += `, ${staleLifecycle} with a residual lifecycle label (Duty 1: newest run terminal — reconcile the stale agent:in-progress/blocked/failed state)`;
642
+ }
643
+ if (unroutable === 0 && staleLifecycle === 0) {
644
+ line += `. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
645
+ } else {
646
+ line += ".";
555
647
  }
556
- return `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label).`;
648
+ return line;
557
649
  }
558
650
  if (summary.routed >= groomBelow) return undefined;
559
651
  let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
@@ -776,32 +868,6 @@ export const TICK_ASK_RULE =
776
868
  `The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
777
869
  `takes, and an unanswered question must never hold the loop.`;
778
870
 
779
- /**
780
- * What a tick says when the bounded ask surface did not actually mount on this
781
- * session (#520). {@link TICK_ASK_RULE} is appended only to a tick whose live
782
- * mounted set carries {@link ASK_TOOL}; this is the other half of that
783
- * agreement, and it has to carry the ask's semantics rather than just its text:
784
- * the escalation category is declared from the vocabulary that already exists
785
- * (the `conductor_ask` category is the same one), and the fallback command
786
- * records the question as an open decision row, so "nobody answered" is a
787
- * durable pending row re-surfaced in every tick — never "asked once, no reply,
788
- * dropped". It also names the #524 consequence of a plain send: delivery
789
- * follows the fleet's reporting policy, so a category the policy defers waits
790
- * for the scheduled digest, and a *blocking* question must declare a category
791
- * the fleet interrupts on.
792
- */
793
- export const TICK_ASK_UNAVAILABLE_RULE =
794
- `The ${ASK_TOOL} tool is NOT mounted on this tick, so the bounded ask surface is unavailable on this session. ` +
795
- `Ask through the CLI fallback instead: run \`omp-conductor message --category <category> --text "<the question>"\` ` +
796
- `with the escalation category declared from the policy vocabulary — fleet-stopped, tier2 or decision-needed — ` +
797
- `never smuggled through a "QUESTION:" text prefix. The command records the question as an open decision row before ` +
798
- `delivering, parked on silence: nobody answering keeps the row open and pending, re-surfaced in every tick until ` +
799
- `answered or the seven-day expiry, never an approval. Delivery follows the same reporting policy as the tick — a ` +
800
- `category the fleet's interruptOn list defers waits for the next digest or working-hours catch-up and prints a ` +
801
- `held-notice id, so a blocking question must declare a category the fleet interrupts on (tier2 or fleet-stopped). ` +
802
- `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
803
- `A returned answer proves an answer, not Telegram delivery.`;
804
-
805
871
  /**
806
872
  * The gate's refusal for a raw {@link TELEGRAM_APPROVAL_TOOL} / `write
807
873
  * xd://telegram_ask` call on a locally injected tick (#438). Fixed wording the
@@ -2220,12 +2286,78 @@ interface TickSession {
2220
2286
  activeLocalTick?: ActiveLocalTick;
2221
2287
  }
2222
2288
 
2289
+ /**
2290
+ * Resolve the project an {@link ASK_TOOL} call routes to, re-reading the live
2291
+ * tick config so a restamp (un-stamped → stamped, or project A → B) binds the
2292
+ * current routing rather than the session-start stamp the ask session closed
2293
+ * over. Only the routing is live; the ceiling and turn budget stay startup-only
2294
+ * (the same contract `budgetSeconds` already has). Mirrors
2295
+ * {@link resolveTickScope}'s decision about when a heartbeat may advertise the
2296
+ * ask surface: a project resolves exactly when a tick would append
2297
+ * {@link TICK_ASK_RULE}, so the model-visible tool and the prompt line never
2298
+ * disagree. Returns the resolved project for delivery, or the failure the tool
2299
+ * reports verbatim.
2300
+ */
2301
+ function resolveAskProject(
2302
+ cwd: string,
2303
+ startup: TickConfig,
2304
+ ): { kind: "ok"; project: ProjectConfig } | { kind: "error"; problem: string } {
2305
+ const live = currentConfig(cwd, startup);
2306
+ try {
2307
+ return { kind: "ok", project: findProject(loadConfig(), live.project) };
2308
+ } catch (err) {
2309
+ return { kind: "error", problem: err instanceof Error ? err.message : String(err) };
2310
+ }
2311
+ }
2312
+
2313
+ /**
2314
+ * Reconcile the bounded ask's model-visible presentation with the current
2315
+ * routing contract. Registered at extension-factory time with
2316
+ * `defaultInactive` (#594), {@link ASK_TOOL} is NOT in the initial active set
2317
+ * — this activates it only once (and for as long as) the live tick config
2318
+ * resolves a project, and deactivates it when resolution degrades (unreadable
2319
+ * config, un-stamped multi-project). That keeps an unusable tool schema out of
2320
+ * the model's view rather than merely omitting the prompt line that names it —
2321
+ * and a restamp (un-stamped → stamped) brings it back on the next resolved
2322
+ * beat. Latched by membership: `setActiveTools` fires only on a state flip,
2323
+ * never per tick.
2324
+ *
2325
+ * Returns whether the requested state now actually holds in the live set
2326
+ * (#683). `true` only after the `setActiveTools` reconciliation is awaited AND
2327
+ * the live membership read confirms it — an already-reconciled beat is
2328
+ * confirmed by the membership read alone. A call that rejected, or one that
2329
+ * resolved without the tool landing in the live set, returns `false`; the
2330
+ * caller must then treat the beat as degraded and never advertise the ask
2331
+ * surface. A flip failure is logged, never fatal — the tool then stays in its
2332
+ * prior state, and executing it still fails closed when it cannot route.
2333
+ */
2334
+ async function ensureAskSurface(pi: TickApi, resolvable: boolean): Promise<boolean> {
2335
+ const current = pi.getActiveTools();
2336
+ const present = current.includes(ASK_TOOL);
2337
+ if (resolvable === present) return true;
2338
+ try {
2339
+ await pi.setActiveTools(
2340
+ resolvable ? [...current, ASK_TOOL] : current.filter((name) => name !== ASK_TOOL),
2341
+ );
2342
+ } catch (err) {
2343
+ pi.logger.error(
2344
+ `[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${ASK_TOOL}: ${
2345
+ err instanceof Error ? err.message : String(err)
2346
+ }`,
2347
+ );
2348
+ return false;
2349
+ }
2350
+ // An awaited call is not yet confirmation: the live membership read after the
2351
+ // reconciliation is what proves the tool is really model-visible.
2352
+ return pi.getActiveTools().includes(ASK_TOOL) === resolvable;
2353
+ }
2354
+
2223
2355
  /**
2224
2356
  * One tick: gather the three facts, ask `tickDecision`, log the reason either
2225
2357
  * way. Skips are deliberately silent in the UI — a disarmed fleet would
2226
2358
  * otherwise emit a notification every interval, forever.
2227
2359
  */
2228
- function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
2360
+ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): Promise<void> {
2229
2361
  const live = currentConfig(ctx.cwd, config);
2230
2362
  const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
2231
2363
  if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
@@ -2378,13 +2510,44 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2378
2510
  // tick — it would wait unbounded — so the tick names the replacement
2379
2511
  // surface up front, custom message included.
2380
2512
  content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
2381
- // The ask rule names the surface this session can actually call (#520):
2382
- // the bounded ask is registered at `session_start`, so the live mounted
2383
- // set read here between turns, in the heartbeat timer is the honest
2384
- // source for whether the registration took. A tick that mandates a tool
2385
- // the session does not carry is #114 all over again, so a missing surface
2386
- // degrades to the CLI path that exists and carries the ask's semantics.
2387
- content = `${content}\n${pi.getActiveTools().includes(ASK_TOOL) ? TICK_ASK_RULE : TICK_ASK_UNAVAILABLE_RULE}`;
2513
+ // The ask rule names the bounded surface this session can call. It is
2514
+ // guarded on a resolved project for the same reason {@link ASK_TOOL} is:
2515
+ // the tool needs the project to file the decision row against and resolve
2516
+ // the delivery target. On a degraded heartbeat (`resolveTickScope` falls
2517
+ // back to DEFAULT_REPORT_SCOPE with no projectName an unreadable config
2518
+ // or a legacy un-stamped multi-project tick) the tool cannot route, so the
2519
+ // prompt must not advertise it. There is no prose to fall back to — the CLI
2520
+ // `omp-conductor message` fallback is deleted (#594), and a registration
2521
+ // failure is a bug to fix, not a surface to name. So the degraded beat
2522
+ // simply names nothing about asking, rather than naming a tool that would
2523
+ // fail closed. The `telegram_ask` refusal stays: it would wait for the
2524
+ // operator for as long as the answer takes (#438).
2525
+ //
2526
+ // The rule is appended only after the ask surface is CONFIRMED
2527
+ // model-visible (#683): a `defaultInactive` registration is not in the
2528
+ // live set until this beat's `setActiveTools` reconciliation lands, so
2529
+ // appending the rule and then firing the reconciliation off in the
2530
+ // background rendered a promise of a tool the next turn might not carry.
2531
+ // The reconciliation is awaited and verified by live membership before the
2532
+ // rule is appended; when it cannot be confirmed, this beat takes the same
2533
+ // degraded branch as an unresolvable project — no rule, tool kept out of
2534
+ // the live set, never the deleted CLI prose fallback — so a tick can never
2535
+ // advertise an ask surface that may still be inactive.
2536
+ const activated = await ensureAskSurface(pi, true);
2537
+ if (activated) {
2538
+ content = `${content}\n${TICK_ASK_RULE}`;
2539
+ } else {
2540
+ // Keeps the tool out of the model-visible set on this beat, matching the
2541
+ // degraded-project branch below: 'not named in the prompt' must not
2542
+ // diverge from 'not callable' (#644).
2543
+ await ensureAskSurface(pi, false);
2544
+ }
2545
+ } else {
2546
+ // Degraded heartbeat: no project to route to, so the bounded tool must not
2547
+ // be model-visible (omitting the prompt line is not enough while the
2548
+ // registration still exposed the schema). A later restamp brings it back on
2549
+ // the next resolved beat.
2550
+ await ensureAskSurface(pi, false);
2388
2551
  }
2389
2552
  let frictionStore: Store | undefined;
2390
2553
  let frictionSignals: FrictionSignal[] = [];
@@ -2416,8 +2579,18 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2416
2579
  // What the daemon already fixed, so the session stops re-deriving that
2417
2580
  // paragraph on every tick (#132). Two intervals wide rather than one: a
2418
2581
  // tick that ran long must not drop the window it was meant to report.
2582
+ // The issue history answers which of those rows are still news: a
2583
+ // recovery whose issue has since moved on is dropped inside the digest
2584
+ // (#711). The project name and store are captured consts so the lookup
2585
+ // callback keeps the flow narrowing the direct calls in this block rely
2586
+ // on.
2587
+ const projectName = scope.projectName;
2588
+ const store = frictionStore;
2419
2589
  const recovered = recoveryDigestLine(
2420
- frictionStore.recoveredSince(scope.projectName, now - 2 * config.intervalSeconds * 1_000),
2590
+ store.recoveredSince(projectName, now - 2 * config.intervalSeconds * 1_000),
2591
+ {
2592
+ runsForIssue: (issue) => store.runsForIssue(projectName, issue),
2593
+ },
2421
2594
  );
2422
2595
  if (recovered !== undefined) content = `${content}\n${recovered}`;
2423
2596
  // The dispatch pass already persists the numbers that answer "is the
@@ -2598,12 +2771,20 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
2598
2771
  * drift: both must fire the same "do not wait a full interval after a poke"
2599
2772
  * behaviour. Every path still runs {@link tick} → {@link tickDecision}, so a
2600
2773
  * live turn coalesces rather than stacking concurrent ticks.
2774
+ *
2775
+ * The two fire-and-forget sites below (mid-interval poll, arm-time poke)
2776
+ * attach their own `.catch`: `tick` is async, the poll callback returns
2777
+ * nothing for ManagedTimers to route, and the arm-time call's handler
2778
+ * dispatch cannot see a rejection — an escaped one reaches the process-level
2779
+ * unhandledRejection handler and takes the session down. The primary
2780
+ * heartbeat interval keeps handing its promise to the harness, which routes
2781
+ * rejections to the extension error channel on its own.
2601
2782
  */
2602
2783
  function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
2603
2784
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
2604
- const runScheduledTick = (): void => {
2785
+ const runScheduledTick = async (): Promise<void> => {
2605
2786
  try {
2606
- tick(pi, ctx, config, session);
2787
+ await tick(pi, ctx, config, session);
2607
2788
  } finally {
2608
2789
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
2609
2790
  }
@@ -2640,13 +2821,30 @@ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, ses
2640
2821
  pi.logger.info(
2641
2822
  `[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`,
2642
2823
  );
2643
- runScheduledTick();
2824
+ // Fire-and-forget like the arm-time poke, with the same containment: the
2825
+ // callback is not async, so the harness has no promise to attach a catch
2826
+ // to — a rejected tick would escape to the process-level
2827
+ // unhandledRejection handler. Log through the same channel instead, and
2828
+ // the fixed heartbeat stays the fallback for this request line.
2829
+ runScheduledTick().catch((err) => {
2830
+ pi.logger.error(
2831
+ `[omp-conductor] poked tick failed: ${err instanceof Error ? err.message : String(err)}`,
2832
+ );
2833
+ });
2644
2834
  }, pollMs);
2645
2835
  }
2646
2836
  if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
2647
2837
  const reason = readTickRequestReason(ctx.cwd) ?? "wake";
2648
2838
  pi.logger.info(`[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`);
2649
- tick(pi, ctx, config, session);
2839
+ // Fire-and-forget with the same containment as the mid-interval poll: the
2840
+ // call runs inside the session_start handler dispatch, which cannot see an
2841
+ // async rejection — an escaped one reaches the process-level
2842
+ // unhandledRejection handler and takes the session down.
2843
+ void tick(pi, ctx, config, session).catch((err) => {
2844
+ pi.logger.error(
2845
+ `[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
2846
+ );
2847
+ });
2650
2848
  }
2651
2849
 
2652
2850
  /**
@@ -2686,7 +2884,21 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
2686
2884
  });
2687
2885
  }
2688
2886
 
2689
- export default function orchestratorTickExtension(pi: TickApi): void {
2887
+ /**
2888
+ * Factory-time seams for the extension, used by tests. Production runs
2889
+ * `orchestratorTickExtension(pi)` with no options and gets the module's own
2890
+ * real-time defaults; the `ask` seam hands the registered {@link ASK_TOOL} an
2891
+ * injected clock (`wait`/`now`) so the two timeout outcomes can be proven
2892
+ * through the real tool deterministically, without real timers (#683).
2893
+ */
2894
+ export interface OrchestratorTickExtensionOptions {
2895
+ ask?: { wait?: (ms: number) => Promise<void>; now?: () => number };
2896
+ }
2897
+
2898
+ export default function orchestratorTickExtension(
2899
+ pi: TickApi,
2900
+ options: OrchestratorTickExtensionOptions = {},
2901
+ ): void {
2690
2902
  // Scoped to this registration rather than the module, so a second
2691
2903
  // `session_start` can neither install a second heartbeat on the same session
2692
2904
  // nor repeat the ownership decline — which is logged exactly once, because it
@@ -2706,8 +2918,21 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2706
2918
  };
2707
2919
  let releaseGateArmed = false;
2708
2920
  let availabilityGateArmed = false;
2709
- let askToolArmed = false;
2710
2921
  let guardArmed = false;
2922
+ // The bounded ask tool's session state. The tool itself is registered at
2923
+ // extension-factory time, before any session exists, because OMP snapshots
2924
+ // the extension's active tool set before it emits `session_start` — a tool
2925
+ // registered there never reaches the session's model-visible set. So the
2926
+ // registration is unconditional, and the state it needs to act is filled
2927
+ // here — by `session_start`, and only for a session whose ownership is
2928
+ // accepted (the fleet agent) — because a non-owner or unresolved session in
2929
+ // the fleet cwd must not be able to execute the model-visible tool and
2930
+ // create or deliver decisions. `config` is the startup tick config that
2931
+ // owns the ceiling (timeout/budget is startup-only); `cwd` lets the tool
2932
+ // re-read the *live* tick config at execution so routing follows the current
2933
+ // project post-restamp. A call before that — or on a session that never
2934
+ // composed a tick — finds `undefined` and fails closed.
2935
+ let askSession: { cwd: string; config: TickConfig } | undefined;
2711
2936
  // An activation file makes this a fleet directory before Herdr can prove
2712
2937
  // which pane owns it. The gate therefore starts closed and only honours a
2713
2938
  // configured grant after ownership is accepted.
@@ -2874,115 +3099,143 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2874
3099
  );
2875
3100
  };
2876
3101
 
2877
- /**
2878
- * Mount the bounded ask surface (#438). One registration per session, next to
2879
- * the gates it complements: raw {@link TELEGRAM_APPROVAL_TOOL} is refused on
2880
- * autonomous ticks, so the session must own the tool that asks instead. The
2881
- * ceiling comes from the tick config the heartbeat started with the same
2882
- * startup-only contract `budgetSeconds` already has and is capped at the
2883
- * turn budget by the tool itself, so no combination of config and arguments
2884
- * produces an unbounded wait.
2885
- *
2886
- * The tool is inert unless the config resolves this fleet's project: it needs
2887
- * the project to file the decision row against and to resolve the delivery
2888
- * target. An unreadable/ambiguous config makes the tool say so and record
2889
- * nothing, which is the same fail-closed posture the autonomous gate takes.
2890
- */
2891
- const armAskTool = (config: TickConfig, configuredProject: string | undefined): void => {
2892
- if (askToolArmed) return;
2893
- pi.registerTool({
2894
- name: ASK_TOOL,
2895
- label: ASK_TOOL,
2896
- description:
2897
- `Ask your operator one question and wait up to a bounded ceiling for the answer. ` +
2898
- `Records the question durably (decision row + the same delivery path as \`omp-conductor message\`), ` +
2899
- `delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
2900
- `(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
2901
- `shorten or extend within ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}s an ask issued without ` +
2902
- `one still gets the default). When nobody answers, the declared "on-timeout" decides: ` +
2903
- `"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
2904
- `auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
2905
- `pending — re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
2906
- `take the blocked work out of the claimable queue and record its state. A timeout is "nobody ` +
2907
- `answered yet", never a cancellation, an error, or an operator "no".`,
2908
- parameters: askParameterSchema(),
2909
- approval: "write",
2910
- execute: async (_toolCallId, params) => {
2911
- const parsed = parseAskRequest(params);
2912
- if (!parsed.ok) {
2913
- return { content: [{ type: "text", text: parsed.problem }], isError: true };
2914
- }
2915
- let projectConfig;
2916
- try {
2917
- projectConfig = findProject(loadConfig(), configuredProject);
2918
- } catch (err) {
2919
- return {
2920
- content: [
2921
- {
2922
- type: "text",
2923
- text:
2924
- `${ASK_TOOL}: conductor config unreadable (${err instanceof Error ? err.message : String(err)}); ` +
2925
- "nothing was asked or recorded. Repair the config, do not ask through another path.",
2926
- },
2927
- ],
2928
- isError: true,
2929
- };
2930
- }
2931
- const store = openStore(dbPath());
2932
- let result: AskResult;
2933
- try {
2934
- result = await performAsk(parsed.request, {
2935
- store,
2936
- project: projectConfig.name,
2937
- configuredCeilingSeconds: config.askTimeoutSeconds,
2938
- turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
2939
- deliver: async (text, category) => {
2940
- const at = Date.now();
2941
- const noticeId = randomUUID();
2942
- try {
2943
- const delivered = await deliverOperatorMessage(projectConfig, text, {
2944
- store,
2945
- at,
2946
- noticeId,
2947
- category,
2948
- });
2949
- return delivered.kind === "sent"
2950
- ? { kind: "sent", category: delivered.category }
2951
- : { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
2952
- } catch (err) {
2953
- // A failed immediate send must not drop the question: fall back
2954
- // to the durable hold exactly like the gate's own hold path, and
2955
- // let the daemon retry with the digest.
2956
- store.addHeldNotice({
2957
- id: noticeId,
2958
- project: projectConfig.name,
2959
- category,
2960
- summary: text.split("\n", 1)[0]!.slice(0, 240),
2961
- detail: text,
2962
- createdAt: at,
2963
- });
2964
- pi.logger.error(
2965
- `[omp-conductor] ${ASK_TOOL} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
2966
- );
2967
- return { kind: "held", category, noticeId };
2968
- }
3102
+ // Mount the bounded ask surface (#438) at extension-factory time, not in
3103
+ // `session_start`: OMP 17.2.9 snapshots the extension's active tool set
3104
+ // before it emits `session_start`, so a tool registered there mutates the
3105
+ // registry but never reaches this session's model-visible set. Registering
3106
+ // here is the only seam that lands the tool in the real snapshot. Next to
3107
+ // the gates it complements: raw {@link TELEGRAM_APPROVAL_TOOL} is refused on
3108
+ // autonomous ticks, so the session must own the tool that asks instead.
3109
+ //
3110
+ // It registers `defaultInactive`: the OMP snapshot auto-includes extension
3111
+ // tools in the initial active set unless the definition opts out, so without
3112
+ // this flag an unreadable or un-stamped multi-project session would expose
3113
+ // the tool's schema even though it cannot route. Inactive by default, the
3114
+ // surface is brought into the live set by {@link tick} and only for as
3115
+ // long as the current tick config resolves a project.
3116
+ //
3117
+ // The ceiling comes from the tick config the heartbeat started with — the
3118
+ // same startup-only contract `budgetSeconds` already has — and is capped at
3119
+ // the turn budget by the tool itself, so no combination of config and
3120
+ // arguments produces an unbounded wait. That session state is read lazily
3121
+ // from {@link askSession}, filled by `session_start` only once ownership is
3122
+ // accepted for a session that goes on to compose a tick; a call on any
3123
+ // other session fails closed.
3124
+ //
3125
+ // The tool is inert unless the config resolves this fleet's project: it needs
3126
+ // the project to file the decision row against and to resolve the delivery
3127
+ // target. An unreadable/ambiguous config makes the tool say so and record
3128
+ // nothing, which is the same fail-closed posture the autonomous gate takes.
3129
+ pi.registerTool({
3130
+ name: ASK_TOOL,
3131
+ label: ASK_TOOL,
3132
+ defaultInactive: true,
3133
+ description:
3134
+ `Ask your operator one question and wait up to a bounded ceiling for the answer. ` +
3135
+ `Records the question durably (decision row + the same delivery path as \`omp-conductor message\`), ` +
3136
+ `delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
3137
+ `(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
3138
+ `shorten or extend within ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}s an ask issued without ` +
3139
+ `one still gets the default). When nobody answers, the declared "on-timeout" decides: ` +
3140
+ `"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
3141
+ `auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
3142
+ `pending re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
3143
+ `take the blocked work out of the claimable queue and record its state. A timeout is "nobody ` +
3144
+ `answered yet", never a cancellation, an error, or an operator "no".`,
3145
+ parameters: askParameterSchema(),
3146
+ approval: "write",
3147
+ execute: async (_toolCallId, params) => {
3148
+ const parsed = parseAskRequest(params);
3149
+ if (!parsed.ok) {
3150
+ return { content: [{ type: "text", text: parsed.problem }], isError: true };
3151
+ }
3152
+ const session = askSession;
3153
+ if (session === undefined) {
3154
+ // Not a conductor tick session (subagent, or a session that never
3155
+ // composed a tick). Same fail-closed posture as an unresolvable config:
3156
+ // say so, record nothing, and never route the question elsewhere.
3157
+ return {
3158
+ content: [
3159
+ {
3160
+ type: "text",
3161
+ text: `${ASK_TOOL}: not available in this session (no orchestrator tick); nothing was asked or recorded.`,
2969
3162
  },
2970
- });
2971
- } finally {
2972
- store.close();
2973
- }
2974
- return { content: [{ type: "text", text: result.text }] };
2975
- },
2976
- });
2977
- // Latched after the registration returns, not before: a throw inside
2978
- // `registerTool` must not leave the surface permanently unmounted — the
2979
- // latch would otherwise turn a transient registration failure into a
2980
- // missing ask surface for the whole session (#520). On a real harness
2981
- // `session_start` fires once, so this is a retry on the next session
2982
- // rather than a loop, but it is the difference between a recovery and a
2983
- // permanent divergence between the prompt and the mounted set.
2984
- askToolArmed = true;
2985
- };
3163
+ ],
3164
+ isError: true,
3165
+ };
3166
+ }
3167
+ // Routing follows the *live* tick config, not the session-start stamp:
3168
+ // a restamp (un-stamped → stamped, or project A → B) must make the next
3169
+ // tick's toolbox land on the project the turn actually ticks for, and an
3170
+ // abandoned stamp must not keep recording against a project that is no
3171
+ // longer this fleet's. Only the ceiling stays startup-only (from
3172
+ // `config` below) routing is re-read every call.
3173
+ const { cwd, config } = session;
3174
+ const routed = resolveAskProject(cwd, config);
3175
+ if (routed.kind === "error") {
3176
+ return {
3177
+ content: [
3178
+ {
3179
+ type: "text",
3180
+ text:
3181
+ `${ASK_TOOL}: conductor config unreadable (${routed.problem}); ` +
3182
+ "nothing was asked or recorded. Repair the config, do not ask through another path.",
3183
+ },
3184
+ ],
3185
+ isError: true,
3186
+ };
3187
+ }
3188
+ const projectConfig = routed.project;
3189
+ const store = openStore(dbPath());
3190
+ let result: AskResult;
3191
+ try {
3192
+ result = await performAsk(parsed.request, {
3193
+ store,
3194
+ project: projectConfig.name,
3195
+ configuredCeilingSeconds: config.askTimeoutSeconds,
3196
+ turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
3197
+ // Test seam (#683): production omits `wait`/`now` and `performAsk`
3198
+ // falls back to the module's real-time defaults; a test that wants
3199
+ // the timeout outcomes deterministically hands both in.
3200
+ ...(options.ask === undefined ? {} : options.ask),
3201
+ deliver: async (text, category) => {
3202
+ const at = Date.now();
3203
+ const noticeId = randomUUID();
3204
+ try {
3205
+ const delivered = await deliverOperatorMessage(projectConfig, text, {
3206
+ store,
3207
+ at,
3208
+ noticeId,
3209
+ category,
3210
+ });
3211
+ return delivered.kind === "sent"
3212
+ ? { kind: "sent", category: delivered.category }
3213
+ : { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
3214
+ } catch (err) {
3215
+ // A failed immediate send must not drop the question: fall back
3216
+ // to the durable hold exactly like the gate's own hold path, and
3217
+ // let the daemon retry with the digest.
3218
+ store.addHeldNotice({
3219
+ id: noticeId,
3220
+ project: projectConfig.name,
3221
+ category,
3222
+ summary: text.split("\n", 1)[0]!.slice(0, 240),
3223
+ detail: text,
3224
+ createdAt: at,
3225
+ });
3226
+ pi.logger.error(
3227
+ `[omp-conductor] ${ASK_TOOL} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
3228
+ );
3229
+ return { kind: "held", category, noticeId };
3230
+ }
3231
+ },
3232
+ });
3233
+ } finally {
3234
+ store.close();
3235
+ }
3236
+ return { content: [{ type: "text", text: result.text }] };
3237
+ },
3238
+ });
2986
3239
 
2987
3240
  pi.on("session_start", (_event, ctx) => {
2988
3241
  if (decided) return;
@@ -3016,7 +3269,13 @@ export default function orchestratorTickExtension(pi: TickApi): void {
3016
3269
  const configuredProject = result.kind === "ok" ? result.config.project : undefined;
3017
3270
  armReleaseGate(configuredProject);
3018
3271
  armAvailabilityGate(configuredProject);
3019
- if (result.kind === "ok") armAskTool(result.config, configuredProject);
3272
+ // The tool is already registered (extension-factory time); only the session
3273
+ // state it acts on is filled, and only once ownership is accepted below.
3274
+ // `configuredProject` isn't closed over at all — routing is re-read from
3275
+ // the live tick config at execution, so a restamp binds the current
3276
+ // project. Ownership gating is the point: a declined or unresolved session
3277
+ // in the fleet cwd must never be able to execute the model-visible tool
3278
+ // and create or deliver decisions.
3020
3279
 
3021
3280
  if (result.kind === "invalid") {
3022
3281
  const detail = `${result.path}: ${result.problem}`;
@@ -3094,6 +3353,11 @@ export default function orchestratorTickExtension(pi: TickApi): void {
3094
3353
  }
3095
3354
  if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
3096
3355
  releaseAuthorityAccepted = true;
3356
+ // Ownership finally resolved to the fleet agent — only now may the
3357
+ // model-visible ask be executable (defect: non-owner sessions must
3358
+ // not create or deliver decisions). `cwd` is for re-reading the live
3359
+ // tick config at execution, `config` for the startup-only ceiling.
3360
+ askSession = { cwd: ctx.cwd, config };
3097
3361
  armTickHeartbeat(pi, ctx, config, session);
3098
3362
  if (!guardArmed) {
3099
3363
  guardArmed = true;
@@ -3112,6 +3376,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
3112
3376
  }
3113
3377
  if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
3114
3378
  releaseAuthorityAccepted = true;
3379
+ // Only the fleet agent may execute the bounded ask (see the retry path for
3380
+ // why): a declined or unresolved session keeps `askSession` undefined and
3381
+ // the tool fails closed.
3382
+ askSession = { cwd: ctx.cwd, config };
3115
3383
  armTickHeartbeat(pi, ctx, config, session);
3116
3384
  if (!guardArmed) {
3117
3385
  guardArmed = true;