@ai-dossier/sched 0.22.1 → 0.23.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
@@ -121,17 +121,6 @@ function slotOf(state, unit) {
121
121
  function msSinceLastProgress(slot, now) {
122
122
  return now.getTime() - (slot.last_progress_at ? Date.parse(slot.last_progress_at) : 0);
123
123
  }
124
- /**
125
- * Metadata patch on a slot WITHOUT a status transition (pid/phase/branch/
126
- * last_head/last_progress are data, not machine states — RFC-0001 §D.3 keeps
127
- * them alongside the status, and the transition tables stay pure).
128
- */
129
- function patchSlot(state, slotId, patch, now) {
130
- return {
131
- ...state,
132
- slots: state.slots.map((s) => s.id === slotId ? { ...s, ...patch, updated_at: now.toISOString() } : s),
133
- };
134
- }
135
124
  /**
136
125
  * Walk a slot to `idle` one declared edge per iteration; `step` picks the
137
126
  * next status. Returns the released slot id — non-null only when the walk
@@ -479,7 +468,7 @@ function spawnAndRecord(ctx, state, unit, slot, opts) {
479
468
  };
480
469
  const next = slot.status === 'assigned' || slot.status === 'recovering'
481
470
  ? (0, state_1.transitionSlot)(state, slot.id, 'running', patch, now)
482
- : patchSlot(state, slot.id, patch, now);
471
+ : (0, state_1.patchSlot)(state, slot.id, patch, now);
483
472
  journal(ctx, 'spawned', unit, {
484
473
  pid,
485
474
  tier: opts.tier,
@@ -718,6 +707,80 @@ function writeFence(ctx, unit, issue, slot, truth) {
718
707
  });
719
708
  return outcome.gen;
720
709
  }
710
+ /**
711
+ * The end of the ladder: the unit has no stronger tier left, so it fails —
712
+ * UNLESS ground truth says its branch already carries an open PR the
713
+ * milestone trail never recorded (#596), in which case it parks and the
714
+ * watcher owns it. Extracted from `enterRecovery` so the terminal decision,
715
+ * which now has four distinct outcomes, is readable on its own.
716
+ *
717
+ * Fails closed (AC5): a report slot (no branch of its own — AC7), a stall
718
+ * (a hung agent never had the chance to open anything), an unknown branch,
719
+ * or an unreachable lookup all take the terminal path. Only a confirmed open
720
+ * PR number parks (AC4). Which of those four it was is journaled as
721
+ * `pr_check`, because "we checked and there was none" and "`gh` was down and
722
+ * we failed a unit whose PR may have been mergeable" are the same
723
+ * `unit-failed` line otherwise — and that indistinguishability is exactly
724
+ * what cost imboard-monorepo#3999 (docs/agent-traps.md).
725
+ */
726
+ function failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, evidence) {
727
+ let prCheck = 'skipped';
728
+ if (!report && causeEvent === 'verify-incomplete') {
729
+ if (slot.branch === null) {
730
+ prCheck = 'no-branch';
731
+ }
732
+ else {
733
+ const openPr = ctx.deps.groundTruth.openPrForBranch(slot.branch);
734
+ if (typeof openPr === 'number') {
735
+ // A unit that demonstrably opened a PR is proof this dispatch was
736
+ // healthy, whatever its exit looked like — reset the suspect streak
737
+ // exactly like the milestone-verified park in `completeUnitOrRecover`
738
+ // (#505), or a rescued unit still counts toward a false-positive
739
+ // `dispatch-unhealthy` pause. `completeUnitOrRecover` has already
740
+ // journaled this tick's `suspect-dispatch`; the reset following it is
741
+ // the correct record of a classification made on incomplete evidence
742
+ // and then retracted.
743
+ return parkUnit(ctx, recordDispatchOutcome(ctx, state, unit, slot, false), unit, openPr, {
744
+ detail: 'unverified-exit-recovered-open-pr',
745
+ branch: slot.branch,
746
+ });
747
+ }
748
+ prCheck = openPr === undefined ? 'unreachable' : 'none';
749
+ if (prCheck === 'unreachable') {
750
+ journal(ctx, 'ground-truth-unreachable', unit, {
751
+ slot: slot.id,
752
+ detail: `open-PR check for branch ${slot.branch} unreachable — failing closed; re-check with \`gh pr list --head ${slot.branch} --state open\` before writing this unit off`,
753
+ });
754
+ }
755
+ }
756
+ }
757
+ // Cap reached (2 escalations) or already at the strongest tier — the
758
+ // designed signal that a human, not a stronger model, is next.
759
+ const reason = report
760
+ ? 'report-escalation-cap'
761
+ : slot.recoveries >= types_1.ESCALATION_CAP
762
+ ? 'escalation-cap'
763
+ : `${cause}-at-strongest-tier`;
764
+ // #591/#620: surface `last_tool` on the terminal `unit-failed` journal
765
+ // entry too — the `evidence` object already carries it for the non-terminal
766
+ // `verify-incomplete` journal event, because BOTH rails that reach here
767
+ // with `causeEvent === 'verify-incomplete'` thread it through
768
+ // `completeUnitOrRecover`: `reconcileRunning`'s dead-pid rail, from
769
+ // `recordDispatchRunLog`'s own read, and (since #620) `reconcileSlots`'
770
+ // `exited`/`verifying` rail, from `readLastToolForSlot`. The two agree —
771
+ // the dispatch log is static once the agent has exited — so the tool name
772
+ // survives regardless of which tick reaches this decision. It stays
773
+ // OPTIONAL: a slice with no parseable `tool_use` yields null, hence the
774
+ // guard below.
775
+ const extra = {
776
+ ...(typeof evidence.last_tool === 'string' ? { last_tool: evidence.last_tool } : {}),
777
+ ...(prCheck !== 'skipped' ? { pr_check: prCheck } : {}),
778
+ };
779
+ return failUnit(ctx, state, unit, reason, {
780
+ merged: report,
781
+ extra: Object.keys(extra).length > 0 ? extra : undefined,
782
+ });
783
+ }
721
784
  /**
722
785
  * The recovery decision for a unit that must be redispatched one tier
723
786
  * stronger (stall or unverified exit, AC4). At the escalation cap or the
@@ -751,18 +814,7 @@ function enterRecovery(ctx, state, unit, causeEvent, cause, truth, evidence = {}
751
814
  const report = (0, state_1.isReportSlot)(slot);
752
815
  const nextTier = report ? (0, dispatch_1.reportTierFor)(slot.recoveries + 1) : (0, dispatch_1.escalateTier)(entry.tier);
753
816
  if (slot.recoveries >= types_1.ESCALATION_CAP || nextTier === null) {
754
- // Cap reached (2 escalations) or already at the strongest tier — the
755
- // designed signal that a human, not a stronger model, is next.
756
- const reason = report
757
- ? 'report-escalation-cap'
758
- : slot.recoveries >= types_1.ESCALATION_CAP
759
- ? 'escalation-cap'
760
- : `${cause}-at-strongest-tier`;
761
- // #591: surface `last_tool` on the terminal `unit-failed` journal entry too — the
762
- // `evidence` object already carries it (added above by `completeUnitOrRecover`)
763
- // for the non-terminal `verify-incomplete` journal event.
764
- const extra = typeof evidence.last_tool === 'string' ? { last_tool: evidence.last_tool } : undefined;
765
- return failUnit(ctx, state, unit, reason, { merged: report, extra });
817
+ return failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, evidence);
766
818
  }
767
819
  // Fence BEFORE the respawn (#504 AC1/AC4): `killUnitAgent` above only reaches a pid
768
820
  // this process can see and signal, and #472 proved that is not the same as a dead
@@ -843,23 +895,31 @@ function completeUnit(ctx, state, unit, via) {
843
895
  return withEntry;
844
896
  }
845
897
  /**
846
- * Park a unit whose agent exited after parking its PR on auto-merge (#468):
847
- * the exit is VERIFIED (the ship phase's `awaiting-merge` milestone with
848
- * `pr=`), entry → parked (pr recorded), slot released — a waiting unit
849
- * consumes zero slots (AC5) and the watcher owns it from here.
898
+ * Park a unit whose agent exited having already produced an open PR — the
899
+ * ship phase's `awaiting-merge` milestone with `pr=` (#468), or (#596) a
900
+ * terminal unverified exit whose branch ground truth found one the milestone
901
+ * trail never recorded: entry → parked (pr recorded), slot released — a
902
+ * waiting unit consumes zero slots (AC5) and the watcher owns it from here.
903
+ * `extra.detail` names WHICH path adopted the PR, so an operator reading
904
+ * `pr-parked` alone can tell a milestone-verified park from a
905
+ * recovery-adopted one (#596 AC3); `extra.branch` names the ref the PR was
906
+ * found on, without which "why is this unit parked on PR N?" has nothing to
907
+ * correlate against — `slot.branch` is captured from whichever milestone
908
+ * first carried one and is not re-derived between recovery redispatches.
909
+ *
910
+ * Typed rather than a `Record<string, unknown>` bag: both keys are declared
911
+ * `JournalEvent` fields, and routing them through an open record would
912
+ * discard the excess-property check they exist to get.
850
913
  */
