omp-conductor 0.15.11 → 0.15.12
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 +98 -56
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/setup.ts +61 -11
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +359 -7
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +179 -42
- package/src/failure-class.ts +37 -0
- package/src/gitops.ts +157 -0
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +91 -13
- package/src/orchestrator-tick.ts +101 -5
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +212 -12
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +286 -60
- package/src/setup-install.ts +237 -28
- package/src/setup-wizard.ts +67 -19
- package/src/setup.ts +25 -0
- package/src/store.ts +13 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +71 -0
- package/src/upgrade.ts +90 -32
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor.service.example +7 -3
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-failover chain (#286): which model the next attempt of one run
|
|
3
|
+
* chain dispatches on, and how the choice is worded so a merged branch built
|
|
4
|
+
* on a different model stays attributable months later.
|
|
5
|
+
*
|
|
6
|
+
* Pure and synchronous: the daemon reads the run rows from the store, hands
|
|
7
|
+
* them here, and does what the returned choice says. Nothing in this module
|
|
8
|
+
* reaches a store or a provider — the same split as `failure-class.ts`, kept
|
|
9
|
+
* here (and NOT there) because `failure-class.ts` owns the *verdict* while
|
|
10
|
+
* this owns the *response to a run of provider verdicts*.
|
|
11
|
+
*
|
|
12
|
+
* The trigger is deliberately counted from the store, never from the
|
|
13
|
+
* harness's `modelFallbackMessage`: that message means the harness already
|
|
14
|
+
* downgraded internally and is evidence about a run, not a decision; keying
|
|
15
|
+
* on it would fire the feature when nothing needed switching and stay silent
|
|
16
|
+
* when a provider returns 402 cleanly.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { FailureClass, RunRecord } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Failure classes that move a chain onto its next model — and ONLY these: a
|
|
23
|
+
* cap kill means the slice was too big, and switching models in response
|
|
24
|
+
* would hide a decomposition problem behind a provider one (#286, #490).
|
|
25
|
+
*/
|
|
26
|
+
export const FAILOVER_CLASSES: readonly FailureClass[] = [
|
|
27
|
+
"provider-transient",
|
|
28
|
+
"provider-credit",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** The default for a project's `modelFallbackThreshold` when it is absent or
|
|
32
|
+
* unusable. Two consecutive provider-class failures, then the next attempt
|
|
33
|
+
* moves to the next model. */
|
|
34
|
+
export const DEFAULT_MODEL_FALLBACK_THRESHOLD = 2;
|
|
35
|
+
|
|
36
|
+
/** True for the classes {@link FAILOVER_CLASSES} names, so a non-provider
|
|
37
|
+
* failure can never push a chain sideways. */
|
|
38
|
+
export function isProviderFailureClass(cls: string | undefined): cls is FailureClass {
|
|
39
|
+
return cls !== undefined && (FAILOVER_CLASSES as readonly string[]).includes(cls);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Consecutive provider-class failures at the head of one run chain. */
|
|
43
|
+
export interface ProviderFailureFacts {
|
|
44
|
+
/** How many terminal runs at the head of the chain failed as a provider. */
|
|
45
|
+
streak: number;
|
|
46
|
+
/** Those runs' classes, newest first — the set is either one class or a mix
|
|
47
|
+
* of `provider-transient` and `provider-credit`. */
|
|
48
|
+
classes: FailureClass[];
|
|
49
|
+
/**
|
|
50
|
+
* The model the most recent failure dispatched on, when its row recorded
|
|
51
|
+
* one. Undefined when the rows predate the column or the project has no
|
|
52
|
+
* chain — the log then falls back to the configured primary, or to "harness
|
|
53
|
+
* default" for a project with no `workerModel`.
|
|
54
|
+
*/
|
|
55
|
+
previousModel: string | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Counts the consecutive provider-class failures at the head of a run chain.
|
|
60
|
+
* `runs` is in store order (oldest first, per `Store.runsForIssue`); the walk
|
|
61
|
+
* starts at the newest row and stops at the first row that is not a
|
|
62
|
+
* provider-class failure — a success, a non-provider failure, an unclassified
|
|
63
|
+
* row, a live row — so "sticky per chain, never global" falls out: a fresh
|
|
64
|
+
* issue has no rows and starts on the primary, and one issue's bad luck can
|
|
65
|
+
* never move another issue's chain.
|
|
66
|
+
*/
|
|
67
|
+
export function providerFailureFacts(runs: readonly RunRecord[]): ProviderFailureFacts {
|
|
68
|
+
const facts: ProviderFailureFacts = { streak: 0, classes: [], previousModel: undefined };
|
|
69
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
70
|
+
const run = runs[i]!;
|
|
71
|
+
if (!isProviderFailureClass(run.failureClass)) break;
|
|
72
|
+
facts.streak += 1;
|
|
73
|
+
facts.classes.push(run.failureClass);
|
|
74
|
+
if (facts.previousModel === undefined && run.model !== undefined) {
|
|
75
|
+
facts.previousModel = run.model;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return facts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The model the next attempt should dispatch on, and whether that is a
|
|
82
|
+
* failover at all. */
|
|
83
|
+
export interface ModelChoice {
|
|
84
|
+
/** Model to dispatch on; `undefined` leaves the harness default, exactly as
|
|
85
|
+
* an unconfigured project dispatches today. */
|
|
86
|
+
model: string | undefined;
|
|
87
|
+
/** True when the choice came from the fallback chain — the failover has
|
|
88
|
+
* fired and the tick report must say so. */
|
|
89
|
+
fallback: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where one run chain's next attempt dispatches, given the chain configuration
|
|
94
|
+
* and the store-derived failure streak.
|
|
95
|
+
*
|
|
96
|
+
* With no chain (empty or absent `modelFallbacks`) the primary model is
|
|
97
|
+
* returned unchanged and `fallback` stays false — today's dispatch byte for
|
|
98
|
+
* byte. Once the streak reaches the threshold the chain advances one slot per
|
|
99
|
+
* extra failure, clamped to the last model: the chain is exhausted there, and
|
|
100
|
+
* the existing escalation path (the provider-transient strike cap) settles it,
|
|
101
|
+
* naming every model tried.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveDispatchModel(args: {
|
|
104
|
+
workerModel: string | undefined;
|
|
105
|
+
modelFallbacks: readonly string[] | undefined;
|
|
106
|
+
threshold: number;
|
|
107
|
+
streak: number;
|
|
108
|
+
}): ModelChoice {
|
|
109
|
+
const { workerModel, modelFallbacks, threshold, streak } = args;
|
|
110
|
+
if (modelFallbacks === undefined || modelFallbacks.length === 0) {
|
|
111
|
+
return { model: workerModel, fallback: false };
|
|
112
|
+
}
|
|
113
|
+
if (streak < threshold) return { model: workerModel, fallback: false };
|
|
114
|
+
const index = Math.min(streak - threshold, modelFallbacks.length - 1);
|
|
115
|
+
return { model: modelFallbacks[index] ?? workerModel, fallback: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The clause the tick report annexes to a failover dispatch:
|
|
120
|
+
* `on <fallback> after N provider-transient failures on <previous>`, the shape
|
|
121
|
+
* the issue asked for (`#123 attempt 3 on <fallback> after 2
|
|
122
|
+
* provider-transient failures on <primary>`). `undefined` when no failover
|
|
123
|
+
* fired, so the ordinary dispatch log line is untouched.
|
|
124
|
+
*/
|
|
125
|
+
export function fallbackClause(
|
|
126
|
+
choice: ModelChoice,
|
|
127
|
+
facts: ProviderFailureFacts,
|
|
128
|
+
workerModel: string | undefined,
|
|
129
|
+
): string | undefined {
|
|
130
|
+
if (!choice.fallback) return undefined;
|
|
131
|
+
const classWord = new Set(facts.classes).size === 1 ? facts.classes[0]! : "provider";
|
|
132
|
+
const countWord = facts.streak === 1 ? "failure" : "failures";
|
|
133
|
+
const previous = facts.previousModel ?? workerModel ?? "harness default";
|
|
134
|
+
return `on ${choice.model} after ${facts.streak} ${classWord} ${countWord} on ${previous}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One recorded model dispatch in a chain: which model, on which attempt. */
|
|
138
|
+
export interface ModelTried {
|
|
139
|
+
model: string;
|
|
140
|
+
attempt: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every recorded model of a chain, in chain order — what the exhaustion
|
|
144
|
+
* escalation names when it says "every model tried". Rows without a recorded
|
|
145
|
+
* model (no chain configured, or written before the column existed) are
|
|
146
|
+
* absent: an unnamed primary is the harness default, which the escalation
|
|
147
|
+
* wording covers without pretending to know its name. */
|
|
148
|
+
export function modelsTried(runs: readonly RunRecord[]): ModelTried[] {
|
|
149
|
+
const tried: ModelTried[] = [];
|
|
150
|
+
for (const run of runs) {
|
|
151
|
+
if (run.model === undefined) continue;
|
|
152
|
+
tried.push({ model: run.model, attempt: run.attempt });
|
|
153
|
+
}
|
|
154
|
+
return tried;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `"acme/a (attempts 1-2), acme/b (attempt 3)"` — consecutive attempts
|
|
159
|
+
* grouped under their model, in chain order.
|
|
160
|
+
*/
|
|
161
|
+
export function formatModelsTried(tried: readonly ModelTried[]): string {
|
|
162
|
+
const groups: { model: string; attempts: number[] }[] = [];
|
|
163
|
+
for (const { model, attempt } of tried) {
|
|
164
|
+
const last = groups[groups.length - 1];
|
|
165
|
+
if (last !== undefined && last.model === model) last.attempts.push(attempt);
|
|
166
|
+
else groups.push({ model, attempts: [attempt] });
|
|
167
|
+
}
|
|
168
|
+
return groups
|
|
169
|
+
.map(({ model, attempts }) => {
|
|
170
|
+
const span =
|
|
171
|
+
attempts.length === 1
|
|
172
|
+
? `attempt ${attempts[0]}`
|
|
173
|
+
: `attempts ${attempts[0]}-${attempts[attempts.length - 1]}`;
|
|
174
|
+
return `${model} (${span})`;
|
|
175
|
+
})
|
|
176
|
+
.join(", ");
|
|
177
|
+
}
|
package/src/omp.ts
CHANGED
|
@@ -19,14 +19,15 @@ import { tmpdir } from "node:os";
|
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
|
|
21
21
|
import { readOnlySession, worktreeConfinement } from "./confinement.ts";
|
|
22
|
-
import { releasePolicyTripwire, type ReleaseBlockContext } from "./release-policy.ts";
|
|
22
|
+
import { releasePolicyTripwire, type GateShape, type ReleaseBlockContext } from "./release-policy.ts";
|
|
23
23
|
import type {
|
|
24
24
|
HostToParent,
|
|
25
25
|
ParentToHost,
|
|
26
26
|
SessionHostSpec,
|
|
27
27
|
} from "./session-host.ts";
|
|
28
28
|
import { decodeFrames, encodeFrame } from "./session-host.ts";
|
|
29
|
-
import type {
|
|
29
|
+
import type { ResolvedGrants, SessionRole } from "./types.ts";
|
|
30
|
+
import { SESSION_ROLE_ENV } from "./types.ts";
|
|
30
31
|
import { conductorVerbs } from "./verbs/client.ts";
|
|
31
32
|
|
|
32
33
|
const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
|
|
@@ -158,7 +159,7 @@ export async function createLocalSession(opts: {
|
|
|
158
159
|
/** Install the release/deploy tool-call gate with these per-shape grants. */
|
|
159
160
|
releaseGrants?: ResolvedGrants;
|
|
160
161
|
/** Durable audit callback invoked only when that gate rejects a call. */
|
|
161
|
-
onReleaseBlocked?: (shape:
|
|
162
|
+
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
162
163
|
/**
|
|
163
164
|
* The conductor verb socket this session's mutation tools call (#126).
|
|
164
165
|
*
|
|
@@ -414,7 +415,7 @@ export interface CreateSessionOptions {
|
|
|
414
415
|
resume?: boolean;
|
|
415
416
|
role: SessionRole;
|
|
416
417
|
releaseGrants?: ResolvedGrants;
|
|
417
|
-
onReleaseBlocked?: (shape:
|
|
418
|
+
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
418
419
|
|
|
419
420
|
/**
|
|
420
421
|
* Where the control socket is bound. The daemon puts it beside the run's own
|
|
@@ -559,6 +560,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
559
560
|
const argv = [process.execPath, opts.hostModule ?? SESSION_HOST, JSON.stringify(spec)];
|
|
560
561
|
const child = Bun.spawn(argv, {
|
|
561
562
|
cwd: opts.cwd,
|
|
563
|
+
// Stamp the session's own role on the child, so a process the agent runs —
|
|
564
|
+
// `omp-conductor report` from its sandbox — can tell a worker session from
|
|
565
|
+
// the operator's shell. Direct CLI runs outside a spawned session inherit
|
|
566
|
+
// nothing and stay the orchestrator surface.
|
|
567
|
+
env: opts.role === undefined ? undefined : { ...process.env, [SESSION_ROLE_ENV]: opts.role },
|
|
562
568
|
stdin: "ignore",
|
|
563
569
|
// The harness writes progress to stdout; both streams are the daemon's log,
|
|
564
570
|
// never the protocol. The protocol has its own socket precisely so a chatty
|
|
@@ -617,6 +623,67 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
617
623
|
return stderrTail.trim();
|
|
618
624
|
};
|
|
619
625
|
|
|
626
|
+
/**
|
|
627
|
+
* The child's own start failure, if it got one out over the socket before it
|
|
628
|
+
* died — the socket counterpart of the pipe drain.
|
|
629
|
+
*
|
|
630
|
+
* A child that cannot start can connect and write `start-error` in the same
|
|
631
|
+
* event-loop turn its exit is dispatched in: the connect already completed
|
|
632
|
+
* in the kernel, the accept is still queued, and closing the listener in
|
|
633
|
+
* that window drops the frame with the reason. So every exit route waits
|
|
634
|
+
* this out BEFORE {@link cleanup} closes the server, then prefers the frame
|
|
635
|
+
* over a bare exit code. Bounded like the pipes: a dead child that never
|
|
636
|
+
* connected has nothing on the wire, and 2s only ever delays an
|
|
637
|
+
* already-failed startup.
|
|
638
|
+
*
|
|
639
|
+
* Memoized: the pre-connect catch and the exit handler both arrive here, and
|
|
640
|
+
* both must read the SAME socket — two independent readers would race on the
|
|
641
|
+
* first `destroy()` and one of them would lose the frame.
|
|
642
|
+
*/
|
|
643
|
+
let startErrorReading: Promise<string | undefined> | undefined;
|
|
644
|
+
const startErrorFromSocket = (): Promise<string | undefined> => {
|
|
645
|
+
startErrorReading ??= (async (): Promise<string | undefined> => {
|
|
646
|
+
const socket = await Promise.race([
|
|
647
|
+
attached.catch(() => undefined),
|
|
648
|
+
new Promise<undefined>((resolve) => {
|
|
649
|
+
setTimeout(() => resolve(undefined), DRAIN_GRACE_MS).unref?.();
|
|
650
|
+
}),
|
|
651
|
+
]);
|
|
652
|
+
if (socket === undefined) return undefined;
|
|
653
|
+
try {
|
|
654
|
+
return await new Promise<string | undefined>((resolve) => {
|
|
655
|
+
let buffer = "";
|
|
656
|
+
let settled = false;
|
|
657
|
+
const finish = (message: string | undefined): void => {
|
|
658
|
+
if (settled) return;
|
|
659
|
+
settled = true;
|
|
660
|
+
clearTimeout(timer);
|
|
661
|
+
resolve(message);
|
|
662
|
+
};
|
|
663
|
+
const timer = setTimeout(() => finish(undefined), DRAIN_GRACE_MS);
|
|
664
|
+
timer.unref?.();
|
|
665
|
+
socket.on("data", (chunk: Buffer) => {
|
|
666
|
+
buffer += chunk.toString("utf8");
|
|
667
|
+
const { frames, rest } = decodeFrames(buffer);
|
|
668
|
+
buffer = rest;
|
|
669
|
+
for (const frame of frames) {
|
|
670
|
+
const message = frame as { t?: unknown; message?: unknown };
|
|
671
|
+
if (message.t === "start-error" && typeof message.message === "string") {
|
|
672
|
+
finish(message.message);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
socket.once("error", () => finish(undefined));
|
|
678
|
+
socket.once("close", () => finish(undefined));
|
|
679
|
+
});
|
|
680
|
+
} finally {
|
|
681
|
+
socket.destroy();
|
|
682
|
+
}
|
|
683
|
+
})();
|
|
684
|
+
return startErrorReading;
|
|
685
|
+
};
|
|
686
|
+
|
|
620
687
|
const cleanup = (): void => {
|
|
621
688
|
server.close();
|
|
622
689
|
rmSync(socketPath, { force: true });
|
|
@@ -649,15 +716,21 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
649
716
|
|
|
650
717
|
void child.exited.then(async (code) => {
|
|
651
718
|
onExit();
|
|
719
|
+
// A child that cannot start writes `start-error` over the socket, not the
|
|
720
|
+
// pipes — and its exit can be dispatched before the accept of a connection
|
|
721
|
+
// that already completed in the kernel. Let the socket settle before the
|
|
722
|
+
// listener closes, so the child's own words survive the ordering.
|
|
723
|
+
if (disposing) {
|
|
724
|
+
cleanup();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const startError = await startErrorFromSocket();
|
|
652
728
|
cleanup();
|
|
653
|
-
// A child that exits during teardown exited because we asked it to. Only an
|
|
654
|
-
// exit while the session is supposed to be live is a failure — and it has
|
|
655
|
-
// to reach whoever is awaiting a prompt, or the dispatcher waits forever
|
|
656
|
-
// for a turn from a process that is gone.
|
|
657
|
-
if (disposing) return;
|
|
658
729
|
// The tail first, so the message carries the child's own words.
|
|
659
730
|
await settledTail();
|
|
660
|
-
fail(
|
|
731
|
+
fail(
|
|
732
|
+
startError ?? `omp-conductor session child exited ${String(code)} before the session ended`,
|
|
733
|
+
);
|
|
661
734
|
});
|
|
662
735
|
|
|
663
736
|
let socket: Socket;
|
|
@@ -681,15 +754,20 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
681
754
|
]);
|
|
682
755
|
} catch (err) {
|
|
683
756
|
child.kill("SIGKILL");
|
|
757
|
+
// The child's exit can win the race above against the accept of a
|
|
758
|
+
// connection that completed in the kernel — then its `start-error` frame
|
|
759
|
+
// is still queued, and closing the listener now would throw it away.
|
|
760
|
+
// Read the socket first (bounded, like the pipes), and prefer its words
|
|
761
|
+
// over a bare exit code.
|
|
762
|
+
const startError = await startErrorFromSocket();
|
|
684
763
|
cleanup();
|
|
685
764
|
// Killed first, so the pipes are already closing, and only then read. This is
|
|
686
765
|
// the route a child that dies before connecting takes — a missing peer
|
|
687
766
|
// dependency, say — and reading `stderrTail` synchronously here raced the
|
|
688
767
|
// drain loops and reported a bare exit code instead of the reason.
|
|
689
768
|
const tail = await settledTail();
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
);
|
|
769
|
+
const reason = startError ?? (err instanceof Error ? err.message : String(err));
|
|
770
|
+
throw new Error(`${reason}${tail === "" ? "" : `\nchild output:\n${tail}`}`);
|
|
693
771
|
}
|
|
694
772
|
|
|
695
773
|
let buffer = "";
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
type FrictionSignal,
|
|
84
84
|
type HeldNotice,
|
|
85
85
|
type InterruptCategory,
|
|
86
|
+
type IntakeItem,
|
|
86
87
|
type MaterialEvent,
|
|
87
88
|
type ReportScopeChoice,
|
|
88
89
|
type ReportingPolicy,
|
|
@@ -704,8 +705,12 @@ export { TELEGRAM_APPROVAL_TOOL };
|
|
|
704
705
|
*/
|
|
705
706
|
export const TICK_APPROVAL_UNAVAILABLE_RULE =
|
|
706
707
|
`The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
|
|
707
|
-
`If you have an amendment to propose, deliver the question with \`omp-conductor message --text "
|
|
708
|
-
`
|
|
708
|
+
`If you have an amendment to propose, deliver the question with \`omp-conductor message --category decision-needed --text "<the question>"\` — ` +
|
|
709
|
+
`it resolves this project's own Telegram chat and topic, records the question as an open decision row, and applies the ` +
|
|
710
|
+
`same availability policy as the tick: an unanswered yes/no stays pending — re-surfaced in every tick until answered ` +
|
|
711
|
+
`or the seven-day expiry, never recorded as approved. ` +
|
|
712
|
+
`Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
|
|
713
|
+
`A returned answer proves an answer, not Telegram delivery. ` +
|
|
709
714
|
`Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
|
|
710
715
|
|
|
711
716
|
/**
|
|
@@ -725,6 +730,32 @@ export const TICK_ASK_RULE =
|
|
|
725
730
|
`The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
|
|
726
731
|
`takes, and an unanswered question must never hold the loop.`;
|
|
727
732
|
|
|
733
|
+
/**
|
|
734
|
+
* What a tick says when the bounded ask surface did not actually mount on this
|
|
735
|
+
* session (#520). {@link TICK_ASK_RULE} is appended only to a tick whose live
|
|
736
|
+
* mounted set carries {@link ASK_TOOL}; this is the other half of that
|
|
737
|
+
* agreement, and it has to carry the ask's semantics rather than just its text:
|
|
738
|
+
* the escalation category is declared from the vocabulary that already exists
|
|
739
|
+
* (the `conductor_ask` category is the same one), and the fallback command
|
|
740
|
+
* records the question as an open decision row, so "nobody answered" is a
|
|
741
|
+
* durable pending row re-surfaced in every tick — never "asked once, no reply,
|
|
742
|
+
* dropped". It also names the #524 consequence of a plain send: delivery
|
|
743
|
+
* follows the fleet's reporting policy, so a category the policy defers waits
|
|
744
|
+
* for the scheduled digest, and a *blocking* question must declare a category
|
|
745
|
+
* the fleet interrupts on.
|
|
746
|
+
*/
|
|
747
|
+
export const TICK_ASK_UNAVAILABLE_RULE =
|
|
748
|
+
`The ${ASK_TOOL} tool is NOT mounted on this tick, so the bounded ask surface is unavailable on this session. ` +
|
|
749
|
+
`Ask through the CLI fallback instead: run \`omp-conductor message --category <category> --text "<the question>"\` ` +
|
|
750
|
+
`with the escalation category declared from the policy vocabulary — fleet-stopped, tier2 or decision-needed — ` +
|
|
751
|
+
`never smuggled through a "QUESTION:" text prefix. The command records the question as an open decision row before ` +
|
|
752
|
+
`delivering, parked on silence: nobody answering keeps the row open and pending, re-surfaced in every tick until ` +
|
|
753
|
+
`answered or the seven-day expiry, never an approval. Delivery follows the same reporting policy as the tick — a ` +
|
|
754
|
+
`category the fleet's interruptOn list defers waits for the next digest or working-hours catch-up and prints a ` +
|
|
755
|
+
`held-notice id, so a blocking question must declare a category the fleet interrupts on (tier2 or fleet-stopped). ` +
|
|
756
|
+
`Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
|
|
757
|
+
`A returned answer proves an answer, not Telegram delivery.`;
|
|
758
|
+
|
|
728
759
|
/**
|
|
729
760
|
* The gate's refusal for a raw {@link TELEGRAM_APPROVAL_TOOL} / `write
|
|
730
761
|
* xd://telegram_ask` call on a locally injected tick (#438). Fixed wording the
|
|
@@ -798,6 +829,49 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
|
|
|
798
829
|
return lines.join("\n");
|
|
799
830
|
}
|
|
800
831
|
|
|
832
|
+
/** Bounds one pending idea's text in the tick prompt, like the digest ledger. */
|
|
833
|
+
const INTAKE_TEXT_LIMIT = 160;
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Pending intake items, each waiting to become an issue (#300).
|
|
837
|
+
*
|
|
838
|
+
* The one appended block that is a *duty* rather than a read-out: every pending
|
|
839
|
+
* item is listed with its id and text (oldest first, as the store returns
|
|
840
|
+
* them), followed by the standing instruction for grooming one into an issue —
|
|
841
|
+
* file it without the queue label, mark provenance with `intake groomed`, and
|
|
842
|
+
* name the grooming in the digest ledger. Absent when nothing is pending: an
|
|
843
|
+
* empty intake is the common state of a healthy fleet, and a block that said
|
|
844
|
+
* "0 pending" every tick would be permanent noise — the same convention as
|
|
845
|
+
* {@link queueDigestLine}. Project-specific grooming taste (priority scales,
|
|
846
|
+
* template wording) stays in POLICY.md; this block carries only the floor duty.
|
|
847
|
+
*/
|
|
848
|
+
export function formatPendingIntake(
|
|
849
|
+
items: readonly IntakeItem[],
|
|
850
|
+
instruction: { tracker: string; queueLabel: string; labelPrefix: string },
|
|
851
|
+
): string | undefined {
|
|
852
|
+
if (items.length === 0) return undefined;
|
|
853
|
+
const oneLine = (text: string): string => {
|
|
854
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
855
|
+
return flat.length <= INTAKE_TEXT_LIMIT ? flat : `${flat.slice(0, INTAKE_TEXT_LIMIT - 1)}…`;
|
|
856
|
+
};
|
|
857
|
+
const lines = [
|
|
858
|
+
`Pending intake — ${items.length} idea(s) captured, waiting to be groomed into issues, oldest first:`,
|
|
859
|
+
...items.map((item) => `- ${item.id} — ${oneLine(item.text)}`),
|
|
860
|
+
"",
|
|
861
|
+
`Groom each into exactly one issue on ${instruction.tracker}: a title stating the problem; a body ` +
|
|
862
|
+
`carrying the product rationale and the acceptance criteria as a checklist; the routing ` +
|
|
863
|
+
`"${instruction.labelPrefix}<repo>" label and a priority per POLICY.md. File it WITHOUT the ` +
|
|
864
|
+
`"${instruction.queueLabel}" queue label — filing is not promoting, and never add ` +
|
|
865
|
+
`${instruction.queueLabel} to an issue you groomed from intake. Then mark provenance with ` +
|
|
866
|
+
"`omp-conductor intake groomed <id> --issue <url>` — an already-groomed id is a no-op, and an " +
|
|
867
|
+
"idea is filed exactly once — and record the outcome with `omp-conductor event record --category " +
|
|
868
|
+
'intake --summary "groomed intake <id> → #<issue-number>" --evidence <url>` so the digest names ' +
|
|
869
|
+
"the grooming. An item that is malformed or empty is dismissed with `omp-conductor intake dismiss " +
|
|
870
|
+
"<id>` and noted in the digest rather than filed.",
|
|
871
|
+
];
|
|
872
|
+
return lines.join("\n");
|
|
873
|
+
}
|
|
874
|
+
|
|
801
875
|
/**
|
|
802
876
|
* The scope this tick carries, where the brief actually lives, and — when the
|
|
803
877
|
* config could not answer — why.
|
|
@@ -2258,7 +2332,13 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
2258
2332
|
// tick — it would wait unbounded — so the tick names the replacement
|
|
2259
2333
|
// surface up front, custom message included.
|
|
2260
2334
|
content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
|
|
2261
|
-
|
|
2335
|
+
// The ask rule names the surface this session can actually call (#520):
|
|
2336
|
+
// the bounded ask is registered at `session_start`, so the live mounted
|
|
2337
|
+
// set read here — between turns, in the heartbeat timer — is the honest
|
|
2338
|
+
// source for whether the registration took. A tick that mandates a tool
|
|
2339
|
+
// the session does not carry is #114 all over again, so a missing surface
|
|
2340
|
+
// degrades to the CLI path that exists and carries the ask's semantics.
|
|
2341
|
+
content = `${content}\n${pi.getActiveTools().includes(ASK_TOOL) ? TICK_ASK_RULE : TICK_ASK_UNAVAILABLE_RULE}`;
|
|
2262
2342
|
}
|
|
2263
2343
|
let frictionStore: Store | undefined;
|
|
2264
2344
|
let frictionSignals: FrictionSignal[] = [];
|
|
@@ -2304,8 +2384,17 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
2304
2384
|
project.groomBelow ?? DEFAULT_GROOM_BELOW,
|
|
2305
2385
|
);
|
|
2306
2386
|
if (queue !== undefined) content = `${content}\n${queue}`;
|
|
2387
|
+
// Pending intake is the same class of standing block as the friction
|
|
2388
|
+
// and decisions read-outs: a store-backed duty the orchestrator must
|
|
2389
|
+
// not derive from memory. The store answers, the prompt instructs.
|
|
2390
|
+
const pendingIntake = formatPendingIntake(frictionStore.pendingIntake(scope.projectName), {
|
|
2391
|
+
tracker: project.tracker.repo,
|
|
2392
|
+
queueLabel: project.queueLabel,
|
|
2393
|
+
labelPrefix: project.routing.labelPrefix,
|
|
2394
|
+
});
|
|
2395
|
+
if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
|
|
2307
2396
|
} catch {
|
|
2308
|
-
// unreadable config: no queue digest this tick
|
|
2397
|
+
// unreadable config: no queue digest or pending-intake block this tick
|
|
2309
2398
|
}
|
|
2310
2399
|
} catch (err) {
|
|
2311
2400
|
frictionStore?.close();
|
|
@@ -2749,7 +2838,6 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
2749
2838
|
*/
|
|
2750
2839
|
const armAskTool = (config: TickConfig, configuredProject: string | undefined): void => {
|
|
2751
2840
|
if (askToolArmed) return;
|
|
2752
|
-
askToolArmed = true;
|
|
2753
2841
|
pi.registerTool({
|
|
2754
2842
|
name: ASK_TOOL,
|
|
2755
2843
|
label: ASK_TOOL,
|
|
@@ -2834,6 +2922,14 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
2834
2922
|
return { content: [{ type: "text", text: result.text }] };
|
|
2835
2923
|
},
|
|
2836
2924
|
});
|
|
2925
|
+
// Latched after the registration returns, not before: a throw inside
|
|
2926
|
+
// `registerTool` must not leave the surface permanently unmounted — the
|
|
2927
|
+
// latch would otherwise turn a transient registration failure into a
|
|
2928
|
+
// missing ask surface for the whole session (#520). On a real harness
|
|
2929
|
+
// `session_start` fires once, so this is a retry on the next session
|
|
2930
|
+
// rather than a loop, but it is the difference between a recovery and a
|
|
2931
|
+
// permanent divergence between the prompt and the mounted set.
|
|
2932
|
+
askToolArmed = true;
|
|
2837
2933
|
};
|
|
2838
2934
|
|
|
2839
2935
|
pi.on("session_start", (_event, ctx) => {
|
package/src/orchestrator.ts
CHANGED
|
@@ -33,8 +33,8 @@ import { stateDir } from "./config.ts";
|
|
|
33
33
|
import { formatEscalation } from "./escalate.ts";
|
|
34
34
|
import { createSession, disposeSession } from "./omp.ts";
|
|
35
35
|
import type { AgentSessionLike } from "./omp.ts";
|
|
36
|
-
import type { ReleaseBlockContext } from "./release-policy.ts";
|
|
37
|
-
import type { Escalation,
|
|
36
|
+
import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
|
|
37
|
+
import type { Escalation, ResolvedGrants, SessionRole } from "./types.ts";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* The session factory {@link startOrchestrator} uses. Named so the test seam
|
|
@@ -47,7 +47,7 @@ export type CreateSessionFn = (opts: {
|
|
|
47
47
|
resume?: boolean;
|
|
48
48
|
role: SessionRole;
|
|
49
49
|
releaseGrants?: ResolvedGrants;
|
|
50
|
-
onReleaseBlocked?: (shape:
|
|
50
|
+
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
51
51
|
onSpawn?: (pid: number) => void;
|
|
52
52
|
socketPath?: string;
|
|
53
53
|
verbSocketPath?: string;
|
|
@@ -85,7 +85,7 @@ export interface OrchestratorOpts {
|
|
|
85
85
|
sessionDir?: string;
|
|
86
86
|
model?: string;
|
|
87
87
|
releaseGrants?: ResolvedGrants;
|
|
88
|
-
onReleaseBlocked?: (shape:
|
|
88
|
+
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
89
89
|
/** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
|
|
90
90
|
onSpawn?: (pid: number) => void;
|
|
91
91
|
/** Control socket for the session child, beside its own working directory. */
|
package/src/privileged.ts
CHANGED
|
@@ -90,6 +90,14 @@ export interface RunPrivilegedOptions {
|
|
|
90
90
|
title?: string;
|
|
91
91
|
/** Extra lines shown above the step list — what this batch is for. */
|
|
92
92
|
preamble?: readonly string[];
|
|
93
|
+
/**
|
|
94
|
+
* Runs after the confirm and the sudo priming, immediately before the first
|
|
95
|
+
* step. A caller that must do something between consent and execution — the
|
|
96
|
+
* pause/drain of a draining restart — does it here, under the same confirm,
|
|
97
|
+
* so the operator authorises the whole batch at once. A throw propagates to
|
|
98
|
+
* the caller with nothing restarted.
|
|
99
|
+
*/
|
|
100
|
+
beforeRun?: () => Promise<void>;
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
/**
|
|
@@ -211,6 +219,8 @@ export async function runPrivileged(
|
|
|
211
219
|
}
|
|
212
220
|
}
|
|
213
221
|
|
|
222
|
+
await options.beforeRun?.();
|
|
223
|
+
|
|
214
224
|
const softFailures: { step: PrivilegedStep; stderr: string }[] = [];
|
|
215
225
|
for (const [index, step] of steps.entries()) {
|
|
216
226
|
ui.notify(`[${index + 1}/${steps.length}] ${step.title}`, "info");
|