@hone-ai/cli 1.39.0 → 1.41.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.
@@ -14,6 +14,18 @@
14
14
  * - The loop never approves and never posts a verdict (`verify-patch --report` does). It never passes
15
15
  * `--trust-source`, `--skip-verify`, `--ready` or `--acknowledge-*`: an untrusted story stays paused for a
16
16
  * person (HC-GF-270), and so does one whose repository never consented to running its tests (HC-GF-064).
17
+ * - HC-GF-363: under `verification.gate: enforce` the server refuses to approve a step_4 attempt whose
18
+ * verify-patch verdict is `fail`, and the loop used to stop there (exit 2). It now sends that one case back
19
+ * to the builder: POST /orchestrate/:runId/reject at step_4, with the measured failing tests as the reason,
20
+ * at most AUTO_REJECT_LIMIT (2) times per run. It rejects ONLY when every one of these holds: the gate is
21
+ * enforce for verify-patch, the server refuses approval, the latest completed step_4 attempt's LATEST
22
+ * verify-patch record says `fail` (not timeout, unresolved, not_run, error, pass_narrowed), the run's error
23
+ * says the same, and fewer than two earlier attempts carry AUTO_REJECT_PREFIX in their reject reason. The
24
+ * count is read from the server's rows on every decision, never kept here, so a restart cannot reset it; a
25
+ * read that fails means "unknown", and unknown never rejects. The two rejects spend step_4's re-execution
26
+ * budget (`max_retries_per_step`, default 2): a later step_5 route-back can pause MAX_RETRIES_EXCEEDED.
27
+ * A run stopped at the limit is named for a person (step, attempts, count, last failure) and the loop exits 2.
28
+ * Upgrading changes one thing: a run already paused on a refused `fail` is rejected on the first tick.
17
29
  * - It stops BEFORE acting when a breaker has fired. A terminal batch ends the loop after one last emit-pr pass.
18
30
  * - It is idempotent across restarts: an attempt that already has a verify-patch verdict on the server is not
19
31
  * verified again, and emit-pr's BRANCH_CONFLICT (5) means the branch was already emitted.
@@ -29,6 +41,7 @@
29
41
  */
30
42
 
31
43
  const { classifyPollState, POLL_OUTCOMES } = require('./poll-decision');
44
+ const { routerSafe } = require('./verify-patch');
32
45
  const { formatStoryLine } = require('./story-line');
33
46
  const { isGitRev, isStoryId } = require('./run-target');
