claude-code-session-manager 0.79.0 → 0.81.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.
Files changed (82) hide show
  1. package/dist/assets/{AgentLibrary-COtVRqBR.js → AgentLibrary-psZYVM2w.js} +1 -1
  2. package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
  3. package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
  4. package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
  5. package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
  6. package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
  7. package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
  9. package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
  10. package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
  11. package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
  12. package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
  13. package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
  15. package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
  16. package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
  18. package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
  20. package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
  21. package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
  22. package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
  23. package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
  24. package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
  25. package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
  26. package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  31. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
  32. package/src/main/__tests__/prdCreate.test.cjs +133 -8
  33. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
  34. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
  35. package/src/main/__tests__/queueHistory.test.cjs +33 -0
  36. package/src/main/__tests__/rcaReport.test.cjs +24 -0
  37. package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
  38. package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
  39. package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
  40. package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
  41. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
  42. package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
  43. package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
  44. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
  45. package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
  46. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
  47. package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
  48. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
  49. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
  50. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
  51. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
  52. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
  53. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
  54. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
  55. package/src/main/ipcSchemas.cjs +15 -1
  56. package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
  57. package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
  58. package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
  59. package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
  60. package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
  61. package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
  62. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
  63. package/src/main/lib/branchSweep.cjs +127 -0
  64. package/src/main/lib/depSlugResolve.cjs +72 -0
  65. package/src/main/lib/epicWorktreeMerge.cjs +3 -3
  66. package/src/main/lib/epicWorktreeMint.cjs +17 -5
  67. package/src/main/lib/fixPlanSlug.cjs +62 -0
  68. package/src/main/lib/gitWorktree.cjs +117 -12
  69. package/src/main/lib/landedSinceRun.cjs +41 -1
  70. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  71. package/src/main/lib/prdCreate.cjs +84 -5
  72. package/src/main/lib/prdFrontmatter.cjs +56 -8
  73. package/src/main/lib/queueHistory.cjs +50 -5
  74. package/src/main/lib/rateLimitWindow.cjs +62 -0
  75. package/src/main/lib/rcaReport.cjs +18 -3
  76. package/src/main/lib/reaperHelpers.cjs +169 -3
  77. package/src/main/lib/scheduleJobTransitions.cjs +28 -5
  78. package/src/main/lib/schedulerBatch.cjs +181 -23
  79. package/src/main/runVerify.cjs +71 -3
  80. package/src/main/scheduler/prdParser.cjs +7 -0
  81. package/src/main/scheduler.cjs +1554 -71
  82. package/src/preload/api.d.ts +8 -0
@@ -52,6 +52,7 @@ const VERDICT_LABELS = {
52
52
  pass_no_commit_already_shipped: 'PASS with no commit — deliverables already shipped',
53
53
  pass_no_commit_prior_run_verified: 'PASS with no commit — prior run of this slug already landed the work',
54
54
  silent_no_op: 'no commit, clean tree — no evidence of work',
55
+ blocked_by_foreign_wip_streak: 'blocked by a sibling job\'s foreign WIP 3x in a row',
55
56
  };
56
57
 
