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/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
- // 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`.
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,
@@ -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 = run.lastError === undefined ? undefined : providerTransientFault({ message: run.lastError });
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 every mutation is refused.
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 — all mutations refused)"
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
  }
@@ -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
- "Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.",
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
- * 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.
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: { due: boolean; scheduled: boolean; at?: string; timezone?: string },
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 !== undefined) {
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 (!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.`
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 scheduled = digestPolicy.cadence === "daily" && digestPolicy.at !== undefined;
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
- scheduled,
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
- // Before ownership is proved this session holds no grant at all, so a
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
 
@@ -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} turns and ` +
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
- caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
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)",
@@ -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 ReleaseBlock {
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
- const record: ReleaseBlock = { project, source, shape, at: now.toISOString() };
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(project: string, root = stateDir(), now = new Date()): string | undefined {
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: ${drift.latest.source} ${drift.latest.shape} at ${drift.latest.at}). ` +
422
+ `(latest: ${attribution}). ` +
363
423
  "Include this divergence from releasePolicy=none in today's digest."
364
424
  );
365
425
  }
@@ -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, turns, wall clock, failed attempts, continuations — then the worker model",
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), ${c.workerMaxTurns} turns, ` +
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"}` +