@ai-dossier/sched 0.2.1 → 0.4.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.
Files changed (57) hide show
  1. package/README.md +179 -17
  2. package/dist/attribution.d.ts +132 -0
  3. package/dist/attribution.d.ts.map +1 -0
  4. package/dist/attribution.js +268 -0
  5. package/dist/attribution.js.map +1 -0
  6. package/dist/bisect.d.ts +67 -0
  7. package/dist/bisect.d.ts.map +1 -0
  8. package/dist/bisect.js +122 -0
  9. package/dist/bisect.js.map +1 -0
  10. package/dist/dispatch.d.ts +48 -1
  11. package/dist/dispatch.d.ts.map +1 -1
  12. package/dist/dispatch.js +92 -5
  13. package/dist/dispatch.js.map +1 -1
  14. package/dist/engine.d.ts +53 -13
  15. package/dist/engine.d.ts.map +1 -1
  16. package/dist/engine.js +421 -75
  17. package/dist/engine.js.map +1 -1
  18. package/dist/enqueue.d.ts +9 -0
  19. package/dist/enqueue.d.ts.map +1 -1
  20. package/dist/enqueue.js +96 -12
  21. package/dist/enqueue.js.map +1 -1
  22. package/dist/groundtruth.d.ts +66 -5
  23. package/dist/groundtruth.d.ts.map +1 -1
  24. package/dist/groundtruth.js +150 -5
  25. package/dist/groundtruth.js.map +1 -1
  26. package/dist/index.d.ts +10 -6
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +51 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/persist.d.ts.map +1 -1
  31. package/dist/persist.js +14 -0
  32. package/dist/persist.js.map +1 -1
  33. package/dist/recovery.d.ts +263 -0
  34. package/dist/recovery.d.ts.map +1 -0
  35. package/dist/recovery.js +730 -0
  36. package/dist/recovery.js.map +1 -0
  37. package/dist/scheduler.d.ts +12 -0
  38. package/dist/scheduler.d.ts.map +1 -1
  39. package/dist/scheduler.js +53 -58
  40. package/dist/scheduler.js.map +1 -1
  41. package/dist/state.d.ts +57 -4
  42. package/dist/state.d.ts.map +1 -1
  43. package/dist/state.js +238 -8
  44. package/dist/state.js.map +1 -1
  45. package/dist/status.d.ts +16 -4
  46. package/dist/status.d.ts.map +1 -1
  47. package/dist/status.js +10 -4
  48. package/dist/status.js.map +1 -1
  49. package/dist/teardown.d.ts +57 -0
  50. package/dist/teardown.d.ts.map +1 -0
  51. package/dist/teardown.js +242 -0
  52. package/dist/teardown.js.map +1 -0
  53. package/dist/types.d.ts +155 -4
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js +39 -5
  56. package/dist/types.js.map +1 -1
  57. package/package.json +2 -2
