mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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:
commit
ee096aa23f
30 files changed
+434
-117
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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