claude-code-session-manager 0.76.0 → 0.77.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 (123) hide show
  1. package/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
  15. package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
  16. package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
  26. package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-6IOLjZZN.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +8 -2
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  31. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  32. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  33. package/scripts/render-project-pages.cjs +70 -0
  34. package/scripts/scheduler-mcp-server.cjs +115 -1
  35. package/scripts/validate-project-pages-summary.cjs +62 -0
  36. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  37. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  38. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  39. package/src/main/__tests__/prdCreate.test.cjs +138 -0
  40. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  41. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  42. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  43. package/src/main/__tests__/projectPages.test.cjs +73 -1
  44. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  45. package/src/main/__tests__/runVerify.test.cjs +94 -0
  46. package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
  47. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  48. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  49. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  50. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  51. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  52. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  53. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  54. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  55. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  56. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  57. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
  58. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  59. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  60. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  61. package/src/main/config.cjs +4 -1
  62. package/src/main/index.cjs +8 -1
  63. package/src/main/ipcSchemas.cjs +51 -0
  64. package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
  65. package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
  66. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
  67. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  68. package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
  69. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  70. package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
  71. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  72. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  73. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
  74. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  75. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  76. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  77. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  78. package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
  79. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  80. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  81. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  82. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  83. package/src/main/lib/agentModelResolve.cjs +58 -0
  84. package/src/main/lib/childWithLog.cjs +40 -5
  85. package/src/main/lib/claudeBin.cjs +54 -1
  86. package/src/main/lib/definitionOfDone.cjs +3 -2
  87. package/src/main/lib/delegationReadiness.cjs +115 -9
  88. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  89. package/src/main/lib/fixChainDepth.cjs +45 -0
  90. package/src/main/lib/gitWorktree.cjs +464 -19
  91. package/src/main/lib/jobWorktree.cjs +1 -0
  92. package/src/main/lib/landedSinceRun.cjs +55 -0
  93. package/src/main/lib/launchFailure.cjs +357 -0
  94. package/src/main/lib/mcpToolCatalog.cjs +87 -2
  95. package/src/main/lib/opsOwnership.cjs +12 -0
  96. package/src/main/lib/prdAgentType.cjs +84 -0
  97. package/src/main/lib/prdCreate.cjs +57 -1
  98. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  99. package/src/main/lib/prdFrontmatter.cjs +17 -3
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  101. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  102. package/src/main/lib/queueHealth.cjs +38 -0
  103. package/src/main/lib/queueStore.cjs +9 -2
  104. package/src/main/lib/quietMachineLease.cjs +48 -0
  105. package/src/main/lib/rcaReport.cjs +53 -3
  106. package/src/main/lib/reaperHelpers.cjs +18 -1
  107. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  108. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  109. package/src/main/lib/schedulerBatch.cjs +133 -29
  110. package/src/main/lib/schedulerConfig.cjs +19 -0
  111. package/src/main/projectPages.cjs +160 -2
  112. package/src/main/runVerify.cjs +50 -9
  113. package/src/main/scheduler/prdParser.cjs +18 -1
  114. package/src/main/scheduler.cjs +1371 -97
  115. package/src/main/seedAgentPersonas.cjs +62 -21
  116. package/src/main/templates/project-pages-catalog.json +741 -0
  117. package/src/main/templates/project-pages-pipeline.md +417 -0
  118. package/src/preload/api.d.ts +118 -2
  119. package/src/preload/index.cjs +7 -0
  120. package/src/seed/agents/project-home-builder.md +59 -0
  121. package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
  122. package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
  123. package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
@@ -14,10 +14,40 @@
14
14
 
15
15
  const path = require('node:path');
16
16
  const os = require('node:os');
17
- const { projectJobCap } = require('./schedulerConfig.cjs');
17
+ const { projectJobCap, quietMachineWaitMs } = require('./schedulerConfig.cjs');
18
18
 
19
19
  const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
20
20
 
21
+ /**
22
+ * 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).
29
+ * Shared by pickForProject (per-project gating) and pickNextBatch's
30
+ * quiet-machine dispatch check (PRD 1107), so the two can never disagree
31
+ * about whether a job is eligible.
32
+ */
33
+ function findBlockingDep(job, projectJobs) {
34
+ const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
35
+ const rowsByBareSlug = new Map();
36
+ for (const j of projectJobs) {
37
+ const bare = String(j.slug ?? '').replace(/^\d+-/, '');
38
+ if (!rowsByBareSlug.has(bare)) rowsByBareSlug.set(bare, []);
39
+ rowsByBareSlug.get(bare).push(j);
40
+ }
41
+ const rowsForDep = (slug) => {
42
+ const exact = rowBySlug.get(slug);
43
+ if (exact) return [exact];
44
+ return rowsByBareSlug.get(String(slug ?? '').replace(/^\d+-/, '')) ?? [];
45
+ };
46
+ return (job.dependsOn ?? []).find((slug) => (
47
+ rowsForDep(slug).some((dep) => dep.status !== 'completed')
48
+ ));
49
+ }
50
+
21
51
  /**
22
52
  * Per-project batch picker. `dependsOn` is the ONLY ordering primitive.
23
53
  *
@@ -52,7 +82,7 @@ const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager
52
82
  * project this tick, plus (when batch is empty because a gate held it) the
53
83
  * human-readable reason text that would otherwise only reach console.log.
54
84
  */
