claude-code-session-manager 0.35.15 → 0.35.17
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-B7PHDZwk.js → TiptapBody-BM9Kz1Nm.js} +1 -1
- package/dist/assets/index-DX2w2YhJ.css +32 -0
- package/dist/assets/index-DuoC6oCy.js +3559 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/main/__tests__/adminServer.test.cjs +205 -0
- package/src/main/__tests__/chat-queue.test.cjs +27 -0
- package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
- package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
- package/src/main/__tests__/prdCreate.test.cjs +82 -0
- package/src/main/__tests__/runVerify.test.cjs +146 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
- package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
- package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
- package/src/main/adminServer.cjs +87 -2
- package/src/main/browserCapture.cjs +21 -7
- package/src/main/chatRunner.cjs +5 -1
- package/src/main/health.cjs +114 -3
- package/src/main/hives.cjs +2 -1
- package/src/main/ipcSchemas.cjs +33 -4
- package/src/main/lib/kebabCase.cjs +12 -0
- package/src/main/lib/memorySlug.cjs +10 -0
- package/src/main/lib/prdCreate.cjs +73 -0
- package/src/main/memoryAggregate.cjs +1 -1
- package/src/main/memoryTool.cjs +2 -1
- package/src/main/pty.cjs +2 -1
- package/src/main/runVerify.cjs +119 -6
- package/src/main/scheduler/prdParser.cjs +88 -0
- package/src/main/scheduler.cjs +176 -9
- package/src/main/templates/PRD_AUTHORING.md +126 -0
- package/src/main/usage.cjs +4 -0
- package/src/main/webRemote.cjs +13 -18
- package/src/preload/api.d.ts +1 -0
- package/dist/assets/index-B39Qiq0n.css +0 -32
- package/dist/assets/index-DRSHENEg.js +0 -3559
|
@@ -973,3 +973,149 @@ test('halt result + sentinel PASS + committedDuringRun:true → still halt', asy
|
|
|
973
973
|
rmdir(tmp);
|
|
974
974
|
}
|
|
975
975
|
});
|
|
976
|
+
|
|
977
|
+
// ─── -merge-main postcondition exemption tests (2026-07-18 feedback) ──────────
|
|
978
|
+
|
|
979
|
+
const { isMergeMainSlug, extractMergeMainPrNumber } = require('../runVerify.cjs');
|
|
980
|
+
|
|
981
|
+
test('isMergeMainSlug matches the NN-prXXX-merge-main and NN-fix-prXXX-merge-main conventions', () => {
|
|
982
|
+
assert.equal(isMergeMainSlug('565-pr188-merge-main'), true);
|
|
983
|
+
assert.equal(isMergeMainSlug('571-fix-pr141-merge-main'), true);
|
|
984
|
+
assert.equal(isMergeMainSlug('523-fix-bounded-fix-plan-retry'), false);
|
|
985
|
+
assert.equal(isMergeMainSlug('406-browser-capture-panel-ui'), false);
|
|
986
|
+
assert.equal(isMergeMainSlug(''), false);
|
|
987
|
+
assert.equal(isMergeMainSlug(undefined), false);
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test('extractMergeMainPrNumber prefers "PR #<n>" in text, falls back to slug', () => {
|
|
991
|
+
assert.equal(extractMergeMainPrNumber('565-pr188-merge-main', 'title: Merge current main into sigma PR #188 (round 2)'), 188);
|
|
992
|
+
assert.equal(extractMergeMainPrNumber('565-pr188-merge-main', 'no pr mention here'), 188);
|
|
993
|
+
assert.equal(extractMergeMainPrNumber('non-standard-slug', 'no pr mention here'), null);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// (1) -merge-main + PASS + no commit + gh reports MERGEABLE/non-CONFLICTING → verified, no issue
|
|
997
|
+
test('merge-main exemption: gh reports MERGEABLE → pass_no_commit_target_verified, no issue pushed', async () => {
|
|
998
|
+
const tmp = makeTmpDir();
|
|
999
|
+
try {
|
|
1000
|
+
const slug = '565-pr188-merge-main';
|
|
1001
|
+
writeLog(tmp, slug, noOpRunEvents('PR #188 was already merged into main by another actor.\nSCHEDULER_VERDICT: PASS'));
|
|
1002
|
+
const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
|
|
1003
|
+
let ghCalls = 0;
|
|
1004
|
+
const ghExecImpl = (cmd, args, opts) => {
|
|
1005
|
+
ghCalls++;
|
|
1006
|
+
assert.equal(cmd, 'gh');
|
|
1007
|
+
assert.deepEqual(args, ['pr', 'view', '188', '--json', 'mergeable,mergeStateStatus']);
|
|
1008
|
+
assert.equal(opts.cwd, '/tmp/sigma');
|
|
1009
|
+
return JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'BEHIND' });
|
|
1010
|
+
};
|
|
1011
|
+
const verdict = await verifyRun({
|
|
1012
|
+
runDir: tmp,
|
|
1013
|
+
prdPath,
|
|
1014
|
+
queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
|
|
1015
|
+
allJobs: [],
|
|
1016
|
+
committedDuringRun: false,
|
|
1017
|
+
ghExecImpl,
|
|
1018
|
+
});
|
|
1019
|
+
assert.equal(ghCalls, 1);
|
|
1020
|
+
assert.equal(verdict.verdict, 'pass_no_commit_target_verified', `expected verified verdict, got ${verdict.verdict}: ${verdict.reason}`);
|
|
1021
|
+
assert.equal(verdict.downgradeTo, null);
|
|
1022
|
+
assert.equal(verdict.verifiedPrNumber, 188);
|
|
1023
|
+
} finally {
|
|
1024
|
+
rmdir(tmp);
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// (2) -merge-main + PASS + no commit + gh reports CONFLICTING → falls through to pass_no_commit
|
|
1029
|
+
test('merge-main exemption: gh reports CONFLICTING → falls through to pass_no_commit', async () => {
|
|
1030
|
+
const tmp = makeTmpDir();
|
|
1031
|
+
try {
|
|
1032
|
+
const slug = '565-pr188-merge-main';
|
|
1033
|
+
writeLog(tmp, slug, noOpRunEvents('PR #188 needs no further work.\nSCHEDULER_VERDICT: PASS'));
|
|
1034
|
+
const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
|
|
1035
|
+
const ghExecImpl = () => JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'CONFLICTING' });
|
|
1036
|
+
const verdict = await verifyRun({
|
|
1037
|
+
runDir: tmp,
|
|
1038
|
+
prdPath,
|
|
1039
|
+
queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
|
|
1040
|
+
allJobs: [],
|
|
1041
|
+
committedDuringRun: false,
|
|
1042
|
+
ghExecImpl,
|
|
1043
|
+
});
|
|
1044
|
+
assert.equal(verdict.verdict, 'pass_no_commit', `expected fallback to pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
|
|
1045
|
+
assert.equal(verdict.downgradeTo, 'needs_review');
|
|
1046
|
+
} finally {
|
|
1047
|
+
rmdir(tmp);
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
// (3) -merge-main + PASS + no commit + gh call fails/times out → falls through to pass_no_commit, never throws
|
|
1052
|
+
test('merge-main exemption: gh call throws → falls through to pass_no_commit, never throws', async () => {
|
|
1053
|
+
const tmp = makeTmpDir();
|
|
1054
|
+
try {
|
|
1055
|
+
const slug = '565-pr188-merge-main';
|
|
1056
|
+
writeLog(tmp, slug, noOpRunEvents('PR #188 needs no further work.\nSCHEDULER_VERDICT: PASS'));
|
|
1057
|
+
const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
|
|
1058
|
+
const ghExecImpl = () => { throw new Error('gh: command not found'); };
|
|
1059
|
+
const verdict = await verifyRun({
|
|
1060
|
+
runDir: tmp,
|
|
1061
|
+
prdPath,
|
|
1062
|
+
queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
|
|
1063
|
+
allJobs: [],
|
|
1064
|
+
committedDuringRun: false,
|
|
1065
|
+
ghExecImpl,
|
|
1066
|
+
});
|
|
1067
|
+
assert.equal(verdict.verdict, 'pass_no_commit', `expected fallback to pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
|
|
1068
|
+
assert.equal(verdict.downgradeTo, 'needs_review');
|
|
1069
|
+
} finally {
|
|
1070
|
+
rmdir(tmp);
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
// (4) non-merge-main slug, PASS + no commit → unchanged pass_no_commit behavior, zero gh invocations
|
|
1075
|
+
test('merge-main exemption: non-merge-main slug never attempts a gh call', async () => {
|
|
1076
|
+
const tmp = makeTmpDir();
|
|
1077
|
+
try {
|
|
1078
|
+
const slug = '406-browser-capture-panel-ui';
|
|
1079
|
+
writeLog(tmp, slug, noOpRunEvents('All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS'));
|
|
1080
|
+
const prdPath = writePrd(tmp, slug, '# Browser capture panel UI');
|
|
1081
|
+
let ghCalls = 0;
|
|
1082
|
+
const ghExecImpl = () => { ghCalls++; return JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' }); };
|
|
1083
|
+
const verdict = await verifyRun({
|
|
1084
|
+
runDir: tmp,
|
|
1085
|
+
prdPath,
|
|
1086
|
+
queueEntry: { slug, status: 'running', cwd: '/tmp/proj' },
|
|
1087
|
+
allJobs: [],
|
|
1088
|
+
committedDuringRun: false,
|
|
1089
|
+
ghExecImpl,
|
|
1090
|
+
});
|
|
1091
|
+
assert.equal(ghCalls, 0, 'gh must never be invoked for a non-merge-main slug');
|
|
1092
|
+
assert.equal(verdict.verdict, 'pass_no_commit', `expected unchanged pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
|
|
1093
|
+
assert.equal(verdict.downgradeTo, 'needs_review');
|
|
1094
|
+
} finally {
|
|
1095
|
+
rmdir(tmp);
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
// (5) -merge-main slug but a real commit DID land → unaffected, path unreachable, zero gh invocations
|
|
1100
|
+
test('merge-main exemption: commit landed → path unreachable, zero gh invocations, verdict clean', async () => {
|
|
1101
|
+
const tmp = makeTmpDir();
|
|
1102
|
+
try {
|
|
1103
|
+
const slug = '565-pr188-merge-main';
|
|
1104
|
+
writeLog(tmp, slug, noOpRunEvents('Merged and pushed.\nSCHEDULER_VERDICT: PASS'));
|
|
1105
|
+
const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
|
|
1106
|
+
let ghCalls = 0;
|
|
1107
|
+
const ghExecImpl = () => { ghCalls++; return JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' }); };
|
|
1108
|
+
const verdict = await verifyRun({
|
|
1109
|
+
runDir: tmp,
|
|
1110
|
+
prdPath,
|
|
1111
|
+
queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
|
|
1112
|
+
allJobs: [],
|
|
1113
|
+
committedDuringRun: true,
|
|
1114
|
+
ghExecImpl,
|
|
1115
|
+
});
|
|
1116
|
+
assert.equal(ghCalls, 0, 'gh must never be invoked when a commit already landed');
|
|
1117
|
+
assert.equal(verdict.verdict, 'clean', `expected clean (commit landed, sentinel PASS), got ${verdict.verdict}: ${verdict.reason}`);
|
|
1118
|
+
} finally {
|
|
1119
|
+
rmdir(tmp);
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* scheduler-committed-in-window.test.cjs — unit tests for committedInWindow.
|
|
3
3
|
*
|
|
4
|
-
* Run: timeout
|
|
4
|
+
* Run: timeout 120 node --test src/main/__tests__/scheduler-committed-in-window.test.cjs
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
'use strict';
|
|
8
8
|
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
9
11
|
const fs = require('node:fs');
|
|
10
12
|
const os = require('node:os');
|
|
11
13
|
const path = require('node:path');
|
|
@@ -42,7 +44,7 @@ test('committedInWindow returns true for a commit landed on a non-checked-out br
|
|
|
42
44
|
git(dir, ['checkout', '-q', '-b', 'other-branch-at-exit']);
|
|
43
45
|
|
|
44
46
|
const result = await committedInWindow(dir, startedAt, finishedAt);
|
|
45
|
-
|
|
47
|
+
assert.strictEqual(result, true);
|
|
46
48
|
} finally {
|
|
47
49
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
48
50
|
}
|
|
@@ -55,7 +57,7 @@ test('committedInWindow returns false when the window covers no commit', async (
|
|
|
55
57
|
const farFutureEnd = new Date(Date.now() + 366 * 24 * 60 * 60 * 1000).toISOString();
|
|
56
58
|
|
|
57
59
|
const result = await committedInWindow(dir, farFutureStart, farFutureEnd);
|
|
58
|
-
|
|
60
|
+
assert.strictEqual(result, false);
|
|
59
61
|
} finally {
|
|
60
62
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
61
63
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-tick-cancel-token.test.cjs — regression test for the 2026-07-14
|
|
3
|
+
* scheduler stall (PRD 543/544): the tick loop stopped picking up pending
|
|
4
|
+
* jobs after a job's run paused the queue (e.g. a rate limit), because the
|
|
5
|
+
* pause's cancelToken.cancelled flag was only ever reset inside
|
|
6
|
+
* runDueJobs() — clearPause() (used by the poll-loop's auth/network
|
|
7
|
+
* auto-recovery and the manual "Resume" button) never reset it, so every
|
|
8
|
+
* subsequent tickQueue() call short-circuited on the stale flag forever,
|
|
9
|
+
* even though queue.json's `paused` field was correctly null.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 node --test src/main/__tests__/scheduler-tick-cancel-token.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { test } = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const { applyPauseCleared, pickNextBatch } = require('../scheduler.cjs');
|
|
19
|
+
|
|
20
|
+
test('clearPause un-cancels the tick guard so the next pending job still starts', () => {
|
|
21
|
+
// A job's run pauses the queue (e.g. rate limit) — setPaused() cancels the
|
|
22
|
+
// in-flight tick batch.
|
|
23
|
+
const cancelToken = { cancelled: true };
|
|
24
|
+
|
|
25
|
+
// The pause is cleared via a recovery path OTHER than runDueJobs() (the
|
|
26
|
+
// poll-loop's auth/network auto-recovery, or the manual "Resume" button —
|
|
27
|
+
// both call clearPause() directly). Before the fix, this left
|
|
28
|
+
// cancelToken.cancelled stuck at true forever.
|
|
29
|
+
applyPauseCleared(true, cancelToken);
|
|
30
|
+
|
|
31
|
+
assert.strictEqual(
|
|
32
|
+
cancelToken.cancelled,
|
|
33
|
+
false,
|
|
34
|
+
'clearPause must un-cancel the tick guard, or every future tick short-circuits forever even though paused=null',
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// With the guard cleared, the next tick's batch-picking proceeds normally
|
|
38
|
+
// and starts the next pending job after the failed one.
|
|
39
|
+
const jobs = [
|
|
40
|
+
{ slug: '543-chat-mode-data-table-rendering', status: 'failed', cwd: '/proj' },
|
|
41
|
+
{ slug: '544-committed-in-window-test-runner-consistency', status: 'pending', cwd: '/proj' },
|
|
42
|
+
];
|
|
43
|
+
const { batch } = pickNextBatch(jobs, new Set(), 3);
|
|
44
|
+
assert.strictEqual(batch.length, 1);
|
|
45
|
+
assert.strictEqual(batch[0].slug, '544-committed-in-window-test-runner-consistency');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('applyPauseCleared is a no-op when the queue was not actually paused', () => {
|
|
49
|
+
const cancelToken = { cancelled: false };
|
|
50
|
+
applyPauseCleared(false, cancelToken);
|
|
51
|
+
assert.strictEqual(cancelToken.cancelled, false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
console.log('scheduler-tick-cancel-token tests: PASS');
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-transient-failure.test.cjs — classifying network-outage vs. real
|
|
3
|
+
* code failures so a transient outage (PRD 543/545: ENOTFOUND) gets a bounded
|
|
4
|
+
* requeue instead of burning a whole run + an auto-fix investigation on an
|
|
5
|
+
* outage that has nothing to do with the code.
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 node --test src/main/__tests__/scheduler-transient-failure.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { test } = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const {
|
|
18
|
+
detectNetworkErrorInLog,
|
|
19
|
+
detectRateLimitInLog,
|
|
20
|
+
classifyFailureOutcome,
|
|
21
|
+
TRANSIENT_RETRY_CAP,
|
|
22
|
+
} = require('../scheduler.cjs');
|
|
23
|
+
|
|
24
|
+
// Real tail captured from the 543 incident run (2026-07-14T14-40-39-306Z),
|
|
25
|
+
// per the PRD's "reuse it, don't re-derive" instruction.
|
|
26
|
+
const NETWORK_OUTAGE_LOG_TAIL = String.raw`
|
|
27
|
+
{"type":"result","subtype":"success","is_error":true,"api_error_status":null,"duration_ms":592749,"duration_api_ms":83313,"num_turns":19,"result":"API Error: Unable to connect to API (ENOTFOUND)","stop_reason":"stop_sequence","session_id":"85b1a3f4-3625-4f2d-a9f0-b3994f062b39","total_cost_usd":0.75,"terminal_reason":"api_error","uuid":"cc041889-4497-4fca-9f83-73e3dae6de54"}
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const RATE_LIMIT_LOG_TAIL = String.raw`
|
|
31
|
+
{"type":"result","subtype":"error","is_error":true,"api_error_status":429,"rateLimitType":"five_hour","result":"You've hit your limit","uuid":"abc"}
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const REAL_CODE_FAILURE_LOG_TAIL = String.raw`
|
|
35
|
+
{"type":"result","subtype":"error","is_error":true,"result":"Error: expected 2 but got 3\n at test/foo.test.ts:12:5","terminal_reason":"error_max_turns","uuid":"def"}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function writeTmpLog(contents) {
|
|
39
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transient-log-'));
|
|
40
|
+
const p = path.join(dir, 'run.log');
|
|
41
|
+
fs.writeFileSync(p, contents);
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('detectNetworkErrorInLog fires only on terminal_reason:api_error + a network-class error', () => {
|
|
46
|
+
const p = writeTmpLog(NETWORK_OUTAGE_LOG_TAIL);
|
|
47
|
+
try {
|
|
48
|
+
assert.strictEqual(detectNetworkErrorInLog(p), true);
|
|
49
|
+
} finally {
|
|
50
|
+
fs.rmSync(path.dirname(p), { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('detectNetworkErrorInLog does not fire on a genuine code failure (no terminal_reason:api_error)', () => {
|
|
55
|
+
const p = writeTmpLog(REAL_CODE_FAILURE_LOG_TAIL);
|
|
56
|
+
try {
|
|
57
|
+
assert.strictEqual(detectNetworkErrorInLog(p), false);
|
|
58
|
+
} finally {
|
|
59
|
+
fs.rmSync(path.dirname(p), { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('detectNetworkErrorInLog does not fire on a rate-limit log (rateLimited handling stays separate)', () => {
|
|
64
|
+
const p = writeTmpLog(RATE_LIMIT_LOG_TAIL);
|
|
65
|
+
try {
|
|
66
|
+
assert.strictEqual(detectNetworkErrorInLog(p), false);
|
|
67
|
+
// And the reverse: the rate-limit detector doesn't fire on the network log.
|
|
68
|
+
assert.strictEqual(detectRateLimitInLog(p), true);
|
|
69
|
+
} finally {
|
|
70
|
+
fs.rmSync(path.dirname(p), { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('detectRateLimitInLog does not fire on a network-outage log', () => {
|
|
75
|
+
const p = writeTmpLog(NETWORK_OUTAGE_LOG_TAIL);
|
|
76
|
+
try {
|
|
77
|
+
assert.strictEqual(detectRateLimitInLog(p), false);
|
|
78
|
+
} finally {
|
|
79
|
+
fs.rmSync(path.dirname(p), { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('classifyFailureOutcome: ENOTFOUND-class network error with a clean tree retries (bounded)', () => {
|
|
84
|
+
const decision = classifyFailureOutcome({
|
|
85
|
+
exitCode: 1,
|
|
86
|
+
networkError: true,
|
|
87
|
+
durationMs: 594_000,
|
|
88
|
+
transientRetries: 0,
|
|
89
|
+
newlyDirtyCount: 0,
|
|
90
|
+
});
|
|
91
|
+
assert.strictEqual(decision.action, 'retry');
|
|
92
|
+
assert.strictEqual(decision.transientKind, 'network');
|
|
93
|
+
assert.strictEqual(decision.retries, 0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('classifyFailureOutcome: network error with uncommitted work left behind refuses to requeue', () => {
|
|
97
|
+
const decision = classifyFailureOutcome({
|
|
98
|
+
exitCode: 1,
|
|
99
|
+
networkError: true,
|
|
100
|
+
durationMs: 594_000,
|
|
101
|
+
transientRetries: 0,
|
|
102
|
+
newlyDirtyCount: 2,
|
|
103
|
+
});
|
|
104
|
+
assert.strictEqual(decision.action, 'fail-dirty');
|
|
105
|
+
assert.strictEqual(decision.newlyDirtyCount, 2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('classifyFailureOutcome: retry cap exhausted fails without auto-fix, never loops unboundedly', () => {
|
|
109
|
+
const decision = classifyFailureOutcome({
|
|
110
|
+
exitCode: 1,
|
|
111
|
+
networkError: true,
|
|
112
|
+
durationMs: 594_000,
|
|
113
|
+
transientRetries: TRANSIENT_RETRY_CAP,
|
|
114
|
+
newlyDirtyCount: 0,
|
|
115
|
+
});
|
|
116
|
+
assert.strictEqual(decision.action, 'fail-cap');
|
|
117
|
+
assert.strictEqual(decision.retries, TRANSIENT_RETRY_CAP);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('classifyFailureOutcome: a genuine code failure (non-network exit 1) is still terminal + investigated', () => {
|
|
121
|
+
const decision = classifyFailureOutcome({
|
|
122
|
+
exitCode: 1,
|
|
123
|
+
networkError: false,
|
|
124
|
+
durationMs: 30_000,
|
|
125
|
+
transientRetries: 0,
|
|
126
|
+
newlyDirtyCount: 0,
|
|
127
|
+
});
|
|
128
|
+
assert.strictEqual(decision.action, 'investigate');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('classifyFailureOutcome: signal-kill transient (exit 143, short run) still retries as before', () => {
|
|
132
|
+
const decision = classifyFailureOutcome({
|
|
133
|
+
exitCode: 143,
|
|
134
|
+
networkError: false,
|
|
135
|
+
durationMs: 60_000,
|
|
136
|
+
transientRetries: 0,
|
|
137
|
+
newlyDirtyCount: 0,
|
|
138
|
+
});
|
|
139
|
+
assert.strictEqual(decision.action, 'retry');
|
|
140
|
+
assert.strictEqual(decision.transientKind, 'exit=143');
|
|
141
|
+
});
|
package/src/main/adminServer.cjs
CHANGED
|
@@ -14,8 +14,17 @@
|
|
|
14
14
|
* ~/.claude/session-manager/admin-api.json with 0600 perms.
|
|
15
15
|
* - Token compared with crypto.timingSafeEqual to avoid a timing
|
|
16
16
|
* side-channel on the comparison itself.
|
|
17
|
-
* -
|
|
18
|
-
* list)
|
|
17
|
+
* - Three routes: reset-job (flips fields on an already-vetted job),
|
|
18
|
+
* jobs (read-only list), and create-prd (PRD 549 — authors a BRAND-NEW
|
|
19
|
+
* file that the scheduler will later run as `claude -p
|
|
20
|
+
* --dangerously-skip-permissions` in a caller-chosen, home-dir-bounded
|
|
21
|
+
* cwd; a materially larger blast radius than reset-job, justified only
|
|
22
|
+
* because it unblocks external automation with no other queueing path).
|
|
23
|
+
* cwd is validated via config.cjs's validatePath; title/cwd are
|
|
24
|
+
* newline-blocked at the zod boundary (ipcSchemas.cjs) to prevent
|
|
25
|
+
* frontmatter injection into the hand-rolled PRD serializer
|
|
26
|
+
* (prdCreate.cjs). pause/resume/cancel/update remain intentionally NOT
|
|
27
|
+
* exposed here.
|
|
19
28
|
*/
|
|
20
29
|
|
|
21
30
|
'use strict';
|
|
@@ -26,6 +35,9 @@ const path = require('node:path');
|
|
|
26
35
|
const crypto = require('node:crypto');
|
|
27
36
|
const fsp = require('node:fs/promises');
|
|
28
37
|
const config = require('./config.cjs');
|
|
38
|
+
const { expandHome } = require('./lib/expandHome.cjs');
|
|
39
|
+
const { schemas } = require('./ipcSchemas.cjs');
|
|
40
|
+
const prdCreate = require('./lib/prdCreate.cjs');
|
|
29
41
|
|
|
30
42
|
const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
|
|
31
43
|
|
|
@@ -123,6 +135,79 @@ function createAdminServer(remote) {
|
|
|
123
135
|
sendJson(res, 200, result);
|
|
124
136
|
return;
|
|
125
137
|
}
|
|
138
|
+
if (req.method === 'POST' && req.url === '/admin/scheduler/create-prd') {
|
|
139
|
+
const raw = await readBody(req);
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
143
|
+
} catch {
|
|
144
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let input;
|
|
149
|
+
try {
|
|
150
|
+
input = schemas.schedulerCreatePrd.parse(parsed);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
sendJson(res, 400, { ok: false, error: 'invalid PRD payload', details: e?.issues ?? e?.message });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// cwd is untrusted: this is the first *creating* mutation on a
|
|
157
|
+
// token-authed API whose product is "a command that will later run
|
|
158
|
+
// with --dangerously-skip-permissions in a chosen cwd". Route it
|
|
159
|
+
// through config.cjs's validatePath (allowedRoots = home dir) —
|
|
160
|
+
// same boundary every other fs-touching IPC handler uses — never a
|
|
161
|
+
// bespoke check here.
|
|
162
|
+
try {
|
|
163
|
+
config.validatePath(expandHome(input.cwd));
|
|
164
|
+
} catch (e) {
|
|
165
|
+
sendJson(res, 400, { ok: false, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const slug = input.slug || prdCreate.deriveSlugFromTitle(input.title);
|
|
170
|
+
if (!slug || !prdCreate.PRD_CREATE_SLUG_RE.test(slug)) {
|
|
171
|
+
sendJson(res, 400, { ok: false, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// NN allocation is delegated to allocateParallelGroup() (PRD 548) via
|
|
176
|
+
// the injected remote — never re-derived here — unless the caller
|
|
177
|
+
// opted into an existing group explicitly.
|
|
178
|
+
const nn = input.parallelGroup ?? await remote.allocateParallelGroup();
|
|
179
|
+
const filenameSlug = `${nn}-${slug}`;
|
|
180
|
+
|
|
181
|
+
// An explicit `parallelGroup` bypasses allocateParallelGroup()'s
|
|
182
|
+
// collision-proof reservation, so re-check for an existing file at
|
|
183
|
+
// this exact destination before writing — remote.writePrd itself has
|
|
184
|
+
// no existence guard (by design, it doubles as the edit-in-place
|
|
185
|
+
// path for Scheduler UI PRD edits), so "create" must not silently
|
|
186
|
+
// clobber an existing job's PRD.
|
|
187
|
+
const existing = await remote.readPrd(filenameSlug);
|
|
188
|
+
if (existing?.ok) {
|
|
189
|
+
sendJson(res, 409, { ok: false, error: `PRD already exists: ${filenameSlug}.md` });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let standardsText;
|
|
194
|
+
try {
|
|
195
|
+
standardsText = await prdCreate.readStandards();
|
|
196
|
+
} catch (e) {
|
|
197
|
+
sendJson(res, 500, { ok: false, error: `could not read engineering standards: ${e?.message}` });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const body = prdCreate.buildPrdBody(input, standardsText);
|
|
202
|
+
const writeResult = await remote.writePrd(filenameSlug, body);
|
|
203
|
+
if (!writeResult?.ok) {
|
|
204
|
+
sendJson(res, 500, { ok: false, error: writeResult?.error ?? 'write failed' });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
sendJson(res, 200, { nn, filename: `${filenameSlug}.md`, status: 'queued' });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
126
211
|
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
127
212
|
} catch (e) {
|
|
128
213
|
sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
|
|
@@ -656,14 +656,21 @@ async function captureSelection({ viewId, selectors, mode }, getView) {
|
|
|
656
656
|
}
|
|
657
657
|
|
|
658
658
|
if (mode === 'a11y') {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
659
|
+
// captureA11y attaches/detaches the CDP debugger per call, so loop
|
|
660
|
+
// sequentially rather than firing selectors in parallel (mirrors the
|
|
661
|
+
// multi-select behavior of the html/selector modes above, which apply to
|
|
662
|
+
// the whole `selectors` array instead of only the first entry).
|
|
663
|
+
const trees = [];
|
|
664
|
+
for (const selector of selectors) {
|
|
665
|
+
const tree = await captureA11y(wc, selector);
|
|
666
|
+
if (tree) trees.push(tree);
|
|
667
|
+
}
|
|
668
|
+
const text = trees.map((t) => formatA11yTree(t)).join('\n\n');
|
|
662
669
|
return { ok: true, mode, text, meta: {} };
|
|
663
670
|
}
|
|
664
671
|
|
|
665
672
|
// mode === 'agent'
|
|
666
|
-
const
|
|
673
|
+
const rawTrees = await wc.executeJavaScript(
|
|
667
674
|
`(function () {
|
|
668
675
|
function serialize(el) {
|
|
669
676
|
if (el.nodeType === 3) {
|
|
@@ -683,11 +690,18 @@ async function captureSelection({ viewId, selectors, mode }, getView) {
|
|
|
683
690
|
}
|
|
684
691
|
return { tag: el.tagName.toLowerCase(), attrs: attrs, style: { display: cs.display, visibility: cs.visibility }, children: children };
|
|
685
692
|
}
|
|
686
|
-
|
|
687
|
-
|
|
693
|
+
return (${JSON.stringify(selectors)}).map(function (sel) {
|
|
694
|
+
var el = document.querySelector(sel);
|
|
695
|
+
return el ? serialize(el) : null;
|
|
696
|
+
}).filter(Boolean);
|
|
688
697
|
})()`,
|
|
689
698
|
);
|
|
690
|
-
const
|
|
699
|
+
const parts = (Array.isArray(rawTrees) ? rawTrees : []).map((t) => buildAgentCapture(capRawTree(t)));
|
|
700
|
+
const text = parts.map((p) => p.text).join('\n\n');
|
|
701
|
+
const meta = {
|
|
702
|
+
chunks: parts.reduce((a, p) => a + (p.meta.chunks || 0), 0),
|
|
703
|
+
tokens: parts.reduce((a, p) => a + (p.meta.tokens || 0), 0),
|
|
704
|
+
};
|
|
691
705
|
return { ok: true, mode, text, meta };
|
|
692
706
|
}
|
|
693
707
|
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -310,8 +310,12 @@ function pump() {
|
|
|
310
310
|
.catch(() => { /* executeRun never rejects; defensive */ })
|
|
311
311
|
.finally(() => { activeCount -= 1; pump(); });
|
|
312
312
|
}
|
|
313
|
-
// Anyone still waiting gets a 1-based position update.
|
|
313
|
+
// Anyone still waiting gets a 1-based position update. Silent (automated
|
|
314
|
+
// probe) runs must stay invisible to the renderer, same as every other
|
|
315
|
+
// broadcast in executeRun — otherwise a queued probe would flip a tab's
|
|
316
|
+
// `running`/`queuedPosition` UI state for a run the user never initiated.
|
|
314
317
|
waiting.forEach((w, i) => {
|
|
318
|
+
if (w.silent) return;
|
|
315
319
|
broadcast('chat:run:queued', { tabId: w.tabId, sessionId: w.sessionId, position: i + 1 });
|
|
316
320
|
});
|
|
317
321
|
}
|