34
47
  const {
@@ -46,9 +59,11 @@ const FORBIDDEN_FLAGS = Object.freeze(['--trust-source', '--skip-verify', '--rea
46
59
  * Pause reasons that mean a child is NOT at a gate waiting for its step_4 output to be judged. `GET /:runId`
47
60
  * sets `awaitingApproval` on ANY paused run with a current step, so without this a child paused by a breaker,
48
61
  * a budget, an agent's hard pause or a guardrail would look like a gate, and the loop would run its patch.
49
- * Sources: orchestrator.js (BATCH_*), orchestrator-guardrails.js GUARDRAIL_REFUSAL_CODES, the hard-pause scan.
62
+ * Sources: orchestrator.js (BATCH_*), orchestrator-guardrails.js GUARDRAIL_REFUSAL_CODES, the hard-pause scan, and
63
+ * architect-consult.js CONSULT_REQUIRED_CODE (HC-GF-030): a run the consult guard refused at a step it had no
64
+ * consult for. Its attempt is `pending` and has no output, and approving it is refused by the server.
50
65
  */
51
- const NOT_A_GATE = /^(BATCH_FAILURE_THRESHOLD_EXCEEDED:|BATCH_BUDGET_EXCEEDED:|MAX_RETRIES_EXCEEDED:|MAX_TOTAL_STEPS_EXCEEDED:|hard_pause at )/;
66
+ const NOT_A_GATE = /^(BATCH_FAILURE_THRESHOLD_EXCEEDED:|BATCH_BUDGET_EXCEEDED:|MAX_RETRIES_EXCEEDED:|MAX_TOTAL_STEPS_EXCEEDED:|ARCHITECT_CONSULT_REQUIRED:|hard_pause at )/;
52
67
 
53
68
  /** What each child exit code means, for the action line. verify-patch.js and emit-pr.js EXIT maps. */
54
69
  const CHECK_EXIT = { 0: 'applies', 1: 'no diff', 2: 'does not apply' };
@@ -92,10 +107,26 @@ function isTerminal(batch) {
92
107
  return !!(batch && batch.status && batch.status !== 'active');
93
108
  }
94
109
 
95
- /** The verify-patch verdict the server already holds for this run's step_4 attempt, or null. */
110
+ /**
111
+ * The verify-patch verdict the server holds for this run's step_4 attempt, or null: the LATEST record for it, as the
112
+ * server reads them (verdictForAttempt takes the last by reportedAt). A record names its attempt, or carries
113
+ * `forAttempt` (the latest completed attempt when it arrived). HC-GF-363: this used to return the FIRST match, so an
114
+ * attempt that failed and then passed read as failed.
115
+ */
96
116
  function priorVerdict(run, attempt) {
97
117
  const list = Array.isArray(run && run.verifications) ? run.verifications : [];
98
- return list.find((v) => v && v.source === 'verify-patch' && v.stepKey === 'step_4' && Number(v.attempt) === attempt) || null;
118
+ const forThis = (v) => v && v.source === 'verify-patch' && v.stepKey === 'step_4'
119
+ && (v.attempt != null ? Number(v.attempt) === attempt : v.forAttempt != null && Number(v.forAttempt) === attempt);
120
+ let best = null;
121
+ let bestAt = -Infinity;
122
+ for (const v of list) {
123
+ if (!forThis(v)) continue;
124
+ const at = Date.parse(v.reportedAt);
125
+ const t = Number.isFinite(at) ? at : -Infinity;
126
+ // Later by clock wins; equal or unreadable clocks fall back to list order (the server appends).
127
+ if (best === null || t >= bestAt) { best = v; bestAt = t; }
128
+ }
129
+ return best;
99
130
  }
100
131
 
101
132
  /**
@@ -123,6 +154,88 @@ function step4Action(run) {
123
154
  return { actionable: true, attempt };
124
155
  }
125
156
 
157
+ /** Marks a step_4 reject reason as this loop's own. It starts the reason (position 0), and is counted back from rows. */
158
+ const AUTO_REJECT_PREFIX = 'auto-rejected by follow-batch: ';
159
+
160
+ /** Automatic rejects per run. Each one is a full step_4 rebuild plus a verify-patch run. */
161
+ const AUTO_REJECT_LIMIT = 2;
162
+
163
+ /** The reason's tail, from the server-held verdict detail: one line, no control characters, bounded. */
164
+ const DETAIL_IN_REASON = 1500;
165
+
166
+ /**
167
+ * HC-GF-363: is this paused run a step_4 gate whose latest attempt FAILED verification under enforce, so that the
168
+ * loop may send it back? Every condition, none inferred from another (`approvalRefusal` alone is also set for a
169
+ * timeout, an unresolved or narrowed run, which are not measured failures and are never sent back):
170
+ * 1. a human gate at step_4 (not a breaker, budget, retry-limit or hard pause: condition 6 excludes those);
171
+ * 2. verification.gate is enforce for verify-patch;
172
+ * 3. the server refuses approval (`approvalRefusal`);
173
+ * 4. the latest step_4 attempt is completed (attempt L);
174
+ * 5. the LATEST verify-patch record for L has verdict exactly `fail`;
175
+ * 6. the run's error message is the one the enforce red branch writes for a `fail`.
176
+ * The count of earlier rejects (7) is read from the server by the caller: see autoRejectDecision.
177
+ * @returns {{attempt: number, detail: string}|null}
178
+ */
179
+ function autoRejectCandidate(run) {
180
+ const c = classifyPollState(run);
181
+ if (c.outcome !== POLL_OUTCOMES.HUMAN_GATE || c.stepKey !== 'step_4') return null;
182
+ // Not a breaker, budget, retry-limit or hard pause (NOT_A_GATE): each carries its own error prefix, so the
183
+ // enforce-fail message required below already excludes them, and tests/…/batch-follow-reject.test.js drives them.
184
+ const why = String((run && run.errorMessage) || '');
185
+ if (!gateEnforced(run.config, 'verify-patch')) return null;
186
+ if (!run.approvalRefusal) return null;
187
+ const attempt = currentStep4Attempt(run);
188
+ if (!attempt) return null;
189
+ const row = run.steps.find((st) => st && st.stepKey === 'step_4' && (Number(st.attempt) || 1) === attempt);
190
+ if (!row || row.status !== 'completed') return null;
191
+ // A hard pause (files the step asked for) that a fail verdict then overwrote the error message of: HC-GF-351's
192
+ // re-serve owns it, not this reject.
193
+ const fr = row.diagnostics && row.diagnostics.failure_record;
194
+ if (fr && fr.context_requests && typeof fr.context_requests === 'object') return null;
195
+ const v = priorVerdict(run, attempt);
196
+ if (!v || v.verdict !== 'fail') return null;
197
+ if (!why.startsWith("verify verdict 'fail' at step_4")) return null;
198
+ return { attempt, detail: String(v.detail == null ? '' : v.detail) };
199
+ }
200
+
201
+ /** The attempts (1-based, of `contexts`) whose reject reason starts with the prefix. */
202
+ function autoRejectedAttempts(contexts) {
203
+ const out = [];
204
+ (Array.isArray(contexts) ? contexts : []).forEach((ctx, i) => {
205
+ if (typeof ctx === 'string' && ctx.startsWith(AUTO_REJECT_PREFIX)) out.push(i + 1);
206
+ });
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * HC-GF-363: what to do about a paused run, given each earlier step_4 attempt's `errorContext`. Pure.
212
+ * @param {object} run a fresh `GET /orchestrate/:runId`
213
+ * @param {Array<string|null>|null} contexts `errorContext` of attempts 1..L-1, in order; null when any read failed
214
+ * @returns {{kind: 'none'} | {kind: 'unknown', attempt: number, why: string}
215
+ * | {kind: 'exhausted', attempt: number, count: number, rejected: number[], note: string}
216
+ * | {kind: 'reject', attempt: number, n: number, reason: string}}
217
+ */
218
+ function autoRejectDecision(run, contexts) {
219
+ const cand = autoRejectCandidate(run);
220
+ if (!cand) return { kind: 'none' };
221
+ const L = cand.attempt;
222
+ if (!Array.isArray(contexts) || contexts.length !== L - 1) {
223
+ return { kind: 'unknown', attempt: L, why: 'the earlier attempts could not all be read, so the rejects already used are unknown' };
224
+ }
225
+ const rejected = autoRejectedAttempts(contexts);
226
+ const count = rejected.length;
227
+ const clean = routerSafe(cand.detail).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, DETAIL_IN_REASON);
228
+ if (count >= AUTO_REJECT_LIMIT) {
229
+ const budget = contexts.some((x) => /MAX_RETRIES_EXCEEDED/.test(String(x || ''))) || /MAX_RETRIES_EXCEEDED/.test(String(run.errorMessage || ''))
230
+ ? ' MAX_RETRIES_EXCEEDED was seen: the rejects use step_4\'s retry budget.' : '';
231
+ return { kind: 'exhausted', attempt: L, count, rejected,
232
+ note: `step_4 attempt ${L} failed verify-patch; ${count} automatic reject(s) used (attempt${rejected.length === 1 ? '' : 's'} ${rejected.join(', ')}); `
233
+ + `last failure: ${clean.slice(0, 200) || '(no detail)'}.${budget} Read it: hone run-report ${run.runId || '<runId>'}` };
234
+ }
235
+ return { kind: 'reject', attempt: L, n: count + 1,
236
+ reason: `${AUTO_REJECT_PREFIX}attempt ${L} failed verify-patch under verification.gate: enforce (automatic reject ${count + 1} of ${AUTO_REJECT_LIMIT}). ${clean || '(no detail)'}` };
237
+ }
238
+
126
239
  /**
127
240
  * The argv for one child verb. Exit codes are the contract; `--format json` only carries detail.
128
241
  * @param {'check-patch'|'verify-patch'|'emit-pr'} verb
@@ -178,11 +291,14 @@ function draftBranch(storyId, runId, attempt) {
178
291
  /** One line of agent- or server-written text, safe to print: control characters (a terminal escape) become spaces. */
179
292
  const oneLine = (s, max = 240) => firstLine(String(s || '')).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max);
180
293
 
294
+ /** Polls an accepted reject may wait for the worker before a person is told. */
295
+ const REJECT_WAIT_TICKS = 20;
296
+
181
297
  /** The statuses a batch child ends in. Anything else is still moving as far as the loop can tell. */
182
298
  const TERMINAL_RUN = new Set(['completed', 'failed', 'killed', 'stalled']);
183
299
 
184
300
  /** The shape every planTick return carries, so a caller never meets an absent field. `stop` is always the caller's. */
185
- const tickPlan = (over) => ({ actions: [], notes: [], waiting: [], moving: 0, follow: [], ...over });
301
+ const tickPlan = (over) => ({ actions: [], notes: [], waiting: [], moving: 0, follow: [], rejecting: [], ...over });
186
302
 
187
303
  /**
188
304
  * Batches the server's own rows say were re-served: a killed run whose reason names the batch its story went on in.
@@ -279,6 +395,9 @@ function priorWhy(prior, runId) {
279
395
  if (prior.state === 'adopted') {
280
396
  return `a newer run of this story already exists${b ? ` (batch ${b})` : ''}, queued by an earlier re-serve or by you, so it is not re-served again`;
281
397
  }
398
+ if (prior.state === 'inherited') {
399
+ return `this run is already the re-serve of ${prior.fromRunId ? `run ${prior.fromRunId}` : 'an earlier run'}${b ? ` (it is in batch ${b})` : ''}, so it is not re-served a second time`;
400
+ }
282
401
  if (prior.state === 'queue-failed') return 're-serving it failed earlier in this run; nothing more is tried';
283
402
  return `it names files again after being re-served as batch ${b}; a second re-serve is not tried`;
284
403
  }
@@ -320,7 +439,7 @@ function decideReserve({ r, d, request, runs, details, ctx, prior, planned, batc
320
439
 
321
440
  /** What the loop says when every unfinished run is waiting on a person. */
322
441
  function stuckReason(waiting) {
323
- const who = waiting.map((w) => `${w.storyId} ${w.runId} at ${w.stepKey || 'a step'} (${oneLine(w.reason, 120)})`);
442
+ const who = waiting.map((w) => `${w.storyId} ${w.runId} at ${w.stepKey || 'a step'} (${oneLine(w.reason, 400)})`);
324
443
  return `every unfinished run is waiting on you: ${who.join('; ')}`;
325
444
  }
326
445
 
@@ -336,7 +455,7 @@ function stuckReason(waiting) {
336
455
  * @param {Record<string, object>} details runId → `GET /orchestrate/:runId`, for paused runs and runs to emit
337
456
  * @param {{emitted: Set<string>, reserved?: Map<string, {batchId: string|null, state: string}>}} memory `emitted`: runs
338
457
  * whose emit-pr already ran in this process; `reserved`: story → what became of its re-serve (`state`: served,
339
- * kill-failed, queue-failed or adopted)
458
+ * kill-failed, queue-failed, adopted or inherited)
340
459
  * @param {{repoName?: string|null, openPr?: boolean, tracked?: Set<string>|null, trackedRetry?: string|null,
341
460
  * realPath?: ((file: string) => string|null)|null}} [ctx] this checkout, so a run for another repository is
342
461
  * not touched; `tracked`: the regular files git tracks here, which a request is checked against (`trackedRetry`:
@@ -354,6 +473,7 @@ function planTick(batch, details, memory, ctx = {}) {
354
473
  const notes = [];
355
474
  const waiting = [];
356
475
  let moving = 0;
476
+ const rejecting = [];
357
477
  const runs = Array.isArray(b.runs) ? b.runs : [];
358
478
  // Stories already re-served: by this process (memory), or by an earlier one (a killed run says so).
359
479
  const follow = reservedFrom(b);
@@ -362,7 +482,7 @@ function planTick(batch, details, memory, ctx = {}) {
362
482
  const planned = new Set();
363
483
  const waitOn = (r, stepKey, reason) => waiting.push({ runId: r.runId, storyId: r.storyId, stepKey, reason });
364
484
  const needsYou = (r, stepKey, reason) => {
365
- const why = oneLine(reason, 300);
485
+ const why = oneLine(reason, 400);
366
486
  notes.push(`${r.storyId} ${r.runId} needs you at ${stepKey || 'a step'}: ${why}`);
367
487
  waitOn(r, stepKey, why);
368
488
  };
@@ -421,6 +541,19 @@ function planTick(batch, details, memory, ctx = {}) {
421
541
  continue;
422
542
  }
423
543
 
544
+ // HC-GF-363: a refused `fail` is sent back, once per attempt, by the executor, which re-reads the run and the
545
+ // earlier attempts' rows before it POSTs. `memory.rejected` and `rejectStuck` only stop this tick repeating
546
+ // itself; the bound is read from the server.
547
+ const cand = autoRejectCandidate(d);
548
+ if (cand) {
549
+ const key = `${r.runId}#${cand.attempt}`;
550
+ const stuck = memory.rejectStuck && memory.rejectStuck.get(key);
551
+ if (stuck) needsYou(r, 'step_4', stuck);
552
+ else if (memory.rejected && memory.rejected.has(key)) { moving += 1; rejecting.push(key); }
553
+ else actions.push({ kind: 'reject', runId: r.runId, storyId: r.storyId, attempt: cand.attempt, run: d });
554
+ continue;
555
+ }
556
+
424
557
  const a = step4Action(d);
425
558
  // verify-patch's verdict POST is best-effort: an attempt this process already ran is not re-run every tick.
426
559
  if (a.actionable && memory.tried && memory.tried.has(`${r.runId}#${a.attempt}`)) {
@@ -443,7 +576,7 @@ function planTick(batch, details, memory, ctx = {}) {
443
576
  if (!isTerminal(b)) {
444
577
  const stuck = actions.length === 0 && moving === 0 && waiting.length > 0;
445
578
  return tickPlan({ stop: stuck ? { kind: 'stuck', exitCode: EXIT.NEEDS_YOU, reason: stuckReason(waiting) } : null,
446
- actions, notes, waiting, moving, follow });
579
+ actions, notes, waiting, moving, follow, rejecting });
447
580
  }
448
581
  // A killed run that was re-served is not unfinished: its story went on in the batch it names.
449
582
  const raw = (b.counts && ((b.counts.failed || 0) + (b.counts.killed || 0) + (b.counts.stalled || 0))) || 0;
@@ -452,7 +585,7 @@ function planTick(batch, details, memory, ctx = {}) {
452
585
  stop: unfinished
453
586
  ? { kind: 'terminal', exitCode: EXIT.NEEDS_YOU, reason: `batch ${b.status}: ${unfinished} run(s) did not complete` }
454
587
  : { kind: 'terminal', exitCode: EXIT.DONE, reason: `batch ${b.status}` },
455
- actions, notes, waiting, moving, follow,
588
+ actions, notes, waiting, moving, follow, rejecting,
456
589
  });
457
590
  }
458
591
 
@@ -466,7 +599,7 @@ function planTick(batch, details, memory, ctx = {}) {
466
599
  * crash between the queue and the kill, leaves no marker, and only a newer run of the same story shows it happened.
467
600
  * @returns {Promise<{lines: string[], needsYou: boolean, batchId: string|null, state: string|null, retry?: boolean}>}
468
601
  * `batchId`: the batch the story was queued as, set whenever it was, even when the kill then failed; `state`: what
469
- * became of the re-serve (served, kill-failed, queue-failed, adopted); `retry`: nothing was decided, ask again
602
+ * became of the re-serve (served, kill-failed, queue-failed, adopted = a newer run exists, inherited = this run is itself the re-serve); `retry`: nothing was decided, ask again
470
603
  */
471
604
  async function reserve(action, io, tag) {
472
605
  if (io.priorServe) {
@@ -478,12 +611,16 @@ async function reserve(action, io, tag) {
478
611
  needsYou: false, batchId: null, state: null, retry: true };
479
612
  }
480
613
  if (prior && prior.kind === 'unknown') {
481
- return { lines: [`${tag} not re-served: could not tell whether the story was served already; the paused run was left alone`],
614
+ return { lines: [`${tag} not re-served: could not tell whether the story was served already${prior.why ? ` (${prior.why})` : ''}; the paused run was left alone`],
482
615
  needsYou: true, batchId: null, state: 'queue-failed' };
483
616
  }
617
+ if (prior && prior.kind === 'earlier') {
618
+ return { lines: [`${tag} not re-served: this run is already the re-serve of run ${prior.runId} (it is in batch ${prior.batchId}), so it is not re-served a second time`],
619
+ needsYou: true, batchId: prior.batchId || null, state: 'inherited', fromRunId: prior.runId };
620
+ }
484
621
  if (prior) {
485
622
  const where = prior.batchId ? ` in batch ${prior.batchId}` : '';
486
- return { lines: [`${tag} not re-served: run ${prior.runId}${where} is this story's ${prior.kind === 'later' ? 'newer' : 'earlier, re-served'} run; following it`],
623
+ return { lines: [`${tag} not re-served: a newer run of this story exists (run ${prior.runId}${where})${prior.batchId ? '; following it' : '; its batch was not found, so it is not followed'}`],
487
624
  needsYou: true, batchId: prior.batchId || null, state: 'adopted' };
488
625
  }
489
626
  }
@@ -514,6 +651,76 @@ async function reserve(action, io, tag) {
514
651
  return { lines, needsYou: false, batchId, state: 'served' };
515
652
  }
516
653
 
654
+ /** The HTTP status of a thrown axios error, or undefined. */
655
+ const statusOf = (e) => (e && e.response && e.response.status) || undefined;
656
+
657
+ /**
658
+ * HC-GF-363: send a refused step_4 `fail` back to the builder, if the bound allows. Re-reads the run and re-decides
659
+ * right before the POST (the plan is up to one poll old), then counts the earlier rejects from each earlier attempt's
660
+ * stored reject reason. Any read that fails means the count is unknown, and unknown never rejects.
661
+ * @returns {Promise<{lines: string[], needsYou: boolean, state: 'posted'|'moved'|'stuck'|'retry', note?: string,
662
+ * countsRead?: boolean, fatal?: string}>}
663
+ * `retry`: nothing was sent, ask again next poll (`countsRead: true` when it was a read that failed, which is
664
+ * given up on after three); `fatal`: the server answered 401, 403 or 404 where it never should
665
+ */
666
+ async function autoReject(action, io, tag) {
667
+ const where = `step_4 attempt ${action.attempt}`;
668
+ const fatal = (e, what) => ([401, 403, 404].includes(statusOf(e)) ? `the server answered ${statusOf(e)} while ${what}` : null);
669
+ if (!io.rejectRun || !io.fetchStep) {
670
+ return { lines: [`${tag} ${where} failed verification; this client cannot reject, so it is left for you`], needsYou: true, state: 'stuck', note: `${where} failed verification and was not sent back` };
671
+ }
672
+ let fresh;
673
+ try {
674
+ fresh = await io.fetchRun(action.runId);
675
+ } catch (e) {
676
+ const f = fatal(e, `re-reading run ${action.runId}`);
677
+ if (f) return { lines: [], needsYou: false, state: 'retry', fatal: f };
678
+ return { lines: [`${tag} not rejected yet: could not re-read the run (${firstLine(e && e.message)}); asking again next poll`], needsYou: false, state: 'retry' };
679
+ }
680
+ const now = autoRejectCandidate(fresh);
681
+ if (!now || now.attempt !== action.attempt) {
682
+ return { lines: [`${tag} ${where} is no longer a refused failure; nothing sent`], needsYou: false, state: 'moved' };
683
+ }
684
+ const contexts = [];
685
+ for (let k = 1; k < action.attempt; k += 1) {
686
+ try {
687
+ const row = await io.fetchStep(action.runId, 'step_4', k);
688
+ contexts.push(row && typeof row.errorContext === 'string' ? row.errorContext : null);
689
+ } catch (e) {
690
+ return { lines: [`${tag} not rejected yet: could not read step_4 attempt ${k} (${statusOf(e) || firstLine(e && e.message)}), so the rejects already used are unknown; asking again next poll`],
691
+ needsYou: false, state: 'retry', countsRead: true };
692
+ }
693
+ }
694
+ const dec = autoRejectDecision(fresh, contexts);
695
+ if (dec.kind === 'exhausted') {
696
+ return { lines: [`${tag} ${dec.note}`], needsYou: true, state: 'stuck', note: dec.note };
697
+ }
698
+ if (dec.kind !== 'reject') {
699
+ return { lines: [`${tag} ${where}: no automatic reject (${dec.why || dec.kind})`], needsYou: false, state: 'retry', countsRead: true };
700
+ }
701
+ try {
702
+ await io.rejectRun(action.runId, { stepKey: 'step_4', reason: dec.reason });
703
+ } catch (e) {
704
+ const status = statusOf(e);
705
+ const f = fatal(e, `rejecting ${where}`);
706
+ if (f) return { lines: [], needsYou: false, state: 'retry', fatal: f };
707
+ // HC-GF-323-followup-2: the server caps rejects at step_4's max_retries_per_step, counting a person's rejects too, so
708
+ // its refusal can come before this client's own bound. That 409 is not "the run moved on": the run is still paused
709
+ // at the failed attempt, and only a person can approve, kill or raise the guardrail.
710
+ if (status === 409 && e.response.data && e.response.data.code === 'REJECT_RETRIES_EXHAUSTED') {
711
+ const note = `${where} failed verification and the server refused the reject: ${oneLine(e.response.data.error, 160)}`;
712
+ return { lines: [`${tag} ${note}`], needsYou: true, state: 'stuck', note };
713
+ }
714
+ if (status === 409) return { lines: [`${tag} ${where} was not rejected: the run moved on (409); this does not count`], needsYou: false, state: 'moved' };
715
+ if (status === 400) {
716
+ const note = `${where} failed verification and the server refused the reject (400: ${oneLine(e.response.data && e.response.data.error, 120)})`;
717
+ return { lines: [`${tag} ${note}`], needsYou: true, state: 'stuck', note };
718
+ }
719
+ return { lines: [`${tag} not rejected yet: ${status || firstLine(e && e.message)}; asking again next poll`], needsYou: false, state: 'retry' };
720
+ }
721
+ return { lines: [`${tag} ${where} failed verification: rejected it (automatic reject ${dec.n} of ${AUTO_REJECT_LIMIT}): ${oneLine(now.detail, 200)}`], needsYou: false, state: 'posted' };
722
+ }
723
+
517
724
  /**
518
725
  * Run one action's child verbs, in order, and describe what happened. Nothing that executes the adopter's tests
519
726
  * (verify-patch, or emit-pr, which re-verifies) starts unless the repository's consent and the story's provenance
@@ -527,6 +734,7 @@ async function reserve(action, io, tag) {
527
734
  async function runTask(action, io, opts = {}) {
528
735
  const tag = `[follow-batch] ${action.storyId} ${action.runId}`;
529
736
  if (action.kind === 'reserve') return reserve(action, io, tag);
737
+ if (action.kind === 'reject') return autoReject(action, io, tag);
530
738
  if (action.kind === 'emit' || action.kind === 'draft') {
531
739
  // emit-pr re-runs the tests before it commits, even as a local dry run, so it needs the same consent and
532
740
  // provenance verify-patch does. Without this, a repository that never set ci.local_command gets `make ci`.
@@ -577,7 +785,8 @@ function settle(stops, memory, again = []) {
577
785
  * @param {{fetchBatch: Function, fetchRun: Function, runVerb: Function, verifyDecision: Function,
578
786
  * sleep: (ms: number) => Promise<void>, now: () => number, log: (line: string) => void,
579
787
  * trackedPaths?: () => Promise<Set<string>>|Set<string>, realPath?: (file: string) => string|null,
580
- * priorServe?: Function, writeStoriesFile?: Function, killRun?: Function}} io
788
+ * priorServe?: Function, writeStoriesFile?: Function, killRun?: Function, rejectRun?: Function,
789
+ * fetchStep?: Function}} io
581
790
  * trackedPaths, realPath, priorServe, writeStoriesFile and killRun serve a re-serve; without trackedPaths or
582
791
  * realPath every request is refused, and without priorServe nobody asks whether the story was served already.
583
792
  * @param {{pollMs?: number, deadlineMs?: number|null, maxOfflineMs?: number, openPr?: boolean,
@@ -590,7 +799,10 @@ async function followBatch(batchId, io, opts = {}) {
590
799
  // owed: runs whose emit-pr needed a person. A completed batch with one of these is not DONE (HC-GF-207's shape:
591
800
  // a failure path that exits 0). reserved: story → what became of its re-serve ({batchId, state}). trackedFails:
592
801
  // consecutive ticks on which listing the tracked files failed.
593
- const memory = { emitted: new Set(), tried: new Set(), drafted: new Set(), said: new Set(), owed: new Map(), reserved: new Map(), trackedFails: 0 };
802
+ const memory = { emitted: new Set(), tried: new Set(), drafted: new Set(), said: new Set(), owed: new Map(), reserved: new Map(), trackedFails: 0,
803
+ // HC-GF-363: only to stop a tick repeating itself. rejected: attempt key → ticks waited since the reject was accepted;
804
+ // rejectStuck: key → why a person has to act; rejectReads: key → consecutive failed reads of the earlier attempts.
805
+ rejected: new Map(), rejectStuck: new Map(), rejectReads: new Map() };
594
806
  const followed = [batchId];
595
807
  const start = io.now();
596
808
  let offlineSince = null;
@@ -663,13 +875,37 @@ async function followBatch(batchId, io, opts = {}) {
663
875
  const halted = plans.find((plan) => plan.stop && plan.stop.kind === 'breaker');
664
876
  if (halted) return { exitCode: halted.stop.exitCode, reason: halted.stop.reason };
665
877
 
878
+ // An accepted reject that the worker has not applied for REJECT_WAIT_TICKS polls is named for a person.
879
+ for (const plan of plans) {
880
+ for (const key of plan.rejecting) {
881
+ const waited = (memory.rejected.get(key) || 0) + 1;
882
+ memory.rejected.set(key, waited);
883
+ if (waited >= REJECT_WAIT_TICKS) {
884
+ memory.rejectStuck.set(key, `a reject was accepted ${waited} polls ago and the run is still paused at the same attempt; look at it with hone run-report`);
885
+ }
886
+ }
887
+ }
888
+
666
889
  let grew = false;
667
890
  for (const plan of plans) {
668
891
  for (const action of plan.actions) {
669
892
  const out = await runTask(action, io, { openPr: opts.openPr });
893
+ if (action.kind === 'reject') {
894
+ if (out.fatal) return { exitCode: EXIT.FATAL, reason: out.fatal };
895
+ const key = `${action.runId}#${action.attempt}`;
896
+ if (out.state === 'posted' || out.state === 'moved') memory.rejected.set(key, 0);
897
+ else if (out.state === 'stuck') memory.rejectStuck.set(key, out.note);
898
+ else if (out.countsRead) {
899
+ const n = (memory.rejectReads.get(key) || 0) + 1;
900
+ memory.rejectReads.set(key, n);
901
+ if (n >= 3) memory.rejectStuck.set(key, `step_4 attempt ${action.attempt} failed verification, but the rejects already used could not be read after ${n} polls; nothing was sent`);
902
+ }
903
+ for (const line of out.lines) say(line);
904
+ continue;
905
+ }
670
906
  if (action.kind === 'reserve') {
671
907
  if (!out.retry) {
672
- memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state });
908
+ memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state, fromRunId: out.fromRunId });
673
909
  if (out.batchId && !followed.includes(out.batchId)) { followed.push(out.batchId); grew = true; }
674
910
  }
675
911
  } else if (action.kind === 'emit' || action.kind === 'draft') {
@@ -697,4 +933,5 @@ module.exports = {
697
933
  EXIT, FORBIDDEN_FLAGS, NOT_A_GATE,
698
934
  breakerStop, isTerminal, priorVerdict, step4Action, buildArgs, buildReserveArgs, planTick, runTask, followBatch,
699
935
  currentStep4Attempt, verifiedStep4Attempt, draftBranch, reservedFrom, otherPause, stuckReason, gateEnforced, stepRank,
936
+ AUTO_REJECT_PREFIX, AUTO_REJECT_LIMIT, autoRejectCandidate, autoRejectDecision, autoRejectedAttempts,
700
937
  };
@@ -74,6 +74,60 @@ function fencedSpans(text) {
74
74
  return { blocks, open: cur };
75
75
  }
76
76
 
77
+ /**
78
+ * The inline code spans of ONE line, as ranges (HC-GF-341 rules, extracted by HC-GF-292 so there is ONE walker).
79
+ *
80
+ * CommonMark's rule, on a single line: a run of N backticks opens a span that closes at the next run of EXACTLY
81
+ * N backticks; a run with no equal-length closer on the line is literal text, masks nothing and does not stop the
82
+ * scan. OUTSIDE a span a backtick after an odd number of backslashes is escaped (it cannot open, nor be part of an
83
+ * opening run); INSIDE a span a backslash is only a character. A span never crosses a line break.
84
+ *
85
+ * LINEAR: the line is cut into backtick runs once, the runs of each length are listed in order, and an opener asks
86
+ * for the first later run of its length with a cursor that only moves forward. A backreference regex backtracks
87
+ * across every longer run and is quadratic on a line of mixed runs, and the line is agent-written text.
88
+ *
89
+ * `from`/`to` address `line`: [from, to) covers the delimiters too; the content is
90
+ * `line.slice(from + openLen, to - openLen)`.
91
+ *
92
+ * @returns {Array<{from:number, to:number, openLen:number}>} in ascending order, disjoint
93
+ */
94
+ function inlineCodeSpans(line) {
95
+ const spans = [];
96
+ if (typeof line !== 'string' || line.indexOf('`') === -1) return spans;
97
+ const starts = [];
98
+ const lens = [];
99
+ for (let i = line.indexOf('`'); i !== -1;) {
100
+ let j = i + 1;
101
+ while (j < line.length && line.charCodeAt(j) === 96) j++;
102
+ starts.push(i);
103
+ lens.push(j - i);
104
+ i = line.indexOf('`', j);
105
+ }
106
+ const runsOfLength = new Map(); // length -> { at: ascending run indices, p: cursor }
107
+ for (let k = 0; k < starts.length; k++) {
108
+ let entry = runsOfLength.get(lens[k]);
109
+ if (!entry) { entry = { at: [], p: 0 }; runsOfLength.set(lens[k], entry); }
110
+ entry.at.push(k);
111
+ }
112
+ const closerFor = (width, after) => {
113
+ const entry = runsOfLength.get(width);
114
+ if (!entry) return -1;
115
+ while (entry.p < entry.at.length && entry.at[entry.p] <= after) entry.p++;
116
+ return entry.p < entry.at.length ? entry.at[entry.p] : -1;
117
+ };
118
+ for (let k = 0; k < starts.length;) {
119
+ let slashes = 0;
120
+ for (let q = starts[k] - 1; q >= 0 && line.charCodeAt(q) === 92; q--) slashes++;
121
+ const escaped = slashes & 1; // 1: the run's first backtick is a literal one
122
+ const width = lens[k] - escaped;
123
+ const closer = width > 0 ? closerFor(width, k) : -1;
124
+ if (closer === -1) { k++; continue; }
125
+ spans.push({ from: starts[k] + escaped, to: starts[closer] + lens[closer], openLen: width });
126
+ k = closer + 1;
127
+ }
128
+ return spans;
129
+ }
130
+
77
131
  /**
78
132
  * A `file:` block as one regex: group 1 the fence, 2 the path, 3 the body.
79
133
  *
@@ -117,6 +171,7 @@ module.exports = {
117
171
  fenceWidth,
118
172
  closesFence,
119
173
  fencedSpans,
174
+ inlineCodeSpans,
120
175
  DIRECTIVE_RE,
121
176
  FILE_BLOCK_RE,
122
177
  EDIT_BLOCK_RE,