test(gate): witness A6 in both consumers; bound the negative path

Three findings, all upheld.

1. A6 was witnessed only for the helper. Both consumers now have real
   -path rows.

   Gate side, driven through a stub worktree --- a temp git repo holding
   a copy of scripts/gate and a controlled helper --- so the gate's own
   code path runs against each verdict without touching the checked-in
   helper: a stub exiting 2 refuses with the ERROR wording and never
   "SIGINT is ignored"; a NON-EXECUTABLE stub maps 126 to boundary
   error 2 with its own wording. That second case is what the original
   guard got wrong twice.

   R-d side: the precondition is split into sigint_diagnosis() ->
   Result, so the message is testable rather than reachable only
   through a panic in a test that cannot run under the condition it
   describes. The new row asserts safe proceeds, ignored says so and
   says "NOT a teardown defect", error says "could not determine" and
   never "ignored", and an unrunnable helper is undecidable at the
   boundary.

2. The refusal row violated this suite's no-recursion constraint: it
   invoked the ordinary gate, so a regression of the exact `if !` bug
   would have launched eight real gate stages inside the gate suite.
   It now uses --self-test, which drives the same runner over a
   hardcoded synthetic plan, so the negative path stays bounded
   whatever the guard does. under_ignored_sigint() also takes the
   program and arguments POSITIONALLY --- `exec "$@"` --- instead of
   interpolating them into script text, which broke for any path
   containing a space or shell metacharacter, and every path here comes
   from a tempdir or CARGO_MANIFEST_DIR.

3. The portable checkpoint is recorded: implementation at 3206433,
   pushed, signed, clean, full default gate green 8/8 foreground. The
   framing header no longer says implementation "may proceed" --- it
   reports IMPLEMENTED. And docs/agent-handoff.md §3 gains the durable
   rule: never start the gate or cargo test from a shell that ignores
   SIGINT, `setsid nohup ... &` is forbidden, SIG_IGN is inherited
   across fork and survives exec, the gate refuses with no override,
   and scripts/check-sigint-deliverable answers the question directly.

35 gate-acceptance rows, 16 gpu_invocation_acceptance rows, full gate
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-19 16:43:37 +02:00
parent 32064336ee
commit 167d830932
No known key found for this signature in database
5 changed files with 248 additions and 27 deletions

View File

@ -279,6 +279,15 @@ from #171 and #215.
**`72da24a`**, worktree
`/home/jeans/Repos/personal/pmacs-probe-sigint`. Recover with
`git fetch githubsucks && git checkout gpu-probe-sigint-teardown`.
- **CHECKPOINT: implementation landed at `3206433`; this entry's own
commit adds the A6 consumer rows on top.** The branch head is the
authority — a literal SHA naming a branch's own tip goes stale the
moment the next commit lands, which this ledger has already recorded
once. State at `3206433`: pushed, signed `G`, worktree clean,
`git diff --check` clean, **full default gate green (8/8) in the
foreground**, 31 gate-acceptance rows passing. After this commit:
**35 gate-acceptance rows and 16 `gpu_invocation_acceptance` rows**,
with the full gate re-run.
- **No PR. Framing revision 12 at `docs/gpu-probe-sigint-framing.md`,
APPROVED 2026-08-19 at `1fc0df6`** — revision 10 was approved at
`4fba9f6` and revision 9 at `15c25ec`; neither approval covered the

View File

