Skip to content

fix(pi): a failed background-job spawn now says why (#504)#508

Merged
SUaDtL merged 1 commit into
mainfrom
fix/504-launch-refusal-diagnostic
Jul 26, 2026
Merged

fix(pi): a failed background-job spawn now says why (#504)#508
SUaDtL merged 1 commit into
mainfrom
fix/504-launch-refusal-diagnostic

Conversation

@SUaDtL

@SUaDtL SUaDtL commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Addresses #504 AC-2. #504 stays open on AC-4 — see "What this does not fix".

The defect

SessionBackgroundJobRuntime.launch refuses with undefined on five paths. Four are policy decisions the caller can predict (runtime disposed/unhealthy, unparseable input or a stale lease, createJob declined, authorization gone stale after publish). The fifth is an environment failure it cannot: the #openTree spawn threw.

That fifth path discarded its error in a bare catch {}. So a Git Bash launch that genuinely failed on windows-latest reached the operator as:

AssertionError: expected undefined to be defined

The runtime was never wrong — it is fail-closed and never owns an untracked process, and #504's own triage confirmed that. What was missing is the diagnosis: a correct, designed refusal was indistinguishable from a real fault, and the release preflight (#385) requires the aggregating gate green for the exact commit being tagged, so this cost a re-run cycle on #503.

The fix

Per-launch, not runtime-scoped. BackgroundJobLaunchInput gains an optional onRefusal?: (diagnostic: string) => void, invoked only on the spawn path. MAX_ACTIVE_JOBS is 4, so a runtime-scoped "last refusal" field would let one launch's environment failure be reported as another's — the same misdiagnosis #504 is about, relocated. A test drives two overlapping launches that settle in the reverse of their spawn order specifically to pin this.

A closed vocabulary, not free text. The detail is a recognized Windows containment reason via windowsRefusalReasonFromMessage()#428's existing extractor, already exported and previously unused by this module — or an errno matching /^E[A-Z]{1,15}$/, or nothing. Never error.message, which embeds the resolved shell path and working directory via realpathSync, and which nothing prevents a future throw in process-tree.ts from widening further.

This follows the convention #428 already set on the adjacent childFailure() channel, explicitly "without widening it unsafely", rather than opening a second channel to free text.

Surfaced where the caller already asks. native-background.ts already called toolFailure(value.runtime.health().diagnostic) on refusal — it asked the right question, but health().diagnostic is only populated when the runtime latches unhealthy, which the spawn path deliberately does not do. It now falls through to the per-launch diagnostic, with the sticky health message still taking precedence when both exist.

Fail-closed is unchanged and re-pinned. The terminal transition, the completed activity publication, and the ownership release all happen before the caller's sink is invoked, and the sink is fail-soft — a throwing or non-function sink cannot alter the refusal path. launch still resolves undefined on all five paths, so no existing caller or assertion changes meaning.

Considered and rejected

  • Extending the activity event. {kind,id,label,state} is display-only by contract — fail-soft at every producer boundary, control-stripped, capped at 128 code points. The footer is not a diagnostics channel.
  • Rejecting on path 5. Splits the return contract across two failure shapes for the sole production caller and ~20 assertions, to express a distinction a diagnostic already carries.
  • A bounded raw error.message. Richest, and the only option scored Strong on availability — but the channel is open by construction. Rejected on that, not on today's contents.

Verification

  • 751 passed / 1 pre-existing skip across the full ca-pi suite; tsc --noEmit clean.
  • 6 new tests: containment-reason reporting, policy-refusal silence, errno + non-disclosure of shell path / cwd / command / env, degradation on hostile or absent reason (hostile code, oversized code, numeric code, non-Error throw), overlapping-launch correlation, hostile-sink fail-soft, and the tool-level surfacing with health precedence.
  • Behavioral proof through the real path with no stubbed openTree — an absolute-but-nonexistent shell makes realpathSync throw ENOENT:
    launch resolved  : undefined
    diagnostic       : Background job could not start the configured shell (ENOENT); run /ca-doctor.
    job 1 state      : failed
    activeJobIds     : []
    leaks shell path : false
    leaks cwd        : false
    
  • The live Git Bash test still passes (555 ms).
  • Full python suite, generator --checks, and the pi platform contract: 24/24 PASS.
  • Release guard exercised against committed state, not the trivially-green pre-commit form: Pi payload, version, changelog, and root metadata advanced together: 0.1.33 -> 0.1.34.

What this does not fix

AC-4 is deliberately open. Diagnostics do not stop the windows-latest flake — they make the next occurrence explain itself instead of asserting on undefined. #504 should stay open on AC-4 alone until a real CI run either names its cause or stops recurring. Closing it here would claim a proof this PR does not have.

Also filed while working this: #507tdd Phase 5 cannot run, because no coverage command exists for any plugin. It blocked this change's Phase 5 rather than being papered over.

Conflict-hierarchy note: the free-text-vs-closed-vocabulary call was resolved at level 1 (security of the audit/disclosure surface) over level 5 (developer velocity), against a marginally more informative message.

https://claude.ai/code/session_01JgdLb6n8mUdkFiKDTA37ML

`launch` refuses with `undefined` on five paths. Four are policy decisions the
caller can predict; the fifth is a spawn failure it cannot. That error was
discarded by a bare `catch {}`, so a Git Bash launch that failed on CI reached
the operator as `expected undefined to be defined` - the runtime's correct,
fail-closed refusal was indistinguishable from a real fault.

The diagnostic is per-launch rather than runtime-scoped, because four launches
can be in flight at once and a shared slot would report one launch's
environment failure as another's - the same misdiagnosis, relocated.

The detail comes from a closed vocabulary: a recognized Windows containment
reason (#428's extractor) or an errno shape, never `error.message`, which
embeds the resolved shell path and working directory. That follows the
convention already set on the adjacent childFailure() channel rather than
widening a second channel to free text.

AC-4 is deliberately not closed. Diagnostics do not stop the flake; they make
the next occurrence explain itself instead of asserting on `undefined`.

CHANGELOG: A background job that cannot start its shell now reports why, with
a bounded reason code, instead of a generic block message.
Ref: #504
Claude-Session: https://claude.ai/code/session_01JgdLb6n8mUdkFiKDTA37ML
@SUaDtL
SUaDtL merged commit 7274c10 into main Jul 26, 2026
38 checks passed
@SUaDtL
SUaDtL deleted the fix/504-launch-refusal-diagnostic branch July 26, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant