Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (#17).

## [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.
Expand Down
16 changes: 16 additions & 0 deletions src/agent-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
join,
resolve,
} from 'node:path';
import {
AGENT_SESSION_IDS,
AGENT_SHELL_MARKERS,
} from './person.js';
import {
holdsLease,
takeResource,
Expand All @@ -28,6 +32,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];
Expand Down
15 changes: 10 additions & 5 deletions src/serve-sentinel.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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. */
Expand All @@ -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. */
Expand Down
14 changes: 2 additions & 12 deletions src/tour.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`;
Expand Down
43 changes: 42 additions & 1 deletion test/health-sentinel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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');
});