claude-code-session-manager 0.79.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentLibrary-COtVRqBR.js → AgentLibrary-psZYVM2w.js} +1 -1
- package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
- package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
- package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
- package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
- package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
- package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
- package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
- package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
- package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
- package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
- package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
- package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
- package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
- package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
- package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
- package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
- package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
- package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
- package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
- package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
- package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
- package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
- package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.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/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- 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__/rcaReport.test.cjs +24 -0
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -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-blocked-by-foreign-wip.test.cjs +107 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
- 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__/branchSweep.test.cjs +164 -0
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/branchSweep.cjs +127 -0
- 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 +117 -12
- package/src/main/lib/landedSinceRun.cjs +41 -1
- 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/rateLimitWindow.cjs +62 -0
- package/src/main/lib/rcaReport.cjs +18 -3
- package/src/main/lib/reaperHelpers.cjs +169 -3
- package/src/main/lib/scheduleJobTransitions.cjs +28 -5
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/runVerify.cjs +71 -3
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1554 -71
- package/src/preload/api.d.ts +8 -0
|
@@ -52,6 +52,7 @@ const VERDICT_LABELS = {
|
|
|
52
52
|
pass_no_commit_already_shipped: 'PASS with no commit — deliverables already shipped',
|
|
53
53
|
pass_no_commit_prior_run_verified: 'PASS with no commit — prior run of this slug already landed the work',
|
|
54
54
|
silent_no_op: 'no commit, clean tree — no evidence of work',
|
|
55
|
+
blocked_by_foreign_wip_streak: 'blocked by a sibling job\'s foreign WIP 3x in a row',
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
function humanVerdict(verdict) {
|
|
@@ -69,14 +70,18 @@ const FAILURE_CLASSES = {
|
|
|
69
70
|
UNCOMMITTED: 'uncommitted-changes',
|
|
70
71
|
TRANSCRIPT_ERRORS: 'transcript-errors',
|
|
71
72
|
ABANDONED_BACKGROUND_TASK: 'abandoned-background-task',
|
|
73
|
+
BLOCKED_BY_FOREIGN_WIP: 'blocked-by-foreign-wip',
|
|
72
74
|
UNKNOWN: 'unknown',
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
// ─── Recovery actions — one per failure class, machine-readable ────────────
|
|
76
78
|
// Closed set the scheduler routes on: 'archive' (stale re-run, do not
|
|
77
|
-
// re-queue
|
|
78
|
-
//
|
|
79
|
-
// '
|
|
79
|
+
// re-queue — also the closest fit for BLOCKED_BY_FOREIGN_WIP: there is
|
|
80
|
+
// nothing to investigate, and re-queuing is already owned by
|
|
81
|
+
// reconcile()'s requeueForeignWipBlockedJobs, not this action), 'resume-and-commit'
|
|
82
|
+
// (PRD 1111's --resume dispatch owns this), 'verify-and-close' (the work
|
|
83
|
+
// likely landed, just missing its sentinel), 'investigate' (the only class
|
|
84
|
+
// that still buys a fix-plan investigation).
|
|
80
85
|
const RECOVERY_ACTIONS = {
|
|
81
86
|
[FAILURE_CLASSES.ALREADY_SHIPPED]: 'archive',
|
|
82
87
|
[FAILURE_CLASSES.SELF_QUEUE]: 'investigate',
|
|
@@ -96,6 +101,7 @@ const RECOVERY_ACTIONS = {
|
|
|
96
101
|
// carries the "check for salvaged work, commit before re-implementing"
|
|
97
102
|
// guidance into that cold-read investigation.
|
|
98
103
|
[FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]: 'investigate',
|
|
104
|
+
[FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]: 'archive',
|
|
99
105
|
[FAILURE_CLASSES.UNKNOWN]: 'investigate',
|
|
100
106
|
};
|
|
101
107
|
|
|
@@ -121,6 +127,8 @@ const PREVENTION_HINTS = {
|
|
|
121
127
|
'Recover or annotate every error within ~10 lines (e.g. `# expected/handled: <why>`) instead of leaving a bare Traceback near the end of the transcript.',
|
|
122
128
|
[FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]:
|
|
123
129
|
'A headless run cannot receive a background-task completion notification — never let a long Bash command auto-background past its foreground timeout and then wait for it. Run long commands with an explicit bound (`timeout <N> <cmd>`) so they finish in the foreground, or poll their output file directly instead of waiting on Monitor/notification.',
|
|
130
|
+
[FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]:
|
|
131
|
+
'This job correctly reported `SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP` against a sibling job\'s in-flight, uncommitted files three times in a row — auto-requeue is exhausted (reconcile() only clears this once the blocked paths go clean). This is not a defect in this PRD\'s own work: check on the sibling job/human that owns the still-dirty path(s) named on the job row, and either wait for them to commit or manually reset this job to `pending` once the tree is clear. Do not author a fix-plan PRD against it.',
|
|
124
132
|
[FAILURE_CLASSES.UNKNOWN]:
|
|
125
133
|
'Re-run the acceptance criteria gate locally against the failure log to pin down the specific break before re-queuing.',
|
|
126
134
|
};
|
|
@@ -159,6 +167,13 @@ const POST_AC_OVERRUN_MIN_TAIL_FRACTION = 0.3;
|
|
|
159
167
|
function classifyFailure({ verdict, logTail }) {
|
|
160
168
|
const lines = (logTail || '').split('\n');
|
|
161
169
|
|
|
170
|
+
// Checked before every other rule: an unambiguous, materially-checked
|
|
171
|
+
// verdict (the scheduler itself validated the executor's claimed paths
|
|
172
|
+
// against the job's disclosed foreign-WIP manifest before ever landing
|
|
173
|
+
// this verdict — see validateForeignWipBlockClaim in scheduler.cjs) needs
|
|
174
|
+
// no log-tail heuristics to classify.
|
|
175
|
+
if (verdict === 'blocked_by_foreign_wip_streak') return FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP;
|
|
176
|
+
|
|
162
177
|
// Checked first, before SELF_QUEUE/STUCK_LOOP: a correct executor that finds
|
|
163
178
|
// its acceptance criteria already satisfied by a prior commit makes no
|
|
164
179
|
// change and truthfully prints a PASS sentinel, so the run lands
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
11
12
|
const { readTail } = require('./fileTail.cjs');
|
|
12
13
|
const { detectRateLimitInLog } = require('./rateLimitDetect.cjs');
|
|
13
14
|
|
|
@@ -34,6 +35,90 @@ function claudePidAlive(pid) {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* findLiveProcessForJob(job, { worktreeDir }) → pid | null
|
|
40
|
+
*
|
|
41
|
+
* Positive liveness scan for a 'running' row whose `runtime.pid` is missing —
|
|
42
|
+
* the case a missing pid must NOT be read as "the process is gone" (2026-09-06
|
|
43
|
+
* incident: 234-uranus-eight-tails-ox marked failed/never_ran while PID
|
|
44
|
+
* 2174739 was a live `claude -p` still writing to that job's own worktree).
|
|
45
|
+
*
|
|
46
|
+
* Linux-`/proc` only. Scans every numeric `/proc/<pid>` entry and matches
|
|
47
|
+
* either: `/proc/<pid>/cwd` resolves to `worktreeDir` (or a path nested under
|
|
48
|
+
* it), or `/proc/<pid>/cmdline` contains both `claude` and the job's slug
|
|
49
|
+
* (fallback for a worktree-disabled/in-place run, where there is no dedicated
|
|
50
|
+
* worktreeDir to match against). Returns the first matching pid, or null if
|
|
51
|
+
* none is found.
|
|
52
|
+
*
|
|
53
|
+
* Safe fallback by construction: on any platform without `/proc` (macOS,
|
|
54
|
+
* Windows) `fs.readdirSync('/proc')` throws and this returns null immediately
|
|
55
|
+
* — i.e. exactly today's behaviour (fail toward terminalizing), never a hang
|
|
56
|
+
* or a thrown error propagating to the caller.
|
|
57
|
+
*/
|
|
58
|
+
function findLiveProcessForJob(job, { worktreeDir } = {}) {
|
|
59
|
+
const slug = job?.slug;
|
|
60
|
+
let entries;
|
|
61
|
+
try {
|
|
62
|
+
entries = fs.readdirSync('/proc');
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
for (const name of entries) {
|
|
67
|
+
if (!/^\d+$/.test(name)) continue;
|
|
68
|
+
const pid = Number(name);
|
|
69
|
+
if (!pid || pid <= 1) continue;
|
|
70
|
+
if (worktreeDir) {
|
|
71
|
+
try {
|
|
72
|
+
const cwdLink = fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
73
|
+
if (cwdLink === worktreeDir || cwdLink.startsWith(worktreeDir + path.sep)) return pid;
|
|
74
|
+
} catch {
|
|
75
|
+
// Process exited mid-scan, or permission denied — try the argv
|
|
76
|
+
// fallback below before giving up on this pid.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (slug) {
|
|
80
|
+
try {
|
|
81
|
+
const cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
|
82
|
+
if (/\bclaude\b/.test(cmd) && cmd.includes(slug)) return pid;
|
|
83
|
+
} catch {
|
|
84
|
+
// Same as above — process gone or unreadable, keep scanning.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when `logPath` exists and has non-zero content — the literal "did the
|
|
93
|
+
* run dir produce any log output" check that gates whether a pidless reap may
|
|
94
|
+
* assert `gateOutcome: 'never_ran'`. A run whose log has real bytes in it DID
|
|
95
|
+
* run, regardless of whether classifyRunOutcome found a clean result event in
|
|
96
|
+
* it — asserting never_ran in that case would be a false claim (see
|
|
97
|
+
* resolvePidlessGateOutcome below).
|
|
98
|
+
*/
|
|
99
|
+
function logHasOutput(logPath) {
|
|
100
|
+
if (!logPath) return false;
|
|
101
|
+
try {
|
|
102
|
+
return fs.statSync(logPath).size > 0;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Gate-outcome for a pidless reap, once no live process was found for it.
|
|
110
|
+
* `never_ran` is asserted ONLY when the run dir produced no log output at
|
|
111
|
+
* all — mapOutcomeToGateOutcome's own 'no_result' → 'never_ran' mapping is
|
|
112
|
+
* otherwise too broad here: a log with real content but no clean result event
|
|
113
|
+
* (e.g. killed mid-turn) proves the job DID run, so that case is reported as
|
|
114
|
+
* 'failed' instead of the false 'never_ran'.
|
|
115
|
+
*/
|
|
116
|
+
function resolvePidlessGateOutcome(outcome, hasOutput) {
|
|
117
|
+
if (!hasOutput) return 'never_ran';
|
|
118
|
+
const mapped = mapOutcomeToGateOutcome(outcome);
|
|
119
|
+
return mapped === 'never_ran' ? 'failed' : mapped;
|
|
120
|
+
}
|
|
121
|
+
|
|
37
122
|
/**
|
|
38
123
|
* Classify the terminal outcome of a completed run by reading the last 64 KB
|
|
39
124
|
* of its log file and scanning for the LAST `{"type":"result"}` JSONL event.
|
|
@@ -129,10 +214,21 @@ const ORPHAN_REQUEUE_CAP = 5;
|
|
|
129
214
|
* A pidless row whose `startedAt` is missing or unparseable is neither
|
|
130
215
|
* reaped nor skipped silently — age can't be proven, so it is surfaced in
|
|
131
216
|
* `warnings` instead (the caller logs it) and left alone.
|
|
217
|
+
*
|
|
218
|
+
* `findLiveProcess` (optional, `(job) → pid | null`) is consulted for a
|
|
219
|
+
* pidless row ONLY once its age clears `grace` — i.e. right before it would
|
|
220
|
+
* otherwise be terminalized. A pid it finds means the process is actually
|
|
221
|
+
* alive despite the missing runtime.pid record: the row is diverted into
|
|
222
|
+
* `recovered` (never `reapable`) so the caller can re-stamp the pid and leave
|
|
223
|
+
* the row `running`, instead of terminalizing a job that is still doing real
|
|
224
|
+
* work (2026-09-06 incident — see findLiveProcessForJob's header). Omitting
|
|
225
|
+
* `findLiveProcess` (existing callers/tests) preserves prior behaviour
|
|
226
|
+
* exactly: every pidless row past grace reaps, none are ever recovered.
|
|
132
227
|
*/
|
|
133
|
-
function selectReapableJobs(jobs, now, { pidAlive, grace } = {}) {
|
|
228
|
+
function selectReapableJobs(jobs, now, { pidAlive, grace, findLiveProcess } = {}) {
|
|
134
229
|
const reapable = [];
|
|
135
230
|
const warnings = [];
|
|
231
|
+
const recovered = [];
|
|
136
232
|
for (const j of jobs ?? []) {
|
|
137
233
|
if (j.status !== 'running') continue;
|
|
138
234
|
const pid = j.runtime?.pid;
|
|
@@ -148,6 +244,11 @@ function selectReapableJobs(jobs, now, { pidAlive, grace } = {}) {
|
|
|
148
244
|
}
|
|
149
245
|
const ageMs = now - startedAt;
|
|
150
246
|
if (ageMs < grace) continue; // spawn may still be mid-flight
|
|
247
|
+
const livePid = typeof findLiveProcess === 'function' ? findLiveProcess(j) : null;
|
|
248
|
+
if (livePid) {
|
|
249
|
+
recovered.push({ slug: j.slug, pid: livePid });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
151
252
|
reapable.push({
|
|
152
253
|
slug: j.slug,
|
|
153
254
|
pid: null,
|
|
@@ -155,7 +256,72 @@ function selectReapableJobs(jobs, now, { pidAlive, grace } = {}) {
|
|
|
155
256
|
reason: `reaped: no runtime.pid recorded after ${Math.round(grace / 60_000)}m — spawn never completed`,
|
|
156
257
|
});
|
|
157
258
|
}
|
|
158
|
-
return { reapable, warnings };
|
|
259
|
+
return { reapable, warnings, recovered };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* isAlreadySatisfiedOnMain(commits) → { sha, verdict, reason } | null
|
|
264
|
+
*
|
|
265
|
+
* Pure decision layer for the finish-protocol commit-guard's second,
|
|
266
|
+
* independently-evidenced route to 'completed' (PRD 1136). The commit-guard
|
|
267
|
+
* in scheduler.cjs parks a clean exit / no-commit / clean-tree run as
|
|
268
|
+
* `needs_review` ("finish protocol incomplete") because that shape is
|
|
269
|
+
* normally the strongest signal that nothing happened — but it is also
|
|
270
|
+
* exactly the shape a run produces when its PRD's work was ALREADY merged to
|
|
271
|
+
* main before the run dispatched (2026-09-06: 1133-reaper-must-verify-
|
|
272
|
+
* integration-before-completed and 1134-land-stranded-sm-job-branches, both
|
|
273
|
+
* false-negatived this way and then auto-fix-minted a redundant `-fix-`
|
|
274
|
+
* child against a codebase where the change was already present).
|
|
275
|
+
*
|
|
276
|
+
* Takes the already-git-queried list of commit SHAs (newest first, caller's
|
|
277
|
+
* job — see scheduler.cjs's findSatisfyingCommitOnMain) that are reachable
|
|
278
|
+
* from `main`, newer than the job's `queuedAt`, and touch the PRD's own
|
|
279
|
+
* declared paths. Returns the winning verdict naming the satisfying sha, or
|
|
280
|
+
* `null` when there is no such commit — the caller must then leave the
|
|
281
|
+
* existing 'finish protocol incomplete' → needs_review verdict untouched.
|
|
282
|
+
* This function never widens what counts as evidence; it only decides what
|
|
283
|
+
* to do once the caller's git query has already proven a satisfying commit
|
|
284
|
+
* exists, so it can never turn a genuine no-op into a false 'completed'.
|
|
285
|
+
*/
|
|
286
|
+
function isAlreadySatisfiedOnMain(commits) {
|
|
287
|
+
if (!Array.isArray(commits) || commits.length === 0) return null;
|
|
288
|
+
const sha = commits[0];
|
|
289
|
+
return {
|
|
290
|
+
sha,
|
|
291
|
+
verdict: 'already_satisfied_on_main',
|
|
292
|
+
reason: `already satisfied by ${sha} — a commit on main newer than this run's queuedAt already touches this PRD's declared paths`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* resolveCommitGuardOutcome(guardVerdict, satisfyingCommits) → verdict | null
|
|
298
|
+
*
|
|
299
|
+
* The full finalize-time decision this PRD adds: takes commitGuardVerdict's
|
|
300
|
+
* own output (scheduler.cjs) plus the already-git-queried satisfying-commit
|
|
301
|
+
* list (scheduler.cjs's findSatisfyingCommitOnMain) and decides which verdict
|
|
302
|
+
* actually wins. Only ever touches the 'silent_no_op' shape — a guardVerdict
|
|
303
|
+
* of null (no violation) or 'uncommitted_changes' (real dirt left behind)
|
|
304
|
+
* passes through completely untouched, so this can never weaken the
|
|
305
|
+
* uncommitted-changes guarantee PRD 1133 introduced. Pure — no I/O, no git —
|
|
306
|
+
* so the whole finalize decision is directly unit-testable without spawning
|
|
307
|
+
* a real job.
|
|
308
|
+
*/
|
|
309
|
+
function resolveCommitGuardOutcome(guardVerdict, satisfyingCommits) {
|
|
310
|
+
if (!guardVerdict || guardVerdict.verdict !== 'silent_no_op') return guardVerdict ?? null;
|
|
311
|
+
const satisfied = isAlreadySatisfiedOnMain(satisfyingCommits);
|
|
312
|
+
if (!satisfied) return guardVerdict;
|
|
313
|
+
return { verdict: satisfied.verdict, reason: satisfied.reason, satisfyingSha: satisfied.sha };
|
|
159
314
|
}
|
|
160
315
|
|
|
161
|
-
module.exports = {
|
|
316
|
+
module.exports = {
|
|
317
|
+
claudePidAlive,
|
|
318
|
+
classifyRunOutcome,
|
|
319
|
+
mapOutcomeToGateOutcome,
|
|
320
|
+
ORPHAN_REQUEUE_CAP,
|
|
321
|
+
selectReapableJobs,
|
|
322
|
+
findLiveProcessForJob,
|
|
323
|
+
logHasOutput,
|
|
324
|
+
resolvePidlessGateOutcome,
|
|
325
|
+
isAlreadySatisfiedOnMain,
|
|
326
|
+
resolveCommitGuardOutcome,
|
|
327
|
+
};
|
|
@@ -41,13 +41,26 @@ const STATUS_HISTORY_CAP = 20;
|
|
|
41
41
|
* Explicit from->to edges. Every real assignment site in scheduler.cjs maps
|
|
42
42
|
* onto one of these (verified against the 16 sites this module replaces):
|
|
43
43
|
* - pending->running (dispatch), pending->completed (archived-PRD skip,
|
|
44
|
-
* manual archive of an already-shipped PRD
|
|
45
|
-
*
|
|
44
|
+
* manual archive of an already-shipped PRD; also source
|
|
45
|
+
* 'spawnJob:dispatch-sidecar-reconcile' — a pending row about to
|
|
46
|
+
* dispatch whose newest run-sidecar already shows a completed-equivalent
|
|
47
|
+
* outcome finished at/after this row's own last pending transition is
|
|
48
|
+
* finalized from that sidecar instead of spawning a redundant re-run;
|
|
49
|
+
* 2026-09-06 incident: a silently-dropped finalize left a slug 'pending'
|
|
50
|
+
* forever and the dispatcher re-fired it three times), pending->failed
|
|
51
|
+
* (admin cancelJob on a not-yet-started job)
|
|
46
52
|
* - running->completed|failed|needs_review (normal run outcomes, reaper),
|
|
47
53
|
* running->skipped (spawnJob:skip-archived's 'prd-missing' case — no
|
|
48
54
|
* executor ever ran; kept distinct from 'completed' so unrun work can't
|
|
49
55
|
* read as shipped), running->pending (halt/rate-limit reset,
|
|
50
|
-
* transient-failure retry)
|
|
56
|
+
* transient-failure retry). Also reached with verdict
|
|
57
|
+
* 'reaped_without_integration' (source 'reapDeadRunningJobs', PRD 1133): a
|
|
58
|
+
* reaped job whose result event looked successful but whose work cannot
|
|
59
|
+
* be shown to have landed — a worktree branch still holding unmerged
|
|
60
|
+
* commits, or an in-place run whose HEAD never advanced during the run
|
|
61
|
+
* window — is parked here instead of 'completed', naming the branch (or
|
|
62
|
+
* the lack of any commit) so the stranded work is never silently treated
|
|
63
|
+
* as shipped.
|
|
51
64
|
* - investigating->failed|needs_review (restore prior status once the
|
|
52
65
|
* investigation probe exits), investigating->completed (defensive: the
|
|
53
66
|
* restored prior status could in principle be 'completed' if a caller
|
|
@@ -61,7 +74,17 @@ const STATUS_HISTORY_CAP = 20;
|
|
|
61
74
|
* declared paths is surfaced to a human as needs_review, never silently
|
|
62
75
|
* auto-completed)
|
|
63
76
|
* - needs_review->investigating, needs_review->pending, needs_review->completed
|
|
64
|
-
* (heal on reverify, or auto-promote) — same shape as `failed
|
|
77
|
+
* (heal on reverify, or auto-promote) — same shape as `failed`. Also
|
|
78
|
+
* reached by source 'scheduler:mechanicalRecovery' (PRD 1130): a job
|
|
79
|
+
* parked with a mechanically-resolvable verdict (starting with exactly
|
|
80
|
+
* 'worktree_integration_failed') gets one bounded, model-free re-attempt
|
|
81
|
+
* of its worktree branch integration; success lands here exactly like any
|
|
82
|
+
* other completion.
|
|
83
|
+
* - needs_review->running (resume-first recovery, PRD 1111: a job parked
|
|
84
|
+
* needs_review with verdict 'uncommitted_changes' gets one bounded
|
|
85
|
+
* `claude -p --resume` dispatch through spawnJob before any fix-plan
|
|
86
|
+
* investigation is authored — spawnJob's own dispatch mutate transitions
|
|
87
|
+
* straight to 'running', same as any pending job)
|
|
65
88
|
* - completed->pending (force-only reset, gated separately by
|
|
66
89
|
* resetJobFields' own guard — this table only says the edge is
|
|
67
90
|
* structurally legal, not that every caller may take it unconditionally)
|
|
@@ -85,7 +108,7 @@ const LEGAL_TRANSITIONS = {
|
|
|
85
108
|
running: ['completed', 'failed', 'needs_review', 'skipped', 'pending'],
|
|
86
109
|
investigating: ['failed', 'needs_review', 'completed', 'pending', 'skipped'],
|
|
87
110
|
failed: ['investigating', 'pending', 'completed', 'needs_review'],
|
|
88
|
-
needs_review: ['investigating', 'pending', 'completed', 'skipped'],
|
|
111
|
+
needs_review: ['investigating', 'pending', 'completed', 'skipped', 'running'],
|
|
89
112
|
completed: ['pending'],
|
|
90
113
|
skipped: ['pending'],
|
|
91
114
|
quarantined: ['pending', 'skipped'],
|
|
@@ -15,37 +15,98 @@
|
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
const os = require('node:os');
|
|
17
17
|
const { projectJobCap, quietMachineWaitMs } = require('./schedulerConfig.cjs');
|
|
18
|
+
const { bareSlug } = require('./depSlugResolve.cjs');
|
|
18
19
|
|
|
19
20
|
const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Sentinel passed as `satisfiedSlugs` when the caller's history/archive
|
|
24
|
+
* lookup itself failed this tick (PRD 1122's fail-open-on-read-failure
|
|
25
|
+
* clause) — every dep with no live row is treated as satisfied, exactly
|
|
26
|
+
* today's pre-1122 behaviour, rather than holding the whole queue hostage to
|
|
27
|
+
* a transient fs error. Never returned BY this module — only ever supplied
|
|
28
|
+
* by a caller (scheduler.cjs's computeDepHistorySatisfaction) that caught an
|
|
29
|
+
* I/O error building the real Set.
|
|
30
|
+
*/
|
|
31
|
+
const DEP_HISTORY_FAIL_OPEN = Symbol('dep-history-fail-open');
|
|
32
|
+
|
|
21
33
|
/**
|
|
22
34
|
* The dep slug (if any) blocking `job` from running, per PRD 832's
|
|
23
|
-
* dependsOn semantics
|
|
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
|
};
|