@ai-dossier/sched 0.58.0 → 0.59.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.
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
package/dist/engine.js CHANGED
@@ -111,6 +111,7 @@ const core_1 = require("@ai-dossier/core");
111
111
  const batch_dispatch_1 = require("./batch-dispatch");
112
112
  const dispatch_1 = require("./dispatch");
113
113
  const dispatch_health_1 = require("./dispatch-health");
114
+ const dossier_root_1 = require("./dossier-root");
114
115
  const fence_1 = require("./fence");
115
116
  const groundtruth_1 = require("./groundtruth");
116
117
  const journal_1 = require("./journal");
@@ -237,6 +238,28 @@ function pollUnits(deps, state) {
237
238
  });
238
239
  continue;
239
240
  }
241
+ if (slot.status === 'assigned') {
242
+ // #778: a crash-left `assigned` slot is spawned fresh by
243
+ // `reconcileAssigned` — the same "resume after days" shape as
244
+ // `recovering`. A live pid is re-attached, never re-spawned, so it needs
245
+ // no read; report slots close their issue at merge, by design.
246
+ const issue = (0, journal_1.issueOfUnit)(slot.unit);
247
+ if (issue === null || out.has(slot.unit) || (0, state_1.isReportSlot)(slot))
248
+ continue;
249
+ if (slot.pid !== null && deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined)) {
250
+ continue;
251
+ }
252
+ if (((0, state_1.findEntry)(state, issue)?.stale_closed_at ?? null) !== null)
253
+ continue;
254
+ out.set(slot.unit, {
255
+ reachable: true,
256
+ milestone: null,
257
+ closed: deps.groundTruth.issueClosed(issue),
258
+ head: null,
259
+ branch: slot.branch,
260
+ });
261
+ continue;
262
+ }
240
263
  if (slot.status !== 'running' && slot.status !== 'verifying' && slot.status !== 'exited') {
241
264
  continue;
242
265
  }
@@ -396,6 +419,36 @@ function pollLabels(deps, state, config, dispatch) {
396
419
  labels.set(issue, deps.groundTruth.issueLabels(issue));
397
420
  return { ran: true, labels };
398
421
  }
