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/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
|
}
|
|
@@ -704,6 +745,10 @@ export function formatSettlementFlags(
|
|
|
704
745
|
pooledHeading(diff.attempts),
|
|
705
746
|
];
|
|
706
747
|
for (const flag of flags.slice(0, RENDERED_FLAGS)) {
|
|
748
|
+
if (flag.kind === "pr-adopted") {
|
|
749
|
+
lines.push(` ${flag.kind} — ${flag.detail}`);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
707
752
|
lines.push(
|
|
708
753
|
` ${flag.kind} ${flag.file}${flag.line === undefined ? "" : `:${flag.line}`} — ${flag.detail}` +
|
|
709
754
|
(flag.unattributed === true ? " [unattributed: the dispatching issue never names this file]" : ""),
|
|
@@ -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
|
+
// The digest can own deferred delivery only while its orchestrator
|
|
217
|
+
// loop is alive. An urgent escalation says that loop is the failed
|
|
218
|
+
// component, so waiting for its digest would park the only warning
|
|
219
|
+
// behind the failure it reports (#246).
|
|
220
|
+
const category = e.category ?? "tier2";
|
|
221
|
+
const interruptOn = p.reporting?.interruptOn;
|
|
222
|
+
if (!e.urgent && 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
|
@@ -22,6 +22,9 @@ export interface ClassifyFacts {
|
|
|
22
22
|
pr?: "open" | "merged" | "closed";
|
|
23
23
|
mergeable?: "conflicting" | "clean" | "unknown";
|
|
24
24
|
checks?: { name: string; state: string; link?: string }[];
|
|
25
|
+
/** Full session error recovered from the transcript. Kept as a fact so a
|
|
26
|
+
* classification retry does not lose an HTTP status the run row cannot store. */
|
|
27
|
+
sessionError?: { status?: number; message: string };
|
|
25
28
|
/** Tail (ANSI-stripped) of the first failed check's log, when one was
|
|
26
29
|
* reachable. Lets the table tell an infrastructure outage (#177) from a
|
|
27
30
|
* deterministic test failure by the log's own words. */
|
|
@@ -144,6 +147,16 @@ export function providerCreditRefusal(error: {
|
|
|
144
147
|
return error.message.split("\n")[0]?.trim() ?? error.message;
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
/** Provider text that names a per-request stream fault. Deliberately narrow:
|
|
151
|
+
* a 429 is rate limiting and a 402 is credit — different remedies (#220). */
|
|
152
|
+
const TRANSIENT_FAULT_SIGNATURES = ["stream stalled"] as const;
|
|
153
|
+
|
|
154
|
+
export function providerTransientFault(error: { status?: number; message: string }): string | undefined {
|
|
155
|
+
const text = error.message.toLowerCase();
|
|
156
|
+
if (!TRANSIENT_FAULT_SIGNATURES.some((s) => text.includes(s))) return undefined;
|
|
157
|
+
return error.message.split("\n")[0]?.trim() ?? error.message;
|
|
158
|
+
}
|
|
159
|
+
|
|
147
160
|
/**
|
|
148
161
|
* Evidence that this run never started, or `undefined` when something did happen.
|
|
149
162
|
*
|
|
@@ -211,6 +224,8 @@ const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
|
|
|
211
224
|
|
|
212
225
|
export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
|
|
213
226
|
const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
|
|
227
|
+
const providerError =
|
|
228
|
+
facts.sessionError ?? (run.lastError === undefined ? undefined : { message: run.lastError });
|
|
214
229
|
|
|
215
230
|
// The PR landed while the row says otherwise. Whatever else is true about this
|
|
216
231
|
// run, it succeeded, and the recovery is bookkeeping.
|
|
@@ -239,15 +254,27 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
239
254
|
// first request produces a turn-0 row with no artifacts, which
|
|
240
255
|
// `env-start-failure` would otherwise absorb and lose the cause.
|
|
241
256
|
if (run.state === "failed" || run.state === "killed") {
|
|
242
|
-
const credit =
|
|
243
|
-
run.lastError === undefined
|
|
244
|
-
? undefined
|
|
245
|
-
: providerCreditRefusal({ message: run.lastError });
|
|
257
|
+
const credit = providerError === undefined ? undefined : providerCreditRefusal(providerError);
|
|
246
258
|
if (credit !== undefined) {
|
|
247
259
|
return { cls: "provider-credit", recovery: "requeue", evidence: credit };
|
|
248
260
|
}
|
|
249
261
|
}
|
|
250
262
|
|
|
263
|
+
// A per-request stream fault: the provider aborted mid-stream — the session
|
|
264
|
+
// records "OpenAI responses stream stalled while waiting for the next event",
|
|
265
|
+
// but the attempt never produced a verdict and 0 tokens were billed. Not the
|
|
266
|
+
// work's fault, so it must not charge an implementation attempt; requeued, but
|
|
267
|
+
// bounded by PROVIDER_TRANSIENT_MAX_STRIKES so a provider that keeps aborting
|
|
268
|
+
// escalates to a human instead of looping (#220). Ahead of `neverStarted` for
|
|
269
|
+
// the same reason as the credit branch: a turn-0 stall would otherwise be
|
|
270
|
+
// absorbed by `env-start-failure` and lose its cause.
|
|
271
|
+
if (run.state === "failed" || run.state === "killed") {
|
|
272
|
+
const transient = providerError === undefined ? undefined : providerTransientFault(providerError);
|
|
273
|
+
if (transient !== undefined) {
|
|
274
|
+
return { cls: "provider-transient", recovery: "requeue", evidence: transient };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
251
278
|
// A run that never started is the most classifiable failure there is, and the
|
|
252
279
|
// least deserving of an implementation attempt: the session did not get as far
|
|
253
280
|
// as reading the issue. See {@link neverStarted} for the two shapes and why the
|
package/src/fleet.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { settlementFlagSummary } from "./diff-flags.ts";
|
|
|
38
38
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
39
39
|
import { formatOpenReports } from "./reports.ts";
|
|
40
40
|
import {
|
|
41
|
+
formatBaseChecks,
|
|
41
42
|
formatDispatchSummary,
|
|
42
43
|
formatReleaseGrants,
|
|
43
44
|
formatSalvagedRuns,
|
|
@@ -1026,7 +1027,7 @@ export function formatFleetStatus(
|
|
|
1026
1027
|
// a halt) answers "who stopped the fleet" without opening a file (#185). An
|
|
1027
1028
|
// unparseable sentinel — paused but with no datable line 1 — is itself news:
|
|
1028
1029
|
// it means a run admitted before an *unknown* pause cannot prove innocence
|
|
1029
|
-
// (#174), so
|
|
1030
|
+
// (#174), so completion mutations fail closed while release gates remain usable.
|
|
1030
1031
|
const dispatchLine =
|
|
1031
1032
|
layers.dispatch === "paused"
|
|
1032
1033
|
? (() => {
|
|
@@ -1036,7 +1037,7 @@ export function formatFleetStatus(
|
|
|
1036
1037
|
return `dispatch paused (source: ${prov.source}${reason})`;
|
|
1037
1038
|
}
|
|
1038
1039
|
return isPaused() && pausedAt() === undefined
|
|
1039
|
-
? "dispatch paused (unparseable sentinel —
|
|
1040
|
+
? "dispatch paused (unparseable sentinel — new work and completion mutations refused; release gates remain available)"
|
|
1040
1041
|
: "dispatch paused";
|
|
1041
1042
|
})()
|
|
1042
1043
|
: `dispatch ${layers.dispatch}`;
|
|
@@ -1110,6 +1111,9 @@ function formatProjectBody(s: StatusSnapshot): string {
|
|
|
1110
1111
|
})`,
|
|
1111
1112
|
]),
|
|
1112
1113
|
` new worker turns ${s.caps.workerMaxTurns}`,
|
|
1114
|
+
...s.turnOverrides.map(
|
|
1115
|
+
({ issue, maxTurns }) => ` turn override #${issue} → ${maxTurns} (next attempt)`,
|
|
1116
|
+
),
|
|
1113
1117
|
` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
|
|
1114
1118
|
` failed attempts ${s.caps.maxAttemptsPerIssue}`,
|
|
1115
1119
|
` continuations ${s.caps.maxContinuationsPerIssue}`,
|
|
@@ -1136,6 +1140,7 @@ function formatProjectBody(s: StatusSnapshot): string {
|
|
|
1136
1140
|
if (flagged !== undefined) lines.push(` ${flagged}`);
|
|
1137
1141
|
}
|
|
1138
1142
|
}
|
|
1143
|
+
lines.push(...formatBaseChecks(s.baseChecks));
|
|
1139
1144
|
lines.push(...formatSalvagedRuns(s.salvagedRuns));
|
|
1140
1145
|
lines.push(...formatOpenReports(s.openReports));
|
|
1141
1146
|
if (s.liveWorkers > 0) {
|
|
@@ -1381,7 +1386,7 @@ export async function probeTelegramHealth(
|
|
|
1381
1386
|
detail: `${username}; inbound configured; interactive profile relays assistant text — /telegram set profile daemon`,
|
|
1382
1387
|
};
|
|
1383
1388
|
}
|
|
1384
|
-
return { kind: "ok", detail: `${username}; inbound configured; telegram_ask
|
|
1389
|
+
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
1390
|
}
|
|
1386
1391
|
|
|
1387
1392
|
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
|
@@ -19,7 +19,7 @@ import { tmpdir } from "node:os";
|
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
|
|
21
21
|
import { worktreeConfinement } from "./confinement.ts";
|
|
22
|
-
import { releasePolicyTripwire } from "./release-policy.ts";
|
|
22
|
+
import { releasePolicyTripwire, type ReleaseBlockContext } from "./release-policy.ts";
|
|
23
23
|
import type {
|
|
24
24
|
HostToParent,
|
|
25
25
|
ParentToHost,
|
|
@@ -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
|
}
|
|
@@ -148,7 +158,7 @@ export async function createLocalSession(opts: {
|
|
|
148
158
|
/** Install the release/deploy tool-call gate with these per-shape grants. */
|
|
149
159
|
releaseGrants?: ResolvedGrants;
|
|
150
160
|
/** Durable audit callback invoked only when that gate rejects a call. */
|
|
151
|
-
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
161
|
+
onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
|
|
152
162
|
/**
|
|
153
163
|
* The conductor verb socket this session's mutation tools call (#126).
|
|
154
164
|
*
|
|
@@ -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.
|
|
@@ -378,7 +393,7 @@ export interface CreateSessionOptions {
|
|
|
378
393
|
resume?: boolean;
|
|
379
394
|
role: SessionRole;
|
|
380
395
|
releaseGrants?: ResolvedGrants;
|
|
381
|
-
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
396
|
+
onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
|
|
382
397
|
|
|
383
398
|
/**
|
|
384
399
|
* Where the control socket is bound. The daemon puts it beside the run's own
|
|
@@ -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,8 +672,20 @@ 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
|
-
opts.onReleaseBlocked?.(message.shape
|
|
684
|
+
opts.onReleaseBlocked?.(message.shape, {
|
|
685
|
+
tool: message.tool,
|
|
686
|
+
reason: message.reason,
|
|
687
|
+
args: message.args,
|
|
688
|
+
});
|
|
662
689
|
break;
|
|
663
690
|
}
|
|
664
691
|
}
|
|
@@ -693,6 +720,14 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
693
720
|
// would make this seam depend on a shape it deliberately does not name.
|
|
694
721
|
return promise.then(() => undefined);
|
|
695
722
|
},
|
|
723
|
+
park() {
|
|
724
|
+
promptSeq += 1;
|
|
725
|
+
const id = promptSeq;
|
|
726
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
727
|
+
pending.set(id, { resolve, reject });
|
|
728
|
+
write({ t: "park", id });
|
|
729
|
+
return promise.then(() => undefined);
|
|
730
|
+
},
|
|
696
731
|
on(event, cb) {
|
|
697
732
|
const list = handlers.get(event);
|
|
698
733
|
if (list) list.push(cb);
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -62,23 +62,27 @@ import {
|
|
|
62
62
|
} from "./setup.ts";
|
|
63
63
|
import {
|
|
64
64
|
recordReleaseBlock,
|
|
65
|
+
redactReleaseArgs,
|
|
65
66
|
releaseDriftDigestLine,
|
|
66
67
|
releaseRefusal,
|
|
67
68
|
releaseShapeFromTool,
|
|
68
69
|
type ReleaseDecision,
|
|
69
70
|
} from "./release-policy.ts";
|
|
70
71
|
import {
|
|
72
|
+
DEFAULT_REPORT_POLICY,
|
|
71
73
|
DEFAULT_REPORT_SCOPE,
|
|
72
74
|
DENIED_RELEASE_GRANTS,
|
|
73
75
|
type DispatchSummary,
|
|
74
76
|
type FrictionSignal,
|
|
75
77
|
type ReportScope,
|
|
78
|
+
type ReportingPolicy,
|
|
76
79
|
type ResolvedGrants,
|
|
77
80
|
type Store,
|
|
78
81
|
} from "./types.ts";
|
|
79
82
|
import { formatDecisionDigest } from "./decisions.ts";
|
|
80
83
|
import type { RunRecord } from "./types.ts";
|
|
81
84
|
import { dbPath, openStore } from "./store.ts";
|
|
85
|
+
import { digestDue } from "./digest-schedule.ts";
|
|
82
86
|
|
|
83
87
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
84
88
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -386,7 +390,17 @@ export function queueDigestLine(
|
|
|
386
390
|
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
391
|
}
|
|
388
392
|
if (summary.routed === 0) {
|
|
389
|
-
|
|
393
|
+
// `ready` counts claimed (in-flight) issues too; route() drops those with a
|
|
394
|
+
// state label silently, so "0 routable" must not blanket-blame missing
|
|
395
|
+
// `repo:` labels (#228). Spare depth is what dispatch can actually claim.
|
|
396
|
+
const claimed = summary.claimed ?? 0;
|
|
397
|
+
const unroutable = summary.holds
|
|
398
|
+
.filter((h) => h.reason.startsWith("unroutable:"))
|
|
399
|
+
.reduce((n, h) => n + h.count, 0);
|
|
400
|
+
if (unroutable === 0) {
|
|
401
|
+
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.`;
|
|
402
|
+
}
|
|
403
|
+
return `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label).`;
|
|
390
404
|
}
|
|
391
405
|
if (summary.routed >= groomBelow) return undefined;
|
|
392
406
|
let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
|
|
@@ -399,11 +413,51 @@ export function queueDigestLine(
|
|
|
399
413
|
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
400
414
|
material: "Report material events per your brief.",
|
|
401
415
|
escalations:
|
|
402
|
-
"
|
|
416
|
+
"Interrupt only for: tier2, fleet-stopped; everything else — releases included — waits for the daily digest.",
|
|
403
417
|
decisions:
|
|
404
418
|
"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
419
|
};
|
|
406
420
|
|
|
421
|
+
/**
|
|
422
|
+
* The reporting constraint appended to a default tick prompt (#229, #242).
|
|
423
|
+
*
|
|
424
|
+
* Material and decisions keep their legacy preset words. The escalations
|
|
425
|
+
* preset instead names the policy's actual interrupt categories, so the prompt
|
|
426
|
+
* cannot drift from the gate. Every daily cadence gets an explicit due state:
|
|
427
|
+
* model-timed digests need that instruction just as scheduled digests do.
|
|
428
|
+
*/
|
|
429
|
+
export function tickReportingConstraint(
|
|
430
|
+
policy: ReportingPolicy | undefined,
|
|
431
|
+
digest: {
|
|
432
|
+
due: boolean;
|
|
433
|
+
cadence: ReportingPolicy["digest"]["cadence"];
|
|
434
|
+
at?: string;
|
|
435
|
+
timezone?: string;
|
|
436
|
+
},
|
|
437
|
+
held: number,
|
|
438
|
+
): string {
|
|
439
|
+
const preset = policy?.scopePreset;
|
|
440
|
+
const allowed = policy?.interruptOn ?? [];
|
|
441
|
+
let base: string;
|
|
442
|
+
if (preset === "escalations") {
|
|
443
|
+
base = `Interrupt only for: ${allowed.join(", ")}; everything else — releases included — waits for the daily digest.`;
|
|
444
|
+
} else if (preset !== undefined) {
|
|
445
|
+
base = TICK_SCOPE_CONSTRAINTS[preset];
|
|
446
|
+
} else {
|
|
447
|
+
base =
|
|
448
|
+
allowed.length === 0
|
|
449
|
+
? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
|
|
450
|
+
: `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
|
|
451
|
+
}
|
|
452
|
+
if (digest.cadence !== "daily") return base;
|
|
453
|
+
if (digest.due) {
|
|
454
|
+
return `${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.`;
|
|
455
|
+
}
|
|
456
|
+
return digest.at === undefined
|
|
457
|
+
? `${base} The daily digest was already sent today; do not send another.`
|
|
458
|
+
: `${base} The daily digest is not due (scheduled ${digest.at}${digest.timezone === undefined ? "" : ` ${digest.timezone}`}); do not send one.`;
|
|
459
|
+
}
|
|
460
|
+
|
|
407
461
|
/**
|
|
408
462
|
* The delivery clause, appended to every default tick prompt.
|
|
409
463
|
*
|
|
@@ -572,6 +626,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
|
|
|
572
626
|
*/
|
|
573
627
|
export function resolveTickScope(): {
|
|
574
628
|
scope: ReportScope;
|
|
629
|
+
policy?: ReportingPolicy;
|
|
575
630
|
briefPath?: string;
|
|
576
631
|
policyPath?: string;
|
|
577
632
|
projectName?: string;
|
|
@@ -580,7 +635,8 @@ export function resolveTickScope(): {
|
|
|
580
635
|
try {
|
|
581
636
|
const project = findProject(loadConfig());
|
|
582
637
|
return {
|
|
583
|
-
scope: project.reporting?.
|
|
638
|
+
scope: project.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
|
|
639
|
+
policy: project.reporting,
|
|
584
640
|
briefPath: briefPathForProject(project),
|
|
585
641
|
policyPath: policyPathForProject(project),
|
|
586
642
|
projectName: project.name,
|
|
@@ -1327,7 +1383,31 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1327
1383
|
pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
|
|
1328
1384
|
}
|
|
1329
1385
|
const bridged = approval?.kind === "ready" && approval.notifyMode === "always" && profile?.kind === "interactive";
|
|
1330
|
-
|
|
1386
|
+
let reportingConstraint = TICK_SCOPE_CONSTRAINTS[scope.scope];
|
|
1387
|
+
if (scope.projectName !== undefined) {
|
|
1388
|
+
const policy = scope.policy;
|
|
1389
|
+
const digestPolicy = policy?.digest ?? DEFAULT_REPORT_POLICY.digest;
|
|
1390
|
+
const cadence = digestPolicy.cadence;
|
|
1391
|
+
const at = Date.now();
|
|
1392
|
+
const store = openStore(dbPath());
|
|
1393
|
+
try {
|
|
1394
|
+
const lastKey = store.lastDigestDedupeKey(scope.projectName);
|
|
1395
|
+
const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
|
|
1396
|
+
reportingConstraint = tickReportingConstraint(
|
|
1397
|
+
policy,
|
|
1398
|
+
{
|
|
1399
|
+
due: digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at),
|
|
1400
|
+
cadence,
|
|
1401
|
+
at: digestPolicy.at,
|
|
1402
|
+
timezone: digestPolicy.timezone,
|
|
1403
|
+
},
|
|
1404
|
+
store.undigestedNotices(scope.projectName).length,
|
|
1405
|
+
);
|
|
1406
|
+
} finally {
|
|
1407
|
+
store.close();
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
|
|
1331
1411
|
}
|
|
1332
1412
|
let frictionStore: Store | undefined;
|
|
1333
1413
|
let frictionSignals: FrictionSignal[] = [];
|
|
@@ -1613,9 +1693,17 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1613
1693
|
// `createSession`; suppress this second copy only after this session has
|
|
1614
1694
|
// proved it owns the external heartbeat.
|
|
1615
1695
|
if (releaseAuthorityAccepted && !external) return undefined;
|
|
1696
|
+
// Before ownership is proved this session holds no grant at all, so a
|
|
1697
|
+
// covered shape still refuses with the deny-all wording.
|
|
1698
|
+
const decision = refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
|
|
1699
|
+
if (decision === undefined) return undefined;
|
|
1616
1700
|
if (projectName !== undefined) {
|
|
1617
1701
|
try {
|
|
1618
|
-
recordReleaseBlock(projectName, "orchestrator", shape
|
|
1702
|
+
recordReleaseBlock(projectName, "orchestrator", shape, {
|
|
1703
|
+
tool: event.toolName,
|
|
1704
|
+
reason: decision.reason,
|
|
1705
|
+
args: redactReleaseArgs(event.input),
|
|
1706
|
+
});
|
|
1619
1707
|
} catch (err) {
|
|
1620
1708
|
pi.logger.error(
|
|
1621
1709
|
`[omp-conductor] could not record release-policy block: ${
|
|
@@ -1624,10 +1712,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1624
1712
|
);
|
|
1625
1713
|
}
|
|
1626
1714
|
}
|
|
1627
|
-
|
|
1628
|
-
// covered shape still refuses — with the wording it would get from a
|
|
1629
|
-
// deny-all map rather than a claim about a grant it cannot yet use.
|
|
1630
|
-
return refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
|
|
1715
|
+
return decision;
|
|
1631
1716
|
});
|
|
1632
1717
|
};
|
|
1633
1718
|
|
package/src/orchestrator.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { stateDir } from "./config.ts";
|
|
|
33
33
|
import { formatEscalation } from "./escalate.ts";
|
|
34
34
|
import { createSession, disposeSession } from "./omp.ts";
|
|
35
35
|
import type { AgentSessionLike } from "./omp.ts";
|
|
36
|
+
import type { ReleaseBlockContext } from "./release-policy.ts";
|
|
36
37
|
import type { Escalation, ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -46,7 +47,7 @@ export type CreateSessionFn = (opts: {
|
|
|
46
47
|
resume?: boolean;
|
|
47
48
|
role: SessionRole;
|
|
48
49
|
releaseGrants?: ResolvedGrants;
|
|
49
|
-
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
50
|
+
onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
|
|
50
51
|
onSpawn?: (pid: number) => void;
|
|
51
52
|
socketPath?: string;
|
|
52
53
|
verbSocketPath?: string;
|
|
@@ -84,7 +85,7 @@ export interface OrchestratorOpts {
|
|
|
84
85
|
sessionDir?: string;
|
|
85
86
|
model?: string;
|
|
86
87
|
releaseGrants?: ResolvedGrants;
|
|
87
|
-
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
88
|
+
onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
|
|
88
89
|
/** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
|
|
89
90
|
onSpawn?: (pid: number) => void;
|
|
90
91
|
/** Control socket for the session child, beside its own working directory. */
|