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.
- package/dist/assets/{AgentLibrary-13pfo8uY.js → AgentLibrary-zS3jw_1e.js} +1 -1
- package/dist/assets/{DataModel-SUyQbFlg.js → DataModel-Cy_vxTpi.js} +1 -1
- package/dist/assets/{History-2GJMS703.js → History-C6JRuqfT.js} +1 -1
- package/dist/assets/{Hooks-DM2nS3RT.js → Hooks-BafPy9mB.js} +1 -1
- package/dist/assets/{HostBilko-BLeC-lpp.js → HostBilko-BZwhQOFt.js} +1 -1
- package/dist/assets/{Library-BaRkU9m0.js → Library-C8JDDliz.js} +1 -1
- package/dist/assets/{ListDetail-D5scjSKq.js → ListDetail-CqiOdwLc.js} +1 -1
- package/dist/assets/{MarkdownEditor-B1lgAo9T.js → MarkdownEditor-CyLyP67L.js} +1 -1
- package/dist/assets/{McpServers-BzyThQSM.js → McpServers-BzMv-_84.js} +1 -1
- package/dist/assets/{Memory-7UdaOTtl.js → Memory-DSBYQdJR.js} +1 -1
- package/dist/assets/{Panel-JbTMaOPq.js → Panel-CLUhkNNA.js} +1 -1
- package/dist/assets/{Permissions-UBam0bJG.js → Permissions-BfC2-HN4.js} +1 -1
- package/dist/assets/{Plugins-B3gUDkeb.js → Plugins-BKi40jT5.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CeHOub7m.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
- package/dist/assets/{SaveBar-BcvQEq6h.js → SaveBar-avk2p9jv.js} +1 -1
- package/dist/assets/{Scheduler-Dc5qiP24.js → Scheduler-Bf_6MdJo.js} +7 -7
- package/dist/assets/{ScopeSwitcher-BvGQmw4Y.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
- package/dist/assets/{Settings-C2dEFb-v.js → Settings-Djd8OoBA.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CIwlosBc.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
- package/dist/assets/{Skills-C0GzzVrQ.js → Skills-D_qAqxZ_.js} +1 -1
- package/dist/assets/{SystemPrompt-mtGPK8zo.js → SystemPrompt-DbHFLQV3.js} +1 -1
- package/dist/assets/{TagLibrary-DX54-mpd.js → TagLibrary-C2y91BT0.js} +1 -1
- package/dist/assets/{TiptapBody-yADC2RWE.js → TiptapBody-D9iz4xQx.js} +1 -1
- package/dist/assets/{Toggle-CRxaCYLI.js → Toggle-BGnFL2E5.js} +1 -1
- package/dist/assets/{index-D6ymGESc.js → index-_2ARyFDj.js} +4 -4
- package/dist/assets/{settingsSchema-TtMvT5Sx.js → settingsSchema-JK15eJU8.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
- package/scripts/project-pages-logic/dist/logic.cjs +12 -12
- package/scripts/render-project-pages/dist/renderer.cjs +22 -22
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
- package/src/main/__tests__/prdCreate.test.cjs +133 -8
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
- package/src/main/__tests__/queueHistory.test.cjs +33 -0
- package/src/main/__tests__/runLogRetention.test.cjs +59 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-never-stop.test.cjs +157 -0
- package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +81 -0
- package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +123 -0
- package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +158 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +30 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
- package/src/main/ipcSchemas.cjs +15 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +90 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/depSlugResolve.cjs +72 -0
- package/src/main/lib/epicWorktreeMerge.cjs +3 -3
- package/src/main/lib/epicWorktreeMint.cjs +17 -5
- package/src/main/lib/fixPlanSlug.cjs +62 -0
- package/src/main/lib/gitWorktree.cjs +97 -12
- package/src/main/lib/jobDirtFilter.cjs +54 -0
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/prdCreate.cjs +84 -5
- package/src/main/lib/prdFrontmatter.cjs +56 -8
- package/src/main/lib/queueHistory.cjs +50 -5
- package/src/main/lib/rateLimitDetect.cjs +35 -0
- package/src/main/lib/reaperHelpers.cjs +31 -6
- package/src/main/lib/runLogRetention.cjs +82 -4
- package/src/main/lib/scheduleJobTransitions.cjs +12 -2
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1165 -70
- 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
|
|
24
|
-
* in a non-completed state
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
89
|
+
return rowsByBareSlug.get(bareSlug(slug)) ?? [];
|
|
45
90
|
};
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
89
|
-
// queue row for it exists in a non-completed state
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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
|
|
283
|
+
depStatus: depStatusOf(depRows),
|
|
284
|
+
rootSlug: root.rootSlug,
|
|
285
|
+
rootStatus: root.rootStatus,
|
|
159
286
|
});
|
|
160
|
-
// running/pending
|
|
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
|