@ai-dossier/sched 0.28.0 → 0.30.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 +46 -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 +203 -22
- 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 +2 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +19 -0
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +36 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -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");
|
|
@@ -185,11 +186,21 @@ function pollUnits(deps, state) {
|
|
|
185
186
|
if (issue === null || out.has(slot.unit))
|
|
186
187
|
continue;
|
|
187
188
|
const milestone = deps.groundTruth.latestMilestone(issue);
|
|
189
|
+
// #684: a slot whose branch no milestone ever named — every redispatch,
|
|
190
|
+
// since `CLEARED_SLOT_FIELDS` nulls `branch` on release and only the
|
|
191
|
+
// setup milestone carries `branch=`, which is never the LATEST milestone
|
|
192
|
+
// again — recovers the branch from the issue's setup milestone here, so
|
|
193
|
+
// the pushed-commit stall signal has a ref to watch. Consulted only while
|
|
194
|
+
// the slot's own is unknown (one successful read persists it below), and
|
|
195
|
+
// only for running slots — exited/verifying slots never consume a head.
|
|
196
|
+
const branch = slot.branch ??
|
|
197
|
+
(slot.status === 'running' ? (deps.groundTruth.setupInfo(issue)?.branch ?? null) : null);
|
|
188
198
|
out.set(slot.unit, {
|
|
189
199
|
reachable: milestone !== undefined,
|
|
190
200
|
milestone: milestone ?? null,
|
|
191
201
|
closed: deps.groundTruth.issueClosed(issue),
|
|
192
|
-
head:
|
|
202
|
+
head: branch !== null ? deps.groundTruth.branchHead(branch) : null,
|
|
203
|
+
branch,
|
|
193
204
|
});
|
|
194
205
|
}
|
|
195
206
|
return out;
|
|
@@ -458,9 +469,15 @@ function spawnAndRecord(ctx, state, unit, slot, opts) {
|
|
|
458
469
|
return failUnit(ctx, state, unit, `spawn-error: ${err.message}`, opts.failOpts);
|
|
459
470
|
}
|
|
460
471
|
const now = ctx.deps.now();
|
|
472
|
+
const pidStart = ctx.deps.spawnDeps.processStart(pid);
|
|
473
|
+
// #683 AC2: the takeover owns a fence from the moment it was fenced in — bind the
|
|
474
|
+
// fence to this process so later reads can verify the owner is real. Runs BEFORE the
|
|
475
|
+
// patch/transition below (which don't touch run_id/fence_phase), against the
|
|
476
|
+
// PRE-spawn slot snapshot that already carries this dispatch's fence coordinates.
|
|
477
|
+
bindFenceToSpawn(ctx, unit, (0, journal_1.issueOfUnit)(unit) ?? 0, slot, pid, pidStart);
|
|
461
478
|
const patch = {
|
|
462
479
|
pid,
|
|
463
|
-
pid_start:
|
|
480
|
+
pid_start: pidStart,
|
|
464
481
|
phase: opts.phase,
|
|
465
482
|
last_progress_at: now.toISOString(),
|
|
466
483
|
// #524: distinct from last_progress_at, which later progress signals
|
|
@@ -515,7 +532,12 @@ function spawnUnit(ctx, state, unit) {
|
|
|
515
532
|
// replaced is refused. A first dispatch is generation 0 and reads as it always did.
|
|
516
533
|
// The tier's own resolved prompt (#527) — falls back to the global
|
|
517
534
|
// dispatch.prompt when the tier has no override.
|
|
518
|
-
prompt: (0, dispatch_1.buildPrompt)(ctx.dispatch.tiers[entry.tier].prompt, issue, slot.gen
|
|
535
|
+
prompt: (0, dispatch_1.buildPrompt)(ctx.dispatch.tiers[entry.tier].prompt, issue, slot.gen,
|
|
536
|
+
// #683 AC6: the takeover is told its slot identity alongside the generation —
|
|
537
|
+
// the same label the fence announced and the bind names (one spelling,
|
|
538
|
+
// `takeoverLabelFor`). Descriptive only: ownership is decided by run id +
|
|
539
|
+
// generation, never by matching this label (AC7).
|
|
540
|
+
slot.gen > 0 ? (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries) : undefined),
|
|
519
541
|
phase: 'gate',
|
|
520
542
|
...(slot.gen > 0 ? { journalExtra: { detail: `takeover gen=${slot.gen}` } } : {}),
|
|
521
543
|
});
|
|
@@ -547,7 +569,9 @@ function spawnReportAgent(ctx, state, unit) {
|
|
|
547
569
|
// (#504): a report slot is fenced by the same ladder, and a report agent that did
|
|
548
570
|
// not know its generation would have its `report done` milestone refused by the
|
|
549
571
|
// 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
|
|
572
|
+
prompt: (0, dispatch_1.buildReportPrompt)(ctx.dispatch.reportPrompt, issue, entry.pr, entry.cleanup, slot.gen,
|
|
573
|
+
// #683 AC6, same rule as the cycle-agent prompt above.
|
|
574
|
+
slot.gen > 0 ? (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries) : undefined),
|
|
551
575
|
phase: 'report',
|
|
552
576
|
// Merged-aware: the PR is merged — a report spawn failure never blocks
|
|
553
577
|
// dependents (gating already released at `shipped`).
|
|
@@ -660,7 +684,7 @@ const RUN_ID_FOR_ISSUE_RE = /^r-(\d+)-[0-9a-f]{4,}$/;
|
|
|
660
684
|
const PHASE_TOKEN_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
661
685
|
/**
|
|
662
686
|
* Post the takeover record for a unit about to be redispatched (#504 AC1), returning the
|
|
663
|
-
*
|
|
687
|
+
* fence it installed — or null when no fence could be written.
|
|
664
688
|
*
|
|
665
689
|
* Every null is journaled as `fence-failed` WITH its cause, and every one is DEGRADED
|
|
666
690
|
* rather than fatal:
|
|
@@ -699,7 +723,11 @@ function writeFence(ctx, unit, issue, slot, truth) {
|
|
|
699
723
|
// is preferred over spending the attempt on it.
|
|
700
724
|
const claimed = truth.milestone?.phase ?? '';
|
|
701
725
|
const phase = PHASE_TOKEN_RE.test(claimed) ? claimed : (slot.phase ?? 'gate');
|
|
702
|
-
|
|
726
|
+
// The label of the dispatch ABOUT to spawn: `recoveries + 1` here, because the
|
|
727
|
+
// slot's counter is bumped by this recovery's transition right after. Every later
|
|
728
|
+
// reader of the same dispatch (the spawn-path bind, the exit-path release) derives
|
|
729
|
+
// the label from the BUMPED counter via `takeoverLabelFor`, so both spellings agree.
|
|
730
|
+
const takeover = (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries + 1);
|
|
703
731
|
const outcome = ctx.deps.fencer(issue, run, phase, takeover);
|
|
704
732
|
if (!outcome.ok) {
|
|
705
733
|
return failed(`${outcome.reason} — redispatching at gen=${slot.gen}`);
|
|
@@ -708,7 +736,60 @@ function writeFence(ctx, unit, issue, slot, truth) {
|
|
|
708
736
|
slot: slot.id,
|
|
709
737
|
detail: `${run} gen=${outcome.gen} takeover=${takeover}`,
|
|
710
738
|
});
|
|
711
|
-
return outcome.gen;
|
|
739
|
+
return { gen: outcome.gen, run, phase, takeover };
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Bind the slot's fence to the process just spawned for its takeover (#683 AC2).
|
|
743
|
+
*
|
|
744
|
+
* Called from `spawnAndRecord` right after the spawn resolved, while pid and
|
|
745
|
+
* `/proc` start-time are in hand: the bind is what lets every later READER of the trail
|
|
746
|
+
* (`runstate check`, `post`'s guard) tell a live owner from a ghost whose release was
|
|
747
|
+
* missed. Best-effort by design — no binder configured (an older engine), no fence on
|
|
748
|
+
* this slot (`gen === 0`), or a failed post all degrade to an UNBOUND fence, which
|
|
749
|
+
* reads fail-closed (still fences) exactly as fences did before #683; a failure
|
|
750
|
+
* journals `fence-bind-failed` so the gap is visible.
|
|
751
|
+
*/
|
|
752
|
+
function bindFenceToSpawn(ctx, unit, issue, slot, pid, pidStart) {
|
|
753
|
+
const binder = ctx.deps.fenceBinder;
|
|
754
|
+
if (binder === undefined || slot.gen <= 0 || slot.run_id === null)
|
|
755
|
+
return;
|
|
756
|
+
const phase = slot.fence_phase ?? slot.phase ?? 'gate';
|
|
757
|
+
const takeover = (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries);
|
|
758
|
+
const outcome = binder(issue, slot.run_id, phase, takeover, slot.gen, pid, pidStart);
|
|
759
|
+
journal(ctx, outcome.ok ? 'fence-bound' : 'fence-bind-failed', unit, {
|
|
760
|
+
slot: slot.id,
|
|
761
|
+
pid,
|
|
762
|
+
fence_gen: slot.gen,
|
|
763
|
+
detail: outcome.ok
|
|
764
|
+
? `${slot.run_id} gen=${slot.gen} owner pid=${pid}${pidStart !== null ? ` pid_start=${pidStart}` : ''}`
|
|
765
|
+
: outcome.reason,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Release the slot's fence because its owning dispatch ENDED (#683 AC1) — the
|
|
770
|
+
* `exit-detected` hook, called only from `reconcileRunning`'s dead-pid branch, the one
|
|
771
|
+
* place the engine already learns "this run's owner just ended", abnormally or not.
|
|
772
|
+
*
|
|
773
|
+
* Best-effort like the bind: no releaser configured, no fence on the slot, or a failed
|
|
774
|
+
* post all leave the fence on the trail, and successors then fall back to the
|
|
775
|
+
* bind-based stale-on-read check — the belt-and-suspenders the issue asks for. A
|
|
776
|
+
* failure journals `fence-release-failed`; a success journals the release so the trail
|
|
777
|
+
* reads "owner ended, fence lifted" without diffing comments.
|
|
778
|
+
*/
|
|
779
|
+
function releaseFenceOnExit(ctx, unit, issue, slot) {
|
|
780
|
+
const releaser = ctx.deps.fenceReleaser;
|
|
781
|
+
if (releaser === undefined || slot.gen <= 0 || slot.run_id === null)
|
|
782
|
+
return;
|
|
783
|
+
const phase = slot.fence_phase ?? slot.phase ?? 'gate';
|
|
784
|
+
const takeover = (0, fence_1.takeoverLabelFor)(slot.id, slot.recoveries);
|
|
785
|
+
const outcome = releaser(issue, slot.run_id, phase, takeover, slot.gen);
|
|
786
|
+
journal(ctx, outcome.ok ? 'fence-released' : 'fence-release-failed', unit, {
|
|
787
|
+
slot: slot.id,
|
|
788
|
+
fence_gen: slot.gen,
|
|
789
|
+
detail: outcome.ok
|
|
790
|
+
? `${slot.run_id} gen=${slot.gen} released — owner exit detected`
|
|
791
|
+
: outcome.reason,
|
|
792
|
+
});
|
|
712
793
|
}
|
|
713
794
|
/**
|
|
714
795
|
* The end of the ladder: the unit has no stronger tier left, so it fails —
|
|
@@ -792,6 +873,16 @@ causeEvent, cause, evidence) {
|
|
|
792
873
|
const extra = {
|
|
793
874
|
...(typeof evidence.last_tool === 'string' ? { last_tool: evidence.last_tool } : {}),
|
|
794
875
|
...(prCheck !== 'skipped' ? { pr_check: prCheck } : {}),
|
|
876
|
+
// #684 AC3: the terminal stall verdict keeps what it checked — last
|
|
877
|
+
// milestone time, last observed head, head at decision time — so a false
|
|
878
|
+
// `stall-at-strongest-tier` (the issue:4156 kill, `last_head` never
|
|
879
|
+
// populated) is diagnosable from the `unit-failed` entry alone. The keys
|
|
880
|
+
// are present only when the caller supplied them: a stall passes all
|
|
881
|
+
// three (nulls included — `last_head: null` IS the diagnostic), while a
|
|
882
|
+
// `verify-incomplete` exit passes none.
|
|
883
|
+
...('last_milestone_at' in evidence ? { last_milestone_at: evidence.last_milestone_at } : {}),
|
|
884
|
+
...('last_head' in evidence ? { last_head: evidence.last_head } : {}),
|
|
885
|
+
...('decision_head' in evidence ? { decision_head: evidence.decision_head } : {}),
|
|
795
886
|
};
|
|
796
887
|
return failUnit(ctx, state, unit, reason, {
|
|
797
888
|
merged: report,
|
|
@@ -822,6 +913,9 @@ options = {}) {
|
|
|
822
913
|
const slot = slotOf(state, unit);
|
|
823
914
|
if (!entry || !slot)
|
|
824
915
|
return state;
|
|
916
|
+
// #683 AC3: a `deferred-to-owner` cause arrives AFTER the agent's exit was
|
|
917
|
+
// already detected and recorded by the dead-pid branch of `reconcileRunning`
|
|
918
|
+
// — same contract as `verify-incomplete` (#524), never re-recorded here.
|
|
825
919
|
killUnitAgent(ctx, state, unit);
|
|
826
920
|
// #524: only the STALL path kills a still-live, not-yet-recorded agent —
|
|
827
921
|
// `causeEvent === 'verify-incomplete'`/`'dispatch-failure'` arrive from
|
|
@@ -840,10 +934,11 @@ options = {}) {
|
|
|
840
934
|
if (escalate) {
|
|
841
935
|
const escalated = report ? (0, dispatch_1.reportTierFor)(slot.recoveries + 1) : (0, dispatch_1.escalateTier)(entry.tier);
|
|
842
936
|
if (slot.recoveries >= types_1.ESCALATION_CAP || escalated === null) {
|
|
843
|
-
// #629: unreachable with `causeEvent === 'dispatch-failure'`
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
937
|
+
// #629: unreachable with `causeEvent === 'dispatch-failure'` or
|
|
938
|
+
// `'deferred-to-owner'` (#683) — both causes always pass
|
|
939
|
+
// `escalate: false` above, so this branch (and the narrower
|
|
940
|
+
// `causeEvent` type `failOrAdoptOpenPr` declares) is never actually
|
|
941
|
+
// asked to terminally fail a unit over a provider wall or a defer.
|
|
847
942
|
return failOrAdoptOpenPr(ctx, state, unit, slot, report, causeEvent, cause, evidence);
|
|
848
943
|
}
|
|
849
944
|
resolvedTier = escalated;
|
|
@@ -863,11 +958,17 @@ options = {}) {
|
|
|
863
958
|
let next = (0, state_1.transitionSlot)(state, slot.id, 'recovering', {
|
|
864
959
|
pid: null,
|
|
865
960
|
recoveries: escalate ? slot.recoveries + 1 : slot.recoveries,
|
|
866
|
-
gen: fenced
|
|
961
|
+
gen: fenced !== null ? fenced.gen : slot.gen,
|
|
867
962
|
// Only a fence that actually landed starts the short takeover watch: an
|
|
868
963
|
// unfenced redispatch is already degraded, and cutting its allowance down
|
|
869
964
|
// would compound one failure with another.
|
|
870
965
|
fenced_at: fenced === null ? null : now.toISOString(),
|
|
966
|
+
// #683: record the fence's trail coordinates so the spawn-path bind and the
|
|
967
|
+
// exit-path release can name the same record without a trail read. A degraded
|
|
968
|
+
// fence keeps whatever the slot already recorded (the same run's earlier fence,
|
|
969
|
+
// if any — the coordinates are per-dispatch, and the spawn that follows an
|
|
970
|
+
// unfenced recovery is not bound to anything).
|
|
971
|
+
...(fenced !== null ? { run_id: fenced.run, fence_phase: fenced.phase } : {}),
|
|
871
972
|
}, now);
|
|
872
973
|
if (!report && escalate) {
|
|
873
974
|
next = {
|
|
@@ -982,6 +1083,16 @@ function applyProgressSignals(ctx, state, slot, truth, unit) {
|
|
|
982
1083
|
let next = state;
|
|
983
1084
|
let progressed = false;
|
|
984
1085
|
let milestoneAdvanced = false;
|
|
1086
|
+
// #684: persist the branch recovered at poll time (the issue's setup
|
|
1087
|
+
// milestone), so the recovery runs once per slot-life instead of every
|
|
1088
|
+
// tick and `last_head` starts flowing for a slot no milestone ever named —
|
|
1089
|
+
// INCLUDING a tick with no milestone at all, which is the long-review
|
|
1090
|
+
// window the pushed-commit signal exists to cover. With the branch known,
|
|
1091
|
+
// the head comparison below is the unchanged pushed-commit signal — a slot
|
|
1092
|
+
// whose branch advanced on the remote within the window is not stalled.
|
|
1093
|
+
if (slot.branch === null && truth.branch !== null) {
|
|
1094
|
+
next = (0, state_1.patchSlot)(next, slot.id, { branch: truth.branch }, now);
|
|
1095
|
+
}
|
|
985
1096
|
if (truth.milestone !== null) {
|
|
986
1097
|
// Live phase per unit (AC6).
|
|
987
1098
|
if (truth.milestone.phase !== slot.phase) {
|
|
@@ -1269,10 +1380,15 @@ function journalStaleMilestoneIfIgnored(ctx, state, unit, slot, truth, verifiedC
|
|
|
1269
1380
|
});
|
|
1270
1381
|
return (0, state_1.patchSlot)(state, slot.id, { stale_milestone_ignored_for: slot.spawned_at }, now);
|
|
1271
1382
|
}
|
|
1272
|
-
const NO_DISPATCH_SIGNALS = {
|
|
1383
|
+
const NO_DISPATCH_SIGNALS = {
|
|
1384
|
+
lastTool: null,
|
|
1385
|
+
apiError: null,
|
|
1386
|
+
fenceAbort: null,
|
|
1387
|
+
};
|
|
1273
1388
|
/**
|
|
1274
|
-
* Read THIS dispatch's last tool call
|
|
1275
|
-
* #620, #629) from its log slice — a
|
|
1389
|
+
* Read THIS dispatch's last tool call, API-error classification (#591,
|
|
1390
|
+
* #620, #629), and fence-abort evidence (#683 AC3) from its log slice — a
|
|
1391
|
+
* pure, side-effect-free parse, safe to
|
|
1276
1392
|
* call from any slot status and any number of times: unlike
|
|
1277
1393
|
* `recordDispatchRunLog` below, it writes nothing to `runs.jsonl`, so it
|
|
1278
1394
|
* carries no exactly-once constraint. Exists because a `verify-incomplete`/
|
|
@@ -1281,12 +1397,23 @@ const NO_DISPATCH_SIGNALS = { lastTool: null, apiError: null };
|
|
|
1281
1397
|
* then the slot has moved past `running` and `recordDispatchRunLog`'s guard
|
|
1282
1398
|
* refuses to re-read, but the dispatch's log file is static once the agent
|
|
1283
1399
|
* has exited, so re-parsing it here yields the same answer every time.
|
|
1400
|
+
*
|
|
1401
|
+
* `run` is the trail run id the fence check must name to count as THIS
|
|
1402
|
+
* dispatch's checkpoint: the slot's own `run_id` when it holds one, else the
|
|
1403
|
+
* trail's current run (a gen-0 dispatch fenced out by a recovery's fence —
|
|
1404
|
+
* the #4153 shape). Doc text quoting HISTORICAL run ids never matches, which
|
|
1405
|
+
* is what keeps the defer classification from being faked by an agent that
|
|
1406
|
+
* merely read `docs/agent-traps.md`.
|
|
1284
1407
|
*/
|
|
1285
|
-
function readDispatchSignalsForSlot(ctx, slot, unit) {
|
|
1408
|
+
function readDispatchSignalsForSlot(ctx, slot, unit, run) {
|
|
1286
1409
|
if (slot.spawned_at === null)
|
|
1287
1410
|
return NO_DISPATCH_SIGNALS;
|
|
1288
1411
|
const { content } = dispatchLogSlice(ctx, slot, unit);
|
|
1289
|
-
return {
|
|
1412
|
+
return {
|
|
1413
|
+
lastTool: (0, core_1.parseLastToolUse)(content),
|
|
1414
|
+
apiError: (0, core_1.parseDispatchApiError)(content),
|
|
1415
|
+
fenceAbort: (0, run_log_1.parseFenceAbort)(content, run),
|
|
1416
|
+
};
|
|
1290
1417
|
}
|
|
1291
1418
|
/**
|
|
1292
1419
|
* THIS dispatch's slice of the per-unit log: path, start offset, contents.
|
|
@@ -1318,7 +1445,12 @@ function dispatchLogSlice(ctx, slot, unit) {
|
|
|
1318
1445
|
* `running` and actually spawned), not by the call sites' ordering — two of
|
|
1319
1446
|
* the four reach slots in any non-idle status.
|
|
1320
1447
|
*/
|
|
1321
|
-
function recordDispatchRunLog(ctx, state, slot, unit
|
|
1448
|
+
function recordDispatchRunLog(ctx, state, slot, unit,
|
|
1449
|
+
// #683 AC3: the trail run id a fence-abort marker in THIS dispatch's log slice must
|
|
1450
|
+
// name to count as evidence (callers with a polled truth pass its run; a kill whose
|
|
1451
|
+
// signals are discarded passes ''). Doc text quotes historical run ids, so without
|
|
1452
|
+
// this match an agent that merely READ docs could be classified as having deferred.
|
|
1453
|
+
run = '') {
|
|
1322
1454
|
// Enforce the once-per-dispatch invariant HERE rather than restating it in
|
|
1323
1455
|
// prose at four call sites (#524 review). `blockTransitiveDependents` and
|
|
1324
1456
|
// `enterRecovery` reach slots in any non-idle status: a slot already moved
|
|
@@ -1375,8 +1507,15 @@ function recordDispatchRunLog(ctx, state, slot, unit) {
|
|
|
1375
1507
|
// #591: the last tool this dispatch called, so an unverified exit attributes to a
|
|
1376
1508
|
// concrete cause (e.g. `Monitor`) without opening the transcript — see `enterRecovery`.
|
|
1377
1509
|
// #629: whether this SAME dispatch was a confirmed provider API error — see
|
|
1378
|
-
// `completeUnitOrRecover`.
|
|
1379
|
-
|
|
1510
|
+
// `completeUnitOrRecover`. #683 AC3: whether it ended because a fence told it to —
|
|
1511
|
+
// `run` names the trail run id the fence check must have answered to; with no run
|
|
1512
|
+
// known, no fence-abort evidence can be this dispatch's own and the parse is skipped.
|
|
1513
|
+
const fenceRun = run !== '' ? run : (slot.run_id ?? '');
|
|
1514
|
+
return {
|
|
1515
|
+
lastTool: (0, core_1.parseLastToolUse)(logContent),
|
|
1516
|
+
apiError: (0, core_1.parseDispatchApiError)(logContent),
|
|
1517
|
+
fenceAbort: (0, run_log_1.parseFenceAbort)(logContent, fenceRun),
|
|
1518
|
+
};
|
|
1380
1519
|
}
|
|
1381
1520
|
/** Reconcile one running slot against its polled ground truth. */
|
|
1382
1521
|
function reconcileRunning(ctx, state, slot, truth, unit) {
|
|
@@ -1385,7 +1524,19 @@ function reconcileRunning(ctx, state, slot, truth, unit) {
|
|
|
1385
1524
|
// is DETECTED, never trusted as completion (AC2/AC3).
|
|
1386
1525
|
if (slot.pid !== null && !ctx.deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined)) {
|
|
1387
1526
|
journal(ctx, 'exit-detected', unit, { pid: slot.pid, slot: slot.id });
|
|
1388
|
-
|
|
1527
|
+
// #683 AC1: the owning run just ended — release its fence here, BEFORE the
|
|
1528
|
+
// classification below decides between completion and a (re-fencing) recovery, so
|
|
1529
|
+
// no path can leave a dead run's fence governing the trail. A recovery re-fences
|
|
1530
|
+
// at gen+1 right after, which dominates the released generation as before.
|
|
1531
|
+
releaseFenceOnExit(ctx, unit, (0, journal_1.issueOfUnit)(unit) ?? 0, slot);
|
|
1532
|
+
// #683 AC3: the defer classification must recognize THIS dispatch's own
|
|
1533
|
+
// checkpoint output. A gen>0 takeover's slot carries its fence's run_id; a
|
|
1534
|
+
// gen-0 dispatch (re-enqueued fresh, fenced out by an earlier recovery —
|
|
1535
|
+
// the #4153 shape) carries none, so the trail's current run — the run id
|
|
1536
|
+
// the checkpoint instruction tells every dispatch to check under — is the
|
|
1537
|
+
// match key. Wrong-run markers never match (docs quote historical ids),
|
|
1538
|
+
// which is what keeps the classification unfakeable by read text alone.
|
|
1539
|
+
const signals = recordDispatchRunLog(ctx, state, slot, unit, truth.milestone?.run ?? '');
|
|
1389
1540
|
const exited = (0, state_1.transitionSlot)(state, slot.id, 'exited', {}, now);
|
|
1390
1541
|
return completeUnitOrRecover(ctx, exited, unit, truth, 'verify-complete', signals);
|
|
1391
1542
|
}
|
|
@@ -1454,6 +1605,14 @@ function reconcileRunning(ctx, state, slot, truth, unit) {
|
|
|
1454
1605
|
return enterRecovery(ctx, progress.state, unit, 'stalled', 'stall', truth, {
|
|
1455
1606
|
active_phase: activePhase,
|
|
1456
1607
|
stall_timeout_ms: stallTimeoutMs,
|
|
1608
|
+
// #684 AC3: record what the verdict checked, so a false stall is
|
|
1609
|
+
// diagnosable from the journal alone — `last_head: null` here is the
|
|
1610
|
+
// signature of a slot whose pushed-commit signal never had a head to
|
|
1611
|
+
// compare (the issue:4156 failure), distinguishable from a head that
|
|
1612
|
+
// was observed and simply had not moved.
|
|
1613
|
+
last_milestone_at: truth.milestone?.at ?? null,
|
|
1614
|
+
last_head: slot.last_head,
|
|
1615
|
+
decision_head: truth.head,
|
|
1457
1616
|
...(slot.fenced_at !== null ? { fenced_at: slot.fenced_at } : {}),
|
|
1458
1617
|
});
|
|
1459
1618
|
}
|
|
@@ -1608,6 +1767,27 @@ dispatchSignals = NO_DISPATCH_SIGNALS) {
|
|
|
1608
1767
|
return enterRecovery(ctx, recorded, unit, 'dispatch-failure', (0, dispatch_health_1.dispatchApiErrorDetail)(resolved.apiError), truth, (0, dispatch_health_1.dispatchApiErrorFields)(resolved.apiError), { escalate: false });
|
|
1609
1768
|
}
|
|
1610
1769
|
const suspect = msSinceLastProgress(slot, now) < types_1.SUSPECT_DISPATCH_WINDOW_MS;
|
|
1770
|
+
// #683 AC3: the dispatch's own log carries its supersession-checkpoint
|
|
1771
|
+
// verdict for THIS trail run — the agent checked, found an owner, and
|
|
1772
|
+
// stepped aside. That is a deliberate, CORRECT no-op: never an
|
|
1773
|
+
// `unverified-exit`, never an escalation rung. Classified deterministically
|
|
1774
|
+
// off the CLI's exact output (the #629 lesson — never a heuristic on exit
|
|
1775
|
+
// codes), recorded as a HEALTHY dispatch (no `suspect-dispatch`), and
|
|
1776
|
+
// redispatched at the same tier via the #629 unescalated rail — the fence
|
|
1777
|
+
// `enterRecovery` writes lands at the next generation and its takeover gets
|
|
1778
|
+
// the pid bind, so the #4153 livelock self-heals in one step instead of
|
|
1779
|
+
// climbing to `unverified-exit-at-strongest-tier`. Checked AFTER
|
|
1780
|
+
// `recordDispatchApiError`'s branch: a confirmed provider wall (num_turns 1,
|
|
1781
|
+
// no check output) cannot also be a defer, and its rail owns that shape.
|
|
1782
|
+
if (resolved.fenceAbort !== null) {
|
|
1783
|
+
const recorded = recordDispatchOutcome(ctx, next, unit, slot, false);
|
|
1784
|
+
return enterRecovery(ctx, recorded, unit, 'deferred-to-owner', 'deferred to the fence owner — deliberate supersession-checkpoint abort, no escalation rung consumed', truth, {
|
|
1785
|
+
fence_gen: resolved.fenceAbort.gen,
|
|
1786
|
+
...(resolved.fenceAbort.takeover !== null
|
|
1787
|
+
? { fence_takeover: resolved.fenceAbort.takeover }
|
|
1788
|
+
: {}),
|
|
1789
|
+
}, { escalate: false });
|
|
1790
|
+
}
|
|
1611
1791
|
next = recordDispatchOutcome(ctx, next, unit, slot, suspect);
|
|
1612
1792
|
return enterRecovery(ctx, next, unit, 'verify-incomplete', 'unverified-exit', truth, {
|
|
1613
1793
|
observed: truth.milestone
|
|
@@ -1664,6 +1844,7 @@ function reconcileSlots(ctx, state, polled) {
|
|
|
1664
1844
|
milestone: null,
|
|
1665
1845
|
closed: false,
|
|
1666
1846
|
head: null,
|
|
1847
|
+
branch: null,
|
|
1667
1848
|
};
|
|
1668
1849
|
switch (slot.status) {
|
|
1669
1850
|
case 'assigned':
|
|
@@ -1683,7 +1864,7 @@ function reconcileSlots(ctx, state, polled) {
|
|
|
1683
1864
|
// whatever `reconcileRunning` saw. Passed as a thunk so the read
|
|
1684
1865
|
// happens only on the tick that actually reaches the unverified-exit
|
|
1685
1866
|
// decision, not on every tick an outage holds the slot here.
|
|
1686
|
-
next = completeUnitOrRecover(ctx, next, unit, truth, 'verify-complete', () => readDispatchSignalsForSlot(ctx, slot, unit));
|
|
1867
|
+
next = completeUnitOrRecover(ctx, next, unit, truth, 'verify-complete', () => readDispatchSignalsForSlot(ctx, slot, unit, truth.milestone?.run ?? ''));
|
|
1687
1868
|
break;
|
|
1688
1869
|
case 'recovering':
|
|
1689
1870
|
next = reconcileRecovering(ctx, next, unit);
|