@ai-dossier/sched 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -27
- package/dist/batch-dispatch.d.ts +33 -14
- package/dist/batch-dispatch.d.ts.map +1 -1
- package/dist/batch-dispatch.js +179 -26
- package/dist/batch-dispatch.js.map +1 -1
- package/dist/engine.d.ts +6 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/persist.d.ts.map +1 -1
- package/dist/persist.js +25 -5
- package/dist/persist.js.map +1 -1
- package/dist/recovery.d.ts +39 -10
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +162 -17
- package/dist/recovery.js.map +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +4 -0
- package/dist/state.js.map +1 -1
- package/dist/teardown.d.ts +12 -0
- package/dist/teardown.d.ts.map +1 -1
- package/dist/teardown.js +8 -5
- package/dist/teardown.js.map +1 -1
- package/dist/types.d.ts +53 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +16 -3
- package/dist/types.js.map +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -172,6 +172,17 @@ containing a `blocked` batch with "unknown batch status", bricking that project'
|
|
|
172
172
|
`SchedStore.load()`. Resume or abandon any blocked batch before downgrading past this
|
|
173
173
|
version.
|
|
174
174
|
|
|
175
|
+
Config schema moves to 1.7.0 (#563): a new top-level `dissolve_policy` key —
|
|
176
|
+
`{ fraction, min_evictions_before_dissolve }`, both required when the key is present —
|
|
177
|
+
overrides the batch dissolve threshold (default `{ fraction: 1/3,
|
|
178
|
+
min_evictions_before_dissolve: 1 }`, RFC-0001 §F.8's ⅓ with no additional floor). An
|
|
179
|
+
absent key falls back wholesale to the default; an invalid one degrades the WHOLE config
|
|
180
|
+
file to built-in defaults, same as every other config field (`loadConfig`'s
|
|
181
|
+
degrade-to-defaults contract). This release also adds the `partial` `dissolveBatch`
|
|
182
|
+
strategy and the `batch-preserved` journal event (see Batch failure recovery above) — both
|
|
183
|
+
are behavioral, not persisted-shape changes, so they carry no schema-version bump of their
|
|
184
|
+
own.
|
|
185
|
+
|
|
175
186
|
Two engine-safety policies were explicit product decisions on #464:
|
|
176
187
|
|
|
177
188
|
- **Pid identity is hybrid-verified (decision 1, option C).** Every spawn records the
|
|
@@ -248,7 +259,11 @@ against real scratch repos.
|
|
|
248
259
|
```
|
|
249
260
|
validating → attributing → fixing (ONE bounded attempt) → validating
|
|
250
261
|
→ evicting (revert the member's commits) → validating
|
|
251
|
-
>
|
|
262
|
+
evictions > max(ceil(N × fraction), min_evictions_before_dissolve), or a
|
|
263
|
+
revert conflict → dissolving → members requeued (`dissolve_policy`, #563)
|
|
264
|
+
same threshold crossed, but the survivors' re-run suite came back green
|
|
265
|
+
→ reviewing (batch preserved; only the evicted
|
|
266
|
+
members requeue — strategy=partial, #563)
|
|
252
267
|
suite report unreadable, after the fallback retry when one applied
|
|
253
268
|
→ blocked → validating (nothing requeued/reverted; #562)
|
|
254
269
|
awaiting-merge (CONFLICTING | auto-merge-blocked)
|
|
@@ -280,13 +295,24 @@ awaiting-merge (CONFLICTING | auto-merge-blocked)
|
|
|
280
295
|
aborted so the worktree is clean and the batch dissolves — the reverts that already
|
|
281
296
|
landed ride along on the abandoned branch, which is why it is abandoned rather than
|
|
282
297
|
reused. An eviction group that reaches an already-shipped member dissolves instead of
|
|
283
|
-
reverting merged work.
|
|
298
|
+
reverting merged work. Crossing the dissolve trigger no longer always dissolves (#563):
|
|
299
|
+
if the re-run suite came back green for the survivors — and every evicted member's
|
|
300
|
+
commits were actually found and reverted — the batch is PRESERVED instead: trimmed to
|
|
301
|
+
its survivors and carried straight to `reviewing`, only the evicted members requeue. A
|
|
302
|
+
red or unreadable re-run, or an evicted member whose commits were never found on the
|
|
303
|
+
branch, still dissolves in full.
|
|
284
304
|
4. **Dissolve (AC3)** — `dissolveBatch` marks the batch `dissolved` and requeues every
|
|
285
|
-
UNSHIPPED member: `full` (each as its own full-cycle run)
|
|
305
|
+
UNSHIPPED member: `full` (each as its own full-cycle run), `halved` (one or two fresh
|
|
286
306
|
`forming` half-batches — a single remaining member yields one — entries retagged,
|
|
287
|
-
eviction groups inherited where they survive the split)
|
|
288
|
-
|
|
289
|
-
|
|
307
|
+
eviction groups inherited where they survive the split), or `partial` (#563 — never
|
|
308
|
+
marks the batch `dissolved` at all: drops the evicted members from `batch.members` and
|
|
309
|
+
its `eviction_groups`/`ranges`, transitions `validating → reviewing`, and requeues
|
|
310
|
+
nothing itself, since the caller's eviction loop already did; falls through to `full`
|
|
311
|
+
if that would leave zero survivors, so an empty batch never ships). Shipped and
|
|
312
|
+
terminal members keep their outcome; nothing green is discarded, and no git runs — the
|
|
313
|
+
batch branch is simply left behind unmerged, since sched deletes nothing. Every dissolve
|
|
314
|
+
decision — all three strategies — journals its policy inputs (`N=`, `evictions=`,
|
|
315
|
+
`threshold=`), so it is explainable without re-deriving the formula.
|
|
290
316
|
5. **PR conflict (AC4)** — `handlePrConflict` rebases the batch branch, re-runs the suite
|
|
291
317
|
and re-ships ONCE. A second occurrence, a conflicting rebase, a failed fetch, an
|
|
292
318
|
unusable `base_branch`, a checkout that is not on the batch branch, or a red suite
|
|
@@ -299,12 +325,13 @@ awaiting-merge (CONFLICTING | auto-merge-blocked)
|
|
|
299
325
|
with no `anchor` or no `run_id` cannot post — the CLI requires both — so the milestone
|
|
300
326
|
it could not post is journaled in full instead of vanishing.
|
|
301
327
|
|
|
302
|
-
|
|
328
|
+
Twelve journal events carry the detail: `suite-failed`, `attributed`, `fix-dispatched`,
|
|
303
329
|
`fix-resolved`, `member-evicted`, `revert-conflict`, `batch-rebased`, `batch-dissolved`,
|
|
304
|
-
`batch-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
330
|
+
`batch-preserved` (#563 — the dissolve threshold was crossed but the survivors' re-run
|
|
331
|
+
suite came back green, so the batch ships them instead of dissolving), `batch-blocked`
|
|
332
|
+
(#562 — the suite report was unreadable), `batch-split` and `milestone-post-failed`, plus
|
|
333
|
+
`git-failed` for any git command that returned non-zero (the injected `ExecFn` collapses
|
|
334
|
+
every git failure into `null`, so the command that produced one is always recorded).
|
|
308
335
|
|
|
309
336
|
Schema 1.3.0 carries the new state: `BatchEntry` gains `anchor`, `branch`, `run_id`,
|
|
310
337
|
`eviction_groups`, `evictions`, `fix_attempts` and `rebase_attempts`; `QueueEntry` gains
|
|
@@ -365,8 +392,14 @@ failure rails: executing → dissolving (a member self-reports blocked)
|
|
|
365
392
|
```
|
|
366
393
|
|
|
367
394
|
- **One shared worktree/branch per batch**, claimed once by a deterministic (no LLM)
|
|
368
|
-
`batch-setup` step
|
|
369
|
-
|
|
395
|
+
`batch-setup` step, named `batch/<id>-<date>`, plus a fresh `ai-dossier runstate mint`
|
|
396
|
+
against the anchor issue. Tries a pool claim first (`npx worktree-pool claim` —
|
|
397
|
+
already warm by construction, `BatchEntry.pool_claimed`); on the cold `git branch`/
|
|
398
|
+
`push`/`worktree add` path, batch-setup warms the worktree itself before returning
|
|
399
|
+
(#561) — `cap run worktree.prepare` when the repo's manifest declares it, else
|
|
400
|
+
package-manager-detected install/build (`@ai-dossier/worktree-pool`'s command
|
|
401
|
+
resolution — respects the repo's `.worktree-pool.json` `project_subdir`/
|
|
402
|
+
`warm_commands` even in repos that never use the pool for anything else).
|
|
370
403
|
- **Members run serially, one fresh `slot-cycle` agent at a time**, in the shared
|
|
371
404
|
worktree. A member's completion signal is `phase=review status=done mode=slot` on its
|
|
372
405
|
OWN issue (`slot-cycle` posts no phase of its own past `review` — ship is batch-owned);
|
|
@@ -391,23 +424,26 @@ failure rails: executing → dissolving (a member self-reports blocked)
|
|
|
391
424
|
`report-issue`'s batch variant.
|
|
392
425
|
- **Scope cuts, recorded rather than discovered later:** no `git bisect` stage for an
|
|
393
426
|
ambiguous aggregate failure (an unattributable red suite dissolves instead); no
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
treated as blocked, not redispatched stronger).
|
|
427
|
+
per-phase stall/escalation ladder for batch sub-agents (a dead-without-verification
|
|
428
|
+
agent is treated as blocked, not redispatched stronger).
|
|
397
429
|
|
|
398
430
|
Schema 1.7.0: `BatchEntry` gains `worktree` (absolute path of the shared batch worktree,
|
|
399
431
|
null until batch-setup lands), `ranges` (`MemberRange[]` — each member's commit range,
|
|
400
432
|
recomputed after every member completes) and `pr` (the batch PR parked on auto-merge,
|
|
401
433
|
persisted so a restart mid-watch still knows what to poll). 1.6.0 states migrate on load:
|
|
402
434
|
no batch was ever dispatched under them, so `null`/`[]`/`null` is the exact backfill, not
|
|
403
|
-
a guess.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
`
|
|
410
|
-
`
|
|
435
|
+
a guess. `BatchEntry` also gains `pool_claimed` (#561) — pre-#561 states carry no such
|
|
436
|
+
key at all, and backfill it to `false` on load (no batch was ever pool-claimed before
|
|
437
|
+
batch-setup had pool integration). Config schema moves to 1.3.0: `dispatch` gains
|
|
438
|
+
`member_prompt`, `batch_tail_prompt` and `batch_report_prompt` (the three new agent
|
|
439
|
+
prompt templates).
|
|
440
|
+
|
|
441
|
+
New journal events: `batch-setup-done`, `batch-setup-failed`, `member-advanced`,
|
|
442
|
+
`batch-warmup-done`, `batch-warmup-failed` (#561 — the cold-path warm step only; a pool
|
|
443
|
+
claim emits neither). Member/tail/report/fix-agent spawn, progress, completion and park
|
|
444
|
+
events reuse the existing unit-generic names (`assigned`/`spawned`/`unit-failed`/
|
|
445
|
+
`external-advance`/`pr-parked`/`merge-accepted`/`report-dispatched`/`teardown-done`/
|
|
446
|
+
`teardown-failed`) with `unit = batch:<id>`.
|
|
411
447
|
|
|
412
448
|
## API surface
|
|
413
449
|
|
|
@@ -474,8 +510,8 @@ import {
|
|
|
474
510
|
beginFixAttempt, // the ONE bounded mid-tier fix dispatch instruction
|
|
475
511
|
resolveFixAttempt, // record its outcome, back to validating
|
|
476
512
|
evictMembers, // revert + requeue with evidence + suite re-run + dissolve check
|
|
477
|
-
checkDissolveTrigger, // pure: >
|
|
478
|
-
dissolveBatch, // full
|
|
513
|
+
checkDissolveTrigger, // pure: evicted > max(ceil(N × fraction), min floor) — dissolve_policy, #563
|
|
514
|
+
dissolveBatch, // full | halved | partial (#563); preserves everything green
|
|
479
515
|
blockBatch, // #562: unreadable suite report → blocked; no requeue, no revert
|
|
480
516
|
type BlockOptions, // { reason, milestonePhase? } for blockBatch
|
|
481
517
|
handlePrConflict, // rebase + re-ship once, then dissolve into halves
|
|
@@ -542,7 +578,8 @@ telemetry" below.
|
|
|
542
578
|
├── config.json # durable intent: max_slots, stall_timeout_ms, reconcile_interval_ms,
|
|
543
579
|
│ # pr_poll_interval_ms, dispatch (incl. report_prompt,
|
|
544
580
|
│ # phase_stall_timeout_ms, fence_takeover_timeout_ms, tiers — #527,
|
|
545
|
-
│ # suite_command — #562), auto_upgrade — #537
|
|
581
|
+
│ # suite_command — #562), auto_upgrade — #537,
|
|
582
|
+
│ # dissolve_policy — #563
|
|
546
583
|
├── events.jsonl # append-only event journal (the operator's flight recorder)
|
|
547
584
|
├── runs/ # per-unit agent output logs (issue-<n>.log)
|
|
548
585
|
└── .sched-lock/ # cross-process directory mutex (pid; stolen from dead holders)
|
package/dist/batch-dispatch.d.ts
CHANGED
|
@@ -47,12 +47,18 @@
|
|
|
47
47
|
* an ambiguous aggregate failure (bisect needs a per-project "run only these
|
|
48
48
|
* tests" command this module has no generic way to construct) — an
|
|
49
49
|
* unattributable red aggregate suite dissolves the batch rather than
|
|
50
|
-
* bisecting, which `attributing → dissolving` already models. No
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
50
|
+
* bisecting, which `attributing → dissolving` already models. No per-phase
|
|
51
|
+
* stall/escalation ladder for batch sub-agents — a dead-without-verification
|
|
52
|
+
* agent is treated as blocked and evicted/reported rather than redispatched
|
|
53
|
+
* stronger. Both are documented follow-ups, not gaps discovered later.
|
|
54
|
+
*
|
|
55
|
+
* `runBatchSetup` (#561) tries a pool claim first (already warm, mirroring
|
|
56
|
+
* `teardown.ts`'s `poolReturn` — same `npx`-through-`deps.exec` pattern, never
|
|
57
|
+
* a direct in-process `claim()` import, which resolves its git root from
|
|
58
|
+
* `process.cwd()` and would break this module's `deps.repoDir` testability
|
|
59
|
+
* contract); on the cold `git worktree add` path it warms the worktree itself
|
|
60
|
+
* before returning, so a member's first command is never the one that
|
|
61
|
+
* discovers `node_modules` is missing (`env-cold`, `docs/agent-traps.md`).
|
|
56
62
|
*/
|
|
57
63
|
import { type ResolvedDispatch, type SpawnDeps } from './dispatch';
|
|
58
64
|
import { type GroundTruth } from './groundtruth';
|
|
@@ -81,17 +87,30 @@ export interface BatchDispatchDeps {
|
|
|
81
87
|
repoDir: string;
|
|
82
88
|
/** Exec for batch git/milestone-CLI operations (never throws — the `ExecFn` contract). */
|
|
83
89
|
exec: ExecFn;
|
|
90
|
+
/**
|
|
91
|
+
* Exec for the cold-path warm-up install/build (#561), on its own budget —
|
|
92
|
+
* a real `npm ci` + build routinely exceeds the git-op timeout `exec` is
|
|
93
|
+
* tuned for. Falls back to `exec` when not supplied (existing callers/tests
|
|
94
|
+
* are unaffected; production wiring should still give this its own longer
|
|
95
|
+
* timeout, e.g. `@ai-dossier/worktree-pool`'s `WARM_COMMAND_TIMEOUT_MS`).
|
|
96
|
+
*/
|
|
97
|
+
warmExec?: ExecFn;
|
|
84
98
|
/** Runs the aggregate suite inside a batch worktree; batches never leave `validating` without one. */
|
|
85
99
|
runSuite: (worktree: string) => SuiteResult;
|
|
86
100
|
/**
|
|
87
|
-
* Runs one `ai-dossier cap run <capabilityId>` in a batch worktree
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* `capability-unavailable`, the gate
|
|
92
|
-
* `slot-cycle` run already attempted this
|
|
93
|
-
*
|
|
94
|
-
*
|
|
101
|
+
* Runs one `ai-dossier cap run <capabilityId>` in a batch worktree. Two call
|
|
102
|
+
* sites, different degrade contracts:
|
|
103
|
+
* - the per-member incremental gate (#523 AC2 — "typecheck + focused tests
|
|
104
|
+
* via `cap run test.focused` when available"): degrade-not-crash — without
|
|
105
|
+
* this hook, or on `automation-broken`/`capability-unavailable`, the gate
|
|
106
|
+
* is skipped (the member's own `slot-cycle` run already attempted this
|
|
107
|
+
* fast path before ever posting `review done`, so a repo with no manifest
|
|
108
|
+
* loses nothing but the engine's independent re-check).
|
|
109
|
+
* - batch-setup's `worktree.prepare` warm step (#561, `warmColdBatchWorktree`):
|
|
110
|
+
* `undefined`/`capability-unavailable`/`automation-broken` fall through to
|
|
111
|
+
* package-manager detection, but a declared-and-`task-failed` capability
|
|
112
|
+
* hard-fails the whole batch setup — a repo that owns its warm-up should
|
|
113
|
+
* never be silently second-guessed by a fallback underneath it.
|
|
95
114
|
*/
|
|
96
115
|
runCapability?: (worktree: string, capabilityId: string) => CapOutcome;
|
|
97
116
|
fsExists?: FsExists;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAiBH,OAAO,EAKL,KAAK,gBAAgB,EAErB,KAAK,SAAS,EAEf,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,KAAK,WAAW,EAOjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,KAAK,OAAO,EAAa,MAAM,WAAW,CAAC;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAUL,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAWpB,OAAO,EAAE,KAAK,QAAQ,EAA2D,MAAM,YAAY,CAAC;AACpG,OAAO,KAAK,EAKV,WAAW,EAIZ,MAAM,SAAS,CAAC;AAGjB;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,aAAa,GAAG,mBAAmB,GAAG,wBAAwB,CAAC;AAE/F,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,UAAU,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,WAAW,CAAC;IACzB,SAAS,EAAE,SAAS,CAAC;IACrB,GAAG,EAAE,MAAM,IAAI,CAAC;IAChB,uFAAuF;IACvF,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sGAAsG;IACtG,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,WAAW,CAAC;IAC5C;;;;;;;;;;;;;;OAcG;IACH,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,UAAU,CAAC;IACvE,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACrB;AAED,8FAA8F;AAC9F,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,8FAA8F;IAC9F,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAw8CD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,iBAAiB,EACvB,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,gBAAgB,GACzB,eAAe,CAsEjB"}
|
package/dist/batch-dispatch.js
CHANGED
|
@@ -48,12 +48,18 @@
|
|
|
48
48
|
* an ambiguous aggregate failure (bisect needs a per-project "run only these
|
|
49
49
|
* tests" command this module has no generic way to construct) — an
|
|
50
50
|
* unattributable red aggregate suite dissolves the batch rather than
|
|
51
|
-
* bisecting, which `attributing → dissolving` already models. No
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
51
|
+
* bisecting, which `attributing → dissolving` already models. No per-phase
|
|
52
|
+
* stall/escalation ladder for batch sub-agents — a dead-without-verification
|
|
53
|
+
* agent is treated as blocked and evicted/reported rather than redispatched
|
|
54
|
+
* stronger. Both are documented follow-ups, not gaps discovered later.
|
|
55
|
+
*
|
|
56
|
+
* `runBatchSetup` (#561) tries a pool claim first (already warm, mirroring
|
|
57
|
+
* `teardown.ts`'s `poolReturn` — same `npx`-through-`deps.exec` pattern, never
|
|
58
|
+
* a direct in-process `claim()` import, which resolves its git root from
|
|
59
|
+
* `process.cwd()` and would break this module's `deps.repoDir` testability
|
|
60
|
+
* contract); on the cold `git worktree add` path it warms the worktree itself
|
|
61
|
+
* before returning, so a member's first command is never the one that
|
|
62
|
+
* discovers `node_modules` is missing (`env-cold`, `docs/agent-traps.md`).
|
|
57
63
|
*/
|
|
58
64
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
59
65
|
if (k2 === undefined) k2 = k;
|
|
@@ -90,7 +96,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
90
96
|
})();
|
|
91
97
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
92
98
|
exports.runBatchTick = runBatchTick;
|
|
99
|
+
const fs = __importStar(require("node:fs"));
|
|
93
100
|
const path = __importStar(require("node:path"));
|
|
101
|
+
const worktree_pool_1 = require("@ai-dossier/worktree-pool");
|
|
94
102
|
const attribution_1 = require("./attribution");
|
|
95
103
|
const dispatch_1 = require("./dispatch");
|
|
96
104
|
const groundtruth_1 = require("./groundtruth");
|
|
@@ -99,6 +107,7 @@ const recovery_1 = require("./recovery");
|
|
|
99
107
|
const scheduler_1 = require("./scheduler");
|
|
100
108
|
const state_1 = require("./state");
|
|
101
109
|
const teardown_1 = require("./teardown");
|
|
110
|
+
const types_1 = require("./types");
|
|
102
111
|
function emptyResult() {
|
|
103
112
|
return { spawned: [], completed: [], parked: [], mergeAccepted: [], failed: [], blocked: [] };
|
|
104
113
|
}
|
|
@@ -182,13 +191,14 @@ function safeSuite(deps, batchId, worktree) {
|
|
|
182
191
|
return { ok: false, failing: [], readable: false, detail };
|
|
183
192
|
}
|
|
184
193
|
}
|
|
185
|
-
function recoveryDeps(deps, batch, now) {
|
|
194
|
+
function recoveryDeps(deps, config, batch, now) {
|
|
186
195
|
return {
|
|
187
196
|
exec: deps.exec,
|
|
188
197
|
repoDir: batch.worktree ?? deps.repoDir,
|
|
189
198
|
journal: deps.journal,
|
|
190
199
|
postMilestone: (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir }),
|
|
191
200
|
runSuite: batch.worktree !== null ? () => deps.runSuite(batch.worktree) : undefined,
|
|
201
|
+
dissolvePolicy: (0, types_1.resolveDissolvePolicy)(config.dissolve_policy),
|
|
192
202
|
now: () => now,
|
|
193
203
|
};
|
|
194
204
|
}
|
|
@@ -230,11 +240,93 @@ function releaseSlot(state, batchId, now) {
|
|
|
230
240
|
}
|
|
231
241
|
// --- Batch setup (ready → executing, member 1) ---
|
|
232
242
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
243
|
+
* A pool claim already returns a warm worktree (deps installed, built), so
|
|
244
|
+
* only the cold `git worktree add` path needs its own warm step (#561).
|
|
245
|
+
*
|
|
246
|
+
* Tries `worktree.prepare` via the optional `runCapability` hook first (a
|
|
247
|
+
* repo that declares it in its manifest knows its own warm-up best) — a
|
|
248
|
+
* declared-and-`task-failed` capability hard-fails the whole batch setup
|
|
249
|
+
* (deliberately NOT degrade-not-crash, unlike every other `runCapability`
|
|
250
|
+
* call site in this file: a repo that owns `worktree.prepare` should never
|
|
251
|
+
* be silently second-guessed by falling back underneath it). `undefined`,
|
|
252
|
+
* `capability-unavailable` (no such id declared) AND `automation-broken`
|
|
253
|
+
* (broken manifest, missing tool, timeout — "do not trust the machinery",
|
|
254
|
+
* not "the task failed") all fall through to the generic path below.
|
|
255
|
+
*
|
|
256
|
+
* The generic path reuses `@ai-dossier/worktree-pool`'s package-manager
|
|
257
|
+
* detection, guarded on `package.json` actually being present — `npm
|
|
258
|
+
* install` (and the pnpm/yarn/bun equivalents) hard-fail with no
|
|
259
|
+
* `package.json` at all, so an unguarded call would turn "nothing to warm"
|
|
260
|
+
* into a false failure for every non-Node repo. The guard is bypassed when
|
|
261
|
+
* `.worktree-pool.json` declares explicit `warm_commands` — the escape hatch
|
|
262
|
+
* for a repo whose warm-up isn't `npm install`-shaped at all.
|
|
263
|
+
*
|
|
264
|
+
* Warm commands run on `deps.warmExec ?? deps.exec` — a separate, longer
|
|
265
|
+
* budget than the git/milestone calls `deps.exec` is tuned for (a cold
|
|
266
|
+
* install+build routinely exceeds a git-op timeout; see `BatchDispatchDeps`).
|
|
267
|
+
*
|
|
268
|
+
* Journals `batch-warmup-done`/`batch-warmup-failed` with the elapsed time
|
|
269
|
+
* appended to `detail` either way (satisfying AC1 even on the no-op branch —
|
|
270
|
+
* `JournalEvent` has no dedicated `duration_ms` field).
|
|
271
|
+
*/
|
|
272
|
+
function warmColdBatchWorktree(deps, batch, worktree, now,
|
|
273
|
+
/** Prefixed onto the journaled detail — e.g. `pool-claim-invalid:` when the cold path was taken because a pool claim returned unusable output, rather than the ordinary "no warm spares" case. */
|
|
274
|
+
poolNote = '') {
|
|
275
|
+
const warmStart = deps.now();
|
|
276
|
+
const elapsedMs = () => deps.now().getTime() - warmStart.getTime();
|
|
277
|
+
const fail = (tag) => {
|
|
278
|
+
deps.journal.append((0, journal_1.unitEvent)('batch-warmup-failed', unit(batch.id), {
|
|
279
|
+
detail: `${poolNote}${tag} ${elapsedMs()}ms`,
|
|
280
|
+
}), now);
|
|
281
|
+
return { ok: false, reason: `warmup-failed:${tag}` };
|
|
282
|
+
};
|
|
283
|
+
const done = (tag) => {
|
|
284
|
+
deps.journal.append((0, journal_1.unitEvent)('batch-warmup-done', unit(batch.id), {
|
|
285
|
+
detail: `${poolNote}${tag} ${elapsedMs()}ms`,
|
|
286
|
+
}), now);
|
|
287
|
+
return { ok: true };
|
|
288
|
+
};
|
|
289
|
+
const capOutcome = deps.runCapability?.(worktree, 'worktree.prepare');
|
|
290
|
+
if (capOutcome === 'ok')
|
|
291
|
+
return done('cap:worktree.prepare');
|
|
292
|
+
if (capOutcome === 'task-failed')
|
|
293
|
+
return fail('cap:worktree.prepare:task-failed');
|
|
294
|
+
// `undefined` (no hook injected) / `capability-unavailable` (not declared)
|
|
295
|
+
// / `automation-broken` (declared but the machinery itself is untrustworthy)
|
|
296
|
+
// all fall through to the generic package-manager path below.
|
|
297
|
+
const fsExists = deps.fsExists ?? ((p) => fs.existsSync(p));
|
|
298
|
+
const cfg = (0, worktree_pool_1.readPoolFileConfig)(deps.repoDir);
|
|
299
|
+
const projectDir = (0, worktree_pool_1.resolveProjectDir)(worktree, cfg.project_subdir);
|
|
300
|
+
// `.worktree-pool.json`'s `project_subdir` is repo config, not attacker
|
|
301
|
+
// input, but a `../`-shaped value would otherwise run install/build
|
|
302
|
+
// OUTSIDE the batch worktree entirely — skip rather than warm the wrong
|
|
303
|
+
// directory (or fail the whole batch over a misconfigured pool file).
|
|
304
|
+
if (projectDir !== worktree && !projectDir.startsWith(worktree + path.sep)) {
|
|
305
|
+
return done('skipped:project-dir-outside-worktree');
|
|
306
|
+
}
|
|
307
|
+
const hasExplicitWarmCommands = (cfg.warm_commands?.length ?? 0) > 0;
|
|
308
|
+
if (!hasExplicitWarmCommands && !fsExists(path.join(projectDir, 'package.json'))) {
|
|
309
|
+
return done('skipped:no-package-json');
|
|
310
|
+
}
|
|
311
|
+
const commands = (0, worktree_pool_1.resolveWarmCommands)(projectDir, cfg);
|
|
312
|
+
for (const [i, cmd] of commands.entries()) {
|
|
313
|
+
const [bin, ...args] = cmd;
|
|
314
|
+
if (bin === undefined)
|
|
315
|
+
return fail(`pm:${i + 1}/${commands.length}:empty-command`);
|
|
316
|
+
if ((deps.warmExec ?? deps.exec)(bin, args, projectDir) === null) {
|
|
317
|
+
return fail(`pm:${i + 1}/${commands.length}:${bin}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const pm = commands[0]?.[0] ?? 'none';
|
|
321
|
+
return done(`pm:${pm}:${commands.length}cmds`);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* `batch/<id>-<YYYYMMDD>` off `base_branch`, and a fresh runstate run id
|
|
325
|
+
* minted against the anchor. The worktree is either a pool claim (already
|
|
326
|
+
* warm) or a cold `git worktree add` that this function then warms itself
|
|
327
|
+
* (#561, see the module doc) — either way, the worktree is warm before this
|
|
328
|
+
* returns `ok: true`. All-or-nothing: any failed step reports the step name
|
|
329
|
+
* and nothing is partially recorded on the batch.
|
|
238
330
|
*/
|
|
239
331
|
function runBatchSetup(deps, batch, now) {
|
|
240
332
|
if (batch.anchor === null)
|
|
@@ -257,6 +349,36 @@ function runBatchSetup(deps, batch, now) {
|
|
|
257
349
|
if (runId === null || runId.trim() === '')
|
|
258
350
|
return { ok: false, reason: 'runstate-mint-failed' };
|
|
259
351
|
const mintedRunId = runId.trim();
|
|
352
|
+
// Pool claim first — a claimed worktree is already warm by construction, so
|
|
353
|
+
// no separate warm step runs for it (AC2). `claim()` itself never pushes
|
|
354
|
+
// the branch (mirrors setup-issue-workflow's per-issue pool-claim step),
|
|
355
|
+
// so that still happens here on success.
|
|
356
|
+
const claimed = deps.exec(teardown_1.POOL_BIN, [...teardown_1.POOL_ARGS_PREFIX, 'claim', '--issue', String(batch.anchor), '--branch', branch], deps.repoDir);
|
|
357
|
+
const claimedWorktree = claimed?.trim();
|
|
358
|
+
// Same hardening `teardown.ts`'s destructive sinks apply to a worktree path
|
|
359
|
+
// (no NUL/newline, no unresolved `..`) — this one is our own CLI's stdout,
|
|
360
|
+
// not attacker input, but garbage here would otherwise be trusted verbatim
|
|
361
|
+
// as `BatchEntry.worktree` and used as a spawn/exec cwd for every member.
|
|
362
|
+
const claimIsUsable = !!claimedWorktree &&
|
|
363
|
+
!claimedWorktree.includes('\0') &&
|
|
364
|
+
!claimedWorktree.includes('\n') &&
|
|
365
|
+
path.isAbsolute(claimedWorktree) &&
|
|
366
|
+
path.resolve(claimedWorktree) === claimedWorktree &&
|
|
367
|
+
(deps.fsExists ?? ((p) => fs.existsSync(p)))(claimedWorktree);
|
|
368
|
+
if (claimedWorktree && claimIsUsable) {
|
|
369
|
+
if (deps.exec('git', ['push', '-u', 'origin', '--', branch], claimedWorktree) === null) {
|
|
370
|
+
// Return the claim rather than leaking a permanently `assigned` pool
|
|
371
|
+
// entry nothing else will ever reference — `BatchEntry` only records
|
|
372
|
+
// `worktree`/`pool_claimed` on the `ok: true` path below.
|
|
373
|
+
deps.exec(teardown_1.POOL_BIN, [...teardown_1.POOL_ARGS_PREFIX, 'return', '--path', claimedWorktree, '--json'], deps.repoDir);
|
|
374
|
+
return { ok: false, reason: 'branch-push-failed', runId: mintedRunId };
|
|
375
|
+
}
|
|
376
|
+
return { ok: true, branch, worktree: claimedWorktree, runId: mintedRunId, poolClaimed: true };
|
|
377
|
+
}
|
|
378
|
+
// A non-null, non-empty, unusable claim response (rather than a plain "no
|
|
379
|
+
// warm spares" null/empty) is unusual enough to note on the batch that
|
|
380
|
+
// otherwise cold-builds silently.
|
|
381
|
+
const poolNote = claimedWorktree ? 'pool-claim-invalid:' : '';
|
|
260
382
|
if (deps.exec('git', ['fetch', 'origin', '--', batch.base_branch], deps.repoDir) === null) {
|
|
261
383
|
return { ok: false, reason: 'fetch-failed', runId: mintedRunId };
|
|
262
384
|
}
|
|
@@ -269,7 +391,20 @@ function runBatchSetup(deps, batch, now) {
|
|
|
269
391
|
if (deps.exec('git', ['worktree', 'add', '--', worktree, branch], deps.repoDir) === null) {
|
|
270
392
|
return { ok: false, reason: 'worktree-add-failed', runId: mintedRunId };
|
|
271
393
|
}
|
|
272
|
-
|
|
394
|
+
const warmed = warmColdBatchWorktree(deps, batch, worktree, now, poolNote);
|
|
395
|
+
if (!warmed.ok) {
|
|
396
|
+
// Restore the "all-or-nothing" contract this function documents: a warm
|
|
397
|
+
// failure otherwise leaves the branch pushed and the worktree on disk,
|
|
398
|
+
// so the NEXT tick's retry dies at `branch-create-failed` forever
|
|
399
|
+
// instead of ever reaching warm-up again. Best-effort — a failed cleanup
|
|
400
|
+
// here just means the next retry's `worktree-add-failed`/`branch-create-failed`
|
|
401
|
+
// surfaces the leftover instead, no worse than before this cleanup existed.
|
|
402
|
+
deps.exec('git', ['worktree', 'remove', '--force', '--', worktree], deps.repoDir);
|
|
403
|
+
deps.exec('git', ['branch', '-D', branch], deps.repoDir);
|
|
404
|
+
deps.exec('git', ['push', 'origin', '--delete', branch], deps.repoDir);
|
|
405
|
+
return { ok: false, reason: warmed.reason, runId: mintedRunId };
|
|
406
|
+
}
|
|
407
|
+
return { ok: true, branch, worktree, runId: mintedRunId, poolClaimed: false };
|
|
273
408
|
}
|
|
274
409
|
/** Spawn one batch member's `slot-cycle` agent into the slot batch-setup (or a prior member) just released. */
|
|
275
410
|
/**
|
|
@@ -422,7 +557,12 @@ function claimAndSetup(deps, config, dispatch, batchId, now, result) {
|
|
|
422
557
|
poster(batch.anchor, setup.runId, {
|
|
423
558
|
phase: 'batch-setup',
|
|
424
559
|
status: 'done',
|
|
425
|
-
kv: {
|
|
560
|
+
kv: {
|
|
561
|
+
branch: setup.branch,
|
|
562
|
+
worktree: setup.worktree,
|
|
563
|
+
base_branch: batch.base_branch,
|
|
564
|
+
pool_claimed: setup.poolClaimed ? 'true' : 'false',
|
|
565
|
+
},
|
|
426
566
|
});
|
|
427
567
|
deps.journal.append((0, journal_1.unitEvent)('batch-setup-done', unit(batchId), { detail: setup.worktree }), now);
|
|
428
568
|
deps.store.withLock((s) => {
|
|
@@ -440,7 +580,12 @@ function claimAndSetup(deps, config, dispatch, batchId, now, result) {
|
|
|
440
580
|
});
|
|
441
581
|
return { state: releaseSlot(s, batchId, now), result: undefined };
|
|
442
582
|
}
|
|
443
|
-
let next = (0, state_1.patchBatch)(s, batchId, {
|
|
583
|
+
let next = (0, state_1.patchBatch)(s, batchId, {
|
|
584
|
+
branch: setup.branch,
|
|
585
|
+
worktree: setup.worktree,
|
|
586
|
+
run_id: setup.runId,
|
|
587
|
+
pool_claimed: setup.poolClaimed,
|
|
588
|
+
}, now);
|
|
444
589
|
next = (0, state_1.transitionBatch)(next, batchId, 'executing', { executing_member: 1 }, now);
|
|
445
590
|
const slot = slotFor(next, batchId);
|
|
446
591
|
if (slot)
|
|
@@ -604,7 +749,7 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
|
|
|
604
749
|
if (!batch || batch.worktree === null)
|
|
605
750
|
return;
|
|
606
751
|
const suite = safeSuite(deps, batchId, batch.worktree);
|
|
607
|
-
const rDeps = recoveryDeps(deps, batch, now);
|
|
752
|
+
const rDeps = recoveryDeps(deps, config, batch, now);
|
|
608
753
|
const poster = (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir });
|
|
609
754
|
if (suite.ok) {
|
|
610
755
|
if (batch.anchor !== null && batch.run_id !== null) {
|
|
@@ -696,12 +841,12 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
|
|
|
696
841
|
return next;
|
|
697
842
|
});
|
|
698
843
|
}
|
|
699
|
-
function evictOffender(deps,
|
|
844
|
+
function evictOffender(deps, config, batchId, offender, attribution, now, result) {
|
|
700
845
|
const state = deps.store.load();
|
|
701
846
|
const batch = (0, state_1.findBatch)(state, batchId);
|
|
702
847
|
if (!batch)
|
|
703
848
|
return;
|
|
704
|
-
const rDeps = recoveryDeps(deps, batch, now);
|
|
849
|
+
const rDeps = recoveryDeps(deps, config, batch, now);
|
|
705
850
|
const outcome = (0, recovery_1.evictMembers)(state, batchId, { issues: [offender], reason: 'suite-red-after-fix', attribution, ranges: batch.ranges }, rDeps);
|
|
706
851
|
deps.store.withLock((s) => ({
|
|
707
852
|
state: applyBatchAndIssues(s, outcome.state, batchId, outcome.requeued),
|
|
@@ -757,7 +902,7 @@ function advanceMemberOrValidate(deps, config, dispatch, batchId, memberCount, c
|
|
|
757
902
|
* (self-reported blocked, and the incremental gate below).
|
|
758
903
|
*/
|
|
759
904
|
function evictMemberAndContinue(deps, config, dispatch, batchId, batch, memberIssue, reason, now, result) {
|
|
760
|
-
const dissolved = evictMemberDirectly(deps, batchId, memberIssue, reason, now);
|
|
905
|
+
const dissolved = evictMemberDirectly(deps, config, batchId, memberIssue, reason, now);
|
|
761
906
|
if (dissolved) {
|
|
762
907
|
result.failed.push(unit(batchId));
|
|
763
908
|
return;
|
|
@@ -854,7 +999,8 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
|
|
|
854
999
|
* and a commit range to revert; a member evicted here has neither). Returns
|
|
855
1000
|
* whether the batch dissolved.
|
|
856
1001
|
*/
|
|
857
|
-
function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
|
|
1002
|
+
function evictMemberDirectly(deps, config, batchId, memberIssue, reason, now) {
|
|
1003
|
+
const dissolvePolicy = (0, types_1.resolveDissolvePolicy)(config.dissolve_policy);
|
|
858
1004
|
// Pass 1 (pure — requeue + record the eviction): safe to run entirely
|
|
859
1005
|
// inside the lock, unlike `dissolveBatch` below, which shells out
|
|
860
1006
|
// (`deps.exec`/`postMilestone`) and so must NOT hold the lock while it runs.
|
|
@@ -886,7 +1032,10 @@ function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
|
|
|
886
1032
|
],
|
|
887
1033
|
}, now);
|
|
888
1034
|
const updated = (0, state_1.findBatch)(next, batchId);
|
|
889
|
-
return {
|
|
1035
|
+
return {
|
|
1036
|
+
state: next,
|
|
1037
|
+
result: updated !== undefined && (0, recovery_1.checkDissolveTrigger)(updated, dissolvePolicy),
|
|
1038
|
+
};
|
|
890
1039
|
});
|
|
891
1040
|
if (!triggered)
|
|
892
1041
|
return false;
|
|
@@ -897,7 +1046,7 @@ function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
|
|
|
897
1046
|
const batch = (0, state_1.findBatch)(state, batchId);
|
|
898
1047
|
if (!batch)
|
|
899
1048
|
return false;
|
|
900
|
-
const rDeps = recoveryDeps(deps, batch, now);
|
|
1049
|
+
const rDeps = recoveryDeps(deps, config, batch, now);
|
|
901
1050
|
const outcome = (0, recovery_1.dissolveBatch)(state, batchId, { strategy: 'full', reason: 'eviction-threshold' }, rDeps);
|
|
902
1051
|
deps.store.withLock((s) => ({
|
|
903
1052
|
state: applyBatchAndIssues(s, outcome.state, batchId, outcome.requeued),
|
|
@@ -918,7 +1067,7 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
|
|
|
918
1067
|
return;
|
|
919
1068
|
deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
|
|
920
1069
|
const suite = safeSuite(deps, batchId, batch.worktree);
|
|
921
|
-
const rDeps = recoveryDeps(deps, batch, now);
|
|
1070
|
+
const rDeps = recoveryDeps(deps, config, batch, now);
|
|
922
1071
|
const { state: resolved } = (0, recovery_1.resolveFixAttempt)(deps.store.load(), batchId, offenderRecord.issue, suite.ok ? 'green' : 'red', rDeps);
|
|
923
1072
|
deps.store.withLock((s) => ({
|
|
924
1073
|
state: applyBatchAndIssues(s, resolved, batchId, []),
|
|
@@ -1064,14 +1213,18 @@ function teardownBatch(deps, batchId) {
|
|
|
1064
1213
|
if (!batch || batch.worktree === null)
|
|
1065
1214
|
return;
|
|
1066
1215
|
const root = deps.exec('git', ['rev-parse', '--show-toplevel'], deps.repoDir) ?? deps.repoDir;
|
|
1067
|
-
|
|
1216
|
+
// Pool-claimed worktrees are validated by the pool's own `return` (it only
|
|
1217
|
+
// accepts paths in pool state) and can legitimately live outside either
|
|
1218
|
+
// `isSafeWorktree` root when `.worktree-pool.json` configures a custom
|
|
1219
|
+
// `pool_dir` — mirrors `runTeardown`'s own internal skip for `poolClaimed`.
|
|
1220
|
+
if (batch.pool_claimed !== true && !(0, teardown_1.isSafeWorktree)(path.resolve(root), batch.worktree)) {
|
|
1068
1221
|
journalEvent(deps, 'teardown-failed', unit(batchId), {
|
|
1069
1222
|
reason: 'unsafe-worktree-path',
|
|
1070
1223
|
detail: batch.worktree,
|
|
1071
1224
|
});
|
|
1072
1225
|
return;
|
|
1073
1226
|
}
|
|
1074
|
-
const result = (0, teardown_1.runTeardown)(deps.exec, deps.repoDir, { worktree: batch.worktree, poolClaimed:
|
|
1227
|
+
const result = (0, teardown_1.runTeardown)(deps.exec, deps.repoDir, { worktree: batch.worktree, poolClaimed: batch.pool_claimed === true, branch: batch.branch }, deps.fsExists);
|
|
1075
1228
|
journalEvent(deps, result.cleanup === 'done' ? 'teardown-done' : 'teardown-failed', unit(batchId), {
|
|
1076
1229
|
cleanup: result.cleanup,
|
|
1077
1230
|
detail: result.detail,
|
|
@@ -1148,7 +1301,7 @@ function runBatchTick(deps, config, dispatch) {
|
|
|
1148
1301
|
? [...b.fix_attempts].reverse().find((a) => a.outcome === 'dispatched')
|
|
1149
1302
|
: undefined;
|
|
1150
1303
|
if (b && offenderRecord) {
|
|
1151
|
-
const rDeps = recoveryDeps(deps, b, now);
|
|
1304
|
+
const rDeps = recoveryDeps(deps, config, b, now);
|
|
1152
1305
|
const { state: resolved } = (0, recovery_1.resolveFixAttempt)(state, batch.id, offenderRecord.issue, 'red', rDeps);
|
|
1153
1306
|
deps.store.withLock((s) => ({
|
|
1154
1307
|
state: applyBatchAndIssues(s, resolved, batch.id, []),
|