mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task controls coordinate provider execution and queued user messages. > - Stop could finish before an embedded ACP provider stopped its tools. > - A later request could be held for reconciliation without a clear task response. > - A restored provider could also retain the stopped run's API credential. > - This pull request verifies provider termination and preserves safe session continuation. > - Operators can continue known-safe work and see why uncertain work cannot start. ## Linked Issues or Issue Description **What happened?** Stop could leave an embedded ACP provider running. A queued follow-up followed by “go” could fail before it reached the provider. Task chat could show a generic missing-response message. Even a restored session could use the previous run's credential and fail its task update. **Expected behavior** Stop waits for confirmed provider termination. A later explicit wake continues the same compatible session only when recorded actions have known outcomes. It carries pending comments and the current run's environment. Uncertain actions retain a visible reconciliation hold. Composer Stop preserves the existing pause rule: conversation can continue while paused, but task work requires Resume. **Steps to reproduce** 1. Start an embedded ACP task. 2. Send a second request while the provider is running. 3. Interrupt the run, then send “go”. Also test composer Stop followed by Resume work. 4. Check that the request is delivered once and that the provider can complete the task through the current run's API credential. 5. Repeat with an unfinished write. Confirm that the write stops and that further execution stays blocked with a visible reason. **Paperclip version or commit** Built from source on master at `3bc60dd8b` plus this branch. **Deployment mode** Local source build with an isolated embedded PostgreSQL instance. Refs #11183. Refs #12552. Those changes address recovery after operator cancellation. This change also covers embedded ACP termination, session proof, pending-comment delivery, and task feedback. ## What Changed - Propagate Stop into embedded ACP and wait for bounded adapter cleanup and provider exit. Retain the actual ChildProcess object for forced termination on all platforms; never signal a recycled numeric PID. - Preserve interrupted checkpoints only for acknowledged, local, persistent sessions with settled reads or no tools. Keep writes, incomplete actions, and forced termination blocked. - Restore the same compatible provider session with the current run's environment. Reject fresh-session fallback for an interrupted checkpoint. - Adopt pending comments on the next explicit wake. Stop alone does not dispatch them. - Share the execution-blocker rule across dispatch, Resume, and task detail. Show Stopped or Couldn't start with the recorded reason. Resolve the stopped agent for the run link, including reviewer runs. - Keep execution reconciliation holds intact when generic recovery sees queued comments or healthy child tasks. - Add process, service, component, and browser regression coverage. Fix disposable database cleanup and React test settling exposed by the full suite. ## Verification - Passed `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates`. - Passed all three `acp-stop-continuation.spec.ts` browser journeys. They use an actual ACP child process and require task completion through the agent API. - Passed 165 adapter execution, operator-stop, and child-process control tests, 17 queued-comment route tests, and 65 tests in the two adjusted UI suites. Earlier focused recovery, heartbeat, and task-control tests also passed. - Manually used the browser to queue a request, Stop, send “go” while paused, and Resume. The same session answered once and moved the task to Done with the current run's credential. - Manually interrupted an unfinished write. Its file size stayed fixed for five seconds. “Go” showed the reconciliation reason and did not start another provider prompt. - Separate live Claude ACP smoke checks confirmed that Stop ended a disposable local write and that a no-tool interruption could resume the exact provider session. The browser fixture does not call Drive or another external app. - Passed all 5,615 UI tests and 3,090 other workspace tests. The CLI and general server groups pass with targeted retries: two transient server failures passed together on retry, and two embedded-database startup failures passed after removing abandoned shared-memory segments from this task's completed browser fixtures. All 144 serialized server suites completed, with 2,189 tests passing after two transient HTTP socket failures passed on retry. - Passed all 135 heartbeat process/recovery tests, including a deterministic regression that failed before the recovery-sweep fix. - Passed 18 dispatch integration tests, including stopped-reviewer links, company boundaries, and malformed run IDs. - Greptile is 5/5 on `7dd170d83`, with zero unresolved review threads. The security scan and all required CI gates pass for the same commit. ## Risks - Safe continuation depends on complete tool reporting and a restorable local provider session. Unknown outcomes remain blocked and require reconciliation. - Provider cleanup can take time. A timeout does not grant replay permission. - The change adds optional adapter context fields and an optional issue projection. It does not change the database schema or require a migration. - Test cleanup truncates company data only in a disposable test database. ## Model Used OpenAI GPT-6, running as Codex with repository tools, code execution, and browser interaction. The runtime does not expose a more specific model deployment 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>
119 lines
5.2 KiB
TypeScript
119 lines
5.2 KiB
TypeScript
// The turn sequence.
|
|
//
|
|
// `runTurn(steps)` is a plain sequence. It runs five steps in a fixed order:
|
|
// promptBuild, preTurnUsage, turnStart, eventRelay, turnFinalize. It never
|
|
// rejects: every step error becomes a typed `TurnCompletion` through the
|
|
// `turnFinalize` step. The sequence owns the wall-clock timer and the abort
|
|
// controller. It borrows the run resources; it finalizes no resource. The
|
|
// coordinator (Phase 20) settles the resources after the turn.
|
|
//
|
|
// The sequence is data, not a graph and not a state machine. The engine supplies
|
|
// each step; the sequence owns only the order, the timer, and the abort signal.
|
|
// This keeps the sequence free of the engine's private run state.
|
|
|
|
import type { TurnCompletion } from "./run-contracts.js";
|
|
|
|
/**
|
|
* A turn the site started. The sequence borrows it to cancel it on a timeout; it
|
|
* releases nothing. The engine's `eventRelay` step reads the events and the
|
|
* terminal result.
|
|
*/
|
|
export interface StartedTurn {
|
|
cancel(reason: string): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* The input the sequence hands to the one `turnFinalize` step. A `terminal`
|
|
* input carries the turn's terminal result; an `error` input carries the step
|
|
* error and the phase it failed in. Both carry the wall-clock timeout flag.
|
|
*/
|
|
export type TurnFinalizeInput<TTerminal> =
|
|
| { readonly kind: "terminal"; readonly terminal: TTerminal; readonly timedOut: boolean }
|
|
| {
|
|
readonly kind: "error";
|
|
readonly error: unknown;
|
|
readonly phase: "prepare_turn" | "turn";
|
|
readonly timedOut: boolean;
|
|
};
|
|
|
|
/**
|
|
* The five steps the sequence runs, plus the timer inputs. The engine implements
|
|
* each step over its own run state. `turnFinalize` maps a terminal result or a
|
|
* step error to a `TurnCompletion`; it must not reject, so the sequence never
|
|
* rejects.
|
|
*/
|
|
export interface TurnSteps<TTerminal> {
|
|
readonly signal?: AbortSignal;
|
|
/** The wall-clock timeout, in milliseconds, or undefined for no timeout. */
|
|
readonly timeoutMs: number | undefined;
|
|
/** The message the timeout cancel carries. */
|
|
readonly timeoutMessage: string;
|
|
/** Build the prompt and emit the run metadata. A failure here is `prepare_turn`. */
|
|
promptBuild(signal: AbortSignal): Promise<void>;
|
|
/** Snapshot the pre-turn usage, so this run's cost excludes earlier runs. */
|
|
preTurnUsage(): Promise<void>;
|
|
/** Start the turn with the run's six inputs. The sequence adds the signal and the timeout. */
|
|
turnStart(signal: AbortSignal, timeoutMs: number | undefined): StartedTurn;
|
|
/** Relay the turn events and return the terminal result. */
|
|
eventRelay(turn: StartedTurn): Promise<TTerminal>;
|
|
/** Map a terminal result or a step error to a `TurnCompletion`. Must not reject. */
|
|
turnFinalize(input: TurnFinalizeInput<TTerminal>): Promise<TurnCompletion>;
|
|
}
|
|
|
|
/**
|
|
* Run the turn sequence. It returns a `TurnCompletion` on every path and never
|
|
* rejects. A failure before `turnStart` returns is a `prepare_turn` failure; a
|
|
* failure after it is a `turn` failure. Only the reported phase differs.
|
|
*/
|
|
export async function runTurn<TTerminal>(steps: TurnSteps<TTerminal>): Promise<TurnCompletion> {
|
|
const controller = new AbortController();
|
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
let timedOut = false;
|
|
let started: StartedTurn | null = null;
|
|
// `turnStarted` separates a pre-turn preparation failure from a running-turn
|
|
// failure, so `turnFinalize` reports the right phase.
|
|
let turnStarted = false;
|
|
let cancellation: Promise<void> | null = null;
|
|
const cancel = () => {
|
|
controller.abort(steps.signal?.reason);
|
|
if (started && !cancellation) {
|
|
cancellation = started.cancel("Paperclip operator stop");
|
|
// The result is observed below; do not create an unhandled rejection
|
|
// while the runtime drains its event stream.
|
|
void cancellation.catch(() => {});
|
|
}
|
|
};
|
|
steps.signal?.addEventListener("abort", cancel, { once: true });
|
|
try {
|
|
steps.signal?.throwIfAborted();
|
|
// Build the prompt and snapshot the pre-turn usage inside the failure
|
|
// boundary. A failure here is a `prepare_turn` failure.
|
|
await steps.promptBuild(controller.signal);
|
|
await steps.preTurnUsage();
|
|
steps.signal?.throwIfAborted();
|
|
// The sequence owns the wall-clock timer. On a timeout it marks the run timed
|
|
// out, aborts the shared signal, and cancels the started turn. The cancel
|
|
// no-ops before `turnStart` returns, because `started` is still null.
|
|
if (steps.timeoutMs !== undefined) {
|
|
timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
void started?.cancel(steps.timeoutMessage).catch(() => {});
|
|
}, steps.timeoutMs);
|
|
}
|
|
started = steps.turnStart(controller.signal, steps.timeoutMs);
|
|
if (steps.signal?.aborted) cancel();
|
|
turnStarted = true;
|
|
const terminal = await steps.eventRelay(started);
|
|
if (cancellation) await cancellation;
|
|
if (timeout) clearTimeout(timeout);
|
|
return await steps.turnFinalize({ kind: "terminal", terminal, timedOut });
|
|
} catch (error) {
|
|
if (timeout) clearTimeout(timeout);
|
|
const phase: "prepare_turn" | "turn" = turnStarted ? "turn" : "prepare_turn";
|
|
return await steps.turnFinalize({ kind: "error", error, phase, timedOut });
|
|
} finally {
|
|
steps.signal?.removeEventListener("abort", cancel);
|
|
}
|
|
}
|