@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/dist/engine.js CHANGED
@@ -73,6 +73,7 @@ exports.runLoop = runLoop;
73
73
  const core_1 = require("@ai-dossier/core");
74
74
  const batch_dispatch_1 = require("./batch-dispatch");
75
75
  const dispatch_1 = require("./dispatch");
76
+ const dispatch_health_1 = require("./dispatch-health");
76
77
  const groundtruth_1 = require("./groundtruth");
77
78
  const journal_1 = require("./journal");
78
79
  const labels_1 = require("./labels");
@@ -405,7 +406,9 @@ function reconcileLabelBlocks(ctx, state, poll) {
405
406
  const ownStillPresent = labels.some((name) => name.toLowerCase() === blockedBy.toLowerCase());
406
407
  const current = (0, labels_1.pickHardBlockLabel)(labels);
407
408
  if (!ownStillPresent && current === null) {
408
- next = (0, state_1.transitionIssue)(next, issue, 'queued', { reason: null }, now);
409
+ // #633: an unblocked entry is a fresh attempt too — see
410
+ // `requeueOrphanedDispatches`.
411
+ next = (0, state_1.transitionIssue)(next, issue, 'queued', { reason: null, ...state_1.CLEARED_ENTRY_DEDUP_MARKERS }, now);
409
412
  journal(ctx, 'label-cleared', unit, { reason: (0, labels_1.labelBlockReason)(blockedBy) });
410
413
  ctx.result.labelCleared.push(unit);
411
414
  }
@@ -723,7 +726,14 @@ function writeFence(ctx, unit, issue, slot, truth) {
723
726
  * `unit-failed` line otherwise — and that indistinguishability is exactly
724
727
  * what cost imboard-monorepo#3999 (docs/agent-traps.md).
725
728
  */
726
- function failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, evidence) {
729
+ function failOrAdoptOpenPr(ctx, state, unit, slot, report,
730
+ // #629: never `'dispatch-failure'` in practice — `enterRecovery` only
731
+ // reaches this function under `escalate: true` (its cap-check is itself
732
+ // gated on `escalate`), and a `dispatch-failure` cause always passes
733
+ // `escalate: false`. Kept as the two-member union `enterRecovery` uses
734
+ // before its escalate check, so a maintainer reading this signature does
735
+ // not read "a spend wall can terminally fail a unit" as a live path.
736
+ causeEvent, cause, evidence) {
727
737
  let prCheck = 'skipped';
728
738
  if (!report && causeEvent === 'verify-incomplete') {
729
739
  if (slot.branch === null) {
@@ -746,6 +756,13 @@ function failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, ev
746
756
  });
747
757
  }
748
758
  prCheck = openPr === undefined ? 'unreachable' : 'none';
759
+ // #632: confirmed NOT one of the per-tick re-emit sites — this
760
+ // function always ends by either parking the unit (the branch above)
761
+ // or falling through to the unconditional `failUnit` below, so the
762
+ // unit is terminal (or parked) by the time this call returns. There is
763
+ // no "next tick" on which the SAME dispatch reaches this check again,
764
+ // so no dedup marker is needed here; the line fires exactly once, on
765
+ // the one terminal decision it accompanies.
749
766
  if (prCheck === 'unreachable') {
750
767
  journal(ctx, 'ground-truth-unreachable', unit, {
751
768
  slot: slot.id,
@@ -767,7 +784,7 @@ function failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, ev
767
784
  // with `causeEvent === 'verify-incomplete'` thread it through
768
785
  // `completeUnitOrRecover`: `reconcileRunning`'s dead-pid rail, from
769
786
  // `recordDispatchRunLog`'s own read, and (since #620) `reconcileSlots`'
770
- // `exited`/`verifying` rail, from `readLastToolForSlot`. The two agree —
787
+ // `exited`/`verifying` rail, from `readDispatchSignalsForSlot`. The two agree —
771
788
  // the dispatch log is static once the agent has exited — so the tool name
772
789
  // survives regardless of which tick reaches this decision. It stays
773
790
  // OPTIONAL: a slice with no parseable `tool_use` yields null, hence the
@@ -789,7 +806,14 @@ function failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, ev
789
806
  * mechanical-starting ladder and fail MERGED-AWARE at the cap: the PR is
790
807
  * already merged, so dependents stay released.
791
808
  */
792
- function enterRecovery(ctx, state, unit, causeEvent, cause, truth, evidence = {}) {
809
+ function enterRecovery(ctx, state, unit, causeEvent, cause, truth, evidence = {},
810
+ // #629: `escalate: false` for a CONFIRMED provider API error — it must
811
+ // never consume the per-unit escalation ladder or the ESCALATION_CAP
812
+ // (a spend/rate wall is not the issue's fault), so the respawn keeps the
813
+ // slot's CURRENT tier and `recoveries` count unchanged. Every other caller
814
+ // keeps the default (`true`), unaffected.
815
+ options = {}) {
816
+ const escalate = options.escalate ?? true;
793
817
  const issue = (0, journal_1.issueOfUnit)(unit);
794
818
  if (issue === null)
795
819
  return state;
@@ -800,21 +824,36 @@ function enterRecovery(ctx, state, unit, causeEvent, cause, truth, evidence = {}
800
824
  return state;
801
825
  killUnitAgent(ctx, state, unit);
802
826
  // #524: only the STALL path kills a still-live, not-yet-recorded agent —
803
- // `causeEvent === 'verify-incomplete'` arrives from `completeUnitOrRecover`
804
- // AFTER the agent's exit was already detected and recorded by the dead-pid
805
- // branch of `reconcileRunning`; recording again here would double-count
806
- // that same dispatch. #591: a stall kill still has a fresh log slice worth
807
- // reading for `last_tool` — a hung agent's last tool is exactly what tells
808
- // an operator what it hung in.
827
+ // `causeEvent === 'verify-incomplete'`/`'dispatch-failure'` arrive from
828
+ // `completeUnitOrRecover` AFTER the agent's exit was already detected and
829
+ // recorded by the dead-pid branch of `reconcileRunning`; recording again
830
+ // here would double-count that same dispatch. #591: a stall kill still has
831
+ // a fresh log slice worth reading for `last_tool` — a hung agent's last
832
+ // tool is exactly what tells an operator what it hung in.
809
833
  if (causeEvent === 'stalled') {
810
- const stallLastTool = recordDispatchRunLog(ctx, state, slot, unit);
834
+ const stallLastTool = recordDispatchRunLog(ctx, state, slot, unit).lastTool;
811
835
  if (stallLastTool !== null)
812
836
  evidence = { ...evidence, last_tool: stallLastTool };
813
837
  }
814
838
  const report = (0, state_1.isReportSlot)(slot);
815
- const nextTier = report ? (0, dispatch_1.reportTierFor)(slot.recoveries + 1) : (0, dispatch_1.escalateTier)(entry.tier);
816
- if (slot.recoveries >= types_1.ESCALATION_CAP || nextTier === null) {
817
- return failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, evidence);
839
+ let resolvedTier;
840
+ if (escalate) {
841
+ const escalated = report ? (0, dispatch_1.reportTierFor)(slot.recoveries + 1) : (0, dispatch_1.escalateTier)(entry.tier);
842
+ if (slot.recoveries >= types_1.ESCALATION_CAP || escalated === null) {
843
+ // #629: unreachable with `causeEvent === 'dispatch-failure'` — that
844
+ // cause always passes `escalate: false` above, so this branch (and the
845
+ // narrower `causeEvent` type `failOrAdoptOpenPr` declares) is never
846
+ // actually asked to terminally fail a unit over a provider wall.
847
+ return failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, evidence);
848
+ }
849
+ resolvedTier = escalated;
850
+ }
851
+ else {
852
+ // An unescalated redispatch keeps the CURRENT tier — `reportTierFor`
853
+ // evaluated at the unchanged `slot.recoveries` for a report slot (mirrors
854
+ // `recordDispatchRunLog`'s own tier expression), `entry.tier` verbatim
855
+ // otherwise — never `escalateTier`/`recoveries + 1`.
856
+ resolvedTier = report ? ((0, dispatch_1.reportTierFor)(slot.recoveries) ?? entry.tier) : entry.tier;
818
857
  }
819
858
  // Fence BEFORE the respawn (#504 AC1/AC4): `killUnitAgent` above only reaches a pid
820
859
  // this process can see and signal, and #472 proved that is not the same as a dead
@@ -823,17 +862,17 @@ function enterRecovery(ctx, state, unit, causeEvent, cause, truth, evidence = {}
823
862
  const fenced = writeFence(ctx, unit, issue, slot, truth);
824
863
  let next = (0, state_1.transitionSlot)(state, slot.id, 'recovering', {
825
864
  pid: null,
826
- recoveries: slot.recoveries + 1,
865
+ recoveries: escalate ? slot.recoveries + 1 : slot.recoveries,
827
866
  gen: fenced ?? slot.gen,
828
867
  // Only a fence that actually landed starts the short takeover watch: an
829
868
  // unfenced redispatch is already degraded, and cutting its allowance down
830
869
  // would compound one failure with another.
831
870
  fenced_at: fenced === null ? null : now.toISOString(),
832
871
  }, now);
833
- if (!report) {
872
+ if (!report && escalate) {
834
873
  next = {
835
874
  ...next,
836
- entries: next.entries.map((e) => e.issue === issue ? { ...e, tier: nextTier, updated_at: now.toISOString() } : e),
875
+ entries: next.entries.map((e) => e.issue === issue ? { ...e, tier: resolvedTier, updated_at: now.toISOString() } : e),
837
876
  };
838
877
  }
839
878
  journal(ctx, causeEvent, unit, {
@@ -842,10 +881,22 @@ function enterRecovery(ctx, state, unit, causeEvent, cause, truth, evidence = {}
842
881
  ...(slot.last_progress_at !== null ? { last_progress_at: slot.last_progress_at } : {}),
843
882
  ...evidence,
844
883
  });
884
+ // #629: an UNESCALATED redispatch (a confirmed provider API error) has no
885
+ // natural bound — `recoveries`/`ESCALATION_CAP` never advance for it — so
886
+ // once the dispatch-health pause has fired, respawning it every tick would
887
+ // reproduce the exact incident this fix exists to stop, just on the
888
+ // per-issue rail instead of the batch one (`runBatchTick` gets the
889
+ // equivalent gate below). Hold the slot in `recovering`, unspawned, until
890
+ // `sched resume`: `reconcileRecovering` (below) is what retries it once the
891
+ // pause clears — no separate re-classification happens while parked, since
892
+ // this function is reached exactly once per dead dispatch.
893
+ if (!escalate && next.paused) {
894
+ return next;
895
+ }
845
896
  journal(ctx, 'redispatched', unit, {
846
- tier: nextTier,
897
+ tier: resolvedTier,
847
898
  slot: slot.id,
848
- ...(0, dispatch_1.journalCmdModelFields)((0, dispatch_1.resolveTierSpawn)(ctx.dispatch, nextTier, issue)),
899
+ ...(0, dispatch_1.journalCmdModelFields)((0, dispatch_1.resolveTierSpawn)(ctx.dispatch, resolvedTier, issue)),
849
900
  });
850
901
  ctx.result.redispatched.push(unit);
851
902
  // Respawn immediately on the recovering rail — recovering → running. A
@@ -980,6 +1031,96 @@ function applyProgressSignals(ctx, state, slot, truth, unit) {
980
1031
  function effectiveClosedSignal(slot, truth) {
981
1032
  return (0, state_1.isReportSlot)(slot) ? false : truth.closed;
982
1033
  }
1034
+ /**
1035
+ * Journal `event` for `unit` on the first tick of a new streak, then again
1036
+ * only every `JOURNAL_DEDUP_REANNOUNCE_TICKS` ticks while it persists (#632).
1037
+ * That window is a TICK count, not a duration: the sites reached every
1038
+ * reconcile re-announce at ~20 min on the default 60 s interval, while
1039
+ * `reconcileParked`/`reconcileStaleFailedParks` are gated on `prPoll.ran` and
1040
+ * so advance once per 150 s PR poll, ~50 min. Both intervals are
1041
+ * operator-tunable — which is why each entry also carries `since`
1042
+ * — `ground-truth-unreachable` and `pr-watch-waiting` were previously
1043
+ * journaled every tick the condition held, for as long as it lasted (an
1044
+ * outage produced one entry every reconcile interval, per affected unit, for
1045
+ * up to `max_slots` units at once).
1046
+ *
1047
+ * `sinceOf`/`ticksOf` read the entry's marker for THIS event family and
1048
+ * `withMarker` writes it back — `ground-truth-unreachable` and
1049
+ * `pr-watch-waiting` dedup independently via two separate marker pairs on
1050
+ * `QueueEntry` (mirrors #630's `pr_watch_failed_*` on `BatchEntry`, scoped
1051
+ * to the issue instead of the batch: these sites span both slot-held units
1052
+ * and parked/stale-failed ones with no live slot, and `QueueEntry` is the
1053
+ * one record every unit has either way).
1054
+ *
1055
+ * A caller with no `QueueEntry` to key on (should not happen for a real
1056
+ * issue-dispatch unit) journals unconditionally rather than silently drop
1057
+ * the line — the pre-#632 behavior, and safe: it can only make the journal
1058
+ * more verbose, never hide a real condition.
1059
+ *
1060
+ * Returns the patched state — callers must thread it, or the marker is lost
1061
+ * and the event re-fires next tick as if nothing had been recorded.
1062
+ */
1063
+ function journalConditionIfDue(ctx, state, unit, event, sinceOf, ticksOf, withMarker, extra) {
1064
+ const issue = (0, journal_1.issueOfUnit)(unit);
1065
+ const entry = issue === null ? undefined : (0, state_1.findEntry)(state, issue);
1066
+ if (issue === null || entry === undefined) {
1067
+ journal(ctx, event, unit, extra);
1068
+ return state;
1069
+ }
1070
+ const isNewStreak = sinceOf(entry) === null;
1071
+ const ticks = isNewStreak ? 1 : ticksOf(entry) + 1;
1072
+ const now = ctx.deps.now();
1073
+ const since = isNewStreak ? now.toISOString() : sinceOf(entry);
1074
+ if (isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0) {
1075
+ // `at` is the decision clock (AC3); `since` is the streak's onset. Both
1076
+ // are needed: `ticks_persisted` is a TICK count, and the two rails tick at
1077
+ // different, operator-tunable rates, so it maps to no fixed wall-clock.
1078
+ journal(ctx, event, unit, {
1079
+ ...extra,
1080
+ at: now.toISOString(),
1081
+ since,
1082
+ ticks_persisted: ticks,
1083
+ });
1084
+ }
1085
+ // `touchUpdatedAt: false` — this marker is dedup bookkeeping, not a
1086
+ // substantive change to the entry; `QueueEntry.updated_at` is relied on
1087
+ // elsewhere (`isStaleFailedPark`'s window, `status.ts`'s "since" display,
1088
+ // `readiness.ts`'s dispatch tiebreak) as a clock that must not reset on a
1089
+ // silent tick.
1090
+ return (0, state_1.patchEntry)(state, issue, withMarker(since, ticks), now, false);
1091
+ }
1092
+ function journalGroundTruthUnreachableIfDue(ctx, state, unit, extra) {
1093
+ return journalConditionIfDue(ctx, state, unit, 'ground-truth-unreachable', (e) => e.ground_truth_unreachable_since, (e) => e.ground_truth_unreachable_ticks, (since, ticks) => ({
1094
+ ground_truth_unreachable_since: since,
1095
+ ground_truth_unreachable_ticks: ticks,
1096
+ }), extra);
1097
+ }
1098
+ function journalPrWatchWaitingIfDue(ctx, state, unit, extra) {
1099
+ return journalConditionIfDue(ctx, state, unit, 'pr-watch-waiting', (e) => e.pr_watch_waiting_since, (e) => e.pr_watch_waiting_ticks, (since, ticks) => ({ pr_watch_waiting_since: since, pr_watch_waiting_ticks: ticks }), extra);
1100
+ }
1101
+ /**
1102
+ * Clear a `QueueEntry`'s `ground-truth-unreachable` marker once truth
1103
+ * answers again (#632) — a no-op when nothing was set, so callers can call
1104
+ * this unconditionally on every tick truth is healthy without churning
1105
+ * `updated_at` for entries that were never in a streak.
1106
+ */
1107
+ function clearGroundTruthUnreachable(state, unit, now) {
1108
+ const issue = (0, journal_1.issueOfUnit)(unit);
1109
+ const entry = issue === null ? undefined : (0, state_1.findEntry)(state, issue);
1110
+ if (issue === null || entry === undefined || entry.ground_truth_unreachable_since === null) {
1111
+ return state;
1112
+ }
1113
+ return (0, state_1.patchEntry)(state, issue, { ground_truth_unreachable_since: null, ground_truth_unreachable_ticks: 0 }, now, false);
1114
+ }
1115
+ /** Same as `clearGroundTruthUnreachable`, for the `pr-watch-waiting` marker. */
1116
+ function clearPrWatchWaiting(state, unit, now) {
1117
+ const issue = (0, journal_1.issueOfUnit)(unit);
1118
+ const entry = issue === null ? undefined : (0, state_1.findEntry)(state, issue);
1119
+ if (issue === null || entry === undefined || entry.pr_watch_waiting_since === null) {
1120
+ return state;
1121
+ }
1122
+ return (0, state_1.patchEntry)(state, issue, { pr_watch_waiting_since: null, pr_watch_waiting_ticks: 0 }, now, false);
1123
+ }
983
1124
  /**
984
1125
  * #575: journal the ignored stale milestone — shared by `reconcileRunning`'s
985
1126
  * external-advance check and `completeUnitOrRecover`'s verify-complete check,
@@ -1026,22 +1167,24 @@ function journalStaleMilestoneIfIgnored(ctx, state, unit, slot, truth, verifiedC
1026
1167
  });
1027
1168
  return (0, state_1.patchSlot)(state, slot.id, { stale_milestone_ignored_for: slot.spawned_at }, now);
1028
1169
  }
1170
+ const NO_DISPATCH_SIGNALS = { lastTool: null, apiError: null };
1029
1171
  /**
1030
- * Read THIS dispatch's last tool call from its log slice (#591, #620) — a
1031
- * pure, side-effect-free parse, safe to call from any slot status and any
1032
- * number of times: unlike `recordDispatchRunLog` below, it writes nothing to
1033
- * `runs.jsonl`, so it carries no exactly-once constraint. Exists because a
1034
- * `verify-incomplete`/`unit-failed` decision can land on a LATER tick than
1035
- * the one that detected the dead pid (e.g. ground truth was unreachable in
1036
- * between) — by then the slot has moved past `running` and
1037
- * `recordDispatchRunLog`'s guard refuses to re-read, but the dispatch's log
1038
- * file is static once the agent has exited, so re-parsing it here yields the
1039
- * same answer every time.
1172
+ * Read THIS dispatch's last tool call and API-error classification (#591,
1173
+ * #620, #629) from its log slice — a pure, side-effect-free parse, safe to
1174
+ * call from any slot status and any number of times: unlike
1175
+ * `recordDispatchRunLog` below, it writes nothing to `runs.jsonl`, so it
1176
+ * carries no exactly-once constraint. Exists because a `verify-incomplete`/
1177
+ * `dispatch-failure` decision can land on a LATER tick than the one that
1178
+ * detected the dead pid (e.g. ground truth was unreachable in between) — by
1179
+ * then the slot has moved past `running` and `recordDispatchRunLog`'s guard
1180
+ * refuses to re-read, but the dispatch's log file is static once the agent
1181
+ * has exited, so re-parsing it here yields the same answer every time.
1040
1182
  */
1041
- function readLastToolForSlot(ctx, slot, unit) {
1183
+ function readDispatchSignalsForSlot(ctx, slot, unit) {
1042
1184
  if (slot.spawned_at === null)
1043
- return null;
1044
- return (0, core_1.parseLastToolUse)(dispatchLogSlice(ctx, slot, unit).content);
1185
+ return NO_DISPATCH_SIGNALS;
1186
+ const { content } = dispatchLogSlice(ctx, slot, unit);
1187
+ return { lastTool: (0, core_1.parseLastToolUse)(content), apiError: (0, core_1.parseDispatchApiError)(content) };
1045
1188
  }
1046
1189
  /**
1047
1190
  * THIS dispatch's slice of the per-unit log: path, start offset, contents.
@@ -1088,7 +1231,7 @@ function recordDispatchRunLog(ctx, state, slot, unit) {
1088
1231
  reason: slot.spawned_at === null ? 'never-spawned' : `already-recorded-${slot.status}`,
1089
1232
  slot: slot.id,
1090
1233
  });
1091
- return null;
1234
+ return NO_DISPATCH_SIGNALS;
1092
1235
  }
1093
1236
  const issue = (0, journal_1.issueOfUnit)(unit);
1094
1237
  const entry = issue === null ? undefined : (0, state_1.findEntry)(state, issue);
@@ -1100,7 +1243,7 @@ function recordDispatchRunLog(ctx, state, slot, unit) {
1100
1243
  journal(ctx, 'run-log-skipped', unit, {
1101
1244
  reason: issue === null ? 'not-an-issue-unit' : 'entry-gone',
1102
1245
  });
1103
- return null;
1246
+ return NO_DISPATCH_SIGNALS;
1104
1247
  }
1105
1248
  // Report slots ride the same tier the cycle escalation ladder set at
1106
1249
  // dispatch time, EXCEPT `entry.tier` is deliberately not updated for a
@@ -1129,7 +1272,9 @@ function recordDispatchRunLog(ctx, state, slot, unit) {
1129
1272
  (0, run_log_1.finalizeRunLogEntry)(runEntry, logContent, ctx.deps.homeDir, (event, extra) => journal(ctx, event, unit, extra), { log: logFile, offset });
1130
1273
  // #591: the last tool this dispatch called, so an unverified exit attributes to a
1131
1274
  // concrete cause (e.g. `Monitor`) without opening the transcript — see `enterRecovery`.
1132
- return (0, core_1.parseLastToolUse)(logContent);
1275
+ // #629: whether this SAME dispatch was a confirmed provider API error — see
1276
+ // `completeUnitOrRecover`.
1277
+ return { lastTool: (0, core_1.parseLastToolUse)(logContent), apiError: (0, core_1.parseDispatchApiError)(logContent) };
1133
1278
  }
1134
1279
  /** Reconcile one running slot against its polled ground truth. */
1135
1280
  function reconcileRunning(ctx, state, slot, truth, unit) {
@@ -1138,21 +1283,25 @@ function reconcileRunning(ctx, state, slot, truth, unit) {
1138
1283
  // is DETECTED, never trusted as completion (AC2/AC3).
1139
1284
  if (slot.pid !== null && !ctx.deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined)) {
1140
1285
  journal(ctx, 'exit-detected', unit, { pid: slot.pid, slot: slot.id });
1141
- const lastTool = recordDispatchRunLog(ctx, state, slot, unit);
1286
+ const signals = recordDispatchRunLog(ctx, state, slot, unit);
1142
1287
  const exited = (0, state_1.transitionSlot)(state, slot.id, 'exited', {}, now);
1143
- return completeUnitOrRecover(ctx, exited, unit, truth, 'verify-complete', lastTool);
1288
+ return completeUnitOrRecover(ctx, exited, unit, truth, 'verify-complete', signals);
1144
1289
  }
1145
1290
  // The milestone poll FAILED (gh outage, missing binary) — unreachable is NOT
1146
1291
  // known-absent (decision 2, option A): stall and advance decisions pause for
1147
1292
  // this unit until truth returns. The dead-pid rail above still ran — local
1148
1293
  // truth needs no network.
1149
1294
  if (!truth.reachable) {
1150
- journal(ctx, 'ground-truth-unreachable', unit, {
1295
+ return journalGroundTruthUnreachableIfDue(ctx, state, unit, {
1151
1296
  slot: slot.id,
1152
1297
  detail: 'stall/advance decisions paused until truth returns',
1153
1298
  });
1154
- return state;
1155
1299
  }
1300
+ // #632: truth answered this tick — any unreachable streak recorded against
1301
+ // a PREVIOUS tick is over.
1302
+ // Named for what it holds — a SchedState with the streak cleared — not for
1303
+ // `truth.reachable`, the boolean five lines up.
1304
+ const cleared = clearGroundTruthUnreachable(state, unit, now);
1156
1305
  // Ground truth says the unit is DONE while the agent still holds the slot —
1157
1306
  // externally-advanced state (AC3): reclaim the slot, kill the leftover agent.
1158
1307
  // A parked milestone is deliberately NOT an advance: a detached run parks
@@ -1165,7 +1314,7 @@ function reconcileRunning(ctx, state, slot, truth, unit) {
1165
1314
  const verifiedComplete = (0, groundtruth_1.isVerifiedComplete)(truth.milestone, closedSignal, slot.spawned_at);
1166
1315
  // Threaded, not discarded: the once-per-dispatch marker lives in the state
1167
1316
  // this returns.
1168
- const marked = journalStaleMilestoneIfIgnored(ctx, state, unit, slot, truth, verifiedComplete, closedSignal);
1317
+ const marked = journalStaleMilestoneIfIgnored(ctx, cleared, unit, slot, truth, verifiedComplete, closedSignal);
1169
1318
  if (verifiedComplete && !(0, groundtruth_1.isParkedMilestone)(truth.milestone)) {
1170
1319
  journal(ctx, 'external-advance', unit, {
1171
1320
  pid: slot.pid,
@@ -1262,11 +1411,13 @@ function recordDispatchOutcome(ctx, state, unit, slot, suspect) {
1262
1411
  * event.
1263
1412
  */
1264
1413
  function completeUnitOrRecover(ctx, state, unit, truth, via,
1265
- // #591: the last tool this dispatch called (from `recordDispatchRunLog`'s dead-pid
1266
- // reading), threaded through to `enterRecovery`'s `unverified-exit` evidence. The
1267
- // `external-advance` caller passes none — it DOES record a run log (`reconcileRunning`,
1268
- // #524), but ground truth already confirmed completion, so this path never reaches the
1269
- // `unverified-exit` branch and the tool name has nothing to attribute.
1414
+ // #591/#629: the last tool called AND whether the exit was a confirmed
1415
+ // provider API error (from `recordDispatchRunLog`'s dead-pid reading),
1416
+ // threaded through to the unverified-exit decision below. The
1417
+ // `external-advance` caller passes none — it DOES record a run log
1418
+ // (`reconcileRunning`, #524), but ground truth already confirmed
1419
+ // completion, so this path never reaches that decision and neither signal
1420
+ // has anything to attribute.
1270
1421
  //
1271
1422
  // #620: a THUNK, not a value, for the deferred `exited`/`verifying` rail — that caller
1272
1423
  // has no read of its own to hand over and would otherwise have to read the dispatch log
@@ -1275,7 +1426,7 @@ function completeUnitOrRecover(ctx, state, unit, truth, via,
1275
1426
  // MAX_DISPATCH_LOG_BYTES (32 MiB) and its size is set by the spawned agent, so an
1276
1427
  // eager read is up to 32 MiB per slot per tick for as long as an outage holds slots in
1277
1428
  // `verifying`. Resolved once, only on the branch that consumes it.
1278
- lastTool = null) {
1429
+ dispatchSignals = NO_DISPATCH_SIGNALS) {
1279
1430
  const now = ctx.deps.now();
1280
1431
  let next = state;
1281
1432
  let slot = slotOf(next, unit);
@@ -1289,11 +1440,10 @@ lastTool = null) {
1289
1440
  // hold the exit in `verifying` until truth returns, then decide. The agent
1290
1441
  // is already gone; no slot work is lost by waiting.
1291
1442
  if (!truth.reachable) {
1292
- journal(ctx, 'ground-truth-unreachable', unit, {
1443
+ return journalGroundTruthUnreachableIfDue(ctx, next, unit, {
1293
1444
  slot: slot.id,
1294
1445
  detail: 'exit verification paused until truth returns',
1295
1446
  });
1296
- return next;
1297
1447
  }
1298
1448
  const issue = (0, journal_1.issueOfUnit)(unit);
1299
1449
  const entry = issue !== null ? (0, state_1.findEntry)(next, issue) : undefined;
@@ -1301,12 +1451,12 @@ lastTool = null) {
1301
1451
  // takes the unit (AC2's "never inferred from agent exit" cut both ways:
1302
1452
  // the park IS the milestone, the merge is not).
1303
1453
  if (entry !== undefined && entry.status === 'dispatched' && (0, groundtruth_1.isParkedMilestone)(truth.milestone)) {
1304
- // A verified park is also proof dispatch is healthy (#505) — reset the
1305
- // streak exactly like the sibling `completeUnit` branch below, or a
1306
- // healthy park sandwiched between two unrelated units' suspect exits
1307
- // would be invisible to the cross-unit correlation and could still tip
1308
- // it into a false-positive pause.
1309
- const parked = recordDispatchOutcome(ctx, next, unit, slot, false);
1454
+ // A verified park is also proof dispatch is healthy (#505/#629) — reset
1455
+ // both streaks exactly like the sibling `completeUnit` branch below, or a
1456
+ // healthy park sandwiched between two unrelated units' suspect/api-error
1457
+ // exits would be invisible to the correlation and could still tip it
1458
+ // into a false-positive pause.
1459
+ const parked = (0, dispatch_health_1.resetDispatchApiErrorStreak)(recordDispatchOutcome(ctx, next, unit, slot, false));
1310
1460
  const pr = (0, groundtruth_1.prOfMilestone)(truth.milestone); // non-null: isParkedMilestone guarantees it
1311
1461
  if (pr === null) {
1312
1462
  // Structurally unreachable, and deliberately not silent if it ever is:
@@ -1314,15 +1464,24 @@ lastTool = null) {
1314
1464
  // journaled leaves it re-deciding the same way every tick, forever,
1315
1465
  // against an empty trail. Return the recorded state (the pre-#596 form
1316
1466
  // did — `parkUnit`'s internal guard returned the state it was handed),
1317
- // not the un-recorded `next`.
1318
- journal(ctx, 'ground-truth-unreachable', unit, {
1467
+ // not the un-recorded `next`. #632: dedup like the sibling check above
1468
+ // — same marker, since only one of the two can be live for this unit
1469
+ // on a given tick (this branch is reached only after `truth.reachable`
1470
+ // already held).
1471
+ return journalGroundTruthUnreachableIfDue(ctx, parked, unit, {
1319
1472
  slot: slot.id,
1320
1473
  detail: `parked milestone (run=${truth.milestone?.run ?? 'unknown'}) carries no parseable pr= key — holding in verifying`,
1321
1474
  });
1322
- return parked;
1323
1475
  }
1324
- return parkUnit(ctx, parked, unit, pr);
1476
+ // #632: a pr= key was found — both flavors of this unit's
1477
+ // ground-truth-unreachable streak (unreachable poll, unparseable pr=)
1478
+ // are resolved.
1479
+ return parkUnit(ctx, clearGroundTruthUnreachable(parked, unit, now), unit, pr);
1325
1480
  }
1481
+ // #632: truth answered this tick and this unit isn't stuck on the
1482
+ // missing-pr= edge above — any streak recorded against a PREVIOUS tick
1483
+ // (either flavor) is over.
1484
+ next = clearGroundTruthUnreachable(next, unit, now);
1326
1485
  // #575: fence to THIS dispatch's `spawned_at` — an agent that exited having
1327
1486
  // posted nothing new must not read as complete against the issue's
1328
1487
  // PREVIOUS run's report milestone; it falls through to the recovery ladder
@@ -1331,18 +1490,30 @@ lastTool = null) {
1331
1490
  const verifiedComplete = (0, groundtruth_1.isVerifiedComplete)(truth.milestone, closedSignal, slot.spawned_at);
1332
1491
  next = journalStaleMilestoneIfIgnored(ctx, next, unit, slot, truth, verifiedComplete, closedSignal);
1333
1492
  if (verifiedComplete) {
1334
- return completeUnit(ctx, recordDispatchOutcome(ctx, next, unit, slot, false), unit, via);
1493
+ const completed = (0, dispatch_health_1.resetDispatchApiErrorStreak)(recordDispatchOutcome(ctx, next, unit, slot, false));
1494
+ return completeUnit(ctx, completed, unit, via);
1495
+ }
1496
+ const resolved = typeof dispatchSignals === 'function' ? dispatchSignals() : dispatchSignals;
1497
+ // #629: a CONFIRMED provider API error is a dispatch failure, never an
1498
+ // unverified exit — it skips `recordDispatchOutcome`'s timing-based
1499
+ // suspect check entirely (the classification is deterministic, not a
1500
+ // heuristic) and never reaches `enterRecovery`'s escalation.
1501
+ if (resolved.apiError) {
1502
+ // `journalFailure: false` — `enterRecovery` journals its own `causeEvent`
1503
+ // (`'dispatch-failure'`) with this same evidence immediately below; without
1504
+ // the flag this rail double-journals every confirmed error (#629 review).
1505
+ const recorded = (0, dispatch_health_1.recordDispatchApiError)((event, u, extra) => journal(ctx, event, u, extra), next, unit, resolved.apiError, { journalFailure: false });
1506
+ return enterRecovery(ctx, recorded, unit, 'dispatch-failure', (0, dispatch_health_1.dispatchApiErrorDetail)(resolved.apiError), truth, (0, dispatch_health_1.dispatchApiErrorFields)(resolved.apiError), { escalate: false });
1335
1507
  }
1336
1508
  const suspect = msSinceLastProgress(slot, now) < types_1.SUSPECT_DISPATCH_WINDOW_MS;
1337
1509
  next = recordDispatchOutcome(ctx, next, unit, slot, suspect);
1338
- const resolvedLastTool = typeof lastTool === 'function' ? lastTool() : lastTool;
1339
1510
  return enterRecovery(ctx, next, unit, 'verify-incomplete', 'unverified-exit', truth, {
1340
1511
  observed: truth.milestone
1341
1512
  ? `milestone ${truth.milestone.phase}/${truth.milestone.status}; closed=${truth.closed}`
1342
1513
  : `no milestone; closed=${truth.closed}`,
1343
1514
  // #591: attributes the unverified exit to a concrete cause (e.g. `Monitor`)
1344
1515
  // without opening the transcript. Omitted when the log yielded no tool_use.
1345
- ...(resolvedLastTool !== null ? { last_tool: resolvedLastTool } : {}),
1516
+ ...(resolved.lastTool !== null ? { last_tool: resolved.lastTool } : {}),
1346
1517
  });
1347
1518
  }
1348
1519
  /** Re-attach or spawn a slot left `assigned` by a crash between assign and spawn. */
@@ -1361,6 +1532,14 @@ function reconcileAssigned(ctx, state, slot, unit) {
1361
1532
  }
1362
1533
  /** Reconcile a `recovering` slot: respawn with the escalated tier. */
1363
1534
  function reconcileRecovering(ctx, state, unit) {
1535
+ // #629: while paused, do not resume a `recovering` slot's respawn — this is
1536
+ // the crash-recovery rail (a sched restart caught a slot between
1537
+ // `enterRecovery`'s transition and its own `spawnUnit` call, OR `enterRecovery`
1538
+ // itself deliberately parked an unescalated redispatch here, see its own
1539
+ // pause check). Either way, `sched resume` is what lets the tick loop reach
1540
+ // this function again and actually respawn.
1541
+ if (state.paused)
1542
+ return state;
1364
1543
  return spawnUnit(ctx, state, unit);
1365
1544
  }
1366
1545
  // --- Tick phases ---
@@ -1402,7 +1581,7 @@ function reconcileSlots(ctx, state, polled) {
1402
1581
  // whatever `reconcileRunning` saw. Passed as a thunk so the read
1403
1582
  // happens only on the tick that actually reaches the unverified-exit
1404
1583
  // decision, not on every tick an outage holds the slot here.
1405
- next = completeUnitOrRecover(ctx, next, unit, truth, 'verify-complete', () => readLastToolForSlot(ctx, slot, unit));
1584
+ next = completeUnitOrRecover(ctx, next, unit, truth, 'verify-complete', () => readDispatchSignalsForSlot(ctx, slot, unit));
1406
1585
  break;
1407
1586
  case 'recovering':
1408
1587
  next = reconcileRecovering(ctx, next, unit);
@@ -1422,7 +1601,12 @@ function requeueOrphanedDispatches(ctx, state) {
1422
1601
  continue;
1423
1602
  const now = ctx.deps.now();
1424
1603
  next = (0, state_1.transitionIssue)(next, entry.issue, 'blocked', { reason: 'orphaned-dispatch' }, now);
1425
- next = (0, state_1.transitionIssue)(next, entry.issue, 'queued', { reason: null }, now);
1604
+ // #633: same rule as `requeueMember` — a requeue is a fresh attempt, so a
1605
+ // dedup streak recorded against the PREVIOUS dispatch must not suppress
1606
+ // that condition's first occurrence on this one. Without it, a crash
1607
+ // during an outage leaves `ground_truth_unreachable_ticks` mid-streak and
1608
+ // the redispatched unit stays silent until the next re-announcement.
1609
+ next = (0, state_1.transitionIssue)(next, entry.issue, 'queued', { reason: null, ...state_1.CLEARED_ENTRY_DEDUP_MARKERS }, now);
1426
1610
  journal(ctx, 'requeued', `issue:${entry.issue}`, {
1427
1611
  detail: 'orphaned dispatch requeued after restart',
1428
1612
  });
@@ -1457,14 +1641,18 @@ function reconcileParked(ctx, state, prPoll) {
1457
1641
  if (!prPoll.truths.has(issue)) {
1458
1642
  continue; // parked AFTER the poll ran (this tick) — next cadence picks it up
1459
1643
  }
1460
- journal(ctx, 'ground-truth-unreachable', unit, {
1644
+ next = journalGroundTruthUnreachableIfDue(ctx, next, unit, {
1461
1645
  detail: 'pr watch paused until truth returns',
1462
1646
  });
1463
1647
  continue;
1464
1648
  }
1649
+ // #632: truth answered this tick — any unreachable streak recorded
1650
+ // against a PREVIOUS tick is over.
1651
+ next = clearGroundTruthUnreachable(next, unit, ctx.deps.now());
1465
1652
  const failWatch = (reason) => {
1466
1653
  journal(ctx, 'pr-watch-failed', unit, { reason, pr: entry.pr });
1467
- return failUnit(ctx, next, unit, reason);
1654
+ // #632: the watch is ending (terminal failure) — no more "waiting".
1655
+ return clearPrWatchWaiting(failUnit(ctx, next, unit, reason), unit, ctx.deps.now());
1468
1656
  };
1469
1657
  // #501: MERGED is checked FIRST, before any failure rail. A PR that is
1470
1658
  // genuinely merged is merged regardless of a leftover `auto-merge-blocked`
@@ -1478,7 +1666,7 @@ function reconcileParked(ctx, state, prPoll) {
1478
1666
  // AC1: the issue must ALSO be closed (a merged PR auto-closes it) —
1479
1667
  // until GitHub propagates, the unit stays parked and keeps watching.
1480
1668
  if (prPoll.closed.get(issue) !== true) {
1481
- journal(ctx, 'pr-watch-waiting', unit, {
1669
+ next = journalPrWatchWaitingIfDue(ctx, next, unit, {
1482
1670
  pr: entry.pr,
1483
1671
  mergedAt: truth.mergedAt,
1484
1672
  detail: 'merge seen but issue not closed — keep watching',
@@ -1486,6 +1674,7 @@ function reconcileParked(ctx, state, prPoll) {
1486
1674
  continue;
1487
1675
  }
1488
1676
  next = (0, state_1.transitionIssue)(next, issue, 'shipped', { reason: null }, ctx.deps.now());
1677
+ next = clearPrWatchWaiting(next, unit, ctx.deps.now());
1489
1678
  journal(ctx, 'merge-accepted', unit, { pr: entry.pr, mergedAt: truth.mergedAt });
1490
1679
  ctx.result.mergeAccepted.push(unit);
1491
1680
  continue;
@@ -1501,7 +1690,12 @@ function reconcileParked(ctx, state, prPoll) {
1501
1690
  if (truth.state === 'CLOSED' && truth.mergedAt === null) {
1502
1691
  next = failWatch('pr-closed-unmerged');
1503
1692
  }
1504
- // OPEN (or mergeable UNKNOWN) — keep watching.
1693
+ else {
1694
+ // OPEN (or mergeable UNKNOWN) — keep watching. #632: this tick's truth
1695
+ // is NOT "merge seen but not closed", so a waiting streak from an
1696
+ // earlier tick's merge-then-reverted flicker is over.
1697
+ next = clearPrWatchWaiting(next, unit, ctx.deps.now());
1698
+ }
1505
1699
  }
1506
1700
  return next;
1507
1701
  }
@@ -1537,15 +1731,23 @@ function reconcileStaleFailedParks(ctx, state, prPoll) {
1537
1731
  if (!prPoll.truths.has(issue)) {
1538
1732
  continue; // failed AFTER the poll ran this tick — next cadence picks it up
1539
1733
  }
1540
- journal(ctx, 'ground-truth-unreachable', unit, {
1734
+ next = journalGroundTruthUnreachableIfDue(ctx, next, unit, {
1541
1735
  detail: 'stale-failure reconcile paused until truth returns',
1542
1736
  });
1543
1737
  continue;
1544
1738
  }
1545
- if (!isPrMerged(truth))
1546
- continue; // still blocked/open/conflicting — stays failed
1739
+ // #632: truth answered this tick — any unreachable streak recorded
1740
+ // against a PREVIOUS tick is over.
1741
+ next = clearGroundTruthUnreachable(next, unit, ctx.deps.now());
1742
+ if (!isPrMerged(truth)) {
1743
+ // Still blocked/open/conflicting — stays failed. #632: not "merge seen
1744
+ // but not closed" either, so a waiting streak from an earlier tick's
1745
+ // merge-then-reverted flicker is over.
1746
+ next = clearPrWatchWaiting(next, unit, ctx.deps.now());
1747
+ continue;
1748
+ }
1547
1749
  if (prPoll.closed.get(issue) !== true) {
1548
- journal(ctx, 'pr-watch-waiting', unit, {
1750
+ next = journalPrWatchWaitingIfDue(ctx, next, unit, {
1549
1751
  pr: entry.pr,
1550
1752
  mergedAt: truth.mergedAt,
1551
1753
  reason: AUTO_MERGE_BLOCKED_REASON,
@@ -1555,6 +1757,7 @@ function reconcileStaleFailedParks(ctx, state, prPoll) {
1555
1757
  }
1556
1758
  const failedAt = entry.updated_at;
1557
1759
  next = (0, state_1.transitionIssue)(next, issue, 'shipped', { reason: null }, ctx.deps.now());
1760
+ next = clearPrWatchWaiting(next, unit, ctx.deps.now());
1558
1761
  journal(ctx, 'stale-failure-reconciled', unit, {
1559
1762
  pr: entry.pr,
1560
1763
  mergedAt: truth.mergedAt,
@@ -1656,6 +1859,17 @@ function runTeardownFor(deps, issue) {
1656
1859
  const info = deps.groundTruth.setupInfo(issue);
1657
1860
  const unit = `issue:${issue}`;
1658
1861
  if (info === undefined) {
1862
+ // #633: AUDITED, NOT DEDUPED. This is a tenth site with #632's shape —
1863
+ // `teardownPendingIssues` reaches it every tick and a `null` return leaves
1864
+ // `cleanup` unset, so an unreachable `setupInfo` re-emits this line once
1865
+ // per reconcile interval for as long as the outage lasts.
1866
+ //
1867
+ // Left as-is deliberately: #632 enumerated nine sites and its plan makes
1868
+ // the boundary load-bearing ("a site outside the table is a NEW issue, not
1869
+ // a reason to widen this one") — widening is what dissolved b-07. The fix
1870
+ // is tracked in #636, and is not a one-liner here: `runTeardownFor` runs
1871
+ // OUTSIDE the store lock and has no `SchedState` to patch, so the marker
1872
+ // has to be threaded through the caller's second lock pass.
1659
1873
  deps.journal.append((0, journal_1.unitEvent)('ground-truth-unreachable', unit, {
1660
1874
  detail: 'teardown paused until truth returns',
1661
1875
  }), deps.now());