omp-conductor 0.15.11 → 0.15.13

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 +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
@@ -71,6 +71,7 @@ import {
71
71
  releaseDriftDigestLine,
72
72
  releaseRefusal,
73
73
  releaseToolMatch,
74
+ sharedHostGuardDigestLine,
74
75
  type ReleaseDecision,
75
76
  } from "./release-policy.ts";
76
77
  import {
@@ -83,6 +84,7 @@ import {
83
84
  type FrictionSignal,
84
85
  type HeldNotice,
85
86
  type InterruptCategory,
87
+ type IntakeItem,
86
88
  type MaterialEvent,
87
89
  type ReportScopeChoice,
88
90
  type ReportingPolicy,
@@ -704,8 +706,12 @@ export { TELEGRAM_APPROVAL_TOOL };
704
706
  */
705
707
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
706
708
  `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. ` +
709
+ `If you have an amendment to propose, deliver the question with \`omp-conductor message --category decision-needed --text "<the question>"\` — ` +
710
+ `it resolves this project's own Telegram chat and topic, records the question as an open decision row, and applies the ` +
711
+ `same availability policy as the tick: an unanswered yes/no stays pending — re-surfaced in every tick until answered ` +
712
+ `or the seven-day expiry, never recorded as approved. ` +
713
+ `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
714
+ `A returned answer proves an answer, not Telegram delivery. ` +
709
715
  `Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
710
716
 
711
717
  /**
@@ -725,6 +731,32 @@ export const TICK_ASK_RULE =
725
731
  `The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
726
732
  `takes, and an unanswered question must never hold the loop.`;
727
733
 
734
+ /**
735
+ * What a tick says when the bounded ask surface did not actually mount on this
736
+ * session (#520). {@link TICK_ASK_RULE} is appended only to a tick whose live
737
+ * mounted set carries {@link ASK_TOOL}; this is the other half of that
738
+ * agreement, and it has to carry the ask's semantics rather than just its text:
739
+ * the escalation category is declared from the vocabulary that already exists
740
+ * (the `conductor_ask` category is the same one), and the fallback command
741
+ * records the question as an open decision row, so "nobody answered" is a
742
+ * durable pending row re-surfaced in every tick — never "asked once, no reply,
743
+ * dropped". It also names the #524 consequence of a plain send: delivery
744
+ * follows the fleet's reporting policy, so a category the policy defers waits
745
+ * for the scheduled digest, and a *blocking* question must declare a category
746
+ * the fleet interrupts on.
747
+ */
748
+ export const TICK_ASK_UNAVAILABLE_RULE =
749
+ `The ${ASK_TOOL} tool is NOT mounted on this tick, so the bounded ask surface is unavailable on this session. ` +
750
+ `Ask through the CLI fallback instead: run \`omp-conductor message --category <category> --text "<the question>"\` ` +
751
+ `with the escalation category declared from the policy vocabulary — fleet-stopped, tier2 or decision-needed — ` +
752
+ `never smuggled through a "QUESTION:" text prefix. The command records the question as an open decision row before ` +
753
+ `delivering, parked on silence: nobody answering keeps the row open and pending, re-surfaced in every tick until ` +
754
+ `answered or the seven-day expiry, never an approval. Delivery follows the same reporting policy as the tick — a ` +
755
+ `category the fleet's interruptOn list defers waits for the next digest or working-hours catch-up and prints a ` +
756
+ `held-notice id, so a blocking question must declare a category the fleet interrupts on (tier2 or fleet-stopped). ` +
757
+ `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
758
+ `A returned answer proves an answer, not Telegram delivery.`;
759
+
728
760
  /**
729
761
  * The gate's refusal for a raw {@link TELEGRAM_APPROVAL_TOOL} / `write
730
762
  * xd://telegram_ask` call on a locally injected tick (#438). Fixed wording the
@@ -798,6 +830,49 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
798
830
  return lines.join("\n");
799
831
  }
800
832
 
833
+ /** Bounds one pending idea's text in the tick prompt, like the digest ledger. */
834
+ const INTAKE_TEXT_LIMIT = 160;
835
+
836
+ /**
837
+ * Pending intake items, each waiting to become an issue (#300).
838
+ *
839
+ * The one appended block that is a *duty* rather than a read-out: every pending
840
+ * item is listed with its id and text (oldest first, as the store returns
841
+ * them), followed by the standing instruction for grooming one into an issue —
842
+ * file it without the queue label, mark provenance with `intake groomed`, and
843
+ * name the grooming in the digest ledger. Absent when nothing is pending: an
844
+ * empty intake is the common state of a healthy fleet, and a block that said
845
+ * "0 pending" every tick would be permanent noise — the same convention as
846
+ * {@link queueDigestLine}. Project-specific grooming taste (priority scales,
847
+ * template wording) stays in POLICY.md; this block carries only the floor duty.
848
+ */
849
+ export function formatPendingIntake(
850
+ items: readonly IntakeItem[],
851
+ instruction: { tracker: string; queueLabel: string; labelPrefix: string },
852
+ ): string | undefined {
853
+ if (items.length === 0) return undefined;
854
+ const oneLine = (text: string): string => {
855
+ const flat = text.replace(/\s+/g, " ").trim();
856
+ return flat.length <= INTAKE_TEXT_LIMIT ? flat : `${flat.slice(0, INTAKE_TEXT_LIMIT - 1)}…`;
857
+ };
858
+ const lines = [
859
+ `Pending intake — ${items.length} idea(s) captured, waiting to be groomed into issues, oldest first:`,
860
+ ...items.map((item) => `- ${item.id} — ${oneLine(item.text)}`),
861
+ "",
862
+ `Groom each into exactly one issue on ${instruction.tracker}: a title stating the problem; a body ` +
863
+ `carrying the product rationale and the acceptance criteria as a checklist; the routing ` +
864
+ `"${instruction.labelPrefix}<repo>" label and a priority per POLICY.md. File it WITHOUT the ` +
865
+ `"${instruction.queueLabel}" queue label — filing is not promoting, and never add ` +
866
+ `${instruction.queueLabel} to an issue you groomed from intake. Then mark provenance with ` +
867
+ "`omp-conductor intake groomed <id> --issue <url>` — an already-groomed id is a no-op, and an " +
868
+ "idea is filed exactly once — and record the outcome with `omp-conductor event record --category " +
869
+ 'intake --summary "groomed intake <id> → #<issue-number>" --evidence <url>` so the digest names ' +
870
+ "the grooming. An item that is malformed or empty is dismissed with `omp-conductor intake dismiss " +
871
+ "<id>` and noted in the digest rather than filed.",
872
+ ];
873
+ return lines.join("\n");
874
+ }
875
+
801
876
  /**
802
877
  * The scope this tick carries, where the brief actually lives, and — when the
803
878
  * config could not answer — why.
@@ -2258,7 +2333,13 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2258
2333
  // tick — it would wait unbounded — so the tick names the replacement
2259
2334
  // surface up front, custom message included.
2260
2335
  content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
2261
- content = `${content}\n${TICK_ASK_RULE}`;
2336
+ // The ask rule names the surface this session can actually call (#520):
2337
+ // the bounded ask is registered at `session_start`, so the live mounted
2338
+ // set read here — between turns, in the heartbeat timer — is the honest
2339
+ // source for whether the registration took. A tick that mandates a tool
2340
+ // the session does not carry is #114 all over again, so a missing surface
2341
+ // degrades to the CLI path that exists and carries the ask's semantics.
2342
+ content = `${content}\n${pi.getActiveTools().includes(ASK_TOOL) ? TICK_ASK_RULE : TICK_ASK_UNAVAILABLE_RULE}`;
2262
2343
  }
2263
2344
  let frictionStore: Store | undefined;
2264
2345
  let frictionSignals: FrictionSignal[] = [];
@@ -2266,6 +2347,12 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2266
2347
  if (scope.projectName !== undefined) {
2267
2348
  const drift = releaseDriftDigestLine(scope.projectName);
2268
2349
  if (drift !== undefined) content = `${content}\n${drift}`;
2350
+ // The shared-host gate's refusals are reported on their own line, apart
2351
+ // from release drift (#562): a worker being stopped by the interlock is
2352
+ // the guard working, never a release-policy divergence, and it must not
2353
+ // move the drift counter or borrow the release-policy heading.
2354
+ const guard = sharedHostGuardDigestLine(scope.projectName);
2355
+ if (guard !== undefined) content = `${content}\n${guard}`;
2269
2356
  if (existsSync(dbPath())) {
2270
2357
  try {
2271
2358
  frictionStore = openStore(dbPath());
@@ -2304,8 +2391,17 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2304
2391
  project.groomBelow ?? DEFAULT_GROOM_BELOW,
2305
2392
  );
2306
2393
  if (queue !== undefined) content = `${content}\n${queue}`;
2394
+ // Pending intake is the same class of standing block as the friction
2395
+ // and decisions read-outs: a store-backed duty the orchestrator must
2396
+ // not derive from memory. The store answers, the prompt instructs.
2397
+ const pendingIntake = formatPendingIntake(frictionStore.pendingIntake(scope.projectName), {
2398
+ tracker: project.tracker.repo,
2399
+ queueLabel: project.queueLabel,
2400
+ labelPrefix: project.routing.labelPrefix,
2401
+ });
2402
+ if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
2307
2403
  } catch {
2308
- // unreadable config: no queue digest this tick
2404
+ // unreadable config: no queue digest or pending-intake block this tick
2309
2405
  }
2310
2406
  } catch (err) {
2311
2407
  frictionStore?.close();
@@ -2749,7 +2845,6 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2749
2845
  */
2750
2846
  const armAskTool = (config: TickConfig, configuredProject: string | undefined): void => {
2751
2847
  if (askToolArmed) return;
2752
- askToolArmed = true;
2753
2848
  pi.registerTool({
2754
2849
  name: ASK_TOOL,
2755
2850
  label: ASK_TOOL,
@@ -2834,6 +2929,14 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2834
2929
  return { content: [{ type: "text", text: result.text }] };
2835
2930
  },
2836
2931
  });
2932
+ // Latched after the registration returns, not before: a throw inside
2933
+ // `registerTool` must not leave the surface permanently unmounted — the
2934
+ // latch would otherwise turn a transient registration failure into a
2935
+ // missing ask surface for the whole session (#520). On a real harness
2936
+ // `session_start` fires once, so this is a retry on the next session
2937
+ // rather than a loop, but it is the difference between a recovery and a
2938
+ // permanent divergence between the prompt and the mounted set.
2939
+ askToolArmed = true;
2837
2940
  };
