Repository navigation
feat(pm): fleet-write relay — closed op table, validator, executor, seat-side dispatcher, and the dispatch transport in the five write tools - #19725
Merged
Conversation
The frozen op table (endpoints, verbs, required and optional keys, caps, the App permission each row spends) and the validator both ends of the relay import. The platform's client_payload ceilings are pinned with their source in the validator's self-test. Claude-Session: https://claude.ai/code/session_01VhatbEZCV5JJBoU579UBCG Co-authored-by: Claude <noreply@anthropic.com>
…ransport in label-write, issue-create and with-fleet The runner-side executor (sender gate from the target repo, actions in order, stop at the first failure, step summary, token never printed), the seat-side dispatcher (transport selector, packer, one paced dispatch, run-poller with two ceilings answered UNCONFIRMED), and the `dispatch` transport in label-write, issue-create and with-fleet's `--via` door. Both relay halves join write-pace's roster; three check rows wire the self-tests. Claude-Session: https://claude.ai/code/session_01VhatbEZCV5JJBoU579UBCG Co-authored-by: Claude <noreply@anthropic.com>
post-stamped packs its one comment or body rewrite into one dispatch and finds the stored body on the board afterwards; close-cards sends the close as one issue_patch and reads the card back, handing the label write the same route. Both keep the read-back they already had. Claude-Session: https://claude.ai/code/session_01VhatbEZCV5JJBoU579UBCG Co-authored-by: Claude <noreply@anthropic.com>
…a live path lead The dispatch derivation reads separator-bearing literals in a gate's source as path leads; the two relay entries resolved to nothing from the repo root and printed as dead leads, which its live self-test battery miscounted. Repo-relative entries are live leads, so a card touching any roster file now names the write-pace gate. Consumers respelled to match. Claude-Session: https://claude.ai/code/session_01VhatbEZCV5JJBoU579UBCG Co-authored-by: Claude <noreply@anthropic.com>
…milies have live path leads
The dispatch derivation listed check:pm-fleet-write-{validate,execute,dispatch}
as unreachable: their only separator-bearing literals named no tracked path.
RELAY_FILES in the op table names the four files, the validator's self-test
checks each on disk, and the one bare `fleet-write/dispatch` label is spelled
as the file it names.
Claude-Session: https://claude.ai/code/session_01VhatbEZCV5JJBoU579UBCG
Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 22, 2026
…ay is live `auto` selects `dispatch` only when all three hold: the cloud discriminator, a well-formed OS_FLEET_SESSION, and the relay workflow present on the board's default branch — one cached GET per process, reached only once the cheaper two hold. Any condition failing is `direct` with one line naming it and no start-window wait, so a seat that has not opted in, or a board without the listener, keeps today's behaviour at today's speed. An explicit OS_FLEET_TRANSPORT=dispatch stays strict (exit 3 on a missing session or a dead relay). The ceilings and the no-run fallback are unchanged for a relay that is live but silent. with-fleet's `auto` asks the same selector through `dispatch.mjs --route`. Claude-Session: https://claude.ai/code/session_01VhatbEZCV5JJBoU579UBCG Co-authored-by: Claude <noreply@anthropic.com>
…o dead path from it The self-test's grep pattern opened with one quote and closed with the other, so the dispatch derivation read `fleet-write/dispatch.mjs` out of it as a path lead that resolves to nothing from the repo root and listed check:pm-with-fleet as unreachable. The pin now spells no separator. Claude-Session: https://claude.ai/code/session_01VhatbEZCV5JJBoU579UBCG Co-authored-by: Claude <noreply@anthropic.com>
os-support-ai
marked this pull request as ready for review
September 22, 2026 13:25
os-support-ai
enabled auto-merge
September 22, 2026 13:25
This was referenced Sep 22, 2026
This was referenced Oct 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #19701 — the seats' side of the fleet-write relay (PR B of two). The relay workflow itself is PR A, on
claude/issue-19701-fleet-write-relay, which depends on this PR landing first. Nothing here needs PR A to be live: every tool keeps itsdirecttransport, andautofalls back to it, out loud, when no relay run appears.What this adds
A cloud seat container cannot act as
objectstack-fleet[bot]: its egress proxy replaces everyAuthorizationheader and refuses the/app/**mint path (the card's readings). What it can do isPOST /repos/objectstack-ai/objectstack/dispatches. So a seat now packs the same requests it would have sent directly into ONErepository_dispatch, and the relay workflow (PR A) executes them as the fleet against the repository the payload names.scripts/pm/fleet-write/— four files, one whitelist spelling:ops.mjs— the frozen op table: per op its required and optional keys, the endpoint(s) and verb it becomes, and the App permission it spends. The closed v1 list from the card:comment,comment_edit,labels_add,labels_remove,assign,unassign,issue_patch(title / body / state / state_reason only),issue_create,pr_create(draft: trueforced),pr_request_reviewers,pr_ready,pr_draft,automerge_enable(SQUASH),automerge_disable. Merge, review submission, ref / contents / workflow writes, releases and org endpoints have no row, so the validator refuses them by construction, and its self-test pins that no request any op can spell reaches those families and none issues a whole-setPUT.validate.mjs—validatePayload(obj) → { ok, errors, payload }, hand-written, no dependency, imported by both the seat-side packer and the runner. Closed envelope{ request_id ≤ 64, repo: objectstack-ai/NAME, session: session_…, actions: 1..20 }; unknown top-level or per-action keys refused by name; integers must be JSON numbers; strings capped in bytes (body ≤ 60000, title ≤ 256, label ≤ 50); lists capped and de-duplicated; enums closed. The platform's ownclient_payloadceilings are pinned with their source: at most 10 top-level properties and under 64KB (GitHub's REST OpenAPI description for "Create a repository dispatch event").--from-env --github-outputis the runner's spelling; a refusal writes every reason to the step summary with zero writes.execute.mjs— the runner-side executor. Sender gate first:GET /repos/{payload.repo}/collaborators/{sender}/permission, allowed only forwrite/maintain/admin(GitHub foldsmaintainintopermission: write;role_nameis read too), a 404 or a lesser role is a run failure with zero writes. Then the actions in order, stop at the first failure (later actions not attempted, and the summary says so); a 404 on a directed label DELETE is idempotent success; the GraphQL ops read the pull's node id first and treat anerrorsarray as failure. Every write is paced throughwrite-pace.mjs. The step summary table: request id · sender and role · session · target · one row per request (op, endpoint, HTTP status, id / url). The token arrives only throughGITHUB_TOKEN, is never printed or written, and every emitted line is scrubbed — the self-test feeds a known token through a platform that echoes it and greps every output.dispatch.mjs— the seat side.OS_FLEET_TRANSPORT ∈ direct | dispatch | auto(defaultauto).autoisdispatchonly when ALL THREE hold: ①CCR_AGENT_PROXY_ENABLED=1(the variable naming the proxy whose header replacement is the reason; never the proxy port), ② a well-formedOS_FLEET_SESSION=session_…on the envelope, ③ the relay live on the board —GET /repos/objectstack-ai/objectstack/contents/.github/workflows/fleet-write.yml?ref=mainanswering 200, read once per process and cached, and only reached once ① and ② hold. Any condition failing ⇒direct, with one printed line naming it and no start-window wait: a seat that has not opted in, or a board without the listening workflow, keeps today's behaviour at today's speed. An explicitOS_FLEET_TRANSPORT=dispatchstays strict — a missing session or a relay that is not live is a prerequisite refusal (exit 3), never a fall-back. Oncedispatchis taken: one paced dispatch per stroke, then the run-poller (display_titlecarrying the request id, then the run untilcompleted); the two ceilingsOS_FLEET_DISPATCH_START_MS(90 s) andOS_FLEET_DISPATCH_TIMEOUT_MS(5 min) answerUNCONFIRMED(exit 6) with the run URL, never retried, and underautoa live-but-silent relay (no run in the start window) falls back todirectwith one line naming the double-write risk.with-fleet.sh --via autoasks the same selector (dispatch.mjs --route).OS_FLEET_RELAY_LIVE=1|0overrides ③ for tests only and says so.The five write tools take the transport, each keeping its shape:
label-write.mjs— steps ①, ② and ④ unchanged; step ③ packs the adds, the directed removes and the assignee change of this one write into one dispatch (relayActions, the same verbs in the same order as the direct plan). The whole-setPATCHfallback staysdirect-only: the relay has additive verbs only. An idempotent no-op dispatches nothing.post-stamped.mjs— the stamp is substituted before packing (the stamp is the seat's act); onecommentor oneissue_patch { body }; afterwards the stored body is read from the board (the newest comment since the dispatch whose stored body is the one sent, footer-appending and newline-stripping counted as landed) and judged by the samereadBackVerdictas the direct path. Exit register kept apart: a refused dispatch is 3 (no act), a failed run is 4 (go read), no run or no completion is 6.issue-create.mjs— oneissue_create; the new number is found by title among issues created since the dispatch, then the ordinary read-back.close-cards.mjs— the comment goes throughpost-stampedas before (the child resolves its own transport from the same environment), the label write is handed this run's route, the close is oneissue_patch { state: closed, state_reason }read back from the card. A relay miss on the close is a half-write: the run stops there naming the run.with-fleet.sh—--via direct | dispatch | auto(orOS_FLEET_TRANSPORT).--via dispatch --repo owner/name --actions actions.jsonsends an actions file through the relay (the door for ops no node tool owns:pr_ready,automerge_enable, reviewers…); a command after--underautoin a cloud container is refused (exit 3) with that spelling, because the identity cannot be minted there and running it as the session login instead would be a silent change of identity. An explicit--via directstill wins.write-pace.mjs: the roster of write-verb files gains the two relay halves (its own self-test demanded it — a write verb inscripts/pmmust be on the roster and call both halves), and the roster is now spelled repo-relative (scripts/pm/…). Reason, measured:check:pm-dispatch-gatesreads every separator-bearing literal in a gate's source as a path lead; a barefleet-write/dispatch.mjsresolved to nothing from the repo root, printed as a dead lead under the write-pace family, and its live self-test battery miscounted that row (1 of 1906 cases red, reproduced without contention). Repo-relative entries are live leads, so a card touching any roster file now names the write-pace gate — the right answer, since that self-test reads those files. The roster's consumers were respelled to match, which is the one line this PR touches infleet-token.mjs(outside the card's file surface; mechanical, same gate family). The same derivation then listed the three new gate families as unreachable (their only separator-bearing literals at module scope named no tracked path), soops.mjsdeclaresRELAY_FILES— the relay's four files, repo-relative, checked on disk by the validator's self-test — which gives each family a live lead through the import channel; on a tree carrying both PRs the derivation reports 0 unreachable families.package.json:check:pm-fleet-write-validate,check:pm-fleet-write-execute,check:pm-fleet-write-dispatch, spelled likecheck:pm-post-stamped; thelint.ymlsteps that run them are in PR A (a workflow file).The permissions the relay token needs (for PR A's mint)
From GitHub's server-to-server permission ledger (
github/docs,src/github-apps/data/fpt-2022-11-28/server-to-server-permissions.json, read 2026-09-22, docs.github.com itself being unreachable from this container): every/issues/**write here isissues: write;POST /pulls,POST /pulls/{n}/requested_reviewersand the pull-request mutations arepull-requests: write;GET /repos/{o}/{r}/collaborators/{login}/permissionismetadata: read.PERMISSIONSinops.mjsis exactly that set and PR A declares exactly it onactions/create-github-app-token@v2(permission-issues,permission-pull-requests,permission-metadata). Not measured: whetherenablePullRequestAutoMergeneedscontents: writein addition — the live probe decides it; the fix would be one morepermission-contents: writeline in PR A, deliberately not pre-granted.Verification
Every self-test offline, no network, run from the pushed tree (
git rev-parse --short HEAD=b656a69):Derived gate families (
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths, off the merge base): 43 families reconciled with--ranatb656a69— 39 run green, 4 NOT MEASURED (each exits 3 because it loads builtdist/, which this diff never touches and CI builds:check:dts-closure,check:dual-build-cjs-loads,check:lean-entry-closure,check:sourcemap-no-sources-content), 0 unrun.check:pm-dispatch-gates(run separately, ~15 min on this box): 1906/1906 cases on this head, and 1906/1906 on a throwaway tree carrying this head plus PR A's two workflow files, where the derivation also reports 0 unreachable families andcheck-self-test-wired(production and--self-test),check-self-test-workflow-commands,check:workflow-status-functions,check:workflow-step-name-quotingandcheck-step-collectorsare green.pnpm exec eslint --no-inline-configover the nine changed / new files: clean.Not measured here, by design: the live dispatch. A dispatch to the board without the listening workflow answers 204 and no run (the card's reading); exercising that path costs a write outside this run's budget, so the start-window and completion ceilings are defaults, to be tuned from the first live probe after PR A lands.
Acceptance notes
close-cardsunderdispatchspends up to three relay runs per card (comment, label, close). Folding the three into one payload would be faster and keeps the executor's stop-at-first-failure order, but it would move the stamp rendering and the label arithmetic out of the tools that own them; left as three strokes. Carrier: none.scripts/pm/fleet-write/dispatch.mjsandlabel-write.mjsimport each other (classifyHttpone way, the transport the other). Both use the imported bindings only inside functions, so the ESM cycle is inert; noted for the next reader.with-fleet.sh --readunderautoin a cloud container is refused like a write: the wrapper's whole job is the fleet identity, which cannot be minted there; a read that needs no identity does not need the wrapper.rest-channel.md,platform-readings.md) do not yet describeOS_FLEET_TRANSPORT/OS_FLEET_SESSION; the card leaves documentation to the seat's own card.Generated by Claude Code
Transport section rewritten by the dispatching seat (director, summon #27) from the dev's patch-round report 5777044285 on #19701 (head 704648c): the three-condition
autoselector ruled in review 5776449328.Generated by Claude Code