@ai-dossier/sched 0.22.2 → 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/README.md +65 -9
- package/dist/batch-dispatch.d.ts.map +1 -1
- package/dist/batch-dispatch.js +15 -2
- package/dist/batch-dispatch.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +205 -59
- package/dist/engine.js.map +1 -1
- package/dist/groundtruth.d.ts +40 -0
- package/dist/groundtruth.d.ts.map +1 -1
- package/dist/groundtruth.js +100 -5
- package/dist/groundtruth.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/state.d.ts +18 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +45 -9
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +48 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -2
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
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,
|
|
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`.
|
|
931
|
-
*
|
|
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:
|
|
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
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
1070
|
-
const exited = (0, state_1.transitionSlot)(
|
|
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,
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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);
|