diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml index 656d67c1..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 @@ -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 diff --git a/docs/archive/2026-09-29-plan-upstream-watch-followups.md b/docs/archive/2026-09-29-plan-upstream-watch-followups.md new file mode 100644 index 00000000..ae6d6e52 --- /dev/null +++ b/docs/archive/2026-09-29-plan-upstream-watch-followups.md @@ -0,0 +1,30 @@ +# 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. +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/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. | diff --git a/docs/upstream-watch.md b/docs/upstream-watch.md index 5857fc88..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 ] [--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 {} @@ -248,8 +247,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, }; @@ -286,7 +285,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; @@ -333,7 +333,11 @@ 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 }); + } + if (options.command === 'ledger') { + if (options.json) stdout.write(`${JSON.stringify({ registry: status }, null, 2)}\n`); + return BLIND; } 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/scripts/upstream-watch/dispatch.mjs b/scripts/upstream-watch/dispatch.mjs index 005e17d8..f3bebe1e 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 @@ -19,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) { @@ -56,7 +62,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 = []; @@ -68,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); @@ -85,6 +91,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/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/scripts/upstream-watch/ledger.mjs b/scripts/upstream-watch/ledger.mjs index 88a27467..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'); /** @@ -80,10 +83,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/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": [ diff --git a/tests/kit/upstream-watch-dispatch.test.mjs b/tests/kit/upstream-watch-dispatch.test.mjs index d6c6446a..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, 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'; @@ -26,9 +26,15 @@ 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('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); @@ -108,6 +114,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' }) }; }; 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-ledger-branch.test.mjs b/tests/kit/upstream-watch-ledger-branch.test.mjs index e7447970..231f2eaa 100644 --- a/tests/kit/upstream-watch-ledger-branch.test.mjs +++ b/tests/kit/upstream-watch-ledger-branch.test.mjs @@ -61,6 +61,33 @@ 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('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/); diff --git a/tests/kit/upstream-watch-notice.test.mjs b/tests/kit/upstream-watch-notice.test.mjs index 7735d3ef..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, ''); @@ -46,6 +58,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 diff --git a/tests/kit/upstream-watch-query.test.mjs b/tests/kit/upstream-watch-query.test.mjs index c13b3506..b064d4b6 100644 --- a/tests/kit/upstream-watch-query.test.mjs +++ b/tests/kit/upstream-watch-query.test.mjs @@ -60,6 +60,17 @@ test('--recorded-since belongs to ledger only', async () => { }); }); +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) => { diff --git a/tests/kit/upstream-watch-record.test.mjs b/tests/kit/upstream-watch-record.test.mjs index c0f18243..6332946d 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 () => { @@ -159,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); }); }); @@ -181,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/); + }); +});