Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 230 additions & 81 deletions scripts/pm/fleet-write/dispatch.mjs

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion scripts/pm/fleet-write/ops.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,22 @@ export const TARGET_OWNER = 'objectstack-ai';
export const TRANSPORT_ENV = 'OS_FLEET_TRANSPORT';
export const TRANSPORTS = Object.freeze(['direct', 'dispatch', 'auto']);

/** The session id a dispatch carries; a seat sets it to its own `session_…`. */
/**
* The session id a dispatch carries. A cloud seat's is READ FROM THE CONTAINER
* (`CONTAINER_SESSION_ENV` below); this variable OVERRIDES it — for a local
* checkout, or a test — and an explicit value that is malformed is refused,
* never replaced by the container's.
*/
export const SESSION_ENV = 'OS_FLEET_SESSION';
/**
* The container's own name for the seat's session: the cloud harness sets
* `CLAUDE_CODE_REMOTE_SESSION_ID=cse_<id>` in every cloud seat container
* measured, and `session_<id>` — the same tail — is the seat's id in the shape
* `SESSION_SHAPE` takes. Read only when `SESSION_ENV` is absent; a subagent
* dev runs in its PM's container and so inherits the DISPATCHING seat's id,
* which is the identity the envelope should carry.
*/
export const CONTAINER_SESSION_ENV = 'CLAUDE_CODE_REMOTE_SESSION_ID';

/** The merge method the auto-merge op arms. ⛔ Never a free string. */
export const MERGE_METHOD = 'SQUASH';
Expand Down Expand Up @@ -113,6 +127,8 @@ export const PAYLOAD_KEYS = Object.freeze(['request_id', 'repo', 'session', 'act

export const REQUEST_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export const SESSION_SHAPE = /^session_[A-Za-z0-9]{6,}$/;
/** The container variable's shape; group 1 is the tail `session_` is prefixed to. */
export const CONTAINER_SESSION_SHAPE = /^cse_([A-Za-z0-9]{6,})$/;
export const TARGET_REPO_SHAPE = new RegExp(`^${TARGET_OWNER}/[A-Za-z0-9_.-]+$`);

/**
Expand Down
3 changes: 2 additions & 1 deletion scripts/pm/issue-create.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,8 @@ const USAGE = [
' The body comes from a file, always; the title from a file by preference. A batch of N ≥ 5 cards is',
' announced first: node scripts/pm/write-pace.mjs --announce-batch N --kind "issue-create POST"',
' OS_FLEET_TRANSPORT=direct|dispatch|auto (default auto): dispatch sends the create through the fleet-write relay as',
' objectstack-fleet[bot]; auto takes it in a cloud seat container (OS_FLEET_SESSION must carry this seat\'s session_… id).',
" objectstack-fleet[bot]; auto takes it in a cloud seat container (the seat's session is read from the container's",
' CLAUDE_CODE_REMOTE_SESSION_ID; OS_FLEET_SESSION overrides it — a local checkout, a test) and direct elsewhere.',
` Exits: 0 created and read back · ${EXIT_USAGE} usage · ${EXIT_PREREQUISITE} prerequisite · ${EXIT_READ_BACK_MISMATCH} read-back mismatch · ${EXIT_PLATFORM_REFUSAL} platform refused · ${EXIT_UNCONFIRMED} dispatched but UNCONFIRMED · ${EXIT_WRITE_PACE_REFUSED} throttle refused`,
].join('\n');

Expand Down
5 changes: 3 additions & 2 deletions scripts/pm/label-write.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,9 @@ const USAGE = [
' ⛔ Never `PUT .../labels` and ⛔ never MCP `issue_write` (lock 1 denies it).',
'',
' OS_FLEET_TRANSPORT=direct|dispatch|auto (default auto): dispatch packs step ③ into ONE repository_dispatch the',
' fleet-write relay executes as objectstack-fleet[bot]; auto takes it in a cloud seat container (OS_FLEET_SESSION',
' must carry this seat\'s session_… id there) and direct elsewhere. Steps ①, ② and ④ are the same either way.',
' fleet-write relay executes as objectstack-fleet[bot]; auto takes it in a cloud seat container (the seat\'s session is',
" read from the container's CLAUDE_CODE_REMOTE_SESSION_ID; OS_FLEET_SESSION overrides it — a local checkout, a test)",
' and direct elsewhere. Steps ①, ② and ④ are the same either way.',
'',
' Exits: 0 landed+read-back · 2 usage · 3 prerequisite (no token/route, or rate-limit exhausted)',
' 4 read-back disagrees with the target · 5 every channel refused · 6 dispatched but UNCONFIRMED.',
Expand Down
81 changes: 80 additions & 1 deletion scripts/pm/post-stamped.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,7 @@ import {
} from './check-half-states.mjs';
import { readClause2Line } from './clause2-line.mjs';
import { EXIT_UNCONFIRMED, fallbackText, packRequest, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs';
import { MAX_BODY_BYTES, TRANSPORT_ENV } from './fleet-write/ops.mjs';
import { refusalText as relayRefusalText } from './fleet-write/validate.mjs';
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';

Expand Down Expand Up @@ -2828,6 +2829,53 @@ export function relayExitFor(result) {
return EXIT_UNCONFIRMED;
}

/**
* The size route: the relay's body cap decides the transport for THIS write,
* and nothing else does. Pure.
*
* The relay carries a body of at most `MAX_BODY_BYTES` UTF-8 bytes (a
* `repository_dispatch` `client_payload` is capped by the platform at 64 KB,
* hence the 60,000-byte body cap in `fleet-write/ops.mjs`), so a body over it
* cannot take the relay however live the relay is:
*
* - under `auto`, THIS write goes DIRECT — as the seat's own user, the token
* this process holds — and the route's reason becomes the ONE printed line
* that says so, naming the bytes, the cap and the identity. ⛔ Never silent.
* - under an explicit `OS_FLEET_TRANSPORT=dispatch` the route becomes a
* PREREQUISITE refusal (exit 3) naming the bytes: the operator asked for
* the relay, and a fall-back would change the identity the write is booked
* against.
*
* ⛔ Only the size re-routes. A refused op, a failed run and an UNCONFIRMED
* outcome keep the register `relayExitFor` gives them; and ⛔ there is no
* compression codec — a body this size is the thing to shrink, not to encode.
* A route that is not `dispatch`, one already carrying an error, or a body at
* or under the cap comes back unchanged (flagged `sizeRouted: false`).
*/
export function sizeRoute(route, body) {
const bytes = Buffer.byteLength(String(body ?? ''), 'utf8');
const cap = MAX_BODY_BYTES;
if (!route || route.transport !== 'dispatch' || route.error || bytes <= cap) return { ...route, bytes, cap, sizeRouted: false };
const size = `the body is ${grouped(bytes)} UTF-8 bytes, ${grouped(bytes - cap)} over the relay's ${grouped(cap)}-byte body cap (a repository_dispatch client_payload is capped by the platform at 64 KB)`;
if (route.requested === 'auto') {
return {
...route,
transport: 'direct',
bytes,
cap,
sizeRouted: true,
reason: `${TRANSPORT_ENV} is auto → direct for THIS write: ${size}, so it cannot take the relay. Written DIRECT as the seat's own user (the token this process holds), not as objectstack-fleet[bot]. ⛔ Not compressed: a body this size is the thing to shrink.`,
};
}
return {
...route,
bytes,
cap,
sizeRouted: true,
error: `${TRANSPORT_ENV}=dispatch but ${size} — the relay cannot carry it. ⛔ Not falling back to direct: the operator asked for the relay, and the fall-back would change the identity the write is booked against. Shrink the body, or run under ${TRANSPORT_ENV}=auto to let THIS write go direct as the seat's own user.`,
};
}

// ---------------------------------------------------------------------------
// Live layer
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -3074,7 +3122,10 @@ async function main(argv) {

// The transport, decided and SAID before anything leaves. A dry run makes no
// request, so a route that cannot be resolved is a note there, not a refusal.
const route = await resolveRoute(process.env);
// The size route runs on the RENDERED body (the stamp is part of what is
// sent): over the relay's cap, `auto` takes direct for this write and the
// line below says so; explicit dispatch is refused.
const route = sizeRoute(await resolveRoute(process.env), rendered.body);
if (route.error && !options.dryRun) {
console.error(`post-stamped: PREREQUISITE NOT MET — ${route.error}\n NOTHING WAS WRITTEN.`);
return EXIT_PREREQUISITE_NOT_MET;
Expand Down Expand Up @@ -3264,6 +3315,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
'the shared rule: this tool and H56 cannot come to disagree': 6,
'the keyed lines: a claim\'s exact-value fields, judged by the readers that own them': 20,
'the relay transport: the same act as one op, the comment found on the board, the exit register kept apart': 12,
"the size route: over the relay's body cap under auto THIS write goes direct with one line naming bytes, cap and identity; at or under it the relay; explicit dispatch refuses naming the bytes; nothing else re-routes": 12,
});
const SELF_TEST_BATTERY_FLOOR = 16;
const UNATTRIBUTED_BATTERY = '(unattributed)';
Expand Down Expand Up @@ -4356,6 +4408,33 @@ export function selfTest() {
t('structural: in main a dry run returns before the relay can be reached, and ONE read-back verdict serves both transports', mainSource.indexOf('if (options.dryRun) {') < mainSource.indexOf('await writeViaRelay(') && (mainSource.match(/readBackVerdict\(\{/g) ?? []).length === 1);
}

// ── the size route ───────────────────────────────────────────────────────
battery("the size route: over the relay's body cap under auto THIS write goes direct with one line naming bytes, cap and identity; at or under it the relay; explicit dispatch refuses naming the bytes; nothing else re-routes");
{
const AUTO = { requested: 'auto', transport: 'dispatch', reason: 'auto → dispatch (fixture)', error: null, failed: null, session: 'session_01ABCDEFGHJKMNPQRSTVWXYZ' };
const STRICT = { ...AUTO, requested: 'dispatch', reason: 'OS_FLEET_TRANSPORT=dispatch (fixture)' };
const ofBytes = (n) => 'x'.repeat(n);
const over = sizeRoute(AUTO, ofBytes(MAX_BODY_BYTES + 1));
t('one byte over the cap under auto: DIRECT for this write, flagged as size-routed, no error', over.transport === 'direct' && over.sizeRouted === true && over.error === null);
t('…and the ONE line names the bytes sent', over.reason.includes(`${grouped(MAX_BODY_BYTES + 1)} UTF-8 bytes`));
t('…the cap', over.reason.includes(`${grouped(MAX_BODY_BYTES)}-byte body cap`));
t("…and the identity used — the seat's own user, not the fleet bot", over.reason.includes("seat's own user") && over.reason.includes('not as objectstack-fleet[bot]'));
const under = sizeRoute(AUTO, ofBytes(MAX_BODY_BYTES - 1));
t('one byte under the cap under auto: the relay, the route untouched', under.transport === 'dispatch' && under.sizeRouted === false && under.reason === AUTO.reason && under.error === null);
t('…and exactly AT the cap is still the relay (the validator admits it)', sizeRoute(AUTO, ofBytes(MAX_BODY_BYTES)).transport === 'dispatch');
const strict = sizeRoute(STRICT, ofBytes(MAX_BODY_BYTES + 1));
t('⛔ explicit dispatch one byte over: a PREREQUISITE refusal naming the bytes and saying it will NOT fall back, the transport NOT changed', strict.transport === 'dispatch' && typeof strict.error === 'string' && strict.error.includes(grouped(MAX_BODY_BYTES + 1)) && strict.error.includes('Not falling back'));
t('bytes are UTF-8 BYTES, not characters: 20,001 three-byte characters are over the cap, 20,000 are not', sizeRoute(AUTO, '中'.repeat(20_001)).transport === 'direct' && sizeRoute(AUTO, '中'.repeat(20_000)).transport === 'dispatch');
t('a route already direct is never touched, whatever the size', sizeRoute({ ...AUTO, transport: 'direct' }, ofBytes(MAX_BODY_BYTES + 1)).sizeRouted === false);
t('a route that already carries an error is never touched — the size never masks a missing session', sizeRoute({ ...STRICT, error: 'no session' }, ofBytes(MAX_BODY_BYTES + 1)).error === 'no session');
t('⛔ nothing else re-routes: a refused dispatch, a failed run and UNCONFIRMED keep their register', relayExitFor({ state: 'refused' }) === EXIT_PREREQUISITE_NOT_MET && relayExitFor({ state: 'failure' }) === EXIT_NOT_STORED && relayExitFor({ state: 'timeout' }) === EXIT_UNCONFIRMED);
const ownSource = readFileSync(SELF_PATH, 'utf8');
const mainSource = ownSource.slice(ownSource.indexOf('async function main(argv)'), ownSource.indexOf('// --self-test — offline'));
t('structural: main hands the resolved route through sizeRoute on the RENDERED body before the transport line is printed', mainSource.includes('sizeRoute(await resolveRoute(process.env), rendered.body)') && mainSource.indexOf('sizeRoute(') < mainSource.indexOf('transport ${route.transport}'));
// An import SHAPE, not the module's bare name: the name is spelled in this very line, so a name test could never fail.
t('⛔ no compression codec: this file imports nothing from zlib', /from '(node:)?zlib'/.test(ownSource) === false && /require\('(node:)?zlib'\)/.test(ownSource) === false);
}

// The floor, evaluated last: a battery that stops running names itself here.
const failed = cases.filter((c) => !c.ok);
for (const c of cases) console.log(` ${c.ok ? '✓' : '✗'} ${c.name}`);
Expand Down
31 changes: 23 additions & 8 deletions scripts/pm/with-fleet.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
# --via dispatch `--repo owner/name --actions <file>`; never a command — the relay hands out no token.
# --via auto (the default, or OS_FLEET_TRANSPORT) — asks `fleet-write/dispatch.mjs --route`,
# the ONE selector every tool shares: `dispatch` only when the cloud discriminator,
# a well-formed OS_FLEET_SESSION and a live relay on the board all hold, `direct`
# otherwise (the line says which condition failed). A COMMAND under auto that
# the seat's session (read from the container's CLAUDE_CODE_REMOTE_SESSION_ID;
# OS_FLEET_SESSION overrides it — a local checkout, a test) and a live relay on the
# board — the workflow file on main AND its Actions state `active` — all hold,
# `direct` otherwise (the line says which condition failed). A COMMAND under auto that
# resolves to `dispatch` is REFUSED (exit 3) with the actions-file spelling: it
# cannot be run as the fleet there, and running it as the session's login instead
# would be a silent change of identity.
Expand Down Expand Up @@ -95,7 +97,7 @@ log() { printf 'with-fleet: %s\n' "$*" >&2; }

ST_PASS=0
ST_FAIL=0
ST_MIN_CASES=28
ST_MIN_CASES=29
st_case() {
local name="$1" got="$2" want="$3"
if [[ "$got" == "$want" ]]; then
Expand Down Expand Up @@ -241,14 +243,26 @@ EOF
run --via dispatch --repo objectstack-ai/objectstack --actions "$dir/actions.json" -- true || rc=$?
st_case '--actions AND a command is usage (2)' "$rc" "$EXIT_USAGE"
# A cloud seat container, under auto, with a command: the ONE shared selector decides. With no
# OS_FLEET_SESSION the seat has not opted in, so it is direct — said out loud — and the command runs.
# session from EITHER source (OS_FLEET_SESSION blank, the container variable unset — this self-test
# runs inside cloud containers too, so the inherited one is dropped) it is direct — said out loud —
# and the command runs.
: > "$err.cloud"
rc=0
env -u GIT_DIR OS_FLEET_TOKEN_CACHE_FILE="$cache" OS_FLEET_APP_ID=1 OS_FLEET_INSTALLATION_ID=2 OS_PM_WRITE_PACE_FILE="$pace" HTTPS_PROXY= https_proxy= OS_FLEET_TRANSPORT= OS_FLEET_SESSION= \
env -u GIT_DIR -u CLAUDE_CODE_REMOTE_SESSION_ID OS_FLEET_TOKEN_CACHE_FILE="$cache" OS_FLEET_APP_ID=1 OS_FLEET_INSTALLATION_ID=2 OS_PM_WRITE_PACE_FILE="$pace" HTTPS_PROXY= https_proxy= OS_FLEET_TRANSPORT= OS_FLEET_SESSION= \
CCR_AGENT_PROXY_ENABLED=1 bash "$SELF" --read -- sh -c 'printf "%s" "$GITHUB_TOKEN"' > "$dir/cloud.out" 2> "$err.cloud" || rc=$?
st_case 'a command under auto in a cloud container WITHOUT a session is direct (the seat has not opted in): it runs, and the line names OS_FLEET_SESSION' \
st_case 'a command under auto in a cloud container WITHOUT a session from either source is direct: it runs, and the line names OS_FLEET_SESSION' \
"$rc|$(grep -c 'auto → direct' "$err.cloud")|$(grep -c 'OS_FLEET_SESSION' "$err.cloud")|$(cat "$dir/cloud.out")" "0|1|1|$token"
cat "$err.cloud" >> "$err"
# …with NO OS_FLEET_SESSION but the container's own session variable (a cloud seat's default) and a live
# relay (stood in by the test override): the selector DERIVES the session, all three hold, so a command
# is refused with the relay spelling — the allowed command spelling routes through the relay with no prefix.
: > "$err.cloud"
rc=0
env -u GIT_DIR OS_FLEET_TOKEN_CACHE_FILE="$cache" OS_FLEET_APP_ID=1 OS_FLEET_INSTALLATION_ID=2 OS_PM_WRITE_PACE_FILE="$pace" HTTPS_PROXY= https_proxy= OS_FLEET_TRANSPORT= OS_FLEET_SESSION= \
CCR_AGENT_PROXY_ENABLED=1 CLAUDE_CODE_REMOTE_SESSION_ID=cse_01ABCDEFGHJKMNPQRSTVWXYZ OS_FLEET_RELAY_LIVE=1 bash "$SELF" --read -- sh -c 'printf "%s" "$GITHUB_TOKEN"' > "$dir/cloud.out" 2> "$err.cloud" || rc=$?
st_case 'a command under auto with the session DERIVED from the container (no OS_FLEET_SESSION) is REFUSED (3) with the relay spelling, the line naming the container variable, and no token reached it' \
"$rc|$(grep -c -- '--via dispatch' "$err.cloud")|$(grep -c 'CLAUDE_CODE_REMOTE_SESSION_ID' "$err.cloud")|$(cat "$dir/cloud.out")" '3|1|1|'
cat "$err.cloud" >> "$err"
# …with a session AND a live relay (stood in by the test override): dispatch, so a command is refused with the relay spelling.
: > "$err.cloud"
rc=0
Expand Down Expand Up @@ -367,8 +381,9 @@ if [[ "$VIA" == dispatch ]]; then
exit "$EXIT_USAGE"
fi
if [[ "$VIA" == auto ]]; then
# The ONE selector every tool shares decides: dispatch only when the cloud discriminator, a
# well-formed OS_FLEET_SESSION and a live relay all hold. Its JSON line is read, never re-derived.
# The ONE selector every tool shares decides: dispatch only when the cloud discriminator, the seat's
# session (from the container, OS_FLEET_SESSION overriding it) and a live relay all hold. Its JSON
# line is read, never re-derived.
ROUTE_JSON="$(node "$HERE/fleet-write/dispatch.mjs" --route)" || {
log "the transport selector refused (fleet-write/dispatch.mjs --route exit $?); nothing ran."
exit 3
Expand Down
Loading