fix(guards): a step whose test is seen green may stop while it waits for its controller
voyage-lekerepo trial (2026-10-06) point 7, operator decision 2026-10-08 («grønt merke på låsen»): the stop guard blocked Stop three times while the executor waited for the controller, with the step's test already green. - test-lock.mjs green --cmd "<Verify>" runs the step's test command (same allowlist + denylist as done-line) and records green_at only on exit 0; pending clears it; a new lock starts without it. - stop-test-lock lets a green-marked lock stop; the write guard still refuses the locked test files. - trekexecute: D1 marks green after a PASS; each fix round marks pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
81dc5af680
commit
393b8e5a0a
5 changed files with 163 additions and 10 deletions
|
|
@ -13,8 +13,20 @@
|
|||
// node lib/util/test-lock.mjs lock --files <a,b> --step <N> [--cwd <dir>]
|
||||
// node lib/util/test-lock.mjs unlock [--cwd <dir>]
|
||||
// node lib/util/test-lock.mjs status [--cwd <dir>] (JSON on stdout)
|
||||
// Exit: 0 = done, 1 = refused (another step holds the lock), 2 = could not
|
||||
// run (not a repository, bad usage, unreadable lock). 2 is never "unlocked".
|
||||
// node lib/util/test-lock.mjs green --cmd "<test command>" [--cwd <dir>]
|
||||
// node lib/util/test-lock.mjs pending [--cwd <dir>]
|
||||
// Exit: 0 = done, 1 = refused (another step holds the lock; for green: no
|
||||
// lock, or the test command did not exit 0), 2 = could not run (not a
|
||||
// repository, bad usage, unreadable lock, a command outside the test-runner
|
||||
// allowlist). 2 is never "unlocked" and never green.
|
||||
//
|
||||
// The green mark (operator decision 2026-10-08). The stop guard blocked Stop
|
||||
// three times while the executor waited for its controller, with the step's
|
||||
// test already green (voyage-lekerepo trial, 2026-10-06). `green` RUNS the
|
||||
// step's test command (the same allowlist and denylist as done-line.mjs) and
|
||||
// records `green_at` only on exit 0; the stop guard then lets the session
|
||||
// stop, while the write guard keeps refusing the locked files. `pending`
|
||||
// clears the mark when a fix round starts; a new `lock` starts without one.
|
||||
// Paths in --files are relative to the --cwd (default: the current directory).
|
||||
// The session is taken from CLAUDE_CODE_SESSION_ID, which Claude Code sets in
|
||||
// Bash tool subprocesses (code.claude.com/docs/en/env-vars).
|
||||
|
|
@ -27,7 +39,10 @@ import { join, resolve, dirname } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolveRepo, canonicalPath } from '../verification/repo-tools.mjs';
|
||||
import { allowedCommand, screenCommand } from '../verification/criteria-runner.mjs';
|
||||
import { commandEnv, DEFAULT_TIMEOUT_MS } from '../verification/red-proof.mjs';
|
||||
|
||||
export const LOCK_VERSION = 1;
|
||||
|
||||
|
|
@ -113,7 +128,7 @@ function parseArgs(argv) {
|
|||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i];
|
||||
if (a === '--files' || a === '--step' || a === '--cwd') { out[a.slice(2)] = argv[i + 1]; i += 1; }
|
||||
if (a === '--files' || a === '--step' || a === '--cwd' || a === '--cmd') { out[a.slice(2)] = argv[i + 1]; i += 1; }
|
||||
else out._.push(a);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -127,7 +142,9 @@ function fail(code, msg) {
|
|||
function main(argv) {
|
||||
const args = parseArgs(argv);
|
||||
const cmd = args._[0];
|
||||
if (!['lock', 'unlock', 'status'].includes(cmd)) fail(2, 'usage: test-lock.mjs lock --files <a,b> --step <N> | unlock | status [--cwd <dir>]');
|
||||
if (!['lock', 'unlock', 'status', 'green', 'pending'].includes(cmd)) {
|
||||
fail(2, 'usage: test-lock.mjs lock --files <a,b> --step <N> | unlock | status | green --cmd "<test command>" | pending [--cwd <dir>]');
|
||||
}
|
||||
const cwd = resolve(args.cwd || process.cwd());
|
||||
const cur = readLock(cwd);
|
||||
if (cur.state === 'no-repo') fail(2, `not a git repository: ${cwd}`);
|
||||
|
|
@ -140,11 +157,36 @@ function main(argv) {
|
|||
const l = cur.lock || {};
|
||||
process.stdout.write(JSON.stringify({
|
||||
locked: cur.state === 'locked', step: l.step ?? null, files: l.files ?? [], session: l.session ?? null,
|
||||
locked_at: l.locked_at ?? null, lock_path: cur.path,
|
||||
locked_at: l.locked_at ?? null, green_at: l.green_at ?? null, lock_path: cur.path,
|
||||
}, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cmd === 'green' || cmd === 'pending') {
|
||||
if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error})`);
|
||||
if (cur.state !== 'locked') fail(1, 'no lock to mark');
|
||||
const lock = { ...cur.lock };
|
||||
if (cmd === 'pending') {
|
||||
delete lock.green_at;
|
||||
} else {
|
||||
if (!args.cmd) fail(2, 'green needs --cmd "<the step\'s test command>"');
|
||||
const allowed = allowedCommand(args.cmd, { cwd: cur.repo.root });
|
||||
if (!allowed.allowed) fail(2, `command not run: ${allowed.reason}`);
|
||||
const screen = screenCommand(args.cmd);
|
||||
if (!screen.allowed) fail(2, `command blocked: ${screen.rule}`);
|
||||
const r = spawnSync('/bin/sh', ['-c', args.cmd], {
|
||||
cwd: cur.repo.root, env: commandEnv(), encoding: 'utf8', timeout: DEFAULT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (r.error || r.status === null || r.status === 126 || r.status === 127) {
|
||||
fail(2, `the test command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`);
|
||||
}
|
||||
if (r.status !== 0) fail(1, `the test command exited ${r.status}; the lock stays unmarked`);
|
||||
lock.green_at = new Date().toISOString();
|
||||
}
|
||||
writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cmd === 'unlock') {
|
||||
if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error}); inspect it before removing it`);
|
||||
removeSessionPointer(cur.lock?.session);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue