From ba1259c080d1ee70406852c381135f442370dd90 Mon Sep 17 00:00:00 2001 From: Ben Silverstein Date: Wed, 4 Nov 2026 10:46:17 -0500 Subject: [PATCH 1/3] fix(view): health scan drops the launching agent's session [A6] The spawned scan inherited the view's shell markers and session id, so after pullboard takeover every scan bound the main checkout under the old session and was refused NOT_YOUR_CHECKOUT, recording no health. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Ben Silverstein --- CHANGELOG.md | 4 ++++ src/agent-session.js | 13 +++++++++++ src/serve-sentinel.js | 15 ++++++++----- src/tour.js | 14 ++---------- test/health-sentinel.test.js | 43 +++++++++++++++++++++++++++++++++++- 5 files changed, 71 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd7e451b3..7f952f858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Notable changes to Pullboard, for people using it. The format follows [Keep a Ch ## [Unreleased] +### Fixed + +- The view's ten-minute health scan runs as the view, not as the agent shell that started it, so a coordinator takeover no longer leaves it refused NOT_YOUR_CHECKOUT every ten minutes with no health findings recorded. + ## [0.9.3] - 2026-10-26 Teams and many boards: a board now knows its people and their roles, each repo can keep a private board beside the team board, and every move completes locally at once. diff --git a/src/agent-session.js b/src/agent-session.js index 6a9f44f38..9d5da7cb3 100644 --- a/src/agent-session.js +++ b/src/agent-session.js @@ -13,6 +13,7 @@ import { join, resolve, } from 'node:path'; +import { AGENT_SESSION_IDS, AGENT_SHELL_MARKERS } from './person.js'; import { holdsLease, takeResource, @@ -28,6 +29,18 @@ export const CHECKOUT_SESSION_TOKEN_ENV = 'PULLBOARD_CHECKOUT_SESSION_TOKEN'; /** How long a hook waits for a checkout another command holds before it names the holder and stops (#906). */ export const HOOK_CHECKOUT_WAIT_MS = 30_000; +/** + * The caller's agent-session identity and checkout lease. A process that acts as its own seat, or as the view, must + * never inherit them: a scripted seat would be recorded under the caller's session and refused as self-verification + * (#952), and a view-spawned scan would be judged against a checkout that a later session of the same agent took over. + */ +export const CALLER_SESSION_NAMES = Object.freeze([...AGENT_SHELL_MARKERS, ...AGENT_SESSION_IDS, CHECKOUT_SESSION_TOKEN_ENV]); + +/** Copy an environment without the caller's agent session, so the child carries no session digest to bind with. */ +export function withoutCallerSession(environment = process.env) { + return Object.fromEntries(Object.entries(environment).filter(([name]) => !CALLER_SESSION_NAMES.includes(name))); +} + /** Take the checkout's lease, act under the one a parent command passed down, or stop after waitLimitMs naming the holder. */ async function checkoutLease(info, name, agent, waitLimitMs) { const inherited = process.env[CHECKOUT_SESSION_TOKEN_ENV]; diff --git a/src/serve-sentinel.js b/src/serve-sentinel.js index 13c392a72..a349c5b69 100644 --- a/src/serve-sentinel.js +++ b/src/serve-sentinel.js @@ -1,6 +1,7 @@ /** Keep each view-served board's health observer running without overlapping scans [A6]. */ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { withoutCallerSession } from './agent-session.js'; import { listProjects } from './projects.js'; import { loadConfig } from './config.js'; import { healthSentinelEnabled } from './settings.js'; @@ -15,11 +16,15 @@ function sentinelRunsFor(root) { return healthSentinelEnabled(loadConfig(root)); } -/** Run only the ordered health scan in one owned child and drain it on view shutdown. */ -function refreshBoard(root, signal, words = ['sentinel', 'run', '--health-only', '--json'], degradedIsExpected = false) { +/** + * Run only the ordered health scan in one owned child and drain it on view shutdown. The child runs as the view, not + * as the agent whose shell started it: with that shell's session markers it would bind the main checkout under a stale + * session and be refused NOT_YOUR_CHECKOUT every ten minutes once a later session of the agent took the checkout over. + */ +export function refreshBoard(root, signal, { words = ['sentinel', 'run', '--health-only', '--json'], degradedIsExpected = false, start = spawn } = {}) { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [CLI, ...words], { - cwd: root, env: process.env, stdio: 'ignore', signal, + const child = start(process.execPath, [CLI, ...words], { + cwd: root, env: withoutCallerSession(process.env), stdio: 'ignore', signal, }); let timer; /** Bound shutdown of this exact child; never signal a peer or a process selected by name. */ @@ -39,7 +44,7 @@ function refreshBoard(root, signal, words = ['sentinel', 'run', '--health-only', /** Run the configured ordinary canary command; a failed round trip is a recorded observation. */ function refreshCanary(root, signal) { - return refreshBoard(root, signal, ['relay', 'canary', 'run', '--json'], true); + return refreshBoard(root, signal, { words: ['relay', 'canary', 'run', '--json'], degradedIsExpected: true }); } /** Start after the view listens; scans use the ordinary sentinel command so each board's off switch and ordering apply. */ diff --git a/src/tour.js b/src/tour.js index a4d3a9546..3fc1dd867 100644 --- a/src/tour.js +++ b/src/tour.js @@ -19,25 +19,15 @@ import { join, } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { CHECKOUT_SESSION_TOKEN_ENV } from './agent-session.js'; +import { CALLER_SESSION_NAMES } from './agent-session.js'; import { displayAgentName } from './agent-names.js'; import { loadConfig } from './config.js'; -import { - AGENT_SESSION_IDS, - AGENT_SHELL_MARKERS, -} from './person.js'; import { forgetProject, listProjects, registerProject, } from './projects.js'; -/** - * The caller's agent-session identity and checkout lease. A scripted seat must never inherit them: a seat that did - * would be recorded under the caller's session, and its verify would then be refused as self-verification (#952). - */ -const CALLER_SESSION_NAMES = new Set([...AGENT_SHELL_MARKERS, ...AGENT_SESSION_IDS, CHECKOUT_SESSION_TOKEN_ENV]); - const BIN = fileURLToPath(new URL('../bin/pullboard.js', import.meta.url)); const SPEC = `# Greeter @@ -129,7 +119,7 @@ export async function tour(io) { // Nothing of the person's git setup reaches the tour: no config files, and no GIT_ variable, such // as GIT_DIR from a hook or GIT_CONFIG_COUNT pairs, which git reads as config too. The caller's agent session // stays out as well, so each seat is judged as its own session below (#952). - const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_') && name !== 'NODE_TEST_CONTEXT' && !CALLER_SESSION_NAMES.has(name))); + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_') && name !== 'NODE_TEST_CONTEXT' && !CALLER_SESSION_NAMES.includes(name))); Object.assign(env, { PATH: `${shims}:${process.env.PATH}`, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', PULLBOARD_HOME: join(dir, '.pullboard'), PULLBOARD_MACHINE_HOME: join(dir, '.pullboard-machine') }); const repo = join(dir, 'greeter'); const app = `${repo}-app-1`; diff --git a/test/health-sentinel.test.js b/test/health-sentinel.test.js index b67a4c721..352e12add 100644 --- a/test/health-sentinel.test.js +++ b/test/health-sentinel.test.js @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { test } from 'node:test'; +import { CALLER_SESSION_NAMES, checkoutSessionFile, withoutCallerSession } from '../src/agent-session.js'; import * as store from '../src/board.js'; import { main } from '../src/cli.js'; import { loadConfig } from '../src/config.js'; @@ -12,8 +13,10 @@ import { applyRelayMove, appliedSequence, prepareEngineMove } from '../src/engin import { exportBoard } from '../src/exchange.js'; import { HEALTH_SENTINEL_ENGINE } from '../src/health-receipts.js'; import { scanHealthSentinel } from '../src/health-sentinel.js'; +import { repoInfo } from '../src/git.js'; +import { agentSessionDigest } from '../src/person.js'; import { eventCauses, rigorReport } from '../src/rigor.js'; -import { startServeSentinel, HEALTH_SENTINEL_INTERVAL_MS } from '../src/serve-sentinel.js'; +import { refreshBoard, startServeSentinel, HEALTH_SENTINEL_INTERVAL_MS } from '../src/serve-sentinel.js'; import { machineSettingsFile } from '../src/settings.js'; import { frozenCriterion, loadSpec } from '../src/spec.js'; import { runFixtureGit } from './fixture-child.js'; @@ -238,3 +241,41 @@ test('the background sentinel uses the ten minute cadence, skips overlaps and dr assert.equal(tick(), null); release(); }); + +test('the view-spawned health scan carries no agent session, so a checkout a later session took over never refuses it [A6]', async t => { + const box = fixture(t); + const launcher = { CLAUDECODE: '1', CLAUDE_CODE_SESSION_ID: 'view-launcher' }; + const original = Object.fromEntries(Object.keys(launcher).map(key => [key, process.env[key]])); + t.after(() => { for (const [key, value] of Object.entries(original)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); + // The same agent's later session took the main checkout over, as pullboard takeover records it. + const later = agentSessionDigest({ CLAUDECODE: '1', CLAUDE_CODE_SESSION_ID: 'later-coordinator' }); + writeFileSync(checkoutSessionFile(repoInfo(box.root)), JSON.stringify({ version: 1, agent: 'coordinator', digest: later, at: new Date().toISOString() }) + '\n'); + const run = async () => { + let stdout = ''; let stderr = ''; + const code = await main(['sentinel', 'run', '--health-only', '--json'], { cwd: box.root, clock: box.clock, + stdout: { isTTY: false, write: value => { stdout += value; } }, stderr: { write: value => { stderr += value; } } }); + return { code, stdout, stderr }; + }; + Object.assign(process.env, launcher); + const stale = await run(); + assert.notEqual(stale.code, 0, 'control: the launching shell’s own session is refused by the taken-over checkout'); + assert.match(stale.stdout + stale.stderr, /NOT_YOUR_CHECKOUT/); + const { EventEmitter } = await import('node:events'); + let spawned; + await refreshBoard(box.root, new AbortController().signal, { start: (command, args, options) => { + spawned = { command, args, options }; + const child = new EventEmitter(); child.exitCode = 0; child.signalCode = null; + queueMicrotask(() => child.emit('close', 0)); + return child; + } }); + assert.deepEqual(spawned.args.slice(-4), ['sentinel', 'run', '--health-only', '--json']); + assert.equal(spawned.options.cwd, box.root); + for (const name of CALLER_SESSION_NAMES) assert.equal(name in spawned.options.env, false, `${name} stays out of the scan`); + assert.equal(spawned.options.env.PULLBOARD_HOME, process.env.PULLBOARD_HOME, 'everything else reaches the scan'); + assert.deepEqual(spawned.options.env, withoutCallerSession(process.env)); + for (const name of CALLER_SESSION_NAMES) delete process.env[name]; + const asView = await run(); + assert.equal(asView.code, 0, asView.stderr); + assert.ok(JSON.parse(asView.stdout).health, 'the scan ran and recorded health'); + assert.equal(JSON.parse(readFileSync(checkoutSessionFile(repoInfo(box.root)), 'utf8')).digest, later, 'the scan never rebinds the checkout'); +}); From 3c845aa4ea0e99b942c1461bbae5bf1c14e35fa9 Mon Sep 17 00:00:00 2001 From: Ben Silverstein Date: Wed, 4 Nov 2026 11:02:04 -0500 Subject: [PATCH 2/3] docs(changelog): cite the view health scan issue [A6] Co-Authored-By: Claude Fable 5.1 Signed-off-by: Ben Silverstein --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f952f858..87434d1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Notable changes to Pullboard, for people using it. The format follows [Keep a Ch ### Fixed -- The view's ten-minute health scan runs as the view, not as the agent shell that started it, so a coordinator takeover no longer leaves it refused NOT_YOUR_CHECKOUT every ten minutes with no health findings recorded. +- The view's ten-minute health scan runs as the view, not as the agent shell that started it, so a coordinator takeover no longer leaves it refused NOT_YOUR_CHECKOUT every ten minutes with no health findings recorded (#17). ## [0.9.3] - 2026-10-26 From a769e6eb1ed8db3ae167657ba0cf01cecd6b72e5 Mon Sep 17 00:00:00 2001 From: Ben Silverstein Date: Wed, 4 Nov 2026 11:52:13 -0500 Subject: [PATCH 3/3] style(session): one import binding per line [C7] Co-Authored-By: Claude Fable 5.1 Signed-off-by: Ben Silverstein --- src/agent-session.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agent-session.js b/src/agent-session.js index 9d5da7cb3..ca6f42314 100644 --- a/src/agent-session.js +++ b/src/agent-session.js @@ -13,7 +13,10 @@ import { join, resolve, } from 'node:path'; -import { AGENT_SESSION_IDS, AGENT_SHELL_MARKERS } from './person.js'; +import { + AGENT_SESSION_IDS, + AGENT_SHELL_MARKERS, +} from './person.js'; import { holdsLease, takeResource,