From 2cfc824e9c9e047bc92f6f826070c3a391205f6a Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:28:28 -0700 Subject: [PATCH 01/15] fix(upstream-watch): skip retries for deterministic fetch failures --- .../2026-09-29-upstream-watch-followups.md | 10 +++++++ scripts/upstream-watch/fetch.mjs | 28 +++++++++++-------- tests/kit/upstream-watch-fixtures.mjs | 3 +- tests/kit/upstream-watch-record.test.mjs | 15 +++++++--- 4 files changed, 39 insertions(+), 17 deletions(-) create mode 100644 docs/plans/2026-09-29-upstream-watch-followups.md diff --git a/docs/plans/2026-09-29-upstream-watch-followups.md b/docs/plans/2026-09-29-upstream-watch-followups.md new file mode 100644 index 00000000..7e22e491 --- /dev/null +++ b/docs/plans/2026-09-29-upstream-watch-followups.md @@ -0,0 +1,10 @@ +# Upstream watch followups + +Scope: M7, M8, and the twelve numbered deferred minors in the recovered PR #253 report. M10 is declined because the numeric PR field matches emitted records. This lane owns watcher scripts, focused tests, one Codex registry entry, and current watcher documentation. The integration owner owns workflow edits. + +1. Add focused failing tests for deterministic retry failures, bounded dispatch polling, ledger errors, and the numbered edge cases. Keep synthetic fetch, dispatch, and git boundaries. +2. Implement the smallest watcher changes that make those tests pass. Check already-correct behavior and record no-change findings without empty commits. +3. Commit independently verifiable items separately. Run focused Node tests and static checks without a full suite, dispatch call, or GitHub write. +4. Put item 11/12 workflow hunks, commands, per-item dispositions, and residual limits in the ignored C4 report. Stop for independent review. + +Acceptance: deterministic errors do not sleep; transient errors retry at most twice; fired PR polling stops after seven days or when registry state is ineligible; invalid ledger registry exits nonzero; notices retain their body and maximum length semantics; all twelve minors are either fixed, proved already handled, or handed off as exact workflow hunks. diff --git a/scripts/upstream-watch/fetch.mjs b/scripts/upstream-watch/fetch.mjs index 198fad9a..5077d683 100644 --- a/scripts/upstream-watch/fetch.mjs +++ b/scripts/upstream-watch/fetch.mjs @@ -14,6 +14,10 @@ const VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/; const NOT_FOUND = /HTTP 404|Not Found/i; const NO_MATCH = /No match found for version/; +/** An invalid local argument or fixture cannot recover by waiting for the network. */ +export class PermanentFetchError extends Error {} +const invalid = (message) => new PermanentFetchError(message); + // What closed a thread: its closing pull requests, else the ClosedEvent's closer. export const FIXING_CHANGES_QUERY = `query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){defaultBranchRef{name} issueOrPullRequest(number:$number){__typename ... on Issue{closedByPullRequestsReferences(first:10,includeClosedPrs:true){nodes{number merged mergedAt baseRefName mergeCommit{oid} repository{nameWithOwner}}} timelineItems(last:1,itemTypes:[CLOSED_EVENT]){nodes{... on ClosedEvent{closer{__typename ... on Commit{oid} ... on PullRequest{number merged mergedAt baseRefName mergeCommit{oid} repository{nameWithOwner}}}}}}} ... on PullRequest{number merged mergedAt baseRefName mergeCommit{oid} repository{nameWithOwner}}}}}`; @@ -80,7 +84,7 @@ export function createFetcher({ exec = run } = {}) { }, async thread(id) { const [, repo, number] = ID.exec(id) ?? []; - if (!repo) throw new Error(`not an owner/repo#number id: ${id}`); + if (!repo) throw invalid(`not an owner/repo#number id: ${id}`); const issue = await json('gh', ['api', `repos/${repo}/issues/${number}`]); return { issue, comments: await this.comments(repo, number) }; }, @@ -89,7 +93,7 @@ export function createFetcher({ exec = run } = {}) { * per line; `--slurp` would need gh 2.48, newer than apt's gh on Ubuntu 24.04. */ async comments(repo, number) { - if (!OWNER_REPO.test(repo ?? '') || !/^[1-9]\d*$/.test(String(number))) throw new Error(`not an issue: ${repo}#${number}`); + if (!OWNER_REPO.test(repo ?? '') || !/^[1-9]\d*$/.test(String(number))) throw invalid(`not an issue: ${repo}#${number}`); const args = ['api', '--paginate', '--jq', '.[]', `repos/${repo}/issues/${number}/comments?per_page=100`]; const result = await exec('gh', args); if (result.status !== 0) throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || result.error?.message || 'no output').trim()}`); @@ -98,7 +102,7 @@ export function createFetcher({ exec = run } = {}) { /** Merged pull requests (or the closing commit) that fixed a thread; empty when none qualifies. */ async fixingChanges(id) { const [, repo, number] = ID.exec(id) ?? []; - if (!repo) throw new Error(`not an owner/repo#number id: ${id}`); + if (!repo) throw invalid(`not an owner/repo#number id: ${id}`); const [owner, name] = repo.split('/'); const answer = await json('gh', ['api', 'graphql', '-f', `query=${FIXING_CHANGES_QUERY}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${number}`]); return changesOf(repo, answer?.data?.repository); @@ -109,10 +113,10 @@ export function createFetcher({ exec = run } = {}) { * a rate limit never reads as "not contained". */ async contains(repo, refs, sha) { - if (!SHA.test(sha ?? '')) throw new Error(`not a commit: ${sha}`); - if (!OWNER_REPO.test(repo ?? '')) throw new Error(`not an owner/repo: ${repo}`); + if (!SHA.test(sha ?? '')) throw invalid(`not a commit: ${sha}`); + if (!OWNER_REPO.test(repo ?? '')) throw invalid(`not an owner/repo: ${repo}`); for (const ref of refs) { - if (!REF.test(ref ?? '')) throw new Error(`not a tag name: ${ref}`); + if (!REF.test(ref ?? '')) throw invalid(`not a tag name: ${ref}`); const args = ['api', `repos/${repo}/compare/${ref}...${sha}`, '--jq', '{status:.status}']; const result = await exec('gh', args); if (result.status !== 0) { @@ -120,7 +124,7 @@ export function createFetcher({ exec = run } = {}) { throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || result.error?.message || 'no output').trim()}`); } const { status } = JSON.parse(result.stdout); - if (!['behind', 'identical', 'ahead', 'diverged'].includes(status)) throw new Error(`gh ${args.join(' ')} returned status ${status}`); + if (!['behind', 'identical', 'ahead', 'diverged'].includes(status)) throw invalid(`gh ${args.join(' ')} returned status ${status}`); return { ref, contained: status === 'behind' || status === 'identical' }; } return { ref: null, contained: null }; @@ -132,8 +136,8 @@ export function createFetcher({ exec = run } = {}) { * every range to its highest published match with npm. */ async bundled(chain, name, at = null) { - for (const pkg of [...chain, name]) if (!PACKAGE_NAME.test(pkg ?? '')) throw new Error(`not a package name: ${pkg}`); - if (at !== null && !VERSION.test(at)) throw new Error(`not a version: ${at}`); + for (const pkg of [...chain, name]) if (!PACKAGE_NAME.test(pkg ?? '')) throw invalid(`not a package name: ${pkg}`); + if (at !== null && !VERSION.test(at)) throw invalid(`not a version: ${at}`); const carrierVersion = at ?? await json('npm', ['view', chain[0], 'version', '--json']); let [pkg, version] = [chain[0], carrierVersion]; const trail = [`${pkg} ${version}`]; @@ -159,13 +163,13 @@ export function createFetcher({ exec = run } = {}) { * only scheduled runs show that the watch is alive. */ async lastRun(repo) { - if (!OWNER_REPO.test(repo ?? '')) throw new Error(`not an owner/repo: ${repo}`); + if (!OWNER_REPO.test(repo ?? '')) throw invalid(`not an owner/repo: ${repo}`); const answer = await json('gh', ['api', `repos/${repo}/actions/workflows/upstream-watch.yml/runs?status=success&event=schedule&per_page=1`]); const run = answer?.workflow_runs?.[0]; return run ? { at: run.run_started_at, url: run.html_url } : null; }, async release({ channel, name }) { - if (!PACKAGE_NAME.test(name)) throw new Error(`not a package or repository name: ${name}`); + if (!PACKAGE_NAME.test(name)) throw invalid(`not a package or repository name: ${name}`); if (channel === 'npm') return releaseFacts('npm', await json('npm', ['view', name, 'time', 'dist-tags', '--json'])); return releaseFacts('github-release', await json('gh', ['api', `repos/${name}/releases?per_page=100`])); }, @@ -186,7 +190,7 @@ export function retrying(fetcher, { delays = [2000, 10_000], sleep = (ms) => new try { return await method.apply(fetcher, args); } catch (error) { - if (attempt >= delays.length) throw error; + if (error instanceof PermanentFetchError || attempt >= delays.length) throw error; await sleep(delays[attempt]); } } diff --git a/tests/kit/upstream-watch-fixtures.mjs b/tests/kit/upstream-watch-fixtures.mjs index bb2dee0c..7d440260 100644 --- a/tests/kit/upstream-watch-fixtures.mjs +++ b/tests/kit/upstream-watch-fixtures.mjs @@ -7,6 +7,7 @@ import path from 'node:path'; import { UPSTREAM_REGISTRY_FILE } from '../../src/lib/hook-audit/upstream.mjs'; import { releaseFacts } from '../../scripts/upstream-watch/classify.mjs'; +import { PermanentFetchError } from '../../scripts/upstream-watch/fetch.mjs'; const FIXTURES = path.resolve('tests/fixtures/upstream-watch'); const threads = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'threads.json'), 'utf8')).threads; @@ -55,7 +56,7 @@ export function fixtureFetcher({ authenticated = true, failing = new Set(), flak thread: async (id) => { if (failing.has(id)) throw new Error('HTTP 502'); if (flaky.get(id) > 0) { flaky.set(id, flaky.get(id) - 1); throw new Error('HTTP 502'); } - if (!threads[id]) throw new Error(`no fixture for ${id}`); + if (!threads[id]) throw new PermanentFetchError(`no fixture for ${id}`); return clone(threads[id]); }, release: async ({ name }) => releaseFacts('npm', npm[name]), diff --git a/tests/kit/upstream-watch-record.test.mjs b/tests/kit/upstream-watch-record.test.mjs index c0f18243..d02c3098 100644 --- a/tests/kit/upstream-watch-record.test.mjs +++ b/tests/kit/upstream-watch-record.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { retrying } from '../../scripts/upstream-watch/fetch.mjs'; +import { PermanentFetchError, retrying } from '../../scripts/upstream-watch/fetch.mjs'; import { isActionRecord } from '../../scripts/upstream-watch/ledger.mjs'; import { main } from '../../scripts/upstream-watch.mjs'; import { @@ -17,15 +17,22 @@ async function record(file, argv, { fetcher = fixtureFetcher(), ledgerStore = me return { code, result: out.text() ? JSON.parse(out.text()) : null, err: err.text(), ledgerStore, dispatcher }; } -test('retrying retries every method but auth, then gives up', async () => { +test('retrying handles transient failures within the budget, but never retries auth or deterministic failures', async () => { let calls = 0; const waits = []; const fetcher = retrying({ auth: async () => { throw new Error('auth is not retried'); }, thread: async () => { calls += 1; if (calls < 3) throw new Error('HTTP 502'); return 'ok'; } }, { sleep: async (ms) => { waits.push(ms); } }); assert.equal(await fetcher.thread('a/b#1'), 'ok'); assert.deepEqual(waits, [2000, 10000]); await assert.rejects(fetcher.auth(), /auth is not retried/); - const always = retrying({ thread: async () => { throw new Error('HTTP 404'); } }, { sleep: noSleep }); - await assert.rejects(always.thread('a/b#1'), /HTTP 404/); + let exhausted = 0; + const always = retrying({ thread: async () => { exhausted++; throw new Error('HTTP 502'); } }, { delays: [1, 2], sleep: async (ms) => { waits.push(ms); } }); + await assert.rejects(always.thread('a/b#1'), /HTTP 502/); + assert.equal(exhausted, 3); + assert.deepEqual(waits, [2000, 10000, 1, 2]); + let deterministic = 0; + const invalid = retrying({ thread: async () => { deterministic++; throw new PermanentFetchError('no fixture'); } }, { sleep: async () => { assert.fail('deterministic failure slept'); } }); + await assert.rejects(invalid.thread('a/b#1'), /no fixture/); + assert.equal(deterministic, 1); }); test('record on an absent ledger starts from --since, commits every record and prints the notice', async () => { From 187c6ba8765d5743233550d51941a711b409162c Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:29:31 -0700 Subject: [PATCH 02/15] fix(upstream-watch): bound dispatch pull request observation --- docs/upstream-watch.md | 7 +++++-- scripts/upstream-watch.mjs | 3 ++- scripts/upstream-watch/dispatch.mjs | 6 +++++- tests/kit/upstream-watch-dispatch.test.mjs | 20 ++++++++++++++++++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/upstream-watch.md b/docs/upstream-watch.md index 5857fc88..bbad0c07 100644 --- a/docs/upstream-watch.md +++ b/docs/upstream-watch.md @@ -298,8 +298,11 @@ has GitHub write tools, so the limits below are instructions in its prompt, not platform checks each push to a branch not prefixed `claude/` and refuses it when the branch is protected, someone else has an open pull request from it, or it carries someone else's commits ([Repositories and branch permissions](https://code.claude.com/docs/en/routines#repositories-and-branch-permissions)). -GitHub does not notify you of your own pull request by default, so the watch's next run records -`dispatch-pr` and its notice says the draft is ready. +GitHub does not notify you of your own pull request by default, so the watch checks for an open +draft pull request while the entry is `watching` or `fixed-unreleased`, for up to seven days after +its latest firing. When found, it records `dispatch-pr` and its notice says the draft is ready. +After that window, a late pull request needs manual reconciliation; the `fired` evidence remains +in the ledger. - **Trigger:** API only. - **Prompt:** diff --git a/scripts/upstream-watch.mjs b/scripts/upstream-watch.mjs index a53f6459..34b5f951 100644 --- a/scripts/upstream-watch.mjs +++ b/scripts/upstream-watch.mjs @@ -286,7 +286,8 @@ async function record(registry, fetcher, options, { stdout, stderr, now, ledgerS const recorded = ledger.records.map((item) => item.line).join('\n'); const all = ledgerEvents(report, registry, { since }); const released = all.filter((event) => event.event === 'released' && event.fields.branch); - const fired = await dispatch({ released, records: ledger.records, dispatcher, repo, sentinel, now, recordedAt: runAt, dryRun: options.dryRun }); + const eligibleIds = new Set(registry.watch.filter((entry) => PENDING.has(entry.status)).map((entry) => entry.id)); + const fired = await dispatch({ released, records: ledger.records, dispatcher, repo, sentinel, now, recordedAt: runAt, dryRun: options.dryRun, eligibleIds }); const records = [...withoutRecorded(all, recorded).map((event) => toRecord(event, runAt)), ...fired.records]; const checkedAt = fetchErrors.length ? (ledger.checkedAt ?? since) : runAt; let commit = null; diff --git a/scripts/upstream-watch/dispatch.mjs b/scripts/upstream-watch/dispatch.mjs index 005e17d8..95611e28 100644 --- a/scripts/upstream-watch/dispatch.mjs +++ b/scripts/upstream-watch/dispatch.mjs @@ -10,6 +10,7 @@ import { toRecord } from './ledger-branch.mjs'; export const FIRE_URL = (routine) => `https://api.anthropic.com/v1/claude_code/routines/${routine}/fire`; export const FIRE_HEADERS = { 'anthropic-beta': 'experimental-cc-routine-2026-04-01', 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }; export const REFIRE_AFTER_DAYS = 3; +export const PR_OBSERVE_DAYS = 7; export const MAX_FIRES = 2; export const FIRE_TIMEOUT_MS = 30_000; // `gh pr list --head` matches the branch name in any fork; only a pull request @@ -56,7 +57,7 @@ export function createDispatcher({ exec = run, fetchImpl = globalThis.fetch, env } /** Fire (or, in a dry run, list in `wouldFire`) each released fix; the branch and pull request lookups only read. */ -export async function dispatch({ released, records, dispatcher, repo, sentinel, now, recordedAt, dryRun = false }) { +export async function dispatch({ released, records, dispatcher, repo, sentinel, now, recordedAt, dryRun = false, eligibleIds = null }) { const out = []; const errors = []; const wouldFire = []; @@ -85,6 +86,9 @@ export async function dispatch({ released, records, dispatcher, repo, sentinel, } for (const id of new Set(records.filter((item) => item.event === 'fired').map((item) => item.id))) { if (recordsOf(id, 'dispatch-pr').length) continue; + if (eligibleIds && !eligibleIds.has(id)) continue; + const latestFiring = Math.max(...recordsOf(id, 'fired').map((item) => Date.parse(item.recordedAt))); + if (!Number.isFinite(latestFiring) || now.getTime() - latestFiring >= PR_OBSERVE_DAYS * DAY) continue; try { const branch = recordsOf(id, 'fired').at(-1).fields.branch; const pr = await dispatcher.openPullRequest(repo, branch); diff --git a/tests/kit/upstream-watch-dispatch.test.mjs b/tests/kit/upstream-watch-dispatch.test.mjs index d6c6446a..742504fa 100644 --- a/tests/kit/upstream-watch-dispatch.test.mjs +++ b/tests/kit/upstream-watch-dispatch.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { eventLine } from '../../scripts/upstream-watch/classify.mjs'; -import { FIRE_HEADERS, FIRE_URL, SAME_REPO_PR, createDispatcher, dispatch } from '../../scripts/upstream-watch/dispatch.mjs'; +import { FIRE_HEADERS, FIRE_URL, PR_OBSERVE_DAYS, SAME_REPO_PR, createDispatcher, dispatch } from '../../scripts/upstream-watch/dispatch.mjs'; const NOW = new Date('2026-10-02T14:17:00Z'); const RECORDED_AT = '2026-10-02T14:17:00Z'; @@ -26,7 +26,7 @@ function fakeDispatcher({ exists = false, pr = null, session = 'https://claude.a fire: async (text) => { calls.fire.push(text); if (fireError) throw new Error(fireError); return session; }, }; } -const run = (dispatcher, records = [], { dryRun, list = [released] } = {}) => dispatch({ released: list, records, dispatcher, repo: 'pacphi/agentic-kit', sentinel: 'UPSTREAM-WATCH', now: NOW, recordedAt: RECORDED_AT, dryRun }); +const run = (dispatcher, records = [], { dryRun, list = [released], eligibleIds = new Set([ID]), now = NOW } = {}) => dispatch({ released: list, records, dispatcher, repo: 'pacphi/agentic-kit', sentinel: 'UPSTREAM-WATCH', now, recordedAt: RECORDED_AT, dryRun, eligibleIds }); const NOTHING = { records: [], errors: [], wouldFire: [] }; test('a released fix without a branch fires once and is recorded with its session', async () => { @@ -108,6 +108,22 @@ test('a fired branch with an open pull request records dispatch-pr once', async assert.deepEqual(again.calls.pr, []); }); +test('PR observation stops on ineligible status or seven days after latest firing', async () => { + assert.equal(PR_OBSERVE_DAYS, 7); + const first = fired('2026-09-20T14:17:00Z'); + const latest = fired('2026-09-26T14:17:00Z'); + const inactive = fakeDispatcher({ exists: true, pr: 261 }); + assert.deepEqual(await run(inactive, [latest], { list: [], eligibleIds: new Set() }), NOTHING); + assert.deepEqual(inactive.calls.pr, []); + const before = fakeDispatcher({ exists: true, pr: 261 }); + const inside = await run(before, [first, latest], { list: [], now: new Date('2026-10-03T14:16:59Z') }); + assert.equal(inside.records[0].event, 'dispatch-pr'); + assert.deepEqual(before.calls.pr, [['pacphi/agentic-kit', BRANCH]]); + const boundary = fakeDispatcher({ exists: true, pr: 261 }); + assert.deepEqual(await run(boundary, [first, latest], { list: [], now: new Date('2026-10-03T14:17:00Z'), dryRun: true }), NOTHING); + assert.deepEqual(boundary.calls.pr, []); +}); + test('the trigger call sends the payload with the documented headers and never prints the token', async () => { const requests = []; const fetchImpl = async (url, init) => { requests.push({ url, init }); return { status: 200, json: async () => ({ claude_code_session_url: 'https://claude.ai/code/session_x' }) }; }; From 5b8d032a1e24ee2192d33f99438af23d0839e592 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:30:13 -0700 Subject: [PATCH 03/15] test(upstream-watch): cover ledger git and spawn failures --- .../kit/upstream-watch-ledger-branch.test.mjs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/kit/upstream-watch-ledger-branch.test.mjs b/tests/kit/upstream-watch-ledger-branch.test.mjs index e7447970..809a480b 100644 --- a/tests/kit/upstream-watch-ledger-branch.test.mjs +++ b/tests/kit/upstream-watch-ledger-branch.test.mjs @@ -61,6 +61,27 @@ test('an absent ledger branch reads as an empty ledger without a fetch', async ( assert.deepEqual(calls.map((call) => call.args), [['ls-remote', '--exit-code', '--heads', 'origin', 'upstream-watch-ledger']]); }); +test('runWithInput reports a spawn failure without rejecting', async () => { + const result = await runWithInput('ak-missing-command-for-test', []); + assert.equal(result.status, null); + assert.equal(result.error?.code, 'ENOENT'); +}); + +test('read reports failures from rev-parse, show and log', async () => { + for (const failedCommand of ['rev-parse', 'show', 'log']) { + const sha = 'a'.repeat(40); + const responses = [ + { stdout: `${sha}\trefs/heads/upstream-watch-ledger\n` }, {}, + { stdout: `${sha}\n` }, { stdout: '' }, { stdout: '' }, + ]; + const index = { 'rev-parse': 2, show: 3, log: 4 }[failedCommand]; + responses[index] = { status: 128, stderr: `${failedCommand} failed` }; + const { exec, calls } = fakeExec(responses); + await assert.rejects(createLedgerStore({ exec }).read('upstream-watch-ledger', { now: NOW }), new RegExp(`git ${failedCommand} failed: ${failedCommand} failed`)); + assert.equal(calls.at(-1).args[0], failedCommand); + } +}); + test('a failed ls-remote or fetch throws', async () => { const lookup = fakeExec([{ status: 128, stderr: 'fatal: unable to access: HTTP 403\n' }]); await assert.rejects(createLedgerStore({ exec: lookup.exec }).read('upstream-watch-ledger', { now: NOW }), /git ls-remote origin upstream-watch-ledger failed: fatal: unable to access/); From a23905073015be98f02ad848270d6a857ab93fb3 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:30:23 -0700 Subject: [PATCH 04/15] test(upstream-watch): reject invalid ledger branch before git --- tests/kit/upstream-watch-ledger-branch.test.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/kit/upstream-watch-ledger-branch.test.mjs b/tests/kit/upstream-watch-ledger-branch.test.mjs index 809a480b..231f2eaa 100644 --- a/tests/kit/upstream-watch-ledger-branch.test.mjs +++ b/tests/kit/upstream-watch-ledger-branch.test.mjs @@ -82,6 +82,12 @@ test('read reports failures from rev-parse, show and log', async () => { } }); +test('read rejects an invalid branch before any git call', async () => { + const { exec, calls } = fakeExec([]); + await assert.rejects(createLedgerStore({ exec }).read('../ledger'), /not a branch name/); + assert.equal(calls.length, 0); +}); + test('a failed ls-remote or fetch throws', async () => { const lookup = fakeExec([{ status: 128, stderr: 'fatal: unable to access: HTTP 403\n' }]); await assert.rejects(createLedgerStore({ exec: lookup.exec }).read('upstream-watch-ledger', { now: NOW }), /git ls-remote origin upstream-watch-ledger failed: fatal: unable to access/); From 91be6dae12b11ef08deca68fa3abebb37db63d00 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:30:53 -0700 Subject: [PATCH 05/15] perf(upstream-watch): measure notice fit with prefix lengths --- scripts/upstream-watch/ledger.mjs | 7 +++++-- tests/kit/upstream-watch-notice.test.mjs | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/upstream-watch/ledger.mjs b/scripts/upstream-watch/ledger.mjs index 88a27467..f29a0c94 100644 --- a/scripts/upstream-watch/ledger.mjs +++ b/scripts/upstream-watch/ledger.mjs @@ -80,10 +80,13 @@ export function renderNotice({ records, mention, date, recordedAt }) { const bullets = items.map((item) => `- ${sentence(item)}${item.event === 'released' && sessions.has(item.id) ? ` Routine session: ${sessions.get(item.id)}` : ''}`); const head = `@${mention} upstream watch: ${items.length} ${items.length === 1 ? 'item needs' : 'items need'} you (${date}).`; const foot = `The full record: \`node scripts/upstream-watch.mjs ledger --recorded-since ${recordedAt}\``; + const lengths = [0]; + for (const bullet of bullets) lengths.push(lengths.at(-1) + bullet.length); for (let count = bullets.length; count > 0; count--) { const more = bullets.length - count; - const body = `${[head, '', ...bullets.slice(0, count), ...(more ? ['', `${more} more; see the ledger.`] : []), '', foot].join('\n')}\n`; - if (body.length <= NOTICE_MAX) return body; + const suffix = more ? `${more} more; see the ledger.` : ''; + const length = head.length + foot.length + lengths[count] + suffix.length + count + 4 + (more ? 2 : 0); + if (length <= NOTICE_MAX) return `${[head, '', ...bullets.slice(0, count), ...(more ? ['', suffix] : []), '', foot].join('\n')}\n`; } return `${[head, '', `${bullets.length} items; see the ledger.`, '', foot].join('\n')}\n`; } diff --git a/tests/kit/upstream-watch-notice.test.mjs b/tests/kit/upstream-watch-notice.test.mjs index 7735d3ef..0c79fd6c 100644 --- a/tests/kit/upstream-watch-notice.test.mjs +++ b/tests/kit/upstream-watch-notice.test.mjs @@ -46,6 +46,17 @@ test('a notice too long for GitHub lists what fits and says how many more', () = assert.ok(body.length <= NOTICE_MAX, String(body.length)); assert.match(body, /\n\d+ more; see the ledger\.\n/); assert.ok(body.includes('`owner/repo#1`') && !body.includes('`owner/repo#3000`')); + const items = records.filter(isActionRecord); + const bullets = items.map((item) => `- ${sentence(item)}`); + const head = `@pacphi upstream watch: ${items.length} items need you (2026-10-02).`; + const foot = 'The full record: `node scripts/upstream-watch.mjs ledger --recorded-since 2026-10-02T14:17:00Z`'; + let expected; + for (let count = bullets.length; count > 0; count--) { + const more = bullets.length - count; + const candidate = `${[head, '', ...bullets.slice(0, count), ...(more ? ['', `${more} more; see the ledger.`] : []), '', foot].join('\n')}\n`; + if (candidate.length <= NOTICE_MAX) { expected = candidate; break; } + } + assert.equal(body, expected, 'the optimized truncation keeps the exact previous body'); }); // A commit message is plain text: GitHub turns `owner/repo#n` or `#n` there into From 7d3838b6eb9080b4ed31f584584c4271be5a4af0 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:31:07 -0700 Subject: [PATCH 06/15] test(upstream-watch): cover singular notice and latest firing --- tests/kit/upstream-watch-notice.test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/kit/upstream-watch-notice.test.mjs b/tests/kit/upstream-watch-notice.test.mjs index 0c79fd6c..78c17bcf 100644 --- a/tests/kit/upstream-watch-notice.test.mjs +++ b/tests/kit/upstream-watch-notice.test.mjs @@ -33,6 +33,18 @@ test('a quiet run has no notice; an action run mentions the maintainer first', ( assert.match(body, /\nThe full record: `node scripts\/upstream-watch\.mjs ledger --recorded-since 2026-10-02T14:17:00Z`\n$/); }); +test('one action uses singular wording and the latest fired session for an id', () => { + const records = [ + rec('released', { version: '1.0.0', branch: 'upstream/ruvnet-ruflo-1' }), + rec('fired', { branch: 'upstream/ruvnet-ruflo-1', session: 'https://claude.ai/code/session_old' }), + rec('fired', { branch: 'upstream/ruvnet-ruflo-1', session: 'https://claude.ai/code/session_new' }), + ]; + const body = renderNotice({ records, mention: 'pacphi', date: '2026-10-02', recordedAt: '2026-10-02T14:17:00Z' }); + assert.match(body, /^@pacphi upstream watch: 1 item needs you/); + assert.match(body, /Routine session: https:\/\/claude\.ai\/code\/session_new/); + assert.doesNotMatch(body, /session_old/); +}); + test('thread ids never autolink; only a dispatch pull request number does', () => { const body = renderNotice({ records: [rec('reply', { by: 'x', at: '10:00:00Z' }, 'a/b#5'), rec('dispatch-pr', { branch: 'upstream/a-b-5', pr: 261 }, 'a/b#5')], mention: 'pacphi', date: '2026-10-02', recordedAt: '2026-10-02T14:17:00Z' }); const prose = body.replace(/`[^`]*`/g, ''); From 507de40d7ac3764446571550fdd93b45b445cb64 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:31:42 -0700 Subject: [PATCH 07/15] fix(upstream-watch): report invalid record registry once --- scripts/upstream-watch.mjs | 6 +++--- tests/kit/upstream-watch-record.test.mjs | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/upstream-watch.mjs b/scripts/upstream-watch.mjs index 34b5f951..18c7f518 100644 --- a/scripts/upstream-watch.mjs +++ b/scripts/upstream-watch.mjs @@ -248,8 +248,8 @@ async function ledgerQuery(registry, options, { stdout, stderr, now, ledgerStore const WEEK = 7 * 86_400_000; -function blindRecord(error, { stdout, stderr, json }, extra = {}) { - stderr.write(`${error}\n`); +function blindRecord(error, { stdout, stderr, json }, extra = {}, { writeError = true } = {}) { + if (writeError) stderr.write(`${error}\n`); const result = { blind: true, error, records: [], fetchErrors: [], dispatchErrors: [], wouldFire: [], fired: [], parent: null, commit: null, notice: { post: false, body: '' }, ...extra, }; @@ -334,7 +334,7 @@ export async function main(argv, { stderr.write(`upstream registry is ${registry.registryStatus ?? registry.status}:\n${registry.errors.map((error) => ` ${error}`).join('\n')}\n`); const status = { status: registry.registryStatus ?? registry.status, errors: registry.errors }; if (options.command === 'record') { - return blindRecord(`upstream registry is ${status.status}`, { stdout, stderr, json: options.json }, { registry: status }); + return blindRecord(`upstream registry is ${status.status}`, { stdout, stderr, json: options.json }, { registry: status }, { writeError: false }); } stdout.write(options.json ? `${JSON.stringify({ registry: status }, null, 2)}\n` : 'No report: the upstream registry is not valid.\n'); return 0; diff --git a/tests/kit/upstream-watch-record.test.mjs b/tests/kit/upstream-watch-record.test.mjs index d02c3098..ee2a42d4 100644 --- a/tests/kit/upstream-watch-record.test.mjs +++ b/tests/kit/upstream-watch-record.test.mjs @@ -166,6 +166,7 @@ test('record is blind (exit 3) when gh, the ledger, the registry or every upstre assert.equal(invalid.code, 3); assert.deepEqual([invalid.result.blind, invalid.result.records, invalid.result.commit], [true, [], null]); assert.match(invalid.result.error, /registry/); + assert.equal(invalid.err.match(/upstream registry is/g)?.length, 1); }); }); From 9e524e101e0d486d5dec36934069f733dabd8e6d Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:31:54 -0700 Subject: [PATCH 08/15] test(upstream-watch): pin invalid registry precedence over future since --- tests/kit/upstream-watch-record.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/kit/upstream-watch-record.test.mjs b/tests/kit/upstream-watch-record.test.mjs index ee2a42d4..6332946d 100644 --- a/tests/kit/upstream-watch-record.test.mjs +++ b/tests/kit/upstream-watch-record.test.mjs @@ -189,3 +189,13 @@ test('future --since is a usage error', async () => { assert.match(err, /--since is in the future/); }); }); + +test('invalid registry takes precedence over a future --since', async () => { + await withRegistryFile([entry('ruvnet/ruflo#3153', { status: 'done' })], async (file) => { + const { code, result, err } = await record(file, ['--since', '2026-10-01T00:00:00Z']); + assert.equal(code, 3); + assert.equal(result.blind, true); + assert.match(err, /upstream registry is invalid/); + assert.doesNotMatch(err, /--since is in the future/); + }); +}); From 25fa47359345b9a564a9f327a053ca21c9c10d3a Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:32:12 -0700 Subject: [PATCH 09/15] refactor(upstream-watch): share ledger event vocabulary --- scripts/upstream-watch.mjs | 3 +-- scripts/upstream-watch/ledger.mjs | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/upstream-watch.mjs b/scripts/upstream-watch.mjs index 18c7f518..3f834043 100644 --- a/scripts/upstream-watch.mjs +++ b/scripts/upstream-watch.mjs @@ -17,7 +17,7 @@ import { import { createDispatcher, dispatch } from './upstream-watch/dispatch.mjs'; import { createFetcher, mapLimit, retrying } from './upstream-watch/fetch.mjs'; import { createLedgerStore, toRecord } from './upstream-watch/ledger-branch.mjs'; -import { commitSafe, isoSeconds, renderNotice, sentence } from './upstream-watch/ledger.mjs'; +import { LEDGER_EVENTS, commitSafe, isoSeconds, renderNotice, sentence } from './upstream-watch/ledger.mjs'; import { renderEvents, renderReport } from './upstream-watch/render.mjs'; const USAGE = `usage: node scripts/upstream-watch.mjs report [--json] [--concurrency <1-16>] [--registry ] @@ -28,7 +28,6 @@ const USAGE = `usage: node scripts/upstream-watch.mjs report [--json] [--concurr const PENDING = new Set(['watching', 'fixed-unreleased']); // record exits BLIND when gh, the registry, the ledger branch or every upstream thread is unreadable. const BLIND = 3; -const LEDGER_EVENTS = ['reply', 'acknowledged', 'closed', 'merged', 'released', 'reopened', 'stale', 'retire-proposed', 'retest-due', 'idle', 'fired', 'dispatch-pr']; class UsageError extends Error {} diff --git a/scripts/upstream-watch/ledger.mjs b/scripts/upstream-watch/ledger.mjs index f29a0c94..12e75be5 100644 --- a/scripts/upstream-watch/ledger.mjs +++ b/scripts/upstream-watch/ledger.mjs @@ -5,6 +5,9 @@ // autolinks to this repository and `owner/repo#n` mentions the upstream thread. const code = (value) => `\`${value}\``; +/** Event names accepted by the ledger query and rendered below. */ +export const LEDGER_EVENTS = ['reply', 'acknowledged', 'closed', 'merged', 'released', 'reopened', 'stale', 'retire-proposed', 'retest-due', 'idle', 'fired', 'dispatch-pr']; + export const isoSeconds = (date) => new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z'); /** From e54947beb5532b7324eb7350d9a03938f3c57172 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:32:45 -0700 Subject: [PATCH 10/15] fix(upstream-watch): fail ledger on invalid registry --- docs/upstream-watch.md | 4 ++-- scripts/upstream-watch.mjs | 4 ++++ tests/kit/upstream-watch-query.test.mjs | 11 +++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/upstream-watch.md b/docs/upstream-watch.md index bbad0c07..32be1c39 100644 --- a/docs/upstream-watch.md +++ b/docs/upstream-watch.md @@ -107,8 +107,8 @@ Every command also takes `--concurrency <1-16>` (default 4) and `--registry { }); }); +test('ledger fails visibly on an invalid registry without reading the ledger', async () => { + await withRegistryFile([entry('ruvnet/ruflo#3153', { status: 'done' })], async (file) => { + let reads = 0; + const result = await query(file, [], { read: async () => { reads++; throw new Error('unexpected read'); } }); + assert.equal(result.code, 3); + assert.equal(reads, 0); + assert.match(result.err, /upstream registry is invalid/); + assert.doesNotMatch(result.out, /No report/); + }); +}); + test('the fetcher reads the last successful scheduled watch run from the Actions API', async () => { const calls = []; const exec = async (command, args) => { From 90c894b37901255ebeda019137e7596f530362a0 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:33:00 -0700 Subject: [PATCH 11/15] docs(upstream-watch): remove stale Codex reprobe instruction --- src/lib/hook-audit/agentic-dependency-constraints.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index 8f51ef94..ff0af7e5 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -287,7 +287,7 @@ "refs": ["closed-upstream review 2026-09-26"], "files": ["docs/adr/0034-schema-native-handoffs-and-hermetic-seats.md", "src/lib/execution/codex.mjs"] }, - "adjustment": "None: closed 2026-04-03 as model behaviour without a fix; parseHandoffText is the validation wrapper the maintainer recommended. Re-probe --output-schema with MCP on Codex upgrades (last probed on 0.149.1).", + "adjustment": "None: closed 2026-04-03 as model behaviour without a fix; parseHandoffText is the validation wrapper the maintainer recommended.", "status": "retired", "constraintIds": [], "history": [ From 87550eee9b82d2471065b6291481f01d9fc8c451 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:34:16 -0700 Subject: [PATCH 12/15] fix(upstream-watch): format exhausted firing sessions as a list --- scripts/upstream-watch/dispatch.mjs | 7 ++++++- tests/kit/upstream-watch-dispatch.test.mjs | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/upstream-watch/dispatch.mjs b/scripts/upstream-watch/dispatch.mjs index 95611e28..f3bebe1e 100644 --- a/scripts/upstream-watch/dispatch.mjs +++ b/scripts/upstream-watch/dispatch.mjs @@ -20,6 +20,11 @@ const ROUTINE = /^trig_[A-Za-z0-9]+$/; const DISPATCH_BRANCH = /^upstream\/[a-z0-9._-]+$/; const DAY = 86_400_000; +export function sessionList(sessions) { + if (sessions.length < 3) return sessions.join(' and '); + return `${sessions.slice(0, -1).join(', ')}, and ${sessions.at(-1)}`; +} + export function createDispatcher({ exec = run, fetchImpl = globalThis.fetch, env = process.env } = {}) { return { async branchExists(branch) { @@ -69,7 +74,7 @@ export async function dispatch({ released, records, dispatcher, repo, sentinel, if (await dispatcher.branchExists(branch)) continue; const firings = recordsOf(event.id, 'fired'); if (firings.length >= MAX_FIRES) { - errors.push({ id: event.id, error: `dispatch did not complete after ${firings.length} firings; see ${firings.map((item) => item.fields.session).join(' and ')}` }); + errors.push({ id: event.id, error: `dispatch did not complete after ${firings.length} firings; see ${sessionList(firings.map((item) => item.fields.session))}` }); continue; } const newest = Math.max(...firings.map((item) => Date.parse(item.recordedAt)), 0); diff --git a/tests/kit/upstream-watch-dispatch.test.mjs b/tests/kit/upstream-watch-dispatch.test.mjs index 742504fa..11ee674c 100644 --- a/tests/kit/upstream-watch-dispatch.test.mjs +++ b/tests/kit/upstream-watch-dispatch.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { eventLine } from '../../scripts/upstream-watch/classify.mjs'; -import { FIRE_HEADERS, FIRE_URL, PR_OBSERVE_DAYS, SAME_REPO_PR, createDispatcher, dispatch } from '../../scripts/upstream-watch/dispatch.mjs'; +import { FIRE_HEADERS, FIRE_URL, PR_OBSERVE_DAYS, SAME_REPO_PR, createDispatcher, dispatch, sessionList } from '../../scripts/upstream-watch/dispatch.mjs'; const NOW = new Date('2026-10-02T14:17:00Z'); const RECORDED_AT = '2026-10-02T14:17:00Z'; @@ -29,6 +29,12 @@ function fakeDispatcher({ exists = false, pr = null, session = 'https://claude.a const run = (dispatcher, records = [], { dryRun, list = [released], eligibleIds = new Set([ID]), now = NOW } = {}) => dispatch({ released: list, records, dispatcher, repo: 'pacphi/agentic-kit', sentinel: 'UPSTREAM-WATCH', now, recordedAt: RECORDED_AT, dryRun, eligibleIds }); const NOTHING = { records: [], errors: [], wouldFire: [] }; +test('exhausted firing links read naturally at any count', () => { + assert.equal(sessionList(['one']), 'one'); + assert.equal(sessionList(['one', 'two']), 'one and two'); + assert.equal(sessionList(['one', 'two', 'three']), 'one, two, and three'); +}); + test('a released fix without a branch fires once and is recorded with its session', async () => { const dispatcher = fakeDispatcher(); const result = await run(dispatcher); From aebe2ae6136cf37687a52239a39d61bc45daa263 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:38:52 -0700 Subject: [PATCH 13/15] fix(watch): require a commit before sending a notice --- .github/workflows/upstream-watch.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml index 656d67c1..83ced377 100644 --- a/.github/workflows/upstream-watch.yml +++ b/.github/workflows/upstream-watch.yml @@ -120,7 +120,8 @@ jobs: if: env.RECORD == 'true' run: | [ "$(jq -r '.notice.post' watch.json)" = true ] || { echo 'Nothing needs the maintainer.'; exit 0; } - commit=$(jq -r '.commit' watch.json) + commit=$(jq -r '.commit // empty' watch.json) + [ -n "$commit" ] || { echo 'Notice requested without a ledger commit.' >&2; exit 1; } jq -r '.notice.body' watch.json > notice.md test -s notice.md gh api "repos/$GITHUB_REPOSITORY/commits/$commit/comments" -F body=@notice.md --jq .html_url From b75c1e3fec4f645700f0f6d18220956867b7e5d0 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 05:38:58 -0700 Subject: [PATCH 14/15] fix(watch): explain blind workflow summaries --- .github/workflows/upstream-watch.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml index 83ced377..907a0cda 100644 --- a/.github/workflows/upstream-watch.yml +++ b/.github/workflows/upstream-watch.yml @@ -58,7 +58,7 @@ jobs: set -e { echo "## Upstream watch preview (exit $code)" - jq -r '"since \(.since) (\(.sinceSource)), new records \(.records | length), could not check \(.fetchErrors | length), blind \(.blind), notice \(.notice.post), would fire \(.wouldFire | length)"' watch.json + jq -r 'if .blind then "blind \(.blind): \(.error // "unknown error"), could not check \(.fetchErrors | length)" else "since \(.since) (\(.sinceSource)), new records \(.records | length), could not check \(.fetchErrors | length), blind \(.blind), notice \(.notice.post), would fire \(.wouldFire | length)" end' watch.json jq -r '(.wouldFire // [])[] | "- would fire \(.id) \(.version) \(.branch)"' watch.json echo; echo '```text'; cat errors.txt; echo '```' } >> "$GITHUB_STEP_SUMMARY" @@ -102,7 +102,7 @@ jobs: set -e { echo "## Upstream watch (exit $code)" - jq -r '"since \(.since) (\(.sinceSource)), new records \(.records | length), could not check \(.fetchErrors | length), dispatch errors \(.dispatchErrors | length), blind \(.blind), commit \(.commit // "none"), would fire \(.wouldFire | length)"' watch.json + jq -r 'if .blind then "blind \(.blind): \(.error // "unknown error"), could not check \(.fetchErrors | length), dispatch errors \(.dispatchErrors | length)" else "since \(.since) (\(.sinceSource)), new records \(.records | length), could not check \(.fetchErrors | length), dispatch errors \(.dispatchErrors | length), blind \(.blind), commit \(.commit // "none"), would fire \(.wouldFire | length)" end' watch.json jq -r '(.wouldFire // [])[] | "- would fire \(.id) \(.version) \(.branch)"' watch.json echo; echo '```text'; cat errors.txt; echo '```' jq -r '.notice.body' watch.json From e8751224857f1301a350c8fa6e51dfc999c81206 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 29 Sep 2026 06:41:21 -0700 Subject: [PATCH 15/15] docs(watch): archive completed follow-up plan --- ...26-09-29-plan-upstream-watch-followups.md} | 20 +++++++++++++++++++ docs/archive/README.md | 1 + 2 files changed, 21 insertions(+) rename docs/{plans/2026-09-29-upstream-watch-followups.md => archive/2026-09-29-plan-upstream-watch-followups.md} (55%) diff --git a/docs/plans/2026-09-29-upstream-watch-followups.md b/docs/archive/2026-09-29-plan-upstream-watch-followups.md similarity index 55% rename from docs/plans/2026-09-29-upstream-watch-followups.md rename to docs/archive/2026-09-29-plan-upstream-watch-followups.md index 7e22e491..ae6d6e52 100644 --- a/docs/plans/2026-09-29-upstream-watch-followups.md +++ b/docs/archive/2026-09-29-plan-upstream-watch-followups.md @@ -1,5 +1,25 @@ # Upstream watch followups +## Status at archival + +Implemented and independently reviewed on `fix/upstream-watch-followups` at +`b75c1e3fec4f645700f0f6d18220956867b7e5d0`. All eight local gates passed at that +source: unit/legacy tests, browser UI, typecheck, lint, complexity, Markdown, +build, and offline links. Controller workflow commits `aebe2ae6` and `b75c1e3f` +complete items 11/12; their shell and jq behavior was independently exercised. +Green `develop@84307574` was subsequently merged at `ff1eff27` without conflicts. +Final-head PR CI and merge remain pending at capture; no real dispatch or +notification was performed for this lane. + +M7 distinguishes deterministic validation failures from transient retries. +M8 bounds eligible fired-PR polling to seven days after the latest firing, +retaining the ledger; a later PR requires manual reconciliation. All twelve +minors have a fix, regression proof of existing behavior, or explicit no-change +disposition. M10 remains declined. Current behavior is documented in +[Upstream watch](../upstream-watch.md). + +## Execution and acceptance + Scope: M7, M8, and the twelve numbered deferred minors in the recovered PR #253 report. M10 is declined because the numeric PR field matches emitted records. This lane owns watcher scripts, focused tests, one Codex registry entry, and current watcher documentation. The integration owner owns workflow edits. 1. Add focused failing tests for deterministic retry failures, bounded dispatch polling, ledger errors, and the numbered edge cases. Keep synthetic fetch, dispatch, and git boundaries. diff --git a/docs/archive/README.md b/docs/archive/README.md index 614a56e4..559d1038 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -68,6 +68,7 @@ reconfirmed by this metadata audit. The per-file inventory and limitations are r | File | Original location | What it was | Why it's historical | |---|---|---|---| +| [2026-09-29-plan-upstream-watch-followups.md](2026-09-29-plan-upstream-watch-followups.md) | `docs/plans/2026-09-29-upstream-watch-followups.md` | V4 C4 execution plan for bounded PR polling, deterministic retry failures and twelve deferred watcher minors. | Implementation and independent review complete; all eight local gates passed at `b75c1e3f`. Final PR CI and merge were pending at archival. Current contract: [Upstream watch](../upstream-watch.md). | | [2026-06-upstream-findings-f1-f6.md](2026-06-upstream-findings-f1-f6.md) | `docs/upstream/ruflo-self-improvement-findings.md` | The F1–F6 findings series: proofs/refutations of ruflo's self-improvement claims (Q-learning persistence, state-encoder collapse, SONA learn→inference wiring, native-training misreporting), with filed upstream issues. | Every finding is now fixed upstream: F2 in 3.10.6 ([#2222](https://github.com/ruvnet/ruflo/issues/2222)), F2b in 3.10.7, F3 in 3.10.11 ([#2239](https://github.com/ruvnet/ruflo/issues/2239)), F4 in `@ruvector/ruvllm` 2.5.6 ([RuVector#519](https://github.com/ruvnet/RuVector/issues/519)), F6 in 3.18.1/3.19.0 + ruvllm 2.5.7 ([#2549](https://github.com/ruvnet/ruflo/issues/2549), closed 2026-07-03). | | [2026-06-token-consumption-incident.md](2026-06-token-consumption-incident.md) | `docs/usage/token-consumption-findings-and-mitigation-2026-06.md` | Root-cause report for the June 2026 token-burn incident: six immortal auto-started daemons consumed ~8.1B tokens over 7 days via headless worker sessions. Produced the opt-in daemon policy, TTL reaper, ⚙ statusline alarm, and `ruflo-token-audit`. | The root cause was fixed upstream in ruflo 3.27/3.28 ([#2661](https://github.com/ruvnet/ruflo/issues/2661)): AI workers are opt-in, launches are governed by a machine-wide budget with telemetry, one supervisor daemon per repo, native daemon TTL. The kit's daemon policy flipped back to default-on (local-only workers) on that baseline; the reapers and token-audit remain as an independent check. | | [2026-06-11-token-consumption-recurrence.md](2026-06-11-token-consumption-recurrence.md) | `docs/usage/token-consumption-recurrence-and-cleanup-2026-06-11.md` | Follow-up audit 10 days later: 17 daemons had accumulated but the TTL auto-reaper had already contained them; cleanup of daemon-state files, logs, and two plugin MCP servers. | Same incident class as above — governed upstream since 3.27/3.28. Kept as evidence the TTL-reaper safety net worked. |