851
- function parkUnit(ctx, state, unit, milestone) {
914
+ function parkUnit(ctx, state, unit, pr, extra = {}) {
852
915
  const issue = (0, journal_1.issueOfUnit)(unit);
853
916
  if (issue === null)
854
917
  return state;
855
918
  const now = ctx.deps.now();
856
- const pr = (0, groundtruth_1.prOfMilestone)(milestone);
857
- if (pr === null)
858
- return state; // isParkedMilestone guarantees this
859
919
  const walked = walkSlotToIdle(state, unit, now, stepVerifiedExitToIdle);
860
920
  let next = walked.state;
861
921
  next = (0, state_1.transitionIssue)(next, issue, 'parked', { pr }, now);
862
- journal(ctx, 'pr-parked', unit, { pr });
922
+ journal(ctx, 'pr-parked', unit, { pr, ...extra });
863
923
  ctx.result.parked.push(unit);
864
924
  journalSlotReleased(ctx, unit, walked.releasedSlotId, 'parked');
865
925
  return next;
@@ -873,13 +933,13 @@ function applyProgressSignals(ctx, state, slot, truth, unit) {
873
933
  if (truth.milestone !== null) {
874
934
  // Live phase per unit (AC6).
875
935
  if (truth.milestone.phase !== slot.phase) {
876
- next = patchSlot(next, slot.id, { phase: truth.milestone.phase }, now);
936
+ next = (0, state_1.patchSlot)(next, slot.id, { phase: truth.milestone.phase }, now);
877
937
  journal(ctx, 'phase-updated', unit, { phase: truth.milestone.phase, slot: slot.id });
878
938
  }
879
939
  // The setup milestone carries the branch name — capture it once so the
880
940
  // pushed-commit stall signal can watch the remote head.
881
941
  if (slot.branch === null && typeof truth.milestone.keys.branch === 'string') {
882
- next = patchSlot(next, slot.id, { branch: truth.milestone.keys.branch }, now);
942
+ next = (0, state_1.patchSlot)(next, slot.id, { branch: truth.milestone.keys.branch }, now);
883
943
  }
884
944
  if (Date.parse(truth.milestone.at) > Date.parse(slot.last_progress_at ?? '')) {
885
945
  progressed = true;
@@ -887,12 +947,12 @@ function applyProgressSignals(ctx, state, slot, truth, unit) {
887
947
  }
888
948
  if (truth.head !== null && truth.head !== slot.last_head) {
889
949
  progressed = true;
890
- next = patchSlot(next, slot.id, { last_head: truth.head }, now);
950
+ next = (0, state_1.patchSlot)(next, slot.id, { last_head: truth.head }, now);
891
951
  }
892
952
  if (progressed) {
893
953
  // The takeover is demonstrably alive, so the short fence watch has done its job and
894
954
  // the phase's ordinary stall allowance takes over from here (#504 AC4).
895
- next = patchSlot(next, slot.id, {
955
+ next = (0, state_1.patchSlot)(next, slot.id, {
896
956
  last_progress_at: now.toISOString(),
897
957
  ...(slot.fenced_at !== null ? { fenced_at: null } : {}),
898
958
  }, now);
@@ -927,21 +987,77 @@ function effectiveClosedSignal(slot, truth) {
927
987
  * when the raw milestone WOULD have completed the unit under the old,
928
988
  * unfenced rule (`isVerifiedComplete(milestone, false)`, `dispatchedAt`
929
989
  * omitted) but the fenced check just rejected it — i.e. a `report/done`
930
- * milestone that predates this dispatch's `spawned_at`. Journaled per-tick
931
- * while the condition holds, same convention as `ground-truth-unreachable`.
990
+ * milestone that predates this dispatch's `spawned_at`.
991
+ *
992
+ * #610 fixed this event on the batch member rail (`reconcileMemberSlot`,
993
+ * `batch-dispatch.ts`) and the same two defects were live here, on the rail
994
+ * that fires far more often:
995
+ *
996
+ * - CADENCE. It was journaled per-tick while the condition held, so a unit
997
+ * carrying one stale milestone emitted an identical line every reconcile
998
+ * interval for its whole run — roughly twenty for a 40-minute unit, which
999
+ * `tick.sh` then forwards to Telegram one by one. It is now gated on the
1000
+ * same `SlotEntry.stale_milestone_ignored_for` marker: at most once per
1001
+ * DISPATCH. The marker holds the `spawned_at` it was decided for, so a
1002
+ * redispatch's new `spawned_at` re-arms it with no reset site.
1003
+ * - PAYLOAD. `at` was the MILESTONE's timestamp here and the engine's
1004
+ * decision time on the batch rail — one event name, two meanings, and
1005
+ * nothing in the line to say which. Both rails now agree: `at` is the
1006
+ * decision time, `milestone_at` is how old the ignored milestone is.
1007
+ *
1008
+ * Returns the patched state (the marker is persisted) — callers must thread
1009
+ * it, or the event re-fires next tick as if nothing had been recorded.
932
1010
  */
933
- function journalStaleMilestoneIfIgnored(ctx, unit, slot, truth, verifiedComplete, closedSignal) {
1011
+ function journalStaleMilestoneIfIgnored(ctx, state, unit, slot, truth, verifiedComplete, closedSignal) {
934
1012
  const { milestone } = truth;
935
1013
  if (verifiedComplete || closedSignal || milestone === null)
936
- return;
1014
+ return state;
937
1015
  if (!(0, groundtruth_1.isVerifiedComplete)(milestone, false))
938
- return;
1016
+ return state;
1017
+ if (slot.stale_milestone_ignored_for === slot.spawned_at)
1018
+ return state;
1019
+ const now = ctx.deps.now();
939
1020
  journal(ctx, 'stale-milestone-ignored', unit, {
940
1021
  slot: slot.id,
941
1022
  run: milestone.run,
942
- at: milestone.at,
1023
+ at: now.toISOString(),
1024
+ milestone_at: milestone.at,
943
1025
  detail: `predates dispatch spawned_at=${slot.spawned_at}`,
944
1026
  });
1027
+ return (0, state_1.patchSlot)(state, slot.id, { stale_milestone_ignored_for: slot.spawned_at }, now);
1028
+ }
1029
+ /**
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.
1040
+ */
1041
+ function readLastToolForSlot(ctx, slot, unit) {
1042
+ if (slot.spawned_at === null)
1043
+ return null;
1044
+ return (0, core_1.parseLastToolUse)(dispatchLogSlice(ctx, slot, unit).content);
1045
+ }
1046
+ /**
1047
+ * THIS dispatch's slice of the per-unit log: path, start offset, contents.
1048
+ *
1049
+ * #524: the log is per-unit and append-mode, so reading from byte 0 would
1050
+ * include every PRIOR dispatch's output too (claude: unparseable JSON
1051
+ * concatenation; opencode: summed tokens double-counted). One reader so the
1052
+ * `?? 0` legacy default (a pre-1.7.0 slot has no recorded offset) cannot
1053
+ * drift between the two callers — `readLastToolForSlot`'s "always agrees
1054
+ * with whatever `reconcileRunning` saw" guarantee is exactly the claim that
1055
+ * a second copy of this derivation would quietly break.
1056
+ */
1057
+ function dispatchLogSlice(ctx, slot, unit) {
1058
+ const logFile = (0, dispatch_1.dispatchLogPath)(ctx.deps.store.runsDir, unit);
1059
+ const offset = slot.log_offset_at_spawn ?? 0;
1060
+ return { logFile, offset, content: (0, run_log_1.readDispatchLog)(logFile, offset) };
945
1061
  }
946
1062
  /**
947
1063
  * Append this dispatch's `runs.jsonl` entry (#524) — one per completed
@@ -996,13 +1112,8 @@ function recordDispatchRunLog(ctx, state, slot, unit) {
996
1112
  // dispatch.command/tierModels — so a mixed agent-CLI ladder's runs.jsonl
997
1113
  // entry (AC3) matches what was actually spawned for this dispatch.
998
1114
  const { cmd, model } = (0, dispatch_1.resolveTierSpawn)(ctx.dispatch, tier, issue);
999
- const logFile = (0, dispatch_1.dispatchLogPath)(ctx.deps.store.runsDir, unit);
1000
- // #524: read only THIS dispatch's slice — the log is per-unit and
1001
- // append-mode, so the whole file would include every prior dispatch's
1002
- // output too (claude: unparseable JSON concatenation; opencode: summed
1003
- // tokens double-counted).
1004
- const offset = slot.log_offset_at_spawn ?? 0;
1005
- const logContent = (0, run_log_1.readDispatchLog)(logFile, offset);
1115
+ // #524: read only THIS dispatch's slice — see `dispatchLogSlice`.
1116
+ const { logFile, offset, content: logContent } = dispatchLogSlice(ctx, slot, unit);
1006
1117
  const runEntry = (0, run_log_1.buildSchedRunLogEntry)({
1007
1118
  unit,
1008
1119
  role: slot.role,
@@ -1052,7 +1163,9 @@ function reconcileRunning(ctx, state, slot, truth, unit) {
1052
1163
  // — a re-enqueued issue's PREVIOUS run's report milestone must not read as
1053
1164
  // "complete" the moment the fresh agent's first tick polls it.
1054
1165
  const verifiedComplete = (0, groundtruth_1.isVerifiedComplete)(truth.milestone, closedSignal, slot.spawned_at);
1055
- journalStaleMilestoneIfIgnored(ctx, unit, slot, truth, verifiedComplete, closedSignal);
1166
+ // Threaded, not discarded: the once-per-dispatch marker lives in the state
1167
+ // this returns.
1168
+ const marked = journalStaleMilestoneIfIgnored(ctx, state, unit, slot, truth, verifiedComplete, closedSignal);
1056
1169
  if (verifiedComplete && !(0, groundtruth_1.isParkedMilestone)(truth.milestone)) {
1057
1170
  journal(ctx, 'external-advance', unit, {
1058
1171
  pid: slot.pid,
@@ -1062,15 +1175,15 @@ function reconcileRunning(ctx, state, slot, truth, unit) {
1062
1175
  // at completion regardless — the actual signal was the report milestone.
1063
1176
  detail: closedSignal ? 'issue closed' : `report done (role=${slot.role})`,
1064
1177
  });
1065
- killUnitAgent(ctx, state, unit);
1178
+ killUnitAgent(ctx, marked, unit);
1066
1179
  // The agent was still alive (that's what "externally-advanced" means) —
1067
1180
  // log it here too, or an external-advance dispatch would never get a
1068
1181
  // runs.jsonl entry at all (it never takes the dead-pid branch above).
1069
- recordDispatchRunLog(ctx, state, slot, unit);
1070
- const exited = (0, state_1.transitionSlot)(state, slot.id, 'exited', {}, now);
1182
+ recordDispatchRunLog(ctx, marked, slot, unit);
1183
+ const exited = (0, state_1.transitionSlot)(marked, slot.id, 'exited', {}, now);
1071
1184
  return completeUnitOrRecover(ctx, exited, unit, truth, 'external-advance');
1072
1185
  }
1073
- const progress = applyProgressSignals(ctx, state, slot, truth, unit);
1186
+ const progress = applyProgressSignals(ctx, marked, slot, truth, unit);
1074
1187
  if (progress.progressed)
1075
1188
  return progress.state;
1076
1189
  // No progress: the stall timer (AC4). The phase now IN FLIGHT is the last
@@ -1154,6 +1267,14 @@ function completeUnitOrRecover(ctx, state, unit, truth, via,
1154
1267
  // `external-advance` caller passes none — it DOES record a run log (`reconcileRunning`,
1155
1268
  // #524), but ground truth already confirmed completion, so this path never reaches the
1156
1269
  // `unverified-exit` branch and the tool name has nothing to attribute.
1270
+ //
1271
+ // #620: a THUNK, not a value, for the deferred `exited`/`verifying` rail — that caller
1272
+ // has no read of its own to hand over and would otherwise have to read the dispatch log
1273
+ // eagerly on every tick, including the majority that return early (ground truth
1274
+ // unreachable) or complete the unit, discarding it. The log is bounded at
1275
+ // MAX_DISPATCH_LOG_BYTES (32 MiB) and its size is set by the spawned agent, so an
1276
+ // eager read is up to 32 MiB per slot per tick for as long as an outage holds slots in
1277
+ // `verifying`. Resolved once, only on the branch that consumes it.
1157
1278
  lastTool = null) {
1158
1279
  const now = ctx.deps.now();
1159
1280
  let next = state;
@@ -1185,7 +1306,22 @@ lastTool = null) {
1185
1306
  // healthy park sandwiched between two unrelated units' suspect exits
1186
1307
  // would be invisible to the cross-unit correlation and could still tip
1187
1308
  // it into a false-positive pause.
1188
- return parkUnit(ctx, recordDispatchOutcome(ctx, next, unit, slot, false), unit, truth.milestone);
1309
+ const parked = recordDispatchOutcome(ctx, next, unit, slot, false);
1310
+ const pr = (0, groundtruth_1.prOfMilestone)(truth.milestone); // non-null: isParkedMilestone guarantees it
1311
+ if (pr === null) {
1312
+ // Structurally unreachable, and deliberately not silent if it ever is:
1313
+ // the slot is already in `verifying`, so an early return with nothing
1314
+ // journaled leaves it re-deciding the same way every tick, forever,
1315
+ // against an empty trail. Return the recorded state (the pre-#596 form
1316
+ // did — `parkUnit`'s internal guard returned the state it was handed),
1317
+ // not the un-recorded `next`.
1318
+ journal(ctx, 'ground-truth-unreachable', unit, {
1319
+ slot: slot.id,
1320
+ detail: `parked milestone (run=${truth.milestone?.run ?? 'unknown'}) carries no parseable pr= key — holding in verifying`,
1321
+ });
1322
+ return parked;
1323
+ }
1324
+ return parkUnit(ctx, parked, unit, pr);
1189
1325
  }
1190
1326
  // #575: fence to THIS dispatch's `spawned_at` — an agent that exited having
1191
1327
  // posted nothing new must not read as complete against the issue's
@@ -1193,19 +1329,20 @@ lastTool = null) {
1193
1329
  // below (`unverified-exit`) instead.
1194
1330
  const closedSignal = effectiveClosedSignal(slot, truth);
1195
1331
  const verifiedComplete = (0, groundtruth_1.isVerifiedComplete)(truth.milestone, closedSignal, slot.spawned_at);
1196
- journalStaleMilestoneIfIgnored(ctx, unit, slot, truth, verifiedComplete, closedSignal);
1332
+ next = journalStaleMilestoneIfIgnored(ctx, next, unit, slot, truth, verifiedComplete, closedSignal);
1197
1333
  if (verifiedComplete) {
1198
1334
  return completeUnit(ctx, recordDispatchOutcome(ctx, next, unit, slot, false), unit, via);
1199
1335
  }
1200
1336
  const suspect = msSinceLastProgress(slot, now) < types_1.SUSPECT_DISPATCH_WINDOW_MS;
1201
1337
  next = recordDispatchOutcome(ctx, next, unit, slot, suspect);
1338
+ const resolvedLastTool = typeof lastTool === 'function' ? lastTool() : lastTool;
1202
1339
  return enterRecovery(ctx, next, unit, 'verify-incomplete', 'unverified-exit', truth, {
1203
1340
  observed: truth.milestone
1204
1341
  ? `milestone ${truth.milestone.phase}/${truth.milestone.status}; closed=${truth.closed}`
1205
1342
  : `no milestone; closed=${truth.closed}`,
1206
1343
  // #591: attributes the unverified exit to a concrete cause (e.g. `Monitor`)
1207
1344
  // without opening the transcript. Omitted when the log yielded no tool_use.
1208
- ...(lastTool !== null ? { last_tool: lastTool } : {}),
1345
+ ...(resolvedLastTool !== null ? { last_tool: resolvedLastTool } : {}),
1209
1346
  });
1210
1347
  }
1211
1348
  /** Re-attach or spawn a slot left `assigned` by a crash between assign and spawn. */
@@ -1256,7 +1393,16 @@ function reconcileSlots(ctx, state, polled) {
1256
1393
  break;
1257
1394
  case 'exited':
1258
1395
  case 'verifying':
1259
- next = completeUnitOrRecover(ctx, next, unit, truth, 'verify-complete');
1396
+ // #620: this branch is reached on a LATER tick than the one that
1397
+ // detected the dead pid whenever the first attempt's ground truth
1398
+ // was unreachable (`completeUnitOrRecover` returns early in that
1399
+ // case, before consuming the lastTool `reconcileRunning` already
1400
+ // read). Re-read it here rather than losing it — the log itself is
1401
+ // static once the agent has exited, so this always agrees with
1402
+ // whatever `reconcileRunning` saw. Passed as a thunk so the read
1403
+ // happens only on the tick that actually reaches the unverified-exit
1404
+ // decision, not on every tick an outage holds the slot here.
1405
+ next = completeUnitOrRecover(ctx, next, unit, truth, 'verify-complete', () => readLastToolForSlot(ctx, slot, unit));
1260
1406
  break;
1261
1407
  case 'recovering':
1262
1408
  next = reconcileRecovering(ctx, next, unit);