claude-code-session-manager 0.78.0 → 0.80.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 (74) hide show
  1. package/dist/assets/{AgentLibrary-13pfo8uY.js → AgentLibrary-zS3jw_1e.js} +1 -1
  2. package/dist/assets/{DataModel-SUyQbFlg.js → DataModel-Cy_vxTpi.js} +1 -1
  3. package/dist/assets/{History-2GJMS703.js → History-C6JRuqfT.js} +1 -1
  4. package/dist/assets/{Hooks-DM2nS3RT.js → Hooks-BafPy9mB.js} +1 -1
  5. package/dist/assets/{HostBilko-BLeC-lpp.js → HostBilko-BZwhQOFt.js} +1 -1
  6. package/dist/assets/{Library-BaRkU9m0.js → Library-C8JDDliz.js} +1 -1
  7. package/dist/assets/{ListDetail-D5scjSKq.js → ListDetail-CqiOdwLc.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-B1lgAo9T.js → MarkdownEditor-CyLyP67L.js} +1 -1
  9. package/dist/assets/{McpServers-BzyThQSM.js → McpServers-BzMv-_84.js} +1 -1
  10. package/dist/assets/{Memory-7UdaOTtl.js → Memory-DSBYQdJR.js} +1 -1
  11. package/dist/assets/{Panel-JbTMaOPq.js → Panel-CLUhkNNA.js} +1 -1
  12. package/dist/assets/{Permissions-UBam0bJG.js → Permissions-BfC2-HN4.js} +1 -1
  13. package/dist/assets/{Plugins-B3gUDkeb.js → Plugins-BKi40jT5.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CeHOub7m.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
  15. package/dist/assets/{SaveBar-BcvQEq6h.js → SaveBar-avk2p9jv.js} +1 -1
  16. package/dist/assets/{Scheduler-Dc5qiP24.js → Scheduler-Bf_6MdJo.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-BvGQmw4Y.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
  18. package/dist/assets/{Settings-C2dEFb-v.js → Settings-Djd8OoBA.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-CIwlosBc.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
  20. package/dist/assets/{Skills-C0GzzVrQ.js → Skills-D_qAqxZ_.js} +1 -1
  21. package/dist/assets/{SystemPrompt-mtGPK8zo.js → SystemPrompt-DbHFLQV3.js} +1 -1
  22. package/dist/assets/{TagLibrary-DX54-mpd.js → TagLibrary-C2y91BT0.js} +1 -1
  23. package/dist/assets/{TiptapBody-yADC2RWE.js → TiptapBody-D9iz4xQx.js} +1 -1
  24. package/dist/assets/{Toggle-CRxaCYLI.js → Toggle-BGnFL2E5.js} +1 -1
  25. package/dist/assets/{index-D6ymGESc.js → index-_2ARyFDj.js} +4 -4
  26. package/dist/assets/{settingsSchema-TtMvT5Sx.js → settingsSchema-JK15eJU8.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/scripts/project-pages-logic/dist/logic.cjs +12 -12
  31. package/scripts/render-project-pages/dist/renderer.cjs +22 -22
  32. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
  33. package/src/main/__tests__/prdCreate.test.cjs +133 -8
  34. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
  35. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
  36. package/src/main/__tests__/queueHistory.test.cjs +33 -0
  37. package/src/main/__tests__/runLogRetention.test.cjs +59 -0
  38. package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
  39. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
  40. package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
  41. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
  42. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
  43. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
  44. package/src/main/__tests__/scheduler-never-stop.test.cjs +157 -0
  45. package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +81 -0
  46. package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +123 -0
  47. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +158 -0
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +30 -0
  49. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
  50. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
  51. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
  52. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
  53. package/src/main/ipcSchemas.cjs +15 -1
  54. package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
  55. package/src/main/lib/__tests__/reaperHelpers.test.cjs +90 -1
  56. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
  57. package/src/main/lib/depSlugResolve.cjs +72 -0
  58. package/src/main/lib/epicWorktreeMerge.cjs +3 -3
  59. package/src/main/lib/epicWorktreeMint.cjs +17 -5
  60. package/src/main/lib/fixPlanSlug.cjs +62 -0
  61. package/src/main/lib/gitWorktree.cjs +97 -12
  62. package/src/main/lib/jobDirtFilter.cjs +54 -0
  63. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  64. package/src/main/lib/prdCreate.cjs +84 -5
  65. package/src/main/lib/prdFrontmatter.cjs +56 -8
  66. package/src/main/lib/queueHistory.cjs +50 -5
  67. package/src/main/lib/rateLimitDetect.cjs +35 -0
  68. package/src/main/lib/reaperHelpers.cjs +31 -6
  69. package/src/main/lib/runLogRetention.cjs +82 -4
  70. package/src/main/lib/scheduleJobTransitions.cjs +12 -2
  71. package/src/main/lib/schedulerBatch.cjs +181 -23
  72. package/src/main/scheduler/prdParser.cjs +7 -0
  73. package/src/main/scheduler.cjs +1165 -70
  74. package/src/preload/api.d.ts +8 -0
@@ -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
  };
@@ -107,6 +107,13 @@ async function parsePrdRaw(filePath) {
107
107
  // quarantines it instead of queuing it to run.
108
108
  createdVia: fm.createdVia || null,
109
109
  issuedAt: fm.issuedAt || null,
110
+ // Explicit fix-plan provenance stamp (PRD 1131) — written only by
111
+ // spawnInvestigation's own prompt template (see scheduler.cjs's
112
+ // buildInvestigationPrompt). Only a literal `true` opts in, matching
113
+ // quietMachine's parse semantics below; reconcile() is the sole reader
114
+ // that turns this (plus createdVia's absence) into a fix-plan verdict —
115
+ // see lib/fixPlanSlug.cjs's classifyDiscoveredFixPlan.
116
+ isFixPlan: fm.isFixPlan === 'true',
110
117
  // WHO executes this PRD (persona name) — distinct from `tag` (work
111
118
  // type). Absent on every PRD authored before this field existed; the
112
119
  // dependent follow-up PRD (scheduler-launch-as-persona) resolves this