package/README.md CHANGED
@@ -3,11 +3,15 @@
3
3
  [![npm](https://img.shields.io/npm/v/@ai-dossier/sched.svg)](https://www.npmjs.com/package/@ai-dossier/sched)
4
4
 
5
5
  Deterministic scheduler core for dossier batch cycles — queue, worker slots, typed state
6
- machines, crash-safe persistence, and (since #464) the **dispatch engine**: spawning agent
7
- processes, verifying their completion against ground truth, and mechanizing the
8
- stall/escalation ladder. The scheduler itself **never invokes an LLM** — it spawns the
9
- agent process the operator configured and reconciles the durable record
10
- (`ai-dossier runstate` / `gh` / `git`) that the spawned run leaves behind.
6
+ machines, crash-safe persistence, the **dispatch engine** (#464: spawning agent
7
+ processes, verifying their completion against ground truth, mechanizing the
8
+ stall/escalation ladder), and since #468 the **PR watcher + tail work** (parked-PR
9
+ watching, script-based teardown, cheap-tier report dispatch retiring the fleet
10
+ pattern of re-dispatching a full-cycle run for the tail), and since #472 **batch failure
11
+ recovery** (attribution, bisect, one bounded fix, eviction, dissolve). The scheduler itself **never
12
+ invokes an LLM** — it spawns the agent process the operator configured and reconciles
13
+ the durable record (`ai-dossier runstate` / `gh` / `git`) that the spawned run leaves
14
+ behind.
11
15
 
12
16
  Design: RFC-0001 *Batch Cycles* §B/C.1/D (the RFC lives on branch `docs/batch-cycles-rfc`,
13
17
  not yet merged to `main`; the §D state machines are frozen verbatim into the types below).
@@ -17,14 +21,14 @@ this state machine makes impossible to forget.
17
21
 
18
22
  ## CLI surface
19
23
 
20
- Consumed through the monorepo CLI (`@ai-dossier/cli` ≥ 0.18.0):
24
+ Consumed through the monorepo CLI (`@ai-dossier/cli` ≥ 0.19.0):
21
25
 
22
26
  ```bash
23
27
  ai-dossier sched enqueue --issues 101,105..109 --deps 100 --tier strong # flags
24
28
  ai-dossier sched enqueue --from-manifest batch-prep.json # batch-prep output
25
- ai-dossier sched start # the dispatch engine: spawn, verify, escalate (Ctrl-C stops it)
29
+ ai-dossier sched start # the dispatch engine: spawn, verify, escalate, watch parked PRs (Ctrl-C stops it)
26
30
  ai-dossier sched start --once # a single reconcile+refill tick (cron-style)
27
- ai-dossier sched status # queue, slots (pid/phase/last-progress), batches, blocked/failed
31
+ ai-dossier sched status # queue (+pr/cleanup), parked PRs, slots, batches, blocked/failed
28
32
  ai-dossier sched pause # stop NEW assignments; live units keep running
29
33
  ai-dossier sched resume
30
34
  ai-dossier sched abandon --issue 42 --reason "operator abort"
@@ -82,6 +86,77 @@ Two engine-safety policies were explicit product decisions on #464:
82
86
  Only `issue:<n>` units are dispatched today — batch member sequencing is a follow-up
83
87
  (#464 non-goal).
84
88
 
89
+ ## Batch failure recovery (#472)
90
+
91
+ What happens when a batch's aggregate suite goes red, or its PR will not merge
92
+ (RFC-0001 §F.2/F.8/F.9).
93
+
94
+ **Not yet wired into `sched start`** — `tick()` still dispatches only `issue:<n>` units
95
+ (the batch execution loop is a follow-up, see above). These modules are the library
96
+ surface that loop will call, and they are tested standalone against real scratch repos.
97
+
98
+ ```
99
+ validating → attributing → fixing (ONE bounded attempt) → validating
100
+ → evicting (revert the member's commits) → validating
101
+ > ⅓ of members evicted, or a revert conflict → dissolving → members requeued
102
+ awaiting-merge (CONFLICTING | auto-merge-blocked)
103
+ → rebasing → re-validating → shipping
104
+ → (2nd occurrence) dissolving into two half-batches
105
+ ```
106
+
107
+ 1. **Attribution (AC1)** — `attributeByOverlap` maps each failing test to a member by
108
+ focused-test match, then by changed-path overlap. Exactly one candidate attributes;
109
+ more than one is AMBIGUOUS and none is UNATTRIBUTED — neither is ever guessed. When
110
+ the caller supplies a `BisectSpec`, both go to `runAttributionBisect`: a real
111
+ `git bisect run` over the branch's `good..bad` range executing ONLY the failing tests,
112
+ whose first-bad commit is mapped to a member through the `(#N)` subject trailer on the
113
+ branch's issue-boundary commits (every unresolved test is then attributed to that
114
+ member). A first-bad commit with no trailer, or an abbreviated sha matching two
115
+ commits, reports `unattributable` rather than blaming a neighbour. The bisect refuses
116
+ to run at all unless the test command actually discriminates — it must fail at `bad`
117
+ AND pass at `good`, so a missing runner cannot silently convict the earliest member —
118
+ and it always resets the checkout to where it found it. Without a `BisectSpec`,
119
+ overlap is the whole verdict and unresolved tests stay unattributed.
120
+ 2. **One bounded fix attempt (AC2)** — `beginFixAttempt` returns the mid-tier command and
121
+ prompt for the CALLER to spawn (sched never invokes an LLM) and records the attempt.
122
+ A second call for the same member returns `null`: the next step is eviction, so a
123
+ batch cannot burn its budget on one broken member.
124
+ 3. **Eviction (AC2)** — `evictMembers` reverts the member's commits newest-first across
125
+ members (an eviction group reverts together), requeues it as full-cycle with
126
+ `failure_evidence` attached (batch, reason, failing tests, attribution method, reverted
127
+ commits), re-runs the suite and checks the dissolve trigger. A conflicting revert is
128
+ aborted so the worktree is clean and the batch dissolves — the reverts that already
129
+ landed ride along on the abandoned branch, which is why it is abandoned rather than
130
+ reused. An eviction group that reaches an already-shipped member dissolves instead of
131
+ reverting merged work.
132
+ 4. **Dissolve (AC3)** — `dissolveBatch` marks the batch `dissolved` and requeues every
133
+ UNSHIPPED member: `full` (each as its own full-cycle run) or `halved` (one or two fresh
134
+ `forming` half-batches — a single remaining member yields one — entries retagged,
135
+ eviction groups inherited where they survive the split). Shipped and terminal members
136
+ keep their outcome; nothing green is discarded, and no git runs — the batch branch is
137
+ simply left behind unmerged, since sched deletes nothing.
138
+ 5. **PR conflict (AC4)** — `handlePrConflict` rebases the batch branch, re-runs the suite
139
+ and re-ships ONCE. A second occurrence, a conflicting rebase, a failed fetch, an
140
+ unusable `base_branch`, a checkout that is not on the batch branch, or a red suite
141
+ after a clean rebase dissolves into two half-batches.
142
+ 6. **Milestones (AC5)** — every eviction and dissolve posts a `batch-validate` /
143
+ `batch-ship` milestone to the batch ANCHOR issue via `ai-dossier runstate post`, with
144
+ the reason, the evicted/requeued/preserved members and the attribution method (a
145
+ successful re-ship posts `batch-ship awaiting-merge`); each per-member outcome is
146
+ journaled and kept in the batch's `evictions` (the classifier feedback signal). A batch
147
+ with no `anchor` or no `run_id` cannot post — the CLI requires both — so the milestone
148
+ it could not post is journaled in full instead of vanishing.
149
+
150
+ Ten journal events carry the detail: `suite-failed`, `attributed`, `fix-dispatched`,
151
+ `fix-resolved`, `member-evicted`, `revert-conflict`, `batch-rebased`, `batch-dissolved`,
152
+ `batch-split` and `milestone-post-failed`, plus `git-failed` for any git command that
153
+ returned non-zero (the injected `ExecFn` collapses every git failure into `null`, so the
154
+ command that produced one is always recorded).
155
+
156
+ Schema 1.3.0 carries the new state: `BatchEntry` gains `anchor`, `branch`, `run_id`,
157
+ `eviction_groups`, `evictions`, `fix_attempts` and `rebase_attempts`; `QueueEntry` gains
158
+ `failure_evidence`. 1.2.0 states migrate on load.
159
+
85
160
  ## API surface
86
161
 
87
162
  ```ts
@@ -91,13 +166,46 @@ import {
91
166
  parseManifest, // batch-prep JSON → EnqueueInput[]
92
167
  computeAssignments, // pure: fill idle slots with runnable units, bounded by max_slots
93
168
  runnableUnits, // pure: which units may run right now (dep-gated)
94
- tick, // one engine cycle: reconcile + verify + refill + spawn
169
+ tick, // one engine cycle: reconcile + verify + refill + spawn,
170
+ // and since #468: park-watch, teardown, report dispatch
95
171
  runLoop, // the sched start loop (tick, sleep, repeat)
96
- type TickResult, // what one tick did (spawned/completed/redispatched/failed/blocked)
97
- type EngineDeps, // inject everything the engine touches (store/journal/spawn/ground truth/clock)
172
+ type TickResult, // what one tick did (spawned/parked/merge-accepted/report-dispatched/
173
+ // teardown/completed/redispatched/failed/blocked)
174
+ type EngineDeps, // inject everything the engine touches (store/journal/spawn/ground
175
+ // truth/clock/repoDir/teardownExec)
98
176
  createSpawnDeps, // real detached-spawn process I/O
99
- createExecGroundTruth, // runstate/gh/git ground truth via subprocesses (injectable exec)
100
- resolveDispatch, // config resolved command/prompt/tier-models/timers
177
+ createExecGroundTruth, // runstate/gh/git ground truth via subprocesses (injectable exec);
178
+ // since #468 also gh pr view PR state + setup info from comments
179
+ resolveDispatch, // config → resolved command/prompt/report-prompt/tier-models/timers
180
+ buildReportPrompt, // report-agent prompt ({issue}/{pr}/{cleanup} substituted)
181
+ reportTierFor, // report (re)dispatch tier after N escalations
182
+ isParkedMilestone, // ship-phase awaiting-merge + pr= → the park signal
183
+ prOfMilestone, // a milestone's pr= key as a positive integer
184
+ parsePrViewJson, // gh pr view --json → PR truth (mergedAt/mergeable/blocked label)
185
+ parseSetupInfo, // gh issue view --json comments → teardown inputs
186
+ runTeardown, // #468 script teardown for a merged unit (pool return / worktree remove)
187
+ isSafeWorktree, // worktree-path containment check (CWE-22)
188
+ TEARDOWN_TIMEOUT_MS, // teardown subprocess timeout (120 s)
189
+ attributeByOverlap, // #472 pure stage-1 attribution: failing tests → members
190
+ parseVitestJson, // vitest --reporter=json → failing tests
191
+ parseBoundaryCommits, // git log → issue-boundary commits via the (#N) trailer
192
+ memberRanges, // boundary commits → each member's commit list
193
+ runAttributionBisect, // stage-2: real git bisect over the failing tests only
194
+ beginAttribution, // validating → attributing (overlap, then bisect if needed)
195
+ beginFixAttempt, // the ONE bounded mid-tier fix dispatch instruction
196
+ resolveFixAttempt, // record its outcome, back to validating
197
+ evictMembers, // revert + requeue with evidence + suite re-run + dissolve check
198
+ checkDissolveTrigger, // pure: > ⅓ of members evicted
199
+ dissolveBatch, // full or halved dissolve; preserves everything green
200
+ handlePrConflict, // rebase + re-ship once, then dissolve into halves
201
+ createExecMilestonePoster, // batch milestones via `ai-dossier runstate post`
202
+ expandEvictionGroups, // members that must revert together (§E.4 eviction groups)
203
+ requeueMember, // the one requeue path abandon/evict/dissolve all take
204
+ isPreservedMember, // the single definition of "already green"
205
+ createBatch, // the single BatchEntry constructor
206
+ type RecoveryDeps, // inject exec/repoDir/journal/milestone-poster/suite-runner/clock
207
+ type SuiteRunner, // re-runs the aggregate suite after a revert or rebase
208
+ type BatchMilestonePoster, // batch-milestone sink (createExecMilestonePoster is default)
101
209
  Journal, // append-only events.jsonl
102
210
  transitionIssue, transitionBatch, transitionSlot, // typed §D transitions
103
211
  TRANSITIONS, // the transition tables themselves (for previews)
@@ -119,7 +227,8 @@ fake agents and stub ground truth; no LLM calls anywhere.
119
227
  ```
120
228
  ~/.dossier/sched/<project>/
121
229
  ├── state.json # hot operational truth — atomic tmp+fsync+rename writes;
122
- ├── config.json # durable intent: max_slots, stall_timeout_ms, reconcile_interval_ms, dispatch
230
+ ├── config.json # durable intent: max_slots, stall_timeout_ms, reconcile_interval_ms,
231
+ │ # pr_poll_interval_ms, dispatch (incl. report_prompt)
123
232
  ├── events.jsonl # append-only event journal (the operator's flight recorder)
124
233
  ├── runs/ # per-unit agent output logs (issue-<n>.log)
125
234
  └── .sched-lock/ # cross-process directory mutex (pid; stolen from dead holders)
@@ -133,14 +242,65 @@ fake agents and stub ground truth; no LLM calls anywhere.
133
242
  - **Corrupt state is loud**: `load()` throws `CorruptStateError` naming the file —
134
243
  never a silent queue reset. `state.json` is deletable and rebuildable from GitHub,
135
244
  which remains the system of record.
136
- - **Schema**: state/config files from #460 (schema 1.0.0) load and migrate to 1.1.0
137
- automatically (slot `branch`/`last_head` backfill to null).
245
+ - **Schema**: state/config files from #460 (schema 1.0.0), #464 (1.1.0) and #468 (1.2.0)
246
+ load and migrate to 1.3.0 automatically (slot `branch`/`last_head`/`pid_start`, entry
247
+ `pr`/`cleanup`/`failure_evidence`, batch `anchor`/`branch`/`run_id`/`eviction_groups`/
248
+ `evictions`/`fix_attempts`/`rebase_attempts`,
249
+ and state-level `last_pr_poll_at` backfill to null).
138
250
  - **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
139
251
  edges gate readiness — an issue with an unmerged dependency, and a batch behind an
140
252
  unmerged batch, are never runnable.
141
253
  - **Pause** stops new assignments only; abandon routes through the typed failure rails
142
254
  (`evicted → requeued{full}` for batch members — nothing green is discarded).
143
255
 
256
+ ## The PR watcher + tail work (#468)
257
+
258
+ Dispatched runs park their PR on `auto-merge` (detached ship mode — the default
259
+ prompt instructs it) and exit. The engine owns everything after the park:
260
+
261
+ 1. **Park detection (AC1)** — an agent exit whose latest milestone is the ship
262
+ phase's `awaiting-merge` (with `pr=`) is a VERIFIED park, not an unverified
263
+ exit: the entry moves to `parked`, the slot is released (a waiting unit
264
+ consumes zero slots), and the watcher takes over.
265
+ 2. **PR watching (AC1)** — parked PRs are polled every `pr_poll_interval_ms`
266
+ (default 150 s — "every 2–3 min", persisted `last_pr_poll_at` so a restart
267
+ honors the cadence; checked on each reconcile tick when due, so a
268
+ `reconcile_interval_ms` longer than the interval slows the effective cadence)
269
+ via `gh pr view --json state,mergedAt,mergeable,labels`.
270
+ A merge is accepted only when state is MERGED **and** `mergedAt` is non-null
271
+ **and** the issue is closed — never inferred from an agent exit. An
272
+ unreachable poll pauses the watcher (decision 2, option A).
273
+ 3. **Failure states (AC3)** — `CONFLICTING`, closed-unmerged, or the
274
+ `auto-merge-blocked` label fail the unit with the reason and block its
275
+ TRANSITIVE dependents. The engine never merges anything itself.
276
+ 4. **Gating on MERGE, not park (AC4)** — `parked` is not a satisfied status:
277
+ dependents stay blocked until the merge lands (`parked → shipped`).
278
+ 5. **Teardown as a script (AC2)** — on merge, the run's setup milestone
279
+ (recovered once from the issue's comments — collaborator-authored only, and
280
+ the worktree path must pass a containment check before any destructive
281
+ subprocess) chooses the script: pool-claimed worktrees run
282
+ `worktree-pool return --path <wt> --json` (the pool's own self-check is the
283
+ verification); cold worktrees run
284
+ `git worktree remove --force <wt>` with a path-gone check. Both are
285
+ verify-first idempotent; a failed step records `cleanup=failed-<step>` on
286
+ the entry and in the journal — degradation, never unit failure.
287
+ 6. **Report dispatch (AC2)** — once teardown is recorded (when a slot is
288
+ free — a waiting report consumes zero slots), a **mechanical-tier** report
289
+ agent is spawned with the report-phase prompt (`dispatch.report_prompt`;
290
+ `{issue}`/`{pr}`/`{cleanup}` substituted — the cleanup status rides into
291
+ the report). It completes like any agent; a report that stalls climbs the
292
+ same ladder (mechanical → mid → strong, cap 2), and at the cap the unit
293
+ completes (`done`, reason `report-escalation-cap`) with a `report-failed`
294
+ journal event — the work is merged, so dependents are never re-blocked.
295
+ The full-cycle tail-run pattern (re-dispatching a whole run for
296
+ teardown+report) is retired.
297
+
298
+ `sched status` shows parked PRs (zero slots, with the last poll's age), a
299
+ `pr` column and a `cleanup` column on the queue; every watcher decision lands
300
+ in `events.jsonl` (`pr-parked`, `merge-accepted`, `pr-watch-failed`,
301
+ `pr-watch-waiting`, `teardown-done`/`teardown-failed`, `report-dispatched`,
302
+ `report-failed`, `ground-truth-unreachable`).
303
+
144
304
  ## Development
145
305
 
146
306
  ```bash
@@ -152,4 +312,6 @@ npm test # vitest — state machines, persistence, crash/restart, engine
152
312
 
153
313
  No network, no GitHub in unit tests — persistence tests run on temp directories; the
154
314
  integration tests spawn fake-agent fixtures against a scratch git repo with stubbed
155
- ground truth.
315
+ ground truth. The #468 integration tests run the full detached-ship tail
316
+ (park → watch → merge → REAL worktree removal → report) end-to-end, including a
317
+ sched restart mid-watch.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Failure attribution, stage 1 (#472 AC1): map failing tests to batch members
3
+ * without an LLM, using only what the batch run already recorded.
4
+ *
5
+ * Two overlap signals, in order of confidence:
6
+ * 1. **focused tests** — the member's own plan/implement phase named these
7
+ * test files; a failure in one of them is that member's.
8
+ * 2. **changed paths** — the failing test file was changed by the member, or
9
+ * sits under a directory the member changed (the `src/x.ts` →
10
+ * `src/__tests__/x.test.ts` relationship, expressed structurally rather
11
+ * than by naming convention).
12
+ *
13
+ * A test with exactly one candidate member is ATTRIBUTED; more than one is
14
+ * AMBIGUOUS and none is UNATTRIBUTED. Both of those go to stage 2 (`bisect.ts`)
15
+ * — this module never guesses, because a wrong attribution evicts innocent
16
+ * work. Everything here is pure; `recovery.ts` owns the effects.
17
+ */
18
+ /** One failing test, as the aggregate suite reported it. */
19
+ export interface FailingTest {
20
+ /** Test file path, repo-relative where the runner reports it that way. */
21
+ file: string;
22
+ /** Full test name (describe path + title). */
23
+ name: string;
24
+ /** Stable id used in evidence and journal lines: `<file>::<name>`. */
25
+ id: string;
26
+ }
27
+ /** What one batch member touched — the attribution surface, recorded as it ran. */
28
+ export interface MemberFootprint {
29
+ issue: number;
30
+ /** Repo-relative paths the member's commits changed. */
31
+ changedPaths: string[];
32
+ /** Test files the member's own phases ran focused (strongest signal). */
33
+ focusedTests: string[];
34
+ }
35
+ /** A failing test that could belong to more than one member. */
36
+ export interface AmbiguousTest {
37
+ test: FailingTest;
38
+ /** Member issue numbers that all overlap it, ascending. */
39
+ candidates: number[];
40
+ }
41
+ /** Stage-1 attribution verdict for one suite run. */
42
+ export interface OverlapAttribution {
43
+ /** Member issue → the failing tests uniquely attributed to it. */
44
+ attributed: Map<number, FailingTest[]>;
45
+ /** Tests overlapping several members — bisect decides. */
46
+ ambiguous: AmbiguousTest[];
47
+ /** Tests overlapping no member at all — bisect decides. */
48
+ unattributed: FailingTest[];
49
+ }
50
+ /** Build a `FailingTest` with the derived id, so the id format exists once. */
51
+ export declare function failingTest(file: string, name: string): FailingTest;
52
+ /**
53
+ * Stage-1 attribution: overlap only, no git, no LLM (AC1). Deterministic —
54
+ * candidates are compared in ascending issue order so the same inputs always
55
+ * produce the same verdict.
56
+ */
57
+ export declare function attributeByOverlap(failing: readonly FailingTest[], footprints: readonly MemberFootprint[]): OverlapAttribution;
58
+ /** Members with at least one failing test attributed to them, ascending. */
59
+ export declare function offendersOf(attributed: ReadonlyMap<number, unknown>): number[];
60
+ /**
61
+ * Failing tests from `vitest run --reporter=json` output. Tolerates the
62
+ * surrounding noise a real run prints around the JSON document (vitest writes
63
+ * the report to stdout alongside its own banner), and skips any record it
64
+ * cannot read rather than throwing — an unparseable suite report degrades to
65
+ * "no attributable tests", which routes to bisect, never to a guess.
66
+ */
67
+ export declare function parseVitestJson(stdout: string | null): FailingTest[];
68
+ /** One commit on the batch branch, with the member it belongs to. */
69
+ export interface BoundaryCommit {
70
+ sha: string;
71
+ subject: string;
72
+ /** Member issue from the `(#N)` subject trailer; null when the commit has none. */
73
+ issue: number | null;
74
+ }
75
+ /**
76
+ * A full or abbreviated sha as git prints it — 7 to 40 hex characters. Bisect
77
+ * endpoints and revert argv are validated against this before they become git
78
+ * arguments (CWE-88).
79
+ */
80
+ export declare const SHA_RE: RegExp;
81
+ /**
82
+ * A git ref name safe to interpolate into argv (CWE-88) — the pattern
83
+ * `groundtruth.ts` established for branch names off untrusted milestone text.
84
+ * Defined here, beside `SHA_RE`, so the package has one "validate before it
85
+ * becomes a git argument" home rather than a copy per module.
86
+ */
87
+ export declare const SAFE_REF_RE: RegExp;
88
+ /**
89
+ * Parse `git log --reverse --format=%H%x09%s <base>..<head>` — OLDEST FIRST,
90
+ * which is the order eviction ranges and bisect boundaries are built in.
91
+ * Malformed lines are skipped; a line whose subject has no `(#N)` trailer
92
+ * still yields a commit, with `issue: null`.
93
+ */
94
+ export declare function parseBoundaryCommits(gitLogOutput: string | null): BoundaryCommit[];
95
+ /** The commits one member contributed to the batch branch, oldest first. */
96
+ export interface MemberRange {
97
+ issue: number;
98
+ /** Oldest commit of the member. */
99
+ from: string;
100
+ /** Newest commit of the member. */
101
+ to: string;
102
+ /** Every commit of the member, oldest first. */
103
+ commits: string[];
104
+ /**
105
+ * Each commit's index in the boundary list, parallel to `commits`. Grouping
106
+ * by member loses branch order; eviction needs it back to revert newest-first
107
+ * ACROSS members (see `memberRanges`).
108
+ */
109
+ positions: number[];
110
+ }
111
+ /**
112
+ * Group boundary commits (oldest first) by member.
113
+ *
114
+ * `from`/`to` describe the member's span for reporting; `commits` is what a
115
+ * revert actually walks. They differ when members interleave — reverting the
116
+ * literal `from^..to` range would then revert a neighbour's commits too, so
117
+ * eviction reverts the explicit commits instead, ordered by `positions`:
118
+ * for a branch `A1 B1 A2 B2`, evicting both members must revert
119
+ * `B2 A2 B1 A1`, not each member's commits as a block.
120
+ */
121
+ export declare function memberRanges(boundary: readonly BoundaryCommit[]): MemberRange[];
122
+ /**
123
+ * The member owning `sha`, or null when the commit carries no `(#N)` trailer.
124
+ *
125
+ * Either side may be abbreviated (bisect reports a short sha; boundary commits
126
+ * are full), so matching is prefix-based — but an AMBIGUOUS prefix returns null
127
+ * rather than the first hit. Attribution feeds eviction, and eviction reverts
128
+ * work: a wrong answer here destroys an innocent member's commits, so "cannot
129
+ * tell" must never collapse into "probably this one".
130
+ */
131
+ export declare function memberOfCommit(boundary: readonly BoundaryCommit[], sha: string): number | null;
132
+ //# sourceMappingURL=attribution.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attribution.d.ts","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,4DAA4D;AAC5D,MAAM,WAAW,WAAW;IAC1B,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,mFAAmF;AACnF,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,yEAAyE;IACzE,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,gEAAgE;AAChE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,WAAW,CAAC;IAClB,2DAA2D;IAC3D,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,qDAAqD;AACrD,MAAM,WAAW,kBAAkB;IACjC,kEAAkE;IAClE,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;IACvC,0DAA0D;IAC1D,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,2DAA2D;IAC3D,YAAY,EAAE,WAAW,EAAE,CAAC;CAC7B;AAED,+EAA+E;AAC/E,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,CAEnE;AAqCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,SAAS,WAAW,EAAE,EAC/B,UAAU,EAAE,SAAS,eAAe,EAAE,GACrC,kBAAkB,CAwBpB;AAED,4EAA4E;AAC5E,wBAAgB,WAAW,CAAC,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAE9E;AAID;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,WAAW,EAAE,CA0BpE;AAiCD,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,mFAAmF;IACnF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AASD;;;;GAIG;AACH,eAAO,MAAM,MAAM,QAAsB,CAAC;AAE1C;;;;;GAKG;AACH,eAAO,MAAM,WAAW,QAAkC,CAAC;AAE3D;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,cAAc,EAAE,CAalF;AAED,4EAA4E;AAC5E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,gDAAgD;IAChD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,GAAG,WAAW,EAAE,CAgB/E;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAS9F"}
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ /**
3
+ * Failure attribution, stage 1 (#472 AC1): map failing tests to batch members
4
+ * without an LLM, using only what the batch run already recorded.
5
+ *
6
+ * Two overlap signals, in order of confidence:
7
+ * 1. **focused tests** — the member's own plan/implement phase named these
8
+ * test files; a failure in one of them is that member's.
9
+ * 2. **changed paths** — the failing test file was changed by the member, or
10
+ * sits under a directory the member changed (the `src/x.ts` →
11
+ * `src/__tests__/x.test.ts` relationship, expressed structurally rather
12
+ * than by naming convention).
13
+ *
14
+ * A test with exactly one candidate member is ATTRIBUTED; more than one is
15
+ * AMBIGUOUS and none is UNATTRIBUTED. Both of those go to stage 2 (`bisect.ts`)
16
+ * — this module never guesses, because a wrong attribution evicts innocent
17
+ * work. Everything here is pure; `recovery.ts` owns the effects.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.SAFE_REF_RE = exports.SHA_RE = void 0;
21
+ exports.failingTest = failingTest;
22
+ exports.attributeByOverlap = attributeByOverlap;
23
+ exports.offendersOf = offendersOf;
24
+ exports.parseVitestJson = parseVitestJson;
25
+ exports.parseBoundaryCommits = parseBoundaryCommits;
26
+ exports.memberRanges = memberRanges;
27
+ exports.memberOfCommit = memberOfCommit;
28
+ /** Build a `FailingTest` with the derived id, so the id format exists once. */
29
+ function failingTest(file, name) {
30
+ return { file, name, id: `${file}::${name}` };
31
+ }
32
+ /** Normalize a path for comparison: strip `./`, collapse `\` to `/`, drop a trailing `/`. */
33
+ function normalizePath(p) {
34
+ const slashed = p.replaceAll('\\', '/').replace(/^\.\//, '');
35
+ return slashed.endsWith('/') ? slashed.slice(0, -1) : slashed;
36
+ }
37
+ /** The directory part of a path, or `''` for a top-level file. */
38
+ function dirOf(p) {
39
+ const idx = p.lastIndexOf('/');
40
+ return idx === -1 ? '' : p.slice(0, idx);
41
+ }
42
+ /** Whether `member` explicitly ran `normalizedFile` focused — the strongest signal. */
43
+ function focusesOn(member, normalizedFile) {
44
+ return member.focusedTests.some((t) => normalizePath(t) === normalizedFile);
45
+ }
46
+ /**
47
+ * Whether `member` overlaps `testFile`. Focused tests win outright; otherwise
48
+ * the file must be one the member changed, or live under a directory it
49
+ * changed. A member that changed only top-level files (dir `''`) never matches
50
+ * by directory — an empty prefix would otherwise own the entire repo.
51
+ */
52
+ function overlaps(member, testFile) {
53
+ const file = normalizePath(testFile);
54
+ if (focusesOn(member, file))
55
+ return true;
56
+ for (const raw of member.changedPaths) {
57
+ const changed = normalizePath(raw);
58
+ if (changed === file)
59
+ return true;
60
+ const dir = dirOf(changed);
61
+ if (dir !== '' && file.startsWith(`${dir}/`))
62
+ return true;
63
+ }
64
+ return false;
65
+ }
66
+ /**
67
+ * Stage-1 attribution: overlap only, no git, no LLM (AC1). Deterministic —
68
+ * candidates are compared in ascending issue order so the same inputs always
69
+ * produce the same verdict.
70
+ */
71
+ function attributeByOverlap(failing, footprints) {
72
+ const attributed = new Map();
73
+ const ambiguous = [];
74
+ const unattributed = [];
75
+ const members = [...footprints].sort((a, b) => a.issue - b.issue);
76
+ for (const test of failing) {
77
+ // A focused-test match is the stronger signal: when any member claims the
78
+ // file explicitly, members that merely changed a neighbouring path are not
79
+ // candidates — otherwise the strong signal could never break a tie.
80
+ const file = normalizePath(test.file);
81
+ const focused = members.filter((m) => focusesOn(m, file));
82
+ const candidates = focused.length > 0 ? focused : members.filter((m) => overlaps(m, test.file));
83
+ if (candidates.length === 1) {
84
+ const issue = candidates[0].issue;
85
+ attributed.set(issue, [...(attributed.get(issue) ?? []), test]);
86
+ }
87
+ else if (candidates.length > 1) {
88
+ ambiguous.push({ test, candidates: candidates.map((m) => m.issue) });
89
+ }
90
+ else {
91
+ unattributed.push(test);
92
+ }
93
+ }
94
+ return { attributed, ambiguous, unattributed };
95
+ }
96
+ /** Members with at least one failing test attributed to them, ascending. */
97
+ function offendersOf(attributed) {
98
+ return [...attributed.keys()].sort((a, b) => a - b);
99
+ }
100
+ // --- Parsers ---
101
+ /**
102
+ * Failing tests from `vitest run --reporter=json` output. Tolerates the
103
+ * surrounding noise a real run prints around the JSON document (vitest writes
104
+ * the report to stdout alongside its own banner), and skips any record it
105
+ * cannot read rather than throwing — an unparseable suite report degrades to
106
+ * "no attributable tests", which routes to bisect, never to a guess.
107
+ */
108
+ function parseVitestJson(stdout) {
109
+ if (stdout === null)
110
+ return [];
111
+ const parsed = extractJsonObject(stdout);
112
+ if (parsed === null)
113
+ return [];
114
+ const results = parsed.testResults;
115
+ if (!Array.isArray(results))
116
+ return [];
117
+ const out = [];
118
+ for (const raw of results) {
119
+ if (raw === null || typeof raw !== 'object')
120
+ continue;
121
+ const suite = raw;
122
+ const file = typeof suite.name === 'string' ? suite.name : null;
123
+ if (file === null || !Array.isArray(suite.assertionResults))
124
+ continue;
125
+ for (const rawAssertion of suite.assertionResults) {
126
+ if (rawAssertion === null || typeof rawAssertion !== 'object')
127
+ continue;
128
+ const assertion = rawAssertion;
129
+ if (assertion.status !== 'failed')
130
+ continue;
131
+ const name = typeof assertion.fullName === 'string'
132
+ ? assertion.fullName
133
+ : typeof assertion.title === 'string'
134
+ ? assertion.title
135
+ : '';
136
+ out.push(failingTest(file, name));
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+ /** The first balanced `{...}` document in `text`, parsed; null when there is none. */
142
+ function extractJsonObject(text) {
143
+ const start = text.indexOf('{');
144
+ if (start === -1)
145
+ return null;
146
+ let depth = 0;
147
+ let inString = false;
148
+ let escaped = false;
149
+ for (let i = start; i < text.length; i++) {
150
+ const ch = text[i];
151
+ if (inString) {
152
+ if (escaped)
153
+ escaped = false;
154
+ else if (ch === '\\')
155
+ escaped = true;
156
+ else if (ch === '"')
157
+ inString = false;
158
+ continue;
159
+ }
160
+ if (ch === '"')
161
+ inString = true;
162
+ else if (ch === '{')
163
+ depth++;
164
+ else if (ch === '}') {
165
+ depth--;
166
+ if (depth === 0) {
167
+ try {
168
+ return JSON.parse(text.slice(start, i + 1));
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+ /**
179
+ * The established commit ↔ member key: the `(#N)` trailer a squash-merge (and
180
+ * this project's commit convention) leaves at the end of the subject. A commit
181
+ * WITHOUT one maps to `issue: null` — unattributable, never a guess.
182
+ */
183
+ const ISSUE_TRAILER_RE = /\(#(\d+)\)\s*$/;
184
+ /**
185
+ * A full or abbreviated sha as git prints it — 7 to 40 hex characters. Bisect
186
+ * endpoints and revert argv are validated against this before they become git
187
+ * arguments (CWE-88).
188
+ */
189
+ exports.SHA_RE = /^[0-9a-f]{7,40}$/i;
190
+ /**
191
+ * A git ref name safe to interpolate into argv (CWE-88) — the pattern
192
+ * `groundtruth.ts` established for branch names off untrusted milestone text.
193
+ * Defined here, beside `SHA_RE`, so the package has one "validate before it
194
+ * becomes a git argument" home rather than a copy per module.
195
+ */
196
+ exports.SAFE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
197
+ /**
198
+ * Parse `git log --reverse --format=%H%x09%s <base>..<head>` — OLDEST FIRST,
199
+ * which is the order eviction ranges and bisect boundaries are built in.
200
+ * Malformed lines are skipped; a line whose subject has no `(#N)` trailer
201
+ * still yields a commit, with `issue: null`.
202
+ */
203
+ function parseBoundaryCommits(gitLogOutput) {
204
+ if (gitLogOutput === null)
205
+ return [];
206
+ const out = [];
207
+ for (const line of gitLogOutput.split('\n')) {
208
+ if (line.trim() === '')
209
+ continue;
210
+ const tab = line.indexOf('\t');
211
+ const sha = (tab === -1 ? line : line.slice(0, tab)).trim();
212
+ if (!exports.SHA_RE.test(sha))
213
+ continue;
214
+ const subject = tab === -1 ? '' : line.slice(tab + 1);
215
+ const match = ISSUE_TRAILER_RE.exec(subject);
216
+ out.push({ sha, subject, issue: match ? Number.parseInt(match[1], 10) : null });
217
+ }
218
+ return out;
219
+ }
220
+ /**
221
+ * Group boundary commits (oldest first) by member.
222
+ *
223
+ * `from`/`to` describe the member's span for reporting; `commits` is what a
224
+ * revert actually walks. They differ when members interleave — reverting the
225
+ * literal `from^..to` range would then revert a neighbour's commits too, so
226
+ * eviction reverts the explicit commits instead, ordered by `positions`:
227
+ * for a branch `A1 B1 A2 B2`, evicting both members must revert
228
+ * `B2 A2 B1 A1`, not each member's commits as a block.
229
+ */
230
+ function memberRanges(boundary) {
231
+ const byIssue = new Map();
232
+ boundary.forEach((commit, position) => {
233
+ if (commit.issue === null)
234
+ return;
235
+ const group = byIssue.get(commit.issue) ?? { commits: [], positions: [] };
236
+ group.commits.push(commit.sha);
237
+ group.positions.push(position);
238
+ byIssue.set(commit.issue, group);
239
+ });
240
+ return [...byIssue.entries()].map(([issue, group]) => ({
241
+ issue,
242
+ from: group.commits[0],
243
+ to: group.commits[group.commits.length - 1],
244
+ commits: group.commits,
245
+ positions: group.positions,
246
+ }));
247
+ }
248
+ /**
249
+ * The member owning `sha`, or null when the commit carries no `(#N)` trailer.
250
+ *
251
+ * Either side may be abbreviated (bisect reports a short sha; boundary commits
252
+ * are full), so matching is prefix-based — but an AMBIGUOUS prefix returns null
253
+ * rather than the first hit. Attribution feeds eviction, and eviction reverts
254
+ * work: a wrong answer here destroys an innocent member's commits, so "cannot
255
+ * tell" must never collapse into "probably this one".
256
+ */
257
+ function memberOfCommit(boundary, sha) {
258
+ const needle = sha.toLowerCase();
259
+ const matches = boundary.filter((c) => {
260
+ const candidate = c.sha.toLowerCase();
261
+ return candidate.startsWith(needle) || needle.startsWith(candidate);
262
+ });
263
+ const issues = new Set(matches.map((m) => m.issue));
264
+ if (matches.length === 0 || issues.size > 1)
265
+ return null;
266
+ return matches[0].issue;
267
+ }
268
+ //# sourceMappingURL=attribution.js.map