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.
- package/README.md +38 -4
- package/REFERENCE.md +18 -12
- package/package.json +2 -1
- package/schema/config.schema.json +16 -0
- package/src/admission.ts +159 -43
- package/src/availability.ts +27 -1
- package/src/briefs/worker.md +2 -0
- package/src/clack-ui.ts +83 -0
- package/src/command-manifest.ts +16 -7
- package/src/commands/arm.ts +11 -3
- package/src/commands/decision.ts +17 -7
- package/src/commands/doctor.ts +18 -1
- package/src/commands/hold.ts +9 -7
- package/src/commands/ledger.ts +25 -4
- package/src/commands/message.ts +32 -4
- package/src/commands/setup.ts +61 -10
- package/src/commands/stats.ts +9 -5
- package/src/commands/status.ts +32 -5
- package/src/commands/tail.ts +13 -1
- package/src/commands/watch.ts +16 -7
- package/src/config-schema.ts +20 -0
- package/src/config.ts +37 -0
- package/src/daemon.ts +1240 -18
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +56 -13
- package/src/fleet.ts +224 -47
- package/src/gitops.ts +103 -24
- package/src/lifecycle.ts +7 -2
- package/src/orchestrator-tick.ts +372 -157
- package/src/privileged.ts +3 -0
- package/src/release-policy.ts +177 -5
- package/src/setup-answers.ts +135 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +2 -0
- package/src/setup-probe.ts +1 -0
- package/src/setup-wizard.ts +1296 -101
- package/src/setup.ts +60 -3
- package/src/status-render.ts +11 -1
- package/src/store.ts +333 -12
- package/src/tracker/github.ts +562 -13
- package/src/types.ts +204 -2
- package/src/ui/progress.ts +32 -0
- package/src/ui/style.ts +11 -0
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +212 -11
- package/src/wizard-ui.ts +14 -5
- package/src/worker.ts +26 -0
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
.
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
2343
|
-
//
|
|
2344
|
-
//
|
|
2345
|
-
//
|
|
2346
|
-
//
|
|
2347
|
-
//
|
|
2348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
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
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
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
|
-
|
|
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;
|