claude-code-session-manager 0.82.0 → 0.84.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-pwlkAFb3.js → AgentLibrary-CCVIpoSz.js} +1 -1
- package/dist/assets/{DataModel-BZK9PXFD.js → DataModel-BltREYde.js} +1 -1
- package/dist/assets/{History-BVRjxJjS.js → History-BZxFkOp6.js} +2 -2
- package/dist/assets/{Hooks-CNuwHeGx.js → Hooks-Bznlfaaa.js} +1 -1
- package/dist/assets/{HostBilko-cjwNodhV.js → HostBilko-DUG5YHA_.js} +1 -1
- package/dist/assets/{Library-YPNm9W92.js → Library-Bi2Fn3w9.js} +1 -1
- package/dist/assets/{ListDetail-CY4GM1Om.js → ListDetail-4VBvXKrz.js} +1 -1
- package/dist/assets/{MarkdownEditor-BF4y2Jiz.js → MarkdownEditor-D2ft_v5j.js} +1 -1
- package/dist/assets/{McpServers-CmBdWtX_.js → McpServers-FDekKkyE.js} +1 -1
- package/dist/assets/{Memory-CvkIXNl1.js → Memory-Dkl80uj_.js} +6 -6
- package/dist/assets/{Panel-D93o-sxe.js → Panel-LurG5VfD.js} +1 -1
- package/dist/assets/{Permissions-DQipg16I.js → Permissions-D2wHBCFA.js} +1 -1
- package/dist/assets/{Plugins-B6NwzPfK.js → Plugins-CTw_wwbI.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DACVJhrB.js → ProvenanceBadge-CW6HNUv6.js} +1 -1
- package/dist/assets/{SaveBar-Cg4lbChb.js → SaveBar-alDHG6cP.js} +1 -1
- package/dist/assets/{Scheduler-Dr5ZcBLe.js → Scheduler-DxiPcaiW.js} +7 -7
- package/dist/assets/{ScopeSwitcher-C-locvy0.js → ScopeSwitcher-DzFXUKLZ.js} +1 -1
- package/dist/assets/Settings-B6H3v2am.js +3 -0
- package/dist/assets/{SkillReferenceGraph-DDzuYgSK.js → SkillReferenceGraph-B8EZalpN.js} +1 -1
- package/dist/assets/{Skills-DFvhiOAQ.js → Skills-DwuHuA08.js} +2 -2
- package/dist/assets/SystemPrompt-CMMqpGYn.js +1 -0
- package/dist/assets/{TagLibrary-DruUYaAc.js → TagLibrary-C2N5C1n9.js} +1 -1
- package/dist/assets/{TiptapBody-Dr4a--42.js → TiptapBody-btlID-dQ.js} +1 -1
- package/dist/assets/{Toggle-B_EH2TFb.js → Toggle-BGOn3DZj.js} +1 -1
- package/dist/assets/{index-DApB4DHS.js → index-QLRf0epp.js} +316 -314
- package/dist/assets/{index-CYhdtisq.css → index-mnjNDpb1.css} +1 -1
- package/dist/assets/{settingsSchema-BKa-xk8g.js → settingsSchema-DA3N2Up3.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -1
- package/scripts/hooks/guard-destructive-git.cjs +514 -0
- package/scripts/hooks/guard-inline-implementation.cjs +219 -0
- package/scripts/hooks/guard-prd-writes.cjs +200 -0
- package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
- package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
- package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +143 -0
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
- package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
- package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
- package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
- package/src/main/__tests__/telemetryClient.test.cjs +810 -0
- package/src/main/__tests__/telemetryContract.test.cjs +883 -0
- package/src/main/crashDiagnostics.cjs +29 -1
- package/src/main/health.cjs +197 -3
- package/src/main/index.cjs +65 -6
- package/src/main/ipcSchemas.cjs +19 -2
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +97 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
- package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
- package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
- package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
- package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +620 -0
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +125 -0
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +130 -0
- package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +89 -0
- package/src/main/lib/crashTelemetry.cjs +37 -0
- package/src/main/lib/delegationReadiness.cjs +119 -1
- package/src/main/lib/epicMint.cjs +2 -0
- package/src/main/lib/gitWorktree.cjs +427 -17
- package/src/main/lib/jobWorktree.cjs +2 -1
- package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
- package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
- package/src/main/lib/opsErrorLog.cjs +78 -25
- package/src/main/lib/queueStore.cjs +233 -20
- package/src/main/lib/reaperHelpers.cjs +23 -1
- package/src/main/lib/scheduleJobSchema.cjs +7 -0
- package/src/main/lib/scheduleJobTransitions.cjs +12 -0
- package/src/main/lib/telemetryBacklog.cjs +601 -0
- package/src/main/lib/telemetryBoot.cjs +71 -0
- package/src/main/lib/telemetryClient.cjs +653 -0
- package/src/main/lib/telemetryConsent.cjs +34 -0
- package/src/main/lib/telemetryCounters.cjs +45 -0
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +2 -0
- package/src/main/scheduler.cjs +481 -33
- package/src/preload/api.d.ts +84 -4
- package/src/preload/index.cjs +9 -0
- package/dist/assets/Settings-BVrAle90.js +0 -3
- package/dist/assets/SystemPrompt-8PiTyUyL.js +0 -1
|
@@ -44,31 +44,7 @@ function todayFile(cwd) {
|
|
|
44
44
|
return path.join(logsDir(cwd), `errors-${yyyy}-${mm}-${dd}.jsonl`);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
* Append one structured error line, tagged for tracing/analysis.
|
|
49
|
-
*
|
|
50
|
-
* @param {{
|
|
51
|
-
* cwd: string, // required — which project's ops root owns this line
|
|
52
|
-
* scope: string, // subsystem name, e.g. 'pty', 'chatRunner', 'voice'
|
|
53
|
-
* level?: string, // default 'error'; 'warn' also accepted
|
|
54
|
-
* tabId?: string, // claudeSessionId of the tab this error belongs to, if any
|
|
55
|
-
* epicId?: string, // Epic id, if this error happened inside an Epic-backed run
|
|
56
|
-
* tags?: string[], // extra caller-supplied tags, merged with the auto-derived ones
|
|
57
|
-
* message: string,
|
|
58
|
-
* meta?: unknown,
|
|
59
|
-
* }} entry
|
|
60
|
-
*/
|
|
61
|
-
function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
|
|
62
|
-
if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
|
|
63
|
-
if (isEphemeralCwd(cwd)) {
|
|
64
|
-
// A worktree is torn down when its Epic/job ends and os.tmpdir() is
|
|
65
|
-
// scratch space either way — never materialize logs there. See
|
|
66
|
-
// ephemeralCwd.cjs / queueStore.cjs's projectStateDir for the sibling
|
|
67
|
-
// refusal on the scheduler namespace (verified live 2026-09-01 as a
|
|
68
|
-
// recreated /tmp/session-manager-operations/logs/ tree).
|
|
69
|
-
console.warn(`[opsErrorLog] appendError: refusing ephemeral cwd "${cwd}" (scope=${scope || 'unknown'})`);
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
47
|
+
function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta }) {
|
|
72
48
|
const file = todayFile(cwd);
|
|
73
49
|
try {
|
|
74
50
|
assertOpsWrite(file, 'logs');
|
|
@@ -102,6 +78,83 @@ function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], me
|
|
|
102
78
|
const fd = fs.openSync(file, 'a', 0o600);
|
|
103
79
|
try { fs.writeSync(fd, JSON.stringify(line) + '\n'); } finally { fs.closeSync(fd); }
|
|
104
80
|
} catch { /* best-effort — a logging failure must never break the caller */ }
|
|
81
|
+
return allTags;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Mirrors every appendError() line to telemetryClient — the single tap that
|
|
86
|
+
* covers pty/chatRunner/scheduler/logs.cjs/renderer in one place, since they
|
|
87
|
+
* all already funnel through appendError. Runs AFTER the local write and is
|
|
88
|
+
* independently wrapped so a throwing telemetry stub can never prevent or
|
|
89
|
+
* corrupt it. Reported regardless of the local ephemeral-cwd refusal — an
|
|
90
|
+
* Epic/job worktree still has a REAL project behind it, just not one that
|
|
91
|
+
* should ever have ops state materialized inside the worktree itself — so
|
|
92
|
+
* `cwd` is normalized through projectRootOf() before being handed to the
|
|
93
|
+
* client (which converts it to projectHash; the raw path never leaves this
|
|
94
|
+
* process).
|
|
95
|
+
*
|
|
96
|
+
* `message` is caller-supplied free text — it can legitimately contain a
|
|
97
|
+
* fragment of a chat/tool error that itself embeds a prompt, a file path, or
|
|
98
|
+
* other project-identifying content (opsErrorLog is a general-purpose error
|
|
99
|
+
* funnel, not a curated allowlist). It is therefore NEVER put on the wire's
|
|
100
|
+
* `msg`/`name` (which only get path-level redaction upstream) — it travels
|
|
101
|
+
* under a literal `message` key inside `context`/`fields`, which
|
|
102
|
+
* telemetryClient's redactDeep unconditionally rewrites to `[redacted]`
|
|
103
|
+
* (REDACT_KEY matches the key name, not its content). `msg`/`name` carry only
|
|
104
|
+
* `scope`, a small closed vocabulary of subsystem names — structural, never
|
|
105
|
+
* free text.
|
|
106
|
+
*/
|
|
107
|
+
function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message }) {
|
|
108
|
+
try {
|
|
109
|
+
const telemetryClient = require('./telemetryClient.cjs');
|
|
110
|
+
const { projectRootOf } = require('../../../scripts/lib/activeSessions.cjs');
|
|
111
|
+
const normalizedCwd = projectRootOf(cwd) || cwd;
|
|
112
|
+
const autoTags = [
|
|
113
|
+
`scope:${scope || 'unknown'}`,
|
|
114
|
+
...(tabId ? [`tab:${tabId}`] : []),
|
|
115
|
+
...(epicId ? [`epic:${epicId}`] : []),
|
|
116
|
+
];
|
|
117
|
+
const allTags = Array.from(new Set([...autoTags, ...(tags || [])]));
|
|
118
|
+
const label = `opsError:${scope || 'unknown'}`;
|
|
119
|
+
const context = { scope: scope || 'unknown', tags: allTags, cwd: normalizedCwd, message };
|
|
120
|
+
if (level === 'warn') {
|
|
121
|
+
telemetryClient.logLine({ level: 'warn', msg: label, fields: context });
|
|
122
|
+
} else {
|
|
123
|
+
telemetryClient.reportError({ name: scope || 'opsError', msg: label, context });
|
|
124
|
+
}
|
|
125
|
+
} catch { /* telemetry must never break the caller — this is the sole error funnel */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Append one structured error line, tagged for tracing/analysis.
|
|
130
|
+
*
|
|
131
|
+
* @param {{
|
|
132
|
+
* cwd: string, // required — which project's ops root owns this line
|
|
133
|
+
* scope: string, // subsystem name, e.g. 'pty', 'chatRunner', 'voice'
|
|
134
|
+
* level?: string, // default 'error'; 'warn' also accepted
|
|
135
|
+
* tabId?: string, // claudeSessionId of the tab this error belongs to, if any
|
|
136
|
+
* epicId?: string, // Epic id, if this error happened inside an Epic-backed run
|
|
137
|
+
* tags?: string[], // extra caller-supplied tags, merged with the auto-derived ones
|
|
138
|
+
* message: string,
|
|
139
|
+
* meta?: unknown,
|
|
140
|
+
* }} entry
|
|
141
|
+
*/
|
|
142
|
+
function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
|
|
143
|
+
if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
|
|
144
|
+
|
|
145
|
+
if (isEphemeralCwd(cwd)) {
|
|
146
|
+
// A worktree is torn down when its Epic/job ends and os.tmpdir() is
|
|
147
|
+
// scratch space either way — never materialize logs there. See
|
|
148
|
+
// ephemeralCwd.cjs / queueStore.cjs's projectStateDir for the sibling
|
|
149
|
+
// refusal on the scheduler namespace (verified live 2026-09-01 as a
|
|
150
|
+
// recreated /tmp/session-manager-operations/logs/ tree). The telemetry
|
|
151
|
+
// tap below still fires — this refusal is about the LOCAL write only.
|
|
152
|
+
console.warn(`[opsErrorLog] appendError: refusing ephemeral cwd "${cwd}" (scope=${scope || 'unknown'})`);
|
|
153
|
+
} else {
|
|
154
|
+
writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message });
|
|
105
158
|
}
|
|
106
159
|
|
|
107
160
|
module.exports = { appendError, logsDir, todayFile };
|
|
@@ -32,6 +32,7 @@ const fs = require('node:fs');
|
|
|
32
32
|
const fsp = require('node:fs/promises');
|
|
33
33
|
const path = require('node:path');
|
|
34
34
|
const os = require('node:os');
|
|
35
|
+
const crypto = require('node:crypto');
|
|
35
36
|
const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
|
|
36
37
|
const { assertOpsWrite, resolveOpsRoot, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
|
|
37
38
|
const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
|
|
@@ -128,20 +129,58 @@ function queuePathOrSkip(cwd, context) {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Unique tmp path PER CALL, not per process — matches the pid-ts-rand house
|
|
134
|
+
* style seen elsewhere in this tree (e.g. `admin-api.json.tmp-1065370-
|
|
135
|
+
* 1784409837234-vbz3fd`). A pid-only tmp name lets two overlapping writes in
|
|
136
|
+
* the SAME process share one tmp path: write A (long doc) is mid-flight when
|
|
137
|
+
* write B (short doc) opens the same tmp with O_TRUNC and completes first —
|
|
138
|
+
* A's still-buffered remainder then lands past B's EOF at its own advanced fd
|
|
139
|
+
* offset, and the rename publishes the interleaved bytes (rename is atomic;
|
|
140
|
+
* the shared tmp file never was). This is the exact shape of the
|
|
141
|
+
* `scheduler-machine.json.corrupt-*` tears observed 2026-09-07/10/11: a
|
|
142
|
+
* complete valid object followed by the orphaned tail of a longer one.
|
|
143
|
+
*/
|
|
144
|
+
function uniqueTmpPath(file) {
|
|
145
|
+
return `${file}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
function writeJsonAtomicSync(file, value) {
|
|
132
149
|
assertOpsWrite(file, 'scheduler');
|
|
133
150
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
134
|
-
const tmp =
|
|
135
|
-
|
|
136
|
-
|
|
151
|
+
const tmp = uniqueTmpPath(file);
|
|
152
|
+
try {
|
|
153
|
+
const fd = fs.openSync(tmp, 'w');
|
|
154
|
+
try {
|
|
155
|
+
fs.writeSync(fd, JSON.stringify(value, null, 2));
|
|
156
|
+
fs.fsyncSync(fd);
|
|
157
|
+
} finally {
|
|
158
|
+
fs.closeSync(fd);
|
|
159
|
+
}
|
|
160
|
+
fs.renameSync(tmp, file);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
try { fs.unlinkSync(tmp); } catch { /* tmp may not exist, or rename already consumed it */ }
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
137
165
|
}
|
|
138
166
|
|
|
139
167
|
async function writeJsonAtomic(file, value) {
|
|
140
168
|
assertOpsWrite(file, 'scheduler');
|
|
141
169
|
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
142
|
-
const tmp =
|
|
143
|
-
|
|
144
|
-
|
|
170
|
+
const tmp = uniqueTmpPath(file);
|
|
171
|
+
try {
|
|
172
|
+
const handle = await fsp.open(tmp, 'w');
|
|
173
|
+
try {
|
|
174
|
+
await handle.writeFile(JSON.stringify(value, null, 2));
|
|
175
|
+
await handle.sync();
|
|
176
|
+
} finally {
|
|
177
|
+
await handle.close();
|
|
178
|
+
}
|
|
179
|
+
await fsp.rename(tmp, file);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
await fsp.unlink(tmp).catch(() => {}); // tmp may not exist, or rename already consumed it
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
145
184
|
}
|
|
146
185
|
|
|
147
186
|
// ---------- project-cwd enumeration (cached) ----------
|
|
@@ -176,8 +215,7 @@ function bustCwdCache() {
|
|
|
176
215
|
|
|
177
216
|
// ---------- merged read ----------
|
|
178
217
|
|
|
179
|
-
function shapeMachine(
|
|
180
|
-
const data = raw ? JSON.parse(raw) : {};
|
|
218
|
+
function shapeMachine(data) {
|
|
181
219
|
return {
|
|
182
220
|
config: data.config || {},
|
|
183
221
|
scheduledFor: data.scheduledFor ?? null,
|
|
@@ -191,6 +229,174 @@ function shapeMachine(raw) {
|
|
|
191
229
|
};
|
|
192
230
|
}
|
|
193
231
|
|
|
232
|
+
// ---------- torn machine-state recovery ----------
|
|
233
|
+
//
|
|
234
|
+
// scheduler-machine.json has torn 4 times (Sep 7/10/11 `.corrupt-*`, plus a
|
|
235
|
+
// `.bak-*`) from the pid-only tmp-path bug fixed above. A pre-existing tear
|
|
236
|
+
// (from before this fix, or from any other future writer bug) must not keep
|
|
237
|
+
// poisoning every read with `unreadable` — readMergedSync/readMerged recover
|
|
238
|
+
// what they can and keep the engine dispatching instead.
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* findLongestValidJsonPrefix(raw) → { value, prefixLength } for the LONGEST
|
|
242
|
+
* leading substring of `raw` that is itself a complete, valid JSON document,
|
|
243
|
+
* or null if none exists. Scans char-by-char tracking object/array nesting
|
|
244
|
+
* depth (skipping over string contents and escapes so a brace inside a
|
|
245
|
+
* string value never miscounts) and attempts JSON.parse every time depth
|
|
246
|
+
* returns to zero. This is exactly the shape of the observed corruption: a
|
|
247
|
+
* complete, valid object followed by the orphaned tail of a longer one — the
|
|
248
|
+
* tail never balances back to depth 0, so it can never win over the real
|
|
249
|
+
* prefix. O(n) in the length of raw.
|
|
250
|
+
*/
|
|
251
|
+
function findLongestValidJsonPrefix(raw) {
|
|
252
|
+
let depth = 0;
|
|
253
|
+
let inString = false;
|
|
254
|
+
let escaped = false;
|
|
255
|
+
let best = null;
|
|
256
|
+
for (let i = 0; i < raw.length; i++) {
|
|
257
|
+
const ch = raw[i];
|
|
258
|
+
if (inString) {
|
|
259
|
+
if (escaped) escaped = false;
|
|
260
|
+
else if (ch === '\\') escaped = true;
|
|
261
|
+
else if (ch === '"') inString = false;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (ch === '"') { inString = true; continue; }
|
|
265
|
+
if (ch === '{' || ch === '[') { depth++; continue; }
|
|
266
|
+
if (ch === '}' || ch === ']') {
|
|
267
|
+
depth--;
|
|
268
|
+
if (depth === 0) {
|
|
269
|
+
try {
|
|
270
|
+
best = { value: JSON.parse(raw.slice(0, i + 1)), prefixLength: i + 1 };
|
|
271
|
+
} catch { /* balanced at this boundary but not valid JSON — keep scanning */ }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return best;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Machine state (config/paused/lastRunAt/...) has no owning project cwd of
|
|
279
|
+
// its own — it's a Session-Manager runtime concern (see this file's header).
|
|
280
|
+
// opsErrorLog.appendError requires a cwd to attribute the line to; this
|
|
281
|
+
// project's own ops root is the natural home for a machine-level log entry,
|
|
282
|
+
// mirroring scheduler.cjs's own `job.cwd || DEFAULT_PROJECT_CWD` fallback for
|
|
283
|
+
// cwd-less machine errors (schedulerBatch.cjs's DEFAULT_PROJECT_CWD).
|
|
284
|
+
const MACHINE_STATE_LOG_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
|
|
285
|
+
|
|
286
|
+
// The truncated tail in the real 2026-09-11 tear contained an orphaned
|
|
287
|
+
// `resumeAt` fragment with no way to reconstruct the `paused` object it
|
|
288
|
+
// belonged to (the fragment starts mid-string, missing its own `"paused":`
|
|
289
|
+
// key and opening brace). That data is genuinely unrecoverable — but silently
|
|
290
|
+
// dropping it is what makes recovery unsafe (an active pause could vanish).
|
|
291
|
+
// This can't rebuild the lost value, so instead it makes the loss loud: any
|
|
292
|
+
// discarded tail that still mentions paused/resumeAt gets called out in the
|
|
293
|
+
// log line so a human verifies pause status instead of trusting recovery blindly.
|
|
294
|
+
const PAUSED_HINT_RE = /"paused"|resumeAt/;
|
|
295
|
+
|
|
296
|
+
function logMachineStateRecovery({ level, message, meta }) {
|
|
297
|
+
try {
|
|
298
|
+
const { appendError } = require('./opsErrorLog.cjs');
|
|
299
|
+
appendError({ cwd: MACHINE_STATE_LOG_CWD, scope: 'scheduler', level, message, meta });
|
|
300
|
+
} catch { /* durable logging must never block recovery */ }
|
|
301
|
+
console.error(`[queueStore] ${message}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* buildMachineStateRecovery(raw, parseError) → the recovered plain object
|
|
306
|
+
* (never shaped yet) plus what to log. Pure — callers persist it and log it.
|
|
307
|
+
*/
|
|
308
|
+
function buildMachineStateRecovery(raw, parseError) {
|
|
309
|
+
const prefix = findLongestValidJsonPrefix(raw);
|
|
310
|
+
if (prefix) {
|
|
311
|
+
const trailing = raw.slice(prefix.prefixLength).trim();
|
|
312
|
+
const pausedHint = Boolean(trailing) && PAUSED_HINT_RE.test(trailing);
|
|
313
|
+
return {
|
|
314
|
+
value: prefix.value,
|
|
315
|
+
mode: 'prefix',
|
|
316
|
+
level: 'warn',
|
|
317
|
+
message:
|
|
318
|
+
`scheduler-machine.json was torn (${parseError?.message}) — recovered the longest valid `
|
|
319
|
+
+ `JSON prefix (${prefix.prefixLength}/${raw.length} bytes)`
|
|
320
|
+
+ (pausedHint
|
|
321
|
+
? '; the discarded tail looks like it contained paused/resumeAt data that could not '
|
|
322
|
+
+ 'be reconstructed — verify pause status manually'
|
|
323
|
+
: ''),
|
|
324
|
+
meta: { path: MACHINE_STATE_PATH, prefixLength: prefix.prefixLength, totalLength: raw.length, pausedHint },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
// No valid JSON prefix at all: fall back to defaults (shapeMachine({}) —
|
|
328
|
+
// callers merge DEFAULT_CONFIG on top) rather than marking `unreadable`,
|
|
329
|
+
// which would halt tickQueue/runDueJobs machine-wide until a human notices.
|
|
330
|
+
return {
|
|
331
|
+
value: {},
|
|
332
|
+
mode: 'default',
|
|
333
|
+
level: 'error',
|
|
334
|
+
message:
|
|
335
|
+
`scheduler-machine.json unrecoverable (${parseError?.message}) — falling back to defaults `
|
|
336
|
+
+ 'so dispatch does not silently halt',
|
|
337
|
+
meta: { path: MACHINE_STATE_PATH, totalLength: raw.length },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function recoverTornMachineStateSync(raw, parseError) {
|
|
342
|
+
const recovery = buildMachineStateRecovery(raw, parseError);
|
|
343
|
+
logMachineStateRecovery(recovery);
|
|
344
|
+
try {
|
|
345
|
+
writeJsonAtomicSync(MACHINE_STATE_PATH, recovery.value);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
console.error(`[queueStore] failed to persist recovered machine state: ${e?.message}`);
|
|
348
|
+
}
|
|
349
|
+
return recovery;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function recoverTornMachineState(raw, parseError) {
|
|
353
|
+
const recovery = buildMachineStateRecovery(raw, parseError);
|
|
354
|
+
logMachineStateRecovery(recovery);
|
|
355
|
+
try {
|
|
356
|
+
await writeJsonAtomic(MACHINE_STATE_PATH, recovery.value);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
console.error(`[queueStore] failed to persist recovered machine state: ${e?.message}`);
|
|
359
|
+
}
|
|
360
|
+
return recovery;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* loadMachineStateSync/loadMachineState → { shaped, recovered?, recoveryMode? }
|
|
365
|
+
* or { unreadable, unreadablePath } (ENOENT is neither — first-boot empty).
|
|
366
|
+
* A parse failure recovers instead of poisoning the whole merged read.
|
|
367
|
+
*/
|
|
368
|
+
function loadMachineStateSync() {
|
|
369
|
+
let raw;
|
|
370
|
+
try {
|
|
371
|
+
raw = fs.readFileSync(MACHINE_STATE_PATH, 'utf8');
|
|
372
|
+
} catch (e) {
|
|
373
|
+
if (e?.code === 'ENOENT') return {};
|
|
374
|
+
return { unreadable: `machine state unreadable: ${e?.message}`, unreadablePath: MACHINE_STATE_PATH };
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
return { shaped: shapeMachine(JSON.parse(raw)) };
|
|
378
|
+
} catch (parseErr) {
|
|
379
|
+
const recovery = recoverTornMachineStateSync(raw, parseErr);
|
|
380
|
+
return { shaped: shapeMachine(recovery.value), recovered: true, recoveryMode: recovery.mode };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function loadMachineState() {
|
|
385
|
+
let raw;
|
|
386
|
+
try {
|
|
387
|
+
raw = await fsp.readFile(MACHINE_STATE_PATH, 'utf8');
|
|
388
|
+
} catch (e) {
|
|
389
|
+
if (e?.code === 'ENOENT') return {};
|
|
390
|
+
return { unreadable: `machine state unreadable: ${e?.message}`, unreadablePath: MACHINE_STATE_PATH };
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
return { shaped: shapeMachine(JSON.parse(raw)) };
|
|
394
|
+
} catch (parseErr) {
|
|
395
|
+
const recovery = await recoverTornMachineState(raw, parseErr);
|
|
396
|
+
return { shaped: shapeMachine(recovery.value), recovered: true, recoveryMode: recovery.mode };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
194
400
|
/**
|
|
195
401
|
* shapeJobs(raw, file) → { jobs, invalid }.
|
|
196
402
|
*
|
|
@@ -236,13 +442,16 @@ function shapeJobs(raw, file) {
|
|
|
236
442
|
function readMergedSync(opts) {
|
|
237
443
|
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
|
|
238
444
|
const sourceCwds = [];
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (
|
|
243
|
-
out.
|
|
244
|
-
out.
|
|
445
|
+
const machine = loadMachineStateSync();
|
|
446
|
+
if (machine.shaped) {
|
|
447
|
+
Object.assign(out, machine.shaped);
|
|
448
|
+
if (machine.recovered) {
|
|
449
|
+
out.machineStateRecovered = true;
|
|
450
|
+
out.machineStateRecoveryMode = machine.recoveryMode;
|
|
245
451
|
}
|
|
452
|
+
} else if (machine.unreadable) {
|
|
453
|
+
out.unreadable = machine.unreadable;
|
|
454
|
+
out.unreadablePath = machine.unreadablePath;
|
|
246
455
|
}
|
|
247
456
|
for (const cwd of stateCwds(opts)) {
|
|
248
457
|
const file = queuePathOrSkip(cwd, 'read');
|
|
@@ -266,13 +475,16 @@ function readMergedSync(opts) {
|
|
|
266
475
|
async function readMerged(opts) {
|
|
267
476
|
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
|
|
268
477
|
const sourceCwds = [];
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
out.
|
|
274
|
-
out.
|
|
478
|
+
const machine = await loadMachineState();
|
|
479
|
+
if (machine.shaped) {
|
|
480
|
+
Object.assign(out, machine.shaped);
|
|
481
|
+
if (machine.recovered) {
|
|
482
|
+
out.machineStateRecovered = true;
|
|
483
|
+
out.machineStateRecoveryMode = machine.recoveryMode;
|
|
275
484
|
}
|
|
485
|
+
} else if (machine.unreadable) {
|
|
486
|
+
out.unreadable = machine.unreadable;
|
|
487
|
+
out.unreadablePath = machine.unreadablePath;
|
|
276
488
|
}
|
|
277
489
|
for (const cwd of stateCwds(opts)) {
|
|
278
490
|
const file = queuePathOrSkip(cwd, 'read');
|
|
@@ -420,4 +632,5 @@ module.exports = {
|
|
|
420
632
|
migrateLegacyGlobalQueue,
|
|
421
633
|
writeJsonAtomic,
|
|
422
634
|
writeJsonAtomicSync,
|
|
635
|
+
findLongestValidJsonPrefix,
|
|
423
636
|
};
|
|
@@ -225,6 +225,27 @@ const ORPHAN_REQUEUE_CAP = 5;
|
|
|
225
225
|
* `findLiveProcess` (existing callers/tests) preserves prior behaviour
|
|
226
226
|
* exactly: every pidless row past grace reaps, none are ever recovered.
|
|
227
227
|
*/
|
|
228
|
+
/**
|
|
229
|
+
* Pure, never-throws formatter for the dispatch-phase breadcrumb appended to
|
|
230
|
+
* a pidless reap's reason string. Returns '' when the row carries no
|
|
231
|
+
* breadcrumb at all (an older-build row, or one that never reached the
|
|
232
|
+
* running-stamped mutate) so that case's message stays byte-identical to
|
|
233
|
+
* the pre-breadcrumb text — nothing downstream that matches on it breaks.
|
|
234
|
+
* A present `dispatchPhase` with a missing/unparseable `dispatchPhaseAt`
|
|
235
|
+
* still names the phase, just without the `at <ts>` clause — the reaper
|
|
236
|
+
* must stay a pure decision layer that cannot crash the tick over a
|
|
237
|
+
* malformed timestamp.
|
|
238
|
+
*/
|
|
239
|
+
function formatDispatchPhaseSuffix(j) {
|
|
240
|
+
if (!j || typeof j.dispatchPhase !== 'string' || !j.dispatchPhase) return '';
|
|
241
|
+
const at = typeof j.dispatchPhaseAt === 'string' && !Number.isNaN(Date.parse(j.dispatchPhaseAt))
|
|
242
|
+
? j.dispatchPhaseAt
|
|
243
|
+
: null;
|
|
244
|
+
return at
|
|
245
|
+
? ` (last dispatch phase: ${j.dispatchPhase} at ${at})`
|
|
246
|
+
: ` (last dispatch phase: ${j.dispatchPhase})`;
|
|
247
|
+
}
|
|
248
|
+
|
|
228
249
|
function selectReapableJobs(jobs, now, { pidAlive, grace, findLiveProcess } = {}) {
|
|
229
250
|
const reapable = [];
|
|
230
251
|
const warnings = [];
|
|
@@ -253,7 +274,7 @@ function selectReapableJobs(jobs, now, { pidAlive, grace, findLiveProcess } = {}
|
|
|
253
274
|
slug: j.slug,
|
|
254
275
|
pid: null,
|
|
255
276
|
pidless: true,
|
|
256
|
-
reason: `reaped: no runtime.pid recorded after ${Math.round(grace / 60_000)}m — spawn never completed`,
|
|
277
|
+
reason: `reaped: no runtime.pid recorded after ${Math.round(grace / 60_000)}m — spawn never completed${formatDispatchPhaseSuffix(j)}`,
|
|
257
278
|
});
|
|
258
279
|
}
|
|
259
280
|
return { reapable, warnings, recovered };
|
|
@@ -324,4 +345,5 @@ module.exports = {
|
|
|
324
345
|
resolvePidlessGateOutcome,
|
|
325
346
|
isAlreadySatisfiedOnMain,
|
|
326
347
|
resolveCommitGuardOutcome,
|
|
348
|
+
formatDispatchPhaseSuffix,
|
|
327
349
|
};
|
|
@@ -117,6 +117,13 @@ const ScheduleJobSchema = z
|
|
|
117
117
|
// finalizes — never meant to outlive one run.
|
|
118
118
|
guardBaseline: z.array(z.string()).optional(),
|
|
119
119
|
guardHeadBefore: z.string().nullable().optional(),
|
|
120
|
+
// Dispatch-phase breadcrumb: how far a 'running' row's own dispatch got
|
|
121
|
+
// through the running-transition → executeJob → onPid region, so a
|
|
122
|
+
// pidless reap can name the step that hung instead of just the symptom.
|
|
123
|
+
// Same lifecycle as heldReason — stamped at each step, deleted at
|
|
124
|
+
// finalize (both the normal completion path and reapDeadRunningJobs).
|
|
125
|
+
dispatchPhase: z.string().optional(),
|
|
126
|
+
dispatchPhaseAt: z.string().optional(),
|
|
120
127
|
})
|
|
121
128
|
.passthrough();
|
|
122
129
|
|
|
@@ -31,6 +31,13 @@
|
|
|
31
31
|
'use strict';
|
|
32
32
|
|
|
33
33
|
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
34
|
+
const telemetryCounters = require('./telemetryCounters.cjs');
|
|
35
|
+
|
|
36
|
+
// A run genuinely finished when it leaves 'running' for one of these —
|
|
37
|
+
// distinct from every other legal edge in LEGAL_TRANSITIONS (retries,
|
|
38
|
+
// investigation probes, admin resets), which don't represent a completed
|
|
39
|
+
// execution attempt.
|
|
40
|
+
const FINISH_STATUSES = new Set(['completed', 'failed', 'skipped', 'needs_review']);
|
|
34
41
|
|
|
35
42
|
// Bounded so queue.json (mutation cost, broadcast payload, pickNextBatch
|
|
36
43
|
// scan) stays small — same rationale as lib/queueHistory.cjs's retention
|
|
@@ -183,6 +190,11 @@ function transitionJob(job, toStatus, { reason, source, allowAnyFrom = false } =
|
|
|
183
190
|
source: source ?? null,
|
|
184
191
|
cwd: job.cwd ?? null,
|
|
185
192
|
});
|
|
193
|
+
|
|
194
|
+
if (from === 'running' && FINISH_STATUSES.has(toStatus)) {
|
|
195
|
+
telemetryCounters.trackSchedulerJobFinish({ status: toStatus });
|
|
196
|
+
}
|
|
197
|
+
|
|
186
198
|
return true;
|
|
187
199
|
}
|
|
188
200
|
|