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