@ai-dossier/sched 0.23.2 → 0.24.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 CHANGED
@@ -146,7 +146,7 @@ where every mechanical supervision decision is code, not remembered prose:
146
146
  itself — see `slot-released` below (#525).
147
147
  6. **Journal (AC6)** — every event (assigned, spawned, exit-detected, external-advance,
148
148
  progress, stalled, redispatched, fence-written, fence-failed, unit-failed,
149
- dependents-blocked, slot-released, suspect-dispatch, dispatch-unhealthy,
149
+ dependents-blocked, slot-released, suspect-dispatch, dispatch-unhealthy, dispatch-failure,
150
150
  run-log-recorded, run-log-no-usage, run-log-skipped, run-log-failed, engine-stale,
151
151
  engine-auto-upgrade-attempted, engine-auto-upgrade-failed, stale-milestone-ignored, …) is
152
152
  appended to `events.jsonl`; `sched status` shows the live phase per unit, plus each
@@ -182,6 +182,33 @@ where every mechanical supervision decision is code, not remembered prose:
182
182
  a wall that hasn't actually cleared), which also clears the streak so `sched status`'s
183
183
  warning doesn't linger against a wall the operator already acted on.
184
184
 
185
+ **Confirmed dispatch failures (#629)** are a SEPARATE, deterministic signal alongside
186
+ the timing heuristic above: a dispatch result carrying `api_error_status` or
187
+ `terminal_reason: "api_error"` (`parseDispatchApiError`, `@ai-dossier/core`) is a
188
+ confirmed provider wall — a 429 spend/rate limit, an auth failure the provider itself
189
+ rejected — never an agent that ran, journaled **`dispatch-failure`** with the
190
+ provider's own status/reason/message and reset time (when supplied). Unlike
191
+ `suspect-dispatch`, a repeat from the SAME unit COUNTS toward its own
192
+ `consecutive_dispatch_api_errors` counter — no cross-unit correlation is needed when
193
+ the classification is a parsed field rather than a timing inference, and the incident
194
+ that motivated this (a batch tail respawning nine times in 33 minutes) was the SAME
195
+ unit throughout. At the same `DISPATCH_UNHEALTHY_THRESHOLD` it reuses `setPaused`/
196
+ `dispatch-unhealthy`. The redispatch itself does NOT escalate — same tier,
197
+ `recoveries` unchanged, `ESCALATION_CAP` never consumed, since a spend wall is not the
198
+ issue's fault — so once paused, BOTH the per-issue rail (`enterRecovery`/
199
+ `reconcileRecovering`, held in `recovering` until `sched resume`) and every batch
200
+ respawn wedge in `runBatchTick` (tail, member continuation, fix, report, and the
201
+ `ready` → `claimAndSetup` claim — `runValidate`'s local suite run is unaffected) stop
202
+ respawning into the wall, which previously ignored `paused` entirely. `dispatch-
203
+ health.ts` is its own module (not `engine.ts`) specifically so it is shared by BOTH
204
+ dispatch paths without `engine.ts` and `batch-dispatch.ts` importing each other.
205
+ Batch dispatch logs (per-role, append-mode) are fenced to `log_offset_at_spawn`,
206
+ stamped by each batch spawn function — a later dispatch that dies with NO result event
207
+ at all is never misclassified against a stale `api_error` result left in the same log
208
+ file by an earlier attempt. A verified completion/park resets the streak on the
209
+ per-issue path; a successful member/tail/report resets it on the batch path; `sched
210
+ resume` resets it on both.
211
+
185
212
  Config schema moves to 1.4.0 (#527): `dispatch` gains `tiers` — a per-tier
186
213
  `{ command?, model?, prompt? }` spawn spec. `command`/`tier_models`/`prompt` remain valid
187
214
  as the shorthand and are the fallback for any field a `tiers` entry leaves unset, so a
@@ -232,7 +259,12 @@ Two engine-safety policies were explicit product decisions on #464:
232
259
  network down), stall and verify-fail decisions pause for that unit — an outage can
233
260
  never kill a healthy agent or fail a unit as "unverified". An agent that exits during
234
261
  an outage holds in `verifying` until truth returns. Each pause is journaled as
235
- `ground-truth-unreachable`.
262
+ `ground-truth-unreachable` — since #632 once per unbroken streak (carrying `since` and
263
+ `ticks_persisted`) rather than once per tick, re-announced every 20 ticks and re-armed
264
+ when truth recovers and fails anew, so an outage across `max_slots` units no longer
265
+ emits one line per unit per reconcile interval. Two sites are deliberately undeduped:
266
+ `failOrAdoptOpenPr`, which is terminal and so fires at most once per dispatch, and
267
+ `runTeardownFor`, which is audited but not yet fixed (#636).
236
268
  - **A completion milestone is fenced to its own dispatch (#575).** `isVerifiedComplete`
237
269
  (issues) and `isMemberComplete` (batch members) both accept the current dispatch's
238
270
  `SlotEntry.spawned_at` and reject a `report done` / `review done mode=slot` milestone
@@ -587,6 +619,39 @@ states migrate on load, backfilling `null` — nothing was ever recorded per-dis
587
619
  under the old once-per-tick behavior, so null is exact, not a guess. Resets with the
588
620
  slot on release (`CLEARED_SLOT_FIELDS`).
589
621
 
622
+ Schema 1.13.0 (#630): `BatchEntry` gains `pr_watch_failed_reason` (the reason already
623
+ journalled for the batch's current `pr-watch-failed` streak, null when the watch is
624
+ healthy), `pr_watch_failed_since` (ISO, when the streak began) and
625
+ `pr_watch_failed_ticks` (ticks the streak has persisted, silent ones included) — so
626
+ `pr-watch-failed` fires once per distinct condition rather than once per reconcile tick,
627
+ re-announcing every `JOURNAL_DEDUP_REANNOUNCE_TICKS` (20) ticks. 1.12.0 states migrate on
628
+ load, backfilling `null`/`null`/`0` — no dedup marker was ever recorded under the old
629
+ once-per-tick behavior, so it is exact, not a guess. Cleared whenever the batch leaves
630
+ `awaiting-merge` (`CLEARED_PR_WATCH_FIELDS`), since the marker is scoped to one
631
+ awaiting-merge stretch and a batch can return to that status on the same id via
632
+ rebase-and-reship.
633
+
634
+ Schema 1.14.0 (#632): `QueueEntry` gains `ground_truth_unreachable_since`/`_ticks` and
635
+ `pr_watch_waiting_since`/`_ticks` — the same dedup scoped to the issue rather than the
636
+ batch, because these sites span slot-held, parked and stale-failed units alike and
637
+ `QueueEntry` is the one record every unit has either way. 1.13.0 and earlier states
638
+ migrate on load, backfilling `null`/`0` — exact, not a guess. Reset on every requeue
639
+ (`CLEARED_ENTRY_DEDUP_MARKERS`, applied by `requeueMember`,
640
+ `requeueOrphanedDispatches` and the label-cleared path): a requeue is a fresh attempt,
641
+ and a streak from the previous run must not silence this one's first occurrence. Written
642
+ with `touchUpdatedAt: false` — `QueueEntry.updated_at` is load-bearing for
643
+ `isStaleFailedPark`'s 7-day window, `status.ts`'s "parked since" and `readiness.ts`'s
644
+ tiebreak, and a silent dedup tick must not reset them. `patchBatch` takes the same flag
645
+ for the same reason on the batch rail.
646
+ Schema 1.13.0 (#629): `SchedState` gains `consecutive_dispatch_api_errors` (number) and
647
+ `dispatch_pause_reset_at` (string or null) — the confirmed-dispatch-failure streak and
648
+ the provider's own reset time. Unlike the #505 suspect-dispatch pair these are NOT a
649
+ single fact: a provider that reports no reset time leaves `dispatch_pause_reset_at`
650
+ null while the counter is nonzero (`validateState` only enforces the reverse — a reset
651
+ time can never outlive a streak that has already cleared to zero). 1.12.0 states
652
+ migrate on load, backfilling `0`/`null` — no confirmed dispatch failures were ever
653
+ tracked under them, so those values are exact, not a guess.
654
+
590
655
  New journal events: `batch-setup-done`, `batch-setup-failed`, `member-advanced`,
591
656
  `batch-warmup-done`, `batch-warmup-failed` (#561 — the cold-path warm step only; a pool
592
657
  claim emits neither). `gate-inconclusive` (#583 — the incremental gate came back
@@ -707,7 +772,7 @@ import {
707
772
  // so batch-dispatch.ts can share it.
708
773
  TRANSITIONS, // the transition tables themselves (for previews)
709
774
  buildStatusReport, // machine-readable status incl. blocked/failed sets
710
- validateState, // strict persisted-state validation (1.0.0-1.11.0 files migrate)
775
+ validateState, // strict persisted-state validation (1.0.0-1.13.0 files migrate)
711
776
  DEFAULT_ISSUE_PRIORITY, DEFAULT_BATCH_PRIORITY, // priority defaults (0 / 10, #565)
712
777
  IllegalTransitionError, EnqueueError, CorruptStateError, LockTimeoutError,
713
778
  SchedNotFoundError,
@@ -795,9 +860,10 @@ ladder is exhausted, the terminal `unit-failed` (`agent-exited-unverified` /
795
860
  the dead-pid detection rail and the stall kill RECORD a fresh log slice (a `runs.jsonl`
796
861
  entry) in the same tick — `recordDispatchRunLog` is once-per-dispatch and refuses to
797
862
  append a second entry over the same slice. Since #620 a slot already `exited`/`verifying`
798
- when reconciled again still ATTRIBUTES one: `readLastToolForSlot` re-parses the same
799
- static slice for the tool name alone, writing nothing, so it carries no exactly-once
800
- constraint. That matters because the verify decision lands on a LATER tick than the
863
+ when reconciled again still ATTRIBUTES one: `readDispatchSignalsForSlot` re-parses the
864
+ same static slice (since #629, for the last-tool-call AND API-error-classification
865
+ signals together — see the dispatch-health section above), writing nothing, so it
866
+ carries no exactly-once constraint. That matters because the verify decision lands on a LATER tick than the
801
867
  dead-pid detection whenever ground truth was unreachable in between — previously the
802
868
  tool name was simply lost on exactly the runs hardest to diagnose. The read is deferred
803
869
  behind a thunk so only the tick that reaches the unverified-exit decision pays for it,
@@ -871,7 +937,8 @@ after-the-fact recovery, not a missing-data bug.
871
937
  queue data.
872
938
  - **Schema**: state/config files from #460 (schema 1.0.0), #464 (1.1.0), #468 (1.2.0),
873
939
  #472 (1.3.0), #500 (1.4.0), #505 (1.5.0), #504 (1.6.0), #523 (1.7.0) and #524 (1.8.0)
874
- load and migrate to 1.12.0 automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
940
+ load and migrate to 1.14.0 automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
941
+ load and migrate to 1.13.0 automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
875
942
  unit's queue entry, with the persisted `phase` as a fallback — #500), entry
876
943
  `pr`/`cleanup`/`failure_evidence`, batch `anchor`/`branch`/`run_id`/`eviction_groups`/
877
944
  `evictions`/`fix_attempts`/`rebase_attempts`, state-level `last_pr_poll_at` backfill to
@@ -879,16 +946,22 @@ after-the-fact recovery, not a missing-data bug.
879
946
  backfill to `0`/`null` — #505, slot `gen`/`fenced_at` backfill to `0`/`null` — #504, and
880
947
  slot `spawned_at`/`log_offset_at_spawn` backfill to `null`/`null` — #524,
881
948
  state-level `last_label_poll_at` backfill to `null` — #544, entry `priority` and batch
882
- `member_gates`/`blocked_reason` — #565/#583, and slot
883
- `stale_milestone_ignored_for` backfill to `null` — #610).
949
+ `member_gates`/`blocked_reason` — #565/#583, slot
950
+ `stale_milestone_ignored_for` backfill to `null` — #610, and state-level
951
+ `consecutive_dispatch_api_errors`/`dispatch_pause_reset_at` backfill to `0`/`null` —
952
+ #629).
884
953
  - **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
885
954
  edges gate readiness — an issue with an unmerged dependency, and a batch behind an
886
955
  unmerged batch, are never runnable.
887
- - **Pause** stops new assignments only (including report-agent dispatch — #505); abandon
888
- routes through the typed failure rails (`evicted → requeued{full}` for batch members —
889
- nothing green is discarded). A pause can be manual (`sched pause`) or automatic
890
- (dispatch-health, #505 above); `sched resume` clears both the flag and the
891
- dispatch-health streak.
956
+ - **Pause** stops new assignments only (including report-agent dispatch — #505) and,
957
+ since #629, every batch respawn wedge in `runBatchTick` — tail, member continuation,
958
+ fix, report, and the `ready` → `claimAndSetup` claim, which previously ignored `paused`
959
+ entirely (`runValidate` is excepted: a local suite run, not a dispatch) — and an
960
+ unescalated per-issue redispatch (`enterRecovery`/`reconcileRecovering`, held in
961
+ `recovering` until resumed). Abandon routes through the typed failure rails
962
+ (`evicted → requeued{full}` for batch members — nothing green is discarded). A pause
963
+ can be manual (`sched pause`) or automatic (dispatch-health, #505/#629 above); `sched
964
+ resume` clears the flag and both dispatch-health streaks.
892
965
 
893
966
  ## Hard-block labels are re-read every tick (#544)
894
967
 
@@ -1018,6 +1091,16 @@ in `events.jsonl` (`pr-parked` — two paths, see 1 and 1b above,
1018
1091
  `pr-watch-waiting`, `teardown-done`/`teardown-failed`, `report-dispatched`,
1019
1092
  `report-failed`, `ground-truth-unreachable`, `stale-failure-reconciled`).
1020
1093
 
1094
+ Since #610/#630/#632, `pr-watch-failed`, `pr-watch-waiting` and
1095
+ `ground-truth-unreachable` are emitted **once per unbroken streak** of the condition, not
1096
+ once per tick, re-announcing every `JOURNAL_DEDUP_REANNOUNCE_TICKS` (20) ticks. Each
1097
+ entry carries `at` (the engine's decision clock), `since` (the streak's onset) and
1098
+ `ticks_persisted` (ticks the streak has run, silent ones included). Read `since`, not the
1099
+ tick count, for duration: 20 ticks is ~20 min on the per-reconcile sites
1100
+ (`reconcile_interval_ms`, default 60 s) and ~50 min on the sites gated on the parked-PR
1101
+ poll (`pr_poll_interval_ms`, default 150 s), and both are operator-tunable. A count of
1102
+ these events is therefore a count of streaks and re-announcements, not of ticks.
1103
+
1021
1104
  ## Development
1022
1105
 
1023
1106
  ```bash
@@ -1 +1 @@
1
- {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"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"}
@@ -104,6 +104,7 @@ const core_1 = require("@ai-dossier/core");
104
104
  const worktree_pool_1 = require("@ai-dossier/worktree-pool");
105
105
  const attribution_1 = require("./attribution");
106
106
  const dispatch_1 = require("./dispatch");
107
+ const dispatch_health_1 = require("./dispatch-health");
107
108
  const groundtruth_1 = require("./groundtruth");
108
109
  const journal_1 = require("./journal");
109
110
  const readiness_1 = require("./readiness");
@@ -143,6 +144,55 @@ function sanitizeUntrustedText(value) {
143
144
  function journalEvent(deps, event, unitId, extra = {}) {
144
145
  deps.journal.append((0, journal_1.unitEvent)(event, unitId, extra), deps.now());
145
146
  }
147
+ /**
148
+ * Read a dead batch dispatch's own log and classify it (#629 AC3) — tail,
149
+ * member, fix and report all reach a `dead` agent the same way (`pid` no
150
+ * longer alive), and every one of them previously treated that exit as a
151
+ * real failure (`tail-agent-exited-unverified`, member eviction,
152
+ * `report-failed`, a red fix-attempt resolution) with no way to tell a
153
+ * confirmed provider API error apart from an agent that actually ran.
154
+ *
155
+ * `offset` fences this read to THIS dispatch's slice, mirroring the
156
+ * per-issue path's `dispatchLogSlice`/`log_offset_at_spawn` (#524): these
157
+ * batch log paths are per-ROLE, not per-dispatch, and opened in append mode
158
+ * (`dispatch.ts`'s `openLogForAppend`), so a byte-0 read would keep
159
+ * classifying a LATER dead exit — one that itself wrote no `result` event at
160
+ * all (killed, OOM, an operator killing the tick — the #629 incident itself)
161
+ * — against a STALE `result` line from an earlier dispatch, permanently
162
+ * suppressing genuine failures and re-arming the pause on every reconcile
163
+ * (#629 review). Each batch spawn function stamps `log_offset_at_spawn` on
164
+ * its slot patch, exactly like the per-issue spawn path already does.
165
+ */
166
+ function readDispatchApiError(logFile, offset) {
167
+ return (0, core_1.parseDispatchApiError)((0, run_log_1.readDispatchLog)(logFile, offset));
168
+ }
169
+ /**
170
+ * Classify a dead batch dispatch and, if it is a confirmed provider API
171
+ * error, record it against the shared #505/#629 pause (the same mechanism
172
+ * `engine.ts`'s `completeUnitOrRecover` uses — see `dispatch-health.ts`) and
173
+ * release the slot, all inside ONE lock (a crash between recording and
174
+ * releasing would otherwise leave the counter incremented with the slot
175
+ * still held by a dead pid — #629 review). Returns whether it classified as
176
+ * an API error — the caller's cue to skip its normal failure handling
177
+ * (eviction, `tail-agent-exited-unverified`, `report-failed`, a red
178
+ * fix-attempt resolution) entirely.
179
+ *
180
+ * `release: false` (the fix-agent caller) records without releasing — that
181
+ * caller has its own release a few lines later, after also deciding whether
182
+ * to resolve the fix attempt.
183
+ */
184
+ function handleDeadDispatchApiError(deps, batchId, logFile, offset, now, options = {}) {
185
+ const apiError = readDispatchApiError(logFile, offset);
186
+ if (!apiError)
187
+ return false;
188
+ deps.store.withLock((s) => {
189
+ let n = (0, dispatch_health_1.recordDispatchApiError)((event, unitId, extra) => journalEvent(deps, event, unitId, extra), s, unit(batchId), apiError);
190
+ if (options.release ?? true)
191
+ n = releaseSlot(n, batchId, now);
192
+ return { state: n, result: undefined };
193
+ });
194
+ return true;
195
+ }
146
196
  /**
147
197
  * Last ~500 bytes of a gate's output tail, falling back to the envelope's
148
198
  * `reason` when no subprocess ran (#583 AC1/AC3 review: `capability-unavailable`
@@ -527,6 +577,11 @@ function spawnMember(deps, dispatch, state, slot, batchId, now, result) {
527
577
  const cmd = spawnSpec.cmd;
528
578
  const prompt = (0, dispatch_1.buildMemberPrompt)(dispatch.memberPrompt, memberIssue, batchId, batch.worktree);
529
579
  const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
580
+ // #629: captured BEFORE spawning, mirroring `engine.ts`'s own
581
+ // `spawnAndRecord` — the log is per-role and append-mode, so the size at
582
+ // this instant is exactly where THIS dispatch's own output starts. Fences
583
+ // `handleDeadDispatchApiError`'s classification to this dispatch's slice.
584
+ const logOffset = (0, dispatch_1.fileSizeOrZero)(logFile);
530
585
  let pid;
531
586
  try {
532
587
  pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
@@ -549,6 +604,7 @@ function spawnMember(deps, dispatch, state, slot, batchId, now, result) {
549
604
  // (line ~745) — `spawnMember` never did, so `recordMemberRunLog`'s
550
605
  // `slot.spawned_at === null` guard silently skipped every member.
551
606
  spawned_at: now.toISOString(),
607
+ log_offset_at_spawn: logOffset,
552
608
  };
553
609
  const next = slot.status === 'assigned' || slot.status === 'recovering'
554
610
  ? (0, state_1.transitionSlot)(withStatus, slot.id, 'running', patch, now)
@@ -709,6 +765,9 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
709
765
  const cmd = spawnSpec.cmd;
710
766
  const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor, batch.members, batch.worktree);
711
767
  const logFile = (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId);
768
+ // #629: fences `handleDeadDispatchApiError`'s classification to this
769
+ // dispatch's slice — see `spawnMember`'s identical comment.
770
+ const logOffset = (0, dispatch_1.fileSizeOrZero)(logFile);
712
771
  let pid;
713
772
  try {
714
773
  pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
@@ -726,6 +785,7 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
726
785
  pid_start: deps.spawnDeps.processStart(pid),
727
786
  phase: 'reviewing',
728
787
  last_progress_at: now.toISOString(),
788
+ log_offset_at_spawn: logOffset,
729
789
  };
730
790
  const next = slot.status === 'assigned' ? (0, state_1.transitionSlot)(state, slot.id, 'running', patch, now) : state;
731
791
  deps.journal.append((0, journal_1.unitEvent)('spawned', unit(batchId), {
@@ -759,6 +819,9 @@ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
759
819
  const cmd = spawnSpec.cmd;
760
820
  const prompt = (0, dispatch_1.buildBatchReportPrompt)(dispatch.batchReportPrompt, batchId, batch.anchor, prNumber);
761
821
  const logFile = (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId);
822
+ // #629: fences `handleDeadDispatchApiError`'s classification to this
823
+ // dispatch's slice — see `spawnMember`'s identical comment.
824
+ const logOffset = (0, dispatch_1.fileSizeOrZero)(logFile);
762
825
  let pid;
763
826
  try {
764
827
  pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
@@ -772,6 +835,7 @@ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
772
835
  pid_start: deps.spawnDeps.processStart(pid),
773
836
  phase: 'report',
774
837
  last_progress_at: now.toISOString(),
838
+ log_offset_at_spawn: logOffset,
775
839
  };
776
840
  const next = slot.status === 'assigned'
777
841
  ? (0, state_1.transitionSlot)(state, slot.id, 'running', patchState, now)
@@ -891,6 +955,9 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
891
955
  }
892
956
  claimAndSpawn(deps, config, batchId, 'fixing', now, (s, slot) => {
893
957
  const logFile = (0, dispatch_1.batchFixLogPath)(deps.store.runsDir, batchId, offender);
958
+ // #629: fences `handleDeadDispatchApiError`'s classification to this
959
+ // dispatch's slice — see `spawnMember`'s identical comment.
960
+ const logOffset = (0, dispatch_1.fileSizeOrZero)(logFile);
894
961
  let pid;
895
962
  try {
896
963
  pid = deps.spawnDeps.spawn(fixDispatch.command, fixDispatch.prompt, logFile);
@@ -913,6 +980,7 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
913
980
  pid_start: deps.spawnDeps.processStart(pid),
914
981
  phase: 'fixing',
915
982
  last_progress_at: now.toISOString(),
983
+ log_offset_at_spawn: logOffset,
916
984
  };
917
985
  const next = slot.status === 'assigned' ? (0, state_1.transitionSlot)(s, slot.id, 'running', patch, now) : s;
918
986
  result.spawned.push(unit(batchId));
@@ -1103,6 +1171,14 @@ function runIncrementalGate(deps, config, dispatch, batchId, batch, memberIssue,
1103
1171
  // Journalled per member: a gate that silently does not run is its own
1104
1172
  // trap (#594's shape — absence reading as a verdict). Silence must never
1105
1173
  // be mistaken for a pass.
1174
+ //
1175
+ // #632: confirmed this fires once per member, not once per tick.
1176
+ // `runIncrementalGate` is called only from the `isMemberComplete` branch
1177
+ // of `reconcileMemberSlot`, and both of that branch's callees
1178
+ // (`completeMemberGate`, `evictMemberAndContinue`) advance
1179
+ // `batch.executing_member` before returning — so the very next tick
1180
+ // reads a DIFFERENT `memberIssue` from `batch.members[executing_member -
1181
+ // 1]`, and this gate never runs twice against the same member.
1106
1182
  journalEvent(deps, 'gate-skipped', unit(batchId), {
1107
1183
  issue: memberIssue,
1108
1184
  reason: `gate-skipped:${skipped.id}`,
@@ -1180,7 +1256,13 @@ function completeMemberGate(deps, config, dispatch, batchId, batch, memberIssue,
1180
1256
  // invariant exists to prevent).
1181
1257
  const ranges = (0, attribution_1.memberRanges)(boundaryCommits(deps, batch));
1182
1258
  deps.store.withLock((s) => {
1183
- let n = releaseSlot(s, batchId, now);
1259
+ // #629: a verified member completion is proof dispatch is healthy —
1260
+ // reset the confirmed-failure streak, mirroring the per-issue path's
1261
+ // `resetDispatchApiErrorStreak` on its own verified-complete branch, or a
1262
+ // healthy member sandwiched between two unrelated api-error hits (this
1263
+ // one and a later one on the tail/report agent) would be invisible to
1264
+ // the streak and could still tip it into a false-positive pause.
1265
+ let n = (0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now));
1184
1266
  n = (0, state_1.patchBatch)(n, batchId, { ranges }, now);
1185
1267
  n = advanceMemberToValidated(n, memberIssue, now);
1186
1268
  return { state: n, result: undefined };
@@ -1316,7 +1398,15 @@ function recordMemberRunLog(deps, dispatch, state, batchId, batch, memberIssue,
1316
1398
  const tier = (0, state_1.findEntry)(state, memberIssue)?.tier ?? 'mid';
1317
1399
  const { cmd, model } = (0, dispatch_1.resolveTierSpawn)(dispatch, tier, memberIssue);
1318
1400
  const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
1319
- const logContent = (0, run_log_1.readDispatchLog)(logFile, 0);
1401
+ // #629: fenced to THIS dispatch's slice (`log_offset_at_spawn`, stamped by
1402
+ // `spawnMember`/`spawnMemberContinuation` at spawn time) rather than a
1403
+ // fixed byte-0 read. Before #629 a member log never outlived one attempt
1404
+ // (eviction was the only exit besides completion), so byte-0 was safe; a
1405
+ // confirmed-API-error hold now retries the SAME member in place, appending
1406
+ // a SECOND attempt to the same file — an unfenced read would double-count
1407
+ // the first attempt's tokens into the second's `runs.jsonl` entry, exactly
1408
+ // the #524 divergence this telemetry system exists to prevent.
1409
+ const logContent = (0, run_log_1.readDispatchLog)(logFile, slot.log_offset_at_spawn ?? 0);
1320
1410
  const runEntry = (0, run_log_1.buildSchedRunLogEntry)({
1321
1411
  unit: `issue:${memberIssue}`,
1322
1412
  role: 'batch-member',
@@ -1461,6 +1551,16 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
1461
1551
  return;
1462
1552
  }
1463
1553
  const blockedNow = (0, groundtruth_1.isMemberBlocked)(milestone, slot.spawned_at);
1554
+ if (dead &&
1555
+ !blockedNow &&
1556
+ handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue), slot.log_offset_at_spawn ?? 0, now)) {
1557
+ // #629: a confirmed provider API error is not a real member failure —
1558
+ // evicting the member for an account-wide spend wall would silently
1559
+ // throw away real, correct work. `runBatchTick`'s "same wedge" retry
1560
+ // (`spawnMemberContinuation`, now pause-gated) retries the SAME member in
1561
+ // place, keeping its position.
1562
+ return;
1563
+ }
1464
1564
  if (blockedNow || dead) {
1465
1565
  // #605: only a milestone THIS dispatch posted may name the reason. A dead
1466
1566
  // agent whose issue carries only a stale `blocked` milestone is an
@@ -1630,6 +1730,19 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
1630
1730
  const offenderRecord = [...batch.fix_attempts].reverse().find((a) => a.outcome === 'dispatched');
1631
1731
  if (!offenderRecord)
1632
1732
  return;
1733
+ // #629: a confirmed provider API error means the fix agent never got a
1734
+ // real chance to fix anything — record it against the shared pause (AC1/
1735
+ // AC2/AC3) so an operator reads "spend limit", not a fix that failed on
1736
+ // its merits. `release: false`: this function releases the slot itself a
1737
+ // few lines below, after also deciding whether to resolve the fix attempt
1738
+ // — one release, not two. The one-shot fix-attempt model has no existing
1739
+ // "retry the same attempt" primitive (unlike tail/member/report, which the
1740
+ // tick loop itself retries), so the suite still runs and resolves the
1741
+ // attempt below exactly as today: the member's one fix attempt is consumed
1742
+ // and resolves per the suite's real result. An operator who wants it back
1743
+ // must re-trigger the fix after `sched resume` — see
1744
+ // packages/sched/README.md's dispatch-health section.
1745
+ handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchFixLogPath)(deps.store.runsDir, batchId, offenderRecord.issue), slot.log_offset_at_spawn ?? 0, now, { release: false });
1633
1746
  deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
1634
1747
  const suite = safeSuite(deps, batchId, batch.worktree);
1635
1748
  const rDeps = recoveryDeps(deps, config, batch, now);
@@ -1672,7 +1785,10 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
1672
1785
  const pr = (0, groundtruth_1.prOfMilestone)(milestone);
1673
1786
  journalEvent(deps, 'pr-parked', unit(batchId), { pr: pr ?? undefined });
1674
1787
  deps.store.withLock((s) => {
1675
- let n = releaseSlot(s, batchId, now);
1788
+ // #629: a verified park is proof dispatch is healthy — reset the
1789
+ // confirmed-failure streak, mirroring the per-issue path's own parked
1790
+ // branch.
1791
+ let n = (0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now));
1676
1792
  const b = (0, state_1.findBatch)(n, batchId);
1677
1793
  if (!b)
1678
1794
  return { state: n, result: undefined };
@@ -1684,6 +1800,13 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
1684
1800
  return;
1685
1801
  }
1686
1802
  if (dead) {
1803
+ // #629: a confirmed provider API error is not a real tail-agent failure —
1804
+ // record it against the shared pause and let `runBatchTick`'s "same
1805
+ // wedge" retry (`spawnTailAgent`, now pause-gated) respawn it, instead of
1806
+ // journaling a failure that will never stop respawning on its own.
1807
+ if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
1808
+ return;
1809
+ }
1687
1810
  deps.journal.append((0, journal_1.unitEvent)('unit-failed', unit(batchId), { reason: 'tail-agent-exited-unverified' }), now);
1688
1811
  deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
1689
1812
  result.failed.push(unit(batchId));
@@ -1701,7 +1824,10 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
1701
1824
  if ((0, groundtruth_1.isBatchPhaseDone)(milestone, 'batch-report', slot.spawned_at)) {
1702
1825
  deps.journal.append((0, journal_1.unitEvent)('external-advance', unit(batchId), { detail: 'batch report done' }), now);
1703
1826
  deps.store.withLock((s) => {
1704
- let n = releaseSlot(s, batchId, now);
1827
+ // #629: a verified report completion is proof dispatch is healthy —
1828
+ // reset the confirmed-failure streak, mirroring the per-issue path's
1829
+ // own verified-complete branch.
1830
+ let n = (0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now));
1705
1831
  const b = (0, state_1.findBatch)(n, batchId);
1706
1832
  if (!b || b.status !== 'deployed')
1707
1833
  return { state: n, result: undefined };
@@ -1714,16 +1840,46 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
1714
1840
  return;
1715
1841
  }
1716
1842
  if (dead) {
1843
+ // #629: same reasoning as `reconcileTailSlot` — a confirmed API error is
1844
+ // not a real report-agent failure; record it and let the `deployed`
1845
+ // branch's `spawnReportAgent` retry (now pause-gated) instead of
1846
+ // journaling `report-failed` for a wall that will keep respawning anyway.
1847
+ if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
1848
+ return;
1849
+ }
1717
1850
  deps.journal.append((0, journal_1.unitEvent)('report-failed', unit(batchId), { detail: 'unverified exit' }), now);
1718
1851
  deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
1719
1852
  }
1720
1853
  }
1721
1854
  // --- PR watch for `awaiting-merge` batches (mirrors `pollParkedPrs`/`reconcileParked`) ---
1855
+ /**
1856
+ * Reset a batch's `pr-watch-failed` dedup marker (#630/#633). A no-op when no
1857
+ * streak is recorded, so it is safe to call on every non-`awaiting-merge`
1858
+ * batch every tick.
1859
+ */
1860
+ function clearPrWatchFailed(deps, batch, now) {
1861
+ if (batch.pr_watch_failed_reason === null)
1862
+ return;
1863
+ deps.store.withLock((s) => ({
1864
+ state: (0, state_1.patchBatch)(s, batch.id, state_1.CLEARED_PR_WATCH_FIELDS, now, false),
1865
+ result: undefined,
1866
+ }));
1867
+ }
1722
1868
  function reconcilePrWatch(deps, now, result) {
1723
1869
  const state = deps.store.load();
1724
1870
  for (const batch of state.batches) {
1725
- if (batch.status !== 'awaiting-merge')
1871
+ if (batch.status !== 'awaiting-merge') {
1872
+ // #633: the marker is scoped to ONE awaiting-merge stretch. Merged,
1873
+ // blocked and dissolved batches all leave through this guard, and
1874
+ // `BATCH_TRANSITIONS` lets a batch come back to `awaiting-merge` on the
1875
+ // same id (`awaiting-merge → rebasing → re-validating → shipping →
1876
+ // awaiting-merge`, `recovery.ts`'s `handlePrConflict`). Leaving a live
1877
+ // reason behind would make the SAME reason recurring after a real
1878
+ // rebase-and-reship read as an unchanged streak and stay silent —
1879
+ // contradicting `BatchEntry.pr_watch_failed_reason`'s own contract.
1880
+ clearPrWatchFailed(deps, batch, now);
1726
1881
  continue;
1882
+ }
1727
1883
  const pr = batch.pr;
1728
1884
  if (pr === null)
1729
1885
  continue;
@@ -1756,14 +1912,52 @@ function reconcilePrWatch(deps, now, result) {
1756
1912
  continue;
1757
1913
  }
1758
1914
  if (truth.blocked || truth.mergeable === 'CONFLICTING') {
1759
- journalEvent(deps, 'pr-watch-failed', unit(batch.id), {
1760
- reason: truth.blocked ? 'auto-merge-blocked' : 'pr-conflicting',
1761
- pr,
1762
- });
1915
+ const reason = truth.blocked ? 'auto-merge-blocked' : 'pr-conflicting';
1916
+ const isNewStreak = batch.pr_watch_failed_reason !== reason;
1917
+ const ticks = isNewStreak ? 1 : batch.pr_watch_failed_ticks + 1;
1918
+ const since = isNewStreak ? now.toISOString() : batch.pr_watch_failed_since;
1919
+ // #630: journal on the streak's first tick, then again only every
1920
+ // `JOURNAL_DEDUP_REANNOUNCE_TICKS` — never every tick — mirrors #610's
1921
+ // `stale_milestone_ignored_for` dedup for the "distinct condition"
1922
+ // half, while still giving a still-blocked streak a later line an
1923
+ // operator can read "still blocked, Nth check" off (a single onset
1924
+ // entry never updates, so "after 40 minutes" would otherwise never be
1925
+ // legible from any journal line at all). `at` is the engine's OWN
1926
+ // decision time, never copied from `truth` (which carries no timestamp
1927
+ // of its own); `since` is the streak's onset, and is what makes the
1928
+ // duration legible — `ticks_persisted` is a tick count against an
1929
+ // operator-tunable `reconcile_interval_ms`, not a fixed wall-clock.
1930
+ if (isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0) {
1931
+ journalEvent(deps, 'pr-watch-failed', unit(batch.id), {
1932
+ reason,
1933
+ pr,
1934
+ at: now.toISOString(),
1935
+ since,
1936
+ ticks_persisted: ticks,
1937
+ });
1938
+ }
1939
+ deps.store.withLock((s) => ({
1940
+ // `touchUpdatedAt: false` — a silent tick that only advances the
1941
+ // streak counter is bookkeeping, not activity (same rule as
1942
+ // `patchEntry`'s dedup writes).
1943
+ state: (0, state_1.patchBatch)(s, batch.id, {
1944
+ pr_watch_failed_reason: reason,
1945
+ pr_watch_failed_since: since,
1946
+ pr_watch_failed_ticks: ticks,
1947
+ }, now, false),
1948
+ result: undefined,
1949
+ }));
1763
1950
  // #472's own rebase-and-reship path (RFC F.9) is a documented follow-up
1764
1951
  // for the batch PR-conflict rail; for now the batch stays parked and
1765
1952
  // the block is visible via the journal + `sched status`.
1766
1953
  }
1954
+ else {
1955
+ // #630: the condition cleared — reset the marker so a future
1956
+ // re-occurrence (even the SAME reason) journals its own fresh entry.
1957
+ // This is de-duplication, not suppression: only an UNCHANGED streak
1958
+ // stays silent.
1959
+ clearPrWatchFailed(deps, batch, now);
1960
+ }
1767
1961
  }
1768
1962
  }
1769
1963
  /**
@@ -1825,10 +2019,23 @@ function runBatchTick(deps, config, dispatch) {
1825
2019
  // only the per-batch `slotFor`/`status` re-check inside the loop reads
1826
2020
  // fresh state (one `store.load()` per iteration, in case an earlier claim
1827
2021
  // in this same pass changed things).
1828
- const readyOrder = [...deps.store.load().batches]
1829
- .filter((b) => b.status === 'ready')
1830
- .sort((a, b) => (0, readiness_1.compareByPriority)((0, readiness_1.batchRank)(a), (0, readiness_1.batchRank)(b)))
1831
- .map((b) => b.id);
2022
+ // #629: dispatch-health pause (#505, and now the confirmed-api-error
2023
+ // streak) stops NEW per-issue assignments via `computeAssignments` — but
2024
+ // nothing in this function ever read `paused` before #629, so claiming a
2025
+ // `ready` batch here (a provider dispatch, `batch-setup`, the most literal
2026
+ // "new assignment" there is) or a respawn wedge in the second loop below
2027
+ // would keep happening every tick regardless, making the pause cosmetic
2028
+ // for the exact incident (a batch tail respawning against a spend wall)
2029
+ // that motivated it. Read once — every use below in this same tick must
2030
+ // agree, and re-reading per iteration only risks a pause landing mid-loop
2031
+ // and gating some batches but not others in one pass.
2032
+ const paused = deps.store.load().paused;
2033
+ const readyOrder = paused
2034
+ ? []
2035
+ : [...deps.store.load().batches]
2036
+ .filter((b) => b.status === 'ready')
2037
+ .sort((a, b) => (0, readiness_1.compareByPriority)((0, readiness_1.batchRank)(a), (0, readiness_1.batchRank)(b)))
2038
+ .map((b) => b.id);
1832
2039
  for (const batchId of readyOrder) {
1833
2040
  const state = deps.store.load();
1834
2041
  const batch = (0, state_1.findBatch)(state, batchId);
@@ -1877,9 +2084,16 @@ function runBatchTick(deps, config, dispatch) {
1877
2084
  if (slot)
1878
2085
  continue; // live but neither running/assigned (e.g. mid-verify) — next tick
1879
2086
  if (batch.status === 'validating') {
2087
+ // A local suite run, not a provider dispatch — unaffected by `paused`.
1880
2088
  runValidate(deps, config, dispatch, batch.id, now, result);
2089
+ continue;
1881
2090
  }
1882
- else if (batch.status === 'deployed') {
2091
+ // #629: every branch below spawns a provider agent — hold the wedge
2092
+ // until `sched resume` (see the top of this function for why `paused`
2093
+ // is read once, up front, rather than per iteration).
2094
+ if (paused)
2095
+ continue;
2096
+ if (batch.status === 'deployed') {
1883
2097
  spawnReportAgent(deps, config, dispatch, batch.id, now, result);
1884
2098
  }
1885
2099
  else if (batch.status === 'executing') {