@hone-ai/cli 1.39.0 → 1.40.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/hone-cli.js +152 -48
- package/lib/anthropic-response.js +58 -0
- package/lib/batch-follow.js +230 -9
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-paths.js +20 -3
- package/lib/judge-provider.js +20 -11
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/security-scanner.js +7 -4
- package/lib/verify-patch.js +122 -12
- package/package.json +1 -1
package/lib/batch-follow.js
CHANGED
|
@@ -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 {
|
|
@@ -92,10 +105,26 @@ function isTerminal(batch) {
|
|
|
92
105
|
return !!(batch && batch.status && batch.status !== 'active');
|
|
93
106
|
}
|
|
94
107
|
|
|
95
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* The verify-patch verdict the server holds for this run's step_4 attempt, or null: the LATEST record for it, as the
|
|
110
|
+
* server reads them (verdictForAttempt takes the last by reportedAt). A record names its attempt, or carries
|
|
111
|
+
* `forAttempt` (the latest completed attempt when it arrived). HC-GF-363: this used to return the FIRST match, so an
|
|
112
|
+
* attempt that failed and then passed read as failed.
|
|
113
|
+
*/
|
|
96
114
|
function priorVerdict(run, attempt) {
|
|
97
115
|
const list = Array.isArray(run && run.verifications) ? run.verifications : [];
|
|
98
|
-
|
|
116
|
+
const forThis = (v) => v && v.source === 'verify-patch' && v.stepKey === 'step_4'
|
|
117
|
+
&& (v.attempt != null ? Number(v.attempt) === attempt : v.forAttempt != null && Number(v.forAttempt) === attempt);
|
|
118
|
+
let best = null;
|
|
119
|
+
let bestAt = -Infinity;
|
|
120
|
+
for (const v of list) {
|
|
121
|
+
if (!forThis(v)) continue;
|
|
122
|
+
const at = Date.parse(v.reportedAt);
|
|
123
|
+
const t = Number.isFinite(at) ? at : -Infinity;
|
|
124
|
+
// Later by clock wins; equal or unreadable clocks fall back to list order (the server appends).
|
|
125
|
+
if (best === null || t >= bestAt) { best = v; bestAt = t; }
|
|
126
|
+
}
|
|
127
|
+
return best;
|
|
99
128
|
}
|
|
100
129
|
|
|
101
130
|
/**
|
|
@@ -123,6 +152,88 @@ function step4Action(run) {
|
|
|
123
152
|
return { actionable: true, attempt };
|
|
124
153
|
}
|
|
125
154
|
|
|
155
|
+
/** Marks a step_4 reject reason as this loop's own. It starts the reason (position 0), and is counted back from rows. */
|
|
156
|
+
const AUTO_REJECT_PREFIX = 'auto-rejected by follow-batch: ';
|
|
157
|
+
|
|
158
|
+
/** Automatic rejects per run. Each one is a full step_4 rebuild plus a verify-patch run. */
|
|
159
|
+
const AUTO_REJECT_LIMIT = 2;
|
|
160
|
+
|
|
161
|
+
/** The reason's tail, from the server-held verdict detail: one line, no control characters, bounded. */
|
|
162
|
+
const DETAIL_IN_REASON = 1500;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* HC-GF-363: is this paused run a step_4 gate whose latest attempt FAILED verification under enforce, so that the
|
|
166
|
+
* loop may send it back? Every condition, none inferred from another (`approvalRefusal` alone is also set for a
|
|
167
|
+
* timeout, an unresolved or narrowed run, which are not measured failures and are never sent back):
|
|
168
|
+
* 1. a human gate at step_4 (not a breaker, budget, retry-limit or hard pause: condition 6 excludes those);
|
|
169
|
+
* 2. verification.gate is enforce for verify-patch;
|
|
170
|
+
* 3. the server refuses approval (`approvalRefusal`);
|
|
171
|
+
* 4. the latest step_4 attempt is completed (attempt L);
|
|
172
|
+
* 5. the LATEST verify-patch record for L has verdict exactly `fail`;
|
|
173
|
+
* 6. the run's error message is the one the enforce red branch writes for a `fail`.
|
|
174
|
+
* The count of earlier rejects (7) is read from the server by the caller: see autoRejectDecision.
|
|
175
|
+
* @returns {{attempt: number, detail: string}|null}
|
|
176
|
+
*/
|
|
177
|
+
function autoRejectCandidate(run) {
|
|
178
|
+
const c = classifyPollState(run);
|
|
179
|
+
if (c.outcome !== POLL_OUTCOMES.HUMAN_GATE || c.stepKey !== 'step_4') return null;
|
|
180
|
+
// Not a breaker, budget, retry-limit or hard pause (NOT_A_GATE): each carries its own error prefix, so the
|
|
181
|
+
// enforce-fail message required below already excludes them, and tests/…/batch-follow-reject.test.js drives them.
|
|
182
|
+
const why = String((run && run.errorMessage) || '');
|
|
183
|
+
if (!gateEnforced(run.config, 'verify-patch')) return null;
|
|
184
|
+
if (!run.approvalRefusal) return null;
|
|
185
|
+
const attempt = currentStep4Attempt(run);
|
|
186
|
+
if (!attempt) return null;
|
|
187
|
+
const row = run.steps.find((st) => st && st.stepKey === 'step_4' && (Number(st.attempt) || 1) === attempt);
|
|
188
|
+
if (!row || row.status !== 'completed') return null;
|
|
189
|
+
// A hard pause (files the step asked for) that a fail verdict then overwrote the error message of: HC-GF-351's
|
|
190
|
+
// re-serve owns it, not this reject.
|
|
191
|
+
const fr = row.diagnostics && row.diagnostics.failure_record;
|
|
192
|
+
if (fr && fr.context_requests && typeof fr.context_requests === 'object') return null;
|
|
193
|
+
const v = priorVerdict(run, attempt);
|
|
194
|
+
if (!v || v.verdict !== 'fail') return null;
|
|
195
|
+
if (!why.startsWith("verify verdict 'fail' at step_4")) return null;
|
|
196
|
+
return { attempt, detail: String(v.detail == null ? '' : v.detail) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The attempts (1-based, of `contexts`) whose reject reason starts with the prefix. */
|
|
200
|
+
function autoRejectedAttempts(contexts) {
|
|
201
|
+
const out = [];
|
|
202
|
+
(Array.isArray(contexts) ? contexts : []).forEach((ctx, i) => {
|
|
203
|
+
if (typeof ctx === 'string' && ctx.startsWith(AUTO_REJECT_PREFIX)) out.push(i + 1);
|
|
204
|
+
});
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* HC-GF-363: what to do about a paused run, given each earlier step_4 attempt's `errorContext`. Pure.
|
|
210
|
+
* @param {object} run a fresh `GET /orchestrate/:runId`
|
|
211
|
+
* @param {Array<string|null>|null} contexts `errorContext` of attempts 1..L-1, in order; null when any read failed
|
|
212
|
+
* @returns {{kind: 'none'} | {kind: 'unknown', attempt: number, why: string}
|
|
213
|
+
* | {kind: 'exhausted', attempt: number, count: number, rejected: number[], note: string}
|
|
214
|
+
* | {kind: 'reject', attempt: number, n: number, reason: string}}
|
|
215
|
+
*/
|
|
216
|
+
function autoRejectDecision(run, contexts) {
|
|
217
|
+
const cand = autoRejectCandidate(run);
|
|
218
|
+
if (!cand) return { kind: 'none' };
|
|
219
|
+
const L = cand.attempt;
|
|
220
|
+
if (!Array.isArray(contexts) || contexts.length !== L - 1) {
|
|
221
|
+
return { kind: 'unknown', attempt: L, why: 'the earlier attempts could not all be read, so the rejects already used are unknown' };
|
|
222
|
+
}
|
|
223
|
+
const rejected = autoRejectedAttempts(contexts);
|
|
224
|
+
const count = rejected.length;
|
|
225
|
+
const clean = routerSafe(cand.detail).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, DETAIL_IN_REASON);
|
|
226
|
+
if (count >= AUTO_REJECT_LIMIT) {
|
|
227
|
+
const budget = contexts.some((x) => /MAX_RETRIES_EXCEEDED/.test(String(x || ''))) || /MAX_RETRIES_EXCEEDED/.test(String(run.errorMessage || ''))
|
|
228
|
+
? ' MAX_RETRIES_EXCEEDED was seen: the rejects use step_4\'s retry budget.' : '';
|
|
229
|
+
return { kind: 'exhausted', attempt: L, count, rejected,
|
|
230
|
+
note: `step_4 attempt ${L} failed verify-patch; ${count} automatic reject(s) used (attempt${rejected.length === 1 ? '' : 's'} ${rejected.join(', ')}); `
|
|
231
|
+
+ `last failure: ${clean.slice(0, 200) || '(no detail)'}.${budget} Read it: hone run-report ${run.runId || '<runId>'}` };
|
|
232
|
+
}
|
|
233
|
+
return { kind: 'reject', attempt: L, n: count + 1,
|
|
234
|
+
reason: `${AUTO_REJECT_PREFIX}attempt ${L} failed verify-patch under verification.gate: enforce (automatic reject ${count + 1} of ${AUTO_REJECT_LIMIT}). ${clean || '(no detail)'}` };
|
|
235
|
+
}
|
|
236
|
+
|
|
126
237
|
/**
|
|
127
238
|
* The argv for one child verb. Exit codes are the contract; `--format json` only carries detail.
|
|
128
239
|
* @param {'check-patch'|'verify-patch'|'emit-pr'} verb
|
|
@@ -178,11 +289,14 @@ function draftBranch(storyId, runId, attempt) {
|
|
|
178
289
|
/** One line of agent- or server-written text, safe to print: control characters (a terminal escape) become spaces. */
|
|
179
290
|
const oneLine = (s, max = 240) => firstLine(String(s || '')).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max);
|
|
180
291
|
|
|
292
|
+
/** Polls an accepted reject may wait for the worker before a person is told. */
|
|
293
|
+
const REJECT_WAIT_TICKS = 20;
|
|
294
|
+
|
|
181
295
|
/** The statuses a batch child ends in. Anything else is still moving as far as the loop can tell. */
|
|
182
296
|
const TERMINAL_RUN = new Set(['completed', 'failed', 'killed', 'stalled']);
|
|
183
297
|
|
|
184
298
|
/** 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 });
|
|
299
|
+
const tickPlan = (over) => ({ actions: [], notes: [], waiting: [], moving: 0, follow: [], rejecting: [], ...over });
|
|
186
300
|
|
|
187
301
|
/**
|
|
188
302
|
* Batches the server's own rows say were re-served: a killed run whose reason names the batch its story went on in.
|
|
@@ -320,7 +434,7 @@ function decideReserve({ r, d, request, runs, details, ctx, prior, planned, batc
|
|
|
320
434
|
|
|
321
435
|
/** What the loop says when every unfinished run is waiting on a person. */
|
|
322
436
|
function stuckReason(waiting) {
|
|
323
|
-
const who = waiting.map((w) => `${w.storyId} ${w.runId} at ${w.stepKey || 'a step'} (${oneLine(w.reason,
|
|
437
|
+
const who = waiting.map((w) => `${w.storyId} ${w.runId} at ${w.stepKey || 'a step'} (${oneLine(w.reason, 400)})`);
|
|
324
438
|
return `every unfinished run is waiting on you: ${who.join('; ')}`;
|
|
325
439
|
}
|
|
326
440
|
|
|
@@ -354,6 +468,7 @@ function planTick(batch, details, memory, ctx = {}) {
|
|
|
354
468
|
const notes = [];
|
|
355
469
|
const waiting = [];
|
|
356
470
|
let moving = 0;
|
|
471
|
+
const rejecting = [];
|
|
357
472
|
const runs = Array.isArray(b.runs) ? b.runs : [];
|
|
358
473
|
// Stories already re-served: by this process (memory), or by an earlier one (a killed run says so).
|
|
359
474
|
const follow = reservedFrom(b);
|
|
@@ -362,7 +477,7 @@ function planTick(batch, details, memory, ctx = {}) {
|
|
|
362
477
|
const planned = new Set();
|
|
363
478
|
const waitOn = (r, stepKey, reason) => waiting.push({ runId: r.runId, storyId: r.storyId, stepKey, reason });
|
|
364
479
|
const needsYou = (r, stepKey, reason) => {
|
|
365
|
-
const why = oneLine(reason,
|
|
480
|
+
const why = oneLine(reason, 400);
|
|
366
481
|
notes.push(`${r.storyId} ${r.runId} needs you at ${stepKey || 'a step'}: ${why}`);
|
|
367
482
|
waitOn(r, stepKey, why);
|
|
368
483
|
};
|
|
@@ -421,6 +536,19 @@ function planTick(batch, details, memory, ctx = {}) {
|
|
|
421
536
|
continue;
|
|
422
537
|
}
|
|
423
538
|
|
|
539
|
+
// HC-GF-363: a refused `fail` is sent back, once per attempt, by the executor, which re-reads the run and the
|
|
540
|
+
// earlier attempts' rows before it POSTs. `memory.rejected` and `rejectStuck` only stop this tick repeating
|
|
541
|
+
// itself; the bound is read from the server.
|
|
542
|
+
const cand = autoRejectCandidate(d);
|
|
543
|
+
if (cand) {
|
|
544
|
+
const key = `${r.runId}#${cand.attempt}`;
|
|
545
|
+
const stuck = memory.rejectStuck && memory.rejectStuck.get(key);
|
|
546
|
+
if (stuck) needsYou(r, 'step_4', stuck);
|
|
547
|
+
else if (memory.rejected && memory.rejected.has(key)) { moving += 1; rejecting.push(key); }
|
|
548
|
+
else actions.push({ kind: 'reject', runId: r.runId, storyId: r.storyId, attempt: cand.attempt, run: d });
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
424
552
|
const a = step4Action(d);
|
|
425
553
|
// verify-patch's verdict POST is best-effort: an attempt this process already ran is not re-run every tick.
|
|
426
554
|
if (a.actionable && memory.tried && memory.tried.has(`${r.runId}#${a.attempt}`)) {
|
|
@@ -443,7 +571,7 @@ function planTick(batch, details, memory, ctx = {}) {
|
|
|
443
571
|
if (!isTerminal(b)) {
|
|
444
572
|
const stuck = actions.length === 0 && moving === 0 && waiting.length > 0;
|
|
445
573
|
return tickPlan({ stop: stuck ? { kind: 'stuck', exitCode: EXIT.NEEDS_YOU, reason: stuckReason(waiting) } : null,
|
|
446
|
-
actions, notes, waiting, moving, follow });
|
|
574
|
+
actions, notes, waiting, moving, follow, rejecting });
|
|
447
575
|
}
|
|
448
576
|
// A killed run that was re-served is not unfinished: its story went on in the batch it names.
|
|
449
577
|
const raw = (b.counts && ((b.counts.failed || 0) + (b.counts.killed || 0) + (b.counts.stalled || 0))) || 0;
|
|
@@ -452,7 +580,7 @@ function planTick(batch, details, memory, ctx = {}) {
|
|
|
452
580
|
stop: unfinished
|
|
453
581
|
? { kind: 'terminal', exitCode: EXIT.NEEDS_YOU, reason: `batch ${b.status}: ${unfinished} run(s) did not complete` }
|
|
454
582
|
: { kind: 'terminal', exitCode: EXIT.DONE, reason: `batch ${b.status}` },
|
|
455
|
-
actions, notes, waiting, moving, follow,
|
|
583
|
+
actions, notes, waiting, moving, follow, rejecting,
|
|
456
584
|
});
|
|
457
585
|
}
|
|
458
586
|
|
|
@@ -514,6 +642,69 @@ async function reserve(action, io, tag) {
|
|
|
514
642
|
return { lines, needsYou: false, batchId, state: 'served' };
|
|
515
643
|
}
|
|
516
644
|
|
|
645
|
+
/** The HTTP status of a thrown axios error, or undefined. */
|
|
646
|
+
const statusOf = (e) => (e && e.response && e.response.status) || undefined;
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* HC-GF-363: send a refused step_4 `fail` back to the builder, if the bound allows. Re-reads the run and re-decides
|
|
650
|
+
* right before the POST (the plan is up to one poll old), then counts the earlier rejects from each earlier attempt's
|
|
651
|
+
* stored reject reason. Any read that fails means the count is unknown, and unknown never rejects.
|
|
652
|
+
* @returns {Promise<{lines: string[], needsYou: boolean, state: 'posted'|'moved'|'stuck'|'retry', note?: string,
|
|
653
|
+
* countsRead?: boolean, fatal?: string}>}
|
|
654
|
+
* `retry`: nothing was sent, ask again next poll (`countsRead: true` when it was a read that failed, which is
|
|
655
|
+
* given up on after three); `fatal`: the server answered 401, 403 or 404 where it never should
|
|
656
|
+
*/
|
|
657
|
+
async function autoReject(action, io, tag) {
|
|
658
|
+
const where = `step_4 attempt ${action.attempt}`;
|
|
659
|
+
const fatal = (e, what) => ([401, 403, 404].includes(statusOf(e)) ? `the server answered ${statusOf(e)} while ${what}` : null);
|
|
660
|
+
if (!io.rejectRun || !io.fetchStep) {
|
|
661
|
+
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` };
|
|
662
|
+
}
|
|
663
|
+
let fresh;
|
|
664
|
+
try {
|
|
665
|
+
fresh = await io.fetchRun(action.runId);
|
|
666
|
+
} catch (e) {
|
|
667
|
+
const f = fatal(e, `re-reading run ${action.runId}`);
|
|
668
|
+
if (f) return { lines: [], needsYou: false, state: 'retry', fatal: f };
|
|
669
|
+
return { lines: [`${tag} not rejected yet: could not re-read the run (${firstLine(e && e.message)}); asking again next poll`], needsYou: false, state: 'retry' };
|
|
670
|
+
}
|
|
671
|
+
const now = autoRejectCandidate(fresh);
|
|
672
|
+
if (!now || now.attempt !== action.attempt) {
|
|
673
|
+
return { lines: [`${tag} ${where} is no longer a refused failure; nothing sent`], needsYou: false, state: 'moved' };
|
|
674
|
+
}
|
|
675
|
+
const contexts = [];
|
|
676
|
+
for (let k = 1; k < action.attempt; k += 1) {
|
|
677
|
+
try {
|
|
678
|
+
const row = await io.fetchStep(action.runId, 'step_4', k);
|
|
679
|
+
contexts.push(row && typeof row.errorContext === 'string' ? row.errorContext : null);
|
|
680
|
+
} catch (e) {
|
|
681
|
+
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`],
|
|
682
|
+
needsYou: false, state: 'retry', countsRead: true };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const dec = autoRejectDecision(fresh, contexts);
|
|
686
|
+
if (dec.kind === 'exhausted') {
|
|
687
|
+
return { lines: [`${tag} ${dec.note}`], needsYou: true, state: 'stuck', note: dec.note };
|
|
688
|
+
}
|
|
689
|
+
if (dec.kind !== 'reject') {
|
|
690
|
+
return { lines: [`${tag} ${where}: no automatic reject (${dec.why || dec.kind})`], needsYou: false, state: 'retry', countsRead: true };
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
await io.rejectRun(action.runId, { stepKey: 'step_4', reason: dec.reason });
|
|
694
|
+
} catch (e) {
|
|
695
|
+
const status = statusOf(e);
|
|
696
|
+
const f = fatal(e, `rejecting ${where}`);
|
|
697
|
+
if (f) return { lines: [], needsYou: false, state: 'retry', fatal: f };
|
|
698
|
+
if (status === 409) return { lines: [`${tag} ${where} was not rejected: the run moved on (409); this does not count`], needsYou: false, state: 'moved' };
|
|
699
|
+
if (status === 400) {
|
|
700
|
+
const note = `${where} failed verification and the server refused the reject (400: ${oneLine(e.response.data && e.response.data.error, 120)})`;
|
|
701
|
+
return { lines: [`${tag} ${note}`], needsYou: true, state: 'stuck', note };
|
|
702
|
+
}
|
|
703
|
+
return { lines: [`${tag} not rejected yet: ${status || firstLine(e && e.message)}; asking again next poll`], needsYou: false, state: 'retry' };
|
|
704
|
+
}
|
|
705
|
+
return { lines: [`${tag} ${where} failed verification: rejected it (automatic reject ${dec.n} of ${AUTO_REJECT_LIMIT}): ${oneLine(now.detail, 200)}`], needsYou: false, state: 'posted' };
|
|
706
|
+
}
|
|
707
|
+
|
|
517
708
|
/**
|
|
518
709
|
* Run one action's child verbs, in order, and describe what happened. Nothing that executes the adopter's tests
|
|
519
710
|
* (verify-patch, or emit-pr, which re-verifies) starts unless the repository's consent and the story's provenance
|
|
@@ -527,6 +718,7 @@ async function reserve(action, io, tag) {
|
|
|
527
718
|
async function runTask(action, io, opts = {}) {
|
|
528
719
|
const tag = `[follow-batch] ${action.storyId} ${action.runId}`;
|
|
529
720
|
if (action.kind === 'reserve') return reserve(action, io, tag);
|
|
721
|
+
if (action.kind === 'reject') return autoReject(action, io, tag);
|
|
530
722
|
if (action.kind === 'emit' || action.kind === 'draft') {
|
|
531
723
|
// emit-pr re-runs the tests before it commits, even as a local dry run, so it needs the same consent and
|
|
532
724
|
// provenance verify-patch does. Without this, a repository that never set ci.local_command gets `make ci`.
|
|
@@ -577,7 +769,8 @@ function settle(stops, memory, again = []) {
|
|
|
577
769
|
* @param {{fetchBatch: Function, fetchRun: Function, runVerb: Function, verifyDecision: Function,
|
|
578
770
|
* sleep: (ms: number) => Promise<void>, now: () => number, log: (line: string) => void,
|
|
579
771
|
* trackedPaths?: () => Promise<Set<string>>|Set<string>, realPath?: (file: string) => string|null,
|
|
580
|
-
* priorServe?: Function, writeStoriesFile?: Function, killRun?: Function
|
|
772
|
+
* priorServe?: Function, writeStoriesFile?: Function, killRun?: Function, rejectRun?: Function,
|
|
773
|
+
* fetchStep?: Function}} io
|
|
581
774
|
* trackedPaths, realPath, priorServe, writeStoriesFile and killRun serve a re-serve; without trackedPaths or
|
|
582
775
|
* realPath every request is refused, and without priorServe nobody asks whether the story was served already.
|
|
583
776
|
* @param {{pollMs?: number, deadlineMs?: number|null, maxOfflineMs?: number, openPr?: boolean,
|
|
@@ -590,7 +783,10 @@ async function followBatch(batchId, io, opts = {}) {
|
|
|
590
783
|
// owed: runs whose emit-pr needed a person. A completed batch with one of these is not DONE (HC-GF-207's shape:
|
|
591
784
|
// a failure path that exits 0). reserved: story → what became of its re-serve ({batchId, state}). trackedFails:
|
|
592
785
|
// 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
|
|
786
|
+
const memory = { emitted: new Set(), tried: new Set(), drafted: new Set(), said: new Set(), owed: new Map(), reserved: new Map(), trackedFails: 0,
|
|
787
|
+
// HC-GF-363: only to stop a tick repeating itself. rejected: attempt key → ticks waited since the reject was accepted;
|
|
788
|
+
// rejectStuck: key → why a person has to act; rejectReads: key → consecutive failed reads of the earlier attempts.
|
|
789
|
+
rejected: new Map(), rejectStuck: new Map(), rejectReads: new Map() };
|
|
594
790
|
const followed = [batchId];
|
|
595
791
|
const start = io.now();
|
|
596
792
|
let offlineSince = null;
|
|
@@ -663,10 +859,34 @@ async function followBatch(batchId, io, opts = {}) {
|
|
|
663
859
|
const halted = plans.find((plan) => plan.stop && plan.stop.kind === 'breaker');
|
|
664
860
|
if (halted) return { exitCode: halted.stop.exitCode, reason: halted.stop.reason };
|
|
665
861
|
|
|
862
|
+
// An accepted reject that the worker has not applied for REJECT_WAIT_TICKS polls is named for a person.
|
|
863
|
+
for (const plan of plans) {
|
|
864
|
+
for (const key of plan.rejecting) {
|
|
865
|
+
const waited = (memory.rejected.get(key) || 0) + 1;
|
|
866
|
+
memory.rejected.set(key, waited);
|
|
867
|
+
if (waited >= REJECT_WAIT_TICKS) {
|
|
868
|
+
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`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
666
873
|
let grew = false;
|
|
667
874
|
for (const plan of plans) {
|
|
668
875
|
for (const action of plan.actions) {
|
|
669
876
|
const out = await runTask(action, io, { openPr: opts.openPr });
|
|
877
|
+
if (action.kind === 'reject') {
|
|
878
|
+
if (out.fatal) return { exitCode: EXIT.FATAL, reason: out.fatal };
|
|
879
|
+
const key = `${action.runId}#${action.attempt}`;
|
|
880
|
+
if (out.state === 'posted' || out.state === 'moved') memory.rejected.set(key, 0);
|
|
881
|
+
else if (out.state === 'stuck') memory.rejectStuck.set(key, out.note);
|
|
882
|
+
else if (out.countsRead) {
|
|
883
|
+
const n = (memory.rejectReads.get(key) || 0) + 1;
|
|
884
|
+
memory.rejectReads.set(key, n);
|
|
885
|
+
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`);
|
|
886
|
+
}
|
|
887
|
+
for (const line of out.lines) say(line);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
670
890
|
if (action.kind === 'reserve') {
|
|
671
891
|
if (!out.retry) {
|
|
672
892
|
memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state });
|
|
@@ -697,4 +917,5 @@ module.exports = {
|
|
|
697
917
|
EXIT, FORBIDDEN_FLAGS, NOT_A_GATE,
|
|
698
918
|
breakerStop, isTerminal, priorVerdict, step4Action, buildArgs, buildReserveArgs, planTick, runTask, followBatch,
|
|
699
919
|
currentStep4Attempt, verifiedStep4Attempt, draftBranch, reservedFrom, otherPause, stuckReason, gateEnforced, stepRank,
|
|
920
|
+
AUTO_REJECT_PREFIX, AUTO_REJECT_LIMIT, autoRejectCandidate, autoRejectDecision, autoRejectedAttempts,
|
|
700
921
|
};
|
package/lib/block-grammar.js
CHANGED
|
@@ -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,
|