ledger: skip T3 reasoning summaries and read issue pages up to a ceiling (row 113, #1607)

T3 messages with role reasoning and an id starting "reasoning:" are now
skipped before any other check and counted as reasoningMessages. Any other
unknown role, including reasoning with another id prefix, still refuses.
Issues updated since the start date are read in pages of 50 until a short
page, at most 10 pages. A full tenth page, a duplicate issue number across
pages, a page over 50 rows or any failed page refuses with exit 2, so
partial totals are never reported. The report prints how many pages it
read. The README describes the skip, the page loop and its refusals.

Candidate manifest 4eed1d6405a505e88cde5062c61f768639d899b8f301019b1403d68fb15d9d36
(Darkwing round 1, packet d6ea56831), BASE cc48b124f. Approved by Filbert
(comment 28407). Sage gate rerun at 644ef5310 plus the candidate passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 11:35:33 -05:00
co-authored by Claude Opus 5.5
parent 644ef5310e
commit a5b8a99a18
5 changed files with 148 additions and 34 deletions
+3 -2
View File
@@ -46,9 +46,9 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
// default without requiring installation or reading auth material here.
const priorPath = process.env.PATH;
process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`;
let issues, states = null;
let issues = null, pages, states = null;
try {
issues = noIssues ? null : readIssues(root, range);
if (!noIssues) ({ issues, pages } = readIssues(root, range));
if (queue && !noIssues) states = issueStates(root, queue.rows, issues);
}
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
@@ -56,6 +56,7 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false });
const sessions = mergeSources(await readSessions(root, range), t3);
const report = summarize(range, commits, issues, sessions);
report.issueSource = noIssues ? { read: false } : { read: true, pages };
report.queue = queue ? queueChecks(queue, { issues: states, seats: readSeatsDir(), exempt, range }) : { checked: false };
// The queue section prints above the weekly table.
console.log(json ? JSON.stringify(report, null, 2) : [...formatQueue(report.queue), formatTable(report)].join('\n'));
+32 -11
View File
@@ -44,29 +44,45 @@ export function readCommits(root, range) {
});
}
export function readIssues(root, range, tool = 'gitea-api.sh') {
// Gitea returns at most 50 issues a page. A page with fewer ends the read.
const PAGE_SIZE = 50, PAGE_CEILING = 10;
function readIssuePage(root, range, page, tool) {
let output;
try {
// Updated-since includes closes in range. No until filter: later updates must
// not hide a close in range. One page only, as required by the brief.
output = execFileSync(tool, ['GET', `repos/mosaicstack/stack/issues?state=all&type=issues&since=${encodeURIComponent(new Date(range.start).toISOString())}&limit=50&page=1`],
// not hide a close in range.
output = execFileSync(tool, ['GET', `repos/mosaicstack/stack/issues?state=all&type=issues&since=${encodeURIComponent(new Date(range.start).toISOString())}&limit=${PAGE_SIZE}&page=${page}`],
{ cwd: root, encoding: 'utf8', timeout: 60000, maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
} catch (error) {
// Never echo an arbitrary API response or stderr that might contain secrets.
const reason = error.status === 3 ? 'credentials missing, unreadable, or invalid' :
error.code === 'ENOENT' ? 'gitea-api.sh unavailable' : 'credential or Gitea request failure';
throw new SourceError(`Issues unavailable: ${reason}; use --no-issues for unknown issue metrics`, 2);
throw new SourceError(`Issues unavailable: ${reason} on page ${page}; use --no-issues for unknown issue metrics`, 2);
}
let issues;
try { issues = JSON.parse(output); } catch { throw new SourceError('Issues unavailable: invalid Gitea JSON', 2); }
if (!Array.isArray(issues) || issues.some(i => !i || !Number.isSafeInteger(i.number) || i.number < 1 ||
try { issues = JSON.parse(output); } catch { throw new SourceError(`Issues unavailable: invalid Gitea JSON on page ${page}`, 2); }
if (!Array.isArray(issues) || issues.length > PAGE_SIZE || issues.some(i => !i || !Number.isSafeInteger(i.number) || i.number < 1 ||
typeof i.title !== 'string' || !Number.isFinite(Date.parse(i.created_at)) ||
!(i.closed_at === null || Number.isFinite(Date.parse(i.closed_at))))) {
throw new SourceError('Issues unavailable: invalid Gitea issue records', 2);
throw new SourceError(`Issues unavailable: invalid Gitea issue records on page ${page}`, 2);
}
if (issues.length >= 50) throw new SourceError('Issues unavailable: full 50-row page may be incomplete; one-call limit forbids pagination. Narrow --since or use --no-issues', 2);
if (new Set(issues.map(i => i.number)).size !== issues.length) throw new SourceError('Issues unavailable: duplicate issue numbers', 2);
return issues.filter(i => !i.pull_request);
return issues;
}
// Reads pages 1, 2, 3 and on until one has fewer than 50 issues, at most 10.
// A full page 10 refuses: the totals would be partial. So does a number seen
// twice, since an update during the read can move an issue to the next page.
export function readIssues(root, range, tool = 'gitea-api.sh') {
const issues = [], seen = new Set();
for (let page = 1; page <= PAGE_CEILING; page++) {
const rows = readIssuePage(root, range, page, tool);
for (const issue of rows) {
if (seen.has(issue.number)) throw new SourceError(`Issues unavailable: duplicate issue number on page ${page}; rerun the report`, 2);
seen.add(issue.number);
issues.push(issue);
}
if (rows.length < PAGE_SIZE) return { issues: issues.filter(i => !i.pull_request), pages: page };
}
throw new SourceError(`Issues unavailable: page ${PAGE_CEILING} is full, so the ${PAGE_CEILING}-page ceiling was reached and totals may be incomplete. Narrow --since or use --no-issues`, 2);
}
export function messageText(content) {
@@ -222,9 +238,14 @@ export function formatTable(report) {
decimal(r.hoursOpen), r.commits, r.followUps, r.seats.join(', ')].join(' | ')),
'', 'Seat | Board | Agent | Human',
...report.seats.map(r => [clean(r.seat), r.board, r.agent, r.human].join(' | ')),
...t3Line(report.t3),
...t3Line(report.t3), ...issueLine(report.issueSource),
'', totalsLine(report.totals)].join('\n');
}
// How many issue pages the report read, so a short read can't pass unseen.
function issueLine(source) {
if (!source) return [];
return [source.read ? `Issues: ${source.pages} ${source.pages === 1 ? 'page' : 'pages'} read from Gitea` : 'Issues: not read (--no-issues)'];
}
// One line when T3 was skipped or read from somewhere other than the default.
function t3Line(t3) {
if (!t3) return [];
+4 -1
View File
@@ -79,7 +79,7 @@ function query(db, root, range, seats) {
throw new SourceError(`T3 has ${projects.length ? 'more than one project' : 'no project'} for ${root}; a project opened through a symlink does not match; ${SKIP}`);
}
const projectId = projects[0].project_id;
const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0 };
const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0, reasoningMessages: 0 };
for (const t of db.prepare('select thread_id, title, archived_at, deleted_at from projection_threads where project_id = ?').all(projectId)) {
if (typeof t.thread_id !== 'string' || typeof t.title !== 'string') throw new SourceError('T3 thread with a non-text id or title');
if (t.thread_id.startsWith('import:')) { excluded.importedThreads++; continue; }
@@ -93,6 +93,9 @@ function query(db, root, range, seats) {
for (const m of messages) {
const thread = threads.get(m.thread_id);
if (!thread) continue;
// T3 stores reasoning summaries as messages since 2026-10-13. They are
// skipped before any check, so their content can't refuse or change a run.
if (m.role === 'reasoning' && typeof m.message_id === 'string' && m.message_id.startsWith('reasoning:')) { excluded.reasoningMessages++; continue; }
const where = `T3 message ${clean(m.message_id)} in thread ${clean(m.thread_id)}`;
if (m.role !== 'user' && m.role !== 'assistant') throw new SourceError(`${where} has an unknown role`);
if (typeof m.text !== 'string') throw new SourceError(`${where} has non-text content`);