omp-conductor 0.9.1 → 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 +57 -12
- package/package.json +1 -1
- package/src/board.ts +203 -63
- package/src/briefs/orchestrator.md +45 -1
- 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 +331 -20
- 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 +72 -0
- package/src/fleet.ts +2 -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 +111 -4
- package/src/verbs/server.ts +49 -0
- package/src/worker.ts +201 -16
package/src/diff-flags.ts
CHANGED
|
@@ -266,17 +266,49 @@ const CHANGED_LINE = /^[ \t>]*changed:[ \t]*(.*)$/im;
|
|
|
266
266
|
*/
|
|
267
267
|
const CLAIMED_PATH = /^(?:[\w.@~+-]+\/)+[\w.@~*+-]*$|^[\w.@~+-]*\.[A-Za-z][A-Za-z0-9]{1,7}$/;
|
|
268
268
|
|
|
269
|
+
/** A Python dotted module: `services.rule_purge`, `omp.routing`. Not a path —
|
|
270
|
+
* segments joined by `.` — but a legitimate way for a report to name the file
|
|
271
|
+
* it touched without disclosing the directory it lives in (#224).
|
|
272
|
+
*
|
|
273
|
+
* Every segment is at least two characters so prose abbreviations (`e.g`,
|
|
274
|
+
* `i.e.`, `a.k.a`) stay commentary: they are exactly the prose the path
|
|
275
|
+
* heuristic exists to reject, and each one that slipped through would become
|
|
276
|
+
* an "you claimed a file you never touched" flag on an honest report. */
|
|
277
|
+
const DOTTED_MODULE = /^[A-Za-z_]\w+(?:\.[A-Za-z_]\w+)+$/;
|
|
278
|
+
|
|
279
|
+
/** One-level brace expansion: `a/{b,c}.py` → [`a/b.py`, `a/c.py`]. Braces are
|
|
280
|
+
* how a report compresses adjacent files into one token; each comma-separated
|
|
281
|
+
* alternative is substituted into the skeleton. Nesting is deliberately
|
|
282
|
+
* unsupported — one level matches the spellings reports actually produce. */
|
|
283
|
+
function expandBraces(token: string): string[] {
|
|
284
|
+
const group = /\{[^{}]*\}/.exec(token);
|
|
285
|
+
if (group === null) return [token];
|
|
286
|
+
const alternatives = group[0].slice(1, -1).split(",").filter((a) => a.length > 0);
|
|
287
|
+
if (alternatives.length === 0) return [token];
|
|
288
|
+
const prefix = token.slice(0, group.index);
|
|
289
|
+
const suffix = token.slice(group.index + group[0].length);
|
|
290
|
+
return alternatives.map((alt) => prefix + alt + suffix);
|
|
291
|
+
}
|
|
292
|
+
|
|
269
293
|
/** Every path-shaped token on the report's `changed:` line. An absent line and
|
|
270
294
|
* a line naming nothing are the same answer: nothing was disclosed. */
|
|
271
295
|
export function claimedPaths(report: string): string[] {
|
|
272
296
|
const line = CHANGED_LINE.exec(report)?.[1] ?? "";
|
|
273
297
|
const seen = new Set<string>();
|
|
274
|
-
|
|
298
|
+
// Split on whitespace and semicolons, and on commas *outside* a brace group:
|
|
299
|
+
// the comma in `a/{b,c}.py` is an alternative separator, not a token
|
|
300
|
+
// separator, so a brace-compressed token must survive whole (#224).
|
|
301
|
+
for (const raw of line.split(/[\s;]+|,(?![^{}]*\})/)) {
|
|
275
302
|
const token = raw.replace(/^[`'"([*-]+/, "").replace(/[`'")\].,:;]+$/, "");
|
|
276
303
|
if (token === "" || token === "none") continue;
|
|
277
304
|
const normalised = token.replace(/^\.?\//, "");
|
|
278
|
-
|
|
279
|
-
|
|
305
|
+
// Expansion happens here so every downstream consumer — both `covers`
|
|
306
|
+
// directions — sees plain paths: a brace token dies earlier at the
|
|
307
|
+
// CLAIMED_PATH filter if it is never opened up (#224).
|
|
308
|
+
for (const expanded of expandBraces(normalised)) {
|
|
309
|
+
if (!CLAIMED_PATH.test(expanded) && !DOTTED_MODULE.test(expanded)) continue;
|
|
310
|
+
seen.add(expanded);
|
|
311
|
+
}
|
|
280
312
|
}
|
|
281
313
|
return [...seen];
|
|
282
314
|
}
|
|
@@ -305,6 +337,15 @@ function covers(claim: string, path: string): boolean {
|
|
|
305
337
|
return pattern.test(path) || pattern.test(basename(path));
|
|
306
338
|
}
|
|
307
339
|
if (path.endsWith(`/${claim}`) || claim.endsWith(`/${path}`)) return true;
|
|
340
|
+
// A Python dotted module names a file by import path, not by location:
|
|
341
|
+
// `services.rule_purge` covers `backend/app/services/rule_purge.py`. Only
|
|
342
|
+
// consulted after the path rules, because `foo.bar` is also a valid filename
|
|
343
|
+
// and a literal match must win. Matching several changed files is fine — the
|
|
344
|
+
// check wants evidence the worker knew, not a unique index (#224).
|
|
345
|
+
if (DOTTED_MODULE.test(claim)) {
|
|
346
|
+
const fragment = `${claim.replaceAll(".", "/")}.py`;
|
|
347
|
+
if (path === fragment || path.endsWith(`/${fragment}`)) return true;
|
|
348
|
+
}
|
|
308
349
|
const last = claim.slice(claim.lastIndexOf("/") + 1);
|
|
309
350
|
return !last.includes(".") && path.startsWith(`${claim}/`);
|
|
310
351
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the daily digest is due, in the zone the operator reads it (#229).
|
|
3
|
+
*
|
|
4
|
+
* Pure by construction: every fact arrives as a timestamp and the policy, and
|
|
5
|
+
* nothing here reads a clock or a file. The digest is "one per day" in the
|
|
6
|
+
* IANA zone it is configured with (host zone when none) — a UTC key would roll
|
|
7
|
+
* the digest over mid-evening for anyone west of Greenwich.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ReportingPolicy } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/** `YYYY-MM-DD` in an IANA timezone (host zone when `timezone` is absent). */
|
|
13
|
+
export function localDayKey(at: number, timezone?: string): string {
|
|
14
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
15
|
+
timeZone: timezone,
|
|
16
|
+
year: "numeric",
|
|
17
|
+
month: "2-digit",
|
|
18
|
+
day: "2-digit",
|
|
19
|
+
}).formatToParts(new Date(at));
|
|
20
|
+
const get = (type: "year" | "month" | "day"): string =>
|
|
21
|
+
parts.find((p) => p.type === type)?.value ?? "00";
|
|
22
|
+
return `${get("year")}-${get("month")}-${get("day")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `HH:MM` wall-clock in the zone, 24h and zero-padded. */
|
|
26
|
+
function localClockAt(at: number, timezone?: string): string {
|
|
27
|
+
return new Intl.DateTimeFormat("en-GB", {
|
|
28
|
+
timeZone: timezone,
|
|
29
|
+
hour: "2-digit",
|
|
30
|
+
minute: "2-digit",
|
|
31
|
+
hourCycle: "h23",
|
|
32
|
+
}).format(new Date(at));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the digest is due now.
|
|
37
|
+
*
|
|
38
|
+
* - `none` → never.
|
|
39
|
+
* - `per-tick` → always (the orchestrator sends it with every report).
|
|
40
|
+
* - `daily` without `at` → once per day in the configured zone.
|
|
41
|
+
* - `daily` with `at` → on a day it has not already run, once the local clock
|
|
42
|
+
* has passed `at`. A restart after `at` still finds today unsent → one
|
|
43
|
+
* catch-up; a fully missed day is skipped, never sent late.
|
|
44
|
+
*/
|
|
45
|
+
export function digestDue(
|
|
46
|
+
policy: Pick<ReportingPolicy, "digest">,
|
|
47
|
+
lastDigestDayKey: string | undefined,
|
|
48
|
+
now: number,
|
|
49
|
+
): boolean {
|
|
50
|
+
const { cadence, at, timezone } = policy.digest;
|
|
51
|
+
if (cadence === "none") return false;
|
|
52
|
+
if (cadence === "per-tick") return true;
|
|
53
|
+
// daily
|
|
54
|
+
if (at === undefined) {
|
|
55
|
+
return lastDigestDayKey !== localDayKey(now, timezone);
|
|
56
|
+
}
|
|
57
|
+
if (lastDigestDayKey === localDayKey(now, timezone)) return false;
|
|
58
|
+
return localClockAt(now, timezone) >= at;
|
|
59
|
+
}
|
package/src/escalate.ts
CHANGED
|
@@ -213,6 +213,23 @@ export function createEscalator(
|
|
|
213
213
|
if (e.tier === 2 && chatId) {
|
|
214
214
|
const token = readTelegramToken();
|
|
215
215
|
if (token) {
|
|
216
|
+
// A category this policy defers does not page now: it is held here so
|
|
217
|
+
// the digest is the delivery authority for it (#229). A missing
|
|
218
|
+
// `reporting` block means the default (page everything); an explicit
|
|
219
|
+
// list decides each escalation by its category, defaulting `tier2`.
|
|
220
|
+
const category = e.category ?? "tier2";
|
|
221
|
+
const interruptOn = p.reporting?.interruptOn;
|
|
222
|
+
if (interruptOn !== undefined && !interruptOn.includes(category)) {
|
|
223
|
+
store.addHeldNotice({
|
|
224
|
+
project: p.name,
|
|
225
|
+
category,
|
|
226
|
+
summary: e.summary,
|
|
227
|
+
detail: text,
|
|
228
|
+
createdAt: Date.now(),
|
|
229
|
+
});
|
|
230
|
+
store.markNotified(key);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
216
233
|
// A send failure throws: `markNotified` stays uncalled so the next
|
|
217
234
|
// poll retries instead of writing the event off as delivered. No
|
|
218
235
|
// backoff in here — the dispatcher tick *is* the retry, and an
|
package/src/failure-class.ts
CHANGED
|
@@ -113,6 +113,47 @@ export function startFailure(lastError: string | undefined): string | undefined
|
|
|
113
113
|
return hit === undefined ? undefined : lastError.split("\n")[0]?.trim();
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Provider messages that mean "this key is out of money", matched on the text
|
|
118
|
+
* the provider itself prints.
|
|
119
|
+
*/
|
|
120
|
+
const CREDIT_REFUSAL_SIGNATURES = [
|
|
121
|
+
"requires more credits",
|
|
122
|
+
"insufficient credits",
|
|
123
|
+
"insufficient_quota",
|
|
124
|
+
"exceeded your current quota",
|
|
125
|
+
"credit balance is too low",
|
|
126
|
+
] as const;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Evidence that a model provider refused this run for credit, or `undefined`
|
|
130
|
+
* when it did not.
|
|
131
|
+
*
|
|
132
|
+
* Two independent signals, either sufficient: HTTP 402, which means exactly
|
|
133
|
+
* this and nothing else, and the refusal text providers print when a key is out
|
|
134
|
+
* of allowance without using 402. Deliberately narrow — a generic 429 is rate
|
|
135
|
+
* limiting, which is a different failure with a different remedy.
|
|
136
|
+
*/
|
|
137
|
+
export function providerCreditRefusal(error: {
|
|
138
|
+
status?: number;
|
|
139
|
+
message: string;
|
|
140
|
+
}): string | undefined {
|
|
141
|
+
const text = error.message.toLowerCase();
|
|
142
|
+
const named = CREDIT_REFUSAL_SIGNATURES.some((signature) => text.includes(signature));
|
|
143
|
+
if (error.status !== 402 && !named) return undefined;
|
|
144
|
+
return error.message.split("\n")[0]?.trim() ?? error.message;
|
|
145
|
+
}
|
|
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
|
+
|
|
116
157
|
/**
|
|
117
158
|
* Evidence that this run never started, or `undefined` when something did happen.
|
|
118
159
|
*
|
|
@@ -201,6 +242,37 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
201
242
|
};
|
|
202
243
|
}
|
|
203
244
|
|
|
245
|
+
// A billing state, not an implementation failure. Its own class because it is
|
|
246
|
+
// the one outage an operator fixes with a card rather than a diagnosis, and
|
|
247
|
+
// because burying a self-describing provider error in `unknown` erodes what
|
|
248
|
+
// `unknown` means (#220). Ahead of `neverStarted` on purpose: a 402 on the
|
|
249
|
+
// first request produces a turn-0 row with no artifacts, which
|
|
250
|
+
// `env-start-failure` would otherwise absorb and lose the cause.
|
|
251
|
+
if (run.state === "failed" || run.state === "killed") {
|
|
252
|
+
const credit =
|
|
253
|
+
run.lastError === undefined
|
|
254
|
+
? undefined
|
|
255
|
+
: providerCreditRefusal({ message: run.lastError });
|
|
256
|
+
if (credit !== undefined) {
|
|
257
|
+
return { cls: "provider-credit", recovery: "requeue", evidence: credit };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
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
|
+
|
|
204
276
|
// A run that never started is the most classifiable failure there is, and the
|
|
205
277
|
// least deserving of an implementation attempt: the session did not get as far
|
|
206
278
|
// as reading the issue. See {@link neverStarted} for the two shapes and why the
|
package/src/fleet.ts
CHANGED
|
@@ -1062,6 +1062,7 @@ export function formatFleetStatus(
|
|
|
1062
1062
|
function formatProjectBody(s: StatusSnapshot): string {
|
|
1063
1063
|
const lines = [
|
|
1064
1064
|
`project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
|
|
1065
|
+
...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
|
|
1065
1066
|
`config ${s.configPath}`,
|
|
1066
1067
|
`state ${s.stateDir}`,
|
|
1067
1068
|
"",
|
|
@@ -1380,7 +1381,7 @@ export async function probeTelegramHealth(
|
|
|
1380
1381
|
detail: `${username}; inbound configured; interactive profile relays assistant text — /telegram set profile daemon`,
|
|
1381
1382
|
};
|
|
1382
1383
|
}
|
|
1383
|
-
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)` };
|
|
1384
1385
|
}
|
|
1385
1386
|
|
|
1386
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;
|