pi-odd-worktree 1.1.0 → 1.2.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.
@@ -0,0 +1,488 @@
1
+ 'use strict';
2
+ // S4: a warp never lands while THIS session still has running agent tasks.
3
+ //
4
+ // The counters come from Gentle Shell's published presence store (one header
5
+ // per live session, validated and bounded by lib/worktree-session-guard.js), so
6
+ // the tests publish a header for their own session id and drive both fronts:
7
+ // the `worktree_warp` tool (which must queue NOTHING) and the `/warp` command
8
+ // (which must refuse instead of switching).
9
+ //
10
+ // Background jobs are deliberately NOT asserted: gentle-shell keeps them in
11
+ // memory and publishes no durable, session-attributable evidence, so no test
12
+ // could prove that half.
13
+ const { test, describe, before, after } = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+ const crypto = require('node:crypto');
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+ const { spawnSync } = require('node:child_process');
20
+ const { loadExtension } = require('./fixtures/load-extension.js');
21
+
22
+ const scratch = [];
23
+ function track(dir) {
24
+ scratch.push(dir);
25
+ return dir;
26
+ }
27
+
28
+ const savedEnv = { ...process.env };
29
+ after(() => {
30
+ for (const key of Object.keys(process.env)) {
31
+ if (!(key in savedEnv)) delete process.env[key];
32
+ }
33
+ for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
34
+ for (const dir of scratch) {
35
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
36
+ }
37
+ });
38
+
39
+ const HOOK_BIN = path.resolve(track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-busy-hookbin-'))));
40
+ fs.writeFileSync(path.join(HOOK_BIN, 'codegraph'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
41
+ const NO_HERDR = path.join(os.tmpdir(), 'odd-worktree-no-such-herdr-binary');
42
+ const AGENT_HOME = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-busy-agent-home-')));
43
+ const PRESENCE = path.join(AGENT_HOME, 'gentle-agents', 'presence');
44
+ const SESSION_ID = 'thread-1';
45
+ const SESSION_HASH = crypto.createHash('sha256').update(SESSION_ID).digest('hex');
46
+
47
+ function git(cwd, args) {
48
+ const out = spawnSync('git', args, { cwd, encoding: 'utf8' });
49
+ if (out.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${out.stderr}`);
50
+ return out.stdout.trim();
51
+ }
52
+
53
+ function makeRepo() {
54
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-busy-repo-')));
55
+ git(dir, ['init', '-b', 'main']);
56
+ git(dir, ['config', 'user.email', 'test@example.test']);
57
+ git(dir, ['config', 'user.name', 'Test']);
58
+ fs.writeFileSync(path.join(dir, 'README.md'), '# fixture\n');
59
+ git(dir, ['add', 'README.md']);
60
+ git(dir, ['commit', '-m', 'init']);
61
+ return dir;
62
+ }
63
+
64
+ // One published header for THIS session. `counts` is the only thing under test;
65
+ // the digest/incarnation shapes are what the reader validates.
66
+ function publishSession({ running = 0, queued = 0, waiting = 0, heartbeat = Date.now(), label = 'Busy session' } = {}) {
67
+ fs.mkdirSync(PRESENCE, { recursive: true, mode: 0o700 });
68
+ fs.chmodSync(PRESENCE, 0o700);
69
+ const incarnation = '11111111-2222-4333-8444-555555555555';
70
+ fs.writeFileSync(path.join(PRESENCE, `${SESSION_HASH}.${incarnation}.header.json`), JSON.stringify({
71
+ schema: 1,
72
+ sessionHash: SESSION_HASH,
73
+ incarnation,
74
+ label,
75
+ heartbeat,
76
+ generation: 1,
77
+ counts: { running, queued, waiting, finished: 0 },
78
+ digest: 'c'.repeat(64),
79
+ unavailable: null,
80
+ }), { mode: 0o600 });
81
+ fs.writeFileSync(path.join(PRESENCE, `${SESSION_HASH}.${incarnation}.discovery.json`), JSON.stringify({
82
+ schema: 1, sessionHash: SESSION_HASH, incarnation, generation: 1,
83
+ metadata: { activation: 'd'.repeat(64), workspace: '/tmp/workspace', tasks: [], omitted: 0 },
84
+ }), { mode: 0o600 });
85
+ }
86
+
87
+ function clearPresence() {
88
+ try { fs.rmSync(PRESENCE, { recursive: true, force: true }); } catch { /* absent is fine */ }
89
+ }
90
+
91
+ const notifications = [];
92
+ function makeCtx(cwd, extra = {}) {
93
+ const switches = [];
94
+ return {
95
+ switches,
96
+ ctx: {
97
+ cwd,
98
+ hasUI: true,
99
+ mode: 'tui',
100
+ sessionManager: { getSessionId: () => SESSION_ID },
101
+ ui: {
102
+ notify: (text, level) => { notifications.push([level, text]); },
103
+ ...extra,
104
+ },
105
+ waitForIdle: async () => {},
106
+ switchSession: async (file) => { switches.push(file); return { cancelled: false }; },
107
+ },
108
+ };
109
+ }
110
+
111
+ function resultText(result) {
112
+ return (result.content || []).map((part) => (part && part.text) || '').join('\n');
113
+ }
114
+
115
+ let host;
116
+ let commands;
117
+ let tools;
118
+ before(async () => {
119
+ process.env.HERDR_BIN_PATH = NO_HERDR;
120
+ process.env.GENTLE_WORKTREE_THREAD = SESSION_ID;
121
+ process.env.PATH = `${HOOK_BIN}${path.delimiter}${savedEnv.PATH || ''}`;
122
+ delete process.env.GENTLE_PI_AGENT_HOME;
123
+ process.env.PI_CODING_AGENT_DIR = AGENT_HOME;
124
+ // The command front's wait is bounded by this budget; tests never wait.
125
+ process.env.ODD_WORKTREE_WARP_BUSY_WAIT_MS = '0';
126
+ const sent = [];
127
+ const loaded = await loadExtension({ sendUserMessage: (text, options) => { sent.push([text, options]); } });
128
+ host = loaded.host;
129
+ host.sent = sent;
130
+ commands = loaded.commands;
131
+ tools = loaded.tools;
132
+ });
133
+
134
+ describe('lib/worktree-warp-busy.js', () => {
135
+ const busyLib = require('../lib/worktree-warp-busy.js');
136
+
137
+ test('reads only this session’s own counters', async () => {
138
+ const home = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-busy-lib-')));
139
+ const dir = path.join(home, 'gentle-agents', 'presence');
140
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
141
+ const mine = crypto.createHash('sha256').update('session-a').digest('hex');
142
+ const other = crypto.createHash('sha256').update('session-b').digest('hex');
143
+ const write = (hash, running) => fs.writeFileSync(path.join(dir, `${hash}.11111111-2222-4333-8444-555555555555.header.json`), JSON.stringify({
144
+ schema: 1, sessionHash: hash, incarnation: '11111111-2222-4333-8444-555555555555',
145
+ label: 'L', heartbeat: 1000, generation: 1,
146
+ counts: { running, queued: 0, waiting: 0, finished: 0 },
147
+ digest: 'e'.repeat(64), unavailable: null,
148
+ }));
149
+ write(mine, 2);
150
+ write(other, 7);
151
+
152
+ const activity = await busyLib.sessionActivity({ sessionId: 'session-a', env: { PI_CODING_AGENT_DIR: home }, now: 1000 });
153
+ assert.equal(activity.status, 'ok');
154
+ assert.equal(activity.running, 2);
155
+ assert.equal(activity.busy, true);
156
+ });
157
+
158
+ test('a missing store is unavailable and blocks nothing', async () => {
159
+ const home = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-busy-empty-')));
160
+ const activity = await busyLib.sessionActivity({ sessionId: 'session-a', env: { PI_CODING_AGENT_DIR: home } });
161
+ assert.equal(activity.status, 'unavailable');
162
+ assert.equal(activity.busy, false);
163
+ });
164
+
165
+ test('a stale heartbeat is not evidence of running work', async () => {
166
+ const home = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-busy-stale-')));
167
+ const dir = path.join(home, 'gentle-agents', 'presence');
168
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
169
+ const mine = crypto.createHash('sha256').update('session-a').digest('hex');
170
+ fs.writeFileSync(path.join(dir, `${mine}.11111111-2222-4333-8444-555555555555.header.json`), JSON.stringify({
171
+ schema: 1, sessionHash: mine, incarnation: '11111111-2222-4333-8444-555555555555',
172
+ label: 'L', heartbeat: 1000, generation: 1,
173
+ counts: { running: 3, queued: 0, waiting: 0, finished: 0 },
174
+ digest: 'e'.repeat(64), unavailable: null,
175
+ }));
176
+ const activity = await busyLib.sessionActivity({ sessionId: 'session-a', env: { PI_CODING_AGENT_DIR: home }, now: 600000 });
177
+ assert.equal(activity.status, 'ok');
178
+ assert.equal(activity.busy, false);
179
+ });
180
+
181
+ test('the bounded wait drains, and reports when the budget runs out', async () => {
182
+ const answers = [
183
+ { busy: true, running: 2, queued: 0, label: 'L', status: 'ok' },
184
+ { busy: true, running: 1, queued: 0, label: 'L', status: 'ok' },
185
+ { busy: false, running: 0, queued: 0, label: 'L', status: 'ok' },
186
+ ];
187
+ let index = 0;
188
+ const slept = [];
189
+ const drained = await busyLib.waitForSessionActivity({
190
+ read: async () => answers[Math.min(index++, answers.length - 1)],
191
+ sleep: async (ms) => { slept.push(ms); },
192
+ waitMs: 6000,
193
+ pollMs: 2000,
194
+ });
195
+ assert.equal(drained.activity.busy, false);
196
+ assert.equal(drained.waitedOut, false);
197
+ assert.deepEqual(slept, [2000, 2000]);
198
+
199
+ const never = await busyLib.waitForSessionActivity({
200
+ read: async () => ({ busy: true, running: 1, queued: 0, label: 'L', status: 'ok' }),
201
+ sleep: async () => {},
202
+ waitMs: 3000,
203
+ pollMs: 2000,
204
+ });
205
+ assert.equal(never.waitedOut, true);
206
+ assert.equal(never.waited, 3000);
207
+ });
208
+
209
+ test('the refusal text names agent tasks and admits jobs are invisible', () => {
210
+ const text = busyLib.busyText({ running: 2, queued: 1, label: 'Worker' });
211
+ assert.match(text, /2 running and 1 queued agent task\(s\) on "Worker"/);
212
+ assert.match(text, /background jobs are not visible here/);
213
+ });
214
+ });
215
+
216
+ describe('warp fronts while this session is busy', () => {
217
+ test('the tool queues nothing while an agent task is running', async () => {
218
+ const repo = makeRepo();
219
+ const { ctx } = makeCtx(repo);
220
+ const created = await tools.get('worktree_create').execute('busy-1', { branch: 'feat/busy', changes: 'none' }, undefined, undefined, ctx);
221
+ assert.notEqual(created.isError, true, resultText(created));
222
+
223
+ publishSession({ running: 1 });
224
+ host.sent.length = 0;
225
+ const refused = await tools.get('worktree_warp').execute('busy-2', { name: 'feat-busy' }, undefined, undefined, ctx);
226
+ assert.equal(refused.isError, true);
227
+ assert.match(resultText(refused), /^worktree warp: this session still has 1 running agent task\(s\)/);
228
+ assert.equal(host.sent.length, 0, 'nothing may be queued while a task runs');
229
+ assert.equal(refused.details.busy, true);
230
+ });
231
+
232
+ test('the tool queues the handoff once the counters drain', async () => {
233
+ const repo = makeRepo();
234
+ const { ctx } = makeCtx(repo);
235
+ const created = await tools.get('worktree_create').execute('busy-3', { branch: 'feat/free', changes: 'none' }, undefined, undefined, ctx);
236
+ assert.notEqual(created.isError, true, resultText(created));
237
+
238
+ publishSession({ running: 0 });
239
+ host.sent.length = 0;
240
+ const queued = await tools.get('worktree_warp').execute('busy-4', { name: 'feat-free' }, undefined, undefined, ctx);
241
+ assert.notEqual(queued.isError, true, resultText(queued));
242
+ assert.match(resultText(queued), /^worktree warp: handoff queued/);
243
+ assert.equal(host.sent.length, 1);
244
+ });
245
+
246
+ test('the command refuses and never switches while an agent task is running', async () => {
247
+ const repo = makeRepo();
248
+ const { ctx, switches } = makeCtx(repo);
249
+ const created = await tools.get('worktree_create').execute('busy-5', { branch: 'feat/busy-cmd', changes: 'none' }, undefined, undefined, ctx);
250
+ assert.notEqual(created.isError, true, resultText(created));
251
+
252
+ publishSession({ running: 2 });
253
+ notifications.length = 0;
254
+ await commands.get('warp').handler('feat-busy-cmd', ctx);
255
+ assert.equal(switches.length, 0, 'the session must not move under a running task');
256
+ assert.equal(notifications.length, 1);
257
+ assert.equal(notifications[0][0], 'error');
258
+ assert.match(notifications[0][1], /^worktree warp: this session still has 2 running agent task\(s\)/);
259
+ });
260
+
261
+ test('an unavailable presence store does not block the command', async () => {
262
+ const repo = makeRepo();
263
+ const { ctx } = makeCtx(repo);
264
+ const created = await tools.get('worktree_create').execute('busy-6', { branch: 'feat/no-store', changes: 'none' }, undefined, undefined, ctx);
265
+ assert.notEqual(created.isError, true, resultText(created));
266
+
267
+ clearPresence();
268
+ notifications.length = 0;
269
+ await commands.get('warp').handler('feat-no-store', ctx);
270
+ // Whatever this minimal host context can or cannot do later, the busy
271
+ // refusal must not appear: without evidence nothing is claimed to be busy.
272
+ const busyRefusals = notifications.filter(([, text]) => /still has .* agent task/.test(String(text)));
273
+ assert.deepEqual(busyRefusals, [], `no busy refusal without evidence: ${JSON.stringify(notifications)}`);
274
+ });
275
+ });
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // S4 boundaries: what the guard must NOT block, every entry path, and the
279
+ // surfaces that must keep working while this session reports running work.
280
+ // ---------------------------------------------------------------------------
281
+ describe('S4 boundaries: negative cases, every front, unaffected surfaces', () => {
282
+ const busyLib = require('../lib/worktree-warp-busy.js');
283
+ const INCARNATION = '11111111-2222-4333-8444-555555555555';
284
+
285
+ function writeOtherSession(counts) {
286
+ fs.mkdirSync(PRESENCE, { recursive: true, mode: 0o700 });
287
+ fs.chmodSync(PRESENCE, 0o700);
288
+ const other = crypto.createHash('sha256').update('thread-2').digest('hex');
289
+ fs.writeFileSync(path.join(PRESENCE, `${other}.${INCARNATION}.header.json`), JSON.stringify({
290
+ schema: 1, sessionHash: other, incarnation: INCARNATION, label: 'Other session',
291
+ heartbeat: Date.now(), generation: 1,
292
+ counts: { running: 0, queued: 0, waiting: 0, finished: 0, ...counts },
293
+ digest: 'f'.repeat(64), unavailable: null,
294
+ }), { mode: 0o600 });
295
+ }
296
+
297
+ test('the tool queues its handoff when the presence store is absent', async () => {
298
+ const repo = makeRepo();
299
+ const { ctx } = makeCtx(repo);
300
+ const created = await tools.get('worktree_create').execute('busy-7', { branch: 'feat/nostore', changes: 'none' }, undefined, undefined, ctx);
301
+ assert.notEqual(created.isError, true, resultText(created));
302
+ clearPresence();
303
+ host.sent.length = 0;
304
+ const queued = await tools.get('worktree_warp').execute('busy-8', { name: 'feat-nostore' }, undefined, undefined, ctx);
305
+ assert.notEqual(queued.isError, true, resultText(queued));
306
+ assert.match(resultText(queued), /^worktree warp: handoff queued/);
307
+ assert.equal(host.sent.length, 1, 'an absent store blocks nothing');
308
+ });
309
+
310
+ test('a stale heartbeat and an unreadable record never block the tool', async () => {
311
+ const repo = makeRepo();
312
+ const { ctx } = makeCtx(repo);
313
+ const created = await tools.get('worktree_create').execute('busy-9', { branch: 'feat/stale', changes: 'none' }, undefined, undefined, ctx);
314
+ assert.notEqual(created.isError, true, resultText(created));
315
+
316
+ publishSession({ running: 3, heartbeat: Date.now() - 600000 });
317
+ host.sent.length = 0;
318
+ const stale = await tools.get('worktree_warp').execute('busy-10', { name: 'feat-stale' }, undefined, undefined, ctx);
319
+ assert.notEqual(stale.isError, true, `a stale heartbeat is not evidence: ${resultText(stale)}`);
320
+ assert.equal(host.sent.length, 1);
321
+
322
+ publishSession({ running: 3 });
323
+ fs.chmodSync(PRESENCE, 0o755);
324
+ try {
325
+ host.sent.length = 0;
326
+ const unreadable = await tools.get('worktree_warp').execute('busy-11', { name: 'feat-stale' }, undefined, undefined, ctx);
327
+ assert.notEqual(unreadable.isError, true, `an unsafe store is no evidence: ${resultText(unreadable)}`);
328
+ assert.equal(host.sent.length, 1);
329
+ } finally {
330
+ fs.chmodSync(PRESENCE, 0o700);
331
+ }
332
+ });
333
+
334
+ test('queued counters block; waiting and finished counters do not', async () => {
335
+ const repo = makeRepo();
336
+ const { ctx } = makeCtx(repo);
337
+ const created = await tools.get('worktree_create').execute('busy-12', { branch: 'feat/queued', changes: 'none' }, undefined, undefined, ctx);
338
+ assert.notEqual(created.isError, true, resultText(created));
339
+
340
+ publishSession({ running: 0, queued: 2, waiting: 1, finished: 4 });
341
+ host.sent.length = 0;
342
+ const refused = await tools.get('worktree_warp').execute('busy-13', { name: 'feat-queued' }, undefined, undefined, ctx);
343
+ assert.equal(refused.isError, true, 'queued work must refuse');
344
+ assert.equal(host.sent.length, 0, 'a refusal queues nothing');
345
+ assert.match(resultText(refused), /2 queued agent task\(s\)/);
346
+
347
+ publishSession({ running: 0, queued: 0, waiting: 4, finished: 9 });
348
+ host.sent.length = 0;
349
+ const idle = await tools.get('worktree_warp').execute('busy-14', { name: 'feat-queued' }, undefined, undefined, ctx);
350
+ assert.notEqual(idle.isError, true, `waiting/finished work is not busy: ${resultText(idle)}`);
351
+ assert.equal(host.sent.length, 1);
352
+ });
353
+
354
+ test('a busy refusal leaves the worktree listing and the registry byte-identical', async () => {
355
+ const repo = makeRepo();
356
+ const { ctx } = makeCtx(repo);
357
+ const created = await tools.get('worktree_create').execute('busy-15', { branch: 'feat/intact', changes: 'none' }, undefined, undefined, ctx);
358
+ assert.notEqual(created.isError, true, resultText(created));
359
+
360
+ const registryPath = path.resolve(repo, git(repo, ['rev-parse', '--git-common-dir']), 'pi-worktrees.json');
361
+ const listBefore = git(repo, ['worktree', 'list', '--porcelain']);
362
+ const registryBefore = crypto.createHash('sha256').update(fs.readFileSync(registryPath)).digest('hex');
363
+
364
+ publishSession({ running: 1 });
365
+ host.sent.length = 0;
366
+ const refused = await tools.get('worktree_warp').execute('busy-16', { name: 'feat-intact' }, undefined, undefined, ctx);
367
+ assert.equal(refused.isError, true);
368
+ assert.equal(host.sent.length, 0);
369
+
370
+ assert.equal(git(repo, ['worktree', 'list', '--porcelain']), listBefore, 'the listing changed');
371
+ assert.equal(
372
+ crypto.createHash('sha256').update(fs.readFileSync(registryPath)).digest('hex'),
373
+ registryBefore,
374
+ 'the registry changed',
375
+ );
376
+ });
377
+
378
+ test('a queued handoff token is refused by /warp when this session turns busy', async () => {
379
+ const repo = makeRepo();
380
+ const { ctx } = makeCtx(repo);
381
+ const created = await tools.get('worktree_create').execute('busy-17', { branch: 'feat/token', changes: 'none' }, undefined, undefined, ctx);
382
+ assert.notEqual(created.isError, true, resultText(created));
383
+
384
+ clearPresence();
385
+ host.sent.length = 0;
386
+ const queued = await tools.get('worktree_warp').execute('busy-18', { name: 'feat-token' }, undefined, undefined, ctx);
387
+ assert.notEqual(queued.isError, true, resultText(queued));
388
+ assert.equal(host.sent.length, 1);
389
+ const token = host.sent[0][0].replace(/^\/warp /, '');
390
+
391
+ publishSession({ running: 2 });
392
+ notifications.length = 0;
393
+ const switches = [];
394
+ ctx.switchSession = async (file) => { switches.push(file); return { cancelled: false }; };
395
+ await commands.get('warp').handler(token, ctx);
396
+ assert.equal(switches.length, 0, 'a handoff must not move the session under a running task');
397
+ assert.equal(notifications.length, 1, JSON.stringify(notifications));
398
+ assert.match(notifications[0][1], /still has 2 running agent task\(s\)/);
399
+ });
400
+
401
+ test('the selector pick refuses and never switches while this session is busy', async () => {
402
+ const repo = makeRepo();
403
+ const { ctx: createCtx } = makeCtx(repo);
404
+ const created = await tools.get('worktree_create').execute('busy-19', { branch: 'feat/pick', changes: 'none' }, undefined, undefined, createCtx);
405
+ assert.notEqual(created.isError, true, resultText(created));
406
+
407
+ const switches = [];
408
+ const picks = [];
409
+ const ctx = {
410
+ cwd: repo,
411
+ hasUI: true,
412
+ mode: 'tui',
413
+ sessionManager: { getSessionId: () => SESSION_ID },
414
+ ui: {
415
+ notify: (text, level) => { notifications.push([level, text]); },
416
+ confirm: async () => true,
417
+ select: async (_title, options) => {
418
+ const want = picks.shift();
419
+ return want ? options.find((option) => String(option).startsWith(`${want} `)) : undefined;
420
+ },
421
+ },
422
+ waitForIdle: async () => {},
423
+ switchSession: async (file) => { switches.push(file); return { cancelled: false }; },
424
+ };
425
+
426
+ publishSession({ running: 2 });
427
+ notifications.length = 0;
428
+ picks.push('feat-pick');
429
+ await commands.get('warp').handler('', ctx);
430
+ assert.equal(switches.length, 0, 'the selector must not move the session under a running task');
431
+ assert.equal(notifications.filter(([, text]) => /still has .* agent task/.test(String(text))).length, 1);
432
+ });
433
+
434
+ test('another session’s running tasks never block this session', async () => {
435
+ const repo = makeRepo();
436
+ const { ctx } = makeCtx(repo);
437
+ const created = await tools.get('worktree_create').execute('busy-20', { branch: 'feat/other', changes: 'none' }, undefined, undefined, ctx);
438
+ assert.notEqual(created.isError, true, resultText(created));
439
+
440
+ clearPresence();
441
+ writeOtherSession({ running: 5, queued: 3 });
442
+ host.sent.length = 0;
443
+ const queued = await tools.get('worktree_warp').execute('busy-21', { name: 'feat-other' }, undefined, undefined, ctx);
444
+ assert.notEqual(queued.isError, true, `a peer's work is not this session's: ${resultText(queued)}`);
445
+ assert.equal(host.sent.length, 1);
446
+
447
+ notifications.length = 0;
448
+ await commands.get('warp').handler('feat-other', ctx);
449
+ assert.deepEqual(notifications.filter(([, text]) => /still has .* agent task/.test(String(text))), []);
450
+ });
451
+
452
+ test('list, create and close still work while this session reports running work', async () => {
453
+ const repo = makeRepo();
454
+ // The close approval question is part of its own flow: it is answered here
455
+ // so this test measures only whether the busy counters leak into it.
456
+ const ctx = {
457
+ cwd: repo,
458
+ hasUI: true,
459
+ mode: 'tui',
460
+ sessionManager: { getSessionId: () => SESSION_ID },
461
+ ui: {
462
+ notify: (text, level) => { notifications.push([level, text]); },
463
+ confirm: async () => true,
464
+ },
465
+ waitForIdle: async () => {},
466
+ switchSession: async () => ({ cancelled: false }),
467
+ };
468
+ publishSession({ running: 2, queued: 1 });
469
+
470
+ const listed = await tools.get('worktree_list').execute('busy-22', {}, undefined, undefined, ctx);
471
+ assert.notEqual(listed.isError, true, resultText(listed));
472
+ const created = await tools.get('worktree_create').execute('busy-23', { branch: 'feat/surface', changes: 'none' }, undefined, undefined, ctx);
473
+ assert.notEqual(created.isError, true, resultText(created));
474
+ const closed = await tools.get('worktree_close').execute('busy-24', { name: 'feat-surface', force: true }, undefined, undefined, ctx);
475
+ assert.notEqual(closed.isError, true, resultText(closed));
476
+ });
477
+
478
+ test('the wait budget is parsed, defaulted and clamped', () => {
479
+ assert.equal(busyLib.DEFAULT_WAIT_MS, 60000);
480
+ assert.equal(busyLib.MAX_WAIT_MS, 600000);
481
+ assert.equal(busyLib.waitBudgetMs({}), 60000);
482
+ assert.equal(busyLib.waitBudgetMs({ ODD_WORKTREE_WARP_BUSY_WAIT_MS: '' }), 60000);
483
+ assert.equal(busyLib.waitBudgetMs({ ODD_WORKTREE_WARP_BUSY_WAIT_MS: '5000' }), 5000);
484
+ assert.equal(busyLib.waitBudgetMs({ ODD_WORKTREE_WARP_BUSY_WAIT_MS: '-1' }), 60000);
485
+ assert.equal(busyLib.waitBudgetMs({ ODD_WORKTREE_WARP_BUSY_WAIT_MS: 'abc' }), 60000);
486
+ assert.equal(busyLib.waitBudgetMs({ ODD_WORKTREE_WARP_BUSY_WAIT_MS: '999999999' }), 600000);
487
+ });
488
+ });