claude-code-session-manager 0.96.0 → 0.98.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/{DataModel-T0D00BpK.js → DataModel-DCEgMTjH.js} +1 -1
- package/dist/assets/{History-DuAovTr7.js → History-Bfdoywlh.js} +1 -1
- package/dist/assets/{Hooks-CgP9jsAN.js → Hooks-CKDJjTJV.js} +1 -1
- package/dist/assets/{HostBilko-DHfz3Mxa.js → HostBilko-CDVHE0hT.js} +1 -1
- package/dist/assets/{Library--poVP-d1.js → Library-7akfgy67.js} +1 -1
- package/dist/assets/{MarkdownEditor-C56-Pc4c.js → MarkdownEditor-DDesiqet.js} +1 -1
- package/dist/assets/{McpServers-D-ykP4Vi.js → McpServers-D4lZtXof.js} +1 -1
- package/dist/assets/{Memory-BrkJjRL8.js → Memory-CeI8G1w5.js} +1 -1
- package/dist/assets/{Permissions-BMpAaxJu.js → Permissions-BGleLja4.js} +1 -1
- package/dist/assets/{Plugins-OuEe5S_T.js → Plugins-P4eCXy_S.js} +2 -2
- package/dist/assets/{ProvenanceBadge-4eNbDYe8.js → ProvenanceBadge-BFczzI1B.js} +1 -1
- package/dist/assets/{SaveBar-BQ8y9Lkv.js → SaveBar-l9jnEMES.js} +1 -1
- package/dist/assets/{Scheduler-DcMT5sT_.js → Scheduler-C_phpeUS.js} +1 -1
- package/dist/assets/{ScopeSwitcher-XdxtVKP-.js → ScopeSwitcher-CjJi8ST5.js} +1 -1
- package/dist/assets/{Settings-FdaPUq2t.js → Settings-BIwZ3FGQ.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CJzvvc0_.js → SkillReferenceGraph-CjbO01Sg.js} +1 -1
- package/dist/assets/{Skills-Wvm9OE1e.js → Skills-BxZ9_fCR.js} +1 -1
- package/dist/assets/{SystemPrompt--LYw_69j.js → SystemPrompt-D8vfFb8b.js} +1 -1
- package/dist/assets/{TagLibrary-Be6HbMMx.js → TagLibrary-2vzUo7YL.js} +1 -1
- package/dist/assets/{TiptapBody-DzFlWMgO.js → TiptapBody-B8ZfbK90.js} +1 -1
- package/dist/assets/{Toggle-BxtAFE1i.js → Toggle-BbW4nGGf.js} +1 -1
- package/dist/assets/{index-XkrLB67D.js → index-Cd98Q1lD.js} +3 -3
- package/dist/assets/{settingsSchema-BIvDZ1Cj.js → settingsSchema-IhUfucCF.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +2 -0
- package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -5
- package/src/main/__tests__/ipcSchemas-dependsOn.test.cjs +39 -0
- package/src/main/__tests__/planValidator.test.cjs +54 -0
- package/src/main/__tests__/prdCreateAdoption.test.cjs +205 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +63 -6
- package/src/main/__tests__/scheduler-mutate-reentrancy.test.cjs +117 -0
- package/src/main/build-info.json +4 -4
- package/src/main/ipcSchemas.cjs +10 -6
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +66 -0
- package/src/main/lib/planValidator.cjs +59 -7
- package/src/main/lib/prdCreate.cjs +32 -7
- package/src/main/lib/watchdogHelpers.cjs +21 -5
- package/src/main/queueOps.cjs +17 -6
- package/src/main/scheduler.cjs +65 -5
package/src/main/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitShortSha": "
|
|
2
|
+
"version": "0.98.0",
|
|
3
|
+
"gitSha": "a733808122aabbe14f7b37834196eb78bb0c339f",
|
|
4
|
+
"gitShortSha": "a7338081",
|
|
5
5
|
"gitBranch": "HEAD",
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-26T00:17:48.074Z",
|
|
7
7
|
"dirty": false
|
|
8
8
|
}
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -322,10 +322,14 @@ const PrdAgentTypeSchema = z.string().regex(PERSONA_NAME_RE, 'agent name must be
|
|
|
322
322
|
// Shared shape for a `dependsOn` slug entry — schedulerCreatePrd and
|
|
323
323
|
// adminUpdatePrd (scheduler_update_prd, PRD 1124) must accept the exact same
|
|
324
324
|
// slug shape or an update could write a dependsOn value create would have
|
|
325
|
-
// rejected. Array-level constraints (`.max(
|
|
326
|
-
// call site since update also allows an explicit empty array to
|
|
327
|
-
// dependency, which create has no reason to accept.
|
|
325
|
+
// rejected. Array-level constraints (`.max(MAX_DEPENDS_ON)`, `.optional()`)
|
|
326
|
+
// stay at each call site since update also allows an explicit empty array to
|
|
327
|
+
// CLEAR the dependency, which create has no reason to accept.
|
|
328
328
|
const DepSlugSchema = z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/);
|
|
329
|
+
// Array-level cap shared by every dependsOn field below — a validate PRD in a
|
|
330
|
+
// large /develop plan depends on every other slug in the plan, so this must
|
|
331
|
+
// stay well above the largest plan size the PRD authoring flow allows.
|
|
332
|
+
const MAX_DEPENDS_ON = 100;
|
|
329
333
|
// Wave-authoring decision (scheduler wave-disposition PRD), shared between
|
|
330
334
|
// schedulerCreatePrd (authoring time) and adminPrdFrontmatterPatch (the
|
|
331
335
|
// after-the-fact change path) so both can never disagree on what a valid
|
|
@@ -350,7 +354,7 @@ const schedulerCreatePrd = z.object({
|
|
|
350
354
|
parallelGroup: z.number().int().min(1).max(999999).optional(),
|
|
351
355
|
// Explicit ordering (PRD 832): slugs that must complete before this PRD
|
|
352
356
|
// becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
|
|
353
|
-
dependsOn: z.array(DepSlugSchema).max(
|
|
357
|
+
dependsOn: z.array(DepSlugSchema).max(MAX_DEPENDS_ON).optional(),
|
|
354
358
|
// Explicit wave-authoring decision (scheduler wave-disposition PRD) — only
|
|
355
359
|
// meaningful (and only stamped) when this PRD joins an Epic that already
|
|
356
360
|
// has incomplete PRDs and lands as a wave root (no dependsOn of its own);
|
|
@@ -417,7 +421,7 @@ const scheduleSetPrdDisposition = z.object({
|
|
|
417
421
|
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
418
422
|
cwd: z.string().min(1).max(4096).optional(),
|
|
419
423
|
disposition: PrdDispositionSchema,
|
|
420
|
-
dependsOn: z.array(DepSlugSchema).max(
|
|
424
|
+
dependsOn: z.array(DepSlugSchema).max(MAX_DEPENDS_ON).optional(),
|
|
421
425
|
});
|
|
422
426
|
|
|
423
427
|
// Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
|
|
@@ -501,7 +505,7 @@ const adminPrdFrontmatterPatch = z.object({
|
|
|
501
505
|
// non-empty array is validated write-time against the SAME resolver
|
|
502
506
|
// (depSlugResolve.cjs) scheduler_create_prd uses, so update and create
|
|
503
507
|
// can never disagree about what a dependsOn entry resolves to.
|
|
504
|
-
dependsOn: z.array(DepSlugSchema).max(
|
|
508
|
+
dependsOn: z.array(DepSlugSchema).max(MAX_DEPENDS_ON).optional(),
|
|
505
509
|
// Patchable after creation too — the Scheduler UI's "change disposition"
|
|
506
510
|
// action (promote a wave to its own head, or attach it behind another
|
|
507
511
|
// chain) rewrites this alongside dependsOn via the same
|
|
@@ -372,4 +372,70 @@ describe('evaluateDispatchLiveness', () => {
|
|
|
372
372
|
assert.equal(evaluateDispatchLiveness(null, now).dead, false);
|
|
373
373
|
assert.equal(evaluateDispatchLiveness({ ts: now }, now).dead, false);
|
|
374
374
|
});
|
|
375
|
+
|
|
376
|
+
const wedgedDeadMs = 10 * 60_000;
|
|
377
|
+
|
|
378
|
+
test('wedged + stale lastDispatchAttemptAt → tick-wedged dead', () => {
|
|
379
|
+
const e = base();
|
|
380
|
+
e.dispatch.lastTickReason = 'wedged';
|
|
381
|
+
e.dispatch.lastDispatchAttemptAt = new Date(now - wedgedDeadMs - 60_000).toISOString();
|
|
382
|
+
assert.deepEqual(
|
|
383
|
+
evaluateDispatchLiveness(e, now, { deadMs, wedgedDeadMs }),
|
|
384
|
+
{ dead: true, reason: 'tick-wedged' },
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('wedged but recent attempt → not dead', () => {
|
|
389
|
+
const e = base();
|
|
390
|
+
e.dispatch.lastTickReason = 'wedged';
|
|
391
|
+
e.dispatch.lastDispatchAttemptAt = new Date(now - 60_000).toISOString();
|
|
392
|
+
// Also clear the unrelated dispatch-dead conditions so a recent wedged
|
|
393
|
+
// attempt reads as genuinely alive, not merely falling through to a
|
|
394
|
+
// different dead reason.
|
|
395
|
+
e.dispatch.lastRunAt = new Date(now - 60_000).toISOString();
|
|
396
|
+
assert.equal(evaluateDispatchLiveness(e, now, { deadMs, wedgedDeadMs }).dead, false);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('wedged while paused → paused (not dead)', () => {
|
|
400
|
+
const e = base();
|
|
401
|
+
e.dispatch.lastTickReason = 'wedged';
|
|
402
|
+
e.dispatch.lastDispatchAttemptAt = new Date(now - wedgedDeadMs - 60_000).toISOString();
|
|
403
|
+
e.dispatch.paused = true;
|
|
404
|
+
assert.deepEqual(
|
|
405
|
+
evaluateDispatchLiveness(e, now, { deadMs, wedgedDeadMs }),
|
|
406
|
+
{ dead: false, reason: 'paused' },
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('wedged with pendingDispatchable 0 still dead (the 2026-09-25 shape)', () => {
|
|
411
|
+
const e = base();
|
|
412
|
+
e.dispatch.lastTickReason = 'wedged';
|
|
413
|
+
e.dispatch.lastDispatchAttemptAt = new Date(now - wedgedDeadMs - 60_000).toISOString();
|
|
414
|
+
e.dispatch.pendingDispatchable = 0;
|
|
415
|
+
assert.deepEqual(
|
|
416
|
+
evaluateDispatchLiveness(e, now, { deadMs, wedgedDeadMs }),
|
|
417
|
+
{ dead: true, reason: 'tick-wedged' },
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('wedged with unparseable lastDispatchAttemptAt → tick-wedged dead', () => {
|
|
422
|
+
const e = base();
|
|
423
|
+
e.dispatch.lastTickReason = 'wedged';
|
|
424
|
+
e.dispatch.lastDispatchAttemptAt = null;
|
|
425
|
+
assert.deepEqual(
|
|
426
|
+
evaluateDispatchLiveness(e, now, { deadMs, wedgedDeadMs }),
|
|
427
|
+
{ dead: true, reason: 'tick-wedged' },
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('default wedgedDeadMs is 10 min', () => {
|
|
432
|
+
const e = base();
|
|
433
|
+
e.dispatch.lastTickReason = 'wedged';
|
|
434
|
+
e.dispatch.lastDispatchAttemptAt = new Date(now - 9 * 60_000).toISOString();
|
|
435
|
+
assert.equal(evaluateDispatchLiveness(e, now, { deadMs }).reason, 'dispatch-dead');
|
|
436
|
+
const e2 = base();
|
|
437
|
+
e2.dispatch.lastTickReason = 'wedged';
|
|
438
|
+
e2.dispatch.lastDispatchAttemptAt = new Date(now - 11 * 60_000).toISOString();
|
|
439
|
+
assert.deepEqual(evaluateDispatchLiveness(e2, now, { deadMs }), { dead: true, reason: 'tick-wedged' });
|
|
440
|
+
});
|
|
375
441
|
});
|
|
@@ -8,21 +8,73 @@
|
|
|
8
8
|
* filesystem or scheduler knowledge — the caller supplies the queue rows.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
const { bareSlug } = require('./depSlugResolve.cjs');
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* hasDownstreamValidator(job, jobs) → boolean
|
|
13
15
|
*
|
|
14
16
|
* True iff `jobs` contains a row with `agentType === 'validator'`, status
|
|
15
|
-
* `pending` or `running`,
|
|
16
|
-
*
|
|
17
|
+
* `pending` or `running`, that reaches `job.slug` transitively by walking
|
|
18
|
+
* `dependsOn` edges through the supplied rows (e.g. validator -> sink ->
|
|
19
|
+
* job). A validator job itself never has a downstream validator (it IS the
|
|
20
|
+
* plan's validation pass). Plans over the `dependsOn` cap point the
|
|
21
|
+
* validator at only the plan's sink PRDs, so a direct-only check would
|
|
22
|
+
* wrongly say every non-sink work item has no downstream validator.
|
|
23
|
+
*
|
|
24
|
+
* `jobs` may be the machine-wide queue (every project's rows merged), so
|
|
25
|
+
* when `job.cwd` is known the walk excludes rows carrying a DIFFERENT
|
|
26
|
+
* explicit `cwd` — a same-bare-named PRD in an unrelated project can never
|
|
27
|
+
* satisfy this job's validator check. A row with no `cwd` recorded is kept
|
|
28
|
+
* (queue rows may omit it; `scheduleJobSchema.cjs` makes it optional) rather
|
|
29
|
+
* than guessed at, since a wrong exclusion (a real validator missed) is far
|
|
30
|
+
* worse than the narrow residual risk of an unstamped row colliding by bare
|
|
31
|
+
* name. Slug matching within that scope reuses the canonical
|
|
32
|
+
* `depSlugResolve.cjs` rule (exact slug, else bare-name with the leading
|
|
33
|
+
* `NN-` stripped) so this walk agrees with the scheduler's own dependency
|
|
34
|
+
* gate on what a `dependsOn` entry resolves to.
|
|
17
35
|
*/
|
|
18
36
|
function hasDownstreamValidator(job, jobs) {
|
|
19
37
|
if (!job || !Array.isArray(jobs)) return false;
|
|
20
38
|
if (job.agentType === 'validator') return false;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
&& (row.
|
|
24
|
-
|
|
25
|
-
|
|
39
|
+
|
|
40
|
+
const projectJobs = job.cwd
|
|
41
|
+
? jobs.filter((row) => row && (row.cwd == null || row.cwd === job.cwd))
|
|
42
|
+
: jobs.filter(Boolean);
|
|
43
|
+
|
|
44
|
+
const rowBySlug = new Map();
|
|
45
|
+
const rowsByBareSlug = new Map();
|
|
46
|
+
for (const row of projectJobs) {
|
|
47
|
+
rowBySlug.set(row.slug, row);
|
|
48
|
+
const bare = bareSlug(row.slug);
|
|
49
|
+
if (!rowsByBareSlug.has(bare)) rowsByBareSlug.set(bare, []);
|
|
50
|
+
rowsByBareSlug.get(bare).push(row);
|
|
51
|
+
}
|
|
52
|
+
const rowsForSlug = (slug) => {
|
|
53
|
+
const exact = rowBySlug.get(slug);
|
|
54
|
+
if (exact) return [exact];
|
|
55
|
+
return rowsByBareSlug.get(bareSlug(slug)) ?? [];
|
|
56
|
+
};
|
|
57
|
+
const matchesJob = (slug) => slug === job.slug || bareSlug(slug) === bareSlug(job.slug);
|
|
58
|
+
|
|
59
|
+
const validators = projectJobs.filter((row) => row.agentType === 'validator'
|
|
60
|
+
&& (row.status === 'pending' || row.status === 'running'));
|
|
61
|
+
|
|
62
|
+
for (const validator of validators) {
|
|
63
|
+
const visited = new Set();
|
|
64
|
+
const queue = Array.isArray(validator.dependsOn) ? [...validator.dependsOn] : [];
|
|
65
|
+
while (queue.length) {
|
|
66
|
+
const slug = queue.shift();
|
|
67
|
+
if (visited.has(slug)) continue;
|
|
68
|
+
visited.add(slug);
|
|
69
|
+
if (matchesJob(slug)) return true;
|
|
70
|
+
for (const row of rowsForSlug(slug)) {
|
|
71
|
+
for (const depSlug of row.dependsOn ?? []) {
|
|
72
|
+
if (!visited.has(depSlug)) queue.push(depSlug);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
26
78
|
}
|
|
27
79
|
|
|
28
80
|
module.exports = { hasDownstreamValidator };
|
|
@@ -480,23 +480,48 @@ async function createPrd(input, remote) {
|
|
|
480
480
|
sourceTabId: input.sourceTabId ?? null,
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
+
// PRD 1446: writePrd() succeeding used to just promise "the next scheduler
|
|
484
|
+
// reconcile pass (~1 minute)" adopts this file — but nothing reliably ran
|
|
485
|
+
// that pass (the 60s pollLoop only reaches reconcile() via
|
|
486
|
+
// maybeLaunchWhenAvailable, which returns early while zero rows are
|
|
487
|
+
// pending; the 10-minute rescheduleTimer could itself stall behind a slow
|
|
488
|
+
// billing fetch). Trigger one reconcile immediately through the EXISTING
|
|
489
|
+
// broadcast-coalescer seam (remote.requestReconcile(), same one resetJob
|
|
490
|
+
// already uses) instead of waiting on either. Best-effort: a reconcile
|
|
491
|
+
// failure here must not fail a write that already landed on disk — the
|
|
492
|
+
// PRD file is real regardless, and the next scheduled pass still adopts
|
|
493
|
+
// it if this one didn't stick.
|
|
494
|
+
let enqueued = false;
|
|
495
|
+
if (typeof remote.requestReconcile === 'function') {
|
|
496
|
+
try {
|
|
497
|
+
await remote.requestReconcile();
|
|
498
|
+
if (typeof remote.getJob === 'function') {
|
|
499
|
+
enqueued = Boolean(await remote.getJob(filenameSlug));
|
|
500
|
+
}
|
|
501
|
+
} catch (e) {
|
|
502
|
+
console.warn(`[prdCreate] immediate requestReconcile failed (non-fatal, next scheduled pass will adopt it): ${e?.message ?? e}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
483
506
|
// No `status` field here, and never one named 'queued' — that string was
|
|
484
507
|
// handed straight to callers as if it were a real ScheduleJob status, and
|
|
485
508
|
// is exactly how the 1021/1022 incident's invalid `"status": "queued"`
|
|
486
509
|
// rows got onto disk (this route's own response was the most likely
|
|
487
510
|
// source). A PRD is a queue row's SOURCE, not the row itself: writePrd
|
|
488
|
-
// only ever joins an Epic and writes the .md file — the queue row
|
|
489
|
-
// created separately, by
|
|
490
|
-
//
|
|
491
|
-
//
|
|
511
|
+
// only ever joins an Epic and writes the .md file — the queue row above
|
|
512
|
+
// is created separately, by reconcile(). `enqueued` reflects whether that
|
|
513
|
+
// reconcile pass (triggered synchronously above) actually produced the
|
|
514
|
+
// row before this function returned — it is not a fabricated status.
|
|
492
515
|
return {
|
|
493
516
|
ok: true,
|
|
494
517
|
nn,
|
|
495
518
|
filename: `${filenameSlug}.md`,
|
|
496
519
|
prdPath: writeResult.path ?? null,
|
|
497
520
|
epicId: writeResult.epicId ?? null,
|
|
498
|
-
enqueued
|
|
499
|
-
note:
|
|
521
|
+
enqueued,
|
|
522
|
+
note: enqueued
|
|
523
|
+
? 'PRD file written and adopted into the queue as a pending row'
|
|
524
|
+
: 'PRD file written; the queue row is derived by the next scheduler reconcile pass, not created here',
|
|
500
525
|
// Advisory only (PRD 1403) — never blocks the write. See prdSizing.cjs.
|
|
501
526
|
warnings: sizingWarnings(input),
|
|
502
527
|
};
|
|
@@ -542,7 +567,7 @@ function registerAdminRoute(adminHttp, remote) {
|
|
|
542
567
|
filename: result.filename,
|
|
543
568
|
prdPath: result.prdPath ?? null,
|
|
544
569
|
epicId: result.epicId ?? null,
|
|
545
|
-
enqueued:
|
|
570
|
+
enqueued: result.enqueued,
|
|
546
571
|
note: result.note,
|
|
547
572
|
warnings: result.warnings,
|
|
548
573
|
});
|
|
@@ -96,19 +96,28 @@ function heartbeatFresh(heartbeatPath = schedulerPaths.heartbeatPath(), maxAgeMs
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
const DEFAULT_DISPATCH_DEAD_MS = 30 * 60_000;
|
|
99
|
+
const DEFAULT_TICK_WEDGED_DEAD_MS = 10 * 60_000;
|
|
99
100
|
|
|
100
101
|
/**
|
|
101
|
-
* evaluateDispatchLiveness(entry, now, { deadMs }?) → { dead, reason }
|
|
102
|
+
* evaluateDispatchLiveness(entry, now, { deadMs, wedgedDeadMs }?) → { dead, reason }
|
|
102
103
|
*
|
|
103
104
|
* Pure. Observability only — a logically-dead scheduler (wedged tick chain,
|
|
104
105
|
* leaked slots) still writes fresh heartbeats, so freshness alone can't see
|
|
105
|
-
* it. `dead`
|
|
106
|
-
*
|
|
107
|
-
* counts as older)
|
|
106
|
+
* it. `dead` when EITHER:
|
|
107
|
+
* - dispatch.lastTickReason === 'wedged' and lastDispatchAttemptAt is
|
|
108
|
+
* older than wedgedDeadMs (absent/unparseable counts as older) — a
|
|
109
|
+
* wedged reconcile never derives rows, so pendingDispatchable stays 0
|
|
110
|
+
* and the rule below can never fire (2026-09-25: watchdog logged
|
|
111
|
+
* 'alive'/'nothing-dispatchable' every 3 min through a 30+ min mutate
|
|
112
|
+
* deadlock because of exactly this gap); or
|
|
113
|
+
* - ALL of: dispatch.lastRunAt older than deadMs (absent/unparseable
|
|
114
|
+
* counts as older), pendingDispatchable > 0, runningCount === 0.
|
|
115
|
+
* Both rules require: heartbeat fresh (not degraded, within
|
|
116
|
+
* DEFAULT_MAX_AGE_MS) and not paused/draining.
|
|
108
117
|
* Callers must NOT relaunch on this: the process is alive, and a relaunch
|
|
109
118
|
* would spawn a second app on top of it.
|
|
110
119
|
*/
|
|
111
|
-
function evaluateDispatchLiveness(entry, now = Date.now(), { deadMs = DEFAULT_DISPATCH_DEAD_MS } = {}) {
|
|
120
|
+
function evaluateDispatchLiveness(entry, now = Date.now(), { deadMs = DEFAULT_DISPATCH_DEAD_MS, wedgedDeadMs = DEFAULT_TICK_WEDGED_DEAD_MS } = {}) {
|
|
112
121
|
if (!entry || entry.degraded === true || typeof entry.ts !== 'number') {
|
|
113
122
|
return { dead: false, reason: 'no-fresh-heartbeat' };
|
|
114
123
|
}
|
|
@@ -117,6 +126,12 @@ function evaluateDispatchLiveness(entry, now = Date.now(), { deadMs = DEFAULT_DI
|
|
|
117
126
|
if (!d || typeof d !== 'object') return { dead: false, reason: 'no-dispatch-field' };
|
|
118
127
|
if (d.paused) return { dead: false, reason: 'paused' };
|
|
119
128
|
if (d.drain) return { dead: false, reason: 'draining' };
|
|
129
|
+
if (d.lastTickReason === 'wedged') {
|
|
130
|
+
const lastAttemptMs = Date.parse(d.lastDispatchAttemptAt ?? '');
|
|
131
|
+
if (!Number.isFinite(lastAttemptMs) || now - lastAttemptMs > wedgedDeadMs) {
|
|
132
|
+
return { dead: true, reason: 'tick-wedged' };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
120
135
|
if (!(d.pendingDispatchable > 0)) return { dead: false, reason: 'nothing-dispatchable' };
|
|
121
136
|
if (d.runningCount !== 0) return { dead: false, reason: 'jobs-running' };
|
|
122
137
|
const lastRunMs = Date.parse(d.lastRunAt ?? '');
|
|
@@ -461,6 +476,7 @@ module.exports = {
|
|
|
461
476
|
maybeRelaunchApp,
|
|
462
477
|
DEFAULT_MAX_AGE_MS,
|
|
463
478
|
DEFAULT_DISPATCH_DEAD_MS,
|
|
479
|
+
DEFAULT_TICK_WEDGED_DEAD_MS,
|
|
464
480
|
DEFAULT_LOCK_STALE_MS,
|
|
465
481
|
DEFAULT_FINALIZE_BUDGET_MS,
|
|
466
482
|
DEFAULT_RELAUNCH_DEBOUNCE_MS,
|
package/src/main/queueOps.cjs
CHANGED
|
@@ -341,13 +341,17 @@ async function archiveOne(slug, ts, cwd) {
|
|
|
341
341
|
* Injected by index.cjs at registration time (see registerQueueOpsHandlers)
|
|
342
342
|
* so a manual archive can retire any still-runnable queue job for the same
|
|
343
343
|
* slug without queueOps.cjs importing scheduler.cjs (circular — scheduler.cjs
|
|
344
|
-
* already requires queueOps.cjs). No-op until set
|
|
345
|
-
*
|
|
346
|
-
*
|
|
344
|
+
* already requires queueOps.cjs). No-op until set. Only ever invoked when
|
|
345
|
+
* archiveMany is called with { retire: true } (the default, used by the
|
|
346
|
+
* manual schedule:archive-prd path) — autoArchiveCompleted passes
|
|
347
|
+
* { retire: false } because it calls retireCompletedSlugsFn (== scheduler's
|
|
348
|
+
* retireCompletedSlugs) from inside reconcile(), itself always inside a
|
|
349
|
+
* mutate() body; retireCompletedSlugs's own mutate() call would then queue
|
|
350
|
+
* behind the mutate awaiting it and deadlock every tick (2026-09-25 incident).
|
|
347
351
|
*/
|
|
348
352
|
let retireCompletedSlugsFn = async () => {};
|
|
349
353
|
|
|
350
|
-
async function archiveMany(slugs, cwd) {
|
|
354
|
+
async function archiveMany(slugs, cwd, { retire = true } = {}) {
|
|
351
355
|
if (!Array.isArray(slugs) || slugs.length === 0) {
|
|
352
356
|
return { ok: true, archived: 0, archivedTo: null, results: [] };
|
|
353
357
|
}
|
|
@@ -362,7 +366,7 @@ async function archiveMany(slugs, cwd) {
|
|
|
362
366
|
}
|
|
363
367
|
const archived = results.filter((r) => r.ok).length;
|
|
364
368
|
const archivedSlugs = results.filter((r) => r.ok).map((r) => r.slug);
|
|
365
|
-
if (archivedSlugs.length > 0) {
|
|
369
|
+
if (retire && archivedSlugs.length > 0) {
|
|
366
370
|
await retireCompletedSlugsFn(archivedSlugs).catch((e) => {
|
|
367
371
|
logs.writeLine({ level: 'warn', scope: 'queueOps', message: 'archiveMany: retireCompletedSlugs failed', meta: { error: e?.message } });
|
|
368
372
|
});
|
|
@@ -440,6 +444,13 @@ function selectAutoArchivable(jobs, { nowMs, retentionMs } = {}) {
|
|
|
440
444
|
* the same path-containment. No-op (without touching disk) when
|
|
441
445
|
* SM_PRD_AUTOARCHIVE_DISABLE=1. Each successful move is logged one line
|
|
442
446
|
* (slug -> dest).
|
|
447
|
+
*
|
|
448
|
+
* Passes { retire: false } to archiveMany: this runs inside reconcile(),
|
|
449
|
+
* which always runs inside a mutate() body or tickBody, so invoking
|
|
450
|
+
* retireCompletedSlugs (which itself calls mutate()) here would queue that
|
|
451
|
+
* mutate behind the one already awaiting it and deadlock every tick — proven
|
|
452
|
+
* live 2026-09-25. Also unneeded on the merits: selectAutoArchivable only
|
|
453
|
+
* ever selects already-completed jobs, so there is nothing left to retire.
|
|
443
454
|
*/
|
|
444
455
|
async function autoArchiveCompleted(state, { nowMs } = {}) {
|
|
445
456
|
if (process.env.SM_PRD_AUTOARCHIVE_DISABLE === '1') {
|
|
@@ -450,7 +461,7 @@ async function autoArchiveCompleted(state, { nowMs } = {}) {
|
|
|
450
461
|
if (slugs.length === 0) {
|
|
451
462
|
return { ok: true, archived: 0, archivedTo: null, results: [] };
|
|
452
463
|
}
|
|
453
|
-
const result = await archiveMany(slugs);
|
|
464
|
+
const result = await archiveMany(slugs, undefined, { retire: false });
|
|
454
465
|
for (const r of result.results) {
|
|
455
466
|
if (r.ok) {
|
|
456
467
|
logs.writeLine({
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -47,6 +47,7 @@ const fs = require('node:fs');
|
|
|
47
47
|
const fsp = require('node:fs/promises');
|
|
48
48
|
const path = require('node:path');
|
|
49
49
|
const os = require('node:os');
|
|
50
|
+
const { AsyncLocalStorage } = require('node:async_hooks');
|
|
50
51
|
const { startDispatchLoop } = require('./lib/dispatchLoop.cjs');
|
|
51
52
|
const schedulerPaths = require('./lib/schedulerPaths.cjs');
|
|
52
53
|
const { randomUUID } = require('node:crypto');
|
|
@@ -1328,9 +1329,17 @@ async function archiveCompletedPrd(slug, cwd) {
|
|
|
1328
1329
|
* `schedule:archive-prd`) so a stale queue entry can never survive to fire
|
|
1329
1330
|
* against a PRD that no longer exists in the live prds/ dir — the same
|
|
1330
1331
|
* ENOENT-avoidance archivedTwinExists provides in executeJob, applied at the
|
|
1331
|
-
* archiving source instead of at fire-time.
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1332
|
+
* archiving source instead of at fire-time.
|
|
1333
|
+
*
|
|
1334
|
+
* MUST NEVER be called from inside a mutate() body (directly, or transitively
|
|
1335
|
+
* via reconcile()/tickBody, which always run inside one) — this function
|
|
1336
|
+
* itself calls mutate() below, and a mutate queued from inside a mutate body
|
|
1337
|
+
* queues behind the very mutate awaiting it, so mutateTail never settles:
|
|
1338
|
+
* every tick wedges machine-wide until the app restarts (proven live
|
|
1339
|
+
* 2026-09-25). That is exactly why queueOps.cjs's autoArchiveCompleted (which
|
|
1340
|
+
* DOES run inside reconcile()) calls archiveMany with { retire: false } and
|
|
1341
|
+
* skips this function entirely — it is exercised only by the manual archive
|
|
1342
|
+
* path (queueOps.cjs's `schedule:archive-prd`, called from outside mutate()).
|
|
1334
1343
|
*/
|
|
1335
1344
|
async function retireCompletedSlugs(slugs) {
|
|
1336
1345
|
const list = Array.isArray(slugs) ? slugs.filter(Boolean) : [];
|
|
@@ -2469,6 +2478,15 @@ async function writeQueue(state) {
|
|
|
2469
2478
|
// preceding mutate threw, so the chain never deadlocks.
|
|
2470
2479
|
let mutateTail = Promise.resolve();
|
|
2471
2480
|
|
|
2481
|
+
// Tracks whether the current async continuation is running inside a live
|
|
2482
|
+
// mutate() body. A mutate() called (and awaited) from inside another mutate's
|
|
2483
|
+
// `fn` would otherwise queue behind its own caller on mutateTail and deadlock
|
|
2484
|
+
// forever — this turns that class of bug into an immediate, audited rejection.
|
|
2485
|
+
// A callback (setTimeout/promise) scheduled from inside a mutate body but
|
|
2486
|
+
// firing AFTER that body has finished sees ctx.active === false and proceeds
|
|
2487
|
+
// normally, since AsyncLocalStorage propagates the store into it.
|
|
2488
|
+
const mutateCtx = new AsyncLocalStorage();
|
|
2489
|
+
|
|
2472
2490
|
// Observe-only watchdog: a mutate body over MUTATE_WATCHDOG_MS is logged and
|
|
2473
2491
|
// audited once per episode (latched until a mutate completes). mutateTail is
|
|
2474
2492
|
// deliberately NEVER reset — it is what enforces the single-writer law, and
|
|
@@ -2477,6 +2495,12 @@ const MUTATE_WATCHDOG_MS = 60_000;
|
|
|
2477
2495
|
let mutateWedgeLatched = false;
|
|
2478
2496
|
|
|
2479
2497
|
function mutate(fn) {
|
|
2498
|
+
if (mutateCtx.getStore()?.active === true) {
|
|
2499
|
+
const err = new Error('mutate() re-entered from inside a running mutate body — would deadlock');
|
|
2500
|
+
console.error('[scheduler] MUTATE RE-ENTRANT', err.stack);
|
|
2501
|
+
appendAuditEvent('mutate_reentrant', { stack: String(err.stack).split('\n').slice(0, 8).join('\n') });
|
|
2502
|
+
return Promise.reject(err);
|
|
2503
|
+
}
|
|
2480
2504
|
const next = mutateTail.then(async () => {
|
|
2481
2505
|
const wedgeTimer = setTimeout(() => {
|
|
2482
2506
|
if (mutateWedgeLatched) return;
|
|
@@ -2485,9 +2509,11 @@ function mutate(fn) {
|
|
|
2485
2509
|
appendAuditEvent('mutate_wedged', { budgetMs: MUTATE_WATCHDOG_MS });
|
|
2486
2510
|
}, MUTATE_WATCHDOG_MS);
|
|
2487
2511
|
if (typeof wedgeTimer.unref === 'function') wedgeTimer.unref();
|
|
2512
|
+
const ctx = { active: true };
|
|
2488
2513
|
try {
|
|
2489
|
-
return await mutateBody(fn);
|
|
2514
|
+
return await mutateCtx.run(ctx, () => mutateBody(fn));
|
|
2490
2515
|
} finally {
|
|
2516
|
+
ctx.active = false;
|
|
2491
2517
|
clearTimeout(wedgeTimer);
|
|
2492
2518
|
mutateWedgeLatched = false;
|
|
2493
2519
|
}
|
|
@@ -3775,13 +3801,31 @@ function computeFireAt(state, nextResetIso) {
|
|
|
3775
3801
|
return reset + (state.config.offsetMinutes * 60_000);
|
|
3776
3802
|
}
|
|
3777
3803
|
|
|
3804
|
+
// PRD 1446: refreshNextReset() awaits billing.fetchUsage(), which can hang
|
|
3805
|
+
// or back off for minutes while the usage meter is rate-limited. Left
|
|
3806
|
+
// unbounded, that stalled THIS function's own mutate(reconcile) call for as
|
|
3807
|
+
// long as the fetch took — the 10-minute rescheduleTimer was meant to be a
|
|
3808
|
+
// second, independent path into reconcile() alongside the 60s pollLoop, but
|
|
3809
|
+
// a hung fetch silently took it out too, leaving newly-created PRDs
|
|
3810
|
+
// unadopted (PRDs 1420-1441, 2026-09-25). Racing against this timeout lets
|
|
3811
|
+
// the reconcile still run on the cached reset value; the abandoned
|
|
3812
|
+
// refreshNextReset() call is left to resolve on its own and update the
|
|
3813
|
+
// cache for next time.
|
|
3814
|
+
const RESCHEDULE_TIMER_BILLING_RACE_MS = 10_000;
|
|
3815
|
+
|
|
3778
3816
|
async function rescheduleTimer() {
|
|
3779
3817
|
clearFireTimer();
|
|
3780
3818
|
// Wrap in try/catch — on failure use the cached value so the on-reset
|
|
3781
3819
|
// timer can still be armed from the last known reset.
|
|
3782
3820
|
let nextResetIso;
|
|
3783
3821
|
try {
|
|
3784
|
-
nextResetIso = await
|
|
3822
|
+
nextResetIso = await Promise.race([
|
|
3823
|
+
refreshNextReset(),
|
|
3824
|
+
new Promise((resolve) => {
|
|
3825
|
+
const t = setTimeout(() => resolve(cachedNextReset), RESCHEDULE_TIMER_BILLING_RACE_MS);
|
|
3826
|
+
if (typeof t.unref === 'function') t.unref();
|
|
3827
|
+
}),
|
|
3828
|
+
]);
|
|
3785
3829
|
} catch {
|
|
3786
3830
|
nextResetIso = cachedNextReset;
|
|
3787
3831
|
}
|
|
@@ -12453,6 +12497,19 @@ const remote = {
|
|
|
12453
12497
|
}
|
|
12454
12498
|
},
|
|
12455
12499
|
|
|
12500
|
+
// Triggers an immediate reconcile pass through the SAME seam resetJob
|
|
12501
|
+
// already uses below (broadcast's coalescer, whose getPayload runs
|
|
12502
|
+
// module.exports.reconcile) — no second reconcile implementation. PRD
|
|
12503
|
+
// 1446: prdCreate.cjs's createPrd() calls this right after a successful
|
|
12504
|
+
// write so a fresh PRD becomes a pending queue row without waiting for
|
|
12505
|
+
// the next scheduled pass — the 60s pollLoop only reaches reconcile() via
|
|
12506
|
+
// maybeLaunchWhenAvailable, which returns early while zero rows are
|
|
12507
|
+
// pending, and the 10-minute rescheduleTimer can itself stall behind a
|
|
12508
|
+
// slow/hung billing fetch (see rescheduleTimer's own bounded race below).
|
|
12509
|
+
async requestReconcile() {
|
|
12510
|
+
await broadcast({ flush: true });
|
|
12511
|
+
},
|
|
12512
|
+
|
|
12456
12513
|
// User-initiated pause/resume — the admin-route/MCP twins of the
|
|
12457
12514
|
// schedule:pause / schedule:resume IPC handlers, through the same setPaused /
|
|
12458
12515
|
// clearPause. Pause stops NEW dispatch only; running jobs are never touched.
|
|
@@ -12876,8 +12933,11 @@ module.exports = {
|
|
|
12876
12933
|
computeDegradedBudget,
|
|
12877
12934
|
healRefusalReason,
|
|
12878
12935
|
writeQueue,
|
|
12936
|
+
_mutateForTests: mutate, // mutate() has no other exported call site; test-only seam.
|
|
12879
12937
|
reconcile,
|
|
12880
12938
|
broadcast,
|
|
12939
|
+
rescheduleTimer,
|
|
12940
|
+
RESCHEDULE_TIMER_BILLING_RACE_MS,
|
|
12881
12941
|
reconcileSourcePromptId,
|
|
12882
12942
|
allocateParallelGroup,
|
|
12883
12943
|
selectHistoryJobs,
|