@ai-dossier/sched 0.27.0 → 0.29.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 -0
- package/dist/dispatch.d.ts +3 -3
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +12 -7
- package/dist/dispatch.js.map +1 -1
- package/dist/engine.d.ts +16 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +270 -26
- package/dist/engine.js.map +1 -1
- package/dist/fence.d.ts +47 -0
- package/dist/fence.d.ts.map +1 -1
- package/dist/fence.js +99 -0
- package/dist/fence.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/run-log.d.ts +26 -0
- package/dist/run-log.d.ts.map +1 -1
- package/dist/run-log.js +36 -0
- package/dist/run-log.js.map +1 -1
- package/dist/state.d.ts +5 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +41 -1
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +67 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/engine.js
CHANGED
|
@@ -74,6 +74,7 @@ const core_1 = require("@ai-dossier/core");
|
|
|
74
74
|
const batch_dispatch_1 = require("./batch-dispatch");
|
|
75
75
|
const dispatch_1 = require("./dispatch");
|
|
76
76
|
const dispatch_health_1 = require("./dispatch-health");
|
|
77
|
+
const fence_1 = require("./fence");
|
|
77
78
|
const groundtruth_1 = require("./groundtruth");
|
|
78
79
|
const journal_1 = require("./journal");
|
|
79
80
|
const labels_1 = require("./labels");
|
|
@@ -458,9 +459,15 @@ function spawnAndRecord(ctx, state, unit, slot, opts) {
|
|
|
458
459
|
return failUnit(ctx, state, unit, `spawn-error: ${err.message}`, opts.failOpts);
|
|
459
460
|
}
|
|
460
461
|
const now = ctx.deps.now();
|
|
462
|
+
const pidStart = ctx.deps.spawnDeps.processStart(pid);
|
|
463
|
+
// #683 AC2: the takeover owns a fence from the moment it was fenced in — bind the
|
|
464
|
+
// fence to this process so later reads can verify the owner is real. Runs BEFORE the
|
|
465
|
+
// patch/transition below (which don't touch run_id/fence_phase), against the
|
|
466
|
+
// PRE-spawn slot snapshot that already carries this dispatch's fence coordinates.
|
|
467
|
+
bindFenceToSpawn(ctx, unit, (0, journal_1.issueOfUnit)(unit) ?? 0, slot, pid, pidStart);
|
|
461
468
|
const patch = {
|
|
462
469
|
pid,
|
|
463
|
-
pid_start:
|
|
470
|
+
pid_start: pidStart,
|
|
464
471
|
phase: opts.phase,
|
|
465
472
|
last_progress_at: now.toISOString(),
|
|
466
473
|
// #524: distinct from last_progress_at, which later progress signals
|
|
@@ -515,7 +522,12 @@ function spawnUnit(ctx, state, unit) {
|
|
|
515
522
|
// replaced is refused. A first dispatch is generation 0 and reads as it always did.
|
|
516
523
|
// The tier's own resolved prompt (#527) — falls back to the global
|
|
517
524
|
// dispatch.prompt when the tier has no override.
|
|
518
|
-
prompt: (0, dispatch_1.buildPrompt)(ctx.dispatch.tiers[entry.tier].prompt, issue, slot.gen
|
|
525
|
+
prompt: (0, dispatch_1.buildPrompt)(ctx.dispatch.tiers[entry.tier].prompt, issue, slot.gen,
|
|
526
|
+
// #683 AC6: the takeover is told its slot identity alongside the generation —
|
|
527
|
+
// the same label the fence announced and the bind names (one spelling,
|
|
528
|
+
// `takeoverLabelFor`). Descriptive only: ownership is decided by run id +
|
|
529
|
+
// generation, never by matching this label (AC7).
|
|
530
|
+
slot.gen > 0 ? (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries) : undefined),
|
|
519
531
|
phase: 'gate',
|
|
520
532
|
...(slot.gen > 0 ? { journalExtra: { detail: `takeover gen=${slot.gen}` } } : {}),
|
|
521
533
|
});
|
|
@@ -547,7 +559,9 @@ function spawnReportAgent(ctx, state, unit) {
|
|
|
547
559
|
// (#504): a report slot is fenced by the same ladder, and a report agent that did
|
|
548
560
|
// not know its generation would have its `report done` milestone refused by the
|
|
549
561
|
// CLI — recovering forever on a PR that already merged.
|
|
550
|
-
prompt: (0, dispatch_1.buildReportPrompt)(ctx.dispatch.reportPrompt, issue, entry.pr, entry.cleanup, slot.gen
|
|
562
|
+
prompt: (0, dispatch_1.buildReportPrompt)(ctx.dispatch.reportPrompt, issue, entry.pr, entry.cleanup, slot.gen,
|
|
563
|
+
// #683 AC6, same rule as the cycle-agent prompt above.
|
|
564
|
+
slot.gen > 0 ? (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries) : undefined),
|
|
551
565
|
phase: 'report',
|
|
552
566
|
// Merged-aware: the PR is merged — a report spawn failure never blocks
|
|
553
567
|
// dependents (gating already released at `shipped`).
|
|
@@ -660,7 +674,7 @@ const RUN_ID_FOR_ISSUE_RE = /^r-(\d+)-[0-9a-f]{4,}$/;
|
|
|
660
674
|
const PHASE_TOKEN_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
661
675
|
/**
|
|
662
676
|
* Post the takeover record for a unit about to be redispatched (#504 AC1), returning the
|
|
663
|
-
*
|
|
677
|
+
* fence it installed — or null when no fence could be written.
|
|
664
678
|
*
|
|
665
679
|
* Every null is journaled as `fence-failed` WITH its cause, and every one is DEGRADED
|
|
666
680
|
* rather than fatal:
|
|
@@ -699,7 +713,11 @@ function writeFence(ctx, unit, issue, slot, truth) {
|
|
|
699
713
|
// is preferred over spending the attempt on it.
|
|
700
714
|
const claimed = truth.milestone?.phase ?? '';
|
|
701
715
|
const phase = PHASE_TOKEN_RE.test(claimed) ? claimed : (slot.phase ?? 'gate');
|
|
702
|
-
|
|
716
|
+
// The label of the dispatch ABOUT to spawn: `recoveries + 1` here, because the
|
|
717
|
+
// slot's counter is bumped by this recovery's transition right after. Every later
|
|
718
|
+
// reader of the same dispatch (the spawn-path bind, the exit-path release) derives
|
|
719
|
+
// the label from the BUMPED counter via `takeoverLabelFor`, so both spellings agree.
|
|
720
|
+
const takeover = (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries + 1);
|
|
703
721
|
const outcome = ctx.deps.fencer(issue, run, phase, takeover);
|
|
704
722
|
if (!outcome.ok) {
|
|
705
723
|
return failed(`${outcome.reason} — redispatching at gen=${slot.gen}`);
|
|
@@ -708,7 +726,60 @@ function writeFence(ctx, unit, issue, slot, truth) {
|
|
|
708
726
|
slot: slot.id,
|
|
709
727
|
detail: `${run} gen=${outcome.gen} takeover=${takeover}`,
|
|
710
728
|
});
|
|
711
|
-
return outcome.gen;
|
|
729
|
+
return { gen: outcome.gen, run, phase, takeover };
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Bind the slot's fence to the process just spawned for its takeover (#683 AC2).
|
|
733
|
+
*
|
|
734
|
+
* Called from `spawnAndRecord` right after the spawn resolved, while pid and
|
|
735
|
+
* `/proc` start-time are in hand: the bind is what lets every later READER of the trail
|
|
736
|
+
* (`runstate check`, `post`'s guard) tell a live owner from a ghost whose release was
|
|
737
|
+
* missed. Best-effort by design — no binder configured (an older engine), no fence on
|
|
738
|
+
* this slot (`gen === 0`), or a failed post all degrade to an UNBOUND fence, which
|
|
739
|
+
* reads fail-closed (still fences) exactly as fences did before #683; a failure
|
|
740
|
+
* journals `fence-bind-failed` so the gap is visible.
|
|
741
|
+
*/
|
|
742
|
+
function bindFenceToSpawn(ctx, unit, issue, slot, pid, pidStart) {
|
|
743
|
+
const binder = ctx.deps.fenceBinder;
|
|
744
|
+
if (binder === undefined || slot.gen <= 0 || slot.run_id === null)
|
|
745
|
+
return;
|
|
746
|
+
const phase = slot.fence_phase ?? slot.phase ?? 'gate';
|
|
747
|
+
const takeover = (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries);
|
|
748
|
+
const outcome = binder(issue, slot.run_id, phase, takeover, slot.gen, pid, pidStart);
|
|
749
|
+
journal(ctx, outcome.ok ? 'fence-bound' : 'fence-bind-failed', unit, {
|
|
750
|
+
slot: slot.id,
|
|
751
|
+
pid,
|
|
752
|
+
fence_gen: slot.gen,
|
|
753
|
+
detail: outcome.ok
|
|
754
|
+
? `${slot.run_id} gen=${slot.gen} owner pid=${pid}${pidStart !== null ? ` pid_start=${pidStart}` : ''}`
|
|
755
|
+
: outcome.reason,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Release the slot's fence because its owning dispatch ENDED (#683 AC1) — the
|
|
760
|
+
* `exit-detected` hook, called only from `reconcileRunning`'s dead-pid branch, the one
|
|
761
|
+
* place the engine already learns "this run's owner just ended", abnormally or not.
|
|
762
|
+
*
|
|
763
|
+
* Best-effort like the bind: no releaser configured, no fence on the slot, or a failed
|
|
764
|
+
* post all leave the fence on the trail, and successors then fall back to the
|
|
765
|
+
* bind-based stale-on-read check — the belt-and-suspenders the issue asks for. A
|
|
766
|
+
* failure journals `fence-release-failed`; a success journals the release so the trail
|
|
767
|
+
* reads "owner ended, fence lifted" without diffing comments.
|
|
768
|
+
*/
|
|
769
|
+
function releaseFenceOnExit(ctx, unit, issue, slot) {
|
|
770
|
+
const releaser = ctx.deps.fenceReleaser;
|
|
771
|
+
if (releaser === undefined || slot.gen <= 0 || slot.run_id === null)
|
|
772
|
+
return;
|
|
773
|
+
const phase = slot.fence_phase ?? slot.phase ?? 'gate';
|
|
774
|
+
const takeover = (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries);
|
|
775
|
+
const outcome = releaser(issue, slot.run_id, phase, takeover, slot.gen);
|
|
776
|
+
journal(ctx, outcome.ok ? 'fence-released' : 'fence-release-failed', unit, {
|
|
777
|
+
slot: slot.id,
|
|
778
|
+
fence_gen: slot.gen,
|
|
779
|
+
detail: outcome.ok
|
|
780
|
+
? `${slot.run_id} gen=${slot.gen} released — owner exit detected`
|
|
781
|
+
: outcome.reason,
|
|
782
|
+
});
|
|
712
783
|
}
|
|
713
784
|
/**
|
|
714
785
|
* The end of the ladder: the unit has no stronger tier left, so it fails —
|
|
@@ -822,6 +893,9 @@ options = {}) {
|
|
|
822
893
|
const slot = slotOf(state, unit);
|
|
823
894
|
if (!entry || !slot)
|
|
824
895
|
return state;
|
|
896
|
+
// #683 AC3: a `deferred-to-owner` cause arrives AFTER the agent's exit was
|
|
897
|
+
// already detected and recorded by the dead-pid branch of `reconcileRunning`
|
|
898
|
+
// — same contract as `verify-incomplete` (#524), never re-recorded here.
|
|
825
899
|
killUnitAgent(ctx, state, unit);
|
|
826
900
|
// #524: only the STALL path kills a still-live, not-yet-recorded agent —
|
|
827
901
|
// `causeEvent === 'verify-incomplete'`/`'dispatch-failure'` arrive from
|
|
@@ -840,10 +914,11 @@ options = {}) {
|
|
|
840
914
|
if (escalate) {
|
|
841
915
|
const escalated = report ? (0, dispatch_1.reportTierFor)(slot.recoveries + 1) : (0, dispatch_1.escalateTier)(entry.tier);
|
|
842
916
|
if (slot.recoveries >= types_1.ESCALATION_CAP || escalated === null) {
|
|
843
|
-
// #629: unreachable with `causeEvent === 'dispatch-failure'`
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
917
|
+
// #629: unreachable with `causeEvent === 'dispatch-failure'` or
|
|
918
|
+
// `'deferred-to-owner'` (#683) — both causes always pass
|
|
919
|
+
// `escalate: false` above, so this branch (and the narrower
|
|
920
|
+
// `causeEvent` type `failOrAdoptOpenPr` declares) is never actually
|
|
921
|
+
// asked to terminally fail a unit over a provider wall or a defer.
|
|
847
922
|
return failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, evidence);
|
|
848
923
|
}
|
|
849
924
|
resolvedTier = escalated;
|
|
@@ -863,11 +938,17 @@ options = {}) {
|
|
|
863
938
|
let next = (0, state_1.transitionSlot)(state, slot.id, 'recovering', {
|
|
864
939
|
pid: null,
|
|
865
940
|
recoveries: escalate ? slot.recoveries + 1 : slot.recoveries,
|
|
866
|
-
gen: fenced
|
|
941
|
+
gen: fenced !== null ? fenced.gen : slot.gen,
|
|
867
942
|
// Only a fence that actually landed starts the short takeover watch: an
|
|
868
943
|
// unfenced redispatch is already degraded, and cutting its allowance down
|
|
869
944
|
// would compound one failure with another.
|
|
870
945
|
fenced_at: fenced === null ? null : now.toISOString(),
|
|
946
|
+
// #683: record the fence's trail coordinates so the spawn-path bind and the
|
|
947
|
+
// exit-path release can name the same record without a trail read. A degraded
|
|
948
|
+
// fence keeps whatever the slot already recorded (the same run's earlier fence,
|
|
949
|
+
// if any — the coordinates are per-dispatch, and the spawn that follows an
|
|
950
|
+
// unfenced recovery is not bound to anything).
|
|
951
|
+
...(fenced !== null ? { run_id: fenced.run, fence_phase: fenced.phase } : {}),
|
|
871
952
|
}, now);
|
|
872
953
|
if (!report && escalate) {
|
|
873
954
|
next = {
|
|
@@ -981,6 +1062,7 @@ function applyProgressSignals(ctx, state, slot, truth, unit) {
|
|
|
981
1062
|
const now = ctx.deps.now();
|
|
982
1063
|
let next = state;
|
|
983
1064
|
let progressed = false;
|
|
1065
|
+
let milestoneAdvanced = false;
|
|
984
1066
|
if (truth.milestone !== null) {
|
|
985
1067
|
// Live phase per unit (AC6).
|
|
986
1068
|
if (truth.milestone.phase !== slot.phase) {
|
|
@@ -994,6 +1076,7 @@ function applyProgressSignals(ctx, state, slot, truth, unit) {
|
|
|
994
1076
|
}
|
|
995
1077
|
if (Date.parse(truth.milestone.at) > Date.parse(slot.last_progress_at ?? '')) {
|
|
996
1078
|
progressed = true;
|
|
1079
|
+
milestoneAdvanced = true;
|
|
997
1080
|
}
|
|
998
1081
|
}
|
|
999
1082
|
if (truth.head !== null && truth.head !== slot.last_head) {
|
|
@@ -1007,14 +1090,114 @@ function applyProgressSignals(ctx, state, slot, truth, unit) {
|
|
|
1007
1090
|
last_progress_at: now.toISOString(),
|
|
1008
1091
|
...(slot.fenced_at !== null ? { fenced_at: null } : {}),
|
|
1009
1092
|
}, now);
|
|
1093
|
+
// The journal entry names the TRIGGER, not whichever truth happened to be
|
|
1094
|
+
// present (#682): before, a push-driven signal was labelled with the
|
|
1095
|
+
// unchanged milestone whenever one existed — 30% of all `progress`
|
|
1096
|
+
// entries read as a re-journal of a milestone that had not moved, and a
|
|
1097
|
+
// repeated `progress` is indistinguishable from real forward motion at a
|
|
1098
|
+
// glance. A milestone advance and a push in the same tick journal once,
|
|
1099
|
+
// as the milestone (the stronger signal); the push is still visible in
|
|
1100
|
+
// `last_head` and any later push re-fires with its own sha.
|
|
1101
|
+
if (milestoneAdvanced && truth.milestone !== null) {
|
|
1102
|
+
next = journalMilestoneProgressIfDue(ctx, next, slot.id, truth.milestone, unit, now, true);
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
journal(ctx, 'progress', unit, {
|
|
1106
|
+
slot: slot.id,
|
|
1107
|
+
detail: 'new pushed commit',
|
|
1108
|
+
// Same `at` contract as the milestone-driven branch: the decision
|
|
1109
|
+
// clock, not a truth's own timestamp (#610's one-event-one-meaning
|
|
1110
|
+
// rule for the `at` field).
|
|
1111
|
+
at: now.toISOString(),
|
|
1112
|
+
...(truth.head !== null ? { head: truth.head } : {}),
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// Persistence counting (#682): every tick the unit is still seen at the SAME
|
|
1117
|
+
// milestone advances the streak — silent unless the re-announce window
|
|
1118
|
+
// elapses — so "still at implement/done after 40 min" is legible from one
|
|
1119
|
+
// line without a journal entry per tick. It runs even when nothing
|
|
1120
|
+
// progressed; it never STARTS a streak (that happens only on a real
|
|
1121
|
+
// milestone advance above) and never journals one.
|
|
1122
|
+
if (truth.milestone !== null && !milestoneAdvanced) {
|
|
1123
|
+
next = journalMilestoneProgressIfDue(ctx, next, slot.id, truth.milestone, unit, now, false);
|
|
1124
|
+
}
|
|
1125
|
+
return { state: next, progressed };
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Journal (or silently count, AC3) the milestone-driven `progress` signal for
|
|
1129
|
+
* `slotId` under the shared dedup idiom (#682): once per distinct milestone
|
|
1130
|
+
* per unit — keyed on `` `${run}:${phase}/${status}` ``, so a NEW milestone
|
|
1131
|
+
* (different phase/status, or the same one re-reached under a NEW run id by a
|
|
1132
|
+
* resumed or redispatched run) always journals, while an unchanged one stays
|
|
1133
|
+
* silent — and then re-announced only every `JOURNAL_DEDUP_REANNOUNCE_TICKS`
|
|
1134
|
+
* ticks while it persists, carrying `since` + `ticks_persisted` so "still at
|
|
1135
|
+
* implement/done after 40 min" is legible from one line. Mirrors #630's
|
|
1136
|
+
* `pr_watch_failed_*` triple scoped to the slot rail (#610's
|
|
1137
|
+
* `stale_milestone_ignored_for` precedent) and #632's
|
|
1138
|
+
* `journalConditionIfDue` cadence — no third mechanism.
|
|
1139
|
+
*
|
|
1140
|
+
* `advanced=false` is a persistence tick: the streak is only counted (and
|
|
1141
|
+
* re-announced on the window), never started — a streak begins exclusively
|
|
1142
|
+
* on a real milestone advance, so a unit seen carrying a milestone that
|
|
1143
|
+
* predates its own dispatch (a redispatch's stale `implement/done`) journals
|
|
1144
|
+
* nothing.
|
|
1145
|
+
*
|
|
1146
|
+
* The streak is keyed on the milestone, not the dispatch: pushes during a
|
|
1147
|
+
* milestone ("new pushed commit" entries) are real motion and journal
|
|
1148
|
+
* freely, but they do not reset the "still at X" clock — a unit that pushed
|
|
1149
|
+
* four times during `implement/done` is still legibly AT `implement/done`.
|
|
1150
|
+
*
|
|
1151
|
+
* Returns the patched state — callers must thread it, or the marker is lost
|
|
1152
|
+
* and the event re-fires next tick as if nothing had been recorded.
|
|
1153
|
+
*/
|
|
1154
|
+
function journalMilestoneProgressIfDue(ctx, state, slotId, milestone, unit, now, advanced) {
|
|
1155
|
+
const key = `${milestone.run}:${milestone.phase}/${milestone.status}`;
|
|
1156
|
+
// Read the CURRENT marker from `state`, not a captured slot — the caller
|
|
1157
|
+
// may have patched the slot earlier in the same tick (last_head,
|
|
1158
|
+
// last_progress_at) and the persisted marker is what the streak continues.
|
|
1159
|
+
const cur = state.slots.find((s) => s.id === slotId);
|
|
1160
|
+
if (cur === undefined)
|
|
1161
|
+
return state;
|
|
1162
|
+
let ticks;
|
|
1163
|
+
let since;
|
|
1164
|
+
let journalNow;
|
|
1165
|
+
if (advanced) {
|
|
1166
|
+
const isNewStreak = cur.progress_milestone_for !== key;
|
|
1167
|
+
ticks = isNewStreak ? 1 : cur.progress_milestone_ticks + 1;
|
|
1168
|
+
since = isNewStreak ? now.toISOString() : (cur.progress_milestone_since ?? now.toISOString());
|
|
1169
|
+
journalNow = isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0;
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
// A persistence tick never STARTS a streak: a unit seen carrying a
|
|
1173
|
+
// milestone it did not advance (a redispatch's stale milestone) counts
|
|
1174
|
+
// nothing and journals nothing.
|
|
1175
|
+
if (cur.progress_milestone_for !== key || cur.progress_milestone_since === null)
|
|
1176
|
+
return state;
|
|
1177
|
+
ticks = cur.progress_milestone_ticks + 1;
|
|
1178
|
+
since = cur.progress_milestone_since;
|
|
1179
|
+
journalNow = ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0;
|
|
1180
|
+
}
|
|
1181
|
+
if (journalNow) {
|
|
1182
|
+
// `at` is the decision clock; `since` is the streak's onset. Both are
|
|
1183
|
+
// needed: `ticks_persisted` is a TICK count, which maps to no fixed
|
|
1184
|
+
// wall-clock across operator-tunable tick intervals.
|
|
1010
1185
|
journal(ctx, 'progress', unit, {
|
|
1011
|
-
slot:
|
|
1012
|
-
detail:
|
|
1013
|
-
|
|
1014
|
-
|
|
1186
|
+
slot: slotId,
|
|
1187
|
+
detail: `milestone ${milestone.phase}/${milestone.status}`,
|
|
1188
|
+
run: milestone.run,
|
|
1189
|
+
at: now.toISOString(),
|
|
1190
|
+
since,
|
|
1191
|
+
ticks_persisted: ticks,
|
|
1015
1192
|
});
|
|
1016
1193
|
}
|
|
1017
|
-
return
|
|
1194
|
+
return (0, state_1.patchSlot)(state, slotId, advanced
|
|
1195
|
+
? {
|
|
1196
|
+
progress_milestone_for: key,
|
|
1197
|
+
progress_milestone_since: since,
|
|
1198
|
+
progress_milestone_ticks: ticks,
|
|
1199
|
+
}
|
|
1200
|
+
: { progress_milestone_ticks: ticks }, now);
|
|
1018
1201
|
}
|
|
1019
1202
|
/**
|
|
1020
1203
|
* The issue-closed completion signal for a live unit (#468): a report agent's
|
|
@@ -1167,10 +1350,15 @@ function journalStaleMilestoneIfIgnored(ctx, state, unit, slot, truth, verifiedC
|
|
|
1167
1350
|
});
|
|
1168
1351
|
return (0, state_1.patchSlot)(state, slot.id, { stale_milestone_ignored_for: slot.spawned_at }, now);
|
|
1169
1352
|
}
|
|
1170
|
-
const NO_DISPATCH_SIGNALS = {
|
|
1353
|
+
const NO_DISPATCH_SIGNALS = {
|
|
1354
|
+
lastTool: null,
|
|
1355
|
+
apiError: null,
|
|
1356
|
+
fenceAbort: null,
|
|
1357
|
+
};
|
|
1171
1358
|
/**
|
|
1172
|
-
* Read THIS dispatch's last tool call
|
|
1173
|
-
* #620, #629) from its log slice — a
|
|
1359
|
+
* Read THIS dispatch's last tool call, API-error classification (#591,
|
|
1360
|
+
* #620, #629), and fence-abort evidence (#683 AC3) from its log slice — a
|
|
1361
|
+
* pure, side-effect-free parse, safe to
|
|
1174
1362
|
* call from any slot status and any number of times: unlike
|
|
1175
1363
|
* `recordDispatchRunLog` below, it writes nothing to `runs.jsonl`, so it
|
|
1176
1364
|
* carries no exactly-once constraint. Exists because a `verify-incomplete`/
|
|
@@ -1179,12 +1367,23 @@ const NO_DISPATCH_SIGNALS = { lastTool: null, apiError: null };
|
|
|
1179
1367
|
* then the slot has moved past `running` and `recordDispatchRunLog`'s guard
|
|
1180
1368
|
* refuses to re-read, but the dispatch's log file is static once the agent
|
|
1181
1369
|
* has exited, so re-parsing it here yields the same answer every time.
|
|
1370
|
+
*
|
|
1371
|
+
* `run` is the trail run id the fence check must name to count as THIS
|
|
1372
|
+
* dispatch's checkpoint: the slot's own `run_id` when it holds one, else the
|
|
1373
|
+
* trail's current run (a gen-0 dispatch fenced out by a recovery's fence —
|
|
1374
|
+
* the #4153 shape). Doc text quoting HISTORICAL run ids never matches, which
|
|
1375
|
+
* is what keeps the defer classification from being faked by an agent that
|
|
1376
|
+
* merely read `docs/agent-traps.md`.
|
|
1182
1377
|
*/
|
|
1183
|
-
function readDispatchSignalsForSlot(ctx, slot, unit) {
|
|
1378
|
+
function readDispatchSignalsForSlot(ctx, slot, unit, run) {
|
|
1184
1379
|
if (slot.spawned_at === null)
|
|
1185
1380
|
return NO_DISPATCH_SIGNALS;
|
|
1186
1381
|
const { content } = dispatchLogSlice(ctx, slot, unit);
|
|
1187
|
-
return {
|
|
1382
|
+
return {
|
|
1383
|
+
lastTool: (0, core_1.parseLastToolUse)(content),
|
|
1384
|
+
apiError: (0, core_1.parseDispatchApiError)(content),
|
|
1385
|
+
fenceAbort: (0, run_log_1.parseFenceAbort)(content, run),
|
|
1386
|
+
};
|
|
1188
1387
|
}
|
|
1189
1388
|
/**
|
|
1190
1389
|
* THIS dispatch's slice of the per-unit log: path, start offset, contents.
|
|
@@ -1216,7 +1415,12 @@ function dispatchLogSlice(ctx, slot, unit) {
|
|
|
1216
1415
|
* `running` and actually spawned), not by the call sites' ordering — two of
|
|
1217
1416
|
* the four reach slots in any non-idle status.
|
|
1218
1417
|
*/
|
|
1219
|
-
function recordDispatchRunLog(ctx, state, slot, unit
|
|
1418
|
+
function recordDispatchRunLog(ctx, state, slot, unit,
|
|
1419
|
+
// #683 AC3: the trail run id a fence-abort marker in THIS dispatch's log slice must
|
|
1420
|
+
// name to count as evidence (callers with a polled truth pass its run; a kill whose
|
|
1421
|
+
// signals are discarded passes ''). Doc text quotes historical run ids, so without
|
|
1422
|
+
// this match an agent that merely READ docs could be classified as having deferred.
|
|
1423
|
+
run = '') {
|
|
1220
1424
|
// Enforce the once-per-dispatch invariant HERE rather than restating it in
|
|
1221
1425
|
// prose at four call sites (#524 review). `blockTransitiveDependents` and
|
|
1222
1426
|
// `enterRecovery` reach slots in any non-idle status: a slot already moved
|
|
@@ -1273,8 +1477,15 @@ function recordDispatchRunLog(ctx, state, slot, unit) {
|
|
|
1273
1477
|
// #591: the last tool this dispatch called, so an unverified exit attributes to a
|
|
1274
1478
|
// concrete cause (e.g. `Monitor`) without opening the transcript — see `enterRecovery`.
|
|
1275
1479
|
// #629: whether this SAME dispatch was a confirmed provider API error — see
|
|
1276
|
-
// `completeUnitOrRecover`.
|
|
1277
|
-
|
|
1480
|
+
// `completeUnitOrRecover`. #683 AC3: whether it ended because a fence told it to —
|
|
1481
|
+
// `run` names the trail run id the fence check must have answered to; with no run
|
|
1482
|
+
// known, no fence-abort evidence can be this dispatch's own and the parse is skipped.
|
|
1483
|
+
const fenceRun = run !== '' ? run : (slot.run_id ?? '');
|
|
1484
|
+
return {
|
|
1485
|
+
lastTool: (0, core_1.parseLastToolUse)(logContent),
|
|
1486
|
+
apiError: (0, core_1.parseDispatchApiError)(logContent),
|
|
1487
|
+
fenceAbort: (0, run_log_1.parseFenceAbort)(logContent, fenceRun),
|
|
1488
|
+
};
|
|
1278
1489
|
}
|
|
1279
1490
|
/** Reconcile one running slot against its polled ground truth. */
|
|
1280
1491
|
function reconcileRunning(ctx, state, slot, truth, unit) {
|
|
@@ -1283,7 +1494,19 @@ function reconcileRunning(ctx, state, slot, truth, unit) {
|
|
|
1283
1494
|
// is DETECTED, never trusted as completion (AC2/AC3).
|
|
1284
1495
|
if (slot.pid !== null && !ctx.deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined)) {
|
|
1285
1496
|
journal(ctx, 'exit-detected', unit, { pid: slot.pid, slot: slot.id });
|
|
1286
|
-
|
|
1497
|
+
// #683 AC1: the owning run just ended — release its fence here, BEFORE the
|
|
1498
|
+
// classification below decides between completion and a (re-fencing) recovery, so
|
|
1499
|
+
// no path can leave a dead run's fence governing the trail. A recovery re-fences
|
|
1500
|
+
// at gen+1 right after, which dominates the released generation as before.
|
|
1501
|
+
releaseFenceOnExit(ctx, unit, (0, journal_1.issueOfUnit)(unit) ?? 0, slot);
|
|
1502
|
+
// #683 AC3: the defer classification must recognize THIS dispatch's own
|
|
1503
|
+
// checkpoint output. A gen>0 takeover's slot carries its fence's run_id; a
|
|
1504
|
+
// gen-0 dispatch (re-enqueued fresh, fenced out by an earlier recovery —
|
|
1505
|
+
// the #4153 shape) carries none, so the trail's current run — the run id
|
|
1506
|
+
// the checkpoint instruction tells every dispatch to check under — is the
|
|
1507
|
+
// match key. Wrong-run markers never match (docs quote historical ids),
|
|
1508
|
+
// which is what keeps the classification unfakeable by read text alone.
|
|
1509
|
+
const signals = recordDispatchRunLog(ctx, state, slot, unit, truth.milestone?.run ?? '');
|
|
1287
1510
|
const exited = (0, state_1.transitionSlot)(state, slot.id, 'exited', {}, now);
|
|
1288
1511
|
return completeUnitOrRecover(ctx, exited, unit, truth, 'verify-complete', signals);
|
|
1289
1512
|
}
|
|
@@ -1506,6 +1729,27 @@ dispatchSignals = NO_DISPATCH_SIGNALS) {
|
|
|
1506
1729
|
return enterRecovery(ctx, recorded, unit, 'dispatch-failure', (0, dispatch_health_1.dispatchApiErrorDetail)(resolved.apiError), truth, (0, dispatch_health_1.dispatchApiErrorFields)(resolved.apiError), { escalate: false });
|
|
1507
1730
|
}
|
|
1508
1731
|
const suspect = msSinceLastProgress(slot, now) < types_1.SUSPECT_DISPATCH_WINDOW_MS;
|
|
1732
|
+
// #683 AC3: the dispatch's own log carries its supersession-checkpoint
|
|
1733
|
+
// verdict for THIS trail run — the agent checked, found an owner, and
|
|
1734
|
+
// stepped aside. That is a deliberate, CORRECT no-op: never an
|
|
1735
|
+
// `unverified-exit`, never an escalation rung. Classified deterministically
|
|
1736
|
+
// off the CLI's exact output (the #629 lesson — never a heuristic on exit
|
|
1737
|
+
// codes), recorded as a HEALTHY dispatch (no `suspect-dispatch`), and
|
|
1738
|
+
// redispatched at the same tier via the #629 unescalated rail — the fence
|
|
1739
|
+
// `enterRecovery` writes lands at the next generation and its takeover gets
|
|
1740
|
+
// the pid bind, so the #4153 livelock self-heals in one step instead of
|
|
1741
|
+
// climbing to `unverified-exit-at-strongest-tier`. Checked AFTER
|
|
1742
|
+
// `recordDispatchApiError`'s branch: a confirmed provider wall (num_turns 1,
|
|
1743
|
+
// no check output) cannot also be a defer, and its rail owns that shape.
|
|
1744
|
+
if (resolved.fenceAbort !== null) {
|
|
1745
|
+
const recorded = recordDispatchOutcome(ctx, next, unit, slot, false);
|
|
1746
|
+
return enterRecovery(ctx, recorded, unit, 'deferred-to-owner', 'deferred to the fence owner — deliberate supersession-checkpoint abort, no escalation rung consumed', truth, {
|
|
1747
|
+
fence_gen: resolved.fenceAbort.gen,
|
|
1748
|
+
...(resolved.fenceAbort.takeover !== null
|
|
1749
|
+
? { fence_takeover: resolved.fenceAbort.takeover }
|
|
1750
|
+
: {}),
|
|
1751
|
+
}, { escalate: false });
|
|
1752
|
+
}
|
|
1509
1753
|
next = recordDispatchOutcome(ctx, next, unit, slot, suspect);
|
|
1510
1754
|
return enterRecovery(ctx, next, unit, 'verify-incomplete', 'unverified-exit', truth, {
|
|
1511
1755
|
observed: truth.milestone
|
|
@@ -1581,7 +1825,7 @@ function reconcileSlots(ctx, state, polled) {
|
|
|
1581
1825
|
// whatever `reconcileRunning` saw. Passed as a thunk so the read
|
|
1582
1826
|
// happens only on the tick that actually reaches the unverified-exit
|
|
1583
1827
|
// decision, not on every tick an outage holds the slot here.
|
|
1584
|
-
next = completeUnitOrRecover(ctx, next, unit, truth, 'verify-complete', () => readDispatchSignalsForSlot(ctx, slot, unit));
|
|
1828
|
+
next = completeUnitOrRecover(ctx, next, unit, truth, 'verify-complete', () => readDispatchSignalsForSlot(ctx, slot, unit, truth.milestone?.run ?? ''));
|
|
1585
1829
|
break;
|
|
1586
1830
|
case 'recovering':
|
|
1587
1831
|
next = reconcileRecovering(ctx, next, unit);
|