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.
- package/REFERENCE.md +12 -8
- package/package.json +1 -1
- package/schema/config.schema.json +40 -1
- package/src/admission.ts +263 -44
- package/src/ask.ts +39 -3
- package/src/availability.ts +27 -1
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +38 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/arm.ts +6 -3
- package/src/commands/message.ts +32 -4
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +53 -0
- package/src/config.ts +97 -1
- package/src/daemon.ts +1479 -1483
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +71 -15
- package/src/fleet.ts +189 -34
- package/src/gitops.ts +103 -24
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +43 -7
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +430 -162
- package/src/release-policy.ts +177 -5
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +1257 -78
- package/src/setup.ts +153 -6
- package/src/status-render.ts +36 -4
- package/src/store.ts +411 -17
- package/src/tracker/github.ts +607 -12
- package/src/types.ts +331 -5
- 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 +270 -13
- package/src/worker.ts +239 -6
- package/src/worktree.ts +115 -8
- 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 { 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
|
-
/**
|
|
493
|
-
|
|
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) =>
|
|
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(
|
|
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
|
|
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
|
-
|
|
554
|
-
|
|
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
|
|
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
|
|
2382
|
-
//
|
|
2383
|
-
//
|
|
2384
|
-
//
|
|
2385
|
-
//
|
|
2386
|
-
//
|
|
2387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
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
|
-
|
|
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;
|