fix(runner): let a finish through after a restart lost the provider turn (#20088)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Runner relays an agent's Paperclip tool calls and keeps them
across control-plane restarts
> - #20069 refuses a `paperclip_finish` or `paperclip_block` decided
before an earlier returned result reached the agent
> - The guard filters by turn id, but each committed event carries the
run's one identity turn id, so provider turns are not separated
> - When a restart loses a provider turn, a call of that turn gets its
result but never its provider completion
> - The guard then refuses every later finish of the run, so the run
exhausts its retries and the user must click Retry
> - This pull request makes a new provider turn end the pending calls of
the previous turn
> - The benefit is that an agent can finish after a restart that lost
its turn

## Linked Issues or Issue Description

Refs #20069

**What happened?**
A restart chaos storm (local, Codex, A1/C1, K2 drains for 30 minutes,
116 tasks) had one failure. Task t110 needed a user Retry. Its run was
drained twice. The first drain lost the provider turn while a
`read_agent_instructions` call was pending. After that, all four
`paperclip_finish` calls failed with `terminal_call_before_results` and
named `read_agent_instructions`. The run ended with
`native_session_retry_exhausted`.

**Steps to reproduce**
1. Start a Codex run that calls a Paperclip tool.
2. Drain the control plane while that call is pending, so the Runner
restores the session and reports `provider_turn_lost_on_restore`.
3. Let the agent continue in the new turn and call `paperclip_finish`.

**Expected behavior**
The finish is recorded. The lost call belongs to a turn that the agent
will not see again.

**Actual behavior**
The Runner refuses every finish with `terminal_call_before_results`. The
run fails and needs a user Retry.

## What Changed

- `unseenReturnedToolCalls` forgets the calls it tracked when a
`turn.started` event begins a new provider turn.
- A new test reproduces the t110 event order: a call in a lost turn,
then a new turn that reads, sees its result, and finishes.

## Verification

- `npx vitest run src/live/terminal-call-freshness.test.ts` in
`packages/paperclip-runner`: the new test fails before the change and
passes after it (7/7).
- `npx vitest run src/live/runnerd-codex-transport.test.ts -t terminal`:
10 passed.
- `tsc --noEmit -p packages/paperclip-runner`: clean.
- Evidence: the kept Runner control-plane state of t110 shows the call
`exec-0f3d1b90` (generation 1) with a `semantic_tool.result` command but
no `tool.execution.completed`, followed by `turn.failed
provider_turn_lost_on_restore` and new `turn.started` events.

## Risks

- The t03 case of #20069 stays covered: the stale block and the earlier
calls are in the same provider turn, with no `turn.started` between
them. The existing t03 tests still pass.
- If a driver emits `turn.started` inside one provider turn, the guard
can miss a stale call of that turn. No current driver does that.

## 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-08 12:29:38 -06:00
1 parent 4f7eee10c2
commit a9ae1b69c3
2 files changed
+15

No files matched your search

@@ -41,6 +41,17 @@ describe("unseenReturnedToolCalls", () => {
});
});
it("forgets calls of a provider turn lost on restore once a new turn starts (restart chaos K2 t110)", () => {
const commands = [{ type: "semantic_tool.result", payload: { callId: "lost", operationId: "read_agent_instructions" } }];
const finish = started("f", "paperclip_finish");
const events = [
event("turn.started", {}), started("lost", "read_agent_instructions"),
event("turn.failed", { error: { code: "provider_turn_lost_on_restore" } }),
event("turn.started", {}), started("r", "read_document"), result("r"), completed("r", "read_document"), finish,
];
expect(unseenReturnedToolCalls(events, commands, { terminalSourceEventId: finish.sourceEventId, turnId: "turn-1" })).toEqual([]);
});
it("counts the controller's semantic_tool.result command as the returned result", () => {
const block = started("b", "paperclip_block");
const commands = [{ type: "semantic_tool.result", payload: { callId: "w", operationId: "write_document" } }];
@@ -46,6 +46,10 @@ export function unseenReturnedToolCalls(
if (input.turnId && typeof event.turnId === "string" && event.turnId !== input.turnId) continue;
const payload = record(event.payload);
const type = event.eventType;
if (type === "turn.started") {
calls.clear();
continue;
}
if ((type === "tool.execution.started" || type === "tool.execution.completed") && payload.transport === "dynamic") {
const id = typeof payload.executionId === "string" ? payload.executionId : null;
if (!id) continue;