omp-conductor 0.17.0 → 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/REFERENCE.md +1 -1
- package/package.json +1 -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/commands/arm.ts +6 -3
- package/src/commands/message.ts +32 -4
- 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 +169 -32
- package/src/gitops.ts +103 -24
- package/src/lifecycle.ts +7 -2
- package/src/orchestrator-tick.ts +327 -151
- package/src/release-policy.ts +177 -5
- package/src/setup-host.ts +193 -4
- package/src/setup-wizard.ts +1182 -78
- 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/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/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 { RecoveryAction, 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>,
|
|
@@ -776,32 +786,6 @@ export const TICK_ASK_RULE =
|
|
|
776
786
|
`The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
|
|
777
787
|
`takes, and an unanswered question must never hold the loop.`;
|
|
778
788
|
|
|
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
789
|
/**
|
|
806
790
|
* The gate's refusal for a raw {@link TELEGRAM_APPROVAL_TOOL} / `write
|
|
807
791
|
* xd://telegram_ask` call on a locally injected tick (#438). Fixed wording the
|
|
@@ -2220,12 +2204,78 @@ interface TickSession {
|
|
|
2220
2204
|
activeLocalTick?: ActiveLocalTick;
|
|
2221
2205
|
}
|
|
2222
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
|
+
|
|
2223
2273
|
/**
|
|
2224
2274
|
* One tick: gather the three facts, ask `tickDecision`, log the reason either
|
|
2225
2275
|
* way. Skips are deliberately silent in the UI — a disarmed fleet would
|
|
2226
2276
|
* otherwise emit a notification every interval, forever.
|
|
2227
2277
|
*/
|
|
2228
|
-
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> {
|
|
2229
2279
|
const live = currentConfig(ctx.cwd, config);
|
|
2230
2280
|
const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
|
|
2231
2281
|
if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
|
|
@@ -2378,13 +2428,44 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
2378
2428
|
// tick — it would wait unbounded — so the tick names the replacement
|
|
2379
2429
|
// surface up front, custom message included.
|
|
2380
2430
|
content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
|
|
2381
|
-
// The ask rule names the surface this session can
|
|
2382
|
-
//
|
|
2383
|
-
//
|
|
2384
|
-
//
|
|
2385
|
-
//
|
|
2386
|
-
//
|
|
2387
|
-
|
|
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);
|
|
2388
2469
|
}
|
|
2389
2470
|
let frictionStore: Store | undefined;
|
|
2390
2471
|
let frictionSignals: FrictionSignal[] = [];
|
|
@@ -2598,12 +2679,20 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
|
|
|
2598
2679
|
* drift: both must fire the same "do not wait a full interval after a poke"
|
|
2599
2680
|
* behaviour. Every path still runs {@link tick} → {@link tickDecision}, so a
|
|
2600
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.
|
|
2601
2690
|
*/
|
|
2602
2691
|
function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
|
|
2603
2692
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
2604
|
-
const runScheduledTick = (): void => {
|
|
2693
|
+
const runScheduledTick = async (): Promise<void> => {
|
|
2605
2694
|
try {
|
|
2606
|
-
tick(pi, ctx, config, session);
|
|
2695
|
+
await tick(pi, ctx, config, session);
|
|
2607
2696
|
} finally {
|
|
2608
2697
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
2609
2698
|
}
|
|
@@ -2640,13 +2729,30 @@ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, ses
|
|
|
2640
2729
|
pi.logger.info(
|
|
2641
2730
|
`[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`,
|
|
2642
2731
|
);
|
|
2643
|
-
|
|
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
|
+
});
|
|
2644
2742
|
}, pollMs);
|
|
2645
2743
|
}
|
|
2646
2744
|
if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
|
|
2647
2745
|
const reason = readTickRequestReason(ctx.cwd) ?? "wake";
|
|
2648
2746
|
pi.logger.info(`[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`);
|
|
2649
|
-
|
|
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
|
+
});
|
|
2650
2756
|
}
|
|
2651
2757
|
|
|
2652
2758
|
/**
|
|
@@ -2686,7 +2792,21 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
|
|
|
2686
2792
|
});
|
|
2687
2793
|
}
|
|
2688
2794
|
|
|
2689
|
-
|
|
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 {
|
|
2690
2810
|
// Scoped to this registration rather than the module, so a second
|
|
2691
2811
|
// `session_start` can neither install a second heartbeat on the same session
|
|
2692
2812
|
// nor repeat the ownership decline — which is logged exactly once, because it
|
|
@@ -2706,8 +2826,21 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
2706
2826
|
};
|
|
2707
2827
|
let releaseGateArmed = false;
|
|
2708
2828
|
let availabilityGateArmed = false;
|
|
2709
|
-
let askToolArmed = false;
|
|
2710
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;
|
|
2711
2844
|
// An activation file makes this a fleet directory before Herdr can prove
|
|
2712
2845
|
// which pane owns it. The gate therefore starts closed and only honours a
|
|
2713
2846
|
// configured grant after ownership is accepted.
|
|
@@ -2874,115 +3007,143 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
2874
3007
|
);
|
|
2875
3008
|
};
|
|
2876
3009
|
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
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
|
-
}
|
|
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.`,
|
|
2969
3070
|
},
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
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
|
+
});
|
|
2986
3147
|
|
|
2987
3148
|
pi.on("session_start", (_event, ctx) => {
|
|
2988
3149
|
if (decided) return;
|
|
@@ -3016,7 +3177,13 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
3016
3177
|
const configuredProject = result.kind === "ok" ? result.config.project : undefined;
|
|
3017
3178
|
armReleaseGate(configuredProject);
|
|
3018
3179
|
armAvailabilityGate(configuredProject);
|
|
3019
|
-
|
|
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.
|
|
3020
3187
|
|
|
3021
3188
|
if (result.kind === "invalid") {
|
|
3022
3189
|
const detail = `${result.path}: ${result.problem}`;
|
|
@@ -3094,6 +3261,11 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
3094
3261
|
}
|
|
3095
3262
|
if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
|
|
3096
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 };
|
|
3097
3269
|
armTickHeartbeat(pi, ctx, config, session);
|
|
3098
3270
|
if (!guardArmed) {
|
|
3099
3271
|
guardArmed = true;
|
|
@@ -3112,6 +3284,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
3112
3284
|
}
|
|
3113
3285
|
if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
|
|
3114
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 };
|
|
3115
3291
|
armTickHeartbeat(pi, ctx, config, session);
|
|
3116
3292
|
if (!guardArmed) {
|
|
3117
3293
|
guardArmed = true;
|