@ -2547,6 +2547,34 @@ its own step, never `&&`-chained.
**Run it with `scripts/gate`. Do not retype it.**
**NEVER start the gate — or `cargo test` — from a shell that ignores
`SIGINT`.** A shell backgrounding a job without job control sets
`SIGINT` (and `SIGQUIT`) to `SIG_IGN`; `nohup` adds `SIGHUP`. `SIG_IGN`
is **inherited across `fork` and survives `exec`**, so it reaches
`cargo`, the test binary, and everything they spawn. Any test that
signals a child and waits for it then hangs to its own deadline and
reports a *product* defect that is not there.
Concretely: `setsid nohup ./scripts/gate … &` is **forbidden**. This
cost one lane seven red full sweeps and nine framing revisions chasing
a GPU teardown bug that never existed
(`docs/gpu-probe-sigint-framing.md` §4c). Long runs do not need it —
measured, an ordinary tool-level background launch leaves `SIGINT`
deliverable.
`scripts/gate` now refuses to start in that state, before any stage,
and **there is no override**: a run under ignored `SIGINT` cannot
produce valid evidence. If you see
```
pmacs: SIGINT is ignored; run this command with SIGINT deliverable
gate: REFUSING TO RUN --- see the diagnosis above.
```
the fix is to re-run it in the foreground, not to work around the
guard. `scripts/check-sigint-deliverable` answers the question on its
own: exit **0** deliverable, **1** ignored, **2** undecidable.
```
scripts/gate [--acceptance SUITE]... [--protocol]
```

View File

@ -1,8 +1,8 @@
# GPU launcher / probe SIGINT teardown — framing
Revision 12. Status: **APPROVED 2026-08-19 at `1fc0df6`.
MECHANISM FOUND (§4c), REMEDY SELECTED (§7c); implementation may
proceed under §8's A1–A7 contract.**
Revision 12, approved 2026-08-19 at `7752bcb`.
Status: **IMPLEMENTED — R-b + R-d landed and witnessed (§8). Mechanism
in §4c; no product change.**
Revision 10 was approved 2026-08-19 at `4fba9f6`, authorising
diagnostic-only D1/D2. They ran, and found the mechanism on the first

View File