422
+ /**
423
+ * #778: one `issueClosed` read per issue unit the dispatch pass could place
424
+ * this tick (the first `max_slots` runnable, unheld, unflagged issue units —
425
+ * the same ceiling as `pollLabels`) plus orphaned `dispatched` entries that
426
+ * `requeueOrphanedDispatches` is about to put back in that queue. A paused
427
+ * fleet dispatches nothing, so it reads nothing. `issueClosed` is false when
428
+ * gh is unreachable: an unreadable issue is never treated as closed.
429
+ */
430
+ function pollClosed(deps, state, config) {
431
+ const out = new Map();
432
+ if (state.paused)
433
+ return out;
434
+ const held = new Set(state.slots.map((slot) => slot.unit).filter((u) => u !== null));
435
+ const unflagged = (issue) => (0, state_1.findEntry)(state, issue)?.stale_closed_at === null;
436
+ const candidates = state.entries
437
+ .filter((e) => e.status === 'dispatched' && !held.has(`issue:${e.issue}`))
438
+ .map((e) => e.issue);
439
+ for (const unit of (0, readiness_1.runnableUnits)(state)) {
440
+ if (unit.kind === 'issue' && !held.has(`issue:${unit.issue}`))
441
+ candidates.push(unit.issue);
442
+ }
443
+ for (const issue of candidates) {
444
+ if (out.size >= config.max_slots)
445
+ break;
446
+ if (out.has(issue) || !unflagged(issue))
447
+ continue;
448
+ out.set(issue, deps.groundTruth.issueClosed(issue));
449
+ }
450
+ return out;
451
+ }
399
452
  /**
400
453
  * Issues this tick's read CONFIRMED carry no hard-block label (#544) — the
401
454
  * only issues `dispatchAssignments` may place. A failed read is absent here,
@@ -655,7 +708,9 @@ function seedResumeTrail(ctx, state, issue) {
655
708
  branch,
656
709
  baseBranch,
657
710
  batch: evidence.batch,
658
- worktree: path.join(ctx.deps.repoDir, 'worktrees', branch.replaceAll('/', '-')),
711
+ // #890: the same project-root resolution batch worktrees use, so a nested
712
+ // layout (`.dossier/` above the checkout) lands slot + batch trees in one root.
713
+ worktree: path.join((0, dossier_root_1.worktreesDirFor)(ctx.deps.repoDir), branch.replaceAll('/', '-')),
659
714
  revertedCommits: evidence.reverted_commits,
660
715
  });
661
716
  if (!outcome.ok) {
@@ -2019,8 +2074,36 @@ dispatchSignals = NO_DISPATCH_SIGNALS) {
2019
2074
  ...(resolved.lastTool !== null ? { last_tool: resolved.lastTool } : {}),
2020
2075
  }, { escalate: progressed });
2021
2076
  }
2077
+ /**
2078
+ * The stale-closed guard shared by every slot rail that would spawn a fresh
2079
+ * agent (#776 `recovering`, #778 `assigned`). Returns the state to keep (the
2080
+ * unit must NOT be spawned this tick) or `null` when the spawn may proceed.
2081
+ *
2082
+ * The flag is sticky and the slot stays held: releasing it is an operator
2083
+ * decision (`sched stop --issue N`), which `sched status` names. `truth.closed`
2084
+ * is false when gh is unreachable, so an unreachable poll never flags a unit
2085
+ * (no work is killed on a network blip) — and an already-flagged unit stays
2086
+ * held without any read, so a blip cannot un-hold it either.
2087
+ */
2088
+ function holdIfIssueClosed(ctx, state, slot, unit, truth) {
2089
+ const issue = (0, journal_1.issueOfUnit)(unit);
2090
+ const entry = issue === null ? undefined : (0, state_1.findEntry)(state, issue);
2091
+ if (!entry || issue === null || (0, state_1.isReportSlot)(slot))
2092
+ return null;
2093
+ if (entry.stale_closed_at !== null)
2094
+ return state;
2095
+ if (!truth.closed)
2096
+ return null;
2097
+ const now = ctx.deps.now();
2098
+ journal(ctx, 'stale-closed', unit, {
2099
+ slot: slot.id,
2100
+ detail: `issue #${issue} is closed — recovery will not re-dispatch it; release the slot with \`sched stop --issue ${issue}\``,
2101
+ });
2102
+ // Not an activity bump: `updated_at` keeps meaning "last state change".
2103
+ return (0, state_1.patchEntry)(state, issue, { stale_closed_at: now.toISOString() }, now, false);
2104
+ }
2022
2105
  /** Re-attach or spawn a slot left `assigned` by a crash between assign and spawn. */
