Files
PaperClipAI/tests/runner-e2e/chat-archive-storage-fault.ts
T
DottaandClaude Opus 5.5 afcf34c9da fix(evals): form repair-cost-recording's saved-usage debt and grade it by saved state (#20895)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat feature evals check that the chat agent helps a person with
real product state
> - The `budgets-costs.repair-cost-recording` case checks that the agent
sends the person to the saved-cost retry control and then reports the
true accounting result
> - The grader checked the agent's replies with keyword regular
expressions, and the scripted person gave the agent the harness's own
accounting readback
> - Keyword checks reject valid wording and accept wrong answers, so the
case did not measure the promise
> - The setup also never formed: the prerequisite run always settled, so
no "Ready to record" saved usage existed
> - This pull request forms that state with a temporary trigger on the
case-owned database (no product change), and replaces the two keyword
checks with checks of saved state, the agent's own product calls, and
rendered links
> - The benefit is a case that can run, and a grader that does not
depend on wording and that still fails the relevant broken behavior

## Linked Issues or Issue Description

**What happened**
The case `budgets-costs.repair-cost-recording` graded the chat replies
with keyword regular expressions (`honest-post-retry-accounting-result`
and `guides-accounting-without-rerun`). The scripted follow-up message
also told the agent the harness's accounting result. Separately, the
setup never formed in paid runs: the prerequisite native run settles as
an unpriced cost event (`missing_price`), not as a `retryable`
saved-usage row. Accounting retries every 5 seconds, so a retryable row
only exists while accounting keeps failing.

**Expected behavior**
The grader accepts any wording. It requires that the guidance before the
retry links the Costs page, that the post-retry run reads saved cost
data before it answers, and that the agent does not write cost, budget
or accounting records or start work.