@ -48,12 +48,57 @@ fn sigint_helper() -> PathBuf {
/// inherited across `fork` **and survives `exec`** — which is the whole
/// mechanism under test, so simulating it this way exercises the real
/// thing rather than a stand-in.
fn under_ignored_sigint(cmd: &str) -> std::process::Output {
Command::new("sh")
fn under_ignored_sigint(
program: &Path,
args: &[&str],
cwd: &Path,
env: &[(&str, &str)],
) -> std::process::Output {
// `exec "$@"` with the program and arguments passed POSITIONALLY.
// Interpolating them into the script text would break on any path
// containing a space or a shell metacharacter, and every path here
// comes from a `tempdir` or `CARGO_MANIFEST_DIR` — neither of which
// this test controls.
let mut command = Command::new("sh");
command
.arg("-c")
.arg(format!("trap \"\" INT; {cmd}"))
.output()
.expect("spawn shell with SIGINT ignored")
.arg("trap \"\" INT; exec \"$@\"")
.arg("sh")
.arg(program)
.args(args)
.current_dir(cwd);
for (key, value) in env {
command.env(key, value);
}
command.output().expect("spawn shell with SIGINT ignored")
}
/// A minimal git worktree holding a copy of `scripts/gate` and a
/// **stub** `check-sigint-deliverable`, so the gate's handling of each
/// helper status can be driven on its real path without touching the
/// checked-in helper.
fn gate_with_stub_helper(stub_body: &str, executable: bool) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let scripts = dir.path().join("scripts");
std::fs::create_dir_all(&scripts).expect("scripts dir");
std::fs::copy(gate(), scripts.join("gate")).expect("copy gate");
let helper = scripts.join("check-sigint-deliverable");
std::fs::write(&helper, stub_body).expect("write stub helper");
let mode = if executable { 0o755 } else { 0o644 };
std::fs::set_permissions(&helper, std::os::unix::fs::PermissionsExt::from_mode(mode))
.expect("chmod stub helper");
std::fs::set_permissions(
scripts.join("gate"),
std::os::unix::fs::PermissionsExt::from_mode(0o755),
)
.expect("chmod gate copy");
let ok = Command::new("git")
.args(["init", "-q"])
.current_dir(dir.path())
.status()
.expect("git init");
assert!(ok.success(), "the stub worktree must be a git worktree");
dir
}
fn gate() -> PathBuf {
@ -114,6 +159,68 @@ fn run(root: &Path, args: &[&str]) -> (String, String, bool) {
// §3, nothing else in the repository would notice. `--print-plan`
// exists to make that checkable without executing anything.
/// A6, gate consumer: a helper verdict of `error` (2) refuses the run
/// with the ERROR wording, and never claims `SIGINT` is ignored.
///
/// Driven through a stub worktree so the gate's real code path runs
/// against a controlled helper status; the helper's own classification
/// is covered by its own rows above.
#[test]
fn gate_refuses_on_helper_error_without_claiming_sigint_is_ignored() {
let root = tempfile::tempdir().expect("tempdir");
let repo = gate_with_stub_helper(
"#!/bin/sh\necho 'pmacs: could not determine whether SIGINT is deliverable (probe status 42)' >&2\nexit 2\n",
true,
);
let out = Command::new(repo.path().join("scripts/gate"))
.arg("--self-test")
.current_dir(repo.path())
.env("PMACS_GATE_TARGET_ROOT", root.path())
.output()
.expect("run the stub-worktree gate");
assert_eq!(
out.status.code(),
Some(2),
"an error verdict exits 2, not 1"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("could not determine"), "error wording: {err}");
assert!(
!err.contains("SIGINT is ignored"),
"an undecidable probe must never be reported as ignored: {err}"
);
assert!(err.contains("no stage has run"), "and no stage ran: {err}");
}
/// A6, gate boundary: a helper that cannot be EXECUTED is an `error` at
/// the call boundary — mapped to 2 — never evidence that `SIGINT` is
/// ignored.
///
/// This is the case the original guard got wrong twice: under `set -e`
/// a bare invocation died before any mapping, and 126/127 would have
/// escaped raw.
#[test]
fn gate_maps_an_unexecutable_helper_to_error_not_ignored() {
let root = tempfile::tempdir().expect("tempdir");
let repo = gate_with_stub_helper("#!/bin/sh\nexit 0\n", false);
let out = Command::new(repo.path().join("scripts/gate"))
.arg("--self-test")
.current_dir(repo.path())
.env("PMACS_GATE_TARGET_ROOT", root.path())
.output()
.expect("run the stub-worktree gate");
assert_eq!(out.status.code(), Some(2), "boundary failures map to 2");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("could not run the SIGINT guard"),
"the boundary has its own wording: {err}"
);
assert!(
!err.contains("SIGINT is ignored"),
"an unrunnable guard is not evidence about the signal: {err}"
);
}
/// §7c: the helper answers `safe` when `SIGINT` is deliverable.
#[test]
fn sigint_helper_reports_safe_when_the_signal_is_deliverable() {
@ -132,7 +239,7 @@ fn sigint_helper_reports_safe_when_the_signal_is_deliverable() {
/// `SIGINT` is inherited as `SIG_IGN`.
#[test]
fn sigint_helper_reports_ignored_when_the_signal_is_inherited_ignored() {
let out = under_ignored_sigint(&format!("{}", sigint_helper().display()));
let out = under_ignored_sigint(&sigint_helper(), &[], &repo_root(), &[]);
assert_eq!(out.status.code(), Some(1), "ignored is exit 1");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
@ -179,12 +286,17 @@ fn sigint_helper_reports_error_and_never_ignored_when_the_probe_cannot_run() {
#[test]
fn gate_refuses_to_start_when_sigint_is_ignored() {
let root = tempfile::tempdir().expect("tempdir");
let out = under_ignored_sigint(&format!(
"cd {} && PMACS_GATE_TARGET_ROOT={} {}",
repo_root().display(),
root.path().display(),
gate().display()
));
// `--self-test`, NOT the ordinary gate. If the guard ever regresses,
// this row must not launch eight real gate stages inside the gate
// suite — the recursion constraint this file opens with. Self-test
// drives the same runner over a hardcoded synthetic plan, so the
// negative path stays bounded whatever the guard does.
let out = under_ignored_sigint(
&gate(),
&["--self-test"],
&repo_root(),
&[("PMACS_GATE_TARGET_ROOT", &root.path().display().to_string())],
);
assert_eq!(
out.status.code(),
Some(1),

View File

@ -190,21 +190,93 @@ mod crdt {
/// helper's own stderr. A helper that cannot be executed is an
/// `error` at this boundary, never evidence that `SIGINT` is
/// ignored.
fn require_sigint_deliverable() {
let helper = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-sigint-deliverable");
let out = match Command::new(&helper).output() {
/// The diagnosis for one helper invocation: `Ok` to proceed, `Err`
/// with the message a caller should fail on.
///
/// Split from the assertion so the message itself is testable.
/// A6 requires this consumer to distinguish `error` from `ignored`,
/// and a diagnosis reachable only through a panic in a test that
/// cannot run under the condition it describes is not a witness.
fn sigint_diagnosis(helper: &Path) -> Result<(), String> {
let out = match Command::new(helper).output() {
Ok(out) => out,
Err(error) => panic!(
"precondition undecidable: could not execute {}: {error}",
// Failure to execute the helper is an `error` AT THIS
// BOUNDARY, never evidence that SIGINT is ignored.
Err(error) => {
return Err(format!(
"precondition undecidable --- could not execute {}: {error}",
helper.display()
),
));
}
};
if !out.status.success() {
panic!(
if out.status.success() {
return Ok(());
}
Err(format!(
"precondition failed --- this is NOT a teardown defect.\n{}",
String::from_utf8_lossy(&out.stderr).trim_end()
);
))
}
fn sigint_helper_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-sigint-deliverable")
}
fn require_sigint_deliverable() {
if let Err(diagnosis) = sigint_diagnosis(&sigint_helper_path()) {
panic!("{diagnosis}");
}
}
/// A6, R-d consumer: the direct test distinguishes `error` from
/// `ignored`, and neither message claims a teardown defect.
#[test]
fn rd_precondition_distinguishes_ignored_from_error() {
// `safe` proceeds silently.
assert!(
sigint_diagnosis(&sigint_helper_path()).is_ok(),
"the foreground case must proceed"
);
let dir = tempfile::tempdir().expect("tempdir");
let stub = |name: &str, body: &str, mode: u32| {
let path = dir.path().join(name);
fs::write(&path, body).expect("write stub");
fs::set_permissions(&path, fs::Permissions::from_mode(mode)).expect("chmod stub");
path
};
let ignored = stub(
"ignored",
"#!/bin/sh\necho 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2\nexit 1\n",
0o755,
);
let message = sigint_diagnosis(&ignored).expect_err("exit 1 must be refused");
assert!(message.contains("SIGINT is ignored"), "{message}");
assert!(
message.contains("NOT a teardown defect"),
"the whole point is not to read as a teardown defect: {message}"
);
let erroring = stub(
"erroring",
"#!/bin/sh\necho 'pmacs: could not determine whether SIGINT is deliverable (probe status 42)' >&2\nexit 2\n",
0o755,
);
let message = sigint_diagnosis(&erroring).expect_err("exit 2 must be refused");
assert!(message.contains("could not determine"), "{message}");
assert!(
!message.contains("SIGINT is ignored"),
"an undecidable probe is not evidence that SIGINT is ignored: {message}"
);
// Not executable at all: an `error` at the boundary.
let unrunnable = stub("unrunnable", "#!/bin/sh\nexit 0\n", 0o644);
let message = sigint_diagnosis(&unrunnable).expect_err("an unrunnable helper must refuse");
assert!(
message.contains("undecidable") && !message.contains("SIGINT is ignored"),
"boundary failure is undecidable, never ignored: {message}"
);
}
fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {