claude-code-session-manager 0.13.1 → 0.15.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-Btu-_mZq.js → TiptapBody-D0tfDVZb.js} +1 -1
- package/dist/assets/{cssMode-EkBJI3CN.js → cssMode-C3tZkaJ9.js} +1 -1
- package/dist/assets/{freemarker2-DFbgmGC_.js → freemarker2-DEh8tC5X.js} +1 -1
- package/dist/assets/{handlebars-CUy9oTnL.js → handlebars-D_6KmsOK.js} +1 -1
- package/dist/assets/{html-CUFKxIqK.js → html-DF1KVjzv.js} +1 -1
- package/dist/assets/{htmlMode-hBSk7Fab.js → htmlMode-DEakPokt.js} +1 -1
- package/dist/assets/{index-D4dlTF2R.css → index-D-kX3T0V.css} +1 -1
- package/dist/assets/{index-BGIAQ9_i.js → index-Zg61GP50.js} +1416 -1115
- package/dist/assets/{javascript-CAH2ooMO.js → javascript-Du7a359D.js} +1 -1
- package/dist/assets/{jsonMode-BSMCRMaw.js → jsonMode-W3BJwbUD.js} +1 -1
- package/dist/assets/{liquid-CSMs05cs.js → liquid-DDj1fqca.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-DEyYbu0m.js → lspLanguageFeatures-uyEbiR-d.js} +1 -1
- package/dist/assets/{mdx-BocaqTLI.js → mdx-DUqSETvC.js} +1 -1
- package/dist/assets/{python-Dj6u2CWq.js → python-D7S2lUAn.js} +1 -1
- package/dist/assets/{razor-D5OWmIwh.js → razor-19nfZNaZ.js} +1 -1
- package/dist/assets/{tsMode-Cy6xJd5e.js → tsMode-CQke5zpL.js} +1 -1
- package/dist/assets/{typescript-CsWWOqVn.js → typescript-D4ge0PUF.js} +1 -1
- package/dist/assets/{xml-DiTZK7ii.js → xml-D42QDS7q.js} +1 -1
- package/dist/assets/{yaml-pdLhZQr9.js → yaml-CEiz9NyU.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/main/__tests__/runVerify.test.cjs +388 -0
- package/src/main/config.cjs +1 -7
- package/src/main/files.cjs +4 -37
- package/src/main/historyAggregator.cjs +37 -14
- package/src/main/index.cjs +57 -134
- package/src/main/ipcSchemas.cjs +4 -0
- package/src/main/lib/childWithLog.cjs +218 -0
- package/src/main/lib/expandHome.cjs +21 -0
- package/src/main/lib/insideHome.cjs +74 -21
- package/src/main/lib/openExternalApp.cjs +141 -0
- package/src/main/pluginInstall.cjs +39 -1
- package/src/main/pty.cjs +11 -4
- package/src/main/queueOps.cjs +2 -2
- package/src/main/runVerify.cjs +527 -0
- package/src/main/scheduler.cjs +335 -286
- package/src/main/search.cjs +1 -6
- package/src/main/superagent.cjs +10 -0
- package/src/main/supervisor.cjs +16 -8
- package/src/main/transcripts.cjs +18 -0
- package/src/main/usageMatrix.cjs +336 -0
- package/src/main/watchers.cjs +2 -2
- package/src/preload/api.d.ts +68 -7
- package/src/preload/index.cjs +9 -0
package/src/main/scheduler.cjs
CHANGED
|
@@ -44,7 +44,6 @@ const fs = require('node:fs');
|
|
|
44
44
|
const fsp = require('node:fs/promises');
|
|
45
45
|
const path = require('node:path');
|
|
46
46
|
const os = require('node:os');
|
|
47
|
-
const { spawn } = require('node:child_process');
|
|
48
47
|
const { randomUUID } = require('node:crypto');
|
|
49
48
|
const { ipcMain } = require('electron');
|
|
50
49
|
const billing = require('./usage.cjs');
|
|
@@ -52,10 +51,12 @@ const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
|
52
51
|
const supervisor = require('./supervisor.cjs');
|
|
53
52
|
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
54
53
|
const { readTail } = require('./lib/fileTail.cjs');
|
|
54
|
+
const { openLog, withChildAndLog } = require('./lib/childWithLog.cjs');
|
|
55
55
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
56
56
|
const prdParser = require('./scheduler/prdParser.cjs');
|
|
57
|
+
const { verifyRun } = require('./runVerify.cjs');
|
|
57
58
|
const logs = require('./logs.cjs');
|
|
58
|
-
const { schemas } = require('./ipcSchemas.cjs');
|
|
59
|
+
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
59
60
|
const {
|
|
60
61
|
POLL_INTERVAL_MS,
|
|
61
62
|
USAGE_REFRESH_INTERVAL_MS,
|
|
@@ -146,7 +147,6 @@ function ensureDirs() {
|
|
|
146
147
|
// would deadlock the exit path.
|
|
147
148
|
const config = require('./config.cjs');
|
|
148
149
|
const atomicWriteJsonSync = (p, data) => config.writeJsonSync(p, data);
|
|
149
|
-
const atomicWriteJson = (p, data) => config.writeJson(p, data);
|
|
150
150
|
|
|
151
151
|
// ---------- scheduler-state.json (sidecar) ----------
|
|
152
152
|
|
|
@@ -168,7 +168,7 @@ function persistSchedulerState() {
|
|
|
168
168
|
// require threading awaits through pause/resume bookkeeping for negligible
|
|
169
169
|
// benefit — the file is well under one page.
|
|
170
170
|
try {
|
|
171
|
-
|
|
171
|
+
config.writeJsonSync(SCHEDULER_STATE_PATH, {
|
|
172
172
|
version: 1,
|
|
173
173
|
lastObservedReset: cachedNextReset,
|
|
174
174
|
lastResetObservedAt: cachedNextReset ? Date.now() : null,
|
|
@@ -242,7 +242,7 @@ async function readQueue() {
|
|
|
242
242
|
|
|
243
243
|
async function writeQueue(state) {
|
|
244
244
|
ensureDirs();
|
|
245
|
-
await
|
|
245
|
+
await config.writeJson(QUEUE_PATH, state);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
// ---------- serialized mutation queue ----------
|
|
@@ -608,21 +608,16 @@ function resetJobFields(job, errorMsg) {
|
|
|
608
608
|
job.exitCode = null;
|
|
609
609
|
job.error = errorMsg ?? null;
|
|
610
610
|
delete job.runtime;
|
|
611
|
+
delete job.verifierVerdict;
|
|
611
612
|
}
|
|
612
613
|
|
|
613
614
|
/** Scan the tail of a job's log for the canonical rate-limit signal. We look
|
|
614
|
-
* at the last 16 KB — final result event always lands at the end.
|
|
615
|
+
* at the last 16 KB — final result event always lands at the end.
|
|
616
|
+
* Uses readTail() so no raw fd lifecycle is needed here. */
|
|
615
617
|
function detectRateLimitInLog(logPath) {
|
|
616
618
|
try {
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
const len = stat.size - start;
|
|
620
|
-
if (len <= 0) return false;
|
|
621
|
-
const fd = fs.openSync(logPath, 'r');
|
|
622
|
-
const buf = Buffer.alloc(len);
|
|
623
|
-
fs.readSync(fd, buf, 0, len, start);
|
|
624
|
-
fs.closeSync(fd);
|
|
625
|
-
const text = buf.toString('utf8');
|
|
619
|
+
const text = readTail(logPath, 16384);
|
|
620
|
+
if (!text) return false;
|
|
626
621
|
return /"rateLimitType":"five_hour"/.test(text)
|
|
627
622
|
|| /"api_error_status":429/.test(text)
|
|
628
623
|
|| /You'?ve hit your limit/.test(text);
|
|
@@ -644,6 +639,10 @@ function pickRunDir() {
|
|
|
644
639
|
* Execute a single PRD job. Writes stdout/stderr to a log file and a meta
|
|
645
640
|
* JSON sidecar. Accepts an optional onPid(pid) callback called synchronously
|
|
646
641
|
* after spawn so callers can persist the pid before the job finishes.
|
|
642
|
+
*
|
|
643
|
+
* Uses withChildAndLog for the child lifecycle (fd open/close, watchdog timers).
|
|
644
|
+
* Watchdogs are declared as an array; the result-tailer's exit-code mapping
|
|
645
|
+
* (success+killedBySignal → 0) is scheduler-specific and lives in onExit.
|
|
647
646
|
*/
|
|
648
647
|
async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
649
648
|
const logPath = path.join(runDir, `${job.slug}.log`);
|
|
@@ -652,17 +651,10 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
652
651
|
const startedAt = Date.now();
|
|
653
652
|
const sessionId = randomUUID();
|
|
654
653
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
// path. Pre-fix, a post-result/idle watchdog firing AFTER closeFd would
|
|
660
|
-
// throw EBADF and crash the host. Every fs.writeSync(fd, …) below goes
|
|
661
|
-
// through this helper.
|
|
662
|
-
const safeLog = (msg) => {
|
|
663
|
-
if (fdClosed) return;
|
|
664
|
-
try { fs.writeSync(fd, msg); } catch { /* fd vanished mid-write */ }
|
|
665
|
-
};
|
|
654
|
+
// Phase 1: open log fd so we can emit pre-spawn diagnostics (early-exit
|
|
655
|
+
// error paths) before the child is created. withChildAndLog takes ownership
|
|
656
|
+
// of fd/safeLog/closeFd from the point it is called.
|
|
657
|
+
const { fd, safeLog, closeFd } = openLog(logPath);
|
|
666
658
|
|
|
667
659
|
safeLog(`[scheduler] starting ${job.slug} at ${new Date().toISOString()}\n[scheduler] cwd=${cwd}\n\n`);
|
|
668
660
|
|
|
@@ -676,7 +668,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
676
668
|
// Sync write: this is an early-exit error path inside an async function,
|
|
677
669
|
// so we could await, but using the sync variant keeps the error path
|
|
678
670
|
// ordering identical to the spawn-failed branch below (also sync).
|
|
679
|
-
|
|
671
|
+
config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs: 0 });
|
|
680
672
|
return { exitCode: -1, durationMs: 0, error: errMsg, sessionId };
|
|
681
673
|
}
|
|
682
674
|
|
|
@@ -696,7 +688,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
696
688
|
if (!promptCheck.ok) {
|
|
697
689
|
safeLog(`[scheduler] ${promptCheck.error}\n`);
|
|
698
690
|
closeFd();
|
|
699
|
-
|
|
691
|
+
config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: promptCheck.error, startedAt, finishedAt: Date.now(), durationMs: 0 });
|
|
700
692
|
return { exitCode: -1, durationMs: 0, error: promptCheck.error, sessionId };
|
|
701
693
|
}
|
|
702
694
|
|
|
@@ -706,178 +698,182 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
706
698
|
// launched from a `claude` shell. CLAUDE_EFFORT=xhigh forces Opus and
|
|
707
699
|
// overrides `--model sonnet`, so scheduled jobs burn Opus credits silently.
|
|
708
700
|
const childEnv = cleanChildEnv();
|
|
709
|
-
// Guard against synchronous spawn failures (EAGAIN, ENOMEM on fork).
|
|
710
|
-
// Without this, the throw bubbles out of the Promise executor and the
|
|
711
|
-
// outer await rejects — but the open fd is leaked.
|
|
712
|
-
let child;
|
|
713
|
-
try {
|
|
714
|
-
child = spawn(claudeBin, [
|
|
715
|
-
'-p', prompt,
|
|
716
|
-
'--model', 'sonnet',
|
|
717
|
-
'--dangerously-skip-permissions',
|
|
718
|
-
'--output-format', 'stream-json',
|
|
719
|
-
'--verbose',
|
|
720
|
-
'--session-id', sessionId,
|
|
721
|
-
], {
|
|
722
|
-
cwd,
|
|
723
|
-
env: childEnv,
|
|
724
|
-
stdio: ['ignore', fd, fd],
|
|
725
|
-
// detached:true puts the child in its own process group so we can kill
|
|
726
|
-
// the entire descendant tree (including any stray background bashes the
|
|
727
|
-
// agent spawned) with `process.kill(-pid)`. Without this, child.kill()
|
|
728
|
-
// only kills the immediate `claude` process, leaving orphaned subprocs
|
|
729
|
-
// that keep the parent alive (the 2026-05-10 cellar-publish hang).
|
|
730
|
-
detached: true,
|
|
731
|
-
});
|
|
732
|
-
} catch (e) {
|
|
733
|
-
const errMsg = `spawn failed: ${e?.message ?? String(e)}`;
|
|
734
|
-
safeLog(`[scheduler] ${errMsg}\n`);
|
|
735
|
-
closeFd();
|
|
736
|
-
const durationMs = Date.now() - startedAt;
|
|
737
|
-
// Sync write: inside the Promise executor, before resolve(). Awaiting
|
|
738
|
-
// here would require restructuring the executor; the meta file is tiny
|
|
739
|
-
// and this is an error path, not the IPC hot path.
|
|
740
|
-
atomicWriteJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs });
|
|
741
|
-
resolve({ exitCode: -1, durationMs, error: errMsg, sessionId });
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
safeLog(`[scheduler] spawned pid=${child.pid} sessionId=${sessionId} (process group)\n\n`);
|
|
746
|
-
|
|
747
|
-
// Fire-and-forget pid persistence — best effort.
|
|
748
|
-
if (onPid) onPid(child.pid, sessionId, cwd).catch(() => {});
|
|
749
701
|
|
|
750
702
|
// Track whether the agent has emitted a `result` event in its JSONL stream.
|
|
751
|
-
// null until seen; then one of "success" | "error_max_turns" |
|
|
703
|
+
// null until seen; then one of "success" | "error_max_turns" | … per the
|
|
752
704
|
// claude harness's result subtype taxonomy.
|
|
705
|
+
// Declared here (outer scope) so the onExit handler can reference it for
|
|
706
|
+
// the success+killedBySignal → exitCode:0 mapping.
|
|
753
707
|
let agentResultSubtype = null;
|
|
754
|
-
let postResultTimer = null;
|
|
755
|
-
let postResultKillTimer = null;
|
|
756
|
-
|
|
757
|
-
const killTree = (signal) => {
|
|
758
|
-
// Kill the whole process group. Negative pid targets the group leader's
|
|
759
|
-
// group (only works because we spawned with detached:true).
|
|
760
|
-
try { process.kill(-child.pid, signal); return true; }
|
|
761
|
-
catch {
|
|
762
|
-
try { process.kill(child.pid, signal); return true; }
|
|
763
|
-
catch { return false; /* already dead */ }
|
|
764
|
-
}
|
|
765
|
-
};
|
|
766
708
|
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
// (
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
709
|
+
// ---------- watchdog declarations ----------
|
|
710
|
+
//
|
|
711
|
+
// All three use fire-once-on-condition semantics (auto-clear on first fire).
|
|
712
|
+
// Secondary timers created inside action() are registered via ctx.addTimer()
|
|
713
|
+
// so they are cleared on child exit even if fired after the primary watchdog.
|
|
714
|
+
|
|
715
|
+
// Result-tailer: scan the log tail for a {"type":"result"} event. On
|
|
716
|
+
// detection, start a grace timer — the agent declared done, so it should
|
|
717
|
+
// exit promptly. If it doesn't, SIGTERM the process group (the
|
|
718
|
+
// cellar-publish failure mode: unbounded background bashes kept the parent
|
|
719
|
+
// alive 22 min after the agent emitted result=success).
|
|
720
|
+
// Note: killedByWatchdog is set inside the cascade timer, NOT when the
|
|
721
|
+
// result is first detected, so a clean exit during the grace period leaves
|
|
722
|
+
// killedByWatchdog null (not misattributed to this watchdog).
|
|
723
|
+
const resultTailWatchdog = {
|
|
724
|
+
label: 'result-tail',
|
|
725
|
+
intervalMs: RESULT_TAIL_POLL_MS,
|
|
726
|
+
shouldFire(ctx) {
|
|
727
|
+
try {
|
|
728
|
+
const tail = readTail(ctx.logPath, RESULT_TAIL_BYTES);
|
|
729
|
+
if (!tail) return false;
|
|
730
|
+
const m = tail.match(/\{"type":"result","subtype":"([a-z_]+)"/);
|
|
731
|
+
if (!m) return false;
|
|
732
|
+
agentResultSubtype = m[1];
|
|
733
|
+
return true;
|
|
734
|
+
} catch { return false; }
|
|
735
|
+
},
|
|
736
|
+
action(ctx) {
|
|
737
|
+
ctx.safeLog(`\n[scheduler] result event detected (subtype=${agentResultSubtype}); ` +
|
|
785
738
|
`starting ${Math.round(POST_RESULT_GRACE_MS/1000)}s exit-grace timer\n`);
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
safeLog(`\n[scheduler] post-result grace expired (${Math.round(POST_RESULT_GRACE_MS/1000)}s); ` +
|
|
739
|
+
const postResultTimer = setTimeout(() => {
|
|
740
|
+
ctx.safeLog(`\n[scheduler] post-result grace expired (${Math.round(POST_RESULT_GRACE_MS/1000)}s); ` +
|
|
789
741
|
`child still alive — SIGTERM process group\n`);
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
742
|
+
ctx.killedByWatchdog = 'result-tail';
|
|
743
|
+
ctx.killTree('SIGTERM');
|
|
744
|
+
const postResultKillTimer = setTimeout(() => {
|
|
745
|
+
ctx.safeLog(`\n[scheduler] still alive ${Math.round(POST_RESULT_KILL_MS/1000)}s after SIGTERM — SIGKILL\n`);
|
|
746
|
+
ctx.killTree('SIGKILL');
|
|
794
747
|
}, POST_RESULT_KILL_MS);
|
|
795
748
|
if (postResultKillTimer.unref) postResultKillTimer.unref();
|
|
749
|
+
ctx.addTimer(postResultKillTimer);
|
|
796
750
|
}, POST_RESULT_GRACE_MS);
|
|
797
751
|
if (postResultTimer.unref) postResultTimer.unref();
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
if (resultTailer.unref) resultTailer.unref();
|
|
801
|
-
|
|
802
|
-
// Kill the child if it runs past the maximum allowed duration.
|
|
803
|
-
const watchdog = setTimeout(() => {
|
|
804
|
-
safeLog(`\n[scheduler] watchdog SIGKILL after ${MAX_JOB_DURATION_MS}ms\n`);
|
|
805
|
-
killTree('SIGKILL');
|
|
806
|
-
}, MAX_JOB_DURATION_MS);
|
|
807
|
-
if (watchdog.unref) watchdog.unref();
|
|
808
|
-
|
|
809
|
-
// Idle-output watchdog: poll log mtime every IDLE_CHECK_INTERVAL_MS; if
|
|
810
|
-
// it hasn't advanced in IDLE_OUTPUT_KILL_MS, presume the agent is stuck
|
|
811
|
-
// and SIGTERM the process group.
|
|
812
|
-
let idleKillTimer = null;
|
|
813
|
-
const idleChecker = setInterval(() => {
|
|
814
|
-
try {
|
|
815
|
-
const stat = fs.statSync(logPath);
|
|
816
|
-
const idleMs = Date.now() - stat.mtimeMs;
|
|
817
|
-
if (idleMs > IDLE_OUTPUT_KILL_MS) {
|
|
818
|
-
safeLog(`\n[scheduler] idle-output watchdog: log mtime stalled ` +
|
|
819
|
-
`${Math.round(idleMs/1000)}s (> ${Math.round(IDLE_OUTPUT_KILL_MS/1000)}s threshold) — SIGTERM process group\n`);
|
|
820
|
-
clearInterval(idleChecker);
|
|
821
|
-
killTree('SIGTERM');
|
|
822
|
-
idleKillTimer = setTimeout(() => {
|
|
823
|
-
safeLog(`\n[scheduler] idle watchdog: still alive ${Math.round(POST_RESULT_KILL_MS/1000)}s after SIGTERM — SIGKILL\n`);
|
|
824
|
-
killTree('SIGKILL');
|
|
825
|
-
}, POST_RESULT_KILL_MS);
|
|
826
|
-
if (idleKillTimer.unref) idleKillTimer.unref();
|
|
827
|
-
}
|
|
828
|
-
} catch { /* log not statable; skip */ }
|
|
829
|
-
}, IDLE_CHECK_INTERVAL_MS);
|
|
830
|
-
if (idleChecker.unref) idleChecker.unref();
|
|
831
|
-
|
|
832
|
-
const clearAllTimers = () => {
|
|
833
|
-
clearTimeout(watchdog);
|
|
834
|
-
clearInterval(resultTailer);
|
|
835
|
-
clearInterval(idleChecker);
|
|
836
|
-
if (postResultTimer) clearTimeout(postResultTimer);
|
|
837
|
-
if (postResultKillTimer) clearTimeout(postResultKillTimer);
|
|
838
|
-
if (idleKillTimer) clearTimeout(idleKillTimer);
|
|
752
|
+
ctx.addTimer(postResultTimer);
|
|
753
|
+
},
|
|
839
754
|
};
|
|
840
755
|
|
|
841
|
-
child
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
756
|
+
// Deadman: kill the child unconditionally after MAX_JOB_DURATION_MS.
|
|
757
|
+
// shouldFire: () => true means the interval fires once at intervalMs then
|
|
758
|
+
// auto-clears (fire-once-on-condition with a condition that's always true).
|
|
759
|
+
const deadmanWatchdog = {
|
|
760
|
+
label: 'deadman',
|
|
761
|
+
intervalMs: MAX_JOB_DURATION_MS,
|
|
762
|
+
shouldFire: () => true,
|
|
763
|
+
action(ctx) {
|
|
764
|
+
ctx.safeLog(`\n[scheduler] watchdog SIGKILL after ${MAX_JOB_DURATION_MS}ms\n`);
|
|
765
|
+
ctx.killedByWatchdog = 'deadman';
|
|
766
|
+
ctx.killTree('SIGKILL');
|
|
767
|
+
},
|
|
768
|
+
};
|
|
850
769
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
770
|
+
// Idle-output watchdog: if log mtime stalls for IDLE_OUTPUT_KILL_MS the
|
|
771
|
+
// agent is presumed stuck (network stall, infinite tool loop, compaction
|
|
772
|
+
// wedge). SIGTERM the group, SIGKILL after POST_RESULT_KILL_MS.
|
|
773
|
+
const idleTailWatchdog = {
|
|
774
|
+
label: 'idle-tail',
|
|
775
|
+
intervalMs: IDLE_CHECK_INTERVAL_MS,
|
|
776
|
+
shouldFire(ctx) {
|
|
777
|
+
try {
|
|
778
|
+
const stat = fs.statSync(ctx.logPath);
|
|
779
|
+
return Date.now() - stat.mtimeMs > IDLE_OUTPUT_KILL_MS;
|
|
780
|
+
} catch { return false; }
|
|
781
|
+
},
|
|
782
|
+
action(ctx) {
|
|
783
|
+
let idleMs = 0;
|
|
784
|
+
try { idleMs = Date.now() - fs.statSync(ctx.logPath).mtimeMs; } catch { /* */ }
|
|
785
|
+
ctx.safeLog(`\n[scheduler] idle-output watchdog: log mtime stalled ` +
|
|
786
|
+
`${Math.round(idleMs/1000)}s (> ${Math.round(IDLE_OUTPUT_KILL_MS/1000)}s threshold) — SIGTERM process group\n`);
|
|
787
|
+
ctx.killedByWatchdog = 'idle-tail';
|
|
788
|
+
ctx.killTree('SIGTERM');
|
|
789
|
+
const idleKillTimer = setTimeout(() => {
|
|
790
|
+
ctx.safeLog(`\n[scheduler] idle watchdog: still alive ${Math.round(POST_RESULT_KILL_MS/1000)}s after SIGTERM — SIGKILL\n`);
|
|
791
|
+
ctx.killTree('SIGKILL');
|
|
792
|
+
}, POST_RESULT_KILL_MS);
|
|
793
|
+
if (idleKillTimer.unref) idleKillTimer.unref();
|
|
794
|
+
ctx.addTimer(idleKillTimer);
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
// ---------- spawn ----------
|
|
799
|
+
|
|
800
|
+
const { child } = withChildAndLog({
|
|
801
|
+
fd,
|
|
802
|
+
logPath,
|
|
803
|
+
safeLog,
|
|
804
|
+
closeFd,
|
|
805
|
+
spawn: {
|
|
806
|
+
command: claudeBin,
|
|
807
|
+
args: [
|
|
808
|
+
'-p', prompt,
|
|
809
|
+
'--model', 'sonnet',
|
|
810
|
+
'--dangerously-skip-permissions',
|
|
811
|
+
'--output-format', 'stream-json',
|
|
812
|
+
'--verbose',
|
|
813
|
+
'--session-id', sessionId,
|
|
814
|
+
],
|
|
815
|
+
options: {
|
|
816
|
+
cwd,
|
|
817
|
+
env: childEnv,
|
|
818
|
+
// detached:true puts the child in its own process group so we can kill
|
|
819
|
+
// the entire descendant tree (including any stray background bashes the
|
|
820
|
+
// agent spawned) with `process.kill(-pid)`. Without this, child.kill()
|
|
821
|
+
// only kills the immediate `claude` process, leaving orphaned subprocs
|
|
822
|
+
// that keep the parent alive (the 2026-05-10 cellar-publish hang).
|
|
823
|
+
detached: true,
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
watchdogs: [resultTailWatchdog, deadmanWatchdog, idleTailWatchdog],
|
|
827
|
+
onExit({ exitCode, signal, killedByWatchdog: _kbw, error, spawnFailed, safeLog: sl }) {
|
|
828
|
+
const durationMs = Date.now() - startedAt;
|
|
829
|
+
|
|
830
|
+
if (error) {
|
|
831
|
+
// Covers both synchronous spawn failure and child 'error' events.
|
|
832
|
+
const errMsg = spawnFailed
|
|
833
|
+
? `spawn failed: ${error?.message ?? String(error)}`
|
|
834
|
+
: error.message;
|
|
835
|
+
sl(`\n[scheduler] ${errMsg}\n`);
|
|
836
|
+
// Sync write: inside a Promise executor callback; must flush meta
|
|
837
|
+
// before resolve() so the spawnJob mutate() that follows sees it.
|
|
838
|
+
config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs });
|
|
839
|
+
resolve({ exitCode: -1, durationMs, error: errMsg, sessionId });
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// If we killed the child (via any watchdog or externally) AND the agent
|
|
844
|
+
// had already emitted result=success, the work succeeded; only the
|
|
845
|
+
// cleanup hung. Map the kill exit code to 0 so the job is marked
|
|
846
|
+
// completed, not failed.
|
|
847
|
+
// Node's child.on('exit') reports either code (normal) or signal (killed);
|
|
848
|
+
// when killed by signal, code is null. We also check 143 (128+SIGTERM)
|
|
849
|
+
// and 137 (128+SIGKILL) in case the process exited via signal-as-code.
|
|
850
|
+
let effectiveCode = exitCode;
|
|
851
|
+
const killedBySignal = signal === 'SIGTERM' || signal === 'SIGKILL' || exitCode === 143 || exitCode === 137 || exitCode === null;
|
|
852
|
+
const mappedToSuccess = agentResultSubtype === 'success' && killedBySignal;
|
|
853
|
+
if (mappedToSuccess) {
|
|
854
|
+
effectiveCode = 0;
|
|
855
|
+
sl(`\n[scheduler] mapping exit code=${exitCode} signal=${signal} → 0 ` +
|
|
856
|
+
`(result=success was emitted before kill)\n`);
|
|
857
|
+
}
|
|
858
|
+
sl(`\n[scheduler] exit code=${effectiveCode} (raw code=${exitCode} signal=${signal}) ` +
|
|
859
|
+
`duration=${Math.round(durationMs / 1000)}s\n`);
|
|
860
|
+
const rateLimited = effectiveCode !== 0 && detectRateLimitInLog(logPath);
|
|
861
|
+
// Sync write: child 'exit' handler must flush meta before resolve()
|
|
862
|
+
// so the spawnJob mutate() that follows sees the persisted exit code.
|
|
863
|
+
config.writeJsonSync(metaPath, {
|
|
864
|
+
slug: job.slug, cwd, sessionId, exitCode: effectiveCode, rateLimited,
|
|
865
|
+
startedAt, finishedAt: Date.now(), durationMs,
|
|
866
|
+
agentResultSubtype, mappedFromSignal: mappedToSuccess ? signal || `code=${exitCode}` : null,
|
|
867
|
+
});
|
|
868
|
+
resolve({ exitCode: effectiveCode, durationMs, rateLimited, sessionId });
|
|
869
|
+
},
|
|
880
870
|
});
|
|
871
|
+
|
|
872
|
+
if (child) {
|
|
873
|
+
safeLog(`[scheduler] spawned pid=${child.pid} sessionId=${sessionId} (process group)\n\n`);
|
|
874
|
+
// Fire-and-forget pid persistence — best effort.
|
|
875
|
+
if (onPid) onPid(child.pid, sessionId, cwd).catch(() => {});
|
|
876
|
+
}
|
|
881
877
|
});
|
|
882
878
|
}
|
|
883
879
|
|
|
@@ -920,7 +916,8 @@ function pickNextBatch(allJobs, running, cap) {
|
|
|
920
916
|
// unblock the gate, but the default is to halt until the failure is
|
|
921
917
|
// acknowledged.
|
|
922
918
|
const blockingFailures = allJobs.filter((j) =>
|
|
923
|
-
j.status === 'failed'
|
|
919
|
+
(j.status === 'failed' || j.status === 'needs_review') &&
|
|
920
|
+
(j.parallelGroup ?? 99) < lowestPendingGroup,
|
|
924
921
|
);
|
|
925
922
|
if (blockingFailures.length > 0) {
|
|
926
923
|
const slugs = blockingFailures.map((j) => j.slug).join(', ');
|
|
@@ -1096,66 +1093,75 @@ ${logTail}
|
|
|
1096
1093
|
|
|
1097
1094
|
DO NOT attempt the fix. ONLY write the file. When the file exists, exit immediately.`;
|
|
1098
1095
|
|
|
1099
|
-
|
|
1096
|
+
// Phase 1: open log fd for pre-spawn diagnostics.
|
|
1097
|
+
const { fd, safeLog, closeFd } = openLog(investigationLogPath);
|
|
1100
1098
|
const sessionId = randomUUID();
|
|
1101
|
-
|
|
1102
|
-
fs.writeSync(fd, `[scheduler] investigation starting for ${failedJob.slug} at ${new Date().toISOString()}\n[scheduler] target fix PRD: ${fixPath}\n[scheduler] sessionId=${sessionId}\n\n`);
|
|
1103
|
-
} catch { /* */ }
|
|
1099
|
+
safeLog(`[scheduler] investigation starting for ${failedJob.slug} at ${new Date().toISOString()}\n[scheduler] target fix PRD: ${fixPath}\n[scheduler] sessionId=${sessionId}\n\n`);
|
|
1104
1100
|
|
|
1105
|
-
const claudeBin = resolveClaudeBin();
|
|
1106
|
-
const childEnv = cleanChildEnv();
|
|
1107
1101
|
const investigationPromptCheck = validatePromptForSpawn(prompt, `<investigation prompt for ${failedJob.slug}>`);
|
|
1108
1102
|
if (!investigationPromptCheck.ok) {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
return;
|
|
1112
|
-
}
|
|
1113
|
-
let child;
|
|
1114
|
-
try {
|
|
1115
|
-
child = spawn(claudeBin, [
|
|
1116
|
-
'-p', prompt,
|
|
1117
|
-
'--model', 'opus',
|
|
1118
|
-
'--dangerously-skip-permissions',
|
|
1119
|
-
'--output-format', 'stream-json',
|
|
1120
|
-
'--verbose',
|
|
1121
|
-
'--session-id', sessionId,
|
|
1122
|
-
], {
|
|
1123
|
-
cwd,
|
|
1124
|
-
env: childEnv,
|
|
1125
|
-
stdio: ['ignore', fd, fd],
|
|
1126
|
-
});
|
|
1127
|
-
} catch (e) {
|
|
1128
|
-
try { fs.writeSync(fd, `\n[scheduler] investigation spawn failed: ${e?.message ?? e}\n`); } catch { /* */ }
|
|
1129
|
-
try { fs.closeSync(fd); } catch { /* */ }
|
|
1103
|
+
safeLog(`\n[scheduler] ${investigationPromptCheck.error}\n`);
|
|
1104
|
+
closeFd();
|
|
1130
1105
|
return;
|
|
1131
1106
|
}
|
|
1132
1107
|
|
|
1133
|
-
|
|
1108
|
+
const claudeBin = resolveClaudeBin();
|
|
1109
|
+
const childEnv = cleanChildEnv();
|
|
1134
1110
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1111
|
+
// Investigation needs only a deadman watchdog — no idle-tail or result-tail
|
|
1112
|
+
// since investigations are short-running Opus probes with a hard ceiling.
|
|
1113
|
+
const deadmanWatchdog = {
|
|
1114
|
+
label: 'deadman',
|
|
1115
|
+
intervalMs: MAX_INVESTIGATION_DURATION_MS,
|
|
1116
|
+
shouldFire: () => true,
|
|
1117
|
+
action(ctx) {
|
|
1118
|
+
ctx.safeLog(`\n[scheduler] investigation watchdog SIGKILL after ${MAX_INVESTIGATION_DURATION_MS}ms\n`);
|
|
1119
|
+
ctx.killedByWatchdog = 'deadman';
|
|
1120
|
+
ctx.killTree('SIGKILL');
|
|
1121
|
+
},
|
|
1122
|
+
};
|
|
1140
1123
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1124
|
+
// Phase 2: spawn with lifecycle managed by withChildAndLog.
|
|
1125
|
+
const { child } = withChildAndLog({
|
|
1126
|
+
fd,
|
|
1127
|
+
logPath: investigationLogPath,
|
|
1128
|
+
safeLog,
|
|
1129
|
+
closeFd,
|
|
1130
|
+
spawn: {
|
|
1131
|
+
command: claudeBin,
|
|
1132
|
+
args: [
|
|
1133
|
+
'-p', prompt,
|
|
1134
|
+
'--model', 'opus',
|
|
1135
|
+
'--dangerously-skip-permissions',
|
|
1136
|
+
'--output-format', 'stream-json',
|
|
1137
|
+
'--verbose',
|
|
1138
|
+
'--session-id', sessionId,
|
|
1139
|
+
],
|
|
1140
|
+
options: { cwd, env: childEnv },
|
|
1141
|
+
},
|
|
1142
|
+
watchdogs: [deadmanWatchdog],
|
|
1143
|
+
onExit({ exitCode, error, spawnFailed, safeLog: sl }) {
|
|
1144
|
+
if (error) {
|
|
1145
|
+
const errMsg = spawnFailed
|
|
1146
|
+
? `investigation spawn failed: ${error?.message ?? String(error)}`
|
|
1147
|
+
: `investigation error: ${error.message}`;
|
|
1148
|
+
sl(`\n[scheduler] ${errMsg}\n`);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
sl(`\n[scheduler] investigation exit code=${exitCode}\n`);
|
|
1152
|
+
if (fs.existsSync(fixPath)) {
|
|
1153
|
+
console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
|
|
1154
|
+
} else {
|
|
1155
|
+
console.log(`[scheduler] investigation finished WITHOUT producing fix plan (slug=${failedJob.slug}, code=${exitCode})`);
|
|
1156
|
+
}
|
|
1157
|
+
// Trigger a tick so the new fix plan is reconciled into the queue and fired.
|
|
1158
|
+
tickQueue().catch(() => {});
|
|
1159
|
+
},
|
|
1145
1160
|
});
|
|
1146
1161
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
try { fs.closeSync(fd); } catch { /* */ }
|
|
1151
|
-
if (fs.existsSync(fixPath)) {
|
|
1152
|
-
console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
|
|
1153
|
-
} else {
|
|
1154
|
-
console.log(`[scheduler] investigation finished WITHOUT producing fix plan (slug=${failedJob.slug}, code=${code})`);
|
|
1155
|
-
}
|
|
1156
|
-
// Trigger a tick so the new fix plan is reconciled into the queue and fired.
|
|
1157
|
-
tickQueue().catch(() => {});
|
|
1158
|
-
});
|
|
1162
|
+
if (child) {
|
|
1163
|
+
safeLog(`[scheduler] investigation pid=${child.pid}\n\n`);
|
|
1164
|
+
}
|
|
1159
1165
|
}
|
|
1160
1166
|
|
|
1161
1167
|
async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
@@ -1187,6 +1193,33 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1187
1193
|
await setPaused('rate_limit', resetIso);
|
|
1188
1194
|
}
|
|
1189
1195
|
|
|
1196
|
+
// Post-run verification: for exit=0 runs, scan the transcript and check
|
|
1197
|
+
// dependency prerequisites before stamping 'completed'. This catches the
|
|
1198
|
+
// false-positive class where an agent exits cleanly while leaving failures
|
|
1199
|
+
// in its tool output (see incidents: PRD 39, 44, 56 on 2026-05-23→24).
|
|
1200
|
+
// Called outside mutate() so the queue lock is not held during I/O.
|
|
1201
|
+
let verifyResult = null;
|
|
1202
|
+
if (res.exitCode === 0 && !res.rateLimited) {
|
|
1203
|
+
const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
|
|
1204
|
+
const stateForDeps = await readQueue();
|
|
1205
|
+
verifyResult = await verifyRun({
|
|
1206
|
+
runDir,
|
|
1207
|
+
prdPath,
|
|
1208
|
+
queueEntry: job,
|
|
1209
|
+
allJobs: stateForDeps.jobs,
|
|
1210
|
+
}).catch((e) => ({
|
|
1211
|
+
verdict: 'verify_unavailable',
|
|
1212
|
+
reason: `verifier threw: ${e?.message ?? String(e)}`,
|
|
1213
|
+
downgradeTo: 'needs_review',
|
|
1214
|
+
}));
|
|
1215
|
+
if (verifyResult.verdict !== 'clean') {
|
|
1216
|
+
console.log(
|
|
1217
|
+
`[scheduler] verifier: ${job.slug} verdict=${verifyResult.verdict}` +
|
|
1218
|
+
` → ${verifyResult.downgradeTo ?? 'completed'}: ${verifyResult.reason}`,
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1190
1223
|
let actuallyFailed = false;
|
|
1191
1224
|
let failedJobSnapshot = null;
|
|
1192
1225
|
await mutate((s) => {
|
|
@@ -1196,32 +1229,74 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1196
1229
|
if (treatAsPending) {
|
|
1197
1230
|
resetJobFields(s.jobs[i2], res.rateLimited ? 'paused: rate limit' : 'paused: queue halted');
|
|
1198
1231
|
} else {
|
|
1199
|
-
|
|
1232
|
+
// Determine effective status, applying the verifier verdict for exit=0 runs.
|
|
1233
|
+
let effectiveStatus;
|
|
1234
|
+
if (res.exitCode !== 0) {
|
|
1235
|
+
effectiveStatus = 'failed';
|
|
1236
|
+
} else if (!verifyResult || verifyResult.verdict === 'clean') {
|
|
1237
|
+
effectiveStatus = 'completed';
|
|
1238
|
+
} else if (verifyResult.downgradeTo === 'pending') {
|
|
1239
|
+
// HALT or deps_unmet: reset to pending so the job re-fires.
|
|
1240
|
+
resetJobFields(s.jobs[i2], verifyResult.reason);
|
|
1241
|
+
return; // job already mutated by resetJobFields; skip the rest
|
|
1242
|
+
} else {
|
|
1243
|
+
// transcript_errors or verify_unavailable: escalate to needs_review.
|
|
1244
|
+
effectiveStatus = 'needs_review';
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
s.jobs[i2].status = effectiveStatus;
|
|
1200
1248
|
s.jobs[i2].finishedAt = new Date().toISOString();
|
|
1201
1249
|
s.jobs[i2].exitCode = res.exitCode;
|
|
1202
|
-
s.jobs[i2].error =
|
|
1250
|
+
s.jobs[i2].error = effectiveStatus === 'needs_review'
|
|
1251
|
+
? (verifyResult?.reason ?? null)
|
|
1252
|
+
: (res.error || null);
|
|
1253
|
+
// Persist the verifier's verdict string so the renderer can show it.
|
|
1254
|
+
if (verifyResult?.verdict && verifyResult.verdict !== 'clean') {
|
|
1255
|
+
s.jobs[i2].verifierVerdict = verifyResult.verdict;
|
|
1256
|
+
} else {
|
|
1257
|
+
delete s.jobs[i2].verifierVerdict;
|
|
1258
|
+
}
|
|
1203
1259
|
delete s.jobs[i2].runtime;
|
|
1204
|
-
|
|
1260
|
+
|
|
1261
|
+
if (effectiveStatus === 'failed') {
|
|
1205
1262
|
actuallyFailed = true;
|
|
1206
1263
|
failedJobSnapshot = { ...s.jobs[i2] };
|
|
1207
1264
|
}
|
|
1265
|
+
// Auto-promote: when a fix-* PRD completes successfully, the original
|
|
1266
|
+
// failed PRD's work is logically done. Flip its status to 'completed'
|
|
1267
|
+
// so the cross-group failure gate in pickNextBatch releases. Without
|
|
1268
|
+
// this, the queue stalls indefinitely behind a stale failure even
|
|
1269
|
+
// though the auto-recovery did its job.
|
|
1270
|
+
if (effectiveStatus === 'completed' && isFixPlanSlug(job.slug)) {
|
|
1271
|
+
const originalSlug = job.slug.replace(/^(\d+)-fix-/, '$1-');
|
|
1272
|
+
const orig = s.jobs.findIndex((x) => x.slug === originalSlug && x.status === 'failed');
|
|
1273
|
+
if (orig >= 0) {
|
|
1274
|
+
console.log(`[scheduler] auto-promote: ${originalSlug} (failed) → completed because ${job.slug} succeeded`);
|
|
1275
|
+
s.jobs[orig].status = 'completed';
|
|
1276
|
+
s.jobs[orig].exitCode = 0;
|
|
1277
|
+
s.jobs[orig].error = null;
|
|
1278
|
+
s.jobs[orig].completedBy = job.slug;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1208
1281
|
}
|
|
1209
1282
|
}
|
|
1210
1283
|
});
|
|
1211
1284
|
await broadcast();
|
|
1212
1285
|
|
|
1213
1286
|
if (actuallyFailed && failedJobSnapshot) {
|
|
1214
|
-
// Transient-failure detector: SIGTERM/SIGKILL within
|
|
1287
|
+
// Transient-failure detector: SIGTERM/SIGKILL within 45s = almost
|
|
1215
1288
|
// always external kill (user-initiated app restart, OOM-kill, manual
|
|
1216
|
-
// process kill). The PRD itself didn't fail; the run was
|
|
1217
|
-
// before it could do meaningful work. Spawning an Opus
|
|
1218
|
-
// these is wasted tokens AND pollutes the queue with
|
|
1219
|
-
// (real example 2026-05-21: 07-agent-view
|
|
1289
|
+
// process kill, Electron HMR). The PRD itself didn't fail; the run was
|
|
1290
|
+
// interrupted before it could do meaningful work. Spawning an Opus
|
|
1291
|
+
// investigator on these is wasted tokens AND pollutes the queue with
|
|
1292
|
+
// redundant fix-PRDs (real example 2026-05-21: 07-agent-view-... got
|
|
1220
1293
|
// SIGTERMed at 10s by an app restart, the rename had already been done
|
|
1221
|
-
// anyway
|
|
1222
|
-
//
|
|
1294
|
+
// anyway). The 45s cutoff (was 30s) catches Electron-HMR borderline
|
|
1295
|
+
// cases like PRD 26 SIGTERM'd at 33s — that was 3s over the old cutoff
|
|
1296
|
+
// and fell through to a fix-PRD that just acknowledged the work was
|
|
1297
|
+
// already done. Auto-retry up to 2x before falling through to investigation.
|
|
1223
1298
|
const ec = failedJobSnapshot.exitCode;
|
|
1224
|
-
const transient = (ec === 143 || ec === 137) && res.durationMs <
|
|
1299
|
+
const transient = (ec === 143 || ec === 137) && res.durationMs < 45_000;
|
|
1225
1300
|
const retries = failedJobSnapshot.transientRetries ?? 0;
|
|
1226
1301
|
if (transient && retries < 2) {
|
|
1227
1302
|
console.log(`[scheduler] transient failure (exit=${ec} dur=${res.durationMs}ms) — auto-retry ${retries + 1}/2 for ${job.slug}`);
|
|
@@ -1449,15 +1524,10 @@ function registerScheduleHandlers() {
|
|
|
1449
1524
|
return { ok: true };
|
|
1450
1525
|
});
|
|
1451
1526
|
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
try {
|
|
1455
|
-
validated = schemas.setConfigSchema.parse(partial || {});
|
|
1456
|
-
} catch (e) {
|
|
1457
|
-
return { ok: false, error: e?.message ?? 'invalid config' };
|
|
1458
|
-
}
|
|
1527
|
+
// .default({}) so callers may omit the payload entirely (same as the old `partial || {}`).
|
|
1528
|
+
ipcMain.handle('schedule:set-config', validated(schemas.setConfigSchema.default({}), async (data) => {
|
|
1459
1529
|
const config = await mutate((state) => {
|
|
1460
|
-
const { supervisor: supPartial, ...rest } =
|
|
1530
|
+
const { supervisor: supPartial, ...rest } = data;
|
|
1461
1531
|
state.config = { ...state.config, ...rest };
|
|
1462
1532
|
if (supPartial !== undefined) {
|
|
1463
1533
|
state.config.supervisor = { ...(state.config.supervisor ?? {}), ...supPartial };
|
|
@@ -1466,15 +1536,9 @@ function registerScheduleHandlers() {
|
|
|
1466
1536
|
});
|
|
1467
1537
|
await rescheduleTimer();
|
|
1468
1538
|
return { ok: true, config };
|
|
1469
|
-
});
|
|
1539
|
+
}));
|
|
1470
1540
|
|
|
1471
|
-
ipcMain.handle('schedule:reset-job', async (
|
|
1472
|
-
let slug;
|
|
1473
|
-
try {
|
|
1474
|
-
({ slug } = schemas.scheduleSlug.parse(payload));
|
|
1475
|
-
} catch (e) {
|
|
1476
|
-
return { ok: false, error: 'invalid slug' };
|
|
1477
|
-
}
|
|
1541
|
+
ipcMain.handle('schedule:reset-job', validated(schemas.scheduleSlug, async ({ slug }) => {
|
|
1478
1542
|
if (!safeSlugPath(slug)) return { ok: false, error: 'invalid slug' };
|
|
1479
1543
|
const found = await mutate((state) => {
|
|
1480
1544
|
const idx = state.jobs.findIndex((j) => j.slug === slug);
|
|
@@ -1485,7 +1549,7 @@ function registerScheduleHandlers() {
|
|
|
1485
1549
|
if (!found) return { ok: false, error: 'not found' };
|
|
1486
1550
|
await broadcast();
|
|
1487
1551
|
return { ok: true };
|
|
1488
|
-
});
|
|
1552
|
+
}));
|
|
1489
1553
|
|
|
1490
1554
|
ipcMain.handle('schedule:run-now', async () => {
|
|
1491
1555
|
// Manual run-now overrides any auto-pause. Clear it first.
|
|
@@ -1563,13 +1627,7 @@ function registerScheduleHandlers() {
|
|
|
1563
1627
|
return { ok: true };
|
|
1564
1628
|
});
|
|
1565
1629
|
|
|
1566
|
-
ipcMain.handle('schedule:read-prd', async (
|
|
1567
|
-
let slug;
|
|
1568
|
-
try {
|
|
1569
|
-
({ slug } = schemas.scheduleSlug.parse(payload));
|
|
1570
|
-
} catch {
|
|
1571
|
-
return { ok: false, error: 'invalid slug' };
|
|
1572
|
-
}
|
|
1630
|
+
ipcMain.handle('schedule:read-prd', validated(schemas.scheduleSlug, async ({ slug }) => {
|
|
1573
1631
|
const filePath = safeSlugPath(slug);
|
|
1574
1632
|
if (!filePath) return { ok: false, error: 'invalid slug' };
|
|
1575
1633
|
try {
|
|
@@ -1578,15 +1636,9 @@ function registerScheduleHandlers() {
|
|
|
1578
1636
|
} catch (e) {
|
|
1579
1637
|
return { ok: false, error: e?.message };
|
|
1580
1638
|
}
|
|
1581
|
-
});
|
|
1639
|
+
}));
|
|
1582
1640
|
|
|
1583
|
-
ipcMain.handle('schedule:read-log', async (
|
|
1584
|
-
let slug, runId;
|
|
1585
|
-
try {
|
|
1586
|
-
({ slug, runId } = schemas.scheduleReadLog.parse(payload));
|
|
1587
|
-
} catch {
|
|
1588
|
-
return { ok: false, error: 'invalid slug or runId' };
|
|
1589
|
-
}
|
|
1641
|
+
ipcMain.handle('schedule:read-log', validated(schemas.scheduleReadLog, async ({ slug, runId }) => {
|
|
1590
1642
|
// Defense-in-depth: re-check containment after path.resolve even though
|
|
1591
1643
|
// SLUG_RE / RUN_ID_RE already forbid path separators.
|
|
1592
1644
|
const logPath = path.resolve(path.join(RUNS_DIR, runId, `${slug}.log`));
|
|
@@ -1599,16 +1651,13 @@ function registerScheduleHandlers() {
|
|
|
1599
1651
|
} catch (e) {
|
|
1600
1652
|
return { ok: false, error: e?.message };
|
|
1601
1653
|
}
|
|
1602
|
-
});
|
|
1654
|
+
}));
|
|
1603
1655
|
|
|
1604
|
-
ipcMain.handle('schedule:write-prd', async (
|
|
1605
|
-
|
|
1606
|
-
try { parsed = schemas.scheduleWritePrd.parse(payload); }
|
|
1607
|
-
catch (e) { return { ok: false, error: e?.message ?? 'invalid payload' }; }
|
|
1608
|
-
const resolved = safeSlugPath(parsed.slug);
|
|
1656
|
+
ipcMain.handle('schedule:write-prd', validated(schemas.scheduleWritePrd, async (data) => {
|
|
1657
|
+
const resolved = safeSlugPath(data.slug);
|
|
1609
1658
|
if (!resolved) return { ok: false, error: 'invalid slug' };
|
|
1610
1659
|
try {
|
|
1611
|
-
await config.writeTextAtomic(resolved,
|
|
1660
|
+
await config.writeTextAtomic(resolved, data.body);
|
|
1612
1661
|
} catch (e) {
|
|
1613
1662
|
return { ok: false, error: e?.message ?? 'write failed' };
|
|
1614
1663
|
}
|
|
@@ -1618,7 +1667,7 @@ function registerScheduleHandlers() {
|
|
|
1618
1667
|
} catch (e) {
|
|
1619
1668
|
return { ok: false, error: e?.message ?? 'stat failed' };
|
|
1620
1669
|
}
|
|
1621
|
-
});
|
|
1670
|
+
}));
|
|
1622
1671
|
|
|
1623
1672
|
ipcMain.handle('schedule:list-prds', async () => {
|
|
1624
1673
|
ensureDirs();
|