omp-conductor 0.12.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 +40 -19
- package/package.json +1 -1
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +32 -2
- package/src/cli.ts +104 -23
- package/src/config.ts +10 -2
- package/src/daemon.ts +818 -131
- package/src/diff-flags.ts +4 -0
- package/src/escalate.ts +5 -5
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +7 -2
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +35 -20
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +28 -2
- package/src/release-policy.ts +66 -6
- package/src/session-host.ts +4 -3
- package/src/setup.ts +4 -2
- package/src/store.ts +190 -29
- package/src/tracker/github.ts +261 -56
- package/src/types.ts +141 -28
- package/src/verbs/actions.ts +127 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +95 -13
- package/src/worker.ts +33 -5
- package/src/worktree.ts +5 -0
package/src/diff-flags.ts
CHANGED
|
@@ -745,6 +745,10 @@ export function formatSettlementFlags(
|
|
|
745
745
|
pooledHeading(diff.attempts),
|
|
746
746
|
];
|
|
747
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
|
+
}
|
|
748
752
|
lines.push(
|
|
749
753
|
` ${flag.kind} ${flag.file}${flag.line === undefined ? "" : `:${flag.line}`} — ${flag.detail}` +
|
|
750
754
|
(flag.unattributed === true ? " [unattributed: the dispatching issue never names this file]" : ""),
|
package/src/escalate.ts
CHANGED
|
@@ -213,13 +213,13 @@ export function createEscalator(
|
|
|
213
213
|
if (e.tier === 2 && chatId) {
|
|
214
214
|
const token = readTelegramToken();
|
|
215
215
|
if (token) {
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
220
|
const category = e.category ?? "tier2";
|
|
221
221
|
const interruptOn = p.reporting?.interruptOn;
|
|
222
|
-
if (interruptOn !== undefined && !interruptOn.includes(category)) {
|
|
222
|
+
if (!e.urgent && interruptOn !== undefined && !interruptOn.includes(category)) {
|
|
223
223
|
store.addHeldNotice({
|
|
224
224
|
project: p.name,
|
|
225
225
|
category,
|
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. */
|
|
@@ -221,6 +224,8 @@ const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
|
|
|
221
224
|
|
|
222
225
|
export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
|
|
223
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 });
|
|
224
229
|
|
|
225
230
|
// The PR landed while the row says otherwise. Whatever else is true about this
|
|
226
231
|
// run, it succeeded, and the recovery is bookkeeping.
|
|
@@ -249,10 +254,7 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
249
254
|
// first request produces a turn-0 row with no artifacts, which
|
|
250
255
|
// `env-start-failure` would otherwise absorb and lose the cause.
|
|
251
256
|
if (run.state === "failed" || run.state === "killed") {
|
|
252
|
-
const credit =
|
|
253
|
-
run.lastError === undefined
|
|
254
|
-
? undefined
|
|
255
|
-
: providerCreditRefusal({ message: run.lastError });
|
|
257
|
+
const credit = providerError === undefined ? undefined : providerCreditRefusal(providerError);
|
|
256
258
|
if (credit !== undefined) {
|
|
257
259
|
return { cls: "provider-credit", recovery: "requeue", evidence: credit };
|
|
258
260
|
}
|
|
@@ -267,7 +269,7 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
267
269
|
// the same reason as the credit branch: a turn-0 stall would otherwise be
|
|
268
270
|
// absorbed by `env-start-failure` and lose its cause.
|
|
269
271
|
if (run.state === "failed" || run.state === "killed") {
|
|
270
|
-
const transient =
|
|
272
|
+
const transient = providerError === undefined ? undefined : providerTransientFault(providerError);
|
|
271
273
|
if (transient !== undefined) {
|
|
272
274
|
return { cls: "provider-transient", recovery: "requeue", evidence: transient };
|
|
273
275
|
}
|
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) {
|
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,
|
|
@@ -158,7 +158,7 @@ export async function createLocalSession(opts: {
|
|
|
158
158
|
/** Install the release/deploy tool-call gate with these per-shape grants. */
|
|
159
159
|
releaseGrants?: ResolvedGrants;
|
|
160
160
|
/** Durable audit callback invoked only when that gate rejects a call. */
|
|
161
|
-
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
161
|
+
onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
|
|
162
162
|
/**
|
|
163
163
|
* The conductor verb socket this session's mutation tools call (#126).
|
|
164
164
|
*
|
|
@@ -393,7 +393,7 @@ export interface CreateSessionOptions {
|
|
|
393
393
|
resume?: boolean;
|
|
394
394
|
role: SessionRole;
|
|
395
395
|
releaseGrants?: ResolvedGrants;
|
|
396
|
-
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
396
|
+
onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
|
|
397
397
|
|
|
398
398
|
/**
|
|
399
399
|
* Where the control socket is bound. The daemon puts it beside the run's own
|
|
@@ -681,7 +681,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
681
681
|
break;
|
|
682
682
|
}
|
|
683
683
|
case "release-blocked":
|
|
684
|
-
opts.onReleaseBlocked?.(message.shape
|
|
684
|
+
opts.onReleaseBlocked?.(message.shape, {
|
|
685
|
+
tool: message.tool,
|
|
686
|
+
reason: message.reason,
|
|
687
|
+
args: message.args,
|
|
688
|
+
});
|
|
685
689
|
break;
|
|
686
690
|
}
|
|
687
691
|
}
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
} from "./setup.ts";
|
|
63
63
|
import {
|
|
64
64
|
recordReleaseBlock,
|
|
65
|
+
redactReleaseArgs,
|
|
65
66
|
releaseDriftDigestLine,
|
|
66
67
|
releaseRefusal,
|
|
67
68
|
releaseShapeFromTool,
|
|
@@ -412,39 +413,48 @@ export function queueDigestLine(
|
|
|
412
413
|
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
413
414
|
material: "Report material events per your brief.",
|
|
414
415
|
escalations:
|
|
415
|
-
"
|
|
416
|
+
"Interrupt only for: tier2, fleet-stopped; everything else — releases included — waits for the daily digest.",
|
|
416
417
|
decisions:
|
|
417
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.",
|
|
418
419
|
};
|
|
419
420
|
|
|
420
421
|
/**
|
|
421
|
-
* The reporting constraint appended to a default tick prompt (#229).
|
|
422
|
+
* The reporting constraint appended to a default tick prompt (#229, #242).
|
|
422
423
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* categories and stating that everything else accumulates for the digest.
|
|
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
428
|
*/
|
|
429
429
|
export function tickReportingConstraint(
|
|
430
430
|
policy: ReportingPolicy | undefined,
|
|
431
|
-
digest: {
|
|
431
|
+
digest: {
|
|
432
|
+
due: boolean;
|
|
433
|
+
cadence: ReportingPolicy["digest"]["cadence"];
|
|
434
|
+
at?: string;
|
|
435
|
+
timezone?: string;
|
|
436
|
+
},
|
|
432
437
|
held: number,
|
|
433
438
|
): string {
|
|
434
439
|
const preset = policy?.scopePreset;
|
|
440
|
+
const allowed = policy?.interruptOn ?? [];
|
|
435
441
|
let base: string;
|
|
436
|
-
if (preset
|
|
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) {
|
|
437
445
|
base = TICK_SCOPE_CONSTRAINTS[preset];
|
|
438
446
|
} else {
|
|
439
|
-
const allowed = policy?.interruptOn ?? [];
|
|
440
447
|
base =
|
|
441
448
|
allowed.length === 0
|
|
442
449
|
? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
|
|
443
450
|
: `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
|
|
444
451
|
}
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
|
|
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.`
|
|
448
458
|
: `${base} The daily digest is not due (scheduled ${digest.at}${digest.timezone === undefined ? "" : ` ${digest.timezone}`}); do not send one.`;
|
|
449
459
|
}
|
|
450
460
|
|
|
@@ -1377,7 +1387,7 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1377
1387
|
if (scope.projectName !== undefined) {
|
|
1378
1388
|
const policy = scope.policy;
|
|
1379
1389
|
const digestPolicy = policy?.digest ?? DEFAULT_REPORT_POLICY.digest;
|
|
1380
|
-
const
|
|
1390
|
+
const cadence = digestPolicy.cadence;
|
|
1381
1391
|
const at = Date.now();
|
|
1382
1392
|
const store = openStore(dbPath());
|
|
1383
1393
|
try {
|
|
@@ -1387,7 +1397,7 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1387
1397
|
policy,
|
|
1388
1398
|
{
|
|
1389
1399
|
due: digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at),
|
|
1390
|
-
|
|
1400
|
+
cadence,
|
|
1391
1401
|
at: digestPolicy.at,
|
|
1392
1402
|
timezone: digestPolicy.timezone,
|
|
1393
1403
|
},
|
|
@@ -1683,9 +1693,17 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1683
1693
|
// `createSession`; suppress this second copy only after this session has
|
|
1684
1694
|
// proved it owns the external heartbeat.
|
|
1685
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;
|
|
1686
1700
|
if (projectName !== undefined) {
|
|
1687
1701
|
try {
|
|
1688
|
-
recordReleaseBlock(projectName, "orchestrator", shape
|
|
1702
|
+
recordReleaseBlock(projectName, "orchestrator", shape, {
|
|
1703
|
+
tool: event.toolName,
|
|
1704
|
+
reason: decision.reason,
|
|
1705
|
+
args: redactReleaseArgs(event.input),
|
|
1706
|
+
});
|
|
1689
1707
|
} catch (err) {
|
|
1690
1708
|
pi.logger.error(
|
|
1691
1709
|
`[omp-conductor] could not record release-policy block: ${
|
|
@@ -1694,10 +1712,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1694
1712
|
);
|
|
1695
1713
|
}
|
|
1696
1714
|
}
|
|
1697
|
-
|
|
1698
|
-
// covered shape still refuses — with the wording it would get from a
|
|
1699
|
-
// deny-all map rather than a claim about a grant it cannot yet use.
|
|
1700
|
-
return refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
|
|
1715
|
+
return decision;
|
|
1701
1716
|
});
|
|
1702
1717
|
};
|
|
1703
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. */
|
package/src/plugin.ts
CHANGED
|
@@ -794,7 +794,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
794
794
|
const tuneCaps = await ctx.ui.confirm(
|
|
795
795
|
"Caps",
|
|
796
796
|
`Defaults: ${workersDefault} workers, ` +
|
|
797
|
-
`${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns}
|
|
797
|
+
`${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` +
|
|
798
|
+
`${DEFAULT_CAPS.workerMaxTurnsCeiling} max turns and ` +
|
|
798
799
|
`${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
|
|
799
800
|
`${DEFAULT_CAPS.maxAttemptsPerIssue} failed attempts and ` +
|
|
800
801
|
`${DEFAULT_CAPS.maxContinuationsPerIssue} operational continuations per issue.${smallHostNote} Change them?`,
|
|
@@ -821,7 +822,32 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
821
822
|
"Spend ceiling per rolling day (USD) — blank = no spend cap",
|
|
822
823
|
caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
|
|
823
824
|
);
|
|
824
|
-
|
|
825
|
+
const workerMaxTurns = await askNumber(
|
|
826
|
+
ctx,
|
|
827
|
+
"Turn ceiling per worker",
|
|
828
|
+
caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns,
|
|
829
|
+
);
|
|
830
|
+
caps.workerMaxTurns = workerMaxTurns;
|
|
831
|
+
const turnCeilingFallback = Math.max(
|
|
832
|
+
workerMaxTurns,
|
|
833
|
+
caps.workerMaxTurnsCeiling ??
|
|
834
|
+
Math.min(workerMaxTurns * 2, Number.MAX_SAFE_INTEGER),
|
|
835
|
+
);
|
|
836
|
+
const workerMaxTurnsCeiling = await askNumber(
|
|
837
|
+
ctx,
|
|
838
|
+
"Maximum turn ceiling for one issue",
|
|
839
|
+
turnCeilingFallback,
|
|
840
|
+
);
|
|
841
|
+
if (workerMaxTurnsCeiling < workerMaxTurns) {
|
|
842
|
+
ctx.ui.notify(
|
|
843
|
+
`Maximum turn ceiling ${workerMaxTurnsCeiling} cannot be below the ` +
|
|
844
|
+
`${workerMaxTurns}-turn worker base — keeping ${turnCeilingFallback}.`,
|
|
845
|
+
"warning",
|
|
846
|
+
);
|
|
847
|
+
caps.workerMaxTurnsCeiling = turnCeilingFallback;
|
|
848
|
+
} else {
|
|
849
|
+
caps.workerMaxTurnsCeiling = workerMaxTurnsCeiling;
|
|
850
|
+
}
|
|
825
851
|
caps.workerWallClockMs = await askNumber(
|
|
826
852
|
ctx,
|
|
827
853
|
"Wall-clock ceiling per worker (ms)",
|
package/src/release-policy.ts
CHANGED
|
@@ -36,11 +36,19 @@ import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
|
36
36
|
|
|
37
37
|
export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl";
|
|
38
38
|
|
|
39
|
-
export interface
|
|
39
|
+
export interface ReleaseBlockContext {
|
|
40
|
+
tool: string;
|
|
41
|
+
reason: string;
|
|
42
|
+
args: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
|
|
40
46
|
project: string;
|
|
41
47
|
source: "worker" | "orchestrator";
|
|
42
48
|
shape: ReleaseShape;
|
|
43
49
|
at: string;
|
|
50
|
+
issue?: number;
|
|
51
|
+
runId?: string;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export type ReleaseDecision = { block: true; reason: string };
|
|
@@ -271,6 +279,32 @@ export function releaseDecision(
|
|
|
271
279
|
return decision === undefined ? undefined : { shape, decision };
|
|
272
280
|
}
|
|
273
281
|
|
|
282
|
+
const RELEASE_ARG_ALLOWLIST = new Set([
|
|
283
|
+
"repo",
|
|
284
|
+
"stack",
|
|
285
|
+
"artefact",
|
|
286
|
+
"artifact",
|
|
287
|
+
"environment",
|
|
288
|
+
"tag",
|
|
289
|
+
"version",
|
|
290
|
+
"image",
|
|
291
|
+
"prUrl",
|
|
292
|
+
"branch",
|
|
293
|
+
"service",
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
/** Keep only explicitly safe release-target strings; redact every other value. */
|
|
297
|
+
export function redactReleaseArgs(input: Record<string, unknown>): Record<string, string> {
|
|
298
|
+
const redacted: Record<string, string> = {};
|
|
299
|
+
for (const [key, value] of Object.entries(input)) {
|
|
300
|
+
redacted[key] =
|
|
301
|
+
RELEASE_ARG_ALLOWLIST.has(key) && typeof value === "string"
|
|
302
|
+
? value.slice(0, 120)
|
|
303
|
+
: "(redacted)";
|
|
304
|
+
}
|
|
305
|
+
return redacted;
|
|
306
|
+
}
|
|
307
|
+
|
|
274
308
|
interface ReleasePolicyPi {
|
|
275
309
|
on(
|
|
276
310
|
event: "tool_call",
|
|
@@ -285,14 +319,18 @@ interface ReleasePolicyPi {
|
|
|
285
319
|
export function releasePolicyTripwire(
|
|
286
320
|
grants: ResolvedGrants,
|
|
287
321
|
role: SessionRole,
|
|
288
|
-
onBlocked: (shape: ReleaseShape) => void = () => {},
|
|
322
|
+
onBlocked: (shape: ReleaseShape, context: ReleaseBlockContext) => void = () => {},
|
|
289
323
|
): (pi: ReleasePolicyPi) => void {
|
|
290
324
|
return (pi) => {
|
|
291
325
|
pi.on("tool_call", (event) => {
|
|
292
326
|
const blocked = releaseDecision(grants, role, event.toolName, event.input);
|
|
293
327
|
if (blocked === undefined) return undefined;
|
|
294
328
|
try {
|
|
295
|
-
onBlocked(blocked.shape
|
|
329
|
+
onBlocked(blocked.shape, {
|
|
330
|
+
tool: event.toolName,
|
|
331
|
+
reason: blocked.decision.reason,
|
|
332
|
+
args: redactReleaseArgs(event.input),
|
|
333
|
+
});
|
|
296
334
|
} catch {
|
|
297
335
|
// Audit is evidence, not the gate. A full disk must not turn a deny into allow.
|
|
298
336
|
}
|
|
@@ -305,11 +343,22 @@ export function recordReleaseBlock(
|
|
|
305
343
|
project: string,
|
|
306
344
|
source: ReleaseBlock["source"],
|
|
307
345
|
shape: ReleaseShape,
|
|
346
|
+
details: Omit<ReleaseBlock, "project" | "source" | "shape" | "at"> = {},
|
|
308
347
|
root = stateDir(),
|
|
309
348
|
now = new Date(),
|
|
310
349
|
): void {
|
|
311
350
|
mkdirSync(root, { recursive: true });
|
|
312
|
-
|
|
351
|
+
// Redact again at the durable boundary. Callers normally pass the tripwire's
|
|
352
|
+
// already-safe context, but no alternate recorder path gets to rely on that.
|
|
353
|
+
const { args, ...attribution } = details;
|
|
354
|
+
const record: ReleaseBlock = {
|
|
355
|
+
project,
|
|
356
|
+
source,
|
|
357
|
+
shape,
|
|
358
|
+
...attribution,
|
|
359
|
+
...(args === undefined ? {} : { args: redactReleaseArgs(args) }),
|
|
360
|
+
at: now.toISOString(),
|
|
361
|
+
};
|
|
313
362
|
appendFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
314
363
|
}
|
|
315
364
|
|
|
@@ -354,12 +403,23 @@ export function releaseDriftToday(
|
|
|
354
403
|
return latest === undefined ? undefined : { count, latest };
|
|
355
404
|
}
|
|
356
405
|
|
|
357
|
-
export function releaseDriftDigestLine(
|
|
406
|
+
export function releaseDriftDigestLine(
|
|
407
|
+
project: string,
|
|
408
|
+
root = stateDir(),
|
|
409
|
+
now = new Date(),
|
|
410
|
+
): string | undefined {
|
|
358
411
|
const drift = releaseDriftToday(project, root, now);
|
|
359
412
|
if (drift === undefined) return undefined;
|
|
413
|
+
const latest = drift.latest;
|
|
414
|
+
const attribution = [
|
|
415
|
+
`${latest.source} ${latest.shape} at ${latest.at}`,
|
|
416
|
+
...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]),
|
|
417
|
+
...(latest.runId === undefined ? [] : [`run ${latest.runId}`]),
|
|
418
|
+
...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]),
|
|
419
|
+
].join(", ");
|
|
360
420
|
return (
|
|
361
421
|
`Release-policy drift today: ${drift.count} release/deploy tool call(s) were blocked ` +
|
|
362
|
-
`(latest: ${
|
|
422
|
+
`(latest: ${attribution}). ` +
|
|
363
423
|
"Include this divergence from releasePolicy=none in today's digest."
|
|
364
424
|
);
|
|
365
425
|
}
|
package/src/session-host.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { connect } from "node:net";
|
|
23
23
|
|
|
24
24
|
import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
|
|
25
|
+
import type { ReleaseBlockContext } from "./release-policy.ts";
|
|
25
26
|
|
|
26
27
|
import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
27
28
|
|
|
@@ -65,7 +66,7 @@ export type HostToParent =
|
|
|
65
66
|
| { t: "session-file"; path: string }
|
|
66
67
|
| { t: "prompt-result"; id: number; ok: boolean; error?: string }
|
|
67
68
|
| { t: "park-result"; id: number; ok: boolean; error?: string }
|
|
68
|
-
| { t: "release-blocked"; shape: ReleaseShape };
|
|
69
|
+
| ({ t: "release-blocked"; shape: ReleaseShape } & ReleaseBlockContext);
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Depth at which a harness event stops being copied for the wire.
|
|
@@ -186,8 +187,8 @@ export async function runSessionHost(
|
|
|
186
187
|
// The release audit lives in the daemon's state directory, which this
|
|
187
188
|
// process may not be able to write and must not be trusted to. It
|
|
188
189
|
// becomes a message; the parent performs the durable write.
|
|
189
|
-
onReleaseBlocked: (shape) => {
|
|
190
|
-
send({ t: "release-blocked", shape });
|
|
190
|
+
onReleaseBlocked: (shape, context) => {
|
|
191
|
+
send({ t: "release-blocked", shape, ...context });
|
|
191
192
|
},
|
|
192
193
|
});
|
|
193
194
|
} catch (err) {
|
package/src/setup.ts
CHANGED
|
@@ -251,6 +251,7 @@ export const RELEASE_REQUIREMENT_CHOICES: { readonly [K in ReleaseRequirement]:
|
|
|
251
251
|
"runs-settled": "every run this release covers actually merged, not merely reached a green PR",
|
|
252
252
|
"no-open-prs": "no pull request is still open against the branch being released",
|
|
253
253
|
"queue-drained": "nothing still carries the queue label",
|
|
254
|
+
"base-branch-green": "the newest observed post-merge base-branch workflows are green",
|
|
254
255
|
"epic-children-closed": "the epic this release closes has no open children",
|
|
255
256
|
};
|
|
256
257
|
|
|
@@ -1191,14 +1192,15 @@ export const AMEND_AREAS: {
|
|
|
1191
1192
|
// The model rides with the caps because it is the other per-worker knob, and
|
|
1192
1193
|
// an area no menu offers is a setting only a full re-interview can reach.
|
|
1193
1194
|
name: "caps & worker model",
|
|
1194
|
-
asks: "concurrency, spend,
|
|
1195
|
+
asks: "concurrency, spend, turn base and extension ceiling, wall clock, failed attempts, continuations — then the worker model",
|
|
1195
1196
|
describe: (p) => {
|
|
1196
1197
|
const c = resolveCaps(p, DEFAULT_CAPS);
|
|
1197
1198
|
const answered = Object.keys(p.caps).length > 0;
|
|
1198
1199
|
const spend =
|
|
1199
1200
|
c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
|
|
1200
1201
|
return (
|
|
1201
|
-
`${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo),
|
|
1202
|
+
`${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo), ` +
|
|
1203
|
+
`${c.workerMaxTurns} base/${c.workerMaxTurnsCeiling} max turns, ` +
|
|
1202
1204
|
`${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` +
|
|
1203
1205
|
`${c.maxAttemptsPerIssue} failed attempt${c.maxAttemptsPerIssue === 1 ? "" : "s"}, ` +
|
|
1204
1206
|
`${c.maxContinuationsPerIssue} continuation${c.maxContinuationsPerIssue === 1 ? "" : "s"}` +
|