Skip to content

fix(pm): fleet-write fails closed on an indeterminate liveness read, reads it only behind the proxy re-exec, and stops reading a proxy 403 as a rate limit - #19780

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-19774-fleet-write-liveness-fail-closed
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-19774-fleet-write-liveness-fail-closed

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19774

What changed

scripts/pm/** only — the fleet's own write tools, not on the governed register.

  1. The liveness read runs only behind the proxy. relayLive() in fleet-write/dispatch.mjs refuses to read from a process whose fetch would bypass HTTPS_PROXY (a proxy configured, no --use-env-proxy on the process): the reading is indeterminate, names the flag, and is not cached, so the re-exec'd child reads for itself. post-stamped.mjs now re-execs on --dry-run too (a dry run resolves the route, and the route's read is a request); label-write, issue-create, close-cards and with-fleet.sh already re-exec before resolving, and --route did. The ordering is pinned in post-stamped's self-test and the unrouted refusal in dispatch's.
  2. Fail closed on an indeterminate read. relayLive() judges three buckets: 200 with state === 'active' is live (dispatch); the file's 404 and the disabled_manually state are the two DEFINITE not-live signals and still turn a cloud seat direct with the printed line; any other status (401, 403, 5xx), a 200 without a readable state, any other state, or no answer is INDETERMINATE, and selectTransport / resolveRoute refuse with exit 3 naming the status and the read — under auto and explicit OS_FLEET_TRANSPORT=dispatch alike, never direct, never silent. The refusal carries the session so a tool can say who was refused. Explicit OS_FLEET_TRANSPORT=direct remains the only way to write as the personal account in a cloud container, and the tool prints it.
  3. A proxy refusal is not a rate limit. classifyHttp (label-write.mjs) no longer reads an absent x-ratelimit-remaining header as zero (Number(null) is 0, which is how the mint's proxy 403 wrote a 30-minute STOP MARKER), and recognises the egress proxy's 403 body — isProxyRefusal() in write-pace.mjs: the message naming the proxy, or a documentation_url that is not GitHub's, the measured discriminant — as a route failure (prerequisite). stopSignalFrom writes no marker for such a body whatever verdict or header accompanies it; noteResponse gives back the slot paceWrite took (no budget spent) and prints one line; fleet-token names the proxy and the relay spelling in its refusal. Callers (dispatch.mjs, fleet-token.mjs) now hand the body to the classifier. Existing markers are not migrated; they expire.
  4. Unchanged: ceilings, the size route, the op table, the executor, the workflow, every allow rule. The documented spellings that described the fall-back (dispatch.mjs header, with-fleet.sh header) now say when the tool refuses instead.

Verification record

Live, in this cloud container, with NO OS_FLEET_SESSION set (origin/main at 2005a55, worktree at 25ee9c2):

$ node scripts/pm/fleet-write/dispatch.mjs --route
ℹ️  re-exec with --use-env-proxy: HTTPS_PROXY is set (http://127.0.0.1:43969) and node's fetch does not read it.
{"requested":"auto","transport":"dispatch","failed":null,"session":"session_01GnJon4xkRvphn4w28xx3An","session_source":"CLAUDE_CODE_REMOTE_SESSION_ID","reason":"OS_FLEET_TRANSPORT is auto → dispatch: …"}
exit 0

A bare node scripts/pm/post-stamped.mjs --comment=19774 --file=… --dry-run (no proxy flag on the command) now prints the re-exec line and then post-stamped: transport dispatch — … with the derived session and state active; on main the same command read condition ③ as HTTP 401 and reported direct. The forced-401 case is proven in the dispatch self-test's fake platform (exit-3 shaped refusal naming HTTP 401 under auto and under explicit dispatch), and --route behind a proxy that answers nothing is proven end to end (real re-exec, exit 3, error naming no answer).

Batteries, each run alone with its exit captured before any pipe:

command exit verdict line
pnpm check:pm-fleet-write-validate 0 69 cases pass across 7 batteries
pnpm check:pm-fleet-write-execute 0 48 cases pass across 9 batteries
pnpm check:pm-fleet-write-dispatch 0 101 cases pass across 11 batteries (was 90 across 10)
pnpm check:pm-with-fleet 0 32 cases pass
pnpm check:pm-post-stamped 0 610 cases pass across 21 batteries (was 609)
pnpm check:pm-label-write 0 91 cases pass across 10 batteries (was 88)
pnpm check:pm-issue-create 0 42 cases pass across 6 batteries
pnpm check:pm-close-cards 0 111 cases pass across 12 batteries
pnpm check:pm-write-pace 0 113 cases pass across 12 batteries (was 109)
pnpm check:pm-fleet-token 0 75 cases pass across 8 batteries (was 73)

npx eslint --no-inline-config on the five changed .mjs files: exit 0.

Gates — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the edited tree derived 39 families (6 paths vs merge base 2005a55; 355 changed lines, under the human-merge threshold). Each was run alone with its exit code captured before any pipe; all 39 exited 0. --ran with the codes recorded: 39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED (a DERIVED zero — all 39 recorded an exit code and none of them is 3). Repo-wide pnpm lint is CI's run; the changed files were linted directly (above).

Changeset

None: scripts/pm/** publishes nothing from any released package, so skip-changeset applies. This dispatch forbids label writes, so the label is the seat's to apply.

Acceptance notes

  • disabled_inactivity (GitHub disabling a workflow for inactivity) is treated as indeterminate, not as a definite not-live signal: the card names exactly two definite signals, and this one is neither the maintainer's switch nor an absent file. If the seat wants it definite, it is a one-line change in relayLive plus its pin.
  • .claude/skills/pm-dispatch/references/rest-channel.md and platform-readings.md still describe the previous fall-back on any non-200 read; governed paths, outside this card's scripts/pm/** scope.

Generated by Claude Code

…nd reads it only behind the proxy re-exec

Under auto, any liveness answer other than 200 + active turned a cloud seat
direct — writing as its personal account instead of objectstack-fleet[bot].
A 401 from a read made before the --use-env-proxy re-exec (node's fetch does
not read HTTPS_PROXY) did exactly that mid-session, for a relay that was
alive.

relayLive now judges three buckets: 200 + active is live; the file's 404 and
the disabled_manually state are the two DEFINITE not-live signals, and still
turn a seat direct with the printed line; any other status, a 200 without a
readable state, any other state, or no answer is INDETERMINATE, and the
selector refuses (exit 3) naming the status and the read — under auto and
explicit dispatch alike. relayLive also refuses to read at all from a process
whose fetch would bypass HTTPS_PROXY (indeterminate, naming the flag, not
cached), and post-stamped re-execs on --dry-run too, so route resolution runs
only behind the proxy in every tool.

classifyHttp no longer reads an absent x-ratelimit-remaining header as zero
(Number(null) is 0), and recognises the egress proxy's 403 body — the message
naming the proxy, or a documentation_url that is not GitHub's — as a route
failure: write-pace's stopSignalFrom writes no marker for it, noteResponse
gives back the slot paceWrite took and prints one line, and fleet-token names
the proxy and the relay spelling in its refusal. Ceilings, the size route, the
op table, the executor, the workflow and the allow rules are untouched.

Self-tests: liveness 401 / 403 / 500 / malformed / no answer refuse under auto
and explicit dispatch; 404 and disabled_manually go direct; 200 + active is
dispatch; the unrouted read makes no request and is not cached; --route behind
a dead proxy exits 3; the proxy 403 classifies as prerequisite with no marker
and no spend; the dry-run re-exec is pinned.

Claude-Session: https://claude.ai/code/session_01GnJon4xkRvphn4w28xx3An
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 23, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 03:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit cc2e951 Sep 23, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19774-fleet-write-liveness-fail-closed branch September 23, 2026 04:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant