mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent adapters run agent sessions through the ACPX engine > - The ACPX engine handled one run attempt as a long implicit procedure > - That shape made resource ownership, cleanup order, and failure behavior hard to verify > - This pull request gives the attempt a coordinator, a typed resource ledger, separate run sites, and explicit turn and settlement sequences > - The benefit is clear ownership, one cleanup path, safer session reuse, and testable failure behavior ## Linked Issues or Issue Description **What existing behavior does this improve?** The ACPX engine manages startup, turn execution, session reuse, and cleanup inside one large run procedure. **Current behavior** The run procedure owns several resources through implicit control flow. Cleanup and session reuse behavior depend on lane-specific branches and error paths. **Proposed behavior** The coordinator owns the run attempt. A typed ledger records six resources and their states. Host and sandbox run sites own lane-specific acquisition. Turn and settlement sequences expose typed outcomes. The engine emits allowlisted phase telemetry. **Reason and benefit** Explicit ownership makes cleanup and failure behavior easier to inspect. The fault matrix and characterization tests protect the external result while the refactor reduces hidden control flow. **Breaking changes** None to the public adapter contract. The host warm-save path now closes and relaunches the runtime because a transferred runtime could retain a run-scoped credential. A cold session-handshake failure now closes the created runtime. **Additional context** This pull request contains the ACPX engine lifecycle refactor, its tests, and the lifecycle document. ## What Changed - Add a run coordinator for startup, turn execution, settlement, and result reproduction. - Add a typed resource ledger with open, sealed, and consumed states. - Add host and sandbox run sites for lane-specific resource acquisition. - Replace separate runtime maps with a generic session reuse store. - Split session fingerprint identity from the outer session key. - Add typed turn and settlement sequences with one cleanup owner. - Add a closed allowlist for phase telemetry. - Add characterization tests and a 17-case fault matrix. - Add `doc/acp-run-lifecycle.md`. ## Verification - `npx vitest run packages/adapter-utils/src/acpx-engine/` passes 18 files and 286 tests at the submitted commit. - `pnpm --filter @paperclipai/adapter-utils typecheck` reports 0 errors at the submitted commit. - Run the full pull request checks after GitHub starts CI. - Run Greptile review after the pull request opens. ## Risks - The refactor changes internal control flow across the ACPX engine. - Host warm-save behavior now closes and relaunches the runtime. - Settlement changes the handling of a cold session-handshake failure from a leak to a close. - The characterization baselines and fault matrix reduce the risk of an external behavior change. > Paperclip is the open source app people use to manage AI agents for work > The adapter layer runs agent sessions through the ACPX engine > The engine needs explicit lifecycle ownership for reliable cleanup > This pull request adds coordinator-owned phases and a typed resource ledger > The result makes lifecycle behavior easier to test and review ## Model Used OpenAI GPT-5 Codex. Exact model ID: GPT-5. The model used tool execution, repository inspection, and code review support. The implementation author supplied the submitted 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 - [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>
205 lines
8.2 KiB
TypeScript
205 lines
8.2 KiB
TypeScript
// The settlement sequence.
|
|
//
|
|
// `settleAcpRun(ledger, cause, steps)` is the one cleanup owner for every site
|
|
// and every settled path. The one exception is the startup rollback, which the
|
|
// coordinator owns (Phase 20).
|
|
//
|
|
// The sequence claims the ledger once, makes the pure reuse decision, then runs
|
|
// the ordered steps:
|
|
//
|
|
// 1. endSession — close every runtime the decision did not transfer, and
|
|
// drop the warm entry when it closes.
|
|
// 2. settleReuse — perform the decision: a save transfers the candidate to the
|
|
// site store (which arms the per-entry idle timer); everything
|
|
// else discards.
|
|
// 3. stopTransport — stop both bridges in one `allSettled`.
|
|
// 4. syncBack — the site sync-back.
|
|
// 5. releaseStagingLease — runs in a `finally`, so an earlier step fault never
|
|
// strands the lease.
|
|
//
|
|
// The Phase 3 error policy governs every step: a step records its error and the
|
|
// later steps still run. Every step no-ops on an empty slot.
|
|
//
|
|
// Amendment B — the credential gate. A runtime is save-eligible only when no
|
|
// run-scoped credential issued to it remains valid. The decision is pure and
|
|
// runs before any close. Settlement stops both bridges before a sandbox-lane
|
|
// transfer, so the bridge tokens die with the bridges. The run API key is not
|
|
// invalidated on run end (grounded server-side), so the host-lane warm save
|
|
// discards instead of transfers (close-and-relaunch) until a credential-rebind
|
|
// protocol exists. The sandbox staged-files reuse is unaffected: its payload
|
|
// carries no credentials.
|
|
|
|
import type {
|
|
AcquiredRunResources,
|
|
ResourceDisposition,
|
|
ResourceId,
|
|
RunResourcePayloads,
|
|
RunSiteKind,
|
|
SettledResourceEntry,
|
|
SettlementCause,
|
|
} from "./run-contracts.js";
|
|
|
|
/** The save-versus-discard decision the settlement makes before any close. */
|
|
export type ReuseDecision =
|
|
| { readonly kind: "save"; readonly savedId: ResourceId }
|
|
| { readonly kind: "discard"; readonly reason: string };
|
|
|
|
/**
|
|
* The inputs the reuse decision reads. The site derives the candidate from the
|
|
* settled slots. A null candidate means the run holds nothing to save.
|
|
*/
|
|
export interface ReuseCandidateInput {
|
|
/** The site kind. "host" saves the runtime; "sandbox" saves the staged files. */
|
|
readonly kind: RunSiteKind;
|
|
/** True when the settlement cause permits a save (a clean persistent turn). */
|
|
readonly causePermitsSave: boolean;
|
|
/**
|
|
* Amendment B: the run-scoped credentials the candidate would still carry into
|
|
* the store. A save is eligible only when this list is empty — no run-scoped
|
|
* credential issued to the runtime remains valid.
|
|
*/
|
|
readonly liveRunScopedCredentials: readonly string[];
|
|
}
|
|
|
|
/** One resource's final disposition, as the settlement records it. */
|
|
export interface ResourceDispositionRecord {
|
|
readonly id: ResourceId;
|
|
readonly disposition: ResourceDisposition;
|
|
}
|
|
|
|
/** The per-resource disposition report the settlement produces at the end. */
|
|
export interface SettlementDispositionReport {
|
|
readonly records: readonly ResourceDispositionRecord[];
|
|
}
|
|
|
|
/** The settled slots view a step reads. `get` returns undefined for an empty slot. */
|
|
export interface SettlementSlots {
|
|
get<Id extends ResourceId>(id: Id): RunResourcePayloads[Id] | undefined;
|
|
has(id: ResourceId): boolean;
|
|
}
|
|
|
|
/**
|
|
* The effectful steps the sequence runs. The engine implements each step over
|
|
* its own run state. Each step no-ops on an empty slot. A step must not throw
|
|
* for the sequence to record its error and continue; a throw is caught and
|
|
* recorded under the Phase 3 error policy.
|
|
*/
|
|
export interface SettlementSteps {
|
|
/**
|
|
* Derive the reuse candidate from the settled slots and the cause, or null
|
|
* when the run holds nothing to save.
|
|
*/
|
|
reuseCandidate(slots: SettlementSlots, cause: SettlementCause | null): ReuseCandidateInput | null;
|
|
/** Close every runtime the decision did not transfer; drop the warm entry on close. */
|
|
endSession(slots: SettlementSlots, decision: ReuseDecision): Promise<void> | void;
|
|
/** Perform the decision: a save transfers to the site store; everything else discards. */
|
|
settleReuse(slots: SettlementSlots, decision: ReuseDecision): Promise<void> | void;
|
|
/** Stop both bridges in one `allSettled`. */
|
|
stopTransport(slots: SettlementSlots): Promise<void> | void;
|
|
/** The site sync-back. */
|
|
syncBack(slots: SettlementSlots): Promise<void> | void;
|
|
/** Release the staging lease. Runs in a `finally`. */
|
|
releaseStagingLease(slots: SettlementSlots): void;
|
|
/** Record a per-step error under the Phase 3 error policy. */
|
|
recordError(step: string, error: unknown): Promise<void> | void;
|
|
}
|
|
|
|
/**
|
|
* The pure reuse decision. A save is eligible only when a candidate exists, the
|
|
* cause permits a save, and Amendment B holds: no run-scoped credential issued
|
|
* to the candidate remains valid. Everything else discards, with a reason.
|
|
*/
|
|
export function decideReuse(candidate: ReuseCandidateInput | null): ReuseDecision {
|
|
if (!candidate) {
|
|
return { kind: "discard", reason: "no reuse candidate" };
|
|
}
|
|
if (!candidate.causePermitsSave) {
|
|
return { kind: "discard", reason: "settlement cause forbids save" };
|
|
}
|
|
if (candidate.liveRunScopedCredentials.length > 0) {
|
|
// Amendment B credential gate: a live run-scoped credential blocks the save.
|
|
return { kind: "discard", reason: "run-scoped credential still valid" };
|
|
}
|
|
const savedId: ResourceId = candidate.kind === "host" ? "acp_runtime" : "staged_runtime";
|
|
return { kind: "save", savedId };
|
|
}
|
|
|
|
/**
|
|
* Settle the run's resources. Claims the ledger once, makes the reuse decision,
|
|
* runs the ordered steps under the Phase 3 error policy, releases the staging
|
|
* lease in a `finally`, and returns the final per-resource disposition report.
|
|
*/
|
|
export async function settleAcpRun(
|
|
ledger: AcquiredRunResources,
|
|
cause: SettlementCause | null,
|
|
steps: SettlementSteps,
|
|
): Promise<SettlementDispositionReport> {
|
|
// Claim the ledger once. Every later step reads the claimed slots.
|
|
const consumed = ledger.takeForSettlement();
|
|
const entries = consumed.entries();
|
|
const slots = makeSlots(entries);
|
|
|
|
// The reuse decision is pure and runs before any close.
|
|
const candidate = steps.reuseCandidate(slots, cause);
|
|
const decision = decideReuse(candidate);
|
|
|
|
const runStep = async (name: string, run: () => Promise<void> | void): Promise<void> => {
|
|
try {
|
|
await run();
|
|
} catch (error) {
|
|
// The Phase 3 error policy: record the step error and let later steps run.
|
|
await steps.recordError(name, error);
|
|
}
|
|
};
|
|
|
|
try {
|
|
await runStep("end_session", () => steps.endSession(slots, decision));
|
|
await runStep("settle_reuse", () => steps.settleReuse(slots, decision));
|
|
await runStep("stop_transport", () => steps.stopTransport(slots));
|
|
await runStep("sync_back", () => steps.syncBack(slots));
|
|
} finally {
|
|
// The staging lease release runs in a `finally`, so an earlier step fault
|
|
// never strands the lease.
|
|
try {
|
|
steps.releaseStagingLease(slots);
|
|
} catch (error) {
|
|
await steps.recordError("release_staging_lease", error);
|
|
}
|
|
}
|
|
|
|
return buildReport(entries, decision);
|
|
}
|
|
|
|
/** Build the settled slots view from the claimed entries. */
|
|
function makeSlots(entries: readonly SettledResourceEntry[]): SettlementSlots {
|
|
const byId = new Map<ResourceId, SettledResourceEntry>();
|
|
for (const entry of entries) byId.set(entry.id, entry);
|
|
return {
|
|
get<Id extends ResourceId>(id: Id): RunResourcePayloads[Id] | undefined {
|
|
return byId.get(id)?.payload as RunResourcePayloads[Id] | undefined;
|
|
},
|
|
has(id: ResourceId): boolean {
|
|
return byId.has(id);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the final per-resource disposition report. The claim-time value the
|
|
* ledger stamps ("transferred") is not the final report; the settlement decides
|
|
* it here. A saved resource is `transferred`; every other resource is
|
|
* `finalized`.
|
|
*/
|
|
function buildReport(
|
|
entries: readonly SettledResourceEntry[],
|
|
decision: ReuseDecision,
|
|
): SettlementDispositionReport {
|
|
const savedId = decision.kind === "save" ? decision.savedId : null;
|
|
return {
|
|
records: entries.map((entry) => ({
|
|
id: entry.id,
|
|
disposition: entry.id === savedId ? ("transferred" as const) : ("finalized" as const),
|
|
})),
|
|
};
|
|
}
|