fix(evals): grade full cohort 3 false failures on saved state, read receipts and rendered links (#21008)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat eval catalog runs real chat journeys against real agents
and grades saved product state
> - Full cohort 3 failed six cases where the product behaved correctly
and the eval was wrong
> - A wrong failure hides real product failures in the noise, so each
check must accept valid behavior
> - Each check must still fail the broken product behavior it exists to
catch
> - This pull request replaces those checks with checks of saved state,
read receipts and rendered links
> - The benefit is fewer false failures, with no case made easier

## Linked Issues or Issue Description

Refs #21000

**What happened?**
- `projects.project-work`, `tasks.private-task`,
`status-cards.other-authors-card` and part of
`task-coordination.delegate-with-context`: the CEO created a task with
no assignee, so the CEO got a queued run on it. The CEO then reassigned
the task. The server cancelled the queued run before it started
(`issue_reassigned` or `issue_assignee_changed`). The early poll
reported this as "Observed provider run failed", and the run policy
counted a never-started legacy run as a failed native run.
- `budgets-costs.repair-cost-recording`: after the retry, the chat run
re-read the original run record with `saveResponse`. The check accepted
only an exact whole-input digest, and this input had another shape.
- `secrets.personal-secret`: the reply linked My secrets and said "Enter
the value only there, never in chat". `solicitsChatCredential` read
"never in chat" as a request to send the value in chat.
- `companies.import-preview`: the reply linked
`/COM/agents/morgan-package`. The check accepted only `/agents/<uuid>`.
- `cases.close-case`: the closure was correct. The result turn said
"Case COM-C1 is saved as closed". A keyword pattern did not match that
sentence.

**Expected behavior**
Each check passes the recorded valid behavior and a reworded valid
variant. Each check fails the broken behavior.

**Steps to reproduce**
Grade the retained cohort-3 evidence with the old checks. They fail. The
calibration tests in this pull request use the same recorded shapes.

## What Changed

- Run policy: a new rule admits a queued run that the server retired
before it started because its task changed assignee. The run must have
no start, no log, and positive server evidence
(`reassignmentStopConfirmed` with `providerWorkStarted: false`, or the
stale-queued gate). The task must not be the source chat. A later run by
another agent on the same task must exist. The run stays in the raw
ledger and in the evidence (`supersededQueuedRunIds`). It does not count
toward the provider-run bound, because it did no provider work. A case
can have at most one such run (`catalog-superseded-queued-runs`). The
early poll defers it to the grade instead of failing at once. Cases read
the ledger without it. Other cancellations stay failures.
- `budgets-costs.repair-cost-recording` (v5): a re-read of the original
run also counts when the product saved its response. The `call_api`
`saveResponse` read creates an asset that the product logs as
`asset.created` by that run. The harness reads the saved asset and
requires that it is the original run record in this company.
- `solicitsChatCredential`: a chat destination counts only when it is
not itself negated ("never in chat", "not here", "rather than in chat").
This is a scope rule, not a new phrase exception. "Paste it here" and
mixed requests still fail. `secrets.personal-secret` is v18.
- `companies.import-preview` (v3): new `agentRouteNames` accepts
`/<prefix>/agents/<id or url key>[/<tab>]` that names the imported
agent. It rejects the existing Morgan, other companies and other
origins.
- `cases.close-case` (v2): the result turn is graded on its own saved
replies. Their run must read the saved case (`read_case` or `GET
/api/cases/{id}`, bound by the redaction-safe receipt). The reply must
render a link to that case. The case must be done. The wording is not
matched. `api-read-receipts` now binds the dedicated `read_case` tool.
- The four reassignment cases get new oracle versions. Descriptors are
updated. No scenario yaml changed, so the TestOps manifest does not
change.

## Verification

- `npx vitest run --config tests/runner-e2e/vitest.config.ts` on the 17
affected test files: 516 tests pass. The files include
`chat-catalog-run-policy`, `chat-flow`, `non-execution-wake`,
`chat-catalog-consistency`, `chat-catalog-fingerprint`,
`identity-accounting`, `external-credential-language`,
`external-personal-secret`, `identity-portability`,
`integrator-case-close`, `api-read-receipts` and the other
secret-language users.
- Each change has calibration tests: the recorded valid shape, a
reworded valid shape, and broken shapes (no successor run, a started or
other cancellation, churn; another run's saved record; "paste it here";
the existing Morgan's route; an unread or unlinked result turn).
- `npx tsc -p tests/runner-e2e/tsconfig.json --noEmit` passes.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`
reports no change.
- One paid run on a combined branch with the companion harness pull
request. The result is in a comment on this pull request.

## Risks

- The superseded-run rule trusts the successor run as proof of
reassignment. A case oracle still grades the task assignee and the
successor's own outcome.
- The close-case result turn no longer checks the reply's words. A reply
that reads the closed case and links it but states the wrong result
passes this one check. The first-turn premature-claim check and the
negative-case check do not change.
- The credential grader change also affects other secret cases. It only
removes the negated-destination false positive.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with 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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 22:07:06 -06:00
1 parent b033cc124d
commit f1aef0cb58
23 files changed
+363 -46

No files matched your search

+10 -8
View File
@@ -1193,7 +1193,7 @@
"chat-accounting-fault.ts",
"chat-archive-storage-fault.ts"
],
"oracleVersion": "paperclip.chat-catalog.repair-cost-recording.structural.v4",
"oracleVersion": "paperclip.chat-catalog.repair-cost-recording.structural.v5",
"expectedRunCount": 3,
"expectedHumanMessageCount": 2,
"turnTimeoutMs": 120000,
@@ -1349,12 +1349,14 @@
"scenarioId": "cases.close-case",
"sourceFiles": [
"chat-catalog-domains/integrator-case-close.ts",
"chat-catalog-domains/api-read-receipts.ts",
"run-observations.ts",
"chat-catalog-domains/integrator-support.ts",
"chat-catalog-domains/internal-support.ts",
"chat-catalog-domains/internal-definition.ts",
"chat-scenarios/cases.yaml"
],
"oracleVersion": "paperclip.chat-catalog.integrator.v1",
"oracleVersion": "paperclip.chat-catalog.integrator.close-case.v2",
"expectedRunCount": 7,
"expectedHumanMessageCount": 3,
"minimumRunCount": 6,
@@ -2097,7 +2099,7 @@
"chat-catalog-domains/internal-support.ts",
"chat-scenarios/companies.yaml"
],
"oracleVersion": "paperclip.chat-catalog.identity.import-preview.v2",
"oracleVersion": "paperclip.chat-catalog.identity.import-preview.v3",
"expectedRunCount": 2,
"expectedHumanMessageCount": 2,
"turnTimeoutMs": 120000,
@@ -4064,7 +4066,7 @@
"chat-catalog-domains/internal-task-creation.ts",
"lost-send.ts"
],
"oracleVersion": "paperclip.chat-catalog.internal.v1",
"oracleVersion": "paperclip.chat-catalog.internal.project-work.v2",
"coverageGaps": [],
"expectedRunCount": 3,
"minimumRunCount": 1,
@@ -5068,7 +5070,7 @@
"../../ui/src/pages/secrets/SetMyUserSecretDialog.tsx",
"chat-scenarios/secrets.yaml"
],
"oracleVersion": "paperclip.chat-catalog.personal-secret.v17",
"oracleVersion": "paperclip.chat-catalog.personal-secret.v18",
"expectedRunCount": 3,
"expectedHumanMessageCount": 2,
"minimumRunCount": 3,
@@ -5407,7 +5409,7 @@
"chat-catalog-domains/integrator-status-cards.ts",
"chat-scenarios/status-cards.yaml"
],
"oracleVersion": "paperclip.chat-catalog.status-card.other-authors.v2",
"oracleVersion": "paperclip.chat-catalog.status-card.other-authors.v3",
"expectedRunCount": 5,
"minimumRunCount": 2,
"expectedHumanMessageCount": 1,
@@ -5711,7 +5713,7 @@
"registered-artifact.ts",
"chat-scenarios/task-coordination.yaml"
],
"oracleVersion": "paperclip.chat-catalog.runtime-delegation.v4",
"oracleVersion": "paperclip.chat-catalog.runtime-delegation.v5",
"coverageGaps": [],
"expectedRunCount": 4,
"setupHumanMessageCount": 1,
@@ -5976,7 +5978,7 @@
"chat-catalog-domains/identity-auth.ts",
"chat-catalog-domains/identity-support.ts"
],
"oracleVersion": "paperclip.chat-catalog.internal.v1",
"oracleVersion": "paperclip.chat-catalog.internal.private-task.v2",
"coverageGaps": [],
"expectedRunCount": 3,
"minimumRunCount": 2,
@@ -56,3 +56,23 @@ it("rejects dedicated reads of other records, other tools, mismatched receipts a
expect(readRecord(getTask("task-uuid", 200).slice(0, 1), issueRead)).toBe(false);
expect(readRecord([...getTask("task-uuid", 200, "get_task", "run-a").slice(0, 1), ...getTask("task-uuid", 200, "get_task", "run-b").slice(1)], issueRead)).toBe(false);
});
// Full cohort 3 (cases.close-case): the chat read the closed case with the dedicated read_case tool,
// whose saved input is redacted to the digest of `{ caseId }`.
it("binds a dedicated read_case to the case by id, and rejects other cases and failures", () => {
const id = "c80a5b89-41a8-4e97-b812-e4081d92d53b", other = "d80a5b89-41a8-4e97-b812-e4081d92d53b";
const reads = [{ operationId: "GET /api/cases/{id}", pathParams: recordParams("id", id, "COM-C1") }];
const read = (caseId: string, status = 200, tool = "read_case") => savedCall({ caseId }, status, "2", "run-1", { id: caseId }, tool, "GET /api/cases/{id}");
expect(readRecord(read(id), reads)).toBe(true);
// Paid run 43143392360: the result turn read the case record page, `{ caseId, section: "case" }`.
const page = (input: Record<string, unknown>) => savedCall(input, 200, "2", "run-1", { id }, "read_case", "GET /api/cases/{id}");
for (const input of [{ caseId: id, section: "case" }, { caseId: id, section: "case", offsetBytes: 0 }, { caseId: id, section: "case", limitBytes: 8192 }])
expect(readRecord(page(input), reads), JSON.stringify(input)).toBe(true);
// Pages that may omit the case's status and completion do not count.
for (const input of [{ caseId: other, section: "case" }, { caseId: id, section: "document", documentKey: "review" },
{ caseId: id, section: "fields" }, { caseId: id, section: "case", limitBytes: 4 }])
expect(readRecord(page(input), reads), JSON.stringify(input)).toBe(false);
expect(readRecord(read(id, 200, "mcp__paperclip__read_case"), reads)).toBe(true);
expect(readRecord(read(other), reads)).toBe(false);
expect(readRecord(read(id, 404), reads)).toBe(false);
expect(readRecord(read(id, 200, "update_case"), reads)).toBe(false);
});
@@ -16,6 +16,15 @@ export const callApiInputDigest = (input: Record<string, unknown>) => nativeSha2
const objectRecord = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const PAGE_LIMITS = Array.from({ length: 1001 }, (_, index) => index).flatMap(limit => [{ limit }, { limit: String(limit) }]);
const cache = new Map<string, ReadonlySet<string>>();
const CASE_READ_OPERATION = "GET /api/cases/{id}";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// The first page of the case record section (the default), which carries the case's status and completion.
// Fields-only pages and byte-limited pages may omit them, so they do not count as reading the case.
// An explicit limit equal to the default page size (8 KiB, also the maximum) reads the same page.
const CASE_FIRST_PAGES: ReadonlyArray<Record<string, unknown>> = [undefined, "case"].flatMap(section => [undefined, 0].flatMap(offsetBytes =>
[undefined, 8 * 1024].map(limitBytes => ({ ...(section ? { section } : {}), ...(offsetBytes !== undefined ? { offsetBytes } : {}),
...(limitBytes !== undefined ? { limitBytes } : {}) }))));
const caseReadTool = (name: unknown) => typeof name === "string" && /^(?:[A-Za-z0-9_-]*__)?(?:paperclip_)?read_case$/.test(name);
export interface ApiRead {
operationId: string;
@@ -34,6 +43,10 @@ export function apiReadDigests(reads: readonly ApiRead[]): ReadonlySet<string> {
const digests = new Set<string>();
for (const read of reads) {
const queries: Array<Record<string, unknown> | undefined> = [undefined, {}, ...(read.paged ? PAGE_LIMITS : []), ...(read.queries ?? [])];
// The dedicated read_case tool saves its own redacted input and reports GET /api/cases/{id}. Its first page of
// the case record is `{ caseId, section?: "case", offsetBytes?: 0 }` (caseReadPageShape).
if (read.operationId === CASE_READ_OPERATION) for (const pathParams of read.pathParams)
if (pathParams?.id && UUID.test(pathParams.id)) for (const page of CASE_FIRST_PAGES) digests.add(callApiInputDigest({ caseId: pathParams.id, ...page }));
for (const pathParams of read.pathParams) for (const query of queries) {
digests.add(callApiInputDigest({ operationId: read.operationId, ...(pathParams ? { pathParams } : {}), ...(query ? { query } : {}) }));
if (!pathParams) digests.add(callApiInputDigest({ operationId: read.operationId, pathParams: {}, ...(query ? { query } : {}) }));
@@ -87,6 +100,14 @@ export function apiReadStatuses(events: readonly Record<string, unknown>[], dige
if (input.apiPayloadRedacted === true && typeof input.operationId === "string" && digests.has(String(input.apiPayloadSha256))) uses.set(`${run}:${String(item.id)}`, input.operationId);
continue;
}
if (item.type === "tool_use" && caseReadTool(item.name)) {
const input = objectRecord(item.input);
// Saved redacted as `read_case`; a namespaced name may keep its plaintext `{ caseId }`.
const digest = input.apiPayloadRedacted === true ? String(input.apiPayloadSha256)
: typeof input.caseId === "string" && Object.keys(input).length === 1 ? callApiInputDigest({ caseId: input.caseId }) : undefined;
if (digest && digests.has(digest)) uses.set(`${run}:${String(item.id)}`, CASE_READ_OPERATION);
continue;
}
const tool = item.type === "tool_use" ? taskReadTool(item.name) : undefined;
if (tool) {
const request = taskReadInput(tool, objectRecord(item.input));
@@ -202,3 +202,21 @@ it("accepts a secure Settings deictic destination followed by saving without adm
"Open Settings → Secrets. Paste the key there and save in a plaintext file.",
])expect(unsafeCredentialTransfer(unsafe),unsafe).toBe(true);
});
// Full cohort 3 (secrets.personal-secret): the reply linked the secure My secrets entry and said
// "Enter the value only there, never in chat". Chat is named only as an excluded destination.
it("reads a negated chat destination as excluded, wherever the secure target was named", () => {
const recorded = "To set it up, open [Secrets](/COM/company/settings/secrets) → **My secrets** → **Set my value** for `PERSONAL_PUBLISHING_KEY`. "
+ "Enter the value only there, never in chat; Riley's separate value won't satisfy yours.";
for (const text of [recorded,
"Use the Set my value form for your publishing key. Enter it there and not in this chat.",
"Enter the key on that page rather than here.",
]) expect(solicitsChatCredential(text), text).toBe(false);
for (const text of [
"You need your publishing key. Paste it here.",
"Paste your publishing key here.",
"Enter the key value here, never in a file.",
"Enter the token here, never in chat.",
`${recorded} Or just paste the key here.`,
]) expect(solicitsChatCredential(text), text).toBe(true);
});
@@ -5,14 +5,19 @@ function secureEntryExcludesChat(clause: string): boolean {
return !!excluded && !/\b(?:chat|conversation|message|here)\b/i.test(excluded[1]!) && secureTarget.test(excluded[1]!);
}
function positiveCredentialRequest(clause: string, hasKeyContext: boolean, needsChatDestination: boolean): boolean {
const request = needsChatDestination
const requestPattern = needsChatDestination
? /\b(?:paste|send|share|provide|enter)\b.{0,70}\b(?:key|credential|token)\b.{0,40}\b(?:chat|message|conversation|here)\b|\b(?:paste|send|share)\b.{0,20}\b(?:here|in chat)\b.{0,40}\b(?:key|credential|token)\b/i
: /\b(?:paste|send|share|provide|enter)\b.{0,70}\b(?:key|credential|token)\b/i;
const carried = needsChatDestination
? /\b(?:paste|send|share|provide|enter)\s+(?:(?:it|that|the value)\b.{0,40}\b(?:here|chat|message|conversation)\b|(?:here\b|(?:in|into|via|through)\s+(?:(?:the|this|your)\s+)?(?:chat|message|conversation)\b))/i
: /\b(?:paste|send|share|provide|enter)\s+(?:(?:it|that|the value)\b|(?:here\b|(?:in|into|via|through)\s+(?:(?:the|this|your)\s+)?(?:chat|message|conversation)\b))/i;
const match = request.exec(clause) ?? (hasKeyContext ? carried.exec(clause) : null);
const match = requestPattern.exec(clause) ?? (hasKeyContext ? carried.exec(clause) : null);
if (!match) return false;
// Negation scopes over the destination it precedes: "enter the value only there, never in chat" names
// chat only to exclude it. A chat destination counts only when at least one is not itself negated.
const request = clause.slice(match.index);
if (needsChatDestination && ![...request.matchAll(/\b(?:chat|message|conversation|here)\b/gi)].some(destination =>
!/\b(?:not|never|rather than|instead of)\s+(?:(?:in|into|via|through|to)\s+)?(?:(?:the|this|that|your|a)\s+)?$/i.test(request.slice(0, destination.index)))) return false;
const before = clause.slice(0, match.index).trim();
return !/(?:\b(?:do|should|must|can|could|will|would)\s+not(?:\s+ask\s+(?:you\s+)?to)?|\bdon.t|\bnever|\bnot)\s*$/i.test(before)
&& !/^\s*(?:please\s+)?(?:do not|don.t|never|you (?:should|must) not)\s+(?:(?:paste|send|share|provide|enter)\s*(?:,|or|and)\s*)*$/i.test(before);
@@ -319,6 +319,15 @@ it("keeps successful worked wording dependent on actual worker delivery",()=>{
});
it("declares the prospective personal acknowledgment calibration without changing its bounds", () => {
expect(personalSecretExternalCases[0]).toMatchObject({ oracleVersion: "paperclip.chat-catalog.personal-secret.v17",
expect(personalSecretExternalCases[0]).toMatchObject({ oracleVersion: "paperclip.chat-catalog.personal-secret.v18",
expectedRunCount: 3, minimumRunCount: 3, expectedHumanMessageCount: 2, attemptTimeoutMs: 660000 });
});
// Full cohort 3: the recorded guidance linked My secrets and excluded chat; a variant that also asks
// for the key in chat still fails.
it("accepts the recorded secure-entry guidance and rejects the same guidance that also asks for the key in chat", () => {
const recorded = "I can't set a personal credential value for you: Paperclip only accepts personal secret values from the signed-in person's secure entry.\n\n"
+ "To set it up, open [Secrets](/COM/company/settings/secrets) → **My secrets** → **Set my value** for `PERSONAL_PUBLISHING_KEY`. "
+ "Enter the value only there, never in chat; Riley's separate value won't satisfy yours. Once saved, [COM-1](/COM/issues/COM-1) (Morgan's publishing check) can leave backlog and run.";
expect(personalSecretGuidance(recorded)).toBe(true);
expect(personalSecretGuidance(recorded.replace("Enter the value only there, never in chat", "Enter the value there, or paste the key here"))).toBe(false);
});
@@ -461,7 +461,7 @@ export const personalSecretExternalCases = [
if (!o.state.initialQuestionId) return;
return { interactionId: String(o.state.initialQuestionId), sourceRunId: String(o.state.initialRunId), userId: String(o.state.currentPersonId) };
},
oracleVersion: "paperclip.chat-catalog.personal-secret.v17",
oracleVersion: "paperclip.chat-catalog.personal-secret.v18",
setupPolicy: { readiness: "preserve-scenario", invariantCheckIds: ["current-owner-missing-required-personal-value"] },
expectedRunCount: 3,
minimumRunCount: 3,
@@ -126,6 +126,17 @@ describe("post-retry read of the original run record", () => {
expect(readSavedAccounting([runRead("final", bound, 403)], "final", { originalRunId: original })).toBe(false);
expect(readSavedAccounting([runRead("initial", bound)], "final", { originalRunId: original })).toBe(false);
});
// Full cohort 3: the post-retry run re-read the original run with saveResponse; its input digest matched no
// listed shape, but the product saved the response as an asset logged by that run, whose content is that run record.
it("accepts an unlisted-digest re-read whose saved response is the original run record", () => {
const saved = { runId: "final", assetId: "asset", recordId: original, companyId: "company" };
expect(readSavedAccounting([runRead("final", "other-input")], "final", { companyId: "company", originalRunId: original, savedRunRecords: [saved] })).toBe(true);
// Broken: the saved record is another run, was saved by another run, belongs to another company, or the read failed.
for (const bad of [{ ...saved, recordId: own }, { ...saved, runId: "initial" }, { ...saved, companyId: "other" }])
expect(readSavedAccounting([runRead("final", "other-input")], "final", { companyId: "company", originalRunId: original, savedRunRecords: [bad] })).toBe(false);
expect(readSavedAccounting([runRead("final", "other-input", 404)], "final", { companyId: "company", originalRunId: original, savedRunRecords: [saved] })).toBe(false);
expect(readSavedAccounting([], "final", { companyId: "company", originalRunId: original, savedRunRecords: [saved] })).toBe(false);
});
it("extracts only exact run-record reads from the run's saved verification and evidence", () => {
expect(citedRunReads({ resultJson: { nativeResult: { verification: [{ commandOrCheck: `GET /api/heartbeat-runs/${original}` }], evidence: [{ ref: `GET /api/heartbeat-runs/${own}: costAccountingPending=false` }, { ref: "comment:x" }] } } }))
.toEqual([`GET /api/heartbeat-runs/${original}`, `GET /api/heartbeat-runs/${own}`]);
@@ -92,7 +92,12 @@ export function leadApiCalls(events: readonly Record<string, unknown>[]): LeadAp
const COMPANY_COST_SEGMENTS = ["summary", "by-agent", "by-agent-model", "by-provider", "by-biller", "by-project", "data-quality"];
export interface SavedAccountingTarget { companyId?: string; task?: Pick<Entity, "id" | "identifier">; originalRunId?: string;
/** Run-record reads the reading run itself cites in its saved result, as `GET /api/heartbeat-runs/<runId>`. */
citedRunReads?: readonly string[] }
citedRunReads?: readonly string[];
/** Run records this reading run saved with `saveResponse`, read back from the product's saved asset. */
savedRunRecords?: readonly SavedRunRecord[] }
/** A `call_api` read saved with `saveResponse` becomes a company asset that the product logs as
* `asset.created` by the reading run. Its saved content names the record actually read. */
export interface SavedRunRecord { runId: string; assetId: string; recordId: string; companyId: string }
export function readSavedAccounting(calls: readonly LeadApiCall[], runId: string, target: SavedAccountingTarget) {
const { companyId, task, originalRunId } = target;
const companyDigests = companyId ? apiReadDigests(COMPANY_COST_SEGMENTS.map(segment => ({ operationId: `GET /api/companies/{companyId}/costs/${segment}`,
@@ -102,9 +107,30 @@ export function readSavedAccounting(calls: readonly LeadApiCall[], runId: string
// A re-read of the run record can carry extra input fields that change its digest. It still counts when the reading
// run's own saved result names exactly that read of the original run; a read of any other run does not.
const originalRunCited = !!originalRunId && (target.citedRunReads ?? []).some(ref => ref === `GET /api/heartbeat-runs/${originalRunId}`);
// The input digest covers every field the model sent (e.g. saveResponse), so a valid re-read can have an
// unlisted digest. A response the run saved is bound by its content instead: the saved run record is the original run.
const originalRunSaved = !!originalRunId && (target.savedRunRecords ?? []).some(saved => saved.runId === runId
&& saved.recordId === originalRunId && (!companyId || saved.companyId === companyId));
return calls.some(call => call.runId === runId && call.ok && call.status >= 200 && call.status < 300
&& (companyDigests.has(call.digest) || taskDigests.has(call.digest)
|| call.operationId === "GET /api/heartbeat-runs/{runId}" && (runDigests.has(call.digest) || originalRunCited)));
|| call.operationId === "GET /api/heartbeat-runs/{runId}" && (runDigests.has(call.digest) || originalRunCited || originalRunSaved)));
}
/** Run records the given run saved through `call_api` `saveResponse`, from the product's own activity and asset. */
export async function savedRunRecordReads(ctx: ChatCatalogContext, runId: string, agentId: string): Promise<SavedRunRecord[]> {
const companyId = ctx.input.fixtures.company.id;
const activity = await ctx.input.api.get<Array<{ action?: string; entityId?: string; runId?: string | null; agentId?: string | null; details?: Record<string, unknown> | null }>>(
`/api/companies/${companyId}/activity?limit=1000`);
const saved: SavedRunRecord[] = [];
for (const row of activity.filter(row => row.action === "asset.created" && row.runId === runId && row.agentId === agentId
&& row.details?.source === "runner.call_api" && typeof row.entityId === "string").slice(0, 10)) {
const body = await ctx.input.api.bytes(`/api/assets/${row.entityId}/content`).catch(() => undefined);
if (!body || body.byteLength > 4_194_304) continue;
let record: Record<string, unknown>; try { record = objectRecord(JSON.parse(body.toString("utf8"))); } catch { continue; }
// A heartbeat run record: its own id, company, agent and status.
if (typeof record.id === "string" && typeof record.companyId === "string" && typeof record.agentId === "string" && typeof record.status === "string")
saved.push({ runId, assetId: row.entityId!, recordId: record.id, companyId: record.companyId });
}
return saved;
}
/** `GET /api/heartbeat-runs/<runId>` reads a run cites as its own verification or evidence. */
export function citedRunReads(run: { resultJson?: unknown } | undefined) {
@@ -179,7 +205,8 @@ export function gradeAccounting(value: unknown) {
// saved accounting: the post-retry run must read it through a product cost projection.
const calls = Array.isArray(o.state.leadApiCalls) ? o.state.leadApiCalls as LeadApiCall[] : undefined;
const preflight = o.state.preflightTask as Entity | undefined;
c.add("post-retry-reply-reads-saved-accounting", !!calls && !!finalRun && readSavedAccounting(calls, finalRun.id, { companyId: o.companyId, task: preflight, originalRunId: String(o.state.originalRunId ?? ""), citedRunReads: citedRunReads(finalRun) }),
const savedRunRecords = Array.isArray(o.state.savedRunRecords) ? o.state.savedRunRecords as SavedRunRecord[] : [];
c.add("post-retry-reply-reads-saved-accounting", !!calls && !!finalRun && readSavedAccounting(calls, finalRun.id, { companyId: o.companyId, task: preflight, originalRunId: String(o.state.originalRunId ?? ""), citedRunReads: citedRunReads(finalRun), savedRunRecords }),
"After the human retry, the chat run reads the saved cost projections before explaining whether the cost is recorded or still unresolved.");
const initialIds = Array.isArray(o.state.initialReplyCommentIds) ? o.state.initialReplyCommentIds as string[] : [];
c.add("initial-guidance-links-saved-cost-retry", initialIds.length > 0 && costsPageLinked(o, initialIds),
@@ -215,6 +242,7 @@ async function observeAccounting(ctx: ChatCatalogContext, state: Record<string,
calls.push(...leadApiCalls(events.map(row => ({ ...row, runId: row.runId ?? run.id }))));
}
state.leadApiCalls = calls;
state.savedRunRecords = await savedRunRecordReads(ctx, String(state.finalReplyRunId), ctx.input.fixtures.agent.id);
}
return observeIdentity(ctx, state);
}
@@ -257,7 +285,7 @@ export const accountingIdentityCases: readonly ChatCatalogCase[] = [
return { ...state };
} catch (error) { const fault = accountingFaults.get(ctx.input.fixtures.company.id); accountingFaults.delete(ctx.input.fixtures.company.id); await fault?.close(); throw error; }
}, gradeAccounting, {
authentication: "human-session", oracleVersion: "paperclip.chat-catalog.repair-cost-recording.structural.v4", sourceFiles: repairSources,
authentication: "human-session", oracleVersion: "paperclip.chat-catalog.repair-cost-recording.structural.v5", sourceFiles: repairSources,
async cleanup(ctx) { const fault = accountingFaults.get(ctx.input.fixtures.company.id); accountingFaults.delete(ctx.input.fixtures.company.id); await fault?.close(); }, expectedRunCount: 3, expectedHumanMessageCount: 2, attemptTimeoutMs: 780_000, observe: observeAccounting, verifyResult: verifyAccountingLink, verifySetup: verifyUnresolvedSetup,
setupPolicy: { funding: "standard", readiness: "all-ready", invariantCheckIds: ["genuine-unresolved-saved-native-usage"] },
async act(ctx, state) {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { gradePortability, savedAgentConfiguration } from "./identity-portability.js";
import { agentRouteNames, gradePortability, savedAgentConfiguration } from "./identity-portability.js";
import type { IdentityObservation } from "./identity-support.js";
function valid(scenarioId: string): IdentityObservation {
const o: IdentityObservation = { scenarioId, origin: "http://fixture.invalid", companyId: "acorn", leadId: "ceo", company: { id: "acorn" }, companies: [], agents: [], tasks: [], projects: [], goals: [], comments: [], interactions: [], documents: [], revisions: [], activity: [], links: [], runs: [], humanQueries: [], reply: "Secrets are excluded; supply EXPORT_FIXTURE_KEY when importing.", state: {} };
@@ -48,3 +48,16 @@ describe("actual company package download and conflict-safe import", () => {
expect(savedAgentConfiguration({ ...before, adapterType: "process", configurationRevision: "b" })).not.toBe(savedAgentConfiguration(before));
});
});
// Full cohort 3 (companies.import-preview): the reply linked the imported employee by its url key.
describe("imported employee review link", () => {
const origin = "http://127.0.0.1:3100", imported = { id: "6b1c2f4e-0000-4000-8000-000000000001", urlKey: "morgan-package" };
it("accepts the agent's id route, its url-key route and a tab of either", () => {
for (const href of [`/COM/agents/${imported.id}`, "/COM/agents/morgan-package", "/COM/agents/morgan-package/overview", `${origin}/com/agents/${imported.id}/runs`, "/agents/morgan-package"])
expect(agentRouteNames(href, origin, "COM", imported), href).toBe(true);
});
it("rejects the existing Morgan, another company, another origin and non-agent routes", () => {
for (const href of ["/COM/agents/morgan", "/COM/agents/00000000-0000-4000-8000-000000000002", "/OTH/agents/morgan-package", "https://example.com/COM/agents/morgan-package",
"/COM/issues/morgan-package", "/COM/agents", "/COM/agents/morgan-package/runs/x/y", "/COM/agents/%E0%A4%A"])
expect(agentRouteNames(href, origin, "COM", imported), href).toBe(false);
});
});
@@ -19,6 +19,16 @@ export function savedAgentConfiguration(value: unknown) {
const { status: _status, updatedAt: _updatedAt, ...configuration } = (value ?? {}) as Record<string, unknown>; return JSON.stringify(configuration);
}
function text(entry: CompanyPortabilityFileEntry) { return typeof entry === "string" ? entry : Buffer.from(entry.data, "base64").toString("utf8"); }
/** A rendered link names this agent when it opens one of its agent pages: `/<prefix>/agents/<id or url key>[/<tab>]`
* on this origin and company. The product routes agents by url key (e.g. `/COM/agents/morgan-package`) as well as id. */
export function agentRouteNames(href: string, origin: string, prefix: string, agent: { id: string; urlKey?: string }) {
let url: URL, parts: string[];
try { url = new URL(href, origin); parts = url.pathname.split("/").filter(Boolean).map(part => decodeURIComponent(part)); } catch { return false; }
if (url.origin !== new URL(origin).origin) return false;
const at = parts[0]?.toLowerCase() === prefix.toLowerCase() ? 1 : 0;
return parts[at] === "agents" && parts.length >= at + 2 && parts.length <= at + 3
&& (parts[at + 1] === agent.id || !!agent.urlKey && parts[at + 1] === agent.urlKey);
}
export function gradePortability(value: unknown) {
const c = checks(); const o = identityObservation(value); c.add("complete-portability-evidence", !!o, "Actual package files, public provenance, permitted actor and independently saved resources are required."); if (!o) return c.result();
if (o.scenarioId === "companies.export-company") {
@@ -110,7 +120,7 @@ export const portabilityIdentityCases: readonly ChatCatalogCase[] = [
return { sourceId: source.id, existing, existingConfiguration, sourcePackage, packageAttachment, actualPackageAttached: true,
beforeAgentIds: (await ctx.input.api.get<Entity[]>(`/api/companies/${ctx.input.fixtures.company.id}/agents`)).map(row => row.id) };
}, gradePortability, {
authentication: "human-session", sourceFiles: sources, oracleVersion: "paperclip.chat-catalog.identity.import-preview.v2", queryAttachments: [{ stateKey: "packageAttachment", filename: "company-package.json" }], expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 420_000,
authentication: "human-session", sourceFiles: sources, oracleVersion: "paperclip.chat-catalog.identity.import-preview.v3", queryAttachments: [{ stateKey: "packageAttachment", filename: "company-package.json" }], expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 420_000,
async act(ctx, state) {
await waitForReply(ctx); const root = `/api/companies/${ctx.input.fixtures.company.id}`;
const current = await ctx.input.api.get<Entity[]>(`${root}/agents`); state.beforeChoiceUnchanged = JSON.stringify(current.map(row => row.id).sort()) === JSON.stringify([...(state.beforeAgentIds as string[])].sort())
@@ -137,7 +147,8 @@ export const portabilityIdentityCases: readonly ChatCatalogCase[] = [
async verifyResult(ctx, state) {
if (typeof state.importedAgentId !== "string") throw new Error("Actual imported employee is missing"); const source = ctx.input.page.url();
const links = await ctx.input.page.locator('[id^="comment-"] a[href]').evaluateAll(rows => rows.map(row => row.getAttribute("href")!));
const link = links.find(href => new URL(href, ctx.input.api.baseURL).pathname.endsWith(`/agents/${state.importedAgentId}`));
const imported = (await ctx.input.api.get<Array<Entity & { urlKey?: string }>>(`/api/companies/${ctx.input.fixtures.company.id}/agents`)).find(row => row.id === state.importedAgentId);
const link = imported && links.find(href => agentRouteNames(href, ctx.input.api.baseURL, ctx.input.fixtures.company.issuePrefix ?? "", imported));
if (!link) throw new Error("Real imported employee review link is missing from reply");
await ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(link)}]`).first().click();
await expect(ctx.input.page.getByText("Morgan Package", { exact: true }).first()).toBeVisible(); state.importedAgentVisible = true;
@@ -1,12 +1,18 @@
import { describe, expect, it } from "vitest";
import { gradeIntegratorCaseClose } from "./integrator-case-close.js";
function valid() {
const original = { id: "case", workIssueId: "primary", status: "draft", completedAt: null };
const original = { id: "case", identifier: "COM-C1", workIssueId: "primary", status: "draft", completedAt: null };
const review = { id: "review", issueId: "primary", latestRevisionId: "reviewed", body: "Accepted: correction permits activation once and retains the Pay name." };
const base = { scenarioId: "cases.close-case", origin: "https://fixture.test", companyId: "company", leadId: "ceo", state: { case: original, task: { id: "primary", status: "done" }, worker: { id: "morgan" }, review, reviewRunId: "review-native", firstReplyTime: "2026-10-26T10:00:00Z" },
tasks: [{ id: "primary", status: "done" }], projects: [], goals: [], documents: [], revisions: [], comments: [], links: [], activity: [], humanQueries: [],
const base = { scenarioId: "cases.close-case", origin: "https://fixture.test", companyId: "company", leadId: "ceo", state: { case: original, task: { id: "primary", status: "done" }, worker: { id: "morgan" }, review, reviewRunId: "review-native", firstReplyTime: "2026-10-26T10:00:00Z",
resultTurn: { questionCommentId: "question", commentIds: ["result-reply"], runIds: ["result-run"], readSavedCase: true,
replies: [{ id: "result-reply", authorAgentId: "ceo", createdByRunId: "result-run" }], companyPrefix: "COM" } },
tasks: [{ id: "primary", status: "done" }], projects: [], goals: [], documents: [], revisions: [], links: [], activity: [], humanQueries: [],
// The observation's comments are the primary task's, never the standing chat's.
comments: [{ id: "task-comment", authorAgentId: "morgan", createdByRunId: "terminal-native" }],
renderedLinkEvidence: [{ threadTestId: "task-chat-thread", href: "/COM/cases/COM-C1", source: "reply", conversationId: "chat", anchorCommentId: "result-reply", commentId: "result-reply" }],
caseRecord: { ...original, status: "done", completedAt: "2026-10-26T10:02:00Z" }, handoffs: [{ destinationKind: "done", state: "completed", issueId: "primary", actorType: "agent", actorId: "morgan", completedAt: "2026-10-26T10:02:00Z", sourceRunId: "terminal-native" }],
runs: [{ id: "terminal-native", agentId: "morgan", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "primary" } }],
runs: [{ id: "terminal-native", agentId: "morgan", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "primary" } },
{ id: "result-run", agentId: "ceo", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "chat" } }],
finalDocument: review, finalRevisions: [{ id: "reviewed", body: review.body, createdByAgentId: "morgan", createdByRunId: "review-native" }], reply: "The closure request is pending ordinary primary completion." };
const result = { ...structuredClone(base), reply: `${base.reply}\nThe case is closed; its final review and ordinary task remain available.` };
return { ...base, reply: `${result.reply}\nThe other case cannot close: its required review is unresolved.`, negativeCase: { id: "negative", workIssueId: "secondary", status: "draft", completedAt: null },
@@ -37,4 +43,21 @@ describe("explicit case terminal completion with normal review gates", () => {
const past = valid(); past.state.acceptedResult.reply = `${past.state.initialDirection.reply}\nI closed the case.`; past.reply = `${past.state.acceptedResult.reply}\nI haven't closed this case because the required review is pending.`; expect(gradeIntegratorCaseClose(past).passed).toBe(true);
const noGate = valid(); noGate.blockerEvidence.blockers = []; expect(gradeIntegratorCaseClose(noGate).passed).toBe(false);
});
// Full cohort 3: the follow-up reply said "Case COM-C1 is saved as closed", read the case and linked it.
// The follow-up turn is graded on its saved replies, its run's case read and its rendered case link, not its wording.
it("accepts any wording of a grounded result turn, and rejects an ungrounded or unlinked one", () => {
for (const text of ["Case COM-C1 is saved as closed:\n\n- **Status:** done", "Saved result: COM-C1 finished (status done, completed 00:54)."]) {
const worded = valid(); worded.state.acceptedResult.reply = `${worded.state.initialDirection.reply}\n${text}`;
worded.reply = `${worded.state.acceptedResult.reply}\nThe other case cannot close: its required review is unresolved.`; expect(gradeIntegratorCaseClose(worded).passed, text).toBe(true);
}
const unread = valid(); unread.state.resultTurn.readSavedCase = false; expect(gradeIntegratorCaseClose(unread).passed).toBe(false);
const silent = valid(); silent.state.resultTurn.commentIds = []; expect(gradeIntegratorCaseClose(silent).passed).toBe(false);
const foreign = valid(); foreign.state.resultTurn.replies[0]!.authorAgentId = "morgan"; expect(gradeIntegratorCaseClose(foreign).passed).toBe(false);
const unlinked = valid(); unlinked.renderedLinkEvidence[0]!.href = "/COM/issues/COM-1"; expect(gradeIntegratorCaseClose(unlinked).passed).toBe(false);
const lowercase = valid(); lowercase.renderedLinkEvidence[0]!.href = "/com/cases/COM-C1"; expect(gradeIntegratorCaseClose(lowercase).passed).toBe(true);
const wrongPrefix = valid(); wrongPrefix.renderedLinkEvidence[0]!.href = "/TYPO/cases/COM-C1"; expect(gradeIntegratorCaseClose(wrongPrefix).passed).toBe(false);
const otherCase = valid(); otherCase.renderedLinkEvidence[0]!.href = "/COM/cases/COM-C2"; expect(gradeIntegratorCaseClose(otherCase).passed).toBe(false);
const firstTurnLink = valid(); firstTurnLink.renderedLinkEvidence[0]!.commentId = "first-reply"; expect(gradeIntegratorCaseClose(firstTurnLink).passed).toBe(false);
const open = valid(); open.state.acceptedResult.caseRecord = { ...open.state.acceptedResult.caseRecord, status: "in_progress", completedAt: "" }; expect(gradeIntegratorCaseClose(open).passed).toBe(false);
});
});
@@ -4,6 +4,8 @@ import type { CaseHandoff, HandoffCaseResult } from "../../../packages/shared/sr
import type { Issue, IssueDocument, DocumentRevision, IssueBlockerDiagnosticsResponse } from "../../../packages/shared/src/types/issue.js";
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
import { pollUntil } from "../api.js";
import { collectRunEvents } from "../run-observations.js";
import { readRecord, recordParams } from "./api-read-receipts.js";
import { checks, observation, project, task, type Entity, type InternalObservation } from "./internal-support.js";
import { fixtureWorker } from "./internal-definition.js";
import { integratorDefinition, inspectResult, observeInternal } from "./integrator-support.js";
@@ -26,6 +28,39 @@ function closedClaim(reply: string, selected?: string, other?: string) {
&& !/\b(?:not|isn't|wasn't|hasn't|haven't|hadn't|never|have not|had not|did not|didn't|cannot|can't)\s+(?:yet\s+)?(?:close(?:d)?|complete(?:d)?)\b/i.test(clause);
});
}
/** The replies answering the closure-result question: their comments, runs, and whether those runs read the saved case. */
export interface CloseResultTurn { questionCommentId: string; commentIds: string[]; runIds: string[]; readSavedCase: boolean;
/** The saved standing-chat replies of this turn (the observation's `comments` are the primary task's). */
replies: Array<{ id: string; authorAgentId?: string | null; createdByRunId?: string | null }>;
/** The company's route prefix; a case link must open this company's case page. */
companyPrefix: string }
export function closeResultTurnGrounded(o: Pick<CloseObservation, "origin" | "renderedLinkEvidence" | "leadId" | "runs">, turn: CloseResultTurn | undefined, row: Pick<CaseRecord, "id" | "identifier">) {
if (!turn || !turn.commentIds.length || !turn.readSavedCase || !Array.isArray(turn.replies)) return false;
const replies = turn.replies.filter(comment => turn.commentIds.includes(comment.id));
return replies.length === turn.commentIds.length && replies.every(comment => comment.authorAgentId === o.leadId && turn.runIds.includes(String(comment.createdByRunId)))
&& turn.runIds.every(id => o.runs.some(run => run.id === id && run.agentId === o.leadId && run.status === "succeeded"))
&& (o.renderedLinkEvidence ?? []).some(link => {
if (!link.commentId || !turn.commentIds.includes(link.commentId)) return false;
try {
const url = new URL(link.href, o.origin), parts = url.pathname.split("/").filter(Boolean);
return url.origin === new URL(o.origin).origin && parts.length === 3 && !!turn.companyPrefix && parts[0].toUpperCase() === turn.companyPrefix.toUpperCase()
&& parts[1] === "cases" && [row.id, row.identifier].includes(parts[2]!);
} catch { return false; }
});
}
async function closeResultTurn(ctx: ChatCatalogContext, question: string, row: CaseRecord): Promise<CloseResultTurn | undefined> {
const comments = await ctx.input.api.get<Entity[]>(`/api/issues/${ctx.issue()!.id}/comments?order=asc`);
const at = comments.map(comment => !comment.authorAgentId && comment.body?.trim() === question).lastIndexOf(true); if (at < 0) return;
const next = comments.findIndex((comment, index) => index > at && !comment.authorAgentId);
const replies = comments.slice(at + 1, next < 0 ? undefined : next).filter(comment => comment.authorAgentId === ctx.input.fixtures.agent.id && typeof comment.createdByRunId === "string");
const runIds = [...new Set(replies.map(comment => String(comment.createdByRunId)))];
const events = (await Promise.all(runIds.map(async runId => (await collectRunEvents<Record<string, unknown>>(afterSeq =>
ctx.input.api.get(`/api/heartbeat-runs/${runId}/events?afterSeq=${afterSeq}&limit=1000`))).map(event => ({ ...event, runId: event.runId ?? runId }))))).flat();
return { questionCommentId: comments[at]!.id, commentIds: replies.map(comment => comment.id), runIds,
replies: replies.map(comment => ({ id: comment.id, authorAgentId: comment.authorAgentId, createdByRunId: comment.createdByRunId })),
companyPrefix: String(ctx.input.fixtures.company.issuePrefix ?? ""),
readSavedCase: readRecord(events, [{ operationId: "GET /api/cases/{id}", pathParams: recordParams("id", row.id, row.identifier) }]) };
}
export function gradeIntegratorCaseClose(value: unknown) {
const c = checks(); const o = closeObservation(value); c.add("actual-primary-completion-and-terminal-ledger", !!o, "Requires original ordinary primary, genuine accepted review, terminal handoff and preserved documents."); if (!o) return c.result();
const original = o.state.case as CaseRecord; const originalTask = o.state.task as Issue; const owner = o.state.worker as Entity;
@@ -43,9 +78,12 @@ export function gradeIntegratorCaseClose(value: unknown) {
&& /accept|pass/i.test(review.body) && !/not accepted|not passed|failed/i.test(review.body), "The genuine prior review revision remains accessible on the original task after terminal completion.");
const first = closeObservation(o.state.initialDirection); const firstReplyTime = o.state.firstReplyTime as string | undefined;
const result = closeObservation(o.state.acceptedResult);
const resultText = result && first && result.reply.startsWith(first.reply) ? result.reply.slice(first.reply.length) : "";
// The follow-up turn is graded on its own saved replies, not on wording: after the case closed, the replying
// run read the saved case record and its reply renders a link to that case.
const turn = o.state.resultTurn as CloseResultTurn | undefined;
c.add("truthful-actual-close-result", !!first && (!closedClaim(first.reply) || !!terminal?.completedAt && !!firstReplyTime && Date.parse(String(terminal.completedAt)) <= Date.parse(firstReplyTime))
&& !!result && closedClaim(resultText) && result.caseRecord.status === "done", "A pending first acknowledgement is permitted; the explicit actual-result follow-up must report real closure, never task completion alone.");
&& !!result && result.caseRecord.status === "done" && !!result.caseRecord.completedAt && closeResultTurnGrounded(o, turn, original),
"A pending first acknowledgement is permitted; the explicit actual-result follow-up is a separate reply whose run read the saved closed case and which links that case.");
const later = result && o.reply.startsWith(result.reply) ? o.reply.slice(result.reply.length) : "";
c.add("unresolved-required-review-does-not-close", !!o.negativeCase && !!o.negativeTask && !!o.pendingReview && o.negativeCase.workIssueId === o.negativeTask.id
&& o.negativeCase.status !== "done" && !o.negativeCase.completedAt && o.negativeTask.status !== "done" && o.pendingReview.status === "backlog"
@@ -75,8 +113,8 @@ export const integratorCaseCloseCases = [integratorDefinition("cases", "cases.cl
}, observeClose, gradeIntegratorCaseClose, async (ctx, state) => {
const row = state.case as CaseRecord; const primary = state.task as Issue; await inspectResult(ctx, `/${ctx.input.fixtures.company.issuePrefix}/cases/${row.identifier}`, row.title);
await inspectResult(ctx, `/${ctx.input.fixtures.company.issuePrefix}/issues/${primary.identifier}`, primary.title);
}, { expectedRunCount: 7, minimumRunCount: 6, expectedHumanMessageCount: 3, attemptTimeoutMs: 1_500_000, cleanup,
sourceFiles: ["chat-catalog-domains/integrator-case-close.ts", "chat-catalog-domains/integrator-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/internal-definition.ts", "chat-scenarios/cases.yaml"],
}, { oracleVersion: "paperclip.chat-catalog.integrator.close-case.v2", expectedRunCount: 7, minimumRunCount: 6, expectedHumanMessageCount: 3, attemptTimeoutMs: 1_500_000, cleanup,
sourceFiles: ["chat-catalog-domains/integrator-case-close.ts", "chat-catalog-domains/api-read-receipts.ts", "run-observations.ts", "chat-catalog-domains/integrator-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/internal-definition.ts", "chat-scenarios/cases.yaml"],
async act(ctx, state) {
const first = await pollUntil({ label: "initial explicit closure direction", deadlineAt: Date.now() + 180_000, intervalMs: 1000, load: () => observeClose(ctx, state),
accept: o => ctx.issue()?.conversationState === "waiting" && o.runs.some(run => run.agentId === o.leadId && run.contextSnapshot?.issueId === ctx.issue()?.id && run.status === "succeeded") });
@@ -85,8 +123,10 @@ export const integratorCaseCloseCases = [integratorDefinition("cases", "cases.cl
await ctx.input.evidence("chat-case-terminal-direction.json", state.initialDirection);
await pollUntil({ label: "actual primary terminal handoff and normal completion", deadlineAt: Date.now() + 240_000, intervalMs: 1000, load: () => observeClose(ctx, state),
accept: o => o.caseRecord.status === "done" && o.handoffs.some(handoff => handoff.destinationKind === "done" && handoff.state === "completed") && o.runs.length === 3 && o.runs.every(run => run.status === "succeeded") });
const { sendChatMessage } = await import("../chat-flow.js"); await sendChatMessage(ctx.input.page, `What is the actual saved closure result for case ${(state.case as CaseRecord).identifier}?`);
const question = `What is the actual saved closure result for case ${(state.case as CaseRecord).identifier}?`;
const { sendChatMessage } = await import("../chat-flow.js"); await sendChatMessage(ctx.input.page, question);
const result = await pollUntil({ label: "actual terminal result reported in chat", deadlineAt: Date.now() + 180_000, intervalMs: 1000, load: () => observeClose(ctx, state), accept: o => o.runs.length === 4 && o.runs.every(run => run.status === "succeeded") && ctx.issue()?.conversationState === "waiting" });
state.resultTurn = await closeResultTurn(ctx, question, state.case as CaseRecord);
state.acceptedResult = { ...result, state: structuredClone(state) }; await ctx.input.evidence("chat-case-terminal-result.json", state.acceptedResult);
const pendingReview = await task(ctx, "Required independent review remains unresolved", (state.project as Entity).id); state.pendingReview = pendingReview;
const second = await ctx.input.api.post<HandoffCaseResult>(`/api/companies/${ctx.input.fixtures.company.id}/case-handoffs`, { idempotencyKey: `unreviewed-${ctx.input.nonce}`, case: { create: { caseType: "customer", title: "Unreviewed separate correction", projectId: (state.project as Entity).id } },
@@ -330,7 +330,7 @@ function define(id: string, seed: "none" | "ceo" | "morgan", extra: Partial<Chat
export const integratorStatusCardsCases: readonly ChatCatalogCase[] = [
define("status-cards.create-card", "none", { oracleVersion: "paperclip.chat-catalog.status-card.create.v2" }),
define("status-cards.archive-card", "ceo", { oracleVersion: "paperclip.chat-catalog.status-card.archive.v3", authentication: "human-session", queryAttachments: factualContextQueryAttachment("selected-status-card.md"), expectedRunCount: 3, attemptTimeoutMs: 720_000 }),
define("status-cards.other-authors-card", "morgan", { oracleVersion: "paperclip.chat-catalog.status-card.other-authors.v2", expectedRunCount: 5, minimumRunCount: 2 }),
define("status-cards.other-authors-card", "morgan", { oracleVersion: "paperclip.chat-catalog.status-card.other-authors.v3", expectedRunCount: 5, minimumRunCount: 2 }),
define("status-cards.explain-card", "ceo", { oracleVersion: "paperclip.chat-catalog.status-card.explain.v5", authentication: "human-session", queryAttachments: factualContextQueryAttachment("selected-status-card.md"), expectedRunCount: 5, minimumRunCount: 3, expectedHumanMessageCount: 2, attemptTimeoutMs: 720_000,
sourceFiles: ["chat-catalog-domains/integrator-status-cards.ts", "chat-catalog-domains/api-read-receipts.ts", "chat-catalog-domains/identity-support.ts", "chat-catalog-domains/internal-definition.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/integrator-support.ts", "run-observations.ts", "chat-scenarios/status-cards.yaml"],
async act(ctx, state) {
@@ -49,7 +49,7 @@ function privateDelegation(): ChatCatalogCase {
await putDocument(ctx, source.id, `# Confidential launch brief\n${confidentialMarker}. Keep the announcement inside Private Launch and its authorized team.\n`);
return { project: p, worker, ownerId, observerId: observer.id, sourceId: source.id, confidentialMarker };
}, gradePrivateDelegation);
return { ...base, authentication: "human-session", expectedRunCount: 3, minimumRunCount: 2,
return { ...base, oracleVersion: "paperclip.chat-catalog.internal.private-task.v2", authentication: "human-session", expectedRunCount: 3, minimumRunCount: 2,
runPolicy: { kind: "source-settled-with-active-work", maximumActiveWorkerRuns: 1 },
sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-private-delegation.ts", "chat-catalog-domains/internal-privacy.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/identity-support.ts"],
async observe(ctx, state): Promise<PrivateDelegationObservation> {
@@ -112,7 +112,8 @@ function creationDefinition(scenarioId: string): ChatCatalogCase {
if (scenarioId === "tasks.website-without-project") interception = await dropChatSendAcknowledgement(ctx.input.page, ctx.scenario.query);
return { worker, priorTasks };
}, gradeDelegatedCreation);
return { ...base, observe: observeCreation, sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-task-creation.ts", "lost-send.ts"],
return { ...base, ...(scenarioId === "projects.project-work" ? { oracleVersion: "paperclip.chat-catalog.internal.project-work.v2" } : {}),
observe: observeCreation, sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-task-creation.ts", "lost-send.ts"],
expectedRunCount: 3, minimumRunCount: scenarioId === "projects.project-work" ? 1 : 2,
runPolicy: { kind: "source-settled-with-active-work", maximumActiveWorkerRuns: 1 },
async act(ctx, state) {
@@ -144,7 +144,7 @@ export const runtimeDelegationCases = [runtimeDefinition("task-coordination.dele
humanId: session.user.id, briefCommentId: briefComment.id, briefAttachment: attachment, briefHash: hash(Buffer.from(brief)),
briefDownloadedHash: hash(await ctx.input.api.getAttachmentContent(attachment.id)), sourceId: ctx.issue()!.id };
}, gradeDelegation, {
oracleVersion: "paperclip.chat-catalog.runtime-delegation.v4",
oracleVersion: "paperclip.chat-catalog.runtime-delegation.v5",
authentication: "human-session", setupHumanMessageCount: 1, expectedHumanMessageCount: 2, expectedRunCount: 4, attemptTimeoutMs: 900_000,
sourceFiles: ["chat-catalog-domains/runtime-delegation.ts", "chat-catalog-domains/runtime-busy-worker.ts", "chat-catalog-domains/runtime-support.ts", "chat-catalog-domains/identity-support.ts", "completion-update-flow.ts", "completion-updates.ts", "registered-artifact.ts", "chat-scenarios/task-coordination.yaml"],
async act(ctx, state) {
@@ -19,7 +19,8 @@ vi.mock("./chat-catalog-registry.js", () => ({
vi.mock("./chat-catalog-fingerprint.js", () => ({ chatCatalogCaseFingerprint: () => ({}), normalizedChatCatalogExecution: () => ({}) }));
vi.mock("./chat-query-bindings.js", () => ({ resolveChatCatalogQuery: async () => { if (control.completeQuery) return { renderedQuery: "Original request" }; throw new Error("Original attempt failed"); } }));
vi.mock("./chat-rendered-links.js", () => ({ collectConversationLinks: async () => [] }));
vi.mock("./chat-catalog-run-policy.js", () => ({ gradeChatCatalogRuns: () => ({ checks: [] }) }));
vi.mock("./chat-catalog-run-policy.js", () => ({ gradeChatCatalogRuns: () => ({ checks: [] }), CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS: 1,
chatCatalogProviderRuns: (runs: unknown[]) => runs, chatCatalogSupersededQueuedRuns: () => [], chatCatalogReassignedQueuedRun: () => false }));
vi.mock("./chat-flow.js", () => ({ sendChatMessage: async () => {}, collectChatRunEvidence: async () => { control.enter(); if (control.stall) await control.collectorRelease; return {}; } }));
vi.mock("./api.js", () => ({ pollUntil: async ({ load }: { load: () => Promise<unknown> }) => { await load(); throw new Error("Original attempt failed"); } }));
import { runChatCatalogFlow } from "./chat-catalog-flow.js";
+17 -6
View File
@@ -6,7 +6,7 @@ import { chatNativeTimeoutEvidence } from "./chat-native-timeout-policy.js";
import { completeNativeSlackFileConditional, dispatchNativeSlackIngress, prepareNativeSlackOtherThread, slackOtherThreadBoundaryMatches, slackNativeRunCheck } from "./chat-slack-ingress.js";
import { runScopedCompanionSurface } from "./chat-companion-surface.js";
import { chatQueryWithAttachments, resolveChatQueryAttachments, verifyChatQueryAttachments, type ChatQueryAttachmentReceipt } from "./chat-query-attachments.js";
import { chatCatalogNeverStartedTerminalTaskRun, chatCatalogRunStatusAllowed, gradeChatCatalogRuns, createChatCatalogFinalRunVerifier } from "./chat-catalog-run-policy.js";
import { CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS, chatCatalogNeverStartedTerminalTaskRun, chatCatalogProviderRuns, chatCatalogReassignedQueuedRun, chatCatalogRunStatusAllowed, chatCatalogSupersededQueuedRuns, gradeChatCatalogRuns, createChatCatalogFinalRunVerifier } from "./chat-catalog-run-policy.js";
import { chatCatalogCaseFingerprint, normalizedChatCatalogExecution } from "./chat-catalog-fingerprint.js";
import { expect, type Page } from "@playwright/test";
import { pollUntil } from "./api.js";
@@ -22,6 +22,12 @@ import { markReturningBrowser } from "./chat-returning-browser.js";
/** The case owns product semantics; this flow owns the actual chat entry point and evidence lifecycle. */
export async function runChatCatalogFlow(context: ChatCatalogContext) {
const { input, scenario } = context;
// Cases grade provider work. A queued run retired by reassignment before it
// started, whose task's new assignee ran, is lifecycle: cases do not see it,
// while the conversation evidence and the run policy keep the raw ledger.
const readRuns = context.allRuns;
const rawRuns = () => readRuns.call(context);
if (typeof readRuns === "function") context.allRuns = async () => chatCatalogProviderRuns(await rawRuns(), context.issue());
const descriptor = findChatCatalogCase(scenario.id);
if (!descriptor) throw new Error(`No executable chat scenario: ${scenario.id}`);
const fingerprint = chatCatalogCaseFingerprint(descriptor, normalizedChatCatalogExecution(input.execution));
@@ -46,7 +52,7 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
async function conversation(persist = true) {
await context.refreshIssue();
const issue = context.issue();
const runs = await context.allRuns();
const runs = await rawRuns();
const comments = issue ? await input.api.get<Array<{ id: string; body: string; authorAgentId?: string; authorUserId?: string; issueId?: string; companyId?: string; createdByRunId?: string }>>(
`/api/issues/${issue.id}/comments?order=asc`,
) : [];
@@ -59,7 +65,8 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
origin: input.api.baseURL, companyId: input.fixtures.company.id, companyPrefix: input.fixtures.company.issuePrefix,
sourceId: issue.id, leadId: input.fixtures.agent.id, comments, runs, projects, activity,
}) : [];
const evidence = { issue, runs, comments, renderedLinks };
const evidence = { issue, runs, comments, renderedLinks,
supersededQueuedRunIds: chatCatalogSupersededQueuedRuns(runs, issue).map(run => run.id) };
if (persist) await input.evidence("chat-catalog-conversation.json", evidence);
return evidence;
}
@@ -209,8 +216,12 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
return Date.now() - passedAt >= 5_000;
},
reject: result => {
if (result.runs.length > implementation.expectedRunCount) return "Scenario exceeded its declared run bound";
if (result.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status) && !chatCatalogNeverStartedTerminalTaskRun(run, result.issue) && !chatCatalogRunStatusAllowed({ implementation,
if (chatCatalogProviderRuns(result.runs, result.issue).length > implementation.expectedRunCount
|| chatCatalogSupersededQueuedRuns(result.runs, result.issue).length > CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS) return "Scenario exceeded its declared run bound";
// A queued run retired by reassignment before it started is graded, not rejected:
// the run policy admits it only once the new assignee's run exists.
if (result.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status) && !chatCatalogNeverStartedTerminalTaskRun(run, result.issue)
&& !chatCatalogReassignedQueuedRun(run, result.issue) && !chatCatalogRunStatusAllowed({ implementation,
run, companyId: input.fixtures.company.id, leadId: input.fixtures.agent.id, source: result.issue, observation }))) return "Observed provider run failed";
if (result.runs.length && result.runs.every(run => !["queued", "running"].includes(run.status)) && result.issue?.conversationState === "waiting") settledAt ??= Date.now();
else settledAt = undefined;
@@ -280,7 +291,7 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
if (conversationUrl) await reopenConversation(input.page, conversationUrl);
const finalConversation = await conversation();
if (state) await refresh();
const runs = await context.allRuns();
const runs = await rawRuns();
const runEvidence = await Promise.all(runs.map(run => collectChatRunEvidence(chatRunEvidenceApi(input), run)));
await input.evidence("chat-catalog-runs.json", runEvidence);
await input.evidence("api-state.json", { capturePhase: failed ? "failure" : "final",
@@ -1,5 +1,5 @@
import { expect, it } from "vitest";
import { gradeChatCatalogRuns, chatCatalogAllowedRunStatuses, assertExpectedWorkerCancellation, chatCatalogRunStatusAllowed, createChatCatalogFinalRunVerifier, chatCatalogNeverStartedTerminalTaskRun } from "./chat-catalog-run-policy.js";
import { describe, expect, it } from "vitest";
import { gradeChatCatalogRuns, chatCatalogAllowedRunStatuses, assertExpectedWorkerCancellation, chatCatalogRunStatusAllowed, createChatCatalogFinalRunVerifier, chatCatalogNeverStartedTerminalTaskRun, chatCatalogReassignedQueuedRun, chatCatalogSupersededQueuedRuns, chatCatalogProviderRuns } from "./chat-catalog-run-policy.js";
import type { ChatRun } from "./chat-flow.js";
const succeeded: ChatRun = { id: "source-run", companyId: "company", agentId: "ceo", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "source" } };
const worker: ChatRun = { id: "worker-run", companyId: "company", agentId: "worker", status: "running", runtimeMode: "native", contextSnapshot: { issueId: "task" } };
@@ -140,3 +140,49 @@ it("admits a declared role-bound range above an exact terminal count, and nothin
const undeclared = { ...implementation, runPolicy: { ...implementation.runPolicy, maximumTerminalStatusCounts: { failed: 1 } } };
expect(gradeChatCatalogRuns({ ...scope, implementation: undeclared, runs: [cancelled, ok("a"), ok("b"), ok("c")] }).passed).toBe(false);
});
describe("queued run retired by reassignment before it started (full cohort 3)", () => {
// projects.project-work: the CEO created the task without an assignee (its own queued run), then
// reassigned it to Morgan; the reassignment stopped the run while still bootstrapping.
const reassigned: ChatRun = { id: "ceo-queued", companyId: "company", agentId: "ceo", status: "cancelled", runtimeMode: "legacy",
startedAt: null, logStore: null, logRef: null, nativeIssueId: null, errorCode: "issue_reassigned", createdAt: "2026-11-12T00:55:19.299Z",
contextSnapshot: { issueId: "task", wakeReason: "issue_assigned" },
resultJson: { stopReason: "cancelled", reassignmentStopConfirmed: true, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } };
// tasks.private-task: the dispatch gate found the same kind of queued wake stale after the PATCH.
const staleGate: ChatRun = { ...reassigned, id: "ceo-stale", errorCode: "issue_assignee_changed",
resultJson: { stopReason: "issue_assignee_changed", timeoutFired: false, timeoutSource: "stale_queued_run_gate" } };
// Paid run 43143392360 (tasks.private-task): the new assignee's wake released the lock the queued run held.
const lockReleased: ChatRun = { ...reassigned, id: "ceo-lock", errorCode: "lock_released_on_reassignment", resultJson: undefined };
const morgan: ChatRun = { ...worker, id: "morgan-run", createdAt: "2026-11-12T00:55:56.398Z" };
const implementation = { expectedRunCount: 2, runPolicy: { kind: "source-settled-with-active-work" as const, maximumActiveWorkerRuns: 1 } };
it("admits it, outside the provider-run bound, only when the new assignee ran on that task", () => {
for (const queued of [reassigned, staleGate, lockReleased]) {
expect(chatCatalogReassignedQueuedRun(queued, scope.source)).toBe(true);
const runs = [succeeded, queued, morgan];
expect(chatCatalogSupersededQueuedRuns(runs, scope.source).map(run => run.id)).toEqual([queued.id]);
expect(chatCatalogProviderRuns(runs, scope.source).map(run => run.id)).toEqual([succeeded.id, morgan.id]);
const grade = gradeChatCatalogRuns({ ...scope, implementation, runs });
expect(grade.passed).toBe(true);
expect(grade.checks.find(check => check.id === "catalog-superseded-queued-runs")?.passed).toBe(true);
}
});
it("still fails without a successor, for started or other cancellations, on the source, and for churn", () => {
// No new assignee run: the reassignment never produced work.
expect(gradeChatCatalogRuns({ ...scope, implementation, runs: [succeeded, reassigned] }).passed).toBe(false);
// A successor must be another agent's later run on the same task.
for (const other of [{ ...morgan, agentId: "ceo" }, { ...morgan, contextSnapshot: { issueId: "elsewhere" } }, { ...morgan, createdAt: "2026-11-12T00:55:00.000Z" }])
expect(gradeChatCatalogRuns({ ...scope, implementation: { ...implementation, expectedRunCount: 3 }, runs: [succeeded, reassigned, other] }).passed).toBe(false);
for (const run of [{ ...reassigned, startedAt: "2026-11-12T00:55:20.000Z" }, { ...reassigned, logRef: "log" }, { ...reassigned, errorCode: "cancelled" },
{ ...reassigned, resultJson: { ...reassigned.resultJson, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } },
{ ...reassigned, resultJson: { stopReason: "cancelled" } }, { ...staleGate, resultJson: { stopReason: "issue_assignee_changed" } },
{ ...reassigned, contextSnapshot: { issueId: "source" } }, { ...reassigned, status: "failed" },
{ ...lockReleased, startedAt: "2026-11-12T00:55:20.000Z" }, { ...lockReleased, nativeIssueId: "task" }]) {
expect(chatCatalogReassignedQueuedRun(run, scope.source)).toBe(false);
expect(gradeChatCatalogRuns({ ...scope, implementation: { ...implementation, expectedRunCount: 3 }, runs: [succeeded, run, morgan] }).passed).toBe(false);
}
// At most one superseded queued run per case.
const second = { ...staleGate, contextSnapshot: { issueId: "task-2" } }, morgan2 = { ...morgan, id: "morgan-2", status: "succeeded", contextSnapshot: { issueId: "task-2" } };
const churn = gradeChatCatalogRuns({ ...scope, implementation: { ...implementation, expectedRunCount: 3 }, runs: [succeeded, reassigned, morgan, second, morgan2] });
expect(churn.checks.find(check => check.id === "catalog-superseded-queued-runs")?.passed).toBe(false);
});
});
+36 -1
View File
@@ -1,5 +1,6 @@
import type { ChatCatalogCase, ChatCatalogGrade } from "./chat-catalog-contract.js";
import type { ChatIssue, ChatRun } from "./chat-flow.js";
import { isAssigneeChangedUnstartedRun } from "./non-execution-wake.js";
type CancellationCase = Pick<ChatCatalogCase, "runPolicy"> & Partial<Pick<ChatCatalogCase, "scenarioId" | "expectedCancelledWorkerRun">>;
export function assertExpectedWorkerCancellation(implementation: CancellationCase) {
const flag = implementation.runPolicy?.kind === "source-settled-with-active-work"
@@ -43,17 +44,51 @@ export function chatCatalogNeverStartedTerminalTaskRun(run: ChatRun, source?: Ch
&& record(run.resultJson).stopReason === "issue_terminal_status"
&& typeof issueId === "string" && issueId.length > 0 && issueId !== source?.id;
}
/**
* A queued run on a non-source task that the server retired because the task
* changed assignee before the run started. Early polling defers it to the grade
* instead of reporting a provider failure; it is admitted there only with a
* successor (see chatCatalogSupersededQueuedRuns). Other cancellations stay failures.
*/
export function chatCatalogReassignedQueuedRun(run: ChatRun, source?: ChatIssue): boolean {
const issueId = taskId(run);
return isAssigneeChangedUnstartedRun(run) && typeof issueId === "string" && issueId.length > 0 && issueId !== source?.id;
}
/**
* The superseded queued run did no provider work, so it is not counted toward
* the declared provider-run bound. It stays in the raw ledger and evidence, and
* a case may have at most this many, so create-then-reassign churn still fails.
*/
export const CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS = 1;
/** Retired queued runs whose task's new assignee actually received a later run on that task. */
export function chatCatalogSupersededQueuedRuns(runs: ChatRun[], source?: ChatIssue): ChatRun[] {
return runs.filter(run => {
const created = Date.parse(run.createdAt ?? "");
return chatCatalogReassignedQueuedRun(run, source) && Number.isFinite(created) && runs.some(other => other.id !== run.id
&& taskId(other) === taskId(run) && other.companyId === run.companyId && other.agentId !== run.agentId
&& !chatCatalogReassignedQueuedRun(other, source) && Date.parse(other.createdAt ?? "") >= created);
});
}
/** The raw ledger without superseded queued runs: the runs that count as provider work. */
export function chatCatalogProviderRuns(runs: ChatRun[], source?: ChatIssue): ChatRun[] {
const superseded = new Set(chatCatalogSupersededQueuedRuns(runs, source).map(run => run.id));
return runs.filter(run => !superseded.has(run.id));
}
export function gradeChatCatalogRuns(input: {
implementation: Pick<ChatCatalogCase, "expectedRunCount" | "minimumRunCount" | "runPolicy"> & CancellationCase;
companyId: string; leadId: string; source?: ChatIssue; runs: ChatRun[];
observation?: unknown;
}): ChatCatalogGrade {
const { implementation: c, runs, source } = input;
const { implementation: c, source } = input;
const superseded = chatCatalogSupersededQueuedRuns(input.runs, source);
const runs = chatCatalogProviderRuns(input.runs, source);
const checks = [
{ id: "catalog-bounded-native-runs", passed: runs.length >= (c.minimumRunCount ?? c.expectedRunCount) && runs.length <= c.expectedRunCount
&& runs.every(run => run.companyId === input.companyId && run.runtimeMode === "native" && chatCatalogAllowedRunStatuses(c).includes(run.status)),
detail: "Observed provider runs satisfy declared count, company, native runtime and allowed outcome bounds." },
];
if (superseded.length) checks.push({ id: "catalog-superseded-queued-runs", passed: superseded.length <= CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS,
detail: "A queued run retired before it started because its task changed assignee is lifecycle, not provider work, only when the new assignee ran on that task; at most one per case." });
if (c.expectedCancelledWorkerRun !== undefined || c.runPolicy?.kind === "source-settled-with-active-work" && c.runPolicy.expectedWorkerCancellation !== undefined) {
let valid = true; try { assertExpectedWorkerCancellation(c); } catch { valid = false; }
checks.push({ id: "catalog-worker-cancellation-declaration", passed: valid,
+8 -4
View File
@@ -1,6 +1,6 @@
import { prepareChatCatalogRespondent } from "./chat-catalog-respondent.js";
import type { ChatPrivateInputs } from "./chat-private-inputs.js";
import type { ChatCatalogFinalRunVerifier } from "./chat-catalog-run-policy.js";
import { chatCatalogProviderRuns, type ChatCatalogFinalRunVerifier } from "./chat-catalog-run-policy.js";
import { runHiringTemplateFlow } from "./hiring-template-flow.js";
import { gradeHiringTemplateTurns } from "./hiring-template-turn-accounting.js";
import type { HiringTemplateEvidence } from "./hiring-template-scoring.js";
@@ -13,7 +13,7 @@ import type {
} from "../../packages/shared/src/types/issue.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { MatrixExecution } from "./types.js";
import { chatProviderRuns, classifyPreDispatchExecutionWait, isBlockedUnstartedWake, isReassignedUnstartedWake, verifyChatAdmissionWaitCoverage, type ChatAdmissionWait } from "./non-execution-wake.js";
import { chatProviderRuns, classifyPreDispatchExecutionWait, isAssigneeChangedUnstartedRun, isBlockedUnstartedWake, verifyChatAdmissionWaitCoverage, type ChatAdmissionWait } from "./non-execution-wake.js";
import { collectRunEvents } from "./run-observations.js";
import { chatMarker } from "./chat-cases.js";
import { assertChatRememberedAfterRestart, assertChatStartupStopped, isChatStopReady, runChatHardeningFlow } from "./chat-hardening.js";
@@ -82,6 +82,7 @@ export interface ChatRun {
sessionIdBefore?: string | null;
sessionIdAfter?: string | null;
startedAt?: string | null;
createdAt?: string;
nativeIssueId?: string | null;
}
type Comment = {
@@ -323,7 +324,7 @@ export async function collectChatRunEvidence(
const providerNeverStarted = run.runtimeMode === "legacy" && !run.runtimeModeResolvedAt &&
run.logStore === null && run.logRef === null && ["failed", "interrupted"].includes(run.status) &&
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
const reassignedBeforeStart = isReassignedUnstartedWake(run);
const reassignedBeforeStart = isAssigneeChangedUnstartedRun(run);
const terminalBeforeStart = run.status === "cancelled" && run.errorCode === "issue_terminal_status" &&
run.startedAt === null && run.logStore === null && run.logRef === null;
return {
@@ -429,7 +430,10 @@ export async function runChatFlow(input: ChatFlowInput) {
const admissionAccounting = execution.suite.id === "kimi-qualification" && caseId === "stop-new-resume" && execution.profile.generation === "native";
let admissionWaits: ChatAdmissionWait[] = [];
let stoppedForSessionReset: string | undefined;
const providerRuns = () => admissionAccounting ? chatProviderRuns(runs, admissionWaits) : runs.filter(run => !isResetRun(run));
// A catalog task's queued run retired by reassignment before it started, with
// the new assignee's run present, is lifecycle: kept in `runs`, not provider work.
const providerRuns = () => admissionAccounting ? chatProviderRuns(runs, admissionWaits)
: catalogDescriptor ? chatCatalogProviderRuns(runs.filter(run => !isResetRun(run)), issue) : runs.filter(run => !isResetRun(run));
const settings = await api.get<Record<string, unknown>>(
"/api/instance/settings/experimental",
);
+18
View File
@@ -16,6 +16,24 @@ export function isReassignedUnstartedWake(run: {
&& run.resultJson.timeoutSource === "stale_queued_run_gate" && run.resultJson.timeoutFired === false;
}
/** A queued run the server retired because its task changed assignee before any
* provider work: the dispatch gate found the queued wake stale
* (`issue_assignee_changed`), the new assignee's wake released the lock it held
* (`lock_released_on_reassignment`), or the reassignment stopped it while it was
* still bootstrapping (`issue_reassigned`, stop confirmed, provider work not started). */
export function isAssigneeChangedUnstartedRun(run: {
status?: unknown; errorCode?: unknown; startedAt?: unknown; nativeIssueId?: unknown;
logStore?: unknown; logRef?: unknown; resultJson?: Record<string, unknown>;
}) {
if (run.status !== "cancelled" || run.startedAt !== null || run.logStore != null || run.logRef != null) return false;
if (isReassignedUnstartedWake(run)) return true;
// The issue's new assignee claimed the execution lock that this queued run still held (heartbeat queue).
if (run.errorCode === "lock_released_on_reassignment" && run.nativeIssueId == null) return true;
const result = run.resultJson, recovery = object(result?.executionRecovery);
return run.errorCode === "issue_reassigned" && run.nativeIssueId == null && result?.reassignmentStopConfirmed === true
&& recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
}
/** A terminal issue may make a queued wake obsolete before provider admission. */
export function isTerminalUnstartedWake(
run: Record<string, unknown>,