Skip to content

feat(pm): fleet-write relay — closed op table, validator, executor, seat-side dispatcher, and the dispatch transport in the five write tools - #19725

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-19701-fleet-write-transport
Sep 22, 2026
Merged

os-support-ai merged 7 commits into
mainfrom
claude/issue-19701-fleet-write-transport

Conversation

@os-support-ai

@os-support-ai os-support-ai commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

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 its direct transport, and auto falls 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 every Authorization header and refuses the /app/** mint path (the card's readings). What it can do is POST /repos/objectstack-ai/objectstack/dispatches. So a seat now packs the same requests it would have sent directly into ONE repository_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: true forced), 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-set PUT.
  • 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 own client_payload ceilings 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-output is 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 for write / maintain / admin (GitHub folds maintain into permission: write; role_name is 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 an errors array as failure. Every write is paced through write-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 through GITHUB_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 (default auto). auto is dispatch only 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-formed OS_FLEET_SESSION=session_… on the envelope, ③ the relay live on the board — GET /repos/objectstack-ai/objectstack/contents/.github/workflows/fleet-write.yml?ref=main answering 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 explicit OS_FLEET_TRANSPORT=dispatch stays strict — a missing session or a relay that is not live is a prerequisite refusal (exit 3), never a fall-back. Once dispatch is taken: one paced dispatch per stroke, then the run-poller (display_title carrying the request id, then the run until completed); the two ceilings OS_FLEET_DISPATCH_START_MS (90 s) and OS_FLEET_DISPATCH_TIMEOUT_MS (5 min) answer UNCONFIRMED (exit 6) with the run URL, never retried, and under auto a live-but-silent relay (no run in the start window) falls back to direct with one line naming the double-write risk. with-fleet.sh --via auto asks the same selector (dispatch.mjs --route). OS_FLEET_RELAY_LIVE=1|0 overrides ③ 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-set PATCH fallback stays direct-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); one comment or one issue_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 same readBackVerdict as 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 — one issue_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 through post-stamped as before (the child resolves its own transport from the same environment), the label write is handed this run's route, the close is one issue_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 (or OS_FLEET_TRANSPORT). --via dispatch --repo owner/name --actions actions.json sends an actions file through the relay (the door for ops no node tool owns: pr_ready, automerge_enable, reviewers…); a command after -- under auto in 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 direct still wins.

write-pace.mjs: the roster of write-verb files gains the two relay halves (its own self-test demanded it — a write verb in scripts/pm must be on the roster and call both halves), and the roster is now spelled repo-relative (scripts/pm/…). Reason, measured: check:pm-dispatch-gates reads every separator-bearing literal in a gate's source as a path lead; a bare fleet-write/dispatch.mjs resolved 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 in fleet-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), so ops.mjs declares RELAY_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 like check:pm-post-stamped; the lint.yml steps 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 is issues: write; POST /pulls, POST /pulls/{n}/requested_reviewers and the pull-request mutations are pull-requests: write; GET /repos/{o}/{r}/collaborators/{login}/permission is metadata: read. PERMISSIONS in ops.mjs is exactly that set and PR A declares exactly it on actions/create-github-app-token@v2 (permission-issues, permission-pull-requests, permission-metadata). Not measured: whether enablePullRequestAutoMerge needs contents: write in addition — the live probe decides it; the fix would be one more permission-contents: write line 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):

node scripts/pm/fleet-write/validate.mjs --self-test   → 69 cases, 7 batteries
node scripts/pm/fleet-write/execute.mjs  --self-test   → 48 cases, 9 batteries
node scripts/pm/fleet-write/dispatch.mjs --self-test   → 48 cases, 8 batteries
node scripts/pm/label-write.mjs          --self-test   → 88 cases, 10 batteries (+12 relay)
node scripts/pm/issue-create.mjs         --self-test   → 42 cases, 6 batteries (+8 relay)
node scripts/pm/post-stamped.mjs         --self-test   → 574 cases, 20 batteries (+12 relay)
node scripts/pm/close-cards.mjs          --self-test   → 111 cases, 12 batteries (+8 relay)
bash scripts/pm/with-fleet.sh            --self-test   → 30 cases (+10 --via)
node scripts/pm/write-pace.mjs           --self-test   → 109 cases, 12 batteries (the nine-file roster)
node scripts/pm/fleet-token.mjs          --self-test   → 73 cases, 8 batteries (the respelled roster row)

Derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths, off the merge base): 43 families reconciled with --ran at b656a69 — 39 run green, 4 NOT MEASURED (each exits 3 because it loads built dist/, 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 and check-self-test-wired (production and --self-test), check-self-test-workflow-commands, check:workflow-status-functions, check:workflow-step-name-quoting and check-step-collectors are green.

pnpm exec eslint --no-inline-config over 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-cards under dispatch spends 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.mjs and label-write.mjs import each other (classifyHttp one 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 --read under auto in 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.
  • The seat-side references (rest-channel.md, platform-readings.md) do not yet describe OS_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 auto selector ruled in review 5776449328.


Generated by Claude Code

os-support-ai and others added 5 commits September 22, 2026 09:56
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>
os-support-ai and others added 2 commits September 22, 2026 12:32
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant