@ai-dossier/sched 0.49.0 → 0.51.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 +100 -9
- package/dist/batch-dispatch.d.ts +82 -0
- package/dist/batch-dispatch.d.ts.map +1 -1
- package/dist/batch-dispatch.js +403 -55
- package/dist/batch-dispatch.js.map +1 -1
- package/dist/groundtruth.d.ts +74 -3
- package/dist/groundtruth.d.ts.map +1 -1
- package/dist/groundtruth.js +141 -14
- package/dist/groundtruth.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -6
- package/dist/index.js.map +1 -1
- package/dist/recovery.d.ts +5 -0
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +11 -6
- package/dist/recovery.js.map +1 -1
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +8 -2
- package/dist/scheduler.js.map +1 -1
- package/dist/state.d.ts +15 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +65 -22
- package/dist/state.js.map +1 -1
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +29 -1
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +43 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +10 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/batch-dispatch.js
CHANGED
|
@@ -96,9 +96,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
96
96
|
};
|
|
97
97
|
})();
|
|
98
98
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
99
|
+
exports.MAX_BATCH_AGENT_RESPAWNS = void 0;
|
|
99
100
|
exports.memberBranchFor = memberBranchFor;
|
|
101
|
+
exports.tailMembersRefusal = tailMembersRefusal;
|
|
100
102
|
exports.evictMemberAndContinue = evictMemberAndContinue;
|
|
101
103
|
exports.resumeBlockedGate = resumeBlockedGate;
|
|
104
|
+
exports.reconcileStaleBlockedBatches = reconcileStaleBlockedBatches;
|
|
102
105
|
exports.memberDispatchModeFor = memberDispatchModeFor;
|
|
103
106
|
exports.runBatchTick = runBatchTick;
|
|
104
107
|
const fs = __importStar(require("node:fs"));
|
|
@@ -1308,6 +1311,50 @@ function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
|
|
|
1308
1311
|
}
|
|
1309
1312
|
claimAndSpawn(deps, config, batchId, 'member', now, (nextState, slot) => spawnMember(deps, dispatch, nextState, slot, batchId, now, result, member));
|
|
1310
1313
|
}
|
|
1314
|
+
/**
|
|
1315
|
+
* #832: why the tail must NOT be dispatched over `landed`, or null when it may.
|
|
1316
|
+
* The deterministic half of the check the tail agent itself makes before
|
|
1317
|
+
* integrating (the members it is told about and the members it derives from
|
|
1318
|
+
* the integration branch's boundary commits must agree) — made here for free
|
|
1319
|
+
* instead of by a strong-tier agent that then exits `members-mismatch`.
|
|
1320
|
+
* `batch.ranges` is the recorded boundary attribution (recomputed at every
|
|
1321
|
+
* landing); when it is empty nothing was attributed, so there is nothing to
|
|
1322
|
+
* cross-check. Invariant this relies on: `ranges` names only landed members —
|
|
1323
|
+
* every exit path that reverts a member also drops its range
|
|
1324
|
+
* (`recovery.ts`'s partial dissolve); a path that re-derived `ranges` from
|
|
1325
|
+
* `git log` after a revert would make this refuse a healthy batch.
|
|
1326
|
+
*/
|
|
1327
|
+
function tailMembersRefusal(batch, landed) {
|
|
1328
|
+
if (landed.length === 0)
|
|
1329
|
+
return 'no-landed-members';
|
|
1330
|
+
if (batch.ranges.length === 0)
|
|
1331
|
+
return null;
|
|
1332
|
+
const attributed = new Set(batch.ranges.map((r) => r.issue));
|
|
1333
|
+
const missing = landed.filter((issue) => !attributed.has(issue));
|
|
1334
|
+
if (missing.length > 0)
|
|
1335
|
+
return `members-mismatch:no-boundary-commit-${missing.join(',')}`;
|
|
1336
|
+
// The tail derives members from the boundary commits too, and refuses when
|
|
1337
|
+
// the two lists disagree in EITHER direction (review-issue Aggregate Step 1).
|
|
1338
|
+
const landedSet = new Set(landed);
|
|
1339
|
+
const unlanded = [...attributed].filter((issue) => !landedSet.has(issue));
|
|
1340
|
+
return unlanded.length > 0
|
|
1341
|
+
? `members-mismatch:unlanded-boundary-commit-${unlanded.join(',')}`
|
|
1342
|
+
: null;
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* #832: the one pre-dispatch verdict for a tail — checked before a slot is
|
|
1346
|
+
* claimed and again under the claim's lock. A tail whose respawns are spent
|
|
1347
|
+
* (a crash landed between counting the exit that spent them and blocking the
|
|
1348
|
+
* batch) is refused, as is a members mismatch. Only a batch in a tail phase
|
|
1349
|
+
* is judged; anything else is not the tail's to refuse.
|
|
1350
|
+
*/
|
|
1351
|
+
function tailDispatchRefusal(state, batch) {
|
|
1352
|
+
if (batch.status !== 'reviewing' && batch.status !== 'shipping')
|
|
1353
|
+
return null;
|
|
1354
|
+
if (agentRespawnsSpent(batch, 'tail'))
|
|
1355
|
+
return 'respawn-cap:tail';
|
|
1356
|
+
return tailMembersRefusal(batch, (0, state_1.validatedMembersOf)(state, batch));
|
|
1357
|
+
}
|
|
1311
1358
|
/**
|
|
1312
1359
|
* Tail/report/fix dispatches (this function, `spawnReportAgent`,
|
|
1313
1360
|
* `reconcileFixSlot`) are NOT recorded live to `runs.jsonl` — only member
|
|
@@ -1316,11 +1363,27 @@ function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
|
|
|
1316
1363
|
* the only way to see their cost today, reconstructed from the raw log
|
|
1317
1364
|
* after the fact. Wiring in live recording for these later means repeating
|
|
1318
1365
|
* `spawnMember`'s own #564 fix first: none of these three spawn functions'
|
|
1319
|
-
* patches
|
|
1366
|
+
* patches stamped `SlotEntry.spawned_at` either, so a `recordXRunLog` guarded
|
|
1320
1367
|
* on `spawned_at !== null` (mirroring `recordMemberRunLog`) would silently
|
|
1321
|
-
* no-op forever, exactly like the original bug.
|
|
1368
|
+
* no-op forever, exactly like the original bug. #832 made the tail and report
|
|
1369
|
+
* spawns stamp it (their hand-back fence needs it); the fix agent still does
|
|
1370
|
+
* not.
|
|
1322
1371
|
*/
|
|
1323
1372
|
function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
|
|
1373
|
+
// #832: the tail runs over the LANDED members only — never `batch.members`,
|
|
1374
|
+
// which still lists a member that was stopped, handed back, evicted or
|
|
1375
|
+
// parked. A refusal blocks the batch before any agent is spawned (and
|
|
1376
|
+
// before a slot is claimed — `blockBatchForOperator` posts a milestone, an
|
|
1377
|
+
// exec that must not run under the store lock).
|
|
1378
|
+
const before = deps.store.load();
|
|
1379
|
+
const pending = (0, state_1.findBatch)(before, batchId);
|
|
1380
|
+
if (pending) {
|
|
1381
|
+
const refusal = tailDispatchRefusal(before, pending);
|
|
1382
|
+
if (refusal !== null) {
|
|
1383
|
+
blockBatchForOperator(deps, config, batchId, { reason: refusal, milestonePhase: 'batch-review' }, now, result);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1324
1387
|
claimAndSpawn(deps, config, batchId, 'reviewing', now, (state, slot) => {
|
|
1325
1388
|
const batch = (0, state_1.findBatch)(state, batchId);
|
|
1326
1389
|
if (!batch || batch.worktree === null || batch.anchor === null) {
|
|
@@ -1330,9 +1393,20 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
|
|
|
1330
1393
|
});
|
|
1331
1394
|
return releaseSlot(state, batchId, now);
|
|
1332
1395
|
}
|
|
1396
|
+
const landed = (0, state_1.validatedMembersOf)(state, batch);
|
|
1397
|
+
const lateRefusal = tailDispatchRefusal(state, batch);
|
|
1398
|
+
if (lateRefusal !== null) {
|
|
1399
|
+
// The state changed between the pre-check and the lock (a concurrent
|
|
1400
|
+
// stop/park) — release, and the next tick's pre-check (reading the same
|
|
1401
|
+
// persisted state) blocks the batch with this reason.
|
|
1402
|
+
journalEvent(deps, 'slot-released', unit(batchId), {
|
|
1403
|
+
detail: `tail not dispatched: ${lateRefusal} (state changed before the claim; the next tick blocks the batch)`,
|
|
1404
|
+
});
|
|
1405
|
+
return releaseSlot(state, batchId, now);
|
|
1406
|
+
}
|
|
1333
1407
|
const spawnSpec = (0, dispatch_1.resolveTierSpawn)(dispatch, 'strong', batch.anchor);
|
|
1334
1408
|
const cmd = spawnSpec.cmd;
|
|
1335
|
-
const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor,
|
|
1409
|
+
const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor, landed, batch.worktree);
|
|
1336
1410
|
const logFile = (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId);
|
|
1337
1411
|
// #629: fences `handleDeadDispatchApiError`'s classification to this
|
|
1338
1412
|
// dispatch's slice — see `spawnMember`'s identical comment.
|
|
@@ -1355,6 +1429,14 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
|
|
|
1355
1429
|
phase: 'reviewing',
|
|
1356
1430
|
last_progress_at: now.toISOString(),
|
|
1357
1431
|
log_offset_at_spawn: logOffset,
|
|
1432
|
+
// #832: the fence `reconcileTailSlot` reads — without it a
|
|
1433
|
+
// `batch-review blocked` left on the anchor by an EARLIER dispatch
|
|
1434
|
+
// (or a previous batch on the same anchor, #605) would block this one.
|
|
1435
|
+
// The fence tolerates `DISPATCH_FENCE_TOLERANCE_MS` (60s) of clock skew,
|
|
1436
|
+
// so a blocked milestone posted less than a minute before this spawn
|
|
1437
|
+
// still counts — only a resume that re-dispatches the tail that fast
|
|
1438
|
+
// could trip it.
|
|
1439
|
+
spawned_at: now.toISOString(),
|
|
1358
1440
|
};
|
|
1359
1441
|
const next = slot.status === 'assigned' ? (0, state_1.transitionSlot)(state, slot.id, 'running', patch, now) : state;
|
|
1360
1442
|
deps.journal.append((0, journal_1.unitEvent)('spawned', unit(batchId), {
|
|
@@ -1369,6 +1451,13 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
|
|
|
1369
1451
|
});
|
|
1370
1452
|
}
|
|
1371
1453
|
function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
|
|
1454
|
+
// #832: respawns already spent (a crash landed between counting the exit
|
|
1455
|
+
// that spent them and closing the batch) — close it now, never spawn.
|
|
1456
|
+
const pending = (0, state_1.findBatch)(deps.store.load(), batchId);
|
|
1457
|
+
if (pending && pending.status === 'deployed' && agentRespawnsSpent(pending, 'report')) {
|
|
1458
|
+
closeWithoutReport(deps, batchId, 'respawn-cap:report', now, result);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1372
1461
|
claimAndSpawn(deps, config, batchId, 'report', now, (state, slot) => {
|
|
1373
1462
|
const batch = (0, state_1.findBatch)(state, batchId);
|
|
1374
1463
|
if (!batch || batch.anchor === null) {
|
|
@@ -1405,6 +1494,8 @@ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
|
|
|
1405
1494
|
phase: 'report',
|
|
1406
1495
|
last_progress_at: now.toISOString(),
|
|
1407
1496
|
log_offset_at_spawn: logOffset,
|
|
1497
|
+
// #832: the fence `reconcileReportSlot`'s hand-back check reads.
|
|
1498
|
+
spawned_at: now.toISOString(),
|
|
1408
1499
|
};
|
|
1409
1500
|
const next = slot.status === 'assigned'
|
|
1410
1501
|
? (0, state_1.transitionSlot)(state, slot.id, 'running', patchState, now)
|
|
@@ -2564,7 +2655,98 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
|
|
|
2564
2655
|
}
|
|
2565
2656
|
evictOffender(deps, config, batchId, offenderRecord.issue, 'overlap', now, result);
|
|
2566
2657
|
}
|
|
2567
|
-
|
|
2658
|
+
/**
|
|
2659
|
+
* #832: how many times one tail-work phase's agent (`tail` or `report`) is
|
|
2660
|
+
* RESPAWNED after exiting unverified — the first dispatch plus this many
|
|
2661
|
+
* respawns; the next unverified exit ends the phase instead. The #629 class:
|
|
2662
|
+
* the tick loop's "same wedge" arm respawns a dead tail/report agent on its
|
|
2663
|
+
* own, so without a cap an agent that exits without a verdict is
|
|
2664
|
+
* redispatched forever (b-20260924-04: the strong-tier tail four times,
|
|
2665
|
+
* ~375k tokens, stopped only by hand).
|
|
2666
|
+
*/
|
|
2667
|
+
exports.MAX_BATCH_AGENT_RESPAWNS = 2;
|
|
2668
|
+
/** #832: whether `batch` has already spent its respawns for `phase`. */
|
|
2669
|
+
function agentRespawnsSpent(batch, phase) {
|
|
2670
|
+
return (batch.agent_exits !== null &&
|
|
2671
|
+
batch.agent_exits.phase === phase &&
|
|
2672
|
+
batch.agent_exits.count > exports.MAX_BATCH_AGENT_RESPAWNS);
|
|
2673
|
+
}
|
|
2674
|
+
/**
|
|
2675
|
+
* #832: count one unverified exit of `phase`'s agent, release its slot, and
|
|
2676
|
+
* journal the exit with its position against the cap — one lock, so the count
|
|
2677
|
+
* and the release cannot come apart. Returns `'capped'` when this exit spent
|
|
2678
|
+
* the phase's last respawn (the caller ends the phase), else `'respawn'`.
|
|
2679
|
+
* The count is per phase — another phase's exits start it over — and verified
|
|
2680
|
+
* progress clears it (`clearAgentExits`).
|
|
2681
|
+
*/
|
|
2682
|
+
function releaseAndCountAgentExit(deps, batchId, phase, failed, now) {
|
|
2683
|
+
const count = deps.store.withLock((s) => {
|
|
2684
|
+
const b = (0, state_1.findBatch)(s, batchId);
|
|
2685
|
+
const prior = b?.agent_exits;
|
|
2686
|
+
const next = prior && prior.phase === phase ? prior.count + 1 : 1;
|
|
2687
|
+
let n = releaseSlot(s, batchId, now);
|
|
2688
|
+
if (b)
|
|
2689
|
+
n = (0, state_1.patchBatch)(n, batchId, { agent_exits: { phase, count: next } }, now);
|
|
2690
|
+
return { state: n, result: next };
|
|
2691
|
+
});
|
|
2692
|
+
journalEvent(deps, failed.event, unit(batchId), {
|
|
2693
|
+
...failed.extra,
|
|
2694
|
+
detail: `${String(failed.extra.detail ?? 'unverified exit')} ${count}/${exports.MAX_BATCH_AGENT_RESPAWNS + 1} (log: ${failed.log})`,
|
|
2695
|
+
});
|
|
2696
|
+
return count > exports.MAX_BATCH_AGENT_RESPAWNS ? 'capped' : 'respawn';
|
|
2697
|
+
}
|
|
2698
|
+
/** #832: verified progress in a tail-work phase — the respawn count starts over. */
|
|
2699
|
+
function clearAgentExits(s, batchId, now) {
|
|
2700
|
+
const b = (0, state_1.findBatch)(s, batchId);
|
|
2701
|
+
return b && b.agent_exits !== null ? (0, state_1.patchBatch)(s, batchId, { agent_exits: null }, now) : s;
|
|
2702
|
+
}
|
|
2703
|
+
/**
|
|
2704
|
+
* #832: the tail posted its own `batch-review`/`batch-ship blocked` — release
|
|
2705
|
+
* its slot and block the batch with that reason in ONE lock (no milestone to
|
|
2706
|
+
* post: the agent's is already the anchor's record, so no exec runs under the
|
|
2707
|
+
* lock), so a crash can never leave the slot released with the batch still
|
|
2708
|
+
* `reviewing` for the wedge arm to respawn. The agent decided; respawning it
|
|
2709
|
+
* would only get the same answer again.
|
|
2710
|
+
*/
|
|
2711
|
+
function blockOnTailHandBack(deps, config, batch, reason, now, result) {
|
|
2712
|
+
const rDeps = recoveryDeps(deps, config, batch, now);
|
|
2713
|
+
deps.store.withLock((s) => {
|
|
2714
|
+
const released = releaseSlot(s, batch.id, now);
|
|
2715
|
+
const b = (0, state_1.findBatch)(released, batch.id);
|
|
2716
|
+
if (!b || types_1.TERMINAL_BATCH_STATUSES.has(b.status) || b.status === 'blocked') {
|
|
2717
|
+
return { state: released, result: undefined };
|
|
2718
|
+
}
|
|
2719
|
+
const blocked = (0, recovery_1.blockBatch)(released, batch.id, { reason, milestonePhase: 'batch-review', milestoneAlreadyPosted: true }, rDeps);
|
|
2720
|
+
return { state: blocked.state, result: undefined };
|
|
2721
|
+
});
|
|
2722
|
+
result.failed.push(unit(batch.id));
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* #832: close a MERGED batch whose report agent spent its respawns, without
|
|
2726
|
+
* its report. The report is the batch's summary, not its work — the PR is
|
|
2727
|
+
* merged and deployed — so the batch goes `deployed → reported → done` and
|
|
2728
|
+
* is torn down like any finished batch. Blocking it instead would not stop
|
|
2729
|
+
* anything: `reconcileStaleBlockedBatches` walks a blocked batch with a
|
|
2730
|
+
* merged PR straight back to `deployed`, and the report agent would respawn.
|
|
2731
|
+
*/
|
|
2732
|
+
function closeWithoutReport(deps, batchId, why, now, result) {
|
|
2733
|
+
const closed = deps.store.withLock((s) => {
|
|
2734
|
+
const b = (0, state_1.findBatch)(s, batchId);
|
|
2735
|
+
if (!b || b.status !== 'deployed')
|
|
2736
|
+
return { state: s, result: false };
|
|
2737
|
+
let n = (0, state_1.transitionBatch)(s, batchId, 'reported', { agent_exits: null }, now);
|
|
2738
|
+
n = (0, state_1.transitionBatch)(n, batchId, 'done', {}, now);
|
|
2739
|
+
return { state: n, result: true };
|
|
2740
|
+
});
|
|
2741
|
+
if (!closed)
|
|
2742
|
+
return;
|
|
2743
|
+
journalEvent(deps, 'report-failed', unit(batchId), {
|
|
2744
|
+
detail: `${why} — batch closed without its report (the PR is merged); post the batch report by hand if one is wanted`,
|
|
2745
|
+
});
|
|
2746
|
+
result.failed.push(unit(batchId));
|
|
2747
|
+
teardownBatch(deps, batchId);
|
|
2748
|
+
}
|
|
2749
|
+
function reconcileTailSlot(deps, config, batchId, slot, now, result) {
|
|
2568
2750
|
const state = deps.store.load();
|
|
2569
2751
|
const batch = (0, state_1.findBatch)(state, batchId);
|
|
2570
2752
|
if (!batch || batch.anchor === null)
|
|
@@ -2579,10 +2761,28 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
|
|
|
2579
2761
|
const b = (0, state_1.findBatch)(s, batchId);
|
|
2580
2762
|
if (!b || b.status !== 'reviewing')
|
|
2581
2763
|
return { state: s, result: undefined };
|
|
2582
|
-
|
|
2764
|
+
const n = clearAgentExits(s, batchId, now);
|
|
2765
|
+
return { state: (0, state_1.transitionBatch)(n, batchId, 'shipping', {}, now), result: undefined };
|
|
2583
2766
|
});
|
|
2584
2767
|
return;
|
|
2585
2768
|
}
|
|
2769
|
+
// #832: the tail handed back (`batch-review`/`batch-ship blocked`, e.g.
|
|
2770
|
+
// `members-mismatch`) — block the batch with its reason, never respawn.
|
|
2771
|
+
// Checked before liveness: the agent is done deciding whether or not its
|
|
2772
|
+
// process has exited yet, and the block releases its slot. Only for a slot
|
|
2773
|
+
// that recorded its dispatch time: a tail spawned before #832 stamped
|
|
2774
|
+
// `spawned_at` has no fence, and an unfenced read would let an older
|
|
2775
|
+
// `blocked` milestone on the anchor block this dispatch — that exit falls
|
|
2776
|
+
// through to the (capped) unverified path instead.
|
|
2777
|
+
const handedBack = slot.spawned_at === null
|
|
2778
|
+
? null
|
|
2779
|
+
: (0, groundtruth_1.batchPhaseBlockedReason)(milestone, ['batch-review', 'batch-ship'], slot.spawned_at);
|
|
2780
|
+
if (handedBack !== null) {
|
|
2781
|
+
if (slot.pid !== null && !dead)
|
|
2782
|
+
deps.spawnDeps.kill(slot.pid, slot.pid_start ?? undefined);
|
|
2783
|
+
blockOnTailHandBack(deps, config, batch, `tail-blocked:${handedBack}`, now, result);
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2586
2786
|
if ((0, groundtruth_1.isBatchTailParked)(milestone)) {
|
|
2587
2787
|
const pr = (0, groundtruth_1.prOfMilestone)(milestone);
|
|
2588
2788
|
journalEvent(deps, 'pr-parked', unit(batchId), { pr: pr ?? undefined });
|
|
@@ -2594,6 +2794,7 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
|
|
|
2594
2794
|
const b = (0, state_1.findBatch)(n, batchId);
|
|
2595
2795
|
if (!b)
|
|
2596
2796
|
return { state: n, result: undefined };
|
|
2797
|
+
n = clearAgentExits(n, batchId, now);
|
|
2597
2798
|
n = b.status === 'reviewing' ? (0, state_1.transitionBatch)(n, batchId, 'shipping', {}, now) : n;
|
|
2598
2799
|
n = (0, state_1.transitionBatch)(n, batchId, 'awaiting-merge', { pr }, now);
|
|
2599
2800
|
return { state: n, result: undefined };
|
|
@@ -2609,12 +2810,20 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
|
|
|
2609
2810
|
if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
|
|
2610
2811
|
return;
|
|
2611
2812
|
}
|
|
2612
|
-
|
|
2613
|
-
|
|
2813
|
+
// #832: counted — once the respawns are spent the batch blocks instead.
|
|
2814
|
+
const outcome = releaseAndCountAgentExit(deps, batchId, 'tail', {
|
|
2815
|
+
event: 'unit-failed',
|
|
2816
|
+
extra: { reason: 'tail-agent-exited-unverified' },
|
|
2817
|
+
log: (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId),
|
|
2818
|
+
}, now);
|
|
2819
|
+
if (outcome === 'capped') {
|
|
2820
|
+
blockBatchForOperator(deps, config, batchId, { reason: 'respawn-cap:tail', milestonePhase: 'batch-review' }, now, result);
|
|
2821
|
+
return;
|
|
2822
|
+
}
|
|
2614
2823
|
result.failed.push(unit(batchId));
|
|
2615
2824
|
}
|
|
2616
2825
|
}
|
|
2617
|
-
function reconcileReportSlot(deps, batchId, slot, now, result) {
|
|
2826
|
+
function reconcileReportSlot(deps, config, batchId, slot, now, result) {
|
|
2618
2827
|
const state = deps.store.load();
|
|
2619
2828
|
const batch = (0, state_1.findBatch)(state, batchId);
|
|
2620
2829
|
if (!batch || batch.anchor === null)
|
|
@@ -2629,7 +2838,7 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
|
|
|
2629
2838
|
// #629: a verified report completion is proof dispatch is healthy —
|
|
2630
2839
|
// reset the confirmed-failure streak, mirroring the per-issue path's
|
|
2631
2840
|
// own verified-complete branch.
|
|
2632
|
-
let n = (0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now));
|
|
2841
|
+
let n = clearAgentExits((0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now)), batchId, now);
|
|
2633
2842
|
const b = (0, state_1.findBatch)(n, batchId);
|
|
2634
2843
|
if (!b || b.status !== 'deployed')
|
|
2635
2844
|
return { state: n, result: undefined };
|
|
@@ -2649,24 +2858,57 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
|
|
|
2649
2858
|
if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
|
|
2650
2859
|
return;
|
|
2651
2860
|
}
|
|
2652
|
-
|
|
2653
|
-
|
|
2861
|
+
// #832: counted — once the respawns are spent the merged batch closes
|
|
2862
|
+
// without its report (see `closeWithoutReport` for why not a block).
|
|
2863
|
+
const outcome = releaseAndCountAgentExit(deps, batchId, 'report', {
|
|
2864
|
+
event: 'report-failed',
|
|
2865
|
+
extra: { detail: 'unverified exit' },
|
|
2866
|
+
log: (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId),
|
|
2867
|
+
}, now);
|
|
2868
|
+
if (outcome === 'capped')
|
|
2869
|
+
closeWithoutReport(deps, batchId, 'respawn-cap:report', now, result);
|
|
2654
2870
|
}
|
|
2655
2871
|
}
|
|
2656
2872
|
// --- PR watch for `awaiting-merge` batches (mirrors `pollParkedPrs`/`reconcileParked`) ---
|
|
2657
2873
|
/**
|
|
2658
|
-
*
|
|
2659
|
-
* streak is recorded
|
|
2660
|
-
*
|
|
2874
|
+
* Clear one `<prefix>_reason`/`_since`/`_ticks` batch dedup marker — a no-op
|
|
2875
|
+
* when no streak is recorded (`currentReason === null`), so every caller is
|
|
2876
|
+
* safe to invoke unconditionally, every tick, on a batch that may or may not
|
|
2877
|
+
* have an open streak. Shared by `pr-watch-failed` (#630/#633),
|
|
2878
|
+
* `anchor-close-failed` (#768) and `pr-detect-ambiguous` (#789) — each
|
|
2879
|
+
* family previously carried its own copy of this exact body (#789 review).
|
|
2661
2880
|
*/
|
|
2662
|
-
function
|
|
2663
|
-
if (
|
|
2881
|
+
function clearBatchDedupMarker(deps, batch, currentReason, clearedFields, now) {
|
|
2882
|
+
if (currentReason === null)
|
|
2664
2883
|
return;
|
|
2665
2884
|
deps.store.withLock((s) => ({
|
|
2666
|
-
state: (0, state_1.patchBatch)(s, batch.id,
|
|
2885
|
+
state: (0, state_1.patchBatch)(s, batch.id, clearedFields, now, false),
|
|
2667
2886
|
result: undefined,
|
|
2668
2887
|
}));
|
|
2669
2888
|
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Advance one `<prefix>_reason`/`_since`/`_ticks` batch dedup marker by one
|
|
2891
|
+
* tick and decide whether THIS tick should journal — the streak's first tick,
|
|
2892
|
+
* or every `JOURNAL_DEDUP_REANNOUNCE_TICKS` after, never every tick
|
|
2893
|
+
* (#610/#630/#632/#768/#789's shared shape). Pure — the caller still owns
|
|
2894
|
+
* building its own journal payload (each family logs different extra fields)
|
|
2895
|
+
* and the `deps.store.withLock` persistence write; this only computes
|
|
2896
|
+
* `since`/`ticks`/whether to announce.
|
|
2897
|
+
*/
|
|
2898
|
+
function advanceBatchDedupStreak(priorReason, priorSince, priorTicks, reason, now) {
|
|
2899
|
+
const isNewStreak = priorReason !== reason || priorSince === null;
|
|
2900
|
+
const ticks = isNewStreak ? 1 : priorTicks + 1;
|
|
2901
|
+
const since = isNewStreak || priorSince === null ? now.toISOString() : priorSince;
|
|
2902
|
+
return { since, ticks, announce: isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0 };
|
|
2903
|
+
}
|
|
2904
|
+
/**
|
|
2905
|
+
* Reset a batch's `pr-watch-failed` dedup marker (#630/#633). A no-op when no
|
|
2906
|
+
* streak is recorded, so it is safe to call on every non-`awaiting-merge`
|
|
2907
|
+
* batch every tick.
|
|
2908
|
+
*/
|
|
2909
|
+
function clearPrWatchFailed(deps, batch, now) {
|
|
2910
|
+
clearBatchDedupMarker(deps, batch, batch.pr_watch_failed_reason, state_1.CLEARED_PR_WATCH_FIELDS, now);
|
|
2911
|
+
}
|
|
2670
2912
|
function reconcilePrWatch(deps, now, result) {
|
|
2671
2913
|
const state = deps.store.load();
|
|
2672
2914
|
for (const batch of state.batches) {
|
|
@@ -2715,9 +2957,6 @@ function reconcilePrWatch(deps, now, result) {
|
|
|
2715
2957
|
}
|
|
2716
2958
|
if (truth.blocked || truth.mergeable === 'CONFLICTING') {
|
|
2717
2959
|
const reason = truth.blocked ? 'auto-merge-blocked' : 'pr-conflicting';
|
|
2718
|
-
const isNewStreak = batch.pr_watch_failed_reason !== reason;
|
|
2719
|
-
const ticks = isNewStreak ? 1 : batch.pr_watch_failed_ticks + 1;
|
|
2720
|
-
const since = isNewStreak ? now.toISOString() : batch.pr_watch_failed_since;
|
|
2721
2960
|
// #630: journal on the streak's first tick, then again only every
|
|
2722
2961
|
// `JOURNAL_DEDUP_REANNOUNCE_TICKS` — never every tick — mirrors #610's
|
|
2723
2962
|
// `stale_milestone_ignored_for` dedup for the "distinct condition"
|
|
@@ -2729,7 +2968,8 @@ function reconcilePrWatch(deps, now, result) {
|
|
|
2729
2968
|
// of its own); `since` is the streak's onset, and is what makes the
|
|
2730
2969
|
// duration legible — `ticks_persisted` is a tick count against an
|
|
2731
2970
|
// operator-tunable `reconcile_interval_ms`, not a fixed wall-clock.
|
|
2732
|
-
|
|
2971
|
+
const { since, ticks, announce } = advanceBatchDedupStreak(batch.pr_watch_failed_reason, batch.pr_watch_failed_since, batch.pr_watch_failed_ticks, reason, now);
|
|
2972
|
+
if (announce) {
|
|
2733
2973
|
journalEvent(deps, 'pr-watch-failed', unit(batch.id), {
|
|
2734
2974
|
reason,
|
|
2735
2975
|
pr,
|
|
@@ -2872,6 +3112,51 @@ function branchMergedIntoBase(deps, batch) {
|
|
|
2872
3112
|
const baseTip = deps.exec('git', ['rev-parse', base], deps.repoDir);
|
|
2873
3113
|
return branchTip !== null && baseTip !== null && branchTip.trim() !== '' && branchTip !== baseTip;
|
|
2874
3114
|
}
|
|
3115
|
+
/**
|
|
3116
|
+
* The ambiguity condition stopped qualifying (the branch resolved to zero or
|
|
3117
|
+
* exactly one candidate), or the batch left `blocked` some other way (this
|
|
3118
|
+
* reconcile's own success, `sched resume --batch`, `sched abandon`, or the
|
|
3119
|
+
* batch aging out of the reconcile window): end any `pr-detect-ambiguous`
|
|
3120
|
+
* streak, so a future ambiguity episode — even a re-block of the SAME batch
|
|
3121
|
+
* with the SAME candidates — journals its own first line instead of silently
|
|
3122
|
+
* continuing a streak from a previous `blocked` stretch (#789 review). A
|
|
3123
|
+
* no-op (no write) when no streak is recorded.
|
|
3124
|
+
*/
|
|
3125
|
+
function clearPrDetectAmbiguous(deps, batch, now) {
|
|
3126
|
+
clearBatchDedupMarker(deps, batch, batch.pr_detect_ambiguous_reason, state_1.CLEARED_PR_DETECT_AMBIGUOUS_FIELDS, now);
|
|
3127
|
+
}
|
|
3128
|
+
/**
|
|
3129
|
+
* #789: more than one MERGED PR matches `batch.branch` — positive evidence
|
|
3130
|
+
* only, so the caller records nothing. Journal it on the streak's first tick
|
|
3131
|
+
* and every `JOURNAL_DEDUP_REANNOUNCE_TICKS` after — never every tick — the
|
|
3132
|
+
* same dedup shape `recordAnchorCloseFailed`/`reconcilePrWatch` use.
|
|
3133
|
+
*/
|
|
3134
|
+
function recordPrDetectAmbiguous(deps, batch, matches, now) {
|
|
3135
|
+
const { since, ticks, announce } = advanceBatchDedupStreak(batch.pr_detect_ambiguous_reason, batch.pr_detect_ambiguous_since, batch.pr_detect_ambiguous_ticks, state_1.PR_DETECT_AMBIGUOUS_REASON, now);
|
|
3136
|
+
if (announce) {
|
|
3137
|
+
journalEvent(deps, 'pr-detect-ambiguous', unit(batch.id), {
|
|
3138
|
+
reason: state_1.PR_DETECT_AMBIGUOUS_REASON,
|
|
3139
|
+
branch: batch.branch ?? undefined,
|
|
3140
|
+
matches: matches.join(','),
|
|
3141
|
+
at: now.toISOString(),
|
|
3142
|
+
since,
|
|
3143
|
+
ticks_persisted: ticks,
|
|
3144
|
+
// #789 review: neither "record it explicitly" (no command exists) nor
|
|
3145
|
+
// "close the extras" (a MERGED PR cannot be closed, so it never leaves
|
|
3146
|
+
// `gh pr list --state merged`) was ever an actionable remedy — state
|
|
3147
|
+
// what actually resolves it instead. #824 tracks the missing verb.
|
|
3148
|
+
detail: `${matches.length} MERGED PRs match head=${batch.branch ?? '?'} (#${matches.join(', #')}) — refusing to guess which is ours; nothing recorded. The batch still reconciles via commits-in-base/members-closed evidence if either holds; otherwise inspect and \`sched abandon --batch ${batch.id}\`, or use \`sched attach-pr\` once #824 ships it`,
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
deps.store.withLock((s) => ({
|
|
3152
|
+
state: (0, state_1.patchBatch)(s, batch.id, {
|
|
3153
|
+
pr_detect_ambiguous_reason: state_1.PR_DETECT_AMBIGUOUS_REASON,
|
|
3154
|
+
pr_detect_ambiguous_since: since,
|
|
3155
|
+
pr_detect_ambiguous_ticks: ticks,
|
|
3156
|
+
}, now, false),
|
|
3157
|
+
result: undefined,
|
|
3158
|
+
}));
|
|
3159
|
+
}
|
|
2875
3160
|
/**
|
|
2876
3161
|
* #686: reconcile a `blocked` batch whose work demonstrably shipped anyway.
|
|
2877
3162
|
* `reconcilePrWatch` above only watches `awaiting-merge` batches, and
|
|
@@ -2884,21 +3169,28 @@ function branchMergedIntoBase(deps, batch) {
|
|
|
2884
3169
|
* (engine.ts) under the same principle: ground truth beats the ledger, for
|
|
2885
3170
|
* ANY stale verdict — the blocked_reason is deliberately not examined.
|
|
2886
3171
|
*
|
|
2887
|
-
* Evidence (
|
|
2888
|
-
*
|
|
2889
|
-
* batch
|
|
2890
|
-
*
|
|
2891
|
-
*
|
|
3172
|
+
* Evidence (cheapest first): the batch PR is MERGED with a real timestamp (a
|
|
3173
|
+
* merged batch PR contains the whole batch branch) — including one
|
|
3174
|
+
* `batch.pr` never recorded at all (#789): when `pr === null`, before falling
|
|
3175
|
+
* through to the weaker branch/members evidence below, look for exactly one
|
|
3176
|
+
* MERGED PR whose head is the batch branch (`imboard#4255`, a hand-opened PR
|
|
3177
|
+
* the ledger never saw). Positive evidence only — zero candidates changes
|
|
3178
|
+
* nothing, more than one is `ambiguous` and journaled rather than guessed —
|
|
3179
|
+
* or the batch branch has fully landed in the base branch. A survivability
|
|
3180
|
+
* guard applies to every path (see `everySurvivingMemberDispatched`) — a
|
|
3181
|
+
* batch with a surviving member that was never dispatched is not
|
|
3182
|
+
* reconcilable.
|
|
2892
3183
|
*
|
|
2893
3184
|
* On reconcile the batch joins the SAME rail a normally-merged batch takes:
|
|
2894
3185
|
* `blocked → merged → deployed` (the `blocked → merged` edge exists for
|
|
2895
3186
|
* exactly this caller), surviving members walked to `done` along legal
|
|
2896
|
-
* edges, and — when
|
|
2897
|
-
*
|
|
2898
|
-
*
|
|
2899
|
-
* PR), so the same lock continues
|
|
2900
|
-
*
|
|
2901
|
-
* agent was dispatched rather than
|
|
3187
|
+
* edges, and — when the batch now carries a PR (recorded before or detected
|
|
3188
|
+
* here) — the ordinary `deployed` machinery dispatches the report agent next
|
|
3189
|
+
* tick, with teardown after `batch-report done`. With no PR at all no report
|
|
3190
|
+
* agent can be prompted (it names the PR), so the same lock continues
|
|
3191
|
+
* `deployed → reported → done` and the worktree is torn down here instead;
|
|
3192
|
+
* the journal line says why no report agent was dispatched rather than
|
|
3193
|
+
* leaving the gap silent.
|
|
2902
3194
|
*
|
|
2903
3195
|
* Bounded by construction: only `blocked` batches within the window are
|
|
2904
3196
|
* examined, the PR check is one `gh` call, git runs only when PR evidence
|
|
@@ -2910,19 +3202,48 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
|
|
|
2910
3202
|
const state = deps.store.load();
|
|
2911
3203
|
const nowMs = now.getTime();
|
|
2912
3204
|
for (const batch of state.batches) {
|
|
2913
|
-
if (batch.status !== 'blocked')
|
|
3205
|
+
if (batch.status !== 'blocked') {
|
|
3206
|
+
// #789 review: the marker is scoped to ONE `blocked` stretch, exactly
|
|
3207
|
+
// like `pr_watch_failed_*`'s own `awaiting-merge` scoping (#633) — the
|
|
3208
|
+
// batch left `blocked` (this reconcile's own success below, `sched
|
|
3209
|
+
// resume --batch`, or `sched abandon`), so a LATER re-block — even of
|
|
3210
|
+
// the SAME batch with the SAME candidate PRs — must journal a fresh
|
|
3211
|
+
// first line, never silently continue a stale streak from before.
|
|
3212
|
+
clearPrDetectAmbiguous(deps, batch, now);
|
|
2914
3213
|
continue;
|
|
3214
|
+
}
|
|
2915
3215
|
if (nowMs - Date.parse(batch.updated_at) >= STALE_BLOCKED_RECONCILE_WINDOW_MS)
|
|
2916
3216
|
continue;
|
|
2917
3217
|
if (!everySurvivingMemberDispatched(state, batch))
|
|
2918
3218
|
continue;
|
|
2919
3219
|
let evidence = null;
|
|
3220
|
+
// #789 review: one outer variable for the whole lookup outcome (rather
|
|
3221
|
+
// than three separate booleans/values re-deriving pieces of it) — the
|
|
3222
|
+
// resolution block below reads `lookup.kind` directly instead of a
|
|
3223
|
+
// parallel flag that has to be kept in sync with it by hand.
|
|
3224
|
+
let lookup;
|
|
2920
3225
|
if (batch.pr !== null) {
|
|
2921
3226
|
const truth = deps.groundTruth.prState(batch.pr);
|
|
2922
3227
|
if (truth !== undefined && truth.state === 'MERGED' && truth.mergedAt !== null) {
|
|
2923
3228
|
evidence = { kind: 'pr-merged', pr: batch.pr, mergedAt: truth.mergedAt };
|
|
2924
3229
|
}
|
|
2925
3230
|
}
|
|
3231
|
+
else if (batch.branch !== null) {
|
|
3232
|
+
// #789: batch.pr was never recorded — a hand-opened or manually
|
|
3233
|
+
// recovered PR the ledger never saw (the imboard#4255 shape). Look for
|
|
3234
|
+
// exactly one MERGED PR whose head is the batch branch before falling
|
|
3235
|
+
// through to the weaker branch/members evidence below. Positive
|
|
3236
|
+
// evidence only: `undefined` (unreachable) touches nothing, and
|
|
3237
|
+
// `ambiguous` (more than one candidate) is journaled, never guessed.
|
|
3238
|
+
lookup = deps.groundTruth.mergedPrForBranch?.(batch.branch, batch.base_branch, batch.created_at);
|
|
3239
|
+
if (lookup?.kind === 'found') {
|
|
3240
|
+
evidence = { kind: 'pr-merged', pr: lookup.pr, mergedAt: lookup.mergedAt };
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
// Set only when THIS tick's automatic lookup found the PR — never when
|
|
3244
|
+
// `batch.pr` was already recorded (that PR is reported via `evidence.pr`
|
|
3245
|
+
// as before, and there is nothing new to persist).
|
|
3246
|
+
const detectedPr = lookup?.kind === 'found' ? lookup.pr : null;
|
|
2926
3247
|
if (evidence === null && branchMergedIntoBase(deps, batch)) {
|
|
2927
3248
|
evidence = { kind: 'commits-in-base' };
|
|
2928
3249
|
}
|
|
@@ -2940,12 +3261,20 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
|
|
|
2940
3261
|
}).kind === 'closable') {
|
|
2941
3262
|
evidence = { kind: 'members-closed' };
|
|
2942
3263
|
}
|
|
3264
|
+
// #789: resolve the ambiguity streak exactly once per tick. An ambiguous
|
|
3265
|
+
// lookup records only when the batch ISN'T reconciling by other evidence
|
|
3266
|
+
// this same tick; any evidence at all (including a `found`/`none` lookup
|
|
3267
|
+
// result) clears a stale or same-tick streak — reconciling via ANY
|
|
3268
|
+
// evidence means the batch is leaving `blocked`, so a same-tick ambiguity
|
|
3269
|
+
// is moot too.
|
|
3270
|
+
if (lookup?.kind === 'ambiguous' && evidence === null) {
|
|
3271
|
+
recordPrDetectAmbiguous(deps, batch, lookup.matches, now);
|
|
3272
|
+
}
|
|
3273
|
+
else if (evidence !== null || lookup?.kind === 'found' || lookup?.kind === 'none') {
|
|
3274
|
+
clearPrDetectAmbiguous(deps, batch, now);
|
|
3275
|
+
}
|
|
2943
3276
|
if (evidence === null)
|
|
2944
3277
|
continue;
|
|
2945
|
-
// A members-closed reconcile has no merged batch PR for a report agent to
|
|
2946
|
-
// report on, whatever `batch.pr` recorded — finish the rail inline, as
|
|
2947
|
-
// for `pr === null`.
|
|
2948
|
-
const finishInline = batch.pr === null || evidence.kind === 'members-closed';
|
|
2949
3278
|
// ...but never tear its worktree down: the members shipped OUTSIDE the
|
|
2950
3279
|
// batch branch, so the blocked integration worktree may hold an
|
|
2951
3280
|
// operator's unpushed repair work. It is left in place, inert, and the
|
|
@@ -2955,6 +3284,12 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
|
|
|
2955
3284
|
const failedAt = batch.updated_at;
|
|
2956
3285
|
let reconciled = false;
|
|
2957
3286
|
let unwalkable = [];
|
|
3287
|
+
// #789 review: what the lock ACTUALLY applied, not what the outer
|
|
3288
|
+
// snapshot predicted — re-derived from `b` (read under the lock) below,
|
|
3289
|
+
// since `batch.pr` could in principle have changed between the snapshot
|
|
3290
|
+
// above and the lock acquiring (e.g. a future `sched attach-pr`, #824).
|
|
3291
|
+
let effectivePr = null;
|
|
3292
|
+
let prWasRecorded = false;
|
|
2958
3293
|
deps.store.withLock((s) => {
|
|
2959
3294
|
const b = (0, state_1.findBatch)(s, batch.id);
|
|
2960
3295
|
// Re-check under the lock: `sched resume --batch` (a passing recheck)
|
|
@@ -2962,7 +3297,19 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
|
|
|
2962
3297
|
// snapshot this pass started from.
|
|
2963
3298
|
if (!b || b.status !== 'blocked')
|
|
2964
3299
|
return { state: s, result: undefined };
|
|
2965
|
-
|
|
3300
|
+
// #789: record the auto-detected PR as part of the SAME transition that
|
|
3301
|
+
// leaves `blocked` — a batch whose ledger never had one now does. Only
|
|
3302
|
+
// when `b.pr` is STILL null under the lock — never overwrite a PR
|
|
3303
|
+
// something else recorded between the snapshot and here.
|
|
3304
|
+
prWasRecorded = detectedPr !== null && b.pr === null;
|
|
3305
|
+
effectivePr = prWasRecorded ? detectedPr : b.pr;
|
|
3306
|
+
let n = (0, state_1.transitionBatch)(s, batch.id, 'merged', {
|
|
3307
|
+
// The ambiguity streak (this tick's own, or a stale one) is moot
|
|
3308
|
+
// the instant the batch reconciles by any evidence — fold the
|
|
3309
|
+
// clear into this same transition instead of a separate lock write.
|
|
3310
|
+
...state_1.CLEARED_PR_DETECT_AMBIGUOUS_FIELDS,
|
|
3311
|
+
...(prWasRecorded ? { pr: detectedPr } : {}),
|
|
3312
|
+
}, now);
|
|
2966
3313
|
n = (0, state_1.transitionBatch)(n, batch.id, 'deployed', {}, now);
|
|
2967
3314
|
unwalkable = [];
|
|
2968
3315
|
for (const issue of b.members) {
|
|
@@ -2986,7 +3333,11 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
|
|
|
2986
3333
|
entry = (0, state_1.findEntry)(n, issue);
|
|
2987
3334
|
}
|
|
2988
3335
|
}
|
|
2989
|
-
|
|
3336
|
+
// The batch now carries a PR — recorded before, recorded just above,
|
|
3337
|
+
// or still null — only when one exists does the ordinary `deployed`
|
|
3338
|
+
// machinery have anything to hand the report agent. A members-closed
|
|
3339
|
+
// reconcile has no merged batch PR to report on regardless.
|
|
3340
|
+
if (effectivePr === null || evidence.kind === 'members-closed') {
|
|
2990
3341
|
// No (merged) PR → no report agent is possible (its prompt names the
|
|
2991
3342
|
// PR). Finish the terminal rail and let teardown run below.
|
|
2992
3343
|
n = (0, state_1.transitionBatch)(n, batch.id, 'reported', {}, now);
|
|
@@ -2997,14 +3348,19 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
|
|
|
2997
3348
|
});
|
|
2998
3349
|
if (!reconciled)
|
|
2999
3350
|
continue;
|
|
3351
|
+
const finishInline = effectivePr === null || evidence.kind === 'members-closed';
|
|
3000
3352
|
journalEvent(deps, 'stale-failure-reconciled', unit(batch.id), {
|
|
3001
|
-
pr:
|
|
3353
|
+
pr: effectivePr ?? undefined,
|
|
3002
3354
|
reason: blockedReason,
|
|
3003
3355
|
failedAt,
|
|
3004
3356
|
evidence: evidence.kind,
|
|
3005
3357
|
...(evidence.kind === 'pr-merged' ? { mergedAt: evidence.mergedAt } : {}),
|
|
3358
|
+
...(prWasRecorded ? { pr_detected: true } : {}),
|
|
3006
3359
|
detail: (evidence.kind === 'pr-merged'
|
|
3007
|
-
?
|
|
3360
|
+
? (prWasRecorded
|
|
3361
|
+
? `batch.pr was never recorded — detected PR #${evidence.pr} MERGED with head=${batch.branch} (gh pr list --state merged), recorded batch.pr=${evidence.pr}`
|
|
3362
|
+
: `PR #${evidence.pr} is MERGED`) +
|
|
3363
|
+
` — ledger reconciled blocked (${blockedReason}) to the merged rail (blocked at ${failedAt}); report and teardown will now dispatch`
|
|
3008
3364
|
: evidence.kind === 'members-closed'
|
|
3009
3365
|
? `every member issue is CLOSED as completed on GitHub with no failure trail — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`
|
|
3010
3366
|
: `every member commit is an ancestor of ${batch.base_branch} — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`) +
|
|
@@ -3221,12 +3577,7 @@ function recordAnchorClosed(deps, batchId, now) {
|
|
|
3221
3577
|
* stale streak's count. A no-op (no write) when no streak is recorded.
|
|
3222
3578
|
*/
|
|
3223
3579
|
function clearAnchorCloseFailed(deps, batch, now) {
|
|
3224
|
-
|
|
3225
|
-
return;
|
|
3226
|
-
deps.store.withLock((s) => ({
|
|
3227
|
-
state: (0, state_1.patchBatch)(s, batch.id, state_1.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS, now, false),
|
|
3228
|
-
result: undefined,
|
|
3229
|
-
}));
|
|
3580
|
+
clearBatchDedupMarker(deps, batch, batch.anchor_close_failed_reason, state_1.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS, now);
|
|
3230
3581
|
}
|
|
3231
3582
|
/**
|
|
3232
3583
|
* A close that qualified but failed: journal it on the streak's first tick and
|
|
@@ -3234,11 +3585,8 @@ function clearAnchorCloseFailed(deps, batch, now) {
|
|
|
3234
3585
|
* exactly `reconcilePrWatch`'s `pr-watch-failed` dedup.
|
|
3235
3586
|
*/
|
|
3236
3587
|
function recordAnchorCloseFailed(deps, batch, outcome, shipped, now) {
|
|
3237
|
-
const
|
|
3238
|
-
|
|
3239
|
-
const ticks = isNewStreak ? 1 : batch.anchor_close_failed_ticks + 1;
|
|
3240
|
-
const since = isNewStreak || priorSince === null ? now.toISOString() : priorSince;
|
|
3241
|
-
if (isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0) {
|
|
3588
|
+
const { since, ticks, announce } = advanceBatchDedupStreak(batch.anchor_close_failed_reason, batch.anchor_close_failed_since, batch.anchor_close_failed_ticks, outcome, now);
|
|
3589
|
+
if (announce) {
|
|
3242
3590
|
journalEvent(deps, 'anchor-close-failed', unit(batch.id), {
|
|
3243
3591
|
issue: batch.anchor ?? undefined,
|
|
3244
3592
|
reason: outcome,
|
|
@@ -4025,7 +4373,7 @@ function runBatchTick(deps, config, dispatch) {
|
|
|
4025
4373
|
if (!fresh || types_1.TERMINAL_BATCH_STATUSES.has(fresh.status) || fresh.status === 'blocked') {
|
|
4026
4374
|
return { state, result: undefined };
|
|
4027
4375
|
}
|
|
4028
|
-
const blocked = (0, state_1.transitionBatch)(state, batchId, 'blocked', { blocked_reason: 'reconcile-error' }, now);
|
|
4376
|
+
const blocked = (0, state_1.transitionBatch)(state, batchId, 'blocked', { blocked_reason: 'reconcile-error', agent_exits: null }, now);
|
|
4029
4377
|
return { state: (0, state_1.releaseAllBatchSlots)(blocked, batchId, now), result: undefined };
|
|
4030
4378
|
});
|
|
4031
4379
|
journalEvent(deps, 'unit-failed', unit(batchId), { reason: 'reconcile-error', detail });
|
|
@@ -4139,10 +4487,10 @@ function runBatchTick(deps, config, dispatch) {
|
|
|
4139
4487
|
reconcileFixSlot(deps, config, batch.id, slot, now, result);
|
|
4140
4488
|
}
|
|
4141
4489
|
else if (batch.status === 'reviewing' || batch.status === 'shipping') {
|
|
4142
|
-
reconcileTailSlot(deps, batch.id, slot, now, result);
|
|
4490
|
+
reconcileTailSlot(deps, config, batch.id, slot, now, result);
|
|
4143
4491
|
}
|
|
4144
4492
|
else if (batch.status === 'deployed') {
|
|
4145
|
-
reconcileReportSlot(deps, batch.id, slot, now, result);
|
|
4493
|
+
reconcileReportSlot(deps, config, batch.id, slot, now, result);
|
|
4146
4494
|
}
|
|
4147
4495
|
continue;
|
|
4148
4496
|
}
|