@ai-dossier/sched 0.23.5 → 0.25.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
@@ -261,6 +288,55 @@ This applies to `issue:<n>` unit dispatch (`dispatchAssignments`). `batch:<id>`
261
288
  through a separate pass with its own claim/reconcile logic — see
262
289
  [Batch dispatch (#523)](#batch-dispatch-523) below.
263
290
 
291
+ ### Supervised deployment (#679)
292
+
293
+ Two supported shapes, one rule: **dispatched agents must outlive the tick or engine that
294
+ spawned them.** Agents are spawned detached and unref'd precisely so they survive a sched
295
+ crash or restart (restart reconciles by pid) — any supervisor that tears down the process
296
+ tree on exit defeats that by construction. The symptom when it happens is cruelly
297
+ misleading: agents die with zero usage events, get classified `unverified-exit`, and walk
298
+ up the escalation ladder to `unverified-exit-at-strongest-tier` — a failure reason that
299
+ reads as a model-capability verdict when the strongest tier was never given a chance to
300
+ run.
301
+
302
+ **Long-running engine** — `Type=simple` user service:
303
+
304
+ ```ini
305
+ # ~/.config/systemd/user/dossier-sched-<project>.service
306
+ [Unit]
307
+ Description=dossier sched engine (<project>)
308
+
309
+ [Service]
310
+ Type=simple
311
+ ExecStart=%h/.local/bin/ai-dossier sched start --project <project>
312
+ Restart=on-failure
313
+ # Kill ONLY the engine process on stop/restart. The default (control-group)
314
+ # kills every dispatched agent with it — the engine's own restart would then
315
+ # read those deaths as failed runs and escalate the units (see above).
316
+ KillMode=process
317
+
318
+ [Install]
319
+ WantedBy=default.target
320
+ ```
321
+
322
+ **One-shot tick** — cron or a `Type=oneshot` unit running `sched start --once` needs the
323
+ same rule: the tick exits immediately after dispatching, so under the default
324
+ `KillMode=control-group` the control group is torn down while every freshly spawned agent
325
+ is still starting — EVERY tick kills EVERY agent it dispatched. `KillMode=process` on the
326
+ unit (or spawning each agent into its own transient scope,
327
+ `systemd-run --user --scope --collect …`) is required, not cosmetic.
328
+
329
+ Verify a deployment survives its own ticks: dispatch one unit, let the tick or engine
330
+ process that spawned it exit, and confirm the agent process is still alive 60 seconds
331
+ later (`ps -p <agent-pid>`), with `run-log-no-usage` absent from the project's
332
+ `events.jsonl` for that unit.
333
+
334
+ Note the engine's own log (redirected stdout) carries one `✓ [ts] …` line per tick even
335
+ when a tick does nothing (`nothing to do`) — a log that stops growing while
336
+ `systemctl status` still says `active (running)` means the loop died silently, which is
337
+ the failure mode #679 fixed: the engine exited cleanly after its first tick because its
338
+ inter-tick sleep handles were unref'd.
339
+
264
340
  ### Zombie-run fencing (#504)
265
341
 
266
342
  The ladder redispatches the SAME run, so a takeover inherits the run id and its milestone
@@ -616,6 +692,14 @@ with `touchUpdatedAt: false` — `QueueEntry.updated_at` is load-bearing for
616
692
  `isStaleFailedPark`'s 7-day window, `status.ts`'s "parked since" and `readiness.ts`'s
617
693
  tiebreak, and a silent dedup tick must not reset them. `patchBatch` takes the same flag
618
694
  for the same reason on the batch rail.
695
+ Schema 1.13.0 (#629): `SchedState` gains `consecutive_dispatch_api_errors` (number) and
696
+ `dispatch_pause_reset_at` (string or null) — the confirmed-dispatch-failure streak and
697
+ the provider's own reset time. Unlike the #505 suspect-dispatch pair these are NOT a
698
+ single fact: a provider that reports no reset time leaves `dispatch_pause_reset_at`
699
+ null while the counter is nonzero (`validateState` only enforces the reverse — a reset
700
+ time can never outlive a streak that has already cleared to zero). 1.12.0 states
701
+ migrate on load, backfilling `0`/`null` — no confirmed dispatch failures were ever
702
+ tracked under them, so those values are exact, not a guess.
619
703
 
620
704
  New journal events: `batch-setup-done`, `batch-setup-failed`, `member-advanced`,
621
705
  `batch-warmup-done`, `batch-warmup-failed` (#561 — the cold-path warm step only; a pool
@@ -825,9 +909,10 @@ ladder is exhausted, the terminal `unit-failed` (`agent-exited-unverified` /
825
909
  the dead-pid detection rail and the stall kill RECORD a fresh log slice (a `runs.jsonl`
826
910
  entry) in the same tick — `recordDispatchRunLog` is once-per-dispatch and refuses to
827
911
  append a second entry over the same slice. Since #620 a slot already `exited`/`verifying`
828
- when reconciled again still ATTRIBUTES one: `readLastToolForSlot` re-parses the same
829
- static slice for the tool name alone, writing nothing, so it carries no exactly-once
830
- constraint. That matters because the verify decision lands on a LATER tick than the
912
+ when reconciled again still ATTRIBUTES one: `readDispatchSignalsForSlot` re-parses the
913
+ same static slice (since #629, for the last-tool-call AND API-error-classification
914
+ signals together — see the dispatch-health section above), writing nothing, so it
915
+ carries no exactly-once constraint. That matters because the verify decision lands on a LATER tick than the
831
916
  dead-pid detection whenever ground truth was unreachable in between — previously the
832
917
  tool name was simply lost on exactly the runs hardest to diagnose. The read is deferred
833
918
  behind a thunk so only the tick that reaches the unverified-exit decision pays for it,
@@ -902,6 +987,7 @@ after-the-fact recovery, not a missing-data bug.
902
987
  - **Schema**: state/config files from #460 (schema 1.0.0), #464 (1.1.0), #468 (1.2.0),
903
988
  #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)
904
989
  load and migrate to 1.14.0 automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
990
+ load and migrate to 1.13.0 automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
905
991
  unit's queue entry, with the persisted `phase` as a fallback — #500), entry
906
992
  `pr`/`cleanup`/`failure_evidence`, batch `anchor`/`branch`/`run_id`/`eviction_groups`/
907
993
  `evictions`/`fix_attempts`/`rebase_attempts`, state-level `last_pr_poll_at` backfill to
@@ -909,16 +995,22 @@ after-the-fact recovery, not a missing-data bug.
909
995
  backfill to `0`/`null` — #505, slot `gen`/`fenced_at` backfill to `0`/`null` — #504, and
910
996
  slot `spawned_at`/`log_offset_at_spawn` backfill to `null`/`null` — #524,
911
997
  state-level `last_label_poll_at` backfill to `null` — #544, entry `priority` and batch
912
- `member_gates`/`blocked_reason` — #565/#583, and slot
913
- `stale_milestone_ignored_for` backfill to `null` — #610).
998
+ `member_gates`/`blocked_reason` — #565/#583, slot
999
+ `stale_milestone_ignored_for` backfill to `null` — #610, and state-level
1000
+ `consecutive_dispatch_api_errors`/`dispatch_pause_reset_at` backfill to `0`/`null` —
1001
+ #629).
914
1002
  - **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
915
1003
  edges gate readiness — an issue with an unmerged dependency, and a batch behind an
916
1004
  unmerged batch, are never runnable.
917
- - **Pause** stops new assignments only (including report-agent dispatch — #505); abandon
918
- routes through the typed failure rails (`evicted → requeued{full}` for batch members —
919
- nothing green is discarded). A pause can be manual (`sched pause`) or automatic
920
- (dispatch-health, #505 above); `sched resume` clears both the flag and the
921
- dispatch-health streak.
1005
+ - **Pause** stops new assignments only (including report-agent dispatch — #505) and,
1006
+ since #629, every batch respawn wedge in `runBatchTick` — tail, member continuation,
1007
+ fix, report, and the `ready` → `claimAndSetup` claim, which previously ignored `paused`
1008
+ entirely (`runValidate` is excepted: a local suite run, not a dispatch) — and an
1009
+ unescalated per-issue redispatch (`enterRecovery`/`reconcileRecovering`, held in
1010
+ `recovering` until resumed). Abandon routes through the typed failure rails
1011
+ (`evicted → requeued{full}` for batch members — nothing green is discarded). A pause
1012
+ can be manual (`sched pause`) or automatic (dispatch-health, #505/#629 above); `sched
1013
+ resume` clears the flag and both dispatch-health streaks.
922
1014
 
923
1015
  ## Hard-block labels are re-read every tick (#544)
924
1016
 
@@ -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));
@@ -1188,7 +1256,13 @@ function completeMemberGate(deps, config, dispatch, batchId, batch, memberIssue,
1188
1256
  // invariant exists to prevent).
1189
1257
  const ranges = (0, attribution_1.memberRanges)(boundaryCommits(deps, batch));
1190
1258
  deps.store.withLock((s) => {
1191
- 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));
1192
1266
  n = (0, state_1.patchBatch)(n, batchId, { ranges }, now);
1193
1267
  n = advanceMemberToValidated(n, memberIssue, now);
1194
1268
  return { state: n, result: undefined };
@@ -1324,7 +1398,15 @@ function recordMemberRunLog(deps, dispatch, state, batchId, batch, memberIssue,
1324
1398
  const tier = (0, state_1.findEntry)(state, memberIssue)?.tier ?? 'mid';
1325
1399
  const { cmd, model } = (0, dispatch_1.resolveTierSpawn)(dispatch, tier, memberIssue);
1326
1400
  const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
1327
- 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);
1328
1410
  const runEntry = (0, run_log_1.buildSchedRunLogEntry)({
1329
1411
  unit: `issue:${memberIssue}`,
1330
1412
  role: 'batch-member',
@@ -1469,6 +1551,16 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
1469
1551
  return;
1470
1552
  }
1471
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
+ }
1472
1564
  if (blockedNow || dead) {
1473
1565
  // #605: only a milestone THIS dispatch posted may name the reason. A dead
1474
1566
  // agent whose issue carries only a stale `blocked` milestone is an
@@ -1638,6 +1730,19 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
1638
1730
  const offenderRecord = [...batch.fix_attempts].reverse().find((a) => a.outcome === 'dispatched');
1639
1731
  if (!offenderRecord)
1640
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 });
1641
1746
  deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
1642
1747
  const suite = safeSuite(deps, batchId, batch.worktree);
1643
1748
  const rDeps = recoveryDeps(deps, config, batch, now);
@@ -1680,7 +1785,10 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
1680
1785
  const pr = (0, groundtruth_1.prOfMilestone)(milestone);
1681
1786
  journalEvent(deps, 'pr-parked', unit(batchId), { pr: pr ?? undefined });
1682
1787
  deps.store.withLock((s) => {
1683
- 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));
1684
1792
  const b = (0, state_1.findBatch)(n, batchId);
1685
1793
  if (!b)
1686
1794
  return { state: n, result: undefined };
@@ -1692,6 +1800,13 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
1692
1800
  return;
1693
1801
  }
1694
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
+ }
1695
1810
  deps.journal.append((0, journal_1.unitEvent)('unit-failed', unit(batchId), { reason: 'tail-agent-exited-unverified' }), now);
1696
1811
  deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
1697
1812
  result.failed.push(unit(batchId));
@@ -1709,7 +1824,10 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
1709
1824
  if ((0, groundtruth_1.isBatchPhaseDone)(milestone, 'batch-report', slot.spawned_at)) {
1710
1825
  deps.journal.append((0, journal_1.unitEvent)('external-advance', unit(batchId), { detail: 'batch report done' }), now);
1711
1826
  deps.store.withLock((s) => {
1712
- 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));
1713
1831
  const b = (0, state_1.findBatch)(n, batchId);
1714
1832
  if (!b || b.status !== 'deployed')
1715
1833
  return { state: n, result: undefined };
@@ -1722,6 +1840,13 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
1722
1840
  return;
1723
1841
  }
1724
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
+ }
1725
1850
  deps.journal.append((0, journal_1.unitEvent)('report-failed', unit(batchId), { detail: 'unverified exit' }), now);
1726
1851
  deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
1727
1852
  }
@@ -1894,10 +2019,23 @@ function runBatchTick(deps, config, dispatch) {
1894
2019
  // only the per-batch `slotFor`/`status` re-check inside the loop reads
1895
2020
  // fresh state (one `store.load()` per iteration, in case an earlier claim
1896
2021
  // in this same pass changed things).
1897
- const readyOrder = [...deps.store.load().batches]
1898
- .filter((b) => b.status === 'ready')
1899
- .sort((a, b) => (0, readiness_1.compareByPriority)((0, readiness_1.batchRank)(a), (0, readiness_1.batchRank)(b)))
1900
- .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);
1901
2039
  for (const batchId of readyOrder) {
1902
2040
  const state = deps.store.load();
1903
2041
  const batch = (0, state_1.findBatch)(state, batchId);
@@ -1946,9 +2084,16 @@ function runBatchTick(deps, config, dispatch) {
1946
2084
  if (slot)
1947
2085
  continue; // live but neither running/assigned (e.g. mid-verify) — next tick
1948
2086
  if (batch.status === 'validating') {
2087
+ // A local suite run, not a provider dispatch — unaffected by `paused`.
1949
2088
  runValidate(deps, config, dispatch, batch.id, now, result);
2089
+ continue;
1950
2090
  }
1951
- 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') {
1952
2097
  spawnReportAgent(deps, config, dispatch, batch.id, now, result);
1953
2098
  }
1954
2099
  else if (batch.status === 'executing') {