@akagilnc/pi-workflow-roles 0.1.2157 → 0.1.2173

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.
@@ -1,15 +1,26 @@
1
1
  /**
2
2
  * Single generic auto-resume loop for #416 (owner scope = single LLM call).
3
- * Call-local retries, at most AUTO_RESUME_LIMIT times, in-place (same runId/session).
3
+ * Call-local retries, at most the effective autoResumeLimit times (injected once
4
+ * per call by the caller, #422 — never re-read from disk inside the loop),
5
+ * in-place (same runId/session).
4
6
  * Unifies presentation: intermediate attempts use dummyIo, only final Terminal is presented.
5
7
  */
6
8
  import { AUTO_RESUME_LIMIT, isSessionPrincipalAvailable, acquireRunWriterLease, RunWriterLeaseHeldError, type RunWriterLease } from "./run-lifecycle.ts";
9
+ import { parseAutoResumeLimit } from "./config.ts";
7
10
  import { isLawfulTypedTerminalOutcome, formatTerminalResult, type TerminalResult } from "./terminal.ts";
8
11
  import { presentFailureTerminal, presentStructuralRejection } from "./settlement.ts";
9
12
  import type { CliIo } from "./cli-io.ts";
10
13
 
11
14
  const dummyIo: CliIo = { stdout: () => {}, stderr: () => {} };
12
15
 
16
+ function presentTerminal(terminal: TerminalResult, io: CliIo): void {
17
+ if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
18
+ presentFailureTerminal(terminal, io);
19
+ } else {
20
+ io.stdout(formatTerminalResult(terminal));
21
+ }
22
+ }
23
+
13
24
  export type AutoResumeDispatchResult = {
14
25
  exitCode: number;
15
26
  terminal?: TerminalResult;
@@ -18,10 +29,21 @@ export type AutoResumeDispatchResult = {
18
29
  export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(options: {
19
30
  admitted: { sessionFile: string; runDirectory: string };
20
31
  io: CliIo;
32
+ /**
33
+ * Effective ceiling (#422), resolved by the caller before the loop; never re-read
34
+ * per round. undefined = package default (AUTO_RESUME_LIMIT). Domain-validated at
35
+ * this single entry point (#422): NaN/negative/fractional/Infinity reject loudly
36
+ * before the first dispatch instead of silently bypassing the ceiling comparison.
37
+ */
38
+ autoResumeLimit?: number | undefined;
21
39
  buildInitialArgs: () => string[];
22
40
  buildResumeArgs: () => string[];
23
41
  dispatch: (extraArgs: string[], lease: RunWriterLease, isFirst: boolean, attemptIo: CliIo) => Promise<T>;
24
42
  }): Promise<T> {
43
+ // #422 single-point resolution + domain validation. NaN would bypass every
44
+ // `attempts >= limit` comparison (always false) — reject here, before any dispatch.
45
+ const limit = options.autoResumeLimit ?? AUTO_RESUME_LIMIT;
46
+ parseAutoResumeLimit(limit);
25
47
  let autoResumeAttempts = 0;
26
48
  let isFirst = true;
27
49
  let currentExtraArgs = options.buildInitialArgs();
@@ -29,7 +51,9 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
29
51
  while (true) {
30
52
  let lease: RunWriterLease;
31
53
  try {
32
- lease = await acquireRunWriterLease(options.admitted.runDirectory);
54
+ lease = await acquireRunWriterLease(options.admitted.runDirectory, (diagnostic) =>
55
+ options.io.stderr(diagnostic),
56
+ );
33
57
  } catch (error) {
34
58
  if (error instanceof RunWriterLeaseHeldError) {
35
59
  presentStructuralRejection(error, options.io);
@@ -54,42 +78,12 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
54
78
  return result;
55
79
  }
56
80
 
57
- // Deterministic incomplete/audit_incomplete (merger/collector/judge) would duplicate callId bindings on retry and lose settlement
58
- if (terminal !== undefined && (terminal.roleOutcome.kind === "incomplete" || terminal.roleOutcome.kind === "audit_incomplete")) {
59
- if (terminal.roleOutcome.kind === "audit_incomplete") {
60
- options.io.stdout(formatTerminalResult(terminal));
61
- } else {
62
- options.io.stdout(formatTerminalResult(terminal));
63
- }
64
- return result;
65
- }
66
- // Rich auditor retention detail would be lost on retry (stale child binding)
67
- if (terminal !== undefined && terminal.roleOutcome.kind === "failure") {
68
- const terminalJson = JSON.stringify(terminal);
69
- if (terminalJson.includes("retentionFailure") || terminalJson.includes("ComplianceResponseRetentionError")) {
70
- presentFailureTerminal(terminal, options.io);
71
- return result;
72
- }
73
- }
74
-
75
- if (autoResumeAttempts >= AUTO_RESUME_LIMIT) {
76
- if (terminal !== undefined) {
77
- if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
78
- presentFailureTerminal(terminal, options.io);
79
- } else {
80
- options.io.stdout(formatTerminalResult(terminal));
81
- }
82
- }
81
+ if (autoResumeAttempts >= limit) {
82
+ if (terminal !== undefined) presentTerminal(terminal, options.io);
83
83
  return result;
84
84
  }
85
85
  if (!(await isSessionPrincipalAvailable(options.admitted.sessionFile))) {
86
- if (terminal !== undefined) {
87
- if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
88
- presentFailureTerminal(terminal, options.io);
89
- } else {
90
- options.io.stdout(formatTerminalResult(terminal));
91
- }
92
- }
86
+ if (terminal !== undefined) presentTerminal(terminal, options.io);
93
87
  return result;
94
88
  }
95
89
 
@@ -17,6 +17,7 @@ import {
17
17
  parseModelSpec,
18
18
  resolveEffectiveSeat,
19
19
  savePublicCliConfig,
20
+ setAutoResumeLimit,
20
21
  setPersistentSeatConfig,
21
22
  setPersistentSeatEngine,
22
23
  validatePublicCliConfigEngines,
@@ -58,7 +59,7 @@ import { runPublicJudge, runPublicResume } from "./judge-run.ts";
58
59
  import { runPublicMerger, runPublicMergerResume } from "./merger-run.ts";
59
60
  import { runPublicReviewer, runPublicReviewerResume } from "./reviewer-run.ts";
60
61
  import { runPublicTaishi } from "./taishi-run.ts";
61
- import { peekRoleRunRole } from "./run-lifecycle.ts";
62
+ import { AUTO_RESUME_LIMIT, peekRoleRunRole } from "./run-lifecycle.ts";
62
63
  import {
63
64
  AUTOMATIC_NAVIGATOR_SEAT,
64
65
  INTERNAL_ROLE_ENTRYPOINT_RELATIVE,
@@ -527,6 +528,8 @@ function renderConfig(config: PublicCliConfig): string {
527
528
  lines.push(`${seat}\t${formatModelSpec(selection)}\t${engine}`);
528
529
  }
529
530
  }
531
+ // #422: show the effective auto-resume ceiling (configured value or default).
532
+ lines.push(`autoResumeLimit\t${config.autoResumeLimit ?? AUTO_RESUME_LIMIT}`);
530
533
  return `${lines.join("\n")}\n`;
531
534
  }
532
535
 
@@ -631,6 +634,39 @@ async function runConfigCommand(
631
634
  return 0;
632
635
  }
633
636
 
637
+ // #422: standalone verb per the set-engine/unset-engine precedent — the
638
+ // existing `config set` grammar stays strictly even-position seat/spec pairs.
639
+ if (args[0] === "set-auto-resume-limit") {
640
+ if (args.length !== 2) {
641
+ throw new CliUsageError(
642
+ "usage: ak-role config set-auto-resume-limit <N>",
643
+ );
644
+ }
645
+ const raw = args[1]!;
646
+ if (!/^[0-9]+$/.test(raw)) {
647
+ throw new CliUsageError(
648
+ `auto-resume limit must be a non-negative integer, got ${raw}`,
649
+ );
650
+ }
651
+ // #422 fidelity boundary (not an upper bound — ADR 0035 stays intact): the
652
+ // persisted representation is a JS number, and Number() silently rounds
653
+ // integers beyond 2^53-1 (e.g. 9007199254740993 → 9007199254740992). Refuse
654
+ // loudly instead of persisting a different N; every exactly-representable
655
+ // non-negative integer remains legal. The regex above guarantees pure
656
+ // digits, so BigInt(raw) has no leading-zero ambiguity.
657
+ const converted = Number(raw);
658
+ if (!Number.isFinite(converted) || BigInt(converted) !== BigInt(raw)) {
659
+ throw new CliUsageError(
660
+ `auto-resume limit ${raw} is not exactly representable as a number; refusing to silently round the value`,
661
+ );
662
+ }
663
+ let config = await loadAndValidateConfig(home, packageRoot);
664
+ config = setAutoResumeLimit(config, converted);
665
+ await savePublicCliConfig(config, home);
666
+ io.stdout(renderConfig(config));
667
+ return 0;
668
+ }
669
+
634
670
  throw new CliUsageError(`unknown config subcommand: ${args[0]}`);
635
671
  }
636
672
 
@@ -922,6 +958,10 @@ export async function runAkRole(
922
958
  ? {}
923
959
  : { timeoutMs: env.judgeTimeoutMs }),
924
960
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
961
+ // #422: effective auto-resume ceiling resolved once here; the loop never re-reads disk.
962
+ ...(config.autoResumeLimit === undefined
963
+ ? {}
964
+ : { autoResumeLimit: config.autoResumeLimit }),
925
965
  },
926
966
  io,
927
967
  PUBLIC_ROLE_ARGV.judge.parse,
@@ -966,6 +1006,10 @@ export async function runAkRole(
966
1006
  ? {}
967
1007
  : { timeoutMs: env.coderTimeoutMs }),
968
1008
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
1009
+ // #422: effective auto-resume ceiling resolved once here; the loop never re-reads disk.
1010
+ ...(config.autoResumeLimit === undefined
1011
+ ? {}
1012
+ : { autoResumeLimit: config.autoResumeLimit }),
969
1013
  },
970
1014
  io,
971
1015
  PUBLIC_ROLE_ARGV.coder.parse,
@@ -1010,6 +1054,10 @@ export async function runAkRole(
1010
1054
  ? {}
1011
1055
  : { timeoutMs: env.fixerTimeoutMs }),
1012
1056
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
1057
+ // #422: effective auto-resume ceiling resolved once here; the loop never re-reads disk.
1058
+ ...(config.autoResumeLimit === undefined
1059
+ ? {}
1060
+ : { autoResumeLimit: config.autoResumeLimit }),
1013
1061
  },