57
58
  function humanVerdict(verdict) {
@@ -69,14 +70,18 @@ const FAILURE_CLASSES = {
69
70
  UNCOMMITTED: 'uncommitted-changes',
70
71
  TRANSCRIPT_ERRORS: 'transcript-errors',
71
72
  ABANDONED_BACKGROUND_TASK: 'abandoned-background-task',
73
+ BLOCKED_BY_FOREIGN_WIP: 'blocked-by-foreign-wip',
72
74
  UNKNOWN: 'unknown',
73
75
  };
74
76
 
75
77
  // ─── Recovery actions — one per failure class, machine-readable ────────────
76
78
  // Closed set the scheduler routes on: 'archive' (stale re-run, do not
77
- // re-queue), 'resume-and-commit' (PRD 1111's --resume dispatch owns this),
78
- // 'verify-and-close' (the work likely landed, just missing its sentinel),
79
- // 'investigate' (the only class that still buys a fix-plan investigation).
79
+ // re-queue — also the closest fit for BLOCKED_BY_FOREIGN_WIP: there is
80
+ // nothing to investigate, and re-queuing is already owned by
81
+ // reconcile()'s requeueForeignWipBlockedJobs, not this action), 'resume-and-commit'
82
+ // (PRD 1111's --resume dispatch owns this), 'verify-and-close' (the work
83
+ // likely landed, just missing its sentinel), 'investigate' (the only class
84
+ // that still buys a fix-plan investigation).
80
85
  const RECOVERY_ACTIONS = {
81
86
  [FAILURE_CLASSES.ALREADY_SHIPPED]: 'archive',
82
87
  [FAILURE_CLASSES.SELF_QUEUE]: 'investigate',
@@ -96,6 +101,7 @@ const RECOVERY_ACTIONS = {
96
101
  // carries the "check for salvaged work, commit before re-implementing"
97
102
  // guidance into that cold-read investigation.
98
103
  [FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]: 'investigate',
104
+ [FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]: 'archive',
99
105
  [FAILURE_CLASSES.UNKNOWN]: 'investigate',
100
106
  };
101
107
 
@@ -121,6 +127,8 @@ const PREVENTION_HINTS = {
121
127
  'Recover or annotate every error within ~10 lines (e.g. `# expected/handled: <why>`) instead of leaving a bare Traceback near the end of the transcript.',
122
128
  [FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]:
123
129
  'A headless run cannot receive a background-task completion notification — never let a long Bash command auto-background past its foreground timeout and then wait for it. Run long commands with an explicit bound (`timeout <N> <cmd>`) so they finish in the foreground, or poll their output file directly instead of waiting on Monitor/notification.',
130
+ [FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]:
131
+ 'This job correctly reported `SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP` against a sibling job\'s in-flight, uncommitted files three times in a row — auto-requeue is exhausted (reconcile() only clears this once the blocked paths go clean). This is not a defect in this PRD\'s own work: check on the sibling job/human that owns the still-dirty path(s) named on the job row, and either wait for them to commit or manually reset this job to `pending` once the tree is clear. Do not author a fix-plan PRD against it.',
124
132
  [FAILURE_CLASSES.UNKNOWN]:
125
133
  'Re-run the acceptance criteria gate locally against the failure log to pin down the specific break before re-queuing.',
126
134
  };
@@ -159,6 +167,13 @@ const POST_AC_OVERRUN_MIN_TAIL_FRACTION = 0.3;
159
167
  function classifyFailure({ verdict, logTail }) {
160
168
  const lines = (logTail || '').split('\n');
161
169
 
170
+ // Checked before every other rule: an unambiguous, materially-checked
171
+ // verdict (the scheduler itself validated the executor's claimed paths
172
+ // against the job's disclosed foreign-WIP manifest before ever landing
173
+ // this verdict — see validateForeignWipBlockClaim in scheduler.cjs) needs
174
+ // no log-tail heuristics to classify.
175
+ if (verdict === 'blocked_by_foreign_wip_streak') return FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP;
176
+
162
177
  // Checked first, before SELF_QUEUE/STUCK_LOOP: a correct executor that finds
163
178
  // its acceptance criteria already satisfied by a prior commit makes no
164
179
  // change and truthfully prints a PASS sentinel, so the run lands
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  const fs = require('node:fs');
11
+ const path = require('node:path');
11
12
  const { readTail } = require('./fileTail.cjs');
12
13
  const { detectRateLimitInLog } = require('./rateLimitDetect.cjs');
13
14
 
@@ -34,6 +35,90 @@ function claudePidAlive(pid) {
34
35
  }
35
36
  }
36
37
 
38
+ /**
39
+ * findLiveProcessForJob(job, { worktreeDir }) → pid | null
40
+ *
41
+ * Positive liveness scan for a 'running' row whose `runtime.pid` is missing —
42
+ * the case a missing pid must NOT be read as "the process is gone" (2026-09-06
43
+ * incident: 234-uranus-eight-tails-ox marked failed/never_ran while PID
44
+ * 2174739 was a live `claude -p` still writing to that job's own worktree).
45
+ *
46
+ * Linux-`/proc` only. Scans every numeric `/proc/<pid>` entry and matches
47
+ * either: `/proc/<pid>/cwd` resolves to `worktreeDir` (or a path nested under
48
+ * it), or `/proc/<pid>/cmdline` contains both `claude` and the job's slug
49
+ * (fallback for a worktree-disabled/in-place run, where there is no dedicated
50
+ * worktreeDir to match against). Returns the first matching pid, or null if
51
+ * none is found.
52
+ *
53
+ * Safe fallback by construction: on any platform without `/proc` (macOS,
54
+ * Windows) `fs.readdirSync('/proc')` throws and this returns null immediately
55
+ * — i.e. exactly today's behaviour (fail toward terminalizing), never a hang
56
+ * or a thrown error propagating to the caller.
57
+ */
58
+ function findLiveProcessForJob(job, { worktreeDir } = {}) {
59
+ const slug = job?.slug;
60
+ let entries;
61
+ try {
62
+ entries = fs.readdirSync('/proc');
63
+ } catch {
64
+ return null;
65
+ }
66
+ for (const name of entries) {
67
+ if (!/^\d+$/.test(name)) continue;
68
+ const pid = Number(name);
69
+ if (!pid || pid <= 1) continue;
70
+ if (worktreeDir) {
71
+ try {
72
+ const cwdLink = fs.readlinkSync(`/proc/${pid}/cwd`);
73
+ if (cwdLink === worktreeDir || cwdLink.startsWith(worktreeDir + path.sep)) return pid;
74
+ } catch {
75
+ // Process exited mid-scan, or permission denied — try the argv
76
+ // fallback below before giving up on this pid.
77
+ }
78
+ }
79
+ if (slug) {
80
+ try {
81
+ const cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
82
+ if (/\bclaude\b/.test(cmd) && cmd.includes(slug)) return pid;
83
+ } catch {
84
+ // Same as above — process gone or unreadable, keep scanning.
85
+ }
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * True when `logPath` exists and has non-zero content — the literal "did the
93
+ * run dir produce any log output" check that gates whether a pidless reap may
94
+ * assert `gateOutcome: 'never_ran'`. A run whose log has real bytes in it DID
95
+ * run, regardless of whether classifyRunOutcome found a clean result event in
96
+ * it — asserting never_ran in that case would be a false claim (see
97
+ * resolvePidlessGateOutcome below).
98
+ */
99
+ function logHasOutput(logPath) {
100
+ if (!logPath) return false;
101
+ try {
102
+ return fs.statSync(logPath).size > 0;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Gate-outcome for a pidless reap, once no live process was found for it.
110
+ * `never_ran` is asserted ONLY when the run dir produced no log output at
111
+ * all — mapOutcomeToGateOutcome's own 'no_result' → 'never_ran' mapping is
112
+ * otherwise too broad here: a log with real content but no clean result event
113
+ * (e.g. killed mid-turn) proves the job DID run, so that case is reported as
114
+ * 'failed' instead of the false 'never_ran'.
115
+ */
116
+ function resolvePidlessGateOutcome(outcome, hasOutput) {
117
+ if (!hasOutput) return 'never_ran';
118
+ const mapped = mapOutcomeToGateOutcome(outcome);
119
+ return mapped === 'never_ran' ? 'failed' : mapped;
120
+ }
121
+
37
122
  /**
38
123
  * Classify the terminal outcome of a completed run by reading the last 64 KB
39
124
  * of its log file and scanning for the LAST `{"type":"result"}` JSONL event.
@@ -129,10 +214,21 @@ const ORPHAN_REQUEUE_CAP = 5;
129
214
  * A pidless row whose `startedAt` is missing or unparseable is neither
130
215
  * reaped nor skipped silently — age can't be proven, so it is surfaced in
131
216
  * `warnings` instead (the caller logs it) and left alone.
217
+ *
218
+ * `findLiveProcess` (optional, `(job) → pid | null`) is consulted for a
219
+ * pidless row ONLY once its age clears `grace` — i.e. right before it would
220
+ * otherwise be terminalized. A pid it finds means the process is actually
221
+ * alive despite the missing runtime.pid record: the row is diverted into
222
+ * `recovered` (never `reapable`) so the caller can re-stamp the pid and leave
223
+ * the row `running`, instead of terminalizing a job that is still doing real
224
+ * work (2026-09-06 incident — see findLiveProcessForJob's header). Omitting
225
+ * `findLiveProcess` (existing callers/tests) preserves prior behaviour
226
+ * exactly: every pidless row past grace reaps, none are ever recovered.
132
227
  */
133
- function selectReapableJobs(jobs, now, { pidAlive, grace } = {}) {
228
+ function selectReapableJobs(jobs, now, { pidAlive, grace, findLiveProcess } = {}) {
134
229
  const reapable = [];
135
230
  const warnings = [];
231
+ const recovered = [];
136
232
  for (const j of jobs ?? []) {
137
233
  if (j.status !== 'running') continue;
138
234
  const pid = j.runtime?.pid;
@@ -148,6 +244,11 @@ function selectReapableJobs(jobs, now, { pidAlive, grace } = {}) {
148
244
  }
149
245
  const ageMs = now - startedAt;
150
246
  if (ageMs < grace) continue; // spawn may still be mid-flight
247
+ const livePid = typeof findLiveProcess === 'function' ? findLiveProcess(j) : null;
248
+ if (livePid) {
249
+ recovered.push({ slug: j.slug, pid: livePid });
250
+ continue;
251
+ }
151
252
  reapable.push({
152
253
  slug: j.slug,
153
254
  pid: null,
@@ -155,7 +256,72 @@ function selectReapableJobs(jobs, now, { pidAlive, grace } = {}) {
155
256
  reason: `reaped: no runtime.pid recorded after ${Math.round(grace / 60_000)}m — spawn never completed`,
156
257
  });
157
258
  }
158
- return { reapable, warnings };
259
+ return { reapable, warnings, recovered };
260
+ }
261
+
262
+ /**
263
+ * isAlreadySatisfiedOnMain(commits) → { sha, verdict, reason } | null
264
+ *
265
+ * Pure decision layer for the finish-protocol commit-guard's second,
266
+ * independently-evidenced route to 'completed' (PRD 1136). The commit-guard
267
+ * in scheduler.cjs parks a clean exit / no-commit / clean-tree run as
268
+ * `needs_review` ("finish protocol incomplete") because that shape is
269
+ * normally the strongest signal that nothing happened — but it is also
270
+ * exactly the shape a run produces when its PRD's work was ALREADY merged to
271
+ * main before the run dispatched (2026-09-06: 1133-reaper-must-verify-
272
+ * integration-before-completed and 1134-land-stranded-sm-job-branches, both
273
+ * false-negatived this way and then auto-fix-minted a redundant `-fix-`
274
+ * child against a codebase where the change was already present).
275
+ *
276
+ * Takes the already-git-queried list of commit SHAs (newest first, caller's
277
+ * job — see scheduler.cjs's findSatisfyingCommitOnMain) that are reachable
278
+ * from `main`, newer than the job's `queuedAt`, and touch the PRD's own
279
+ * declared paths. Returns the winning verdict naming the satisfying sha, or
280
+ * `null` when there is no such commit — the caller must then leave the
281
+ * existing 'finish protocol incomplete' → needs_review verdict untouched.
282
+ * This function never widens what counts as evidence; it only decides what
283
+ * to do once the caller's git query has already proven a satisfying commit
284
+ * exists, so it can never turn a genuine no-op into a false 'completed'.
285
+ */
286
+ function isAlreadySatisfiedOnMain(commits) {
287
+ if (!Array.isArray(commits) || commits.length === 0) return null;
288
+ const sha = commits[0];
289
+ return {
290
+ sha,
291
+ verdict: 'already_satisfied_on_main',
292
+ reason: `already satisfied by ${sha} — a commit on main newer than this run's queuedAt already touches this PRD's declared paths`,
293
+ };
294
+ }
295
+
296
+ /**
297
+ * resolveCommitGuardOutcome(guardVerdict, satisfyingCommits) → verdict | null
298
+ *
299
+ * The full finalize-time decision this PRD adds: takes commitGuardVerdict's
300
+ * own output (scheduler.cjs) plus the already-git-queried satisfying-commit
301
+ * list (scheduler.cjs's findSatisfyingCommitOnMain) and decides which verdict
302
+ * actually wins. Only ever touches the 'silent_no_op' shape — a guardVerdict
303
+ * of null (no violation) or 'uncommitted_changes' (real dirt left behind)
304
+ * passes through completely untouched, so this can never weaken the
305
+ * uncommitted-changes guarantee PRD 1133 introduced. Pure — no I/O, no git —
306
+ * so the whole finalize decision is directly unit-testable without spawning
307
+ * a real job.
308
+ */
309
+ function resolveCommitGuardOutcome(guardVerdict, satisfyingCommits) {
310
+ if (!guardVerdict || guardVerdict.verdict !== 'silent_no_op') return guardVerdict ?? null;
311
+ const satisfied = isAlreadySatisfiedOnMain(satisfyingCommits);
312
+ if (!satisfied) return guardVerdict;
313
+ return { verdict: satisfied.verdict, reason: satisfied.reason, satisfyingSha: satisfied.sha };
159
314
  }
160
315
 
161
- module.exports = { claudePidAlive, classifyRunOutcome, mapOutcomeToGateOutcome, ORPHAN_REQUEUE_CAP, selectReapableJobs };
316
+ module.exports = {
317
+ claudePidAlive,
318
+ classifyRunOutcome,
319
+ mapOutcomeToGateOutcome,
320
+ ORPHAN_REQUEUE_CAP,
321
+ selectReapableJobs,
322
+ findLiveProcessForJob,
323
+ logHasOutput,
324
+ resolvePidlessGateOutcome,
325
+ isAlreadySatisfiedOnMain,
326
+ resolveCommitGuardOutcome,
327
+ };
@@ -41,13 +41,26 @@ const STATUS_HISTORY_CAP = 20;
41
41
  * Explicit from->to edges. Every real assignment site in scheduler.cjs maps
42
42
  * onto one of these (verified against the 16 sites this module replaces):
43
43
  * - pending->running (dispatch), pending->completed (archived-PRD skip,
44
- * manual archive of an already-shipped PRD), pending->failed (admin
45
- * cancelJob on a not-yet-started job)
44
+ * manual archive of an already-shipped PRD; also source
45
+ * 'spawnJob:dispatch-sidecar-reconcile' — a pending row about to
46
+ * dispatch whose newest run-sidecar already shows a completed-equivalent
47
+ * outcome finished at/after this row's own last pending transition is
48
+ * finalized from that sidecar instead of spawning a redundant re-run;
49
+ * 2026-09-06 incident: a silently-dropped finalize left a slug 'pending'
50
+ * forever and the dispatcher re-fired it three times), pending->failed
51
+ * (admin cancelJob on a not-yet-started job)
46
52
  * - running->completed|failed|needs_review (normal run outcomes, reaper),
47
53
  * running->skipped (spawnJob:skip-archived's 'prd-missing' case — no
48
54
  * executor ever ran; kept distinct from 'completed' so unrun work can't
49
55
  * read as shipped), running->pending (halt/rate-limit reset,
50
- * transient-failure retry)
56
+ * transient-failure retry). Also reached with verdict
57
+ * 'reaped_without_integration' (source 'reapDeadRunningJobs', PRD 1133): a
58
+ * reaped job whose result event looked successful but whose work cannot
59
+ * be shown to have landed — a worktree branch still holding unmerged
60
+ * commits, or an in-place run whose HEAD never advanced during the run
61
+ * window — is parked here instead of 'completed', naming the branch (or
62
+ * the lack of any commit) so the stranded work is never silently treated
63
+ * as shipped.
51
64
  * - investigating->failed|needs_review (restore prior status once the
52
65
  * investigation probe exits), investigating->completed (defensive: the
53
66
  * restored prior status could in principle be 'completed' if a caller
@@ -61,7 +74,17 @@ const STATUS_HISTORY_CAP = 20;
61
74
  * declared paths is surfaced to a human as needs_review, never silently
62
75
  * auto-completed)
63
76
  * - needs_review->investigating, needs_review->pending, needs_review->completed
64
- * (heal on reverify, or auto-promote) — same shape as `failed`
77
+ * (heal on reverify, or auto-promote) — same shape as `failed`. Also
78
+ * reached by source 'scheduler:mechanicalRecovery' (PRD 1130): a job
79
+ * parked with a mechanically-resolvable verdict (starting with exactly
80
+ * 'worktree_integration_failed') gets one bounded, model-free re-attempt
81
+ * of its worktree branch integration; success lands here exactly like any
82
+ * other completion.
83
+ * - needs_review->running (resume-first recovery, PRD 1111: a job parked
84
+ * needs_review with verdict 'uncommitted_changes' gets one bounded
85
+ * `claude -p --resume` dispatch through spawnJob before any fix-plan
86
+ * investigation is authored — spawnJob's own dispatch mutate transitions
87
+ * straight to 'running', same as any pending job)
65
88
  * - completed->pending (force-only reset, gated separately by
66
89
  * resetJobFields' own guard — this table only says the edge is
67
90
  * structurally legal, not that every caller may take it unconditionally)
@@ -85,7 +108,7 @@ const LEGAL_TRANSITIONS = {
85
108
  running: ['completed', 'failed', 'needs_review', 'skipped', 'pending'],
86
109
  investigating: ['failed', 'needs_review', 'completed', 'pending', 'skipped'],
87
110
  failed: ['investigating', 'pending', 'completed', 'needs_review'],
88
- needs_review: ['investigating', 'pending', 'completed', 'skipped'],
111
+ needs_review: ['investigating', 'pending', 'completed', 'skipped', 'running'],
89
112
  completed: ['pending'],
90
113
  skipped: ['pending'],
91
114
  quarantined: ['pending', 'skipped'],
@@ -15,37 +15,98 @@
15
15
  const path = require('node:path');
16
16
  const os = require('node:os');
17
17
  const { projectJobCap, quietMachineWaitMs } = require('./schedulerConfig.cjs');
18
+ const { bareSlug } = require('./depSlugResolve.cjs');
18
19
 
19
20
  const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
20
21
 
22
+ /**
23
+ * Sentinel passed as `satisfiedSlugs` when the caller's history/archive
24
+ * lookup itself failed this tick (PRD 1122's fail-open-on-read-failure
25
+ * clause) — every dep with no live row is treated as satisfied, exactly
26
+ * today's pre-1122 behaviour, rather than holding the whole queue hostage to
27
+ * a transient fs error. Never returned BY this module — only ever supplied
28
+ * by a caller (scheduler.cjs's computeDepHistorySatisfaction) that caught an
29
+ * I/O error building the real Set.
30
+ */
31
+ const DEP_HISTORY_FAIL_OPEN = Symbol('dep-history-fail-open');
32
+
21
33
  /**
22
34
  * The dep slug (if any) blocking `job` from running, per PRD 832's
23
- * dependsOn semantics — a dep is blocking while a queue row for it exists
24
- * in a non-completed state; a slug with no row is treated as already done
25
- * (completed rows are retired to history shards). Resolves a dep by exact
26
- * slug first, then by bare-name match (a human-authored `dependsOn:` can't
27
- * know the `NN-` prefix the allocator will hand a sibling PRD — see
28
- * pickForProject's own comment on this for the incident it fixes).
35
+ * dependsOn semantics, refined by PRD 1122: a dep is blocking while a queue
36
+ * row for it exists in a non-completed state. A dep slug with NO live row is
37
+ * NOT automatically treated as done — it is checked against `satisfiedSlugs`
38
+ * (built ONCE per tick by the caller from scheduler `state/history.jsonl`
39
+ * and/or `prds-archived/`, per PRD 1122's once-per-tick requirement; pass
40
+ * `DEP_HISTORY_FAIL_OPEN` to fall back to the old blanket-satisfied
41
+ * behaviour when that lookup itself failed). A dep absent from BOTH live
42
+ * rows and `satisfiedSlugs` blocks — pickForProject surfaces this as a
43
+ * distinct 'unresolved' hold rather than silently dispatching the dependent
44
+ * (see PRD 1122: a typo'd or double-prefixed dependsOn entry used to be
45
+ * indistinguishable from a dep that genuinely finished and aged out to
46
+ * history).
47
+ *
48
+ * Resolves a dep by exact slug first, then by bare-name match (a
49
+ * human-authored `dependsOn:` can't know the `NN-` prefix the allocator will
50
+ * hand a sibling PRD — see pickForProject's own comment on this for the
51
+ * incident it fixes) — the SAME rule applies when matching against
52
+ * `satisfiedSlugs`, so a bare-named dep resolves against a prefixed history
53
+ * slug the same way it would against a live row.
54
+ *
29
55
  * Shared by pickForProject (per-project gating) and pickNextBatch's
30
56
  * quiet-machine dispatch check (PRD 1107), so the two can never disagree
31
57
  * about whether a job is eligible.
32
58
  */
33
- function findBlockingDep(job, projectJobs) {
59
+ /**
60
+ * De-duplicate dependsOn-cycle reports (PRD 1123). Two jobs sitting in the
61
+ * same 2-node cycle each independently discover it while walking upward from
62
+ * their own row, so without this the same cycle would surface once per
63
+ * member instead of once per distinct cycle.
64
+ */
65
+ function dedupeCycles(cycles) {
66
+ const seen = new Set();
67
+ const out = [];
68
+ for (const members of cycles) {
69
+ const uniq = [...new Set(members)].sort();
70
+ const key = uniq.join('|');
71
+ if (seen.has(key)) continue;
72
+ seen.add(key);
73
+ out.push(uniq);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ function findBlockingDep(job, projectJobs, satisfiedSlugs = new Set()) {
34
79
  const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
35
80
  const rowsByBareSlug = new Map();
36
81
  for (const j of projectJobs) {
37
- const bare = String(j.slug ?? '').replace(/^\d+-/, '');
82
+ const bare = bareSlug(j.slug);
38
83
  if (!rowsByBareSlug.has(bare)) rowsByBareSlug.set(bare, []);
39
84
  rowsByBareSlug.get(bare).push(j);
40
85
  }
41
86
  const rowsForDep = (slug) => {
42
87
  const exact = rowBySlug.get(slug);
43
88
  if (exact) return [exact];
44
- return rowsByBareSlug.get(String(slug ?? '').replace(/^\d+-/, '')) ?? [];
89
+ return rowsByBareSlug.get(bareSlug(slug)) ?? [];
45
90
  };
46
- return (job.dependsOn ?? []).find((slug) => (
47
- rowsForDep(slug).some((dep) => dep.status !== 'completed')
48
- ));
91
+ // Bare-name fallback applies here too: a satisfiedSlugs entry is almost
92
+ // always the FULL NN-prefixed slug (a history.jsonl row or an archived
93
+ // `<slug>.md` filename), but a human-authored dependsOn: writes the bare
94
+ // name — same asymmetry findBlockingDep already resolves for live rows.
95
+ let satisfiedBareSlugs = null;
96
+ const isKnownSatisfied = (slug) => {
97
+ if (satisfiedSlugs === DEP_HISTORY_FAIL_OPEN) return true;
98
+ if (satisfiedSlugs.has(slug)) return true;
99
+ if (satisfiedBareSlugs === null) {
100
+ satisfiedBareSlugs = new Set();
101
+ for (const s of satisfiedSlugs) satisfiedBareSlugs.add(bareSlug(s));
102
+ }
103
+ return satisfiedBareSlugs.has(bareSlug(slug));
104
+ };
105
+ return (job.dependsOn ?? []).find((slug) => {
106
+ const rows = rowsForDep(slug);
107
+ if (rows.length > 0) return rows.some((dep) => dep.status !== 'completed');
108
+ return !isKnownSatisfied(slug);
109
+ });
49
110
  }
50
111
 
51
112
  /**
@@ -78,18 +139,28 @@ function findBlockingDep(job, projectJobs) {
78
139
  * runningSet that belong to this project.
79
140
  * @param {number} slots - Maximum jobs to return (global remaining slots;
80
141
  * caller enforces the global cap across projects).
142
+ * @param {Map<string,string>} [heldSlugs] - Launch-gate holds, see below.
143
+ * @param {Set<string>|symbol} [satisfiedSlugs] - Dep slugs with no live queue
144
+ * row that are nonetheless known-satisfied (found in scheduler
145
+ * `state/history.jsonl` and/or `prds-archived/`), built ONCE per tick by
146
+ * the caller (PRD 1122) — or `DEP_HISTORY_FAIL_OPEN` when that lookup
147
+ * itself failed this tick. Defaults to an empty Set (fail CLOSED: a dep
148
+ * with no live row and nothing in this set is genuinely unresolved).
81
149
  * @returns {{ batch: object[], reason: string | null }} Jobs to spawn for this
82
150
  * project this tick, plus (when batch is empty because a gate held it) the
83
151
  * human-readable reason text that would otherwise only reach console.log.
84
152
  */
85
- function pickForProject(projectJobs, runningSlugsInProject, slots, heldSlugs = new Map()) {
153
+ function pickForProject(projectJobs, runningSlugsInProject, slots, heldSlugs = new Map(), satisfiedSlugs = new Set()) {
86
154
  const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
87
155
 
88
- // Explicit dependsOn eligibility (PRD 832). A dep slug is BLOCKING while a
89
- // queue row for it exists in a non-completed state; a slug with no row is
90
- // treated as already done (completed rows are retired to history shards,
91
- // so absence is the normal end-state of a finished dep). A FAILED dep
92
- // holds the dependent with an explicit reason, mirroring the failure gate.
156
+ // Explicit dependsOn eligibility (PRD 832, refined by PRD 1122). A dep slug
157
+ // is BLOCKING while a queue row for it exists in a non-completed state. A
158
+ // slug with NO row is only treated as done when `satisfiedSlugs` (this
159
+ // tick's precomputed history/archive lookup — see findBlockingDep's own
160
+ // doc) says so; otherwise it HOLDS as 'unresolved' — see PRD 1122: a typo
161
+ // or a double-prefixed slug used to be silently indistinguishable from a
162
+ // dep that genuinely finished and aged out to history. A FAILED dep holds
163
+ // the dependent with an explicit reason, mirroring the failure gate.
93
164
  // Legacy jobs without dependsOn keep the shared-NN group semantics below
94
165
  // unchanged (lowest-number-first waves), so an in-flight mixed queue keeps
95
166
  // its order without migration.
@@ -111,7 +182,32 @@ function pickForProject(projectJobs, runningSlugsInProject, slots, heldSlugs = n
111
182
  const bare = String(slug ?? '').replace(/^\d+-/, '');
112
183
  return projectJobs.filter((j) => String(j.slug ?? '').replace(/^\d+-/, '') === bare);
113
184
  };
114
- const blockingDep = (j) => findBlockingDep(j, projectJobs);
185
+ const blockingDep = (j) => findBlockingDep(j, projectJobs, satisfiedSlugs);
186
+ const depStatusOf = (rows) => (rows.length === 0 ? 'unresolved' : (rows.find((d) => d.status !== 'completed')?.status ?? 'unknown'));
187
+
188
+ // Root-blocker walk (PRD 1123): follows dependsOn upward from a held job
189
+ // until it reaches a row with no blocking dep of its own — the ACTUAL
190
+ // cause of a stalled chain, not just the immediate link the held job
191
+ // names (which is very often just another `pending` row, not the reason
192
+ // the chain isn't moving). Bounded by `visited`, which doubles as the
193
+ // cycle guard: a dependsOn cycle can never grow `visited` past
194
+ // `projectJobs.length` without revisiting a node, so this always
195
+ // terminates instead of recursing forever.
196
+ function findRootBlocker(startJob) {
197
+ const visited = new Set([startJob.slug]);
198
+ let current = startJob;
199
+ for (let hops = 0; hops <= projectJobs.length; hops += 1) {
200
+ const dep = blockingDep(current);
201
+ if (!dep) return { rootSlug: current.slug, rootStatus: current.status, cycle: false };
202
+ const rows = rowsForDep(dep);
203
+ if (rows.length === 0) return { rootSlug: dep, rootStatus: 'unresolved', cycle: false };
204
+ const nextRow = rows.find((r) => r.status !== 'completed') ?? rows[0];
205
+ if (visited.has(nextRow.slug)) return { cycle: true, members: [...visited, nextRow.slug] };
206
+ visited.add(nextRow.slug);
207
+ current = nextRow;
208
+ }
209
+ return { cycle: true, members: [...visited] }; // unreachable given the bound above; never hang.
210
+ }
115
211
 
116
212
  // quietMachine jobs (PRD 1107) never dispatch through the ordinary
117
213
  // per-project picker — pickNextBatch's dedicated quiet-machine check
@@ -141,6 +237,20 @@ function pickForProject(projectJobs, runningSlugsInProject, slots, heldSlugs = n
141
237
  // the generic `holds` bucket — resetting won't help here (the source file
142
238
  // is gone), so the guidance differs from the failed-dep case.
143
239
  const heldBySkippedDep = [];
240
+ // A dep with NO live row anywhere and no completion record either (PRD
241
+ // 1122) — a typo'd or double-prefixed dependsOn entry, indistinguishable
242
+ // at write time from a dep that finished and aged out, until now.
243
+ const heldByUnresolvedDep = [];
244
+ // Deeper root causes the shallow (immediate-dep) buckets above can't see —
245
+ // PRD 1123. A job's own immediate dep is very often just another `pending`
246
+ // row; the ROOT is the transitive ancestor that isn't blocked by anything
247
+ // itself. Keyed by that root's slug so the reason names ONE row and the
248
+ // count of everything transitively held behind it, rather than restating
249
+ // "held by a pending dep" once per link in the chain (which is exactly how
250
+ // a stalled needs_review row several hops down used to read as generic
251
+ // starvation instead of a named, actionable blocker).
252
+ const heldByRoot = new Map(); // rootSlug -> { rootStatus, slugs: Set<string> }
253
+ const cycles = [];
144
254
  // Per-job hold records, surfaced to the UI so a `pending` row can say WHICH
145
255
  // dep is holding it instead of leaving the reason in console.log.
146
256
  const holds = [...launchHolds];
@@ -148,16 +258,34 @@ function pickForProject(projectJobs, runningSlugsInProject, slots, heldSlugs = n
148
258
  const dep = blockingDep(j);
149
259
  if (!dep) { pending.push(j); continue; }
150
260
  const depRows = rowsForDep(dep);
261
+ const root = findRootBlocker(j);
262
+ if (root.cycle) {
263
+ cycles.push(root.members);
264
+ holds.push({ slug: j.slug, dep, depStatus: depStatusOf(depRows), rootSlug: null, rootStatus: 'cycle' });
265
+ continue;
266
+ }
267
+ if (depRows.length === 0) {
268
+ heldByUnresolvedDep.push({ job: j, dep });
269
+ holds.push({ slug: j.slug, dep, depStatus: 'unresolved', rootSlug: root.rootSlug, rootStatus: root.rootStatus });
270
+ continue;
271
+ }
151
272
  const failed = depRows.some((d) => d.status === 'failed');
152
273
  const skipped = depRows.some((d) => d.status === 'skipped');
153
274
  if (failed) heldByFailedDep.push({ job: j, dep });
154
275
  else if (skipped) heldBySkippedDep.push({ job: j, dep });
276
+ else if (['failed', 'skipped', 'needs_review', 'unresolved'].includes(root.rootStatus)) {
277
+ if (!heldByRoot.has(root.rootSlug)) heldByRoot.set(root.rootSlug, { rootStatus: root.rootStatus, slugs: new Set() });
278
+ heldByRoot.get(root.rootSlug).slugs.add(j.slug);
279
+ }
155
280
  holds.push({
156
281
  slug: j.slug,
157
282
  dep,
158
- depStatus: depRows.find((d) => d.status !== 'completed')?.status ?? 'unknown',
283
+ depStatus: depStatusOf(depRows),
284
+ rootSlug: root.rootSlug,
285
+ rootStatus: root.rootStatus,
159
286
  });
160
- // running/pending/needs_review dep — simply not eligible this tick.
287
+ // running/pending dep with a running/pending root — simply not eligible
288
+ // this tick; expected to clear on its own, not a stall worth naming.
161
289
  }
162
290
  if (pending.length === 0) {
163
291
  const reasons = [];
@@ -169,6 +297,25 @@ function pickForProject(projectJobs, runningSlugsInProject, slots, heldSlugs = n
169
297
  const detail = heldBySkippedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
170
298
  reasons.push(`holding ${heldBySkippedDep.length} job(s) behind never-ran dependencies [${detail}]. The dep's PRD source is gone — author a fresh PRD for that work to unblock.`);
171
299
  }
300
+ if (heldByUnresolvedDep.length > 0) {
301
+ const detail = heldByUnresolvedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
302
+ reasons.push(`holding ${heldByUnresolvedDep.length} job(s) behind unresolvable dependencies [${detail}]. The dep slug matches no live queue row and no completion record in history/archive — fix the dependsOn entry or archive the dependent.`);
303
+ }
304
+ for (const [rootSlug, { rootStatus, slugs }] of heldByRoot) {
305
+ const names = [...slugs].sort().join(', ');
306
+ if (rootStatus === 'needs_review') {
307
+ reasons.push(`holding ${slugs.size} job(s) transitively behind root blocker ${rootSlug} (needs_review) [${names}]. ${rootSlug} is a QUESTION awaiting a human — the chain resumes once it is retired or reset; it will not clear on its own.`);
308
+ } else if (rootStatus === 'failed') {
309
+ reasons.push(`holding ${slugs.size} job(s) transitively behind root blocker ${rootSlug} (failed) [${names}]. Reset or archive ${rootSlug} to unblock the chain.`);
310
+ } else if (rootStatus === 'skipped') {
311
+ reasons.push(`holding ${slugs.size} job(s) transitively behind root blocker ${rootSlug} (skipped) [${names}]. ${rootSlug}'s PRD source is gone — author a fresh PRD for that work to unblock the chain.`);
312
+ } else {
313
+ reasons.push(`holding ${slugs.size} job(s) transitively behind unresolvable root blocker ${rootSlug} (status: ${rootStatus}) [${names}]. Fix the dependsOn chain or archive the dependent(s).`);
314
+ }
315
+ }
316
+ for (const members of dedupeCycles(cycles)) {
317
+ reasons.push(`dependsOn CYCLE detected among [${members.join(' -> ')}] — these jobs can never become eligible; break the cycle by editing one PRD's dependsOn.`);
318
+ }
172
319
  if (reasons.length > 0) {
173
320
  const reason = `[scheduler] depends-gate [${projectCwd}]: ${reasons.join(' ')}`;
174
321
  console.log(reason);
@@ -293,6 +440,14 @@ function enqueueTimestamp(job) {
293
440
  * NOW (scheduler jobs AND chat runs). A quiet-machine job only dispatches
294
441
  * when this is 0, unless it has waited past quietMachineWaitMs().
295
442
  * - now: ms epoch, defaults to Date.now() (injectable for tests).
443
+ * - satisfiedSlugsByCwd: `Map<cwd, Set<string>|symbol>` — this tick's
444
+ * precomputed dependsOn history/archive lookup (PRD 1122), built ONCE by
445
+ * the caller (scheduler.cjs's computeDepHistorySatisfaction) before this
446
+ * function runs, never inside it. Per-cwd value is either a Set of
447
+ * satisfied slugs or `DEP_HISTORY_FAIL_OPEN` when that project's lookup
448
+ * itself failed this tick. Missing entry for a cwd defaults to an empty
449
+ * Set (fail closed). Threaded to findBlockingDep everywhere it's called
450
+ * below — pickForProject and the quiet-machine check both need it.
296
451
  * @returns {{ batch: object[], reason: string | null, holds: object[] }} Jobs
297
452
  * to spawn this tick, plus (when batch is empty because a gate held it) the
298
453
  * human-readable hold reason that would otherwise only reach console.log.
@@ -303,6 +458,8 @@ function pickNextBatch(allJobs, running, freeSlots, quietOpts = {}) {
303
458
  // this tick (scheduler.cjs computes it from state.launchBlocks). Held rows
304
459
  // are invisible to every pick below but still surface as holds.
305
460
  const heldSlugs = quietOpts.heldSlugs instanceof Map ? quietOpts.heldSlugs : new Map();
461
+ const satisfiedSlugsByCwd = quietOpts.satisfiedSlugsByCwd instanceof Map ? quietOpts.satisfiedSlugsByCwd : new Map();
462
+ const satisfiedSlugsFor = (cwd) => satisfiedSlugsByCwd.get(cwd || DEFAULT_PROJECT_CWD) ?? new Set();
306
463
  const pickable = (j) => j.status === 'pending' && !running.has(j.slug) && !heldSlugs.has(j.slug);
307
464
 
308
465
  // Exclusive lease (PRD 1107): while a quiet-machine job is running, it
@@ -361,7 +518,7 @@ function pickNextBatch(allJobs, running, freeSlots, quietOpts = {}) {
361
518
  const quietPending = allJobs.filter((j) => pickable(j) && j.quietMachine === true);
362
519
  for (const job of quietPending) {
363
520
  const projectJobs = projectMap.get(job.cwd || DEFAULT_PROJECT_CWD) || [job];
364
- if (findBlockingDep(job, projectJobs)) continue; // blocked — try the next quiet candidate
521
+ if (findBlockingDep(job, projectJobs, satisfiedSlugsFor(job.cwd))) continue; // blocked — try the next quiet candidate
365
522
  const machineQuiet = machineInUse === 0;
366
523
  const waitedMs = now - enqueueTimestamp(job);
367
524
  const degraded = !machineQuiet && waitedMs >= quietWaitMs;
@@ -408,7 +565,7 @@ function pickNextBatch(allJobs, running, freeSlots, quietOpts = {}) {
408
565
  for (const c of candidates) {
409
566
  if (slots <= 0) break;
410
567
  if (!c.active) continue;
411
- const result = pickForProject(c.view, c.runningSlugsInProject, 1, heldSlugs);
568
+ const result = pickForProject(c.view, c.runningSlugsInProject, 1, heldSlugs, satisfiedSlugsFor(c.cwd));
412
569
  for (const h of result.holds ?? []) if (!holdsBySlug.has(h.slug)) holdsBySlug.set(h.slug, h);
413
570
  if (result.batch.length === 0) {
414
571
  if (heldReason === null && result.reason) heldReason = result.reason;
@@ -491,4 +648,5 @@ function findStarvedProjects(jobs, now, thresholdMs) {
491
648
 
492
649
  module.exports = {
493
650
  pickForProject, pickNextBatch, enqueueTimestamp, findStarvedProjects, findBlockingDep, DEFAULT_PROJECT_CWD,
651
+ DEP_HISTORY_FAIL_OPEN,
494
652
  };