55
- function pickForProject(projectJobs, runningSlugsInProject, slots) {
85
+ function pickForProject(projectJobs, runningSlugsInProject, slots, heldSlugs = new Map()) {
56
86
  const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
57
87
 
58
88
  // Explicit dependsOn eligibility (PRD 832). A dep slug is BLOCKING while a
@@ -63,7 +93,6 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
63
93
  // Legacy jobs without dependsOn keep the shared-NN group semantics below
64
94
  // unchanged (lowest-number-first waves), so an in-flight mixed queue keeps
65
95
  // its order without migration.
66
- const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
67
96
  // A PRD's filename slug carries the auto-allocated `NN-` prefix
68
97
  // (prdCreate.cjs builds `${nn}-${slug}`), but a human/agent authoring
69
98
  // `dependsOn:` writes the BARE name it chose — it cannot know the number
@@ -72,29 +101,37 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
72
101
  // dependency gate into a no-op (observed live 2026-08-01: 22 PRDs listing
73
102
  // `leftnav-two-face-framework` while the real row was
74
103
  // `873-leftnav-two-face-framework`, all of them eligible immediately even
75
- // though the framework PRD they build on had not landed). Resolve a dep by
76
- // exact slug first, then fall back to matching rows whose slug is that dep
77
- // with a leading `NN-` stripped.
78
- const rowsByBareSlug = new Map();
79
- for (const j of projectJobs) {
80
- const bare = String(j.slug ?? '').replace(/^\d+-/, '');
81
- if (!rowsByBareSlug.has(bare)) rowsByBareSlug.set(bare, []);
82
- rowsByBareSlug.get(bare).push(j);
83
- }
84
- /** Every queue row a dep string refers to (exact match, else bare-name match). */
104
+ // though the framework PRD they build on had not landed). findBlockingDep
105
+ // resolves a dep by exact slug first, then falls back to matching rows
106
+ // whose slug is that dep with a leading `NN-` stripped.
107
+ const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
85
108
  const rowsForDep = (slug) => {
86
109
  const exact = rowBySlug.get(slug);
87
110
  if (exact) return [exact];
88
- return rowsByBareSlug.get(String(slug ?? '').replace(/^\d+-/, '')) ?? [];
111
+ const bare = String(slug ?? '').replace(/^\d+-/, '');
112
+ return projectJobs.filter((j) => String(j.slug ?? '').replace(/^\d+-/, '') === bare);
89
113
  };
90
- const blockingDep = (j) => (j.dependsOn ?? []).find((slug) => (
91
- rowsForDep(slug).some((dep) => dep.status !== 'completed')
92
- ));
114
+ const blockingDep = (j) => findBlockingDep(j, projectJobs);
93
115
 
116
+ // quietMachine jobs (PRD 1107) never dispatch through the ordinary
117
+ // per-project picker — pickNextBatch's dedicated quiet-machine check
118
+ // (evaluated before this function is ever called) is the only path that
119
+ // can fire one, since dispatching one is a whole-tick, all-projects
120
+ // exclusive decision this per-project function can't see.
121
+ // Launch-gate holds (lib/launchFailure.cjs, issue #11): a pending row
122
+ // whose launch persona is circuit-broken is NOT a candidate this tick and
123
+ // must not shadow a pickable sibling behind it. It still gets a per-row
124
+ // hold record so the Queue UI says why it is waiting.
125
+ const launchHolds = [];
126
+ for (const j of projectJobs) {
127
+ if (j.status === 'pending' && !runningSlugsInProject.has(j.slug) && heldSlugs.has(j.slug)) {
128
+ launchHolds.push({ slug: j.slug, dep: null, depStatus: null, reason: heldSlugs.get(j.slug) || 'launch blocked' });
129
+ }
130
+ }
94
131
  const allPending = projectJobs.filter(
95
- (j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
132
+ (j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug) && j.quietMachine !== true && !heldSlugs.has(j.slug),
96
133
  );
97
- if (allPending.length === 0) return { batch: [], reason: null, holds: [] };
134
+ if (allPending.length === 0) return { batch: [], reason: null, holds: launchHolds };
98
135
 
99
136
  const pending = [];
100
137
  const heldByFailedDep = [];
@@ -106,7 +143,7 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
106
143
  const heldBySkippedDep = [];
107
144
  // Per-job hold records, surfaced to the UI so a `pending` row can say WHICH
108
145
  // dep is holding it instead of leaving the reason in console.log.
109
- const holds = [];
146
+ const holds = [...launchHolds];
110
147
  for (const j of allPending) {
111
148
  const dep = blockingDep(j);
112
149
  if (!dep) { pending.push(j); continue; }
@@ -247,13 +284,46 @@ function enqueueTimestamp(job) {
247
284
  * have the running count subtracted from it again. The scheduler no longer
248
285
  * keeps a private concurrency cap of its own: per sessionSlots.cjs's own
249
286
  * charter, caps belong to Session-Manager's one pool, not to each consumer.
287
+ * @param {object} [quietOpts] - PRD 1107 quiet-machine state, all pure inputs
288
+ * so this function stays unit-testable without electron/sessionSlots:
289
+ * - leaseHeld: true while a quiet-machine job is currently running
290
+ * (quietMachineLease.cjs's isHeld()) — holds ALL dispatch, every project,
291
+ * until it releases.
292
+ * - machineInUse: sessionSlots.inUse() — jobs running machine-wide RIGHT
293
+ * NOW (scheduler jobs AND chat runs). A quiet-machine job only dispatches
294
+ * when this is 0, unless it has waited past quietMachineWaitMs().
295
+ * - now: ms epoch, defaults to Date.now() (injectable for tests).
250
296
  * @returns {{ batch: object[], reason: string | null, holds: object[] }} Jobs
251
297
  * to spawn this tick, plus (when batch is empty because a gate held it) the
252
298
  * human-readable hold reason that would otherwise only reach console.log.
253
299
  */
254
- function pickNextBatch(allJobs, running, freeSlots) {
255
- if (!allJobs.some((j) => j.status === 'pending' && !running.has(j.slug))) {
256
- return { batch: [], reason: null, holds: [] };
300
+ function pickNextBatch(allJobs, running, freeSlots, quietOpts = {}) {
301
+ const { leaseHeld = false, machineInUse = 0, now = Date.now() } = quietOpts;
302
+ // slug → hold reason for rows the launch circuit breaker is holding back
303
+ // this tick (scheduler.cjs computes it from state.launchBlocks). Held rows
304
+ // are invisible to every pick below but still surface as holds.
305
+ const heldSlugs = quietOpts.heldSlugs instanceof Map ? quietOpts.heldSlugs : new Map();
306
+ const pickable = (j) => j.status === 'pending' && !running.has(j.slug) && !heldSlugs.has(j.slug);
307
+
308
+ // Exclusive lease (PRD 1107): while a quiet-machine job is running, it
309
+ // holds the WHOLE pool — no other job, in any project, dispatches until it
310
+ // releases. Checked first, ahead of even the "anything pending?" early
311
+ // return, so the reason is always attributable to the lease rather than a
312
+ // generic drain.
313
+ if (leaseHeld) {
314
+ const reason = '[scheduler] quiet-machine: lease held by a running job — holding all dispatch until it releases';
315
+ console.log(reason);
316
+ return { batch: [], reason, holds: [] };
317
+ }
318
+
319
+ if (!allJobs.some(pickable)) {
320
+ const holds = [];
321
+ for (const j of allJobs) {
322
+ if (j.status === 'pending' && !running.has(j.slug) && heldSlugs.has(j.slug)) {
323
+ holds.push({ slug: j.slug, dep: null, depStatus: null, reason: heldSlugs.get(j.slug) || 'launch blocked' });
324
+ }
325
+ }
326
+ return { batch: [], reason: null, holds };
257
327
  }
258
328
 
259
329
  // Orphan correction: a queue.json row still marked `running` that this
@@ -279,14 +349,38 @@ function pickNextBatch(allJobs, running, freeSlots) {
279
349
  projectMap.get(key).push(job);
280
350
  }
281
351
 
352
+ // Quiet-machine dispatch (PRD 1107) — evaluated BEFORE the ordinary
353
+ // per-project round-robin, and as an ALL-OR-NOTHING decision for this
354
+ // tick: a quiet-machine job either fires alone (nothing else in this
355
+ // batch), or it doesn't fire and the ordinary round-robin runs unaffected.
356
+ // This is what makes "while it runs no other job is dispatched" true —
357
+ // ordinary jobs are simply never offered a slot in the SAME tick a quiet
358
+ // job dispatches.
359
+ if (slots > 0) {
360
+ const quietWaitMs = quietMachineWaitMs();
361
+ const quietPending = allJobs.filter((j) => pickable(j) && j.quietMachine === true);
362
+ for (const job of quietPending) {
363
+ const projectJobs = projectMap.get(job.cwd || DEFAULT_PROJECT_CWD) || [job];
364
+ if (findBlockingDep(job, projectJobs)) continue; // blocked — try the next quiet candidate
365
+ const machineQuiet = machineInUse === 0;
366
+ const waitedMs = now - enqueueTimestamp(job);
367
+ const degraded = !machineQuiet && waitedMs >= quietWaitMs;
368
+ if (!machineQuiet && !degraded) continue; // still waiting for a quiet machine
369
+ const reason = machineQuiet
370
+ ? `[scheduler] quiet-machine: dispatching ${job.slug} exclusively (machine idle)`
371
+ : `[scheduler] quiet-machine: dispatching ${job.slug} DEGRADED — waited ${Math.round(waitedMs / 60_000)}m ` +
372
+ `> ${Math.round(quietWaitMs / 60_000)}m without the machine going quiet`;
373
+ console.log(reason);
374
+ return { batch: [{ ...job, quietLeaseDegraded: degraded }], reason: null, holds: [] };
375
+ }
376
+ }
377
+
282
378
  // Per-project candidates (only projects with pending work), each carrying
283
379
  // a per-tick VIEW of its rows that this loop mutates as it picks, so
284
380
  // pickForProject's running count and cap stay accurate across passes.
285
381
  const candidates = [];
286
382
  for (const [cwd, projectJobs] of projectMap) {
287
- const hasPending = projectJobs.some(
288
- (j) => j.status === 'pending' && !running.has(j.slug),
289
- );
383
+ const hasPending = projectJobs.some(pickable);
290
384
  if (!hasPending) continue;
291
385
  const view = projectJobs.map((j) => ({ ...j }));
292
386
  const runningSlugsInProject = new Set(
@@ -294,7 +388,7 @@ function pickNextBatch(allJobs, running, freeSlots) {
294
388
  );
295
389
  const runningCount = projectJobs.filter((j) => j.status === 'running').length;
296
390
  const oldestPendingMs = projectJobs
297
- .filter((j) => j.status === 'pending' && !running.has(j.slug))
391
+ .filter(pickable)
298
392
  .reduce((min, j) => Math.min(min, enqueueTimestamp(j)), Infinity);
299
393
  candidates.push({ cwd, view, runningSlugsInProject, runningCount, oldestPendingMs, active: true });
300
394
  }
@@ -314,7 +408,7 @@ function pickNextBatch(allJobs, running, freeSlots) {
314
408
  for (const c of candidates) {
315
409
  if (slots <= 0) break;
316
410
  if (!c.active) continue;
317
- const result = pickForProject(c.view, c.runningSlugsInProject, 1);
411
+ const result = pickForProject(c.view, c.runningSlugsInProject, 1, heldSlugs);
318
412
  for (const h of result.holds ?? []) if (!holdsBySlug.has(h.slug)) holdsBySlug.set(h.slug, h);
319
413
  if (result.batch.length === 0) {
320
414
  if (heldReason === null && result.reason) heldReason = result.reason;
@@ -334,6 +428,14 @@ function pickNextBatch(allJobs, running, freeSlots) {
334
428
  }
335
429
  if (addedThisPass === 0) break;
336
430
  }
431
+ // A project whose only pending rows are launch-held never becomes a
432
+ // candidate above, so its hold records would be lost — surface them here
433
+ // so the Queue UI still says why those rows are waiting.
434
+ for (const j of allJobs) {
435
+ if (j.status === 'pending' && !running.has(j.slug) && heldSlugs.has(j.slug) && !holdsBySlug.has(j.slug)) {
436
+ holdsBySlug.set(j.slug, { slug: j.slug, dep: null, depStatus: null, reason: heldSlugs.get(j.slug) || 'launch blocked' });
437
+ }
438
+ }
337
439
  const holds = [...holdsBySlug.values()];
338
440
  return { batch, reason: batch.length === 0 ? heldReason : null, holds };
339
441
  }
@@ -387,4 +489,6 @@ function findStarvedProjects(jobs, now, thresholdMs) {
387
489
  return out;
388
490
  }
389
491
 
390
- module.exports = { pickForProject, pickNextBatch, enqueueTimestamp, findStarvedProjects, DEFAULT_PROJECT_CWD };
492
+ module.exports = {
493
+ pickForProject, pickNextBatch, enqueueTimestamp, findStarvedProjects, findBlockingDep, DEFAULT_PROJECT_CWD,
494
+ };
@@ -129,8 +129,27 @@ module.exports = {
129
129
  // SM_LOAD_GATE_PER_CORE (float, clamped to [0.25, 4]; 0 disables).
130
130
  LOAD_GATE_PER_CORE: 0.85,
131
131
  loadGateThreshold,
132
+
133
+ // A `quietMachine: true` PRD (PRD 1107 — see quietMachineLease.cjs +
134
+ // schedulerBatch.cjs's pickNextBatch) waits for zero other jobs to be
135
+ // running machine-wide before it dispatches, so its own timing-sensitive
136
+ // acceptance criteria (frame time, performance fences) aren't measured
137
+ // under CPU contention. A queue's worth of ordinary jobs must never wedge
138
+ // behind one waiting forever: past this many ms of waiting it dispatches
139
+ // anyway, stamped `quietLeaseDegraded: true`. Override with
140
+ // SM_QUIET_MACHINE_WAIT_MINUTES (integer minutes, clamped to [1, 1440]).
141
+ DEFAULT_QUIET_MACHINE_WAIT_MS: 30 * 60_000,
142
+ quietMachineWaitMs,
132
143
  };
133
144
 
145
+ function quietMachineWaitMs() {
146
+ const raw = process.env.SM_QUIET_MACHINE_WAIT_MINUTES;
147
+ if (raw === undefined || raw === '') return module.exports.DEFAULT_QUIET_MACHINE_WAIT_MS;
148
+ const parsed = parseInt(raw, 10);
149
+ if (!Number.isFinite(parsed)) return module.exports.DEFAULT_QUIET_MACHINE_WAIT_MS;
150
+ return Math.min(1440, Math.max(1, parsed)) * 60_000;
151
+ }
152
+
134
153
  function loadGateThreshold() {
135
154
  const raw = process.env.SM_LOAD_GATE_PER_CORE;
136
155
  if (raw === undefined || raw === '') return module.exports.LOAD_GATE_PER_CORE;
@@ -5,7 +5,9 @@
5
5
  * display (PRD 932). Reads what the `project-home-builder` Epic's own
6
6
  * session writes directly with its Write tool
7
7
  * (`session-manager-operations/project-pages/output/*.html` + manifest.json)
8
- * — this module never writes anything, matching the corrected "not an
8
+ * — this module never writes any CONTENT (the one exception, watchOutput()'s
9
+ * `fs.mkdirSync` to ensure the output dir exists before attaching a watcher,
10
+ * creates an empty directory only), matching the corrected "not an
9
11
  * OWNERS namespace" storage/ownership note in
10
12
  * session-manager-operations/architecture/project-pages-pipeline.md and
11
13
  * session-manager-operations/project-pages/README.md. Structure mirrors
@@ -25,13 +27,36 @@
25
27
  const { ipcMain } = require('electron');
26
28
  const fs = require('node:fs');
27
29
  const path = require('node:path');
30
+ const chokidar = require('chokidar');
28
31
  const config = require('./config.cjs');
29
32
  const { opsPath } = require('./lib/opsOwnership.cjs');
33
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
34
+
35
+ let mainWindow = null;
36
+ function attachWindow(window) {
37
+ mainWindow = window;
38
+ }
30
39
 
31
40
  function outputDir(cwd) {
32
41
  return opsPath(cwd, 'project-pages', 'output');
33
42
  }
34
43
 
44
+ /**
45
+ * Resolve `cwd`'s output dir, treating the opsOwnership "ephemeral cwd"
46
+ * throw (a tmpdir/linked-worktree project root — see opsOwnership.cjs's
47
+ * resolveProjectRoot) as "no output dir available" rather than letting it
48
+ * propagate. Both get() and the watcher below go through this so a builder
49
+ * Epic running in a worktree can never crash either path.
50
+ */
51
+ function tryOutputDir(realCwd) {
52
+ try {
53
+ return { dir: outputDir(realCwd), ephemeral: false };
54
+ } catch (e) {
55
+ if (e.ephemeral) return { dir: null, ephemeral: true };
56
+ throw e;
57
+ }
58
+ }
59
+
35
60
  // Shipped build-time asset (see PRD "project-home-hosted-html-spec"'s
36
61
  // default-document requirement) — a fixed app-relative path with no
37
62
  // user-controlled segment, so it deliberately does NOT go through
@@ -60,13 +85,24 @@ function defaultOutput() {
60
85
  // the empty state for every pre-existing project. So brief.html is read
61
86
  // tolerantly — present, its text is returned; absent, the field is simply
62
87
  // omitted from the payload rather than failing the whole read.
88
+ // Partial-output strategy (see this file's module doc + PRD
89
+ // "project-home-generate-preflight-and-push"): the render route
90
+ // (projectHomeAdminRoutes.cjs's doRender) writes every lens file
91
+ // concurrently and writes manifest.json ONLY AFTER all of them have landed.
92
+ // So manifest.json's existence is already an all-or-nothing commit signal —
93
+ // gating get() on it (below) means a reader can never observe a lens file
94
+ // that arrived without its siblings. No second partial-state representation
95
+ // is needed; this file just relies on that write order.
63
96
  const REQUIRED_LENSES = ['home', 'marketing', 'feature', 'architecture'];
64
97
  const OPTIONAL_LENSES = ['brief'];
65
98
  const LENSES = [...REQUIRED_LENSES, ...OPTIONAL_LENSES];
66
99
 
67
100
  async function get({ cwd }) {
68
101
  const realCwd = config.validatePath(cwd);
69
- const dir = outputDir(realCwd);
102
+ const { dir, ephemeral } = tryOutputDir(realCwd);
103
+ if (ephemeral) {
104
+ return { output: defaultOutput() };
105
+ }
70
106
  const manifestResult = await config.readJson(path.join(dir, 'manifest.json'));
71
107
  if (!manifestResult.exists || !manifestResult.data || manifestResult.parseError) {
72
108
  return { output: defaultOutput() };
@@ -100,14 +136,136 @@ async function get({ cwd }) {
100
136
  return { output };
101
137
  }
102
138
 
139
+ // ─── Push channel (PRD "project-home-generate-preflight-and-push") ────────
140
+ //
141
+ // project-pages:get above is a one-shot pull, fetched once at mount by
142
+ // ProjectHome.tsx. That leaves a finished (or stuck) builder Epic invisible
143
+ // until the next manual refresh/app restart. This section adds a per-cwd
144
+ // chokidar watcher on the output dir (the same debounced-write library
145
+ // config.cjs already uses for its generic file watcher) that recomputes
146
+ // get() and pushes it to the renderer whenever the dir changes.
147
+ //
148
+ // Map<realCwd, { watcher, refCount }> — refcounted like config.cjs's own
149
+ // watchers Map so more than one mounted component watching the same cwd
150
+ // doesn't spawn a second chokidar instance, and doesn't get torn down by
151
+ // the first component's unmount while a second is still watching.
152
+ const outputWatchers = new Map();
153
+
154
+ /**
155
+ * Watch `cwd`'s output dir. Resolves `{ ok: true }` once watching is actually
156
+ * live (or already watching), or `{ ok: false, reason: 'ephemeral' |
157
+ * 'invalid-cwd' }` when there is nothing safe to watch — callers must not
158
+ * treat that as an error, just as "no live updates for this cwd" (e.g. a
159
+ * worktree cwd). Async: waits for chokidar's own 'ready' before resolving so
160
+ * a write landing immediately after the call is never missed in the window
161
+ * before the underlying fs watch handle is actually registered.
162
+ */
163
+ async function watchOutput(cwd) {
164
+ let realCwd;
165
+ try {
166
+ realCwd = config.validatePath(cwd);
167
+ } catch {
168
+ return { ok: false, reason: 'invalid-cwd' };
169
+ }
170
+
171
+ const existing = outputWatchers.get(realCwd);
172
+ if (existing) {
173
+ existing.refCount += 1;
174
+ return { ok: true };
175
+ }
176
+
177
+ const { dir, ephemeral } = tryOutputDir(realCwd);
178
+ if (ephemeral) {
179
+ return { ok: false, reason: 'ephemeral' };
180
+ }
181
+ // Ensure the (possibly not-yet-generated) output dir exists before
182
+ // chokidar attaches: watching a path 3 levels deep that doesn't exist yet
183
+ // does not reliably pick up files later written into it (verified — the
184
+ // watcher silently misses the first write without this). This creates an
185
+ // EMPTY directory only, no content, so it does not conflict with this
186
+ // module's read-only contract or the single-writer law (opsOwnership.cjs)
187
+ // — it bypasses config.cjs's writer-tracked write path deliberately,
188
+ // exactly like a project-home-builder Epic's own untracked Write-tool
189
+ // calls into this same folder (see opsOwnership.cjs's 'project-pages'
190
+ // entry).
191
+ fs.mkdirSync(dir, { recursive: true });
192
+
193
+ const push = async () => {
194
+ let result;
195
+ try {
196
+ result = await get({ cwd: realCwd });
197
+ } catch {
198
+ return; // best-effort; the renderer keeps its last-known output
199
+ }
200
+ sendIfAlive(mainWindow, 'project-pages:changed', { cwd: realCwd, output: result.output });
201
+ };
202
+
203
+ const watcher = chokidar.watch(dir, {
204
+ ignoreInitial: true,
205
+ persistent: true,
206
+ // manifest.json is the LAST file doRender writes (see REQUIRED_LENSES's
207
+ // comment above) — but the burst of per-lens writes leading up to it
208
+ // still fires several fs events in quick succession. awaitWriteFinish
209
+ // (same 50ms/25ms shape config.cjs's generic watcher uses) collapses
210
+ // that burst into one push instead of flapping the UI once per file.
211
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
212
+ });
213
+ watcher.on('add', push);
214
+ watcher.on('change', push);
215
+ watcher.on('unlink', push);
216
+ watcher.on('error', (err) => {
217
+ console.warn('[projectPages] watcher error for', dir, err?.message);
218
+ });
219
+
220
+ outputWatchers.set(realCwd, { watcher, refCount: 1 });
221
+ await new Promise((resolve) => watcher.once('ready', resolve));
222
+ return { ok: true };
223
+ }
224
+
225
+ /** Decrement `cwd`'s watch refcount; closes the underlying watcher at 0. */
226
+ function unwatchOutput(cwd) {
227
+ let realCwd;
228
+ try {
229
+ realCwd = config.validatePath(cwd);
230
+ } catch {
231
+ return;
232
+ }
233
+ const entry = outputWatchers.get(realCwd);
234
+ if (!entry) return;
235
+ entry.refCount -= 1;
236
+ if (entry.refCount <= 0) {
237
+ entry.watcher.close().catch(() => {});
238
+ outputWatchers.delete(realCwd);
239
+ }
240
+ }
241
+
242
+ /** Window close / app quit teardown — mirrors config.cjs's closeAllWatchers. */
243
+ function closeAllOutputWatchers() {
244
+ for (const { watcher } of outputWatchers.values()) {
245
+ watcher.close().catch(() => {});
246
+ }
247
+ outputWatchers.clear();
248
+ }
249
+
103
250
  function registerProjectPagesIpc() {
104
251
  const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
105
252
  ipcMain.handle('project-pages:get', v(s.projectPagesCwd, get));
253
+ ipcMain.handle('project-pages:watch', v(s.projectPagesCwd, ({ cwd }) => watchOutput(cwd)));
254
+ ipcMain.handle('project-pages:unwatch', v(s.projectPagesCwd, ({ cwd }) => {
255
+ unwatchOutput(cwd);
256
+ return { ok: true };
257
+ }));
106
258
  }
107
259
 
108
260
  module.exports = {
261
+ attachWindow,
109
262
  registerProjectPagesIpc,
110
263
  get,
264
+ watchOutput,
265
+ unwatchOutput,
266
+ closeAllOutputWatchers,
111
267
  DEFAULT_HOME_PATH,
112
268
  readDefaultHomeHtml,
269
+ // Test-only introspection of the refcounted watcher map.
270
+ _outputWatchers: outputWatchers,
113
271
  };
@@ -13,7 +13,8 @@
13
13
  * verifyRun({ runDir, prdPath, queueEntry, allJobs }) → Promise<Verdict>
14
14
  *
15
15
  * Verdict shape:
16
- * { verdict: 'clean'|'halt'|'deps_unmet'|'transcript_errors'|'verify_unavailable',
16
+ * { verdict: 'clean'|'halt'|'deps_unmet'|'transcript_errors'|'verify_unavailable'
17
+ * |'no_verdict_sentinel'|'abandoned_background_task',
17
18
  * reason: string,
18
19
  * downgradeTo: 'pending'|'needs_review'|null }
19
20
  *
@@ -23,6 +24,8 @@
23
24
  * deps_unmet → 'pending' (re-fires when deps clear)
24
25
  * transcript_errors → 'needs_review' (requires human flip)
25
26
  * verify_unavailable → 'needs_review'
27
+ * no_verdict_sentinel → 'needs_review'
28
+ * abandoned_background_task → 'needs_review'
26
29
  *
27
30
  * Time complexity: O(N·L) where N = log line count, L = avg content line count.
28
31
  * The log is read once with readFileSync (700 KB typical; well within Node.js
@@ -231,6 +234,28 @@ function parseLog(logPath) {
231
234
  return { events, resultEvent, error: null, transcriptCommitLanded };
232
235
  }
233
236
 
237
+ // Stable, narrow substrings of the harness's auto-background tool_result text
238
+ // (Bash: "Command did not complete within its 120s timeout and was moved to
239
+ // the background... You will be notified when it completes."). Anchored on
240
+ // short distinctive fragments rather than the whole sentence so harness
241
+ // wording drift degrades to the generic no_verdict_sentinel verdict instead
242
+ // of throwing.
243
+ const BACKGROUND_TASK_MARKERS = ['moved to the background', 'You will be notified when it completes'];
244
+
245
+ /**
246
+ * True when any tool_result in the transcript carries the harness's
247
+ * auto-background marker text — i.e. a Bash command was auto-backgrounded
248
+ * after its foreground timeout and the run ended waiting for a notification
249
+ * a headless run structurally cannot receive.
250
+ */
251
+ function hasAbandonedBackgroundTask(events) {
252
+ for (const ev of events) {
253
+ if (ev.kind !== 'tool_result' || !ev.content) continue;
254
+ if (BACKGROUND_TASK_MARKERS.some((marker) => ev.content.includes(marker))) return true;
255
+ }
256
+ return false;
257
+ }
258
+
234
259
  // ─── self-recovery helpers ────────────────────────────────────────────────────
235
260
 
236
261
  /**
@@ -905,15 +930,30 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
905
930
  : undefined;
906
931
 
907
932
  // No pattern hits is not automatically "clean": a run that neither
908
- // committed anything nor ever emitted the finish-protocol sentinel likely
909
- // ended before doing real work (e.g. stopped on a clarifying question).
910
- // Weaker evidence than a caught transcript error, but still not clean.
933
+ // committed anything nor ever emitted the finish-protocol sentinel did not
934
+ // finish for SOME reason. When the transcript shows the harness's
935
+ // auto-background marker, that reason is knowable and cheap to recover
936
+ // from: the run backgrounded a long Bash command past its foreground
937
+ // timeout and ended waiting for a "you will be notified" callback a
938
+ // headless run can never receive — it did not necessarily fail to do the
939
+ // work, it failed to come back and commit it. That gets its own verdict
940
+ // (abandoned_background_task) so the auto-fix investigation checks the
941
+ // working tree for already-written work first, instead of re-planning the
942
+ // whole PRD from a wrong "stopped on a clarifying question" premise.
911
943
  if (sentinel === null && !commitEvidence) {
912
- issues.push({
913
- verdict: 'no_verdict_sentinel',
914
- reason: 'run made no commit and emitted no SCHEDULER_VERDICT sentinel — likely ended before the finish protocol (e.g. stopped on a clarifying question)',
915
- priority: 1,
916
- });
944
+ if (hasAbandonedBackgroundTask(events)) {
945
+ issues.push({
946
+ verdict: 'abandoned_background_task',
947
+ reason: 'run made no commit and emitted no SCHEDULER_VERDICT sentinel, and the transcript shows a Bash command auto-backgrounded past its foreground timeout — the run likely ended waiting for a background-task notification a headless run cannot receive, not because the work failed; the work is probably already written and just needs to be committed',
948
+ priority: 1,
949
+ });
950
+ } else {
951
+ issues.push({
952
+ verdict: 'no_verdict_sentinel',
953
+ reason: 'run made no commit and emitted no SCHEDULER_VERDICT sentinel — the run ended before the finish protocol for some reason (stopped on a clarifying question, crashed, ran out of turns, or another cause not identifiable from this transcript alone)',
954
+ priority: 1,
955
+ });
956
+ }
917
957
  }
918
958
 
919
959
  // A truthful-looking PASS sentinel with no commit is still not "clean":
@@ -1122,4 +1162,5 @@ module.exports = {
1122
1162
  allDeliverablesAlreadyTracked,
1123
1163
  isAncestorCommit,
1124
1164
  isGitRepo,
1165
+ hasAbandonedBackgroundTask,
1125
1166
  };
@@ -19,6 +19,7 @@ const os = require('node:os');
19
19
  const path = require('node:path');
20
20
  const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
21
21
  const { deriveEpicIdFromPrdPath } = require('../lib/prdLocations.cjs');
22
+ const { reportDanglingAgentTypeOnce } = require('../lib/prdAgentType.cjs');
22
23
 
23
24
  /**
24
25
  * Expand a PRD `cwd` value to an absolute path.
@@ -57,12 +58,19 @@ async function parsePrdRaw(filePath) {
57
58
  const m = base.match(/^(\d+)-/);
58
59
  return m ? Number(m[1]) : null;
59
60
  })();
61
+ const cwd = expandCwd(fm.cwd || null);
62
+
63
+ // Read-time tolerance for the agentType FK (throw on write, report on
64
+ // read — prdAgentType.cjs's header): a persona renamed/deleted after this
65
+ // PRD was written must not stop the PRD from loading. Best-effort only —
66
+ // never lets a logging failure affect parsing.
67
+ if (fm.agentType && cwd) reportDanglingAgentTypeOnce(cwd, fm.agentType);
60
68
 
61
69
  return {
62
70
  slug: base,
63
71
  path: filePath,
64
72
  title: fm.title || base,
65
- cwd: expandCwd(fm.cwd || null),
73
+ cwd,
66
74
  estimateMinutes: fm.estimateMinutes ? Number(fm.estimateMinutes) || null : null,
67
75
  parallelGroup: (fm.parallelGroup ? Number(fm.parallelGroup) || null : null) ?? groupFromName ?? 99,
68
76
  // Optional traceability back to the PromptTicket.id (PRD 748) that was
@@ -99,6 +107,15 @@ async function parsePrdRaw(filePath) {
99
107
  // quarantines it instead of queuing it to run.
100
108
  createdVia: fm.createdVia || null,
101
109
  issuedAt: fm.issuedAt || null,
110
+ // WHO executes this PRD (persona name) — distinct from `tag` (work
111
+ // type). Absent on every PRD authored before this field existed; the
112
+ // dependent follow-up PRD (scheduler-launch-as-persona) resolves this
113
+ // into a launch decision, which this module does not do.
114
+ agentType: fm.agentType || null,
115
+ // Opt-in exclusive-lease flag (PRD 1107, quietMachineLease.cjs +
116
+ // schedulerBatch.cjs's pickNextBatch). Only a literal `true` opts in —
117
+ // matches prdFrontmatter.cjs's applyKey semantics for this field.
118
+ quietMachine: fm.quietMachine === 'true',
102
119
  body: body.trim(),
103
120
  };
104
121
  }