mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 05:17:41 +02:00
fix(runner): settle accepted results after shutdown failures (#15217)
## Thinking Path > - Paperclip manages AI agents and the tasks they perform. > - The native runner saves tool results and completion reports before it releases a session. > - Large project discovery responses can exceed the durable command limit. > - A shutdown failure can leave a saved answer waiting for workspace repair. > - Recovery reused the old assessment for a different status decision, which violated a database constraint. > - This pull request bounds discovery responses and lets recovery commit the saved result after workspace repair. > - The benefit is a task that reaches its correct final status without another provider turn. ## Linked Issues or Issue Description **What happened?** A native run can save its final answer, fail during shutdown, and leave the task In Progress after workspace repair succeeds. Reconciliation tries to reuse the failed-workspace assessment for a new decision. The one-decision-per-assessment constraint rejects the write. Replaying the old decision can also retain a fresh coordinator lease. Separately, retained session cleanup only recognizes the old `adapter_failed` label. The project-list tool returns full project records, including large descriptions and workspace configuration. A large response exceeds the runner's durable command limit. The settlement diagnostic previously recorded only a failure flag. **Expected behavior** Project discovery stays within the command limit. Recovery finishes the saved result after workspace repair, releases its lease, and preserves the original error for inspection. It does not repeat provider work or relax session ownership checks. **Steps to reproduce** 1. Return large project records from `list_projects` and observe an oversized semantic result. 2. Persist an accepted native completion result, then record a shutdown failure. 3. Finalize with a failed workspace, repeat that attempt, then record successful workspace repair. 4. Reconcile the run. Before this fix, the issue stays In Progress. **Paperclip version or commit** Reproduced on `a386a599983519eb1d399f8b770bfccdb2a74762`. **Deployment mode** Self-hosted server with Paperclip Runner. Related transport work: #12208 drains queued events; #12241 resumes interrupted semantic calls. This change addresses bounded project discovery and accepted-result finalization. ## What Changed - Read bounded project summary projections from the database and return at most 50 authorized summaries with a continuation cursor and explicit description truncation. Agent and run trust boundaries narrow the database candidates; project-specific policies still receive full authorization. Only visible projects determine continuations. The default project-list API remains unchanged. - Record bounded, content-free settlement failure causes for command limits, storage errors, and rejected dispatch. - Include workspace state in assessment identity. Preserve the initial assessment for interrupted finalization, and commit replacement assessment and decision references together. - Release the coordinator lease when an existing decision is replayed, without repeating its effects. - Clear stale errors when recovery succeeds and retain them in `recoveredExecutionFailure`. - Accept both current and legacy close-failure labels in the existing exact-state cleanup path. - Add regression tests and update the tool contracts and recovery documentation. ## Verification - Red: the new project paging, settlement diagnostic, current cleanup label, and repaired-workspace regressions failed on the original implementation. - Green: protocol/catalog/tool checks (117 tests), the full project-tool and finalizer suites (47 tests), cleanup ownership cases (70 tests), and cleanup sweep cases (4 tests) pass. Database-backed pagination covers large descriptions/configuration, complete enumeration, uppercase cursors, agent/run restrictions, project-policy scope contributions, and identical results/cursors when hidden projects are added. - The initial CI failures in interrupted Board waits, contended endpoint proof, and semantic schema validation were reproduced and fixed; all affected cases pass locally. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — started before the review corrections; it spanned several source revisions and was stopped after reporting old-behavior and timing failures. It is not claimed green. Fresh project and recovery suites pass; the recovery suite also passes all 25 cases with the broad runner’s isolated home/config. The Slack timing case passed in isolation. Latest-head CI is the authoritative complete test matrix. - Existing authorization suite — 66 tests passed. - Latest-head CI on `8e5763d915aa6f375bdab6601996899ea01496fc` — 55 checks passed, 4 intentionally skipped, no pending or failing checks. - Greptile — 5/5, zero unresolved threads on the same commit. - `git diff --check` — passed. ## Risks - `list_projects` now returns summaries. Callers must follow `nextCursor` and use the authorized project API for full records. Candidate narrowing is only an optimization: project policy and responsible-user authorization remain authoritative. - Assessment identity changes for workspace finalization. Existing evidence remains intact; no migration is needed. - Cleanup still requires matching identities, settled tool evidence, and verified process ownership. Unknown tool outcomes remain blocked from session reuse. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, code execution, and GitHub tools. The exact serving model ID and context window are not exposed in this session. ## 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>
This commit is contained in:
1 parent
a386a59998
commit
a65ca09508
30 files changed
+434
-117
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { projectService } from "../services/projects.js";
|
||||
import { callProjectTool } from "../services/project-tools.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -19,6 +20,55 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; });
|
||||
const call = (fixture: Awaited<ReturnType<typeof server.fixture>>, tool: string, args: Record<string, unknown>) => fixture.authority.execute({ tool, arguments: args, callId: randomUUID() });
|
||||
|
||||
it("pages only visible projects and prefilters low-trust agent and run scopes", async () => {
|
||||
const f = await server.fixture({ disableWakeOnDemand: true });
|
||||
const ids = Array.from({ length: 53 }, (_, i) => `abcdefab-0000-4000-8000-${String(i).padStart(12, "0")}`);
|
||||
await server.db.insert(projects).values(ids.map((id, i) => ({
|
||||
id, companyId: f.companyId, name: `Project ${i}`, status: "in_progress",
|
||||
description: "日本語🦀".repeat(5_000), executionWorkspacePolicy: { oversized: "x".repeat(20_000) },
|
||||
})));
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const toolInput = { name: "list_projects", apiUrl: server.apiUrl, token, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: false };
|
||||
const first = await callProjectTool({ ...toolInput, arguments: {} });
|
||||
expect(first.projects).toHaveLength(50);
|
||||
expect(Buffer.byteLength(JSON.stringify(first))).toBeLessThan(256 * 1024);
|
||||
expect(first.projects.find((p: { id: string }) => p.id === ids[0])).toMatchObject({ descriptionTruncated: true });
|
||||
expect(first.projects.every((p: object) => !Object.hasOwn(p, "executionWorkspacePolicy") && !Object.hasOwn(p, "workspaces"))).toBe(true);
|
||||
const last = await callProjectTool({ ...toolInput, arguments: { cursor: first.nextCursor } });
|
||||
expect([...first.projects, ...last.projects].map((p: { id: string }) => p.id).sort()).toEqual([...ids, f.projectId].sort());
|
||||
expect(last.nextCursor).toBeNull();
|
||||
// A permitted project beyond the first database batch must remain discoverable.
|
||||
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: ids.slice(-2) } } };
|
||||
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
|
||||
expect((await projectService(server.db).listSummaries(f.companyId, {
|
||||
limit: 51, includeArchived: false, candidateIds: ids.slice(-2),
|
||||
})).map(p => p.id)).toEqual(ids.slice(-2));
|
||||
const restricted = await callProjectTool({ ...toolInput, arguments: { limit: 1 } });
|
||||
expect(restricted.projects.map((p: { id: string }) => p.id)).toEqual([ids[51]]);
|
||||
expect(restricted.nextCursor).toBe(ids[51]);
|
||||
const restrictedLast = await callProjectTool({ ...toolInput, arguments: { limit: 1, cursor: restricted.nextCursor } });
|
||||
expect(restrictedLast.projects.map((p: { id: string }) => p.id)).toEqual([ids[52]]);
|
||||
expect(restrictedLast.nextCursor).toBeNull();
|
||||
// A run-only boundary also restricts the database candidates.
|
||||
await server.db.update(agents).set({ permissions: {} }).where(eq(agents.id, f.agentId));
|
||||
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
await server.db.update(heartbeatRuns).set({ contextSnapshot: { ...run.contextSnapshot, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
const runRestricted = await callProjectTool({ ...toolInput, arguments: {} });
|
||||
expect(runRestricted.projects.map((p: { id: string }) => p.id)).toEqual(ids.slice(-2));
|
||||
expect(runRestricted.nextCursor).toBeNull();
|
||||
// Adding denied projects must not change either the visible page or cursor.
|
||||
await server.db.insert(projects).values({ companyId: f.companyId, name: "Hidden" });
|
||||
expect(await callProjectTool({ ...toolInput, arguments: {} })).toEqual(runRestricted);
|
||||
// Project policy may contribute a root scope, so candidate narrowing must
|
||||
// not suppress a project that the full authorization decision permits.
|
||||
await server.db.update(projects).set({ executionWorkspacePolicy: {
|
||||
authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId } },
|
||||
} }).where(eq(projects.id, f.projectId));
|
||||
const withProjectScope = await callProjectTool({ ...toolInput, arguments: {} });
|
||||
expect(withProjectScope.projects.map((p: { id: string }) => p.id).sort()).toEqual([...ids.slice(-2), f.projectId].sort());
|
||||
expect(withProjectScope.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a conversation reply to enter review without manufacturing a review interaction", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
|
||||
@@ -1798,7 +1798,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["settled", "rejected", "late_callback"] as const)(
|
||||
it.each(["settled", "rejected", "late_callback", "provider_transport_failed"] as const)(
|
||||
"joins startup and reap retained cleanup, keeps recovery live, and drains its %s operation",
|
||||
async (outcome) => {
|
||||
await withTempPaperclipHome(async () => {
|
||||
@@ -1882,7 +1882,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
.set({
|
||||
resultJson: sql`${heartbeatRuns.resultJson} || ${JSON.stringify({
|
||||
recoveredExecutionFailure: {
|
||||
errorCode: "adapter_failed",
|
||||
errorCode: outcome === "provider_transport_failed" ? "provider_transport_failed" : "adapter_failed",
|
||||
error:
|
||||
"provider_transport_failed: runner did not durably suspend before checkpoint",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { callProjectTool, projectToolDefinitions } from "../services/project-tools.js";
|
||||
|
||||
const input = {
|
||||
name: "list_projects", arguments: {}, apiUrl: "http://paperclip.test", token: "test-token",
|
||||
companyId: "company", issueId: "issue", agentId: "agent", conversation: false,
|
||||
};
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("project discovery result bounds", () => {
|
||||
it("requests bounded summaries and normalizes continuation cursors", async () => {
|
||||
const page = { projects: [{ id: "abcdefab-0000-4000-8000-000000000001", name: "Project", status: "in_progress", description: "Summary", descriptionTruncated: true }], nextCursor: "abcdefab-0000-4000-8000-000000000001" };
|
||||
const fetch = vi.fn(async (_url: string, _options: RequestInit) => ({ ok: true, json: async () => page }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
expect(await callProjectTool(input)).toEqual(page);
|
||||
expect(fetch.mock.calls[0]?.[0]).toBe("http://paperclip.test/api/companies/company/projects?view=summary&limit=50");
|
||||
await callProjectTool({ ...input, arguments: { limit: 3, cursor: page.nextCursor.toUpperCase() } });
|
||||
expect(fetch.mock.calls[1]?.[0]).toBe(`http://paperclip.test/api/companies/company/projects?view=summary&limit=3&cursor=${page.nextCursor}`);
|
||||
expect(projectToolDefinitions("standard").find(t => t.name === "list_projects")?.inputSchema)
|
||||
.toMatchObject({ properties: { cursor: expect.any(Object), limit: expect.any(Object) } });
|
||||
});
|
||||
|
||||
it.each([{ limit: 0 }, { limit: 51 }, { cursor: "invalid" }])("rejects invalid paging arguments %j before fetching", async (arguments_) => {
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await expect(callProjectTool({ ...input, arguments: arguments_ })).rejects.toThrow();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import { Router, type Request, type Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createProjectSchema,
|
||||
projectDiscoverySchema,
|
||||
type ProjectDiscoveryPage,
|
||||
createProjectWorkspaceSchema,
|
||||
findWorkspaceCommandDefinition,
|
||||
isUuidLike,
|
||||
@@ -210,6 +212,26 @@ export function projectRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const includeArchived = req.query.includeArchived === "true";
|
||||
if (req.query.view === "summary") {
|
||||
const page = projectDiscoverySchema.extend({ limit: z.coerce.number().int().min(1).max(50).default(50) }).parse({
|
||||
limit: req.query.limit, cursor: req.query.cursor,
|
||||
});
|
||||
const candidateIds = await access.projectDiscoveryCandidateIds(req.actor, companyId);
|
||||
const visible: ProjectDiscoveryPage["projects"] = [];
|
||||
let cursor = page.cursor;
|
||||
// Scan bounded projections; authorization runs before selecting the public
|
||||
// page/cursor so denied projects neither fill pages nor leak their IDs.
|
||||
while (visible.length <= page.limit) {
|
||||
const batch = await svc.listSummaries(companyId, { limit: 51, cursor, includeArchived, candidateIds });
|
||||
const allowed = await filterProjectsForActor(req, batch.map(project => ({ ...project, companyId })));
|
||||
visible.push(...allowed.map(({ companyId: _companyId, ...project }) => project));
|
||||
if (batch.length < 51) break;
|
||||
cursor = batch.at(-1)!.id;
|
||||
}
|
||||
const selected = visible.slice(0, page.limit);
|
||||
res.json({ projects: selected, nextCursor: visible.length > page.limit ? selected.at(-1)!.id : null } satisfies ProjectDiscoveryPage);
|
||||
return;
|
||||
}
|
||||
const result = await svc.list(companyId, { includeArchived });
|
||||
res.json(await filterProjectsForActor(req, result));
|
||||
});
|
||||
|
||||
@@ -1128,6 +1128,7 @@ export function accessService(db: Db) {
|
||||
return {
|
||||
isInstanceAdmin,
|
||||
decide,
|
||||
projectDiscoveryCandidateIds: authorization.projectDiscoveryCandidateIds,
|
||||
canUser,
|
||||
hasPermission,
|
||||
getMembership,
|
||||
|
||||
@@ -2461,8 +2461,28 @@ export function authorizationService(db: Db | DbTransaction) {
|
||||
return applyResponsibleUserIntersection(input, agentDecision);
|
||||
}
|
||||
|
||||
// A candidate filter only: project policies and responsible-user grants are
|
||||
// still evaluated by decide(). Project policies can contribute an additional
|
||||
// root/project scope, so the query must also retain projects with such policy.
|
||||
async function projectDiscoveryCandidateIds(actor: AuthorizationActor, companyId: string): Promise<string[] | null> {
|
||||
if (actor.type !== "agent" || !actor.agentId || actor.keyScope) return null;
|
||||
const agent = await loadAgent(actor.agentId);
|
||||
if (!agent || agent.companyId !== companyId) return [];
|
||||
const run = await loadRunPolicy(actor.runId, companyId, agent.id);
|
||||
const resolution = resolveCoreTrustPreset({ companyId, agent, run });
|
||||
// A project can supply a missing boundary; never prefilter that case.
|
||||
if (resolution.kind !== "low_trust_review") return null;
|
||||
const ids = new Set(resolution.boundary.projectIds ?? []);
|
||||
if (resolution.boundary.rootIssueId) {
|
||||
const root = await loadIssue(resolution.boundary.rootIssueId);
|
||||
if (root?.companyId === companyId && root.projectId) ids.add(root.projectId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
return {
|
||||
decide,
|
||||
projectDiscoveryCandidateIds,
|
||||
decidePrincipalGrant,
|
||||
};
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export function reconcileRetainedNativeSessionCleanups(
|
||||
),
|
||||
// The accepted-result projector preserves a recovered close failure
|
||||
// privately after clearing the visible successful run's stale error.
|
||||
sql`coalesce(${heartbeatRuns.errorCode}, ${heartbeatRuns.resultJson}->'recoveredExecutionFailure'->>'errorCode') = 'adapter_failed'`,
|
||||
sql`coalesce(${heartbeatRuns.errorCode}, ${heartbeatRuns.resultJson}->'recoveredExecutionFailure'->>'errorCode') in ('adapter_failed', 'provider_transport_failed')`,
|
||||
sql`coalesce(${heartbeatRuns.error}, ${heartbeatRuns.resultJson}->'recoveredExecutionFailure'->>'error') = 'provider_transport_failed: runner did not durably suspend before checkpoint'`,
|
||||
sql`not (${nativeRunFinalizations.recoveryHistory} @> '[{"kind":"native_cleanup_runner_epoch"}]'::jsonb)`,
|
||||
sql`not (${nativeRunFinalizations.recoveryHistory} @> '[{"kind":"native_cleanup_source_archive","phase":"operator_required"}]'::jsonb)`,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
nativeRunFinalizations,
|
||||
nativeRunResults,
|
||||
workAssessments,
|
||||
statusDecisions,
|
||||
issueRecoveryActions,
|
||||
agentWakeupRequests,
|
||||
workspaceOperations,
|
||||
@@ -184,6 +185,42 @@ describeEmbeddedPostgres("native run finalizer / status decision committer — a
|
||||
});
|
||||
}
|
||||
|
||||
it("finishes an accepted result after shutdown failed and workspace repair succeeded", async () => {
|
||||
const fixture = await seedNativeRun();
|
||||
await db.update(completionContracts).set({ risk: "low", completionAuthority: "agent_claim_policy",
|
||||
contractJson: { revision: CONTROL_PLANE_CONFORMANCE_RESULT.completionClaim.contractRevision,
|
||||
objective: "Finish the work", criteria: [{ id: "objective", requirement: "Complete the task" }] } })
|
||||
.where(eq(completionContracts.id, fixture.contractId));
|
||||
await driveToCompleteResult(fixture);
|
||||
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date(),
|
||||
errorCode: "provider_transport_failed", error: "provider_transport_failed: runner did not durably suspend before checkpoint" })
|
||||
.where(eq(heartbeatRuns.id, fixture.runId));
|
||||
await db.update(nativeRunFinalizations).set({ phase: "retryable_failure", leaseOwner: null,
|
||||
leaseExpiresAt: null, nextAttemptAt: null }).where(eq(nativeRunFinalizations.runId, fixture.runId));
|
||||
const failed = await finalizeNativeRun({ db, runId: fixture.runId, workspaceFinalizeStatus: "failed" });
|
||||
// An unchanged failed workspace must replay its existing decision.
|
||||
await finalizeNativeRun({ db, runId: fixture.runId, workspaceFinalizeStatus: "failed" });
|
||||
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, fixture.runId))).toHaveLength(1);
|
||||
await db.insert(workspaceOperations).values({ companyId, issueId: fixture.issueId,
|
||||
heartbeatRunId: fixture.runId, phase: "workspace_finalize", status: "succeeded" });
|
||||
await db.update(nativeRunFinalizations).set({ nextAttemptAt: null }).where(eq(nativeRunFinalizations.runId, fixture.runId));
|
||||
await reconcileNativeFinalizations(db, [fixture.runId]);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, fixture.issueId)))[0]).toMatchObject({
|
||||
status: "done", executionRunId: null, checkoutRunId: null,
|
||||
});
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)))[0]).toMatchObject({
|
||||
status: "succeeded", errorCode: null, error: null,
|
||||
resultJson: { recoveredExecutionFailure: { errorCode: "provider_transport_failed" } },
|
||||
});
|
||||
const assessments = await db.select().from(workAssessments).where(eq(workAssessments.runId, fixture.runId));
|
||||
expect(assessments).toHaveLength(2);
|
||||
expect(assessments.find(row => row.id !== failed.assessmentId)?.supersedesAssessmentId).toBe(failed.assessmentId);
|
||||
await reconcileNativeFinalizations(db, [fixture.runId]);
|
||||
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, fixture.runId))).toHaveLength(2);
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, fixture.runId))).toHaveLength(1);
|
||||
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits exactly one agent.task_run event when the native finalizer commits a terminal status", async () => {
|
||||
const fixture = await seedNativeRun();
|
||||
await driveToCompleteResult(fixture);
|
||||
|
||||
@@ -615,6 +615,16 @@ async function resolveCommittedFinalizationRecovery(db: Db, run: typeof heartbea
|
||||
});
|
||||
}
|
||||
|
||||
function recoveredExecutionFailureMetadata() {
|
||||
// Read the row being updated so a concurrent cleanup diagnostic is retained.
|
||||
return sql`case when ${heartbeatRuns.error} is not null or ${heartbeatRuns.errorCode} is not null
|
||||
then jsonb_build_object('recoveredExecutionFailure', jsonb_build_object(
|
||||
'schema', 'paperclip.recovered_execution_failure.v1',
|
||||
'errorCode', ${heartbeatRuns.errorCode}, 'error', ${heartbeatRuns.error},
|
||||
'observedAt', ${heartbeatRuns.updatedAt}
|
||||
)) else '{}'::jsonb end`;
|
||||
}
|
||||
|
||||
async function projectCommittedRun(input: {
|
||||
db: Db;
|
||||
run: typeof heartbeatRuns.$inferSelect;
|
||||
@@ -658,18 +668,9 @@ async function projectCommittedRun(input: {
|
||||
// Capture the row being updated, not the earlier admission read:
|
||||
// cleanup may have recorded a new diagnostic in the meantime.
|
||||
// Other result metadata and all physical-owner evidence stay put.
|
||||
resultJson: sql`case
|
||||
when ${heartbeatRuns.error} is not null or ${heartbeatRuns.errorCode} is not null
|
||||
then coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || jsonb_build_object(
|
||||
'recoveredExecutionFailure', jsonb_build_object(
|
||||
'schema', 'paperclip.recovered_execution_failure.v1',
|
||||
'errorCode', ${heartbeatRuns.errorCode},
|
||||
'error', ${heartbeatRuns.error},
|
||||
'observedAt', ${heartbeatRuns.updatedAt}
|
||||
)
|
||||
)
|
||||
else coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb)
|
||||
end || jsonb_build_object('finalizationPhase', 'committed', 'failureCode', null, 'originalFailureCode', null, 'nextAttemptAt', null)`,
|
||||
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb)
|
||||
|| ${recoveredExecutionFailureMetadata()}
|
||||
|| jsonb_build_object('finalizationPhase', 'committed', 'failureCode', null, 'originalFailureCode', null, 'nextAttemptAt', null)`,
|
||||
}
|
||||
: { resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || jsonb_build_object('finalizationPhase', 'committed', 'failureCode', null, 'originalFailureCode', null, 'nextAttemptAt', null)` }),
|
||||
updatedAt: now,
|
||||
@@ -1197,7 +1198,7 @@ export async function finalizeNativeRun(input: {
|
||||
// One follow-up may repair an incomplete report. Repeated incomplete results
|
||||
// require a visible recovery action instead of an unbounded wake loop.
|
||||
const allowIncompleteContinuation = record(sourceWake?.payload).continuationIdempotencyKey !== "native-completion-incomplete";
|
||||
let supersedesAssessmentId: string | null = null;
|
||||
let supersedesAssessmentId: string | null = coordinator.assessmentId;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const authoritativeIssue = await input.db
|
||||
.select()
|
||||
@@ -1322,12 +1323,18 @@ export async function finalizeNativeRun(input: {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
assessment,
|
||||
supersedesAssessmentId,
|
||||
workspaceFinalizeStatus: input.workspaceFinalizeStatus,
|
||||
});
|
||||
await input.db
|
||||
.update(nativeRunFinalizations)
|
||||
.set({
|
||||
phase: "arbitrating",
|
||||
assessmentId: assessmentRow.id,
|
||||
// The old decision refers to its original assessment through a
|
||||
// composite foreign key. Replace this pair together in the status
|
||||
// committer, after the new decision and its effects are durable.
|
||||
// Before any decision exists, retain the assessment for crash recovery.
|
||||
assessmentId: sql`case when ${nativeRunFinalizations.decisionId} is null
|
||||
then ${assessmentRow.id}::uuid else ${nativeRunFinalizations.assessmentId} end`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
@@ -1391,6 +1398,40 @@ export async function finalizeNativeRun(input: {
|
||||
const alreadyEmittedByCommittedDecision = decision.effects.some(
|
||||
(effect) => effect.kind === "cancel_continuations",
|
||||
);
|
||||
const clearExecutionFailure = input.projectRunStatus && finalizationPhase === "committed" && terminalState === "succeeded";
|
||||
const finalizationMetadata = {
|
||||
finalizationPhase,
|
||||
...(finalizationPhase === "committed" ? { failureCode: null, originalFailureCode: null, nextAttemptAt: null } : {}),
|
||||
assessmentId: assessmentRow.id,
|
||||
decisionId: committed.decision.id,
|
||||
authoritativeDecision: decision.toStatus,
|
||||
finalizationPolicyVersion: decision.policyVersion,
|
||||
finalizationReasonCode: decision.reasonCode,
|
||||
// Only the locked status transaction can mint this presentation
|
||||
// proof. Never carry a runner-provided marker forward.
|
||||
externalChatReviewPresentation: record(
|
||||
committed.decision.decisionJson,
|
||||
).externalChatReviewPresentation
|
||||
? {
|
||||
...record(
|
||||
record(committed.decision.decisionJson)
|
||||
.externalChatReviewPresentation,
|
||||
),
|
||||
decisionId: committed.decision.id,
|
||||
}
|
||||
: null,
|
||||
...(record(committed.decision.decisionJson)
|
||||
.externalChatReviewPresentation
|
||||
? { nativeResult: result }
|
||||
: {}),
|
||||
verificationCaveats: assessment.verificationCaveats,
|
||||
ignoredAttentionRequests: assessment.ignoredAttentionRequests,
|
||||
issueStatusBefore: authoritativeIssue.status,
|
||||
issueStatusAfter: committed.issue.status,
|
||||
statusVersionBefore: Number(authoritativeIssue.statusVersion),
|
||||
statusVersionAfter: Number(committed.issue.statusVersion),
|
||||
workspaceFinalizeStatus: input.workspaceFinalizeStatus,
|
||||
};
|
||||
const [updatedRun] = await input.db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
@@ -1408,40 +1449,10 @@ export async function finalizeNativeRun(input: {
|
||||
: {}),
|
||||
nativePhase: finalizationPhase,
|
||||
nativePhaseUpdatedAt: now,
|
||||
resultJson: {
|
||||
...record(run.resultJson),
|
||||
finalizationPhase,
|
||||
...(finalizationPhase === "committed" ? { failureCode: null, originalFailureCode: null, nextAttemptAt: null } : {}),
|
||||
assessmentId: assessmentRow.id,
|
||||
decisionId: committed.decision.id,
|
||||
authoritativeDecision: decision.toStatus,
|
||||
finalizationPolicyVersion: decision.policyVersion,
|
||||
finalizationReasonCode: decision.reasonCode,
|
||||
// Only the locked status transaction can mint this presentation
|
||||
// proof. Never carry a runner-provided marker forward.
|
||||
externalChatReviewPresentation: record(
|
||||
committed.decision.decisionJson,
|
||||
).externalChatReviewPresentation
|
||||
? {
|
||||
...record(
|
||||
record(committed.decision.decisionJson)
|
||||
.externalChatReviewPresentation,
|
||||
),
|
||||
decisionId: committed.decision.id,
|
||||
}
|
||||
: null,
|
||||
...(record(committed.decision.decisionJson)
|
||||
.externalChatReviewPresentation
|
||||
? { nativeResult: result }
|
||||
: {}),
|
||||
verificationCaveats: assessment.verificationCaveats,
|
||||
ignoredAttentionRequests: assessment.ignoredAttentionRequests,
|
||||
issueStatusBefore: authoritativeIssue.status,
|
||||
issueStatusAfter: committed.issue.status,
|
||||
statusVersionBefore: Number(authoritativeIssue.statusVersion),
|
||||
statusVersionAfter: Number(committed.issue.statusVersion),
|
||||
workspaceFinalizeStatus: input.workspaceFinalizeStatus,
|
||||
},
|
||||
...(clearExecutionFailure ? { error: null, errorCode: null } : {}),
|
||||
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb)
|
||||
|| ${JSON.stringify(finalizationMetadata)}::jsonb
|
||||
|| ${clearExecutionFailure ? recoveredExecutionFailureMetadata() : sql`'{}'::jsonb`}`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id))
|
||||
|
||||
@@ -2641,6 +2641,7 @@ const execution = {
|
||||
describe("retained native cleanup activation", () => {
|
||||
it.each([
|
||||
"settled",
|
||||
"provider_transport_failed",
|
||||
"canonical_source",
|
||||
"canonical_live_owner",
|
||||
"canonical_foreign_owner",
|
||||
@@ -2955,7 +2956,7 @@ describe("retained native cleanup activation", () => {
|
||||
processGroupId: mode.endsWith("live_owner") ? process.pid : 99_999_999,
|
||||
completionContractId: "contract",
|
||||
completionContractSha256: "sha",
|
||||
errorCode: "adapter_failed",
|
||||
errorCode: mode === "provider_transport_failed" ? "provider_transport_failed" : "adapter_failed",
|
||||
error:
|
||||
"provider_transport_failed: runner did not durably suspend before checkpoint",
|
||||
runnerProfileJson: {
|
||||
@@ -3822,6 +3823,7 @@ describe("retained native cleanup activation", () => {
|
||||
"provider_home",
|
||||
"legacy_copy",
|
||||
"settled",
|
||||
"provider_transport_failed",
|
||||
"activation_commit_stalled",
|
||||
"empty_root",
|
||||
"distinct_provider_account",
|
||||
|
||||
@@ -2829,7 +2829,7 @@ export async function reconcileRetainedNativeSessionCleanup(
|
||||
const errorCode = run.errorCode ?? failure.errorCode;
|
||||
const error = run.error ?? failure.error;
|
||||
if (
|
||||
errorCode !== "adapter_failed" ||
|
||||
!["adapter_failed", "provider_transport_failed"].includes(String(errorCode)) ||
|
||||
error !==
|
||||
"provider_transport_failed: runner did not durably suspend before checkpoint" ||
|
||||
execution.binding.companyId !== run.companyId ||
|
||||
|
||||
@@ -1746,6 +1746,34 @@ export async function commitNativeStatusDecision(input: {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const recordCoordinatorDecision = async (decisionId: string) => {
|
||||
const finalizationError = input.decision.effects.find(
|
||||
effect => effect.kind === "record_finalization_error",
|
||||
);
|
||||
await tx
|
||||
.update(nativeRunFinalizations)
|
||||
.set({
|
||||
phase: finalizationError ? "retryable_failure" : "committed",
|
||||
assessmentId: input.assessmentId,
|
||||
decisionId,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
failureCode: finalizationError?.cause ?? null,
|
||||
failureDetail: finalizationError
|
||||
? {
|
||||
originalFailureCode: finalizationError.cause,
|
||||
recoveryOwner: {
|
||||
kind: "agent",
|
||||
agentId: finalizationError.agentId,
|
||||
},
|
||||
nextAction: finalizationError.nextAction,
|
||||
}
|
||||
: null,
|
||||
nextAttemptAt: finalizationError ? new Date(Date.now() + 30_000) : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(nativeRunFinalizations.runId, input.runId));
|
||||
};
|
||||
const decisionJson = {
|
||||
statusAction: input.decision.statusAction,
|
||||
toStatus: input.decision.toStatus,
|
||||
@@ -1789,6 +1817,9 @@ export async function commitNativeStatusDecision(input: {
|
||||
decisionRow?.applicationState === "applied" ||
|
||||
decisionRow?.applicationState === "proposed"
|
||||
) {
|
||||
// The effects already committed, but this retry owns a fresh coordinator
|
||||
// lease. Release it and restore the retry deadline without replaying effects.
|
||||
await recordCoordinatorDecision(decisionRow.id);
|
||||
return { decision: decisionRow, issue, replayed: true };
|
||||
}
|
||||
if (!decisionRow) {
|
||||
@@ -2042,9 +2073,6 @@ export async function commitNativeStatusDecision(input: {
|
||||
const shadowOnly = input.decision.effects.some(
|
||||
(effect) => effect.kind === "record_shadow_decision",
|
||||
);
|
||||
const finalizationError = input.decision.effects.find(
|
||||
(effect) => effect.kind === "record_finalization_error",
|
||||
);
|
||||
const applicationState = shadowOnly ? "proposed" : "applied";
|
||||
await tx
|
||||
.update(statusDecisions)
|
||||
@@ -2053,29 +2081,7 @@ export async function commitNativeStatusDecision(input: {
|
||||
appliedAt: shadowOnly ? null : new Date(),
|
||||
})
|
||||
.where(eq(statusDecisions.id, decisionRow.id));
|
||||
await tx
|
||||
.update(nativeRunFinalizations)
|
||||
.set({
|
||||
phase: finalizationError ? "retryable_failure" : "committed",
|
||||
assessmentId: input.assessmentId,
|
||||
decisionId: decisionRow.id,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
failureCode: finalizationError?.cause ?? null,
|
||||
failureDetail: finalizationError
|
||||
? {
|
||||
originalFailureCode: finalizationError.cause,
|
||||
recoveryOwner: {
|
||||
kind: "agent",
|
||||
agentId: finalizationError.agentId,
|
||||
},
|
||||
nextAction: finalizationError.nextAction,
|
||||
}
|
||||
: null,
|
||||
nextAttemptAt: finalizationError ? new Date(Date.now() + 30_000) : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(nativeRunFinalizations.runId, input.runId));
|
||||
await recordCoordinatorDecision(decisionRow.id);
|
||||
if (externalChatReviewPresentation && input.reviewResponsePresentation) {
|
||||
await restoreNativeChatReviewPresentationInTransaction(
|
||||
tx as unknown as Db,
|
||||
|
||||
@@ -19,6 +19,8 @@ export async function recordNativeWorkAssessment(input: {
|
||||
priorDecisionId: string | null;
|
||||
policyVersion: string;
|
||||
assessment: NativeEvidenceAssessment;
|
||||
/** Workspace repair changes the status decision even when work evidence is unchanged. */
|
||||
workspaceFinalizeStatus?: "succeeded" | "failed";
|
||||
supersedesAssessmentId?: string | null;
|
||||
}) {
|
||||
const assessmentJson = input.assessment as unknown as Record<string, unknown>;
|
||||
@@ -36,6 +38,7 @@ export async function recordNativeWorkAssessment(input: {
|
||||
triggerCapability: "server_native_finalizer",
|
||||
policyVersion: input.policyVersion,
|
||||
assessment: assessmentJson,
|
||||
...(input.workspaceFinalizeStatus ? { workspaceFinalizeStatus: input.workspaceFinalizeStatus } : {}),
|
||||
});
|
||||
const existing = await input.db.select().from(workAssessments).where(and(
|
||||
eq(workAssessments.issueId, input.issueId),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createProjectSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared";
|
||||
import { projectDiscoverySchema, createProjectSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared";
|
||||
import { z } from "zod";
|
||||
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../vendor/paperclip-runner/index.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
@@ -21,13 +21,18 @@ export async function callProjectTool(input: {
|
||||
companyId: string; issueId: string; agentId: string; conversation: boolean;
|
||||
}) {
|
||||
const args = input.arguments;
|
||||
const page = input.name === "list_projects" ? projectDiscoverySchema.parse(args) : null;
|
||||
let path = `/companies/${input.companyId}/projects`;
|
||||
let body: unknown;
|
||||
if (input.name === "set_task_title") {
|
||||
path = `/issues/${input.issueId}/title`;
|
||||
body = setIssueTitleSchema.parse(args);
|
||||
} else if (input.name === "list_project_repositories") path = `/companies/${input.companyId}/project-repositories`;
|
||||
else if (input.name === "list_projects") { /* read projects */ }
|
||||
else if (page) {
|
||||
const query = new URLSearchParams({ view: "summary", limit: String(page.limit) });
|
||||
if (page.cursor) query.set("cursor", page.cursor);
|
||||
path += `?${query}`;
|
||||
}
|
||||
else if (input.name === "create_project") {
|
||||
body = createProjectSchema.extend({ idempotencyKey: z.string().min(1).max(255) }).parse(args);
|
||||
} else if (input.name === "create_task") {
|
||||
@@ -51,5 +56,5 @@ export async function callProjectTool(input: {
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Project tool failed (${response.status})`);
|
||||
return input.name === "list_projects" ? { projects: result } : result;
|
||||
return result;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
projects,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
normalizeProjectUrlKey,
|
||||
type BudgetWindowKind,
|
||||
type ProjectBudgetSummary,
|
||||
type ProjectDiscoverySummary,
|
||||
type ProjectCodebase,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
type ProjectGoalRef,
|
||||
@@ -620,6 +621,27 @@ export function projectService(db: Db) {
|
||||
};
|
||||
|
||||
return {
|
||||
// Project discovery never reads workspace JSON, goals, metrics, or full descriptions.
|
||||
listSummaries: async (companyId: string, opts: { limit: number; cursor?: string; includeArchived: boolean; candidateIds: string[] | null }): Promise<ProjectDiscoverySummary[]> => {
|
||||
return db.select({
|
||||
id: projects.id,
|
||||
name: sql<string>`left(${projects.name}, 500)`,
|
||||
status: projects.status,
|
||||
description: sql<string | null>`left(${projects.description}, 1000)`,
|
||||
descriptionTruncated: sql<boolean>`coalesce(length(${projects.description}) > 1000, false)`,
|
||||
}).from(projects).where(and(
|
||||
eq(projects.companyId, companyId),
|
||||
opts.includeArchived ? undefined : isNull(projects.archivedAt),
|
||||
opts.cursor ? gt(projects.id, opts.cursor) : undefined,
|
||||
opts.candidateIds === null ? undefined : or(
|
||||
inArray(projects.id, opts.candidateIds),
|
||||
// Project policy can add a root/project boundary to the actor scope.
|
||||
// Keep these candidates for the authoritative per-project decision.
|
||||
sql`${projects.executionWorkspacePolicy}->'authorizationPolicy' is not null`,
|
||||
),
|
||||
)).orderBy(asc(projects.id)).limit(opts.limit);
|
||||
},
|
||||
|
||||
list: async (companyId: string, opts: { includeArchived?: boolean } = {}): Promise<ProjectWithGoals[]> => {
|
||||
// NOTE: this service default is intentionally the inverse of the HTTP route default.
|
||||
// The route (`GET /companies/:companyId/projects`) defaults `includeArchived` to `false`
|
||||
|
||||
Reference in new issue
Block a user