From 153f1e3d9ffde251f45a4a70f3e9a63aff23815b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 04:27:32 +0000 Subject: [PATCH] fix(pm): fleet-roster reads a shared-account seat's own activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seat whose account also holds a lower seat of its lane has no in-flight cards of its own (the cell reads `= seat N`), so its last activity was its seat post's updated_at alone and it read 疑似撞墙 while working. Last activity is now the newest of the post, its in-flight cards, its mailbox pull's updated_at, and the comments of the last STALE_HOURS signed with the row's own LANE#N (by seat, never by account). One paged read per repo (/issues/comments?since=now - STALE_HOURS) carries the signed comments; the mailbox time comes from the pulls list already read. The signature pattern is mirrored from check-half-states (the relay imports this file and must not load the patrol); the self-test holds the two byte-equal and adds a battery for one account on two seats of a lane. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- scripts/pm/fleet-roster.mjs | 120 ++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 11 deletions(-) diff --git a/scripts/pm/fleet-roster.mjs b/scripts/pm/fleet-roster.mjs index 13beaf2d070..eb4e4ea3760 100644 --- a/scripts/pm/fleet-roster.mjs +++ b/scripts/pm/fleet-roster.mjs @@ -63,9 +63,18 @@ * (`LEDGER_EXCLUSION`). * wall note a line-initial `Wall:` (or `撞墙:`) line in the post * body, the seat's own record of a usage wall. - * last activity the newest `updated_at` of the post and its in-flight - * cards. A seated seat older than `STALE_HOURS` with no - * wall note is flagged 「疑似撞墙」. + * last activity the newest of the seat's own traces: the post's + * `updated_at`, its in-flight cards', its mailbox pull's + * (the column below), and every comment of the last + * `STALE_HOURS` signed `LANE#N · ACCOUNT · SESSION` on its + * first line with THIS row's lane and seat — by seat, ⛔ + * never by account, so a seat whose account also holds a + * lower seat of its lane (in flight `= seat N`) still reads + * its own work. A seated seat older than `STALE_HOURS` with + * no wall note is flagged 「疑似撞墙」. The comment read + * stops at `STALE_HOURS`, so a flagged row's age can read + * older than a signed comment past that window; the flag + * is exact. * mailbox open `pm:mailbox` pulls of the SEAT, whichever account * holds it: the seat on the body's first line * `Mailbox-of: · `, or the head @@ -283,6 +292,38 @@ export function epicSeatsHeld(lane, mailboxes) { } const isEpicLane = (lane) => lane.startsWith(`${SEAT_EPIC_KIND}:`); +/** + * The seat signature line `LANE#N · ACCOUNT · SESSION`, MIRRORED from `check-half-states.mjs`'s `SEAT_SIGNATURE_LINE` + * (#27113) — not imported, so the relay never loads the patrol; the self-test holds the two patterns byte-equal. + */ +const SIGNATURE_LINE = new RegExp( + '^((?:domain:[a-z][a-z0-9-]*|repo:[a-z][a-z0-9-]*|epic:#[1-9][0-9]*|triage|director|coordinator))#([1-9][0-9]*) · ([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}) · (session_[A-Za-z0-9]{6,})$', + 'u', +); + +/** The seat a body is signed by — its FIRST non-empty line, `readSignatureLine`'s reading — as `LANE#N`, or null. Pure. */ +export function signedSeatOf(body) { + const line = String(body ?? '').split(/\r?\n/).find((l) => l.trim() !== ''); + const m = line === undefined ? null : SIGNATURE_LINE.exec(line.trim()); + return m ? `${m[1]}#${m[2]}` : null; +} + +/** The newest signed-comment time per seat, `LANE#N` → ISO, over every repo's `recentComments`. Pure. */ +export function signedActivity(snapshot) { + const out = new Map(); + for (const repo of Object.keys(snapshot?.repos ?? {})) { + for (const c of snapshot.repos[repo]?.recentComments ?? []) { + const seat = signedSeatOf(c?.body); + const at = c?.updated_at ?? c?.created_at ?? null; + if (seat !== null && at && !(out.get(seat) >= at)) out.set(seat, at); + } + } + return out; +} + +/** Where the signed-comment read starts: `STALE_HOURS` before `now` — no older comment can lift a flag. Pure. */ +export const activitySince = (now) => new Date(Date.parse(now) - STALE_HOURS * 3_600_000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + /** A seat row's mailbox slug — its `LANE#N` as the opener spells it in a ref — or null. Pure. */ const seatSlugOf = (row) => seatKey(`${row.lane}#${row.seat}`)?.slug ?? null; @@ -319,7 +360,7 @@ const keyOf = (r) => `${r.repo}#${r.number}`; const byRef = (a, b) => (a.repo === b.repo ? a.number - b.number : a.repo < b.repo ? -1 : 1); /** - * snapshot = { repos: { 'owner/name': { seats, dispatched, rulings, pulls, comments: { '': [...] } } } } + * snapshot = { repos: { 'owner/name': { seats, dispatched, rulings, pulls, comments: { '': [...] }, recentComments } } } * → the roster rows, deterministically ordered (repo, lane, seat, post). Pure. */ export function buildRows(snapshot, { now }) { @@ -330,6 +371,8 @@ export function buildRows(snapshot, { now }) { const rulings = []; const pulls = []; const mailboxes = readMailboxes(snapshot); + const signed = signedActivity(snapshot); + const pullUpdatedAt = new Map(); // pull key → its updated_at, for the mailbox's share of last activity for (const repo of repos) { const s = snapshot.repos[repo] ?? {}; for (const post of s.seats ?? []) { @@ -350,7 +393,11 @@ export function buildRows(snapshot, { now }) { const asCard = (i) => ({ repo, number: i.number, labels: labelNames(i), assignees: assigneeLogins(i).map(lc), updatedAt: i.updated_at ?? null }); for (const i of s.dispatched ?? []) if (!i.pull_request && i.state !== 'closed') cards.push(asCard(i)); for (const i of s.rulings ?? []) if (!i.pull_request && i.state !== 'closed') rulings.push(asCard(i)); - for (const p of s.pulls ?? []) if (p.state !== 'closed') pulls.push({ repo, pr: p, comments: s.comments?.[String(p.number)] ?? [] }); + for (const p of s.pulls ?? []) { + if (p.state === 'closed') continue; + pulls.push({ repo, pr: p, comments: s.comments?.[String(p.number)] ?? [] }); + pullUpdatedAt.set(keyOf(ref(repo, p.number)), p.updated_at ?? null); + } } rows.sort((a, b) => (a.repo !== b.repo ? (a.repo < b.repo ? -1 : 1) : a.lane !== b.lane ? (a.lane < b.lane ? -1 : 1) : a.seat !== b.seat ? a.seat - b.seat : a.post - b.post)); @@ -368,7 +415,10 @@ export function buildRows(snapshot, { now }) { const sharesWith = first !== row ? first.seat : null; const inFlight = row.account && sharesWith === null ? cards.filter((c) => cardLaneMatch(c, row) && c.assignees.includes(acct)) : []; const awaitingLanding = inFlight.flatMap((c) => landing.get(keyOf(c)) ?? []); - const times = [row.updatedAt, ...inFlight.map((c) => c.updatedAt)].filter(Boolean).sort(); + // Per seat, never per account: one account holds several seats, and each seat opens its own. + const mailbox = mailboxes.filter((m) => m.slug !== null && m.slug === seatSlugOf(row)).map((m) => m.ref); + // The seat's own traces: a seat sharing its lane's account with a lower seat has no in-flight cards of its own. + const times = [row.updatedAt, ...inFlight.map((c) => c.updatedAt), ...mailbox.map((m) => pullUpdatedAt.get(keyOf(m))), signed.get(`${row.lane}#${row.seat}`)].filter(Boolean).sort(); const lastActivity = times.at(-1) ?? null; const ageHours = lastActivity && Number.isFinite(nowMs) ? (nowMs - Date.parse(lastActivity)) / 3_600_000 : null; return { @@ -379,8 +429,7 @@ export function buildRows(snapshot, { now }) { awaitingLanding: awaitingLanding.sort(byRef), lastActivity, suspectedWall: row.status === 'seated' && !isEpicLane(row.lane) && row.wallNote === null && ageHours !== null && ageHours > STALE_HOURS, - // Per seat, never per account: one account holds several seats, and each seat opens its own. - mailbox: mailboxes.filter((m) => m.slug !== null && m.slug === seatSlugOf(row)).map((m) => m.ref), + mailbox, }; }); @@ -414,6 +463,7 @@ export function readsFor(repos) { `GET /repos/${r}/issues?state=open&labels=pm:dispatched`, `GET /repos/${r}/issues?state=open&labels=needs-user-decision`, `GET /repos/${r}/pulls?state=open`, + `GET /repos/${r}/issues/comments?since=(now − ${STALE_HOURS} h), each read for its first-line seat signature`, `GET /repos/${r}/issues/{n}/comments for each claude/ pull closing an in-flight card (${LEDGER_EXCLUSION} excluded)`, ]); } @@ -483,7 +533,7 @@ function ghJson(path, { paginate = false } = {}) { } /** Read every repo into the snapshot shape `buildRows` takes; throws on the first failed read. */ -function readLive(repos) { +function readLive(repos, { since }) { const need = (path, paginate = true) => { const r = ghJson(path, { paginate }); if (!r.ok) throw new Error(`GET ${path} — ${r.error}`); @@ -498,6 +548,7 @@ function readLive(repos) { rulings: need(`/repos/${repo}/issues?state=open&labels=needs-user-decision&per_page=100`), pulls: need(`/repos/${repo}/pulls?state=open&per_page=100`), comments: {}, + recentComments: need(`/repos/${repo}/issues/comments?since=${encodeURIComponent(since)}&per_page=100`), }; snapshot.repos[repo] = s; } @@ -552,7 +603,7 @@ export async function main(argv, { env = process.env } = {}) { if (opts.fixture !== null) { snapshot = JSON.parse(readFileSync(opts.fixture, 'utf8')); repos = Object.keys(snapshot.repos ?? {}).sort(); - } else snapshot = readLive(repos); + } else snapshot = readLive(repos, { since: activitySince(opts.now ?? new Date().toISOString()) }); } catch (e) { console.error(`✗ fleet-roster: ${opts.fixture !== null ? `could not read the fixture ${opts.fixture}` : 'a read failed'} — ${e.message}. Nothing was written.`); return EXIT_PREREQUISITE; @@ -606,12 +657,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'seat titles: lane, seat, repo, status, account, project': 13, 'wall notes, closing refs, verdicts and mailboxes': 13, 'the fixture snapshot: rows for the three repos': 25, + 'one account on two seats of a lane: the second seat reads its own traces': 13, 'the rendered body: marker, columns, footer, determinism': 10, 'refusing to overwrite what is not the roster': 6, 'the CLI: fixture runs offline, usage errors are exit 2': 6, 'paging: a three-page read through the loop equals the slurped array': 4, }); -const SELF_TEST_BATTERY_FLOOR = 8; +const SELF_TEST_BATTERY_FLOOR = 9; async function selfTest() { let failed = 0; @@ -732,6 +784,52 @@ async function selfTest() { t('last activity is the newest of post and in-flight cards', row(6023).lastActivity, '2026-10-23T13:28:00Z'); t('one account on two seats of a lane: cards on the lowest seat, the other reads = seat 1', [row(5560, 'objectstack-ai/objectui').inFlight.length, row(10243, 'objectstack-ai/objectui').sharesWith, row(10243, 'objectstack-ai/objectui').inFlight, row(10243, 'objectstack-ai/objectui').suspectedWall], [1, 1, [], true]); + battery('one account on two seats of a lane: the second seat reads its own traces'); + { + const NOW = '2026-11-02T04:00:00Z'; + const R = 'objectstack-ai/objectstack'; + const fresh = '2026-11-02T03:00:00Z'; // 1 h before NOW + const stale = '2026-11-01T20:00:00Z'; // 8 h before NOW, past STALE_HOURS + const sig = (laneSeat, account = 'os-pm') => `${laneSeat} · ${account} · session_01SharedSeatSess`; + const snap = ({ mailboxAt = null, comments = [], seat1MailboxAt = null } = {}) => ({ + repos: { + [R]: { + seats: [ + { number: 100, title: '[PM seat] domain:server — 🟢 os-pm', body: '', labels: ['pm:seat'], assignees: ['os-pm'], updated_at: '2026-10-30T00:00:00Z' }, + { number: 200, title: '[PM seat] domain:server · seat 2 — 🟢 os-pm', body: '', labels: ['pm:seat'], assignees: ['os-pm'], updated_at: '2026-10-31T00:00:00Z' }, + ], + dispatched: [{ number: 300, labels: ['pm:dispatched', 'domain:server'], assignees: ['os-pm'], updated_at: fresh }], + rulings: [], + pulls: [ + ...(seat1MailboxAt ? [{ number: 401, state: 'open', head: { ref: 'mailbox/os-pm/server-1' }, body: 'Mailbox-of: domain:server#1 · os-pm', labels: ['pm:mailbox'], updated_at: seat1MailboxAt }] : []), + ...(mailboxAt ? [{ number: 402, state: 'open', head: { ref: 'mailbox/os-pm/server-2' }, body: 'Mailbox-of: domain:server#2 · os-pm', labels: ['pm:mailbox'], updated_at: mailboxAt }] : []), + ], + comments: {}, + recentComments: comments, + }, + }, + }); + const seat2 = (s) => (({ sharesWith, inFlight, mailbox, lastActivity, suspectedWall }) => ({ sharesWith, inFlight, mailbox, lastActivity, suspectedWall }))(buildRows(s, { now: NOW }).find((r) => r.post === 200)); + t('no trace of its own: seat 2 reads = seat 1, its post alone, and is flagged', seat2(snap()), { sharesWith: 1, inFlight: [], mailbox: [], lastActivity: '2026-10-31T00:00:00Z', suspectedWall: true }); + t('⭐ a fresh mailbox pull lifts the flag, and the in-flight cell still reads = seat 1', seat2(snap({ mailboxAt: fresh })), { sharesWith: 1, inFlight: [], mailbox: [{ repo: R, number: 402 }], lastActivity: fresh, suspectedWall: false }); + t('⭐ a fresh comment signed domain:server#2 lifts the flag', seat2(snap({ comments: [{ body: `${sig('domain:server#2')}\n\n## 席位核对 — ACCEPT`, updated_at: fresh }] })), { sharesWith: 1, inFlight: [], mailbox: [], lastActivity: fresh, suspectedWall: false }); + t('⛔ mailbox and signed comment both older than STALE_HOURS: flagged, last activity the newer of them', seat2(snap({ mailboxAt: stale, comments: [{ body: sig('domain:server#2'), updated_at: '2026-11-01T19:00:00Z' }] })), { sharesWith: 1, inFlight: [], mailbox: [{ repo: R, number: 402 }], lastActivity: stale, suspectedWall: true }); + t('⛔ by seat, never by account: seat 1\'s fresh signed comment and fresh mailbox, same account, leave seat 2 flagged', seat2(snap({ seat1MailboxAt: fresh, comments: [{ body: sig('domain:server#1'), updated_at: fresh }] })).suspectedWall, true); + t('⛔ another lane\'s seat 2, or a seat-2 signature on a repo: lane, is not this seat', seat2(snap({ comments: [{ body: sig('domain:ui#2'), updated_at: fresh }, { body: sig('repo:server#2'), updated_at: fresh }] })).suspectedWall, true); + t('⛔ a signature that is not the FIRST non-empty line, or is spelled wrong, signs nothing', seat2(snap({ comments: [{ body: `Claim: x\n${sig('domain:server#2')}`, updated_at: fresh }, { body: 'domain:server#2 - os-pm - session_01SharedSeatSess', updated_at: fresh }, { body: 'domain:server#2 · os-pm · session_01x', updated_at: fresh }] })).suspectedWall, true); + t('the newest of the four traces wins, and the comment\'s updated_at is its time', seat2(snap({ mailboxAt: '2026-11-02T01:00:00Z', comments: [{ body: sig('domain:server#2'), created_at: '2026-11-02T00:00:00Z', updated_at: '2026-11-02T03:30:00Z' }, { body: sig('domain:server#2'), updated_at: '2026-11-02T02:00:00Z' }] })).lastActivity, '2026-11-02T03:30:00Z'); + t('seat 1 keeps its cards and reads its own traces too', (({ inFlight, lastActivity, suspectedWall }) => ({ inFlight, lastActivity, suspectedWall }))(buildRows(snap({ comments: [{ body: sig('domain:server#1'), updated_at: '2026-11-02T03:45:00Z' }] }), { now: NOW }).find((r) => r.post === 100)), { inFlight: [{ repo: R, number: 300 }], lastActivity: '2026-11-02T03:45:00Z', suspectedWall: false }); + t('the rendered row keeps = seat 1 and drops the flag once a trace is fresh', (() => { + const line = renderBody(buildRows(snap({ mailboxAt: fresh }), { now: NOW }), { now: NOW, repos: [R] }).split('\n').find((l) => l.includes('/issues/200)')); + return [line.includes('| = seat 1 |'), line.includes(SUSPECTED_WALL)]; + })(), [true, false]); + t('the comment read starts STALE_HOURS before now, and the footer names it', [activitySince(NOW), readsFor([R]).some((q) => q.startsWith(`GET /repos/${R}/issues/comments?since=`))], ['2026-11-01T22:00:00Z', true]); + const { SEAT_SIGNATURE_LINE, readSignatureLine } = await import('./check-half-states.mjs'); + t('the signature pattern is check-half-states\' SEAT_SIGNATURE_LINE, byte for byte — mirrored, not imported', [SIGNATURE_LINE.source === SEAT_SIGNATURE_LINE.source, SIGNATURE_LINE.flags === SEAT_SIGNATURE_LINE.flags], [true, true]); + const corpus = ['', sig('domain:server#2'), `\n\n ${sig('domain:engine-core#3')}\r\nbody`, `body\n${sig('triage#1')}`, 'epic:#27293#1 · sunhaolin · session_017xRBcVBKAFk1ntXSjvPt7U', 'domain:server#0 · os-pm · session_01SharedSeatSess', 'domain:server#2 · os-pm · cse_01SharedSeatSess']; + t('signedSeatOf reads every body as readSignatureLine does', corpus.map(signedSeatOf), corpus.map((b) => { const r = readSignatureLine(b); return r ? `${r.lane}#${r.seat}` : null; })); + } + battery('the rendered body: marker, columns, footer, determinism'); const body = renderBody(rows, { now: fixture.now, repos: Object.keys(fixture.repos).sort(), session: 'cse_01ABC' }); t('the body opens with the marker', body.split('\n')[0], ROSTER_MARKER);