From 5bbb9dc80d2e81fc1bb22fb9bd19da586c4ab415 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:40:46 +0000 Subject: [PATCH 1/3] wip(pm): fleet-write relay reports created/moved card numbers as check-run annotations Claude-Session: https://claude.ai/code/session_01FNKm1SmPpuJASnbjxWGtsJ Co-authored-by: Claude --- scripts/pm/fleet-write/dispatch.mjs | 218 +++++++++++++++++++++++++--- scripts/pm/fleet-write/execute.mjs | 87 ++++++++++- 2 files changed, 285 insertions(+), 20 deletions(-) diff --git a/scripts/pm/fleet-write/dispatch.mjs b/scripts/pm/fleet-write/dispatch.mjs index ccea8e06b6c..7d6578cb487 100644 --- a/scripts/pm/fleet-write/dispatch.mjs +++ b/scripts/pm/fleet-write/dispatch.mjs @@ -176,12 +176,14 @@ * and an issue the last re-list still lacks is `unfound` — exit 6, * UNCONFIRMED, read the board — exactly what it meant before. * - * Why a re-list and not the run's own report of the number it created: the - * executor prints `#number url` to the job log and the step summary, and a - * seat container reads neither — the job-log endpoint answers 302 to blob - * storage that the egress proxy refuses (CONNECT 403), and the job's check run - * carries a null `output.summary`. Making the run report it somewhere a seat - * CAN read is a change to what the relay emits, not to this read-back. + * The re-list is the FALLBACK. The run knows the number it created, and a + * seat container reads neither the job log (its endpoint answers 302 to blob + * storage the egress proxy refuses, CONNECT 403) nor the step summary (the + * job's check run carries a null `output.summary`) — but it does read the + * check run's ANNOTATIONS, so the run reports the number there (next section). + * An `issue_create` whose action the run's annotation names is read back AT + * that number: one GET, no list, no re-list. Only an action no annotation + * names takes the re-list above. * * How the callers read the new outcomes — one exit vocabulary, no caller edited: * - `failure` + `notStored` keeps the state every caller already reads as "the @@ -196,6 +198,38 @@ * - a stroke carrying no body (labels, assignees, state, a transfer, the * GraphQL ops) reads nothing back and its outcome is unchanged. * + * ## The run's annotations — the numbers a seat CAN read + * + * `execute.mjs` prints, for every action whose op is in `ANNOTATED_OPS` (the + * ops that create or move a card) and whose request LANDED, ONE workflow + * command — a `notice` titled `fleet-write ` whose message is + * `fleet-write action= op= number= url=`, the number and url + * taken from the platform's ANSWER to that request (the created issue's + * `number` / `html_url`, the `transferIssue` answer's `issue.number` / + * `issue.url`), never from the request. The runner turns it into an + * annotation on the job's check run. After a success run carrying such an + * op, `sendFleetWrite` reads them — `GET /repos/{board}/actions/runs/{id}/jobs` + * (a job's id is its check run's id, measured) then + * `GET /repos/{board}/check-runs/{id}/annotations`, both measured readable + * from a seat container — and hands them on as `result.annotations`; a + * caller that needs a card's number (`issue-create.mjs`, `issue-transfer.mjs`) + * reads it there first. `relayAnnotationMessage` spells the message and + * `parseRelayAnnotation` reads it — ONE spelling for the writer and every + * reader; the executor emits only a message the parser reads back as what it + * meant. A parsed row is used only when the stroke's action at its index has + * its op and its url names the repository that op lands the card on + * (`matchRunAnnotations`); two different rows for one action are neither. + * + * Absent is an ordinary state, never a failure, and always said: a relay + * older than the emission, an annotation read that does not answer, or an + * action past the platform's cap — 10 notice annotations per step and 50 per + * job (actions/toolkit `docs/problem-matchers.md`), the rest dropped without + * a word, so of a stroke carrying more than ten such ops only the first ten + * are named. Each reader then takes its fallback: the re-list here, the + * redirect or the title in `issue-transfer.mjs`, the title in + * `issue-create.mjs`. ⛔ No second read path beyond that fallback, and ⛔ not + * a gate: an annotation only ever replaces a search for a number. + * * ## Exit codes — capture them BEFORE any pipe * * 0 the run completed with conclusion `success`, and every body it wrote @@ -525,7 +559,7 @@ export const BODY_OPS = Object.freeze(OP_NAMES.filter((op) => [...OPS[op].requir export const READ_BACK_LOCATORS = Object.freeze({ issue_patch: Object.freeze({ found: 'address', where: 'GET /repos/{repo}/issues/{issue}' }), comment_edit: Object.freeze({ found: 'address', where: 'GET /repos/{repo}/issues/comments/{comment_id}' }), - issue_create: Object.freeze({ found: 'key', where: 'the newest issue created since the dispatch whose title is the title sent, the list re-read on ISSUE_CREATE_RELIST_DELAYS_MS before unfound' }), + issue_create: Object.freeze({ found: 'key', where: "the issue at the number the run's annotation names; absent that, the newest issue created since the dispatch whose title is the title sent, the list re-read on ISSUE_CREATE_RELIST_DELAYS_MS before unfound" }), pr_create: Object.freeze({ found: 'key', where: 'the newest pull request on the head sent, created since the dispatch' }), comment: Object.freeze({ found: 'content', where: 'the newest comment on the issue, created since the dispatch, whose stored body holds the bytes sent' }), }); @@ -631,6 +665,88 @@ export function unverifiedText(result, tool = 'fleet-write') { ); } +// --------------------------------------------------------------------------- +// The run's annotations — pure halves (the header's annotations section is the authority) +// --------------------------------------------------------------------------- + +/** + * The ops whose landing the executor reports as an annotation — every op that + * creates or moves a card, and nothing else — each with the repository it + * lands the card on, which a parsed row's url is held to. `execute.mjs` reads + * a number and url out of each one's answer (`ANNOTATION_ANSWERS`, pinned to + * these keys by its self-test). + */ +export const ANNOTATED_OPS = Object.freeze({ + issue_create: Object.freeze({ lands: (action, repo) => repo }), + transfer: Object.freeze({ lands: (action) => action?.target_repo }), +}); + +/** The word a relay annotation's message starts with — and its title, before the op. */ +export const RELAY_ANNOTATION_PREFIX = 'fleet-write'; + +const RELAY_ANNOTATION_SHAPE = new RegExp(`^${RELAY_ANNOTATION_PREFIX} action=([1-9][0-9]*) op=([a-z_]+) number=([1-9][0-9]*) url=(https://\\S+)$`); +/** An issue's web url — group 1 its repository, group 2 its number. */ +const ISSUE_URL_SHAPE = /^https:\/\/[^/\s]+\/([^/\s]+\/[^/\s]+)\/issues\/([1-9][0-9]*)$/; + +/** The message of the one annotation the executor emits for a landed annotated op. Pure. */ +export function relayAnnotationMessage({ action, op, number, url }) { + return `${RELAY_ANNOTATION_PREFIX} action=${action} op=${op} number=${number} url=${url}`; +} + +/** + * An annotation's `message` as `{ action, op, number, url, repo }`, or null — + * for anything else on the check run (the runner's own notices, an action's + * deprecation warnings), an op outside `ANNOTATED_OPS`, or a url that is not + * an issue url carrying the same number. Pure; never throws. + */ +export function parseRelayAnnotation(message) { + const m = RELAY_ANNOTATION_SHAPE.exec(typeof message === 'string' ? message : ''); + if (!m || !Object.hasOwn(ANNOTATED_OPS, m[2])) return null; + const at = ISSUE_URL_SHAPE.exec(m[4]); + if (!at || at[2] !== m[3]) return null; + return { action: Number(m[1]), op: m[2], number: Number(m[3]), url: m[4], repo: at[1] }; +} + +/** + * The parsed rows a stroke may use: a row whose action index holds an action + * of its op, and whose url names the repository that op lands the card on. + * Anything else is `ignored`, with why — and so are BOTH rows of an action + * two different rows name: a reader never picks between two answers. Pure. + */ +export function matchRunAnnotations(payload, parsed) { + const actions = Array.isArray(payload?.actions) ? payload.actions : []; + const ignored = []; + const byAction = new Map(); + for (const row of Array.isArray(parsed) ? parsed : []) { + const action = actions[row.action - 1]; + if (!action || action.op !== row.op) { + ignored.push({ ...row, why: `the stroke's action ${row.action} is ${action ? action.op : 'absent'}, not ${row.op}` }); + continue; + } + const lands = String(ANNOTATED_OPS[row.op].lands(action, payload.repo) ?? ''); + if (lands.toLowerCase() !== row.repo.toLowerCase()) { + ignored.push({ ...row, why: `its url names ${row.repo}, and ${row.op} lands the card on ${lands || 'no repository'}` }); + continue; + } + byAction.set(row.action, [...(byAction.get(row.action) ?? []), row]); + } + const rows = []; + for (const [action, list] of byAction) { + const distinct = new Set(list.map((r) => `${r.number} ${r.url}`)); + if (distinct.size > 1) ignored.push(...list.map((r) => ({ ...r, why: `${distinct.size} different annotations name action ${action}` }))); + else rows.push(list[0]); + } + rows.sort((a, b) => a.action - b.action); + return { rows, ignored }; +} + +/** A job's check run id — from its `check_run_url`, else its own id (measured equal). Pure. */ +export function checkRunIdOf(job) { + const m = /\/check-runs\/([1-9][0-9]*)$/.exec(String(job?.check_run_url ?? '')); + if (m) return Number(m[1]); + return Number.isInteger(job?.id) && job.id > 0 ? job.id : null; +} + // --------------------------------------------------------------------------- // Transport — the one POST, paced; the reads around it are not. // --------------------------------------------------------------------------- @@ -687,15 +803,66 @@ async function listAll(api, path, t, maxPages = 10) { return { ok: true, rows }; } +/** + * The relay annotations on a run's check runs — its jobs, then each job's + * check-run annotations, every message through `parseRelayAnnotation` (the + * rest of the check run's annotations are ignored). Returns `{ state, parsed, + * why }`: `read`, or `unread` naming the call that did not answer, with + * whatever was parsed before it. Reads only — never paced; never throws. + */ +export async function readRunAnnotations(runId, deps = {}) { + const api = deps.api ?? DEFAULT_API; + const t = { fetch: deps.fetch, token: deps.token }; + const said = (r) => `${r.call} -> HTTP ${r.status}${r.detail ? ` (${r.detail})` : ''}`; + const jobs = await rest(api, `/repos/${RELAY_REPO}/actions/runs/${runId}/jobs?per_page=100`, {}, t); + if (jobs.status !== 200 || !Array.isArray(jobs.json?.jobs)) return { state: 'unread', parsed: [], why: said(jobs) }; + const parsed = []; + for (const job of jobs.json.jobs) { + const id = checkRunIdOf(job); + if (id === null) continue; + const r = await rest(api, `/repos/${RELAY_REPO}/check-runs/${id}/annotations?per_page=100`, {}, t); + if (r.status !== 200 || !Array.isArray(r.json)) return { state: 'unread', parsed, why: said(r) }; + for (const a of r.json) { + const row = parseRelayAnnotation(a?.message); + if (row) parsed.push(row); + } + } + return { state: 'read', parsed, why: '' }; +} + +/** + * The annotations a stroke's success run carries, matched to its actions and + * said: one line per row, per ignored row, per annotated action no row names, + * and one when the read did not answer. A stroke with no annotated op reads + * nothing (`state: 'none'`). Returns `{ state, rows, ignored, why }`. + */ +async function runAnnotationsFor(payload, run, deps) { + const annotated = payload.actions.map((a, i) => ({ action: i + 1, op: a.op })).filter((a) => Object.hasOwn(ANNOTATED_OPS, a.op)); + if (!annotated.length) return { state: 'none', rows: [], ignored: [], why: '' }; + const read = await readRunAnnotations(run.id, deps); + const { rows, ignored } = matchRunAnnotations(payload, read.parsed); + const log = deps.log; + for (const r of rows) log(`fleet-write: run ${run.id}'s annotation names action ${r.action} ${r.op} → ${r.repo}#${r.number} ${r.url} (the platform's own answer).`); + for (const r of ignored) log(`fleet-write: run ${run.id}'s annotation for action ${r.action} ${r.op} (#${r.number}) is ignored — ${r.why}.`); + if (read.state === 'unread') log(`fleet-write: run ${run.id}'s annotations could not be read — ${read.why}; a number no annotation above names is read from the board instead.`); + for (const a of annotated) { + if (!rows.some((r) => r.action === a.action)) log(`fleet-write: no annotation on run ${run.id} names action ${a.action} ${a.op} — its number is read from the board instead (the fallback).`); + } + return { state: read.state, rows, ignored, why: read.why }; +} + /** * Read back every body a completed stroke wrote and judge it against the bytes * sent. Returns `{ state, rows }` — `state` from `strokeReadBackState`. Never * throws on a status: a read that fails makes its row `unverified`, with the * call and status in `why`. `taken` keeps two actions of one stroke from being - * judged against the same created object. `sleep` and `log` serve the - * `issue_create` re-list alone (`ISSUE_CREATE_RELIST_DELAYS_MS`). + * judged against the same created object. `annotations` are the run's matched + * rows (`matchRunAnnotations`): an `issue_create` one of them names is read + * at that number, never listed. `sleep` and `log` serve the `issue_create` + * re-list alone (`ISSUE_CREATE_RELIST_DELAYS_MS`), which only an + * `issue_create` no annotation names takes. */ -export async function readBackStroke(payload, { dispatchedAt }, deps = {}) { +export async function readBackStroke(payload, { dispatchedAt, annotations = [] }, deps = {}) { const targets = readBackTargets(payload); if (!targets.length) return { state: 'none', rows: [] }; const api = deps.api ?? DEFAULT_API; @@ -719,6 +886,18 @@ export async function readBackStroke(payload, { dispatchedAt }, deps = {}) { rows.push(r.status === 200 && r.json ? judged(where, r.json.body) : unread(target, where, r)); continue; } + // An `issue_create` the run's annotation names is read AT that number — the platform's own answer to the create: + // one GET, no list, no re-list (header). A read that fails is `unread`, as any addressed read is. + const named = target.op === 'issue_create' ? annotations.find((a) => a.op === target.op && a.action === target.action) : undefined; + if (named) { + const where = `${repo}#${named.number}`; + const r = await rest(api, `/repos/${repo}/issues/${named.number}`, {}, t); + if (r.status === 200 && r.json) { + if (r.json.id !== undefined) taken.add(r.json.id); + rows.push({ ...judged(where, r.json.body), foundBy: 'annotation' }); + } else rows.push({ ...unread(target, where, r), foundBy: 'annotation' }); + continue; + } if (target.op === 'issue_create' || target.op === 'pr_create') { const isPr = target.op === 'pr_create'; const head = isPr ? (String(target.head).includes(':') ? String(target.head) : `${repo.split('/')[0]}:${target.head}`) : null; @@ -751,7 +930,7 @@ export async function readBackStroke(payload, { dispatchedAt }, deps = {}) { continue; } taken.add(hit.id); - rows.push(judged(`${repo}#${hit.number}`, hit.body)); + rows.push({ ...judged(`${repo}#${hit.number}`, hit.body), ...(isPr ? {} : { foundBy: 'list' }) }); continue; } // `comment`: only the body finds a new comment, so a body that matches nothing is unverified, never not-stored. @@ -783,7 +962,8 @@ export async function readBackStroke(payload, { dispatchedAt }, deps = {}) { * `success` (the run succeeded and every body reads back as sent) · `failure` (the run completed otherwise, or — `notStored: * true` — it succeeded and the read-back measured a body the board does not hold as sent) · `unverified` (it succeeded and a * body could not be read back) · `no-run` · `timeout` · `refused` (the dispatch itself). `readBack` is `readBackStroke`'s - * answer, present once the run succeeded. Never throws on an HTTP status. + * answer, present once the run succeeded, and so is `annotations` — `{ state, rows, ignored, why }`, the run's + * annotations matched to the stroke (`state` `none` when it carries no annotated op). Never throws on an HTTP status. */ export async function sendFleetWrite(payload, deps = {}) { const api = deps.api ?? DEFAULT_API; @@ -842,18 +1022,21 @@ export async function sendFleetWrite(payload, deps = {}) { const done = { ...base, run, status: 204, verdict: 'ok', dispatchedAt }; if (!ok) return { ...done, state: 'failure', ok: false, detail: `conclusion ${run.conclusion}` }; + // ── the run's annotations — the numbers it created or moved, where a seat CAN read them (header) ───── + const annotations = await runAnnotationsFor(payload, run, { api, fetch: deps.fetch, token: deps.token, log }); + // ── read it back — the run's success is the executor's, not the write's ───── - const readBack = await readBackStroke(payload, { dispatchedAt }, { api, fetch: deps.fetch, token: deps.token, judge: deps.judge, sleep, log }); + const readBack = await readBackStroke(payload, { dispatchedAt, annotations: annotations.rows }, { api, fetch: deps.fetch, token: deps.token, judge: deps.judge, sleep, log }); for (const row of readBack.rows) log(readBackLine(row)); if (readBack.state === 'not-stored') { const first = readBack.rows.find((r) => r.verdict === 'not-stored'); - return { ...done, state: 'failure', ok: false, notStored: true, readBack, detail: `conclusion success, but NOT STORED — action ${first.action} (${first.op} ${first.where}) first differs from the bytes sent at byte ${first.offset}` }; + return { ...done, state: 'failure', ok: false, notStored: true, readBack, annotations, detail: `conclusion success, but NOT STORED — action ${first.action} (${first.op} ${first.where}) first differs from the bytes sent at byte ${first.offset}` }; } if (readBack.state === 'unverified') { const first = readBack.rows.find((r) => r.verdict === 'unverified'); - return { ...done, state: 'unverified', ok: false, readBack, detail: `conclusion success, but UNVERIFIED — action ${first.action} (${first.op}): ${first.why ?? first.cls}` }; + return { ...done, state: 'unverified', ok: false, readBack, annotations, detail: `conclusion success, but UNVERIFIED — action ${first.action} (${first.op}): ${first.why ?? first.cls}` }; } - return { ...done, state: 'success', ok: true, readBack, detail: `conclusion ${run.conclusion}` }; + return { ...done, state: 'success', ok: true, readBack, annotations, detail: `conclusion ${run.conclusion}` }; } /** The exit a tool takes from a result that is not `success`. */ @@ -1778,7 +1961,8 @@ export async function main(argv, deps = {}) { not_stored: result.notStored === true, run: result.run, dispatched_at: new Date(result.dispatchedAt).toISOString(), - read_back: (result.readBack?.rows ?? []).map((r) => ({ action: r.action, op: r.op, where: r.where, verdict: r.verdict, class: r.cls, first_difference_byte: r.offset ?? null, sent_bytes: r.sentBytes ?? null, stored_bytes: r.storedBytes ?? null })), + read_back: (result.readBack?.rows ?? []).map((r) => ({ action: r.action, op: r.op, where: r.where, verdict: r.verdict, class: r.cls, first_difference_byte: r.offset ?? null, sent_bytes: r.sentBytes ?? null, stored_bytes: r.storedBytes ?? null, found_by: r.foundBy ?? null })), + annotations: (result.annotations?.rows ?? []).map((a) => ({ action: a.action, op: a.op, number: a.number, url: a.url })), }), ); } diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index 801438738df..8b17520d9ca 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -61,7 +61,7 @@ * apply on the runner too (the runner's own home holds the log; nothing is * shared with a seat container, and the twenty-action cap bounds the run). * - * ## The step summary — what a seat reads back + * ## The step summary — what a person reads back * * `$GITHUB_STEP_SUMMARY` gets one table per run: request id · sender and its * role · session · target, then one row per request — op, endpoint, HTTP @@ -69,6 +69,29 @@ * or where it stopped. Text is passed through unchanged: attribution stays * the session id inside the text, per protocol. * + * ## The annotations — the numbers a seat CAN read + * + * A seat container reads neither this job's log (the endpoint answers 302 to + * blob storage its egress proxy refuses) nor its step summary (the check + * run's `output.summary` is null), but it does read the check run's + * annotations. So for every action whose op creates or moves a card + * (`ANNOTATED_OPS` in `dispatch.mjs`) and whose request LANDED, this file + * prints ONE workflow command to stdout — a `notice` titled + * `fleet-write ` whose message `relayAnnotationMessage` spells as + * `fleet-write action= op= number= url=` — and the runner turns + * it into an annotation. The number and url are read from the platform's + * ANSWER to that request (`ANNOTATION_ANSWERS`: the created issue's + * `number` / `html_url`, the `transferIssue` answer's `issue.number` / + * `issue.url`), ⛔ never from the request; an answer that carries neither, or + * a message `parseRelayAnnotation` would not read back as exactly this + * number, url and landing repository, emits nothing and says so — the + * reader's fallback then finds the card. No other op emits one. The + * platform keeps 10 notice annotations per step and 50 per job and drops the + * rest without a word (actions/toolkit `docs/problem-matchers.md`), so a + * stroke past ten such ops names only its first ten; `dispatch.mjs`'s header + * section of the same name is the reader's half. The message is escaped the + * way actions/toolkit `command.ts` escapes a command's data and properties. + * * ## Exit codes — capture them BEFORE any pipe * * 0 every action landed. @@ -88,6 +111,7 @@ import { isEntrypoint } from '../../invoked-as.mjs'; import { scrub } from '../fleet-token.mjs'; import { classifyHttp } from '../label-write.mjs'; import { EXIT_WRITE_PACE_REFUSED, isWriteMethod, noteResponse, paceWrite, releaseWriteLease } from '../write-pace.mjs'; +import { ANNOTATED_OPS, parseRelayAnnotation, RELAY_ANNOTATION_PREFIX, relayAnnotationMessage } from './dispatch.mjs'; import { OPS, PERMISSIONS, PR_LANDED_STATE, transferRemedy } from './ops.mjs'; import { PAYLOAD_ENV, refusalText, tokenRepositoriesOf, validatePayload } from './validate.mjs'; @@ -197,6 +221,53 @@ export function resultOf(req, json) { return bits.join(' ') || 'ok'; } +/** + * Where each annotated op's ANSWER carries the number and url of the card it + * created or moved — keyed exactly as `ANNOTATED_OPS` (the self-test pins it). + * ⛔ Never the request: an annotation reports what the platform answered. + */ +export const ANNOTATION_ANSWERS = Object.freeze({ + issue_create: (req, json) => ({ number: json?.number, url: json?.html_url }), + transfer: (req, json) => { + const moved = json?.data?.[req?.graphql?.mutation]?.issue; + return { number: moved?.number, url: moved?.url }; + }, +}); + +/** A workflow command's data, escaped as actions/toolkit `command.ts` `escapeData` does. */ +export function escapeCommandData(s) { + return String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} + +/** A workflow command's property value, escaped as actions/toolkit `command.ts` `escapeProperty` does. */ +export function escapeCommandProperty(s) { + return escapeCommandData(s).replace(/:/g, '%3A').replace(/,/g, '%2C'); +} + +/** + * The annotation one landed request earns: `{ note }` — `{ action, op, + * number, url, repo }`, exactly what `parseRelayAnnotation` reads back from + * its message — or `{ note: null, why }`: `why` is empty for an op that is not + * annotated, and names the reason for one whose answer cannot be reported + * (no number and url, a url the reader would not parse as this number, or one + * naming another repository than the op lands the card on). Pure. + */ +export function annotationFor({ action, op, req, json, payload }) { + if (!Object.hasOwn(ANNOTATION_ANSWERS, op) || !Object.hasOwn(ANNOTATED_OPS, op)) return { note: null, why: '' }; + const { number, url } = ANNOTATION_ANSWERS[op](req, json); + if (!Number.isInteger(number) || number < 1 || typeof url !== 'string' || url === '') return { note: null, why: 'the answer carries no number and url' }; + const back = parseRelayAnnotation(relayAnnotationMessage({ action, op, number, url })); + if (!back || back.action !== action || back.op !== op || back.number !== number || back.url !== url) return { note: null, why: `the answer's url ${url} is not one the reader parses as #${number}` }; + const lands = String(ANNOTATED_OPS[op].lands(payload?.actions?.[action - 1], payload?.repo) ?? ''); + if (back.repo.toLowerCase() !== lands.toLowerCase()) return { note: null, why: `the answer's url names ${back.repo}, not ${lands || 'the repository the op lands on'}` }; + return { note: back, why: '' }; +} + +/** The workflow command that turns a note into a `notice` annotation on this job's check run. Pure. */ +export function annotationCommand(note) { + return `::notice title=${escapeCommandProperty(`${RELAY_ANNOTATION_PREFIX} ${note.op}`)}::${escapeCommandData(relayAnnotationMessage(note))}`; +} + /** The summary table, as markdown lines. */ export function summaryText({ payload, sender, role, rows, stoppedAt = null, notAttempted = 0, refusal = null, remedy = null, reaches = null }) { const lines = [`### fleet-write \`${payload?.request_id ?? '?'}\``, '']; @@ -287,7 +358,8 @@ async function graphqlVariables(req, payload, api, t) { } /** - * The run. Returns `{ exit, rows, summary, lines }`; never throws on a status. + * The run. Returns `{ exit, rows, summary, lines, annotations }` — `annotations` + * the notes this run emitted, in order; never throws on a status. * * @param {{ payload: unknown, sender: string, token: string, api?: string }} input * @param {{ fetch?: Function, pace?: object, log?: Function }} [deps] @@ -337,6 +409,7 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF // ── the actions, in order ───────────────────────────────────────────────── const rows = []; + const annotations = []; let stoppedAt = null; for (let i = 0; i < payload.actions.length && stoppedAt === null; i++) { const action = payload.actions[i]; @@ -365,6 +438,14 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF break; } log(` ✓ action ${i + 1} ${action.op}: ${call} -> HTTP ${r.status} · ${rows[rows.length - 1].result}`); + // A landed op that creates or moves a card reports its number where a seat CAN read it (header). + const { note, why } = annotationFor({ action: i + 1, op: action.op, req, json: r.json, payload }); + if (note) { + annotations.push(note); + log(annotationCommand(note)); + } else if (why) { + log(` ⚠ action ${i + 1} ${action.op}: no annotation — ${scrub(why, [token])}; a seat reading this run finds the card on the board instead.`); + } } } const notAttempted = stoppedAt ? payload.actions.length - stoppedAt.action : 0; @@ -374,7 +455,7 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF if (stoppedAt) log(`fleet-write/execute: ✗ stopped at action ${stoppedAt.action} (${stoppedAt.op}); ${notAttempted} later action(s) NOT attempted. The board holds what the rows above say landed.`); else log(`fleet-write/execute: ✓ ${rows.length} request(s) landed, every action done.`); if (remedy) log(`fleet-write/execute: remedy — ${remedy}`); - return { exit: stoppedAt ? EXIT_ACTION_FAILED : EXIT_OK, rows, summary, lines, role }; + return { exit: stoppedAt ? EXIT_ACTION_FAILED : EXIT_OK, rows, summary, lines, role, annotations }; } // --------------------------------------------------------------------------- From 3ecae497028f7a8137ab196bb9c8b98ba34a65d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:45:52 +0000 Subject: [PATCH 2/3] wip(pm): readers take the relay run's annotated number first; batteries pin both arms Claude-Session: https://claude.ai/code/session_01FNKm1SmPpuJASnbjxWGtsJ Co-authored-by: Claude --- scripts/pm/fleet-write/dispatch.mjs | 117 ++++++++++++++++++++- scripts/pm/fleet-write/execute.mjs | 85 ++++++++++++++- scripts/pm/issue-create.mjs | 82 +++++++++++---- scripts/pm/issue-transfer.mjs | 157 ++++++++++++++++++++++++---- 4 files changed, 393 insertions(+), 48 deletions(-) diff --git a/scripts/pm/fleet-write/dispatch.mjs b/scripts/pm/fleet-write/dispatch.mjs index 7d6578cb487..d9679330cc5 100644 --- a/scripts/pm/fleet-write/dispatch.mjs +++ b/scripts/pm/fleet-write/dispatch.mjs @@ -1109,10 +1109,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ "the round trip: the card's 41,699 bytes with a multi-byte character across every 16 KiB boundary of every stream, byte for byte through pack, the wire, the runner's env text, the validator and the executor; a per-chunk decode is NOT STORED": 8, 'the read-back end to end: after a success run each body at its locator — a corrupted read-back exits 4 through the CLI, an unreadable or unfound one 6, a body-less stroke reads nothing, never a retry': 17, 'the issue_create re-list: a list that lags the create reads back IDENTICAL on a bounded re-list; a real miss is still unfound (exit 6) after exactly the declared window; an unreadable re-list is unread; the create is never re-sent': 13, + "the run's annotations: after a success run carrying an op that creates or moves a card its check-run annotations are read first — an issue_create they name read back AT that number with NO re-list; absent, unreadable, ignored or past the cap, the re-list as before; a stroke with no such op reads none": 14, 'the wiring: the POST is paced and on the roster, the reads are not, the token never reaches the log': 5, 'the CLI: a dry run sends nothing, usage, the exit ladder, the session derived from the container, a route read behind a dead proxy refuses': 11, }); -const SELF_TEST_BATTERY_FLOOR = 16; +const SELF_TEST_BATTERY_FLOOR = 17; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -1366,7 +1367,14 @@ export async function selfTest() { const T0 = Date.UTC(2026, 8, 22, 9, 4, 0); const at = (ms) => new Date(T0 + ms).toISOString(); const DEFAULT_STORE = { [`GET /repos/objectstack-ai/objectstack/issues/19701/comments`]: { status: 200, json: [{ id: 501, created_at: at(8_000), body: ACTIONS[0].body }] } }; - const platform = ({ dispatch = { status: 204 }, runs = () => [], one = () => null, store = DEFAULT_STORE }, seen, clock) => async (url, init) => { + /** + * The run's one job and its check run's annotations: by default the job answers and its check run carries only the + * runner's own notice (measured on a live relay run) — no relay annotation, so every stroke below that does not + * script `notes` takes the fallback, as a run of a relay older than the emission does. + */ + const JOB = { id: 4200, name: 'Fleet write relay', check_run_url: `https://api.github.test/repos/${RELAY_REPO}/check-runs/4200` }; + const RUNNER_NOTICE = { path: '.github', annotation_level: 'notice', title: '', message: '"The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026."' }; + const platform = ({ dispatch = { status: 204 }, runs = () => [], one = () => null, store = DEFAULT_STORE, jobs = () => ({ status: 200, json: { total_count: 1, jobs: [JOB] } }), notes = () => ({ status: 200, json: [RUNNER_NOTICE] }) }, seen, clock) => async (url, init) => { const u = new URL(url); const call = `${init?.method ?? 'GET'} ${u.pathname}`; seen.push({ call, query: u.search, body: init?.body ? JSON.parse(init.body) : null, auth: init?.headers?.authorization ?? '' }); @@ -1380,6 +1388,16 @@ export async function selfTest() { if (r === 'down') return { status: 503, headers, json: async () => ({ message: 'down' }) }; return { status: 200, headers, json: async () => ({ total_count: r.length, workflow_runs: r }) }; } + const jm = /\/actions\/runs\/(\d+)\/jobs$/.exec(u.pathname); + if (jm) { + const a = jobs(Number(jm[1])); + return { status: a.status, headers, json: async () => a.json }; + } + const am = /\/check-runs\/(\d+)\/annotations$/.exec(u.pathname); + if (am) { + const a = notes(Number(am[1])); + return { status: a.status, headers, json: async () => a.json }; + } const m = /\/actions\/runs\/(\d+)$/.exec(u.pathname); if (m) { const r = one(Number(m[1]), clock.elapsed()); @@ -1722,6 +1740,99 @@ export async function selfTest() { t("the re-list is issue_create's alone: a pull its head does not find is read ONCE and unfound, as before", [noPr.state, noPr.readBack?.rows?.[0]?.cls, pulls], ['unverified', 'unfound', 1]); } + // ── the run's annotations ─────────────────────────────────────────────── + battery("the run's annotations: after a success run carrying an op that creates or moves a card its check-run annotations are read first — an issue_create they name read back AT that number with NO re-list; absent, unreadable, ignored or past the cap, the re-list as before; a stroke with no such op reads none"); + { + const REPO = 'objectstack-ai/objectstack'; + const UI = 'objectstack-ai/objectui'; + const ISSUES = `GET /repos/${REPO}/issues`; + const ONE = `GET /repos/${REPO}/issues/20998`; + const JOBS = `GET /repos/${RELAY_REPO}/actions/runs/42/jobs`; + const NOTES = `GET /repos/${RELAY_REPO}/check-runs/4200/annotations`; + const OK_RUN = { runs: () => [RUN('completed', 'success')] }; + const B = 'Body with a multi-byte tail: no… 全部\n'; + const SPLIT = B.replace('全', '\uFFFD\uFFFD'); + const strokeOf = (actions, repo = REPO) => packRequest({ repo, session: SESSION, actions, requestId: 'fw-test-1' }).payload; + const create = strokeOf([{ op: 'issue_create', title: 'Card', body: B }]); + const CARD = { id: 8, number: 20998, title: 'Card', created_at: at(3_000), body: B }; + const URL98 = `https://github.test/${REPO}/issues/20998`; + /** One annotation as the runner stores the executor's notice: the message the relay's own speller writes. */ + const note = (action, op, number, repo = REPO) => ({ path: '.github', annotation_level: 'notice', title: `fleet-write ${op}`, message: relayAnnotationMessage({ action, op, number, url: `https://github.test/${repo}/issues/${number}` }) }); + const named = (...rows) => () => ({ status: 200, json: [RUNNER_NOTICE, ...rows] }); + const calls = (r, call) => r.seen.filter((x) => x.call === call).length; + const writes = (r) => r.seen.filter((x) => !x.call.startsWith('GET ')).map((x) => x.call); + // The issue list never shows the new card — the measured lag at its worst: only the annotation can find it. + const blindList = { [ISSUES]: { status: 200, json: [] }, [ONE]: { status: 200, json: CARD } }; + + const hit = await drive({ ...OK_RUN, notes: named(note(1, 'issue_create', 20998)), store: blindList }, { stroke: create }); + t( + "⭐ the run's annotation names #20998: read back AT that number — IDENTICAL, success, exit 0 — with ZERO list reads and no wait, though the list never shows it", + [hit.state, hit.readBack?.rows?.[0]?.where, hit.readBack?.rows?.[0]?.cls, hit.readBack?.rows?.[0]?.foundBy, calls(hit, ISSUES), calls(hit, ONE), hit.logs.some((l) => l.includes('re-list')), exitForResult(hit)], + ['success', `${REPO}#20998`, 'identical', 'annotation', 0, 1, false, EXIT_OK], + hit.logs.join(' | '), + ); + const order = hit.seen.map((x) => x.call); + t('…the jobs, then their check run\'s annotations, then the issue — all after the run completed — and ONE write, the dispatch', [order.indexOf(JOBS) > order.lastIndexOf(`GET /repos/${RELAY_REPO}/actions/runs`), order.indexOf(NOTES) > order.indexOf(JOBS), order.indexOf(ONE) > order.indexOf(NOTES), writes(hit)], [true, true, true, [DISPATCH]]); + t("…and the result hands the matched row on for the callers, the runner's own notice passed over", [hit.annotations?.state, hit.annotations?.rows], ['read', [{ action: 1, op: 'issue_create', number: 20998, url: URL98, repo: REPO }]]); + const split = await drive({ ...OK_RUN, notes: named(note(1, 'issue_create', 20998)), store: { ...blindList, [ONE]: { status: 200, json: { ...CARD, body: SPLIT } } } }, { stroke: create }); + t('a split character on the annotated issue is NOT STORED on THAT issue, exit 4', [split.state, split.notStored, split.readBack?.rows?.[0]?.where, exitForResult(split)], ['failure', true, `${REPO}#20998`, EXIT_NOT_STORED]); + const gone = await drive({ ...OK_RUN, notes: named(note(1, 'issue_create', 20998)), store: { ...blindList, [ONE]: { status: 503, json: { message: 'down' } } } }, { stroke: create }); + t('⛔ an annotated number whose issue cannot be read is unverified (unread), exit 6 — and still NO list: the fallback is for an ABSENT annotation only', [gone.state, gone.readBack?.rows?.[0]?.cls, calls(gone, ISSUES), exitForResult(gone)], ['unverified', 'unread', 0, EXIT_UNCONFIRMED]); + + // The fallback, unchanged: absent, unreadable or ignored, the list is read and re-read as before. + const lagReads = []; + const lagging = { [ISSUES]: (q, ms) => { + if ((q.get('page') ?? '1') === '1') lagReads.push(ms); + return { status: 200, json: ms >= 8_000 ? [CARD] : [] }; + } }; + const absent = await drive({ ...OK_RUN, store: lagging }, { stroke: create }); + t('absent (the check run carries only the runner\'s notice): said, and the re-list finds the card as before — success via the list', [absent.state, absent.readBack?.rows?.[0]?.foundBy, lagReads.length, absent.logs.some((l) => l.includes('no annotation on run 42 names action 1 issue_create'))], ['success', 'list', 3, true], absent.logs.join(' | ')); + const unreadable = await drive({ ...OK_RUN, jobs: () => ({ status: 403, json: { message: 'Forbidden' } }), store: { ...blindList, [ISSUES]: { status: 200, json: [CARD] } } }, { stroke: create }); + t('unreadable (the jobs read answers 403): said, naming the call, and the list finds the card — never a failure', [unreadable.state, unreadable.annotations?.state, unreadable.readBack?.rows?.[0]?.foundBy, unreadable.logs.some((l) => l.includes('could not be read') && l.includes(JOBS) && l.includes('HTTP 403'))], ['success', 'unread', 'list', true], unreadable.logs.join(' | ')); + const foreign = await drive({ ...OK_RUN, notes: named(note(1, 'issue_create', 20998, UI)), store: { ...blindList, [ISSUES]: { status: 200, json: [CARD] } } }, { stroke: create }); + t('an annotation whose url names another repository than the create landed on is ignored — said — and the list finds the card', [foreign.state, foreign.annotations?.rows, foreign.readBack?.rows?.[0]?.foundBy, foreign.logs.some((l) => l.includes('is ignored') && l.includes(`names ${UI}`))], ['success', [], 'list', true]); + + // Past the cap: the platform keeps ten notices per step, so a later action may carry none — that one falls back alone. + const CARD2 = { id: 9, number: 20999, title: 'Card', created_at: at(4_000), body: B }; + const two = await drive({ ...OK_RUN, notes: named(note(1, 'issue_create', 20998)), store: { [ONE]: { status: 200, json: CARD }, [ISSUES]: { status: 200, json: [CARD2, CARD] } } }, { stroke: strokeOf([{ op: 'issue_create', title: 'Card', body: B }, { op: 'issue_create', title: 'Card', body: B }]) }); + t('two creates, only the first annotated: the first read at its number, the second found by the list — never the first one\'s issue again', [two.state, two.readBack?.rows?.map((r) => [r.action, r.where, r.foundBy])], ['success', [[1, `${REPO}#20998`, 'annotation'], [2, `${REPO}#20999`, 'list']]]); + + const plain = await drive(OK_RUN, { stroke: strokeOf([{ op: 'labels_add', issue: 1, labels: ['a'] }]) }); + t('a stroke with no op that creates or moves a card reads NO jobs and NO annotations', [plain.state, plain.annotations?.state, calls(plain, JOBS), calls(plain, NOTES)], ['success', 'none', 0, 0]); + const move = await drive({ ...OK_RUN, notes: named(note(1, 'transfer', 31, UI)) }, { stroke: strokeOf([{ op: 'transfer', issue: 7, target_repo: UI }]) }); + t('a transfer stroke reads them (it carries no body, so nothing is read back) and hands its row on for issue-transfer', [move.state, move.readBack?.state, move.annotations?.rows], ['success', 'none', [{ action: 1, op: 'transfer', number: 31, url: `https://github.test/${UI}/issues/31`, repo: UI }]]); + + const P = (action, op, number, repo = REPO) => parseRelayAnnotation(note(action, op, number, repo).message); + const mixed = { repo: REPO, actions: [{ op: 'issue_create', title: 'a', body: 'b' }, { op: 'comment', issue: 1, body: 'c' }, { op: 'issue_create', title: 'd', body: 'e' }] }; + const matched = matchRunAnnotations(mixed, [P(1, 'issue_create', 5), P(1, 'issue_create', 5), P(2, 'issue_create', 6), P(4, 'issue_create', 7), P(3, 'issue_create', 8), P(3, 'issue_create', 9)]); + t( + 'the matcher: an identical repeat is one row; an index holding another op, or no action, is ignored; two DIFFERENT rows for one action are both ignored — a reader never picks between two answers', + [matched.rows.map((r) => [r.action, r.number]), matched.ignored.map((r) => [r.action, r.number])], + [[[1, 5]], [[2, 6], [4, 7], [3, 8], [3, 9]]], + ); + t('a job\'s check run id: from its check_run_url, else its own id, else none', [checkRunIdOf(JOB), checkRunIdOf({ id: 77 }), checkRunIdOf({})], [4200, 77, null]); + + // The CLI: --json carries the numbers a seat could not read before, and how each read-back found its object. + writeFileSync(join(dir, 'create.json'), JSON.stringify([{ op: 'issue_create', title: 'Card', body: B }]), 'utf8'); + let nowMs = T0; + const clock = { now: () => nowMs, elapsed: () => nowMs - T0 }; + const out = []; + const [log, err] = [console.log, console.error]; + console.log = (l) => out.push(String(l)); + console.error = () => {}; + let code; + try { + code = await main(['--repo', REPO, '--actions-file', join(dir, 'create.json'), '--request-id', 'fw-test-1', '--json'], { + env: { GITHUB_TOKEN: TOKEN, [SESSION_ENV]: SESSION }, + send: { fetch: platform({ ...OK_RUN, notes: named(note(1, 'issue_create', 20998)), store: blindList }, [], clock), pace: paceFor(join(dir, `pace-cli-${paceCase++}.jsonl`)), now: clock.now, sleep: async (ms) => { nowMs += ms; }, log: () => {}, ceilings: { startMs: 90_000, ceilingMs: 300_000, pollMs: 5_000, notes: [] } }, + }); + } finally { + [console.log, console.error] = [log, err]; + } + const json = out.length ? JSON.parse(out[out.length - 1]) : null; + t('the CLI: exit 0, and --json names the annotated number and url and that the read-back found it by the annotation', [code, json?.annotations, json?.read_back?.[0]?.found_by], [EXIT_OK, [{ action: 1, op: 'issue_create', number: 20998, url: URL98 }], 'annotation']); + } + // ── the wiring ────────────────────────────────────────────────────────── battery('the wiring: the POST is paced and on the roster, the reads are not, the token never reaches the log'); { @@ -1803,7 +1914,7 @@ export async function selfTest() { `✓ fleet-write/dispatch self-test: ${cases.length} cases pass across ${declared.length} batteries — the transport selector that never guesses, ` + 'the session on the envelope, one paced dispatch per stroke, a run found by its request id and waited to its conclusion, both ceilings answered UNCONFIRMED and never retried, ' + 'and every body read back after a success run — the card\'s 41,699 bytes byte-exact across every 16 KiB boundary, a corrupted read-back NOT STORED (exit 4), an unfound one UNCONFIRMED (6) ' + - 'only after a lagging issue list was re-read on its bounded schedule, the create never re-sent.', + "only after a lagging issue list was re-read on its bounded schedule — unless the run's annotation named the new number, which is read at once and never listed — the create never re-sent.", ); selfTestReachedVerdict = true; return 0; diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index 8b17520d9ca..532d318e726 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -473,9 +473,10 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'redaction: the token reaches no summary line, log line or error': 3, 'the wiring: both halves around every write verb, on the roster': 4, 'the CLI: environment inputs, the payload refusal, the exit ladder': 7, + "the annotations: ONE notice per landed op that creates or moves a card — op, number and url read from the platform's answer, never the request; none for any other op, a failed request or an answer that cannot be reported; the reader's own parser reads back exactly what was emitted": 12, 'the transfer: the sender gated on BOTH repositories, both node ids first, a pull or a moved card refused before the mutation, landing only on the target, the remedy on failure': 11, }); -const SELF_TEST_BATTERY_FLOOR = 11; +const SELF_TEST_BATTERY_FLOOR = 12; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -758,6 +759,85 @@ export async function selfTest() { t('⛔ and the token never reached the throttle\'s log', records.includes(TOKEN), false); } + // ── the annotations ───────────────────────────────────────────────────── + battery("the annotations: ONE notice per landed op that creates or moves a card — op, number and url read from the platform's answer, never the request; none for any other op, a failed request or an answer that cannot be reported; the reader's own parser reads back exactly what was emitted"); + { + const UI = 'objectstack-ai/objectui'; + const notices = (r) => r.logs.filter((l) => l.startsWith('::')); + /** The data half of a workflow command, unescaped the way the runner stores it as the annotation's message. */ + const messageOf = (line) => line.slice(line.indexOf('::', 2) + 2).replace(/%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%'); + const issueUrl = (repo, n) => `https://github.test/${repo}/issues/${n}`; + t('the two tables name exactly the ops that create or move a card: issue_create and transfer', [Object.keys(ANNOTATION_ANSWERS).sort(), Object.keys(ANNOTATED_OPS).sort()], [['issue_create', 'transfer'], ['issue_create', 'transfer']]); + + const created = await run(base([{ op: 'issue_create', title: 'I', body: 'B' }]), { ...allowed, [`POST /repos/${REPO}/issues`]: { status: 201, json: { number: 10, html_url: issueUrl(REPO, 10) } } }, { file: paceFile }); + t( + 'a landed issue_create prints exactly ONE workflow command: a notice titled `fleet-write issue_create` whose message names the action, op, number and url', + [created.exit, notices(created)], + [EXIT_OK, [`::notice title=fleet-write issue_create::fleet-write action=1 op=issue_create number=10 url=${issueUrl(REPO, 10)}`]], + created.logs.join(' | '), + ); + const back = parseRelayAnnotation(messageOf(notices(created)[0] ?? '')); + t("…which the reader's own parser reads back as exactly what the run returns as emitted", [back, created.annotations], [{ action: 1, op: 'issue_create', number: 10, url: issueUrl(REPO, 10), repo: REPO }, [{ action: 1, op: 'issue_create', number: 10, url: issueUrl(REPO, 10), repo: REPO }]]); + + const moved = await run(base([{ op: 'transfer', issue: 7, target_repo: UI }]), { + ...allowed, + [`GET /repos/${UI}/collaborators/os-support-ai/permission`]: { status: 200, json: { permission: 'write', role_name: 'write' } }, + [`GET /repos/${REPO}/issues/7`]: { status: 200, json: { number: 7, node_id: 'I_7', repository_url: `https://api.github.test/repos/${REPO}` } }, + [`GET /repos/${UI}`]: { status: 200, json: { node_id: 'R_ui' } }, + 'POST /graphql': { status: 200, json: { data: { transferIssue: { issue: { number: 31, url: issueUrl(UI, 31), repository: { nameWithOwner: UI } } } } } }, + }, { file: paceFile }); + t("⛔ a transfer's notice carries the number and url the transferIssue ANSWER placed on the target — #31 on the target, never the request's #7", [moved.exit, moved.annotations, notices(moved).length], [EXIT_OK, [{ action: 1, op: 'transfer', number: 31, url: issueUrl(UI, 31), repo: UI }], 1], moved.logs.join(' | ')); + + const quiet = await run(base([ + { op: 'comment', issue: 1, body: 'a' }, + { op: 'labels_add', issue: 1, labels: ['x'] }, + { op: 'issue_patch', issue: 1, state: 'closed' }, + { op: 'pr_create', title: 'T', head: 'h', base: 'main' }, + ]), { + ...allowed, + [`POST /repos/${REPO}/issues/1/comments`]: { status: 201, json: { id: 1, html_url: issueUrl(REPO, 1) } }, + [`POST /repos/${REPO}/issues/1/labels`]: { status: 200, json: [{ name: 'x' }] }, + [`PATCH /repos/${REPO}/issues/1`]: { status: 200, json: { number: 1, html_url: issueUrl(REPO, 1) } }, + [`POST /repos/${REPO}/pulls`]: { status: 201, json: { number: 9, html_url: `https://github.test/${REPO}/pull/9` } }, + }, { file: paceFile }); + t('no other op emits one — a comment, labels, a patch and a NEW PULL REQUEST all landed, zero workflow commands, zero notes', [quiet.exit, notices(quiet), quiet.annotations], [EXIT_OK, [], []]); + + const mixed = await run(base([{ op: 'issue_create', title: 'A', body: 'a' }, { op: 'comment', issue: 1, body: 'c' }, { op: 'issue_create', title: 'B', body: 'b' }]), { + ...allowed, + [`POST /repos/${REPO}/issues`]: { status: 201, json: { number: 11, html_url: issueUrl(REPO, 11) } }, + [`POST /repos/${REPO}/issues/1/comments`]: { status: 201, json: { id: 2 } }, + }, { file: paceFile }); + t('one per annotated action, in order, each naming its OWN action index — 1 and 3 around the comment', mixed.annotations.map((a) => [a.action, a.op]), [[1, 'issue_create'], [3, 'issue_create']]); + + const refused = await run(base([{ op: 'issue_create', title: 'I', body: 'B' }]), { ...allowed, [`POST /repos/${REPO}/issues`]: { status: 403, json: { message: 'Resource not accessible by integration' } } }); + t('a create the platform refused emits nothing', [refused.exit, notices(refused), refused.annotations], [EXIT_ACTION_FAILED, [], []]); + const blank = await run(base([{ op: 'issue_create', title: 'I', body: 'B' }]), { ...allowed, [`POST /repos/${REPO}/issues`]: { status: 201, json: {} } }); + t('an answer that carries no number and url emits nothing, says so, and the action still landed', [blank.exit, notices(blank), blank.logs.some((l) => l.includes('no annotation — the answer carries no number and url'))], [EXIT_OK, [], true]); + const elsewhere = await run(base([{ op: 'issue_create', title: 'I', body: 'B' }]), { ...allowed, [`POST /repos/${REPO}/issues`]: { status: 201, json: { number: 12, html_url: issueUrl(UI, 12) } } }); + t('⛔ an answer whose url names another repository than the op lands on emits nothing, naming it', [notices(elsewhere), elsewhere.logs.some((l) => l.includes(`names ${UI}, not ${REPO}`))], [[], true]); + + t( + "the command's escapes are actions/toolkit's: data escapes %, CR and LF; a property also : and ,", + [escapeCommandData('a%b\r\nc'), escapeCommandProperty('t: a,b%'), annotationCommand({ action: 2, op: 'transfer', number: 5, url: 'https://github.test/o/r/issues/5?x=%41' })], + ['a%25b%0D%0Ac', 't%3A a%2Cb%25', '::notice title=fleet-write transfer::fleet-write action=2 op=transfer number=5 url=https://github.test/o/r/issues/5?x=%2541'], + ); + // The other annotations a relay run carries, measured on a live run's check run: the runner's image notice and an action's deprecation warning. + t( + "the parser reads ONLY the relay's own message: the runner's notices, a deprecation warning, an op outside the table, or a url that disagrees with its number are null", + [ + parseRelayAnnotation('"The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026."'), + parseRelayAnnotation("Input 'app-id' has been deprecated with message: Use 'client-id' instead."), + parseRelayAnnotation(relayAnnotationMessage({ action: 1, op: 'pr_create', number: 9, url: issueUrl(REPO, 9) })), + parseRelayAnnotation(relayAnnotationMessage({ action: 1, op: 'issue_create', number: 9, url: issueUrl(REPO, 8) })), + parseRelayAnnotation(`${relayAnnotationMessage({ action: 1, op: 'issue_create', number: 9, url: issueUrl(REPO, 9) })} trailing`), + parseRelayAnnotation(null), + ], + [null, null, null, null, null, null], + ); + // The needle is assembled so this line does not match itself: a pattern for the message would spell its number group. + t('⛔ the parser is the reader\'s, imported — this file spells no pattern for the message of its own', [readReal(SELF_PATH, 'utf8').includes(['number=', '(['].join('')), typeof parseRelayAnnotation], [false, 'function']); + } + // ── the CLI ───────────────────────────────────────────────────────────── battery('the CLI: environment inputs, the payload refusal, the exit ladder'); { @@ -814,7 +894,8 @@ export async function selfTest() { console.log( `✓ fleet-write/execute self-test: ${cases.length} cases pass across ${declared.length} batteries — the sender gate from the target repo's answer, ` + 'every op as the request the table declares, stop at the first failure with later actions untouched, the idempotent label DELETE, the GraphQL ' + - 'ops behind a node-id read, a pull mutation landing only when its answer shows the state it asked for (armed or queued, off, ready, draft), a transfer gated on both repositories and landing only on its target, one summary row per request, and a known ' + + 'ops behind a node-id read, a pull mutation landing only when its answer shows the state it asked for (armed or queued, off, ready, draft), a transfer gated on both repositories and landing only on its target, one summary row per request, ' + + "one annotation per created or moved card carrying the number the platform answered, read back by the reader's own parser, and a known " + 'token that came back out of NO summary, log or error.', ); selfTestReachedVerdict = true; diff --git a/scripts/pm/issue-create.mjs b/scripts/pm/issue-create.mjs index df6343ac168..fc478c11141 100644 --- a/scripts/pm/issue-create.mjs +++ b/scripts/pm/issue-create.mjs @@ -44,9 +44,13 @@ * it as `objectstack-fleet[bot]` — a cloud seat container's proxy replaces the * Authorization header, so that is the only way it can create as the fleet. * `auto` (the default) takes `dispatch` there and `direct` elsewhere, and says - * which. Under the relay the new card's number is found by READING it back: + * which. Under the relay the new card's number is the one the relay run's + * annotation carries — the platform's own answer to the create, reported on + * the job's check run because a seat container reads neither the run's log + * nor its summary (`fleet-write/dispatch.mjs`, "The run's annotations"). + * Only when no annotation names it is the number found by READING the board: * the newest issue on the target created at or after the dispatch whose title - * is the one sent — then the same read-back as the direct path. + * is the one sent. Either way, then the same read-back as the direct path. * * ## Read-back * @@ -78,7 +82,7 @@ import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../invoked-as.mjs'; import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, proxyRearmPlan, resolveSweepRepo } from './check-half-states.mjs'; -import { EXIT_UNCONFIRMED, exitForResult, fallbackText, packRequest, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs'; +import { EXIT_UNCONFIRMED, exitForResult, fallbackText, matchRunAnnotations, packRequest, parseRelayAnnotation, relayAnnotationMessage, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs'; import { refusalText as relayRefusalText } from './fleet-write/validate.mjs'; import { classifyHttp } from './label-write.mjs'; import { EXIT_WRITE_PACE_REFUSED, isWriteMethod, noteResponse, paceWrite, releaseWriteLease } from './write-pace.mjs'; @@ -311,23 +315,31 @@ export async function createIssue(plan, deps = {}) { const sent = await (deps.send ?? sendFleetWrite)(packed.payload, { token, log: (line) => lines.push(` ${line}`) }); if (sent.ok) { relay = sent; - // The relay does not hand the number back; the board does. The newest - // issue on the target created at or after the dispatch carrying the title - // sent is the one — and the ordinary read-back below then judges it. - const since = new Date(dispatchedAt - 60_000).toISOString(); - const listed = await rest(`/repos/${plan.repo}/issues?state=all&sort=created&direction=desc&per_page=30&since=${encodeURIComponent(since)}`, {}, deps); - const hit = (Array.isArray(listed.json) ? listed.json : []).find((i) => !i.pull_request && String(i.title ?? '').trim() === plan.payload.title && Date.parse(i.created_at) >= dispatchedAt - 60_000); - if (listed.status !== 200 || !hit) { - lines.push( - `✗ issue-create: UNCONFIRMED — the relay run ${sent.run?.url ?? sent.run?.id ?? ''} completed, but ${listed.status !== 200 ? `${listed.call} → HTTP ${listed.status}` : 'no issue created since the dispatch carries the title sent'}. ` + - `Go READ the board; ⛔ do not re-run blind — a second dispatch is a second card. Exit ${EXIT_UNCONFIRMED}.`, - ); - return { exitCode: EXIT_UNCONFIRMED, number: null, url: null, author: null, lines, transport: route.transport, relay }; + // The number the relay run's annotation carries — the platform's own answer to the create, matched by + // `sendFleetWrite` to this stroke's one action and to this repository — is read first (header). + const named = (sent.annotations?.rows ?? []).find((a) => a.op === 'issue_create' && a.action === 1) ?? null; + if (named) { + number = named.number; + url = named.url; + lines.push(`✓ issue-create: created #${number} ${url} — via the relay run ${sent.run?.url ?? sent.run?.id ?? ''}, the number from the run's annotation`); + } else { + // No annotation names it, so the board does: the newest issue on the target created at or after the + // dispatch carrying the title sent is the one — and the ordinary read-back below then judges it. + const since = new Date(dispatchedAt - 60_000).toISOString(); + const listed = await rest(`/repos/${plan.repo}/issues?state=all&sort=created&direction=desc&per_page=30&since=${encodeURIComponent(since)}`, {}, deps); + const hit = (Array.isArray(listed.json) ? listed.json : []).find((i) => !i.pull_request && String(i.title ?? '').trim() === plan.payload.title && Date.parse(i.created_at) >= dispatchedAt - 60_000); + if (listed.status !== 200 || !hit) { + lines.push( + `✗ issue-create: UNCONFIRMED — the relay run ${sent.run?.url ?? sent.run?.id ?? ''} completed, but ${listed.status !== 200 ? `${listed.call} → HTTP ${listed.status}` : 'no issue created since the dispatch carries the title sent'}. ` + + `Go READ the board; ⛔ do not re-run blind — a second dispatch is a second card. Exit ${EXIT_UNCONFIRMED}.`, + ); + return { exitCode: EXIT_UNCONFIRMED, number: null, url: null, author: null, lines, transport: route.transport, relay }; + } + number = hit.number; + url = hit.html_url ?? null; + author = hit.user?.login ?? null; + lines.push(`✓ issue-create: created #${number}${url ? ` ${url}` : ''}${author ? ` (as ${author})` : ''} — via the relay run ${sent.run?.url ?? sent.run?.id ?? ''}`); } - number = hit.number; - url = hit.html_url ?? null; - author = hit.user?.login ?? null; - lines.push(`✓ issue-create: created #${number}${url ? ` ${url}` : ''}${author ? ` (as ${author})` : ''} — via the relay run ${sent.run?.url ?? sent.run?.id ?? ''}`); } else if (route.requested === 'auto' && sent.state === 'no-run') { lines.push(` ${fallbackText(sent, 'issue-create')}`); } else if (sent.state === 'no-run' || sent.state === 'timeout') { @@ -359,6 +371,8 @@ export async function createIssue(plan, deps = {}) { lines.push(`✗ issue-create: the read-back ${back.call} → HTTP ${back.status}${back.detail ? ` — ${back.detail}` : ''}; #${number} exists but could not be verified.`); return { exitCode: EXIT_READ_BACK_MISMATCH, number, url, author, lines }; } + // The author the card answers with, when nothing named it before (a number from the relay run's annotation). + author = author ?? back.json.user?.login ?? null; const gotTitle = String(back.json.title ?? '').trim(); if (gotTitle !== plan.payload.title) { lines.push(`✗ issue-create: read-back of #${number} disagrees — title ${JSON.stringify(gotTitle)}, wanted ${JSON.stringify(plan.payload.title)}.`); @@ -379,8 +393,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'dry-run: no request leaves, and the plan is printed': 3, 'the wiring: both halves around the one POST, on the write verb only': 4, 'the relay transport: ONE dispatch carrying the create, the card found by title since the dispatch, auto falls back only on no-run': 8, + "the relay annotation: the number the relay run's annotation carries is read first — the card read back at it with NO list; absent, the title finds it as before; the read-back still judges the annotated card": 5, }); -const SELF_TEST_BATTERY_FLOOR = 6; +const SELF_TEST_BATTERY_FLOOR = 7; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -559,6 +574,30 @@ export async function selfTest() { t('under AUTO, no run falls back to the direct POST — said out loud — and the card is created and read back', [noRunAuto.exitCode, noRunAuto.number, noRunAuto.lines.some((l) => l.includes('Falling back to DIRECT')), noRunAuto.seen.map((s) => s.call)], [EXIT_OK, 12, true, [`POST /repos/${ORG_REPO}/issues`, `GET /repos/${ORG_REPO}/issues/12`]]); const failedRun = await drive(orgHappy, { plan: orgPlan, route: dispatchRoute('auto'), send: outcome('failure', { run: { ...RUN, conclusion: 'failure' }, detail: 'conclusion failure' }) }); t('⛔ a run that FAILED is never fallen back from, even under auto: exit 5, no POST', [failedRun.exitCode, failedRun.seen.length], [EXIT_PLATFORM_REFUSAL, 0]); + + // ── the relay annotation ────────────────────────────────────────────── + battery("the relay annotation: the number the relay run's annotation carries is read first — the card read back at it with NO list; absent, the title finds it as before; the read-back still judges the annotated card"); + { + const URL12 = `https://github.test/${ORG_REPO}/issues/12`; + // The run's annotation, spelled and parsed by the relay's own pair and matched by its own matcher — what `sendFleetWrite` hands on. + const annotated = (number = 12) => async (p) => { + const parsed = [parseRelayAnnotation(relayAnnotationMessage({ action: 1, op: 'issue_create', number, url: `https://github.test/${ORG_REPO}/issues/${number}` }))]; + const { rows, ignored } = matchRunAnnotations(p, parsed); + return outcome('success', { annotations: { state: 'read', rows, ignored, why: '' } })(p); + }; + const withBot = { ...orgHappy, [`GET /repos/${ORG_REPO}/issues/12`]: { status: 200, json: { number: 12, title: orgPlan.payload.title, state: 'open', labels: [{ name: 'pm:queue' }], user: { login: 'objectstack-fleet[bot]' } } } }; + // A list that would NOT find the card (the measured lag): only the annotation can. + const lagging = listing([{ ...created, number: 13, title: 'another card' }]); + const named = await drive({ ...withBot, ...lagging }, { plan: orgPlan, route: dispatchRoute(), now: () => NOW, send: annotated() }); + t("⭐ the annotation names #12: exit 0 on #12 with its url, read back once — and the issue list is NEVER read, so its lag cannot reach this", [named.exitCode, named.number, named.url, named.seen.map((s) => s.call)], [EXIT_OK, 12, URL12, [`GET /repos/${ORG_REPO}/issues/12`]], named.lines.join(' | ')); + t('…the transcript says the number came from the run\'s annotation, and the author is read off the card', [named.lines.some((l) => l.includes('created #12') && l.includes("the number from the run's annotation")), named.author], [true, 'objectstack-fleet[bot]']); + const control = await drive({ ...withBot, ...lagging }, { plan: orgPlan, route: dispatchRoute(), now: () => NOW, send: outcome('success', { annotations: { state: 'read', rows: [], ignored: [], why: '' } }) }); + t('the control — no annotation, the same lagging list — is UNCONFIRMED (6): exactly what the annotation spares', [control.exitCode, control.seen.map((s) => s.call)], [EXIT_UNCONFIRMED, [`GET /repos/${ORG_REPO}/issues`]]); + const absent = await drive({ ...orgHappy, ...listing([created]) }, { plan: orgPlan, route: dispatchRoute(), now: () => NOW, send: outcome('success', { annotations: { state: 'unread', rows: [], ignored: [], why: 'GET /jobs -> HTTP 403' } }) }); + t('absent (the annotations unreadable): the title finds the card on the list, exactly as before', [absent.exitCode, absent.number, absent.seen.map((s) => s.call)], [EXIT_OK, 12, [`GET /repos/${ORG_REPO}/issues`, `GET /repos/${ORG_REPO}/issues/12`]]); + const disagrees = await drive({ ...orgHappy, [`GET /repos/${ORG_REPO}/issues/12`]: { status: 200, json: { number: 12, title: 'something else' } } }, { plan: orgPlan, route: dispatchRoute(), now: () => NOW, send: annotated() }); + t('the annotated card is still judged: another title on #12 is exit 4, the number printed', [disagrees.exitCode, disagrees.number], [EXIT_READ_BACK_MISMATCH, 12]); + } } // ── the wiring ────────────────────────────────────────────────────────── @@ -606,7 +645,8 @@ export async function selfTest() { } console.log( `✓ issue-create self-test: ${cases.length} cases pass across ${declared.length} batteries — prose from files and never argv, ` + - 'one title source, a fake platform through every exit code, a dry run that sends nothing, and both halves of the throttle around the one POST.', + "one title source, a fake platform through every exit code, the relay run's annotation read first for the new number, a dry run that sends nothing, " + + 'and both halves of the throttle around the one POST.', ); selfTestReachedVerdict = true; return 0; diff --git a/scripts/pm/issue-transfer.mjs b/scripts/pm/issue-transfer.mjs index 7cd9be392f5..ece83575401 100644 --- a/scripts/pm/issue-transfer.mjs +++ b/scripts/pm/issue-transfer.mjs @@ -51,13 +51,16 @@ * * The transfer is confirmed when the card answers from the target with the * title the pre-read saw. The card is found by its number when one is in - * hand — the number the `transferIssue` answer carried (direct), else the - * number the old URL's 301 names — and otherwise (the relay, whose run prints - * the number only to a job log a seat container cannot read) by that title - * among the target's cards updated since the transfer was sent; two such - * cards are not a confirmation. `GET /repos/{source}/issues/{n}` WITHOUT - * following redirects is read first, and its answer is printed beside the - * verdict, never instead of it: + * hand — the number the `transferIssue` answer carried: directly (direct), or + * as the relay run's ANNOTATION reports it (`fleet-write/dispatch.mjs`, "The + * run's annotations": the executor's copy of that same answer, read from the + * job's check run because a seat container reads neither its log nor its + * summary) — else the number the old URL's 301 names, and otherwise by that + * title among the target's cards updated since the transfer was sent; two + * such cards are not a confirmation. So under the relay the order is the + * annotation, then the 301, then the title. `GET /repos/{source}/issues/{n}` + * WITHOUT following redirects is read first, and its answer is printed beside + * the verdict, never instead of it: * * - a 301 naming the card corroborates it; one naming another card, or * another repository, is the board disagreeing; @@ -77,9 +80,15 @@ * target still lacks the card AND the old URL, read again, still serves it * from the source. A target that cannot be read at all — a cloud session * reads only the repositories attached to it, and answers 403 for the rest — - * ends the wait at once, UNCONFIRMED. Labels with no same-named label on the - * target are dropped by the platform (the relay never asks it to create - * them); the read-back prints what stayed. + * ends the wait at once, UNCONFIRMED; with ONE exception: when the number came + * from the relay run's annotation and the target answers 403 (not a rate + * limit), the annotation IS the platform's answer to the mutation, so the + * transfer is confirmed by it — exit 0, `confirmed_by: relay-annotation`, the + * target URL the annotation carries, and the old URL's reading printed as + * corroboration (a pending redirect too). A 404 or another title on the + * target keeps its exit 4 / 6 whatever named the number. Labels with no + * same-named label on the target are dropped by the platform (the relay + * never asks it to create them); the read-back prints what stayed. * * ## When the platform refuses — fail closed, name the remedy * @@ -97,7 +106,9 @@ * * 0 transferred, and confirmed on the target: the card answers there with * the title the pre-read saw, at the URL printed — whether the old URL - * already redirects or is still a pending redirect. + * already redirects or is still a pending redirect. Or, under the relay + * in a session the target answers 403: confirmed by the run's annotation + * (`confirmed_by: relay-annotation`), the old URL printed beside it. * 2 usage, or a refusal above. Nothing was transferred. * 3 PREREQUISITE NOT MET — no token, the platform unreachable, the * credential rate-limit exhausted, or no route. Nothing was transferred. @@ -120,7 +131,7 @@ import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../invoked-as.mjs'; import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, proxyRearmPlan, resolveSweepRepo } from './check-half-states.mjs'; -import { EXIT_UNCONFIRMED, READ_BACK_SLACK_MS, exitForResult, fallbackText, packRequest, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs'; +import { EXIT_UNCONFIRMED, READ_BACK_SLACK_MS, exitForResult, fallbackText, matchRunAnnotations, packRequest, parseRelayAnnotation, relayAnnotationMessage, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs'; import { repoOfIssue, requestLanded } from './fleet-write/execute.mjs'; import { OPS, TARGET_OWNER, TARGET_REPO_SHAPE, TRANSFER_TARGETS, transferRemedy } from './fleet-write/ops.mjs'; import { refusalText as relayRefusalText, validateStroke } from './fleet-write/validate.mjs'; @@ -202,6 +213,11 @@ export function dryRunText(plan) { ].join('\n'); } +/** What `--json` prints for a result that names a card. Pure. */ +export function jsonLine(result) { + return { from: result.from, to: result.to, transport: result.transport ?? null, relay_run: result.relay?.run?.url ?? null, pending_redirect: result.pendingRedirect ?? null, confirmed_by: result.confirmedBy ?? null, exit: result.exitCode }; +} + /** The exit a transport verdict maps to. `null` means "carry on". */ export function exitForVerdict(verdict) { if (verdict === 'ok') return null; @@ -305,9 +321,10 @@ async function rest(path, { method = 'GET', body = null, redirect = 'follow' } = /** * Transfer the card the plan names, read it back, and say what happened. - * Returns `{ exitCode, from, to, lines, transport, relay }` — plus - * `pendingRedirect` on a success, true while the old URL still serves the - * card; never throws on a status. `deps.sleep` and `deps.now` serve the + * Returns `{ exitCode, from, to, lines, transport, relay }` — plus, on a + * success, `pendingRedirect` (true while the old URL still serves the card) + * and `confirmedBy`: `target` (read back there) or `relay-annotation` (the + * run's annotation, the target answering 403); never throws on a status. `deps.sleep` and `deps.now` serve the * read-back's bounded re-read alone (`TRANSFER_READ_BACK_DELAYS_MS`). */ export async function transferIssue(plan, deps = {}) { @@ -351,6 +368,8 @@ export async function transferIssue(plan, deps = {}) { // ── the transfer ────────────────────────────────────────────────────────── const sentAt = (deps.now ?? Date.now)(); let claimed = null; + // The relay run's annotation for this transfer — the platform's own transferIssue answer, as the executor read it. + let annotated = null; if (route.transport === 'dispatch') { const packed = packRequest({ repo: plan.repo, session: route.session, actions: [plan.action] }); if (!packed.ok) { @@ -361,6 +380,14 @@ export async function transferIssue(plan, deps = {}) { if (sent.ok) { ctx.relay = sent; lines.push(` issue-transfer: the relay run ${sent.run?.url ?? sent.run?.id ?? ''} completed — reading the card back.`); + // The annotation first (header): `sendFleetWrite` matched it to this stroke's one action and to the target. + annotated = (sent.annotations?.rows ?? []).find((a) => a.op === plan.action.op && a.action === 1) ?? null; + if (annotated) { + claimed = annotated.number; + lines.push(` issue-transfer: the relay run's annotation names ${annotated.repo}#${annotated.number} ${annotated.url} — the platform's own transferIssue answer; the card is read there first.`); + } else { + lines.push(" issue-transfer: no annotation on the relay run names this transfer — the number comes from the old URL's 301, else the title finds the card."); + } } else if (route.requested === 'auto' && sent.state === 'no-run') { lines.push(` ${fallbackText(sent, 'issue-transfer')}`); } else if (sent.state === 'no-run' || sent.state === 'timeout') { @@ -432,7 +459,7 @@ export async function transferIssue(plan, deps = {}) { return done(EXIT_BOARD_DISAGREES, { to: { repo: old.repo, number: old.number, url: null } }); } if (claimed !== null && claimed !== old.number) { - lines.push(`✗ issue-transfer: the board disagrees — the mutation answered #${claimed}, the old URL redirects to #${old.number}.`); + lines.push(`✗ issue-transfer: the board disagrees — ${annotated ? "the relay run's annotation names" : 'the mutation answered'} #${claimed}, the old URL redirects to #${old.number}.`); return done(EXIT_BOARD_DISAGREES, { to: { repo: plan.to, number: old.number, url: null } }); } number = old.number; @@ -463,7 +490,17 @@ export async function transferIssue(plan, deps = {}) { found = await look(); if (found.state === 'answered') lines.push(` issue-transfer: ${plan.to}#${found.card.number ?? number} found on re-read ${i + 1}, ${waited} ms after the first read.`); } - const known = number !== null ? { to: { repo: plan.to, number, url: null } } : {}; + const known = number !== null ? { to: { repo: plan.to, number, url: annotated && annotated.number === number ? annotated.url : null } } : {}; + // The one exception to "unreadable is UNCONFIRMED" (header): the number is the relay run's annotation — the platform's + // answer to the mutation — and the target refused THIS session (403, not an exhausted rate limit). + if (found.state === 'unread' && annotated && annotated.number === number && found.r.status === 403 && found.r.rateRemaining !== 0) { + lines.push(`✓ issue-transfer: ${plan.repo}#${plan.issue} → ${plan.to}#${number} ${annotated.url}`); + lines.push( + ` confirmed_by: relay-annotation — the relay run ${ctx.relay?.run?.url ?? ctx.relay?.run?.id ?? ''} reported #${number} ${annotated.url} from the platform's transferIssue answer; ` + + `the target was not read back: ${found.r.call} answered HTTP 403 to this session${said(found.r)}; ${oldAccount(old)}.`, + ); + return done(EXIT_OK, { to: { repo: plan.to, number, url: annotated.url }, pendingRedirect: pendingAt(old), confirmedBy: 'relay-annotation' }); + } if (found.state === 'unread') { return unconfirmed(`the target could not be read — ${found.r.call} answered HTTP ${found.r.status}${said(found.r)}; ${oldAccount(old)}`, known); } @@ -499,9 +536,10 @@ export async function transferIssue(plan, deps = {}) { lines.push( ` read-back: #${cardNumber} answers from ${plan.to} with the same title${number === null ? ' (found by that title: no number was in hand)' : ''}; ${oldAccount(old)}` + `; labels kept: ${labelsAfter.join(', ') || '(none)'}` + - `${dropped.length ? `; dropped (no same-named label on the target): ${dropped.join(', ')}` : ''}.`, + `${dropped.length ? `; dropped (no same-named label on the target): ${dropped.join(', ')}` : ''}` + + `${annotated && annotated.number === cardNumber ? "; its number came from the relay run's annotation" : ''}.`, ); - return done(EXIT_OK, { to, pendingRedirect: pending }); + return done(EXIT_OK, { to, pendingRedirect: pending, confirmedBy: 'target' }); } // --------------------------------------------------------------------------- @@ -516,10 +554,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the read-back: the old URL answers 301 to the new card, which answers from the target with the same title': 9, 'the relay transport: ONE dispatch carrying ONE transfer, the new number read from the redirect, a failed run names the remedy and is never fallen back from': 8, 'the target decides: a card the target answers is a transfer even while the old URL still serves it (a pending redirect: exit 0, the target URL); exit 4 only when the target still lacks it after the bounded re-read AND the old URL is unchanged; an unreadable or ambiguous target is UNCONFIRMED; the transfer is never re-sent': 16, + "the relay annotation: the number the relay run's annotation carries is read first — the card confirmed on the target with no title search; a target that answers this session 403 is still exit 0, confirmed_by relay-annotation, the old URL its corroboration; a 404, another title or another number keeps exit 4 / 6; absent, the 301 and the title as before": 12, 'dry-run: no request leaves, and the plan is printed': 3, 'the wiring: both halves around the one write verb, on the roster': 4, }); -const SELF_TEST_BATTERY_FLOOR = 8; +const SELF_TEST_BATTERY_FLOOR = 9; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -851,6 +890,79 @@ export async function selfTest() { t('relay: the target still lacks the card after the window, but the old URL now answers 301 — UNCONFIRMED (6), not exit 4: the source is no longer unchanged', [turn.exitCode, turn.text.includes('did not move'), turn.text.includes('answers 301 naming no card')], [EXIT_UNCONFIRMED, false, true], turn.text); } + // ── the relay annotation ──────────────────────────────────────────────── + battery("the relay annotation: the number the relay run's annotation carries is read first — the card confirmed on the target with no title search; a target that answers this session 403 is still exit 0, confirmed_by relay-annotation, the old URL its corroboration; a 404, another title or another number keeps exit 4 / 6; absent, the 301 and the title as before"); + { + const DELAYS = TRANSFER_READ_BACK_DELAYS_MS; + const calls = (r, call) => r.seen.filter((x) => x.call === call).length; + const T31 = `GET /repos/${UI}/issues/31`; + const LIST = `GET /repos/${UI}/issues`; + const URL31 = `https://github.test/${UI}/issues/31`; + const GATE = 'GitHub access to this repository is not enabled for this session. Use add_repo to request access.'; + // A relay run that reports the transfer through its annotation — spelled and parsed by the relay's own pair, + // matched to the stroke by the relay's own matcher, exactly as `sendFleetWrite` hands it on. + const annotatedRun = (b, number = 31, { moves = true } = {}) => async (payload) => { + if (moves) b.state.moved = true; + const parsed = [parseRelayAnnotation(relayAnnotationMessage({ action: 1, op: 'transfer', number, url: `https://github.test/${UI}/issues/${number}` }))]; + const { rows, ignored } = matchRunAnnotations(payload, parsed); + return { state: 'success', ok: true, status: 204, verdict: 'ok', requestId: payload.request_id, startMs: 1, ceilingMs: 2, run: RUN, detail: '', annotations: { state: 'read', rows, ignored, why: '' } }; + }; + const relayed = (b, send) => drive(b, { route: dispatchRoute(), send: send ?? annotatedRun(b) }); + + const b1 = board(); + const one = await relayed(b1); + t( + "the annotation's number is read first: #31 read once on the target, ZERO title searches, no wait — exit 0, confirmed by the target", + [one.exitCode, one.to, one.confirmedBy, calls(one, T31), calls(one, LIST), one.sleeps, posts(one.seen).length], + [EXIT_OK, { repo: UI, number: 31, url: URL31 }, 'target', 1, 0, [], 0], + one.text, + ); + t('…the transcript names the annotation, and the read-back line says where the number came from', [one.text.includes(`the relay run's annotation names ${UI}#31 ${URL31}`), one.text.includes("its number came from the relay run's annotation")], [true, true], one.text); + const b2 = board(); + const pending = await relayed(lagOld(b2), annotatedRun(b2)); + t('an old URL still answering 200 from the source beside it: exit 0, a PENDING REDIRECT, still no title search', [pending.exitCode, pending.pendingRedirect, pending.text.includes('PENDING REDIRECT'), calls(pending, LIST)], [EXIT_OK, true, true, 0], pending.text); + + // ⭐ The PM ruling's exit: a session that cannot read the target. + const gate = (b) => withAnswers(lagOld(b), { [T31]: () => ({ status: 403, json: { message: GATE } }), [LIST]: () => ({ status: 403, json: { message: GATE } }) }); + const b3 = board(); + const gated = await relayed(gate(b3), annotatedRun(b3)); + t( + "⭐ the target answers this session 403: exit 0, confirmed_by relay-annotation, the target URL the annotation carries — not re-read, no title search", + [gated.exitCode, gated.confirmedBy, gated.to, gated.sleeps, calls(gated, T31), calls(gated, LIST)], + [EXIT_OK, 'relay-annotation', { repo: UI, number: 31, url: URL31 }, [], 1, 0], + gated.text, + ); + t( + "…printing `confirmed_by: relay-annotation`, the platform's 403 sentence, and the old URL as corroboration (here a PENDING REDIRECT)", + [gated.text.includes('confirmed_by: relay-annotation'), gated.text.includes(GATE), gated.text.includes('PENDING REDIRECT'), gated.pendingRedirect, gated.text.includes(`✓ issue-transfer: ${SRC}#7 → ${UI}#31 ${URL31}`)], + [true, true, true, true, true], + gated.text, + ); + t('…and --json carries the confirmation: confirmed_by relay-annotation, the target URL, the pending redirect', [jsonLine(gated).confirmed_by, jsonLine(gated).to, jsonLine(gated).pending_redirect, jsonLine(one).confirmed_by], ['relay-annotation', { repo: UI, number: 31, url: URL31 }, true, 'target']); + const b4 = board(); + const bare = await relayed(gate(b4), outcome('success', b4, { annotations: { state: 'read', rows: [], ignored: [], why: '' } })); + t('the control: the same 403 with NO annotation stays UNCONFIRMED (6) — the exception is the annotation\'s alone', [bare.exitCode, bare.confirmedBy ?? null, bare.text.includes('no annotation on the relay run names this transfer')], [EXIT_UNCONFIRMED, null, true], bare.text); + const b5 = board(); + const limited = await relayed(withAnswers(lagOld(b5), { [T31]: () => ({ status: 403, headers: { 'x-ratelimit-remaining': '0' }, json: { message: 'API rate limit exceeded' } }) }), annotatedRun(b5)); + t('a 403 that is an EXHAUSTED rate limit is not that exception: UNCONFIRMED (6), carrying the annotated number and url', [limited.exitCode, limited.to], [EXIT_UNCONFIRMED, { repo: UI, number: 31, url: URL31 }], limited.text); + + // A 404, another title, another number: the board's own exits stand, whatever named the number. + const b6 = board(); + const absent = await relayed(withAnswers(b6, { [T31]: () => ({ status: 404, json: { message: 'Not Found' } }) }), annotatedRun(b6, 31, { moves: false })); + t("the target never holds the annotated #31 and the old URL still serves the card: exit 4 after exactly the window, ONE dispatch, no POST", [absent.exitCode, absent.sleeps, calls(absent, T31), posts(absent.seen).length, absent.text.includes('the card did not move')], [EXIT_BOARD_DISAGREES, [...DELAYS], DELAYS.length + 1, 0, true], absent.text); + const b7 = board(); + const retitled = await relayed(withAnswers(b7, { [T31]: () => ({ status: 200, json: card(UI, 31, { title: 'something else' }) }) }), annotatedRun(b7)); + t('the annotated #31 answering under another title is exit 4', [retitled.exitCode, retitled.text.includes('titled "something else"')], [EXIT_BOARD_DISAGREES, true], retitled.text); + const b8 = board(); + const other = await relayed(b8, annotatedRun(b8, 30)); + t("an annotation naming #30 while the old URL redirects to #31 is exit 4, both numbers printed", [other.exitCode, other.text.includes("the relay run's annotation names #30, the old URL redirects to #31")], [EXIT_BOARD_DISAGREES, true], other.text); + + // Absent: the order the header names — the 301, then the title. + const b9 = board(); + const fallback = await relayed(b9, outcome('success', b9, { annotations: { state: 'unread', rows: [], ignored: [], why: 'GET /jobs -> HTTP 403' } })); + t("no annotation: the old URL's 301 supplies #31 and the card is read there, exit 0 confirmed by the target", [fallback.exitCode, fallback.to?.number, calls(fallback, T31), calls(fallback, LIST), fallback.confirmedBy], [EXIT_OK, 31, 1, 0, 'target'], fallback.text); + } + // ── dry-run ───────────────────────────────────────────────────────────── battery('dry-run: no request leaves, and the plan is printed'); { @@ -910,7 +1022,8 @@ export async function selfTest() { `✓ issue-transfer self-test: ${cases.length} cases pass across ${declared.length} batteries — one card to a governed target judged by the relay's own validator, ` + 'a pull request or an already-moved card refused before any write, one paced mutation or one dispatch, a read-back that confirms on the TARGET — an old URL ' + 'still answering 200 read as a pending redirect, exit 4 only when a bounded re-read still finds no card AND the old URL is unchanged, the transfer never ' + - 're-sent — a failed run that names the installation remedy and is never fallen back from, and both halves of the throttle around the one write.', + 're-sent — the relay run\'s annotation read first for the number (a target that answers 403 confirmed by it, confirmed_by relay-annotation), ' + + 'a failed run that names the installation remedy and is never fallen back from, and both halves of the throttle around the one write.', ); selfTestReachedVerdict = true; return 0; @@ -982,7 +1095,7 @@ export async function main(argv) { if (rearmed !== null) return rearmed; const result = await transferIssue(plan); for (const line of result.lines) console.error(line); - if (opts.json && result.to) console.log(JSON.stringify({ from: result.from, to: result.to, transport: result.transport ?? null, relay_run: result.relay?.run?.url ?? null, pending_redirect: result.pendingRedirect ?? null, exit: result.exitCode })); + if (opts.json && result.to) console.log(JSON.stringify(jsonLine(result))); return result.exitCode; } From 4121efc7d45fd0908fb638b8e0087bf2d1ed7cad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:46:26 +0000 Subject: [PATCH 3/3] wip(pm): reflow issue-transfer docblock Claude-Session: https://claude.ai/code/session_01FNKm1SmPpuJASnbjxWGtsJ Co-authored-by: Claude --- scripts/pm/issue-transfer.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/pm/issue-transfer.mjs b/scripts/pm/issue-transfer.mjs index ece83575401..2468d1735c8 100644 --- a/scripts/pm/issue-transfer.mjs +++ b/scripts/pm/issue-transfer.mjs @@ -324,8 +324,9 @@ async function rest(path, { method = 'GET', body = null, redirect = 'follow' } = * Returns `{ exitCode, from, to, lines, transport, relay }` — plus, on a * success, `pendingRedirect` (true while the old URL still serves the card) * and `confirmedBy`: `target` (read back there) or `relay-annotation` (the - * run's annotation, the target answering 403); never throws on a status. `deps.sleep` and `deps.now` serve the - * read-back's bounded re-read alone (`TRANSFER_READ_BACK_DELAYS_MS`). + * run's annotation, the target answering 403); never throws on a status. + * `deps.sleep` and `deps.now` serve the read-back's bounded re-read alone + * (`TRANSFER_READ_BACK_DELAYS_MS`). */ export async function transferIssue(plan, deps = {}) { const lines = [];