omp-conductor 0.15.13 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +72 -2
- package/package.json +2 -1
- package/schema/config.schema.json +7 -0
- package/src/admission.ts +849 -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/tail.ts +204 -44
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +13 -0
- package/src/config.ts +54 -0
- package/src/daemon.ts +255 -530
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +122 -0
- package/src/doctor.ts +297 -5
- package/src/escalate.ts +191 -19
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +168 -452
- 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/transcript.ts +1 -1
- 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 +358 -10
- package/src/worktree.ts +13 -1
package/src/commands/tail.ts
CHANGED
|
@@ -7,24 +7,146 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
|
-
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
10
|
+
import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
11
12
|
import { findProject, loadConfig } from "../config.ts";
|
|
12
13
|
import { dbPath, LIVE_STATES, openStore } from "../store.ts";
|
|
13
|
-
import { formatTranscriptLine } from "../transcript.ts";
|
|
14
|
+
import { formatTranscriptLine, prop } from "../transcript.ts";
|
|
15
|
+
import type { Store } from "../types.ts";
|
|
14
16
|
|
|
15
|
-
/** How often `tail` re-stats the
|
|
17
|
+
/** How often `tail` re-stats the transcripts it is following. */
|
|
16
18
|
const TAIL_POLL_MS = 1_000;
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
|
-
* How long the
|
|
21
|
+
* How long the transcripts must stay unchanged, after its run has left the live
|
|
20
22
|
* states, before `tail` calls it over. The state flips from the daemon's thread
|
|
21
23
|
* while the harness may still be flushing its last message, so exiting on the
|
|
22
24
|
* state alone truncates the ending an operator ran this command to watch.
|
|
23
25
|
*/
|
|
24
26
|
const TAIL_QUIET_MS = 5_000;
|
|
25
27
|
|
|
28
|
+
/** The harness records advisor turns under this reserved stem. */
|
|
29
|
+
const ADVISOR_TRANSCRIPT_PREFIX = "__advisor";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One followed file: its open descriptor, how many bytes have been rendered,
|
|
33
|
+
* and whatever byte tail did not end on a newline yet. Holding the partial line
|
|
34
|
+
* across polls is what stops a UTF-8 sequence straddling a read boundary (or
|
|
35
|
+
* the newline itself) from being mangled by decoding each chunk on its own.
|
|
36
|
+
*/
|
|
37
|
+
interface FollowedStream {
|
|
38
|
+
fd: number;
|
|
39
|
+
offset: number;
|
|
40
|
+
pending: Buffer;
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
/**
|
|
27
|
-
*
|
|
44
|
+
* Return the complete lines appended to `file` since the last poll, plus
|
|
45
|
+
* whether any bytes changed at all (the quiet-timer needs the latter even when
|
|
46
|
+
* a delta rendered no displayable line). A file that shrank (truncated or
|
|
47
|
+
* replaced) restarts from zero; a final line without a newline stays in
|
|
48
|
+
* `pending` until its newline lands.
|
|
49
|
+
*/
|
|
50
|
+
function followLines(
|
|
51
|
+
state: FollowedStream,
|
|
52
|
+
file: string,
|
|
53
|
+
): { lines: Buffer[]; changed: boolean } {
|
|
54
|
+
const lines: Buffer[] = [];
|
|
55
|
+
let changed = false;
|
|
56
|
+
let size = 0;
|
|
57
|
+
try {
|
|
58
|
+
size = statSync(file).size;
|
|
59
|
+
} catch {
|
|
60
|
+
// A transcript that vanishes mid-follow is not worth crashing over.
|
|
61
|
+
return { lines, changed };
|
|
62
|
+
}
|
|
63
|
+
// Shorter than what we have already read means truncated or replaced;
|
|
64
|
+
// resuming from the old offset would read the middle of another file.
|
|
65
|
+
if (size < state.offset) {
|
|
66
|
+
state.offset = 0;
|
|
67
|
+
state.pending = Buffer.alloc(0);
|
|
68
|
+
}
|
|
69
|
+
if (size > state.offset) {
|
|
70
|
+
const chunk = Buffer.allocUnsafe(size - state.offset);
|
|
71
|
+
const read = readSync(state.fd, chunk, 0, chunk.length, state.offset);
|
|
72
|
+
state.offset += read;
|
|
73
|
+
state.pending = Buffer.concat([state.pending, chunk.subarray(0, read)]);
|
|
74
|
+
for (;;) {
|
|
75
|
+
const nl = state.pending.indexOf(0x0a);
|
|
76
|
+
if (nl < 0) break;
|
|
77
|
+
lines.push(state.pending.subarray(0, nl));
|
|
78
|
+
state.pending = state.pending.subarray(nl + 1);
|
|
79
|
+
}
|
|
80
|
+
changed = read > 0;
|
|
81
|
+
}
|
|
82
|
+
return { lines, changed };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One advisor-transcript line rendered for the watcher, or `undefined` for the
|
|
87
|
+
* lines not worth a row. An advisor speaks in two forms, and an operator
|
|
88
|
+
* watching a run wants both:
|
|
89
|
+
*
|
|
90
|
+
* - plain assistant text, rendered exactly like the primary transcript's
|
|
91
|
+
* (one spelling, so the two cannot drift), and
|
|
92
|
+
* - `advise` tool calls — the reviewer's actual advisory, whose note and
|
|
93
|
+
* severity live in the call arguments and would otherwise be invisible.
|
|
94
|
+
*
|
|
95
|
+
* The reviewer's other tool calls (`glob`/`read` probes into the workspace)
|
|
96
|
+
* render nothing: they are the investigation, not the advice. Every rendered
|
|
97
|
+
* line is marked so the watcher can tell which reviewer produced it. Exported
|
|
98
|
+
* so a unit test pins the surface without a store.
|
|
99
|
+
*/
|
|
100
|
+
export function renderAdvisorLine(line: string): string | undefined {
|
|
101
|
+
let entry: unknown;
|
|
102
|
+
try {
|
|
103
|
+
entry = JSON.parse(line);
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (prop(entry, "type") !== "message") return undefined;
|
|
108
|
+
const message = prop(entry, "message");
|
|
109
|
+
if (prop(message, "role") !== "assistant") return undefined;
|
|
110
|
+
|
|
111
|
+
const content = prop(message, "content");
|
|
112
|
+
// A string-typed message is one plain text block — the same shape the
|
|
113
|
+
// primary transcript's formatter accepts, so an advisor advisory written as
|
|
114
|
+
// a bare string is not silently dropped.
|
|
115
|
+
if (typeof content === "string") {
|
|
116
|
+
return content.trim() === "" ? undefined : `[advisor] assistant: ${content.trim()}`;
|
|
117
|
+
}
|
|
118
|
+
const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
|
|
119
|
+
const out: string[] = [];
|
|
120
|
+
for (const block of blocks) {
|
|
121
|
+
const type = prop(block, "type");
|
|
122
|
+
if (type === "text") {
|
|
123
|
+
const text = prop(block, "text");
|
|
124
|
+
if (typeof text === "string" && text.trim() !== "") out.push(`assistant: ${text.trim()}`);
|
|
125
|
+
} else if (type === "toolCall" && prop(block, "name") === "advise") {
|
|
126
|
+
let args = prop(block, "arguments");
|
|
127
|
+
if (typeof args === "string") {
|
|
128
|
+
try {
|
|
129
|
+
args = JSON.parse(args);
|
|
130
|
+
} catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const note = prop(args, "note");
|
|
135
|
+
if (typeof note === "string" && note.trim() !== "") {
|
|
136
|
+
const severity = prop(args, "severity");
|
|
137
|
+
const label = typeof severity === "string" && severity !== "" ? severity : "nit";
|
|
138
|
+
out.push(`${label}: ${note.trim()}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out.length === 0 ? undefined : `[advisor] ${out.join("\n")}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Follow one run's transcripts the way `tail -f` follows a log: the worker's
|
|
147
|
+
* own transcript plus any `__advisor*.jsonl` sitting beside it (the mid-run
|
|
148
|
+
* reviewer's turns, #542), so an advisory the reviewer raised is visible in
|
|
149
|
+
* the same stream as the work it reacted to.
|
|
28
150
|
*
|
|
29
151
|
* Reads from byte zero rather than from the end: attaching to a worker that is
|
|
30
152
|
* already ten turns in and then showing nothing until turn eleven is not
|
|
@@ -36,10 +158,24 @@ const TAIL_QUIET_MS = 5_000;
|
|
|
36
158
|
* here is buffered, and a handler could only add a poll interval of latency to
|
|
37
159
|
* every Ctrl-C.
|
|
38
160
|
*/
|
|
39
|
-
async function tailRun(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
161
|
+
export async function tailRun(
|
|
162
|
+
project: string,
|
|
163
|
+
issue: number,
|
|
164
|
+
deps: {
|
|
165
|
+
/** Read-only in practice: opened WAL with a busy timeout. Tests inject one. */
|
|
166
|
+
store?: Store;
|
|
167
|
+
/** How often the transcripts are re-statted (tests shrink it). */
|
|
168
|
+
pollMs?: number;
|
|
169
|
+
/** How long a terminal run must stay quiet before the watcher exits. */
|
|
170
|
+
quietMs?: number;
|
|
171
|
+
/** Where rendered lines go; the CLI writes to stdout, tests capture. */
|
|
172
|
+
write?: (line: string) => void;
|
|
173
|
+
} = {},
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const store = deps.store ?? openStore(dbPath());
|
|
176
|
+
const write = deps.write ?? ((line: string) => process.stdout.write(`${line}\n`));
|
|
177
|
+
const pollMs = deps.pollMs ?? TAIL_POLL_MS;
|
|
178
|
+
const quietMs = deps.quietMs ?? TAIL_QUIET_MS;
|
|
43
179
|
try {
|
|
44
180
|
const run = store.latestRun(project, issue);
|
|
45
181
|
if (run === undefined) throw new Error(`no run recorded for #${issue}`);
|
|
@@ -47,56 +183,80 @@ async function tailRun(project: string, issue: number): Promise<void> {
|
|
|
47
183
|
// Claimed but not yet started, or an attempt whose session never opened one.
|
|
48
184
|
if (path === undefined) throw new Error(`no transcript yet (state: ${run.state})`);
|
|
49
185
|
|
|
50
|
-
const
|
|
186
|
+
const primary: FollowedStream = { fd: openSync(path, "r"), offset: 0, pending: Buffer.alloc(0) };
|
|
187
|
+
// Advisor transcripts live in the directory named after the primary
|
|
188
|
+
// transcript stem; the harness writes them there beside the session file.
|
|
189
|
+
const advisorDir = path.endsWith(".jsonl") ? path.slice(0, -".jsonl".length) : undefined;
|
|
190
|
+
const advisors = new Map<string, FollowedStream>();
|
|
51
191
|
try {
|
|
52
|
-
let offset = 0;
|
|
53
|
-
let pending = Buffer.alloc(0);
|
|
54
192
|
let lastChange = Date.now();
|
|
55
193
|
|
|
56
194
|
for (;;) {
|
|
57
|
-
let
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
195
|
+
let changed = false;
|
|
196
|
+
const primaryDelta = followLines(primary, path);
|
|
197
|
+
changed = primaryDelta.changed || changed;
|
|
198
|
+
for (const raw of primaryDelta.lines) {
|
|
199
|
+
const rendered = formatTranscriptLine(raw.toString("utf8"));
|
|
200
|
+
if (rendered !== undefined) write(rendered);
|
|
63
201
|
}
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
if (
|
|
202
|
+
|
|
203
|
+
// Follow every advisor transcript that appears beside the primary one.
|
|
204
|
+
// Files land mid-run (the reviewer's first turn), so each poll re-lists
|
|
205
|
+
// the directory and opens whatever is new.
|
|
206
|
+
if (advisorDir !== undefined) {
|
|
207
|
+
let names: string[] = [];
|
|
208
|
+
try {
|
|
209
|
+
names = readdirSync(advisorDir).filter(
|
|
210
|
+
(name) =>
|
|
211
|
+
name.startsWith(ADVISOR_TRANSCRIPT_PREFIX) &&
|
|
212
|
+
name.endsWith(".jsonl"),
|
|
213
|
+
);
|
|
214
|
+
} catch {
|
|
215
|
+
// No advisor directory — nothing to surface.
|
|
216
|
+
}
|
|
217
|
+
for (const name of names) {
|
|
218
|
+
const file = join(advisorDir, name);
|
|
219
|
+
let state = advisors.get(file);
|
|
220
|
+
if (state === undefined) {
|
|
221
|
+
try {
|
|
222
|
+
state = { fd: openSync(file, "r"), offset: 0, pending: Buffer.alloc(0) };
|
|
223
|
+
advisors.set(file, state);
|
|
224
|
+
} catch {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const advisorDelta = followLines(state, file);
|
|
229
|
+
changed = advisorDelta.changed || changed;
|
|
230
|
+
for (const raw of advisorDelta.lines) {
|
|
231
|
+
const rendered = renderAdvisorLine(raw.toString("utf8"));
|
|
232
|
+
if (rendered !== undefined) write(rendered);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Drop watched files that vanished (mid-run rotation is rare, but a
|
|
236
|
+
// stale descriptor would otherwise pin the old inode forever).
|
|
237
|
+
for (const [file, state] of advisors) {
|
|
238
|
+
if (!names.includes(file.slice(advisorDir.length + 1))) {
|
|
239
|
+
closeSync(state.fd);
|
|
240
|
+
advisors.delete(file);
|
|
241
|
+
}
|
|
83
242
|
}
|
|
84
|
-
if (read > 0) lastChange = Date.now();
|
|
85
243
|
}
|
|
244
|
+
if (changed) lastChange = Date.now();
|
|
86
245
|
|
|
87
246
|
// Re-read this exact run every poll — not `latestRun`, which would jump
|
|
88
247
|
// to a retry started meanwhile and report its state against the wrong
|
|
89
248
|
// transcript. The daemon writes the row from another process, so looking
|
|
90
249
|
// is the only way to notice the run finished.
|
|
91
250
|
const state = store.getRun(run.id)?.state ?? run.state;
|
|
92
|
-
if (!LIVE_STATES.includes(state) && Date.now() - lastChange >=
|
|
93
|
-
|
|
251
|
+
if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= quietMs) {
|
|
252
|
+
write(`run ended: ${state}`);
|
|
94
253
|
return;
|
|
95
254
|
}
|
|
96
|
-
await new Promise<void>((resolve) => setTimeout(resolve,
|
|
255
|
+
await new Promise<void>((resolve) => setTimeout(resolve, pollMs));
|
|
97
256
|
}
|
|
98
257
|
} finally {
|
|
99
|
-
closeSync(fd);
|
|
258
|
+
closeSync(primary.fd);
|
|
259
|
+
for (const state of advisors.values()) closeSync(state.fd);
|
|
100
260
|
}
|
|
101
261
|
} finally {
|
|
102
262
|
store.close();
|
|
@@ -104,6 +264,6 @@ async function tailRun(project: string, issue: number): Promise<void> {
|
|
|
104
264
|
}
|
|
105
265
|
|
|
106
266
|
export async function tailCommand(ctx: CommandContext): Promise<void> {
|
|
107
|
-
const issue = ctx.issueArg("tail", ctx.argv[1]);
|
|
108
|
-
await tailRun(findProject(loadConfig(), ctx.projectFlag).name, issue);
|
|
267
|
+
const issue = ctx.issueArg("tail", ctx.argv[1]);
|
|
268
|
+
await tailRun(findProject(loadConfig(), ctx.projectFlag).name, issue);
|
|
109
269
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unfreeze <repo>` — the operator's sanctioned override for a base-red-freeze.
|
|
3
|
+
*
|
|
4
|
+
* A base-red-freeze is a mechanical gate: `prMergeVerb` refuses every
|
|
5
|
+
* `conductor_pr_merge` to a repo whose base is red, and the floor binds even
|
|
6
|
+
* the orchestrator to that refusal. This verb is the *one* sanctioned way to
|
|
7
|
+
* lift it early — after the operator has judged the base repaired (or the
|
|
8
|
+
* merge warranted anyway) — and it is ledger-recorded: the freeze row keeps
|
|
9
|
+
* `clearedBy`/`clearedReason`/`clearedAt` as its durable override audit, and a
|
|
10
|
+
* material event names the override for the queue digest. It is deliberately
|
|
11
|
+
* not label surgery or a raw DB edit, and it does not re-arm — a still-red base
|
|
12
|
+
* will re-freeze the repo on the next watch observation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CommandContext } from "./context.ts";
|
|
16
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
17
|
+
import { dbPath, openStore } from "../store.ts";
|
|
18
|
+
|
|
19
|
+
export async function unfreezeCommand(ctx: CommandContext): Promise<void> {
|
|
20
|
+
const repo = ctx.argv[1];
|
|
21
|
+
if (repo === undefined || repo.length === 0 || repo.startsWith("-")) {
|
|
22
|
+
process.stderr.write("omp-conductor: unfreeze needs the routed repo name, e.g. `omp-conductor unfreeze api`\n");
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
const reason = ctx.flag("--reason") ?? "operator-override";
|
|
26
|
+
const cfg = loadConfig();
|
|
27
|
+
const project = findProject(cfg, ctx.projectFlag);
|
|
28
|
+
const store = openStore(dbPath());
|
|
29
|
+
try {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const lifted = store.clearBaseFreeze(project.name, repo, "operator", reason, now);
|
|
32
|
+
if (!lifted) {
|
|
33
|
+
process.stdout.write(
|
|
34
|
+
`unfreeze: ${repo} has no active base-red freeze in ${project.name} (merges already allowed). Nothing to lift.\n`,
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
store.recordMaterialEvent({
|
|
39
|
+
project: project.name,
|
|
40
|
+
category: "unfreeze",
|
|
41
|
+
summary: `operator unfroze merges to ${repo} (${reason})`,
|
|
42
|
+
evidence:
|
|
43
|
+
`An operator lifted the base-red-freeze on ${repo} with reason "${reason}". ` +
|
|
44
|
+
"This is the sanctioned override path — the freeze re-arms automatically if the base is " +
|
|
45
|
+
"observed red again.",
|
|
46
|
+
occurredAt: now,
|
|
47
|
+
recordedAt: now,
|
|
48
|
+
});
|
|
49
|
+
process.stdout.write(
|
|
50
|
+
`unfreeze: ${repo} base-red freeze lifted in ${project.name} (reason: ${reason}). ` +
|
|
51
|
+
"Merges to this repo resume; a still-red base will re-freeze on the next watch observation.\n",
|
|
52
|
+
);
|
|
53
|
+
} finally {
|
|
54
|
+
store.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `watch` — set a condition for the orchestrator itself, with no human in the
|
|
3
|
+
* loop (#459).
|
|
4
|
+
*
|
|
5
|
+
* The split exists because the old `decision open --resolves-when` kept
|
|
6
|
+
* producing rows rendered as questions put to the operator — the fleet looked
|
|
7
|
+
* like it was waiting on a person when it was waiting on GitHub. A watch is a
|
|
8
|
+
* row the orchestrator opened for itself: either a condition the daemon checks
|
|
9
|
+
* (`--resolves-when`) that wakes the next tick when met, or a carry note the
|
|
10
|
+
* next tick should read, neither of which ever needs an operator answer. It is
|
|
11
|
+
* distinguished durably by its `kind`, never by whether it carries a
|
|
12
|
+
* condition — a real question may carry one too.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CommandContext } from "./context.ts";
|
|
16
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
17
|
+
import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
|
|
18
|
+
import { dbPath, openStore } from "../store.ts";
|
|
19
|
+
|
|
20
|
+
export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
21
|
+
const sub = ctx.argv[1];
|
|
22
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
23
|
+
const store = openStore(dbPath());
|
|
24
|
+
try {
|
|
25
|
+
if (sub === "add") {
|
|
26
|
+
const note = ctx.flag("note")?.trim();
|
|
27
|
+
if (note === undefined || note.length === 0 || note.startsWith("--")) {
|
|
28
|
+
process.stderr.write("omp-conductor: watch add needs --note with what the next tick should know\n");
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
const condition = ctx.flag("resolves-when")?.trim();
|
|
32
|
+
if (condition !== undefined && parseCondition(condition) === undefined) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
`omp-conductor: --resolves-when must be one of:\n${CONDITION_FORMS.map((f) => ` ${f}`).join("\n")}\n`,
|
|
35
|
+
);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
const blocks = ctx.flag("blocks")?.trim();
|
|
39
|
+
const watch = store.createDecision({
|
|
40
|
+
project: project.name,
|
|
41
|
+
question: note,
|
|
42
|
+
kind: "watch",
|
|
43
|
+
...(blocks === undefined || blocks.length === 0 ? {} : { blocks }),
|
|
44
|
+
...(condition === undefined ? {} : { condition }),
|
|
45
|
+
at: Date.now(),
|
|
46
|
+
});
|
|
47
|
+
const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
|
|
48
|
+
process.stdout.write(`watch ${watch.id} added — ${wake} (no operator answer needed)\n`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sub === "list" || sub === undefined) {
|
|
53
|
+
const watches = store.openDecisions(project.name).filter((d) => d.kind === "watch");
|
|
54
|
+
if (watches.length === 0) {
|
|
55
|
+
process.stdout.write("no watches\n");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
for (const d of watches) {
|
|
60
|
+
const condition =
|
|
61
|
+
d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
|
|
62
|
+
const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
|
|
63
|
+
process.stdout.write(
|
|
64
|
+
`${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
`omp-conductor: unknown watch subcommand "${sub}" — expected add or list\n`,
|
|
72
|
+
);
|
|
73
|
+
process.exit(2);
|
|
74
|
+
} finally {
|
|
75
|
+
store.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/config-schema.ts
CHANGED
|
@@ -321,6 +321,14 @@ const projectSchema = z
|
|
|
321
321
|
// usable, exactly like `workerModel`.
|
|
322
322
|
modelFallbacks: z.unknown().optional(),
|
|
323
323
|
modelFallbackThreshold: z.unknown().optional(),
|
|
324
|
+
// The fleet-owned omp settings overlay (#537): an opaque map omp's own
|
|
325
|
+
// schema owns. Conductor validates YAML shape only — the loader keeps it
|
|
326
|
+
// when it is a mapping and drops anything else, like `modelFallbacks`.
|
|
327
|
+
ompSettings: z.unknown().optional(),
|
|
328
|
+
// Opt-in omp advisor on workers (#542). Only the literal boolean `true`
|
|
329
|
+
// opts in (it stages `advisor.enabled: true` into the omp settings
|
|
330
|
+
// overlay); anything else is dropped by the loader, like `workerModel`.
|
|
331
|
+
workerAdvisor: z.unknown().optional(),
|
|
324
332
|
escalation: escalationSchema.optional(),
|
|
325
333
|
authority: authoritySchema.optional(),
|
|
326
334
|
releasePolicy: releasePolicySchema.optional(),
|
|
@@ -344,6 +352,11 @@ const configSchema = z
|
|
|
344
352
|
$schema: z.string().optional().describe("Path to the shipped config.schema.json"),
|
|
345
353
|
version: z.number().describe(`The config format version (${READABLE_CONFIG_VERSIONS.join(" or ")})`),
|
|
346
354
|
defaults: capsSchema.optional(),
|
|
355
|
+
dbBackupDir: z
|
|
356
|
+
.string()
|
|
357
|
+
.min(1, "must name a directory")
|
|
358
|
+
.optional()
|
|
359
|
+
.describe("Absolute directory for restorable conductor.db snapshots; defaults to <stateDir>/backups/db"),
|
|
347
360
|
projects: z.array(projectSchema).min(1, `"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`),
|
|
348
361
|
})
|
|
349
362
|
.loose()
|
package/src/config.ts
CHANGED
|
@@ -124,6 +124,18 @@ export function configBackupDir(): string {
|
|
|
124
124
|
return join(stateDir(), "backups", "config");
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Where restorable `conductor.db` snapshots land: the configured `dbBackupDir`,
|
|
129
|
+
* or the state-root default `<stateDir()>/backups/db` beside the config
|
|
130
|
+
* backups when the field is absent. A `~/`-prefixed configured value is
|
|
131
|
+
* expanded like every other hand-written path field.
|
|
132
|
+
*/
|
|
133
|
+
export function dbBackupDirFor(cfg: Pick<ConductorConfig, "dbBackupDir"> | undefined): string {
|
|
134
|
+
const configured = cfg?.dbBackupDir;
|
|
135
|
+
const dir = configured !== undefined && configured.trim() !== "" ? expandHome(configured) : join(stateDir(), "backups", "db");
|
|
136
|
+
return dir;
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
/**
|
|
128
140
|
* Where per-run worktrees and bare mirrors live when a project names neither.
|
|
129
141
|
*
|
|
@@ -1099,6 +1111,47 @@ function finalizeProject(
|
|
|
1099
1111
|
? rawThreshold
|
|
1100
1112
|
: undefined;
|
|
1101
1113
|
|
|
1114
|
+
// The fleet-owned omp settings overlay (#537): an opaque map omp's own schema
|
|
1115
|
+
// owns, so the loader validates YAML shape only — a non-mapping is dropped
|
|
1116
|
+
// like an unusable `modelFallbacks` entry rather than failing the load, and
|
|
1117
|
+
// an absent field keeps today's dispatch byte for byte. Everything inside the
|
|
1118
|
+
// map is omp's to interpret, never conductor's.
|
|
1119
|
+
const rawOmpSettings = p["ompSettings"];
|
|
1120
|
+
const ompSettings =
|
|
1121
|
+
typeof rawOmpSettings === "object" && rawOmpSettings !== null && !Array.isArray(rawOmpSettings)
|
|
1122
|
+
? (rawOmpSettings as Record<string, unknown>)
|
|
1123
|
+
: undefined;
|
|
1124
|
+
|
|
1125
|
+
// Opt-in omp advisor on this project's workers (#542). `workerAdvisor: true`
|
|
1126
|
+
// stages `advisor.enabled: true` into the omp settings overlay — the same
|
|
1127
|
+
// #537 channel everything else uses — so the advisor's own behaviour (roster,
|
|
1128
|
+
// model, tools) stays omp's to resolve from the staged or global config, and
|
|
1129
|
+
// conductor never names a model or grants a tool. Only the literal boolean
|
|
1130
|
+
// `true` opts in; anything else (absent, false, a string) is dropped like an
|
|
1131
|
+
// unusable `modelFallbacks` entry, keeping today's dispatch byte for byte.
|
|
1132
|
+
const workerAdvisor = p["workerAdvisor"] === true;
|
|
1133
|
+
const effectiveOmpSettings =
|
|
1134
|
+
workerAdvisor || ompSettings !== undefined
|
|
1135
|
+
? {
|
|
1136
|
+
...(ompSettings === undefined ? {} : ompSettings),
|
|
1137
|
+
...(workerAdvisor
|
|
1138
|
+
? {
|
|
1139
|
+
// Merge, not replace: an operator's own `ompSettings.advisor`
|
|
1140
|
+
// block (roster or model overrides) survives; only `enabled` is
|
|
1141
|
+
// forced on, because that is the point of the opt-in.
|
|
1142
|
+
advisor: {
|
|
1143
|
+
...(typeof ompSettings?.advisor === "object" &&
|
|
1144
|
+
ompSettings.advisor !== null &&
|
|
1145
|
+
!Array.isArray(ompSettings.advisor)
|
|
1146
|
+
? (ompSettings.advisor as Record<string, unknown>)
|
|
1147
|
+
: {}),
|
|
1148
|
+
enabled: true,
|
|
1149
|
+
},
|
|
1150
|
+
}
|
|
1151
|
+
: {}),
|
|
1152
|
+
}
|
|
1153
|
+
: undefined;
|
|
1154
|
+
|
|
1102
1155
|
// Marked critical-base/safety markers (commit SHAs or refs). A continuation
|
|
1103
1156
|
// branch must contain every one before the dispatcher may reattach it;
|
|
1104
1157
|
// unusable entries are dropped like `modelFallbacks` and an absent or empty
|
|
@@ -1128,6 +1181,7 @@ function finalizeProject(
|
|
|
1128
1181
|
...(workerModel === undefined ? {} : { workerModel }),
|
|
1129
1182
|
...(modelFallbacks.length === 0 ? {} : { modelFallbacks }),
|
|
1130
1183
|
...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
|
|
1184
|
+
...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }),
|
|
1131
1185
|
escalation,
|
|
1132
1186
|
authority,
|
|
1133
1187
|
releasePolicy,
|