1014
1062
  io,
1015
1063
  PUBLIC_ROLE_ARGV.fixer.parse,
@@ -1098,6 +1146,10 @@ export async function runAkRole(
1098
1146
  ? {}
1099
1147
  : { timeoutMs: env.reviewerTimeoutMs }),
1100
1148
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
1149
+ // #422: effective auto-resume ceiling resolved once here; the loop never re-reads disk.
1150
+ ...(config.autoResumeLimit === undefined
1151
+ ? {}
1152
+ : { autoResumeLimit: config.autoResumeLimit }),
1101
1153
  },
1102
1154
  io,
1103
1155
  PUBLIC_ROLE_ARGV.reviewer.parse,
@@ -1186,6 +1238,10 @@ export async function runAkRole(
1186
1238
  ? {}
1187
1239
  : { timeoutMs: env.mergerTimeoutMs }),
1188
1240
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
1241
+ // #422: effective auto-resume ceiling resolved once here; the loop never re-reads disk.
1242
+ ...(config.autoResumeLimit === undefined
1243
+ ? {}
1244
+ : { autoResumeLimit: config.autoResumeLimit }),
1189
1245
  },
1190
1246
  io,
1191
1247
  PUBLIC_ROLE_ARGV.merger.parse,
@@ -86,6 +86,8 @@ export type CoderRunEnv = {
86
86
  engine?: string;
87
87
  credentials?: CredentialProviders;
88
88
  createRunId?: () => string;
89
+ /** #422: effective single-call auto-resume ceiling; undefined = package default (AUTO_RESUME_LIMIT). */
90
+ autoResumeLimit?: number;
89
91
  extraPiArgs?: readonly string[];
90
92
  timeoutMs?: number;
91
93
  };
