claude-code-session-manager 0.76.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-13pfo8uY.js} +2 -2
- package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-SUyQbFlg.js} +1 -1
- package/dist/assets/{History-CpdtWhC8.js → History-2GJMS703.js} +2 -2
- package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-DM2nS3RT.js} +2 -2
- package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-BLeC-lpp.js} +1 -1
- package/dist/assets/{Library-CQmo4QVC.js → Library-BaRkU9m0.js} +1 -1
- package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-D5scjSKq.js} +1 -1
- package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-B1lgAo9T.js} +1 -1
- package/dist/assets/{McpServers-CLarzwqA.js → McpServers-BzyThQSM.js} +1 -1
- package/dist/assets/{Memory-B0sCdIy1.js → Memory-7UdaOTtl.js} +6 -6
- package/dist/assets/{Panel-BhWPVOCD.js → Panel-JbTMaOPq.js} +1 -1
- package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-UBam0bJG.js} +2 -2
- package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-B3gUDkeb.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-CeHOub7m.js} +1 -1
- package/dist/assets/SaveBar-BcvQEq6h.js +1 -0
- package/dist/assets/Scheduler-Dc5qiP24.js +14 -0
- package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-BvGQmw4Y.js} +1 -1
- package/dist/assets/{Settings-2Vx3X5SI.js → Settings-C2dEFb-v.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-CIwlosBc.js} +1 -1
- package/dist/assets/{Skills-Cmrz_LeN.js → Skills-C0GzzVrQ.js} +2 -2
- package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-mtGPK8zo.js} +1 -1
- package/dist/assets/TagLibrary-DX54-mpd.js +1 -0
- package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-yADC2RWE.js} +1 -1
- package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-CRxaCYLI.js} +1 -1
- package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{index-B_4PNh9T.js → index-D6ymGESc.js} +469 -469
- package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-TtMvT5Sx.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +115 -1
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +138 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/config.cjs +4 -1
- package/src/main/index.cjs +16 -4
- package/src/main/ipcSchemas.cjs +53 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +197 -2
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/childWithLog.cjs +40 -5
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +115 -9
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +464 -19
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/mcpToolCatalog.cjs +87 -2
- package/src/main/lib/opsOwnership.cjs +12 -0
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +57 -1
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +9 -2
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +53 -3
- package/src/main/lib/reaperHelpers.cjs +18 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +133 -29
- package/src/main/lib/schedulerConfig.cjs +19 -0
- package/src/main/projectPages.cjs +160 -2
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1371 -97
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +126 -6
- package/src/preload/index.cjs +8 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
- package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
- 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).
|
|
76
|
-
// exact slug first, then
|
|
77
|
-
// with a leading `NN-` stripped.
|
|
78
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
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(
|
|
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 = {
|
|
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
|
|
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 =
|
|
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
|
};
|
package/src/main/runVerify.cjs
CHANGED
|
@@ -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
|
|
909
|
-
//
|
|
910
|
-
//
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
|
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
|
}
|