claude-code-session-manager 0.75.3 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
- package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.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/lib/activeSessions.cjs +116 -6
- 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 +269 -96
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +247 -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 +58 -3
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -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 +207 -1
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -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/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +25 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +64 -5
- package/src/main/ipcSchemas.cjs +69 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
- 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__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -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 +133 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -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__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +69 -2
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +514 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +520 -21
- package/src/main/lib/jobWorktree.cjs +2 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +370 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +106 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +103 -15
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +40 -7
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +54 -4
- package/src/main/lib/reaperHelpers.cjs +64 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +301 -55
- package/src/main/lib/schedulerConfig.cjs +99 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +162 -3
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1701 -130
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/seedSchedulerMcp.cjs +58 -4
- 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 +187 -3
- package/src/preload/index.cjs +9 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
- package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
- package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* projectRootResolve.cjs — server-side project/Epic resolution for every
|
|
5
|
+
* cwd-accepting ops-root entry point (PRD: worktree-cwd Epic-lookup hazard).
|
|
6
|
+
*
|
|
7
|
+
* Problem this closes. An agent running inside an Epic's git worktree has
|
|
8
|
+
* `pwd` = `/tmp/session-manager-epic-worktrees/<hash>/<epicId>` and naturally
|
|
9
|
+
* passes that as `cwd` to `scheduler_create_prd` / `feedback_open_session`.
|
|
10
|
+
* `active-index.json` (the Epic registry) is git-tracked, so the worktree's
|
|
11
|
+
* copy is a snapshot frozen at branch time — it usually does NOT contain the
|
|
12
|
+
* very Epic the agent is running inside. Verified live 2026-09-01 on
|
|
13
|
+
* starry-night-ships: main's index had 5 rows including the running Epic; the
|
|
14
|
+
* worktree's copy had 1 row and did not contain it, so a naive per-cwd lookup
|
|
15
|
+
* returns null and the write is refused with "create/approve an Epic first" —
|
|
16
|
+
* reading to the human as "the Epic got lost", when it never did.
|
|
17
|
+
*
|
|
18
|
+
* `resolveProjectContext` fixes this at the API boundary, not by trying to
|
|
19
|
+
* keep every cwd-accepting caller worktree-aware individually:
|
|
20
|
+
* 1. Normalizes any supplied cwd through `activeSessions.projectRootOf`
|
|
21
|
+
* (worktree -> main tree, ops-internal path -> project root).
|
|
22
|
+
* 2. When an `originClaudeSessionId` (or explicit `epicId`) is known,
|
|
23
|
+
* searches for the owning Epic first in the normalized cwd's own
|
|
24
|
+
* active-index, then across every project this machine has ever opened
|
|
25
|
+
* (`activeSessions.allProjectCwds`) — because the Epic's OWN project may
|
|
26
|
+
* not even be the cwd the agent guessed (e.g. a worktree whose gitdir
|
|
27
|
+
* pointer is stale/unresolvable, or a cross-project mixup).
|
|
28
|
+
* 3. A resolved Epic's own project cwd (`epicCwd`) always wins over the
|
|
29
|
+
* supplied/hinted cwd when they differ — the Epic is the ground truth.
|
|
30
|
+
*
|
|
31
|
+
* Pure, synchronous, fs-only — no Electron deps — so it can be required from
|
|
32
|
+
* the standalone scheduler-mcp-server.cjs process and from every ops-root
|
|
33
|
+
* path helper a sibling PRD (ops-root-single-resolver) folds in later.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const { projectRootOf, allProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
|
|
38
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
39
|
+
const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* resolveProjectContext({ cwd, originClaudeSessionId, epicId, originProjectRoot }, deps?)
|
|
43
|
+
* → { cwd, epicId, epicCwd, source }
|
|
44
|
+
*
|
|
45
|
+
* `cwd` — the caller-supplied cwd (may be inside a worktree/ops-internal path,
|
|
46
|
+
* or absent entirely when the caller is relying purely on Epic resolution).
|
|
47
|
+
* `originClaudeSessionId` — the calling session's own claude session id
|
|
48
|
+
* (chatRunner.cjs's SM_CHAT_SESSION_ID / pty.cjs's tabId) — matched against
|
|
49
|
+
* each Epic's `claudeSessionId`.
|
|
50
|
+
* `epicId` — an explicit already-known Epic id (e.g. a caller-supplied
|
|
51
|
+
* `sourcePromptId`) — matched against each Epic's `id`. Either identifier
|
|
52
|
+
* (or both) may be supplied; a match on either counts.
|
|
53
|
+
* `originProjectRoot` — a trusted hint (e.g. SM_PROJECT_ROOT env, stamped by
|
|
54
|
+
* the process that actually knows the real project root) — normalized the
|
|
55
|
+
* same way as `cwd` and preferred over it when both are given, but still
|
|
56
|
+
* loses to a resolved Epic's own `epicCwd`.
|
|
57
|
+
*
|
|
58
|
+
* `source` records which path produced the returned `cwd`/`epicId`:
|
|
59
|
+
* 'cwd' — no Epic identifier given, or none resolved; cwd is
|
|
60
|
+
* just the normalized supplied/hinted value.
|
|
61
|
+
* 'epic-index' — the Epic was found in the (single) normalized
|
|
62
|
+
* base cwd's own active-index.
|
|
63
|
+
* 'cross-project-scan' — the Epic was found only by scanning other known
|
|
64
|
+
* projects (or a duplicate id required tie-breaking).
|
|
65
|
+
*
|
|
66
|
+
* Never throws: an unresolvable cwd/hint/Epic id just yields a smaller
|
|
67
|
+
* result, never an error — refusal is the caller's decision, not this
|
|
68
|
+
* resolver's.
|
|
69
|
+
*/
|
|
70
|
+
function resolveProjectContext({ cwd, originClaudeSessionId, epicId: wantedEpicId, originProjectRoot } = {}, deps = {}) {
|
|
71
|
+
const projectRootOfFn = deps.projectRootOf || projectRootOf;
|
|
72
|
+
const allProjectCwdsFn = deps.allProjectCwds || allProjectCwds;
|
|
73
|
+
const readActiveIndexFn = deps.readActiveIndex || readActiveIndex;
|
|
74
|
+
const isEphemeralCwdFn = deps.isEphemeralCwd || isEphemeralCwd;
|
|
75
|
+
const warn = deps.warn || console.warn;
|
|
76
|
+
|
|
77
|
+
let baseCwd = null;
|
|
78
|
+
if (cwd && typeof cwd === 'string') {
|
|
79
|
+
baseCwd = projectRootOfFn(path.resolve(cwd));
|
|
80
|
+
}
|
|
81
|
+
if (originProjectRoot && typeof originProjectRoot === 'string') {
|
|
82
|
+
const hintRoot = projectRootOfFn(path.resolve(originProjectRoot));
|
|
83
|
+
// A trusted hint outranks the agent-supplied cwd, but a resolved Epic
|
|
84
|
+
// (below) still outranks both.
|
|
85
|
+
if (hintRoot) baseCwd = hintRoot;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = { cwd: baseCwd, epicId: null, epicCwd: null, source: 'cwd' };
|
|
89
|
+
|
|
90
|
+
if (!originClaudeSessionId && !wantedEpicId) return result;
|
|
91
|
+
|
|
92
|
+
const candidates = [];
|
|
93
|
+
if (baseCwd) candidates.push(baseCwd);
|
|
94
|
+
for (const p of allProjectCwdsFn()) {
|
|
95
|
+
if (!candidates.includes(p)) candidates.push(p);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const matches = [];
|
|
99
|
+
for (const projectCwd of candidates) {
|
|
100
|
+
const { sessions } = readActiveIndexFn(projectCwd);
|
|
101
|
+
for (const session of Object.values(sessions)) {
|
|
102
|
+
if (!session) continue;
|
|
103
|
+
const idHit = wantedEpicId && session.id === wantedEpicId;
|
|
104
|
+
const claudeHit = originClaudeSessionId && session.claudeSessionId === originClaudeSessionId;
|
|
105
|
+
if (idHit || claudeHit) {
|
|
106
|
+
matches.push({ projectCwd, epicId: session.id, status: session.status ?? null });
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (matches.length === 0) return result;
|
|
113
|
+
|
|
114
|
+
let chosen = matches[0];
|
|
115
|
+
if (matches.length > 1) {
|
|
116
|
+
const score = (m) => (isEphemeralCwdFn(m.projectCwd) ? 0 : 2) + (m.status === 'active' ? 1 : 0);
|
|
117
|
+
chosen = matches.reduce((best, m) => (score(m) > score(best) ? m : best), matches[0]);
|
|
118
|
+
warn(
|
|
119
|
+
`[projectRootResolve] Epic id ${chosen.epicId} matched in multiple projects: `
|
|
120
|
+
+ `${matches.map((m) => m.projectCwd).join(', ')} — choosing ${chosen.projectCwd} `
|
|
121
|
+
+ '(prefers a non-worktree/tmp project with status=active)',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
result.epicId = chosen.epicId;
|
|
126
|
+
result.epicCwd = chosen.projectCwd;
|
|
127
|
+
result.cwd = chosen.projectCwd;
|
|
128
|
+
result.source = matches.length > 1
|
|
129
|
+
? 'cross-project-scan'
|
|
130
|
+
: (chosen.projectCwd === baseCwd ? 'epic-index' : 'cross-project-scan');
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { resolveProjectContext };
|
|
@@ -79,6 +79,13 @@ const PromptSessionSchema = z.object({
|
|
|
79
79
|
// Passthrough only as of PRD 1032 — nothing populates this yet; the next
|
|
80
80
|
// PRD in this chain wires Epic worktree minting.
|
|
81
81
|
worktree: EpicWorktreeSchema.optional(),
|
|
82
|
+
// Status-mirror fields (epicStatusMirror.cjs) written onto prompt-sessions/
|
|
83
|
+
// <id>.json alongside the active-index.json row — never onto the index row
|
|
84
|
+
// itself, so a plain PromptSession read from active-index.json normally
|
|
85
|
+
// omits them. Optional here only so activeIndexRebuild.cjs can validate a
|
|
86
|
+
// mirrored file's reconstructed row against this same schema.
|
|
87
|
+
archivedAt: z.string().nullable().optional(),
|
|
88
|
+
indexedAt: z.string().optional(),
|
|
82
89
|
});
|
|
83
90
|
|
|
84
91
|
/**
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* queueHealth.cjs — pure per-project rollup for the periodic queue-health
|
|
5
|
+
* sweep (PRD 1109). Kept separate from scheduler.cjs so it can be unit
|
|
6
|
+
* tested without importing electron/ipcMain, matching reaperHelpers.cjs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Non-terminal statuses that mean a row is parked waiting on a human rather
|
|
10
|
+
// than actively progressing (pending/running are expected to move on their
|
|
11
|
+
// own; investigating is mid-flight and already tracked by
|
|
12
|
+
// findStrandedInvestigations).
|
|
13
|
+
const STUCK_STATUSES = new Set(['needs_review', 'quarantined']);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* computeQueueHealth(jobs) → [{ cwd, neverRan, looksDone, stuck }]
|
|
17
|
+
*
|
|
18
|
+
* Read-only: never mutates a row. Counts, per project cwd, how many rows
|
|
19
|
+
* carry `gateOutcome === 'never_ran'`, how many carry a `looksDone`
|
|
20
|
+
* annotation, and how many sit in a stuck (parked, non-terminal) status.
|
|
21
|
+
* Only projects with a non-zero total are returned — the sweep logs one
|
|
22
|
+
* line per non-zero project, nothing for a clean queue.
|
|
23
|
+
*/
|
|
24
|
+
function computeQueueHealth(jobs) {
|
|
25
|
+
const byCwd = new Map();
|
|
26
|
+
for (const j of Array.isArray(jobs) ? jobs : []) {
|
|
27
|
+
if (!j) continue;
|
|
28
|
+
const cwd = j.cwd || '(unknown)';
|
|
29
|
+
if (!byCwd.has(cwd)) byCwd.set(cwd, { cwd, neverRan: 0, looksDone: 0, stuck: 0 });
|
|
30
|
+
const bucket = byCwd.get(cwd);
|
|
31
|
+
if (j.gateOutcome === 'never_ran') bucket.neverRan += 1;
|
|
32
|
+
if (j.looksDone) bucket.looksDone += 1;
|
|
33
|
+
if (STUCK_STATUSES.has(j.status)) bucket.stuck += 1;
|
|
34
|
+
}
|
|
35
|
+
return [...byCwd.values()].filter((b) => b.neverRan + b.looksDone + b.stuck > 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { computeQueueHealth, STUCK_STATUSES };
|
|
@@ -33,13 +33,13 @@ const fsp = require('node:fs/promises');
|
|
|
33
33
|
const path = require('node:path');
|
|
34
34
|
const os = require('node:os');
|
|
35
35
|
const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
|
|
36
|
-
const { assertOpsWrite } = require('./opsOwnership.cjs');
|
|
36
|
+
const { assertOpsWrite, resolveOpsRoot, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
|
|
37
37
|
const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
|
|
38
38
|
|
|
39
39
|
const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
|
|
40
40
|
const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
|
|
41
|
-
const OPS_DIRNAME =
|
|
42
|
-
const STATE_SUBPATH = [
|
|
41
|
+
const OPS_DIRNAME = OPS_ROOT_DIR;
|
|
42
|
+
const STATE_SUBPATH = ['scheduler', 'state'];
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* A project's `session-manager-operations/scheduler/state/` directory.
|
|
@@ -75,7 +75,29 @@ function projectStateDir(cwd) {
|
|
|
75
75
|
`projectStateDir: cwd must be a project root, not a path inside ${OPS_DIRNAME}/, got "${cwd}"`,
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
// ...and it must not be an EPHEMERAL cwd (inside os.tmpdir(), or a linked
|
|
79
|
+
// git worktree root) — a worktree is torn down when its Epic/job ends, so
|
|
80
|
+
// any state written there is silently destroyed (verified live 2026-09-01
|
|
81
|
+
// as a scheduler shard rewritten every reconcile pass inside a since-deleted
|
|
82
|
+
// epic worktree). That check, and the worktree → main-tree normalization,
|
|
83
|
+
// live in opsOwnership.resolveOpsRoot (PRD 1082) — the one resolver every
|
|
84
|
+
// ops-root reader/writer goes through. `opsInternal: 'refuse'` keeps this
|
|
85
|
+
// WRITER fail-closed on an ops-internal cwd (the guard above shapes the
|
|
86
|
+
// message; the resolver is the backstop).
|
|
87
|
+
let opsRoot;
|
|
88
|
+
try {
|
|
89
|
+
opsRoot = resolveOpsRoot(cwd, { opsInternal: 'refuse' });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (e?.ephemeral) {
|
|
92
|
+
const err = new Error(
|
|
93
|
+
`projectStateDir: refusing ephemeral cwd (tmpdir or linked git worktree), got "${cwd}"`,
|
|
94
|
+
);
|
|
95
|
+
err.ephemeral = true;
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
return path.join(opsRoot, ...STATE_SUBPATH);
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
function projectQueuePath(cwd) {
|
|
@@ -97,7 +119,11 @@ function queuePathOrSkip(cwd, context) {
|
|
|
97
119
|
try {
|
|
98
120
|
return projectQueuePath(cwd);
|
|
99
121
|
} catch (e) {
|
|
100
|
-
|
|
122
|
+
if (e?.ephemeral) {
|
|
123
|
+
console.warn(`[queueStore] ${context}: refusing ephemeral cwd "${cwd}" — ${e.message}`);
|
|
124
|
+
} else {
|
|
125
|
+
console.error(`[queueStore] ${context}: skipping invalid project cwd — ${e?.message}`);
|
|
126
|
+
}
|
|
101
127
|
return null;
|
|
102
128
|
}
|
|
103
129
|
}
|
|
@@ -157,6 +183,11 @@ function shapeMachine(raw) {
|
|
|
157
183
|
scheduledFor: data.scheduledFor ?? null,
|
|
158
184
|
lastRunAt: data.lastRunAt ?? null,
|
|
159
185
|
paused: data.paused ?? null,
|
|
186
|
+
// Launch circuit breaker (lib/launchFailure.cjs): per-persona blocks and
|
|
187
|
+
// the degraded-mode env a persona is currently launching with. Machine
|
|
188
|
+
// state, not per-project: the broken thing is the installed CLI.
|
|
189
|
+
launchBlocks: data.launchBlocks && typeof data.launchBlocks === 'object' ? data.launchBlocks : {},
|
|
190
|
+
launchMitigations: data.launchMitigations && typeof data.launchMitigations === 'object' ? data.launchMitigations : {},
|
|
160
191
|
};
|
|
161
192
|
}
|
|
162
193
|
|
|
@@ -203,7 +234,7 @@ function shapeJobs(raw, file) {
|
|
|
203
234
|
* consulted so writeSplit can persist "this project now has zero jobs".
|
|
204
235
|
*/
|
|
205
236
|
function readMergedSync(opts) {
|
|
206
|
-
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, invalidJobs: [] };
|
|
237
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
|
|
207
238
|
const sourceCwds = [];
|
|
208
239
|
try {
|
|
209
240
|
Object.assign(out, shapeMachine(fs.readFileSync(MACHINE_STATE_PATH, 'utf8')));
|
|
@@ -233,7 +264,7 @@ function readMergedSync(opts) {
|
|
|
233
264
|
|
|
234
265
|
/** Async twin of readMergedSync for IPC hot paths. */
|
|
235
266
|
async function readMerged(opts) {
|
|
236
|
-
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, invalidJobs: [] };
|
|
267
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
|
|
237
268
|
const sourceCwds = [];
|
|
238
269
|
try {
|
|
239
270
|
Object.assign(out, shapeMachine(await fsp.readFile(MACHINE_STATE_PATH, 'utf8')));
|
|
@@ -283,6 +314,8 @@ async function writeSplit(state, defaultCwd) {
|
|
|
283
314
|
scheduledFor: state.scheduledFor ?? null,
|
|
284
315
|
lastRunAt: state.lastRunAt ?? null,
|
|
285
316
|
paused: state.paused ?? null,
|
|
317
|
+
launchBlocks: state.launchBlocks ?? {},
|
|
318
|
+
launchMitigations: state.launchMitigations ?? {},
|
|
286
319
|
});
|
|
287
320
|
|
|
288
321
|
const byCwd = new Map();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quietMachineLease.cjs — the exclusive, machine-wide dispatch lock a
|
|
3
|
+
* `quietMachine: true` PRD holds for the duration of its run (PRD 1107).
|
|
4
|
+
*
|
|
5
|
+
* Process-local, like sessionSlots.cjs's holder map — reset on restart by
|
|
6
|
+
* design; a crashed lease must never wedge the queue permanently. This is
|
|
7
|
+
* NOT a second concurrency pool: sessionSlots.cjs remains the sole slot
|
|
8
|
+
* pool. This module only answers "is a quiet-machine job currently running,
|
|
9
|
+
* and if so, hold every other dispatch until it releases?" — schedulerBatch
|
|
10
|
+
* .cjs's pickNextBatch reads isHeld() to gate the WHOLE tick, not just one
|
|
11
|
+
* project.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
// slug of the job currently holding the lease, or null.
|
|
16
|
+
let heldBySlug = null;
|
|
17
|
+
|
|
18
|
+
function isHeld() {
|
|
19
|
+
return heldBySlug !== null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** acquire(slug) → true if the lease was free and is now held by `slug`. */
|
|
23
|
+
function acquire(slug) {
|
|
24
|
+
if (heldBySlug !== null) return false;
|
|
25
|
+
heldBySlug = String(slug);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** release(slug) — idempotent; releasing when not held, or held by a
|
|
30
|
+
* different slug, is a no-op (mirrors sessionSlots.release's tolerance for
|
|
31
|
+
* an already-released token). */
|
|
32
|
+
function release(slug) {
|
|
33
|
+
if (heldBySlug === null) return false;
|
|
34
|
+
if (heldBySlug !== String(slug)) return false;
|
|
35
|
+
heldBySlug = null;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function holder() {
|
|
40
|
+
return heldBySlug;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Test hook: force the lease back to free. */
|
|
44
|
+
function __resetForTests() {
|
|
45
|
+
heldBySlug = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { isHeld, acquire, release, holder, __resetForTests };
|
|
@@ -47,6 +47,7 @@ const VERDICT_LABELS = {
|
|
|
47
47
|
verify_unavailable: 'verify unavailable',
|
|
48
48
|
uncommitted_changes: 'uncommitted changes',
|
|
49
49
|
no_verdict_sentinel: 'no commit or verdict sentinel',
|
|
50
|
+
abandoned_background_task: 'abandoned auto-backgrounded task',
|
|
50
51
|
pass_no_commit: 'PASS sentinel but no commit landed',
|
|
51
52
|
pass_no_commit_already_shipped: 'PASS with no commit — deliverables already shipped',
|
|
52
53
|
pass_no_commit_prior_run_verified: 'PASS with no commit — prior run of this slug already landed the work',
|
|
@@ -67,14 +68,47 @@ const FAILURE_CLASSES = {
|
|
|
67
68
|
NO_SENTINEL: 'no-sentinel',
|
|
68
69
|
UNCOMMITTED: 'uncommitted-changes',
|
|
69
70
|
TRANSCRIPT_ERRORS: 'transcript-errors',
|
|
71
|
+
ABANDONED_BACKGROUND_TASK: 'abandoned-background-task',
|
|
70
72
|
UNKNOWN: 'unknown',
|
|
71
73
|
};
|
|
72
74
|
|
|
75
|
+
// ─── Recovery actions — one per failure class, machine-readable ────────────
|
|
76
|
+
// Closed set the scheduler routes on: 'archive' (stale re-run, do not
|
|
77
|
+
// re-queue), 'resume-and-commit' (PRD 1111's --resume dispatch owns this),
|
|
78
|
+
// 'verify-and-close' (the work likely landed, just missing its sentinel),
|
|
79
|
+
// 'investigate' (the only class that still buys a fix-plan investigation).
|
|
80
|
+
const RECOVERY_ACTIONS = {
|
|
81
|
+
[FAILURE_CLASSES.ALREADY_SHIPPED]: 'archive',
|
|
82
|
+
[FAILURE_CLASSES.SELF_QUEUE]: 'investigate',
|
|
83
|
+
[FAILURE_CLASSES.STUCK_LOOP]: 'investigate',
|
|
84
|
+
[FAILURE_CLASSES.POST_AC_OVERRUN]: 'investigate',
|
|
85
|
+
[FAILURE_CLASSES.NO_SENTINEL]: 'verify-and-close',
|
|
86
|
+
[FAILURE_CLASSES.UNCOMMITTED]: 'resume-and-commit',
|
|
87
|
+
[FAILURE_CLASSES.TRANSCRIPT_ERRORS]: 'investigate',
|
|
88
|
+
// NOT 'resume-and-commit': that action is only ever actually dispatched by
|
|
89
|
+
// selectResumeRecoveryTarget in scheduler.cjs, which gates strictly on
|
|
90
|
+
// verifierVerdict === 'uncommitted_changes' plus a sessionId + recorded
|
|
91
|
+
// uncommittedPaths — none of which an abandoned_background_task job has.
|
|
92
|
+
// Labeling it 'resume-and-commit' here would be pure telemetry fiction (the
|
|
93
|
+
// RCA report / job.rcaRecoveryAction would claim a resume attempt that
|
|
94
|
+
// never happens); 'investigate' is what actually runs (spawnInvestigation),
|
|
95
|
+
// and buildInvestigationPrompt's abandoned-background-task note is what
|
|
96
|
+
// carries the "check for salvaged work, commit before re-implementing"
|
|
97
|
+
// guidance into that cold-read investigation.
|
|
98
|
+
[FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]: 'investigate',
|
|
99
|
+
[FAILURE_CLASSES.UNKNOWN]: 'investigate',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Pure — returns the closed-set recovery action for a failure class, defaulting to 'investigate'. */
|
|
103
|
+
function recoveryActionFor(failureClass) {
|
|
104
|
+
return RECOVERY_ACTIONS[failureClass] ?? 'investigate';
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
const PREVENTION_HINTS = {
|
|
74
108
|
[FAILURE_CLASSES.ALREADY_SHIPPED]:
|
|
75
109
|
"This run found its acceptance criteria already satisfied by a prior commit and correctly made no change, so no commit landed and the verifier returned `pass_no_commit`. This is a stale re-run, not an execution failure — the PRD's `.md` was never moved out of `session-manager-operations/scheduler/prds/` after the work shipped. Archive the PRD into `session-manager-operations/scheduler/prds-archived/` instead of re-queuing or re-running it.",
|
|
76
110
|
[FAILURE_CLASSES.SELF_QUEUE]:
|
|
77
|
-
"This run either invoked /develop or /process-feedback from inside its own headless execution, or backgrounded a long-running command and called ScheduleWakeup to check back later —
|
|
111
|
+
"This run either invoked /develop or /process-feedback from inside its own headless execution, or backgrounded a long-running command (e.g. a verification/test run) and called Monitor, TaskOutput, or ScheduleWakeup to check back later — all are the 'you ARE the executor — never re-queue or self-schedule' anti-pattern. A headless PRD run must perform its own acceptance criteria directly and has no next turn to resume it (standards.md → Execution discipline).",
|
|
78
112
|
[FAILURE_CLASSES.STUCK_LOOP]:
|
|
79
113
|
'Bound every command with `timeout <N> <cmd>` — never leave an unbounded `until`/`while true`/`sleep` poll in a PRD body (PRD_AUTHORING.md loop-hang guidance).',
|
|
80
114
|
[FAILURE_CLASSES.POST_AC_OVERRUN]:
|
|
@@ -85,6 +119,8 @@ const PREVENTION_HINTS = {
|
|
|
85
119
|
'Stage the exact paths you created or modified — never a blanket/wildcard git-add of the whole tree, since the queue can run sibling jobs against this same working tree and a blanket add sweeps up their in-flight edits — and `git commit` as the final finish-protocol step before printing the verdict sentinel; never end a run with a dirty working tree.',
|
|
86
120
|
[FAILURE_CLASSES.TRANSCRIPT_ERRORS]:
|
|
87
121
|
'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
|
+
[FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]:
|
|
123
|
+
'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.',
|
|
88
124
|
[FAILURE_CLASSES.UNKNOWN]:
|
|
89
125
|
'Re-run the acceptance criteria gate locally against the failure log to pin down the specific break before re-queuing.',
|
|
90
126
|
};
|
|
@@ -139,6 +175,16 @@ function classifyFailure({ verdict, logTail }) {
|
|
|
139
175
|
return FAILURE_CLASSES.ALREADY_SHIPPED;
|
|
140
176
|
}
|
|
141
177
|
|
|
178
|
+
// Checked before SELF_QUEUE/STUCK_LOOP: the verifier already did the real
|
|
179
|
+
// detection work (scanning for the harness's own auto-background marker
|
|
180
|
+
// text) to produce this verdict — trust it rather than re-deriving from the
|
|
181
|
+
// log tail, which can otherwise false-match SELF_QUEUE_WAKEUP_RE/STUCK_LOOP_RE
|
|
182
|
+
// on words like "sleep"/"background" that appear in the harness's own
|
|
183
|
+
// auto-background tool_result.
|
|
184
|
+
if (verdict === 'abandoned_background_task') {
|
|
185
|
+
return FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK;
|
|
186
|
+
}
|
|
187
|
+
|
|
142
188
|
// Checked before STUCK_LOOP: a backgrounded command + ScheduleWakeup
|
|
143
189
|
// (variant b) can land words like "sleep"/"poll" in the tail via the PRD's
|
|
144
190
|
// own AC text, which would otherwise false-match STUCK_LOOP_RE (see PRD 771).
|
|
@@ -226,7 +272,8 @@ function readPrdBody(cwd, slug) {
|
|
|
226
272
|
*/
|
|
227
273
|
function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, investigationText }) {
|
|
228
274
|
const title = `Root cause: ${job.slug} → needs_review (${humanVerdict(verdict)})`;
|
|
229
|
-
const
|
|
275
|
+
const recoveryAction = recoveryActionFor(failureClass);
|
|
276
|
+
const fm = ['---', `title: ${title}`, 'source: scheduler-rca', 'type: rca', 'severity: normal', `prdSlug: ${job.slug}`, `runId: ${job.runId}`, `verdict: ${verdict}`, `failure-class: ${failureClass}`, `recovery-action: ${recoveryAction}`, '---'].join('\n');
|
|
230
277
|
|
|
231
278
|
const last60 = lastNLines(logTail, 60);
|
|
232
279
|
const exitCode = meta?.exitCode ?? job.exitCode ?? 'unknown';
|
|
@@ -315,8 +362,9 @@ async function writeRcaReport({ job, runDir, verdict, annotations, investigation
|
|
|
315
362
|
fs.renameSync(tmp, reportPath);
|
|
316
363
|
|
|
317
364
|
const summary = rcaSummaryLine(job, verdict);
|
|
318
|
-
|
|
319
|
-
|
|
365
|
+
const recoveryAction = recoveryActionFor(failureClass);
|
|
366
|
+
console.log(`[rca] wrote ${reportPath} (${failureClass} → ${recoveryAction})`);
|
|
367
|
+
return { filed: true, path: reportPath, summary, failureClass, recoveryAction };
|
|
320
368
|
} catch (e) {
|
|
321
369
|
console.error('[rca] error writing RCA report', e?.message ?? String(e));
|
|
322
370
|
return { filed: false, reason: 'error', error: e?.message ?? String(e) };
|
|
@@ -334,4 +382,6 @@ module.exports = {
|
|
|
334
382
|
buildRcaMarkdown,
|
|
335
383
|
FAILURE_CLASSES,
|
|
336
384
|
VERDICT_LABELS,
|
|
385
|
+
RECOVERY_ACTIONS,
|
|
386
|
+
recoveryActionFor,
|
|
337
387
|
};
|
|
@@ -64,10 +64,73 @@ function classifyRunOutcome(logPath) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Map classifyRunOutcome()'s four-way result onto the smaller, persisted
|
|
69
|
+
* `gateOutcome` a job row carries. 'no_result' really means the verdict gate
|
|
70
|
+
* never fired — the process died or was killed before it could emit one —
|
|
71
|
+
* a different fact from 'failed' (a verdict event exists and says error,
|
|
72
|
+
* i.e. the gate ran and went red). Collapsing both behind the same free-text
|
|
73
|
+
* `error` string is exactly the ambiguity this field exists to remove.
|
|
74
|
+
*/
|
|
75
|
+
function mapOutcomeToGateOutcome(outcome) {
|
|
76
|
+
switch (outcome) {
|
|
77
|
+
case 'success': return 'passed';
|
|
78
|
+
case 'failed': return 'failed';
|
|
79
|
+
case 'no_result': return 'never_ran';
|
|
80
|
+
default: return 'unknown';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
// Max times an orphaned job may be re-queued before giving up (marking failed).
|
|
68
85
|
// Single source of truth: both the in-app reaper (scheduler.cjs) and the external
|
|
69
86
|
// offline watchdog (watchdogHelpers.cjs) import this so their give-up budgets can
|
|
70
87
|
// never drift apart (they increment the SAME j.orphanRetries field).
|
|
71
88
|
const ORPHAN_REQUEUE_CAP = 5;
|
|
72
89
|
|
|
73
|
-
|
|
90
|
+
/**
|
|
91
|
+
* selectReapableJobs(jobs, now, { pidAlive, grace }) → { reapable, warnings }
|
|
92
|
+
*
|
|
93
|
+
* Pure predicate (no IO — `pidAlive` is injected) deciding which 'running'
|
|
94
|
+
* rows reapDeadRunningJobs() should finalize this cycle. Two distinct dead
|
|
95
|
+
* shapes:
|
|
96
|
+
* - has a runtime.pid, but the process is gone (pidAlive(pid) === false):
|
|
97
|
+
* the existing, unchanged behaviour.
|
|
98
|
+
* - has NO runtime.pid at all: previously skipped forever ("spawn may be
|
|
99
|
+
* mid-flight; give it a cycle" with no age bound). Now reaped once
|
|
100
|
+
* `startedAt` is older than `grace` — the spawn never got far enough to
|
|
101
|
+
* record a pid and nothing pid-bound can ever catch it (see
|
|
102
|
+
* PIDLESS_SPAWN_GRACE_MS's header for why the grace window is safe).
|
|
103
|
+
*
|
|
104
|
+
* A pidless row whose `startedAt` is missing or unparseable is neither
|
|
105
|
+
* reaped nor skipped silently — age can't be proven, so it is surfaced in
|
|
106
|
+
* `warnings` instead (the caller logs it) and left alone.
|
|
107
|
+
*/
|
|
108
|
+
function selectReapableJobs(jobs, now, { pidAlive, grace } = {}) {
|
|
109
|
+
const reapable = [];
|
|
110
|
+
const warnings = [];
|
|
111
|
+
for (const j of jobs ?? []) {
|
|
112
|
+
if (j.status !== 'running') continue;
|
|
113
|
+
const pid = j.runtime?.pid;
|
|
114
|
+
if (pid) {
|
|
115
|
+
if (pidAlive(pid)) continue;
|
|
116
|
+
reapable.push({ slug: j.slug, pid, pidless: false });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const startedAt = Date.parse(j.startedAt ?? '');
|
|
120
|
+
if (Number.isNaN(startedAt)) {
|
|
121
|
+
warnings.push({ slug: j.slug, reason: 'pidless row with missing/unparseable startedAt — cannot prove age' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const ageMs = now - startedAt;
|
|
125
|
+
if (ageMs < grace) continue; // spawn may still be mid-flight
|
|
126
|
+
reapable.push({
|
|
127
|
+
slug: j.slug,
|
|
128
|
+
pid: null,
|
|
129
|
+
pidless: true,
|
|
130
|
+
reason: `reaped: no runtime.pid recorded after ${Math.round(grace / 60_000)}m — spawn never completed`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { reapable, warnings };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { claudePidAlive, classifyRunOutcome, mapOutcomeToGateOutcome, ORPHAN_REQUEUE_CAP, selectReapableJobs };
|
|
@@ -77,6 +77,11 @@ const ScheduleJobSchema = z
|
|
|
77
77
|
finishedAt: z.string().nullable().optional(),
|
|
78
78
|
exitCode: z.number().nullable().optional(),
|
|
79
79
|
error: z.string().nullable().optional(),
|
|
80
|
+
// Structured terminal-outcome label set alongside the free-text `error`
|
|
81
|
+
// (never replacing it) so a queue reader can tell "the gate ran and went
|
|
82
|
+
// red" (failed) apart from "the gate never got to run" (never_ran) —
|
|
83
|
+
// see reaperHelpers.cjs's mapOutcomeToGateOutcome (PRD 1109).
|
|
84
|
+
gateOutcome: z.enum(['passed', 'failed', 'never_ran', 'unknown']).optional(),
|
|
80
85
|
sessionId: z.string().optional(),
|
|
81
86
|
runtime: ScheduleJobRuntimeSchema.optional(),
|
|
82
87
|
verifierVerdict: z.string().optional(),
|
|
@@ -86,6 +91,32 @@ const ScheduleJobSchema = z
|
|
|
86
91
|
sourcePromptId: z.string().nullable().optional(),
|
|
87
92
|
epicId: z.string().nullable().optional(),
|
|
88
93
|
statusHistory: z.array(ScheduleJobStatusHistoryEntrySchema).optional(),
|
|
94
|
+
// Opt-in exclusive-lease PRD flag (PRD 1107) — serializes this job against
|
|
95
|
+
// every other job machine-wide for its run so a timing-sensitive gate
|
|
96
|
+
// (frame time, performance fence) measures without CPU contention. See
|
|
97
|
+
// quietMachineLease.cjs + schedulerBatch.cjs's pickNextBatch.
|
|
98
|
+
quietMachine: z.boolean().optional(),
|
|
99
|
+
// Set true when the job was dispatched anyway after waiting past
|
|
100
|
+
// quietMachineWaitMs without the machine going quiet — tells a reader the
|
|
101
|
+
// job's measurements ran under contention, not on a quiet machine.
|
|
102
|
+
quietLeaseDegraded: z.boolean().optional(),
|
|
103
|
+
// Salvaged uncommitted diff patch path (PRD 1098).
|
|
104
|
+
salvagePatch: z.string().nullable().optional(),
|
|
105
|
+
// Leftover-attribution fields: the newly-dirty paths a terminal run left
|
|
106
|
+
// uncommitted, diffed against the persisted pre-run baseline
|
|
107
|
+
// (guardBaseline/guardHeadBefore below). Set on EVERY terminal outcome
|
|
108
|
+
// that left dirt (completed/failed/needs_review alike), not just the
|
|
109
|
+
// exit=0 commit-guard path — deleted when the run left nothing. Capped
|
|
110
|
+
// at LEFTOVER_PATHS_CAP paths (leftoverCount carries the true total).
|
|
111
|
+
leftoverPaths: z.array(z.string()).optional(),
|
|
112
|
+
leftoverCount: z.number().optional(),
|
|
113
|
+
leftoverPathsTruncated: z.boolean().optional(),
|
|
114
|
+
// Pre-run working-tree snapshot, persisted at dispatch so a finalizer
|
|
115
|
+
// that never reaches spawnJob's own completion path (reapDeadRunningJobs)
|
|
116
|
+
// can still compute a truthful newly-dirty delta. Cleared once the run
|
|
117
|
+
// finalizes — never meant to outlive one run.
|
|
118
|
+
guardBaseline: z.array(z.string()).optional(),
|
|
119
|
+
guardHeadBefore: z.string().nullable().optional(),
|
|
89
120
|
})
|
|
90
121
|
.passthrough();
|
|
91
122
|
|
|
@@ -55,7 +55,11 @@ const STATUS_HISTORY_CAP = 20;
|
|
|
55
55
|
* (admin reset can target a job mid-investigation)
|
|
56
56
|
* - failed->investigating (spawn a probe), failed->pending (admin reset /
|
|
57
57
|
* transient retry), failed->completed (auto-promote a fix-plan's healed
|
|
58
|
-
* original)
|
|
58
|
+
* original), failed->needs_review (reverifyNeedsReview's looksDone pass,
|
|
59
|
+
* PRD 1102: an unverified-shaped failed row — no sentinel, no result
|
|
60
|
+
* event — with a commit landed after its run window that touches its own
|
|
61
|
+
* declared paths is surfaced to a human as needs_review, never silently
|
|
62
|
+
* auto-completed)
|
|
59
63
|
* - needs_review->investigating, needs_review->pending, needs_review->completed
|
|
60
64
|
* (heal on reverify, or auto-promote) — same shape as `failed`
|
|
61
65
|
* - completed->pending (force-only reset, gated separately by
|
|
@@ -80,7 +84,7 @@ const LEGAL_TRANSITIONS = {
|
|
|
80
84
|
pending: ['running', 'completed', 'failed', 'skipped'],
|
|
81
85
|
running: ['completed', 'failed', 'needs_review', 'skipped', 'pending'],
|
|
82
86
|
investigating: ['failed', 'needs_review', 'completed', 'pending', 'skipped'],
|
|
83
|
-
failed: ['investigating', 'pending', 'completed'],
|
|
87
|
+
failed: ['investigating', 'pending', 'completed', 'needs_review'],
|
|
84
88
|
needs_review: ['investigating', 'pending', 'completed', 'skipped'],
|
|
85
89
|
completed: ['pending'],
|
|
86
90
|
skipped: ['pending'],
|