**Steps to reproduce**
1. Read `gradeAccounting` in
`tests/runner-e2e/chat-catalog-domains/identity-accounting.ts` on
master.
2. Reword a valid reply (for example "Head to Costs and press the button
there; the work stays as it is.").
3. See that the keyword checks reject it.

Refs #20600

## What Changed

- New `tests/runner-e2e/chat-accounting-fault.ts`, following
`chat-archive-storage-fault.ts`: a temporary trigger on the case-owned
database refuses cost-event writes for the one prerequisite task. The
product's own accounting transaction rolls back, so the finished run
keeps its saved usage pending and the product's automatic retries keep
failing. Refused writes are counted in a rollback-proof sequence. It is
allowed only when this case is the single selected execution, and it
never writes product rows.
- Setup: the case creates the prerequisite task unassigned, arms the
trigger, assigns the task, and waits until Costs shows the genuine
`retryable` row. The trigger is closed on a setup failure and at
cleanup.
- Act: the trigger is dropped right before the person clicks "Retry cost
recording". Automatic recovery can record it first; the saved accounting
read afterwards classifies either result (an unpriced event still shows
in data quality, so it counts as unresolved).
- `chat-archive-storage-fault.ts`: export its owned-database resolver
for reuse (no behavior change).

- `identity-accounting.ts`: remove the reply keyword checks for the
repair case.
- Add `initial-guidance-links-saved-cost-retry`: a reply from before the
retry has a rendered link to the Costs page (`/<prefix>/activity/costs`
or the `/<prefix>/costs` redirect).
- Add `post-retry-reply-reads-saved-accounting`: the post-retry chat run
made a successful read of a company cost report, or of the cost summary
of the prerequisite task (bound by its redacted input digest).
- Add `no-agent-accounting-fabrication-or-rerun`: no successful
chat-agent call writes cost, finance, budget or accounting records or
starts run or task execution. A denied attempt is not a write.
- `observe` collects the chat agent's redacted `call_api` uses and
results (`leadApiCalls`).
- The scripted follow-up now reports only the toast that the page
showed. That toast says "Costs recorded." even when the usage still has
no price.
- Own `oracleVersion`
(`paperclip.chat-catalog.repair-cost-recording.structural.v3`) and
source files in code and in `chat-catalog-descriptors.json`.
- Calibration tests: reworded valid replies pass; a missing or late
Costs link, a run link only, a foreign link, no fresh read, a read in
the wrong run, a denied read, an unrelated read, another task's cost
summary, and agent cost or execution writes fail.

## Verification

- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/chat-accounting-fault.test.ts
tests/runner-e2e/chat-archive-storage-fault.test.ts
tests/runner-e2e/chat-catalog-domains/identity-accounting.test.ts
tests/runner-e2e/chat-catalog-contract.test.ts
tests/runner-e2e/chat-catalog-consistency.test.ts`: all pass. The
embedded-Postgres test drives the product's `accountRunCost`,
`reconcileRunCosts`, data-quality report and board retry against the
trigger: only the faulted task's run stays `retryable`, other debt in
the company settles, and the retry records it once the trigger is
dropped.
- `npx tsc --noEmit -p tests/runner-e2e`: no errors in the changed
files.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <sha> --check`:
unchanged.
- Paid run still needed:
`chat-features.runner-opencode.local.budgets-costs.repair-cost-recording`.

## Risks

- Low risk. The change affects only this eval case.
- The new read check needs the chat agent to have company cost-read
access. Agents cannot read `costs/data-quality` (board only), so the
agent uses the cost reports or the task cost summary.
- The trigger runs DDL on the case-owned database only; the same
isolation checks as the archive storage fault apply, and it is dropped
before the retry and removed at cleanup.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:29:54 -06:00

110 lines
7.4 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { readFile, realpath } from "node:fs/promises";
import path from "node:path";
import { reviewedChatExecutionIds } from "./chat-runtime-profile.js";
import { DOCKER_DATABASE_ENV } from "./docker-postgres.js";
/**
* Test-only storage fault for `inbox-search.archive-partial-failure`.
*
* The native runner calls the product API on the server's own loopback
* listener, so a fault placed in front of PAPERCLIP_API_URL never sees the
* agent's archive requests. This fault sits below every route instead: a
* temporary trigger on the case-owned database makes the product's own archive
* write fail with a Postgres I/O error. The trigger is scoped to one company,
* person, agent and the three seeded tasks, fails only the write that would be
* the third archive of that set, counts every injected failure per task in
* non-transactional sequences, and is dropped before the declared retry. It
* never writes or edits product rows; all saved state comes from the product.
*/
export const ARCHIVE_SCENARIO = "inbox-search.archive-partial-failure";
export interface ArchiveFaultBinding { companyId: string; agentId: string; personId: string; taskIds: string[] }
export interface ArchiveStorageFault {
/** Injected failures per seeded task id, read from rollback-proof sequences. */
failures(): Promise<Record<string, number>>;
release(): Promise<void>;
close(): Promise<void>;
}
const uuid = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i;
export function validArchiveFaultBinding(value: ArchiveFaultBinding) {
return !!value && Object.keys(value).sort().join() === "agentId,companyId,personId,taskIds" && uuid.test(value.companyId) && uuid.test(value.agentId)
&& typeof value.personId === "string" && /^[A-Za-z0-9_-]{8,128}$/.test(value.personId)
&& Array.isArray(value.taskIds) && value.taskIds.length === 3 && value.taskIds.every(id => typeof id === "string" && uuid.test(id)) && new Set(value.taskIds).size === 3;
}
/** Statements only; ids are validated above and are the only interpolated values. */
export function archiveStorageFaultSql(tag: string, binding: ArchiveFaultBinding) {
if (!/^[a-f0-9]{12}$/.test(tag) || !validArchiveFaultBinding(binding)) throw new Error("Archive storage fault requires an exact owned binding");
const name = `paperclip_e2e_archive_fault_${tag}`, ids = `'{${binding.taskIds.join(",")}}'::uuid[]`, lock = Number.parseInt(tag.slice(0, 7), 16);
const sequences = binding.taskIds.map((_, index) => `${name}_hits_${index + 1}`);
const install = [
...sequences.map(sequence => `create sequence ${sequence}`),
`create function ${name}() returns trigger language plpgsql as $fault$
declare archived integer; position integer;
begin
if NEW.company_id <> '${binding.companyId}'::uuid or NEW.user_id <> '${binding.personId}' or NEW.archived_by_actor_type <> 'agent'
or NEW.archived_by_agent_id is distinct from '${binding.agentId}'::uuid or not (NEW.issue_id = any(${ids})) then
return NEW;
end if;
-- Serialize this person's archive writes for the seeded set so parallel
-- requests cannot all observe zero prior archives.
perform pg_advisory_xact_lock(${lock});
select count(*) into archived from issue_inbox_archives
where company_id = NEW.company_id and user_id = NEW.user_id and issue_id = any(${ids}) and issue_id <> NEW.issue_id;
if archived >= 2 then
position := array_position(${ids}, NEW.issue_id);
perform nextval(format('${name}_hits_%s', position)::regclass);
raise exception 'could not write inbox archive row' using errcode = '58030';
end if;
return NEW;
end
$fault$`,
`create trigger ${name} before insert or update on issue_inbox_archives for each row execute function ${name}()`,
];
return {
install,
release: [`drop trigger if exists ${name} on issue_inbox_archives`],
remove: [`drop trigger if exists ${name} on issue_inbox_archives`, `drop function if exists ${name}()`, ...sequences.map(sequence => `drop sequence if exists ${sequence}`)],
failures: sequences.map(sequence => `select case when is_called then last_value else 0 end as hits from ${sequence}`),
};
}
export async function ownedDatabaseUrl(env: NodeJS.ProcessEnv) {
const temporary = env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT, config = env.PAPERCLIP_CONFIG, home = env.PAPERCLIP_HOME, instance = env.PAPERCLIP_INSTANCE_ID;
if (!(temporary && config && home && instance && path.isAbsolute(temporary) && path.isAbsolute(config) && path.isAbsolute(home) && /^[A-Za-z0-9_-]+$/.test(instance))) throw new Error("Archive storage fault requires the owned runner instance");
const [root, actualConfig, actualHome] = await Promise.all([realpath(temporary), realpath(config), realpath(home)]);
if (actualHome !== path.join(root, "paperclip-home") || actualConfig !== path.join(actualHome, "instances", instance, "config.json")) throw new Error("Archive storage fault refuses an unowned instance");
const { assertRunnerDatabaseIsolation } = await import("./instance-isolation.js");
const docker = env[DOCKER_DATABASE_ENV];
await assertRunnerDatabaseIsolation(actualConfig, root, docker || undefined);
const parsed = JSON.parse(await readFile(actualConfig, "utf8")) as { database?: { mode?: string; connectionString?: string; embeddedPostgresPort?: number } };
const url = parsed.database?.mode === "postgres" ? parsed.database.connectionString
: Number.isInteger(parsed.database?.embeddedPostgresPort) ? `postgres://paperclip:paperclip@127.0.0.1:${parsed.database!.embeddedPostgresPort}/paperclip` : undefined;
if (!url) throw new Error("Archive storage fault could not resolve the owned database");
return url;
}
export async function armArchiveStorageFault(input: { executionId: string; binding: ArchiveFaultBinding; env?: NodeJS.ProcessEnv }): Promise<ArchiveStorageFault> {
const env = input.env ?? process.env;
let selected: unknown; try { selected = JSON.parse(env.PAPERCLIP_RUNNER_E2E_EXECUTION_IDS ?? "null"); } catch { selected = null; }
if (!reviewedChatExecutionIds(ARCHIVE_SCENARIO).includes(input.executionId) || !Array.isArray(selected) || selected.length !== 1 || selected[0] !== input.executionId)
throw new Error("Archive storage fault is allowed only when its own case is the single selected execution");
const statements = archiveStorageFaultSql(randomBytes(6).toString("hex"), input.binding);
const { createDb } = await import("../../packages/db/src/index.js");
const db = createDb(await ownedDatabaseUrl(env), { maxConnections: 1, connectTimeoutSeconds: 5 }), client = db.$client;
let closed = false, released = false;
const run = async (list: string[]) => { for (const statement of list) await client.unsafe(statement); };
try { await client.begin(async tx => { for (const statement of statements.install) await tx.unsafe(statement); }); }
catch (error) { await client.end({ timeout: 5 }); throw error; }
return {
async failures() {
if (closed) throw new Error("Archive storage fault is closed");
const counts: Record<string, number> = {};
for (const [index, statement] of statements.failures.entries()) { const [row] = await client.unsafe(statement); counts[input.binding.taskIds[index]!] = Number(row?.hits ?? 0); }
return counts;
},
async release() { if (closed || released) throw new Error("Archive storage fault was already released"); await run(statements.release); released = true; },
async close() { if (closed) return; closed = true; try { await run(statements.remove); } finally { await client.end({ timeout: 5 }); } },
};
}