mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip manages AI agents and their tasks. > - A task can outlive a provider process or a server restart. > - Legacy recovery treated unknown tool outcomes as a permanent execution hold. > - That hold could also reject a later user message. > - A conversation turn can use prior history without replaying prior tool calls. > - This pull request lets supported conversation adapters continue within the existing retry budget. > - Users can send a new message after automatic attempts stop. ## Linked Issues or Issue Description **What happened?** A server restart could interrupt a local ACP run and leave its task behind a permanent recovery hold. A later user message could be cancelled before the provider answered. The immediate recovery path could also create a successor outside the durable failure counter. **Expected behavior** Continue with a bounded new conversation turn. Preserve a compatible provider session or use full task context when it is unavailable. Do not replay recorded tools. When automatic attempts stop, allow a new user request through the normal execution gates. **Steps to reproduce** 1. Start a task with a local conversation adapter. 2. Restart the server while the provider is working. 3. Let the previous run become interrupted. 4. Send a follow-up message and observe the recovery hold on the old behavior. Related work: Refs #13075 for durable task recovery. Refs #12946 for retry-limit and checkout-lock handling. This change routes conversation recovery through the existing bounded scheduler. ## What Changed - Mark supported local conversation failures for continuation. Keep native-runner and non-conversation recovery rules. - Carry an interruption notice into the next turn. Retain stopped ACP session history even when a write outcome is unknown. - Clear unavailable ACP sessions so the next bounded attempt can use full task context. - Route immediate failure recovery through the same durable scheduler as process-loss recovery. Release only the predecessor checkout when its retry takes ownership. - Retire obsolete conversation holds using immutable run evidence, in bounded batches with an activity record. Preserve outcome evidence and do not wake historical tasks. - Block actual admission and Resume while a predecessor process or environment lease is still active. Keep the original interruption notice after a rejected wake. Preserve the upstream blocked-wake waiting contract: bounded retry planning can happen during cleanup, while deferred messages and execution remain gated. - Add subprocess and database regression tests. Update the execution contract. - Add the current thread-status field to the native recovery provider fixture so its damaged-journal test reaches the intended boundary. Tolerate an already-exited fixture process during test cleanup while still asserting both processes terminate. ## Verification - Workspace typecheck passed: `pnpm -r typecheck`. - Build passed: `pnpm build`. - Module boundaries passed: `pnpm check:module-boundaries`. - Focused tests passed: 293 recovery/session/dispatch tests, 66 retry and response-gate tests, and 37 native-session tests. Some suites overlap. - Tests cover interrupted writes, missing sessions, concurrent retries, restart persistence, pending questions and approvals, execution gates, and historical holds. - Built the Rust test executables with `pnpm --filter @paperclipai/paperclip-runner build:rust` for native-runner verification. - Full Vitest coverage verified locally using the repository’s general and serialized shards, with focused reruns for failures and files not reached after a shard stopped. The ownership-gate regression is fixed and the complete affected server shard passes (1,390 tests). Local parallel runs also hit temporary-directory, resource, and timing failures; those suites pass with canonical temporary paths and sequential reruns. No test timeouts were increased. - Final merged-branch regression run: 577 tests pass across process recovery, retry scheduling, liveness, durable chat, wake-queue application/adapter, dispatch, continuation, native sessions, and task chat. Earlier focused verification also passed 19 native control tests. Token gates and whitespace validation pass. - Browser verification passed all three ACP Stop/continue/pause scenarios, including a rerun after merging the upstream waiting behavior: `PAPERCLIP_E2E_PORT=3397 pnpm test:e2e tests/e2e/acp-stop-continuation.spec.ts`. The interrupted-write case verifies that follow-up completes without a repeated write. - Final-head [CI run 34625037394](https://github.com/paperclipai/paperclip/actions/runs/34625037394) passed on `06ac4bd9d150f8b209a96e5fd609c696958794a0`: all 31 reported checks are green, including server/workspace suites, all browser shards, native runner verification, build, typecheck, release dry run, and aggregate gates. The two conditional Storybook checks were skipped. Greptile reviewed this exact commit at 5/5; all review threads are resolved. ## Risks - A new model turn can choose to repeat an action. Paperclip does not replay recorded tool calls and does not certify unknown action outcomes. - Conversation adapters now stop after their retry budget instead of requiring action reconciliation. Explicit Stop, pause, dependency, approval, budget, and ownership gates remain in force. - No schema migration or dependency changes. Historical holds are folded without changing task status or waking work. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and test execution. The session does not expose a more specific model build ID or context-window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
80 lines
4.6 KiB
JavaScript
80 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// Deterministic ACP process for interruption and same-session continuation tests.
|
|
import fs from 'node:fs';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { createInterface } from 'node:readline';
|
|
const root = process.env.PAPERCLIP_STOP_FIXTURE_ROOT ?? process.cwd();
|
|
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
let active;
|
|
let timer;
|
|
if (process.env.PAPERCLIP_STOP_FIXTURE_HANG_ON_CLOSE === '1') {
|
|
process.on('SIGTERM', () => {});
|
|
setInterval(() => {}, 1000);
|
|
}
|
|
const update = (sessionId, value) => send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: value } });
|
|
async function request(message) {
|
|
fs.appendFileSync(`${root}/requests`, `${Date.now()} ${message.method}\n`);
|
|
switch (message.method) {
|
|
case 'initialize': return { protocolVersion: 1, agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } }, agentInfo: { name: 'stop-fixture', version: '1' } };
|
|
case 'session/new': {
|
|
const sessionId = randomUUID();
|
|
fs.writeFileSync(`${root}/session`, sessionId);
|
|
return { sessionId };
|
|
}
|
|
case 'session/load':
|
|
if (!fs.existsSync(`${root}/session`) || fs.readFileSync(`${root}/session`, 'utf8') !== message.params.sessionId) throw Error('Unknown session');
|
|
return {};
|
|
case 'session/prompt': {
|
|
fs.appendFileSync(`${root}/prompts`, `${JSON.stringify(message.params)}\n`);
|
|
fs.appendFileSync(`${root}/run-env`, `${JSON.stringify({ runId: process.env.PAPERCLIP_RUN_ID, tokenHash: createHash('sha256').update(process.env.PAPERCLIP_API_KEY ?? '').digest('hex'), scratchDir: process.env.PAPERCLIP_RUN_SCRATCH_DIR })}\n`);
|
|
if (fs.existsSync(`${root}/continued`)) {
|
|
const paused = JSON.stringify(message.params.prompt).includes('tree-hold interaction: yes');
|
|
if (!paused) {
|
|
fs.appendFileSync(`${root}/completed`, 'follow-up\n');
|
|
// Browser journeys finish the task through the normal agent API so
|
|
// the scheduler does not need a separate successful-run handoff.
|
|
if (process.env.PAPERCLIP_STOP_FIXTURE_FINISH_TASK === '1') {
|
|
const base = process.env.PAPERCLIP_API_URL.replace(/\/api\/?$/, '').replace(/\/$/, '');
|
|
const response = await fetch(`${base}/api/issues/${process.env.PAPERCLIP_TASK_ID}`, {
|
|
method: 'PATCH',
|
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`, 'X-Paperclip-Run-Id': process.env.PAPERCLIP_RUN_ID },
|
|
body: JSON.stringify({ status: 'done' }),
|
|
});
|
|
if (!response.ok) throw new Error(`Task completion failed: ${response.status} ${await response.text()}`);
|
|
}
|
|
}
|
|
update(message.params.sessionId, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: paused ? 'Task remains paused. Use Resume work to continue.' : 'Answered the pending follow-up once.' } });
|
|
return { stopReason: 'end_turn' };
|
|
}
|
|
active = message;
|
|
if (process.env.PAPERCLIP_STOP_FIXTURE_TOOL === 'read') {
|
|
update(message.params.sessionId, { sessionUpdate: 'tool_call', toolCallId: 'read-1', title: 'Read local file', kind: 'read', status: 'completed' });
|
|
}
|
|
if (process.env.PAPERCLIP_STOP_FIXTURE_TOOL === 'write') {
|
|
update(message.params.sessionId, { sessionUpdate: 'tool_call', toolCallId: 'write-1', title: 'Write local file', kind: 'edit', status: 'in_progress' });
|
|
timer = setInterval(() => fs.appendFileSync(`${root}/writes`, 'tick\n'), 20);
|
|
}
|
|
update(message.params.sessionId, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Waiting for Stop.' } });
|
|
return undefined;
|
|
}
|
|
case 'session/cancel':
|
|
clearInterval(timer);
|
|
fs.writeFileSync(`${root}/continued`, 'ready');
|
|
if (active) send({ jsonrpc: '2.0', id: active.id, result: { stopReason: 'cancelled' } });
|
|
active = undefined;
|
|
return undefined;
|
|
case 'session/close': clearInterval(timer); return {};
|
|
case 'session/set_mode': case 'session/set_config_option': return {};
|
|
default: throw Error(`Unsupported method: ${message.method}`);
|
|
}
|
|
}
|
|
createInterface({ input: process.stdin }).on('line', async (line) => {
|
|
const message = JSON.parse(line);
|
|
try {
|
|
const result = await request(message);
|
|
if (message.id !== undefined && result !== undefined) send({ jsonrpc: '2.0', id: message.id, result });
|
|
} catch (error) {
|
|
if (message.id !== undefined) send({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error.message } });
|
|
}
|
|
});
|