Merge master project discovery and finalization fixes into native guidance work

Regenerate semantic contracts from both source changes. Frozen paid comparison branches remain unchanged.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 10:41:04 -05:00
commit ee096aa23f
30 files changed
+434 -117

No files matched your search

+23
View File
@@ -1216,6 +1216,29 @@ the updated sandbox image with the matching runner qualification changes.
### Native runner restart recovery
Project discovery through `list_projects` returns up to 50 compact summaries.
It uses `GET /api/companies/:companyId/projects?view=summary&limit=50&cursor=...`;
the cursor is optional. The database reads bounded summary projections. Agent
and run trust boundaries narrow database candidates before per-project access
checks. Projects with their own authorization policy remain candidates because
that policy can contribute scope; every result still passes the full access check.
Only visible projects determine page boundaries and continuations.
The default project-list API response remains unchanged.
Use its `nextCursor` as the next call's `cursor` until it is null; `limit` accepts
1–50. Descriptions include at most 1,000 characters and an explicit truncation
flag. Full project records remain available through the authorized project API.
Workspace configuration is excluded from discovery responses so large projects
cannot overflow the runner's durable tool-result command limit.
When an accepted result survives a shutdown failure, workspace repair uses a
new assessment for the repaired workspace state. Its status decision and
assessment reference commit together. Recovery completes from the saved result
without another provider turn, clears stale successful-run errors, and retains
the original error in `recoveredExecutionFailure`. Exact-state session cleanup
recognizes both the legacy `adapter_failed` and current `provider_transport_failed`
close-failure labels. Process ownership, pending tool outcomes, and checkpoint
verification still control whether that session can be reused.
Paperclip Runner keeps its heartbeat run, native session, logical runner, and
provider session identities across server restarts. A coordinated hot restart
registers a correlated recovery request before it signals the dev supervisor.
+7
View File
@@ -52,6 +52,13 @@ and incomplete result-delivery command IDs and statuses. If execution and
cleanup both fail, execution retains its original error identity and cleanup is
attached as `cleanupError`.
Semantic settlement also includes up to 20 content-free failure records, with
the call ID, operation ID, stage (`dispatch` or `persist_result`), and cause.
Causes distinguish an oversized command, a full command journal, known storage
errors, dispatcher rejection, and other persistence failures. Exception messages
and tool results are excluded. These diagnostics do not authorize replay of an
operation whose outcome is unknown.
Instruction writes also commit an `agent.instruction_write_attempted` activity
row and a run-scoped `instructionToolAttempts` entry before permitting the
filesystem effect. They retain the call ID, operation ID, and input digest, not
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
@@ -1878,11 +1878,23 @@
"planning",
"skill_test"
],
"description": "Inspect available company projects before selecting a project for new work.",
"description": "List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.",
"effect": "read",
"inputSchema": {
"additionalProperties": false,
"properties": {},
"properties": {
"cursor": {
"description": "The nextCursor returned by the previous page.",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"type": "string"
},
"limit": {
"description": "Page size (default 50).",
"maximum": 50,
"minimum": 1,
"type": "integer"
}
},
"required": [],
"type": "object"
},
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:d1ba8cd54e3a1648f1b54cc942e7c8405c50133dc6aabdd27f220e8472a1ba68",
"catalogSha256": "sha256:4a98f05fc33a6b407ca7b4ae668f799a7b7ac55e4f293fc84888b22fe371c244",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "044d8532b9a51d5ad254618a32f2d9d39227b70110d916e1a05b8072f4ee4e22",
"sha256": "1ef1b62506d7c044d68dd9ed8033bd7901f8dba47aea70188c85878d448f6a0a",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -1,3 +1,4 @@
import { listProjectsDescription, listProjectsInputSchema } from "../protocol-actions/list-projects.js";
import { setTaskTitleAction } from "../protocol-actions/set-task-title.js";
import { reassignTaskAction } from "../protocol-actions/reassign-task.js";
import { hireAgentAction } from "../protocol-actions/hire-agent.js";
@@ -475,9 +476,9 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
operationId: "list_projects",
title: "List projects",
requiredClaims: ["discovery:projects:read"],
description: "Inspect available company projects before selecting a project for new work.",
description: listProjectsDescription,
placement: "optional",
inputSchema: object({}),
inputSchema: listProjectsInputSchema,
}),
descriptor({
operationId: "list_project_repositories",
@@ -2258,6 +2258,27 @@ describe.sequential("DurablePrpControlPlane", () => {
} finally { await core.stop(); rmSync(root, { recursive: true, force: true }); }
});
it("reports an oversized semantic result without exposing its content or redispatching", async () => {
const root = mkdtempSync(resolve(tmpdir(), "paperclip-result-limit-"));
const handler = vi.fn(async () => ({ result: { privateContent: "x".repeat(600_000) } }));
const core = new DurablePrpControlPlane({ stateDirectory: root, identity,
expectedRunnerVersion, expectedRunnerDigest, onSemanticToolInput: handler });
try {
await core.start();
const client = (await authenticate(core, core.issueBootstrapTicket()))!;
sendSecure(client, semanticInputEvent());
await vi.waitFor(() => expect(core.semanticToolSettlementDiagnostics()).toMatchObject({
persistenceFailed: true,
failures: [{ callId: "call-1", operationId: "get_task_context", stage: "persist_result", code: "command_payload_too_large" }],
}));
expect(core.store.state.commands).toEqual([]);
expect(core.semanticToolResultsSettled()).toBe(false);
expect(JSON.stringify(core.semanticToolSettlementDiagnostics())).not.toContain("privateContent");
expect(handler).toHaveBeenCalledOnce();
client.socket.destroy();
} finally { await core.stop(); rmSync(root, { recursive: true, force: true }); }
});
it.each(["before_dispatch", "during_effect"] as const)("settles only proven pre-dispatch cancellation (%s)", async (stage) => {
const root = mkdtempSync(resolve(tmpdir(), "paperclip-tool-dispatch-boundary-"));
const handler = vi.fn(async () => {
@@ -55,6 +55,11 @@ const maxFrameBytes = 1024 * 1024;
// Secure frames hex-encode ciphertext. Reserve envelope/tag space as well.
const maxCommandBytes = Math.floor((maxFrameBytes - 4 * 1024) / 2);
const maxCommands = 500;
class CommandJournalLimitError extends Error {
constructor(readonly code: "command_payload_too_large" | "command_journal_full") {
super(`Durable PRP command journal bound exceeded: ${code}.`);
}
}
// A provider can emit several 100-event runner batches before the transport's
// polling turn regains the event loop. Match the transport's explicit deferred
// event bound so a valid burst is not compacted before it can be observed.
@@ -1476,6 +1481,12 @@ export class DurablePrpControlPlane {
#connectionProcessing = new Map<AuthorityConnection, Promise<void>>();
#pendingSemanticCalls = new Set<string>();
#semanticResultPersistenceFailed = false;
#semanticResultFailures: Array<{
callId: string;
operationId: string;
stage: "persist_result" | "dispatch";
code: string;
}> = [];
#port: number | null = null;
#onSemanticToolInput?: DurablePrpControlPlaneOptions["onSemanticToolInput"];
#onCommittedEvent?: DurablePrpControlPlaneOptions["onCommittedEvent"];
@@ -1641,6 +1652,7 @@ export class DurablePrpControlPlane {
);
return {
persistenceFailed: this.#semanticResultPersistenceFailed,
failures: this.#semanticResultFailures,
pending: pending.map((entry) => {
const event = entry.envelope.payload as Record<string, unknown>;
const payload = event.payload as Record<string, unknown>;
@@ -1948,12 +1960,10 @@ export class DurablePrpControlPlane {
status: "pending",
result: null,
};
if (
this.#store.state.commands.length >= maxCommands ||
Buffer.byteLength(JSON.stringify(command)) > maxCommandBytes
) {
throw new Error("Durable PRP command journal bound exceeded.");
}
if (this.#store.state.commands.length >= maxCommands)
throw new CommandJournalLimitError("command_journal_full");
if (Buffer.byteLength(JSON.stringify(command)) > maxCommandBytes)
throw new CommandJournalLimitError("command_payload_too_large");
this.#store.state.commands.push(command);
this.#store.save();
if (deliverImmediately) {
@@ -3262,6 +3272,18 @@ export class DurablePrpControlPlane {
// evidence stays pending for authoritative reconciliation.
if (existing === undefined && !alreadyQueued && !this.#pendingSemanticCalls.has(commandId)) {
this.#pendingSemanticCalls.add(commandId);
const recordFailure = (stage: "persist_result" | "dispatch", error: unknown) => {
this.#semanticResultPersistenceFailed = true;
// Keep diagnostics bounded and content-free. Exception messages can
// contain tool bodies or credentials; retain only known error codes.
const storageCode = error && typeof error === "object" && "code" in error ? error.code : null;
const code = error instanceof CommandJournalLimitError ? error.code
: typeof storageCode === "string" && ["ENOSPC", "EDQUOT", "EACCES", "EPERM", "EIO", "EROFS"].includes(storageCode)
? storageCode : stage === "dispatch" ? "dispatcher_rejected" : "result_persistence_failed";
this.#semanticResultFailures.push({ callId: call.callId, operationId: call.operationId, stage, code });
this.#semanticResultFailures = this.#semanticResultFailures.slice(-20);
this.disconnectActiveRunner();
};
const queueResult = (result: unknown, isError: boolean): void => {
try {
// Retain the full input once in its canonical event. Copying a
@@ -3274,12 +3296,11 @@ export class DurablePrpControlPlane {
commandId,
true,
);
} catch {
this.#semanticResultPersistenceFailed = true;
} catch (error) {
// A result that cannot fit the bounded durable journal cannot be
// acknowledged as a usable tool response. Force a reconnect so
// the caller can recover or terminate the run explicitly.
this.disconnectActiveRunner();
recordFailure("persist_result", error);
}
};
void this.#onSemanticToolInput(call)
@@ -3297,8 +3318,7 @@ export class DurablePrpControlPlane {
}
// A rejected dispatcher promise does not prove that its effect
// rolled back. Do not fabricate a final failure or retry the write.
this.#semanticResultPersistenceFailed = true;
this.disconnectActiveRunner();
recordFailure("dispatch", error);
})
.finally(() => this.#pendingSemanticCalls.delete(commandId));
}
@@ -1,3 +1,15 @@
export const listProjectsInputSchema = {
type: "object",
properties: {
limit: { type: "integer", minimum: 1, maximum: 50, description: "Page size (default 50)." },
cursor: { type: "string", pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", description: "The nextCursor returned by the previous page." },
},
required: [],
additionalProperties: false,
} as const;
export const listProjectsDescription = "List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.";
/** Canonical project discovery definition. */
export const listProjectsAction = {
"id": "list_projects",
@@ -29,7 +41,7 @@ export const listProjectsAction = {
},
"documentation": {
"title": "List projects",
"description": "Inspect available company projects before selecting a project for new work.",
"description": listProjectsDescription,
"note": null
},
"examples": {
@@ -50,7 +62,7 @@ export const listProjectsAction = {
"operationId": "list_projects",
"version": 1,
"title": "List projects",
"description": "Inspect available company projects before selecting a project for new work.",
"description": listProjectsDescription,
"effect": "read",
"requiredClaims": [
"discovery:projects:read"
@@ -61,12 +73,7 @@ export const listProjectsAction = {
"planning",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"inputSchema": listProjectsInputSchema,
"outputSchema": {
"type": "object",
"additionalProperties": true
@@ -80,13 +87,8 @@ export const listProjectsAction = {
"operationId": "list_projects",
"version": 1,
"title": "List Projects",
"description": "List Projects through the Capability discovery capability set.",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"description": listProjectsDescription,
"inputSchema": listProjectsInputSchema,
"outputSchema": {
"type": "object",
"properties": {
+3
View File
@@ -962,6 +962,8 @@ export type {
SetupTokenTransportAdvisoryCode,
AssetImage,
Project,
ProjectDiscoverySummary,
ProjectDiscoveryPage,
ProjectBudgetSummary,
ProjectRepository,
ProjectRepositoryOptions,
@@ -1918,6 +1920,7 @@ export {
type ResetAgentSession,
type TestAdapterEnvironment,
type UpdateAgentPermissions,
projectDiscoverySchema,
createProjectSchema,
updateProjectSchema,
createProjectWorkspaceSchema,
+1 -1
View File
@@ -351,7 +351,7 @@ export type {
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
} from "./document-annotation.js";
export type { Project, ProjectRepository, ProjectRepositoryOptions, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type { Project, ProjectDiscoverySummary, ProjectDiscoveryPage, ProjectRepository, ProjectRepositoryOptions, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
CompanySearchCountType,
CompanySearchExtractIssueResult,
+13
View File
@@ -75,6 +75,19 @@ export interface ProjectManagedByPlugin {
updatedAt: Date;
}
export interface ProjectDiscoverySummary {
id: string;
name: string;
status: string;
description: string | null;
descriptionTruncated: boolean;
}
export interface ProjectDiscoveryPage {
projects: ProjectDiscoverySummary[];
nextCursor: string | null;
}
export interface Project {
id: string;
companyId: string;
+1
View File
@@ -387,6 +387,7 @@ export {
} from "./agent.js";
export {
projectDiscoverySchema,
createProjectSchema,
updateProjectSchema,
createProjectWorkspaceSchema,
@@ -4,6 +4,11 @@ import { envConfigSchema } from "./secret.js";
import { trustAuthorizationPolicySchema } from "./trust-policy.js";
import { objectWithoutDefaults } from "./partial.js";
export const projectDiscoverySchema = z.object({
limit: z.number().int().min(1).max(50).default(50),
cursor: z.string().uuid().transform(value => value.toLowerCase()).optional(),
}).strict();
const executionWorkspaceStrategySchema = z
.object({
type: z.enum(["project_primary", "git_worktree", "adapter_managed", "cloud_sandbox"]).optional(),
@@ -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();
});
});
+22
View File
@@ -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));
});
+1
View File
@@ -1128,6 +1128,7 @@ export function accessService(db: Db) {
return {
isInstanceAdmin,
decide,
projectDiscoveryCandidateIds: authorization.projectDiscoveryCandidateIds,
canUser,
hasPermission,
getMembership,
+20
View File
@@ -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),
+8 -3
View File
@@ -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;
}
+23 -1
View File
@@ -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`