From 2f9672e0f68d221291d27019b874651a0de816f0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 21:46:29 -0700 Subject: [PATCH 1/6] Run review history work in an abortable worker thread ReviewService.load read Git history (synchronous Git children and an object-storage walk) and linked it on the HTTP server's event loop, for up to 30 s. During that time cancellation, shutdown, request timeouts and every other request were stuck. History reading and linking now run in one worker thread per load (runner/history-job.ts). readHistory spawns Git asynchronously and waits for each process to exit, so a cancel kills the running Git process; a worker that cannot read the cancel within 1 s is terminated. The load's deadline crosses threads as a relative budget counted from the worker's start. Store reads and writes stay on the server thread; a load whose inputs changed while the worker ran reads again, at most three reads in all. load and act are async, and a synchronous apply runs inside Store.userAction. Every load takes the request's or operation's abort signal. Merge admission, pre-merge preparation and merge replays recheck their trust and task generations after each new wait, in the same turn as the write they guard. A failure after a review command was applied now says outcomeUnknown, a load failure before it is not recorded under its action ID, and a question saved during shutdown still gets its retryable answer. Browser specs ran the server in the Playwright process and relied on the old blocking loads to sequence steps. They now wait for the page's review load or refresh, and the Playwright timeouts allow for the server's real Git work. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- core/path-identity.ts | 16 + docs/implementation/read-only-review.md | 14 + git/history.ts | 67 ++-- playwright.config.ts | 4 +- runner/history-job.ts | 93 ++++++ runner/history-work.ts | 24 ++ runner/history-worker.ts | 17 + runner/merge.ts | 36 +- runner/pre-merge.ts | 57 ++-- runner/questions.ts | 24 +- runner/review.ts | 87 +++-- scripts/plant.ts | 6 +- test/browser/issues.spec.ts | 45 +-- test/browser/plans-authoring.spec.ts | 17 +- test/browser/plans-production.spec.ts | 3 +- test/browser/plans.spec.ts | 165 ++++----- test/browser/review-page.ts | 30 ++ test/browser/review.spec.ts | 242 +++++++------- test/fixtures/gated-git.ts | 46 +++ test/fixtures/history-worker-busy.ts | 6 + test/fixtures/history-worker-silent.ts | 3 + test/fixtures/history-worker-slow-start.ts | 7 + test/history-job.test.ts | 372 +++++++++++++++++++++ test/history-spawn.test.ts | 20 ++ test/history.test.ts | 184 +++++----- test/merge-task-pr.test.ts | 37 +- test/merge.test.ts | 35 +- test/plant.test.ts | 30 +- test/pre-merge.test.ts | 191 +++++++++-- test/question-agent.test.ts | 2 +- test/questions.test.ts | 48 +-- test/review.test.ts | 153 ++++----- test/runner-planning-feedback.test.ts | 2 +- test/runner-production.test.ts | 2 +- test/runner-publishing.test.ts | 4 +- test/runner-shutdown.test.ts | 8 +- test/runner-start.test.ts | 18 +- test/runner-workspace.test.ts | 6 +- test/store.test.ts | 6 +- web/server.ts | 65 ++-- 41 files changed, 1551 insertions(+), 645 deletions(-) create mode 100644 core/path-identity.ts create mode 100644 runner/history-job.ts create mode 100644 runner/history-work.ts create mode 100644 runner/history-worker.ts create mode 100644 test/browser/review-page.ts create mode 100644 test/fixtures/gated-git.ts create mode 100644 test/fixtures/history-worker-busy.ts create mode 100644 test/fixtures/history-worker-silent.ts create mode 100644 test/fixtures/history-worker-slow-start.ts create mode 100644 test/history-job.test.ts create mode 100644 test/history-spawn.test.ts diff --git a/README.md b/README.md index 55af6a7c..8aa4776f 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ const { plan, warnings } = importPlan(planText, 'yaml', { allowedCommands: [['npm', 'test']], issue: 412, }, nextRevision); -const history = readHistory(repoPath, baseCommit, headCommit); +const history = await readHistory(repoPath, baseCommit, headCommit); const segments = linkHistory(plan, history, trustedCommitLedger, checkoutPathKey); ``` @@ -82,7 +82,7 @@ Inputs such as `planText` and the ledger must come from the trusted runner. `run ## Current limits and safety -- History must be linear and descend from the requested base. Merge histories are rejected with a rebase instruction; repositories using grafts, shallow ancestry, object alternates, or symlinked object storage are rejected (storage inspection is limited to 100,000 entries). Reads are bounded to 500 commits, 32 MiB per Git response, 64 MiB of unique blob bytes across the history (callers may lower `maxBlobBytes`), 8 MiB of cumulative diff output, 20,000 cumulative file records (including the final diff), and a shared 30-second monotonic deadline for a read. Git children receive only the remaining time and are killed on timeout. Filesystem inspections check the deadline between operations; a blocked filesystem syscall still requires an external worker supervisor. Oversized work fails explicitly. +- History must be linear and descend from the requested base. Merge histories are rejected with a rebase instruction; repositories using grafts, shallow ancestry, object alternates, or symlinked object storage are rejected (storage inspection is limited to 100,000 entries). Reads are bounded to 500 commits, 32 MiB per Git response, 64 MiB of unique blob bytes across the history (callers may lower `maxBlobBytes`), 8 MiB of cumulative diff output, 20,000 cumulative file records (including the final diff), and a shared 30-second monotonic deadline for a read. Git runs as asynchronous child processes that receive only the remaining time; a timeout or an abort `signal` kills the running one, and the read settles only after it has exited. Filesystem inspections check the deadline between operations. The review server runs history reads and linking in a worker thread (`runner/history-job.ts`), so they never block it; a filesystem syscall that blocks still holds that worker until it returns. Oversized work fails explicitly. - Linking separately bounds cumulative split lines and candidate segments to 100,000 each, reference/work operations to 1,000,000, and text/origin strings to 32 Mi UTF-16 code units. It checks before expanding lines/origin sets; grouping no longer repeatedly splits accumulated content. Callers may lower these budgets. Linking has a 30-second overall deadline and each line diff uses at most 2 seconds or the remaining total, whichever is smaller. - The caller selects and trusts the repository and its Git administrative directory. Normal Git discovery, linked-worktree gitfiles, and symlinked gitdirs are supported; object-storage links and alternates inside that selected gitdir are rejected. This adapter is not a filesystem-containment boundary for untrusted repository roots. - Git administrative metadata and object storage must remain unchanged during a read; these library checks do not isolate a concurrently hostile filesystem. diff --git a/core/path-identity.ts b/core/path-identity.ts new file mode 100644 index 00000000..987770f0 --- /dev/null +++ b/core/path-identity.ts @@ -0,0 +1,16 @@ +/** How the checkout compares paths. Linking needs it to decide whether two paths name the same file. */ +export interface PathIdentity { caseSensitive: boolean; unicodeNormalization: 'none' | 'NFC' } + +export function validPathIdentity(identity: PathIdentity | undefined): identity is PathIdentity { + return typeof identity?.caseSensitive === 'boolean' && ['none', 'NFC'].includes(identity.unicodeNormalization); +} + +/** The comparison key of a path under a checkout's path identity. */ +export function pathKeyFor(identity: PathIdentity): (path: string) => string { + if (!validPathIdentity(identity)) throw new Error('Known checkout path identity is required.'); + return path => { + if (!identity.caseSensitive && /[^\x20-\x7e]/.test(path)) throw new Error('Non-ASCII case-insensitive paths require a filesystem-specific identity adapter.'); + const normalized = identity.unicodeNormalization === 'NFC' ? path.normalize('NFC') : path; + return identity.caseSensitive ? normalized : normalized.toLowerCase(); + }; +} diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md index 3e553e86..7e44e315 100644 --- a/docs/implementation/read-only-review.md +++ b/docs/implementation/read-only-review.md @@ -130,3 +130,17 @@ The answer polling endpoint previously called `ReviewService.load()` every two s The displayed review remains the owner of note text, ordering, snapshot, plan revision, assignment, and reference-validity metadata. Poll responses update only `answer` and `answerActive` for IDs already displayed. The existing generation guard rejects responses started before an action or Refresh, and unknown note IDs are ignored. Explicit Refresh and mutating actions still call the full review service to validate current repository state. The focused browser regression observes a completed answer and its durable SQLite state with zero review loads, verifies that the status payload omits note text, then proves explicit Refresh still performs a full load. Existing regressions continue to cover a poll returning after a newer submission and cancellation remaining active after a persisted attempt fails. + +## History work off the server thread + +Review loading used to run history reading and linking on the server's event loop. Git ran as synchronous child processes and object storage was walked synchronously, so one load could stop every other request, cancellation and shutdown for up to its 30-second deadline. + +`ReviewService.load` now runs both in a worker thread (`runner/history-job.ts`, `runner/history-worker.ts`). The rest of the load (Store reads and writes, approval states and the token) stays on the server thread. + +- **Lifecycle.** A job is running until its worker replies, fails or exits. Its promise settles only after the worker thread has ended. +- **Deadline.** What the load's overall deadline leaves is the worker's budget, counted from the worker's start: the history read gets what remains when it starts, and linking gets what the read left. If the worker has not answered one second after its budget, the server cancels it; until the worker reports that it has started, it also gets five seconds to start. The load still refuses a result that arrives after its own overall deadline. +- **Cancellation.** An abort (a request ending at shutdown, a merge or preparation deadline) sends the worker a cancel. The worker kills its running Git process, waits for it to exit, and replies. A worker that has not replied within one second is terminated: one in synchronous work (linking, blob decoding, the object storage walk), which runs no Git process, or, rarely, one whose killed Git process is stuck in the kernel, which then outlives the load. The load rejects with the abort's own reason. +- **Staleness.** The plan, snapshot, review version and ledger ownership are read before the worker starts. Every ledger write records a new snapshot, so an unchanged review version, plan revision and snapshot after the worker mean its inputs are current. If they changed (another load recorded a new HEAD, or a write landed), the load reads the history again from the current state, in at most three reads in all within its deadline, and is refused as stale only after that. +- **Review commands.** A command applies, synchronously, to a review loaded just before it (inside the recorded action when it has an action ID). Its writes are guarded by that review's state. A load failure before the command is not recorded under its action ID, so the same ID can be resent. If the reload after the command was applied fails, or shutdown stops it, the refusal says `outcomeUnknown`, and a question saved during shutdown still gets its retryable "Server stopped" answer. +- **Merge and preparation.** Every merge load takes the click's operation signal; a replayed click's status refresh takes the coordinator's shutdown signal, and shutdown waits for it. Every preparation load takes the preparation's signal. Merge admission loads the review before its final local trust and token checks, preparation checks its generations and local trust again after its final load, and its generations again after reading the old history it rebases and before it records a moved pull request head. Each write is either in the same turn as those checks, or (a rebase, after its authorization read) guarded again by the Store in its own transaction. + diff --git a/git/history.ts b/git/history.ts index 5264b292..df611e70 100644 --- a/git/history.ts +++ b/git/history.ts @@ -1,11 +1,16 @@ import { imageSize } from 'image-size'; -import { execFileSync } from 'node:child_process'; +import { execFile } from 'node:child_process'; import { lstatSync, opendirSync } from 'node:fs'; import { resolve as resolvePath, join } from 'node:path'; import type { FileDelta, FileVersion, History } from '../core/linking.ts'; -/** Read-only Git adapter. Never follows working-tree symlinks or runs diff helpers. */ -export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', limits: { maxBlobBytes?: number; maxObjectEntries?: number; maxDiffBytes?: number; maxFileEntries?: number; maxDurationMs?: number } = {}): History { +export interface HistoryLimits { maxBlobBytes?: number; maxObjectEntries?: number; maxDiffBytes?: number; maxFileEntries?: number; maxDurationMs?: number } +/** + * Read-only Git adapter. Never follows working-tree symlinks or runs diff helpers. Git runs as asynchronous child + * processes: `signal` kills the running one, and the read settles only after that process has exited. + */ +export async function readHistory(repo: string, baseRef: string, headRef = 'HEAD', limits: HistoryLimits = {}, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); const maxDurationMs = limits.maxDurationMs ?? 30_000; if (!Number.isSafeInteger(maxDurationMs) || maxDurationMs < 1 || maxDurationMs > 30_000) throw new Error('Duration budget must be a positive integer no larger than 30000 ms.'); const deadline = performance.now() + maxDurationMs; @@ -28,35 +33,43 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim if (diffBytes > maxDiffBytes) throw new Error('Review history exceeds the cumulative diff byte budget; choose a narrower base.'); return data; }; - const run = (...args: string[]) => { + const run = async (...args: string[]): Promise => { const timeout = remaining(); - let result: Buffer; - try { - result = execFileSync('git', ['--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'protocol.allow=never', ...args], { - cwd: repo, maxBuffer: 32 * 1024 * 1024, timeout, killSignal: 'SIGKILL', + // execFile reports an abort, the timeout or a full buffer at once, while the child it is killing may still run. The + // child's `close` comes after it has exited, so wait for both; a child that never started (no PID) has nothing to + // wait for, and Node may not emit `close` for every failed spawn. + const { error, stdout } = await new Promise<{ error: Error | null; stdout: Buffer }>(resolve => { + let result: { error: Error | null; stdout: Buffer } | null = null, closed = false; + const child = execFile('git', ['--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'protocol.allow=never', ...args], { + cwd: repo, maxBuffer: 32 * 1024 * 1024, timeout, killSignal: 'SIGKILL', encoding: 'buffer', ...(signal ? { signal } : {}), // Allowlisted: inherited variables can redirect repository, index, config and object lookup, and Git reads // variables outside the GIT_ namespace too, such as HOME and XDG_CONFIG_HOME for user attributes. env: { PATH: process.env.PATH ?? '', GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_GRAFT_FILE: '/dev/null' }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (error) { - if ((error as { code?: string }).code === 'ETIMEDOUT') throw new Error('History read exceeded its overall deadline.', { cause: error }); + }, (failure, output) => { result = { error: failure, stdout: output }; if (closed || child.pid === undefined) resolve(result); }); + child.once('close', () => { closed = true; if (result) resolve(result); }); + // No Git command here reads input; close it as the synchronous adapter's ignored stdin did. + child.stdin?.end(); + }); + if (error) { + if (signal?.aborted) throw signal.reason; + const failure = error as { killed?: boolean; code?: unknown }; + if (failure.killed && failure.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') throw new Error('History read exceeded its overall deadline.', { cause: error }); throw error; } remaining(); - return result; + return stdout; }; // Parent-rewriting files are not immutable commit ancestry. Resolve via Git so // linked worktrees use the common administrative directory as well. - const commonDirectory = resolvePath(repo, run('rev-parse', '--git-common-dir').toString().trim()); + const commonDirectory = resolvePath(repo, (await run('rev-parse', '--git-common-dir')).toString().trim()); for (const name of ['info/grafts', 'shallow']) { const path = join(commonDirectory, name); if (lstatSync(path, { throwIfNoEntry: false })) throw new Error(`Review repositories must not use graft or shallow ancestry metadata (${name}).`); } // Inspect storage without following links before any object-resolving command. - const objects = resolvePath(repo, run('rev-parse', '--git-path', 'objects').toString().trim()); + const objects = resolvePath(repo, (await run('rev-parse', '--git-path', 'objects')).toString().trim()); const pending = [objects]; let inspected = 0; while (pending.length) { @@ -78,9 +91,9 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim } const alternates = join(objects, 'info/alternates'); if (lstatSync(alternates, { throwIfNoEntry: false })) throw new Error('Review repositories must not use object alternates.'); - const resolve = (ref: string) => run('rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`).toString().trim(); - const base = resolve(baseRef), head = resolve(headRef); - const records = run('rev-list', '--reverse', '--parents', `${base}..${head}`).toString().trim().split('\n').filter(Boolean); + const resolve = async (ref: string) => (await run('rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`)).toString().trim(); + const base = await resolve(baseRef), head = await resolve(headRef); + const records = (await run('rev-list', '--reverse', '--parents', `${base}..${head}`)).toString().trim().split('\n').filter(Boolean); if (records.length > 500) throw new Error('Review history exceeds 500 commits; choose a narrower base.'); let expectedParent = base; const commits = records.map(record => { @@ -92,13 +105,13 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim if (expectedParent !== head) throw new Error('The base must be an ancestor of the head.'); const blobs = new Map(); let previewBytes = 0, previewPixels = 0; - const version = (oid: string, mode: string): FileVersion | null => { + const version = async (oid: string, mode: string): Promise => { if (/^0+$/.test(oid)) return null; if (mode === '160000') return { oid, mode, text: null }; // gitlink is not a local blob if (!blobs.has(oid)) { - const size = Number(run('cat-file', '-s', oid).toString().trim()); + const size = Number((await run('cat-file', '-s', oid)).toString().trim()); if (!Number.isSafeInteger(size) || size < 0 || size > maxBlobBytes - blobBytes) throw new Error('Review history exceeds the cumulative blob byte budget; choose a narrower base.'); - const data = run('cat-file', 'blob', oid); + const data = await run('cat-file', 'blob', oid); blobBytes += data.length; let text: string | null = null; if (!data.includes(0)) { try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data); } catch { /* binary */ } } @@ -122,8 +135,8 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim } return { oid, mode, ...blobs.get(oid)! }; }; - const diff = (from: string, to: string, contexts: boolean): FileDelta[] => { - const raw = accountDiff(run('diff', '--ignore-submodules=none', '--no-relative', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv', '-M', from, to, '--')); + const diff = async (from: string, to: string, contexts: boolean): Promise => { + const raw = accountDiff(await run('diff', '--ignore-submodules=none', '--no-relative', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv', '-M', from, to, '--')); const fields = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw).split('\0'); const result: FileDelta[] = []; for (let i = 0; i < fields.length && fields[i];) { @@ -135,12 +148,12 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim const first = fields[i++]!; const oldPath = status === 'A' ? null : first; const newPath = status === 'D' ? null : status === 'R' ? fields[i++]! : first; - const before = version(oldOid!, oldMode!), after = version(newOid!, newMode!); + const before = await version(oldOid!, oldMode!), after = await version(newOid!, newMode!); const ranges: FileDelta['contexts'] = []; if (contexts && (before?.text !== null || after?.text !== null)) { const paths = [...new Set([oldPath, newPath].filter((path): path is string => path !== null))]; // Literal pathspecs preserve filenames containing Git pathspec metacharacters. - const patch = accountDiff(run('diff', '--ignore-submodules=none', '--no-relative', '--no-ext-diff', '--no-textconv', '--no-color', '--unified=0', '-M', from, to, '--', ...paths.map(path => `:(literal)${path}`))).toString(); + const patch = accountDiff(await run('diff', '--ignore-submodules=none', '--no-relative', '--no-ext-diff', '--no-textconv', '--no-color', '--unified=0', '-M', from, to, '--', ...paths.map(path => `:(literal)${path}`))).toString(); for (const line of patch.split('\n')) { const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line); if (hunk) ranges.push({ oldStart: +hunk[1]!, oldCount: +(hunk[2] ?? 1), newStart: +hunk[3]!, newCount: +(hunk[4] ?? 1), name: hunk[5]!.trim() }); @@ -150,8 +163,8 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim } return result; }; - for (const commit of commits) commit.files = diff(commit.parent, commit.sha, false); - const final = diff(base, head, true); + for (const commit of commits) commit.files = await diff(commit.parent, commit.sha, false); + const final = await diff(base, head, true); remaining(); return { base, head, commits, final }; } diff --git a/playwright.config.ts b/playwright.config.ts index 79d8a1a7..08f130b7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,2 +1,4 @@ import { defineConfig } from '@playwright/test'; -export default defineConfig({ testDir: './test/browser', fullyParallel: false, workers: 1, use: { browserName:'chromium', viewport:{width:1512,height:982}, trace:'retain-on-failure' } }); +// The review server runs in the test process. Its review loads no longer block that process, so test and assertion +// timeouts now include the server's real Git work (a merge click runs several review loads); allow for it. +export default defineConfig({ testDir: './test/browser', fullyParallel: false, workers: 1, timeout: 60_000, expect: { timeout: 15_000 }, use: { browserName:'chromium', viewport:{width:1512,height:982}, trace:'retain-on-failure' } }); diff --git a/runner/history-job.ts b/runner/history-job.ts new file mode 100644 index 00000000..9f525768 --- /dev/null +++ b/runner/history-job.ts @@ -0,0 +1,93 @@ +import { Worker } from 'node:worker_threads'; +import type { HistoryLimits } from '../git/history.ts'; +import type { History, Segment } from '../core/linking.ts'; +import type { Plan } from '../core/plan.ts'; +import type { PathIdentity } from '../core/path-identity.ts'; + +/** + * One history read, and optionally its linking, for one worker thread. `budgetMs` is what the caller's overall deadline + * leaves when the job is sent; the worker enforces it from its own start as one budget for both stages. A relative + * budget, because threads share no monotonic clock: each `performance.now()` counts from its own thread's start, and + * `performance.timeOrigin` is wall-clock time, which a system sleep or clock step moves apart from them. + */ +export interface HistoryJob { + repository: string; base: string; head: string; budgetMs: number; + limits?: Omit; + link?: { plan: Plan; ownership: [string, string | null][]; pathIdentity: PathIdentity; resolvedConflicts: string[] }; +} +export type HistoryReply = { ok: true; history: History; segments: Segment[] | null } | { ok: false; error: string; code?: string }; +/** The worker's first message: its modules are loaded and its budget starts now. */ +export type HistoryStarted = { started: true }; +export type HistoryCancel = { type: 'cancel'; reason: string }; + +/** After a cancel, how long the worker may take to kill its Git process and reply before it is terminated. */ +export const HISTORY_CANCEL_GRACE_MS = 1_000; +/** + * Past the job's budget, how long the worker may take to report its own deadline error before it is stopped. The budget + * is counted from the worker's start message, so a slow thread start does not cut into it. + */ +export const HISTORY_DEADLINE_GRACE_MS = 1_000; +/** How long a worker may take to start (load its modules) before its budget is counted from its creation instead. */ +export const HISTORY_START_GRACE_MS = 5_000; + +const workerUrl = new URL('./history-worker.ts', import.meta.url); + +/** + * Runs a history job in its own worker thread, so Git processes, object storage inspection and linking never block + * the caller's event loop. + * + * Lifecycle: the job is running until the worker replies, fails or exits. An abort, or the budget passing without a + * reply, sends a cancel: the worker kills its running Git process, waits for it to exit, and replies. A worker that has + * not replied within the grace is terminated. That is the case for a worker in synchronous work (linking, blob + * decoding, the object storage walk), where it runs no Git process; and, rarely, for one whose killed Git process has + * not exited within the grace (a process in an uninterruptible kernel wait), which then outlives the job. The returned + * promise settles only after the worker thread has ended. An abort rejects with the signal's reason, unchanged. + */ +export function runHistoryJob(job: HistoryJob, signal?: AbortSignal, url: URL = workerUrl): Promise<{ history: History; segments: Segment[] | null }> { + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const worker = new Worker(url, { workerData: job }); + // The outcome is decided once (the first reply, failure or stop) and delivered once the thread has exited. + let outcome: (() => void) | null = null, delivered = false, stopping = false; + let deadlineTimer: ReturnType | undefined, graceTimer: ReturnType | undefined; + const decide = (value: () => void) => { outcome ??= value; }; + const deliver = () => { + if (delivered) return; + delivered = true; + clearTimeout(deadlineTimer); clearTimeout(graceTimer); + signal?.removeEventListener('abort', onAbort); + (outcome ?? (() => reject(new Error('History worker stopped without a reply.'))))(); + }; + // A reply is the worker's last act; terminate makes sure the thread ends and resolves once it has. + const end = () => { void worker.terminate().then(deliver, deliver); }; + const stop = (reason: unknown) => { + if (stopping || outcome) return; + stopping = true; + decide(() => reject(reason)); + worker.postMessage({ type: 'cancel', reason: reason instanceof Error ? reason.message : String(reason) } satisfies HistoryCancel); + graceTimer = setTimeout(end, HISTORY_CANCEL_GRACE_MS); + }; + const onAbort = () => stop(signal!.reason); + signal?.addEventListener('abort', onAbort, { once: true }); + const armDeadline = (startGraceMs: number) => { + clearTimeout(deadlineTimer); + deadlineTimer = setTimeout(() => stop(new Error('History work exceeded its overall deadline.')), startGraceMs + job.budgetMs + HISTORY_DEADLINE_GRACE_MS); + }; + armDeadline(HISTORY_START_GRACE_MS); + worker.on('message', (reply: HistoryReply | HistoryStarted) => { + if ('started' in reply) { if (!stopping && !outcome) armDeadline(0); return; } + decide(reply.ok ? () => resolve({ history: reply.history, segments: reply.segments }) + : () => reject(Object.assign(new Error(reply.error), reply.code ? { code: reply.code } : {}))); + end(); + }); + worker.on('error', error => { decide(() => reject(error)); end(); }); + worker.on('exit', code => { decide(() => reject(new Error(`History worker stopped (exit code ${code}).`))); deliver(); }); + }); +} + +/** `readHistory` in a worker thread, within `maxDurationMs` from now (at most 30 s, its own ceiling). */ +export async function readHistoryInWorker(repository: string, base: string, head: string, options: { maxDurationMs?: number; signal?: AbortSignal } = {}): Promise { + const maxDurationMs = options.maxDurationMs ?? 30_000; + if (!Number.isSafeInteger(maxDurationMs) || maxDurationMs < 1 || maxDurationMs > 30_000) throw new Error('Duration budget must be a positive integer no larger than 30000 ms.'); + return (await runHistoryJob({ repository, base, head, budgetMs: maxDurationMs }, options.signal)).history; +} diff --git a/runner/history-work.ts b/runner/history-work.ts new file mode 100644 index 00000000..31a38c59 --- /dev/null +++ b/runner/history-work.ts @@ -0,0 +1,24 @@ +import { readHistory } from '../git/history.ts'; +import { linkHistory, type History, type Segment } from '../core/linking.ts'; +import { pathKeyFor } from '../core/path-identity.ts'; +import type { HistoryJob } from './history-job.ts'; + +/** + * The work of one history job, as its worker runs it: the history read, then linking, within the job's one budget, + * counted from now on this thread's monotonic clock. Each stage gets what the budget leaves (at most 30 s, the stages' + * own ceiling), so a slow read leaves linking less. `signal` kills the running Git process. + */ +export async function runHistoryWork(job: HistoryJob, signal: AbortSignal, now: () => number = () => performance.now()): Promise<{ history: History; segments: Segment[] | null }> { + const deadline = now() + job.budgetMs; + const remaining = (what: string) => { + const ms = deadline - now(); + if (ms <= 0) throw new Error(`${what} exceeded its overall deadline.`); + return Math.min(30_000, Math.max(1, Math.ceil(ms))); + }; + const history = await readHistory(job.repository, job.base, job.head, { ...job.limits, maxDurationMs: remaining('History read') }, signal); + if (!job.link) return { history, segments: null }; + signal.throwIfAborted(); + const segments = linkHistory(job.link.plan, history, new Map(job.link.ownership), pathKeyFor(job.link.pathIdentity), + { maxDurationMs: remaining('Review load') }, new Set(job.link.resolvedConflicts)); + return { history, segments }; +} diff --git a/runner/history-worker.ts b/runner/history-worker.ts new file mode 100644 index 00000000..28885467 --- /dev/null +++ b/runner/history-worker.ts @@ -0,0 +1,17 @@ +import { parentPort, workerData } from 'node:worker_threads'; +import type { HistoryJob, HistoryReply, HistoryCancel, HistoryStarted } from './history-job.ts'; +import { runHistoryWork } from './history-work.ts'; + +// Review history reads run Git and walk object storage, and linking replays every changed line. Both run here so the +// review server's thread stays free; see history-job.ts for the protocol. +const controller = new AbortController(); +parentPort!.on('message', (message: HistoryCancel) => { if (message.type === 'cancel') controller.abort(new Error(message.reason)); }); +parentPort!.postMessage({ started: true } satisfies HistoryStarted); + +// Deferred a turn, so a cancel already posted with the job usually arrives before the first Git process starts; one +// that arrives later kills that process instead. +setImmediate(() => void runHistoryWork(workerData as HistoryJob, controller.signal).then( + ({ history, segments }) => parentPort!.postMessage({ ok: true, history, segments } satisfies HistoryReply), + (error: unknown) => parentPort!.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error), + ...(typeof (error as { code?: unknown })?.code === 'string' ? { code: (error as { code: string }).code } : {}) } satisfies HistoryReply), +).finally(() => parentPort!.close())); diff --git a/runner/merge.ts b/runner/merge.ts index e46f0e7e..992bcc4f 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -1,10 +1,9 @@ -import type { ReviewService } from './review.ts'; +import type { ReviewService, ReviewView } from './review.ts'; import { mergeActionResponse, type MergeAttempt } from './store.ts'; import { ActionIdReused, GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, assertUuidV4, settleWith, type ShutdownCapability } from './lifecycle.ts'; import { MERGE_INSPECTION_TIMEOUT_MS, MergeSubmissionError, type MergeGateway, type MergeQueueGateway, type MergeQueueObservation, type MergeResult, type MergeTarget, type RemoteMergeState } from '../github/merge.ts'; import { openingMarker } from '../github/pull-requests.ts'; -type ReviewView = ReturnType; type QueueGateway = MergeGateway & MergeQueueGateway; export interface MergeBlocker { code: string; message: string; } /** The longest a merge click may run before it is aborted; its last gh call's stop wait comes on top (github/merge.ts). */ @@ -62,6 +61,9 @@ export class MergeCoordinator { /** The click the active merge serves; a resend of it joins that merge before its action is saved. */ #activeClick: { actionId: string; token: string } | null = null; #abort: AbortController | null = null; + /** Ends the review loads of replayed clicks at shutdown; close() waits for those replays to settle. */ + #replayAbort = new AbortController(); + #replays = new Set>(); #queuePoll: Promise | null = null; #queueAbort: AbortController | null = null; #closing = false; @@ -158,8 +160,8 @@ export class MergeCoordinator { approval.revision === plan.revision && approval.snapshotId === snapshot.id && approval.reviewVersion !== undefined && approval.reviewVersion >= attempt.reviewVersion)); } - async status(view = this.service.load(), fresh = false, signal?: AbortSignal): Promise { - return (await this.#status(view, fresh, signal)).status; + async status(view?: ReviewView, fresh = false, signal?: AbortSignal): Promise { + return (await this.#status(view ?? await this.service.load({ signal }), fresh, signal)).status; } /** Fresh exact base/head pair for pre-merge preparation, using the same task-PR resolution as merge admission. */ @@ -256,7 +258,8 @@ export class MergeCoordinator { return null; } - async displayStatus(view = this.service.load(), signal?: AbortSignal): Promise { + async displayStatus(view?: ReviewView, signal?: AbortSignal): Promise { + view ??= await this.service.load({ signal }); try { return await this.status(view, false, signal); } catch (error) { if (error instanceof ShuttingDownError) throw error; @@ -360,9 +363,15 @@ export class MergeCoordinator { // The status refresh may reconcile the attempt (merged URL, queue failure), so answer from the record re-read after // it. A failed re-read is not answered with the older record: read() turns a storage error into "resend" (503). const current = () => read()?.response ?? saved.response; - let view: ReviewView; - try { view = this.service.load(); } catch (error) { return Promise.resolve().then(() => answer(current(), unavailable(error))); } - return this.displayStatus(view).then(status => answer(current(), status), error => answer(current(), unavailable(error))); + const signal = this.#replayAbort.signal; + const replayed = (async () => { + let status: MergeStatus | MergeUnavailableStatus; + try { status = await this.displayStatus(await this.service.load({ signal }), signal); } catch (error) { status = unavailable(error); } + return answer(current(), status); + })(); + const tracked = replayed.then(() => undefined, () => undefined); + this.#replays.add(tracked); void tracked.then(() => this.#replays.delete(tracked)); + return replayed; } async #merge(token: string, signal: AbortSignal, actionId?: string): Promise<{ status: MergeStatus | MergeUnavailableStatus; result: MergeResult }> { @@ -371,14 +380,14 @@ export class MergeCoordinator { try { // Captured when the request arrives and re-checked in the admission transaction after the final await. const taskStateVersion = this.service.store && this.service.config ? this.service.store.getTask(this.service.config.identity).stateVersion : null; - let view = this.service.load(); + let view = await this.service.load({ signal }); if (view.token !== token) throw new Error('Stale review state. Refresh before merging.'); // Capture the externally authorized issue identity before validation. Its final external refresh precedes the // final PR inspection; a synchronous local trust check then closes the admission window without another await. const authorization = this.#authorize ? await this.#authorize(signal) : null; const { status, resolved } = await this.#statusForMerge(view, signal); if (!status.ready) throw new Error(status.blockers[0]?.message ?? 'Merge is blocked.'); - view = this.service.load(); + view = await this.service.load({ signal }); if (view.token !== token) throw new Error('Review changed during merge validation. Refresh before merging.'); const { status: finalStatus, resolved: finalTarget } = await this.#statusForMerge(view, signal); // The same PR, opened by the same opening, in every pass (#121). @@ -426,8 +435,11 @@ export class MergeCoordinator { } // The external authorization read completed before the final PR inspection and queue boundary. Re-read local // trust synchronously after those awaits so neither remote identity nor local authorization can race admission. + // The review is loaded first: its history work is the last await, so the local trust check and the token check + // below run in the same turn as the attempt that begins after them. + const final = await this.service.load({ signal }); authorization?.validate(); - if (this.service.load().token !== token) throw new Error('Review changed during merge validation. Refresh before merging.'); + if (final.token !== token) throw new Error('Review changed during merge validation. Refresh before merging.'); if (signal.aborted) throw signal.reason; if (this.service.store && this.service.config && view.expected.reviewVersion !== undefined) { const { store, config } = this.service, reviewVersion = view.expected.reviewVersion; @@ -563,7 +575,9 @@ export class MergeCoordinator { const active = this.#active, poll = this.#queuePoll; this.#abort?.abort(new Error('Merge cancelled during shutdown.')); this.#queueAbort?.abort(new Error('Merge-queue inspection cancelled during shutdown.')); + this.#replayAbort.abort(new Error('Merge status refresh cancelled during shutdown.')); if (active) try { await active; } catch {} + await Promise.all(this.#replays); if (poll) try { await poll; } catch {} } } diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index adb8bf8d..89e79993 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -1,11 +1,12 @@ -import { readHistory } from '../git/history.ts'; +import type { History } from '../core/linking.ts'; +import { readHistoryInWorker } from './history-job.ts'; import { DEFAULT_PROCESS_SETTLEMENT_MS } from '../agents/process-group.ts'; import type { PlanIdentity } from '../core/identity.ts'; import type { RunnerCoordinator } from './coordinator.ts'; import { GuardRefusal, MERGEABLE_STATUSES, settleWith, type ShutdownCapability } from './lifecycle.ts'; import { MAX_REBASE_TIMEOUT_MS, MIN_REBASE_CLEANUP_TIMEOUT_MS, MIN_REBASE_TIMEOUT_MS, RebaseResourcesUnsettled, type GitRebaser } from './rebase.ts'; -import type { ReviewService } from './review.ts'; +import type { ReviewService, ReviewView } from './review.ts'; import type { PreMergeReadiness, RebaseMarker } from './store.ts'; export interface RemotePair { base: string; head: string } @@ -148,12 +149,13 @@ export class PreMergeCoordinator { return outcome; } - #refresh(pair: RemotePair, remaining: () => number, priorHead?: string) { - const historyOptions = () => ({ maxDurationMs: Math.min(30_000, remaining()) }); - const view = this.service.load(historyOptions()); + /** `beforeRecord` rechecks the caller's task and review generations, in the turn that records the refreshed pair. */ + async #refresh(pair: RemotePair, remaining: () => number, signal: AbortSignal, beforeRecord: () => void, priorHead?: string) { + const historyOptions = () => ({ maxDurationMs: Math.min(30_000, remaining()), signal }); + const view = await this.service.load(historyOptions()); const repository = this.service.reviewRepository().path; - let history: ReturnType; - try { history = readHistory(repository, pair.base, pair.head, historyOptions()); } + let history: History; + try { history = await readHistoryInWorker(repository, pair.base, pair.head, historyOptions()); } catch (error) { // A collaborator may push on the old remote head while the base moves (or while F5 retains an unpushed local // rebase). Review that head against the latest stored base it actually descended from; the next preparation can @@ -161,11 +163,11 @@ export class PreMergeCoordinator { // remains fail-closed. if (!priorHead || !(error instanceof Error) || !/linear history descended from the base|base must be an ancestor of the head/i.test(error.message)) throw error; - let recovered: ReturnType | null = null; + let recovered: History | null = null; for (const candidate of [priorHead, ...this.service.store.rewrittenAncestors(this.service.config.identity, priorHead)]) { const priorId = this.service.store.snapshotWithHead(this.service.config.identity, candidate); if (!priorId) continue; - try { recovered = readHistory(repository, this.service.store.getSnapshot(this.service.config.identity, priorId).base, + try { recovered = await readHistoryInWorker(repository, this.service.store.getSnapshot(this.service.config.identity, priorId).base, pair.head, historyOptions()); break; } catch (fallback) { if (!(fallback instanceof Error) @@ -175,10 +177,12 @@ export class PreMergeCoordinator { if (!recovered) throw error; history = recovered; } + // The reads above awaited workers; the task must still be the one the caller checked. + beforeRecord(); this.service.store.recordHistory(this.service.config.identity, view.expected, history.base, history.head, []); - return this.service.load(historyOptions()); + return await this.service.load(historyOptions()); } - #reviewBlocker(view: ReturnType, requireChecks = false): string | null { + #reviewBlocker(view: ReviewView, requireChecks = false): string | null { const invalidCommand = view.items.find(item => item.checks.tests === '✕ Invalid command'); if (invalidCommand) return `${invalidCommand.id} has an invalid command check; amend the plan before preparing the merge.`; const item = view.items.find(value => value.state !== 'approved' || value.outside.length); @@ -239,7 +243,7 @@ export class PreMergeCoordinator { await authorization.refresh(); authorization.validate(); signal.throwIfAborted(); remaining(); return authorization; }; - const track = >(current: T): T => { + const track = (current: T): T => { onObserved({ pair: { base: current.snapshot.base, head: current.snapshot.head }, binding: { stateVersion: this.service.store.getTask(identity).stateVersion, reviewVersion: this.service.store.reviewVersion(identity), snapshotId: current.snapshot.id, @@ -257,7 +261,7 @@ export class PreMergeCoordinator { } }); } catch { /* An unbound failure is conservatively historical. */ } }; - const load = () => track(this.service.load({ maxDurationMs: Math.min(30_000, reviewBudget()) })); + const load = async () => track(await this.service.load({ maxDurationMs: Math.min(30_000, reviewBudget()), signal })); signal.throwIfAborted(); if (this.runner.isActive(identity)) throw new GuardRefusal('An attempt is already active for this task.'); const runnerStatus = this.runner.status(identity); @@ -265,7 +269,7 @@ export class PreMergeCoordinator { throw new GuardRefusal('Runner cleanup is unresolved; restart and recover owned resources before preparing a merge.'); if (this.service.store.getTask(identity).rebaseInProgress !== null) throw new GuardRefusal('Rebase cleanup is unresolved; restart and recover the owned rebase before preparing a merge.'); - let view = load(), task = this.service.store.getTask(identity); + let view = await load(), task = this.service.store.getTask(identity); if (task.stateVersion !== expected.stateVersion || view.expected.reviewVersion !== expected.reviewVersion || view.snapshot.id !== expected.snapshotId) throw new GuardRefusal('The review changed before preparation started. Reload first.'); if (!MERGEABLE_STATUSES.includes(task.status)) throw new GuardRefusal(`The task is ${task.status}; prepare it from review.`); @@ -289,14 +293,16 @@ export class PreMergeCoordinator { const retainedRemoteHead = initial.head !== view.snapshot.head && this.service.store.isRewrittenHead(identity, initial.head, view.snapshot.head); if (initial.head !== view.snapshot.head && !retainedRemoteHead) { - view = track(this.#refresh(initial, reviewBudget, view.snapshot.head)); + view = track(await this.#refresh(initial, reviewBudget, signal, () => assertCurrent(expected), view.snapshot.head)); return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked: [], reason: 'The pull request head moved; attribution and approvals were refreshed.' }; } if (initial.base !== view.snapshot.base) { const repository = this.service.reviewRepository().path; - const old = readHistory(repository, view.snapshot.base, view.snapshot.head, - { maxDurationMs: Math.min(30_000, reviewBudget()) }).commits.map(commit => commit.sha); + const old = (await readHistoryInWorker(repository, view.snapshot.base, view.snapshot.head, + { maxDurationMs: Math.min(30_000, reviewBudget()), signal })).commits.map(commit => commit.sha); + // The read awaited a worker: the task the rebase binds to must still be the one checked above. + assertCurrent(expected); task = this.service.store.getTask(identity); const reviewed = { revision: view.expected.revision, snapshotId: view.expected.snapshotId, reviewVersion: view.expected.reviewVersion! }; @@ -326,7 +332,7 @@ export class PreMergeCoordinator { bindRebaseFailure(reviewed, { base: oldBase, head: oldHead }); throw error; } - view = load(); + view = await load(); } const blocker = this.#reviewBlocker(view); if (blocker) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, @@ -371,7 +377,7 @@ export class PreMergeCoordinator { throw new GuardRefusal('Command-check cleanup could not be confirmed; restart and recover owned resources before preparing a merge.'); if (settled.state !== 'completed') throw new GuardRefusal(settled.exitCode === null && settled.diagnostic ? settled.diagnostic : `${item.id} command checks did not pass.`); - checked.push(item.id); onChecked(checked); view = load(); + checked.push(item.id); onChecked(checked); view = await load(); const changed = this.#reviewBlocker(view); if (changed) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: changed }; } @@ -383,11 +389,11 @@ export class PreMergeCoordinator { assertCurrent(guarded); if (final.base !== initial.base || final.head !== initial.head) { await this.remote.fetch(final, signal); signal.throwIfAborted(); assertCurrent(guarded); - view = track(this.#refresh(final, reviewBudget, initial.head)); + view = track(await this.#refresh(final, reviewBudget, signal, () => assertCurrent(guarded), initial.head)); return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: 'The pull request moved during preparation; attribution and approvals were refreshed.' }; } - view = load(); + view = await load(); if (view.snapshot.base !== prepared.base || view.snapshot.head !== prepared.head) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: 'The local review changed during preparation; reload it.' }; @@ -398,7 +404,7 @@ export class PreMergeCoordinator { // Authorization is asynchronous. A review edit during that read invalidates its result just as one during the // final remote inspection does; nothing may persist readiness from the pre-authorization view. assertCurrent(guarded); - view = load(); + view = await load(); if (view.snapshot.base !== prepared.base || view.snapshot.head !== prepared.head) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: 'The local review changed during final authorization; reload it.' }; @@ -412,11 +418,14 @@ export class PreMergeCoordinator { authorization?.validate(); if (authorizedRemote.base !== initial.base || authorizedRemote.head !== initial.head) { await this.remote.fetch(authorizedRemote, signal); signal.throwIfAborted(); assertCurrent(guarded); - view = track(this.#refresh(authorizedRemote, reviewBudget, initial.head)); + view = track(await this.#refresh(authorizedRemote, reviewBudget, signal, () => assertCurrent(guarded), initial.head)); return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: 'The pull request moved during final authorization; attribution and approvals were refreshed.' }; } - view = load(); + view = await load(); + // That load awaited its history work: recheck every local generation and local trust again, in this turn. + assertCurrent(guarded); + authorization?.validate(); if (view.snapshot.base !== prepared.base || view.snapshot.head !== prepared.head) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: 'The local review changed after final authorization; reload it.' }; diff --git a/runner/questions.ts b/runner/questions.ts index fbe7a483..2709fe8f 100644 --- a/runner/questions.ts +++ b/runner/questions.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import type { ReviewService } from './review.ts'; +import type { ReviewService, ReviewView } from './review.ts'; import { QuestionWorker } from './question-agent.ts'; import { LeftoverLedger } from './question-leftovers.ts'; import { StopError, type QuestionScope } from './question-container.ts'; @@ -8,7 +8,7 @@ import { settleWith, type ShutdownCapability } from './lifecycle.ts'; export type QuestionAgent = (prompt: string, signal: AbortSignal, scope?: QuestionScope, timeoutMs?: number) => Promise; const QUESTION_TIMEOUT_MS = 120_000; const SHUTDOWN_SETTLE_MS = 20_000; -export function questionPrompt(view: ReturnType, note: ReviewNote): string { +export function questionPrompt(view: ReviewView, note: ReviewNote): string { let remaining = 100_000; const changes = view.segments.filter(s => s.row === note.item).map(s => { const content = s.content.slice(0, Math.max(0, remaining)); remaining -= content.length; @@ -40,16 +40,22 @@ export class Questions { get stopping() { return this.closing; } /** * A question saved by a request that was admitted before shutdown began: no agent (and no container worker) starts, - * but it gets a retryable failed answer, as it would had shutdown cancelled it. + * but it gets a retryable failed answer, as it would had shutdown cancelled it. `view` gives the answer its context + * ID; without one (shutdown stopped the request's review load), the note is read from the Store and the answer has no + * context ID. Like the other settlement writes, these still land after the Store write gate closes. */ - markStopped(id: string, view: ReturnType) { - const note = view.notes.find(n=>n.id===id && n.kind==='question'); + markStopped(id: string, view?: ReviewView) { + const identity=this.service.config.identity; + const note: { answer?: unknown; contextId?: string } | undefined = view ? view.notes.find(n=>n.id===id && n.kind==='question') + : this.service.store.getReviewNotes(identity).find(n=>n.id===id && n.kind==='question'); if (!note || note.answer || this.running.has(id)) return; - const attempt=randomUUID(); - this.service.store.beginAnswer(this.service.config.identity,id,attempt,this.service.store.questionProvider()??undefined,note.contextId); - this.service.store.finishAnswer(this.service.config.identity,id,attempt,{status:'failed',error:'Server stopped. Retry the question.'}); + const attempt=randomUUID(), contextId=note.contextId; + this.write(() => { + this.service.store.beginAnswer(identity,id,attempt,this.service.store.questionProvider()??undefined,contextId); + this.service.store.finishAnswer(identity,id,attempt,{status:'failed',error:'Server stopped. Retry the question.'}); + }); } - start(id: string, view: ReturnType) { + start(id: string, view: ReviewView) { if (this.closing) throw new Error('Server is stopping. Reconnect before asking again.'); if (this.running.has(id)) throw new Error('Agent is already answering this question.'); const note = view.notes.find(n=>n.id===id && n.kind==='question'); diff --git a/runner/review.ts b/runner/review.ts index 039ea92e..8e1dd598 100644 --- a/runner/review.ts +++ b/runner/review.ts @@ -3,21 +3,25 @@ import { basename } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import { Store, type ReviewState, type SnippetReference } from './store.ts'; import type { PlanIdentity } from '../core/identity.ts'; -import { readHistory } from '../git/history.ts'; import { execFileSync } from 'node:child_process'; import { HARDENED_GIT_OPTIONS, hardenedGitEnvironment } from '../scripts/git-environment.ts'; import { commandArgv, PlanError, type BaseEntry, type PlanContext } from '../core/plan.ts'; -import { linkHistory } from '../core/linking.ts'; +import { pathKeyFor, validPathIdentity, type PathIdentity } from '../core/path-identity.ts'; +import { runHistoryJob } from './history-job.ts'; +import type { Segment } from '../core/linking.ts'; import { applyChoices, approvalStates, approveItem, choiceKeys, fingerprint, reviewedSegment, stable } from '../core/approvals.ts'; import type { PlanItem } from '../core/plan.ts'; import type { GhMergeConfig } from '../github/merge.ts'; import { GuardRefusal } from './lifecycle.ts'; import { commandDigest } from './checks.ts'; +/** How many times one load reads the history when the Store keeps changing while its worker runs. */ +export const LOAD_ATTEMPTS = 3; + export interface ReviewConfig { database: string; repository: string; /** The runner-owned repository (#87) holding the production task branch, including its original imported head. */ runnerRepository?: string; - identity: PlanIdentity; pathIdentity: { caseSensitive: boolean; unicodeNormalization: 'none' | 'NFC' }; demo?: boolean; github?: GhMergeConfig; + identity: PlanIdentity; pathIdentity: PathIdentity; demo?: boolean; github?: GhMergeConfig; /** Exact complete argv arrays a stored plan may execute as `cmd:` acceptance checks. */ allowedCommands?: readonly (readonly string[])[]; /** The runner block, parsed by `parseRunnerConfig`. Its presence also makes the merge target the task's published PR (#121). */ @@ -32,17 +36,13 @@ export class ReviewService { #baseEntries: { base: string; entries: readonly BaseEntry[] } | null = null; #pathKey: (path: string) => string; constructor(config: ReviewConfig) { - if (typeof config.pathIdentity?.caseSensitive !== 'boolean' || !['none', 'NFC'].includes(config.pathIdentity.unicodeNormalization)) throw new Error('Known checkout path identity is required.'); + if (!validPathIdentity(config.pathIdentity)) throw new Error('Known checkout path identity is required.'); this.config = config; if (config.allowedCommands !== undefined && (!Array.isArray(config.allowedCommands) || config.allowedCommands.some(argv => !Array.isArray(argv) || argv.length === 0 || argv.some(arg => typeof arg !== 'string' || !arg.isWellFormed() || arg.includes('\0'))))) throw new Error('allowedCommands must contain complete literal argv arrays.'); - this.#pathKey = path => { - if (!config.pathIdentity.caseSensitive && /[^\x20-\x7e]/.test(path)) throw new Error('Non-ASCII case-insensitive paths require a filesystem-specific identity adapter.'); - const normalized = config.pathIdentity.unicodeNormalization === 'NFC' ? path.normalize('NFC') : path; - return config.pathIdentity.caseSensitive ? normalized : normalized.toLowerCase(); - }; + this.#pathKey = pathKeyFor(config.pathIdentity); this.store = new Store(config.database, this.#pathKey); } /** Stable binding for preparation evidence; a restart with a different command policy invalidates old readiness. */ @@ -59,31 +59,45 @@ export class ReviewService { throw new Error('This task has runner commits, so its review needs the runner-owned repository, which is not configured.'); return { path: this.config.repository, runnerOwned: false }; } - load(options: { maxDurationMs?: number } = {}) { + /** + * The review as the page shows it. History reading and linking run in a worker thread (history-job.ts) within one + * overall deadline; `signal` stops that work and rejects with its reason. Store reads and writes, and the work on the + * linked segments (choices, approval states, hashes), stay on this thread. If the Store moved on while the worker ran + * (another load observed a new HEAD, or a write landed), the history is read again from the current state, in at most + * LOAD_ATTEMPTS reads in all within the deadline, so a load returns the review as it is when it finishes. + */ + async load(options: { maxDurationMs?: number; signal?: AbortSignal } = {}) { const maxDurationMs = options.maxDurationMs ?? 30_000; if (!Number.isSafeInteger(maxDurationMs) || maxDurationMs < 1 || maxDurationMs > 30_000) throw new Error('Review duration budget must be a positive integer no larger than 30000 ms.'); + options.signal?.throwIfAborted(); const deadline = performance.now() + maxDurationMs; - const remaining = () => { - const ms = deadline - performance.now(); - if (ms <= 0) throw new Error('Review load exceeded its overall deadline.'); - return Math.max(1, Math.ceil(ms)); - }; const { identity } = this.config; - const reviewVersion = this.store.reviewVersion(identity); - const plan = this.store.getPlan(identity); - let snapshot = this.store.getSnapshot(identity); - const reviewed = this.reviewRepository(); - // HEAD changes in the user's repository are observed; no Git mutation is performed by the review service. Runner - // commits move the head only through the Store, in the same transaction as their ledger entries, so there the - // recorded head is read as it is: observing the user's HEAD would record its older commit and roll the task back. - const history = readHistory(reviewed.path, snapshot.base, reviewed.runnerOwned ? snapshot.head : 'HEAD', - { maxDurationMs: remaining() }); + let reviewVersion!: number, plan!: ReturnType, snapshot!: ReturnType; + let history!: Awaited>['history'], raw!: Segment[]; + for (let attempt = 1; ; attempt++) { + reviewVersion = this.store.reviewVersion(identity); + plan = this.store.getPlan(identity); + snapshot = this.store.getSnapshot(identity); + const reviewed = this.reviewRepository(); + const ownership = [...this.store.ownership(identity, plan.revision)]; + const resolvedConflicts = this.store.getLedger(identity).filter(entry => entry.conflictResolved).map(entry => entry.sha); + // HEAD changes in the user's repository are observed; no Git mutation is performed by the review service. Runner + // commits move the head only through the Store, in the same transaction as their ledger entries, so there the + // recorded head is read as it is: observing the user's HEAD would record its older commit and roll the task back. + const job = await runHistoryJob({ repository: reviewed.path, base: snapshot.base, + head: reviewed.runnerOwned ? snapshot.head : 'HEAD', budgetMs: Math.max(1, Math.ceil(deadline - performance.now())), + link: { plan, ownership, pathIdentity: this.config.pathIdentity, resolvedConflicts } }, options.signal); + // The worker linked the inputs read above. Every ledger write records a new snapshot in its transaction, so an + // unchanged review version, plan revision and snapshot mean every input is still current; everything from here + // on runs in this one turn. + if (this.store.reviewVersion(identity) === reviewVersion && this.store.getPlan(identity).revision === plan.revision + && this.store.getSnapshot(identity).id === snapshot.id) { history = job.history; raw = job.segments!; break; } + if (performance.now() >= deadline) throw new Error('Review load exceeded its overall deadline.'); + if (attempt === LOAD_ATTEMPTS) throw new Error('Stale review state. Reload before writing.'); + } if (history.head !== snapshot.head) snapshot = this.store.recordHistory(identity, { revision: plan.revision, snapshotId: snapshot.id, reviewVersion }, history.base, history.head, []); const pathKey = this.#pathKey; - const ledger = this.store.getLedger(identity); - const raw = linkHistory(plan, history, this.store.ownership(identity, plan.revision), pathKey, { maxDurationMs: remaining() }, - new Set(ledger.filter(entry => entry.conflictResolved).map(entry => entry.sha))); const saved = this.store.getReview(identity), keys = choiceKeys(raw, identity); const deltas = new Map(history.final.map(file => [JSON.stringify([file.newPath ?? file.oldPath, file.oldPath]), file])); let previewBudget = 6 * 1024 * 1024; @@ -186,7 +200,7 @@ export class ReviewService { }; }); const token = createHash('sha256').update(JSON.stringify({ expected, saved, plan, segments })).digest('hex'); - remaining(); + if (performance.now() >= deadline) throw new Error('Review load exceeded its overall deadline.'); return { repository: basename(this.config.repository), demo: this.config.demo ?? false, plan, snapshot, expected, token, items, segments, notes, approved: items.filter(item => item.state === 'approved').length }; } /** The trusted plan context for import and Apply, or for a continuation at an audited runner head. */ @@ -236,11 +250,19 @@ export class ReviewService { continuationContext: { checkpointId: checkpoint.id, head: progress.head, completedItems: progress.completed, ownerItem: checkpoint.item, outOfScopePaths: checkpoint.outOfScopePaths } }; } - /** With an actionId (inside Store.userAction), feedback-producing actions record their event in the same transaction. */ - act(input: unknown, actionId?: string) { + /** Loads the review, applies one review command to it and loads the result. */ + async act(input: unknown, options: { signal?: AbortSignal } = {}) { + const createdNoteId = this.apply(await this.load(options), input); + return { ...await this.load(options), createdNoteId }; + } + /** + * Applies one review command to `view`, synchronously, so a caller can run it inside Store.userAction. With an + * actionId, feedback-producing actions record their event in the same transaction. Every write is guarded by the + * view's expected review state, so a view the Store has moved past is refused. + */ + apply(view: ReviewView, input: unknown, actionId?: string): string | undefined { if (!input || typeof input !== 'object') throw new Error('Invalid review command.'); const command = input as Record; - const view = this.load(); let createdNoteId: string | undefined; if (command.token !== view.token) throw new Error('Stale review state. Reload before writing.'); const { identity } = this.config; @@ -278,6 +300,7 @@ export class ReviewService { createdNoteId = this.store.addReviewNote(identity, view.expected, command.item, command.kind, command.text, reference).id; if (actionId && command.kind === 'change') this.store.recordFeedback(identity, actionId, { kind: 'change-request', item: command.item, text: command.text.trim(), sourceRef: createdNoteId }); } else throw new Error('Unknown review command.'); - return { ...this.load(), createdNoteId }; + return createdNoteId; } } +export type ReviewView = Awaited>; diff --git a/scripts/plant.ts b/scripts/plant.ts index 4c2e076a..3ba82648 100644 --- a/scripts/plant.ts +++ b/scripts/plant.ts @@ -12,7 +12,7 @@ export interface PlantInput { declaredText: string; undeclaredText: string; unde /** Prepare a separate local experiment clone. Never rewrites/pushes the source repository. * The experiment operator runs this before the reviewer sees the PR; sealed.json is unblinding data. */ -export function plant(config: ReviewConfig, destination: string, input: PlantInput): string { +export async function plant(config: ReviewConfig, destination: string, input: PlantInput): Promise { const root=resolve(destination); if(existsSync(root))throw new Error('Experiment destination must not exist.'); let parent=root; const missing:string[]=[]; @@ -32,7 +32,7 @@ export function plant(config: ReviewConfig, destination: string, input: PlantInp try{plan=source.getPlan(config.identity);snapshot=source.getSnapshot(config.identity);entries=source.getLedger(config.identity);}finally{source.close();} if(plan.items.some(item=>item.files.some(file=>pathKey(file.path)===pathKey(input.undeclaredPath)||(file.renamed_from!==null&&pathKey(file.renamed_from)===pathKey(input.undeclaredPath)))))throw new Error('Undeclared plant must be outside every declared file.'); for(const item of plan.items) for(const file of item.files) { pathKey(file.path); if(file.renamed_from) pathKey(file.renamed_from); } - const history=readHistory(config.repository,snapshot.base,snapshot.head); + const history=await readHistory(config.repository,snapshot.base,snapshot.head); const owners=new Map(entries.map(entry=>[entry.sha,entry.owner])); const candidates=history.commits.flatMap((commit,index)=>{ const item=plan.items.find(item=>item.id===owners.get(commit.sha)); @@ -91,5 +91,5 @@ export function plant(config: ReviewConfig, destination: string, input: PlantInp if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){ const [configFile,destination,inputFile]=process.argv.slice(2); if(!configFile||!destination||!inputFile)throw new Error('Usage: node scripts/plant.ts review.json NEW_DIRECTORY plant-input.json'); - console.log(plant(JSON.parse(readFileSync(configFile,'utf8')),destination,JSON.parse(readFileSync(inputFile,'utf8')))); + console.log(await plant(JSON.parse(readFileSync(configFile,'utf8')),destination,JSON.parse(readFileSync(inputFile,'utf8')))); } diff --git a/test/browser/issues.spec.ts b/test/browser/issues.spec.ts index 5d9d033a..f644af9d 100644 --- a/test/browser/issues.spec.ts +++ b/test/browser/issues.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { openReview, reloadReview } from './review-page.ts'; import { randomUUID } from 'node:crypto'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -36,7 +37,7 @@ const issueRows = (page: import('@playwright/test').Page) => page.locator('#issu test('ranks demo issues with visible reasons and trust, and keeps review input across navigation', async ({ page }) => { const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); app = await startServer(createDemo(join(root, 'demo')), 0); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.getByRole('heading', { name: 'Bound exponential retries' })).toBeVisible(); await page.getByLabel('Question about this item').fill('Unsent question'); @@ -72,7 +73,7 @@ test('ranks demo issues with visible reasons and trust, and keeps review input a await expect(page.getByLabel('Question about this item')).toHaveValue('Unsent question'); await page.getByRole('link', { name: 'Issues', exact: true }).click(); - await page.reload(); + await reloadReview(page); await expect(page).toHaveURL(/\?view=issues$/); await expect(page.getByRole('link', { name: 'Issues', exact: true })).toHaveAttribute('aria-current', 'page'); await expect(issueRows(page)).toHaveCount(5); @@ -83,7 +84,7 @@ test('ranks demo issues with visible reasons and trust, and keeps review input a test('can explicitly trust a collaborator issue to include all comments, then remove that widening', async ({ page }) => { app = await startServer(createDemo(join(root, 'demo')), 0); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); const row = page.locator('tr[data-issue="17"]'); await row.getByRole('button', { name: 'Trust all comments' }).click(); @@ -96,7 +97,7 @@ test('can explicitly trust a collaborator issue to include all comments, then re test('trusts and untrusts a demo issue without GitHub and keeps keyboard focus on the action', async ({ page }) => { app = await startServer(createDemo(join(root, 'demo')), 0); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); const row = page.locator('tr[data-issue="21"]'); const trust = row.getByRole('button', { name: 'Trust this issue' }); @@ -106,7 +107,7 @@ test('trusts and untrusts a demo issue without GitHub and keeps keyboard focus o const remove = row.getByRole('button', { name: 'Remove trust' }); await expect(remove).toBeFocused(); await expect(row.getByLabel(/trusted by you on/i)).toContainText('✓ Trusted by you on'); - await page.reload(); + await reloadReview(page); await expect(row.getByRole('button', { name: 'Remove trust' })).toBeVisible(); await row.getByRole('button', { name: 'Remove trust' }).focus(); await page.keyboard.press('Enter'); @@ -123,7 +124,7 @@ test('keeps keyboard focus on an issue link when a pending trust response rerend if (body.action !== 'trust' || body.number !== 21 || held) { await route.continue(); return; } held = { route, response: await route.fetch() }; captured.resolve(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await page.locator('tr[data-issue="21"]').getByRole('button', { name: 'Trust this issue' }).click(); await captured.promise; @@ -145,7 +146,7 @@ test('ignores an older trust response that returns after a newer action', async first = { route, response: await route.fetch() }; captured.resolve(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); const row = page.locator('tr[data-issue="21"]'), button = row.locator('button.issue-trust'); await button.click(); @@ -174,7 +175,7 @@ test('reuses the trust action ID after a lost response without overwriting a new } await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); const row = page.locator('tr[data-issue="21"]'); await row.getByRole('button', { name: 'Trust this issue' }).click(); @@ -203,7 +204,7 @@ test('does not let an older same-author trust response overwrite a newer untrust if (body?.action !== 'trust' || body.number !== 21 || held) { await route.continue(); return; } held = { route, response: await route.fetch(), body }; captured.resolve(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); const row = page.locator('tr[data-issue="21"]'); await row.getByRole('button', { name: 'Trust this issue' }).click(); @@ -243,7 +244,7 @@ test('does not let an equal-version trust response overwrite refreshed collabora } await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); const row = page.locator('tr[data-issue="17"]'); await row.getByRole('button', { name: 'Trust all comments' }).click(); @@ -271,7 +272,7 @@ test('reuses the trust action ID after a retryable 503', async ({ page }) => { } await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); const row = page.locator('tr[data-issue="21"]'); await row.getByRole('button', { name: 'Trust this issue' }).click(); @@ -294,7 +295,7 @@ test('keeps another issue disabled and focused when an overlapping trust request held = route; captured.resolve(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await page.locator('tr[data-issue="21"]').getByRole('button', { name: 'Trust this issue' }).click(); await captured.promise; @@ -320,7 +321,7 @@ test('keeps one issue trust failure visible when another overlapping request suc if (body.number === 23 && !second) { second = { route, response: await route.fetch() }; secondCaptured.resolve(); return; } await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await page.locator('tr[data-issue="21"]').getByRole('button', { name: 'Trust this issue' }).click(); await firstCaptured.promise; @@ -347,7 +348,7 @@ test('keeps a refresh failure visible through trust rendering and clears it on t } await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await expect(page.locator('#issues-status')).toContainText('✓ Current'); failRefresh = true; @@ -375,7 +376,7 @@ test('does not reconcile a trust failure that occurs after refresh starts', asyn } await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await expect(page.locator('#issues-status')).toContainText('✓ Current'); holdRefresh = true; @@ -398,7 +399,7 @@ test('merges successful overlapping trust responses for different issues', async held = { route, response: await route.fetch() }; captured.resolve(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await page.locator('tr[data-issue="21"]').getByRole('button', { name: 'Trust this issue' }).click(); await captured.promise; @@ -426,7 +427,7 @@ test('does not let a refresh started during trust overwrite the committed decisi } await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await page.locator('tr[data-issue="21"]').getByRole('button', { name: 'Trust this issue' }).click(); await trustCaptured.promise; @@ -459,7 +460,7 @@ test('does not let an older trust response overwrite a refresh or trust an obsol } await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); const row = page.locator('tr[data-issue="21"]'); await row.getByRole('button', { name: 'Trust this issue' }).click(); @@ -475,7 +476,7 @@ test('does not let an older trust response overwrite a refresh or trust an obsol test('shows unavailable, then current, then stale issue data with the retrieval error', async ({ page }) => { const { gateway, pending } = scriptedGateway(); app = await startServer(createDemo(join(root, 'demo')), 0, undefined, undefined, undefined, gateway); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await expect(page.getByRole('button', { name: 'Refreshing…' })).toHaveAttribute('aria-disabled', 'true'); await expect.poll(() => pending.length).toBe(1); @@ -501,7 +502,7 @@ test('shows unavailable, then current, then stale issue data with the retrieval test('keyboard focus stays on Refresh issues through a refresh', async ({ page }) => { const { gateway, pending } = scriptedGateway(); app = await startServer(createDemo(join(root, 'demo')), 0, undefined, undefined, undefined, gateway); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await expect.poll(() => pending.length).toBe(1); pending[0]!.resolve(snapshot(['First'])); @@ -524,7 +525,7 @@ test('keyboard focus stays on Refresh issues through a refresh', async ({ page } test('a refresh that returns after the user leaves Issues does not pull them back', async ({ page }) => { const { gateway, pending } = scriptedGateway(); app = await startServer(createDemo(join(root, 'demo')), 0, undefined, undefined, undefined, gateway); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await expect.poll(() => pending.length).toBe(1); await page.getByRole('link', { name: 'Review', exact: true }).click(); @@ -542,7 +543,7 @@ test('a refresh that returns after the user leaves Issues does not pull them bac test('explains that issue ranking needs a GitHub repository', async ({ page }) => { app = await startServer({ ...createDemo(join(root, 'demo')), demo: false }, 0); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Issues', exact: true }).click(); await expect(page.locator('#issues-status')).toHaveText(/^– Not configured\. Issue ranking needs a GitHub repository/); await expect(issueRows(page)).toHaveCount(0); diff --git a/test/browser/plans-authoring.spec.ts b/test/browser/plans-authoring.spec.ts index 37c367f3..17da263b 100644 --- a/test/browser/plans-authoring.spec.ts +++ b/test/browser/plans-authoring.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; +import { openReview, reloadReview } from './review-page.ts'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -38,7 +39,7 @@ test.afterEach(async () => { async function openPlans(page: Page, deps: PlanningDeps) { app = await startServer(createDemo(join(root, 'demo')), 0, undefined, undefined, undefined, undefined, undefined, deps); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); } @@ -100,7 +101,7 @@ test('accepts complete payloads for every edit operation', async ({ page }) => { const { deps } = planning(); await openPlans(page, deps); const current = app.service.store.getPlan(app.service.config.identity); - const snapshotId = app.service.load().snapshot.id; + const snapshotId = (await app.service.load()).snapshot.id; const card = (op: EditReply['edits'][number]['op'], payload: Record = {}, item = 'P1') => ({ op, item, summary: `Exercise ${op}`, reason: `The ${op} payload is complete.`, field: null, value: null, file: null, check: null, check_index: null, depends_on: null, new_item: null, ...payload, @@ -137,7 +138,7 @@ test('renders a whole next-revision draft without changing the current plan', as const { deps, invocations } = planning(); await openPlans(page, deps); const current = app.service.store.getPlan(app.service.config.identity); - const snapshotId = app.service.load().snapshot.id; + const snapshotId = (await app.service.load()).snapshot.id; let reads = 0; await page.route('**/api/plan/drafts/*', async route => { reads++; @@ -166,7 +167,7 @@ test('renders a whole next-revision draft without changing the current plan', as test('applies a draft once while preserving newer review text and its attachment', async ({ page }) => { const { deps, invocations } = planning(); app = await startServer(createDemo(join(root, 'demo')), 0, undefined, undefined, undefined, undefined, undefined, deps); - await page.goto(app.url); + await openReview(page, app.url); await page.locator('.added [data-line]').first().click(); await page.getByRole('button', { name: 'Ask about selection', exact: true }).click(); await page.getByLabel('Question about this item').fill('Before Apply'); @@ -460,7 +461,7 @@ test('keeps ownership while a malformed status response is retried', async ({ pa test('rejects failed output and marks consumed output stale', async ({ page }) => { const { deps } = planning(); await openPlans(page, deps); - const snapshotId = app.service.load().snapshot.id; + const snapshotId = (await app.service.load()).snapshot.id; let reads = 0, starts = 0; let releaseConsumed!: () => void; const consumedHeld = new Promise(resolve => { releaseConsumed = resolve; }); @@ -498,7 +499,7 @@ test('rejects a status response for the wrong planning operation', async ({ page const { deps, invocations } = planning(); await openPlans(page, deps); const current = app.service.store.getPlan(app.service.config.identity); - const snapshotId = app.service.load().snapshot.id; + const snapshotId = (await app.service.load()).snapshot.id; let reads = 0; await page.route('**/api/plan/suggestions/*', async route => { reads++; @@ -542,7 +543,7 @@ test('marks invalidated output stale when it moves into history', async ({ page const { deps, invocations } = planning(); await openPlans(page, deps); const current = app.service.store.getPlan(app.service.config.identity); - const snapshotId = app.service.load().snapshot.id; + const snapshotId = (await app.service.load()).snapshot.id; let reads = 0; await page.route('**/api/plan/suggestions/*', async route => { reads++; @@ -595,7 +596,7 @@ test('rejects a status observation bound to another snapshot', async ({ page }) test('rejects an operation-specific payload that is unsafe to render', async ({ page }) => { const { deps, invocations } = planning(); await openPlans(page, deps); - const snapshotId = app.service.load().snapshot.id; + const snapshotId = (await app.service.load()).snapshot.id; let reads = 0; await page.route('**/api/plan/suggestions/*', async route => { reads++; diff --git a/test/browser/plans-production.spec.ts b/test/browser/plans-production.spec.ts index 84c98b4e..4d2cf4e0 100644 --- a/test/browser/plans-production.spec.ts +++ b/test/browser/plans-production.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; +import { openReview, reloadReview } from './review-page.ts'; import { lstatSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -114,7 +115,7 @@ async function boot(config: ReviewConfig, observations: { } async function openPlans(page: Page) { - await page.goto(app!.url); + await openReview(page, app!.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); } diff --git a/test/browser/plans.spec.ts b/test/browser/plans.spec.ts index e9d9f76f..4583334b 100644 --- a/test/browser/plans.spec.ts +++ b/test/browser/plans.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { openReview, reloadReview } from './review-page.ts'; import { randomUUID } from 'node:crypto'; import { chmodSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -84,7 +85,7 @@ for (const fixture of [ test(`displays the current plan and imports the next ${fixture.format} revision without losing a review draft`, async ({ page }) => { const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); - await page.goto(app.url); + await openReview(page, app.url); await page.getByLabel('Question about this item').fill('Keep this unsent question.'); await page.getByRole('link', { name: 'Plans', exact: true }).click(); @@ -111,7 +112,7 @@ for (const fixture of [ await page.getByRole('link', { name: 'Review', exact: true }).click(); await expect(page.getByLabel('Question about this item')).toHaveValue('Keep this unsent question.'); await page.getByRole('link', { name: 'Plans', exact: true }).click(); - await page.reload(); + await reloadReview(page); await expect(page).toHaveURL(/\?view=plans$/); await expect(page.locator('#plans-revision')).toHaveText('r2'); if (fixture.format === 'JSON') await page.screenshot({ path: 'test-results/plans-desktop.png', fullPage: true }); @@ -120,7 +121,7 @@ for (const fixture of [ } test('shows a rejected import without replacing the current plan', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); const wrongIssue = JSON.stringify({ ...nextPlan, issue: 413 }); await page.getByLabel('Plan file').setInputFiles({ name: 'wrong-issue.json', mimeType: 'application/json', buffer: Buffer.from(wrongIssue) }); @@ -131,7 +132,7 @@ test('shows a rejected import without replacing the current plan', async ({ page }); test('keeps focus and a newly selected file when an older import response returns', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); const file = page.getByLabel('Plan file'); await file.setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); @@ -159,7 +160,7 @@ test('keeps focus and a newly selected file when an older import response return }); test('reuses the exact import action after an ambiguous response', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); const sent: unknown[] = []; @@ -177,7 +178,7 @@ test('reuses the exact import action after an ambiguous response', async ({ page }); test('replays an ambiguous committed import after refresh without creating another revision', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); const sent: unknown[] = []; @@ -210,7 +211,7 @@ test('does not let a replayed import reload overwrite a newer terminal merge obs let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -220,7 +221,7 @@ test('does not let a replayed import reload overwrite a newer terminal merge obs enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); const sent: unknown[] = []; @@ -238,11 +239,11 @@ test('does not let a replayed import reload overwrite a newer terminal merge obs await page.locator('#plans-refresh').click(); await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); - let view = app.service.load(); + let view = await app.service.load(); for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + view = await app.service.act({ action: 'accept', key: segment.key, token: view.token }); for (const item of view.items) - view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + view = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); await page.getByRole('link', { name: 'Review', exact: true }).click(); await page.getByRole('button', { name: 'Refresh', exact: true }).click(); await expect(page.getByRole('button', { name: 'Merge PR', exact: true })).toBeEnabled(); @@ -299,7 +300,7 @@ test('does not let a replayed import reload overwrite a newer terminal merge obs }); test('preserves an unresolved import retry while another file is definitely refused', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); const file = page.getByLabel('Plan file'); const source = JSON.stringify(nextPlan); @@ -333,7 +334,7 @@ test('preserves an unresolved import retry while another file is definitely refu }); test('reports a committed import separately when reloading the plan fails', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); await page.route('**/api/review', route => route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Reload unavailable.' }) })); @@ -344,7 +345,7 @@ test('reports a committed import separately when reloading the plan fails', asyn }); test('does not let an older Review refresh overwrite a committed import', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); let release!: () => void, arrived!: () => void; const held = new Promise(resolve => { release = resolve; }); const captured = new Promise(resolve => { arrived = resolve; }); @@ -378,7 +379,7 @@ test('does not let an older Review refresh overwrite a committed import', async }); test('clears stale Plans data when a Review refresh fails after navigation', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); let release!: () => void, arrived!: () => void; const held = new Promise(resolve => { release = resolve; }); const captured = new Promise(resolve => { arrived = resolve; }); @@ -398,7 +399,7 @@ test('clears stale Plans data when a Review refresh fails after navigation', asy }); test('updates a settled Plans revision status when Review refresh observes a newer plan', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); await page.getByRole('link', { name: 'Plans', exact: true }).click(); await page.locator('#plans-refresh').click(); await expect(page.getByRole('status').filter({ hasText: 'Revision r1 is current.' })).toBeVisible(); @@ -419,7 +420,7 @@ test('updates a settled Plans revision status when Review refresh observes a new }); test('clears a Plans load-failure status when Review refresh recovers', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); let release!: () => void, arrived!: () => void; const held = new Promise(resolve => { release = resolve; }); const captured = new Promise(resolve => { arrived = resolve; }); @@ -443,7 +444,7 @@ test('clears a Plans load-failure status when Review refresh recovers', async ({ }); test('does not let an older failed Review refresh replace newer Plans validation feedback', async ({ page }) => { - await page.goto(app.url); + await openReview(page, app.url); let release!: () => void, arrived!: () => void; const held = new Promise(resolve => { release = resolve; }); const captured = new Promise(resolve => { arrived = resolve; }); @@ -472,7 +473,7 @@ test('does not let a question poll started during Plans refresh replace its comp const config = app.service.config; await app.close(); app = await startServer(config, 0, (_prompt, signal) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }))); - await page.goto(app.url); + await openReview(page, app.url); await page.getByLabel('Question about this item').fill('Will this answer finish?'); await page.getByRole('button', { name: 'Ask agent', exact: true }).click(); await expect(page.getByText('Agent · Answering…', { exact: true })).toBeVisible(); @@ -521,7 +522,7 @@ test('does not let an older Plans refresh replace an answer completed while it w const config = app.service.config; await app.close(); app = await startServer(config, 0, (_prompt, signal) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }))); - await page.goto(app.url); + await openReview(page, app.url); await page.getByLabel('Question about this item').fill('Did this finish during refresh?'); await page.getByRole('button', { name: 'Ask agent', exact: true }).click(); await expect(page.getByText('Agent · Answering…', { exact: true })).toBeVisible(); @@ -563,7 +564,7 @@ test('does not let an older Plans refresh replace an answer failure from the sam const config = app.service.config; await app.close(); app = await startServer(config, 0, (_prompt, signal) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }))); - await page.goto(app.url); + await openReview(page, app.url); await page.getByLabel('Question about this item').fill('Will this failure stay visible?'); await page.getByRole('button', { name: 'Ask agent', exact: true }).click(); await expect(page.getByText('Agent · Answering…', { exact: true })).toBeVisible(); @@ -606,7 +607,7 @@ test('does not let an older Plans refresh restore cancellation activity after fa const config = app.service.config; await app.close(); app = await startServer(config, 0, (_prompt, signal) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }))); - await page.goto(app.url); + await openReview(page, app.url); await page.getByLabel('Question about this item').fill('Did cancellation settle during refresh?'); await page.getByRole('button', { name: 'Ask agent', exact: true }).click(); await expect(page.getByText('Agent · Answering…', { exact: true })).toBeVisible(); @@ -658,19 +659,19 @@ test('does not let an older Plans refresh re-enable merge after it commits', asy fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); const gateway: MergeGateway = { inspect: async () => { - const snapshot = app.service.load().snapshot; + const snapshot = (await app.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: false, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), }; app = await startServer(config, 0, undefined, gateway); - let view = app.service.load(); + let view = await app.service.load(); for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + view = await app.service.act({ action: 'accept', key: segment.key, token: view.token }); for (const item of view.items) - view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); - await page.goto(app.url); + view = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + await openReview(page, app.url); await expect(page.getByRole('button', { name: 'Merge PR', exact: true })).toBeEnabled(); await page.getByRole('link', { name: 'Plans', exact: true }).click(); let release!: () => void, arrived!: () => void; @@ -712,7 +713,7 @@ test('does not let a merge poll started during a review action replace restored let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -722,11 +723,11 @@ test('does not let a merge poll started during a review action replace restored enqueuedAt: '2026-10-08T08:00:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let review = app.service.load(); + let review = await app.service.load(); for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + review = await app.service.act({ action: 'accept', key: segment.key, token: review.token }); for (const item of review.items) - review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + review = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); @@ -746,7 +747,7 @@ test('does not let a merge poll started during a review action replace restored await heldPoll; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: queued }) }); }); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); app.service.store.finishMergeAttempt(config.identity, attempt.id, { state: 'removed', reason: 'Required check failed.' }); const retryReview = await page.request.get(new URL('/api/review', app.url).toString(), { @@ -793,7 +794,7 @@ test('lets a full refresh verify retry readiness after a matching terminal poll' let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -803,15 +804,15 @@ test('lets a full refresh verify retry readiness after a matching terminal poll' enqueuedAt: '2026-10-08T08:00:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let review = app.service.load(); + let review = await app.service.load(); for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + review = await app.service.act({ action: 'accept', key: segment.key, token: review.token }); for (const item of review.items) - review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + review = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); app.service.store.finishMergeAttempt(config.identity, attempt.id, { state: 'removed', reason: 'Required check failed.' }); @@ -869,7 +870,7 @@ test('lets a terminal full response replace an active poll for the same merge at let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -879,15 +880,15 @@ test('lets a terminal full response replace an active poll for the same merge at enqueuedAt: '2026-10-08T08:00:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let review = app.service.load(); + let review = await app.service.load(); for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + review = await app.service.act({ action: 'accept', key: segment.key, token: review.token }); for (const item of review.items) - review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + review = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); app.service.store.finishMergeAttempt(config.identity, attempt.id, { state: 'removed', reason: 'Required check failed.' }); @@ -942,7 +943,7 @@ test('lets a queued full response replace a stale submitting poll for the same m let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -952,14 +953,14 @@ test('lets a queued full response replace a stale submitting poll for the same m enqueuedAt: '2026-10-08T08:00:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let review = app.service.load(); + let review = await app.service.load(); for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + review = await app.service.act({ action: 'accept', key: segment.key, token: review.token }); for (const item of review.items) - review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + review = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.getByRole('button', { name: 'Submitting…', exact: true })).toBeDisabled(); app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); app.service.store.observeQueuedMerge(config.identity, attempt.id, { entryId: 'MQE_1', phase: 'QUEUED', position: 3 }); @@ -1028,7 +1029,7 @@ test('does not let an older review action response replace a newer queued observ let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -1038,11 +1039,11 @@ test('does not let an older review action response replace a newer queued observ enqueuedAt: '2026-10-08T08:10:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let review = app.service.load(); + let review = await app.service.load(); for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + review = await app.service.act({ action: 'accept', key: segment.key, token: review.token }); for (const item of review.items) - review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + review = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); app.service.store.observeQueuedMerge(config.identity, attempt.id, { entryId: 'MQE_3', phase: 'QUEUED', position: 3 }); @@ -1077,7 +1078,7 @@ test('does not let an older review action response replace a newer queued observ await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); await page.getByRole('button', { name: 'Request change', exact: true }).click(); await page.getByLabel('Change to request').fill('Preserve queue progress after this response.'); @@ -1125,7 +1126,7 @@ for (const mismatch of [ let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -1135,11 +1136,11 @@ for (const mismatch of [ enqueuedAt: '2026-10-08T08:15:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let review = app.service.load(); + let review = await app.service.load(); for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + review = await app.service.act({ action: 'accept', key: segment.key, token: review.token }); for (const item of review.items) - review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + review = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); app.service.store.observeQueuedMerge(config.identity, attempt.id, { entryId: 'MQE_identity', phase: 'QUEUED', position: 3 }); @@ -1162,7 +1163,7 @@ for (const mismatch of [ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(actionResponse) }); }); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); await page.getByRole('button', { name: 'Request change', exact: true }).click(); const note = `Keep the action response for a different ${mismatch.label}.`; @@ -1213,7 +1214,7 @@ test('does not let an older Plans refresh replace a confirmed queued merge with let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -1223,14 +1224,14 @@ test('does not let an older Plans refresh replace a confirmed queued merge with enqueuedAt: '2026-10-08T08:05:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let review = app.service.load(); + let review = await app.service.load(); for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + review = await app.service.act({ action: 'accept', key: segment.key, token: review.token }); for (const item of review.items) - review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + review = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.getByRole('button', { name: 'Submitting…', exact: true })).toBeDisabled(); let releaseRefresh!: () => void, refreshArrived!: () => void; const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); @@ -1282,7 +1283,7 @@ test('does not let merge polls started during Review or Plans refresh overwrite let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -1292,13 +1293,13 @@ test('does not let merge polls started during Review or Plans refresh overwrite enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let view = app.service.load(); + let view = await app.service.load(); for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + view = await app.service.act({ action: 'accept', key: segment.key, token: view.token }); for (const item of view.items) - view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + view = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); - await page.goto(app.url); + await openReview(page, app.url); page.on('dialog', dialog => dialog.accept()); await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); @@ -1385,7 +1386,7 @@ test('does not let an older Plans refresh overwrite a newer terminal merge obser let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -1395,13 +1396,13 @@ test('does not let an older Plans refresh overwrite a newer terminal merge obser enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let view = app.service.load(); + let view = await app.service.load(); for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + view = await app.service.act({ action: 'accept', key: segment.key, token: view.token }); for (const item of view.items) - view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + view = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); - await page.goto(app.url); + await openReview(page, app.url); page.on('dialog', dialog => dialog.accept()); await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); @@ -1458,7 +1459,7 @@ test('does not let an older Review refresh overwrite a newer terminal merge obse let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -1468,13 +1469,13 @@ test('does not let an older Review refresh overwrite a newer terminal merge obse enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let view = app.service.load(); + let view = await app.service.load(); for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + view = await app.service.act({ action: 'accept', key: segment.key, token: view.token }); for (const item of view.items) - view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + view = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); - await page.goto(app.url); + await openReview(page, app.url); page.on('dialog', dialog => dialog.accept()); await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); @@ -1530,7 +1531,7 @@ test('keeps merge-queue polling current across Plans refresh and import outcomes let appRef: typeof app; const gateway: MergeGateway & MergeQueueGateway = { inspect: async () => { - const snapshot = appRef.service.load().snapshot; + const snapshot = (await appRef.service.load()).snapshot; return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; }, @@ -1540,11 +1541,11 @@ test('keeps merge-queue polling current across Plans refresh and import outcomes enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), }; app = appRef = await startServer(config, 0, undefined, gateway); - let view = app.service.load(); + let view = await app.service.load(); for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) - view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + view = await app.service.act({ action: 'accept', key: segment.key, token: view.token }); for (const item of view.items) - view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + view = await app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); let release!: () => void, releaseNew!: () => void, arrived!: () => void; const held = new Promise(resolve => { release = resolve; }); @@ -1565,7 +1566,7 @@ test('keeps merge-queue polling current across Plans refresh and import outcomes await heldNew; await route.continue(); }); - await page.goto(app.url); + await openReview(page, app.url); page.on('dialog', dialog => dialog.accept()); await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); await captured; diff --git a/test/browser/review-page.ts b/test/browser/review-page.ts new file mode 100644 index 00000000..b8bcf7a5 --- /dev/null +++ b/test/browser/review-page.ts @@ -0,0 +1,30 @@ +import { expect, type Page } from '@playwright/test'; + +/** + * Every page load fetches the review once and shows "Linking changes to plan items…" until it has applied the answer + * (or its failure). The server runs in this test process; while its review loads blocked the event loop, Playwright + * could not act until a load had finished. They no longer block, so a test that acts on the loaded page waits for it. + */ +const LOADING = 'Linking changes to plan items…'; + +export async function openReview(page: Page, url: string): Promise { + await page.goto(url); + await expect(page.locator('#banner')).not.toHaveText(LOADING, { timeout: 30_000 }); +} + +export async function reloadReview(page: Page): Promise { + await page.reload(); + await expect(page.locator('#banner')).not.toHaveText(LOADING, { timeout: 30_000 }); +} + +/** + * Clicks Refresh and waits for a review response and for the page to apply it, so the next step acts on the refreshed + * page. Call it on a settled page: a click the page ignores (another action in progress) starts no load and fails here, + * unless another review request was already in flight. + */ +export async function refreshReview(page: Page): Promise { + const answered = page.waitForResponse(response => new URL(response.url()).pathname === '/api/review', { timeout: 30_000 }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await answered; + await expect(page.locator('#banner')).not.toHaveText(LOADING, { timeout: 30_000 }); +} diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index c028ced0..d4975f88 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { test, expect } from '@playwright/test'; +import { openReview, refreshReview, reloadReview } from './review-page.ts'; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -23,34 +24,34 @@ test.beforeEach(async () => { root=mkdtempSync(join(tmpdir(),'codeboost-browser- test.afterEach(async () => { await app.close();rmSync(root,{recursive:true,force:true}); }); test('resends the same merge key after a 503, a lost response, an unreadable body or an unknown outcome, and a new key after a definite answer',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); - const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=await startServer(config,0,undefined,gateway); - let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); const keys:string[]=[],answers:Array<'503'|'lost'|'garbled'|'unknown'|'409'>=['503','lost','garbled','unknown','409','409']; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}keys.push(body.actionId);const answer=answers[keys.length-1]; if(answer==='lost'){await route.abort('failed');return;} if(answer==='unknown'){await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Direct merge response was lost.',outcomeUnknown:true})});return;} if(answer==='garbled'){await route.fulfill({status:200,contentType:'application/json',body:'{"mergeResult":'});return;} await route.fulfill({status:answer==='503'?503:409,contentType:'application/json',body:JSON.stringify({error:answer==='503'?'The review server is shutting down.':'Merge is blocked.'})});}); - await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); - for(let click=0;click<6;click++){await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeEnabled();await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect.poll(()=>keys.length).toBe(click+1);await expect(page.locator('#banner')).toContainText('Merge blocked');} + await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept()); + for(let click=0;click<6;click++){await refreshReview(page);await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeEnabled();await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect.poll(()=>keys.length).toBe(click+1);await expect(page.locator('#banner')).toContainText('Merge blocked');} expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/);expect(keys.slice(1,5)).toEqual([keys[0],keys[0],keys[0],keys[0]]);expect(keys[5]).not.toBe(keys[4]); }); test('mints a new merge key for a retry once the attempt started by a retained key has ended',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); - const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=await startServer(config,0,undefined,gateway); - let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); const keys:string[]=[]; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}keys.push(body.actionId); if(keys.length===1){await route.abort('failed');return;} await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Merge is blocked.'})});}); - await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect.poll(()=>keys.length).toBe(1);await expect(page.locator('#banner')).toContainText('Merge blocked'); // The lost click did reach GitHub (its response was lost), and that attempt later ends as refused. const expected={...view.expected,reviewVersion:view.expected.reviewVersion!};const attempt=app.service.store.beginMergeAttempt(config.identity,expected,view.snapshot.head,null,'direct',keys[0]); app.service.store.finishMergeAttempt(config.identity,attempt.id,{state:'failed',reason:'Required status check is expected.'}); - await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toBeEnabled(); + await refreshReview(page);await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toBeEnabled(); await page.getByRole('button',{name:'Retry merge',exact:true}).click();await expect.poll(()=>keys.length).toBe(2); expect(keys[1]).not.toBe(keys[0]); }); @@ -59,58 +60,58 @@ test('answers 503 when the merge deadline stops a click, so the browser may rese const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); const gateway:MergeGateway={inspect:async options=>new Promise((_,reject)=>options?.signal?.addEventListener('abort',()=>reject(options.signal!.reason),{once:true})),merge:async()=>{throw new Error('The deadline stops the click before any merge.');}}; app=await startServer(config,0,undefined,gateway); - let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); const response=await fetch(new URL('/api/action',app.url),{method:'POST',headers:{'x-codeboost-token':app.token,'content-type':'application/json'},body:JSON.stringify({action:'merge',token:view.token,actionId:randomUUID()})}); expect(response.status).toBe(503);expect((await response.json()).error).toMatch(/deadline/i); expect(app.service.store.getMergeAttempt(app.service.config.identity)).toBeNull(); }); test('resends a retained merge key with the request it was made for, even after a review edit and Refresh',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config); - const gateway:MergeGateway={inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('The browser test answers every merge request itself.');}}; app=await startServer(config,0,undefined,gateway); - let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); const sent:{actionId:string;token:string}[]=[]; await page.route('**/api/action',async route=>{const body=route.request().postDataJSON();if(body.action!=='merge'){await route.continue();return;}sent.push({actionId:body.actionId,token:body.token}); if(sent.length===1){await route.abort('failed');return;} await route.fulfill({status:409,contentType:'application/json',body:JSON.stringify({error:'Merge is blocked.'})});}); - await page.goto(app.url);page.on('dialog',dialog=>dialog.accept()); + await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept()); await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect.poll(()=>sent.length).toBe(1);await expect(page.locator('#banner')).toContainText('Merge blocked'); // A review edit changes the token the next Refresh shows. - view=app.service.load();app.service.act({action:'note',item:view.items[0]!.id,kind:'question',text:'Edited after the lost click.',token:view.token}); - await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeEnabled(); + view=await app.service.load();await app.service.act({action:'note',item:view.items[0]!.id,kind:'question',text:'Edited after the lost click.',token:view.token}); + await refreshReview(page);await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeEnabled(); await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect.poll(()=>sent.length).toBe(2); expect(sent[1]).toEqual(sent[0]); - expect(app.service.load().token).not.toBe(sent[0]!.token); + expect((await app.service.load()).token).not.toBe(sent[0]!.token); }); test('refuses a merge request without an idempotency key before doing any work',async()=>{ - const view=app.service.load(),post=(body:object)=>fetch(new URL('/api/action',app.url),{method:'POST',headers:{'x-codeboost-token':app.token,'content-type':'application/json'},body:JSON.stringify(body)}); + const view=await app.service.load(),post=(body:object)=>fetch(new URL('/api/action',app.url),{method:'POST',headers:{'x-codeboost-token':app.token,'content-type':'application/json'},body:JSON.stringify(body)}); for(const actionId of [undefined,'not-a-uuid']){const response=await post({action:'merge',token:view.token,actionId});expect(response.status).toBe(400);expect((await response.json()).error).toMatch(/actionId/);} expect(app.service.store.getMergeAttempt(app.service.config.identity)).toBeNull(); }); test('reviews real changes, persists approval and conversation, and assigns foreign code',async({page})=>{ const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message)); - await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); + await openReview(page, app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); await expect(page.getByText('0 of 3 approved')).toBeVisible();await expect(page.getByText('– No tests defined',{exact:false}).first()).toBeVisible(); await page.getByRole('button',{name:'Approve P1',exact:true}).click();await expect(page.getByText('1 of 3 approved')).toBeVisible(); await page.getByRole('button',{name:'Request change',exact:true}).click();await page.getByLabel('Change to request').fill('Add a test for the upper bound.');await page.getByRole('button',{name:'Save change request'}).click();await expect(page.getByText('Add a test for the upper bound.',{exact:true})).toBeVisible(); - await page.reload();await expect(page.getByText('1 of 3 approved')).toBeVisible();await expect(page.getByText('Add a test for the upper bound.',{exact:true})).toBeVisible(); + await reloadReview(page);await expect(page.getByText('1 of 3 approved')).toBeVisible();await expect(page.getByText('Add a test for the upper bound.',{exact:true})).toBeVisible(); await page.getByRole('button',{name:/Unplanned changes/}).click();await page.getByLabel('Assign change 1 to').selectOption('P1');await page.getByRole('button',{name:'Assign',exact:true}).first().click(); await page.getByRole('button',{name:/P1 Bound exponential retries/}).click();await expect(page.getByText('! Stale:',{exact:false})).toBeVisible();await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); await page.screenshot({path:'test-results/review-desktop.png',fullPage:true});expect(errors).toEqual([]); }); test('labels a resolved foreign conflict as Unplanned agent-resolved work',async({page})=>{ - const service=app.service,identity=service.config.identity,initial=service.load(),repository=service.config.repository; + const service=app.service,identity=service.config.identity,initial=await service.load(),repository=service.config.repository; writeFileSync(join(repository,'resolved-conflict.txt'),'resolved by agent\n');fixtureGit(repository,'add','resolved-conflict.txt'); fixtureGit(repository,'commit','-m','Resolved foreign conflict');const head=fixtureGit(repository,'rev-parse','HEAD'); service.store.recordHistory(identity,initial.expected,initial.snapshot.base,head,[ {sha:head,owner:null,origin:'foreign',sourceSha:initial.snapshot.head,conflictResolved:true}, ]); - await page.goto(app.url);await page.getByRole('button',{name:/Unplanned changes/}).click(); + await openReview(page, app.url);await page.getByRole('button',{name:/Unplanned changes/}).click(); await expect(page.getByText('Unplanned · conflict resolved by agent',{exact:true}).first()).toBeVisible(); await expect(page.getByRole('article',{name:/resolved-conflict\.txt, Unplanned · conflict resolved by agent/}).first()).toBeVisible(); }); test('opens an item selected while an assignment is in flight in the view its answered state calls for',async({page})=>{ - await page.goto(app.url);await page.getByRole('button',{name:'Approve P1',exact:true}).click();await expect(page.getByText('1 of 3 approved')).toBeVisible(); + await openReview(page, app.url);await page.getByRole('button',{name:'Approve P1',exact:true}).click();await expect(page.getByText('1 of 3 approved')).toBeVisible(); let release!:()=>void,arrived!:()=>void;const held=new Promise(resolve=>{release=resolve;}),assignArrived=new Promise(resolve=>{arrived=resolve;}); await page.route('**/api/action',async route=>{if(route.request().postDataJSON().action==='assign'){arrived();await held;}await route.continue();}); await page.getByRole('button',{name:/Unplanned changes/}).click();await page.getByLabel('Assign change 1 to').selectOption('P1');await page.getByRole('button',{name:'Assign',exact:true}).first().click();await assignArrived; @@ -118,36 +119,36 @@ test('opens an item selected while an assignment is in flight in the view its an await page.getByRole('button',{name:/P1 Bound exponential retries/}).click();await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();await expect(page.getByText('! Stale:',{exact:false})).toHaveCount(0); release();await expect(page.getByText('! Stale:',{exact:false})).toBeVisible(); await expect(page.getByRole('button',{name:'Since approval',exact:true})).toHaveAttribute('aria-pressed','true');await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); - expect(app.service.load().items.find(item=>item.id==='P1')!.state).toBe('stale'); + expect((await app.service.load()).items.find(item=>item.id==='P1')!.state).toBe('stale'); // An explicit choice survives the next answer. await page.getByRole('button',{name:'Full change',exact:true}).click();await expect(page.getByRole('heading',{name:'At approval'})).toHaveCount(0); // Every check below already holds before the Refresh answer, so wait until that answer is rendered. - const answered=page.waitForResponse('**/api/review');await page.getByRole('button',{name:'Refresh',exact:true}).click();await answered;await expect(page.locator('#banner')).not.toContainText('Linking changes'); + const answered=page.waitForResponse('**/api/review');await refreshReview(page);await answered;await expect(page.locator('#banner')).not.toContainText('Linking changes'); await expect(page.getByText('! Stale:',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:'Full change',exact:true})).toHaveAttribute('aria-pressed','true');await expect(page.getByRole('heading',{name:'At approval'})).toHaveCount(0); // A code change on the still-stale item is a new state, so the choice made on the old one no longer holds. const repository=app.service.config.repository;writeFileSync(join(repository,'retry.ts'),'export function delay(attempt: number) {\n return 42;\n}\n');fixtureGit(repository,'commit','-am','External change'); - await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('! Stale:',{exact:false}).first()).toBeVisible();await expect(page.getByRole('button',{name:'Since approval',exact:true})).toHaveAttribute('aria-pressed','true');await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); + await refreshReview(page);await expect(page.getByText('! Stale:',{exact:false}).first()).toBeVisible();await expect(page.getByRole('button',{name:'Since approval',exact:true})).toHaveAttribute('aria-pressed','true');await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); // After approval, a new stale state opens the comparison again. await page.getByRole('button',{name:'Full change',exact:true}).click();await expect(page.getByRole('heading',{name:'At approval'})).toHaveCount(0); await page.getByRole('button',{name:'Approve P1',exact:true}).click();await expect(page.getByText('! Stale:',{exact:false})).toHaveCount(0); - let view=app.service.load();view=app.service.act({action:'assign',key:view.segments.find(segment=>segment.row==='Unplanned'&&segment.path==='retry.ts')!.key,item:'P1',token:view.token});expect(view.items.find(item=>item.id==='P1')!.state).toBe('stale'); - await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('! Stale:',{exact:false}).first()).toBeVisible();await expect(page.getByRole('button',{name:'Since approval',exact:true})).toHaveAttribute('aria-pressed','true');await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); + let view=await app.service.load();view=await app.service.act({action:'assign',key:view.segments.find(segment=>segment.row==='Unplanned'&&segment.path==='retry.ts')!.key,item:'P1',token:view.token});expect(view.items.find(item=>item.id==='P1')!.state).toBe('stale'); + await refreshReview(page);await expect(page.getByText('! Stale:',{exact:false}).first()).toBeVisible();await expect(page.getByRole('button',{name:'Since approval',exact:true})).toHaveAttribute('aria-pressed','true');await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); }); test('keeps an explicit view choice only while the server names the same stale state',async({page})=>{ - let view=app.service.load();view=app.service.act({action:'approve',item:'P1',confirmNoChange:false,token:view.token}); - view=app.service.act({action:'assign',key:view.segments.find(segment=>segment.row==='Unplanned')!.key,item:'P1',token:view.token});expect(view.items.find(item=>item.id==='P1')!.state).toBe('stale'); + let view=await app.service.load();view=await app.service.act({action:'approve',item:'P1',confirmNoChange:false,token:view.token}); + view=await app.service.act({action:'assign',key:view.segments.find(segment=>segment.row==='Unplanned')!.key,item:'P1',token:view.token});expect(view.items.find(item=>item.id==='P1')!.state).toBe('stale'); // The answer is the server's own, except that P1's staleKey can be replaced; nothing else about P1 changes. let staleKey:string|null=null; await page.route('**/api/review',async route=>{const response=await route.fetch(),body=await response.json();if(staleKey)body.items.find((item:{id:string})=>item.id==='P1').staleKey=staleKey;await route.fulfill({response,json:body});}); const refresh=async()=>{const answered=page.waitForResponse('**/api/review');await page.getByRole('button',{name:'Refresh',exact:true}).click();await answered;await expect(page.locator('#banner')).not.toContainText('Linking changes');}; - await page.goto(app.url);await page.getByRole('button',{name:/P1 Bound exponential retries/}).click();await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); + await openReview(page, app.url);await page.getByRole('button',{name:/P1 Bound exponential retries/}).click();await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); await page.getByRole('button',{name:'Full change',exact:true}).click();await expect(page.getByRole('heading',{name:'At approval'})).toHaveCount(0); await refresh();await expect(page.getByRole('button',{name:'Full change',exact:true})).toHaveAttribute('aria-pressed','true');await expect(page.getByRole('heading',{name:'At approval'})).toHaveCount(0); staleKey='0'.repeat(64);await refresh(); await expect(page.getByRole('button',{name:'Since approval',exact:true})).toHaveAttribute('aria-pressed','true');await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); }); test('shows file metadata and no-change confirmation, supports narrow desktop and keyboard',async({page})=>{ - await page.goto(app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await expect(page.getByText('File mode changed',{exact:false})).toBeVisible();await expect(page.getByText('✕ Out of scope',{exact:true})).toBeVisible(); + await openReview(page, app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await expect(page.getByText('File mode changed',{exact:false})).toBeVisible();await expect(page.getByText('✕ Out of scope',{exact:true})).toBeVisible(); await page.getByRole('button',{name:/P3 Confirm API compatibility/}).click();await page.getByRole('button',{name:'Confirm no change needed',exact:true}).click();await expect(page.getByText('1 of 3 approved')).toBeVisible(); await page.setViewportSize({width:1280,height:900});await expect(page.getByRole('complementary',{name:'Conversation',exact:true})).not.toBeVisible();await page.getByRole('button',{name:'Conversation',exact:true}).click();await expect(page.getByRole('complementary',{name:'Conversation',exact:true})).toBeVisible(); await page.getByLabel('Question about this item').fill('npa');await expect(page.getByRole('heading',{name:'Confirm API compatibility'})).toBeVisible(); @@ -155,10 +156,10 @@ test('shows file metadata and no-change confirmation, supports narrow desktop an await page.setViewportSize({width:1000,height:800});await expect(page.getByRole('heading',{name:'A little more room to review'})).toBeVisible(); }); test('refresh makes changed code stale and stale browser actions cannot approve it',async({page,context})=>{ - await page.goto(app.url);await page.getByRole('button',{name:'Approve P1',exact:true}).click();await expect(page.getByText('1 of 3 approved')).toBeVisible(); - const other=await context.newPage();await other.goto(app.url);await expect(other.getByText('1 of 3 approved')).toBeVisible(); + await openReview(page, app.url);await page.getByRole('button',{name:'Approve P1',exact:true}).click();await expect(page.getByText('1 of 3 approved')).toBeVisible(); + const other=await context.newPage();await openReview(other, app.url);await expect(other.getByText('1 of 3 approved')).toBeVisible(); const repository=app.service.config.repository;writeFileSync(join(repository,'retry.ts'),'export function delay(attempt: number) {\n return 42;\n}\n');fixtureGit(repository,'commit','-am','External change'); - await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('! Stale:',{exact:false}).first()).toBeVisible(); + await refreshReview(page);await expect(page.getByText('! Stale:',{exact:false}).first()).toBeVisible(); await other.getByRole('button',{name:/P3 Confirm API compatibility/}).click();await other.getByRole('button',{name:'Confirm no change needed',exact:true}).click();await expect(other.getByRole('status').filter({hasText:'Stale review state'})).toBeVisible(); }); test('requires private credentials and rejects foreign origins',async({request})=>{ @@ -170,92 +171,92 @@ test('requires private credentials and rejects foreign origins',async({request}) test('shows merge blockers and submits one exact-head merge',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let mergeCalls:string[]=[];let release!:()=>void;const held=new Promise(resolve=>{release=resolve;}); const gateway:MergeGateway={ - inspect:async()=>{const snapshot=app.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + inspect:async()=>{const snapshot=(await app.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, merge:async head=>{mergeCalls.push(head);await held;app.service.load=()=>{throw new Error('post-command reload failed');};app.service.store.getMergeAttempt=()=>{throw new Error('post-command queue read failed');};return {url:'https://github.com/example/repo/pull/21'};}, }; - app=await startServer(config,0,undefined,gateway);await page.goto(app.url); + app=await startServer(config,0,undefined,gateway);await openReview(page, app.url); await expect(page.locator('#merge')).toBeDisabled();await page.getByRole('button',{name:'Review blockers'}).click();await expect(page.getByRole('heading',{name:'Merge blockers'})).toBeVisible();await expect(page.getByText(/is unreviewed/).first()).toBeVisible();await page.screenshot({path:'test-results/merge-blockers.png',fullPage:true});await page.getByRole('button',{name:'Close',exact:true}).click(); - let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); - await page.getByRole('button',{name:'Refresh',exact:true}).click();const expectedHead=view.snapshot.head;await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeEnabled();page.on('dialog',dialog=>dialog.accept()); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + await refreshReview(page);const expectedHead=view.snapshot.head;await expect(page.getByRole('button',{name:'Merge PR',exact:true})).toBeEnabled();page.on('dialog',dialog=>dialog.accept()); await page.locator('#merge').evaluate((button:HTMLButtonElement)=>{button.click();button.click();});await expect.poll(()=>mergeCalls.length).toBe(1);await expect(page.locator('#merge')).toBeDisabled();release();await expect(page.locator('#banner')).toContainText('Merge submitted.');await expect(page.locator('#merge')).toBeDisabled();await page.locator('#merge').evaluate((button:HTMLButtonElement)=>button.click());expect(mergeCalls).toEqual([expectedHead]); }); test('keeps the reviewed head queued until confirmed merged and preserves current input',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app,phase:'queued'|'merged'='queued',queueReads=0; const gateway:MergeGateway&MergeQueueGateway={ - inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, queueWatermark:async()=>null, merge:async()=>({url:'https://github.com/example/repo/pull/24'}), inspectQueue:async head=>{queueReads++;return phase==='queued'?{state:'queued',reviewedHead:head,entryId:'MQE_1',phase:'AWAITING_CHECKS',position:2,enqueuedAt:'2026-09-24T08:00:00Z',queueHead:head}:{state:'merged',reviewedHead:head,mergedAt:'2026-09-24T08:10:00Z'};}, }; - app=appRef=await startServer(config,0,undefined,gateway);let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); - await page.goto(app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await page.getByLabel('Question about this item').fill('Keep this draft while polling.');page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click(); + app=appRef=await startServer(config,0,undefined,gateway);let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + await openReview(page, app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await page.getByLabel('Question about this item').fill('Keep this draft while polling.');page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click(); await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled();await expect(page.getByLabel('Question about this item')).toHaveValue('Keep this draft while polling.');await expect.poll(()=>queueReads).toBeGreaterThan(0); phase='merged';await expect(page.getByRole('button',{name:'Merged',exact:true})).toBeDisabled({timeout:10000});await expect(page.locator('#banner')).toContainText('GitHub confirmed the reviewed head was merged.');await expect(page.getByRole('heading',{name:'Document retry behavior'})).toBeVisible();await expect(page.getByLabel('Question about this item')).toHaveValue('Keep this draft while polling.'); }); test('backs off repeated merge-queue polling',async({page})=>{ await page.addInitScript(()=>{const delays:number[]=[];(window as typeof window&{__mergePollDelays:number[]}).__mergePollDelays=delays;const native=window.setTimeout.bind(window);window.setTimeout=((handler:TimerHandler,timeout?:number,...args:unknown[])=>{if(typeof timeout==='number')delays.push(timeout);return native(handler,timeout,...args);}) as typeof window.setTimeout;}); const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app,queueReads=0; - const gateway:MergeGateway&MergeQueueGateway={inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},queueWatermark:async()=>null,merge:async()=>({url:'https://github.com/example/repo/pull/24'}),inspectQueue:async head=>{queueReads++;return{state:'queued',reviewedHead:head,entryId:'MQE_1',phase:'QUEUED',position:1,enqueuedAt:'2026-09-24T08:00:00Z',queueHead:head};}}; - app=appRef=await startServer(config,0,undefined,gateway);let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); - await page.goto(app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect.poll(()=>queueReads,{timeout:10000}).toBeGreaterThanOrEqual(2); + const gateway:MergeGateway&MergeQueueGateway={inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},queueWatermark:async()=>null,merge:async()=>({url:'https://github.com/example/repo/pull/24'}),inspectQueue:async head=>{queueReads++;return{state:'queued',reviewedHead:head,entryId:'MQE_1',phase:'QUEUED',position:1,enqueuedAt:'2026-09-24T08:00:00Z',queueHead:head};}}; + app=appRef=await startServer(config,0,undefined,gateway);let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect.poll(()=>queueReads,{timeout:20000}).toBeGreaterThanOrEqual(2); const delays=await page.evaluate(()=>(window as typeof window&{__mergePollDelays:number[]}).__mergePollDelays.filter(value=>value>=500));expect(delays.slice(0,2)).toEqual([2000,4000]); }); test('does not queue-poll an ambiguous direct merge attempt',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app,polls=0; - const gateway:MergeGateway={inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>({url:''})}; - app=appRef=await startServer(config,0,undefined,gateway);const view=app.service.load();app.service.store.beginMergeAttempt(config.identity,{...view.expected,reviewVersion:view.expected.reviewVersion!},view.snapshot.head,null,'direct'); - await page.route('**/api/merge',async route=>{polls++;await route.continue();});await page.goto(app.url);await expect(page.getByRole('button',{name:'Submitting…',exact:true})).toBeDisabled();await page.waitForTimeout(2500);expect(polls).toBe(0); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>({url:''})}; + app=appRef=await startServer(config,0,undefined,gateway);const view=await app.service.load();app.service.store.beginMergeAttempt(config.identity,{...view.expected,reviewVersion:view.expected.reviewVersion!},view.snapshot.head,null,'direct'); + await page.route('**/api/merge',async route=>{polls++;await route.continue();});await openReview(page, app.url);await expect(page.getByRole('button',{name:'Submitting…',exact:true})).toBeDisabled();await page.waitForTimeout(2500);expect(polls).toBe(0); }); test('surfaces queue removal and retries only the same reviewed head',async({page})=>{ test.slow(); const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app,mergeCalls=0; const gateway:MergeGateway&MergeQueueGateway={ - inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, queueWatermark:async()=>null, merge:async()=>{mergeCalls++;return {url:'https://github.com/example/repo/pull/24'};}, inspectQueue:async head=>mergeCalls===1?{state:'removed',reviewedHead:head,removedAt:'2026-09-24T08:05:00Z',reason:'Required check failed.'}:{state:'queued',reviewedHead:head,entryId:'MQE_2',phase:'QUEUED',position:1,enqueuedAt:'2026-09-24T08:06:00Z',queueHead:head}, }; - app=appRef=await startServer(config,0,undefined,gateway);let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); - await page.goto(app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect(page.locator('#banner')).toContainText('Refresh to verify retry readiness.',{timeout:10000});await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toHaveCount(0);await expect(page.locator('#merge')).toBeDisabled();await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toBeEnabled(); + app=appRef=await startServer(config,0,undefined,gateway);let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect(page.locator('#banner')).toContainText('Refresh to verify retry readiness.',{timeout:10000});await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toHaveCount(0);await expect(page.locator('#merge')).toBeDisabled();await refreshReview(page);await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toBeEnabled(); await page.getByRole('button',{name:'Retry merge',exact:true}).click();await expect.poll(()=>mergeCalls).toBe(2);await expect(page.getByRole('button',{name:'Merge queued',exact:true})).toBeDisabled();expect(app.service.store.getMergeAttempt(config.identity)).toMatchObject({state:'queued',reviewedHead:view.snapshot.head}); }); test('ignores a merge poll started before a newer review action',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app; const gateway:MergeGateway&MergeQueueGateway={ - inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, queueWatermark:async()=>null, merge:async()=>({url:'https://github.com/example/repo/pull/24'}), inspectQueue:async head=>({state:'removed',reviewedHead:head,removedAt:'2026-09-24T08:05:00Z',reason:'Old poll result.'}), }; - app=appRef=await startServer(config,0,undefined,gateway);let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + app=appRef=await startServer(config,0,undefined,gateway);let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); let release!:()=>void,releaseNew!:()=>void,arrived!:()=>void;const held=new Promise(resolve=>release=resolve),heldNew=new Promise(resolve=>releaseNew=resolve),started=new Promise(resolve=>arrived=resolve);let polls=0; await page.route('**/api/merge',async route=>{polls++;if(polls===1){arrived();await held;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({queue:{state:'removed',reviewedHead:view.snapshot.head,url:null,reason:'Old poll result.',phase:null,position:null,occurredAt:'2026-09-24T08:05:00Z',retryable:true}})});return;}await heldNew;await route.continue();}); - await page.goto(app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await started; + await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await started; await page.getByRole('button',{name:'Request change',exact:true}).click();await page.getByLabel('Change to request').fill('New review decision.');await page.getByRole('button',{name:'Save change request'}).click();await expect(page.getByText('New review decision.',{exact:true})).toBeVisible(); const staleResponse=page.waitForResponse(response=>response.url().endsWith('/api/merge'));release();try{await staleResponse;await page.waitForTimeout(100);await expect(page.locator('#banner')).not.toContainText('Old poll result.');await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toHaveCount(0);await expect(page.locator('#merge')).toBeDisabled();}finally{releaseNew();} }); test('requires fresh review instead of retry when GitHub replaces the queued head',async({page})=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app; const gateway:MergeGateway&MergeQueueGateway={ - inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, + inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};}, queueWatermark:async()=>null, merge:async()=>({url:'https://github.com/example/repo/pull/24'}),inspectQueue:async()=>{throw new Error('The pull request head changed after review.');}, }; - app=appRef=await startServer(config,0,undefined,gateway);let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); - await page.goto(app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect(page.locator('#banner')).toContainText('The pull request head changed after review.',{timeout:10000});await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toHaveCount(0);await expect(page.locator('#merge')).toBeDisabled(); + app=appRef=await startServer(config,0,undefined,gateway);let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect(page.locator('#banner')).toContainText('The pull request head changed after review.',{timeout:10000});await expect(page.getByRole('button',{name:'Retry merge',exact:true})).toHaveCount(0);await expect(page.locator('#merge')).toBeDisabled(); }); test('keeps stale merge failures disabled until refresh',async({page})=>{ - const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app;const gateway:MergeGateway={inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('head changed');}};app=appRef=await startServer(config,0,undefined,gateway); - let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); - await page.goto(app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect(page.locator('#banner')).toContainText('Merge blocked. head changed');await expect(page.locator('#merge')).toBeDisabled(); + const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app;const gateway:MergeGateway={inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{throw new Error('head changed');}};app=appRef=await startServer(config,0,undefined,gateway); + let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + await openReview(page, app.url);page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'Merge PR',exact:true}).click();await expect(page.locator('#banner')).toContainText('Merge blocked. head changed');await expect(page.locator('#merge')).toBeDisabled(); }); test('never enables merging for a demo even with an injected gateway',async({page})=>{ - const config=app.service.config;await app.close();let inspections=0;const gateway:MergeGateway={inspect:async()=>{inspections++;throw new Error('must not run');},merge:async()=>({url:''})};app=await startServer(config,0,undefined,gateway);await page.goto(app.url);await expect(page.locator('#merge')).toBeHidden();expect(inspections).toBe(0); + const config=app.service.config;await app.close();let inspections=0;const gateway:MergeGateway={inspect:async()=>{inspections++;throw new Error('must not run');},merge:async()=>({url:''})};app=await startServer(config,0,undefined,gateway);await openReview(page, app.url);await expect(page.locator('#merge')).toBeHidden();expect(inspections).toBe(0); }); test('shows an honest history error and keeps markup in notes as text',async({page})=>{ - await page.goto(app.url);await page.getByLabel('Question about this item').fill('');await page.getByRole('button',{name:'Ask agent'}).click();await expect(page.getByText('',{exact:true})).toBeVisible();await expect(page.locator('#notes img')).toHaveCount(0); + await openReview(page, app.url);await page.getByLabel('Question about this item').fill('');await page.getByRole('button',{name:'Ask agent'}).click();await expect(page.getByText('',{exact:true})).toBeVisible();await expect(page.locator('#notes img')).toHaveCount(0); const repository=app.service.config.repository;fixtureGit(repository,'checkout','--orphan','unrelated');fixtureGit(repository,'commit','-m','Unrelated history'); - await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('Could not read this branch’s history.',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:'Approve P1',exact:true})).not.toBeVisible(); + await refreshReview(page);await expect(page.getByText('Could not read this branch’s history.',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:'Approve P1',exact:true})).not.toBeVisible(); }); test('can assign a large foreign change without sending its content back in the command',async({request})=>{ const repository=app.service.config.repository;writeFileSync(join(repository,'debug.log'),'x'.repeat(20000)+'\n');fixtureGit(repository,'commit','-am','Large foreign change'); @@ -265,47 +266,47 @@ test('can assign a large foreign change without sending its content back in the }); test('shows bounded raster previews and byte sizes for file-change cards',async({page})=>{ const repository=app.service.config.repository;writeFileSync(join(repository,'pixel.png'),Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aRHsAAAAASUVORK5CYII=','base64'));fixtureGit(repository,'add','pixel.png');fixtureGit(repository,'commit','-m','Add image'); - await page.goto(app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await expect(page.getByRole('img',{name:'Current image in pixel.png'})).toBeVisible();await expect(page.getByRole('img',{name:'Current image in pixel.png'})).toHaveJSProperty('naturalWidth',1);await expect(page.getByText('Size: N/A → 68 bytes',{exact:true})).toBeVisible(); + await openReview(page, app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await expect(page.getByRole('img',{name:'Current image in pixel.png'})).toBeVisible();await expect(page.getByRole('img',{name:'Current image in pixel.png'})).toHaveJSProperty('naturalWidth',1);await expect(page.getByText('Size: N/A → 68 bytes',{exact:true})).toBeVisible(); }); test('rejects malformed non-ASCII credentials consistently',async({request})=>{ const response=await request.get(app.url.split('#')[0]+'api/review',{headers:{'x-codeboost-token':'é'.repeat(64)}});expect(response.status()).toBe(403); }); test('keeps stale item controls unavailable after a failed refresh',async({page})=>{ - await page.goto(app.url);const repository=app.service.config.repository;fixtureGit(repository,'checkout','--orphan','unrelated');fixtureGit(repository,'commit','-m','Other history'); - await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('Could not read this branch’s history.',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:/P3 Confirm API compatibility/})).toHaveCount(0); + await openReview(page, app.url);const repository=app.service.config.repository;fixtureGit(repository,'checkout','--orphan','unrelated');fixtureGit(repository,'commit','-m','Other history'); + await refreshReview(page);await expect(page.getByText('Could not read this branch’s history.',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:/P3 Confirm API compatibility/})).toHaveCount(0); }); test('accepts a foreign segment and keeps that choice across reload',async({page})=>{ - await page.goto(app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await page.getByRole('button',{name:'Accept as is',exact:true}).first().click();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible();await expect(page.getByText('Accepted outside plan',{exact:true})).toBeVisible();await page.reload();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible(); + await openReview(page, app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await page.getByRole('button',{name:'Accept as is',exact:true}).first().click();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible();await expect(page.getByText('Accepted outside plan',{exact:true})).toBeVisible();await reloadReview(page);await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible(); }); test('shows the whole-plan empty state without claiming checks passed',async({page})=>{ const {repository,identity}=app.service.config;const base=app.service.store.getSnapshot(identity).base;fixtureGit(repository,'reset','--hard',base); - await page.goto(app.url);await expect(page.getByRole('heading',{name:'No code changes yet'})).toBeVisible();await expect(page.getByRole('button',{name:'Confirm no change needed',exact:true})).toBeVisible();await expect(page.getByRole('button',{name:'AI review: – Not run',exact:true})).toBeVisible(); + await openReview(page, app.url);await expect(page.getByRole('heading',{name:'No code changes yet'})).toBeVisible();await expect(page.getByRole('button',{name:'Confirm no change needed',exact:true})).toBeVisible();await expect(page.getByRole('button',{name:'AI review: – Not run',exact:true})).toBeVisible(); }); test('routes mixed owned and ambiguous items to attribution resolution',async({page})=>{ const {repository,identity}=app.service.config;writeFileSync(join(repository,'retry.ts'),'export function delay(attempt: number) {\n return Math.min(10000, 200 * 2 ** attempt);\n}\n');fixtureGit(repository,'commit','-am','P2 changes retry'); const head=fixtureGit(repository,'rev-parse','HEAD'),snapshot=app.service.store.getSnapshot(identity);app.service.store.recordHistory(identity,{revision:1,snapshotId:snapshot.id},snapshot.base,head,[{sha:head,owner:'P2',origin:'owned',sourceSha:null}]); - await page.goto(app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await page.getByRole('button',{name:'Resolve ambiguous changes',exact:true}).click();await expect(page.getByRole('heading',{name:'Ambiguous',exact:true})).toBeVisible(); + await openReview(page, app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await page.getByRole('button',{name:'Resolve ambiguous changes',exact:true}).click();await expect(page.getByRole('heading',{name:'Ambiguous',exact:true})).toBeVisible(); }); test('keeps review shortcuts active while a toolbar button has focus',async({page})=>{ - await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();await page.getByRole('button',{name:'Refresh',exact:true}).focus();await page.keyboard.press('n');await expect(page.getByRole('heading',{name:'Document retry behavior'})).toBeVisible(); + await openReview(page, app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();await page.getByRole('button',{name:'Refresh',exact:true}).focus();await page.keyboard.press('n');await expect(page.getByRole('heading',{name:'Document retry behavior'})).toBeVisible(); }); test('attaches clicked lines to a question, persists and navigates the reference, and shows archived evidence',async({page})=>{ - await page.goto(app.url); + await openReview(page, app.url); const line=page.locator('.added [data-line]').first();await line.click(); await page.getByRole('button',{name:'Ask about selection',exact:true}).click(); await expect(page.locator('#attachment')).toContainText('retry.ts'); - await page.getByLabel('Question about this item').fill('Why this exact line?');await page.getByRole('button',{name:'Ask agent',exact:true}).click(); - await page.reload();await page.locator('.note-reference').click();await expect(page.locator('.selected-line').first()).toBeVisible(); + await page.getByLabel('Question about this item').fill('Why this exact line?');const asked=page.waitForResponse(response=>new URL(response.url()).pathname==='/api/action');await page.getByRole('button',{name:'Ask agent',exact:true}).click();await asked; + await reloadReview(page);await page.locator('.note-reference').click();await expect(page.locator('.selected-line').first()).toBeVisible(); fixtureGit(app.service.config.repository,'commit','--allow-empty','-m','another revision'); - await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.locator('.note-reference')).toContainText('Outdated');await page.locator('.note-reference').click();await expect(page.getByRole('heading',{name:'! Outdated code reference'})).toBeVisible();await expect(page.locator('#dialog-body pre')).toContainText('Math.min'); + await refreshReview(page);await expect(page.locator('.note-reference')).toContainText('Outdated');await page.locator('.note-reference').click();await expect(page.getByRole('heading',{name:'! Outdated code reference'})).toBeVisible();await expect(page.locator('#dialog-body pre')).toContainText('Math.min'); }); test('supports shift ranges, highlighted lines, and independent snippet drafts',async({page})=>{ const repository=app.service.config.repository; writeFileSync(join(repository,'retry.ts'),'export const cap = 5000;\nexport const base = 100;\nexport const delay = (n: number) => Math.min(cap, base * 2 ** n);\n'); fixtureGit(repository,'commit','-am','rewrite retry'); - let view=app.service.load(); - for(const key of view.segments.filter(s=>s.path==='retry.ts'&&['Unplanned','Ambiguous'].includes(s.row)).map(s=>s.key)) view=app.service.act({action:'assign',key,item:'P1',token:view.token}); - await page.goto(app.url); + let view=await app.service.load(); + for(const key of view.segments.filter(s=>s.path==='retry.ts'&&['Unplanned','Ambiguous'].includes(s.row)).map(s=>s.key)) view=await app.service.act({action:'assign',key,item:'P1',token:view.token}); + await openReview(page, app.url); const block=page.locator('.added[data-segment]').filter({hasText:'export const cap'}); await block.locator('[data-line]').nth(0).click();await block.locator('[data-line]').nth(2).click({modifiers:['Shift']}); await expect(page.locator('#selection-label')).toContainText('L1–3'); @@ -319,23 +320,24 @@ test('supports shift ranges, highlighted lines, and independent snippet drafts', await page.getByRole('button',{name:'Save change request',exact:true}).click();await expect(page.locator('.note-reference')).toContainText('L1–3'); }); test('configures the question agent in Settings and displays persisted asynchronous answers',async({page})=>{ - const config=app.service.config;await app.close();let providerAtCall:string|null=null; - app=await startServer(config,0,async prompt=>{providerAtCall=app.service.store.questionProvider();expect(prompt).toContain('Why this cap?');await new Promise(resolve=>setTimeout(resolve,300));return 'The cap prevents unbounded retry delays.';}); - await page.goto(app.url);await page.getByRole('button',{name:'Settings',exact:true}).click();await expect(page.locator('#question-provider option[value="codex"]')).toHaveCount(0);await page.getByLabel('Question agent',{exact:true}).selectOption('claude');await page.getByRole('button',{name:'Save settings',exact:true}).click();await expect(page.getByText('Settings saved.',{exact:true})).toBeVisible();await page.getByRole('button',{name:'Close',exact:true}).click(); + const config=app.service.config;await app.close();let providerAtCall:string|null=null;let release!:()=>void;const released=new Promise(resolve=>{release=resolve;}); + // The agent answers only once the page has shown the question as answering, however long the server's review loads take. + app=await startServer(config,0,async prompt=>{providerAtCall=app.service.store.questionProvider();expect(prompt).toContain('Why this cap?');await released;return 'The cap prevents unbounded retry delays.';}); + await openReview(page, app.url);await page.getByRole('button',{name:'Settings',exact:true}).click();await expect(page.locator('#question-provider option[value="codex"]')).toHaveCount(0);await page.getByLabel('Question agent',{exact:true}).selectOption('claude');await page.getByRole('button',{name:'Save settings',exact:true}).click();await expect(page.getByText('Settings saved.',{exact:true})).toBeVisible();await page.getByRole('button',{name:'Close',exact:true}).click(); await page.getByLabel('Question about this item').fill('Why this cap?');await page.getByRole('button',{name:'Ask agent',exact:true}).click();await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible(); - await page.getByLabel('Question about this item').fill('My next draft');await expect(page.getByText('The cap prevents unbounded retry delays.',{exact:true})).toBeVisible({timeout:10000});await expect(page.getByLabel('Question about this item')).toHaveValue('My next draft');expect(providerAtCall).toBe('claude'); - await page.reload();await expect(page.getByText('The cap prevents unbounded retry delays.',{exact:true})).toBeVisible();await page.getByRole('button',{name:'Settings',exact:true}).click();await expect(page.getByLabel('Question agent',{exact:true})).toHaveValue('claude'); + await page.getByLabel('Question about this item').fill('My next draft');release();await expect(page.getByText('The cap prevents unbounded retry delays.',{exact:true})).toBeVisible({timeout:10000});await expect(page.getByLabel('Question about this item')).toHaveValue('My next draft');expect(providerAtCall).toBe('claude'); + await reloadReview(page);await expect(page.getByText('The cap prevents unbounded retry delays.',{exact:true})).toBeVisible();await page.getByRole('button',{name:'Settings',exact:true}).click();await expect(page.getByLabel('Question agent',{exact:true})).toHaveValue('claude'); }); test('shows agent errors and retries the saved question',async({page})=>{ const config=app.service.config;await app.close();let attempts=0;app=await startServer(config,0,async()=>{if(++attempts===1)throw new Error('Test login failure');return 'Answer after retry';}); - await page.goto(app.url);await page.getByLabel('Question about this item').fill('Explain this');await page.getByRole('button',{name:'Ask agent',exact:true}).click();await expect(page.getByText('! Test login failure',{exact:true})).toBeVisible();await page.getByRole('button',{name:'Retry answer',exact:true}).click();await expect(page.getByText('Answer after retry',{exact:true})).toBeVisible({timeout:10000});expect(app.service.store.getReviewNotes(config.identity)).toHaveLength(1); + await openReview(page, app.url);await page.getByLabel('Question about this item').fill('Explain this');await page.getByRole('button',{name:'Ask agent',exact:true}).click();await expect(page.getByText('! Test login failure',{exact:true})).toBeVisible();await page.getByRole('button',{name:'Retry answer',exact:true}).click();await expect(page.getByText('Answer after retry',{exact:true})).toBeVisible({timeout:10000});expect(app.service.store.getReviewNotes(config.identity)).toHaveLength(1); }); test('shows interrupted questions as retryable without polling indefinitely',async({page})=>{ - const service=app.service,view=service.load();const asked=service.act({action:'note',item:'P1',kind:'question',text:'Interrupted question',token:view.token}); + const service=app.service,view=await service.load();const asked=await service.act({action:'note',item:'P1',kind:'question',text:'Interrupted question',token:view.token}); const now=Date.now;Date.now=()=>now()-200000; try {service.store.beginAnswer(service.config.identity,asked.createdNoteId!,'interrupted-attempt');} finally {Date.now=now;} let polls=0;page.on('request',request=>{if(request.url().endsWith('/api/questions'))polls++;}); - await page.clock.install();await page.goto(app.url);await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toBeVisible();await page.clock.fastForward(3000);await expect(page.getByText(/Agent was interrupted or timed out/)).toBeVisible();expect(polls).toBe(1); + await page.clock.install();await openReview(page, app.url);await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toBeVisible();await page.clock.fastForward(3000);await expect(page.getByText(/Agent was interrupted or timed out/)).toBeVisible();expect(polls).toBe(1); }); test('opens Settings while the initial review is still loading',async({page})=>{ let release!:()=>void;const ready=new Promise(resolve=>{release=resolve;}); @@ -349,7 +351,7 @@ test('opens Settings while the initial review is still loading',async({page})=>{ test('acknowledges the first Ask agent click immediately and prevents duplicate submissions',async({page})=>{ const config=app.service.config;await app.close();let calls=0; app=await startServer(config,0,async()=>{calls++;return 'Single-click answer';}); - await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); + await openReview(page, app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); let release!:()=>void;const pending=new Promise(resolve=>{release=resolve;});let submissions=0; await page.route('**/api/action',async route=>{submissions++;await pending;await route.continue();}); try { @@ -367,8 +369,8 @@ test('acknowledges the first Ask agent click immediately and prevents duplicate for (const readingEarlier of [false,true]) test(`answer arrival ${readingEarlier ? 'preserves earlier reading position' : 'follows the conversation bottom'}`,async({page})=>{ const config=app.service.config;await app.close();let answer!:(text:string)=>void; app=await startServer(config,0,()=>new Promise(resolve=>{answer=resolve;})); - for(let i=0;i<3;i++) app.service.act({action:'note',item:'P1',kind:'change',text:`Earlier message ${i}\n`+'Earlier context.\n'.repeat(20),token:app.service.load().token}); - await page.goto(app.url);await page.getByLabel('Question about this item').fill('Explain the cap');await page.getByRole('button',{name:'Ask agent',exact:true}).click(); + for(let i=0;i<3;i++) await app.service.act({action:'note',item:'P1',kind:'change',text:`Earlier message ${i}\n`+'Earlier context.\n'.repeat(20),token:(await app.service.load()).token}); + await openReview(page, app.url);await page.getByLabel('Question about this item').fill('Explain the cap');await page.getByRole('button',{name:'Ask agent',exact:true}).click(); await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible(); const notes=page.locator('#notes'); await notes.evaluate((element,earlier)=>{element.scrollTop=earlier ? 80 : element.scrollHeight;},readingEarlier); @@ -379,7 +381,7 @@ for (const readingEarlier of [false,true]) test(`answer arrival ${readingEarlier else await expect.poll(()=>notes.evaluate(element=>element.scrollHeight-element.clientHeight-element.scrollTop)).toBeLessThan(2); }); test('resizes Conversation using its divider and keyboard',async({page})=>{ - await page.goto(app.url);const pane=page.locator('#conversation-pane');const divider=page.getByRole('separator',{name:'Resize conversation'}); + await openReview(page, app.url);const pane=page.locator('#conversation-pane');const divider=page.getByRole('separator',{name:'Resize conversation'}); await expect(divider).toBeVisible();const initial=(await pane.boundingBox())!.width;const handle=(await divider.boundingBox())!; await page.mouse.move(handle.x+handle.width/2,handle.y+100);await page.mouse.down();await page.mouse.move(handle.x-100,handle.y+100);await page.mouse.up(); expect((await pane.boundingBox())!.width).toBeGreaterThan(initial+90); @@ -394,9 +396,9 @@ test('places selection actions beside code deep in a scrolled diff',async({page} const repository=app.service.config.repository; writeFileSync(join(repository,'retry.ts'),Array.from({length:90},(_,i)=>`export const value${i} = ${i};`).join('\n')+'\n'); fixtureGit(repository,'commit','-am','Long diff'); - let view=app.service.load(); - for(const key of view.segments.filter(s=>s.path==='retry.ts'&&['Unplanned','Ambiguous'].includes(s.row)).map(s=>s.key)) view=app.service.act({action:'assign',key,item:'P1',token:view.token}); - await page.goto(app.url); + let view=await app.service.load(); + for(const key of view.segments.filter(s=>s.path==='retry.ts'&&['Unplanned','Ambiguous'].includes(s.row)).map(s=>s.key)) view=await app.service.act({action:'assign',key,item:'P1',token:view.token}); + await openReview(page, app.url); const line=page.locator('.added [data-line]').nth(60);await line.scrollIntoViewIfNeeded();await line.click(); const actions=page.getByRole('group',{name:'Selected code'});await expect(actions).toBeVisible(); const selection=(await line.boundingBox())!,toolbar=(await actions.boundingBox())!,code=(await page.locator('#code').boundingBox())!; @@ -408,7 +410,7 @@ test('places selection actions beside code deep in a scrolled diff',async({page} await line.scrollIntoViewIfNeeded();await expect(actions).toBeVisible();await page.getByRole('button',{name:'Clear selection',exact:true}).click();await expect(actions).toBeHidden(); }); test('Clear selection removes native text selection as well as selected lines',async({page})=>{ - await page.goto(app.url); + await openReview(page, app.url); await page.locator('.added .code-lines').first().evaluate(element=>{ const line=element.querySelector('[data-code-line]')!; const range=document.createRange();range.selectNodeContents(line); @@ -426,7 +428,7 @@ test('Clear selection removes native text selection as well as selected lines',a }); test('ignores question polls started before a newer submission',async({page})=>{ const config=app.service.config;await app.close();app=await startServer(config,0,(_prompt,signal)=>new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}))); - await page.goto(app.url);await page.getByLabel('Question about this item').fill('First question');await page.getByRole('button',{name:'Ask agent',exact:true}).click();await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible(); + await openReview(page, app.url);await page.getByLabel('Question about this item').fill('First question');await page.getByRole('button',{name:'Ask agent',exact:true}).click();await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible(); let release!:()=>void,arrived!:()=>void;const held=new Promise(r=>release=r),captured=new Promise(r=>arrived=r); await page.route('**/api/questions',async route=>{const response=await route.fetch();arrived();await held;await route.fulfill({response});}); await captured; @@ -438,7 +440,7 @@ test('ignores question polls started before a newer submission',async({page})=>{ test('polls persisted answer status without rebuilding the review',async({page})=>{ const config=app.service.config;await app.close();let settle!:(answer:string)=>void; app=await startServer(config,0,()=>new Promise(resolve=>settle=resolve)); - await page.goto(app.url);await page.getByLabel('Question about this item').fill('Why is this bounded?');await page.getByRole('button',{name:'Ask agent',exact:true}).click(); + await openReview(page, app.url);await page.getByLabel('Question about this item').fill('Why is this bounded?');await page.getByRole('button',{name:'Ask agent',exact:true}).click(); await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible(); const service=app.service,load=service.load.bind(service);let reviewLoads=0; service.load=()=>{reviewLoads++;return load();}; @@ -449,20 +451,20 @@ test('polls persisted answer status without rebuilding the review',async({page}) expect(reviewLoads).toBe(0); expect(statusBody.notes.find((note:{id:string})=>note.id)).not.toHaveProperty('text'); expect(service.store.getReviewNotes(config.identity).find(note=>note.text==='Why is this bounded?')?.answer?.status).toBe('complete'); - await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect.poll(()=>reviewLoads).toBeGreaterThan(0); + await refreshReview(page);await expect.poll(()=>reviewLoads).toBeGreaterThan(0); }); test('outdated questions explain how to continue without offering a broken retry',async({page})=>{ const service=app.service; - service.act({action:'note',item:'P1',kind:'question',text:'Old question',token:service.load().token}); + await service.act({action:'note',item:'P1',kind:'question',text:'Old question',token:(await service.load()).token}); fixtureGit(service.config.repository,'commit','--allow-empty','-m','New snapshot'); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.getByText('Old question',{exact:true})).toBeVisible(); await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toHaveCount(0); await expect(page.getByText('This question refers to an earlier review. Ask again against the current code.',{exact:true})).toBeVisible(); await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); }); for(const kind of ['question','change']) test(`preserves ${kind} edits and snippets during refresh`,async({page})=>{ - await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); + await openReview(page, app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); await page.locator('.added [data-line]').first().click(); await page.getByRole('button',{name:kind==='question'?'Ask about selection':'Request change to selection',exact:true}).click(); const composer=page.locator('#message');await composer.fill('Before refresh'); @@ -470,16 +472,16 @@ for(const kind of ['question','change']) test(`preserves ${kind} edits and snipp await page.route('**/api/review',async route=>{arrived();await held;await route.continue();}); await page.getByRole('button',{name:'Refresh',exact:true}).click();await started; await composer.fill('Edited during refresh'); - app.service.act({action:'approve',item:'P3',confirmNoChange:true,token:app.service.load().token}); + await app.service.act({action:'approve',item:'P3',confirmNoChange:true,token:(await app.service.load()).token}); release();await expect(page.getByText('1 of 3 approved')).toBeVisible(); await expect(composer).toHaveValue('Edited during refresh'); await expect(page.locator(kind==='question'?'#ask':'#request')).toHaveAttribute('aria-pressed','true'); await expect(page.locator('#attachment')).toContainText('retry.ts');await expect(page.locator('#attachment')).not.toContainText('Outdated'); await expect(page.locator('#save-note')).toBeEnabled(); - expect(app.service.load().approved).toBe(1);expect(app.service.load().notes).toHaveLength(0); + expect((await app.service.load()).approved).toBe(1);expect((await app.service.load()).notes).toHaveLength(0); }); for(const scenario of ['navigation','snapshot','removed item','failure']) test(`preserves drafts during refresh with ${scenario}`,async({page})=>{ - await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); + await openReview(page, app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); await page.locator('.added [data-line]').first().click();await page.getByRole('button',{name:'Ask about selection',exact:true}).click(); await page.locator('#message').fill('Before refresh'); let release!:()=>void,arrived!:()=>void;const held=new Promise(resolve=>release=resolve),started=new Promise(resolve=>arrived=resolve); @@ -509,7 +511,7 @@ for(const scenario of ['navigation','snapshot','removed item','failure']) test(` await expect(page.locator('#message')).toHaveValue('Latest question'); if(scenario==='snapshot'){ await expect(page.locator('#attachment')).toContainText('Outdated');await expect(page.locator('#save-note')).toBeDisabled(); - expect(app.service.load().snapshot.head).toBe(fixtureGit(app.service.config.repository,'rev-parse','HEAD')); + expect((await app.service.load()).snapshot.head).toBe(fixtureGit(app.service.config.repository,'rev-parse','HEAD')); } if(scenario==='removed item'){ await expect(page.locator('#item-details')).toContainText('no longer in the plan');await expect(page.locator('#save-note')).toBeDisabled(); @@ -520,12 +522,12 @@ for(const scenario of ['navigation','snapshot','removed item','failure']) test(` await page.getByRole('button',{name:'! P1 · Retained draft',exact:true}).click(); await expect(page.getByRole('button',{name:'! P1 · Retained draft',exact:true})).toHaveAttribute('aria-current','true'); await expect(page.locator('#message')).toHaveValue('Latest question');await expect(page.locator('#message')).toBeEnabled(); - expect(app.service.load().items.map(item=>item.id)).toEqual(['P2','P3']); + expect((await app.service.load()).items.map(item=>item.id)).toEqual(['P2','P3']); } - expect(app.service.load().notes).toHaveLength(0); + expect((await app.service.load()).notes).toHaveLength(0); }); for(const switchItem of [false,true]) test(`preserves edits made while a question submission is in flight (switch item: ${switchItem})`,async({page})=>{ - await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); + await openReview(page, app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); let release!:()=>void,arrived!:(body:string)=>void;const held=new Promise(resolve=>release=resolve),submitted=new Promise(resolve=>arrived=resolve); await page.route('**/api/action',async route=>{arrived(route.request().postData()??'');await held;await route.continue();}); await page.getByLabel('Question about this item').fill('Submitted question');await page.getByRole('button',{name:'Ask agent',exact:true}).click(); @@ -539,21 +541,21 @@ for(const switchItem of [false,true]) test(`preserves edits made while a questio expect(app.service.store.getReviewNotes(app.service.config.identity).map(note=>note.text)).toEqual(['Submitted question']); }); test('warns on completed answers when a snippet assignment changes',async({page})=>{ - const service=app.service,identity=service.config.identity,initial=service.load(); + const service=app.service,identity=service.config.identity,initial=await service.load(); const foreign=initial.segments.find(s=>s.row==='Unplanned'&&s.operation==='+')!; - const assigned=service.act({action:'assign',item:'P1',key:foreign.key,token:initial.token}); - const asked=service.act({action:'note',item:'P1',kind:'question',text:'Explain assigned code',reference:{key:foreign.key,start:foreign.newLine,end:foreign.newLine},token:assigned.token}); + const assigned=await service.act({action:'assign',item:'P1',key:foreign.key,token:initial.token}); + const asked=await service.act({action:'note',item:'P1',kind:'question',text:'Explain assigned code',reference:{key:foreign.key,start:foreign.newLine,end:foreign.newLine},token:assigned.token}); service.store.beginAnswer(identity,asked.createdNoteId!,'completed');service.store.finishAnswer(identity,asked.createdNoteId!,'completed',{status:'complete',text:'Historical answer'}); const index=initial.segments.findIndex(s=>s.key===foreign.key); service.store.saveReview(identity,asked.expected,[],[{action:'assign',item:'P2',key:choiceKeys(initial.segments,identity)[index]!}]); - await page.goto(app.url); + await openReview(page, app.url); await expect(page.locator('.agent-answer')).toContainText('Historical answer'); await expect(page.locator('.agent-answer')).toContainText('Answer refers to earlier code or review context.'); }); test('drains an in-flight question request before closing its agent manager',async()=>{ const config=app.service.config;await app.close();let calls=0; app=await startServer(config,0,(_prompt,signal)=>{calls++;return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));}); - const view=app.service.load(),body=JSON.stringify({action:'note',item:'P1',kind:'question',text:'Question during shutdown',token:view.token}); + const view=await app.service.load(),body=JSON.stringify({action:'note',item:'P1',kind:'question',text:'Question during shutdown',token:view.token}); const endpoint=new URL('/api/action',app.url); let response='',finish!:()=>void;const {admitted,marker}=requestAdmitted(app.server); const completed=new Promise((resolve,reject)=>{ @@ -567,7 +569,7 @@ test('drains an in-flight question request before closing its agent manager',asy await Promise.all([closing,completed]); const reopened=new ReviewService(config); try { - const note=reopened.load().notes.find(note=>note.text==='Question during shutdown'); + const note=(await reopened.load()).notes.find(note=>note.text==='Question during shutdown'); // Shutdown had begun, so no agent was started; the saved question still gets a retryable failed answer. expect(calls).toBe(0);expect(note?.answer?.status).toBe('failed');expect(note?.answer?.error).toMatch(/Server stopped/); expect(JSON.parse(response).notes.some((candidate:{text:string})=>candidate.text==='Question during shutdown')).toBe(true); @@ -576,8 +578,8 @@ test('drains an in-flight question request before closing its agent manager',asy test('drains an admitted merge request before closing its coordinator',async()=>{ const config={...app.service.config,demo:false};await app.close();removeDemoOutOfScope(config);let appRef:typeof app,started!:(value?:void)=>void,release!:(value?:void)=>void,commandSignal:AbortSignal|undefined; const commandStarted=new Promise(resolve=>{started=resolve;}),held=new Promise(resolve=>{release=resolve;}); - const gateway:MergeGateway={inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async(_head,options)=>{commandSignal=options?.signal;started();await held;return {url:'https://github.com/example/repo/pull/24'};}}; - app=appRef=await startServer(config,0,undefined,gateway);let view=app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async(_head,options)=>{commandSignal=options?.signal;started();await held;return {url:'https://github.com/example/repo/pull/24'};}}; + app=appRef=await startServer(config,0,undefined,gateway);let view=await app.service.load();for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token});for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); const endpoint=new URL('/api/action',app.url),body=JSON.stringify({action:'merge',token:view.token,actionId:randomUUID()});let status=0; const completed=new Promise((resolve,reject)=>{const req=httpRequest(endpoint,{method:'POST',headers:{'x-codeboost-token':app.token,'content-type':'application/json','content-length':Buffer.byteLength(body)}},res=>{status=res.statusCode??0;res.resume();res.on('end',resolve);});req.on('error',reject);req.end(body);}); await commandStarted;const closing=app.close();await new Promise(resolve=>setTimeout(resolve,25));expect(commandSignal?.aborted).toBe(false);release();await Promise.all([closing,completed]);expect(status).toBe(200);app=await startServer(config,0); @@ -585,8 +587,8 @@ test('drains an admitted merge request before closing its coordinator',async()=> test('bounds shutdown draining before aborting active queue polling',async()=>{ const config={...app.service.config,demo:false};await app.close();let appRef:typeof app,started!:(value?:void)=>void,settled=false; const pollingStarted=new Promise(resolve=>{started=resolve;}); - const gateway:MergeGateway&MergeQueueGateway={inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},queueWatermark:async()=>null,merge:async()=>({url:'https://github.com/example/repo/pull/24'}),inspectQueue:async(_head,options)=>new Promise((_resolve,reject)=>{started();options?.signal?.addEventListener('abort',()=>{settled=true;reject(options.signal?.reason);},{once:true});})}; - app=appRef=await startServer(config,0,undefined,gateway,50);const view=app.service.load(),attempt=app.service.store.beginMergeAttempt(config.identity,{...view.expected,reviewVersion:view.expected.reviewVersion!},view.snapshot.head);app.service.store.queueMergeAttempt(config.identity,attempt.id,'https://github.com/example/repo/pull/24'); + const gateway:MergeGateway&MergeQueueGateway={inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:true,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},queueWatermark:async()=>null,merge:async()=>({url:'https://github.com/example/repo/pull/24'}),inspectQueue:async(_head,options)=>new Promise((_resolve,reject)=>{started();options?.signal?.addEventListener('abort',()=>{settled=true;reject(options.signal?.reason);},{once:true});})}; + app=appRef=await startServer(config,0,undefined,gateway,50);const view=await app.service.load(),attempt=app.service.store.beginMergeAttempt(config.identity,{...view.expected,reviewVersion:view.expected.reviewVersion!},view.snapshot.head);app.service.store.queueMergeAttempt(config.identity,attempt.id,'https://github.com/example/repo/pull/24'); const response=fetch(new URL('/api/merge',app.url),{headers:{'x-codeboost-token':app.token}});await pollingStarted;await app.close();expect(settled).toBe(true);expect((await response).status).toBe(409);app=await startServer(config,0); }); test('aborts an admitted review status inspection after the shutdown drain',async()=>{ @@ -605,10 +607,10 @@ test('destroys a partial request body after the shutdown drain',async()=>{ }); test('blocks a partially received merge request when shutdown starts',async()=>{ const config={...app.service.config,demo:false};await app.close();let mergeCalls=0;let appRef:typeof app; - const gateway:MergeGateway={inspect:async()=>{const snapshot=appRef.service.load().snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{mergeCalls++;return {url:''};}}; - app=appRef=await startServer(config,0,undefined,gateway);let view=app.service.load(); - for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=app.service.act({action:'accept',key:segment.key,token:view.token}); - for(const item of view.items)view=app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + const gateway:MergeGateway={inspect:async()=>{const snapshot=(await appRef.service.load()).snapshot;return {base:snapshot.base,head:snapshot.head,pullRequestState:'OPEN',mergeable:'MERGEABLE',rulesKnown:true,atomicBaseGuard:true,mergeQueue:false,requiredChecks:[],alreadyFixed:'clear',pullRequest:7,draft:false};},merge:async()=>{mergeCalls++;return {url:''};}}; + app=appRef=await startServer(config,0,undefined,gateway);let view=await app.service.load(); + for(const segment of view.segments.filter(value=>value.row==='Unplanned'||value.row==='Ambiguous'))view=await app.service.act({action:'accept',key:segment.key,token:view.token}); + for(const item of view.items)view=await app.service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); const body=JSON.stringify({action:'merge',token:view.token,actionId:randomUUID()}),endpoint=new URL('/api/action',app.url);let status=0,response='',finish!:()=>void;const {admitted,marker}=requestAdmitted(app.server); // Another client, such as a polling tab, reaches the server first; shutdown must still wait for the merge request itself. expect((await fetch(new URL('/api/settings',app.url),{headers:{'x-codeboost-token':app.token}})).status).toBe(200); @@ -619,11 +621,11 @@ test('blocks a partially received merge request when shutdown starts',async()=>{ test('hides Retry until a timed-out invocation has actually settled',async({page})=>{ const config=app.service.config;await app.close();let settle!:(answer:string)=>void; app=await startServer(config,0,()=>new Promise(resolve=>settle=resolve)); - await page.goto(app.url);await page.getByLabel('Question about this item').fill('Slow question');await page.getByRole('button',{name:'Ask agent',exact:true}).click(); + await openReview(page, app.url);await page.getByLabel('Question about this item').fill('Slow question');await page.getByRole('button',{name:'Ask agent',exact:true}).click(); await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible(); const note=app.service.store.getReviewNotes(config.identity).find(note=>note.text==='Slow question')!; app.service.store.finishAnswer(config.identity,note.id,note.answer!.attempt,{status:'failed',error:'Agent timed out. Try again.'}); - await page.reload(); + await reloadReview(page); await expect(page.getByText('Agent · Finishing cancellation…',{exact:true})).toBeVisible({timeout:10000}); await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toHaveCount(0); settle('Late answer'); diff --git a/test/fixtures/gated-git.ts b/test/fixtures/gated-git.ts new file mode 100644 index 00000000..e13f0b2d --- /dev/null +++ b/test/fixtures/gated-git.ts @@ -0,0 +1,46 @@ +import { execFileSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * A `git` first on PATH that holds every call whose arguments contain `match` until the test opens it. The n-th held + * call records its PID, then waits for gate n; other calls run Git at once. The wrapper replaces itself (exec) at every + * step, so the recorded PID is the Git process itself, and killing it leaves nothing behind. + * + * History reads pass only PATH to Git, and a worker thread copies the environment when it starts, so install this + * (with `vi.stubEnv('PATH', gated.path)`) before the read or worker begins. + */ +export function gatedGit(root: string, match: string) { + const dir = join(root, 'gated-git'), log = join(dir, 'calls'), gate = join(dir, 'gate-'); + mkdirSync(dir, { recursive: true }); writeFileSync(log, ''); + const real = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + const wait = `open(my $f, ">>", shift); print $f "$$\\n"; close $f; my $n = () = do { local @ARGV = ($ARGV[0] =~ s/gate-$/calls/r); <> };` + + ` my $g = shift; select(undef, undef, undef, 0.02) until -e "$g$n"; exec @ARGV`; + writeFileSync(join(dir, 'git'), `#!/bin/sh +case "$*" in + *${match}*) exec perl -e ${quote(wait)} ${quote(log)} ${quote(gate)} ${quote(real)} "$@";; +esac +exec ${quote(real)} "$@" +`); + chmodSync(join(dir, 'git'), 0o755); + const pids = () => readFileSync(log, 'utf8').split('\n').filter(Boolean).map(Number); + return { + path: `${dir}:${process.env.PATH ?? ''}`, + pids, + /** Resolves with the PID of the n-th held call (1-based) once it is waiting at its gate. */ + async held(n = 1): Promise { + for (let i = 0; i < 2_000 && pids().length < n; i++) await new Promise(resolve => setTimeout(resolve, 10)); + const pid = pids()[n - 1]; + if (pid === undefined) throw new Error(`Git call ${n} matching ${match} never started.`); + return pid; + }, + open(n = 1) { writeFileSync(`${gate}${n}`, ''); }, + opened: (n = 1) => existsSync(`${gate}${n}`), + }; +} + +/** Whether a process with this PID still exists. */ +export function alive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} diff --git a/test/fixtures/history-worker-busy.ts b/test/fixtures/history-worker-busy.ts new file mode 100644 index 00000000..1bb689cb --- /dev/null +++ b/test/fixtures/history-worker-busy.ts @@ -0,0 +1,6 @@ +import { workerData } from 'node:worker_threads'; +// A history worker stuck in synchronous work (as linking is): it never reads the parent's cancel. While it runs it +// counts in the shared `heartbeat` buffer, so a test can see when the thread has stopped. +const beats = workerData?.heartbeat instanceof SharedArrayBuffer ? new Int32Array(workerData.heartbeat) : null; +const until = Date.now() + 60_000; +while (Date.now() < until) { if (beats) Atomics.add(beats, 0, 1); } diff --git a/test/fixtures/history-worker-silent.ts b/test/fixtures/history-worker-silent.ts new file mode 100644 index 00000000..85df3f5d --- /dev/null +++ b/test/fixtures/history-worker-silent.ts @@ -0,0 +1,3 @@ +import { parentPort } from 'node:worker_threads'; +// A history worker that reads the parent's cancel and never replies, past any deadline. +parentPort!.on('message', () => { /* ignored */ }); diff --git a/test/fixtures/history-worker-slow-start.ts b/test/fixtures/history-worker-slow-start.ts new file mode 100644 index 00000000..991034dc --- /dev/null +++ b/test/fixtures/history-worker-slow-start.ts @@ -0,0 +1,7 @@ +import { parentPort, workerData } from 'node:worker_threads'; +// A history worker whose start takes `startMs` (as a slow module load does), then announces its start, reads the +// parent's cancel and never replies. +const until = Date.now() + (workerData?.startMs ?? 0); +while (Date.now() < until) { /* starting */ } +parentPort!.on('message', () => { /* ignored */ }); +parentPort!.postMessage({ started: true }); diff --git a/test/history-job.test.ts b/test/history-job.test.ts new file mode 100644 index 00000000..d2d0c0e5 --- /dev/null +++ b/test/history-job.test.ts @@ -0,0 +1,372 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createDemo } from '../scripts/demo.ts'; +import { fixtureGit } from './fixtures/git.ts'; +import { writeFileSync } from 'node:fs'; +import { LOAD_ATTEMPTS, ReviewService } from '../runner/review.ts'; +import { readHistory } from '../git/history.ts'; +import { linkHistory } from '../core/linking.ts'; +import { HISTORY_CANCEL_GRACE_MS, HISTORY_DEADLINE_GRACE_MS, HISTORY_START_GRACE_MS, runHistoryJob, type HistoryJob } from '../runner/history-job.ts'; +import { runHistoryWork } from '../runner/history-work.ts'; +import { startServer } from '../web/server.ts'; +import { Store } from '../runner/store.ts'; +import { alive, gatedGit } from './fixtures/gated-git.ts'; + +// Passthroughs, so a test can read the budget each stage was given. +vi.mock('../git/history.ts', async original => { + const actual = await original(); return { ...actual, readHistory: vi.fn(actual.readHistory) }; +}); +vi.mock('../core/linking.ts', async original => { + const actual = await original(); return { ...actual, linkHistory: vi.fn(actual.linkHistory) }; +}); +vi.setConfig({ testTimeout: 30_000 }); + +const roots: string[] = [], services: ReviewService[] = []; +afterEach(() => { + vi.unstubAllEnvs(); + services.splice(0).forEach(service => service.close()); + roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true })); +}); +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'codeboost-history-job-')); roots.push(root); + const config = createDemo(join(root, 'demo')); + return { root, config }; +} +/** A review service whose history reads hold at their first Git call until the test opens it. */ +function held() { + const { root, config } = fixture(), git = gatedGit(root, '--git-common-dir'); + vi.stubEnv('PATH', git.path); + const service = new ReviewService(config); services.push(service); + return { root, config, git, service }; +} +const fixtureUrl = (name: string) => new URL(`./fixtures/${name}`, import.meta.url); + +it('kills the running Git process when a history read is aborted, and settles only after it has exited', async () => { + const { root, config } = fixture(), git = gatedGit(root, '--git-common-dir'); + vi.stubEnv('PATH', git.path); + const service = new ReviewService(config); services.push(service); + const { base, head } = service.store.getSnapshot(config.identity); + const controller = new AbortController(), reason = new Error('Stopped by the test.'); + const read = readHistory(config.repository, base, head, {}, controller.signal); + const pid = await git.held(); + expect(alive(pid)).toBe(true); + controller.abort(reason); + await expect(read).rejects.toBe(reason); + expect(alive(pid)).toBe(false); + expect(git.opened()).toBe(false); +}); + +it('loads the review in a worker while this thread keeps running, then applies it', async () => { + const { git, service } = held(); + let settled = false; + const loading = service.load().finally(() => { settled = true; }); + await git.held(); + // A synchronous load would hold this thread until Git returned, and Git waits for this thread to open the gate. + await new Promise(resolve => setTimeout(resolve, 50)); + expect(settled).toBe(false); + git.open(); + const view = await loading; + expect(view.segments.length).toBeGreaterThan(0); + expect(view.items.length).toBeGreaterThan(0); +}); + +it('stops a review load on abort with the abort reason, after its Git process has exited', async () => { + const { git, service } = held(); + const controller = new AbortController(), reason = new Error('Request cancelled during shutdown.'); + const loading = service.load({ signal: controller.signal }); + const pid = await git.held(); + controller.abort(reason); + await expect(loading).rejects.toBe(reason); + expect(alive(pid)).toBe(false); +}); + +it('enforces the review deadline in the worker, ending its Git process', async () => { + const { git, service } = held(); + const started = performance.now(); + const loading = service.load({ maxDurationMs: 3_000 }); + const pid = await git.held(); + // The worker's own budget ends the read (the parent's backstop would say "History work exceeded…"). + await expect(loading).rejects.toThrow('History read exceeded its overall deadline.'); + expect(performance.now() - started).toBeGreaterThanOrEqual(3_000 - 50); + expect(alive(pid)).toBe(false); +}); + +it('reads the history again when the review changes while the worker runs, and returns the current review', async () => { + const { config, git, service } = held(); + const { store } = service, identity = config.identity; + const loading = service.load(); + await git.held(1); + const expected = { revision: store.getPlan(identity).revision, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }; + store.saveReview(identity, expected, [], []); + git.open(1); + // The first worker linked a review that is no longer current: the load reads the history a second time. + await git.held(2); git.open(2); + expect((await loading).expected.reviewVersion).toBe(expected.reviewVersion + 1); + expect(git.pids()).toHaveLength(2); +}); + +it(`refuses a review as stale when it changes during each of the load's ${LOAD_ATTEMPTS} history reads`, async () => { + const { config, git, service } = held(); + const { store } = service, identity = config.identity; + const loading = service.load(); + for (let attempt = 1; attempt <= LOAD_ATTEMPTS; attempt++) { + await git.held(attempt); + store.saveReview(identity, { revision: store.getPlan(identity).revision, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }, [], []); + git.open(attempt); + } + await expect(loading).rejects.toThrow('Stale review state. Reload before writing.'); + expect(git.pids()).toHaveLength(LOAD_ATTEMPTS); +}); + +it('lets two overlapping loads both observe a moved HEAD', async () => { + const { config, git, service } = held(); + writeFileSync(join(config.repository, 'late.txt'), 'moved\n'); + fixtureGit(config.repository, 'add', 'late.txt'); fixtureGit(config.repository, 'commit', '-qm', 'Move HEAD'); + const head = fixtureGit(config.repository, 'rev-parse', 'HEAD'); + const first = service.load(), second = service.load(); + await git.held(1); await git.held(2); + // Whichever worker answers first records the new snapshot; the other load finds the Store moved, reads again and agrees. + git.open(1); git.open(2); + await git.held(3); git.open(3); + const [one, two] = await Promise.all([first, second]); + expect(one.snapshot.head).toBe(head); + expect(two.snapshot).toEqual(one.snapshot); +}); + +it('gives linking what the history read left of one overall deadline', async () => { + const { config } = fixture(); + const service = new ReviewService(config); services.push(service); + const plan = service.store.getPlan(config.identity), { base, head } = service.store.getSnapshot(config.identity); + const link = { plan, ownership: [...service.store.ownership(config.identity)], pathIdentity: config.pathIdentity, resolvedConflicts: [] }; + vi.mocked(readHistory).mockClear(); vi.mocked(linkHistory).mockClear(); + // The budget starts when the work does (the first reading), then each stage reads the clock once. + const clock = [0, 0, 5_000]; + const done = await runHistoryWork({ repository: config.repository, base, head, budgetMs: 10_000, link }, new AbortController().signal, () => clock.shift()!); + expect(done.segments!.length).toBeGreaterThan(0); + expect(vi.mocked(readHistory).mock.calls[0]![3]).toMatchObject({ maxDurationMs: 10_000 }); + expect(vi.mocked(linkHistory).mock.calls[0]![4]).toMatchObject({ maxDurationMs: 5_000 }); + // A read that uses the whole deadline leaves linking none: it never starts. + vi.mocked(linkHistory).mockClear(); + const spent = [0, 0, 10_000]; + await expect(runHistoryWork({ repository: config.repository, base, head, budgetMs: 10_000, link }, new AbortController().signal, () => spent.shift()!)) + .rejects.toThrow('Review load exceeded its overall deadline.'); + expect(linkHistory).not.toHaveBeenCalled(); +}); + +it('terminates a worker that cannot read the cancel, and rejects with the abort reason only once its thread has stopped', async () => { + const controller = new AbortController(), reason = new Error('Stopped by the test.'); + const heartbeat = new SharedArrayBuffer(4), beats = new Int32Array(heartbeat); + const job = runHistoryJob({ repository: '.', base: 'HEAD', head: 'HEAD', budgetMs: 30_000, heartbeat } as HistoryJob, controller.signal, + fixtureUrl('history-worker-busy.ts')); + await vi.waitFor(() => expect(Atomics.load(beats, 0)).toBeGreaterThan(0)); + const aborted = performance.now(); + controller.abort(reason); + await expect(job).rejects.toBe(reason); + // It waited out the cancel grace, then terminate() ended the thread mid-loop instead of after its 60 s. + expect(performance.now() - aborted).toBeGreaterThanOrEqual(HISTORY_CANCEL_GRACE_MS - 50); + expect(performance.now() - aborted).toBeLessThan(HISTORY_CANCEL_GRACE_MS + 10_000); + const stopped = Atomics.load(beats, 0); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(Atomics.load(beats, 0)).toBe(stopped); +}); + +it('counts a job\'s budget from its worker\'s start, so a slow thread start does not cut into it', async () => { + const started = performance.now(), startMs = 1_500; + const job = runHistoryJob({ repository: '.', base: 'HEAD', head: 'HEAD', budgetMs: 200, startMs } as HistoryJob, undefined, + fixtureUrl('history-worker-slow-start.ts')); + await expect(job).rejects.toThrow('History work exceeded its overall deadline.'); + expect(performance.now() - started).toBeGreaterThanOrEqual(startMs + 200 + HISTORY_DEADLINE_GRACE_MS + HISTORY_CANCEL_GRACE_MS - 50); +}); + +it('stops a started worker that never replies once its budget and grace have passed', async () => { + const started = performance.now(); + const job = runHistoryJob({ repository: '.', base: 'HEAD', head: 'HEAD', budgetMs: 200, startMs: 0 } as HistoryJob, undefined, fixtureUrl('history-worker-slow-start.ts')); + await expect(job).rejects.toThrow('History work exceeded its overall deadline.'); + expect(performance.now() - started).toBeGreaterThanOrEqual(200 + HISTORY_DEADLINE_GRACE_MS + HISTORY_CANCEL_GRACE_MS - 50); + expect(performance.now() - started).toBeLessThan(HISTORY_START_GRACE_MS); +}); + +it('stops a worker that never starts once the start grace, its budget and grace have passed', async () => { + const started = performance.now(); + const job = runHistoryJob({ repository: '.', base: 'HEAD', head: 'HEAD', budgetMs: 200 }, undefined, fixtureUrl('history-worker-silent.ts')); + await expect(job).rejects.toThrow('History work exceeded its overall deadline.'); + expect(performance.now() - started).toBeGreaterThanOrEqual(HISTORY_START_GRACE_MS + 200 + HISTORY_DEADLINE_GRACE_MS + HISTORY_CANCEL_GRACE_MS - 50); +}); + +it('reports a history read failure from the worker with its own message', async () => { + const { config } = fixture(); + const job = runHistoryJob({ repository: config.repository, base: 'HEAD', head: 'HEAD~1', budgetMs: 30_000 }); + await expect(job).rejects.toThrow('The base must be an ancestor of the head.'); +}); + +it('serves other requests while a review load runs, and shutdown ends that load and its Git process', async () => { + const { root, config } = fixture(), git = gatedGit(root, '--git-common-dir'); + vi.stubEnv('PATH', git.path); + const app = await startServer(config, 0, undefined, undefined, 1); + const address = app.server.address(), origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + const get = (path: string) => fetch(`${origin}${path}`, { headers: { 'x-codeboost-token': app.token } }); + const review = get('/api/review'); + const pid = await git.held(); + const settings = await get('/api/settings'); + expect(settings.status).toBe(200); + const closing = app.close(); + const answer = await review; + // As for any admitted request the drain outlasts: a refusal naming the shutdown. + expect(answer.status).toBe(409); + expect(await answer.json()).toEqual({ error: 'Request cancelled during shutdown.' }); + await closing; + expect(alive(pid)).toBe(false); +}); + +it('reports an unknown outcome when shutdown stops the reload after a review command was applied', async () => { + const { root, config } = fixture(), git = gatedGit(root, '--git-common-dir'); + vi.stubEnv('PATH', git.path); + const app = await startServer(config, 0, undefined, undefined, 1); + const address = app.server.address(), origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + const call = (path: string, body?: unknown) => fetch(`${origin}${path}`, { method: body ? 'POST' : 'GET', + headers: { 'x-codeboost-token': app.token, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); + const reading = call('/api/review'); await git.held(1); git.open(1); + const view = await (await reading).json() as { token: string }; + // Load 2 is the review the command applies to; load 3, after the write, is held while the server shuts down. + const notes = () => app.service.store.getReviewNotes(config.identity).length, before = notes(); + const command = call('/api/action', { action: 'note', item: 'P1', kind: 'question', text: 'Why cap the retry delay?', token: view.token }); + await git.held(2); git.open(2); + await git.held(3); + expect(notes()).toBe(before + 1); + const closing = app.close(); + const answer = await command; + expect(answer.status).toBe(409); + expect(await answer.json()).toEqual({ error: 'Request cancelled during shutdown.', outcomeUnknown: true }); + await closing; +}); + +it.each([[3, 'the reload that would start its agent'], [4, 'the response\'s final reload']])( + 'gives a question admitted before shutdown its retryable answer when shutdown stops load %i, %s', async (stoppedLoad) => { + const { root, config } = fixture(), git = gatedGit(root, '--git-common-dir'); + vi.stubEnv('PATH', git.path); + // A drain long enough for loads 2 and 3 on a loaded machine; the held load then waits out the rest of it. + const app = await startServer(config, 0, async () => 'answer', undefined, 8_000); + const address = app.server.address(), origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + const call = (path: string, body?: unknown) => fetch(`${origin}${path}`, { method: body ? 'POST' : 'GET', + headers: { 'x-codeboost-token': app.token, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); + const reading = call('/api/review'); await git.held(1); git.open(1); + const view = await (await reading).json() as { token: string }; + const asked = call('/api/action', { action: 'note', item: 'P1', kind: 'question', text: 'Why cap the retry delay?', token: view.token }); + await git.held(2); + // Shutdown begins after the request was admitted: no Ask agent may start for its question. + const closing = app.close(); + // Load 2 is the review the question is saved against. The stopped load is held until the drain ends and shutdown + // aborts it. + for (let load = 2; load < stoppedLoad; load++) { if (load > 2) await git.held(load); git.open(load); } + await git.held(stoppedLoad); + const answer = await asked; + expect(answer.status).toBe(409); + expect(await answer.json()).toEqual({ error: 'Request cancelled during shutdown.', outcomeUnknown: true }); + await closing; + const store = new Store(config.database); + try { + const note = store.getReviewNotes(config.identity).find(value => value.text === 'Why cap the retry delay?'); + expect(note?.answer).toMatchObject({ status: 'failed', error: 'Server stopped. Retry the question.' }); + } finally { store.close(); } +}); + +it('does not record a review load failure under the command\'s action ID, so the same ID can be resent', async () => { + const { config } = fixture(); + const app = await startServer(config, 0); + try { + const address = app.server.address(), origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + const call = (path: string, body?: unknown) => fetch(`${origin}${path}`, { method: body ? 'POST' : 'GET', + headers: { 'x-codeboost-token': app.token, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); + const view = await (await call('/api/review')).json() as { token: string }; + const command = { action: 'note', item: 'P1', kind: 'change', text: 'Keep the signature.', token: view.token, actionId: crypto.randomUUID() }; + const loads = vi.spyOn(app.service, 'load').mockRejectedValueOnce(new Error('History read failed.')); + const failed = await call('/api/action', command); + expect(failed.status).toBe(409); + expect(await failed.json()).toEqual({ error: 'History read failed.' }); + expect(app.service.store.savedAction(config.identity, { actionId: command.actionId, kind: 'review-note', request: (({ actionId: _omit, ...rest }) => rest)(command) })).toBeUndefined(); + loads.mockClear(); + const resent = await call('/api/action', command); + expect(resent.status).toBe(200); + const applying = loads.mock.calls.length; + expect(app.service.store.getReviewNotes(config.identity).filter(note => note.text === 'Keep the signature.')).toHaveLength(1); + // A replay of the recorded command loads only the review it answers with: one load fewer than applying it. + loads.mockClear(); + expect((await call('/api/action', command)).status).toBe(200); + expect(loads.mock.calls.length).toBe(applying - 1); + expect(app.service.store.getReviewNotes(config.identity).filter(note => note.text === 'Keep the signature.')).toHaveLength(1); + loads.mockRestore(); + } finally { await app.close(); } +}); + +it('reports an unknown outcome when shutdown stops the reload after a question retry started an answer', async () => { + const { root, config } = fixture(), git = gatedGit(root, '--git-common-dir'); + vi.stubEnv('PATH', git.path); + let asks = 0; + // The first answer fails, so the question can be retried; the retry's answer waits for its abort. + const app = await startServer(config, 0, (_prompt, signal) => ++asks === 1 ? Promise.reject(new Error('Agent failed.')) + : new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true })), undefined, 1); + const address = app.server.address(), origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + const call = (path: string, body?: unknown) => fetch(`${origin}${path}`, { method: body ? 'POST' : 'GET', + headers: { 'x-codeboost-token': app.token, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); + // Setup loads run freely: every held call is let through until the retry. + let setup = true; + void (async () => { while (setup) { git.pids().forEach((_pid, index) => { if (!git.opened(index + 1)) git.open(index + 1); }); await new Promise(resolve => setTimeout(resolve, 10)); } })(); + const view = await (await call('/api/review')).json() as { token: string }; + const asked = await (await call('/api/action', { action: 'note', item: 'P1', kind: 'question', text: 'Why cap the retry delay?', token: view.token })).json() as { token: string; notes: { id: string }[] }; + const id = asked.notes.at(-1)!.id; + await vi.waitFor(() => expect(app.service.store.getReviewNotes(config.identity).find(note => note.id === id)?.answer?.status).toBe('failed')); + const current = await (await call('/api/review')).json() as { token: string }; + setup = false; await new Promise(resolve => setTimeout(resolve, 20)); + const loads = git.pids().length; + const retry = call('/api/action', { action: 'retry-question', id, token: current.token }); + // The retry's review is loaded and its answer started; the response's reload is held while the server shuts down. + await git.held(loads + 1); git.open(loads + 1); + await git.held(loads + 2); + expect(asks).toBe(2); + const closing = app.close(); + const answer = await retry; + expect(answer.status).toBe(409); + expect(await answer.json()).toEqual({ error: 'Request cancelled during shutdown.', outcomeUnknown: true }); + await closing; +}); + +it('reports an unknown outcome when the reload after an applied review command fails', async () => { + const { config } = fixture(); + const app = await startServer(config, 0); + try { + const address = app.server.address(), origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + const call = (path: string, body?: unknown) => fetch(`${origin}${path}`, { method: body ? 'POST' : 'GET', + headers: { 'x-codeboost-token': app.token, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); + const view = await (await call('/api/review')).json() as { token: string }; + const original = app.service.load.bind(app.service); + // The review the command applies to loads; the reload after it does not. + const loads = vi.spyOn(app.service, 'load').mockImplementationOnce(original) + .mockImplementationOnce(async () => { throw new Error('Stale review state. Reload before writing.'); }); + const answer = await call('/api/action', { action: 'note', item: 'P1', kind: 'question', text: 'Saved before the reload failed?', token: view.token }); + loads.mockRestore(); + expect(answer.status).toBe(409); + expect(await answer.json()).toEqual({ error: 'Stale review state. Reload before writing.', outcomeUnknown: true }); + expect(app.service.store.getReviewNotes(config.identity).filter(note => note.text === 'Saved before the reload failed?')).toHaveLength(1); + } finally { await app.close(); } +}); + +it('reports the deadline, not staleness, when a load runs out of time on its last read while the review keeps changing', async () => { + const { config, git, service } = held(); + const { store } = service, identity = config.identity; + let now = 0; + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now); + try { + const loading = service.load({ maxDurationMs: 30_000 }); + for (let attempt = 1; attempt <= LOAD_ATTEMPTS; attempt++) { + await git.held(attempt); + store.saveReview(identity, { revision: store.getPlan(identity).revision, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }, [], []); + // Time runs out during the last read, where the read limit is reached too: the deadline is what ended the load. + if (attempt === LOAD_ATTEMPTS) now = 30_000; + git.open(attempt); + } + await expect(loading).rejects.toThrow('Review load exceeded its overall deadline.'); + expect(git.pids()).toHaveLength(LOAD_ATTEMPTS); + } finally { clock.mockRestore(); } +}); diff --git a/test/history-spawn.test.ts b/test/history-spawn.test.ts new file mode 100644 index 00000000..bfcd9536 --- /dev/null +++ b/test/history-spawn.test.ts @@ -0,0 +1,20 @@ +import { EventEmitter } from 'node:events'; +import { expect, it, vi } from 'vitest'; + +// A Git process that never starts: Node reports the spawn error to execFile's callback, the child has no PID, and +// (for EMFILE and ENFILE) it may never emit `close`. +vi.mock('node:child_process', async original => { + const actual = await original(); + return { ...actual, execFile: vi.fn((_file: string, _args: string[], _options: object, callback: (error: Error | null, stdout: Buffer) => void) => { + const child = Object.assign(new EventEmitter(), { pid: undefined, stdin: null }); + setImmediate(() => callback(Object.assign(new Error('spawn git EMFILE'), { code: 'EMFILE' }), Buffer.alloc(0))); + return child; + }) }; +}); +const { readHistory } = await import('../git/history.ts'); + +it('fails a history read at once when its Git process cannot start, without waiting for a close that never comes', async () => { + const started = performance.now(); + await expect(readHistory('.', 'HEAD', 'HEAD', { maxDurationMs: 30_000 })).rejects.toMatchObject({ code: 'EMFILE' }); + expect(performance.now() - started).toBeLessThan(5_000); +}); diff --git a/test/history.test.ts b/test/history.test.ts index 4ee83601..d68afcbb 100644 --- a/test/history.test.ts +++ b/test/history.test.ts @@ -27,89 +27,89 @@ function fixture(initial: Record = { 'a.txt': 'one\ntwo\nthree\n const plan: Plan = { schema_version: 1, issue: 1, revision: 1, summary: 'Test', questions: [], items: ['P1', 'P2'].map(id => ({ id, title: id, intent: 'Change', files: [{ path: 'a.txt', kind: 'edit', renamed_from: null, change: 'Change' }], acceptance: [{ type: 'check', text: 'Works' }], depends_on: [], })) }; - const segments = () => linkHistory(plan, readHistory(dir, base), ledger, path => path); + const segments = async () => linkHistory(plan, await readHistory(dir, base), ledger, path => path); return { dir, git, write, commit, base, ledger, plan, segments }; } -it('splits a shared hunk by ledger owner; detects out-of-scope and forged trailers', () => { +it('splits a shared hunk by ledger owner; detects out-of-scope and forged trailers', async () => { const f = fixture(); f.write('a.txt', 'ONE\ntwo\nthree\n'); f.commit('P1'); f.write('a.txt', 'ONE\ntwo\nTHREE\n'); f.write('outside.txt', 'outside\n'); f.commit('P2'); f.write('foreign.txt', 'foreign\n'); f.commit(undefined, 'Forged\n\nPlan-Item: P1'); - const parts = f.segments(); + const parts = await f.segments(); expect(parts.find(s => s.content === 'ONE\n')?.row).toBe('P1'); expect(parts.find(s => s.content === 'THREE\n')?.row).toBe('P2'); expect(parts.find(s => s.content === 'ONE\n')?.sharesHunkWith).toContain('P2'); expect(parts.filter(s => s.path === 'outside.txt').every(s => s.scope === 'out-of-scope')).toBe(true); expect(parts.filter(s => s.path === 'foreign.txt').every(s => s.row === 'Unplanned')).toBe(true); }); -it('keeps a resolved foreign conflict Unplanned and carries its visible provenance', () => { +it('keeps a resolved foreign conflict Unplanned and carries its visible provenance', async () => { const f = fixture(); f.write('a.txt', 'resolved by agent\ntwo\nthree\n'); const sha = f.commit(); - const parts = linkHistory(f.plan, readHistory(f.dir, f.base), new Map([[sha, null]]), path => path, {}, new Set([sha])); + const parts = linkHistory(f.plan, await readHistory(f.dir, f.base), new Map([[sha, null]]), path => path, {}, new Set([sha])); expect(parts.length).toBeGreaterThan(0); expect(parts.every(segment => segment.row === 'Unplanned' && segment.conflictResolved === true)).toBe(true); }); -it('does not merge adjacent foreign lines with different conflict-resolution provenance', () => { +it('does not merge adjacent foreign lines with different conflict-resolution provenance', async () => { const f = fixture(); f.write('a.txt', 'ONE\ntwo\nthree\n'); const first = f.commit(); f.write('a.txt', 'ONE\nTWO\nthree\n'); const second = f.commit(); - const history = readHistory(f.dir, f.base), ledger = new Map([[first, null], [second, null]]); + const history = await readHistory(f.dir, f.base), ledger = new Map([[first, null], [second, null]]); for (const resolved of [first, second]) { const added = linkHistory(f.plan, history, ledger, path => path, {}, new Set([resolved])).filter(segment => segment.operation === '+'); expect(added.find(segment => segment.content === 'ONE\n')?.conflictResolved).toBe(resolved === first ? true : undefined); expect(added.find(segment => segment.content === 'TWO\n')?.conflictResolved).toBe(resolved === second ? true : undefined); } }); -it('attributes pure deletions and marks repeated edits as ambiguous', () => { +it('attributes pure deletions and marks repeated edits as ambiguous', async () => { const f = fixture(); f.write('a.txt', 'one\nthree\n'); f.commit('P1'); - expect(f.segments().find(s => s.content === 'two\n')?.row).toBe('P1'); + expect((await f.segments()).find(s => s.content === 'two\n')?.row).toBe('P1'); f.write('a.txt', 'ONE\nthree\n'); f.commit('P1'); f.write('a.txt', 'FIRST\nthree\n'); f.commit('P2'); - const changed = f.segments().find(s => s.content === 'FIRST\n'); + const changed = (await f.segments()).find(s => s.content === 'FIRST\n'); expect(changed?.row).toBe('Ambiguous'); expect(changed?.owners).toEqual(['P1', 'P2']); }); -it('omits changes reverted back to the base', () => { +it('omits changes reverted back to the base', async () => { const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); f.write('a.txt', 'one\ntwo\nthree\n'); f.commit('P2'); - expect(f.segments()).toEqual([]); + expect(await f.segments()).toEqual([]); }); -it('represents binary, executable, empty, rename, symlink and submodule changes', () => { +it('represents binary, executable, empty, rename, symlink and submodule changes', async () => { const f = fixture({ 'a.txt': 'one\ntwo\nthree\n', 'rename.txt': 'rename\n' }); chmodSync(join(f.dir, 'a.txt'), 0o755); f.write('binary.dat', Buffer.from([0, 1, 2])); f.write('empty', ''); renameSync(join(f.dir, 'rename.txt'), join(f.dir, 'renamed.txt')); symlinkSync('a.txt', join(f.dir, 'link')); f.git('add', '-A'); f.git('update-index', '--add', '--cacheinfo', `160000,${f.base},submodule`); f.git('commit', '-m', 'File changes'); f.ledger.set(f.git('rev-parse', 'HEAD'), 'P1'); f.git('config', 'diff.ignoreSubmodules', 'all'); - const cards = f.segments().filter(s => s.kind === 'file'); + const cards = (await f.segments()).filter(s => s.kind === 'file'); expect(cards.map(s => s.path).sort()).toEqual(['a.txt', 'binary.dat', 'empty', 'link', 'renamed.txt', 'submodule']); expect(cards.every(s => s.row === 'P1')).toBe(true); expect(cards.find(s => s.path === 'renamed.txt')?.oldPath).toBe('rename.txt'); }); -it('handles filenames with spaces and pathspec characters literally', () => { +it('handles filenames with spaces and pathspec characters literally', async () => { const f = fixture({ 'a [1].txt': 'old\n' }); f.write('a [1].txt', 'new\n'); f.commit('P1'); - expect(f.segments().every(s => s.path === 'a [1].txt')).toBe(true); + expect((await f.segments()).every(s => s.path === 'a [1].txt')).toBe(true); }); -it('rejects a merge history rather than inventing attribution', () => { +it('rejects a merge history rather than inventing attribution', async () => { const f = fixture(); f.git('switch', '-c', 'side'); f.write('side', 'side'); f.commit('P1'); f.git('switch', 'main'); f.write('main', 'main'); f.commit('P2'); f.git('merge', '--no-ff', 'side', '-m', 'merge'); - expect(() => f.segments()).toThrow(/linear/); + await expect(f.segments()).rejects.toThrow(/linear/); }); -it('keeps approvals after a clean rebase and remapped ledger, but stales changed checks and dependents', () => { +it('keeps approvals after a clean rebase and remapped ledger, but stales changed checks and dependents', async () => { const f = fixture(); f.git('switch', '-c', 'feature'); f.write('a.txt', 'one\nTWO\nthree\n'); const oldSha = f.commit('P1'); - const parts = f.segments(); const approvals = [approveItem(f.plan, parts, 'P1'), approveItem(f.plan, parts, 'P2', true)]; + const parts = await f.segments(); const approvals = [approveItem(f.plan, parts, 'P1'), approveItem(f.plan, parts, 'P2', true)]; f.git('switch', 'main'); f.write('unrelated', 'base update'); const newBase = f.commit(); f.git('switch', 'feature'); f.git('rebase', 'main'); const newSha = f.git('rev-parse', 'HEAD'); f.ledger.delete(oldSha); f.ledger.set(newSha, 'P1'); - const rebased = linkHistory(f.plan, readHistory(f.dir, newBase), f.ledger, path => path); + const rebased = linkHistory(f.plan, await readHistory(f.dir, newBase), f.ledger, path => path); expect(approvalStates(f.plan, rebased, approvals)).toEqual({ P1: 'approved', P2: 'approved' }); f.plan.items[1]!.depends_on = ['P1']; const p2 = approveItem(f.plan, rebased, 'P2', true); f.plan.items[0]!.acceptance[0]!.text = 'Different check'; expect(approvalStates(f.plan, rebased, [approvals[0]!, p2])).toEqual({ P1: 'stale', P2: 'stale' }); }); -it('stales whitespace and function-context changes, but not line numbers', () => { +it('stales whitespace and function-context changes, but not line numbers', async () => { const f = fixture(); f.write('a.txt', 'one\nTWO\nthree\n'); f.commit('P1'); - const parts = f.segments(); const approval = approveItem(f.plan, parts, 'P1'); + const parts = await f.segments(); const approval = approveItem(f.plan, parts, 'P1'); const shifted = parts.map(s => ({ ...s, oldLine: s.oldLine === null ? null : s.oldLine + 20, newLine: s.newLine === null ? null : s.newLine + 20 })); expect(approvalStates(f.plan, shifted, [approval]).P1).toBe('approved'); expect(approvalStates(f.plan, parts.map(s => ({ ...s, content: s.content + ' ' })), [approval]).P1).toBe('stale'); expect(approvalStates(f.plan, parts.map(s => ({ ...s, context: 'other function' })), [approval]).P1).toBe('stale'); }); -it('assignments stale the target and duplicate-copy count changes invalidate choices', () => { - const f = fixture(); f.write('a.txt', 'one\nTWO\nthree\n'); f.commit(); const parts = f.segments(); +it('assignments stale the target and duplicate-copy count changes invalidate choices', async () => { + const f = fixture(); f.write('a.txt', 'one\nTWO\nthree\n'); f.commit(); const parts = await f.segments(); const approval = approveItem(f.plan, parts, 'P1', true); const choice = { key: choiceKeys(parts)[0]!, action: 'assign' as const, item: 'P1' }; expect(approvalStates(f.plan, applyChoices(f.plan, parts, [choice]), [approval]).P1).toBe('stale'); @@ -120,170 +120,170 @@ it('assignments stale the target and duplicate-copy count changes invalidate cho const shifted = parts.map(s => ({ ...s, newLine: 50 })); expect(applyChoices(f.plan, shifted, [choice])[0]!.row).toBe('P1'); }); -it('retains line ancestry through a rename and subsequent edit', () => { +it('retains line ancestry through a rename and subsequent edit', async () => { const f = fixture(); f.write('a.txt', 'ONE\ntwo\nthree\n'); f.commit('P1'); renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); f.write('b.txt', 'FIRST\ntwo\nthree\n'); f.commit('P2'); - const parts = f.segments(); + const parts = await f.segments(); expect(parts.find(s => s.content === 'FIRST\n')?.owners).toEqual(['P1', 'P2']); expect(parts.some(s => s.kind === 'file')).toBe(true); }); -it('keeps separated changes as separate segments and preserves EOF changes', () => { +it('keeps separated changes as separate segments and preserves EOF changes', async () => { const f = fixture(); f.write('a.txt', 'ONE\ntwo\nTHREE'); f.commit('P1'); - const additions = f.segments().filter(s => s.operation === '+'); + const additions = (await f.segments()).filter(s => s.operation === '+'); expect(additions.map(s => s.content)).toEqual(['ONE\n', 'THREE']); expect(additions.map(s => s.newLine)).toEqual([1, 3]); }); -it('reads real function context from Git hunk headers', () => { +it('reads real function context from Git hunk headers', async () => { const f = fixture({ 'a.py': 'def first():\n return 1\n\ndef second():\n return 2\n' }); f.write('a.py', 'def first():\n return 3\n\ndef second():\n return 2\n'); f.commit('P1'); - expect(f.segments().find(s => s.operation === '+')?.context).toBe('def first():'); + expect((await f.segments()).find(s => s.operation === '+')?.context).toBe('def first():'); }); -it('attributes unchanged moved lines when final rename detection is lost', () => { +it('attributes unchanged moved lines when final rename detection is lost', async () => { const f = fixture({ 'a.txt': 'one\ntwo\nthree\nfour\nfive\n' }); renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); f.write('b.txt', 'one\nnew2\nnew3\nnew4\nnew5\n'); f.commit('P2'); - const parts = f.segments(); + const parts = await f.segments(); expect(parts.filter(s => s.kind === 'text').every(s => s.row !== 'Unplanned')).toBe(true); }); -it('does not hide a UTF-8 byte-order-mark-only change', () => { +it('does not hide a UTF-8 byte-order-mark-only change', async () => { const f = fixture(); f.write('a.txt', '\uFEFFone\ntwo\nthree\n'); f.commit('P1'); - expect(f.segments().some(s => s.operation === '+' && s.content.startsWith('\uFEFF'))).toBe(true); + expect((await f.segments()).some(s => s.operation === '+' && s.content.startsWith('\uFEFF'))).toBe(true); }); -it('reads the whole repository even when called from a subdirectory with relative diffs configured', () => { +it('reads the whole repository even when called from a subdirectory with relative diffs configured', async () => { const f = fixture({ 'a.txt': 'before\n', 'sub/b.txt': 'before\n' }); f.write('a.txt', 'after\n'); f.write('sub/b.txt', 'after\n'); f.commit('P1'); f.git('config', 'diff.relative', 'true'); - const parts = linkHistory(f.plan, readHistory(join(f.dir, 'sub'), f.base), f.ledger, path => path); + const parts = linkHistory(f.plan, await readHistory(join(f.dir, 'sub'), f.base), f.ledger, path => path); expect(new Set(parts.map(s => s.path))).toEqual(new Set(['a.txt', 'sub/b.txt'])); }); -it('checks scope at each owning commit, not against both ends of a final rename', () => { +it('checks scope at each owning commit, not against both ends of a final rename', async () => { const f = fixture(); f.plan.items[0]!.files = [{ path: 'b.txt', kind: 'rename', renamed_from: 'a.txt', change: 'Move' }]; f.plan.items[1]!.files = [{ path: 'b.txt', kind: 'edit', renamed_from: null, change: 'Edit new name' }]; renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); f.write('b.txt', 'ONE\ntwo\nthree\n'); f.commit('P2'); - expect(f.segments().some(s => s.row === 'P2')).toBe(true); - expect(f.segments().filter(s => s.row === 'P2').every(s => s.scope === 'in-scope')).toBe(true); + expect((await f.segments()).some(s => s.row === 'P2')).toBe(true); + expect((await f.segments()).filter(s => s.row === 'P2').every(s => s.scope === 'in-scope')).toBe(true); // Declaring only the old name must not authorize edits to the new one. f.plan.items[1]!.files[0]!.path = 'a.txt'; - expect(f.segments().filter(s => s.row === 'P2').every(s => s.scope === 'out-of-scope')).toBe(true); + expect((await f.segments()).filter(s => s.row === 'P2').every(s => s.scope === 'out-of-scope')).toBe(true); }); -it('checks scope of deletion after a rename using the deleted current path', () => { +it('checks scope of deletion after a rename using the deleted current path', async () => { const f = fixture(); f.plan.items[0]!.files = [{ path: 'b.txt', kind: 'rename', renamed_from: 'a.txt', change: 'Move' }]; f.plan.items[1]!.files = [{ path: 'b.txt', kind: 'delete', renamed_from: null, change: 'Delete' }]; renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); rmSync(join(f.dir, 'b.txt')); f.commit('P2'); - expect(f.segments().some(s => s.row === 'P2')).toBe(true); - expect(f.segments().filter(s => s.row === 'P2').every(s => s.scope === 'in-scope')).toBe(true); + expect((await f.segments()).some(s => s.row === 'P2')).toBe(true); + expect((await f.segments()).filter(s => s.row === 'P2').every(s => s.scope === 'in-scope')).toBe(true); }); -it('Git built-ins cannot be overridden by repository shell aliases', () => { +it('Git built-ins cannot be overridden by repository shell aliases', async () => { const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); for (const name of ['rev-parse', 'rev-list', 'diff', 'cat-file']) f.git('config', `alias.${name}`, '!touch alias-executed'); - expect(f.segments().length).toBeGreaterThan(0); + expect((await f.segments()).length).toBeGreaterThan(0); expect(existsSync(join(f.dir, 'alias-executed'))).toBe(false); }); -it('ignores inherited user Git attributes outside the GIT_ namespace', () => { +it('ignores inherited user Git attributes outside the GIT_ namespace', async () => { const f = fixture(); f.write('a.txt', 'ONE\ntwo\nthree\n'); f.commit('P1'); - const read = () => readHistory(f.dir, f.base).final.map(file => file.contexts); - const expected = read(); expect(expected.flat()).not.toHaveLength(0); + const read = async () => (await readHistory(f.dir, f.base)).final.map(file => file.contexts); + const expected = await read(); expect(expected.flat()).not.toHaveLength(0); const home = mkdtempSync(join(tmpdir(), 'codeboost-history-home-')); dirs.push(home); mkdirSync(join(home, 'git')); writeFileSync(join(home, 'git', 'attributes'), '* -diff\n'); vi.stubEnv('XDG_CONFIG_HOME', home); vi.stubEnv('HOME', home); - try { expect(read()).toEqual(expected); } finally { vi.unstubAllEnvs(); } + try { expect(await read()).toEqual(expected); } finally { vi.unstubAllEnvs(); } }); -it('isolates repository selection from inherited Git environment variables', () => { +it('isolates repository selection from inherited Git environment variables', async () => { const expected = fixture(); expected.write('a.txt', 'expected repo\n'); expected.commit('P1'); const foreign = fixture(); foreign.write('a.txt', 'foreign repo\n'); foreign.commit(); const previous = process.env.GIT_DIR; try { process.env.GIT_DIR = join(foreign.dir, '.git'); - const history = readHistory(expected.dir, 'HEAD~1'); + const history = await readHistory(expected.dir, 'HEAD~1'); expect(history.final[0]!.after!.text).toBe('expected repo\n'); } finally { if (previous === undefined) delete process.env.GIT_DIR; else process.env.GIT_DIR = previous; } }); -it('rejects repository-local object alternates before reading borrowed history', () => { +it('rejects repository-local object alternates before reading borrowed history', async () => { const source = fixture(); source.write('a.txt', 'borrowed content\n'); source.commit(); const borrower = fixture(); writeFileSync(join(borrower.dir, '.git/objects/info/alternates'), join(source.dir, '.git/objects') + '\n'); - expect(() => readHistory(borrower.dir, source.base, source.git('rev-parse', 'HEAD'))).toThrow(/alternates/i); + await expect(readHistory(borrower.dir, source.base, source.git('rev-parse', 'HEAD'))).rejects.toThrow(/alternates/i); }); -it('keeps a foreign rename on its file card while attributing later text edits', () => { +it('keeps a foreign rename on its file card while attributing later text edits', async () => { const f = fixture(); renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit(); f.plan.items[1]!.files[0]!.path = 'b.txt'; f.write('b.txt', 'ONE\ntwo\nthree\n'); f.commit('P2'); - const parts = f.segments(); + const parts = await f.segments(); expect(parts.find(s => s.kind === 'file')?.row).toBe('Unplanned'); const text = parts.filter(s => s.kind === 'text'); expect(text.length).toBeGreaterThan(0); expect(text.every(s => s.row === 'P2' && s.scope === 'in-scope')).toBe(true); }); -it('fails explicitly when cumulative unique blob bytes exceed the history budget', () => { +it('fails explicitly when cumulative unique blob bytes exceed the history budget', async () => { const f = fixture(); f.write('a.txt', 'changed text\n'); f.commit('P1'); - expect(() => readHistory(f.dir, f.base, 'HEAD', { maxBlobBytes: 20 })).toThrow(/blob byte budget/i); - expect(readHistory(f.dir, f.base, 'HEAD', { maxBlobBytes: 27 }).final).toHaveLength(1); + await expect(readHistory(f.dir, f.base, 'HEAD', { maxBlobBytes: 20 })).rejects.toThrow(/blob byte budget/i); + expect((await readHistory(f.dir, f.base, 'HEAD', { maxBlobBytes: 27 })).final).toHaveLength(1); }); -it.each(['root', 'loose', 'pack'])('rejects symlinked %s object storage', kind => { +it.each(['root', 'loose', 'pack'])('rejects symlinked %s object storage', async kind => { const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); const objects = join(f.dir, '.git/objects'); const storage = kind === 'root' ? objects : join(objects, kind === 'pack' ? 'pack' : readdirSync(objects).find(name => /^[0-9a-f]{2}$/.test(name))!); const borrowed = join(f.dir, 'borrowed-objects'); renameSync(storage, borrowed); symlinkSync(borrowed, storage); - expect(() => readHistory(f.dir, f.base)).toThrow(/symlinked object storage/i); + await expect(readHistory(f.dir, f.base)).rejects.toThrow(/symlinked object storage/i); }); -it('enforces a caller-lowered object inspection entry limit', () => { +it('enforces a caller-lowered object inspection entry limit', async () => { const f = fixture(); - expect(() => readHistory(f.dir, f.base, 'HEAD', { maxObjectEntries: 1 })).toThrow(/inspection exceeds/i); + await expect(readHistory(f.dir, f.base, 'HEAD', { maxObjectEntries: 1 })).rejects.toThrow(/inspection exceeds/i); }); -it('supports a caller-selected linked worktree gitdir', () => { +it('supports a caller-selected linked worktree gitdir', async () => { const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); const worktree = mkdtempSync(join(tmpdir(), 'codeboost-linked-')); dirs.push(worktree); f.git('worktree', 'add', '--detach', worktree, 'HEAD'); - expect(readHistory(worktree, f.base).final[0]!.after!.text).toBe('changed\n'); + expect((await readHistory(worktree, f.base)).final[0]!.after!.text).toBe('changed\n'); }); -it('supports a caller-selected symlinked gitdir with regular object storage', () => { +it('supports a caller-selected symlinked gitdir with regular object storage', async () => { const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); const metadata = mkdtempSync(join(tmpdir(), 'codeboost-gitdir-')); dirs.push(metadata); rmSync(metadata, { recursive: true }); renameSync(join(f.dir, '.git'), metadata); symlinkSync(metadata, join(f.dir, '.git')); - expect(readHistory(f.dir, f.base).final[0]!.after!.text).toBe('changed\n'); + expect((await readHistory(f.dir, f.base)).final[0]!.after!.text).toBe('changed\n'); }); it.each([ [{ maxDiffBytes: 1 }, /diff byte budget/i], [{ maxFileEntries: 1 }, /file entry budget/i], -] as const)('bounds accumulated diff records with %j', (limits, error) => { +] as const)('bounds accumulated diff records with %j', async (limits, error) => { const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); - expect(() => readHistory(f.dir, f.base, 'HEAD', limits)).toThrow(error); - expect(readHistory(f.dir, f.base, 'HEAD', { maxFileEntries: 2 }).final).toHaveLength(1); + await expect(readHistory(f.dir, f.base, 'HEAD', limits)).rejects.toThrow(error); + expect((await readHistory(f.dir, f.base, 'HEAD', { maxFileEntries: 2 })).final).toHaveLength(1); }); -it('uses checkout identity for scope and treats explicit null ledger owners as foreign', () => { +it('uses checkout identity for scope and treats explicit null ledger owners as foreign', async () => { const f = fixture(); f.plan.items[0]!.files[0]!.path = 'A.TXT'; f.write('a.txt', 'ONE\ntwo\nthree\n'); const sha = f.commit('P1'); - const history = readHistory(f.dir, f.base); + const history = await readHistory(f.dir, f.base); const parts = linkHistory(f.plan, history, f.ledger, path => path.toLowerCase()); expect(parts.length).toBeGreaterThan(0); expect(parts.every(s => s.scope === 'in-scope')).toBe(true); expect(linkHistory(f.plan, history, new Map([[sha, null]]), path => path).every(s => s.row === 'Unplanned')).toBe(true); }); -it('records typed object identities on real mode-change cards', () => { +it('records typed object identities on real mode-change cards', async () => { const f = fixture(); chmodSync(join(f.dir, 'a.txt'), 0o755); f.commit('P1'); - const card = f.segments().find(s => s.kind === 'file'); expect(card).toBeDefined(); + const card = (await f.segments()).find(s => s.kind === 'file'); expect(card).toBeDefined(); const metadata = JSON.parse(card!.content); expect(metadata.oldObject.kind).toBe('blob'); expect(metadata.newObject.kind).toBe('blob'); expect(metadata.oldObject.oid).toBe(metadata.newObject.oid); expect(metadata.oldMode).toBe('100644'); expect(metadata.newMode).toBe('100755'); }); -it('parses scored renames and consumes both paths before the next raw record', () => { +it('parses scored renames and consumes both paths before the next raw record', async () => { const initial = Array.from({ length: 100 }, (_, i) => `line ${i}\n`).join(''); const f = fixture({ 'a.txt': initial }); renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); @@ -291,39 +291,39 @@ it('parses scored renames and consumes both paths before the next raw record', ( f.write('z.txt', 'another record\n'); const head = f.commit('P1'); const raw = f.git('diff', '--raw', '-z', '-M', f.base, head); expect(raw).toMatch(/ R\d+\0a\.txt\0b\.txt\0/u); - const history = readHistory(f.dir, f.base); + const history = await readHistory(f.dir, f.base); expect(history.final.some(d => d.oldPath === 'a.txt' && d.newPath === 'b.txt')).toBe(true); expect(history.final.some(d => d.oldPath === null && d.newPath === 'z.txt')).toBe(true); }); -it('bounds cumulative line and segment allocation before replay can grow', () => { +it('bounds cumulative line and segment allocation before replay can grow', async () => { const f = fixture(); f.write('a.txt', 'changed\ntwo\nthree\n'); f.commit('P1'); - const history = readHistory(f.dir, f.base); + const history = await readHistory(f.dir, f.base); expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxLines: 2 })).toThrow(/line.*budget/i); expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxSegments: 1 })).toThrow(/segment.*budget/i); expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxLines: 12, maxSegments: 2 })).not.toThrow(); expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxLines: 11 })).toThrow(/line.*budget/i); expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxReferences: 1 })).toThrow(/reference.*budget/i); }); -it.each(['one\n', '', '\0binary'])('carries rename lineage into a final deletion file card (%j)', content => { +it.each(['one\n', '', '\0binary'])('carries rename lineage into a final deletion file card (%j)', async content => { const f = fixture({ 'a.txt': content }); f.plan.items[0]!.files = [{ path: 'b.txt', kind: 'rename', renamed_from: 'a.txt', change: 'Move' }]; f.plan.items[1]!.files = [{ path: 'b.txt', kind: 'delete', renamed_from: null, change: 'Delete' }]; renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); rmSync(join(f.dir, 'b.txt')); f.commit('P2'); - expect(f.segments().find(s => s.kind === 'file')!.owners).toContain('P2'); + expect((await f.segments()).find(s => s.kind === 'file')!.owners).toContain('P2'); }); -it('bounds the total Git read duration across subprocesses', () => { +it('bounds the total Git read duration across subprocesses', async () => { const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); let elapsed = 0; const clock = vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 1000)); - try { expect(() => readHistory(f.dir, f.base, 'HEAD', { maxDurationMs: 3000 })).toThrow(/deadline|duration/i); } + try { await expect(readHistory(f.dir, f.base, 'HEAD', { maxDurationMs: 3000 })).rejects.toThrow(/deadline|duration/i); } finally { clock.mockRestore(); } }); -it('bounds linking duration and replacement-origin fanout', () => { +it('bounds linking duration and replacement-origin fanout', async () => { const f = fixture({ 'a.txt': 'old\n'.repeat(100) }); f.write('a.txt', 'new\n'.repeat(100)); f.commit('P1'); - const history = readHistory(f.dir, f.base); + const history = await readHistory(f.dir, f.base); expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxReferences: 1000 })).toThrow(/reference.*budget/i); expect(() => linkHistory(f.plan, history, f.ledger, p => p)).not.toThrow(); let elapsed = 0; @@ -332,31 +332,31 @@ it('bounds linking duration and replacement-origin fanout', () => { finally { clock.mockRestore(); } }); -it.each(['P1', undefined])('retains deletion and recreation owners on reused-path file cards (%s)', owner => { +it.each(['P1', undefined])('retains deletion and recreation owners on reused-path file cards (%s)', async owner => { const f = fixture({ 'a.txt': '\0old binary' }); rmSync(join(f.dir, 'a.txt')); f.commit(owner); f.write('a.txt', '\0new binary'); f.commit('P2'); - const card = f.segments().find(s => s.kind === 'file')!; + const card = (await f.segments()).find(s => s.kind === 'file')!; expect(card.owners).toEqual([owner ?? null, 'P2']); expect(card.row).toBe(owner ? 'Ambiguous' : 'Unplanned'); }); -it('rejects local grafts that make an unrelated commit appear descended from the base', () => { +it('rejects local grafts that make an unrelated commit appear descended from the base', async () => { const f = fixture(); f.git('checkout', '--orphan', 'unrelated'); f.git('rm', '-rf', '.'); f.write('a.txt', 'unrelated history\n'); const head = f.commit('P1'); f.write('.git/info/grafts', `${head} ${f.base}\n`); - expect(() => readHistory(f.dir, f.base, head)).toThrow(/graft/i); + await expect(readHistory(f.dir, f.base, head)).rejects.toThrow(/graft/i); }); -it('rejects shallow parent rewriting before loading history', () => { +it('rejects shallow parent rewriting before loading history', async () => { const f = fixture(); f.write('a.txt', 'changed\n'); const head = f.commit('P1'); f.write('.git/shallow', `${head}\n`); - expect(() => readHistory(f.dir, f.base, head)).toThrow(/shallow/i); + await expect(readHistory(f.dir, f.base, head)).rejects.toThrow(/shallow/i); }); -it('omits raster previews with oversized declared dimensions', () => { +it('omits raster previews with oversized declared dimensions', async () => { const f=fixture(); const base=f.git('rev-parse','HEAD'); const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lXsAAAAASUVORK5CYII=','base64'); image.writeUInt32BE(100000,16);image.writeUInt32BE(100000,20); f.write('oversized.png',image);const head=f.commit(); - expect(readHistory(f.dir,base,head).final.find(file=>file.newPath==='oversized.png')?.after?.preview).toBeUndefined(); + expect((await readHistory(f.dir,base,head)).final.find(file=>file.newPath==='oversized.png')?.after?.preview).toBeUndefined(); }); diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index 89f44a90..35ca9384 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -77,14 +77,16 @@ function withLaterOpening() { return { store, first, later, adopt }; } +/** The review state the fake review below expects. */ +const expectedOf = (store: Store) => ({ revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) }); function service(store: Store) { const snapshot = store.getSnapshot(identity); const view = { items: [{ id: 'P1', state: 'approved', outside: [], acceptance: [{ type: 'check', text: 'Works' }], checks: { tests: '– No tests defined' } }], plan: { revision: 1 }, segments: [], notes: [], snapshot: { id: snapshot.id, base: oid(1), head: oid(2) }, token: 'review-token', - expected: { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, + expected: expectedOf(store), }; - return { store, config: { identity }, load: vi.fn(() => view), commandPolicyDigest: () => commandDigest([]) } as unknown as ReviewService; + return { store, config: { identity }, load: vi.fn(async () => view), commandPolicyDigest: () => commandDigest([]) } as unknown as ReviewService; } /** @@ -186,6 +188,22 @@ describe('the merge gate with a runner block (#121)', () => { expect(store.getMergeAttempt(identity)).toBeNull(); }); + it('revalidates local authorization after the final review load', async () => { + const { store } = published(), gh = github(store); + let refreshed = false, revoked = false; + prepare(store); + const reviewing = service(store), load = reviewing.load.bind(reviewing); + // The review loaded after the last external authorization read is the final one; trust is revoked while it loads. + reviewing.load = vi.fn(async (options?: Parameters[0]) => { if (refreshed) revoked = true; return load(options); }) as ReviewService['load']; + const merges = new MergeCoordinator(reviewing, gh.client, undefined, undefined, PREPARED_PUBLISHED); + merges.setAuthorization(async () => ({ refresh: async () => { refreshed = true; }, validate: () => { + if (revoked) throw new GuardRefusal('Issue trust was revoked during the final review load.'); + } })); + await expect(merges.merge('review-token')).rejects.toThrow(/trust was revoked during the final review load/i); + expect(gh.merged).toEqual([]); + expect(store.getMergeAttempt(identity)).toBeNull(); + }); + it('captures the queue event boundary after the final authorization and pull request reads', async () => { const { store } = published(), gh = github(store, () => ({ mergeQueue: true })); let authorized = false; @@ -369,7 +387,7 @@ describe('the merge gate with a runner block (#121)', () => { it('does not settle an attempt for another PR that was admitted while this status read was in flight', async () => { const { store } = published(); - const view = service(store).load(); + const view = await service(store).load(); // No PR is pinned when the read starts; the attempt for PR 8 is admitted by another coordinator during the read. const gh = github(store, () => ({ pullRequest: 99, pullRequestState: 'MERGED' })); gh.client.before = () => { store.beginMergeAttempt(identity, view.expected as never, oid(2), null, 'direct', null, null, { pullRequest: 8, openingId: null }); }; @@ -385,7 +403,7 @@ describe('the merge gate with a runner block (#121)', () => { it('settles a direct attempt only from its own PR, even after a newer PR is recorded', async () => { const { store, first, adopt } = withLaterOpening(); - const view = service(store).load(); + const view = await service(store).load(); const attempt = store.beginMergeAttempt(identity, view.expected as never, oid(2), null, 'direct', null, null, { pullRequest: 7, openingId: first.openingId }); // A newer PR recorded while the attempt is in flight; the attempt keeps the PR it was started for. adopt(); @@ -398,7 +416,7 @@ describe('the merge gate with a runner block (#121)', () => { it('never settles an attempt from the state of another PR', async () => { const { store, opening: first } = published(); - const view = service(store).load(); + const view = await service(store).load(); const attempt = store.beginMergeAttempt(identity, view.expected as never, oid(2), null, 'direct', null, null, { pullRequest: 7, openingId: first.openingId }); const gh = github(store, () => ({ pullRequest: 8, pullRequestState: 'MERGED' })); const shown = await new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED).displayStatus(); @@ -408,15 +426,14 @@ describe('the merge gate with a runner block (#121)', () => { it('polls the queue for the attempt\'s own PR; an attempt saved without one uses github.pullRequest or reports why it cannot', async () => { const { store, opening: first } = published(); - const view = service(store).load(); + const view = await service(store).load(); store.beginMergeAttempt(identity, view.expected as never, oid(2), 'CURSOR', 'queue', null, null, { pullRequest: 7, openingId: first.openingId }); const gh = github(store); await new MergeCoordinator(service(store), gh.client, undefined, undefined, PUBLISHED).pollQueue(); expect(gh.queued).toEqual([7]); const legacy = published(); - const legacyView = service(legacy.store).load(); - legacy.store.beginMergeAttempt(identity, legacyView.expected as never, oid(2), 'CURSOR', 'queue'); + legacy.store.beginMergeAttempt(identity, expectedOf(legacy.store) as never, oid(2), 'CURSOR', 'queue'); expect(legacy.store.getMergeAttempt(identity)!.pullRequest).toBeNull(); const old = github(legacy.store); const before = legacy.store.getMergeAttempt(identity); @@ -435,7 +452,7 @@ describe('the merge gate with a runner block (#121)', () => { it('shows the review\'s own blockers beside one that names no PR to inspect', async () => { const store = runningTask(); toReview(store); const reviewing = service(store); - (reviewing.load() as unknown as { items: Array<{ state: string }> }).items[0]!.state = 'pending'; + (await reviewing.load() as unknown as { items: Array<{ state: string }> }).items[0]!.state = 'pending'; const shown = await new MergeCoordinator(reviewing, github(store).client, undefined, undefined, PUBLISHED).displayStatus(); expect(shown.blockers).toEqual([ { code: 'pull-request', message: 'The task has no published pull request into main.' }, @@ -455,7 +472,7 @@ describe('the merge gate with a runner block (#121)', () => { describe('admission re-reads the task\'s PR record (#121)', () => { const admit = (store: Store, target: { pullRequest: number; openingId: string | null }) => - store.beginMergeAttempt(identity, service(store).load().expected as never, oid(2), null, 'direct', null, null, target); + store.beginMergeAttempt(identity, expectedOf(store) as never, oid(2), null, 'direct', null, null, target); it('refuses a PR that is no longer the task\'s latest, or another number', () => { const { store, first, later, adopt } = withLaterOpening(); expect(() => admit(store, { pullRequest: 9, openingId: first.openingId })).toThrow(GuardRefusal); diff --git a/test/merge.test.ts b/test/merge.test.ts index 336c04c0..dd9dc5fd 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -7,7 +7,7 @@ import { Store, mergeActionResponse } from '../runner/store.ts'; import { GhAlreadyFixedGateway, type AlreadyFixedGateway, type AlreadyFixedInput, type AlreadyFixedResult } from '../github/already-fixed.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; -type ReviewView = ReturnType; +type ReviewView = Awaited>; const sha = (digit: string) => digit.repeat(40); const xref = (number: number, willCloseTarget: boolean, over: Record = {}) => ({ __typename: 'CrossReferencedEvent', willCloseTarget, source: { __typename: 'PullRequest', number, state: 'OPEN', isDraft: false, baseRefName: 'main', repository: { nameWithOwner: 'owner/repo' }, ...over } }); @@ -41,7 +41,7 @@ function readyView(): ReviewView { plan: { revision: 1 }, segments: [], notes: [], snapshot: { id: 'snapshot', base: sha('a'), head: sha('b') }, token: 'review-token', } as unknown as ReviewView; } -function serviceFor(view: ReviewView): ReviewService { return { load: vi.fn(() => view) } as unknown as ReviewService; } +function serviceFor(view: ReviewView): ReviewService { return { load: vi.fn(async () => view) } as unknown as ReviewService; } function remote(view: ReviewView, change: Partial = {}): RemoteMergeState { return { base: view.snapshot.base, head: view.snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: false, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false, ...change }; @@ -59,7 +59,7 @@ function queueHarness(observations: Array) { const context = { identity, issue: 24, baseEntries: [{ path: 'a', kind: 'file' as const }], pathKey: (path: string) => path, allowedCommands: [] }; store.createPlan(JSON.stringify(plan), 'json', context, sha('a'), sha('b')); let view = { ...readyView(), expected: { revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) } } as ReviewView; - const service = { store, config: { identity }, load: vi.fn(() => view) } as unknown as ReviewService; + const service = { store, config: { identity }, load: vi.fn(async () => view) } as unknown as ReviewService; const merges: string[] = []; const client: MergeGateway & MergeQueueGateway = { inspect: vi.fn(async () => remote(view, { mergeQueue: true })), @@ -124,7 +124,7 @@ it('does not report the issue as already fixed by another change once this PR ha it('blocks command acceptance that has no current passing runner result', async () => { const view = readyView(); const changed = { ...view, items: view.items.map((item, index) => index ? item : { ...item, acceptance: [{ type: 'cmd' as const, text: 'npm test' }] }) }; - const fakeService = { load: () => changed } as unknown as ReviewService; + const fakeService = { load: async () => changed } as unknown as ReviewService; const status = await new MergeCoordinator(fakeService, gateway([remote(view)])).status(changed); expect(status.blockers.map(blocker => blocker.code)).toContain('acceptance'); }); @@ -172,7 +172,7 @@ it('refuses a requirement that changes after the initial validation', async () = it('revalidates the store generation after the final asynchronous status check', async () => { const view = readyView(), changed = { ...view, token: 'new-review-token' }; let loads = 0; - const service = { load: vi.fn(() => ++loads < 3 ? view : changed) } as unknown as ReviewService; + const service = { load: vi.fn(async () => ++loads < 3 ? view : changed) } as unknown as ReviewService; const client = gateway([remote(view), remote(view)]); await expect(new MergeCoordinator(service, client).merge(view.token)).rejects.toThrow(/Review changed during merge validation/); expect(client.heads).toEqual([]); @@ -285,7 +285,7 @@ it('returns a committed merge on replay even when the local review cannot be rel const actionId = randomUUID(); try { await h.coordinator.merge(h.view().token, actionId); - vi.mocked(h.service.load).mockImplementation(() => { throw new Error('checkout unreadable'); }); + vi.mocked(h.service.load).mockImplementation(async () => { throw new Error('checkout unreadable'); }); const replay = await h.coordinator.merge(h.view().token, actionId); expect(replay.result).toEqual({ url: 'https://github.example/pr/1' }); expect(replay.status).toMatchObject({ ready: false, blockers: [{ code: 'refresh' }] }); @@ -394,7 +394,7 @@ it('answers with the in-flight outcome another coordinator saved just before adm const inspect = h.client.inspect; let inspections = 0, armed = false, injected = false; h.client.inspect = vi.fn(async (...args: Parameters) => { if (++inspections === 3) armed = true; return inspect(...args); }); - vi.mocked(h.service.load).mockImplementation(() => { + vi.mocked(h.service.load).mockImplementation(async () => { const view = h.view(); if (armed) { armed = false; injected = true; @@ -651,6 +651,27 @@ it('replays a saved merge click after shutdown has started', async () => { } finally { await h.coordinator.close(); h.store.close(); } }); +it('stops a replayed click\'s review load at shutdown and waits for it, still answering the committed merge', async () => { + const h = queueHarness([]); + h.client.inspect = vi.fn(async () => remote(h.view())); + const actionId = randomUUID(); + try { + await h.coordinator.merge(h.view().token, actionId); + let signal: AbortSignal | undefined, loadSettled = false; + vi.mocked(h.service.load).mockImplementation(options => new Promise((_, reject) => { + signal = options?.signal; + // The load settles a little after its abort, as a worker does once its Git process has exited. + signal?.addEventListener('abort', () => setTimeout(() => { loadSettled = true; reject(signal!.reason); }, 50), { once: true }); + })); + const replay = h.coordinator.merge(h.view().token, actionId); + await vi.waitFor(() => expect(signal).toBeDefined()); + await h.coordinator.close(); + expect(loadSettled).toBe(true); + await expect(replay).resolves.toMatchObject({ result: { url: 'https://github.example/pr/1' }, status: { blockers: [{ code: 'refresh' }] } }); + expect(h.merges).toHaveLength(1); + } finally { await h.coordinator.close(); h.store.close(); } +}); + it('keeps a direct merge URL for a replayed click', async () => { const h = queueHarness([]); h.client.inspect = vi.fn(async () => remote(h.view())); diff --git a/test/plant.test.ts b/test/plant.test.ts index b090e6bc..dd099b2f 100644 --- a/test/plant.test.ts +++ b/test/plant.test.ts @@ -9,28 +9,28 @@ import { ReviewService } from '../runner/review.ts'; import { Store } from '../runner/store.ts'; import type { Plan } from '../core/plan.ts'; const roots:string[]=[];afterEach(()=>roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}))); -it('plants in an isolated clone, retains ledger attribution, and leaves source history unchanged',()=>{ +it('plants in an isolated clone, retains ledger attribution, and leaves source history unchanged',async()=>{ const root=mkdtempSync(join(tmpdir(),'codeboost-plant-'));roots.push(root);const config=createDemo(join(root,'source')); config.github={repository:'owner/source',pullRequest:7,issue:3}; config.allowedCommands=[['npm','test']]; config.runnerRepository=config.repository; const head=()=>fixtureGit(config.repository,'rev-parse','HEAD');const before=head(); - const path=plant(config,join(root,'experiment'),{declaredText:'// planted extra behavior',undeclaredText:'unrelated diagnostic',undeclaredPath:'extra.txt'}); + const path=await plant(config,join(root,'experiment'),{declaredText:'// planted extra behavior',undeclaredText:'unrelated diagnostic',undeclaredPath:'extra.txt'}); expect(head()).toBe(before);const output=JSON.parse(readFileSync(path,'utf8'));expect(output.github).toBeUndefined();expect(output.allowedCommands).toBeUndefined();expect(output.runnerRepository).toBeUndefined();const service=new ReviewService(output); - try {const view=service.load();expect(view.segments.some(s=>s.path==='extra.txt'&&s.scope==='out-of-scope')).toBe(true);expect(view.segments.some(s=>s.content.includes('// planted extra behavior')&&s.row.startsWith('P'))).toBe(true);}finally{service.close();} + try {const view=await service.load();expect(view.segments.some(s=>s.path==='extra.txt'&&s.scope==='out-of-scope')).toBe(true);expect(view.segments.some(s=>s.content.includes('// planted extra behavior')&&s.row.startsWith('P'))).toBe(true);}finally{service.close();} expect(JSON.parse(readFileSync(join(root,'experiment','sealed.json'),'utf8')).mappings).toHaveLength(3); },15000); -it('refuses non-ASCII plants on case-insensitive filesystems pending an identity adapter', () => { +it('refuses non-ASCII plants on case-insensitive filesystems pending an identity adapter', async () => { const root=mkdtempSync(join(tmpdir(),'codeboost-plant-path-'));roots.push(root); const config=createDemo(join(root,'source'));config.pathIdentity.caseSensitive=false; - expect(()=>plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'café.txt'})).toThrow(/filesystem-specific identity adapter/); + await expect(plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'café.txt'})).rejects.toThrow(/filesystem-specific identity adapter/); },15000); -it('rejects canonical declared and existing path collisions before creating a clone', () => { +it('rejects canonical declared and existing path collisions before creating a clone', async () => { const root=mkdtempSync(join(tmpdir(),'codeboost-plant-collision-'));roots.push(root); const config=createDemo(join(root,'source'));config.pathIdentity.caseSensitive=false; - for (const path of ['RETRY.TS','RUN.SH']) expect(()=>plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:path})).toThrow(/outside every declared file|already exists/); + for (const path of ['RETRY.TS','RUN.SH']) await expect(plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:path})).rejects.toThrow(/outside every declared file|already exists/); },15000); -it('treats metacharacters literally when checking declared-file transitions',()=>{ +it('treats metacharacters literally when checking declared-file transitions',async()=>{ const root=mkdtempSync(join(tmpdir(),'codeboost-plant-literal-'));roots.push(root);const repository=join(root,'source');mkdirSync(repository); const git=(...args:string[])=>fixtureGit(repository,...args); git('init','-b','main');git('config','user.name','Test');git('config','user.email','test@example.invalid');git('config','commit.gpgsign','false'); @@ -45,28 +45,28 @@ it('treats metacharacters literally when checking declared-file transitions',()= try{store.createPlan(JSON.stringify(plan),'json',{identity,issue:10,baseEntries:['*.txt','a.txt'].map(path=>({path,kind:'file' as const})),pathKey:path=>path,allowedCommands:[]},base,head);store.recordHistory(identity,{revision:1,snapshotId:store.getSnapshot(identity).id},base,head,[{sha:owned,owner:'P1',origin:'owned',sourceSha:null}]);}finally{store.close();} expect(git('ls-tree','--name-only',head,'--','*.txt')).toBe(''); expect(()=>git('ls-tree','--name-only',head,'--',':(glob)*.txt')).toThrow(/pathspec magic not supported/); - expect(()=>plant(config,join(root,'experiment'),{declaredText:'planted',undeclaredText:'outside',undeclaredPath:'extra.txt'})).toThrow(/Declared plant needs a regular file retained/); + await expect(plant(config,join(root,'experiment'),{declaredText:'planted',undeclaredText:'outside',undeclaredPath:'extra.txt'})).rejects.toThrow(/Declared plant needs a regular file retained/); },15000); -it('rejects experiment destinations inside the source, including symlink aliases', () => { +it('rejects experiment destinations inside the source, including symlink aliases', async () => { const root=mkdtempSync(join(tmpdir(),'codeboost-plant-destination-'));roots.push(root); const config=createDemo(join(root,'source'));symlinkSync(config.repository,join(root,'alias')); for(const destination of [join(config.repository,'experiment'),join(config.repository,'.git','experiment'),join(root,'alias','nested','experiment')]) { - expect(()=>plant(config,destination,{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'})).toThrow(/outside the source repository/); + await expect(plant(config,destination,{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'})).rejects.toThrow(/outside the source repository/); } },15000); -it('passes its isolated environment to every planting Git process', () => { +it('passes its isolated environment to every planting Git process', async () => { const root=mkdtempSync(join(tmpdir(),'codeboost-plant-env-'));roots.push(root); const config=createDemo(join(root,'source')); vi.stubEnv('GIT_DIR',join(root,'outside.git'));vi.stubEnv('GIT_WORK_TREE',join(root,'outside')); - try {expect(plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'})).toBe(join(root,'experiment','review.json'));} + try {expect(await plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'})).toBe(join(root,'experiment','review.json'));} finally {vi.unstubAllEnvs();} },15000); -it('commits every plant even when an inherited user Git ignore file matches it', () => { +it('commits every plant even when an inherited user Git ignore file matches it', async () => { const root=mkdtempSync(join(tmpdir(),'codeboost-plant-ignore-'));roots.push(root); const config=createDemo(join(root,'source'));const home=join(root,'home');mkdirSync(join(home,'git'),{recursive:true});writeFileSync(join(home,'git','ignore'),'extra.txt\n'); vi.stubEnv('XDG_CONFIG_HOME',home);vi.stubEnv('HOME',root); - try {plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'});} + try {await plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'});} finally {vi.unstubAllEnvs();} expect(fixtureGit(join(root,'experiment','repository'),'ls-tree','--name-only','HEAD').split('\n')).toContain('extra.txt'); },15000); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index e38b4a74..1a93a77f 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -15,6 +15,19 @@ import type { InvocationHandle, InvocationResult } from '../agents/contract.ts'; import type { TaskWorkspace, WorkspaceRef } from '../runner/execution.ts'; import { MIN_REBASE_CLEANUP_TIMEOUT_MS, MIN_REBASE_TIMEOUT_MS, RebaseResourcesUnsettled } from '../runner/rebase.ts'; +// A passthrough, so a test can act while a preparation's worker history read is in flight. +const historyReads = vi.hoisted(() => ({ during: undefined as (() => void) | undefined })); +vi.mock('../runner/history-job.ts', async original => { + const actual = await original(); + return { ...actual, readHistoryInWorker: vi.fn((...args: Parameters) => { + const reading = actual.readHistoryInWorker(...args); historyReads.during?.(); return reading; + }) }; +}); +afterEach(() => { historyReads.during = undefined; }); +/** The review state a load would expect, read synchronously so a hook acts at its exact point in the preparation. */ +const reviewState = (service: ReviewService) => ({ revision: service.store.getPlan(service.config.identity).revision, + snapshotId: service.store.getSnapshot(service.config.identity).id, reviewVersion: service.store.reviewVersion(service.config.identity) }); + const roots: string[] = [], services: ReviewService[] = []; vi.setConfig({ testTimeout: 15_000 }); afterEach(() => { for (const service of services.splice(0)) service.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -45,6 +58,8 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: duringRebase?: (service: ReviewService, coordinator: PreMergeCoordinator, timeoutMs?: number) => void; duringFinalInspect?: (service: ReviewService, coordinator: PreMergeCoordinator, read: number) => void; duringAuthorize?: (service: ReviewService, call: number) => { base: string; head: string } | void; + /** Runs while each review load the preparation starts is in flight. */ + duringLoad?: (service: ReviewService) => void; closeDuringCommand?: boolean; closeAfterCommandAdmission?: boolean; commandWaitsForDeadline?: boolean; @@ -83,10 +98,10 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: acceptance: commandExit === undefined ? [{ type: 'check', text: 'a changed' }] : [{ type: 'cmd', text: 'npm test' }], depends_on: [] }] }; const context: PlanContext = { identity, issue: 1, baseEntries: [{ path: 'a.txt', kind: 'file' }], pathKey: path => path, allowedCommands }; service.store.createPlan(JSON.stringify(plan), 'json', context, base, head); - let view = service.load(); + let view = await service.load(); service.store.recordHistory(identity, view.expected, base, head, [{ sha: head, owner: 'P1', origin: 'owned', sourceSha: null }]); if (options.runnerCommitted !== false) markRunnerOwned(service); else markRunnerUnchanged(service); - view = service.load(); view = service.act({ action: 'approve', item: 'P1', token: view.token }); + view = await service.load(); view = await service.act({ action: 'approve', item: 'P1', token: view.token }); let coordinator!: PreMergeCoordinator, rebaseRuns = 0; let rebaseAborts = 0; const rebaseBudgets: Array = [], abortBudgets: Array = []; @@ -165,6 +180,10 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: refresh: async () => undefined, validate: () => { remotePair = options.duringAuthorize!(service, ++authorizationChecks) ?? remotePair; }, }) : undefined); + if (options.duringLoad) { + const load = service.load.bind(service); + service.load = (loadOptions?: Parameters[0]) => { const loading = load(loadOptions); options.duringLoad!(service); return loading; }; + } coordinator = new PreMergeCoordinator(service, runner, rebaser, { inspect: async () => { @@ -190,7 +209,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options: it('preserves an approval when a moved base leaves its attributed fingerprint unchanged', async () => { const { service, coordinator, runner, result, rebased } = await rebaseFixture('feature\n'); expect(result).toMatchObject({ state: 'ready', head: rebased, checked: [], reason: null }); - expect(service.load().items.find(item => item.id === 'P1')?.state).toBe('approved'); + expect((await service.load()).items.find(item => item.id === 'P1')?.state).toBe('approved'); await coordinator.close(); await runner.close(); }); it('prepares a published original head when every runner item was unchanged', async () => { @@ -203,13 +222,13 @@ it('returns to review when a moved-base rewrite changes an approved fingerprint' const { service, coordinator, runner, result, rebased } = await rebaseFixture('changed by rebase\n'); expect(result).toMatchObject({ state: 'review-required', head: rebased, checked: [] }); expect(result.reason).toMatch(/requires refreshed attribution or approval/); - expect(service.load().items.find(item => item.id === 'P1')?.state).toBe('stale'); + expect((await service.load()).items.find(item => item.id === 'P1')?.state).toBe('stale'); await coordinator.close(); await runner.close(); }); it('marks a failed preparation stale when its final remote inspection was invalidated by a review edit', async () => { const fixture = await rebaseFixture('feature\n', undefined, { duringFinalInspect(service) { - const current = service.load(); + const current = { expected: reviewState(service) }; service.store.addReviewNote(service.config.identity, current.expected, 'P1', 'change', 'Changed during inspection.'); } }); expect(fixture.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/changed during preparation/i) }); @@ -240,7 +259,7 @@ it('keeps a failed live cleanup current while its rebase marker awaits recovery' it('keeps a failed rebase bound to the review that started it when that review changes in flight', async () => { const fixture = await rebaseFixture('feature\n', undefined, { duringRebase(service) { - const current = service.load(); + const current = { expected: reviewState(service) }; service.store.addReviewNote(service.config.identity, current.expected, 'P1', 'change', 'Changed during rebase.'); }, rebaseFailure: new Error('rebase failed'), rebaseCleanupFailure: new Error('cleanup failed'), @@ -277,11 +296,80 @@ it('refuses readiness when local issue trust is revoked during the final remote await fixture.coordinator.close(); await fixture.runner.close(); }); +it('refuses readiness when local issue trust is revoked during the final review load', async () => { + let trusted = true, finalInspected = false; + const fixture = await rebaseFixture('feature\n', undefined, { + authorize: async () => ({ refresh: async () => undefined, + validate: () => { if (!trusted) throw new Error('Issue trust was revoked.'); } }), + duringFinalInspect: (_service, _coordinator, read) => { if (read === 3) finalInspected = true; }, + // Only the load after the last remote inspection: trust holds through that inspection's own check. + duringLoad: () => { if (finalInspected) trusted = false; }, + }); + expect(fixture.result).toMatchObject({ state: 'failed', reason: 'Issue trust was revoked.' }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('refuses readiness when the task changes during the final review load', async () => { + let finalInspected = false, cancelled = false; + const fixture = await rebaseFixture('feature\n', undefined, { + duringFinalInspect: (_service, _coordinator, read) => { if (read === 3) finalInspected = true; }, + // Only the load after the last remote inspection; the review itself does not change. + duringLoad: service => { + if (!finalInspected || cancelled) return; + cancelled = true; + const identity = service.config.identity; + service.store.cancelTask(identity, service.store.getTask(identity).stateVersion, randomUUID()); + }, + }); + expect(cancelled).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/task or review changed during preparation/i) }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('refuses to rebase when the task changes while the old history is read', async () => { + let changed = false; + historyReads.during = () => { + if (changed) return; + changed = true; + // Still mergeable and the same review: only the task's state version moves. + const service = fixtureService!, identity = service.config.identity; + service.store.transitionTask(identity, service.store.getTask(identity).stateVersion, 'in review'); + }; + let fixtureService: ReviewService | undefined; + const fixture = await rebaseFixture('feature\n', undefined, { duringLoad: service => { fixtureService = service; } }); + expect(changed).toBe(true); + expect(fixture.rebaseRuns()).toBe(0); + expect(fixture.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/task or review changed during preparation/i) }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + +it('refuses to record a moved pull request head when the task changes while its history is read', async () => { + let moved = false, changed = false, reviewing: ReviewService | undefined; + historyReads.during = () => { + if (!moved || changed) return; + changed = true; + // Still mergeable and the same review: only the task's state version moves. + const identity = reviewing!.config.identity; + reviewing!.store.transitionTask(identity, reviewing!.store.getTask(identity).stateVersion, 'in review'); + }; + const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { + reviewing = service; + if (call !== 2) return; + moved = true; + writeFileSync(join(service.config.repository, 'late.txt'), 'late collaborator push\n'); + fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'late push'); + return { base: service.store.getSnapshot(service.config.identity).base, head: fixtureGit(service.config.repository, 'rev-parse', 'HEAD') }; + } }); + expect(changed).toBe(true); + expect(fixture.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/task or review changed during preparation/i) }); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('does not report readiness while a current change request remains open', async () => { const fixture = await rebaseFixture('feature\n'); - const current = fixture.service.load(); + const current = await fixture.service.load(); fixture.service.store.addReviewNote(fixture.service.config.identity, current.expected, 'P1', 'change', 'Please revise this.'); - const changed = fixture.service.load(), task = fixture.service.store.getTask(fixture.service.config.identity); + const changed = await fixture.service.load(), task = fixture.service.store.getTask(fixture.service.config.identity); const result = await fixture.coordinator.start({ stateVersion: task.stateVersion, reviewVersion: changed.expected.reviewVersion!, snapshotId: changed.snapshot.id, base: changed.snapshot.base, head: changed.snapshot.head }); expect(result).toMatchObject({ state: 'review-required', checked: [] }); @@ -291,8 +379,8 @@ it('does not report readiness while a current change request remains open', asyn it('preserves an unpushed rebased head across review and preparation retries', async () => { const first = await rebaseFixture('changed by rebase\n'); - let view = first.service.load(); - view = first.service.act({ action: 'approve', item: 'P1', token: view.token }); + let view = await first.service.load(); + view = await first.service.act({ action: 'approve', item: 'P1', token: view.token }); const task = first.service.store.getTask(first.service.config.identity); const result = await first.coordinator.start({ stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); @@ -328,7 +416,7 @@ it('does not report readiness when command-check cleanup is unresolved', async ( it('revalidates task and review versions after the final remote read', async () => { const fixture = await rebaseFixture('feature\n', undefined, { duringFinalInspect(service) { - const view = service.load(); + const view = { expected: reviewState(service) }; service.store.addReviewNote(service.config.identity, view.expected, 'P1', 'change', 'Changed while preparing.'); }, }); @@ -340,7 +428,7 @@ it('revalidates task and review versions after the final remote read', async () it('revalidates task and review versions after final authorization', async () => { const fixture = await rebaseFixture('feature\n', undefined, { duringAuthorize(service, call) { if (call !== 2) return; - const view = service.load(); + const view = { expected: reviewState(service) }; service.store.addReviewNote(service.config.identity, view.expected, 'P1', 'change', 'Changed during authorization.'); } }); expect(fixture.result).toMatchObject({ state: 'failed' }); @@ -353,18 +441,18 @@ it('refreshes a pull request head that moves during final authorization', async if (call !== 2) return; writeFileSync(join(service.config.repository, 'late.txt'), 'late collaborator push\n'); fixtureGit(service.config.repository, 'add', 'late.txt'); fixtureGit(service.config.repository, 'commit', '-qm', 'late push'); - return { base: service.load().snapshot.base, head: fixtureGit(service.config.repository, 'rev-parse', 'HEAD') }; + return { base: service.store.getSnapshot(service.config.identity).base, head: fixtureGit(service.config.repository, 'rev-parse', 'HEAD') }; } }); expect(fixture.result).toMatchObject({ state: 'review-required' }); expect(fixture.result.reason).toMatch(/moved during final authorization/); - expect(fixture.service.load().snapshot.head).toBe(fixture.result.head); + expect((await fixture.service.load()).snapshot.head).toBe(fixture.result.head); await fixture.coordinator.close(); await fixture.runner.close(); }); it('marks historical readiness stale after the review changes', async () => { const fixture = await rebaseFixture('feature\n'); expect(fixture.coordinator.last).toMatchObject({ state: 'ready', stale: false }); - const view = fixture.service.load(); + const view = await fixture.service.load(); fixture.service.store.addReviewNote(fixture.service.config.identity, view.expected, 'P1', 'change', 'Review changed later.'); expect(fixture.coordinator.last).toMatchObject({ state: 'ready', stale: true }); await fixture.coordinator.close(); await fixture.runner.close(); @@ -377,7 +465,7 @@ it.each([ const fixture = await rebaseFixture(text, exitCode); expect(fixture.result.state).toBe(state); expect(fixture.coordinator.last).toMatchObject({ state, stale: false }); - const view = fixture.service.load(); + const view = await fixture.service.load(); fixture.service.store.addReviewNote(fixture.service.config.identity, view.expected, 'P1', 'change', 'Review changed later.'); expect(fixture.coordinator.last).toMatchObject({ state, stale: true }); await fixture.coordinator.close(); await fixture.runner.close(); @@ -447,11 +535,44 @@ it('stops an active command check when the operation-wide deadline expires', asy await fixture.coordinator.close(); await fixture.runner.close(); }); +it('refuses to record a head moved before preparation when the task changes while its history is read', async () => { + const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-')); roots.push(root); + const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); + markRunnerOwned(service); + const view = await service.load(); + const work = join(root, 'collaborator'); + fixtureGit(config.repository, 'branch', 'collaborator-push', view.snapshot.head); + fixtureGit(config.repository, 'worktree', 'add', '-q', work, 'collaborator-push'); + writeFileSync(join(work, 'README.md'), 'A collaborator changed this before preparation.\n'); + fixtureGit(work, 'add', 'README.md'); fixtureGit(work, 'commit', '-qm', 'collaborator push'); + const moved = { base: view.snapshot.base, head: fixtureGit(work, 'rev-parse', 'HEAD') }; + let changed = false; + historyReads.during = () => { + if (changed) return; + changed = true; + // Still mergeable and the same review: only the task's state version moves. + service.store.transitionTask(config.identity, service.store.getTask(config.identity).stateVersion, 'in review'); + }; + const coordinator = new PreMergeCoordinator(service, + { start() { throw new Error('No command checks expected.'); }, settled: async () => undefined, + stop: () => false, isActive: () => false, status: () => ({ active: false, stopRequested: null, unresolved: null }), + get unreleased() { return null; } } as never, + { run: async () => { throw new Error('No rebase expected.'); }, abort: async () => undefined } as never, + { inspect: async () => moved, fetch: async () => undefined }); + const task = service.store.getTask(config.identity); + const result = await coordinator.start({ stateVersion: task.stateVersion, + reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); + expect(changed).toBe(true); + expect(result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/task or review changed during preparation/i) }); + expect(service.store.getSnapshot(config.identity).head).toBe(view.snapshot.head); + await coordinator.close(); +}); + it('refreshes the moved head against its prior base when the PR base and head advance together', async () => { const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); markRunnerOwned(service); - const view = service.load(); + const view = await service.load(); const initial = { base: view.snapshot.base, head: view.snapshot.head }; const branch = 'collaborator-refresh', work = join(root, 'collaborator'); fixtureGit(config.repository, 'branch', branch, initial.head); @@ -475,7 +596,7 @@ it('refreshes the moved head against its prior base when the PR base and head ad reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); expect(result.reason).toMatch(/head moved/); expect(result).toMatchObject({ state: 'review-required', base: initial.base, head: moved.head, checked: [] }); - const refreshed = service.load(); + const refreshed = await service.load(); expect(refreshed.snapshot).toMatchObject({ base: initial.base, head: moved.head }); expect(refreshed.segments.some(segment => segment.row === 'Unplanned')).toBe(true); expect(refreshed.items.some(item => item.state !== 'approved')).toBe(true); @@ -511,13 +632,13 @@ it('traces an unpushed rewrite back to the base of a collaborator head', async ( acceptance: [{ type: 'check', text: 'a changed' }], depends_on: [] }] }; const context: PlanContext = { identity, issue: 1, baseEntries: [{ path: 'a.txt', kind: 'file' }], pathKey: path => path, allowedCommands: [] }; service.store.createPlan(JSON.stringify(plan), 'json', context, oldBase, oldHead); - let view = service.load(); + let view = await service.load(); service.store.recordHistory(identity, view.expected, oldBase, oldHead, [{ sha: oldHead, owner: 'P1', origin: 'owned', sourceSha: null }]); - markRunnerOwned(service); view = service.load(); + markRunnerOwned(service); view = await service.load(); service.store.recordRebase(identity, view.expected, newBase, rewrittenHead, [{ oldSha: oldHead, newSha: rewrittenHead }]); - view = service.load(); view = service.act({ action: 'approve', item: 'P1', token: view.token }); + view = await service.load(); view = await service.act({ action: 'approve', item: 'P1', token: view.token }); let reads = 0; const coordinator = new PreMergeCoordinator(service, { start() { throw new Error('No command checks expected.'); }, settled: async () => undefined, @@ -531,7 +652,7 @@ it('traces an unpushed rewrite back to the base of a collaborator head', async ( reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); expect(result).toMatchObject({ state: 'review-required', base: oldBase, head: collaboratorHead }); expect(result.reason).toMatch(/moved during preparation/); - expect(service.load().snapshot).toMatchObject({ base: oldBase, head: collaboratorHead }); + expect((await service.load()).snapshot).toMatchObject({ base: oldBase, head: collaboratorHead }); await coordinator.close(); }); @@ -539,7 +660,7 @@ it('refuses preparation before review or remote work while runner cleanup is unr const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-unresolved-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); markRunnerOwned(service); - const view = service.load(), task = service.store.getTask(config.identity); + const view = await service.load(), task = service.store.getTask(config.identity); for (const kind of ['marker', 'resources'] as const) { let inspected = false; const load = vi.spyOn(service, 'load'); @@ -565,7 +686,7 @@ it('refuses preparation before review or remote work while a durable rebase mark const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-rebase-marker-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); markRunnerOwned(service); - const view = service.load(); + const view = await service.load(); service.store.beginRebase(config.identity, { revision: view.expected.revision, snapshotId: view.expected.snapshotId, reviewVersion: view.expected.reviewVersion! }, service.store.getTask(config.identity).stateVersion, @@ -592,7 +713,7 @@ it('preserves an already-requested shutdown without loading the review', async ( const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-pre-aborted-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); markRunnerOwned(service); - const view = service.load(), task = service.store.getTask(config.identity); + const view = await service.load(), task = service.store.getTask(config.identity); const load = vi.spyOn(service, 'load'); const coordinator = new PreMergeCoordinator(service, { start() { throw new Error('No command checks expected.'); }, settled: async () => undefined, @@ -611,7 +732,7 @@ it('settles an admitted action after shutdown aborts its remote refresh', async const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-shutdown-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); markRunnerOwned(service); - const view = service.load(), task = service.store.getTask(config.identity), actionId = randomUUID(); + const view = await service.load(), task = service.store.getTask(config.identity), actionId = randomUUID(); const request = { attemptId: undefined, expectedStateVersion: task.stateVersion, expectedReviewVersion: view.expected.reviewVersion }; service.store.userAction(config.identity, { actionId, kind: 'prepare-merge', request }, () => ({ outcome: 'preparing' })); const capability = service.store.shutdownCapability(); @@ -635,7 +756,7 @@ it('applies one operation-wide deadline to remote work and later checks', async const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-deadline-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); markRunnerOwned(service); - const view = service.load(), task = service.store.getTask(config.identity); + const view = await service.load(), task = service.store.getTask(config.identity); const coordinator = new PreMergeCoordinator(service, { start() { throw new Error('No command checks expected.'); }, settled: async () => undefined, stop: () => false, isActive: () => false, status: () => ({ active: false, stopRequested: null, unresolved: null }), @@ -652,7 +773,7 @@ it('aborts remote work early enough to reserve bounded subprocess settlement', a const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-remote-settlement-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); markRunnerOwned(service); - const view = service.load(), task = service.store.getTask(config.identity); + const view = await service.load(), task = service.store.getTask(config.identity); let abortedAt = -1; const processReserve = 1_000, operationTimeout = 2_000; const coordinator = new PreMergeCoordinator(service, @@ -710,7 +831,7 @@ it('makes a preparation action resendable when its terminal storage write fails' const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-settlement-failure-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); markRunnerOwned(service); - const view = service.load(), task = service.store.getTask(config.identity), actionId = randomUUID(); + const view = await service.load(), task = service.store.getTask(config.identity), actionId = randomUUID(); const request = { attemptId: undefined, expectedStateVersion: task.stateVersion, expectedReviewVersion: view.expected.reviewVersion }; const readiness = { stateVersion: task.stateVersion, reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head, @@ -745,7 +866,7 @@ it('settles a failed preparation even when the fallback snapshot read would fail const root = mkdtempSync(join(tmpdir(), 'codeboost-pre-merge-fallback-read-')); roots.push(root); const config = createDemo(join(root, 'demo')), service = new ReviewService(config); services.push(service); markRunnerOwned(service); - const view = service.load(), task = service.store.getTask(config.identity), actionId = randomUUID(); + const view = await service.load(), task = service.store.getTask(config.identity), actionId = randomUUID(); const request = { attemptId: undefined, expectedStateVersion: task.stateVersion, expectedReviewVersion: view.expected.reviewVersion }; service.store.userAction(config.identity, { actionId, kind: 'prepare-merge', request }, () => ({ outcome: 'preparing' })); const original = service.store.getSnapshot.bind(service.store); let reads = 0; @@ -771,7 +892,7 @@ it('threads the remaining operation budget through every synchronous review relo const operationTimeoutMs = MIN_REBASE_TIMEOUT_MS + MIN_REBASE_CLEANUP_TIMEOUT_MS + PRE_MERGE_PROCESS_SETTLEMENT_RESERVE_MS + 10_000, processMs = 10_000; const fixture = await rebaseFixture('feature\n', undefined, { operationTimeoutMs, reserves: { processMs, commandMs: 0 } }); - const view = fixture.service.load(), task = fixture.service.store.getTask(fixture.service.config.identity); + const view = await fixture.service.load(), task = fixture.service.store.getTask(fixture.service.config.identity); const original = fixture.service.load.bind(fixture.service); const budgets: number[] = []; vi.spyOn(fixture.service, 'load').mockImplementation(options => { budgets.push(options?.maxDurationMs ?? -1); return original(options); @@ -801,10 +922,10 @@ it('rechecks the remote after local preparation and refreshes a head that moved acceptance: [{ type: 'check', text: 'a changed' }], depends_on: [] }] }; const context: PlanContext = { identity, issue: 1, baseEntries: [{ path: 'a.txt', kind: 'file' }], pathKey: path => path, allowedCommands: [] }; service.store.createPlan(JSON.stringify(plan), 'json', context, base, head); - let view = service.load(); + let view = await service.load(); service.store.recordHistory(identity, view.expected, base, head, [{ sha: head, owner: 'P1', origin: 'owned', sourceSha: null }]); - markRunnerOwned(service); view = service.load(); - view = service.act({ action: 'approve', item: 'P1', token: view.token }); + markRunnerOwned(service); view = await service.load(); + view = await service.act({ action: 'approve', item: 'P1', token: view.token }); const work = join(root, 'collaborator'); fixtureGit(repository, 'branch', 'collaborator-race', head); fixtureGit(repository, 'worktree', 'add', '-q', work, 'collaborator-race'); writeFileSync(join(work, 'a.txt'), 'collaborator\n'); fixtureGit(work, 'commit', '-am', 'collaborator', '-q'); @@ -821,6 +942,6 @@ it('rechecks the remote after local preparation and refreshes a head that moved reviewVersion: view.expected.reviewVersion!, snapshotId: view.snapshot.id, base: view.snapshot.base, head: view.snapshot.head }); expect(result).toMatchObject({ state: 'review-required', base, head: moved.head, checked: [] }); expect(result.reason).toMatch(/moved during preparation/); - expect(service.load().segments.some(segment => segment.row === 'Unplanned')).toBe(true); + expect((await service.load()).segments.some(segment => segment.row === 'Unplanned')).toBe(true); await coordinator.close(); }); diff --git a/test/question-agent.test.ts b/test/question-agent.test.ts index 567fda5e..f2f7215d 100644 --- a/test/question-agent.test.ts +++ b/test/question-agent.test.ts @@ -357,7 +357,7 @@ it('does not start an Ask for a question whose request finishes arriving after s const root = mkdtempSync(join(tmpdir(), 'ask-shutdown-')); roots.push(root); let asked = 0; const app = await startServer(createDemo(join(root, 'demo')), 0, async () => { asked++; return 'Answer'; }); - const view = app.service.load(); + const view = await app.service.load(); const body = JSON.stringify({ action: 'note', item: view.items[0]!.id, kind: 'question', text: 'Why?', token: view.token }); const completed = new Promise((resolve, reject) => { const req = request(new URL('/api/action', app.url), { method: 'POST', headers: { 'x-codeboost-token': app.token, diff --git a/test/questions.test.ts b/test/questions.test.ts index bb9eb57e..7989b882 100644 --- a/test/questions.test.ts +++ b/test/questions.test.ts @@ -14,32 +14,32 @@ const roots:string[]=[], services:ReviewService[]=[], managers:Questions[]=[]; afterEach(async()=>{for(const manager of managers.splice(0))await manager.close();services.splice(0).forEach(s=>s.close());roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}));vi.restoreAllMocks();}); function waitForAbort(_prompt:string,signal:AbortSignal):Promise{return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));} function fixture(){const root=mkdtempSync(join(tmpdir(),'codeboost-answers-'));roots.push(root);const service=new ReviewService(createDemo(join(root,'demo')));services.push(service);return service;} -function question(service:ReviewService){const view=service.load();const segment=view.segments.find(s=>s.row==='P1'&&s.operation==='+')!;return service.act({action:'note',item:'P1',kind:'question',text:'Why cap the retry delay?',reference:{key:segment.key,start:segment.newLine,end:segment.newLine},token:view.token});} +async function question(service:ReviewService){const view=await service.load();const segment=view.segments.find(s=>s.row==='P1'&&s.operation==='+')!;return await service.act({action:'note',item:'P1',kind:'question',text:'Why cap the retry delay?',reference:{key:segment.key,start:segment.newLine,end:segment.newLine},token:view.token});} it('persists answers with plan, code, selected snippet and prior conversation context',async()=>{ - const service=fixture();let view=service.load();service.act({action:'note',item:'P1',kind:'change',text:'Keep the signature.',token:view.token});const asked=question(service); + const service=fixture();let view=await service.load();await service.act({action:'note',item:'P1',kind:'change',text:'Keep the signature.',token:view.token});const asked=await question(service); let prompt='';const manager=new Questions(service,async input=>{prompt=input;return 'The cap bounds retry latency.';});managers.push(manager);manager.start(asked.createdNoteId!,asked); await vi.waitFor(()=>expect(service.store.getReviewNotes(service.config.identity).at(-1)?.answer?.status).toBe('complete')); expect(prompt).toContain('Why cap the retry delay?');expect(prompt).toContain('Math.min');expect(prompt).toContain('Keep the signature.'); - const after=service.load();expect(after.plan.revision).toBe(asked.plan.revision);expect(after.token).toBe(asked.token);expect(after.approved).toBe(0); - const reopened=new ReviewService(service.config);services.push(reopened);expect(reopened.load().notes.at(-1)?.answer?.text).toContain('bounds retry latency'); + const after=await service.load();expect(after.plan.revision).toBe(asked.plan.revision);expect(after.token).toBe(asked.token);expect(after.approved).toBe(0); + const reopened=new ReviewService(service.config);services.push(reopened);expect((await reopened.load()).notes.at(-1)?.answer?.text).toContain('bounds retry latency'); },30_000); it('asks about the configured repository at the reviewed snapshot head',async()=>{ - const service=fixture(),asked=question(service);let received:unknown; + const service=fixture(),asked=await question(service);let received:unknown; const manager=new Questions(service,async(_prompt,_signal,scope)=>{received=scope;return 'Answer';});managers.push(manager);manager.start(asked.createdNoteId!,asked); await vi.waitFor(()=>expect(received).toBeDefined()); expect(received).toEqual({repository:service.config.repository,head:asked.snapshot.head,snapshotId:asked.snapshot.id,planId:service.config.identity.planId,planRevision:asked.plan.revision,noteId:asked.createdNoteId,attemptId:service.store.getReviewNotes(service.config.identity)[0]!.answer!.attempt,contextId:asked.notes.find(note=>note.id===asked.createdNoteId)!.contextId}); }); it('fails visibly and retries without duplicating the question or accepting stale completions',async()=>{ - const service=fixture(),asked=question(service);let calls=0; + const service=fixture(),asked=await question(service);let calls=0; const manager=new Questions(service,async()=>{if(++calls===1)throw new Error('Login required');return 'Recovered answer';});managers.push(manager);manager.start(asked.createdNoteId!,asked); await vi.waitFor(()=>expect(service.store.getReviewNotes(service.config.identity)[0]?.answer?.status).toBe('failed')); const first=service.store.getReviewNotes(service.config.identity)[0]!.answer!.attempt; - manager.start(asked.createdNoteId!,service.load());await vi.waitFor(()=>expect(service.store.getReviewNotes(service.config.identity)[0]?.answer?.status).toBe('complete')); + manager.start(asked.createdNoteId!,await service.load());await vi.waitFor(()=>expect(service.store.getReviewNotes(service.config.identity)[0]?.answer?.status).toBe('complete')); service.store.finishAnswer(service.config.identity,asked.createdNoteId!,first,{status:'complete',text:'Late old answer'}); expect(service.store.getReviewNotes(service.config.identity)).toHaveLength(1);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer!.text).toBe('Recovered answer'); }); it('prevents duplicate invocations and records interruption when the server stops',async()=>{ - const service=fixture(),asked=question(service);const manager=new Questions(service,waitForAbort);managers.push(manager);manager.start(asked.createdNoteId!,asked); + const service=fixture(),asked=await question(service);const manager=new Questions(service,waitForAbort);managers.push(manager);manager.start(asked.createdNoteId!,asked); expect(()=>manager.start(asked.createdNoteId!,asked)).toThrow(/already answering/);await manager.close(); expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.error).toMatch(/Server stopped/); }); @@ -47,14 +47,14 @@ it('persists provider selection and rejects anything but a known provider',()=>{ const service=fixture();expect(service.store.questionProvider()).toBeNull();service.store.setQuestionProvider('claude');const reopened=new ReviewService(service.config);services.push(reopened);expect(reopened.store.questionProvider()).toBe('claude');expect(()=>service.store.setQuestionProvider('codex')).toThrow(/Codex cannot answer questions/);expect(reopened.store.questionProvider()).toBe('claude');expect(()=>service.store.setQuestionProvider('sh -c anything')).toThrow(/Choose/); }); it('times out an unresponsive agent and allows expired pending attempts to be recovered',async()=>{ - const service=fixture(),asked=question(service);const manager=new Questions(service,waitForAbort);managers.push(manager); + const service=fixture(),asked=await question(service);const manager=new Questions(service,waitForAbort);managers.push(manager); vi.useFakeTimers(); try {manager.start(asked.createdNoteId!,asked);await vi.advanceTimersByTimeAsync(120001);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.error).toMatch(/timed out/); service.store.beginAnswer(service.config.identity,asked.createdNoteId!,'interrupted');await vi.advanceTimersByTimeAsync(125001);service.store.beginAnswer(service.config.identity,asked.createdNoteId!,'replacement');service.store.finishAnswer(service.config.identity,asked.createdNoteId!,'interrupted',{status:'complete',text:'Old answer'});expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.attempt).toBe('replacement'); } finally {vi.useRealTimers();} }); it('does not replace a locally running job when its persisted lease expires',async()=>{ - const service=fixture(),asked=question(service);let calls=0,signal:AbortSignal|undefined; + const service=fixture(),asked=await question(service);let calls=0,signal:AbortSignal|undefined; const manager=new Questions(service,(_prompt,currentSignal)=>{calls++;signal=currentSignal;return waitForAbort(_prompt,currentSignal);});managers.push(manager); manager.start(asked.createdNoteId!,asked); const original=service.store.getReviewNotes(service.config.identity)[0]!.answer!.attempt; @@ -64,7 +64,7 @@ it('does not replace a locally running job when its persisted lease expires',asy await manager.close();expect(signal?.aborted).toBe(true);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.error).toMatch(/Server stopped/); }); it('rejects new work as soon as shutdown begins',async()=>{ - const service=fixture(),first=question(service),second=question(service);let calls=0; + const service=fixture(),first=await question(service),second=await question(service);let calls=0; const manager=new Questions(service,(prompt,signal)=>{calls++;return waitForAbort(prompt,signal);});managers.push(manager); manager.start(first.createdNoteId!,first); const closing=manager.close(); @@ -75,7 +75,7 @@ it('rejects new work as soon as shutdown begins',async()=>{ expect(service.store.getReviewNotes(service.config.identity).find(note=>note.id===second.createdNoteId)?.answer).toBeUndefined(); }); it('keeps cancelled invocations tracked until they settle',async()=>{ - const service=fixture(),asked=question(service);let settle!:(value:string)=>void; + const service=fixture(),asked=await question(service);let settle!:(value:string)=>void; const manager=new Questions(service,()=>new Promise(resolve=>settle=resolve));managers.push(manager); vi.useFakeTimers(); try { @@ -88,36 +88,36 @@ it('keeps cancelled invocations tracked until they settle',async()=>{ expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.status).toBe('failed'); } finally {settle('Cleanup');vi.useRealTimers();} }); -it('rejects questions whose snippet was reassigned without changing the snapshot',()=>{ - const service=fixture();const initial=service.load(),foreign=initial.segments.find(s=>s.row==='Unplanned'&&s.operation==='+')!; - const assigned=service.act({action:'assign',item:'P1',key:foreign.key,token:initial.token}); - const asked=service.act({action:'note',item:'P1',kind:'question',text:'Explain this',reference:{key:foreign.key,start:foreign.newLine,end:foreign.newLine},token:assigned.token}); +it('rejects questions whose snippet was reassigned without changing the snapshot',async ()=>{ + const service=fixture();const initial=await service.load(),foreign=initial.segments.find(s=>s.row==='Unplanned'&&s.operation==='+')!; + const assigned=await service.act({action:'assign',item:'P1',key:foreign.key,token:initial.token}); + const asked=await service.act({action:'note',item:'P1',kind:'question',text:'Explain this',reference:{key:foreign.key,start:foreign.newLine,end:foreign.newLine},token:assigned.token}); const note=asked.notes.find(note=>note.id===asked.createdNoteId)!; const index=initial.segments.findIndex(segment=>segment.key===foreign.key); service.store.saveReview(service.config.identity,asked.expected,[],[{action:'assign',item:'P2',key:choiceKeys(initial.segments,service.config.identity)[index]!}]); - const moved=service.load(); + const moved=await service.load(); expect(moved.snapshot.id).toBe(asked.snapshot.id);expect(moved.notes.find(n=>n.id===note.id)?.outdated).toBe(true); const agent=vi.fn(async()=>'Should not run');const manager=new Questions(service,agent);managers.push(manager); expect(()=>manager.start(note.id,moved)).toThrow(/older review|outdated/);expect(agent).not.toHaveBeenCalled(); }); it('marks an item-level attempt historical and rejects retry when assigned code changes',async()=>{ - const service=fixture(),initial=service.load(),foreign=initial.segments.find(s=>s.row==='Unplanned')!; - const asked=service.act({action:'note',item:'P1',kind:'question',text:'Is this item complete?',token:initial.token}); + const service=fixture(),initial=await service.load(),foreign=initial.segments.find(s=>s.row==='Unplanned')!; + const asked=await service.act({action:'note',item:'P1',kind:'question',text:'Is this item complete?',token:initial.token}); const agent=vi.fn(async()=>{throw new Error('Provider failed');}),manager=new Questions(service,agent);managers.push(manager); manager.start(asked.createdNoteId!,asked);await vi.waitFor(()=>expect(service.store.getReviewNotes(service.config.identity)[0]?.answer?.status).toBe('failed')); const index=initial.segments.findIndex(segment=>segment.key===foreign.key); service.store.saveReview(service.config.identity,asked.expected,[],[{action:'assign',item:'P1',key:choiceKeys(initial.segments,service.config.identity)[index]!}]); - const changed=service.load(),note=changed.notes.find(note=>note.id===asked.createdNoteId)!; + const changed=await service.load(),note=changed.notes.find(note=>note.id===asked.createdNoteId)!; expect(changed.snapshot.id).toBe(asked.snapshot.id);expect(note.answerOutdated).toBe(true); expect(()=>manager.start(note.id,changed)).toThrow(/older review/);expect(agent).toHaveBeenCalledTimes(1); }); -it('refuses new questions once admission has stopped, before close() runs',()=>{ - const service=fixture(),asked=question(service);const manager=new Questions(service,async()=>'Answer');managers.push(manager); +it('refuses new questions once admission has stopped, before close() runs',async ()=>{ + const service=fixture(),asked=await question(service);const manager=new Questions(service,async()=>'Answer');managers.push(manager); manager.stopAdmission(); expect(()=>manager.start(asked.createdNoteId!,asked)).toThrow('Server is stopping'); expect(manager.isRunning(asked.createdNoteId!)).toBe(false); expect(service.store.getReviewNotes(service.config.identity).find(note=>note.id===asked.createdNoteId)?.answer).toBeUndefined(); - manager.markStopped(asked.createdNoteId!,service.load()); + manager.markStopped(asked.createdNoteId!,await service.load()); expect(service.store.getReviewNotes(service.config.identity).find(note=>note.id===asked.createdNoteId)?.answer).toMatchObject({status:'failed',error:'Server stopped. Retry the question.'}); }); it('refuses a review database that already names Codex, recording a failed answer and starting no agent',async()=>{ @@ -130,7 +130,7 @@ it('refuses a review database that already names Codex, recording a failed answe expect(service.store.questionProvider()).toBe('codex'); // The review lock is taken before any worker, scan or Docker work, so an untaken lock means none of it started. const acquire=vi.spyOn(LeftoverLedger.prototype,'acquire'); - const asked=question(service);const manager=new Questions(service);managers.push(manager);manager.start(asked.createdNoteId!,asked); + const asked=await question(service);const manager=new Questions(service);managers.push(manager);manager.start(asked.createdNoteId!,asked); await vi.waitFor(()=>expect(service.store.getReviewNotes(service.config.identity).at(-1)?.answer?.status).toBe('failed')); expect(service.store.getReviewNotes(service.config.identity).at(-1)?.answer?.error).toBe(CODEX_QUESTIONS_REFUSED); expect(acquire).not.toHaveBeenCalled(); diff --git a/test/review.test.ts b/test/review.test.ts index 8cbff940..8879265b 100644 --- a/test/review.test.ts +++ b/test/review.test.ts @@ -17,9 +17,10 @@ vi.setConfig({ testTimeout: 15000 }); const roots:string[]=[];const services:ReviewService[]=[]; afterEach(()=>{services.splice(0).forEach(service=>service.close());roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}));}); function fixture(){const root=mkdtempSync(join(tmpdir(),'codeboost-review-'));roots.push(root);const config=createDemo(join(root,'demo'));const service=new ReviewService(config);services.push(service);return {service,config};} -it('shares one deadline across history reading and linking',()=>{ - const {service}=fixture();let elapsed=0;const clock=vi.spyOn(performance,'now').mockImplementation(()=>elapsed++); - try{expect(()=>service.load({maxDurationMs:200})).toThrow(/deadline/i);} +// History reading and linking share the worker's budget (history-job.test.ts); the work after them shares the same deadline. +it('counts the work after the history worker against the load\'s one overall deadline',async ()=>{ + const {service}=fixture();let now=0;const clock=vi.spyOn(performance,'now').mockImplementation(()=>now); + try{const loading=service.load({maxDurationMs:30_000});now=30_000;await expect(loading).rejects.toThrow('Review load exceeded its overall deadline.');} finally{clock.mockRestore();} }); it('closes the review service when merge gateway construction fails', async()=>{ @@ -29,32 +30,32 @@ it('closes the review service when merge gateway construction fails', async()=>{ await expect(startServer(config,0)).rejects.toThrow(/merge method/i); expect(close).toHaveBeenCalledTimes(1);close.mockRestore(); }); -it('expires a browser token after another view assigns a segment and recomputes scope honestly',()=>{ - const {service}=fixture();const view=service.load();const foreign=view.segments.find(s=>s.row==='Unplanned')!; - const next=service.act({action:'assign',key:foreign.key,item:'P1',token:view.token}); +it('expires a browser token after another view assigns a segment and recomputes scope honestly',async ()=>{ + const {service}=fixture();const view=await service.load();const foreign=view.segments.find(s=>s.row==='Unplanned')!; + const next=await service.act({action:'assign',key:foreign.key,item:'P1',token:view.token}); expect(next.items[0]!.checks.scope).toContain('out of scope'); - expect(()=>service.act({action:'approve',item:'P1',token:view.token})).toThrow(/Stale/); + await expect(service.act({action:'approve',item:'P1',token:view.token})).rejects.toThrow(/Stale/); }); -it('offers reapproval when a later attribution choice invalidates execution approvals',()=>{ - const {service,config}=fixture();let view=service.load(); - for(const item of view.items)view=service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); +it('offers reapproval when a later attribution choice invalidates execution approvals',async ()=>{ + const {service,config}=fixture();let view=await service.load(); + for(const item of view.items)view=await service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); expect(service.store.unapprovedExecutionItems(config.identity,view.plan.revision)).toEqual([]); - view=service.act({action:'accept',key:view.segments.find(segment=>segment.row==='Unplanned')!.key,token:view.token}); + view=await service.act({action:'accept',key:view.segments.find(segment=>segment.row==='Unplanned')!.key,token:view.token}); expect(view.items.every(item=>item.state==='stale'&&item.reasons.includes('Execution approval is out of date'))).toBe(true); expect(service.store.unapprovedExecutionItems(config.identity,view.plan.revision)).toEqual(view.plan.items.map(item=>item.id)); - for(const item of view.items)view=service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); + for(const item of view.items)view=await service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); expect(view.items.every(item=>item.state==='approved')).toBe(true); expect(service.store.unapprovedExecutionItems(config.identity,view.plan.revision)).toEqual([]); },30000); -it('keeps review load available when continuation reconciliation refuses', () => { - const {service,config}=fixture();let view=service.load(); - for(const item of view.items)view=service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); +it('keeps review load available when continuation reconciliation refuses', async () => { + const {service,config}=fixture();let view=await service.load(); + for(const item of view.items)view=await service.act({action:'approve',item:item.id,confirmNoChange:item.count===0,token:view.token}); vi.spyOn(service.store,'unapprovedExecutionItems').mockImplementation(()=>{throw new GuardRefusal('Invalid continuation prefix.');}); - expect(()=>service.load()).not.toThrow(); - expect(service.load().items.every(item=>item.state==='stale')).toBe(true); + await expect(service.load()).resolves.toBeDefined(); + expect((await service.load()).items.every(item=>item.state==='stale')).toBe(true); expect(service.store.getPlan(config.identity).items.map(item=>item.id)).toEqual(view.plan.items.map(item=>item.id)); }); -it('keeps both sides of a declared rename in scope after manual reassignment',()=>{ +it('keeps both sides of a declared rename in scope after manual reassignment',async ()=>{ const {service,config}=fixture(),identity=config.identity,plan=service.store.getPlan(identity); plan.items=[{...plan.items[0]!,files:[{path:'renamed.ts',kind:'rename',renamed_from:'retry.ts',change:'Rename the implementation.'}],depends_on:[]}]; expect(plan.items.map(item=>item.id)).toEqual(['P1']); @@ -63,109 +64,109 @@ it('keeps both sides of a declared rename in scope after manual reassignment',() writeFileSync(join(config.repository,'renamed.ts'),'export function delay(attempt: number) {\n return Math.min(5000, 200 * 2 ** attempt);\n}\n'); fixtureGit(config.repository,'add','-A'); fixtureGit(config.repository,'commit','-m','Rename retry implementation'); - let view=service.load();const segmentPath=(segment:typeof view.segments[number]):string=>{ + let view=await service.load();const segmentPath=(segment:typeof view.segments[number]):string=>{ const path=segment.operation==='-'?(segment.oldPath??segment.path):segment.path;return path??''; }; const candidates=view.segments.filter(segment=>segment.row==='Unplanned'&&segment.kind==='text'&&(segment.operation==='-'||segment.operation==='+')&&['retry.ts','renamed.ts'].includes(segmentPath(segment))); expect(new Set(candidates.map(segment=>segment.operation))).toEqual(new Set(['-','+'])); expect(new Set(candidates.map(segmentPath))).toEqual(new Set(['retry.ts','renamed.ts'])); - for(const candidate of candidates){view=service.act({action:'assign',key:candidate.key,item:'P1',token:view.token});expect(view.segments.find(segment=>segment.key===candidate.key)?.scope).toBe('in-scope');} + for(const candidate of candidates){view=await service.act({action:'assign',key:candidate.key,item:'P1',token:view.token});expect(view.segments.find(segment=>segment.key===candidate.key)?.scope).toBe('in-scope');} },30000); -it('persists bounded per-item notes without creating a plan revision',()=>{ - const {service,config}=fixture();const view=service.load();service.act({action:'note',item:'P1',kind:'question',text:'Why this limit?',token:view.token}); - const reopened=new ReviewService(config);services.push(reopened);expect(reopened.load().notes[0]!.text).toBe('Why this limit?');expect(reopened.load().plan.revision).toBe(1); - expect(()=>service.act({action:'note',item:'P2',kind:'change',text:'x'.repeat(4001),token:service.load().token})).toThrow(/Invalid/); +it('persists bounded per-item notes without creating a plan revision',async ()=>{ + const {service,config}=fixture();const view=await service.load();await service.act({action:'note',item:'P1',kind:'question',text:'Why this limit?',token:view.token}); + const reopened=new ReviewService(config);services.push(reopened);expect((await reopened.load()).notes[0]!.text).toBe('Why this limit?');expect((await reopened.load()).plan.revision).toBe(1); + await expect(service.act({action:'note',item:'P2',kind:'change',text:'x'.repeat(4001),token:(await service.load()).token})).rejects.toThrow(/Invalid/); }); -it('migrates a v1 database without losing its plans or ledger',()=>{ +it('migrates a v1 database without losing its plans or ledger',async ()=>{ const {service,config}=fixture();service.close();services.splice(services.indexOf(service),1); const db=new DatabaseSync(config.database);db.exec('ALTER TABLE plans DROP COLUMN review_version; DROP TABLE review_notes; DROP TABLE app_settings; ALTER TABLE requests DROP COLUMN reason; ALTER TABLE requests DROP COLUMN snapshot_id; PRAGMA user_version=1;');db.close(); - const migrated=new ReviewService(config);services.push(migrated);expect(migrated.load().plan.revision).toBe(1);expect(migrated.store.getLedger(config.identity)).toHaveLength(2);expect(migrated.load().notes).toEqual([]); + const migrated=new ReviewService(config);services.push(migrated);expect((await migrated.load()).plan.revision).toBe(1);expect(migrated.store.getLedger(config.identity)).toHaveLength(2);expect((await migrated.load()).notes).toEqual([]); }); -it('rejects a concurrent store review edit through the atomic review counter',()=>{ - const {service,config}=fixture();const other=new ReviewService(config);services.push(other);const view=service.load(); +it('rejects a concurrent store review edit through the atomic review counter',async ()=>{ + const {service,config}=fixture();const other=new ReviewService(config);services.push(other);const view=await service.load(); other.store.addReviewNote(config.identity,view.expected,'P1','change','Please explain'); expect(()=>service.store.saveReview(config.identity,view.expected,[],[])).toThrow(/Stale/); }); -it('requires every item to be reviewed again after a queued head is replaced',()=>{ - const {service,config}=fixture();let view=service.load(); - service.store.saveReview(config.identity,view.expected,view.items.map(item=>approveItem(view.plan,view.segments,item.id,config.identity,item.count===0)),[]);view=service.load(); +it('requires every item to be reviewed again after a queued head is replaced',async ()=>{ + const {service,config}=fixture();let view=await service.load(); + service.store.saveReview(config.identity,view.expected,view.items.map(item=>approveItem(view.plan,view.segments,item.id,config.identity,item.count===0)),[]);view=await service.load(); const attempt=service.store.beginMergeAttempt(config.identity,{...view.expected,reviewVersion:view.expected.reviewVersion!},view.snapshot.head);service.store.queueMergeAttempt(config.identity,attempt.id,'https://github.example/pr/24');service.store.finishMergeAttempt(config.identity,attempt.id,{state:'failed',reason:'The pull request head changed after review.',requiresFreshReview:true}); fixtureGit(config.repository,'commit','--allow-empty','-m','Replace reviewed head'); - view=service.load();expect(view.items.every(item=>item.state==='stale'&&item.reasons.includes('Pull request snapshot changed after the queue attempt'))).toBe(true); - const [first,...remaining]=view.items;service.store.saveReview(config.identity,view.expected,[approveItem(view.plan,view.segments,first!.id,config.identity,first!.count===0)],[]);view=service.load();expect(view.items.find(item=>item.id===first!.id)?.state).toBe('approved');expect(view.items.filter(item=>item.id!==first!.id).every(item=>item.state==='stale')).toBe(true); - service.store.saveReview(config.identity,view.expected,remaining.map(item=>approveItem(view.plan,view.segments,item.id,config.identity,item.count===0)),[]);view=service.load(); + view=await service.load();expect(view.items.every(item=>item.state==='stale'&&item.reasons.includes('Pull request snapshot changed after the queue attempt'))).toBe(true); + const [first,...remaining]=view.items;service.store.saveReview(config.identity,view.expected,[approveItem(view.plan,view.segments,first!.id,config.identity,first!.count===0)],[]);view=await service.load();expect(view.items.find(item=>item.id===first!.id)?.state).toBe('approved');expect(view.items.filter(item=>item.id!==first!.id).every(item=>item.state==='stale')).toBe(true); + service.store.saveReview(config.identity,view.expected,remaining.map(item=>approveItem(view.plan,view.segments,item.id,config.identity,item.count===0)),[]);view=await service.load(); expect(view.items.every(item=>item.state==='approved')).toBe(true); },30000); -it('marks unchanged items stale after a same-snapshot plan amendment',()=>{ - const {service,config}=fixture();let view=service.load(); - service.store.saveReview(config.identity,view.expected,view.items.map(item=>approveItem(view.plan,view.segments,item.id,config.identity,item.count===0)),[]);view=service.load(); +it('marks unchanged items stale after a same-snapshot plan amendment',async ()=>{ + const {service,config}=fixture();let view=await service.load(); + service.store.saveReview(config.identity,view.expected,view.items.map(item=>approveItem(view.plan,view.segments,item.id,config.identity,item.count===0)),[]);view=await service.load(); const attempt=service.store.beginMergeAttempt(config.identity,{...view.expected,reviewVersion:view.expected.reviewVersion!},view.snapshot.head);service.store.queueMergeAttempt(config.identity,attempt.id,'https://github.example/pr/24');service.store.finishMergeAttempt(config.identity,attempt.id,{state:'removed',reason:'Checks failed.'}); const amended={...view.plan,summary:'Amended review requirements'};service.store.importRevision(JSON.stringify(amended),'json',{identity:config.identity,issue:amended.issue,baseEntries:['retry.ts','README.md','run.sh'].map(path=>({path,kind:'file' as const})),pathKey:path=>path,allowedCommands:[]},amended.revision); - view=service.load();expect(view.items.every(item=>item.state==='stale'&&item.reasons.includes('Plan revision changed after the queue attempt'))).toBe(true); - const first=view.items[0]!;view=service.act({action:'approve',item:first.id,confirmNoChange:first.count===0,token:view.token});expect(view.items.find(item=>item.id===first.id)?.state).toBe('approved'); + view=await service.load();expect(view.items.every(item=>item.state==='stale'&&item.reasons.includes('Plan revision changed after the queue attempt'))).toBe(true); + const first=view.items[0]!;view=await service.act({action:'approve',item:first.id,confirmNoChange:first.count===0,token:view.token});expect(view.items.find(item=>item.id===first.id)?.state).toBe('approved'); }); -it('gives each stale state its own stale key, including states whose segments and reasons repeat',()=>{ - const {service,config}=fixture();let view=service.load();const item=(id:string)=>view.items.find(value=>value.id===id)!; +it('gives each stale state its own stale key, including states whose segments and reasons repeat',async ()=>{ + const {service,config}=fixture();let view=await service.load();const item=(id:string)=>view.items.find(value=>value.id===id)!; const own=(id:string)=>view.segments.filter(segment=>segment.row===id).map(segment=>segment.key); expect(view.items.every(value=>value.staleKey===null)).toBe(true); - view=service.act({action:'approve',item:'P1',token:view.token});view=service.act({action:'approve',item:'P3',confirmNoChange:true,token:view.token});expect(item('P1').staleKey).toBeNull(); + view=await service.act({action:'approve',item:'P1',token:view.token});view=await service.act({action:'approve',item:'P3',confirmNoChange:true,token:view.token});expect(item('P1').staleKey).toBeNull(); const unplanned=()=>view.segments.find(segment=>segment.row==='Unplanned')!.key; - view=service.act({action:'assign',key:unplanned(),item:'P1',token:view.token}); + view=await service.act({action:'assign',key:unplanned(),item:'P1',token:view.token}); const first={p1:item('P1').staleKey,p3:item('P3').staleKey,p3Reasons:item('P3').reasons}; expect(item('P1').state).toBe('stale');expect(first.p1).toMatch(/^[0-9a-f]{64}$/);expect(item('P3').state).toBe('stale');expect(first.p3).toMatch(/^[0-9a-f]{64}$/); - view=service.load();expect(item('P1').staleKey).toBe(first.p1);expect(item('P3').staleKey).toBe(first.p3); + view=await service.load();expect(item('P1').staleKey).toBe(first.p1);expect(item('P3').staleKey).toBe(first.p3); // P1 changes again: P3's own segments and reasons stay the same, but its dependency's stale state is new. - view=service.act({action:'assign',key:unplanned(),item:'P1',token:view.token}); + view=await service.act({action:'assign',key:unplanned(),item:'P1',token:view.token}); const second={p1:item('P1').staleKey,p1Segments:own('P1'),p1Reasons:item('P1').reasons}; expect(second.p1).not.toBe(first.p1);expect(item('P3').reasons).toEqual(first.p3Reasons);expect(item('P3').staleKey).not.toBe(first.p3); // Re-approving P3 clears its execution reason, but P1's stale dependency keeps P3 stale with a new key. - const p3Before=item('P3').staleKey;view=service.act({action:'approve',item:'P3',confirmNoChange:true,token:view.token}); + const p3Before=item('P3').staleKey;view=await service.act({action:'approve',item:'P3',confirmNoChange:true,token:view.token}); expect(item('P3').state).toBe('stale');expect(item('P3').reasons).toEqual(['Depends on P1, which changed']);expect(item('P3').staleKey).not.toBe(p3Before); // P1 is approved at its current code, then an ambiguous change it shares makes it stale with the same own segments and reasons as before. - view=service.act({action:'approve',item:'P1',token:view.token});expect(item('P1').staleKey).toBeNull(); + view=await service.act({action:'approve',item:'P1',token:view.token});expect(item('P1').staleKey).toBeNull(); // P1 adds a line to an existing file and P2 then edits that same line, so the line is ambiguous between them. const readme=join(config.repository,'README.md'),original=readFileSync(readme,'utf8'); const commit=(line:string,message:string)=>{writeFileSync(readme,`${original}${line}\n`);fixtureGit(config.repository,'commit','-am',message);return fixtureGit(config.repository,'rev-parse','HEAD');}; const byP1=commit('Shared note.','P1 shared line'),byP2=commit('Shared note, revised.','P2 shared line'),snapshot=service.store.getSnapshot(config.identity); service.store.recordHistory(config.identity,{revision:view.plan.revision,snapshotId:snapshot.id},snapshot.base,byP2,[{sha:byP1,owner:'P1',origin:'owned',sourceSha:null},{sha:byP2,owner:'P2',origin:'owned',sourceSha:null}]); - view=service.load();expect(view.segments.some(segment=>segment.row==='Ambiguous'&&segment.owners.includes('P1'))).toBe(true); + view=await service.load();expect(view.segments.some(segment=>segment.row==='Ambiguous'&&segment.owners.includes('P1'))).toBe(true); expect(item('P1').state).toBe('stale');expect(own('P1')).toEqual(second.p1Segments);expect(item('P1').reasons).toEqual(second.p1Reasons); expect(item('P1').staleKey).not.toBe(second.p1);expect(item('P1').staleKey).not.toBe(first.p1); },30000); -it('gives a still-stale item a new stale key when it gains an ambiguous change',()=>{ - const {service,config}=fixture();let view=service.load();const item=(id:string)=>view.items.find(value=>value.id===id)!; - for(const id of ['P1','P2','P3'])view=service.act({action:'approve',item:id,confirmNoChange:item(id).count===0,token:view.token});view=service.act({action:'assign',key:view.segments.find(segment=>segment.row==='Unplanned')!.key,item:'P1',token:view.token}); +it('gives a still-stale item a new stale key when it gains an ambiguous change',async ()=>{ + const {service,config}=fixture();let view=await service.load();const item=(id:string)=>view.items.find(value=>value.id===id)!; + for(const id of ['P1','P2','P3'])view=await service.act({action:'approve',item:id,confirmNoChange:item(id).count===0,token:view.token});view=await service.act({action:'assign',key:view.segments.find(segment=>segment.row==='Unplanned')!.key,item:'P1',token:view.token}); const before={key:item('P1').staleKey,reasons:item('P1').reasons,own:view.segments.filter(segment=>segment.row==='P1').map(segment=>segment.key)};expect(item('P1').state).toBe('stale'); const readme=join(config.repository,'README.md'),original=readFileSync(readme,'utf8'); const commit=(line:string,message:string)=>{writeFileSync(readme,`${original}${line}\n`);fixtureGit(config.repository,'commit','-am',message);return fixtureGit(config.repository,'rev-parse','HEAD');}; const byP1=commit('Shared note.','P1 shared line'),byP2=commit('Shared note, revised.','P2 shared line'),snapshot=service.store.getSnapshot(config.identity); service.store.recordHistory(config.identity,{revision:view.plan.revision,snapshotId:snapshot.id},snapshot.base,byP2,[{sha:byP1,owner:'P1',origin:'owned',sourceSha:null},{sha:byP2,owner:'P2',origin:'owned',sourceSha:null}]); - view=service.load();expect(view.segments.some(segment=>segment.row==='Ambiguous'&&segment.owners.includes('P1'))).toBe(true); + view=await service.load();expect(view.segments.some(segment=>segment.row==='Ambiguous'&&segment.owners.includes('P1'))).toBe(true); expect(view.segments.filter(segment=>segment.row==='P1').map(segment=>segment.key)).toEqual(before.own);expect(item('P1').reasons).toEqual(before.reasons);expect(item('P1').staleKey).not.toBe(before.key); // P2 re-commits the shared line with CRLF only. The rendered approval ignores line endings, but the execution gate also // owns the changed snapshot, so the stale state gets a new key even though the reviewed segment is equivalent. const unchanged=item('P1').staleKey;writeFileSync(readme,`${original}Shared note, revised.\r\n`);fixtureGit(config.repository,'commit','-am','P2 line endings');const crlf=fixtureGit(config.repository,'rev-parse','HEAD'),endings=service.store.getSnapshot(config.identity); service.store.recordHistory(config.identity,{revision:view.plan.revision,snapshotId:endings.id},endings.base,crlf,[{sha:crlf,owner:'P2',origin:'owned',sourceSha:null}]); - const lf=view.segments.find(segment=>segment.row==='Ambiguous'&&segment.path==='README.md')!;view=service.load();const ending=view.segments.find(segment=>segment.row==='Ambiguous'&&segment.path==='README.md')!; + const lf=view.segments.find(segment=>segment.row==='Ambiguous'&&segment.path==='README.md')!;view=await service.load();const ending=view.segments.find(segment=>segment.row==='Ambiguous'&&segment.path==='README.md')!; expect(ending.content).toContain('\r');expect(ending.owners).toEqual(lf.owners);expect(item('P1').staleKey).not.toBe(unchanged); // P3 then edits the same line back to P2's text: the segment keeps its choice key but gains an owner, so the stale state is new. const shared=()=>view.segments.find(segment=>segment.row==='Ambiguous'&&segment.path==='README.md')!,second={key:item('P1').staleKey,segment:shared()}; writeFileSync(readme,`${original}Shared note, draft.\n`);fixtureGit(config.repository,'commit','-am','P3 draft');const byP3=fixtureGit(config.repository,'rev-parse','HEAD'); const back=commit('Shared note, revised.','P3 restores the line'),current=service.store.getSnapshot(config.identity); service.store.recordHistory(config.identity,{revision:view.plan.revision,snapshotId:current.id},current.base,back,[{sha:byP3,owner:'P3',origin:'owned',sourceSha:null},{sha:back,owner:'P3',origin:'owned',sourceSha:null}]); - view=service.load();expect(shared().key).toBe(second.segment.key);expect(shared().owners).not.toEqual(second.segment.owners); + view=await service.load();expect(shared().key).toBe(second.segment.key);expect(shared().owners).not.toEqual(second.segment.owners); expect(view.segments.filter(segment=>segment.row==='P1').map(segment=>segment.key)).toEqual(before.own);expect(item('P1').reasons).toEqual(before.reasons);expect(item('P1').staleKey).not.toBe(second.key); // The shared segment belongs to three stale items, yet one load serializes it once. - vi.mocked(reviewedSegment).mockClear();view=service.load(); + vi.mocked(reviewedSegment).mockClear();view=await service.load(); expect(shared().owners.filter(owner=>owner!==null&&item(owner).state==='stale').length).toBeGreaterThanOrEqual(3); expect(vi.mocked(reviewedSegment).mock.calls.filter(([segment])=>(segment as {key?:string}).key===shared().key)).toHaveLength(1); },30000); -it('gives each replaced head after a queue attempt its own stale key',()=>{ - const {service,config}=fixture();let view=service.load(); - service.store.saveReview(config.identity,view.expected,view.items.map(item=>approveItem(view.plan,view.segments,item.id,config.identity,item.count===0)),[]);view=service.load(); +it('gives each replaced head after a queue attempt its own stale key',async ()=>{ + const {service,config}=fixture();let view=await service.load(); + service.store.saveReview(config.identity,view.expected,view.items.map(item=>approveItem(view.plan,view.segments,item.id,config.identity,item.count===0)),[]);view=await service.load(); const attempt=service.store.beginMergeAttempt(config.identity,{...view.expected,reviewVersion:view.expected.reviewVersion!},view.snapshot.head);service.store.queueMergeAttempt(config.identity,attempt.id,'https://github.example/pr/24');service.store.finishMergeAttempt(config.identity,attempt.id,{state:'failed',reason:'The pull request head changed after review.',requiresFreshReview:true}); - const replace=()=>{fixtureGit(config.repository,'commit','--allow-empty','-m','Replace reviewed head');return service.load();}; - const first=replace(),second=replace(),p3=(value:typeof view)=>value.items.find(item=>item.id==='P3')!; + const replace=async ()=>{fixtureGit(config.repository,'commit','--allow-empty','-m','Replace reviewed head');return await service.load();}; + const first=await replace(),second=await replace(),p3=(value:typeof view)=>value.items.find(item=>item.id==='P3')!; expect(p3(first).state).toBe('stale');expect(p3(second).reasons).toEqual(p3(first).reasons);expect(p3(second).staleKey).not.toBe(p3(first).staleKey); },30000); it('refuses no-change confirmation while the item still owns ambiguous changes',async()=>{ @@ -174,33 +175,33 @@ it('refuses no-change confirmation while the item still owns ambiguous changes', fixtureGit(config.repository,'commit','-am','P2 changes retry'); const head=fixtureGit(config.repository,'rev-parse','HEAD');const snapshot=service.store.getSnapshot(config.identity); service.store.recordHistory(config.identity,{revision:1,snapshotId:snapshot.id},snapshot.base,head,[{sha:head,owner:'P2',origin:'owned',sourceSha:null}]); - const view=service.load();expect(view.items[0]!.count).toBe(0);expect(view.segments.some(s=>s.row==='Ambiguous'&&s.owners.includes('P1'))).toBe(true); - expect(()=>service.act({action:'approve',item:'P1',confirmNoChange:true,token:view.token})).toThrow(/ambiguous/i); + const view=await service.load();expect(view.items[0]!.count).toBe(0);expect(view.segments.some(s=>s.row==='Ambiguous'&&s.owners.includes('P1'))).toBe(true); + await expect(service.act({action:'approve',item:'P1',confirmNoChange:true,token:view.token})).rejects.toThrow(/ambiguous/i); expect(view.items[1]!.count).toBeGreaterThan(0); - expect(()=>service.act({action:'approve',item:'P2',token:view.token})).toThrow(/ambiguous/i); + await expect(service.act({action:'approve',item:'P2',token:view.token})).rejects.toThrow(/ambiguous/i); }); -it('anchors notes to server-owned code and rejects invalid ranges and cross-item references',()=>{ - const {service,config}=fixture();let view=service.load();const segment=view.segments.find(s=>s.row==='P1'&&s.kind!=='file'&&s.operation==='+')!; +it('anchors notes to server-owned code and rejects invalid ranges and cross-item references',async ()=>{ + const {service,config}=fixture();let view=await service.load();const segment=view.segments.find(s=>s.row==='P1'&&s.kind!=='file'&&s.operation==='+')!; const start=segment.newLine!; - expect(()=>service.act({action:'note',item:'P2',kind:'question',text:'Why?',reference:{key:segment.key,start,end:start},token:view.token})).toThrow(/Invalid snippet/); - expect(()=>service.act({action:'note',item:'P1',kind:'question',text:'Why?',reference:{key:segment.key,start:0,end:start},token:view.token})).toThrow(/changed block/); - view=service.act({action:'note',item:'P1',kind:'question',text:'Why?',reference:{key:segment.key,start,end:start,text:'forged',path:'forged'},token:view.token}); + await expect(service.act({action:'note',item:'P2',kind:'question',text:'Why?',reference:{key:segment.key,start,end:start},token:view.token})).rejects.toThrow(/Invalid snippet/); + await expect(service.act({action:'note',item:'P1',kind:'question',text:'Why?',reference:{key:segment.key,start:0,end:start},token:view.token})).rejects.toThrow(/changed block/); + view=await service.act({action:'note',item:'P1',kind:'question',text:'Why?',reference:{key:segment.key,start,end:start,text:'forged',path:'forged'},token:view.token}); const ref=view.notes[0]!.reference!;expect(ref.text).toBe(segment.content.split('\n')[0]);expect(ref.path).toBe(segment.path);expect(ref.head).toBe(view.snapshot.head);expect(view.notes[0]!.outdated).toBe(false); - const reopened=new ReviewService(config);services.push(reopened);expect(reopened.load().notes[0]!.reference).toEqual(ref); + const reopened=new ReviewService(config);services.push(reopened);expect((await reopened.load()).notes[0]!.reference).toEqual(ref); }); it('preserves removed-side snippets and marks their references outdated after HEAD changes',async()=>{ - const {service,config}=fixture();let view=service.load();const segment=view.segments.find(s=>s.row==='P1'&&s.kind!=='file'&&s.operation==='-')!; - view=service.act({action:'note',item:'P1',kind:'change',text:'Keep this?',reference:{key:segment.key,start:segment.oldLine,end:segment.oldLine},token:view.token}); + const {service,config}=fixture();let view=await service.load();const segment=view.segments.find(s=>s.row==='P1'&&s.kind!=='file'&&s.operation==='-')!; + view=await service.act({action:'note',item:'P1',kind:'change',text:'Keep this?',reference:{key:segment.key,start:segment.oldLine,end:segment.oldLine},token:view.token}); const ref=view.notes[0]!.reference!;expect(ref.side).toBe('old'); fixtureGit(config.repository,'commit','--allow-empty','-m','new revision'); - view=service.load();expect(view.notes[0]!.outdated).toBe(true);expect(view.notes[0]!.reference).toEqual(ref); + view=await service.load();expect(view.notes[0]!.outdated).toBe(true);expect(view.notes[0]!.reference).toEqual(ref); }); -it('keeps observing the user\'s HEAD for a task the runner has not committed to, owned ledger entries or not (#87)', () => { +it('keeps observing the user\'s HEAD for a task the runner has not committed to, owned ledger entries or not (#87)', async () => { // The demo's ledger already owns its commits (a reviewed branch in the user's repository), but no attempt made them. const {service,config}=fixture(); expect(service.reviewRepository()).toEqual({path:config.repository,runnerOwned:false}); fixtureGit(config.repository,'commit','--allow-empty','-m','User work'); - expect(service.load().snapshot.head).toBe(fixtureGit(config.repository,'rev-parse','HEAD')); + expect((await service.load()).snapshot.head).toBe(fixtureGit(config.repository,'rev-parse','HEAD')); }); it('uses a configured runner repository before the first changed runner commit', () => { const {service,config}=fixture();service.config.runnerRepository=config.repository; @@ -211,13 +212,13 @@ it('reads the base tree from the authoritative runner repository', () => { service.config={...config,repository:join(config.repository,'missing'),runnerRepository:config.repository}; expect(service.planContext().baseEntries.length).toBeGreaterThan(0); }); -it('keeps a legacy malformed command visible and blocked so the plan can be amended', () => { +it('keeps a legacy malformed command visible and blocked so the plan can be amended', async () => { const {service,config}=fixture();service.close();services.splice(services.indexOf(service),1); const db=new DatabaseSync(config.database),key=identityKey(config.identity); const row=db.prepare('SELECT data FROM revisions WHERE key=? AND revision=1').get(key)!; const plan=JSON.parse(row.data as string);plan.items[0].acceptance=[{type:'cmd',text:`node "${String.fromCharCode(0xd800)}"`}]; db.prepare('UPDATE revisions SET data=? WHERE key=? AND revision=1').run(JSON.stringify(plan),key);db.close(); - const reopened=new ReviewService(config);services.push(reopened);const view=reopened.load(); + const reopened=new ReviewService(config);services.push(reopened);const view=await reopened.load(); expect(view.items[0]!.checks.tests).toBe('✕ Invalid command'); expect(view.items[0]!.state).not.toBe('approved'); const repaired=structuredClone(view.plan);repaired.items[0]!.acceptance=[{type:'check',text:'Review manually.'}]; diff --git a/test/runner-planning-feedback.test.ts b/test/runner-planning-feedback.test.ts index 233a5f30..fa451f7c 100644 --- a/test/runner-planning-feedback.test.ts +++ b/test/runner-planning-feedback.test.ts @@ -66,7 +66,7 @@ describe('feedback from review actions', () => { }); it('records a segment choice with a fixed-size source, and links a later choice for the same source', async () => { const { app, config, close } = await serve(); - const loaded = app.service.load(), index = loaded.segments.findIndex(s => s.row === 'Unplanned'); + const loaded = await app.service.load(), index = loaded.segments.findIndex(s => s.row === 'Unplanned'); const sourceRef = `choice:${createHash('sha256').update(choiceKeys(loaded.segments, config.identity)[index]!).digest('hex')}`; const accepted = await api(app, 'POST', '/api/action', { action: 'accept', key: loaded.segments[index]!.key, token: loaded.token, actionId: randomUUID() }); expect(accepted.status).toBe(200); diff --git a/test/runner-production.test.ts b/test/runner-production.test.ts index a15f6da4..1b0056b7 100644 --- a/test/runner-production.test.ts +++ b/test/runner-production.test.ts @@ -114,7 +114,7 @@ describe('runner startup', () => { for (const commit of new Set([snapshot.base, snapshot.head])) expect(existsSync(join(service.config.runnerRepository!, 'refs', 'codeboost', 'remote-commits', commit))).toBe(true); // Selecting a freshly-created runner repository must not make the initial review unreadable before any attempt runs. - expect(service.load().snapshot).toMatchObject({ base: snapshot.base, head: snapshot.head }); + expect((await service.load()).snapshot).toMatchObject({ base: snapshot.base, head: snapshot.head }); // Per database, under its runner token: another database sharing the root keeps its own folder. expect(statSync(join(root, 'runner', assembly.deps.runnerOwner, 'diagnostics')).mode & 0o777).toBe(0o700); expect(existsSync(join(root, 'runner', 'diagnostics'))).toBe(false); diff --git a/test/runner-publishing.test.ts b/test/runner-publishing.test.ts index 6e0bdc0b..07ca51a8 100644 --- a/test/runner-publishing.test.ts +++ b/test/runner-publishing.test.ts @@ -241,7 +241,7 @@ async function serve(w: World, options: { before?: (service: ReviewService) => v app = await startServer(config, 0, undefined, undefined, 2_000, options.issueGateway, undefined, undefined, async service => { const identity = service.config.identity, task = service.store.getTask(identity), plan = service.store.getPlan(identity); if (task.status !== 'merged' && task.status !== 'cancelled' && service.store.unapprovedExecutionItems(identity, plan.revision).length) { - const review = service.load(); + const review = await service.load(); service.store.saveReview(identity, review.expected, review.items.map(item => approveItem(review.plan, review.segments, item.id, identity, item.count === 0)), []); } @@ -787,7 +787,7 @@ describe('publishing a finished task (#103)', () => { expect(w.github.head(branch)).toBe(head); expect(w.github.prs).toEqual([expect.objectContaining({ open: true, draft: false })]); // Review shows its change in the Unplanned row, and the merge gate blocks until a person assigns or accepts it. - const view = app.service.load(); + const view = await app.service.load(); expect(view.segments.filter(segment => segment.row === 'Unplanned').map(segment => segment.path)).toContain('debug.log'); // The gate inspects the published PR (#121), which GitHub shows as the task's. const pr = store.taskPullRequests(identity).find(record => record.state === 'opened')!; diff --git a/test/runner-shutdown.test.ts b/test/runner-shutdown.test.ts index 15cb4124..3ae10b30 100644 --- a/test/runner-shutdown.test.ts +++ b/test/runner-shutdown.test.ts @@ -5,7 +5,7 @@ import { request as httpRequest } from 'node:http'; import { randomUUID } from 'node:crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createDemo } from '../scripts/demo.ts'; -import { MAX_REQUEST_BYTES, RUNNER_NOT_IN_DEMO, startServer } from '../web/server.ts'; +import { MAX_REQUEST_BYTES, MAX_SHUTDOWN_DRAIN_MS, RUNNER_NOT_IN_DEMO, startServer } from '../web/server.ts'; import { Store } from '../runner/store.ts'; import { ReviewService } from '../runner/review.ts'; import { MergeCoordinator } from '../runner/merge.ts'; @@ -86,7 +86,7 @@ describe('merge coordinator after the gate closes', () => { const store = new Store(':memory:'); cleanups.push(() => store.close()); const plan = { schema_version: 1 as const, revision: 1, issue: 1, summary: 'M', questions: [], items: [{ id: 'P1', title: 'M', intent: 'M', files: [{ path: 'a', kind: 'edit' as const, renamed_from: null, change: 'M' }], acceptance: [{ type: 'check' as const, text: 'M' }], depends_on: [] }] }; store.createPlan(JSON.stringify(plan), 'json', { identity, issue: 1, baseEntries: [{ path: 'a', kind: 'file' }], pathKey: p => p, allowedCommands: [] }, oid(1), oid(2)); - const view = { items: [{ id: 'P1', state: 'approved', outside: [], acceptance: [], checks: {} }], plan: { revision: 1 }, segments: [], notes: [], snapshot: { id: store.getSnapshot(identity).id, base: oid(1), head: oid(2) }, token: 't', expected: { revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) } } as unknown as ReturnType; + const view = { items: [{ id: 'P1', state: 'approved', outside: [], acceptance: [], checks: {} }], plan: { revision: 1 }, segments: [], notes: [], snapshot: { id: store.getSnapshot(identity).id, base: oid(1), head: oid(2) }, token: 't', expected: { revision: 1, snapshotId: store.getSnapshot(identity).id, reviewVersion: store.reviewVersion(identity) } } as unknown as Awaited>; const service = { store, config: { identity }, load: vi.fn(() => view) } as unknown as ReviewService; const state: RemoteMergeState = { base: oid(1), head: oid(2), pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, atomicBaseGuard: true, mergeQueue: false, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false, ...remote }; const gateway: MergeGateway & MergeQueueGateway = { @@ -132,7 +132,9 @@ describe('merge coordinator after the gate closes', () => { // Each demo review action runs git and takes about a second, so these exceed Vitest's 5 s default under a full run. describe('server shutdown', { timeout: 15_000 }, () => { it('lets an admitted request finish during the drain (AGENTS.md: drain admitted requests)', async () => { - const { app, config, close } = await serve(); + // Review loads no longer block the server, so the drain limit really ends the request: give it the production + // limit, which this request's two review loads fit inside even on a loaded machine. + const { app, config, close } = await serve(undefined, undefined, MAX_SHUTDOWN_DRAIN_MS); const view = (await api(app, 'GET', '/api/review')).body; const sent = partialPost(app, '/api/action', { action: 'note', kind: 'change', item: 'P1', text: 'admitted note', token: view.token, actionId: randomUUID() }); await tick(); diff --git a/test/runner-start.test.ts b/test/runner-start.test.ts index 7123e19b..3bc16610 100644 --- a/test/runner-start.test.ts +++ b/test/runner-start.test.ts @@ -27,14 +27,14 @@ type App = Awaited>; * A server with a runner whose preparation always fails: an admitted item ends `failed` without an agent, which is * enough to see what start and resume admit. `before` shapes the Store before the runner exists, as recovery would. */ -async function serve(options: { kinds?: AttemptKind[]; approve?: boolean; before?: (service: ReviewService) => void; findings?: (findings: SafetyFindings, service: ReviewService) => void; +async function serve(options: { kinds?: AttemptKind[]; approve?: boolean; before?: (service: ReviewService) => void | Promise; findings?: (findings: SafetyFindings, service: ReviewService) => void; issueGateway?: IssueTrustGateway } = {}) { const root = mkdtempSync(join(tmpdir(), 'codeboost-start-')); roots.push(root); const demo = createDemo(join(root, 'demo')); const config = options.issueGateway ? { ...demo, demo: false, github: { repository: options.issueGateway.repository, issue: 3, pullRequest: 1 } } : demo; const app = await startServer(config, 0, undefined, undefined, 2_000, options.issueGateway, undefined, undefined, async service => { - if (options.approve !== false) approvePlan(service); - options.before?.(service); + if (options.approve !== false) await approvePlan(service); + await options.before?.(service); const deps: RunnerDeps = { runnerOwner: OWNER, kinds: options.kinds ?? ['execute'], prepare: async () => { throw new Error('no agent here'); }, cleanupPreparation: async () => undefined, start: () => { throw new Error('no agent here'); }, validate: () => null }; const sources: ExecutionSources = { planContext: () => service.planContext(), checkpointContext: () => service.planContext(), @@ -57,8 +57,8 @@ async function act(app: App, action: string, request: { expectedStateVersion?: n return { status: response.status, body: await response.json() as Record }; } -function approvePlan(service: ReviewService) { - const view = service.load(); +async function approvePlan(service: ReviewService) { + const view = await service.load(); service.store.saveReview(service.config.identity, view.expected, view.items.map(item => approveItem(view.plan, view.segments, item.id, service.config.identity, item.count === 0)), []); } @@ -174,7 +174,7 @@ describe('start (#91 part 2)', () => { store.saveReview(identity, { revision: plan.revision, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, [], [{ key: 'later-choice', action: 'assign', item: plan.items[0]!.id }]); expect(await view(app)).toMatchObject({ startable: false }); - approvePlan(app.service); + await approvePlan(app.service); expect(await view(app)).toMatchObject({ startable: true }); }); it('moves a task in review to queued and admits its first item, in one action', async () => { @@ -463,7 +463,7 @@ describe('start and resume refusals and races (#91 part 2)', () => { expect((await act(app, 'resume')).body.error).toMatch(/Every item of this plan has run/); }); it('clears a recovery requeue when an amended suffix removes the interrupted final item', async () => { - const { app, identity, store } = await serve({ before: service => { + const { app, identity, store } = await serve({ before: async service => { const s = service.store, id = service.config.identity; const plan = s.getPlan(id), firstTwo = plan.items.slice(0, 2).map(item => item.id), firstSnapshot = s.getSnapshot(id); s.transitionTask(id, s.getTask(id).stateVersion, 'queued'); @@ -483,7 +483,7 @@ describe('start and resume refusals and races (#91 part 2)', () => { amended.items[0]!.files.push({ path: 'extra.ts', kind: 'add', renamed_from: null, change: 'Declare observed output' }); s.importRevision(JSON.stringify({ ...amended, revision: amended.revision + 1 }), 'json', service.planContext(), amended.revision); const current = s.getPlan(id), snapshot = s.getSnapshot(id); - approvePlan(service); + await approvePlan(service); const checkpointContext = { ...service.planContext(), baseEntries: checkpoint.baseEntries }; service.planContextAt = () => ({ ...checkpointContext, baseEntries: entries }); s.approveContinuation(id, checkpoint.id, { revision: current.revision, snapshotId: snapshot.id, reviewVersion: s.reviewVersion(id) }, @@ -499,7 +499,7 @@ describe('start and resume refusals and races (#91 part 2)', () => { amended.items = amended.items.slice(0, 2); s.importRevision(JSON.stringify({ ...amended, revision: amended.revision + 1 }), 'json', service.planContext(), amended.revision); const current = s.getPlan(id), snapshot = s.getSnapshot(id); - approvePlan(service); + await approvePlan(service); const entries = checkpoint.baseEntries; const checkpointContext = { ...service.planContext(), baseEntries: checkpoint.baseEntries }; service.planContextAt = () => ({ ...checkpointContext, baseEntries: entries }); diff --git a/test/runner-workspace.test.ts b/test/runner-workspace.test.ts index 2eecd1cc..dff74939 100644 --- a/test/runner-workspace.test.ts +++ b/test/runner-workspace.test.ts @@ -98,7 +98,7 @@ describe('real task workspace (#87)', () => { const review = new ReviewService({ database: join(s.root, 'state.sqlite'), repository: s.source, runnerRepository: s.repository.path, identity, pathIdentity: { caseSensitive: true, unicodeNormalization: 'none' } }); try { - const view = review.load(); + const view = await review.load(); expect(view.snapshot.head).toBe(two); // a.ts's lines are P1's own runner commit's. expect(view.segments.filter(segment => segment.path === 'a.ts').map(segment => [segment.row, segment.operation])) @@ -108,7 +108,7 @@ describe('real task workspace (#87)', () => { // Without the runner-owned repository it fails rather than read the user's. const unconfigured = new ReviewService({ database: join(s.root, 'state.sqlite'), repository: s.source, identity, pathIdentity: { caseSensitive: true, unicodeNormalization: 'none' } }); - try { expect(() => unconfigured.load()).toThrow('needs the runner-owned repository'); } finally { unconfigured.close(); } + try { await expect(unconfigured.load()).rejects.toThrow('needs the runner-owned repository'); } finally { unconfigured.close(); } }, 600_000); it('sends a file the repository ignores to needs human, and keeps no commit of it', async () => { @@ -133,7 +133,7 @@ describe('real task workspace (#87)', () => { // The review load reads the runner commit at the recorded head: it does not replace the checkpoint's snapshot. const review = new ReviewService({ database: join(s.root, 'state.sqlite'), repository: s.source, runnerRepository: s.repository.path, identity, pathIdentity: { caseSensitive: true, unicodeNormalization: 'none' } }); - expect(review.load().snapshot.id).toBe(checkpoint.snapshotId); + expect((await review.load()).snapshot.id).toBe(checkpoint.snapshotId); // A person amends the plan to declare the extra file, then approves continuing from the audited checkpoint. const amended = { ...plan, items: [{ ...plan.items[0]!, files: [...plan.items[0]!.files, { path: 'extra.ts', kind: 'add' as const, renamed_from: null, change: 'z' }] }, plan.items[1]!] }; s.store.importRevision(JSON.stringify(amended), 'json', { identity, issue: 1, pathKey: p => p, allowedCommands: [], diff --git a/test/store.test.ts b/test/store.test.ts index fcf945fb..7a274da3 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -359,7 +359,7 @@ it('preserves an approved checkpoint continuation across its validated rewrite l expect(progress).toMatchObject({ completed: ['P1'], head: oid(12), next: 'P2' }); expect(store.continuationApproved(identity, progress!)).toBe(true); }); -it('feeds persisted remapped ownership into the linking engine on real Git history', () => { +it('feeds persisted remapped ownership into the linking engine on real Git history', async () => { const dir = directory(); const git = (...args: string[]) => fixtureGit(dir, ...args); git('init', '-b', 'main'); git('config', 'user.name', 'Test'); git('config', 'user.email', 'test@example.invalid'); git('config', 'commit.gpgsign', 'false'); const commit = () => { git('add', 'a'); git('commit', '-m', 'Change'); return git('rev-parse', 'HEAD'); }; @@ -367,10 +367,10 @@ it('feeds persisted remapped ownership into the linking engine on real Git histo const store = open(join(directory(), 'state.sqlite')); store.createPlan(JSON.stringify(plan()), 'json', context, base, base); store.recordHistory(identity, state(store), base, base, [{ sha: oid(9), owner: 'P1', origin: 'owned', sourceSha: null }]); store.recordRebase(identity, state(store), base, head, [{ oldSha: oid(9), newSha: head }]); - const segments = linkHistory(store.getPlan(identity), readHistory(dir, base, head), store.ownership(identity), p => p); + const segments = linkHistory(store.getPlan(identity), await readHistory(dir, base, head), store.ownership(identity), p => p); expect(segments.length).toBeGreaterThan(0); expect(segments.every(s => s.row === 'P1')).toBe(true); const amended = plan(); amended.items[0]!.id = 'P2'; store.importRevision(JSON.stringify(amended), 'json', context, 1); - const afterRemoval = linkHistory(store.getPlan(identity), readHistory(dir, base, head), store.ownership(identity), p => p); + const afterRemoval = linkHistory(store.getPlan(identity), await readHistory(dir, base, head), store.ownership(identity), p => p); expect(afterRemoval.every(s => s.row === 'Unplanned')).toBe(true); }); it('retains typed file-card approvals/choices while fingerprints detect later metadata and plan changes', () => { diff --git a/web/server.ts b/web/server.ts index 42d705bc..f1a4dc85 100644 --- a/web/server.ts +++ b/web/server.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { randomBytes, timingSafeEqual } from 'node:crypto'; import type { PlanIdentity } from '../core/identity.ts'; -import { ReviewService, type ReviewConfig } from '../runner/review.ts'; +import { ReviewService, type ReviewConfig, type ReviewView } from '../runner/review.ts'; import { Questions, type QuestionAgent } from '../runner/questions.ts'; import { GhMergeGateway, type MergeGateway } from '../github/merge.ts'; import { MERGE_OPERATION_TIMEOUT_MS, MergeCoordinator, MergeNotApplied, MergeOutcomeUnknown } from '../runner/merge.ts'; @@ -138,8 +138,9 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge }); } } catch (error) { service.close(); throw error; } } - const loadReview=()=>{const view=service.load();return {...view,notes:view.notes.map(note=>({...note,answerActive:questions.isRunning(note.id)}))};}; - const load=async(signal?:AbortSignal)=>{const view=loadReview();return {...view,merge:merges?await merges.displayStatus(view,signal):{available:false}};}; + // History reading and linking run in a worker (runner/history-job.ts); `signal` ends with the request at shutdown. + const loadReview=async(signal?:AbortSignal)=>{const view=await service.load({signal});return {...view,notes:view.notes.map(note=>({...note,answerActive:questions.isRunning(note.id)}))};}; + const load=async(signal?:AbortSignal)=>{const view=await loadReview(signal);return {...view,merge:merges?await merges.displayStatus(view,signal):{available:false}};}; const answerStatuses=()=>service.store.getReviewNotes(config.identity) .filter(note=>note.kind==='question') .map(note=>({id:note.id,answer:note.answer,answerActive:questions.isRunning(note.id)})); @@ -586,6 +587,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge res.writeHead(status, { 'Content-Type': contentType }); }; const json = (status: number, value: unknown) => { head(status, 'application/json'); res.end(JSON.stringify(value)); }; + /** Whether this request's review command (or question retry) has been applied or replayed, so an abort after it leaves the outcome unknown. */ + let applied = false; try { if (req.headers.host !== `127.0.0.1:${actualPort}` || (req.headers.origin && req.headers.origin !== origin)) { json(403, { error: 'Local origin required.' }); return; } const path = new URL(req.url ?? '/', origin).pathname; @@ -668,8 +671,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if(planningPath) { json(200, { result: await planningAction(path, input, requestAbort.signal) }); return; } if(path==='/api/settings') {service.store.setQuestionProvider(input.questionProvider);json(200,{questionProvider:service.store.questionProvider()});return;} if(input.action==='retry-question') { - const view=service.load();if(input.token!==view.token)throw new Error('Stale review state. Refresh and retry.'); - questions.start(input.id,view);json(200,await load(requestAbort.signal));return; + const view=await service.load({signal:requestAbort.signal});if(input.token!==view.token)throw new Error('Stale review state. Refresh and retry.'); + questions.start(input.id,view);applied=true;json(200,await load(requestAbort.signal));return; } if(input.action==='merge') { // Every merge click carries its idempotency key; a request without one would bypass replay. @@ -684,30 +687,47 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge if (error instanceof MergeOutcomeUnknown) { json(409,{error:error.message,outcomeUnknown:true}); return; } throw error; } - try { const mergeQueue=merges.queueSnapshot();json(200,{...loadReview(),merge:{...merged.status,queue:mergeQueue},mergeResult:merged.result,mergeQueue,mergeRefreshRequired:false}); } + try { const mergeQueue=merges.queueSnapshot();json(200,{...await loadReview(requestAbort.signal),merge:{...merged.status,queue:mergeQueue},mergeResult:merged.result,mergeQueue,mergeRefreshRequired:false}); } catch { json(200,{mergeResult:merged.result,mergeQueue:null,mergeRefreshRequired:true}); } return; } // Feedback-producing actions need an actionId: the action and its feedback event share one transaction. const feedback = input.action==='accept' || input.action==='assign' || (input.action==='note' && input.kind==='change'); if (feedback) requireAction(input); - let view: ReturnType, replayed = false; + // The command applies to a review loaded before it, synchronously (inside the recorded action when it has an + // action ID); its writes are guarded by that review's state. A saved action replays without loading a review + // to apply to, and a failed load before the command records nothing under its action ID. + let createdNoteId: string | undefined, replayed = false; if (input.actionId !== undefined) { const { actionId, ...request } = input; - const outcome = service.store.userAction(identity, { actionId, kind: `review-${String(input.action).replace(/[^a-z-]/g, '')}`, request }, () => { - const acted = service.act(input, actionId); return { createdNoteId: acted.createdNoteId ?? null }; - }); - replayed = outcome.replayed; - view = { ...service.load(), createdNoteId: outcome.response.createdNoteId ?? undefined }; - } else view=service.act(input); - if(view.createdNoteId && input.kind==='question' && !replayed) { - try {questions.start(view.createdNoteId,view);} catch(error) { - // The saved question remains visible and retryable when capacity is reached. If shutdown began while this - // request was arriving, no agent starts; the question gets a retryable "Server stopped" answer instead. - if (questions.stopping) questions.markStopped(view.createdNoteId,service.load()); + const action = { actionId, kind: `review-${String(input.action).replace(/[^a-z-]/g, '')}`, request }; + const saved = service.store.savedAction<{ createdNoteId: string | null }>(identity, action); + const outcome = saved ? { response: saved.response, replayed: true } : await (async () => { + const current = await service.load({ signal: requestAbort.signal }); + return service.store.userAction(identity, action, () => ({ createdNoteId: service.apply(current, input, actionId) ?? null })); + })(); + replayed = outcome.replayed; createdNoteId = outcome.response.createdNoteId ?? undefined; + } else createdNoteId = service.apply(await service.load({ signal: requestAbort.signal }), input); + applied = true; + const asked = createdNoteId !== undefined && input.kind === 'question' && !replayed ? createdNoteId : null; + let answered = false; + try { + const view: ReviewView & { createdNoteId?: string | undefined } = { ...await service.load({ signal: requestAbort.signal }), createdNoteId }; + if (asked) { + try { questions.start(asked, view); answered = true; } catch { + // The saved question remains visible and retryable when capacity is reached. If shutdown began while this + // request was arriving, no agent starts; the question gets a retryable "Server stopped" answer instead. + if (questions.stopping) { questions.markStopped(asked, view); answered = true; } + } + } + json(200,await load(requestAbort.signal));return; + } finally { + // Shutdown may stop the reload above before the question was started: it still gets its retryable answer. + if (asked && !answered && questions.stopping) { + try { questions.markStopped(asked); } + catch (error) { console.error(`Could not record the stopped answer of question ${asked}: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); } } } - json(200,await load(requestAbort.signal));return; } if (req.method !== 'GET') { json(405, { error: 'Method not allowed.' }); return; } const files: Record = { '/': ['index.html', 'text/html'], '/app.js': ['app.js', 'text/javascript'], '/style.css': ['style.css', 'text/css'] }; @@ -720,13 +740,16 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge } json(404, { error: 'Not found.' }); } catch (error) { - if (error instanceof ShuttingDownError) { json(503, { error: error.message }); return; } + // A failure after a review command or question retry was applied or replayed (its reload failed, or shutdown + // aborted it) leaves the outcome unknown to the browser: only a command with an action ID is safe to resend. + const unknown = applied ? { outcomeUnknown: true } : {}; + if (error instanceof ShuttingDownError) { json(503, { error: error.message, ...unknown }); return; } if ((error as { code?: string })?.code === 'ERR_SQLITE_ERROR') { json(503, { error: error instanceof Error ? error.message : 'Storage failed.', outcomeUnknown: true }); return; } if (error instanceof BadRequest) { json(400, { error: error.message }); return; } if (error instanceof UpstreamFailure) { json(502, { error: error.message }); return; } - json(409, { error: error instanceof Error ? error.message : 'Review failed.' }); + json(409, { error: error instanceof Error ? error.message : 'Review failed.', ...unknown }); } finally { activeRequests.delete(activeRequest); From 771a6834be43b2b4937758a18313e1b29d1f768d Mon Sep 17 00:00:00 2001 From: mchwang Date: Mon, 12 Oct 2026 22:25:20 -0700 Subject: [PATCH 2/6] Make a remote read the last await before merge and preparation writes (#170 review) The worker-backed review load had become an await after the final GitHub read: merge admission could begin an attempt for a pull request that moved while the load ran, and pre-merge preparation could record readiness for a stale remote pair. Merge admission now inspects the pull request once more after its final review load, compares it with the previous pass, and only then checks local trust and the review's generations synchronously before the attempt. Preparation drops the review load that followed its final remote inspection; readiness uses the view loaded just before it, which the synchronous generation check after the inspection keeps current. Tests cover a pull request moving and the review changing during the final merge load, the load/inspect order in preparation, and trust or task changes during preparation's final load, each asserting the check that refuses it. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/read-only-review.md | 2 +- runner/merge.ts | 21 +++++++++++---- runner/pre-merge.ts | 15 +++-------- test/merge-task-pr.test.ts | 36 ++++++++++++++++++++++++- test/merge.test.ts | 3 ++- test/pre-merge.test.ts | 34 ++++++++++++++++------- 6 files changed, 82 insertions(+), 29 deletions(-) diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md index 7e44e315..97a2253c 100644 --- a/docs/implementation/read-only-review.md +++ b/docs/implementation/read-only-review.md @@ -142,5 +142,5 @@ Review loading used to run history reading and linking on the server's event loo - **Cancellation.** An abort (a request ending at shutdown, a merge or preparation deadline) sends the worker a cancel. The worker kills its running Git process, waits for it to exit, and replies. A worker that has not replied within one second is terminated: one in synchronous work (linking, blob decoding, the object storage walk), which runs no Git process, or, rarely, one whose killed Git process is stuck in the kernel, which then outlives the load. The load rejects with the abort's own reason. - **Staleness.** The plan, snapshot, review version and ledger ownership are read before the worker starts. Every ledger write records a new snapshot, so an unchanged review version, plan revision and snapshot after the worker mean its inputs are current. If they changed (another load recorded a new HEAD, or a write landed), the load reads the history again from the current state, in at most three reads in all within its deadline, and is refused as stale only after that. - **Review commands.** A command applies, synchronously, to a review loaded just before it (inside the recorded action when it has an action ID). Its writes are guarded by that review's state. A load failure before the command is not recorded under its action ID, so the same ID can be resent. If the reload after the command was applied fails, or shutdown stops it, the refusal says `outcomeUnknown`, and a question saved during shutdown still gets its retryable "Server stopped" answer. -- **Merge and preparation.** Every merge load takes the click's operation signal; a replayed click's status refresh takes the coordinator's shutdown signal, and shutdown waits for it. Every preparation load takes the preparation's signal. Merge admission loads the review before its final local trust and token checks, preparation checks its generations and local trust again after its final load, and its generations again after reading the old history it rebases and before it records a moved pull request head. Each write is either in the same turn as those checks, or (a rebase, after its authorization read) guarded again by the Store in its own transaction. +- **Merge and preparation.** Every merge load takes the click's operation signal; a replayed click's status refresh takes the coordinator's shutdown signal, and shutdown waits for it. Every preparation load takes the preparation's signal. A review load awaits a worker, so neither runs one after its final remote read: merge admission inspects the pull request once more after its final review load, and preparation inspects it after its last review load. Both then check local trust and the review's generations in the same turn as the write. Preparation also checks its generations after reading the old history it rebases and before it records a moved pull request head. Each write is either in the same turn as those checks, or (a rebase, after its authorization read) guarded again by the Store in its own transaction. diff --git a/runner/merge.ts b/runner/merge.ts index 992bcc4f..71f7b6a5 100644 --- a/runner/merge.ts +++ b/runner/merge.ts @@ -433,13 +433,24 @@ export class MergeCoordinator { throw new Error(`Merge requirements changed after queue correlation. ${boundary.status.blockers[0]!.message}`); commandStatus = boundary.status; } - // The external authorization read completed before the final PR inspection and queue boundary. Re-read local - // trust synchronously after those awaits so neither remote identity nor local authorization can race admission. - // The review is loaded first: its history work is the last await, so the local trust check and the token check - // below run in the same turn as the attempt that begins after them. + // The final review load awaits its history worker, so the pull request is inspected once more after it: the last + // await before admission is a remote read. The external authorization read completed before both; local trust and + // the review's generations are then rechecked synchronously, in the same turn as the attempt that begins after them. const final = await this.service.load({ signal }); - authorization?.validate(); if (final.token !== token) throw new Error('Review changed during merge validation. Refresh before merging.'); + const last = await this.#statusForMerge(final, signal); + if (last.status.remote.base !== commandStatus.remote.base || last.status.remote.head !== commandStatus.remote.head + || last.status.remote.mergeQueue !== commandStatus.remote.mergeQueue || !samePullRequest(last)) + throw new Error('The pull request changed during merge validation. Refresh before merging.'); + if (!last.status.ready) throw new Error(`Merge requirements changed during validation. ${last.status.blockers[0]!.message}`); + commandStatus = last.status; + authorization?.validate(); + if (this.service.store && this.service.config) { + const { store, config } = this.service; + if (store.reviewVersion(config.identity) !== final.expected.reviewVersion || store.getPlan(config.identity).revision !== final.expected.revision + || store.getSnapshot(config.identity).id !== final.expected.snapshotId) + throw new Error('Review changed during merge validation. Refresh before merging.'); + } if (signal.aborted) throw signal.reason; if (this.service.store && this.service.config && view.expected.reviewVersion !== undefined) { const { store, config } = this.service, reviewVersion = view.expected.reviewVersion; diff --git a/runner/pre-merge.ts b/runner/pre-merge.ts index 89e79993..8700bc03 100644 --- a/runner/pre-merge.ts +++ b/runner/pre-merge.ts @@ -411,8 +411,9 @@ export class PreMergeCoordinator { const authorizedBlocker = this.#reviewBlocker(view, true); if (authorizedBlocker) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: authorizedBlocker }; - // The authorization read above is asynchronous, so the PR may have moved after the earlier inspection. Reinspect - // it, then immediately recheck every local generation before recording readiness. + // The authorization read and the review load above are asynchronous, so the PR may have moved after the earlier + // inspection. Reinspect it as the last await, then recheck every local generation and local trust in this turn + // before recording readiness; the review loaded above stays current while the generations are unchanged. const authorizedRemote = await this.remote.inspect(signal); signal.throwIfAborted(); assertCurrent(guarded); authorization?.validate(); @@ -422,16 +423,6 @@ export class PreMergeCoordinator { return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, reason: 'The pull request moved during final authorization; attribution and approvals were refreshed.' }; } - view = await load(); - // That load awaited its history work: recheck every local generation and local trust again, in this turn. - assertCurrent(guarded); - authorization?.validate(); - if (view.snapshot.base !== prepared.base || view.snapshot.head !== prepared.head) - return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, checked, - reason: 'The local review changed after final authorization; reload it.' }; - const postAuthorizationBlocker = this.#reviewBlocker(view, true); - if (postAuthorizationBlocker) return { state: 'review-required', base: view.snapshot.base, head: view.snapshot.head, - checked, reason: postAuthorizationBlocker }; return { state: 'ready', base: view.snapshot.base, head: view.snapshot.head, checked, reason: null, readiness: { stateVersion: this.service.store.getTask(identity).stateVersion, reviewVersion: this.service.store.reviewVersion(identity), snapshotId: view.snapshot.id, diff --git a/test/merge-task-pr.test.ts b/test/merge-task-pr.test.ts index 35ca9384..f30138e2 100644 --- a/test/merge-task-pr.test.ts +++ b/test/merge-task-pr.test.ts @@ -204,6 +204,39 @@ describe('the merge gate with a runner block (#121)', () => { expect(store.getMergeAttempt(identity)).toBeNull(); }); + it('reinspects the pull request after the final review load, refusing one that moved during it', async () => { + const { store } = published(); + let remoteBase = oid(1), refreshed = false; + const gh = github(store, () => ({ base: remoteBase })); + prepare(store); + const reviewing = service(store), load = reviewing.load.bind(reviewing); + // The review loaded after the last external authorization read is the final one; the pull request moves meanwhile. + reviewing.load = vi.fn(async (options?: Parameters[0]) => { if (refreshed) remoteBase = oid(3); return load(options); }) as ReviewService['load']; + const merges = new MergeCoordinator(reviewing, gh.client, undefined, undefined, PREPARED_PUBLISHED); + merges.setAuthorization(async () => ({ refresh: async () => { refreshed = true; }, validate: () => undefined })); + await expect(merges.merge('review-token')).rejects.toThrow(/pull request changed during merge validation/i); + expect(gh.merged).toEqual([]); + expect(store.getMergeAttempt(identity)).toBeNull(); + }); + + it('refuses admission when the review changes while the final review load runs', async () => { + const { store } = published(), gh = github(store); + let refreshed = false, changed = false; + prepare(store); + const reviewing = service(store), load = reviewing.load.bind(reviewing); + // The final load still answers with the reviewed token, but an approval change lands while it runs. + reviewing.load = vi.fn(async (options?: Parameters[0]) => { + if (refreshed && !changed) { changed = true; store.saveReview(identity, expectedOf(store) as never, [], []); } + return load(options); + }) as ReviewService['load']; + const merges = new MergeCoordinator(reviewing, gh.client, undefined, undefined, PREPARED_PUBLISHED); + merges.setAuthorization(async () => ({ refresh: async () => { refreshed = true; }, validate: () => undefined })); + await expect(merges.merge('review-token')).rejects.toThrow(/Review changed during merge validation/); + expect(changed).toBe(true); + expect(gh.merged).toEqual([]); + expect(store.getMergeAttempt(identity)).toBeNull(); + }); + it('captures the queue event boundary after the final authorization and pull request reads', async () => { const { store } = published(), gh = github(store, () => ({ mergeQueue: true })); let authorized = false; @@ -464,7 +497,8 @@ describe('the merge gate with a runner block (#121)', () => { const gh = github(store); const merges = new MergeCoordinator(service(store), gh.client); await merges.merge('review-token'); - expect(gh.targets).toEqual([undefined, undefined]); + // Every inspection (two validation passes and the one after the final review load) asks for no particular PR. + expect(gh.targets).toEqual([undefined, undefined, undefined]); expect(gh.merged).toEqual([99]); expect(store.getMergeAttempt(identity)!.pullRequest).toBe(99); }); diff --git a/test/merge.test.ts b/test/merge.test.ts index dd9dc5fd..5c7cdb49 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -131,7 +131,8 @@ it('blocks command acceptance that has no current passing runner result', async it('rechecks the exact base and head, then invokes the guarded head merge', async () => { const view = readyView(), service = serviceFor(view); - const client = gateway([remote(view), remote(view)]); + // Two validation passes, then one more inspection after the final review load. + const client = gateway([remote(view), remote(view), remote(view)]); const merged = await new MergeCoordinator(service, client).merge(view.token); expect(client.heads).toEqual([view.snapshot.head]); expect(merged.result.url).toContain('/pr/1'); diff --git a/test/pre-merge.test.ts b/test/pre-merge.test.ts index 1a93a77f..10e7e27d 100644 --- a/test/pre-merge.test.ts +++ b/test/pre-merge.test.ts @@ -297,31 +297,47 @@ it('refuses readiness when local issue trust is revoked during the final remote }); it('refuses readiness when local issue trust is revoked during the final review load', async () => { - let trusted = true, finalInspected = false; + let trusted = true, refreshes = 0, inspected = 0; const fixture = await rebaseFixture('feature\n', undefined, { - authorize: async () => ({ refresh: async () => undefined, + authorize: async () => ({ refresh: async () => { refreshes++; }, validate: () => { if (!trusted) throw new Error('Issue trust was revoked.'); } }), - duringFinalInspect: (_service, _coordinator, read) => { if (read === 3) finalInspected = true; }, - // Only the load after the last remote inspection: trust holds through that inspection's own check. - duringLoad: () => { if (finalInspected) trusted = false; }, + duringFinalInspect: (_service, _coordinator, read) => { inspected = read; }, + // The rebase authorizes first; the review load after the second (final) authorization read is the final one. + duringLoad: () => { if (refreshes === 2) trusted = false; }, }); + // Refused by the check after the final remote inspection, not earlier. + expect(inspected).toBe(3); expect(fixture.result).toMatchObject({ state: 'failed', reason: 'Issue trust was revoked.' }); await fixture.coordinator.close(); await fixture.runner.close(); }); +it('inspects the pull request after its last review load, so no worker await follows the final remote read', async () => { + const events: string[] = []; + const fixture = await rebaseFixture('feature\n', undefined, { + duringLoad: () => { events.push('load'); }, + duringFinalInspect: () => { events.push('inspect'); }, + }); + expect(fixture.result).toMatchObject({ state: 'ready' }); + expect(events.lastIndexOf('inspect')).toBeGreaterThan(events.lastIndexOf('load')); + await fixture.coordinator.close(); await fixture.runner.close(); +}); + it('refuses readiness when the task changes during the final review load', async () => { - let finalInspected = false, cancelled = false; + let refreshes = 0, inspected = 0, cancelled = false; const fixture = await rebaseFixture('feature\n', undefined, { - duringFinalInspect: (_service, _coordinator, read) => { if (read === 3) finalInspected = true; }, - // Only the load after the last remote inspection; the review itself does not change. + authorize: async () => ({ refresh: async () => { refreshes++; }, validate: () => undefined }), + duringFinalInspect: (_service, _coordinator, read) => { inspected = read; }, + // The rebase authorizes first; the review load after the second (final) authorization read is the final one. duringLoad: service => { - if (!finalInspected || cancelled) return; + if (refreshes !== 2 || cancelled) return; cancelled = true; const identity = service.config.identity; service.store.cancelTask(identity, service.store.getTask(identity).stateVersion, randomUUID()); }, }); expect(cancelled).toBe(true); + // Refused by the check after the final remote inspection, not earlier. + expect(inspected).toBe(3); expect(fixture.result).toMatchObject({ state: 'failed', reason: expect.stringMatching(/task or review changed during preparation/i) }); await fixture.coordinator.close(); await fixture.runner.close(); }); From 3de30ecdafb85101a03ea55a052d5d8b5f7e78c2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 13 Oct 2026 14:04:17 -0700 Subject: [PATCH 3/6] Bound concurrent history workers and settle only after the thread exits (#170 review) Review loads no longer serialize on the server thread, so several tabs or quick refreshes could start any number of worker threads and Git processes. At most two history workers now run at once. A later job waits for a slot in arrival order; the wait counts against its budget, and a job aborted or out of time while it waits never starts. Each slot is held until its worker thread has ended. A rejected worker.terminate() no longer settles the job: it proves nothing about the thread, so the job waits for the thread's exit event. The gated Git test fixture numbers calls under a lock, so calls that start together get distinct numbers. The review deadline test moves its mocked clock only once the worker holds its budget, and its clock and environment stubs are restored in afterEach. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/read-only-review.md | 3 +- runner/history-job.ts | 56 +++++++++++++++-- test/fixtures/gated-git.ts | 4 +- test/history-job.test.ts | 84 ++++++++++++++++++++++++- test/review.test.ts | 13 ++-- 5 files changed, 148 insertions(+), 12 deletions(-) diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md index 97a2253c..bab7c8cf 100644 --- a/docs/implementation/read-only-review.md +++ b/docs/implementation/read-only-review.md @@ -137,7 +137,8 @@ Review loading used to run history reading and linking on the server's event loo `ReviewService.load` now runs both in a worker thread (`runner/history-job.ts`, `runner/history-worker.ts`). The rest of the load (Store reads and writes, approval states and the token) stays on the server thread. -- **Lifecycle.** A job is running until its worker replies, fails or exits. Its promise settles only after the worker thread has ended. +- **Lifecycle.** A job is running until its worker replies, fails or exits. Its promise settles only after the worker thread has ended; if terminating the worker fails, the job waits for the thread's exit. +- **Concurrency.** At most two history workers run at once (`MAX_HISTORY_WORKERS`), so tabs and refreshes cannot start any number of threads and Git processes. A later job waits for a slot, and each slot is held until its worker thread has ended. A waiting job that is aborted, or whose budget runs out while it waits, never starts: the wait counts against its budget. - **Deadline.** What the load's overall deadline leaves is the worker's budget, counted from the worker's start: the history read gets what remains when it starts, and linking gets what the read left. If the worker has not answered one second after its budget, the server cancels it; until the worker reports that it has started, it also gets five seconds to start. The load still refuses a result that arrives after its own overall deadline. - **Cancellation.** An abort (a request ending at shutdown, a merge or preparation deadline) sends the worker a cancel. The worker kills its running Git process, waits for it to exit, and replies. A worker that has not replied within one second is terminated: one in synchronous work (linking, blob decoding, the object storage walk), which runs no Git process, or, rarely, one whose killed Git process is stuck in the kernel, which then outlives the load. The load rejects with the abort's own reason. - **Staleness.** The plan, snapshot, review version and ledger ownership are read before the worker starts. Every ledger write records a new snapshot, so an unchanged review version, plan revision and snapshot after the worker mean its inputs are current. If they changed (another load recorded a new HEAD, or a write landed), the load reads the history again from the current state, in at most three reads in all within its deadline, and is refused as stale only after that. diff --git a/runner/history-job.ts b/runner/history-job.ts index 9f525768..7138494c 100644 --- a/runner/history-job.ts +++ b/runner/history-job.ts @@ -29,12 +29,45 @@ export const HISTORY_CANCEL_GRACE_MS = 1_000; export const HISTORY_DEADLINE_GRACE_MS = 1_000; /** How long a worker may take to start (load its modules) before its budget is counted from its creation instead. */ export const HISTORY_START_GRACE_MS = 5_000; +/** + * How many history workers run at once. The server no longer serializes review loads, so tabs and refreshes could + * otherwise start any number of threads and Git processes; later jobs wait for a slot. + */ +export const MAX_HISTORY_WORKERS = 2; + +/** Slots in use and jobs waiting for one, in arrival order. A slot is held until its worker thread has ended. */ +let running = 0; +const waiting: { grant: () => void }[] = []; +const release = () => { + running--; + while (running < MAX_HISTORY_WORKERS && waiting.length) { running++; waiting.shift()!.grant(); } +}; +/** + * Waits for a worker slot. A job waiting for one is not started at all if its signal aborts (it rejects with the + * signal's reason) or its budget runs out first (it rejects with the deadline error). + */ +function acquire(budgetMs: number, signal?: AbortSignal): Promise { + if (running < MAX_HISTORY_WORKERS) { running++; return Promise.resolve(); } + return new Promise((resolve, reject) => { + const entry = { grant: () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); resolve(); } }; + const leave = (reason: unknown) => { + const index = waiting.indexOf(entry); + if (index < 0) return; + waiting.splice(index, 1); clearTimeout(timer); signal?.removeEventListener('abort', onAbort); reject(reason); + }; + const onAbort = () => leave(signal!.reason); + const timer = setTimeout(() => leave(new Error('History work exceeded its overall deadline.')), budgetMs); + signal?.addEventListener('abort', onAbort, { once: true }); + waiting.push(entry); + }); +} const workerUrl = new URL('./history-worker.ts', import.meta.url); /** * Runs a history job in its own worker thread, so Git processes, object storage inspection and linking never block - * the caller's event loop. + * the caller's event loop. At most MAX_HISTORY_WORKERS run at once; a job waits for a slot first, and the wait counts + * against its budget. * * Lifecycle: the job is running until the worker replies, fails or exits. An abort, or the budget passing without a * reply, sends a cancel: the worker kills its running Git process, waits for it to exit, and replies. A worker that has @@ -43,8 +76,20 @@ const workerUrl = new URL('./history-worker.ts', import.meta.url); * not exited within the grace (a process in an uninterruptible kernel wait), which then outlives the job. The returned * promise settles only after the worker thread has ended. An abort rejects with the signal's reason, unchanged. */ -export function runHistoryJob(job: HistoryJob, signal?: AbortSignal, url: URL = workerUrl): Promise<{ history: History; segments: Segment[] | null }> { - if (signal?.aborted) return Promise.reject(signal.reason); +export async function runHistoryJob(job: HistoryJob, signal?: AbortSignal, url: URL = workerUrl): Promise<{ history: History; segments: Segment[] | null }> { + signal?.throwIfAborted(); + const queued = performance.now(); + await acquire(job.budgetMs, signal); + try { + const budgetMs = Math.ceil(job.budgetMs - (performance.now() - queued)); + if (budgetMs < 1) throw new Error('History work exceeded its overall deadline.'); + signal?.throwIfAborted(); + return await runInWorker({ ...job, budgetMs }, url, signal); + } finally { release(); } +} + +/** One job in one worker thread; settles only after that thread has ended (see runHistoryJob). */ +function runInWorker(job: HistoryJob, url: URL, signal?: AbortSignal): Promise<{ history: History; segments: Segment[] | null }> { return new Promise((resolve, reject) => { const worker = new Worker(url, { workerData: job }); // The outcome is decided once (the first reply, failure or stop) and delivered once the thread has exited. @@ -58,8 +103,9 @@ export function runHistoryJob(job: HistoryJob, signal?: AbortSignal, url: URL = signal?.removeEventListener('abort', onAbort); (outcome ?? (() => reject(new Error('History worker stopped without a reply.'))))(); }; - // A reply is the worker's last act; terminate makes sure the thread ends and resolves once it has. - const end = () => { void worker.terminate().then(deliver, deliver); }; + // A reply is the worker's last act; terminate makes sure the thread ends and resolves once it has. A rejected + // terminate proves nothing about the thread, so the job then waits for its `exit` event, which delivers. + const end = () => { void worker.terminate().then(deliver, () => undefined); }; const stop = (reason: unknown) => { if (stopping || outcome) return; stopping = true; diff --git a/test/fixtures/gated-git.ts b/test/fixtures/gated-git.ts index e13f0b2d..f9bd3bec 100644 --- a/test/fixtures/gated-git.ts +++ b/test/fixtures/gated-git.ts @@ -15,7 +15,9 @@ export function gatedGit(root: string, match: string) { mkdirSync(dir, { recursive: true }); writeFileSync(log, ''); const real = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; - const wait = `open(my $f, ">>", shift); print $f "$$\\n"; close $f; my $n = () = do { local @ARGV = ($ARGV[0] =~ s/gate-$/calls/r); <> };` + // The append and the count run under one lock, so calls that start together still get distinct numbers. + const wait = `use Fcntl qw(:flock); my $log = shift; open(my $f, ">>", $log); flock($f, LOCK_EX); select((select($f), $| = 1)[0]);` + + ` print $f "$$\\n"; my $n = () = do { open(my $r, "<", $log); <$r> }; close $f;` + ` my $g = shift; select(undef, undef, undef, 0.02) until -e "$g$n"; exec @ARGV`; writeFileSync(join(dir, 'git'), `#!/bin/sh case "$*" in diff --git a/test/history-job.test.ts b/test/history-job.test.ts index d2d0c0e5..d2fab296 100644 --- a/test/history-job.test.ts +++ b/test/history-job.test.ts @@ -8,7 +8,8 @@ import { writeFileSync } from 'node:fs'; import { LOAD_ATTEMPTS, ReviewService } from '../runner/review.ts'; import { readHistory } from '../git/history.ts'; import { linkHistory } from '../core/linking.ts'; -import { HISTORY_CANCEL_GRACE_MS, HISTORY_DEADLINE_GRACE_MS, HISTORY_START_GRACE_MS, runHistoryJob, type HistoryJob } from '../runner/history-job.ts'; +import { HISTORY_CANCEL_GRACE_MS, HISTORY_DEADLINE_GRACE_MS, HISTORY_START_GRACE_MS, MAX_HISTORY_WORKERS, runHistoryJob, type HistoryJob } from '../runner/history-job.ts'; +import { Worker } from 'node:worker_threads'; import { runHistoryWork } from '../runner/history-work.ts'; import { startServer } from '../web/server.ts'; import { Store } from '../runner/store.ts'; @@ -370,3 +371,84 @@ it('reports the deadline, not staleness, when a load runs out of time on its las expect(git.pids()).toHaveLength(LOAD_ATTEMPTS); } finally { clock.mockRestore(); } }); + +const pause = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +it(`runs at most ${MAX_HISTORY_WORKERS} history workers at once, starting a waiting load when a slot frees`, async () => { + const { git, service } = held(); + const loads = Array.from({ length: MAX_HISTORY_WORKERS + 1 }, () => service.load()); + for (let n = 1; n <= MAX_HISTORY_WORKERS; n++) await git.held(n); + await pause(500); + // The extra load waits for a slot: no worker, so no Git process. + expect(git.pids()).toHaveLength(MAX_HISTORY_WORKERS); + git.open(1); + await git.held(MAX_HISTORY_WORKERS + 1); + for (let n = 2; n <= MAX_HISTORY_WORKERS + 1; n++) git.open(n); + const views = await Promise.all(loads); + expect(new Set(views.map(view => view.snapshot.id)).size).toBe(1); +}); + +it('never starts a load that is aborted while it waits for a worker slot', async () => { + const { git, service } = held(); + const running = Array.from({ length: MAX_HISTORY_WORKERS }, () => service.load()); + for (let n = 1; n <= MAX_HISTORY_WORKERS; n++) await git.held(n); + const controller = new AbortController(), reason = new Error('Stopped while waiting.'); + const waiting = service.load({ signal: controller.signal }); + // Long enough for a started worker to reach Git: none did, because this load is waiting for a slot. + await pause(500); + expect(git.pids()).toHaveLength(MAX_HISTORY_WORKERS); + controller.abort(reason); + await expect(waiting).rejects.toBe(reason); + for (let n = 1; n <= MAX_HISTORY_WORKERS; n++) git.open(n); + await Promise.all(running); + await pause(300); + expect(git.pids()).toHaveLength(MAX_HISTORY_WORKERS); +}); + +it('counts the wait for a worker slot against the budget, never starting a load whose time ran out', async () => { + const { git, service } = held(); + const running = Array.from({ length: MAX_HISTORY_WORKERS }, () => service.load()); + for (let n = 1; n <= MAX_HISTORY_WORKERS; n++) await git.held(n); + await expect(service.load({ maxDurationMs: 300 })).rejects.toThrow('History work exceeded its overall deadline.'); + for (let n = 1; n <= MAX_HISTORY_WORKERS; n++) git.open(n); + await Promise.all(running); + await pause(300); + expect(git.pids()).toHaveLength(MAX_HISTORY_WORKERS); +}); + +it('keeps a worker slot until the worker thread has ended, not merely until its job was cancelled', async () => { + const { git, service } = held(); + const controller = new AbortController(), reason = new Error('Stopped by the test.'); + const heartbeat = new SharedArrayBuffer(4), beats = new Int32Array(heartbeat); + // Workers stuck in synchronous work hold every slot; they end only when terminated after the cancel grace. + const busy = Array.from({ length: MAX_HISTORY_WORKERS }, () => runHistoryJob({ repository: '.', base: 'HEAD', head: 'HEAD', budgetMs: 30_000, heartbeat } as HistoryJob, + controller.signal, fixtureUrl('history-worker-busy.ts'))); + busy.forEach(job => job.catch(() => undefined)); + await vi.waitFor(() => expect(Atomics.load(beats, 0)).toBeGreaterThan(0)); + const waiting = service.load(); + controller.abort(reason); + await pause(HISTORY_CANCEL_GRACE_MS / 2); + expect(git.pids()).toHaveLength(0); + for (const job of busy) await expect(job).rejects.toBe(reason); + await git.held(1); git.open(1); + await expect(waiting).resolves.toBeDefined(); +}); + +it('keeps waiting for the worker thread to exit when terminating it fails', async () => { + let worker: Worker | undefined; + const terminate = vi.spyOn(Worker.prototype, 'terminate').mockImplementationOnce(function (this: Worker) { + worker = this; return Promise.reject(new Error('Terminate failed.')); + }); + try { + let settled = false; + const job = runHistoryJob({ repository: '.', base: 'HEAD', head: 'HEAD', budgetMs: 200, startMs: 0 } as HistoryJob, undefined, + fixtureUrl('history-worker-slow-start.ts')).finally(() => { settled = true; }); + job.catch(() => undefined); + // The budget, deadline grace and cancel grace pass; the failed terminate leaves the thread running. + await vi.waitFor(() => expect(worker).toBeDefined(), { timeout: 10_000 }); + await pause(500); + expect(settled).toBe(false); + await worker!.terminate(); + await expect(job).rejects.toThrow('History work exceeded its overall deadline.'); + } finally { terminate.mockRestore(); } +}); diff --git a/test/review.test.ts b/test/review.test.ts index 8879265b..1dc47bad 100644 --- a/test/review.test.ts +++ b/test/review.test.ts @@ -10,18 +10,23 @@ import { approveItem, reviewedSegment } from '../core/approvals.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { identityKey } from '../core/identity.ts'; import { fixtureGit } from './fixtures/git.ts'; +import { gatedGit } from './fixtures/gated-git.ts'; // A passthrough, so the stale-key test can count how often load() serializes a segment. vi.mock('../core/approvals.ts', async original => { const actual = await original(); return { ...actual, reviewedSegment: vi.fn(actual.reviewedSegment) }; }); // Each integration case performs several bounded real-Git reads. vi.setConfig({ testTimeout: 15000 }); const roots:string[]=[];const services:ReviewService[]=[]; -afterEach(()=>{services.splice(0).forEach(service=>service.close());roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}));}); +// Restorers for a test's clock and environment stubs, run even when the test times out before its own cleanup. +const restores:(()=>void)[]=[]; +afterEach(()=>{restores.splice(0).forEach(restore=>restore());vi.unstubAllEnvs();services.splice(0).forEach(service=>service.close());roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}));}); function fixture(){const root=mkdtempSync(join(tmpdir(),'codeboost-review-'));roots.push(root);const config=createDemo(join(root,'demo'));const service=new ReviewService(config);services.push(service);return {service,config};} // History reading and linking share the worker's budget (history-job.test.ts); the work after them shares the same deadline. it('counts the work after the history worker against the load\'s one overall deadline',async ()=>{ - const {service}=fixture();let now=0;const clock=vi.spyOn(performance,'now').mockImplementation(()=>now); - try{const loading=service.load({maxDurationMs:30_000});now=30_000;await expect(loading).rejects.toThrow('Review load exceeded its overall deadline.');} - finally{clock.mockRestore();} + const root=mkdtempSync(join(tmpdir(),'codeboost-review-gate-'));roots.push(root);const git=gatedGit(root,'--git-common-dir');vi.stubEnv('PATH',git.path); + const {service}=fixture();let now=0;const clock=vi.spyOn(performance,'now').mockImplementation(()=>now);restores.push(()=>clock.mockRestore()); + // The clock passes the deadline only once the worker holds its budget and is reading: the time is spent after it. + const loading=service.load({maxDurationMs:30_000});loading.catch(()=>undefined);await git.held(1);now=30_000;git.open(1); + await expect(loading).rejects.toThrow('Review load exceeded its overall deadline.'); }); it('closes the review service when merge gateway construction fails', async()=>{ const root=mkdtempSync(join(tmpdir(),'codeboost-review-'));roots.push(root);const demo=createDemo(join(root,'demo')); From ca97365632c43c5f3e86586217fe1df42bc8b98c Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 13 Oct 2026 15:35:56 -0700 Subject: [PATCH 4/6] Record the review lessons from moving review loads off the server thread (#170) Four rules for async work, from the findings on this pull request: recheck every same-turn guard after a new await and make the final remote validation the last await before an irreversible action; bound work that leaves a serializing path; never compare clocks across threads; and in tests that host the server, wait for observable state instead of relying on the server blocking the test's event loop. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e1ada05e..e3b56094 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,10 @@ For features with background jobs, polling, retries, cancellation, or shutdown: - Bound the HTTP drain during shutdown. After its grace period, abort and await owned work before awaiting server closure so an admitted poll cannot deadlock teardown. - Polling endpoints should read only the state they need. Do not rebuild Git history or the full review merely to retrieve background-job status. - Back off recurring external-status polling to a bounded cap. Reset the interval only after a meaningful lifecycle change or explicit user action. +- When synchronous work moves behind an await (a worker thread, a subprocess, an async API), list every await it adds. A check that used to run in the same turn as the write it guards must run again after that await, in the write's turn. The last await before an irreversible external action must be its final remote validation. +- Work that leaves a serializing path (the event loop, a lock, a single queue) needs its own concurrency bound. Queued work must honour cancellation and deadlines, and each slot is held until its work has settled. +- Never compare clock readings across threads or processes. Pass a relative budget and let each side count it on its own monotonic clock. +- A test that hosts the server in its own process must wait for observable state before acting. Do not rely on the server blocking the test's event loop to order steps. ## Async review UI From 7b8b698cf17306bff86cd4365dd67cdba61f1418 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 13 Oct 2026 15:37:19 -0700 Subject: [PATCH 5/6] Trim the new async rules to what existing rules do not already say (#170 review) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e3b56094..2798ccc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,8 @@ For features with background jobs, polling, retries, cancellation, or shutdown: - Bound the HTTP drain during shutdown. After its grace period, abort and await owned work before awaiting server closure so an admitted poll cannot deadlock teardown. - Polling endpoints should read only the state they need. Do not rebuild Git history or the full review merely to retrieve background-job status. - Back off recurring external-status polling to a bounded cap. Reset the interval only after a meaningful lifecycle change or explicit user action. -- When synchronous work moves behind an await (a worker thread, a subprocess, an async API), list every await it adds. A check that used to run in the same turn as the write it guards must run again after that await, in the write's turn. The last await before an irreversible external action must be its final remote validation. -- Work that leaves a serializing path (the event loop, a lock, a single queue) needs its own concurrency bound. Queued work must honour cancellation and deadlines, and each slot is held until its work has settled. +- When synchronous work moves behind an await (a worker thread, a subprocess, an async API), list every await it adds and apply the local-generation rule under "Guarded external actions" to each one. A local await must not follow the final remote validation: the last await before an irreversible external action is that validation. +- Work that leaves a serializing path (the event loop, a lock, a single queue) needs its own concurrency bound. Queued work must honour cancellation and deadlines. - Never compare clock readings across threads or processes. Pass a relative budget and let each side count it on its own monotonic clock. - A test that hosts the server in its own process must wait for observable state before acting. Do not rely on the server blocking the test's event loop to order steps. From c4667adb32e681169fba63d8be6e638f2f9e733f Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 13 Oct 2026 15:38:15 -0700 Subject: [PATCH 6/6] Say plainly which process the in-process test rule is about (#170 review) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2798ccc9..4783d1f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ For features with background jobs, polling, retries, cancellation, or shutdown: - When synchronous work moves behind an await (a worker thread, a subprocess, an async API), list every await it adds and apply the local-generation rule under "Guarded external actions" to each one. A local await must not follow the final remote validation: the last await before an irreversible external action is that validation. - Work that leaves a serializing path (the event loop, a lock, a single queue) needs its own concurrency bound. Queued work must honour cancellation and deadlines. - Never compare clock readings across threads or processes. Pass a relative budget and let each side count it on its own monotonic clock. -- A test that hosts the server in its own process must wait for observable state before acting. Do not rely on the server blocking the test's event loop to order steps. +- A test that runs the server in the test's own process must wait for observable state before acting. Do not rely on the server blocking the test's event loop to order steps. ## Async review UI