claude-code-session-manager 0.35.18 → 0.37.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/{TiptapBody-DgFO_m3g.js → TiptapBody-Cg8YZhVZ.js} +58 -58
- package/dist/assets/index-CTTjT08J.css +32 -0
- package/dist/assets/index-_iBXLuvt.js +3496 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
- package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
- package/src/main/__tests__/docEdit.test.cjs +82 -0
- package/src/main/__tests__/historyDashboard.test.cjs +163 -0
- package/src/main/__tests__/historyRollup.test.cjs +333 -0
- package/src/main/__tests__/memoryStale.test.cjs +88 -0
- package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
- package/src/main/__tests__/queueHistory.test.cjs +233 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +4 -5
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
- package/src/main/chatRunner.cjs +57 -18
- package/src/main/config.cjs +8 -0
- package/src/main/docEdit.cjs +133 -0
- package/src/main/files.cjs +53 -0
- package/src/main/historyAggregator.cjs +391 -50
- package/src/main/historyDashboard.cjs +226 -0
- package/src/main/index.cjs +37 -1
- package/src/main/ipcSchemas.cjs +29 -0
- package/src/main/lib/broadcastCoalescer.cjs +46 -0
- package/src/main/lib/historyRollup.cjs +148 -0
- package/src/main/lib/memoryStale.cjs +116 -0
- package/src/main/lib/queueHistory.cjs +216 -0
- package/src/main/lib/rcaFeedbackHook.cjs +346 -0
- package/src/main/lib/schedulerConfig.cjs +16 -0
- package/src/main/memoryTool.cjs +49 -0
- package/src/main/queueOps.cjs +92 -0
- package/src/main/scheduler/prdParser.cjs +52 -1
- package/src/main/scheduler.cjs +237 -29
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +7 -0
- package/dist/assets/index-4dJkn9nT.js +0 -3559
- package/dist/assets/index-DX2w2YhJ.css +0 -32
package/src/main/queueOps.cjs
CHANGED
|
@@ -38,6 +38,7 @@ const { SCHEDULE_SLUG_RE: SLUG_RE, schemas } = require('./ipcSchemas.cjs');
|
|
|
38
38
|
const logs = require('./logs.cjs');
|
|
39
39
|
const config = require('./config.cjs');
|
|
40
40
|
const { expandHome } = require('./lib/expandHome.cjs');
|
|
41
|
+
const { HISTORY_RETENTION_MS } = require('./lib/schedulerConfig.cjs');
|
|
41
42
|
|
|
42
43
|
const ROOT = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans');
|
|
43
44
|
const PRDS_DIR = path.join(ROOT, 'prds');
|
|
@@ -243,6 +244,95 @@ async function archiveMany(slugs) {
|
|
|
243
244
|
return { ok: true, archived, archivedTo: archiveDir, results };
|
|
244
245
|
}
|
|
245
246
|
|
|
247
|
+
// ────────────────────────────────────────────── auto-archive completed PRDs
|
|
248
|
+
//
|
|
249
|
+
// PRDS_DIR grows monotonically (738+ .md files, ~100% long-completed) because
|
|
250
|
+
// nothing ever moves a finished PRD's .md out of the live directory — every
|
|
251
|
+
// allocateParallelGroup scan, reconcile listing, and lint pass walks all of
|
|
252
|
+
// them. Once a job is durably 'completed' (queueHistory.cjs's same retention
|
|
253
|
+
// window has already judged it safe to leave jobs[]) and nothing still needs
|
|
254
|
+
// its file in place, move the .md to prds-archived/ alongside it.
|
|
255
|
+
//
|
|
256
|
+
// 'needs_review' and 'failed' PRDs are NEVER selected here — humans and the
|
|
257
|
+
// auto-fix loop (scheduler.cjs's healTargetForFix) still need those files on
|
|
258
|
+
// disk. Same fix-plan-protection regex as queueHistory.cjs's partitionJobs,
|
|
259
|
+
// duplicated (not shared) because the two modules intentionally stay
|
|
260
|
+
// decoupled — kept in sync by comment, same convention queueHistory.cjs uses
|
|
261
|
+
// against scheduler.cjs's healTargetForFix.
|
|
262
|
+
|
|
263
|
+
const FIX_PLAN_RE = /^\d+-fix-/;
|
|
264
|
+
|
|
265
|
+
function protectedByPendingFix(jobs) {
|
|
266
|
+
const protectedSlugs = new Set();
|
|
267
|
+
for (const j of jobs) {
|
|
268
|
+
if (!j || !FIX_PLAN_RE.test(j.slug || '')) continue;
|
|
269
|
+
if (j.status !== 'pending' && j.status !== 'running') continue;
|
|
270
|
+
protectedSlugs.add(j.slug.replace(/^(\d+)-fix-/, '$1-'));
|
|
271
|
+
}
|
|
272
|
+
return protectedSlugs;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Pure predicate: which slugs are safe to move to prds-archived/. O(n) over
|
|
277
|
+
* `jobs`. A slug qualifies only when ALL of:
|
|
278
|
+
* - its job status is 'completed' (never 'needs_review'/'failed'/
|
|
279
|
+
* 'pending'/'running' — the completed check alone also covers "not
|
|
280
|
+
* itself a pending/running job")
|
|
281
|
+
* - finishedAt parses and is older than retentionMs (default
|
|
282
|
+
* HISTORY_RETENTION_MS, the same constant queueHistory.cjs's
|
|
283
|
+
* partitionJobs uses — by the time a file is archive-eligible its queue
|
|
284
|
+
* row has already left jobs[] into history.jsonl)
|
|
285
|
+
* - no pending/running `NN-fix-<slug>` sibling still needs it in place
|
|
286
|
+
*/
|
|
287
|
+
function selectAutoArchivable(jobs, { nowMs, retentionMs } = {}) {
|
|
288
|
+
const list = Array.isArray(jobs) ? jobs : [];
|
|
289
|
+
const retention = retentionMs ?? HISTORY_RETENTION_MS;
|
|
290
|
+
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
291
|
+
const protectedSlugs = protectedByPendingFix(list);
|
|
292
|
+
|
|
293
|
+
const slugs = [];
|
|
294
|
+
for (const j of list) {
|
|
295
|
+
if (!j || !j.slug) continue;
|
|
296
|
+
if (j.status !== 'completed') continue;
|
|
297
|
+
const finishedMs = j.finishedAt ? Date.parse(j.finishedAt) : NaN;
|
|
298
|
+
if (!Number.isFinite(finishedMs)) continue;
|
|
299
|
+
if ((now - finishedMs) <= retention) continue;
|
|
300
|
+
if (protectedSlugs.has(j.slug)) continue;
|
|
301
|
+
slugs.push(j.slug);
|
|
302
|
+
}
|
|
303
|
+
return slugs;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Selects and moves eligible completed PRDs' .md files to prds-archived/.
|
|
308
|
+
* Reuses archiveMany (no second mover) so moves stay atomic fsp.rename with
|
|
309
|
+
* the same path-containment. No-op (without touching disk) when
|
|
310
|
+
* SM_PRD_AUTOARCHIVE_DISABLE=1. Each successful move is logged one line
|
|
311
|
+
* (slug -> dest).
|
|
312
|
+
*/
|
|
313
|
+
async function autoArchiveCompleted(state, { nowMs } = {}) {
|
|
314
|
+
if (process.env.SM_PRD_AUTOARCHIVE_DISABLE === '1') {
|
|
315
|
+
return { ok: true, archived: 0, archivedTo: null, results: [], skipped: 'disabled' };
|
|
316
|
+
}
|
|
317
|
+
const jobs = Array.isArray(state?.jobs) ? state.jobs : [];
|
|
318
|
+
const slugs = selectAutoArchivable(jobs, { nowMs });
|
|
319
|
+
if (slugs.length === 0) {
|
|
320
|
+
return { ok: true, archived: 0, archivedTo: null, results: [] };
|
|
321
|
+
}
|
|
322
|
+
const result = await archiveMany(slugs);
|
|
323
|
+
for (const r of result.results) {
|
|
324
|
+
if (r.ok) {
|
|
325
|
+
logs.writeLine({
|
|
326
|
+
level: 'info',
|
|
327
|
+
scope: 'queueOps',
|
|
328
|
+
message: `auto-archived completed PRD: ${r.slug} -> ${r.archivedTo}`,
|
|
329
|
+
meta: { slug: r.slug, dest: r.archivedTo },
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
246
336
|
// ────────────────────────────────────────────── retag
|
|
247
337
|
|
|
248
338
|
/**
|
|
@@ -398,6 +488,8 @@ module.exports = {
|
|
|
398
488
|
lintAll,
|
|
399
489
|
lintOneAsync,
|
|
400
490
|
archiveMany,
|
|
491
|
+
selectAutoArchivable,
|
|
492
|
+
autoArchiveCompleted,
|
|
401
493
|
retagMany,
|
|
402
494
|
PRDS_DIR,
|
|
403
495
|
PRDS_ARCHIVE_DIR,
|
|
@@ -172,6 +172,48 @@ const RESERVATION_RE = /^\.reserved-(\d+)$/;
|
|
|
172
172
|
const GROUP_PREFIX_RE = /^(\d+)-/;
|
|
173
173
|
const MAX_RESERVE_ATTEMPTS = 1000;
|
|
174
174
|
|
|
175
|
+
// Auto-archiving completed PRDs (see queueOps.cjs's autoArchiveCompleted)
|
|
176
|
+
// moves old `NN-*.md` files out of prdsDir into prds-archived/, which would
|
|
177
|
+
// let maxParallelGroupInUse's directory scan regress once the highest-NN
|
|
178
|
+
// files age out — a later allocation could then reissue an already-used NN
|
|
179
|
+
// and collide with an archived group. A high-water-mark sidecar closes that:
|
|
180
|
+
// once a scan or allocation has seen NN, the allocator never returns
|
|
181
|
+
// anything ≤ NN again, even if every file at or above NN is later archived.
|
|
182
|
+
// Sidecar (not queue.json) so this module stays self-contained and testable
|
|
183
|
+
// against a bare tmp dir, matching the `.reserved-NN` marker convention.
|
|
184
|
+
const MAX_GROUP_FILE = '.max-allocated-group';
|
|
185
|
+
|
|
186
|
+
async function readHighWaterMark(prdsDir) {
|
|
187
|
+
try {
|
|
188
|
+
const raw = await fsp.readFile(path.join(prdsDir, MAX_GROUP_FILE), 'utf8');
|
|
189
|
+
const n = Number(raw.trim());
|
|
190
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
191
|
+
} catch {
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function writeHighWaterMark(prdsDir, n) {
|
|
197
|
+
// Re-read immediately before writing so two concurrent allocations that
|
|
198
|
+
// both raced past the caller's earlier read can't have the smaller `n`
|
|
199
|
+
// land last and regress the persisted mark below the larger one. Narrows
|
|
200
|
+
// (does not fully eliminate) the race; the reservation markers remain the
|
|
201
|
+
// authoritative concurrency guard, this file is a best-effort backstop
|
|
202
|
+
// for after files are archived away.
|
|
203
|
+
const current = await readHighWaterMark(prdsDir);
|
|
204
|
+
if (n <= current) return;
|
|
205
|
+
|
|
206
|
+
const dest = path.join(prdsDir, MAX_GROUP_FILE);
|
|
207
|
+
// `n` itself is unique per concurrent caller (the reservation marker each
|
|
208
|
+
// caller holds guarantees no two callers ever share a candidate), so it
|
|
209
|
+
// doubles as a collision-free tmp-file suffix — process.pid + Date.now()
|
|
210
|
+
// is NOT safe here since concurrent allocateParallelGroup calls in the
|
|
211
|
+
// same process can land in the same millisecond.
|
|
212
|
+
const tmp = `${dest}.tmp-${n}-${process.pid}`;
|
|
213
|
+
await fsp.writeFile(tmp, String(n), 'utf8');
|
|
214
|
+
await fsp.rename(tmp, dest);
|
|
215
|
+
}
|
|
216
|
+
|
|
175
217
|
/**
|
|
176
218
|
* Highest `NN` currently in use under prdsDir, across real `NN-slug.md`
|
|
177
219
|
* PRD files (any digit count, unpadded or not — matches groupFromName
|
|
@@ -208,7 +250,10 @@ async function maxParallelGroupInUse(prdsDir) {
|
|
|
208
250
|
*/
|
|
209
251
|
async function allocateParallelGroup(prdsDir) {
|
|
210
252
|
await fsp.mkdir(prdsDir, { recursive: true });
|
|
211
|
-
|
|
253
|
+
const scanMax = await maxParallelGroupInUse(prdsDir);
|
|
254
|
+
const highWater = await readHighWaterMark(prdsDir);
|
|
255
|
+
const floor = Math.max(scanMax, highWater);
|
|
256
|
+
let candidate = floor + 1;
|
|
212
257
|
for (let attempt = 0; attempt < MAX_RESERVE_ATTEMPTS; attempt += 1) {
|
|
213
258
|
const markerPath = path.join(prdsDir, `.reserved-${candidate}`);
|
|
214
259
|
let fh;
|
|
@@ -222,6 +267,12 @@ async function allocateParallelGroup(prdsDir) {
|
|
|
222
267
|
throw e;
|
|
223
268
|
}
|
|
224
269
|
await fh.close();
|
|
270
|
+
// Persist the new floor before returning so a later archive of every
|
|
271
|
+
// NN-*.md file (including this one) can never regress the allocator
|
|
272
|
+
// below it.
|
|
273
|
+
if (candidate > highWater) {
|
|
274
|
+
await writeHighWaterMark(prdsDir, candidate);
|
|
275
|
+
}
|
|
225
276
|
return candidate;
|
|
226
277
|
}
|
|
227
278
|
throw new Error(`allocateParallelGroup: exhausted ${MAX_RESERVE_ATTEMPTS} reservation attempts starting at ${candidate}`);
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -36,8 +36,11 @@
|
|
|
36
36
|
* Orphaned entries (.md gone) are pruned.
|
|
37
37
|
*
|
|
38
38
|
* Renderer events:
|
|
39
|
-
* - 'schedule:state' broadcasts the full state on
|
|
40
|
-
* panel UI dead simple — no diff machinery.
|
|
39
|
+
* - 'schedule:state' broadcasts the full state on mutation. Keeps the
|
|
40
|
+
* panel UI dead simple — no diff machinery. Sends are trailing-edge
|
|
41
|
+
* debounced (~BROADCAST_COALESCE_MS) by default so a burst of mutations
|
|
42
|
+
* collapses into one push; state-machine transitions (pause/resume, job
|
|
43
|
+
* start/finish) call broadcast({ flush: true }) to bypass the window.
|
|
41
44
|
*/
|
|
42
45
|
|
|
43
46
|
const fs = require('node:fs');
|
|
@@ -55,6 +58,7 @@ const { readTail } = require('./lib/fileTail.cjs');
|
|
|
55
58
|
const { claudePidAlive, classifyRunOutcome, ORPHAN_REQUEUE_CAP } = require('./lib/reaperHelpers.cjs');
|
|
56
59
|
const { openLog, withChildAndLog } = require('./lib/childWithLog.cjs');
|
|
57
60
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
61
|
+
const { createBroadcastCoalescer } = require('./lib/broadcastCoalescer.cjs');
|
|
58
62
|
const prdParser = require('./scheduler/prdParser.cjs');
|
|
59
63
|
const { verifyRun } = require('./runVerify.cjs');
|
|
60
64
|
const logs = require('./logs.cjs');
|
|
@@ -63,9 +67,13 @@ const {
|
|
|
63
67
|
POLL_INTERVAL_MS,
|
|
64
68
|
USAGE_REFRESH_INTERVAL_MS,
|
|
65
69
|
MAX_JOB_DURATION_MS,
|
|
70
|
+
BROADCAST_COALESCE_MS,
|
|
66
71
|
} = require('./lib/schedulerConfig.cjs');
|
|
67
72
|
const { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD } = require('./lib/schedulerBatch.cjs');
|
|
68
73
|
const { runDefinitionOfDoneOnDrain } = require('./lib/dodDrainHook.cjs');
|
|
74
|
+
const { fileRcaFeedback, extractRcaBlock } = require('./lib/rcaFeedbackHook.cjs');
|
|
75
|
+
const queueHistory = require('./lib/queueHistory.cjs');
|
|
76
|
+
const queueOps = require('./queueOps.cjs');
|
|
69
77
|
|
|
70
78
|
const MAX_INVESTIGATION_DURATION_MS = 30 * 60_000;
|
|
71
79
|
|
|
@@ -224,9 +232,14 @@ const HEARTBEAT_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'sc
|
|
|
224
232
|
const HEARTBEAT_MAX_BYTES = 1024 * 1024;
|
|
225
233
|
// DEFAULT_PROJECT_CWD imported from lib/schedulerBatch.cjs (single source of truth).
|
|
226
234
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
// Read lazily (not captured at module-load time) so tests can stub the env
|
|
236
|
+
// per-case without vi.resetModules(); production behavior is unaffected
|
|
237
|
+
// since the env var never changes mid-process.
|
|
238
|
+
function getEnvCap() {
|
|
239
|
+
return process.env.SM_SCHEDULER_MAX_CONCURRENCY
|
|
240
|
+
? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
|
|
241
|
+
: null;
|
|
242
|
+
}
|
|
230
243
|
|
|
231
244
|
// Each headless claude -p job can shell out to tsc/vite/pytest and grow well
|
|
232
245
|
// past 1 GB at peak; reserve 2.5 GB per running+pending slot. Raised from 1.5 GB
|
|
@@ -252,7 +265,7 @@ const OOM_SCORE_ADJ_JOB = 500;
|
|
|
252
265
|
|
|
253
266
|
const DEFAULT_CONFIG = {
|
|
254
267
|
offsetMinutes: 15,
|
|
255
|
-
concurrencyCap:
|
|
268
|
+
concurrencyCap: getEnvCap() ?? 3,
|
|
256
269
|
defaultCwd: DEFAULT_PROJECT_CWD,
|
|
257
270
|
// 'when-available' = poll usage and fire whenever utilization < threshold.
|
|
258
271
|
// 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
|
|
@@ -361,6 +374,46 @@ function ensureDirs() {
|
|
|
361
374
|
} catch { /* non-fatal: the guide is a convenience, not load-bearing for a run */ }
|
|
362
375
|
}
|
|
363
376
|
|
|
377
|
+
// Matches only numbered timestamp backups (queue.json.bak-<epoch>), not the
|
|
378
|
+
// bare `queue.json.bak` single-shot copy some older code paths left behind.
|
|
379
|
+
const QUEUE_BAK_RE = /^queue\.json\.bak-\d+$/;
|
|
380
|
+
const QUEUE_BAK_KEEP = 5;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Sweeps accumulated queue.json.bak-<epoch> files down to the newest
|
|
384
|
+
* QUEUE_BAK_KEEP, deleting the rest. Runs at boot. `entries` comes from
|
|
385
|
+
* fs.readdir(ROOT), so every candidate path is already contained in ROOT.
|
|
386
|
+
*/
|
|
387
|
+
async function sweepQueueBackups() {
|
|
388
|
+
let entries;
|
|
389
|
+
try {
|
|
390
|
+
entries = await fsp.readdir(ROOT);
|
|
391
|
+
} catch {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const baks = entries.filter((f) => QUEUE_BAK_RE.test(f));
|
|
395
|
+
if (baks.length <= QUEUE_BAK_KEEP) return;
|
|
396
|
+
|
|
397
|
+
baks.sort((a, b) => {
|
|
398
|
+
const ta = Number(a.slice('queue.json.bak-'.length));
|
|
399
|
+
const tb = Number(b.slice('queue.json.bak-'.length));
|
|
400
|
+
return tb - ta; // newest (largest epoch) first
|
|
401
|
+
});
|
|
402
|
+
const toDelete = baks.slice(QUEUE_BAK_KEEP);
|
|
403
|
+
let removed = 0;
|
|
404
|
+
for (const f of toDelete) {
|
|
405
|
+
try {
|
|
406
|
+
await fsp.unlink(path.join(ROOT, f));
|
|
407
|
+
removed++;
|
|
408
|
+
} catch (e) {
|
|
409
|
+
console.warn('[scheduler] backup sweep: unlink failed', f, e?.message);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (removed > 0) {
|
|
413
|
+
console.log(`[scheduler] backup sweep: removed ${removed} old queue.json.bak-* file(s), kept ${QUEUE_BAK_KEEP}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
364
417
|
// Atomic JSON write helpers delegate to config.cjs's shared implementation.
|
|
365
418
|
// Sync variant is required for the executeJob exit handler (Promise resolver
|
|
366
419
|
// callback that must flush meta.json before resolving) — replacing with async
|
|
@@ -625,8 +678,41 @@ async function reconcile(state) {
|
|
|
625
678
|
bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
|
|
626
679
|
});
|
|
627
680
|
}
|
|
681
|
+
// Slugs on disk with no matching state.jobs row are normally brand-new
|
|
682
|
+
// PRDs — but once queueHistory.partitionJobs (above, later this same
|
|
683
|
+
// function) has been dropping terminal jobs into history.jsonl across
|
|
684
|
+
// prior reconcile passes, an old completed/failed job's row can ALSO be
|
|
685
|
+
// "unmatched" simply because it already left jobs[]. Without this check
|
|
686
|
+
// it would get resurrected below as a fresh 'pending' entry and the
|
|
687
|
+
// scheduler would genuinely re-execute an already-completed PRD — the
|
|
688
|
+
// exact backlog this auto-archive feature exists to clean up. Only pay
|
|
689
|
+
// the history lookup when there's actually an unmatched slug to resolve.
|
|
690
|
+
const unmatchedSlugs = [];
|
|
691
|
+
for (const [slug] of onDisk) {
|
|
692
|
+
if (!seen.has(slug)) unmatchedSlugs.push(slug);
|
|
693
|
+
}
|
|
694
|
+
const historyBySlug = unmatchedSlugs.length > 0
|
|
695
|
+
? await queueHistory.historyTerminalBySlug()
|
|
696
|
+
: new Map();
|
|
697
|
+
|
|
698
|
+
// Terminal-in-history slugs whose .md file is still on disk: fed into the
|
|
699
|
+
// auto-archive selection pass below (as synthetic completed entries) so
|
|
700
|
+
// their file can still be swept, without ever creating a live job row
|
|
701
|
+
// for them (that row already exists, durably, in history.jsonl).
|
|
702
|
+
const historyArchiveCandidates = [];
|
|
703
|
+
|
|
628
704
|
for (const [slug, p] of onDisk) {
|
|
629
705
|
if (seen.has(slug)) continue;
|
|
706
|
+
const hist = historyBySlug.get(slug);
|
|
707
|
+
if (hist) {
|
|
708
|
+
if (hist.status === 'completed') {
|
|
709
|
+
historyArchiveCandidates.push({ slug, status: hist.status, finishedAt: hist.finishedAt });
|
|
710
|
+
}
|
|
711
|
+
// 'failed' (or any other terminal status found in history) is left
|
|
712
|
+
// alone entirely: not resurrected, not archived — matches "needs_
|
|
713
|
+
// review and failed PRD files are NEVER auto-archived."
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
630
716
|
const entry = {
|
|
631
717
|
slug,
|
|
632
718
|
title: p.title,
|
|
@@ -651,7 +737,44 @@ async function reconcile(state) {
|
|
|
651
737
|
}
|
|
652
738
|
next.push(entry);
|
|
653
739
|
}
|
|
654
|
-
|
|
740
|
+
const sorted = next.sort((a, b) => b.slug.localeCompare(a.slug));
|
|
741
|
+
|
|
742
|
+
// Move terminal jobs past the retention window out to history.jsonl so
|
|
743
|
+
// queue.json (mutation cost, broadcast payload, pickNextBatch scan) stays
|
|
744
|
+
// small. Append BEFORE dropping so a crash between the two can't lose a
|
|
745
|
+
// record — appendHistory dedupes by slug+runId, so a replay of the same
|
|
746
|
+
// batch on next boot is a safe no-op.
|
|
747
|
+
const nowMs = Date.now();
|
|
748
|
+
const { hot, toArchive } = queueHistory.partitionJobs(sorted, nowMs);
|
|
749
|
+
if (toArchive.length > 0) {
|
|
750
|
+
await queueHistory.appendHistory(toArchive);
|
|
751
|
+
console.log(`[scheduler] queue history: archived ${toArchive.length} job(s), jobs[] ${sorted.length} -> ${hot.length}`);
|
|
752
|
+
state.jobs = hot;
|
|
753
|
+
} else {
|
|
754
|
+
state.jobs = sorted;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Auto-archive completed PRDs' .md files out of the live prds/ dir. Runs
|
|
758
|
+
// AFTER the history append above (which is awaited) so a job's queue row
|
|
759
|
+
// is always durably in history.jsonl before its file can be moved — a
|
|
760
|
+
// file is never removed ahead of its queue row. `sorted` (not `hot`) is
|
|
761
|
+
// passed so a job that just crossed retention THIS pass is eligible for
|
|
762
|
+
// file-archiving in the same pass, per its already-persisted history row.
|
|
763
|
+
// `historyArchiveCandidates` adds slugs whose row left jobs[] on an
|
|
764
|
+
// EARLIER pass (or before this feature shipped) — same predicate applies
|
|
765
|
+
// via selectAutoArchivable, they just don't get a live job row.
|
|
766
|
+
try {
|
|
767
|
+
const archiveInput = historyArchiveCandidates.length > 0
|
|
768
|
+
? sorted.concat(historyArchiveCandidates)
|
|
769
|
+
: sorted;
|
|
770
|
+
const archiveResult = await queueOps.autoArchiveCompleted({ jobs: archiveInput }, { nowMs });
|
|
771
|
+
if (archiveResult.archived > 0) {
|
|
772
|
+
console.log(`[scheduler] auto-archived ${archiveResult.archived} completed PRD file(s) -> ${archiveResult.archivedTo}`);
|
|
773
|
+
}
|
|
774
|
+
} catch (e) {
|
|
775
|
+
console.warn('[scheduler] autoArchiveCompleted failed', e?.message);
|
|
776
|
+
}
|
|
777
|
+
|
|
655
778
|
return state;
|
|
656
779
|
}
|
|
657
780
|
|
|
@@ -775,6 +898,10 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
|
|
|
775
898
|
lastFailureKind,
|
|
776
899
|
},
|
|
777
900
|
memGate: lastMemGate,
|
|
901
|
+
effectiveConcurrency: {
|
|
902
|
+
cap: getEnvCap() ?? state.config.concurrencyCap,
|
|
903
|
+
source: getEnvCap() != null ? 'env' : 'config',
|
|
904
|
+
},
|
|
778
905
|
};
|
|
779
906
|
if (withPaths) {
|
|
780
907
|
payload.paths = { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: QUEUE_PATH };
|
|
@@ -782,12 +909,32 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
|
|
|
782
909
|
return payload;
|
|
783
910
|
}
|
|
784
911
|
|
|
785
|
-
|
|
912
|
+
// Trailing-edge debounce for the `schedule:state` IPC push: a burst of
|
|
913
|
+
// broadcast() calls (boot reverify healing several rows, poll-loop refreshes,
|
|
914
|
+
// queue-linter fixups) arms one BROADCAST_COALESCE_MS timer and sends a
|
|
915
|
+
// single payload built fresh at fire time, instead of one full-payload push
|
|
916
|
+
// per mutation. Callers where latency matters (pause/resume, job
|
|
917
|
+
// start/finish/reap/reset) pass `{ flush: true }` to bypass the window and
|
|
918
|
+
// send immediately.
|
|
919
|
+
const broadcastCoalescer = createBroadcastCoalescer({
|
|
920
|
+
delayMs: BROADCAST_COALESCE_MS,
|
|
921
|
+
send: (payload) => {
|
|
922
|
+
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
923
|
+
sendIfAlive(mainWindow, 'schedule:state', payload);
|
|
924
|
+
},
|
|
925
|
+
getPayload: async () => buildScheduleStatePayload(await readQueue()),
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
async function broadcast(opts = {}) {
|
|
786
929
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
787
930
|
const state = await readQueue();
|
|
788
931
|
await reconcile(state);
|
|
789
932
|
await writeQueue(state);
|
|
790
|
-
|
|
933
|
+
if (opts.flush) {
|
|
934
|
+
await broadcastCoalescer.flush();
|
|
935
|
+
} else {
|
|
936
|
+
broadcastCoalescer.schedule();
|
|
937
|
+
}
|
|
791
938
|
}
|
|
792
939
|
|
|
793
940
|
function clearFireTimer() {
|
|
@@ -855,7 +1002,7 @@ async function setPaused(reason, resumeAtIso) {
|
|
|
855
1002
|
s.paused = { reason, since: new Date().toISOString(), resumeAt: effectiveResumeAt || null };
|
|
856
1003
|
}
|
|
857
1004
|
});
|
|
858
|
-
await broadcast();
|
|
1005
|
+
await broadcast({ flush: true });
|
|
859
1006
|
cancelToken.cancelled = true;
|
|
860
1007
|
if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; }
|
|
861
1008
|
if (!effectiveResumeAt) return;
|
|
@@ -897,7 +1044,7 @@ async function clearPause(source) {
|
|
|
897
1044
|
lastFailureKind = null;
|
|
898
1045
|
persistSchedulerState();
|
|
899
1046
|
}
|
|
900
|
-
if (wasPaused) await broadcast();
|
|
1047
|
+
if (wasPaused) await broadcast({ flush: true });
|
|
901
1048
|
}
|
|
902
1049
|
|
|
903
1050
|
/** Mutate a job in place to "pending" with cleared run metadata. */
|
|
@@ -1312,6 +1459,17 @@ ${logTail}
|
|
|
1312
1459
|
1. Read the full failure log at ${failedLogPath} if the tail above isn't sufficient.
|
|
1313
1460
|
2. Read source files in ${cwd} as needed to understand the context.
|
|
1314
1461
|
3. Identify the root cause of the failure.
|
|
1462
|
+
3.5. Before writing the fix-plan PRD file, print a block summarizing the root
|
|
1463
|
+
cause in 15 lines or fewer, in exactly this form (plain text between the
|
|
1464
|
+
tags, no markdown fences):
|
|
1465
|
+
|
|
1466
|
+
<RCA>
|
|
1467
|
+
<your root-cause summary here>
|
|
1468
|
+
</RCA>
|
|
1469
|
+
|
|
1470
|
+
This is parsed out of your transcript and folded into the RCA feedback item
|
|
1471
|
+
already filed for this job, so it must stand alone (the reader won't have
|
|
1472
|
+
your reasoning, only this block).
|
|
1315
1473
|
4. Write a NEW fix-plan PRD file at exactly this path:
|
|
1316
1474
|
|
|
1317
1475
|
${fixPath}
|
|
@@ -1482,6 +1640,21 @@ async function spawnInvestigation(failedJob, runDir) {
|
|
|
1482
1640
|
return;
|
|
1483
1641
|
}
|
|
1484
1642
|
sl(`\n[scheduler] investigation exit code=${exitCode}\n`);
|
|
1643
|
+
// Fold the investigation's <RCA> summary into the RCA feedback item already
|
|
1644
|
+
// filed for this job (needs_review jobs only — fileRcaFeedback no-ops when
|
|
1645
|
+
// failedJob has no verifierVerdict, e.g. plain 'failed' jobs never got one).
|
|
1646
|
+
const investigationText = extractRcaBlock(readTail(investigationLogPath, 64 * 1024));
|
|
1647
|
+
if (investigationText) {
|
|
1648
|
+
fileRcaFeedback({
|
|
1649
|
+
job: failedJob,
|
|
1650
|
+
runDir,
|
|
1651
|
+
verdict: failedJob.verifierVerdict,
|
|
1652
|
+
annotations: failedJob.verifierAnnotations,
|
|
1653
|
+
investigationText,
|
|
1654
|
+
}).catch((e) => {
|
|
1655
|
+
console.error('[scheduler] fileRcaFeedback (investigation enrichment) error', failedJob.slug, e);
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1485
1658
|
if (fs.existsSync(fixPath)) {
|
|
1486
1659
|
console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
|
|
1487
1660
|
} else {
|
|
@@ -1522,7 +1695,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1522
1695
|
s.jobs[idx].startedAt = new Date().toISOString();
|
|
1523
1696
|
}
|
|
1524
1697
|
});
|
|
1525
|
-
await broadcast();
|
|
1698
|
+
await broadcast({ flush: true });
|
|
1526
1699
|
|
|
1527
1700
|
// Commit-guard baseline: snapshot the working tree BEFORE the run so the
|
|
1528
1701
|
// post-run check flags only paths THIS job left dirty, not pre-existing WIP.
|
|
@@ -1538,7 +1711,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1538
1711
|
s.jobs[idx].runtime = { pid, runId, startedAt: s.jobs[idx].startedAt, sessionId, cwd };
|
|
1539
1712
|
}
|
|
1540
1713
|
});
|
|
1541
|
-
await broadcast();
|
|
1714
|
+
await broadcast({ flush: true });
|
|
1542
1715
|
});
|
|
1543
1716
|
|
|
1544
1717
|
if (res.rateLimited) {
|
|
@@ -1635,6 +1808,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1635
1808
|
let failedJobSnapshot = null;
|
|
1636
1809
|
let needsInvestigationNow = false;
|
|
1637
1810
|
let investigationJobSnapshot = null;
|
|
1811
|
+
let needsReviewRcaSnapshot = null;
|
|
1638
1812
|
await mutate((s) => {
|
|
1639
1813
|
const i2 = s.jobs.findIndex((x) => x.slug === job.slug);
|
|
1640
1814
|
if (i2 >= 0) {
|
|
@@ -1692,6 +1866,12 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1692
1866
|
actuallyFailed = true;
|
|
1693
1867
|
failedJobSnapshot = { ...s.jobs[i2] };
|
|
1694
1868
|
} else if (effectiveStatus === 'needs_review') {
|
|
1869
|
+
// Snapshot for the RCA feedback hook (fired outside mutate(), below).
|
|
1870
|
+
// Every needs_review transition gets an RCA — including uncommitted_changes,
|
|
1871
|
+
// the least self-healing verdict — but never a rate-limit pause (that
|
|
1872
|
+
// takes the treatAsPending branch above and never reaches here).
|
|
1873
|
+
needsReviewRcaSnapshot = { ...s.jobs[i2] };
|
|
1874
|
+
|
|
1695
1875
|
// Same-tick auto-fix (feedback 2026-07-12): rather than waiting up to
|
|
1696
1876
|
// 10 min for reverifyNeedsReview()'s periodic pass, check right here
|
|
1697
1877
|
// whether this job qualifies for auto-fix (same eligibility rule
|
|
@@ -1738,7 +1918,20 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1738
1918
|
}
|
|
1739
1919
|
}
|
|
1740
1920
|
});
|
|
1741
|
-
await broadcast();
|
|
1921
|
+
await broadcast({ flush: true });
|
|
1922
|
+
|
|
1923
|
+
if (needsReviewRcaSnapshot) {
|
|
1924
|
+
// Fire-and-forget, mirroring the DoD drain hook: never blocks the status
|
|
1925
|
+
// transition, never throws to this caller.
|
|
1926
|
+
fileRcaFeedback({
|
|
1927
|
+
job: needsReviewRcaSnapshot,
|
|
1928
|
+
runDir,
|
|
1929
|
+
verdict: needsReviewRcaSnapshot.verifierVerdict,
|
|
1930
|
+
annotations: needsReviewRcaSnapshot.verifierAnnotations,
|
|
1931
|
+
}).catch((e) => {
|
|
1932
|
+
console.error('[scheduler] fileRcaFeedback error', job.slug, e);
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1742
1935
|
|
|
1743
1936
|
if (actuallyFailed && failedJobSnapshot) {
|
|
1744
1937
|
// Transient-failure detector. A 143/137 exit is ALWAYS a signal kill — the
|
|
@@ -1786,7 +1979,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1786
1979
|
s.jobs[i].transientRetries = decision.retries + 1;
|
|
1787
1980
|
}
|
|
1788
1981
|
});
|
|
1789
|
-
await broadcast();
|
|
1982
|
+
await broadcast({ flush: true });
|
|
1790
1983
|
} else if (decision.action === 'fail-dirty') {
|
|
1791
1984
|
console.log(`[scheduler] transient failure (${decision.transientKind}) for ${job.slug} left ${newlyDirtyCount} uncommitted file(s) (e.g. ${dirtySample}) — not auto-requeuing`);
|
|
1792
1985
|
await mutate((s) => {
|
|
@@ -1796,7 +1989,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1796
1989
|
s.jobs[i].error = `transient failure (${decision.transientKind}) left ${newlyDirtyCount} uncommitted file(s) in working tree (e.g. ${dirtySample}) — not auto-requeued to avoid overwriting partial work; review and commit or discard manually`;
|
|
1797
1990
|
}
|
|
1798
1991
|
});
|
|
1799
|
-
await broadcast();
|
|
1992
|
+
await broadcast({ flush: true });
|
|
1800
1993
|
// No auto-fix investigation: this isn't a code defect, and the
|
|
1801
1994
|
// dirty tree needs a human, not a diagnosis run.
|
|
1802
1995
|
} else if (decision.action === 'fail-cap') {
|
|
@@ -1810,7 +2003,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1810
2003
|
s.jobs[i].error = `transient failure (${decision.transientKind}) exhausted retry cap (${decision.retries}/${TRANSIENT_RETRY_CAP}) — marking failed without auto-fix investigation`;
|
|
1811
2004
|
}
|
|
1812
2005
|
});
|
|
1813
|
-
await broadcast();
|
|
2006
|
+
await broadcast({ flush: true });
|
|
1814
2007
|
} else {
|
|
1815
2008
|
spawnInvestigation(failedJobSnapshot, runDir).catch((e) => {
|
|
1816
2009
|
console.error('[scheduler] spawnInvestigation error', job.slug, e);
|
|
@@ -1846,7 +2039,7 @@ function tickQueue() {
|
|
|
1846
2039
|
if (cancelToken.cancelled) return { fired: false, reason: 'cancelled' };
|
|
1847
2040
|
|
|
1848
2041
|
await reconcile(state);
|
|
1849
|
-
const cap =
|
|
2042
|
+
const cap = getEnvCap() ?? state.config.concurrencyCap;
|
|
1850
2043
|
const { batch, reason: holdReason } = pickNextBatch(state.jobs, runningSet, cap);
|
|
1851
2044
|
if (batch.length === 0) {
|
|
1852
2045
|
// Queue drained — run the definition-of-done gate fire-and-forget.
|
|
@@ -2009,7 +2202,7 @@ async function reapDeadRunningJobs() {
|
|
|
2009
2202
|
}
|
|
2010
2203
|
});
|
|
2011
2204
|
|
|
2012
|
-
await broadcast();
|
|
2205
|
+
await broadcast({ flush: true });
|
|
2013
2206
|
tickQueue().catch(() => {});
|
|
2014
2207
|
} catch (e) {
|
|
2015
2208
|
console.warn('[scheduler] reapDeadRunningJobs error', e?.message);
|
|
@@ -2149,13 +2342,26 @@ async function pollLoop() {
|
|
|
2149
2342
|
|
|
2150
2343
|
// ---------- IPC ----------
|
|
2151
2344
|
|
|
2152
|
-
// Pure helper: filter to completed/failed,
|
|
2153
|
-
//
|
|
2154
|
-
//
|
|
2155
|
-
|
|
2345
|
+
// Pure helper: filter to completed/failed, merge with archived history
|
|
2346
|
+
// entries (now that terminal jobs age out of jobs[] into history.jsonl —
|
|
2347
|
+
// see queueHistory.cjs), dedupe by slug+runId (jobs[] wins on overlap —
|
|
2348
|
+
// it's the fresher copy in the append-before-drop crash window), sort
|
|
2349
|
+
// newest-finished first, cap to limit clamped to [1, 500] (default 50).
|
|
2350
|
+
// O(n log n) on queue+history size (small at any realistic scale).
|
|
2351
|
+
// Exported for unit testing. `historyEntries` defaults to [] so existing
|
|
2352
|
+
// callers/tests that only pass `jobs` are unaffected.
|
|
2353
|
+
function selectHistoryJobs(jobs, limit, historyEntries = []) {
|
|
2156
2354
|
const cap = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 50));
|
|
2157
|
-
|
|
2158
|
-
|
|
2355
|
+
const hot = (Array.isArray(jobs) ? jobs : []).filter((j) => j && (j.status === 'completed' || j.status === 'failed'));
|
|
2356
|
+
const seen = new Set(hot.map((j) => `${j.slug}|${j.runId ?? ''}`));
|
|
2357
|
+
const archived = (Array.isArray(historyEntries) ? historyEntries : []).filter((j) => {
|
|
2358
|
+
if (!j) return false;
|
|
2359
|
+
const key = `${j.slug}|${j.runId ?? ''}`;
|
|
2360
|
+
if (seen.has(key)) return false;
|
|
2361
|
+
seen.add(key);
|
|
2362
|
+
return true;
|
|
2363
|
+
});
|
|
2364
|
+
return [...hot, ...archived]
|
|
2159
2365
|
.sort((a, b) => {
|
|
2160
2366
|
const at = a.finishedAt ? Date.parse(a.finishedAt) : 0;
|
|
2161
2367
|
const bt = b.finishedAt ? Date.parse(b.finishedAt) : 0;
|
|
@@ -2550,7 +2756,7 @@ function registerScheduleHandlers() {
|
|
|
2550
2756
|
return true;
|
|
2551
2757
|
});
|
|
2552
2758
|
if (!found) return { ok: false, error: 'not found' };
|
|
2553
|
-
await broadcast();
|
|
2759
|
+
await broadcast({ flush: true });
|
|
2554
2760
|
return { ok: true };
|
|
2555
2761
|
}));
|
|
2556
2762
|
|
|
@@ -2713,7 +2919,8 @@ function registerScheduleHandlers() {
|
|
|
2713
2919
|
const limit = (payload && typeof payload.limit === 'number') ? payload.limit : 50;
|
|
2714
2920
|
try {
|
|
2715
2921
|
const state = await readQueue();
|
|
2716
|
-
|
|
2922
|
+
const historyEntries = await queueHistory.readHistory({ limit });
|
|
2923
|
+
return { ok: true, jobs: selectHistoryJobs(state.jobs, limit, historyEntries) };
|
|
2717
2924
|
} catch (e) {
|
|
2718
2925
|
return { ok: false, jobs: [], error: e?.message ?? 'read failed' };
|
|
2719
2926
|
}
|
|
@@ -2722,6 +2929,7 @@ function registerScheduleHandlers() {
|
|
|
2722
2929
|
|
|
2723
2930
|
async function init() {
|
|
2724
2931
|
ensureDirs();
|
|
2932
|
+
sweepQueueBackups().catch((e) => console.warn('[scheduler] backup sweep failed', e?.message));
|
|
2725
2933
|
|
|
2726
2934
|
// Hydrate cached state from the sidecar before any scheduling decisions.
|
|
2727
2935
|
loadSchedulerState();
|
|
@@ -2970,7 +3178,7 @@ const remote = {
|
|
|
2970
3178
|
return true;
|
|
2971
3179
|
});
|
|
2972
3180
|
if (!found) return { ok: false, error: 'not found' };
|
|
2973
|
-
await broadcast();
|
|
3181
|
+
await broadcast({ flush: true });
|
|
2974
3182
|
return { ok: true, slug, status: 'pending' };
|
|
2975
3183
|
},
|
|
2976
3184
|
|
|
@@ -3008,4 +3216,4 @@ const remote = {
|
|
|
3008
3216
|
},
|
|
3009
3217
|
};
|
|
3010
3218
|
|
|
3011
|
-
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP };
|
|
3219
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload };
|