omp-conductor 0.10.0 → 0.13.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/README.md +73 -19
- package/package.json +1 -1
- package/src/board.ts +125 -11
- package/src/briefs/orchestrator.md +50 -4
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +188 -19
- package/src/config.ts +195 -15
- package/src/daemon.ts +1017 -119
- package/src/diff-flags.ts +48 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +31 -4
- package/src/fleet.ts +8 -3
- package/src/gitops.ts +49 -0
- package/src/omp.ts +44 -9
- package/src/orchestrator-tick.ts +94 -9
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +46 -4
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +5 -6
- package/src/session-host.ts +23 -6
- package/src/setup.ts +43 -10
- package/src/store.ts +263 -31
- package/src/tracker/github.ts +261 -56
- package/src/types.ts +251 -32
- package/src/verbs/actions.ts +127 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +144 -13
- package/src/worker.ts +183 -17
- package/src/worktree.ts +5 -0
package/src/plugin.ts
CHANGED
|
@@ -91,6 +91,7 @@ import {
|
|
|
91
91
|
type ProjectPolicy,
|
|
92
92
|
type ReleaseRequirement,
|
|
93
93
|
type ReportScope,
|
|
94
|
+
type ReportScopeChoice,
|
|
94
95
|
type ResolvedGrants,
|
|
95
96
|
} from "./types.ts";
|
|
96
97
|
|
|
@@ -309,7 +310,7 @@ async function askGates(
|
|
|
309
310
|
* means silence. The cursor starts on the current setting so Enter re-affirms
|
|
310
311
|
* it, the same contract every other prompt here has.
|
|
311
312
|
*/
|
|
312
|
-
async function askReportScope(ctx: CommandContext, current:
|
|
313
|
+
async function askReportScope(ctx: CommandContext, current: ReportScopeChoice): Promise<ReportScopeChoice> {
|
|
313
314
|
const options = REPORT_SCOPE_CHOICES.map((c) => ({ label: c.label, description: c.description }));
|
|
314
315
|
const at = REPORT_SCOPE_CHOICES.findIndex((c) => c.scope === current);
|
|
315
316
|
const picked = await ctx.ui.select("What should the orchestrator report unprompted?", options, {
|
|
@@ -793,7 +794,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
793
794
|
const tuneCaps = await ctx.ui.confirm(
|
|
794
795
|
"Caps",
|
|
795
796
|
`Defaults: ${workersDefault} workers, ` +
|
|
796
|
-
`${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns}
|
|
797
|
+
`${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` +
|
|
798
|
+
`${DEFAULT_CAPS.workerMaxTurnsCeiling} max turns and ` +
|
|
797
799
|
`${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
|
|
798
800
|
`${DEFAULT_CAPS.maxAttemptsPerIssue} failed attempts and ` +
|
|
799
801
|
`${DEFAULT_CAPS.maxContinuationsPerIssue} operational continuations per issue.${smallHostNote} Change them?`,
|
|
@@ -820,7 +822,32 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
820
822
|
"Spend ceiling per rolling day (USD) — blank = no spend cap",
|
|
821
823
|
caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
|
|
822
824
|
);
|
|
823
|
-
|
|
825
|
+
const workerMaxTurns = await askNumber(
|
|
826
|
+
ctx,
|
|
827
|
+
"Turn ceiling per worker",
|
|
828
|
+
caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns,
|
|
829
|
+
);
|
|
830
|
+
caps.workerMaxTurns = workerMaxTurns;
|
|
831
|
+
const turnCeilingFallback = Math.max(
|
|
832
|
+
workerMaxTurns,
|
|
833
|
+
caps.workerMaxTurnsCeiling ??
|
|
834
|
+
Math.min(workerMaxTurns * 2, Number.MAX_SAFE_INTEGER),
|
|
835
|
+
);
|
|
836
|
+
const workerMaxTurnsCeiling = await askNumber(
|
|
837
|
+
ctx,
|
|
838
|
+
"Maximum turn ceiling for one issue",
|
|
839
|
+
turnCeilingFallback,
|
|
840
|
+
);
|
|
841
|
+
if (workerMaxTurnsCeiling < workerMaxTurns) {
|
|
842
|
+
ctx.ui.notify(
|
|
843
|
+
`Maximum turn ceiling ${workerMaxTurnsCeiling} cannot be below the ` +
|
|
844
|
+
`${workerMaxTurns}-turn worker base — keeping ${turnCeilingFallback}.`,
|
|
845
|
+
"warning",
|
|
846
|
+
);
|
|
847
|
+
caps.workerMaxTurnsCeiling = turnCeilingFallback;
|
|
848
|
+
} else {
|
|
849
|
+
caps.workerMaxTurnsCeiling = workerMaxTurnsCeiling;
|
|
850
|
+
}
|
|
824
851
|
caps.workerWallClockMs = await askNumber(
|
|
825
852
|
ctx,
|
|
826
853
|
"Wall-clock ceiling per worker (ms)",
|
|
@@ -898,7 +925,22 @@ const askEscalation: AreaAsker = async (ctx, a) => {
|
|
|
898
925
|
};
|
|
899
926
|
|
|
900
927
|
/** How loud the orchestrator is when nobody asked it anything. */
|
|
901
|
-
const askReporting: AreaAsker = async (ctx, a) =>
|
|
928
|
+
const askReporting: AreaAsker = async (ctx, a) => {
|
|
929
|
+
const reportScope = await askReportScope(ctx, a.reportScope);
|
|
930
|
+
if (reportScope !== "quiet") return { ...a, reportScope };
|
|
931
|
+
// `quiet` picks the explicit form, whose only free parameter is when the
|
|
932
|
+
// daily rollup happens. Blank = whenever the orchestrator composes it.
|
|
933
|
+
const at = await ctx.ui.input(
|
|
934
|
+
"Daily rollup time, 24h HH:MM (blank = whenever the orchestrator composes it):",
|
|
935
|
+
a.quietDigestAt,
|
|
936
|
+
);
|
|
937
|
+
const trimmed = at?.trim() ?? "";
|
|
938
|
+
if (trimmed !== "" && !/^([01]\d|2[0-3]):[0-5]\d$/.test(trimmed)) {
|
|
939
|
+
ctx.ui.notify(`"${trimmed}" is not a 24h HH:MM time — leaving the digest model-timed.`, "warning");
|
|
940
|
+
return { ...a, reportScope };
|
|
941
|
+
}
|
|
942
|
+
return { ...a, reportScope, ...(trimmed === "" ? {} : { quietDigestAt: trimmed }) };
|
|
943
|
+
};
|
|
902
944
|
|
|
903
945
|
/** The operator's own brief. Asked last in the full interview, because the
|
|
904
946
|
* question quotes the path the rest of the answers derive. */
|
package/src/release-policy.ts
CHANGED
|
@@ -36,11 +36,19 @@ import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
|
36
36
|
|
|
37
37
|
export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl";
|
|
38
38
|
|
|
39
|
-
export interface
|
|
39
|
+
export interface ReleaseBlockContext {
|
|
40
|
+
tool: string;
|
|
41
|
+
reason: string;
|
|
42
|
+
args: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
|
|
40
46
|
project: string;
|
|
41
47
|
source: "worker" | "orchestrator";
|
|
42
48
|
shape: ReleaseShape;
|
|
43
49
|
at: string;
|
|
50
|
+
issue?: number;
|
|
51
|
+
runId?: string;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export type ReleaseDecision = { block: true; reason: string };
|
|
@@ -271,6 +279,32 @@ export function releaseDecision(
|
|
|
271
279
|
return decision === undefined ? undefined : { shape, decision };
|
|
272
280
|
}
|
|
273
281
|
|
|
282
|
+
const RELEASE_ARG_ALLOWLIST = new Set([
|
|
283
|
+
"repo",
|
|
284
|
+
"stack",
|
|
285
|
+
"artefact",
|
|
286
|
+
"artifact",
|
|
287
|
+
"environment",
|
|
288
|
+
"tag",
|
|
289
|
+
"version",
|
|
290
|
+
"image",
|
|
291
|
+
"prUrl",
|
|
292
|
+
"branch",
|
|
293
|
+
"service",
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
/** Keep only explicitly safe release-target strings; redact every other value. */
|
|
297
|
+
export function redactReleaseArgs(input: Record<string, unknown>): Record<string, string> {
|
|
298
|
+
const redacted: Record<string, string> = {};
|
|
299
|
+
for (const [key, value] of Object.entries(input)) {
|
|
300
|
+
redacted[key] =
|
|
301
|
+
RELEASE_ARG_ALLOWLIST.has(key) && typeof value === "string"
|
|
302
|
+
? value.slice(0, 120)
|
|
303
|
+
: "(redacted)";
|
|
304
|
+
}
|
|
305
|
+
return redacted;
|
|
306
|
+
}
|
|
307
|
+
|
|
274
308
|
interface ReleasePolicyPi {
|
|
275
309
|
on(
|
|
276
310
|
event: "tool_call",
|
|
@@ -285,14 +319,18 @@ interface ReleasePolicyPi {
|
|
|
285
319
|
export function releasePolicyTripwire(
|
|
286
320
|
grants: ResolvedGrants,
|
|
287
321
|
role: SessionRole,
|
|
288
|
-
onBlocked: (shape: ReleaseShape) => void = () => {},
|
|
322
|
+
onBlocked: (shape: ReleaseShape, context: ReleaseBlockContext) => void = () => {},
|
|
289
323
|
): (pi: ReleasePolicyPi) => void {
|
|
290
324
|
return (pi) => {
|
|
291
325
|
pi.on("tool_call", (event) => {
|
|
292
326
|
const blocked = releaseDecision(grants, role, event.toolName, event.input);
|
|
293
327
|
if (blocked === undefined) return undefined;
|
|
294
328
|
try {
|
|
295
|
-
onBlocked(blocked.shape
|
|
329
|
+
onBlocked(blocked.shape, {
|
|
330
|
+
tool: event.toolName,
|
|
331
|
+
reason: blocked.decision.reason,
|
|
332
|
+
args: redactReleaseArgs(event.input),
|
|
333
|
+
});
|
|
296
334
|
} catch {
|
|
297
335
|
// Audit is evidence, not the gate. A full disk must not turn a deny into allow.
|
|
298
336
|
}
|
|
@@ -305,11 +343,22 @@ export function recordReleaseBlock(
|
|
|
305
343
|
project: string,
|
|
306
344
|
source: ReleaseBlock["source"],
|
|
307
345
|
shape: ReleaseShape,
|
|
346
|
+
details: Omit<ReleaseBlock, "project" | "source" | "shape" | "at"> = {},
|
|
308
347
|
root = stateDir(),
|
|
309
348
|
now = new Date(),
|
|
310
349
|
): void {
|
|
311
350
|
mkdirSync(root, { recursive: true });
|
|
312
|
-
|
|
351
|
+
// Redact again at the durable boundary. Callers normally pass the tripwire's
|
|
352
|
+
// already-safe context, but no alternate recorder path gets to rely on that.
|
|
353
|
+
const { args, ...attribution } = details;
|
|
354
|
+
const record: ReleaseBlock = {
|
|
355
|
+
project,
|
|
356
|
+
source,
|
|
357
|
+
shape,
|
|
358
|
+
...attribution,
|
|
359
|
+
...(args === undefined ? {} : { args: redactReleaseArgs(args) }),
|
|
360
|
+
at: now.toISOString(),
|
|
361
|
+
};
|
|
313
362
|
appendFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
314
363
|
}
|
|
315
364
|
|
|
@@ -354,12 +403,23 @@ export function releaseDriftToday(
|
|
|
354
403
|
return latest === undefined ? undefined : { count, latest };
|
|
355
404
|
}
|
|
356
405
|
|
|
357
|
-
export function releaseDriftDigestLine(
|
|
406
|
+
export function releaseDriftDigestLine(
|
|
407
|
+
project: string,
|
|
408
|
+
root = stateDir(),
|
|
409
|
+
now = new Date(),
|
|
410
|
+
): string | undefined {
|
|
358
411
|
const drift = releaseDriftToday(project, root, now);
|
|
359
412
|
if (drift === undefined) return undefined;
|
|
413
|
+
const latest = drift.latest;
|
|
414
|
+
const attribution = [
|
|
415
|
+
`${latest.source} ${latest.shape} at ${latest.at}`,
|
|
416
|
+
...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]),
|
|
417
|
+
...(latest.runId === undefined ? [] : [`run ${latest.runId}`]),
|
|
418
|
+
...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]),
|
|
419
|
+
].join(", ");
|
|
360
420
|
return (
|
|
361
421
|
`Release-policy drift today: ${drift.count} release/deploy tool call(s) were blocked ` +
|
|
362
|
-
`(latest: ${
|
|
422
|
+
`(latest: ${attribution}). ` +
|
|
363
423
|
"Include this divergence from releasePolicy=none in today's digest."
|
|
364
424
|
);
|
|
365
425
|
}
|
package/src/reports.ts
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import { readTelegramToken, sendTelegram, TelegramSendError } from "./escalate.ts";
|
|
40
|
+
import { localDayKey } from "./digest-schedule.ts";
|
|
40
41
|
import type { Escalation, ProjectConfig, ReportRecord, Store } from "./types.ts";
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -120,13 +121,11 @@ export interface ReportOutboxDeps {
|
|
|
120
121
|
/**
|
|
121
122
|
* Local day, matching how a human reads "one daily digest" and how the
|
|
122
123
|
* dispatcher's own `startOfToday` reads "today". A UTC key would roll the
|
|
123
|
-
* digest over mid-evening for anyone west of Greenwich
|
|
124
|
+
* digest over mid-evening for anyone west of Greenwich; the zone is the
|
|
125
|
+
* project's `reporting.digest.timezone` when set, else the host zone (#229).
|
|
124
126
|
*/
|
|
125
|
-
export function digestDedupeKey(at: number): string {
|
|
126
|
-
|
|
127
|
-
const month = `${d.getMonth() + 1}`.padStart(2, "0");
|
|
128
|
-
const day = `${d.getDate()}`.padStart(2, "0");
|
|
129
|
-
return `digest:${d.getFullYear()}-${month}-${day}`;
|
|
127
|
+
export function digestDedupeKey(at: number, timezone?: string): string {
|
|
128
|
+
return `digest:${localDayKey(at, timezone)}`;
|
|
130
129
|
}
|
|
131
130
|
|
|
132
131
|
/** Exponential, capped. `attempts` is attempts *started*, so the first failure
|
package/src/session-host.ts
CHANGED
|
@@ -9,19 +9,20 @@
|
|
|
9
9
|
* fleet, and what makes a kill a real kill.
|
|
10
10
|
*
|
|
11
11
|
* This file is the far side: it loads the harness, runs the real session, and
|
|
12
|
-
* speaks a
|
|
12
|
+
* speaks a small protocol back over a unix socket to the
|
|
13
13
|
* {@link AgentSessionLike} proxy in `omp.ts`. It holds no conductor state, opens
|
|
14
14
|
* no database, and reads no config — everything it needs arrives in
|
|
15
15
|
* {@link SessionHostSpec}, so the child's inputs are data a caller can see
|
|
16
16
|
* rather than ambient state it inherits.
|
|
17
17
|
*
|
|
18
|
-
* The protocol
|
|
19
|
-
*
|
|
18
|
+
* The protocol deliberately mirrors the narrow session surface and carries
|
|
19
|
+
* only data.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { connect } from "node:net";
|
|
23
23
|
|
|
24
24
|
import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
|
|
25
|
+
import type { ReleaseBlockContext } from "./release-policy.ts";
|
|
25
26
|
|
|
26
27
|
import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
27
28
|
|
|
@@ -53,6 +54,7 @@ export interface SessionHostSpec {
|
|
|
53
54
|
/** Parent → child. */
|
|
54
55
|
export type ParentToHost =
|
|
55
56
|
| { t: "prompt"; id: number; text: string; opts?: Record<string, unknown> }
|
|
57
|
+
| { t: "park"; id: number }
|
|
56
58
|
| { t: "abort" }
|
|
57
59
|
| { t: "dispose" };
|
|
58
60
|
|
|
@@ -63,7 +65,8 @@ export type HostToParent =
|
|
|
63
65
|
| { t: "event"; event: unknown }
|
|
64
66
|
| { t: "session-file"; path: string }
|
|
65
67
|
| { t: "prompt-result"; id: number; ok: boolean; error?: string }
|
|
66
|
-
| { t: "
|
|
68
|
+
| { t: "park-result"; id: number; ok: boolean; error?: string }
|
|
69
|
+
| ({ t: "release-blocked"; shape: ReleaseShape } & ReleaseBlockContext);
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
72
|
* Depth at which a harness event stops being copied for the wire.
|
|
@@ -184,8 +187,8 @@ export async function runSessionHost(
|
|
|
184
187
|
// The release audit lives in the daemon's state directory, which this
|
|
185
188
|
// process may not be able to write and must not be trusted to. It
|
|
186
189
|
// becomes a message; the parent performs the durable write.
|
|
187
|
-
onReleaseBlocked: (shape) => {
|
|
188
|
-
send({ t: "release-blocked", shape });
|
|
190
|
+
onReleaseBlocked: (shape, context) => {
|
|
191
|
+
send({ t: "release-blocked", shape, ...context });
|
|
189
192
|
},
|
|
190
193
|
});
|
|
191
194
|
} catch (err) {
|
|
@@ -240,6 +243,20 @@ export async function runSessionHost(
|
|
|
240
243
|
);
|
|
241
244
|
continue;
|
|
242
245
|
}
|
|
246
|
+
if (message.t === "park") {
|
|
247
|
+
const id = message.id;
|
|
248
|
+
void live.park().then(
|
|
249
|
+
() => send({ t: "park-result", id, ok: true }),
|
|
250
|
+
(err: unknown) =>
|
|
251
|
+
send({
|
|
252
|
+
t: "park-result",
|
|
253
|
+
id,
|
|
254
|
+
ok: false,
|
|
255
|
+
error: err instanceof Error ? err.message : String(err),
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
243
260
|
if (message.t === "abort") {
|
|
244
261
|
live.abort();
|
|
245
262
|
continue;
|
package/src/setup.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
resolveCaps,
|
|
41
41
|
resolvePolicy,
|
|
42
42
|
resolveReleaseGrants,
|
|
43
|
+
SCOPE_PRESETS,
|
|
43
44
|
stateDir,
|
|
44
45
|
} from "./config.ts";
|
|
45
46
|
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
@@ -61,6 +62,8 @@ import {
|
|
|
61
62
|
type ProjectPolicy,
|
|
62
63
|
type ReleaseRequirement,
|
|
63
64
|
type ReportScope,
|
|
65
|
+
type ReportScopeChoice,
|
|
66
|
+
type ReportingPolicy,
|
|
64
67
|
type RepoTarget,
|
|
65
68
|
type ResolvedGrants,
|
|
66
69
|
} from "./types.ts";
|
|
@@ -88,7 +91,9 @@ export interface SetupAnswers {
|
|
|
88
91
|
telegramChatId?: string;
|
|
89
92
|
fallbackToIssueComment: boolean;
|
|
90
93
|
/** How loud the supervising orchestrator session should be. */
|
|
91
|
-
reportScope:
|
|
94
|
+
reportScope: ReportScopeChoice;
|
|
95
|
+
/** The daily digest wall-clock (`HH:MM`) for the `quiet` choice, blank = model-timed. */
|
|
96
|
+
quietDigestAt?: string;
|
|
92
97
|
/**
|
|
93
98
|
* Mechanical gate for release/deploy-shaped tool calls, per shape. Always
|
|
94
99
|
* complete: the wizard asks about every shape, so an answer object can never
|
|
@@ -175,7 +180,11 @@ export const SETUP_DEFAULTS = {
|
|
|
175
180
|
* brief spells the same three options out — so "material" cannot come to mean
|
|
176
181
|
* one thing in the dialog and another in the session that has to honour it.
|
|
177
182
|
*/
|
|
178
|
-
export const REPORT_SCOPE_CHOICES: readonly {
|
|
183
|
+
export const REPORT_SCOPE_CHOICES: readonly {
|
|
184
|
+
scope: ReportScopeChoice;
|
|
185
|
+
label: string;
|
|
186
|
+
description: string;
|
|
187
|
+
}[] = [
|
|
179
188
|
{
|
|
180
189
|
scope: "decisions",
|
|
181
190
|
label: "Decisions interrupt, rest batches",
|
|
@@ -192,6 +201,12 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
|
|
|
192
201
|
label: "Escalations only",
|
|
193
202
|
description: "escalations when they happen, plus one daily digest — silent otherwise",
|
|
194
203
|
},
|
|
204
|
+
{
|
|
205
|
+
scope: "quiet",
|
|
206
|
+
label: "Quiet (scheduled digest)",
|
|
207
|
+
description:
|
|
208
|
+
"interrupt only for escalations, fleet stops and confirmed failures; one daily rollup on your schedule",
|
|
209
|
+
},
|
|
195
210
|
];
|
|
196
211
|
|
|
197
212
|
/**
|
|
@@ -204,7 +219,7 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
|
|
|
204
219
|
* answers "an operator being asked the question for the first time", where the
|
|
205
220
|
* recommended answer is the useful one — and they see it, and confirm it.
|
|
206
221
|
*/
|
|
207
|
-
export const SETUP_DEFAULT_REPORT_SCOPE:
|
|
222
|
+
export const SETUP_DEFAULT_REPORT_SCOPE: ReportScopeChoice = REPORT_SCOPE_CHOICES[0]!.scope;
|
|
208
223
|
|
|
209
224
|
/**
|
|
210
225
|
* What each precondition value means to the operator being asked about it, in
|
|
@@ -236,6 +251,7 @@ export const RELEASE_REQUIREMENT_CHOICES: { readonly [K in ReleaseRequirement]:
|
|
|
236
251
|
"runs-settled": "every run this release covers actually merged, not merely reached a green PR",
|
|
237
252
|
"no-open-prs": "no pull request is still open against the branch being released",
|
|
238
253
|
"queue-drained": "nothing still carries the queue label",
|
|
254
|
+
"base-branch-green": "the newest observed post-merge base-branch workflows are green",
|
|
239
255
|
"epic-children-closed": "the epic this release closes has no open children",
|
|
240
256
|
};
|
|
241
257
|
|
|
@@ -560,8 +576,24 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
560
576
|
// and a release require without anyone having to know a default (#129).
|
|
561
577
|
policy: clonePolicy(a.policy),
|
|
562
578
|
// Written out even when it is the default, so an operator amending the
|
|
563
|
-
// volume has a line in the file to point at.
|
|
564
|
-
|
|
579
|
+
// volume has a line in the file to point at. A `scope` answer is
|
|
580
|
+
// materialised as its preset policy (with `scopePreset` set) so the file
|
|
581
|
+
// and the prompt agree with no migration rule; `quiet` is the explicit
|
|
582
|
+
// form — a curated interrupt list and a scheduled digest (#229).
|
|
583
|
+
reporting:
|
|
584
|
+
a.reportScope === "quiet"
|
|
585
|
+
? {
|
|
586
|
+
interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
|
|
587
|
+
digest: {
|
|
588
|
+
cadence: "daily",
|
|
589
|
+
...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
|
|
590
|
+
},
|
|
591
|
+
}
|
|
592
|
+
: {
|
|
593
|
+
interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
|
|
594
|
+
digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
|
|
595
|
+
scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
|
|
596
|
+
},
|
|
565
597
|
// Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
|
|
566
598
|
// uninstall, and neither can land in a repo the daemon then tries to commit.
|
|
567
599
|
workspaceRoot: defaultWorkspaceRoot(),
|
|
@@ -667,7 +699,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
667
699
|
releaseGrants: resolveReleaseGrants(p),
|
|
668
700
|
policy: resolvePolicy(p),
|
|
669
701
|
orchestratorMode: p.escalation.orchestrator,
|
|
670
|
-
reportScope: p.reporting?.
|
|
702
|
+
reportScope: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
|
|
671
703
|
writeOrchestratorBrief: false,
|
|
672
704
|
};
|
|
673
705
|
|
|
@@ -704,7 +736,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
|
|
|
704
736
|
QUEUE_LABEL: p.queueLabel,
|
|
705
737
|
RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
|
|
706
738
|
MERGE_DUTY: MERGE_DUTY[p.authority.merge],
|
|
707
|
-
REPORT_SCOPE: p.reporting?.
|
|
739
|
+
REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
|
|
708
740
|
POLICY_SOURCE: policySourceLine(p),
|
|
709
741
|
};
|
|
710
742
|
}
|
|
@@ -1160,14 +1192,15 @@ export const AMEND_AREAS: {
|
|
|
1160
1192
|
// The model rides with the caps because it is the other per-worker knob, and
|
|
1161
1193
|
// an area no menu offers is a setting only a full re-interview can reach.
|
|
1162
1194
|
name: "caps & worker model",
|
|
1163
|
-
asks: "concurrency, spend,
|
|
1195
|
+
asks: "concurrency, spend, turn base and extension ceiling, wall clock, failed attempts, continuations — then the worker model",
|
|
1164
1196
|
describe: (p) => {
|
|
1165
1197
|
const c = resolveCaps(p, DEFAULT_CAPS);
|
|
1166
1198
|
const answered = Object.keys(p.caps).length > 0;
|
|
1167
1199
|
const spend =
|
|
1168
1200
|
c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
|
|
1169
1201
|
return (
|
|
1170
|
-
`${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo),
|
|
1202
|
+
`${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo), ` +
|
|
1203
|
+
`${c.workerMaxTurns} base/${c.workerMaxTurnsCeiling} max turns, ` +
|
|
1171
1204
|
`${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` +
|
|
1172
1205
|
`${c.maxAttemptsPerIssue} failed attempt${c.maxAttemptsPerIssue === 1 ? "" : "s"}, ` +
|
|
1173
1206
|
`${c.maxContinuationsPerIssue} continuation${c.maxContinuationsPerIssue === 1 ? "" : "s"}` +
|
|
@@ -1234,7 +1267,7 @@ export const AMEND_AREAS: {
|
|
|
1234
1267
|
name: "reporting scope",
|
|
1235
1268
|
asks: "how much the orchestrator says unprompted",
|
|
1236
1269
|
describe: (p) => {
|
|
1237
|
-
const scope = p.reporting?.
|
|
1270
|
+
const scope = p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE;
|
|
1238
1271
|
const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
|
|
1239
1272
|
return `${scope} — ${choice?.description ?? "unknown scope"}`;
|
|
1240
1273
|
},
|