@@ -475,6 +477,8 @@ export async function runPublicCoder(
475
477
  return runWithAutoResumeLoop({
476
478
  admitted,
477
479
  io,
480
+ // #422: pass-through only; the loop entry resolves the default and validates the domain once.
481
+ autoResumeLimit: env.autoResumeLimit,
478
482
  buildInitialArgs: () =>
479
483
  buildCoderActivationExtraArgs(admitted, {
480
484
  packageRoot: env.packageRoot,
@@ -548,7 +552,7 @@ export async function runPublicCoderResume(
548
552
 
549
553
  let lease: RunWriterLease;
550
554
  try {
551
- lease = await acquireRunWriterLease(admitted.runDirectory);
555
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
552
556
  } catch (error) {
553
557
  if (error instanceof RunWriterLeaseHeldError) {
554
558
  // Concurrent resume: reject without second writer or dispatch.
@@ -355,7 +355,7 @@ export async function runPublicCollector(
355
355
 
356
356
  let lease: RunWriterLease;
357
357
  try {
358
- lease = await acquireRunWriterLease(admitted.runDirectory);
358
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
359
359
  } catch (error) {
360
360
  if (error instanceof RunWriterLeaseHeldError) {
361
361
  presentStructuralRejection(error, io);
@@ -33,6 +33,13 @@ export type PersistentSeatConfig = SeatModelConfig & {
33
33
 
34
34
  export type PublicCliConfig = {
35
35
  seats: Partial<Record<PublicConfigurableSeat, PersistentSeatConfig>>;
36
+ /**
37
+ * #422: single-call auto-resume retry ceiling, sibling of `seats`.
38
+ * Non-negative integer; 0 disables auto-resume (one dispatch per call).
39
+ * undefined = package default (AUTO_RESUME_LIMIT). No package-local upper
40
+ * bound (ADR 0035).
41
+ */
42
+ autoResumeLimit?: number;
36
43
  };
37
44
 
38
45
  export type EffectiveSource = "persistent" | "startup" | "invocation" | "unconfigured";
@@ -108,6 +115,9 @@ export function setPersistentSeatConfig(
108
115
  ): PublicCliConfig {
109
116
  const previous = config.seats[seat];
110
117
  return {
118
+ // Spread preserves sibling top-level keys such as autoResumeLimit (#422):
119
+ // a seat write must never silently drop them.
120
+ ...config,
111
121
  seats: {
112
122
  ...config.seats,
113
123
  [seat]: {
@@ -146,6 +156,7 @@ export function setPersistentSeatEngine(
146
156
  if (engine === undefined) {
147
157
  const { engine: _dropped, ...modelOnly } = previous;
148
158
  return {
159
+ ...config,
149
160
  seats: {
150
161
  ...config.seats,
151
162
  [seat]: modelOnly,
@@ -155,6 +166,7 @@ export function setPersistentSeatEngine(
155
166
  // Engine-name path-safety syntax is owned solely by assertLegalEngineName
156
167
  // (call-request + config-parse seams). Setter is pure seat mutation.
157
168
  return {
169
+ ...config,
158
170
  seats: {
159
171
  ...config.seats,
160
172
  [seat]: { ...previous, engine },
@@ -162,6 +174,32 @@ export function setPersistentSeatEngine(
162
174
  };
163
175
  }
164
176
 
177
+ /**
178
+ * #422 value domain authority for the auto-resume ceiling: non-negative integer,
179
+ * no package-local upper bound (ADR 0035). `0` is legal and means auto-resume is
180
+ * disabled (a single dispatch, no in-place retry). Negative numbers, fractions,
181
+ * NaN, Infinity and non-number types are rejected loudly — never silently coerced.
182
+ */
183
+ export function parseAutoResumeLimit(value: unknown): number {
184
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
185
+ throw new Error(
186
+ `auto-resume limit must be a non-negative integer, got ${JSON.stringify(value)}`,
187
+ );
188
+ }
189
+ return value;
190
+ }
191
+
192
+ /**
193
+ * #422: set the persistent autoResumeLimit top-level key. Pure mutation that
194
+ * preserves all sibling keys (seats included).
195
+ */
196
+ export function setAutoResumeLimit(
197
+ config: PublicCliConfig,
198
+ limit: number,
199
+ ): PublicCliConfig {
200
+ return { ...config, autoResumeLimit: parseAutoResumeLimit(limit) };
201
+ }
202
+
165
203
  /** Strip optional engine so activation model argv never sees the engine axis. */
166
204
  export function seatModelOnly(seat: PersistentSeatConfig): SeatModelConfig {
167
205
  return seat.thinking === undefined
@@ -261,9 +299,18 @@ function parsePublicCliConfig(value: unknown): PublicCliConfig {
261
299
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
262
300
  throw new Error("public CLI config must be an object");
263
301
  }
264
- const record = value as { seats?: unknown };
302
+ const record = value as { seats?: unknown; autoResumeLimit?: unknown };
303
+ // #422 round-trip preservation: the sibling top-level key must survive every
304
+ // parse→save cycle; an unknown-key drop would silently erase it on any write.
305
+ let autoResumeLimit: number | undefined;
306
+ if (record.autoResumeLimit !== undefined) {
307
+ autoResumeLimit = parseAutoResumeLimit(record.autoResumeLimit);
308
+ }
265
309
  if (record.seats === undefined) {
266
- return { seats: {} };
310
+ return {
311
+ seats: {},
312
+ ...(autoResumeLimit === undefined ? {} : { autoResumeLimit }),
313
+ };
267
314
  }
268
315
  if (
269
316
  record.seats === null ||
@@ -281,7 +328,10 @@ function parsePublicCliConfig(value: unknown): PublicCliConfig {
281
328
  }
282
329
  seats[key as PublicConfigurableSeat] = parseSeatModelConfig(raw, key);
283
330
  }
284
- return { seats };
331
+ return {
332
+ seats,
333
+ ...(autoResumeLimit === undefined ? {} : { autoResumeLimit }),
334
+ };
285
335
  }
286
336
 
287
337
  function parseSeatModelConfig(value: unknown, seat: string): PersistentSeatConfig {
@@ -334,7 +334,7 @@ export async function runPublicDoctor(
334
334
 
335
335
  let lease: RunWriterLease;
336
336
  try {
337
- lease = await acquireRunWriterLease(admitted.runDirectory);
337
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
338
338
  } catch (error) {
339
339
  if (error instanceof RunWriterLeaseHeldError) {
340
340
  presentStructuralRejection(error, io);
@@ -89,6 +89,8 @@ export type FixerRunEnv = {
89
89
  engine?: string;
90
90
  credentials?: CredentialProviders;
91
91
  createRunId?: () => string;
92
+ /** #422: effective single-call auto-resume ceiling; undefined = package default (AUTO_RESUME_LIMIT). */
93
+ autoResumeLimit?: number;
92
94
  extraPiArgs?: readonly string[];
93
95
  timeoutMs?: number;
94
96
  };
@@ -494,6 +496,8 @@ export async function runPublicFixer(
494
496
  return runWithAutoResumeLoop({
495
497
  admitted,
496
498
  io,
499
+ // #422: pass-through only; the loop entry resolves the default and validates the domain once.
500
+ autoResumeLimit: env.autoResumeLimit,
497
501
  buildInitialArgs: () =>
498
502
  buildFixerActivationExtraArgs(admitted, {
499
503
  packageRoot: env.packageRoot,
@@ -567,7 +571,7 @@ export async function runPublicFixerResume(
567
571
 
568
572
  let lease: RunWriterLease;
569
573
  try {
570
- lease = await acquireRunWriterLease(admitted.runDirectory);
574
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
571
575
  } catch (error) {
572
576
  if (error instanceof RunWriterLeaseHeldError) {
573
577
  io.stderr(formatCliDiagnostic(error.message));
@@ -89,6 +89,8 @@ export type JudgeRunEnv = {
89
89
  */
90
90
  credentials?: CredentialProviders;
91
91
  createRunId?: () => string;
92
+ /** #422: effective single-call auto-resume ceiling; undefined = package default (AUTO_RESUME_LIMIT). */
93
+ autoResumeLimit?: number;
92
94
  /** Extra Pi args inserted before the prompt (tests: faux provider extension). */
93
95
  extraPiArgs?: readonly string[];
94
96
  /** Override default role-run timeout. */
@@ -463,6 +465,8 @@ export async function runPublicJudge(
463
465
  return runWithAutoResumeLoop({
464
466
  admitted,
465
467
  io,
468
+ // #422: pass-through only; the loop entry resolves the default and validates the domain once.
469
+ autoResumeLimit: env.autoResumeLimit,
466
470
  buildInitialArgs: () =>
467
471
  buildJudgeActivationExtraArgs(admitted, {
468
472
  packageRoot: env.packageRoot,
@@ -536,7 +540,7 @@ export async function runPublicResume(
536
540
 
537
541
  let lease: RunWriterLease;
538
542
  try {
539
- lease = await acquireRunWriterLease(admitted.runDirectory);
543
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
540
544
  } catch (error) {
541
545
  if (error instanceof RunWriterLeaseHeldError) {
542
546
  // Concurrent resume: reject without second writer or dispatch.
@@ -90,6 +90,8 @@ export type MergerRunEnv = {
90
90
  engine?: string;
91
91
  credentials?: CredentialProviders;
92
92
  createRunId?: () => string;
93
+ /** #422: effective single-call auto-resume ceiling; undefined = package default (AUTO_RESUME_LIMIT). */
94
+ autoResumeLimit?: number;
93
95
  extraPiArgs?: readonly string[];
94
96
  timeoutMs?: number;
95
97
  };
@@ -575,6 +577,8 @@ export async function runPublicMerger(
575
577
  return runWithAutoResumeLoop({
576
578
  admitted,
577
579
  io,
580
+ // #422: pass-through only; the loop entry resolves the default and validates the domain once.
581
+ autoResumeLimit: env.autoResumeLimit,
578
582
  buildInitialArgs: () =>
579
583
  buildMergerActivationExtraArgs(admitted, {
580
584
  packageRoot: env.packageRoot,
@@ -648,7 +652,7 @@ export async function runPublicMergerResume(
648
652
 
649
653
  let lease: RunWriterLease;
650
654
  try {
651
- lease = await acquireRunWriterLease(admitted.runDirectory);
655
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
652
656
  } catch (error) {
653
657
  if (error instanceof RunWriterLeaseHeldError) {
654
658
  io.stderr(formatCliDiagnostic(error.message));
@@ -1098,10 +1098,12 @@ const SUPPORT_COMMAND_HELP = {
1098
1098
  "ak-role config set <seat> <provider/model[:thinking]> [<seat> <spec> ...]",
1099
1099
  "ak-role config set-engine <seat> <name>",
1100
1100
  "ak-role config unset-engine <seat>",
1101
+ "ak-role config set-auto-resume-limit <N>",
1101
1102
  ],
1102
1103
  examples: [
1103
1104
  "ak-role config set judge openai-codex/gpt-5.6-sol:high",
1104
1105
  "ak-role config set-engine judge opus",
1106
+ "ak-role config set-auto-resume-limit 3",
1105
1107
  ],
1106
1108
  },
1107
1109
  help: {
@@ -91,6 +91,8 @@ export type ReviewerRunEnv = {
91
91
  engine?: string;
92
92
  credentials?: CredentialProviders;
93
93
  createRunId?: () => string;
94
+ /** #422: effective single-call auto-resume ceiling; undefined = package default (AUTO_RESUME_LIMIT). */
95
+ autoResumeLimit?: number;
94
96
  extraPiArgs?: readonly string[];
95
97
  timeoutMs?: number;
96
98
  };
@@ -514,6 +516,8 @@ export async function runPublicReviewer(
514
516
  return runWithAutoResumeLoop({
515
517
  admitted,
516
518
  io,
519
+ // #422: pass-through only; the loop entry resolves the default and validates the domain once.
520
+ autoResumeLimit: env.autoResumeLimit,
517
521
  buildInitialArgs: () =>
518
522
  buildReviewerActivationExtraArgs(admitted, {
519
523
  packageRoot: env.packageRoot,
@@ -587,7 +591,7 @@ export async function runPublicReviewerResume(
587
591
 
588
592
  let lease: RunWriterLease;
589
593
  try {
590
- lease = await acquireRunWriterLease(admitted.runDirectory);
594
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
591
595
  } catch (error) {
592
596
  if (error instanceof RunWriterLeaseHeldError) {
593
597
  io.stderr(formatCliDiagnostic(error.message));
@@ -52,7 +52,10 @@ export type TypedHttp429Observation = {
52
52
  readonly provider: V1ResumableProvider;
53
53
  };
54
54
 
55
- /** Per single LLM call auto-resume retries (call-local, no persistence). See #416 scope correction 2026-08-22. */
55
+ /** Default for the #422 configurable single-call auto-resume ceiling
56
+ * (public-cli.json top-level `autoResumeLimit`). No longer the runtime truth
57
+ * source: runWithAutoResumeLoop receives the effective value once per call.
58
+ */
56
59
  export const AUTO_RESUME_LIMIT = 2 as const;
57
60
 
58
61
  export type RoleRunRecord = {
@@ -340,13 +343,52 @@ export type RunWriterLease = {
340
343
  release(): Promise<void>;
341
344
  };
342
345
 
346
+ /**
347
+ * True error identity for diagnostics — name/code/message as-is, never a
348
+ * guessed label (failure-honesty constitution).
349
+ */
350
+ function describeErrorIdentity(error: unknown): string {
351
+ const candidate = error as { name?: unknown; code?: unknown; message?: unknown };
352
+ const name =
353
+ typeof candidate?.name === "string" && candidate.name !== ""
354
+ ? candidate.name
355
+ : typeof error;
356
+ const code =
357
+ typeof candidate?.code === "string" || typeof candidate?.code === "number"
358
+ ? ` code=${String(candidate.code)}`
359
+ : "";
360
+ const message =
361
+ typeof candidate?.message === "string" && candidate.message !== ""
362
+ ? `: ${candidate.message}`
363
+ : "";
364
+ return `${name}${code}${message}`;
365
+ }
366
+
343
367
  /**
344
368
  * Acquire the one-writer lease for a Role run. Concurrent acquire rejects
345
369
  * without dispatch. Exclusive create — no second writer.
370
+ *
371
+ * `onCleanupFailure` receives a non-terminal diagnostic line when release-time
372
+ * lock cleanup fails. Release stays best-effort (a stale lock resurfaces as
373
+ * RunWriterLeaseHeldError on next acquire), but the true error identity must
374
+ * still land somewhere observable — silent swallowing is forbidden.
346
375
  */
347
376
  export async function acquireRunWriterLease(
348
377
  runDirectory: string,
378
+ onCleanupFailure?: (diagnostic: string) => void,
349
379
  ): Promise<RunWriterLease> {
380
+ const reportCleanupFailure = (error: unknown): void => {
381
+ // Sink isolation: a throwing onCleanupFailure must not propagate through
382
+ // release() — release stays best-effort by contract. The true cleanup
383
+ // cause has already been handed to the sink as its argument.
384
+ try {
385
+ onCleanupFailure?.(
386
+ `writer lease lock cleanup failed (best-effort continue; stale lock resurfaces as lease-held on next acquire) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
387
+ );
388
+ } catch {
389
+ // diagnostic-sink failure is itself best-effort; never break release().
390
+ }
391
+ };
350
392
  const lockPath = join(runDirectory, WRITER_LOCK_FILE);
351
393
  try {
352
394
  const handle = await open(lockPath, "wx");
@@ -371,7 +413,14 @@ export async function acquireRunWriterLease(
371
413
  try {
372
414
  await chmod(runDirectory, 0o755);
373
415
  await unlink(lockPath);
374
- } catch {}
416
+ } catch (retryError) {
417
+ // best-effort cleanup: stale lock will surface as lease-held on next acquire (exit 2),
418
+ // but the true chmod/unlink cause must be recorded, not swallowed.
419
+ reportCleanupFailure(retryError);
420
+ }
421
+ } else {
422
+ // non-EACCES unlink failure is best-effort settlement cleanup; record true cause.
423
+ reportCleanupFailure(error);
375
424
  }
376
425
  }
377
426
  },