omp-conductor 0.10.0 → 0.12.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 +35 -2
- package/package.json +1 -1
- package/src/board.ts +124 -10
- package/src/briefs/orchestrator.md +18 -2
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +90 -2
- package/src/config.ts +185 -13
- package/src/daemon.ts +238 -27
- package/src/diff-flags.ts +44 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +25 -0
- package/src/fleet.ts +1 -1
- package/src/gitops.ts +49 -0
- package/src/omp.ts +36 -5
- package/src/orchestrator-tick.ts +73 -3
- package/src/plugin.ts +18 -2
- package/src/reports.ts +5 -6
- package/src/session-host.ts +19 -3
- package/src/setup.ts +39 -8
- package/src/store.ts +73 -2
- package/src/types.ts +110 -4
- package/src/verbs/server.ts +49 -0
- package/src/worker.ts +152 -14
package/src/failure-class.ts
CHANGED
|
@@ -144,6 +144,16 @@ export function providerCreditRefusal(error: {
|
|
|
144
144
|
return error.message.split("\n")[0]?.trim() ?? error.message;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Provider text that names a per-request stream fault. Deliberately narrow:
|
|
148
|
+
* a 429 is rate limiting and a 402 is credit — different remedies (#220). */
|
|
149
|
+
const TRANSIENT_FAULT_SIGNATURES = ["stream stalled"] as const;
|
|
150
|
+
|
|
151
|
+
export function providerTransientFault(error: { status?: number; message: string }): string | undefined {
|
|
152
|
+
const text = error.message.toLowerCase();
|
|
153
|
+
if (!TRANSIENT_FAULT_SIGNATURES.some((s) => text.includes(s))) return undefined;
|
|
154
|
+
return error.message.split("\n")[0]?.trim() ?? error.message;
|
|
155
|
+
}
|
|
156
|
+
|
|
147
157
|
/**
|
|
148
158
|
* Evidence that this run never started, or `undefined` when something did happen.
|
|
149
159
|
*
|
|
@@ -248,6 +258,21 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
248
258
|
}
|
|
249
259
|
}
|
|
250
260
|
|
|
261
|
+
// A per-request stream fault: the provider aborted mid-stream — the session
|
|
262
|
+
// records "OpenAI responses stream stalled while waiting for the next event",
|
|
263
|
+
// but the attempt never produced a verdict and 0 tokens were billed. Not the
|
|
264
|
+
// work's fault, so it must not charge an implementation attempt; requeued, but
|
|
265
|
+
// bounded by PROVIDER_TRANSIENT_MAX_STRIKES so a provider that keeps aborting
|
|
266
|
+
// escalates to a human instead of looping (#220). Ahead of `neverStarted` for
|
|
267
|
+
// the same reason as the credit branch: a turn-0 stall would otherwise be
|
|
268
|
+
// absorbed by `env-start-failure` and lose its cause.
|
|
269
|
+
if (run.state === "failed" || run.state === "killed") {
|
|
270
|
+
const transient = run.lastError === undefined ? undefined : providerTransientFault({ message: run.lastError });
|
|
271
|
+
if (transient !== undefined) {
|
|
272
|
+
return { cls: "provider-transient", recovery: "requeue", evidence: transient };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
251
276
|
// A run that never started is the most classifiable failure there is, and the
|
|
252
277
|
// least deserving of an implementation attempt: the session did not get as far
|
|
253
278
|
// as reading the issue. See {@link neverStarted} for the two shapes and why the
|
package/src/fleet.ts
CHANGED
|
@@ -1381,7 +1381,7 @@ export async function probeTelegramHealth(
|
|
|
1381
1381
|
detail: `${username}; inbound configured; interactive profile relays assistant text — /telegram set profile daemon`,
|
|
1382
1382
|
};
|
|
1383
1383
|
}
|
|
1384
|
-
return { kind: "ok", detail: `${username}; inbound configured; telegram_ask
|
|
1384
|
+
return { kind: "ok", detail: `${username}; inbound configured; telegram_ask expected per config (mounted per turn by omp-telegram — a tick that finds it missing falls back to telegram_send and says so)` };
|
|
1385
1385
|
}
|
|
1386
1386
|
|
|
1387
1387
|
export function sessionDirForCwd(cwd: string): string {
|
package/src/gitops.ts
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
|
|
22
|
+
import { parseChainSource, type ChainEntry } from "./chain-check.ts";
|
|
22
23
|
import type { ProjectConfig, RepoTarget } from "./types.ts";
|
|
24
|
+
import { ensureMirror } from "./worktree.ts";
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* One process run, captured. Injected everywhere in this module so the
|
|
@@ -190,3 +192,50 @@ export async function openRunPr(
|
|
|
190
192
|
}
|
|
191
193
|
return { ok: true, url };
|
|
192
194
|
}
|
|
195
|
+
|
|
196
|
+
// ------------------------------------------------- the migration-chain guard
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The base branch's ordered migration chain, read at the tip — not from a
|
|
200
|
+
* checkout that might be stale, but from the live mirror `ensureMirror`
|
|
201
|
+
* refreshes every call, which is the same freshness contract the dispatch path
|
|
202
|
+
* relies on. Alembic fields only; files without a `revision` line are skipped.
|
|
203
|
+
*/
|
|
204
|
+
export async function readBaseChain(
|
|
205
|
+
project: Pick<ProjectConfig, "mirrorRoot">,
|
|
206
|
+
repo: RepoTarget,
|
|
207
|
+
dir: string,
|
|
208
|
+
exec: Exec = spawnCaptured,
|
|
209
|
+
): Promise<{ ok: true; entries: ChainEntry[] } | { ok: false; stderr: string }> {
|
|
210
|
+
try {
|
|
211
|
+
const mirror = await ensureMirror(repo, project.mirrorRoot);
|
|
212
|
+
const env = credentialedEnv();
|
|
213
|
+
const listed = await exec(
|
|
214
|
+
["git", "--git-dir", mirror, "ls-tree", "-r", "--name-only", repo.defaultBranch, "--", dir],
|
|
215
|
+
{ env },
|
|
216
|
+
);
|
|
217
|
+
if (listed.code !== 0) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
stderr: scrubUserinfo(listed.stderr.trim() || listed.stdout.trim() || `git ls-tree exited ${String(listed.code)}`),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const entries: ChainEntry[] = [];
|
|
224
|
+
for (const rel of listed.stdout.split("\n")) {
|
|
225
|
+
const path = rel.trim();
|
|
226
|
+
if (path === "" || !path.endsWith(".py")) continue;
|
|
227
|
+
const shown = await exec(["git", "--git-dir", mirror, "show", `${repo.defaultBranch}:${path}`], { env });
|
|
228
|
+
if (shown.code !== 0) {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
stderr: scrubUserinfo(shown.stderr.trim() || `git show ${path} exited ${String(shown.code)}`),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const parsed = parseChainSource(path, shown.stdout);
|
|
235
|
+
if (parsed !== undefined) entries.push(parsed);
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, entries };
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return { ok: false, stderr: err instanceof Error ? err.message : String(err) };
|
|
240
|
+
}
|
|
241
|
+
}
|
package/src/omp.ts
CHANGED
|
@@ -46,6 +46,12 @@ export interface AgentSessionLike {
|
|
|
46
46
|
*/
|
|
47
47
|
on(event: string, cb: (e: unknown) => void): void;
|
|
48
48
|
abort(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Cooperative operator park: abort the active turn and resolve when the
|
|
51
|
+
* harness is idle (#238). Distinct from `abort()`, which is the
|
|
52
|
+
* fire-and-forget kill path and must stay cheap for cap kills.
|
|
53
|
+
*/
|
|
54
|
+
park(): Promise<void>;
|
|
49
55
|
/**
|
|
50
56
|
* Absolute transcript path the harness opened, so a human — and the
|
|
51
57
|
* arm/monitor tooling — can read what the worker actually did. `undefined`
|
|
@@ -93,7 +99,11 @@ interface OmpModule {
|
|
|
93
99
|
interface RawSession {
|
|
94
100
|
prompt(text: string, opts?: unknown): Promise<unknown>;
|
|
95
101
|
subscribe(listener: (event: unknown) => void): unknown;
|
|
96
|
-
abort(opts?:
|
|
102
|
+
abort(opts?: {
|
|
103
|
+
goalReason?: "interrupted" | "internal";
|
|
104
|
+
reason?: string;
|
|
105
|
+
preserveCompaction?: boolean;
|
|
106
|
+
}): Promise<void> | void;
|
|
97
107
|
dispose?(opts?: unknown): Promise<unknown>;
|
|
98
108
|
readonly sessionFile?: string;
|
|
99
109
|
}
|
|
@@ -262,6 +272,11 @@ export async function createLocalSession(opts: {
|
|
|
262
272
|
abort() {
|
|
263
273
|
raw.abort();
|
|
264
274
|
},
|
|
275
|
+
park() {
|
|
276
|
+
return Promise.resolve(
|
|
277
|
+
raw.abort({ goalReason: "interrupted", reason: "operator pause" }),
|
|
278
|
+
).then(() => undefined);
|
|
279
|
+
},
|
|
265
280
|
// The path the session actually opened, never one we asked for: the
|
|
266
281
|
// arm/monitor tooling reads this file as proof of activity, so a path
|
|
267
282
|
// nothing ever writes to is worse than no path at all.
|
|
@@ -421,10 +436,10 @@ export interface CreateSessionOptions {
|
|
|
421
436
|
* Start one omp coding session in a **child process** and return a proxy for it.
|
|
422
437
|
*
|
|
423
438
|
* The proxy is the whole of `omp-conductor`'s view of a session, and it is
|
|
424
|
-
* deliberately thin: {@link AgentSessionLike}
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
439
|
+
* deliberately thin: {@link AgentSessionLike} is the entire forwarded
|
|
440
|
+
* surface. `prompt`, `park`, and `abort` go out over a unix socket, harness
|
|
441
|
+
* events come back and are re-emitted to the same `on()` subscribers the
|
|
442
|
+
* in-process version served, and `sessionFile` is
|
|
428
443
|
* whatever path the child reports the session actually opened — never one this
|
|
429
444
|
* side invented.
|
|
430
445
|
*
|
|
@@ -657,6 +672,14 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
657
672
|
else waiter.reject(new Error(message.error ?? "session prompt failed"));
|
|
658
673
|
break;
|
|
659
674
|
}
|
|
675
|
+
case "park-result": {
|
|
676
|
+
const waiter = pending.get(message.id);
|
|
677
|
+
if (waiter === undefined) break;
|
|
678
|
+
pending.delete(message.id);
|
|
679
|
+
if (message.ok) waiter.resolve();
|
|
680
|
+
else waiter.reject(new Error(message.error ?? "session park failed"));
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
660
683
|
case "release-blocked":
|
|
661
684
|
opts.onReleaseBlocked?.(message.shape);
|
|
662
685
|
break;
|
|
@@ -693,6 +716,14 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
693
716
|
// would make this seam depend on a shape it deliberately does not name.
|
|
694
717
|
return promise.then(() => undefined);
|
|
695
718
|
},
|
|
719
|
+
park() {
|
|
720
|
+
promptSeq += 1;
|
|
721
|
+
const id = promptSeq;
|
|
722
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
723
|
+
pending.set(id, { resolve, reject });
|
|
724
|
+
write({ t: "park", id });
|
|
725
|
+
return promise.then(() => undefined);
|
|
726
|
+
},
|
|
696
727
|
on(event, cb) {
|
|
697
728
|
const list = handlers.get(event);
|
|
698
729
|
if (list) list.push(cb);
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -68,17 +68,20 @@ import {
|
|
|
68
68
|
type ReleaseDecision,
|
|
69
69
|
} from "./release-policy.ts";
|
|
70
70
|
import {
|
|
71
|
+
DEFAULT_REPORT_POLICY,
|
|
71
72
|
DEFAULT_REPORT_SCOPE,
|
|
72
73
|
DENIED_RELEASE_GRANTS,
|
|
73
74
|
type DispatchSummary,
|
|
74
75
|
type FrictionSignal,
|
|
75
76
|
type ReportScope,
|
|
77
|
+
type ReportingPolicy,
|
|
76
78
|
type ResolvedGrants,
|
|
77
79
|
type Store,
|
|
78
80
|
} from "./types.ts";
|
|
79
81
|
import { formatDecisionDigest } from "./decisions.ts";
|
|
80
82
|
import type { RunRecord } from "./types.ts";
|
|
81
83
|
import { dbPath, openStore } from "./store.ts";
|
|
84
|
+
import { digestDue } from "./digest-schedule.ts";
|
|
82
85
|
|
|
83
86
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
84
87
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -386,7 +389,17 @@ export function queueDigestLine(
|
|
|
386
389
|
return `Queue: empty — nothing carries "${queueLabel}". Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report why there is nothing to do.`;
|
|
387
390
|
}
|
|
388
391
|
if (summary.routed === 0) {
|
|
389
|
-
|
|
392
|
+
// `ready` counts claimed (in-flight) issues too; route() drops those with a
|
|
393
|
+
// state label silently, so "0 routable" must not blanket-blame missing
|
|
394
|
+
// `repo:` labels (#228). Spare depth is what dispatch can actually claim.
|
|
395
|
+
const claimed = summary.claimed ?? 0;
|
|
396
|
+
const unroutable = summary.holds
|
|
397
|
+
.filter((h) => h.reason.startsWith("unroutable:"))
|
|
398
|
+
.reduce((n, h) => n + h.count, 0);
|
|
399
|
+
if (unroutable === 0) {
|
|
400
|
+
return `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
|
|
401
|
+
}
|
|
402
|
+
return `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label).`;
|
|
390
403
|
}
|
|
391
404
|
if (summary.routed >= groomBelow) return undefined;
|
|
392
405
|
let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
|
|
@@ -404,6 +417,37 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
|
404
417
|
"Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event accumulates and ships as ONE message with this tick's report via omp-conductor report -- a merge, a green PR, a pulled issue wait for the tick; nothing between ticks.",
|
|
405
418
|
};
|
|
406
419
|
|
|
420
|
+
/**
|
|
421
|
+
* The reporting constraint appended to a default tick prompt (#229).
|
|
422
|
+
*
|
|
423
|
+
* A legacy `scopePreset` keeps its exact words — those are what the policy
|
|
424
|
+
* tests and the fleet already read — with a DUE/not-due clause appended only
|
|
425
|
+
* when the digest is actually scheduled (`daily` + `at`). An explicit
|
|
426
|
+
* non-preset policy derives one sentence naming its allowed interrupt
|
|
427
|
+
* categories and stating that everything else accumulates for the digest.
|
|
428
|
+
*/
|
|
429
|
+
export function tickReportingConstraint(
|
|
430
|
+
policy: ReportingPolicy | undefined,
|
|
431
|
+
digest: { due: boolean; scheduled: boolean; at?: string; timezone?: string },
|
|
432
|
+
held: number,
|
|
433
|
+
): string {
|
|
434
|
+
const preset = policy?.scopePreset;
|
|
435
|
+
let base: string;
|
|
436
|
+
if (preset !== undefined) {
|
|
437
|
+
base = TICK_SCOPE_CONSTRAINTS[preset];
|
|
438
|
+
} else {
|
|
439
|
+
const allowed = policy?.interruptOn ?? [];
|
|
440
|
+
base =
|
|
441
|
+
allowed.length === 0
|
|
442
|
+
? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
|
|
443
|
+
: `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
|
|
444
|
+
}
|
|
445
|
+
if (!digest.scheduled) return base;
|
|
446
|
+
return digest.due
|
|
447
|
+
? `${base} The daily digest is DUE now — compose it from this tick's accumulated events and the ${held} held notice(s) below, then send via omp-conductor report --kind digest.`
|
|
448
|
+
: `${base} The daily digest is not due (scheduled ${digest.at}${digest.timezone === undefined ? "" : ` ${digest.timezone}`}); do not send one.`;
|
|
449
|
+
}
|
|
450
|
+
|
|
407
451
|
/**
|
|
408
452
|
* The delivery clause, appended to every default tick prompt.
|
|
409
453
|
*
|
|
@@ -572,6 +616,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
|
|
|
572
616
|
*/
|
|
573
617
|
export function resolveTickScope(): {
|
|
574
618
|
scope: ReportScope;
|
|
619
|
+
policy?: ReportingPolicy;
|
|
575
620
|
briefPath?: string;
|
|
576
621
|
policyPath?: string;
|
|
577
622
|
projectName?: string;
|
|
@@ -580,7 +625,8 @@ export function resolveTickScope(): {
|
|
|
580
625
|
try {
|
|
581
626
|
const project = findProject(loadConfig());
|
|
582
627
|
return {
|
|
583
|
-
scope: project.reporting?.
|
|
628
|
+
scope: project.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
|
|
629
|
+
policy: project.reporting,
|
|
584
630
|
briefPath: briefPathForProject(project),
|
|
585
631
|
policyPath: policyPathForProject(project),
|
|
586
632
|
projectName: project.name,
|
|
@@ -1327,7 +1373,31 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1327
1373
|
pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
|
|
1328
1374
|
}
|
|
1329
1375
|
const bridged = approval?.kind === "ready" && approval.notifyMode === "always" && profile?.kind === "interactive";
|
|
1330
|
-
|
|
1376
|
+
let reportingConstraint = TICK_SCOPE_CONSTRAINTS[scope.scope];
|
|
1377
|
+
if (scope.projectName !== undefined) {
|
|
1378
|
+
const policy = scope.policy;
|
|
1379
|
+
const digestPolicy = policy?.digest ?? DEFAULT_REPORT_POLICY.digest;
|
|
1380
|
+
const scheduled = digestPolicy.cadence === "daily" && digestPolicy.at !== undefined;
|
|
1381
|
+
const at = Date.now();
|
|
1382
|
+
const store = openStore(dbPath());
|
|
1383
|
+
try {
|
|
1384
|
+
const lastKey = store.lastDigestDedupeKey(scope.projectName);
|
|
1385
|
+
const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
|
|
1386
|
+
reportingConstraint = tickReportingConstraint(
|
|
1387
|
+
policy,
|
|
1388
|
+
{
|
|
1389
|
+
due: digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at),
|
|
1390
|
+
scheduled,
|
|
1391
|
+
at: digestPolicy.at,
|
|
1392
|
+
timezone: digestPolicy.timezone,
|
|
1393
|
+
},
|
|
1394
|
+
store.undigestedNotices(scope.projectName).length,
|
|
1395
|
+
);
|
|
1396
|
+
} finally {
|
|
1397
|
+
store.close();
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
|
|
1331
1401
|
}
|
|
1332
1402
|
let frictionStore: Store | undefined;
|
|
1333
1403
|
let frictionSignals: FrictionSignal[] = [];
|
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, {
|
|
@@ -898,7 +899,22 @@ const askEscalation: AreaAsker = async (ctx, a) => {
|
|
|
898
899
|
};
|
|
899
900
|
|
|
900
901
|
/** How loud the orchestrator is when nobody asked it anything. */
|
|
901
|
-
const askReporting: AreaAsker = async (ctx, a) =>
|
|
902
|
+
const askReporting: AreaAsker = async (ctx, a) => {
|
|
903
|
+
const reportScope = await askReportScope(ctx, a.reportScope);
|
|
904
|
+
if (reportScope !== "quiet") return { ...a, reportScope };
|
|
905
|
+
// `quiet` picks the explicit form, whose only free parameter is when the
|
|
906
|
+
// daily rollup happens. Blank = whenever the orchestrator composes it.
|
|
907
|
+
const at = await ctx.ui.input(
|
|
908
|
+
"Daily rollup time, 24h HH:MM (blank = whenever the orchestrator composes it):",
|
|
909
|
+
a.quietDigestAt,
|
|
910
|
+
);
|
|
911
|
+
const trimmed = at?.trim() ?? "";
|
|
912
|
+
if (trimmed !== "" && !/^([01]\d|2[0-3]):[0-5]\d$/.test(trimmed)) {
|
|
913
|
+
ctx.ui.notify(`"${trimmed}" is not a 24h HH:MM time — leaving the digest model-timed.`, "warning");
|
|
914
|
+
return { ...a, reportScope };
|
|
915
|
+
}
|
|
916
|
+
return { ...a, reportScope, ...(trimmed === "" ? {} : { quietDigestAt: trimmed }) };
|
|
917
|
+
};
|
|
902
918
|
|
|
903
919
|
/** The operator's own brief. Asked last in the full interview, because the
|
|
904
920
|
* question quotes the path the rest of the answers derive. */
|
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,14 +9,14 @@
|
|
|
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";
|
|
@@ -53,6 +53,7 @@ export interface SessionHostSpec {
|
|
|
53
53
|
/** Parent → child. */
|
|
54
54
|
export type ParentToHost =
|
|
55
55
|
| { t: "prompt"; id: number; text: string; opts?: Record<string, unknown> }
|
|
56
|
+
| { t: "park"; id: number }
|
|
56
57
|
| { t: "abort" }
|
|
57
58
|
| { t: "dispose" };
|
|
58
59
|
|
|
@@ -63,6 +64,7 @@ export type HostToParent =
|
|
|
63
64
|
| { t: "event"; event: unknown }
|
|
64
65
|
| { t: "session-file"; path: string }
|
|
65
66
|
| { t: "prompt-result"; id: number; ok: boolean; error?: string }
|
|
67
|
+
| { t: "park-result"; id: number; ok: boolean; error?: string }
|
|
66
68
|
| { t: "release-blocked"; shape: ReleaseShape };
|
|
67
69
|
|
|
68
70
|
/**
|
|
@@ -240,6 +242,20 @@ export async function runSessionHost(
|
|
|
240
242
|
);
|
|
241
243
|
continue;
|
|
242
244
|
}
|
|
245
|
+
if (message.t === "park") {
|
|
246
|
+
const id = message.id;
|
|
247
|
+
void live.park().then(
|
|
248
|
+
() => send({ t: "park-result", id, ok: true }),
|
|
249
|
+
(err: unknown) =>
|
|
250
|
+
send({
|
|
251
|
+
t: "park-result",
|
|
252
|
+
id,
|
|
253
|
+
ok: false,
|
|
254
|
+
error: err instanceof Error ? err.message : String(err),
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
243
259
|
if (message.t === "abort") {
|
|
244
260
|
live.abort();
|
|
245
261
|
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
|
|
@@ -560,8 +575,24 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
560
575
|
// and a release require without anyone having to know a default (#129).
|
|
561
576
|
policy: clonePolicy(a.policy),
|
|
562
577
|
// 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
|
-
|
|
578
|
+
// volume has a line in the file to point at. A `scope` answer is
|
|
579
|
+
// materialised as its preset policy (with `scopePreset` set) so the file
|
|
580
|
+
// and the prompt agree with no migration rule; `quiet` is the explicit
|
|
581
|
+
// form — a curated interrupt list and a scheduled digest (#229).
|
|
582
|
+
reporting:
|
|
583
|
+
a.reportScope === "quiet"
|
|
584
|
+
? {
|
|
585
|
+
interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
|
|
586
|
+
digest: {
|
|
587
|
+
cadence: "daily",
|
|
588
|
+
...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
: {
|
|
592
|
+
interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
|
|
593
|
+
digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
|
|
594
|
+
scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
|
|
595
|
+
},
|
|
565
596
|
// Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
|
|
566
597
|
// uninstall, and neither can land in a repo the daemon then tries to commit.
|
|
567
598
|
workspaceRoot: defaultWorkspaceRoot(),
|
|
@@ -667,7 +698,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
667
698
|
releaseGrants: resolveReleaseGrants(p),
|
|
668
699
|
policy: resolvePolicy(p),
|
|
669
700
|
orchestratorMode: p.escalation.orchestrator,
|
|
670
|
-
reportScope: p.reporting?.
|
|
701
|
+
reportScope: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
|
|
671
702
|
writeOrchestratorBrief: false,
|
|
672
703
|
};
|
|
673
704
|
|
|
@@ -704,7 +735,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
|
|
|
704
735
|
QUEUE_LABEL: p.queueLabel,
|
|
705
736
|
RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
|
|
706
737
|
MERGE_DUTY: MERGE_DUTY[p.authority.merge],
|
|
707
|
-
REPORT_SCOPE: p.reporting?.
|
|
738
|
+
REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
|
|
708
739
|
POLICY_SOURCE: policySourceLine(p),
|
|
709
740
|
};
|
|
710
741
|
}
|
|
@@ -1234,7 +1265,7 @@ export const AMEND_AREAS: {
|
|
|
1234
1265
|
name: "reporting scope",
|
|
1235
1266
|
asks: "how much the orchestrator says unprompted",
|
|
1236
1267
|
describe: (p) => {
|
|
1237
|
-
const scope = p.reporting?.
|
|
1268
|
+
const scope = p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE;
|
|
1238
1269
|
const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
|
|
1239
1270
|
return `${scope} — ${choice?.description ?? "unknown scope"}`;
|
|
1240
1271
|
},
|
package/src/store.ts
CHANGED
|
@@ -26,6 +26,8 @@ import type {
|
|
|
26
26
|
FrictionKind,
|
|
27
27
|
FrictionObservation,
|
|
28
28
|
FrictionSignal,
|
|
29
|
+
HeldNotice,
|
|
30
|
+
HeldNoticeDraft,
|
|
29
31
|
LabelOp,
|
|
30
32
|
MergeLock,
|
|
31
33
|
ReportDeliveryState,
|
|
@@ -332,6 +334,24 @@ CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
|
|
|
332
334
|
CREATE INDEX IF NOT EXISTS reports_project_state
|
|
333
335
|
ON reports (project, state, nextAttemptAt);
|
|
334
336
|
|
|
337
|
+
-- Interrupts a project's reporting.interruptOn policy deferred to the digest
|
|
338
|
+
-- (#229): an escalation that must not page the operator's phone now is held
|
|
339
|
+
-- here so the daily rollup can surface it. digestedAt set -> re-surfaced by a
|
|
340
|
+
-- digest pass and no longer owed. A table rather than a flag on reports: these
|
|
341
|
+
-- are escalations that never reached a send, distinct from reports the model
|
|
342
|
+
-- wrote on purpose.
|
|
343
|
+
CREATE TABLE IF NOT EXISTS held_notices (
|
|
344
|
+
id TEXT PRIMARY KEY,
|
|
345
|
+
project TEXT NOT NULL,
|
|
346
|
+
category TEXT NOT NULL,
|
|
347
|
+
summary TEXT NOT NULL,
|
|
348
|
+
detail TEXT NOT NULL,
|
|
349
|
+
createdAt INTEGER NOT NULL,
|
|
350
|
+
digestedAt INTEGER
|
|
351
|
+
);
|
|
352
|
+
CREATE INDEX IF NOT EXISTS held_notices_project_undigested
|
|
353
|
+
ON held_notices (project, digestedAt) WHERE digestedAt IS NULL;
|
|
354
|
+
|
|
335
355
|
-- The action ledger for the conductor-owned mutation verbs (#126). Every
|
|
336
356
|
-- decided call lands here, refusals included: the question an escalation asks
|
|
337
357
|
-- is "what did this run try", and a table that only records what succeeded
|
|
@@ -902,12 +922,12 @@ export function openStore(dbPath: string): Store {
|
|
|
902
922
|
const countFailures = db.query<{ n: number }, [string, number]>(
|
|
903
923
|
`SELECT COUNT(*) AS n FROM runs
|
|
904
924
|
WHERE project = ? AND issue = ? AND state = 'failed'
|
|
905
|
-
AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit'))`,
|
|
925
|
+
AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
|
|
906
926
|
);
|
|
907
927
|
const countContinuations = db.query<{ n: number }, [string, number]>(
|
|
908
928
|
`SELECT COUNT(*) AS n FROM runs
|
|
909
929
|
WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
|
|
910
|
-
AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit'))`,
|
|
930
|
+
AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
|
|
911
931
|
);
|
|
912
932
|
// How many times one issue reached a given class. Recovery uses it to bound a
|
|
913
933
|
// retry loop whose cause is persistent (e.g. a mirror that will not refresh):
|
|
@@ -1079,6 +1099,33 @@ export function openStore(dbPath: string): Store {
|
|
|
1079
1099
|
ORDER BY createdAt ASC, rowid ASC
|
|
1080
1100
|
LIMIT ?`,
|
|
1081
1101
|
);
|
|
1102
|
+
|
|
1103
|
+
// Held notices (#229): escalations deferred to the digest by the project's
|
|
1104
|
+
// `reporting.interruptOn` policy. `digestedAt` NULL = still owed.
|
|
1105
|
+
const insertHeldNotice = db.query<unknown, SqlValue[]>(
|
|
1106
|
+
`INSERT INTO held_notices (id, project, category, summary, detail, createdAt, digestedAt)
|
|
1107
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
|
|
1108
|
+
);
|
|
1109
|
+
const selectUndigestedNotices = db.query<
|
|
1110
|
+
{ id: string; category: string; summary: string; detail: string; createdAt: number },
|
|
1111
|
+
[string]
|
|
1112
|
+
>(
|
|
1113
|
+
`SELECT id, category, summary, detail, createdAt FROM held_notices
|
|
1114
|
+
WHERE project = ? AND digestedAt IS NULL
|
|
1115
|
+
ORDER BY createdAt ASC`,
|
|
1116
|
+
);
|
|
1117
|
+
const markNoticesDigestedRow = db.query<unknown, [number, string]>(
|
|
1118
|
+
`UPDATE held_notices SET digestedAt = ? WHERE project = ? AND digestedAt IS NULL`,
|
|
1119
|
+
);
|
|
1120
|
+
// The newest digest dedupe key a project has actually run toward — an
|
|
1121
|
+
// `updatedAt`-newest row whose key is a digest prefix and that did not end in
|
|
1122
|
+
// failure. The CLI refuses an off-schedule digest by comparing this against
|
|
1123
|
+
// today's key under `digestDue` (#229).
|
|
1124
|
+
const selectLastDigestKey = db.query<{ key: string | null }, [string]>(
|
|
1125
|
+
`SELECT dedupeKey AS key FROM reports
|
|
1126
|
+
WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state <> 'failed'
|
|
1127
|
+
ORDER BY createdAt DESC LIMIT 1`,
|
|
1128
|
+
);
|
|
1082
1129
|
const claimReportRow = db.query<unknown, [string, number, string, number]>(
|
|
1083
1130
|
`UPDATE reports
|
|
1084
1131
|
SET state = 'sending', attemptId = ?, attempts = attempts + 1, updatedAt = ?
|
|
@@ -1445,6 +1492,30 @@ export function openStore(dbPath: string): Store {
|
|
|
1445
1492
|
return row === null ? undefined : toDispatchSummary(row.summary);
|
|
1446
1493
|
},
|
|
1447
1494
|
|
|
1495
|
+
lastDigestDedupeKey(project: string): string | undefined {
|
|
1496
|
+
const row = selectLastDigestKey.get(project);
|
|
1497
|
+
return row?.key ?? undefined;
|
|
1498
|
+
},
|
|
1499
|
+
|
|
1500
|
+
addHeldNotice(notice: HeldNoticeDraft): void {
|
|
1501
|
+
insertHeldNotice.run(
|
|
1502
|
+
crypto.randomUUID(),
|
|
1503
|
+
notice.project,
|
|
1504
|
+
notice.category,
|
|
1505
|
+
notice.summary,
|
|
1506
|
+
notice.detail,
|
|
1507
|
+
notice.createdAt,
|
|
1508
|
+
);
|
|
1509
|
+
},
|
|
1510
|
+
|
|
1511
|
+
undigestedNotices(project: string): HeldNotice[] {
|
|
1512
|
+
return selectUndigestedNotices.all(project).map((row) => ({ ...row }) as HeldNotice);
|
|
1513
|
+
},
|
|
1514
|
+
|
|
1515
|
+
markNoticesDigested(project: string, at: number): void {
|
|
1516
|
+
markNoticesDigestedRow.run(at, project);
|
|
1517
|
+
},
|
|
1518
|
+
|
|
1448
1519
|
recordFriction,
|
|
1449
1520
|
|
|
1450
1521
|
recordGhRefusal,
|