Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 109 additions & 11 deletions scripts/pm/fleet-roster.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <seat> · <login>`, or the head
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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: { '<n>': [...] } } } }
* snapshot = { repos: { 'owner/name': { seats, dispatched, rulings, pulls, comments: { '<n>': [...] }, recentComments } } }
* → the roster rows, deterministically ordered (repo, lane, seat, post). Pure.
*/
export function buildRows(snapshot, { now }) {
Expand All @@ -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 ?? []) {
Expand All @@ -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));

Expand All @@ -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 {
Expand All @@ -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,
};
});

Expand Down Expand Up @@ -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)`,
]);
}
Expand Down Expand Up @@ -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}`);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading