omp-conductor 0.15.13 → 0.16.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 +72 -2
- package/package.json +2 -1
- package/schema/config.schema.json +6 -0
- package/src/admission.ts +745 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +15 -3
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +24 -15
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +24 -0
- package/src/daemon.ts +239 -530
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +73 -0
- package/src/doctor.ts +178 -5
- package/src/escalate.ts +114 -15
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +41 -410
- package/src/gitops.ts +86 -1
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +39 -0
- package/src/orchestrator-tick.ts +7 -1
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-wizard.ts +36 -0
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +53 -0
- package/src/store.ts +352 -11
- package/src/types.ts +187 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +1 -2
- package/src/verbs/server.ts +25 -0
- package/src/worker.ts +162 -10
package/src/model-fallback.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { FailureClass, RunRecord } from "./types.ts";
|
|
|
26
26
|
export const FAILOVER_CLASSES: readonly FailureClass[] = [
|
|
27
27
|
"provider-transient",
|
|
28
28
|
"provider-credit",
|
|
29
|
+
"provider-capacity",
|
|
29
30
|
];
|
|
30
31
|
|
|
31
32
|
/** The default for a project's `modelFallbackThreshold` when it is absent or
|
|
@@ -44,7 +45,7 @@ export interface ProviderFailureFacts {
|
|
|
44
45
|
/** How many terminal runs at the head of the chain failed as a provider. */
|
|
45
46
|
streak: number;
|
|
46
47
|
/** Those runs' classes, newest first — the set is either one class or a mix
|
|
47
|
-
* of `provider-transient` and `provider-
|
|
48
|
+
* of `provider-transient`, `provider-credit` and `provider-capacity`. */
|
|
48
49
|
classes: FailureClass[];
|
|
49
50
|
/**
|
|
50
51
|
* The model the most recent failure dispatched on, when its row recorded
|
|
@@ -97,7 +98,7 @@ export interface ModelChoice {
|
|
|
97
98
|
* returned unchanged and `fallback` stays false — today's dispatch byte for
|
|
98
99
|
* byte. Once the streak reaches the threshold the chain advances one slot per
|
|
99
100
|
* extra failure, clamped to the last model: the chain is exhausted there, and
|
|
100
|
-
* the existing escalation path (the provider-
|
|
101
|
+
* the existing escalation path (the provider-class strike cap) settles it,
|
|
101
102
|
* naming every model tried.
|
|
102
103
|
*/
|
|
103
104
|
export function resolveDispatchModel(args: {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fleet-owned omp settings overlay (#537).
|
|
3
|
+
*
|
|
4
|
+
* Workers inherit omp configuration from the daemon account's global
|
|
5
|
+
* `~/.omp/agent/config.yml` and nothing else. This module is the channel for
|
|
6
|
+
* saying "this project's workers run with *these* omp settings" without
|
|
7
|
+
* editing that fleet-wide file: a project's `ompSettings` map (an opaque
|
|
8
|
+
* settings tree the harness owns) is materialised to a YAML overlay *outside
|
|
9
|
+
* the worktree* — under the run's own session directory, alongside the
|
|
10
|
+
* transcript and the control socket — and threaded to the session through
|
|
11
|
+
* omp's own `Settings.init({ configFiles: [<path>] })` seam.
|
|
12
|
+
*
|
|
13
|
+
* Two properties make this channel distinct from the obvious alternative of
|
|
14
|
+
* writing `<worktree>/.omp/config.yml`:
|
|
15
|
+
*
|
|
16
|
+
* - The overlay is fleet-owned, never a file in the git checkout, so the
|
|
17
|
+
* diff a worker eventually ships is untouched and nothing needs an ignore
|
|
18
|
+
* entry. The worktree ignore block's standing rule — an ignored new file
|
|
19
|
+
* is invisible to salvage — is exactly why an in-tree staging was refused.
|
|
20
|
+
* - It is rewritten from config on every dispatch, so a config edit takes
|
|
21
|
+
* effect on the next attempt (and a resumed attempt reuses the kept
|
|
22
|
+
* session directory with the *current* config, not the one as of first
|
|
23
|
+
* provision).
|
|
24
|
+
*
|
|
25
|
+
* Conductor validates YAML shape only — omp owns the schema. An unknown key or
|
|
26
|
+
* a wrong-typed value is omp's to reject when it resolves the overlay, never
|
|
27
|
+
* conductor's to interpret.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { stringify } from "yaml";
|
|
33
|
+
import { stateDir } from "./config.ts";
|
|
34
|
+
import type { ProjectConfig } from "./types.ts";
|
|
35
|
+
|
|
36
|
+
/** The overlay filename inside a run's session directory. */
|
|
37
|
+
export const OMP_SETTINGS_FILE = "omp-settings.yml";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The effective overlay map for a project: the opaque `ompSettings` map plus
|
|
41
|
+
* the retry keys (`retry.modelFallback`, `retry.fallbackChains.default`)
|
|
42
|
+
* derived from `modelFallbacks` — #539's staging half, moved onto this general
|
|
43
|
+
* channel so there is one way to stage settings into a worker session, not one
|
|
44
|
+
* seam per feature.
|
|
45
|
+
*
|
|
46
|
+
* `ompSettings.retry` is the operator's explicit word: when it is already a
|
|
47
|
+
* mapping, the derived retry block is skipped entirely (the overlay then says
|
|
48
|
+
* what it says, and the dispatch-failover chain #286 reads is unchanged — only
|
|
49
|
+
* omp's *retry-time* model swap respects the explicit stanza). Otherwise the
|
|
50
|
+
* derived block is merged in, preserving #539's behaviour exactly.
|
|
51
|
+
*
|
|
52
|
+
* Returns `undefined` when nothing is staged — a project with neither an
|
|
53
|
+
* `ompSettings` map nor a model-fallback chain gets no overlay at all, so its
|
|
54
|
+
* dispatch is byte-for-byte today's.
|
|
55
|
+
*/
|
|
56
|
+
export function ompSettingsOverlay(
|
|
57
|
+
p: Pick<ProjectConfig, "ompSettings" | "modelFallbacks">,
|
|
58
|
+
): Record<string, unknown> | undefined {
|
|
59
|
+
const map: Record<string, unknown> = { ...(p.ompSettings ?? {}) };
|
|
60
|
+
const chain = p.modelFallbacks;
|
|
61
|
+
const explicitRetry =
|
|
62
|
+
typeof map.retry === "object" && map.retry !== null && !Array.isArray(map.retry);
|
|
63
|
+
if (chain !== undefined && chain.length > 0 && !explicitRetry) {
|
|
64
|
+
map.retry = {
|
|
65
|
+
modelFallback: true,
|
|
66
|
+
fallbackChains: { default: [...chain] },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return Object.keys(map).length === 0 ? undefined : map;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The directory every run's session dir lives under — where the overlay is
|
|
74
|
+
* materialised at dispatch. `doctor` probes its writability to report whether
|
|
75
|
+
* the next dispatch's overlay can actually land.
|
|
76
|
+
*/
|
|
77
|
+
export function sessionRootDir(): string {
|
|
78
|
+
return join(stateDir(), "sessions");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Materialises a project's effective overlay to `<sessionDir>/omp-settings.yml`
|
|
83
|
+
* and returns its absolute path, or `undefined` when nothing is staged.
|
|
84
|
+
*
|
|
85
|
+
* When nothing is staged, any overlay a previous attempt wrote is removed, so
|
|
86
|
+
* a project that dropped its map leaves no stale file in a kept session dir —
|
|
87
|
+
* the resume path reuses that directory, and an orphaned overlay with nobody
|
|
88
|
+
* reading it would be the exact silent fake this channel exists to avoid.
|
|
89
|
+
*
|
|
90
|
+
* Re-runs every dispatch, so a kept worktree picks up a config change on the
|
|
91
|
+
* next attempt rather than at the next worktree creation. A non-staged
|
|
92
|
+
* project's dispatch degrades to the harness discovering settings exactly as
|
|
93
|
+
* it always has.
|
|
94
|
+
*/
|
|
95
|
+
export function materializeOmpSettings(
|
|
96
|
+
project: Pick<ProjectConfig, "ompSettings" | "modelFallbacks">,
|
|
97
|
+
sessionDir: string,
|
|
98
|
+
): string | undefined {
|
|
99
|
+
const path = join(sessionDir, OMP_SETTINGS_FILE);
|
|
100
|
+
const overlay = ompSettingsOverlay(project);
|
|
101
|
+
if (overlay === undefined) {
|
|
102
|
+
try {
|
|
103
|
+
unlinkSync(path);
|
|
104
|
+
} catch {
|
|
105
|
+
// Nothing written — a fresh session dir, or the absent case on first
|
|
106
|
+
// dispatch. Either way there is no stale overlay to remove.
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
// 0600: the overlay can carry provider names and model selectors, and the
|
|
111
|
+
// session dir already holds the run's private channels.
|
|
112
|
+
writeFileSync(path, stringify(overlay), { mode: 0o600 });
|
|
113
|
+
return path;
|
|
114
|
+
}
|
package/src/omp.ts
CHANGED
|
@@ -168,6 +168,17 @@ export async function createLocalSession(opts: {
|
|
|
168
168
|
releaseGrants?: ResolvedGrants;
|
|
169
169
|
/** Durable audit callback invoked only when that gate rejects a call. */
|
|
170
170
|
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
171
|
+
/**
|
|
172
|
+
* The fleet-owned omp settings overlay staged into this session's omp
|
|
173
|
+
* settings (#537): an absolute path to a YAML overlay the daemon materialised
|
|
174
|
+
* from the project's `ompSettings` map (plus the retry keys derived from
|
|
175
|
+
* `modelFallbacks`), passed to `Settings.init({ configFiles: [<path>] })` so
|
|
176
|
+
* it layers on top of the ordinary global/project discovery — never an
|
|
177
|
+
* isolated instance, which would drop providers and approvals out from under
|
|
178
|
+
* the worker. Absent, no settings are passed and the harness discovers
|
|
179
|
+
* exactly as it does today.
|
|
180
|
+
*/
|
|
181
|
+
ompSettingsFile?: string;
|
|
171
182
|
/**
|
|
172
183
|
* The conductor verb socket this session's mutation tools call (#126).
|
|
173
184
|
*
|
|
@@ -243,11 +254,31 @@ export async function createLocalSession(opts: {
|
|
|
243
254
|
conductorVerbs(opts.verbSocketPath),
|
|
244
255
|
];
|
|
245
256
|
|
|
257
|
+
// The fleet-owned omp settings overlay (#537): when the daemon staged one,
|
|
258
|
+
// layer it into a Settings instance over the ordinary global/project
|
|
259
|
+
// discovery. `Settings.init({ cwd, configFiles })` keeps the daemon account's
|
|
260
|
+
// global config (providers, approvals, modelRoles) and any project
|
|
261
|
+
// `.omp/config.yml` intact — the overlay is deep-merged *after* them — and
|
|
262
|
+
// never `Settings.isolated()`, which would build from the overlay alone and
|
|
263
|
+
// drop everything out from under the worker. Absent a staged overlay, no
|
|
264
|
+
// settings are passed and the harness discovers exactly as it does today.
|
|
265
|
+
let overlaySettings: unknown = undefined;
|
|
266
|
+
if (opts.ompSettingsFile !== undefined) {
|
|
267
|
+
const Settings = Reflect.get(namespace, "Settings");
|
|
268
|
+
if (typeof Settings === "function" && typeof Settings.init === "function") {
|
|
269
|
+
overlaySettings = await Settings.init({
|
|
270
|
+
cwd: opts.cwd,
|
|
271
|
+
configFiles: [opts.ompSettingsFile],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
246
276
|
const created = await mod.createAgentSession({
|
|
247
277
|
cwd: opts.cwd,
|
|
248
278
|
// A raw pattern rather than a resolved Model: the harness resolves it
|
|
249
279
|
// after extensions load, so we never have to import its model registry.
|
|
250
280
|
...(opts.model === undefined ? {} : { modelPattern: opts.model }),
|
|
281
|
+
...(overlaySettings === undefined ? {} : { settings: overlaySettings }),
|
|
251
282
|
sessionManager,
|
|
252
283
|
// A private registry per session, never the process-global default: that
|
|
253
284
|
// one admits only one "Main" identity per generation, so a second session
|
|
@@ -424,6 +455,13 @@ export interface CreateSessionOptions {
|
|
|
424
455
|
role: SessionRole;
|
|
425
456
|
releaseGrants?: ResolvedGrants;
|
|
426
457
|
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
458
|
+
/**
|
|
459
|
+
* Absolute path to the fleet-owned omp settings overlay (#537), forwarded to
|
|
460
|
+
* the child through the host spec so the far side's `createLocalSession`
|
|
461
|
+
* loads it via `Settings.init({ configFiles: [<path>] })`. Absent, nothing
|
|
462
|
+
* is staged and the session discovers settings as it does today.
|
|
463
|
+
*/
|
|
464
|
+
ompSettingsFile?: string;
|
|
427
465
|
|
|
428
466
|
/**
|
|
429
467
|
* Where the control socket is bound. The daemon puts it beside the run's own
|
|
@@ -562,6 +600,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
562
600
|
...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
|
|
563
601
|
...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
|
|
564
602
|
...(opts.readOnly === undefined ? {} : { readOnly: opts.readOnly }),
|
|
603
|
+
...(opts.ompSettingsFile === undefined ? {} : { ompSettingsFile: opts.ompSettingsFile }),
|
|
565
604
|
};
|
|
566
605
|
|
|
567
606
|
const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -519,7 +519,13 @@ export function queueDigestLine(
|
|
|
519
519
|
if (summary.routed >= groomBelow) return undefined;
|
|
520
520
|
let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
|
|
521
521
|
if (summary.admitted === 0 && summary.holds.length > 0) {
|
|
522
|
-
|
|
522
|
+
const held = summary.holds
|
|
523
|
+
.map((h) => {
|
|
524
|
+
const first = h.details?.[0];
|
|
525
|
+
return `${h.reason} ${h.count}${first === undefined ? "" : ` (${first})`}`;
|
|
526
|
+
})
|
|
527
|
+
.join(", ");
|
|
528
|
+
line += ` All held: ${held}.`;
|
|
523
529
|
}
|
|
524
530
|
return line;
|
|
525
531
|
}
|
package/src/reports.ts
CHANGED
|
@@ -37,7 +37,14 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import { availabilityDisposition, interruptDisposition } from "./availability.ts";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
readTelegramToken,
|
|
42
|
+
resolveProjectTopicId,
|
|
43
|
+
sendTelegram,
|
|
44
|
+
telegramTextParts,
|
|
45
|
+
TelegramSendError,
|
|
46
|
+
} from "./escalate.ts";
|
|
47
|
+
import { createHash } from "node:crypto";
|
|
41
48
|
import { localDayKey } from "./digest-schedule.ts";
|
|
42
49
|
import {
|
|
43
50
|
DIGEST_BACKLOG_LIMIT,
|
|
@@ -86,11 +93,68 @@ const NO_ISSUE = 0;
|
|
|
86
93
|
const ERROR_SAMPLE = 90;
|
|
87
94
|
|
|
88
95
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
96
|
+
* The within-run harness reliability a settlement now names where a human
|
|
97
|
+
* reads it (#584). The row always carries the resolved model/provider and the
|
|
98
|
+
* counts (written at settlement straight off the worker result); this is only
|
|
99
|
+
* the *wording* — the sentence a settlement report and a tick digest tell an
|
|
100
|
+
* operator who would otherwise have to read a transcript to learn that the
|
|
101
|
+
* run finished on a fallback after a mid-run swap, or rode out a throttled
|
|
102
|
+
* provider on retries and compaction.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately empty for a clean run: a run that never swapped, retried or
|
|
105
|
+
* compacted changes neither the settlement report nor the digest, so the
|
|
106
|
+
* "additive" claim is the observable one — byte-for-byte today's output on a
|
|
107
|
+
* quiet fleet. The resolved model is still recorded on the row every time (#584's
|
|
108
|
+
* second fake), but a clean run's narrative has nothing to add.
|
|
109
|
+
*/
|
|
110
|
+
export interface ReliabilitySettlement {
|
|
111
|
+
resolvedModel?: string;
|
|
112
|
+
resolvedProvider?: string;
|
|
113
|
+
retryFallbacks: { from: string; to: string }[];
|
|
114
|
+
retryFallbackSucceeded: number;
|
|
115
|
+
modelRecoveries: number;
|
|
116
|
+
autoRetryCount: number;
|
|
117
|
+
autoCompactionCount: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The human sentence for the above reliability surface, or undefined when there
|
|
122
|
+
* is nothing worth saying (a clean run). One line so it drops into both the
|
|
123
|
+
* settlement report and a material event's summary unchanged.
|
|
124
|
+
*/
|
|
125
|
+
export function reliabilitySettlementLine(
|
|
126
|
+
f: ReliabilitySettlement,
|
|
127
|
+
): string | undefined {
|
|
128
|
+
const { resolvedModel, resolvedProvider } = f;
|
|
129
|
+
if (f.retryFallbacks.length === 0 && f.retryFallbackSucceeded === 0 && f.modelRecoveries === 0 && f.autoRetryCount === 0 && f.autoCompactionCount === 0) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const activity = [
|
|
133
|
+
f.retryFallbacks.length > 0
|
|
134
|
+
? `${f.retryFallbacks.length} within-run model swap${f.retryFallbacks.length === 1 ? "" : "s"}`
|
|
135
|
+
: undefined,
|
|
136
|
+
f.retryFallbackSucceeded > 0 ? `${f.retryFallbackSucceeded} recovered` : undefined,
|
|
137
|
+
f.modelRecoveries > 0 ? `${f.modelRecoveries} model recovery` : undefined,
|
|
138
|
+
f.autoRetryCount > 0 ? `${f.autoRetryCount} provider retry` : undefined,
|
|
139
|
+
f.autoCompactionCount > 0 ? `${f.autoCompactionCount} compaction` : undefined,
|
|
140
|
+
].filter((part): part is string => part !== undefined);
|
|
141
|
+
const finished =
|
|
142
|
+
resolvedModel === undefined
|
|
143
|
+
? "finished on the fallback"
|
|
144
|
+
: `finished on ${resolvedModel}${resolvedProvider === undefined ? "" : ` (${resolvedProvider})`}`;
|
|
145
|
+
return `Reliability: ${activity.join(", ")}; ${finished}.`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One delivery attempt's transport for a *single message*. Injected so the
|
|
150
|
+
* delivery contract — claim, split, send, record progress, retry — is testable
|
|
151
|
+
* without a live bot, which is the same split `confineToolCall` and `verifyPr`
|
|
152
|
+
* use: the decision is separable from the I/O. The outbox splits a long report
|
|
153
|
+
* into labelled parts itself and drives one call per part (so partial progress
|
|
154
|
+
* can be persisted and resumed, #566); a text passed here is always under the
|
|
155
|
+
* wire limit. Resolves with Telegram's own message id when it returns one,
|
|
156
|
+
* throws on any known failure — {@link TelegramSendError} carries the
|
|
157
|
+
* definitive/unknown verdict the retry's honesty hangs on.
|
|
94
158
|
*/
|
|
95
159
|
export type ReportSend = (text: string) => Promise<number | undefined>;
|
|
96
160
|
|
|
@@ -291,7 +355,14 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
|
|
|
291
355
|
"definitive",
|
|
292
356
|
);
|
|
293
357
|
}
|
|
294
|
-
|
|
358
|
+
// `sendTelegram` splits internally and resolves with every part's id. A
|
|
359
|
+
// caller on this seam always passes one message under the wire limit, so a
|
|
360
|
+
// single-element array comes back; `[0]` is that one id. A text over the
|
|
361
|
+
// limit would still be delivered whole (never truncated) — the seam would
|
|
362
|
+
// simply under-report the extra ids, which is why the split lives at the
|
|
363
|
+
// caller, not here.
|
|
364
|
+
const ids = await sendTelegram(token, chatId, text, { topicId: resolveProjectTopicId(p) });
|
|
365
|
+
return ids[0];
|
|
295
366
|
};
|
|
296
367
|
}
|
|
297
368
|
|
|
@@ -345,7 +416,13 @@ export async function deliverOperatorMessage(
|
|
|
345
416
|
});
|
|
346
417
|
return { kind: "held", category, noticeId: deps.noticeId, reason: disposition };
|
|
347
418
|
}
|
|
348
|
-
|
|
419
|
+
const send = deps.send ?? telegramReportSend(project);
|
|
420
|
+
// A long operator message is split and driven part-by-part through the same
|
|
421
|
+
// seam the outbox uses, so a custom transport sees the same per-part calls
|
|
422
|
+
// and the default one never truncates (#566).
|
|
423
|
+
for (const part of telegramTextParts(text)) {
|
|
424
|
+
await send(part);
|
|
425
|
+
}
|
|
349
426
|
return { kind: "sent", category };
|
|
350
427
|
}
|
|
351
428
|
|
|
@@ -596,9 +673,38 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
|
|
|
596
673
|
return;
|
|
597
674
|
}
|
|
598
675
|
|
|
599
|
-
|
|
676
|
+
const text = formatReportMessage(claimed, project.name);
|
|
677
|
+
const parts = telegramTextParts(text);
|
|
678
|
+
// Fingerprint of the exact text the split below is computed from. The
|
|
679
|
+
// per-part watermark on the row is only meaningful while this is unchanged:
|
|
680
|
+
// the resume point is a count into a specific split, so any drift in the
|
|
681
|
+
// text invalidates it (#566).
|
|
682
|
+
const messageHash = createHash("sha256").update(text).digest("hex");
|
|
683
|
+
// Resume at the first part this attempt has not yet confirmed. A count is
|
|
684
|
+
// only a valid resume point while the text — and therefore the split — is
|
|
685
|
+
// byte-identical to the attempt that wrote the watermark. Any change,
|
|
686
|
+
// notably the possible-repeat banner an unknown outcome adds to an
|
|
687
|
+
// ambiguous report, reshapes the parts, so the watermark is deliberately
|
|
688
|
+
// not carried across it and the whole report is re-sent amber-flagged —
|
|
689
|
+
// the same at-least-once semantics as the single-message path (#566).
|
|
690
|
+
const resumeAt = claimed.sentPartsHash === messageHash ? (claimed.sentParts ?? 0) : 0;
|
|
691
|
+
const start = Math.min(resumeAt, parts.length);
|
|
692
|
+
|
|
693
|
+
const messageIds: number[] = [];
|
|
600
694
|
try {
|
|
601
|
-
|
|
695
|
+
// Strictly in order, one `sendMessage` at a time: parts never interleave
|
|
696
|
+
// with each other or with a message sent concurrently to the same chat.
|
|
697
|
+
for (let i = start; i < parts.length; i += 1) {
|
|
698
|
+
const id = await send(parts[i]!);
|
|
699
|
+
if (id !== undefined) messageIds.push(id);
|
|
700
|
+
// Persist progress before the next part ships. Only a multi-part send
|
|
701
|
+
// has a crossing point to protect: a crash or a definitive failure can
|
|
702
|
+
// then only ever re-send the part that was in flight, never the
|
|
703
|
+
// confirmed prefix. Guarded by attempt id like every other transition.
|
|
704
|
+
if (parts.length > 1) {
|
|
705
|
+
store.markReportPartsSent(claimed.id, attemptId, i + 1, messageHash, now());
|
|
706
|
+
}
|
|
707
|
+
}
|
|
602
708
|
} catch (err) {
|
|
603
709
|
const at = now();
|
|
604
710
|
const error = err instanceof Error ? err.message : String(err);
|
|
@@ -641,15 +747,21 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
|
|
|
641
747
|
pass.requeued.push(claimed.id);
|
|
642
748
|
log(
|
|
643
749
|
`report ${claimed.id} attempt ${claimed.attempts}/${REPORT_MAX_ATTEMPTS} was rejected, ` +
|
|
750
|
+
`${start + messageIds.length}/${parts.length} parts accepted, ` +
|
|
644
751
|
`retrying in ${Math.round(delay / 1_000)}s: ${error}`,
|
|
645
752
|
);
|
|
646
753
|
return;
|
|
647
754
|
}
|
|
648
755
|
|
|
649
|
-
store.markReportDelivered(claimed.id, attemptId,
|
|
756
|
+
store.markReportDelivered(claimed.id, attemptId, messageIds, now());
|
|
650
757
|
pass.delivered.push(claimed.id);
|
|
651
758
|
log(
|
|
652
|
-
`report ${claimed.id} delivered
|
|
759
|
+
`report ${claimed.id} delivered` +
|
|
760
|
+
(parts.length === 1
|
|
761
|
+
? messageIds[0] === undefined
|
|
762
|
+
? ""
|
|
763
|
+
: ` as telegram message ${messageIds[0]}`
|
|
764
|
+
: ` as ${parts.length} telegram messages (${messageIds.join(", ")})`) +
|
|
653
765
|
`${claimed.ambiguous ? " (marked as a possible repeat)" : ""}`,
|
|
654
766
|
);
|
|
655
767
|
};
|
package/src/session-host.ts
CHANGED
|
@@ -50,6 +50,11 @@ export interface SessionHostSpec {
|
|
|
50
50
|
verbSocketPath?: string;
|
|
51
51
|
/** Deny every tool but reading and searching (#307) — the setup probes. */
|
|
52
52
|
readOnly?: boolean;
|
|
53
|
+
/** The fleet-owned omp settings overlay (#537): absolute path to the YAML
|
|
54
|
+
* overlay the daemon materialised from the project's `ompSettings` map.
|
|
55
|
+
* Carried as a plain scalar like the other fields; absent, nothing is staged
|
|
56
|
+
* on the far side either. */
|
|
57
|
+
ompSettingsFile?: string;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
/** Parent → child. */
|
|
@@ -186,6 +191,7 @@ export async function runSessionHost(
|
|
|
186
191
|
...(spec.releaseGrants === undefined ? {} : { releaseGrants: spec.releaseGrants }),
|
|
187
192
|
...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
|
|
188
193
|
...(spec.readOnly === undefined ? {} : { readOnly: spec.readOnly }),
|
|
194
|
+
...(spec.ompSettingsFile === undefined ? {} : { ompSettingsFile: spec.ompSettingsFile }),
|
|
189
195
|
// The release audit lives in the daemon's state directory, which this
|
|
190
196
|
// process may not be able to write and must not be trusted to. It
|
|
191
197
|
// becomes a message; the parent performs the durable write.
|
package/src/setup-wizard.ts
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
detectTelegram,
|
|
72
72
|
formatGates,
|
|
73
73
|
orchestratorBriefPath,
|
|
74
|
+
parseOmpSettingsYaml,
|
|
74
75
|
planAgainstLabels,
|
|
75
76
|
planLabels,
|
|
76
77
|
summariseAmend,
|
|
@@ -1204,6 +1205,37 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
|
|
|
1204
1205
|
return next;
|
|
1205
1206
|
};
|
|
1206
1207
|
|
|
1208
|
+
/** The free-form omp settings overlay (#537): one YAML answer, omp's own
|
|
1209
|
+
* vocabulary rather than a list this package can offer. The seed is the
|
|
1210
|
+
* current map rendered as JSON — a YAML 1.2 subset — so a re-run that Enters
|
|
1211
|
+
* re-affirms it on one line; a blank answer clears the overlay; anything that
|
|
1212
|
+
* is not a YAML mapping re-asks, because an answer the dispatcher would
|
|
1213
|
+
* silently drop is exactly the input this wizard exists to catch. */
|
|
1214
|
+
const askOmpSettings: AreaAsker = async (ui, a) => {
|
|
1215
|
+
const next: SetupAnswers = { ...a };
|
|
1216
|
+
const seed = a.ompSettings === undefined ? "" : JSON.stringify(a.ompSettings);
|
|
1217
|
+
const answered = await askValid(
|
|
1218
|
+
ui,
|
|
1219
|
+
"Omp settings overlay for workers (YAML, blank = none — omp's schema, not conductor's)",
|
|
1220
|
+
seed,
|
|
1221
|
+
(value) => {
|
|
1222
|
+
if (value.trim().length === 0) return undefined;
|
|
1223
|
+
const parsed = parseOmpSettingsYaml(value);
|
|
1224
|
+
return parsed.ok ? undefined : parsed.problem;
|
|
1225
|
+
},
|
|
1226
|
+
);
|
|
1227
|
+
if (answered.trim().length === 0) {
|
|
1228
|
+
delete next.ompSettings;
|
|
1229
|
+
return next;
|
|
1230
|
+
}
|
|
1231
|
+
const parsed = parseOmpSettingsYaml(answered);
|
|
1232
|
+
// askValid already proved the shape on this exact string; a parse failure
|
|
1233
|
+
// here is an internal invariant, never a silently dropped overlay.
|
|
1234
|
+
if (!parsed.ok) throw new Error(`unreachable: askValid accepted unparseable omp settings: ${parsed.problem}`);
|
|
1235
|
+
next.ompSettings = parsed.value;
|
|
1236
|
+
return next;
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1207
1239
|
/** The three ownership questions, then the mechanical gate one shape at a time:
|
|
1208
1240
|
* together they are what decides what an unattended fleet may do unasked. */
|
|
1209
1241
|
const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
@@ -1492,6 +1524,7 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
|
|
|
1492
1524
|
// The two per-worker knobs the full interview separates with the authority
|
|
1493
1525
|
// grants; an amend has no reason to put anything between them.
|
|
1494
1526
|
caps: async (ui, a, probes) => await askWorkerModel(ui, await askCaps(ui, a, probes), probes),
|
|
1527
|
+
"omp-settings": askOmpSettings,
|
|
1495
1528
|
"code-graph": askGraph,
|
|
1496
1529
|
authority: askAuthorityArea,
|
|
1497
1530
|
policy: askPolicy,
|
|
@@ -1528,6 +1561,9 @@ const INTERVIEW_AREAS: readonly { label: string; asker: AreaAsker }[] = [
|
|
|
1528
1561
|
{ label: "authority & release grants", asker: askAuthorityArea },
|
|
1529
1562
|
{ label: "merge & release preconditions", asker: askPolicy },
|
|
1530
1563
|
{ label: "worker model", asker: askWorkerModel },
|
|
1564
|
+
// The omp settings overlay rides next to the worker model: both are
|
|
1565
|
+
// per-worker runtime knobs, and neither is a ceiling the caps block owns.
|
|
1566
|
+
{ label: "omp settings overlay", asker: askOmpSettings },
|
|
1531
1567
|
{ label: "escalation & triage", asker: askEscalation },
|
|
1532
1568
|
{ label: "reporting", asker: askReporting },
|
|
1533
1569
|
{ label: "orchestrator brief", asker: askBrief },
|
package/src/setup.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
|
+
import { parse as parseYaml } from "yaml";
|
|
26
27
|
import {
|
|
27
28
|
COMPOSE_BANNER,
|
|
28
29
|
ORCHESTRATOR_BRIEF_NAME,
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
stateDir,
|
|
47
48
|
} from "./config.ts";
|
|
48
49
|
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
50
|
+
import { ompSettingsOverlay } from "./omp-settings.ts";
|
|
49
51
|
import {
|
|
50
52
|
CONFIG_VERSION,
|
|
51
53
|
DEFAULT_AUTHORITY,
|
|
@@ -163,6 +165,15 @@ export interface SetupAnswers {
|
|
|
163
165
|
*/
|
|
164
166
|
modelFallbacks?: string[];
|
|
165
167
|
modelFallbackThreshold?: number;
|
|
168
|
+
/**
|
|
169
|
+
* The project's omp settings overlay (#537): an opaque map layered into
|
|
170
|
+
* every worker session via the fleet-owned settings channel. The wizard asks
|
|
171
|
+
* for it as one free-form YAML answer (the only prompt this map gets — the
|
|
172
|
+
* keys inside it are omp's vocabulary, not a list conductor can offer); it
|
|
173
|
+
* also survives an amend of any other area unchanged, like
|
|
174
|
+
* {@link modelFallbacks}.
|
|
175
|
+
*/
|
|
176
|
+
ompSettings?: Record<string, unknown>;
|
|
166
177
|
telegramChatId?: string;
|
|
167
178
|
/** Forum topic for tier-2 Telegram pages; absent keeps flat-chat 0.13 behaviour. */
|
|
168
179
|
telegramTopicId?: number;
|
|
@@ -855,6 +866,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
855
866
|
// would pin every run of the project onto a dead provider again (#286).
|
|
856
867
|
...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
|
|
857
868
|
...(a.modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold: a.modelFallbackThreshold }),
|
|
869
|
+
// The omp settings overlay is an opaque map the wizard collects as free-form
|
|
870
|
+
// YAML; an unrelated amend must not delete it (#537).
|
|
871
|
+
...(a.ompSettings === undefined ? {} : { ompSettings: a.ompSettings }),
|
|
858
872
|
escalation,
|
|
859
873
|
authority: { ...a.authority },
|
|
860
874
|
// Written out in full, never as the legacy string: the file then says which
|
|
@@ -1061,6 +1075,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
1061
1075
|
if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
|
|
1062
1076
|
if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
|
|
1063
1077
|
if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
|
|
1078
|
+
if (p.ompSettings !== undefined) answers.ompSettings = { ...p.ompSettings };
|
|
1064
1079
|
if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
|
|
1065
1080
|
if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
|
|
1066
1081
|
if (p.recoveryMerges !== undefined) {
|
|
@@ -1682,19 +1697,47 @@ export function formatGates(gates: readonly { cmd: string; cwd: string }[]): str
|
|
|
1682
1697
|
return gates.map((g) => (g.cwd === "." ? g.cmd : `${g.cmd} @ ${g.cwd}`)).join(", ");
|
|
1683
1698
|
}
|
|
1684
1699
|
|
|
1700
|
+
/**
|
|
1701
|
+
* Parses the wizard's free-form YAML answer for a project's omp settings
|
|
1702
|
+
* overlay (#537) and validates YAML shape only — the same boundary the config
|
|
1703
|
+
* loader enforces. A mapping at the document root is the whole contract:
|
|
1704
|
+
* everything inside it is omp's schema to own, so an unknown key or a
|
|
1705
|
+
* wrong-typed value is omp's to reject, never this module's to understand
|
|
1706
|
+
* (non-string keys are coerced by the YAML parser exactly as omp's own loader
|
|
1707
|
+
* coerces them, so a `true: x` mapping stays YAML-valid on both sides). Blank
|
|
1708
|
+
* input is a valid "no overlay" answer.
|
|
1709
|
+
*/
|
|
1710
|
+
export function parseOmpSettingsYaml(
|
|
1711
|
+
text: string,
|
|
1712
|
+
): { ok: true; value: Record<string, unknown> } | { ok: false; problem: string } {
|
|
1713
|
+
const trimmed = text.trim();
|
|
1714
|
+
if (trimmed.length === 0) return { ok: true, value: {} };
|
|
1715
|
+
let parsed: unknown;
|
|
1716
|
+
try {
|
|
1717
|
+
parsed = parseYaml(trimmed);
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
return { ok: false, problem: `"${text}" is not valid YAML: ${err instanceof Error ? err.message : String(err)}` };
|
|
1720
|
+
}
|
|
1721
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1722
|
+
return { ok: false, problem: "the omp settings overlay must be a YAML mapping at the root, e.g. `modelRoles: { worker: \"@slow\" }`" };
|
|
1723
|
+
}
|
|
1724
|
+
return { ok: true, value: parsed as Record<string, unknown> };
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1685
1727
|
/**
|
|
1686
1728
|
* The wizard's questions, grouped as the areas a re-run can amend one of, in the
|
|
1687
1729
|
* order the full interview asks them.
|
|
1688
1730
|
*
|
|
1689
1731
|
* Data rather than a switch so the menu, the CLI's positional area vocabulary,
|
|
1690
1732
|
* ./setup-wizard.ts's `AREA_ASKERS` table and the amend summary all enumerate the
|
|
1691
|
-
* same
|
|
1733
|
+
* same ten areas: an added area fails to compile until it has a name, a current
|
|
1692
1734
|
* value and a set of questions.
|
|
1693
1735
|
*/
|
|
1694
1736
|
export const AMEND_AREA_IDS = [
|
|
1695
1737
|
"tracker",
|
|
1696
1738
|
"gates",
|
|
1697
1739
|
"caps",
|
|
1740
|
+
"omp-settings",
|
|
1698
1741
|
"code-graph",
|
|
1699
1742
|
"authority",
|
|
1700
1743
|
"policy",
|
|
@@ -1765,6 +1808,20 @@ export const AMEND_AREAS: {
|
|
|
1765
1808
|
);
|
|
1766
1809
|
},
|
|
1767
1810
|
},
|
|
1811
|
+
"omp-settings": {
|
|
1812
|
+
// The free-form omp settings overlay (#537), the sibling per-worker knob to
|
|
1813
|
+
// the model: one area no menu offers is a setting only a full re-interview
|
|
1814
|
+
// can reach, so this one has its own row like the caps.
|
|
1815
|
+
name: "omp settings overlay",
|
|
1816
|
+
asks: "the free-form YAML map layered into every worker session's omp settings — omp's schema, not conductor's",
|
|
1817
|
+
// The *effective* overlay — `ompSettings` plus the retry keys derived from
|
|
1818
|
+
// `modelFallbacks` — so the menu says what a worker would actually load,
|
|
1819
|
+
// matching `doctor` and the dispatcher rather than echoing raw config.
|
|
1820
|
+
describe: (p) => {
|
|
1821
|
+
const overlay = ompSettingsOverlay(p);
|
|
1822
|
+
return overlay === undefined ? "no overlay — workers inherit global settings" : JSON.stringify(overlay);
|
|
1823
|
+
},
|
|
1824
|
+
},
|
|
1768
1825
|
"code-graph": {
|
|
1769
1826
|
name: "code graph",
|
|
1770
1827
|
asks: "whether workers query a code-graph index, and the root its one-clone-per-repo lives under",
|