2838
2941
 
2839
2942
  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;
@@ -76,6 +76,11 @@ export interface OrchestratorHandle {
76
76
  */
77
77
  deliver(e: Escalation, project: string): Promise<DeliveryReceipt>;
78
78
  busy(): boolean;
79
+ /** Whether the underlying session is still running. Event-driven, never a
80
+ * timer: set false by the proxy's real `session_exit` terminal event, and
81
+ * false once the handle has been disposed. A wedged-but-alive session still
82
+ * reads alive here — that is the stall watchdog's job, not liveness. */
83
+ alive(): boolean;
79
84
  sessionFile(): string | undefined;
80
85
  dispose(): Promise<void>;
81
86
  }
@@ -85,7 +90,7 @@ export interface OrchestratorOpts {
85
90
  sessionDir?: string;
86
91
  model?: string;
87
92
  releaseGrants?: ResolvedGrants;
88
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
93
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
89
94
  /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
90
95
  onSpawn?: (pid: number) => void;
91
96
  /** Control socket for the session child, beside its own working directory. */
@@ -216,6 +221,14 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
216
221
  });
217
222
 
218
223
  let disposed = false;
224
+ // Read by the handle's `alive()`: a session that has ended is gone whether
225
+ // it crashed or was shut down, and from that instant nothing about it can be
226
+ // relied on. Driven by the proxy's `session_exit` terminal event — a real
227
+ // signal, not a timer, and not the `agent_end`/streaming bookkeeping above.
228
+ let terminated = false;
229
+ session.on("session_exit", () => {
230
+ terminated = true;
231
+ });
219
232
  /** Tail of the delivery queue. Always settled-or-settling, never rejected. */
220
233
  let queue: Promise<void> = Promise.resolve();
221
234
  /** Consumed by the first injection; see {@link OrchestratorOpts.brief}. */
@@ -273,6 +286,7 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
273
286
  return accepted;
274
287
  },
275
288
  busy: () => streaming,
289
+ alive: () => !terminated && !disposed,
276
290
  // The path the harness actually opened, read live: the transcript is how a
277
291
  // human audits what the orchestrator decided on their behalf.
278
292
  sessionFile: () => session.sessionFile,
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");