claude-code-session-manager 0.79.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentLibrary-COtVRqBR.js → AgentLibrary-psZYVM2w.js} +1 -1
- package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
- package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
- package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
- package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
- package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
- package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
- package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
- package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
- package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
- package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
- package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
- package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
- package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
- package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
- package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
- package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
- package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
- package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
- package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
- package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
- package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
- package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
- package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
- package/src/main/__tests__/prdCreate.test.cjs +133 -8
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
- package/src/main/__tests__/queueHistory.test.cjs +33 -0
- package/src/main/__tests__/rcaReport.test.cjs +24 -0
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
- package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
- package/src/main/ipcSchemas.cjs +15 -1
- package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/branchSweep.cjs +127 -0
- package/src/main/lib/depSlugResolve.cjs +72 -0
- package/src/main/lib/epicWorktreeMerge.cjs +3 -3
- package/src/main/lib/epicWorktreeMint.cjs +17 -5
- package/src/main/lib/fixPlanSlug.cjs +62 -0
- package/src/main/lib/gitWorktree.cjs +117 -12
- package/src/main/lib/landedSinceRun.cjs +41 -1
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/prdCreate.cjs +84 -5
- package/src/main/lib/prdFrontmatter.cjs +56 -8
- package/src/main/lib/queueHistory.cjs +50 -5
- package/src/main/lib/rateLimitWindow.cjs +62 -0
- package/src/main/lib/rcaReport.cjs +18 -3
- package/src/main/lib/reaperHelpers.cjs +169 -3
- package/src/main/lib/scheduleJobTransitions.cjs +28 -5
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/runVerify.cjs +71 -3
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1554 -71
- package/src/preload/api.d.ts +8 -0
|
@@ -79,12 +79,24 @@ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
|
|
|
79
79
|
expect(res.batch.map((j) => j.slug)).toContain('851-next');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it('a dep slug with NO queue row counts as done
|
|
82
|
+
it('a dep slug with NO queue row counts as done when found in history/archive (PRD 1122)', () => {
|
|
83
83
|
const dependent = job('853-next', 'pending', 853, { dependsOn: ['852-archived-long-ago'] });
|
|
84
|
-
|
|
84
|
+
// Caller (scheduler.cjs's computeDepHistorySatisfaction) precomputes this
|
|
85
|
+
// Set from history.jsonl/prds-archived — pickForProject never does I/O.
|
|
86
|
+
const satisfiedSlugs = new Set(['852-archived-long-ago']);
|
|
87
|
+
const res = pickForProject([dependent], new Set(), 3, new Map(), satisfiedSlugs);
|
|
85
88
|
expect(res.batch.map((j) => j.slug)).toContain('853-next');
|
|
86
89
|
});
|
|
87
90
|
|
|
91
|
+
it('a dep slug with NO queue row and NO history/archive record HOLDS as unresolved (PRD 1122)', () => {
|
|
92
|
+
const dependent = job('853-next', 'pending', 853, { dependsOn: ['852-archived-long-ago'] });
|
|
93
|
+
const res = pickForProject([dependent], new Set(), 3);
|
|
94
|
+
expect(res.batch).toEqual([]);
|
|
95
|
+
expect(res.reason).toMatch(/unresolvable/);
|
|
96
|
+
expect(res.reason).toContain('853-next');
|
|
97
|
+
expect(res.reason).toContain('852-archived-long-ago');
|
|
98
|
+
});
|
|
99
|
+
|
|
88
100
|
it('a FAILED dep holds the dependent with an explicit depends-gate reason', () => {
|
|
89
101
|
const dep = job('854-base', 'failed', 854);
|
|
90
102
|
const dependent = job('855-next', 'pending', 855, { dependsOn: ['854-base'] });
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -313,6 +313,13 @@ const NO_NEWLINE_RE = /^[^\r\n]*$/;
|
|
|
313
313
|
// prdCreate.cjs's createPrd via prdAgentType.cjs's assertAgentTypeWritable —
|
|
314
314
|
// this schema only enforces the name SHAPE.
|
|
315
315
|
const PrdAgentTypeSchema = z.string().regex(PERSONA_NAME_RE, 'agent name must be lowercase, hyphenated (e.g. "dev-lead")');
|
|
316
|
+
// Shared shape for a `dependsOn` slug entry — schedulerCreatePrd and
|
|
317
|
+
// adminUpdatePrd (scheduler_update_prd, PRD 1124) must accept the exact same
|
|
318
|
+
// slug shape or an update could write a dependsOn value create would have
|
|
319
|
+
// rejected. Array-level constraints (`.max(20)`, `.optional()`) stay at each
|
|
320
|
+
// call site since update also allows an explicit empty array to CLEAR the
|
|
321
|
+
// dependency, which create has no reason to accept.
|
|
322
|
+
const DepSlugSchema = z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/);
|
|
316
323
|
const schedulerCreatePrd = z.object({
|
|
317
324
|
title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
|
|
318
325
|
// Optional (PRD: worktree-cwd Epic-lookup hazard) — when omitted, prdCreate.cjs's
|
|
@@ -332,7 +339,7 @@ const schedulerCreatePrd = z.object({
|
|
|
332
339
|
parallelGroup: z.number().int().min(1).max(999999).optional(),
|
|
333
340
|
// Explicit ordering (PRD 832): slugs that must complete before this PRD
|
|
334
341
|
// becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
|
|
335
|
-
dependsOn: z.array(
|
|
342
|
+
dependsOn: z.array(DepSlugSchema).max(20).optional(),
|
|
336
343
|
// An EXISTING Epic's promptSessionId (PRD 748) — NOT a PromptTicket.id.
|
|
337
344
|
// ensureEpic() (epicMint.cjs) joins the Epic whose active-index.json
|
|
338
345
|
// sessions key literally equals this value; the renderer only ever sends
|
|
@@ -455,6 +462,13 @@ const adminPrdFrontmatterPatch = z.object({
|
|
|
455
462
|
// Opt-in exclusive-lease flag (PRD 1107) — patchable after creation too,
|
|
456
463
|
// same as every other recognized key here.
|
|
457
464
|
quietMachine: z.boolean().optional(),
|
|
465
|
+
// Explicit ordering (PRD 832), promoted to patchable by PRD 1124 — see
|
|
466
|
+
// prdFrontmatter.cjs's header. An explicit `[]` CLEARS the dependency
|
|
467
|
+
// (updatePrd's array-emptiness check skips writing the key at all); a
|
|
468
|
+
// non-empty array is validated write-time against the SAME resolver
|
|
469
|
+
// (depSlugResolve.cjs) scheduler_create_prd uses, so update and create
|
|
470
|
+
// can never disagree about what a dependsOn entry resolves to.
|
|
471
|
+
dependsOn: z.array(DepSlugSchema).max(20).optional(),
|
|
458
472
|
});
|
|
459
473
|
|
|
460
474
|
const adminUpdatePrd = z.object({
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branchSweep.test.cjs — PRD 1135: recovering `sm-job/<slug>` branches left
|
|
3
|
+
* stranded (unmerged, owning job row terminal or gone) never gets swept away
|
|
4
|
+
* by hand months later.
|
|
5
|
+
*
|
|
6
|
+
* Exercises the real `git` plumbing against throwaway repos under
|
|
7
|
+
* os.tmpdir() (fast — no network, no Electron).
|
|
8
|
+
*
|
|
9
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/branchSweep.test.cjs
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { execFileSync } = require('node:child_process');
|
|
18
|
+
const { sweepStrandedJobBranches } = require('../branchSweep.cjs');
|
|
19
|
+
|
|
20
|
+
let tmpRoot;
|
|
21
|
+
let repoCwd;
|
|
22
|
+
let worktreeDir;
|
|
23
|
+
|
|
24
|
+
function git(args, cwd) {
|
|
25
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function initRepo(dir) {
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
git(['init', '-q'], dir);
|
|
31
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
32
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
34
|
+
git(['add', '-A'], dir);
|
|
35
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-'));
|
|
40
|
+
repoCwd = path.join(tmpRoot, 'repo');
|
|
41
|
+
initRepo(repoCwd);
|
|
42
|
+
delete process.env.SM_BRANCH_SWEEP_DISABLE;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
if (worktreeDir && fs.existsSync(worktreeDir)) {
|
|
47
|
+
try { git(['worktree', 'remove', '--force', worktreeDir], repoCwd); } catch { /* best effort */ }
|
|
48
|
+
}
|
|
49
|
+
worktreeDir = undefined;
|
|
50
|
+
delete process.env.SM_BRANCH_SWEEP_DISABLE;
|
|
51
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Create an `sm-job/<slug>` branch with one commit beyond HEAD, without checking it out. */
|
|
55
|
+
function makeStrandedBranch(cwd, slug, fileContents = 'stranded work\n') {
|
|
56
|
+
const branch = `sm-job/${slug}`;
|
|
57
|
+
git(['branch', branch], cwd);
|
|
58
|
+
const tmpCheckout = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-checkout-'));
|
|
59
|
+
git(['worktree', 'add', '-q', tmpCheckout, branch], cwd);
|
|
60
|
+
fs.writeFileSync(path.join(tmpCheckout, `${slug}.txt`), fileContents, 'utf8');
|
|
61
|
+
git(['add', '-A'], tmpCheckout);
|
|
62
|
+
git(['commit', '-q', '-m', `job ${slug} work`], tmpCheckout);
|
|
63
|
+
git(['worktree', 'remove', '--force', tmpCheckout], cwd);
|
|
64
|
+
return branch;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('stranded branch with a terminal job row is integrated', async () => {
|
|
68
|
+
const branch = makeStrandedBranch(repoCwd, 'alpha');
|
|
69
|
+
const jobs = [{ slug: 'alpha', status: 'completed' }];
|
|
70
|
+
const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
|
|
71
|
+
const r = results.find((x) => x.branch === branch);
|
|
72
|
+
expect(r.action).toBe('integrated');
|
|
73
|
+
expect(fs.existsSync(path.join(repoCwd, 'alpha.txt'))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('stranded branch with NO job row at all is also integrated', async () => {
|
|
77
|
+
const branch = makeStrandedBranch(repoCwd, 'ghost');
|
|
78
|
+
const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs: [], attemptedBranches: new Set() });
|
|
79
|
+
const r = results.find((x) => x.branch === branch);
|
|
80
|
+
expect(r.action).toBe('integrated');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('branch already merged into HEAD is a silent no-op', async () => {
|
|
84
|
+
const branch = makeStrandedBranch(repoCwd, 'bravo');
|
|
85
|
+
git(['merge', '--no-ff', '--no-edit', branch], repoCwd);
|
|
86
|
+
const jobs = [{ slug: 'bravo', status: 'completed' }];
|
|
87
|
+
const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
|
|
88
|
+
const r = results.find((x) => x.branch === branch);
|
|
89
|
+
expect(r.action).toBe('no-op-merged');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('branch checked out in a live worktree is skipped entirely', async () => {
|
|
93
|
+
const branch = makeStrandedBranch(repoCwd, 'charlie');
|
|
94
|
+
worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-live-'));
|
|
95
|
+
git(['worktree', 'add', '-q', worktreeDir, branch], repoCwd);
|
|
96
|
+
const jobs = [{ slug: 'charlie', status: 'running' }];
|
|
97
|
+
const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
|
|
98
|
+
const r = results.find((x) => x.branch === branch);
|
|
99
|
+
expect(r.action).toBe('skip-checked-out');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('branch owned by a NON-terminal job row is left alone, never integrated', async () => {
|
|
103
|
+
const branch = makeStrandedBranch(repoCwd, 'delta');
|
|
104
|
+
const jobs = [{ slug: 'delta', status: 'needs_review' }];
|
|
105
|
+
const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
|
|
106
|
+
const r = results.find((x) => x.branch === branch);
|
|
107
|
+
expect(r.action).toBe('skip-active-row');
|
|
108
|
+
expect(fs.existsSync(path.join(repoCwd, 'delta.txt'))).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('conflicting branch is flagged, never forced, and the branch is never deleted', async () => {
|
|
112
|
+
// Same path committed differently on both HEAD and the branch → real conflict.
|
|
113
|
+
fs.writeFileSync(path.join(repoCwd, 'shared.txt'), 'main tree version\n', 'utf8');
|
|
114
|
+
git(['add', '-A'], repoCwd);
|
|
115
|
+
git(['commit', '-q', '-m', 'main tree edits shared.txt'], repoCwd);
|
|
116
|
+
|
|
117
|
+
const branch = 'sm-job/echo';
|
|
118
|
+
git(['branch', branch, 'HEAD~1'], repoCwd);
|
|
119
|
+
const tmpCheckout = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-conflict-'));
|
|
120
|
+
git(['worktree', 'add', '-q', tmpCheckout, branch], repoCwd);
|
|
121
|
+
fs.writeFileSync(path.join(tmpCheckout, 'shared.txt'), 'branch version — conflicts\n', 'utf8');
|
|
122
|
+
git(['add', '-A'], tmpCheckout);
|
|
123
|
+
git(['commit', '-q', '-m', 'job echo edits shared.txt'], tmpCheckout);
|
|
124
|
+
git(['worktree', 'remove', '--force', tmpCheckout], repoCwd);
|
|
125
|
+
|
|
126
|
+
const jobs = [{ slug: 'echo', status: 'failed' }];
|
|
127
|
+
const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
|
|
128
|
+
const r = results.find((x) => x.branch === branch);
|
|
129
|
+
expect(r.action).toBe('conflict');
|
|
130
|
+
expect(r.integration.ok).toBe(false);
|
|
131
|
+
// Branch itself must survive a conflict, untouched.
|
|
132
|
+
const stillExists = git(['rev-parse', '--verify', branch], repoCwd).trim();
|
|
133
|
+
expect(stillExists.length).toBe(40);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a branch is retried at most once per process lifetime via the shared attempted set', async () => {
|
|
137
|
+
fs.writeFileSync(path.join(repoCwd, 'shared2.txt'), 'main\n', 'utf8');
|
|
138
|
+
git(['add', '-A'], repoCwd);
|
|
139
|
+
git(['commit', '-q', '-m', 'main edits shared2'], repoCwd);
|
|
140
|
+
const branch = 'sm-job/foxtrot';
|
|
141
|
+
git(['branch', branch, 'HEAD~1'], repoCwd);
|
|
142
|
+
const tmpCheckout = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-retry-'));
|
|
143
|
+
git(['worktree', 'add', '-q', tmpCheckout, branch], repoCwd);
|
|
144
|
+
fs.writeFileSync(path.join(tmpCheckout, 'shared2.txt'), 'branch — conflicts\n', 'utf8');
|
|
145
|
+
git(['add', '-A'], tmpCheckout);
|
|
146
|
+
git(['commit', '-q', '-m', 'job foxtrot edits shared2'], tmpCheckout);
|
|
147
|
+
git(['worktree', 'remove', '--force', tmpCheckout], repoCwd);
|
|
148
|
+
|
|
149
|
+
const jobs = [{ slug: 'foxtrot', status: 'failed' }];
|
|
150
|
+
const attemptedBranches = new Set();
|
|
151
|
+
const first = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches });
|
|
152
|
+
expect(first.results.find((x) => x.branch === branch).action).toBe('conflict');
|
|
153
|
+
const second = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches });
|
|
154
|
+
expect(second.results.find((x) => x.branch === branch).action).toBe('skip-already-attempted');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('SM_BRANCH_SWEEP_DISABLE=1 restores today\'s behaviour exactly — no scan, no integration', async () => {
|
|
158
|
+
makeStrandedBranch(repoCwd, 'golf');
|
|
159
|
+
process.env.SM_BRANCH_SWEEP_DISABLE = '1';
|
|
160
|
+
const jobs = [{ slug: 'golf', status: 'completed' }];
|
|
161
|
+
const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
|
|
162
|
+
expect(results).toEqual([]);
|
|
163
|
+
expect(fs.existsSync(path.join(repoCwd, 'golf.txt'))).toBe(false);
|
|
164
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[scheduler] starting 204-mercury-steam-horse at 2026-09-05T16:32:31.039Z
|
|
2
|
+
[scheduler] cwd=/home/bilko/Projects/starry-night-ships (running isolated in worktree /tmp/session-manager-job-worktrees/443997d5bbc5f8ab/204-mercury-steam-horse)
|
|
3
|
+
|
|
4
|
+
[scheduler] agentType=dev-lead persona=/home/bilko/.claude/agents/dev-lead.md model=fable
|
|
5
|
+
[scheduler] spawned pid=3537290 sessionId=5cb9d69b-e2e7-49f9-806c-2307f1c94b2b (process group)
|
|
6
|
+
|
|
7
|
+
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":1789059600,"rateLimitType":"seven_day","utilization":0.59,"isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0,"resetsAt":1788643800},"seven_day":{"utilization":0.59,"resetsAt":1789059600}}},"uuid":"f7ef520a-9c3f-470e-89de-4ba9af7d5968","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b"}
|
|
8
|
+
{"type":"system","subtype":"init","cwd":"/tmp/session-manager-job-worktrees/443997d5bbc5f8ab/204-mercury-steam-horse","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b","tools":["Task","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","ListAgents","ListMcpResourcesTool","Monitor","NotebookEdit","PushNotification","Read","ReadMcpResourceDirTool","ReadMcpResourceTool","RemoteTrigger","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskOutput","TaskStop","ToolSearch","WebFetch","WebSearch","Workflow","Write","mcp__bilko-host__get_host_contract","mcp__bilko-host__list_projects","mcp__bilko-host__publish_static_project","mcp__bilko-host__register_static_project","mcp__bilko-host__status","mcp__bilko-host__unregister_project","mcp__bilko-host__usage_report","mcp__claude_ai_Gmail__apply_sensitive_message_label","mcp__claude_ai_Gmail__apply_sensitive_thread_label","mcp__claude_ai_Gmail__create_draft","mcp__claude_ai_Gmail__create_label","mcp__claude_ai_Gmail__delete_label","mcp__claude_ai_Gmail__forward","mcp__claude_ai_Gmail__get_draft","mcp__claude_ai_Gmail__get_message","mcp__claude_ai_Gmail__get_thread","mcp__claude_ai_Gmail__label_message","mcp__claude_ai_Gmail__label_thread","mcp__claude_ai_Gmail__list_drafts","mcp__claude_ai_Gmail__list_labels","mcp__claude_ai_Gmail__mark_message_spam","mcp__claude_ai_Gmail__mark_thread_spam","mcp__claude_ai_Gmail__reply","mcp__claude_ai_Gmail__search_threads","mcp__claude_ai_Gmail__send_message","mcp__claude_ai_Gmail__trash_message","mcp__claude_ai_Gmail__trash_thread","mcp__claude_ai_Gmail__unlabel_message","mcp__claude_ai_Gmail__unlabel_thread","mcp__claude_ai_Gmail__unmark_message_spam","mcp__claude_ai_Gmail__unmark_thread_spam","mcp__claude_ai_Gmail__untrash_message","mcp__claude_ai_Gmail__untrash_thread","mcp__claude_ai_Gmail__update_draft","mcp__claude_ai_Gmail__update_label","mcp__claude_ai_Gmail__update_message_labels","mcp__fetch__fetch","mcp__godot__add_node","mcp__godot__create_scene","mcp__godot__export_mesh_library","mcp__godot__get_debug_output","mcp__godot__get_godot_version","mcp__godot__get_project_info","mcp__godot__get_uid","mcp__godot__launch_editor","mcp__godot__list_projects","mcp__godot__load_sprite","mcp__godot__run_project","mcp__godot__save_scene","mcp__godot__stop_project","mcp__godot__update_project_uids","mcp__google-workspace__batch_modify_gmail_message_labels","mcp__google-workspace__batch_update_doc","mcp__google-workspace__check_drive_file_public_access","mcp__google-workspace__copy_drive_file","mcp__google-workspace__create_calendar","mcp__google-workspace__create_doc","mcp__google-workspace__create_drive_file","mcp__google-workspace__create_drive_folder","mcp__google-workspace__create_reaction","mcp__google-workspace__create_table_with_data","mcp__google-workspace__debug_docs_runtime_info","mcp__google-workspace__debug_table_structure","mcp__google-workspace__download_chat_attachment","mcp__google-workspace__draft_gmail_message","mcp__google-workspace__export_doc_to_pdf","mcp__google-workspace__find_and_replace_doc","mcp__google-workspace__get_doc_as_markdown","mcp__google-workspace__get_doc_content","mcp__google-workspace__get_drive_file_content","mcp__google-workspace__get_drive_file_download_url","mcp__google-workspace__get_drive_file_permissions","mcp__google-workspace__get_drive_shareable_link","mcp__google-workspace__get_events","mcp__google-workspace__get_gmail_attachment_content","mcp__google-workspace__get_gmail_message_content","mcp__google-workspace__get_gmail_messages_content_batch","mcp__google-workspace__get_gmail_thread_content","mcp__google-workspace__get_gmail_threads_content_batch","mcp__google-workspace__get_messages","mcp__google-workspace__import_to_google_doc","mcp__google-workspace__import_to_google_sheets","mcp__google-workspace__import_to_google_slides","mcp__google-workspace__insert_doc_elements","mcp__google-workspace__insert_doc_image","mcp__google-workspace__inspect_doc_structure","mcp__google-workspace__list_calendars","mcp__google-workspace__list_docs_in_folder","mcp__google-workspace__list_document_comments","mcp__google-workspace__list_drive_items","mcp__google-workspace__list_gmail_filters","mcp__google-workspace__list_gmail_labels","mcp__google-workspace__list_spaces","mcp__google-workspace__manage_doc_tab","mcp__google-workspace__manage_document_comment","mcp__google-workspace__manage_drive_access","mcp__google-workspace__manage_event","mcp__google-workspace__manage_focus_time","mcp__google-workspace__manage_gmail_filter","mcp__google-workspace__manage_gmail_label","mcp__google-workspace__manage_out_of_office","mcp__google-workspace__modify_doc_text","mcp__google-workspace__modify_gmail_message_labels","mcp__google-workspace__query_freebusy","mcp__google-workspace__search_docs","mcp__google-workspace__search_drive_files","mcp__google-workspace__search_gmail_messages","mcp__google-workspace__search_messages","mcp__google-workspace__send_gmail_message","mcp__google-workspace__send_message","mcp__google-workspace__set_drive_file_permissions","mcp__google-workspace__start_google_auth","mcp__google-workspace__update_doc_headers_footers","mcp__google-workspace__update_drive_file","mcp__google-workspace__update_paragraph_style","mcp__playwright__browser_click","mcp__playwright__browser_close","mcp__playwright__browser_console_messages","mcp__playwright__browser_drag","mcp__playwright__browser_drop","mcp__playwright__browser_evaluate","mcp__playwright__browser_file_upload","mcp__playwright__browser_fill_form","mcp__playwright__browser_handle_dialog","mcp__playwright__browser_hover","mcp__playwright__browser_navigate","mcp__playwright__browser_navigate_back","mcp__playwright__browser_network_request","mcp__playwright__browser_network_requests","mcp__playwright__browser_press_key","mcp__playwright__browser_resize","mcp__playwright__browser_run_code_unsafe","mcp__playwright__browser_select_option","mcp__playwright__browser_snapshot","mcp__playwright__browser_tabs","mcp__playwright__browser_take_screenshot","mcp__playwright__browser_type","mcp__playwright__browser_wait_for","mcp__session-manager-scheduler__chat_send_prompt","mcp__session-manager-scheduler__feedback_list_projects","mcp__session-manager-scheduler__feedback_open_session","mcp__session-manager-scheduler__project_home_get_contract","mcp__session-manager-scheduler__project_home_render","mcp__session-manager-scheduler__project_home_status","mcp__session-manager-scheduler__project_home_validate_summary","mcp__session-manager-scheduler__scheduler_archive_prd","mcp__session-manager-scheduler__scheduler_cancel_job","mcp__session-manager-scheduler__scheduler_create_prd","mcp__session-manager-scheduler__scheduler_get_prd","mcp__session-manager-scheduler__scheduler_list_jobs","mcp__session-manager-scheduler__scheduler_list_prds","mcp__session-manager-scheduler__scheduler_reset_job","mcp__session-manager-scheduler__scheduler_retag_prd","mcp__session-manager-scheduler__scheduler_update_prd","mcp__session-manager-scheduler__session_manager_help"],"mcp_servers":[{"name":"plugin:stripe:stripe","status":"needs-auth"},{"name":"fetch","status":"connected"},{"name":"sqlite","status":"failed"},{"name":"playwright","status":"connected"},{"name":"google-workspace","status":"connected"},{"name":"n8n","status":"failed"},{"name":"session-manager-scheduler","status":"connected"},{"name":"bilko-host","status":"connected"},{"name":"godot","status":"connected"},{"name":"claude.ai Google Calendar","status":"needs-auth"},{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-fable-5-1","permissionMode":"bypassPermissions","slash_commands":["refresh-claude-md","deep-research","stripe:explain-error","stripe:test-cards","session-manager-dev:blog-for-project-feature","session-manager-dev:builder","session-manager-dev:develop","session-manager-dev:discover-features","session-manager-dev:explain-to-me","session-manager-dev:find-opportunity","session-manager-dev:issue-address","session-manager-dev:local-project-health","session-manager-dev:memory-sanitation","session-manager-dev:ops-sweep","session-manager-dev:pr-review-sweep","session-manager-dev:pr-signal","session-manager-dev:project-status","session-manager-dev:requesting-code-review","session-manager-dev:send-feedback","stripe:connect-recommend","stripe:connect-required-verification-information","stripe:stripe-apps","stripe:stripe-best-practices","stripe:stripe-directory","stripe:stripe-docs","stripe:stripe-projects","stripe:upgrade-stripe","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator","advisor","agents","auto-mode-setup","autocompact","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","import","model","__remote-workflow","workflow-launch-exec","reload-plugins","reload-skills","rename","ultrareview","security-review","usage-credits","extra-usage","usage","insights","recap","skill-doctor","goal","design","design-consent","design-revoke","list-agents","team-onboarding","mcp__fetch__fetch"],"terminal_slash_commands":["doctor","color","reload-plugins"],"apiKeySource":"none","claude_code_version":"2.1.261","output_style":"default","agents":["architect","bilko-host-publisher","builder","claude","dev-lead","Explore","general-purpose","Plan","project-home-builder","statusline-setup","stripe:Company Researcher"],"skills":["deep-research","session-manager-dev:blog-for-project-feature","session-manager-dev:builder","session-manager-dev:develop","session-manager-dev:discover-features","session-manager-dev:explain-to-me","session-manager-dev:find-opportunity","session-manager-dev:issue-address","session-manager-dev:local-project-health","session-manager-dev:memory-sanitation","session-manager-dev:ops-sweep","session-manager-dev:pr-review-sweep","session-manager-dev:pr-signal","session-manager-dev:project-status","session-manager-dev:requesting-code-review","session-manager-dev:send-feedback","stripe:connect-recommend","stripe:connect-required-verification-information","stripe:stripe-apps","stripe:stripe-best-practices","stripe:stripe-directory","stripe:stripe-docs","stripe:stripe-projects","stripe:upgrade-stripe","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator"],"plugins":[{"name":"session-manager-dev","path":"/home/bilko/Projects/session-manager/plugins/session-manager-dev","source":"session-manager-dev@session-manager","version":"0.1.0"},{"name":"stripe","path":"/home/bilko/.claude/plugins/cache/claude-plugins-official/stripe/0.6.4","source":"stripe@claude-plugins-official","version":"0.6.4"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"7f8e3566-fc40-427c-a9ad-0c0719d7f3b2","memory_paths":{"auto":"/home/bilko/.claude/projects/-home-bilko-Projects-starry-night-ships/memory/"},"messaging_socket_path":"/run/user/1000/cc-socks/3537290.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"}
|
|
9
|
+
{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","resetsAt":1789059600,"rateLimitType":"seven_day_overage_included","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0,"resetsAt":1788643800},"seven_day":{"utilization":0.59,"resetsAt":1789059600},"seven_day_overage_included":{"utilization":1,"resetsAt":1789059600}}},"uuid":"faa8b505-db69-4afe-9436-9c812b76569a","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b"}
|
|
10
|
+
{"type":"assistant","message":{"diagnostics":null,"id":"dcbde611-72e9-4f07-bc35-c8ec9911f789","container":null,"model":"<synthetic>","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"output_tokens_details":null,"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null},"content":[{"type":"text","text":"You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cli_limit_message, to continue."}],"context_management":null},"parent_tool_use_id":null,"session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b","uuid":"a651974e-39c4-440f-a34e-eeec87e65fa4","timestamp":"2026-09-05T16:32:35.275Z","error":"rate_limit","request_id":"req_011CekcYKs4mrLVHXVc5xC2X","is_api_error_message":true}
|
|
11
|
+
{"duration_api_ms":1120,"stop_reason":"stop_sequence","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b","total_cost_usd":0.005226,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":5161,"outputTokens":13,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.005226,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":0,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":true,"num_turns":1,"subtype":"success","api_error_status":429,"result":"You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cli_limit_message, to continue.","type":"result","duration_ms":1036,"uuid":"b729d2fe-95bd-41a0-9558-eb7149222eb3","queued_turn_count":0}
|
|
12
|
+
|
|
13
|
+
[scheduler] exit code=1 (raw code=1 signal=null) duration=5s
|
|
@@ -186,7 +186,7 @@ test('[job] integration is skipped (carried-wip-only) when the branch only commi
|
|
|
186
186
|
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
test('[job] a branch that commits carried paths ALONGSIDE its own work
|
|
189
|
+
test('[job] a branch that commits carried paths ALONGSIDE its own work auto-resolves the merge (PRD 1125: carried path is byte-identical duplicate, real work still lands)', async () => {
|
|
190
190
|
const slug = 'test-slug-carried-plus-own-work';
|
|
191
191
|
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
192
192
|
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
@@ -198,16 +198,20 @@ test('[job] a branch that commits carried paths ALONGSIDE its own work still att
|
|
|
198
198
|
git(['commit', '-q', '-m', 'job commit touching carried path + own work'], worktree.dir);
|
|
199
199
|
|
|
200
200
|
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
|
|
201
|
-
// The base tree still holds README.md dirty,
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
expect(
|
|
201
|
+
// The base tree still holds README.md dirty, but its content is BYTE
|
|
202
|
+
// IDENTICAL to the branch's committed copy (the branch never edited it,
|
|
203
|
+
// only carried it through) — this is exactly the shape PRD 1125 teaches
|
|
204
|
+
// integrateBranch to auto-resolve: discard the duplicate, retry once, land
|
|
205
|
+
// the job's real new-file.txt commit too. Not the carried-wip-only
|
|
206
|
+
// shortcut (that only fires when the branch's ONLY changes are carried
|
|
207
|
+
// paths) — here the merge itself is what resolves.
|
|
208
|
+
expect(outcome.ok).toBe(true);
|
|
209
|
+
expect(outcome.integrated).toBe(true);
|
|
210
|
+
expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
|
|
211
|
+
expect(outcome.resolvedPaths).toEqual(['README.md']);
|
|
212
|
+
expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
|
|
209
213
|
|
|
210
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch:
|
|
214
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
211
215
|
});
|
|
212
216
|
|
|
213
217
|
test('[job] a branch that ADDS its own edit on top of the carried content on the SAME path is never classified carried-wip-only (content-verify, not path-only)', async () => {
|
|
@@ -683,3 +687,118 @@ test('[job] reconcileWorktreesOnBoot logs a one-shot report line with reclaimed
|
|
|
683
687
|
logSpy.mockRestore();
|
|
684
688
|
}
|
|
685
689
|
});
|
|
690
|
+
|
|
691
|
+
// ──────────────────────────────────────────── PRD 1125: auto-resolve identical-duplicate merge blocks
|
|
692
|
+
|
|
693
|
+
test('[job] integrateBranch auto-resolves when every blocking path (tracked + untracked) is byte-identical to the branch', async () => {
|
|
694
|
+
const slug = 'test-slug-identical-duplicates';
|
|
695
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
696
|
+
expect(worktree.ok).toBe(true);
|
|
697
|
+
|
|
698
|
+
// The job's own real work, committed on its branch.
|
|
699
|
+
fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
|
|
700
|
+
fs.mkdirSync(path.join(worktree.dir, 'newdir'), { recursive: true });
|
|
701
|
+
fs.writeFileSync(path.join(worktree.dir, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
|
|
702
|
+
git(['add', '-A'], worktree.dir);
|
|
703
|
+
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
704
|
+
|
|
705
|
+
// The shared main tree independently ends up with the SAME bytes on both
|
|
706
|
+
// a tracked path (dirty, uncommitted) and an untracked path — exactly the
|
|
707
|
+
// 2026-09-06 starry-night-ships incident shape: a prior in-place run left
|
|
708
|
+
// these duplicates behind uncommitted.
|
|
709
|
+
fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
|
|
710
|
+
fs.mkdirSync(path.join(repoCwd, 'newdir'), { recursive: true });
|
|
711
|
+
fs.writeFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
|
|
712
|
+
|
|
713
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
714
|
+
expect(outcome.ok).toBe(true);
|
|
715
|
+
expect(outcome.integrated).toBe(true);
|
|
716
|
+
expect(outcome.mergeCommit ?? outcome.fastForward).toBeTruthy();
|
|
717
|
+
expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
|
|
718
|
+
expect(outcome.resolvedPaths.sort()).toEqual(['fileA', 'newdir/fileU'].sort());
|
|
719
|
+
|
|
720
|
+
expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('shared-value\n');
|
|
721
|
+
expect(fs.readFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'utf8')).toBe('untracked-shared-value\n');
|
|
722
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
723
|
+
|
|
724
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test('[job] integrateBranch does NOT auto-resolve when one blocking path differs from the branch — same reason as today', async () => {
|
|
728
|
+
const slug = 'test-slug-one-differing-path';
|
|
729
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
730
|
+
expect(worktree.ok).toBe(true);
|
|
731
|
+
|
|
732
|
+
fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'branch-value\n', 'utf8');
|
|
733
|
+
git(['add', '-A'], worktree.dir);
|
|
734
|
+
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
735
|
+
|
|
736
|
+
// Main tree's dirty copy diverges from the branch's committed content —
|
|
737
|
+
// real information is at risk, so this must NOT be discarded.
|
|
738
|
+
fs.writeFileSync(path.join(repoCwd, 'fileA'), 'different-local-value\n', 'utf8');
|
|
739
|
+
|
|
740
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
741
|
+
expect(outcome.ok).toBe(false);
|
|
742
|
+
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
743
|
+
expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('different-local-value\n');
|
|
744
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).not.toBe('');
|
|
745
|
+
|
|
746
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test('[job] integrateBranch falls through to today\'s failure when stderr has no parseable blocking-path block (a genuine content conflict)', async () => {
|
|
750
|
+
const slug = 'test-slug-unparseable-stderr';
|
|
751
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
752
|
+
expect(worktree.ok).toBe(true);
|
|
753
|
+
|
|
754
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
755
|
+
git(['add', '-A'], worktree.dir);
|
|
756
|
+
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
757
|
+
|
|
758
|
+
// A committed (not dirty) diverging edit on the main tree produces git's
|
|
759
|
+
// ordinary "CONFLICT (content)" stderr shape, not the "would be
|
|
760
|
+
// overwritten" block parseBlockingMergePaths looks for.
|
|
761
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
|
|
762
|
+
git(['add', '-A'], repoCwd);
|
|
763
|
+
git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
|
|
764
|
+
|
|
765
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
766
|
+
expect(outcome.ok).toBe(false);
|
|
767
|
+
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
768
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
769
|
+
|
|
770
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test('[job] integrateBranch aborts cleanly when the single retry also fails on a genuine conflict elsewhere', async () => {
|
|
774
|
+
const slug = 'test-slug-retry-fails';
|
|
775
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
776
|
+
expect(worktree.ok).toBe(true);
|
|
777
|
+
|
|
778
|
+
// The branch's own real work: extends fileA (existing, tracked since
|
|
779
|
+
// initRepo's README.md commit doesn't cover it — add it fresh here) and
|
|
780
|
+
// ADDS a brand-new fileB.txt.
|
|
781
|
+
fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
|
|
782
|
+
fs.writeFileSync(path.join(worktree.dir, 'fileB.txt'), 'branch B change\n', 'utf8');
|
|
783
|
+
git(['add', '-A'], worktree.dir);
|
|
784
|
+
git(['commit', '-q', '-m', 'job commit: fileA + fileB'], worktree.dir);
|
|
785
|
+
|
|
786
|
+
// Main tree independently ADDS the SAME path with DIFFERENT content — a
|
|
787
|
+
// genuine "both added, diverging content" conflict on fileB.txt.
|
|
788
|
+
fs.writeFileSync(path.join(repoCwd, 'fileB.txt'), 'base B change\n', 'utf8');
|
|
789
|
+
git(['add', '-A'], repoCwd);
|
|
790
|
+
git(['commit', '-q', '-m', 'main tree diverges fileB'], repoCwd);
|
|
791
|
+
// ...and ALSO leaves fileA dirty with content byte-identical to the branch
|
|
792
|
+
// — the one blocking path the initial merge attempt refuses on, and the
|
|
793
|
+
// one the auto-resolve retry successfully discards.
|
|
794
|
+
fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
|
|
795
|
+
|
|
796
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
797
|
+
expect(outcome.ok).toBe(false);
|
|
798
|
+
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
799
|
+
// The retry's half-applied merge must be aborted, not left mid-merge.
|
|
800
|
+
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
801
|
+
expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
802
|
+
|
|
803
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
804
|
+
});
|
|
@@ -13,7 +13,7 @@ const fs = require('node:fs');
|
|
|
13
13
|
const os = require('node:os');
|
|
14
14
|
const path = require('node:path');
|
|
15
15
|
const { execFileSync } = require('node:child_process');
|
|
16
|
-
const { landedSinceRun } = require('../landedSinceRun.cjs');
|
|
16
|
+
const { landedSinceRun, landedOnMainSince } = require('../landedSinceRun.cjs');
|
|
17
17
|
|
|
18
18
|
function mkRepo() {
|
|
19
19
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-since-run-'));
|
|
@@ -23,6 +23,16 @@ function mkRepo() {
|
|
|
23
23
|
return dir;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// -b main pins the branch name regardless of the host's init.defaultBranch
|
|
27
|
+
// config — landedOnMainSince's tests need a deterministic 'main' ref.
|
|
28
|
+
function mkRepoOnMain() {
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-on-main-'));
|
|
30
|
+
execFileSync('git', ['-C', dir, 'init', '-q', '-b', 'main']);
|
|
31
|
+
execFileSync('git', ['-C', dir, 'config', 'user.email', 'test@test.com']);
|
|
32
|
+
execFileSync('git', ['-C', dir, 'config', 'user.name', 'Test']);
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
function commitFile(dir, relPath, content, message) {
|
|
27
37
|
const abs = path.join(dir, relPath);
|
|
28
38
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
@@ -71,3 +81,53 @@ test('has a bounded default timeout', () => {
|
|
|
71
81
|
const { LANDED_SINCE_RUN_TIMEOUT_MS } = require('../landedSinceRun.cjs');
|
|
72
82
|
assert.ok(LANDED_SINCE_RUN_TIMEOUT_MS > 0 && LANDED_SINCE_RUN_TIMEOUT_MS <= 60_000);
|
|
73
83
|
});
|
|
84
|
+
|
|
85
|
+
// landedOnMainSince — PRD 1136's already-satisfied-on-main evidence query:
|
|
86
|
+
// scoped to `main` (not --all) and to the PRD's declared paths.
|
|
87
|
+
|
|
88
|
+
test('landedOnMainSince: finds a commit on main after sinceIso touching a declared path', async () => {
|
|
89
|
+
const dir = mkRepoOnMain();
|
|
90
|
+
commitFile(dir, 'README.md', 'hello', 'initial');
|
|
91
|
+
const since = new Date().toISOString();
|
|
92
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
93
|
+
commitFile(dir, 'src/foo.js', 'hello', 'fix foo');
|
|
94
|
+
const shas = await landedOnMainSince(dir, since, ['src/foo.js']);
|
|
95
|
+
assert.strictEqual(shas.length, 1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('landedOnMainSince: ignores a commit that does not touch any declared path', async () => {
|
|
99
|
+
const dir = mkRepoOnMain();
|
|
100
|
+
commitFile(dir, 'README.md', 'hello', 'initial');
|
|
101
|
+
const since = new Date().toISOString();
|
|
102
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
103
|
+
commitFile(dir, 'src/unrelated.js', 'hello', 'touch unrelated');
|
|
104
|
+
const shas = await landedOnMainSince(dir, since, ['src/foo.js']);
|
|
105
|
+
assert.deepStrictEqual(shas, []);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('landedOnMainSince: a commit older than sinceIso is not evidence', async () => {
|
|
109
|
+
const dir = mkRepoOnMain();
|
|
110
|
+
commitFile(dir, 'src/foo.js', 'hello', 'fix foo');
|
|
111
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
112
|
+
const since = new Date().toISOString();
|
|
113
|
+
const shas = await landedOnMainSince(dir, since, ['src/foo.js']);
|
|
114
|
+
assert.deepStrictEqual(shas, []);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('landedOnMainSince: empty paths never fabricates evidence — resolves []', async () => {
|
|
118
|
+
const dir = mkRepoOnMain();
|
|
119
|
+
commitFile(dir, 'src/foo.js', 'hello', 'fix foo');
|
|
120
|
+
const shas = await landedOnMainSince(dir, new Date(0).toISOString(), []);
|
|
121
|
+
assert.deepStrictEqual(shas, []);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('landedOnMainSince: no cwd resolves [] without throwing', async () => {
|
|
125
|
+
const shas = await landedOnMainSince(null, new Date().toISOString(), ['src/foo.js']);
|
|
126
|
+
assert.deepStrictEqual(shas, []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('landedOnMainSince: git-unavailable (non-repo cwd) resolves [] without throwing', async () => {
|
|
130
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-on-main-norepo-'));
|
|
131
|
+
const shas = await landedOnMainSince(dir, new Date().toISOString(), ['src/foo.js']);
|
|
132
|
+
assert.deepStrictEqual(shas, []);
|
|
133
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rateLimitWindow.test.cjs — resolveBindingRateLimitReset: reads the log's
|
|
3
|
+
* OWN rate_limit_event windows and returns the reset of whichever window
|
|
4
|
+
* actually bound (utilization >= 1.0), not the billing endpoint's five_hour
|
|
5
|
+
* assumption (PRD 1118).
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/rateLimitWindow.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test } from 'vitest';
|
|
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 { resolveBindingRateLimitReset } = require('../rateLimitWindow.cjs');
|
|
18
|
+
|
|
19
|
+
// Repo-tracked, byte-for-byte copy of the real incident log (2026-09-05
|
|
20
|
+
// starry-night-ships 429) so this test is hermetic — CI, a fresh clone, or
|
|
21
|
+
// this machine's own run-log retention sweep (runLogRetention.test.cjs)
|
|
22
|
+
// pruning the live copy must not turn this red for reasons unrelated to the
|
|
23
|
+
// code under test.
|
|
24
|
+
const TRACKED_FIXTURE = path.join(__dirname, 'fixtures', '204-mercury-steam-horse.log.txt');
|
|
25
|
+
const REAL_FIXTURE_LIVE_PATH = '/home/bilko/.claude/session-manager/scheduled-plans/runs/2026-09-05T16-32-18-381Z/204-mercury-steam-horse.log';
|
|
26
|
+
|
|
27
|
+
test('tracked fixture (verbatim copy of the real incident log): seven_day_overage_included (utilization 1.0) binds, not five_hour', () => {
|
|
28
|
+
const reset = resolveBindingRateLimitReset(TRACKED_FIXTURE);
|
|
29
|
+
assert.strictEqual(reset, 1789059600);
|
|
30
|
+
assert.notStrictEqual(reset, 1788643800);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('tracked fixture is byte-identical to the live incident log, when present on this machine', () => {
|
|
34
|
+
if (!fs.existsSync(REAL_FIXTURE_LIVE_PATH)) return; // machine-local artifact, not guaranteed to exist
|
|
35
|
+
assert.strictEqual(
|
|
36
|
+
fs.readFileSync(TRACKED_FIXTURE, 'utf8'),
|
|
37
|
+
fs.readFileSync(REAL_FIXTURE_LIVE_PATH, 'utf8'),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function writeTmpLog(lines) {
|
|
42
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rate-limit-window-test-'));
|
|
43
|
+
const file = path.join(dir, 'job.log');
|
|
44
|
+
fs.writeFileSync(file, lines.map((l) => (typeof l === 'string' ? l : JSON.stringify(l))).join('\n') + '\n');
|
|
45
|
+
return file;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('no rate_limit_event in log → null', () => {
|
|
49
|
+
const logPath = writeTmpLog(['[scheduler] starting job', '{"type":"system","subtype":"init"}']);
|
|
50
|
+
assert.strictEqual(resolveBindingRateLimitReset(logPath), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('rate_limit_event present but no window at/above 1.0 utilization → null', () => {
|
|
54
|
+
const logPath = writeTmpLog([
|
|
55
|
+
{ type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
|
|
56
|
+
five_hour: { utilization: 0, resetsAt: 1788643800 },
|
|
57
|
+
seven_day: { utilization: 0.59, resetsAt: 1789059600 },
|
|
58
|
+
} } },
|
|
59
|
+
]);
|
|
60
|
+
assert.strictEqual(resolveBindingRateLimitReset(logPath), null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('multiple windows bind in the last event → the LATEST reset wins', () => {
|
|
64
|
+
const logPath = writeTmpLog([
|
|
65
|
+
{ type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
|
|
66
|
+
five_hour: { utilization: 1.0, resetsAt: 1000 },
|
|
67
|
+
seven_day: { utilization: 1.0, resetsAt: 2000 },
|
|
68
|
+
} } },
|
|
69
|
+
]);
|
|
70
|
+
assert.strictEqual(resolveBindingRateLimitReset(logPath), 2000);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('uses the LAST rate_limit_event in the log, not the first', () => {
|
|
74
|
+
const logPath = writeTmpLog([
|
|
75
|
+
{ type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
|
|
76
|
+
seven_day: { utilization: 0.3, resetsAt: 111 },
|
|
77
|
+
} } },
|
|
78
|
+
{ type: 'other' },
|
|
79
|
+
{ type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
|
|
80
|
+
seven_day_overage_included: { utilization: 1.0, resetsAt: 999 },
|
|
81
|
+
} } },
|
|
82
|
+
]);
|
|
83
|
+
assert.strictEqual(resolveBindingRateLimitReset(logPath), 999);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('missing file → null (best-effort, matches readTail contract)', () => {
|
|
87
|
+
assert.strictEqual(resolveBindingRateLimitReset('/nonexistent/path/job.log'), null);
|
|
88
|
+
});
|