2023
- function reconcileAssigned(ctx, state, slot, unit) {
2106
+ function reconcileAssigned(ctx, state, slot, unit, truth) {
2024
2107
  if (slot.pid !== null && ctx.deps.spawnDeps.isAlive(slot.pid)) {
2025
2108
  // Crash after spawn, before the running transition: re-attach by pid.
2026
2109
  journal(ctx, 'orphan-pid', unit, {
@@ -2030,6 +2113,11 @@ function reconcileAssigned(ctx, state, slot, unit) {
2030
2113
  });
2031
2114
  return (0, state_1.transitionSlot)(state, slot.id, 'running', {}, ctx.deps.now());
2032
2115
  }
2116
+ // #778: never spawn fresh for an issue that is closed on GitHub. Same model
2117
+ // as `reconcileRecovering` (#776): flag, journal, hold the slot.
2118
+ const held = holdIfIssueClosed(ctx, state, slot, unit, truth);
2119
+ if (held !== null)
2120
+ return held;
2033
2121
  if (state.paused)
2034
2122
  return state;
2035
2123
  journal(ctx, 'assigned', unit, { slot: slot.id, detail: 'crash-recovery spawn' });
@@ -2040,24 +2128,10 @@ function reconcileRecovering(ctx, state, slot, truth, unit) {
2040
2128
  // #776: never re-dispatch a unit whose issue is closed. Checked BEFORE the
2041
2129
  // pause guard so a paused scheduler still flags it (the incident shape:
2042
2130
  // paused for days with a recovering slot on an issue shipped elsewhere —
2043
- // `sched resume` would have re-run finished work). The flag is sticky and
2044
- // the slot stays held: releasing it is an operator decision
2045
- // (`sched stop --issue N`), which `sched status` names.
2046
- const issue = (0, journal_1.issueOfUnit)(unit);
2047
- const entry = issue === null ? undefined : (0, state_1.findEntry)(state, issue);
2048
- if (entry && issue !== null && !(0, state_1.isReportSlot)(slot)) {
2049
- if (entry.stale_closed_at !== null)
2050
- return state;
2051
- if (truth.closed) {
2052
- const now = ctx.deps.now();
2053
- journal(ctx, 'stale-closed', unit, {
2054
- slot: slot.id,
2055
- detail: `issue #${issue} is closed — recovery will not re-dispatch it; release the slot with \`sched stop --issue ${issue}\``,
2056
- });
2057
- // Not an activity bump: `updated_at` keeps meaning "last state change".
2058
- return (0, state_1.patchEntry)(state, issue, { stale_closed_at: now.toISOString() }, now, false);
2059
- }
2060
- }
2131
+ // `sched resume` would have re-run finished work).
2132
+ const held = holdIfIssueClosed(ctx, state, slot, unit, truth);
2133
+ if (held !== null)
2134
+ return held;
2061
2135
  // #629: while paused, do not resume a `recovering` slot's respawn — this is
2062
2136
  // the crash-recovery rail (a sched restart caught a slot between
2063
2137
  // `enterRecovery`'s transition and its own `spawnUnit` call, OR `enterRecovery`
@@ -2096,7 +2170,7 @@ function reconcileSlots(ctx, state, polled) {
2096
2170
  };
2097
2171
  switch (slot.status) {
2098
2172
  case 'assigned':
2099
- next = reconcileAssigned(ctx, next, slot, unit);
2173
+ next = reconcileAssigned(ctx, next, slot, unit, truth);
2100
2174
  break;
2101
2175
  case 'running':
2102
2176
  next = reconcileRunning(ctx, next, slot, truth, unit);
@@ -2137,7 +2211,15 @@ function requeueOrphanedDispatches(ctx, state) {
2137
2211
  // that condition's first occurrence on this one. Without it, a crash
2138
2212
  // during an outage leaves `ground_truth_unreachable_ticks` mid-streak and
2139
2213
  // the redispatched unit stays silent until the next re-announcement.
2140
- next = (0, state_1.transitionIssue)(next, entry.issue, 'queued', { reason: null, ...state_1.CLEARED_ENTRY_DEDUP_MARKERS }, now);
2214
+ next = (0, state_1.transitionIssue)(next, entry.issue, 'queued', {
2215
+ reason: null,
2216
+ ...state_1.CLEARED_ENTRY_DEDUP_MARKERS,
2217
+ // #778: a stale-closed flag survives the requeue — clearing it would
2218
+ // let one unreachable-gh tick re-dispatch a shipped issue. Unflagged
2219
+ // orphans are read by `pollClosed` and flagged in the dispatch pass.
2220
+ stale_closed_at: entry.stale_closed_at,
2221
+ stale_closed_ticks: entry.stale_closed_ticks,
2222
+ }, now);
2141
2223
  journal(ctx, 'requeued', `issue:${entry.issue}`, {
2142
2224
  detail: 'orphaned dispatch requeued after restart',
2143
2225
  });
@@ -2401,7 +2483,7 @@ function runTeardownFor(deps, issue) {
2401
2483
  return (0, teardown_1.runTeardown)(deps.teardownExec, deps.repoDir, info);
2402
2484
  }
2403
2485
  /** Phase 3: refill — every freed slot is filled in THIS tick (AC5). */
2404
- function dispatchAssignments(ctx, state, config, labelVerifiedIssues) {
2486
+ function dispatchAssignments(ctx, state, config, labelVerifiedIssues, closedPoll) {
2405
2487
  const now = ctx.deps.now();
2406
2488
  // #544: `pollLabels` reads only the first `max_slots` runnable units — the
2407
2489
  // ceiling on what this pass can place — which keeps the per-tick `gh` cost
@@ -2420,6 +2502,12 @@ function dispatchAssignments(ctx, state, config, labelVerifiedIssues) {
2420
2502
  .filter((entry) => !labelVerifiedIssues.has(entry.issue))
2421
2503
  .map((entry) => `issue:${entry.issue}`))
2422
2504
  : new Set();
2505
+ // #778: a stale-closed entry is never dispatched (the flag is sticky; only
2506
+ // `sched stop` / a requeue-elsewhere resolves it).
2507
+ for (const entry of state.entries) {
2508
+ if (entry.stale_closed_at !== null)
2509
+ exclude.add(`issue:${entry.issue}`);
2510
+ }
2423
2511
  // #565: decide who wins each free slot over BOTH kinds — a ready batch
2424
2512
  // outranking a same-readiness issue (priority desc → readiness age → issue
2425
2513
  // number, `runnableUnits`) must not lose its capacity to an issue dispatch
@@ -2451,6 +2539,19 @@ function dispatchAssignments(ctx, state, config, labelVerifiedIssues) {
2451
2539
  const entry = (0, state_1.findEntry)(next, assignment.issue);
2452
2540
  if (!entry)
2453
2541
  continue;
2542
+ // #778: queued/classified entries (and requeued orphans) whose issue was
2543
+ // closed while they waited are flagged, not dispatched.
2544
+ // Unread (unblocked mid-tick by the label pass, or slid into the window)
2545
+ // falls back to one bounded read here: assignments never exceed free
2546
+ // capacity, and a same-tick refill (#525 AC5) must not be deferred.
2547
+ const closed = closedPoll.get(assignment.issue) ?? ctx.deps.groundTruth.issueClosed(assignment.issue);
2548
+ if (closed) {
2549
+ journal(ctx, 'stale-closed', unit, {
2550
+ detail: `issue #${assignment.issue} is closed — will not dispatch it; drop it with \`sched stop --issue ${assignment.issue}\``,
2551
+ });
2552
+ next = (0, state_1.patchEntry)(next, assignment.issue, { stale_closed_at: now.toISOString() }, now, false);
2553
+ continue;
2554
+ }
2454
2555
  const { state: withSlot, slotId } = (0, scheduler_1.assignToIdleSlot)(next, unit, null, now);
2455
2556
  next = withSlot;
2456
2557
  journal(ctx, 'assigned', unit, { slot: slotId, priority: entry.priority });
@@ -2504,6 +2605,7 @@ function tick(deps, config) {
2504
2605
  const prPoll = pollParkedPrs(deps, state0, dispatch);
2505
2606
  const labelPoll = pollLabels(deps, state0, config, dispatch);
2506
2607
  const labelVerifiedIssues = labelVerified(labelPoll);
2608
+ const closedPoll = pollClosed(deps, state0, config);
2507
2609
  const pass1 = deps.store.withLock((state) => {
2508
2610
  const ctx = { deps, config, dispatch, result: emptyResult() };
2509
2611
  let next = reconcileSlots(ctx, state, polled);
@@ -2514,7 +2616,7 @@ function tick(deps, config) {
2514
2616
  // #544: immediately before the dispatch pass, so a cleared label can be
2515
2617
  // dispatched this same tick and a fresh one can never be dispatched over.
2516
2618
  next = reconcileLabelBlocks(ctx, next, labelPoll);
2517
- next = dispatchAssignments(ctx, next, config, labelVerifiedIssues);
2619
+ next = dispatchAssignments(ctx, next, config, labelVerifiedIssues, closedPoll);
2518
2620
  return {
2519
2621
  state: next,
2520
2622
  result: { tick: ctx.result, teardownPending: teardownPendingIssues(next) },