pi-odd-worktree 1.1.1 → 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,119 @@
1
+ 'use strict';
2
+ // S4: a warp waits for THIS session's own background work.
3
+ //
4
+ // Gentle Shell publishes one presence header per live Pi session, carrying that
5
+ // session's agent-task counters (`counts.{running,queued,waiting,finished}`) and
6
+ // a heartbeat. This module reads that store through the SAME bounded reader the
7
+ // checkout-deletion guard uses, hashes this session's id with the same sha256,
8
+ // and keeps only ITS freshest header. Nothing else is read: no transcript, no
9
+ // prompt, no activity payload.
10
+ //
11
+ // WHAT IT DOES NOT COVER. Background jobs (`bash_background`, `monitor`) live in
12
+ // gentle-shell's in-memory job registry (lib/background-jobs.ts) and leave no
13
+ // durable, session-attributable state on disk, and the Pi host exposes no API
14
+ // for them. This module therefore never claims to see a job: the refusal text
15
+ // names agent tasks only.
16
+ //
17
+ // FAILURE MODE. Best effort, like the close guard: an absent or unreadable
18
+ // store answers `unavailable` and blocks nothing. Only a fresh header that
19
+ // reports running or queued work makes a session busy.
20
+
21
+ const { readGentlePresence, hashSessionId, PRESENCE_FRESH_MS } = require('./worktree-session-guard.js');
22
+
23
+ // How long the command front waits for the published counters to drain before
24
+ // it refuses, and how often it re-reads. Overridable so a session can shorten
25
+ // or lengthen its own wait; the default keeps a warp from stalling a turn.
26
+ const DEFAULT_WAIT_MS = 60000;
27
+ const POLL_MS = 2000;
28
+ const MAX_WAIT_MS = 600000;
29
+
30
+ const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
31
+
32
+ function waitBudgetMs(env) {
33
+ const source = env && typeof env === 'object' ? env : {};
34
+ const raw = source.ODD_WORKTREE_WARP_BUSY_WAIT_MS;
35
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_WAIT_MS;
36
+ const parsed = Number.parseInt(String(raw), 10);
37
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return DEFAULT_WAIT_MS;
38
+ return Math.min(parsed, MAX_WAIT_MS);
39
+ }
40
+
41
+ function idleActivity(status) {
42
+ return { status, running: 0, queued: 0, waiting: 0, label: null, heartbeat: null, busy: false };
43
+ }
44
+
45
+ // This session's own counters, or an idle/unavailable answer. Never rejects.
46
+ async function sessionActivity(options = {}) {
47
+ const opts = options && typeof options === 'object' ? options : {};
48
+ const hash = hashSessionId(opts.sessionId);
49
+ if (hash === null) return idleActivity('unknown');
50
+ let read;
51
+ try {
52
+ read = await readGentlePresence({ env: opts.env, now: opts.now });
53
+ } catch {
54
+ return idleActivity('unavailable');
55
+ }
56
+ if (!read || read.status !== 'ok') return idleActivity(read && read.status === 'unavailable' ? 'unavailable' : 'unknown');
57
+ let freshest = null;
58
+ for (const entry of Array.isArray(read.entries) ? read.entries : []) {
59
+ if (!entry || entry.sessionHash !== hash || entry.recent !== true) continue;
60
+ if (!entry.counts || !isCount(entry.counts.running) || !isCount(entry.counts.queued) || !isCount(entry.counts.waiting)) continue;
61
+ if (freshest === null || entry.heartbeat > freshest.heartbeat) freshest = entry;
62
+ }
63
+ if (freshest === null) return idleActivity('ok');
64
+ const running = freshest.counts.running;
65
+ const queued = freshest.counts.queued;
66
+ return {
67
+ status: 'ok',
68
+ running,
69
+ queued,
70
+ waiting: freshest.counts.waiting,
71
+ label: typeof freshest.label === 'string' ? freshest.label : null,
72
+ heartbeat: freshest.heartbeat,
73
+ busy: running > 0 || queued > 0,
74
+ };
75
+ }
76
+
77
+ // The one sentence both fronts use. It names the counters it actually read and
78
+ // says plainly that background jobs are not visible to it.
79
+ function busyText(activity) {
80
+ const running = activity && isCount(activity.running) ? activity.running : 0;
81
+ const queued = activity && isCount(activity.queued) ? activity.queued : 0;
82
+ const parts = [];
83
+ if (running > 0) parts.push(`${running} running`);
84
+ if (queued > 0) parts.push(`${queued} queued`);
85
+ const workload = parts.length > 0 ? parts.join(' and ') : 'no';
86
+ const label = activity && typeof activity.label === 'string' && activity.label.length > 0
87
+ ? ` on "${activity.label}"`
88
+ : '';
89
+ return `this session still has ${workload} agent task(s)${label}: the warp waits for them to finish (as of the last published heartbeat); background jobs are not visible here, so confirm they finished too`;
90
+ }
91
+
92
+ // Poll until this session has no running or queued agent task, or the budget
93
+ // runs out. `read` and `sleep` are injected so a test never waits for real.
94
+ async function waitForSessionActivity(options = {}) {
95
+ const opts = options && typeof options === 'object' ? options : {};
96
+ const read = typeof opts.read === 'function' ? opts.read : () => sessionActivity({ sessionId: opts.sessionId, env: opts.env });
97
+ const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
98
+ const budget = isCount(opts.waitMs) ? opts.waitMs : waitBudgetMs(opts.env);
99
+ const poll = isCount(opts.pollMs) && opts.pollMs > 0 ? opts.pollMs : POLL_MS;
100
+ let activity = await read();
101
+ let waited = 0;
102
+ while (activity && activity.busy === true && waited < budget) {
103
+ const step = Math.min(poll, budget - waited);
104
+ if (step <= 0) break;
105
+ await sleep(step);
106
+ waited += step;
107
+ activity = await read();
108
+ }
109
+ return { activity, waited, waitedOut: !!(activity && activity.busy === true) };
110
+ }
111
+
112
+ module.exports = {
113
+ DEFAULT_WAIT_MS,
114
+ MAX_WAIT_MS,
115
+ busyText,
116
+ sessionActivity,
117
+ waitBudgetMs,
118
+ waitForSessionActivity,
119
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-odd-worktree",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Pi package for odd-worktree: the Agent Worktree tools (create, list, close, warp), the /warp worktree panel, the /odd-list ODD viewer, and the /issues GitHub issue reader. Works in Pi alone; Herdr is optional.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+ // S1/S2: no creation path reaches a Herdr binary.
3
+ //
4
+ // The package no longer resolves or builds Herdr argv at all, so this suite pins
5
+ // the claim two ways: a `herdr` sentinel FIRST on PATH records any argv it is
6
+ // given (an empty log is the assertion), and a source scan proves no file
7
+ // outside the git seam builds Herdr argv or resolves the binary. Both creation
8
+ // fronts are exercised because they share one creation helper.
9
+ const { test, describe, before, after } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { spawnSync } = require('node:child_process');
15
+ const { loadExtension, SRC, LIB_DIR } = require('./fixtures/load-extension.js');
16
+
17
+ const savedEnv = { ...process.env };
18
+ const scratch = [];
19
+ const track = (entry) => { scratch.push(entry); return entry; };
20
+ after(() => {
21
+ for (const key of Object.keys(process.env)) if (!(key in savedEnv)) delete process.env[key];
22
+ for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
23
+ for (const dir of scratch) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }
24
+ });
25
+
26
+ const BIN = track(fs.mkdtempSync(path.join(os.tmpdir(), 'no-herdr-bin-')));
27
+ const HERDR_LOG = path.join(BIN, 'calls.log');
28
+ fs.writeFileSync(
29
+ path.join(BIN, 'herdr'),
30
+ '#!/bin/sh\nprintf "%s\\n" "$*" >> "$HERDR_LOG"\nexit 1\n',
31
+ { mode: 0o755 },
32
+ );
33
+ fs.writeFileSync(path.join(BIN, 'codegraph'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
34
+
35
+ function herdrCalls() {
36
+ try {
37
+ return fs.readFileSync(HERDR_LOG, 'utf8').trim();
38
+ } catch {
39
+ return '';
40
+ }
41
+ }
42
+ const resetHerdr = () => fs.writeFileSync(HERDR_LOG, '');
43
+
44
+ function git(cwd, args) {
45
+ const out = spawnSync('git', args, { cwd, encoding: 'utf8' });
46
+ if (out.status !== 0) throw new Error(`git ${args.join(' ')}: ${out.stderr}`);
47
+ return out.stdout.trim();
48
+ }
49
+
50
+ function makeRepo() {
51
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'no-herdr-repo-')));
52
+ git(dir, ['init', '-b', 'main']);
53
+ git(dir, ['config', 'user.email', 'no-herdr@example.test']);
54
+ git(dir, ['config', 'user.name', 'No Herdr']);
55
+ fs.writeFileSync(path.join(dir, 'README.md'), '# fixture\n');
56
+ git(dir, ['add', 'README.md']);
57
+ git(dir, ['commit', '-m', 'init']);
58
+ return fs.realpathSync(dir);
59
+ }
60
+
61
+ const resultText = (result) => (result.content || []).map((part) => (part && part.text) || '').join('\n');
62
+
63
+ function ctxFor(repo, notes, ui) {
64
+ return {
65
+ cwd: repo,
66
+ hasUI: true,
67
+ mode: 'tui',
68
+ sessionManager: { getSessionId: () => 'no-herdr-session' },
69
+ ui: { confirm: async () => true, notify: (text, level) => notes.push([level, text]), ...(ui || {}) },
70
+ };
71
+ }
72
+
73
+ // `/warp` create: the selector's create action, the create panel's answer, then
74
+ // the closing selector. Only those answers are scripted.
75
+ function warpCreateUi(panelAnswer) {
76
+ let call = 0;
77
+ return {
78
+ custom: async () => {
79
+ call += 1;
80
+ if (call === 1) return { create: true };
81
+ if (call === 2) return panelAnswer;
82
+ return undefined;
83
+ },
84
+ };
85
+ }
86
+
87
+ let tools;
88
+ let commands;
89
+ before(async () => {
90
+ process.env.PATH = `${BIN}${path.delimiter}${savedEnv.PATH || ''}`;
91
+ process.env.GENTLE_WORKTREE_THREAD = 'no-herdr-session';
92
+ ({ tools, commands } = await loadExtension({}));
93
+ });
94
+
95
+ describe('no creation path invokes Herdr', () => {
96
+ for (const existing of [false, true]) {
97
+ const kind = existing ? 'an existing' : 'a new';
98
+
99
+ test(`worktree_create never invokes Herdr for ${kind} branch`, async () => {
100
+ resetHerdr();
101
+ const repo = makeRepo();
102
+ const branch = existing ? 'feat/adopted' : 'feat/fresh';
103
+ if (existing) git(repo, ['branch', branch]);
104
+ const result = await tools.get('worktree_create').execute(
105
+ 'no-herdr-tool',
106
+ { branch, changes: 'none' },
107
+ undefined,
108
+ undefined,
109
+ ctxFor(repo, []),
110
+ );
111
+ assert.notEqual(result.isError, true, resultText(result));
112
+ assert.equal(result.details.branchCreated, !existing);
113
+ assert.equal(herdrCalls(), '', `Herdr was invoked: ${herdrCalls()}`);
114
+ assert.doesNotMatch(resultText(result), /herdr (workspace|association)/i, 'no Herdr workspace is reported');
115
+ });
116
+
117
+ test(`/warp create never invokes Herdr for ${kind} branch`, async () => {
118
+ resetHerdr();
119
+ const repo = makeRepo();
120
+ const name = existing ? 'adopted' : 'fresh';
121
+ if (existing) git(repo, ['branch', `feat/${name}`]);
122
+ const notes = [];
123
+ await commands.get('warp').handler('', ctxFor(repo, notes, warpCreateUi({ ok: true, type: 'feat', name })));
124
+ const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, `feat-${name}`);
125
+ assert.ok(fs.existsSync(target), `the /warp create made no checkout (notifications: ${JSON.stringify(notes)})`);
126
+ assert.equal(herdrCalls(), '', `Herdr was invoked: ${herdrCalls()}`);
127
+ for (const [, text] of notes) assert.doesNotMatch(text, /herdr (workspace|association)/i, `Herdr reported: ${text}`);
128
+ });
129
+ }
130
+
131
+ test('a refused create writes nothing and never invokes Herdr', async () => {
132
+ resetHerdr();
133
+ const repo = makeRepo();
134
+ const before = git(repo, ['branch', '--format=%(refname:short)']);
135
+ const result = await tools.get('worktree_create').execute(
136
+ 'no-herdr-bad',
137
+ { branch: 'bad..name', changes: 'none' },
138
+ undefined,
139
+ undefined,
140
+ ctxFor(repo, []),
141
+ );
142
+ assert.equal(result.isError, true);
143
+ assert.match(resultText(result), /^invalid branch name/);
144
+ assert.equal(git(repo, ['branch', '--format=%(refname:short)']), before);
145
+ assert.equal(herdrCalls(), '', `Herdr was invoked: ${herdrCalls()}`);
146
+ });
147
+
148
+ // S1 is an AUDIT claim: no source file outside the git seam reaches a Herdr
149
+ // binary. Pinned as text so a reintroduced call cannot hide in a helper.
150
+ test('no source file outside lib/worktree-git.js calls a Herdr binary', () => {
151
+ const files = [
152
+ SRC,
153
+ ...fs.readdirSync(LIB_DIR).filter((name) => name.endsWith('.js')).map((name) => path.join(LIB_DIR, name)),
154
+ ].filter((file) => !file.endsWith(`${path.sep}worktree-git.js`));
155
+ for (const file of files) {
156
+ const source = fs.readFileSync(file, 'utf8');
157
+ assert.doesNotMatch(source, /herdrArgv\s*\(/, `${file} builds Herdr argv`);
158
+ assert.doesNotMatch(source, /resolveHerdrBinary\s*\(/, `${file} resolves the Herdr binary`);
159
+ }
160
+ });
161
+ });