mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
fix(onboarding): keep approved child task handoffs runnable (#18160)
## Thinking Path > - Paperclip manages work through tasks, agent runs, dependencies, and human decisions. > - The first-task workflow asks for approval before it creates an execution task. > - File-producing work must run on a child task before onboarding can finish. > - A child blocked by its onboarding source cannot start while the source waits for that child. > - This change rejects that dependency during task creation and later edits, and gives the agent a correction. > - The shipped recipe shows the dependency direction and requires the actual approved inputs in the child instructions. ## Linked Issues or Issue Description **What happened?** After human approval, an onboarding run could create a child with the onboarding source in `blockedByTaskIds`. Creation saved a blocked child with no wake. The parent could then wait for that child. A handoff could also refer to input from the parent conversation without including the input in the child description. **Expected behavior** The approved child can execute before its waiting parent. Invalid first-task dependency input fails before task creation and leaves the same command key available for a corrected call. The child instructions include the required approved input and constraints. **Steps to reproduce** 1. Accept a proposal on a server-created first task. 2. Call native `create_task` with `status: "todo"` and the onboarding source in `blockedByTaskIds`. 3. Observe a blocked child with no scheduled wake. Block the parent on that child to expose the stalled handoff. **Paperclip version or commit** The regression reproduces on `f8484a9f8d1f68e9534fee4f29e00e716dc680f4`. The repair is based on current master. **Deployment mode** Core native and REST task creation. The same server boundary serves self-hosted and hosted instances. Related: #18080 fixed remote delivery of the selected first-task skill. This change handles the later child handoff boundary. ## What Changed - Reject a task blocked by its authenticated first-task source during creation and later dependency edits. Later agent edits also check the locked target's server-owned onboarding parent, protecting a child running under its own execution. Compare UUID identity in PostgreSQL so alternate supported UUID spellings cannot bypass the check. The error explains the correction. - Add a native child/dependency recipe with self-contained approved input guidance. Clarify the general `create_task` input descriptions and update their generated contracts. - Cover native and REST rejection, corrected retry, preserved valid dependencies and replay, and an executable recipe with exact input bytes read from the child's own context. Thread the authenticated run through native dependency edits. Keep blocker corrections independent of a new proposal decision. - Update execution semantics and the existing onboarding map entries. ## Verification - Red on `f8484a9f`: the new native regression received a created blocked child instead of the expected rejection. - Review regressions were red on the original PR head: uppercase, unhyphenated and braced source UUIDs created blocked children; a later child PATCH returned 200 and saved the reversed blocker. - Review follow-up reproduced both native and REST child-own-run bypasses on `75fa75a6`: the native dependency call applied the reverse edge; the child's own PATCH returned 200. The regression uses the actual authenticated child execution and an already checked-out task. - All 95 tests across first-task admission, assets, and the full native tool-authority suite pass. The shipped recipe is validated against the native schemas and exercised through real task/dependency services with disposable PostgreSQL. Rejected PATCH tests preserve existing authorization and attempt-audit behavior and require no requested task-field, relation or wake change. Compatibility controls retain ordinary manual parent blockers, explicit board edits, other blockers and empty-list corrections. - Runner TypeScript build, generated contracts, capability inventory, protocol manifest, and map checks passed. - Server typecheck and all 95 focused tests pass with the child-run follow-up. All latest checks pass on `f24509ae`: 55 successful checks, one neutral review check, four skipped checks, and a successful legacy security status. Both automated reviews are 5/5 on this exact commit. - Prior-head CI failures are preserved: the Runner close fixture had unconfirmed provider drain while suspension succeeded; its exact fake-provider subprocess row passed locally with unchanged sources. The browser outage test lost its title textarea before any outage write. A bounded local control with a fully loaded composer passed every original outage assertion. The prior failure is an editor-readiness race; its exact asynchronous ordering remains unproved. No UI, browser-test, or timeout change is included. Neither failure is counted as a pass or silently discarded. - The first `f24509ae` CI attempt had two interrupted jobs: Typecheck and General server 9/12. Their logs record runner shutdown signals. The server job also records lost runner communication. The same-head failed-job rerun passed both jobs and the required aggregate. It reused the other successful results. No source, test, or timeout change was made for the rerun. The failed attempt remains preserved. Full repository checks pass in CI; the local full repository typecheck, test, and build commands were not repeated. - No external model or provider calls are used by these checks. Real-provider acceptance at this revision remains separate. ## Risks - The new rejection uses the authenticated run and the server-owned onboarding origin. Ordinary tasks, other blockers, empty-list corrections, and already committed creation replay retain their existing behavior. Blocker edits do not reapply the creation approval gate. - Nonempty blocker edits perform two indexed identity reads; the task lookup covers at most the run source and locked structural parent. Other patches and empty-list corrections add no source lookup. The update guard adds no parent row lock behind a child. Existing REST authorization checkout and attempt auditing remain in place before the domain mutation guard. - The explicit input recipe guides the model. Schema and persistence tests do not prove that a future model follows every instruction. - The tool guidance changes the generated semantic catalog identity. The maintained generated files are synchronized in this change. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with `ultra` reasoning effort, repository tools, code generation, and test execution. The context window is not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used and stated the available runtime detail - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the bug issue template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket ID - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
76a796e964
commit
c32e1ba6da
13 files changed
+460
-24
No files matched your search
@@ -40,6 +40,20 @@ their existing behavior, including dispatch recovery for already admitted work.
|
||||
This boundary governs new task creation; other execution and resource authorities
|
||||
continue to apply independently.
|
||||
|
||||
An approved first-task execution handoff cannot name its onboarding source as a
|
||||
blocker of the new task. Creation and later dependency edits reject that input
|
||||
using database UUID identity, including supported alternate UUID text forms.
|
||||
Later agent dependency edits also protect the locked task's server-owned
|
||||
onboarding parent when the child is executing under its own run.
|
||||
Rejected creation commits no task, dependency, wake or command receipt. Dependency
|
||||
edits leave the requested fields and relations unchanged and return guidance for
|
||||
a corrected retry. The API retains its normal authorization and attempt audit.
|
||||
Dependency edits do not require or consume a new proposal decision. Create
|
||||
the dependency-ready child, then make the onboarding parent wait on that child
|
||||
through `set_dependencies`. Parentage alone is not a dependency. Other blockers,
|
||||
ordinary task delegation and replay of previously admitted creation retain their
|
||||
existing behavior.
|
||||
|
||||
## 2. Assignee Semantics
|
||||
|
||||
An issue has at most one assignee.
|
||||
|
||||
@@ -194,6 +194,17 @@ conversational resolution with a genuine later same-task user message supplies
|
||||
acceptance; an agent-only resolution does not. A plan approval must name the
|
||||
current same-task revision. Newer pending/rejected proposals stop new creation.
|
||||
Already admitted task replay and ordinary delegation retain their existing rules.
|
||||
An accepted onboarding execution cannot create a new task blocked by its
|
||||
authenticated onboarding source. Native and REST creation return a recoverable
|
||||
input error before committing that task or its wake. The same source restriction
|
||||
applies to later blocker edits, using database UUID identity. A child's own agent
|
||||
run also cannot add its server-owned onboarding parent as a blocker. Native dependency
|
||||
edits carry their authenticated run. Empty-list corrections and other blockers
|
||||
remain available without a new proposal decision. The API keeps its normal
|
||||
attempt audit. A corrected creation call retains
|
||||
the human acceptance and can reuse the unconsumed command key. Other blocker
|
||||
relationships remain available. The child needs self-contained approved inputs;
|
||||
the parent uses `set_dependencies` to wait on the child, not the reverse.
|
||||
|
||||
Native `paperclip_finish` does not grant completion authority from an opening
|
||||
answer. The onboarding parent requires a trusted saved human decision on its
|
||||
|
||||
@@ -155,6 +155,16 @@ admit new work. [Admission controls](../../server/src/__tests__/onboarding-first
|
||||
exercise native and REST calls against disposable PostgreSQL; live model and
|
||||
Cloud onboarding remain separate evidence.
|
||||
|
||||
After acceptance, reject a new task whose blockers include its onboarding source.
|
||||
The rejected command must leave no task, dependency, wake or creation receipt;
|
||||
a corrected retry must still create the ready child. Preserve valid blockers for
|
||||
other work. Check equivalent UUID spellings and a later child PATCH as well as
|
||||
native dependency edits, including the child's own run with a waiting parent and
|
||||
no first-class dependency yet. Preserve normal API attempt auditing and allow blocker
|
||||
corrections without a new proposal acceptance. The shipped handoff recipe copies the actual approved inputs and
|
||||
constraints into the child, then makes the parent depend on the child. Its
|
||||
schema and persistence controls do not prove that a live model follows it.
|
||||
|
||||
Native completion also requires a saved human proposal decision. An opening
|
||||
answer alone must leave the first task in review. Verify that premature native
|
||||
completion is refused, including a result already saved before finalization.
|
||||
|
||||
@@ -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
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:72ced94fe0d081422eddb9dbf9e34ce9b5212e6007887e088590c22c37ae5294",
|
||||
"catalogSha256": "sha256:695e4e81354298ad22b3321573dba2d811a07145ccc1826eb795e26ba95a0896",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "64b6f6f618bb6f6182d2a6b066b1f2775204857a2c460156931346c69a8e46fc",
|
||||
"sha256": "4e970b2c9ad58399a6874a56fee35c76f8aec5554488ad17143c4578269b0222",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export const createTaskAction = {
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Create task",
|
||||
"description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.",
|
||||
"description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers. Include the actual required inputs and constraints in self-contained child instructions.",
|
||||
"note": null
|
||||
},
|
||||
"examples": {
|
||||
@@ -77,7 +77,7 @@ export const createTaskAction = {
|
||||
"operationId": "create_task",
|
||||
"version": 1,
|
||||
"title": "Create task",
|
||||
"description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.",
|
||||
"description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers. Include the actual required inputs and constraints in self-contained child instructions.",
|
||||
"exposure": "optional",
|
||||
"requiredClaims": [
|
||||
"delegation:tasks:create"
|
||||
@@ -106,7 +106,7 @@ export const createTaskAction = {
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Child task description.",
|
||||
"description": "Self-contained child instructions with actual required inputs, exact payloads or accessible authorized source references, constraints, and requested outputs. The child does not inherit the parent conversation. Do not use unresolved references such as provided above, or copy credentials or secrets.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"assigneeUserId": { "type": ["string", "null"], "description": "Company person ID from list_people. Mutually exclusive with assigneeActorId." },
|
||||
@@ -132,7 +132,7 @@ export const createTaskAction = {
|
||||
},
|
||||
"blockedByTaskIds": {
|
||||
"type": "array",
|
||||
"description": "Initial blocker task ids.",
|
||||
"description": "Tasks that must finish before this new task can execute. Parent-child linkage is not a blocker; do not list a parent that is waiting for this child.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
@@ -271,7 +271,8 @@ export const createTaskAction = {
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "Self-contained child instructions with actual required inputs, exact payloads or accessible authorized source references, constraints, and requested outputs. The child does not inherit the parent conversation. Do not use unresolved references such as provided above, or copy credentials or secrets."
|
||||
},
|
||||
"assigneeUserId": { "type": ["string", "null"], "description": "Company person ID from list_people. Mutually exclusive with assigneeActorId." },
|
||||
"assigneeActorId": {
|
||||
@@ -286,6 +287,7 @@ export const createTaskAction = {
|
||||
},
|
||||
"blockedByTaskIds": {
|
||||
"type": "array",
|
||||
"description": "Tasks that must finish before this new task can execute. Parent-child linkage is not a blocker; do not list a parent that is waiting for this child.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { documentService } from "../services/documents.js";
|
||||
import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { initializeRunIdentity } from "../services/run-identity.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
|
||||
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
||||
|
||||
@@ -104,6 +106,300 @@ describe.skipIf(!support.supported)("onboarding first-task execution admission",
|
||||
await p.svc.acceptInteraction(p.issue, p.card.id, p.plan ? { selectedOptionIds: ["execute"] } : {}, { userId: f.responsibleUserId! });
|
||||
};
|
||||
|
||||
it("rejects a first-task child blocked by its onboarding parent and permits a corrected retry", async () => {
|
||||
const f = await fixture();
|
||||
await accept(f, await proposal(f));
|
||||
const wakes: unknown[] = [];
|
||||
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
|
||||
const before = await f.snapshot();
|
||||
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const input = { title: "Approved artifact child", idempotencyKey: "first-task-handoff", status: "todo",
|
||||
blockedByTaskIds: [f.issueId] };
|
||||
const rejected = authority.execute({ tool: "create_task", callId: randomUUID(), arguments: input });
|
||||
await expect(rejected).rejects.toMatchObject({
|
||||
status: 400, details: { code: "onboarding_first_task_parent_blocker" },
|
||||
});
|
||||
await expect(rejected).rejects.toThrow(/Remove.*blockedByTaskIds.*set_dependencies/);
|
||||
const after = await f.snapshot();
|
||||
expect(after.issues).toEqual(before.issues);
|
||||
expect(after.issueRelations).toEqual(before.issueRelations);
|
||||
expect(after.activity).toEqual(before.activity);
|
||||
expect(after.issueDocuments).toEqual(before.issueDocuments);
|
||||
expect(wakes).toEqual([]);
|
||||
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
|
||||
|
||||
// A definite input error must not consume the command key or approval.
|
||||
const corrected = await authority.execute({ tool: "create_task", callId: randomUUID(),
|
||||
arguments: { ...input, blockedByTaskIds: [] } }) as { task: { id: string }; scheduledWakeIds: string[] };
|
||||
expect(corrected).toMatchObject({ task: { parentId: f.issueId, status: "todo", assigneeActorId: f.agentId } });
|
||||
expect(corrected.scheduledWakeIds).toEqual([`created-child:${corrected.task.id}`]);
|
||||
expect(wakes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["root", "child"])("rejects a backwards first-task dependency through generic %s creation", async path => {
|
||||
const f = await fixture();
|
||||
await accept(f, await proposal(f));
|
||||
const before = await f.snapshot();
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const route = path === "root" ? `/api/companies/${f.companyId}/issues` : `/api/issues/${f.issueId}/children`;
|
||||
const response = await fetch(`${server.apiUrl}${route}`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Approved execution", parentId: null, assigneeAgentId: f.agentId,
|
||||
status: "todo", blockedByIssueIds: [f.issueId] }),
|
||||
});
|
||||
expect(response.status, JSON.stringify(await response.json())).toBe(400);
|
||||
const after = await f.snapshot();
|
||||
expect(after.issues).toEqual(before.issues);
|
||||
expect(after.issueRelations).toEqual(before.issueRelations);
|
||||
expect(after.activity).toEqual(before.activity);
|
||||
});
|
||||
|
||||
it("preserves other first-task blockers and ordinary parent-blocked delegation", async () => {
|
||||
for (const onboarding of [true, false]) {
|
||||
const f = await fixture();
|
||||
if (onboarding) await accept(f, await proposal(f));
|
||||
else await server.db.update(issues).set({ originKind: "manual", description: "Ordinary task mentioning /first-task." }).where(eq(issues.id, f.issueId));
|
||||
const blockerId = onboarding ? f.blockerId : f.issueId;
|
||||
const wakes: unknown[] = [];
|
||||
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
|
||||
const created = await authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
||||
title: "Work with a real prerequisite", idempotencyKey: "valid-blocker", status: "todo", blockedByTaskIds: [blockerId],
|
||||
} }) as { task: { id: string } };
|
||||
expect(created).toMatchObject({ task: { status: "blocked", parentId: f.issueId }, scheduledWakeIds: [] });
|
||||
expect((await f.snapshot()).issueRelations).toContainEqual(expect.objectContaining({
|
||||
issueId: blockerId, relatedIssueId: created.task.id, type: "blocks",
|
||||
}));
|
||||
expect(wakes).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("executes the shipped handoff recipe with exact inputs and the child blocking its parent", async () => {
|
||||
const f = await fixture();
|
||||
const inputBytes = Buffer.from('{"nonce":"handoff-snowman-☃","values":[2,5,-1]}\n');
|
||||
const encodedInput = inputBytes.toString("base64");
|
||||
const description = `Decode this exact base64 into handoff-input.json, preserving its final newline:\n${encodedInput}\n`
|
||||
+ "Write handoff-proof.json with the nonce, value count, total, and SHA-256 of the exact input bytes. "
|
||||
+ "Save the proof document and register the file on this task. Do not create other tasks or access credentials.";
|
||||
const p = await proposal(f);
|
||||
await server.db.update(issueThreadInteractions).set({ payload: { version: 1, prompt: description } })
|
||||
.where(eq(issueThreadInteractions.id, p.card.id));
|
||||
await accept(f, p);
|
||||
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
|
||||
const recipe = [...skill.matchAll(/```json\s+([\s\S]*?)```/g)]
|
||||
.map(match => JSON.parse(match[1]!) as Record<string, Record<string, unknown>>)
|
||||
.find(value => value.createTask && value.setDependencies);
|
||||
if (!recipe) throw new Error("Missing shipped first-task handoff recipe");
|
||||
const wakes: unknown[] = [];
|
||||
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
|
||||
await authority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: {
|
||||
idempotencyKey: "existing-prerequisite", blockedByTaskIds: [f.blockerId],
|
||||
} });
|
||||
const created = await authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
||||
...recipe.createTask, idempotencyKey: `first-task-child:${p.card.id}`,
|
||||
title: "Approved input proof", description, assigneeActorId: f.agentId,
|
||||
} }) as { task: { id: string } };
|
||||
expect(created).toMatchObject({ task: { status: "todo", parentId: f.issueId },
|
||||
scheduledWakeIds: [`created-child:${created.task.id}`] });
|
||||
expect(wakes).toHaveLength(1);
|
||||
await authority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: {
|
||||
...recipe.setDependencies, idempotencyKey: `first-task-wait:${p.card.id}`,
|
||||
blockedByTaskIds: [f.blockerId, created.task.id],
|
||||
} });
|
||||
const relations = (await f.snapshot()).issueRelations;
|
||||
expect(relations).toHaveLength(2);
|
||||
for (const blocker of [f.blockerId, created.task.id]) expect(relations).toContainEqual(expect.objectContaining({
|
||||
issueId: blocker, relatedIssueId: f.issueId, type: "blocks",
|
||||
}));
|
||||
|
||||
// Read under the child's own native execution binding, without a model call.
|
||||
const childRunId = randomUUID();
|
||||
await server.db.insert(heartbeatRuns).values({ id: childRunId, companyId: f.companyId, agentId: f.agentId,
|
||||
status: "running", runtimeMode: "native", nativeIssueId: created.task.id,
|
||||
responsibleUserId: f.responsibleUserId, contextSnapshot: { issueId: created.task.id } });
|
||||
await server.db.update(issues).set({ status: "in_progress", executionRunId: childRunId }).where(eq(issues.id, created.task.id));
|
||||
const childAuthority = new PaperclipRunnerToolAuthority(server.db, { ...f, issueId: created.task.id, runId: childRunId });
|
||||
const context = await childAuthority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} }) as {
|
||||
activeTask: { description: string };
|
||||
};
|
||||
expect(context.activeTask.description).toBe(description);
|
||||
const recoveredInput = Buffer.from(context.activeTask.description.split("\n")[1]!, "base64");
|
||||
expect(recoveredInput).toEqual(inputBytes);
|
||||
});
|
||||
|
||||
it.each(["uppercase", "hyphenless", "braced"])("rejects a PostgreSQL-equivalent %s onboarding blocker UUID", async variant => {
|
||||
const f = await fixture();
|
||||
await accept(f, await proposal(f));
|
||||
const sourceId = variant === "uppercase" ? f.issueId.toUpperCase()
|
||||
: variant === "hyphenless" ? f.issueId.replaceAll("-", "") : `{${f.issueId}}`;
|
||||
const before = await f.snapshot();
|
||||
await expect(f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
||||
title: "Approved child", idempotencyKey: "equivalent-parent", status: "todo", blockedByTaskIds: [sourceId],
|
||||
} })).rejects.toMatchObject({ status: 400, details: { code: "onboarding_first_task_parent_blocker" } });
|
||||
const after = await f.snapshot();
|
||||
expect(after.issues).toEqual(before.issues);
|
||||
expect(after.issueRelations).toEqual(before.issueRelations);
|
||||
});
|
||||
|
||||
it("rejects adding the onboarding source blocker through a child PATCH", async () => {
|
||||
const f = await fixture();
|
||||
await accept(f, await proposal(f));
|
||||
const created = await f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
||||
title: "Approved ready child", idempotencyKey: "ready-child", status: "todo",
|
||||
} }) as { task: { id: string } };
|
||||
const before = await f.snapshot();
|
||||
const beforeWakes = await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId));
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const response = await fetch(`${server.apiUrl}/api/issues/${created.task.id}`, {
|
||||
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "blocked", blockedByIssueIds: [f.issueId.toUpperCase()] }),
|
||||
});
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(400);
|
||||
expect(body).toMatchObject({ details: { code: "onboarding_first_task_parent_blocker" } });
|
||||
const after = await f.snapshot();
|
||||
expect(after.issues).toEqual(before.issues);
|
||||
expect(after.issueRelations).toEqual(before.issueRelations);
|
||||
const priorActivityIds = new Set(before.activity.map(row => row.id));
|
||||
expect(after.activity.filter(row => !priorActivityIds.has(row.id))).toEqual([expect.objectContaining({
|
||||
action: "issue.cross_issue_influence_observed", entityId: created.task.id, runId: f.runId,
|
||||
details: expect.objectContaining({ sourceIssueId: f.issueId, targetIssueId: created.task.id, kind: "update", allowed: true }),
|
||||
})]);
|
||||
expect(await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toEqual(beforeWakes);
|
||||
});
|
||||
|
||||
const childExecution = async (onboarding = true) => {
|
||||
const f = await fixture();
|
||||
if (onboarding) await accept(f, await proposal(f));
|
||||
else await server.db.update(issues).set({ originKind: "manual" }).where(eq(issues.id, f.issueId));
|
||||
const created = await f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
||||
title: "Approved ready child", idempotencyKey: "own-execution-child", status: "todo",
|
||||
} }) as { task: { id: string } };
|
||||
// The parent is waiting, without a first-class dependency. This is not a
|
||||
// graph cycle: the forbidden parent -> child edge would be the only edge.
|
||||
await server.db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await server.db.update(issues).set({ status: "blocked", executionRunId: null }).where(eq(issues.id, f.issueId));
|
||||
const childRunId = randomUUID();
|
||||
await server.db.insert(heartbeatRuns).values({ id: childRunId, companyId: f.companyId, agentId: f.agentId,
|
||||
status: "running", runtimeMode: "native", nativeIssueId: created.task.id,
|
||||
responsibleUserId: f.responsibleUserId, contextSnapshot: { issueId: created.task.id } });
|
||||
await server.db.update(issues).set({ status: "in_progress", executionRunId: childRunId }).where(eq(issues.id, created.task.id));
|
||||
await initializeRunIdentity(server.db, { companyId: f.companyId, runId: childRunId, issueId: created.task.id,
|
||||
responsibleUserId: f.responsibleUserId, cause: "instruction" });
|
||||
const childAuthority = new PaperclipRunnerToolAuthority(server.db, { ...f, issueId: created.task.id, runId: childRunId });
|
||||
await issueService(server.db).checkout(created.task.id, f.agentId, ["in_progress"], childRunId);
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", childRunId, f.responsibleUserId)!;
|
||||
return { f, childId: created.task.id, childRunId, childAuthority, token };
|
||||
};
|
||||
|
||||
it.each(["native", "REST"])("rejects the onboarding parent blocker from the child's own %s execution", async transport => {
|
||||
const { f, childId, childRunId, childAuthority, token } = await childExecution();
|
||||
const input = { idempotencyKey: "child-own-parent-blocker", blockedByTaskIds: [f.issueId.toUpperCase()] };
|
||||
const before = await f.snapshot();
|
||||
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, childRunId));
|
||||
const beforeWakes = await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId));
|
||||
if (transport === "native") {
|
||||
await expect(childAuthority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: input })).rejects.toMatchObject({
|
||||
status: 400, details: { code: "onboarding_first_task_parent_blocker" },
|
||||
});
|
||||
} else {
|
||||
const response = await fetch(`${server.apiUrl}/api/issues/${childId}`, {
|
||||
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "blocked", blockedByIssueIds: input.blockedByTaskIds }),
|
||||
});
|
||||
expect(response.status, JSON.stringify(await response.json())).toBe(400);
|
||||
}
|
||||
const after = await f.snapshot();
|
||||
expect(after.issues).toEqual(before.issues);
|
||||
expect(after.issueRelations).toEqual([]);
|
||||
expect(after.activity).toEqual(before.activity);
|
||||
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, childRunId));
|
||||
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
|
||||
expect(await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toEqual(beforeWakes);
|
||||
// A definite input error leaves the key available and other blockers valid.
|
||||
for (const blockedByTaskIds of [[f.blockerId], []]) await expect(childAuthority.execute({ tool: "set_dependencies",
|
||||
callId: randomUUID(), arguments: { ...input, idempotencyKey: blockedByTaskIds.length ? input.idempotencyKey : randomUUID(), blockedByTaskIds },
|
||||
})).resolves.toMatchObject({ disposition: "applied" });
|
||||
});
|
||||
|
||||
it.each(["native", "REST", "board"])("preserves ordinary parent blockers and explicit board edits (%s)", async actor => {
|
||||
const { f, childId, childAuthority, token } = await childExecution(actor === "board");
|
||||
if (actor === "native") {
|
||||
await expect(childAuthority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: {
|
||||
idempotencyKey: "ordinary-parent-blocker", blockedByTaskIds: [f.issueId],
|
||||
} })).resolves.toMatchObject({ disposition: "applied" });
|
||||
} else {
|
||||
const headers = actor === "board"
|
||||
? { Cookie: await server.humanSession(f.responsibleUserId!), "Content-Type": "application/json", Origin: server.apiUrl }
|
||||
: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
||||
const response = await fetch(`${server.apiUrl}/api/issues/${childId}`, {
|
||||
method: "PATCH", headers, body: JSON.stringify({ blockedByIssueIds: [f.issueId] }),
|
||||
});
|
||||
expect(response.status, JSON.stringify(await response.json())).toBe(200);
|
||||
}
|
||||
expect((await f.snapshot()).issueRelations).toContainEqual(expect.objectContaining({
|
||||
issueId: f.issueId, relatedIssueId: childId, type: "blocks",
|
||||
}));
|
||||
});
|
||||
|
||||
it("guards native dependency edits without consuming the retry key", async () => {
|
||||
const f = await fixture();
|
||||
await accept(f, await proposal(f));
|
||||
const before = await f.snapshot();
|
||||
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const input = { idempotencyKey: "native-parent-blocker", blockedByTaskIds: [f.issueId.toUpperCase()] };
|
||||
await expect(f.authority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: input })).rejects.toMatchObject({
|
||||
status: 400, details: { code: "onboarding_first_task_parent_blocker" },
|
||||
});
|
||||
const after = await f.snapshot();
|
||||
expect(after.issues).toEqual(before.issues);
|
||||
expect(after.issueRelations).toEqual(before.issueRelations);
|
||||
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
|
||||
await expect(f.authority.execute({ tool: "set_dependencies", callId: randomUUID(), arguments: {
|
||||
...input, blockedByTaskIds: [f.blockerId],
|
||||
} })).resolves.toMatchObject({ disposition: "applied" });
|
||||
});
|
||||
|
||||
it("keeps blocker edits and clears independent of a newer first-task proposal", async () => {
|
||||
const f = await fixture();
|
||||
await accept(f, await proposal(f));
|
||||
const created = await f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
||||
title: "Existing approved child", idempotencyKey: "approved-before-new-proposal", status: "todo",
|
||||
} }) as { task: { id: string } };
|
||||
await proposal(f);
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
for (const blockedByIssueIds of [[f.blockerId], []]) {
|
||||
const response = await fetch(`${server.apiUrl}/api/issues/${created.task.id}`, {
|
||||
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blockedByIssueIds }),
|
||||
});
|
||||
expect(response.status, JSON.stringify(await response.json())).toBe(200);
|
||||
}
|
||||
for (const blockedByTaskIds of [[created.task.id], []]) await expect(f.authority.execute({
|
||||
tool: "set_dependencies", callId: randomUUID(), arguments: { idempotencyKey: randomUUID(), blockedByTaskIds },
|
||||
})).resolves.toMatchObject({ disposition: "applied" });
|
||||
expect((await f.snapshot()).issueRelations).toEqual([]);
|
||||
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
||||
});
|
||||
|
||||
it("preserves an ordinary run's parent-blocked child update", async () => {
|
||||
const f = await fixture();
|
||||
await server.db.update(issues).set({ originKind: "manual" }).where(eq(issues.id, f.issueId));
|
||||
const created = await f.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
||||
title: "Ordinary child", idempotencyKey: "ordinary-child", status: "todo",
|
||||
} }) as { task: { id: string } };
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const response = await fetch(`${server.apiUrl}/api/issues/${created.task.id}`, {
|
||||
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "blocked", blockedByIssueIds: [f.issueId] }),
|
||||
});
|
||||
expect(response.status, JSON.stringify(await response.json())).toBe(200);
|
||||
expect((await f.snapshot()).issueRelations).toContainEqual(expect.objectContaining({
|
||||
issueId: f.issueId, relatedIssueId: created.task.id, type: "blocks",
|
||||
}));
|
||||
});
|
||||
|
||||
it("an explicit Approve/Revise question remains input rather than proposal authority after the human answers", async () => {
|
||||
const f = await fixture();
|
||||
const issueIds = (await f.snapshot()).issues.map(issue => issue.id).sort();
|
||||
|
||||
@@ -87,6 +87,27 @@ Work in this order.
|
||||
- For a plan-only request, retain the accepted plan on this task. Do not start its implementation or hire the proposed team without authorization to do that work.
|
||||
- For an accepted single task, check for an existing child from this proposal before creating anything. Create exactly one child linked to this onboarding task and assign it to yourself. On later wakes, continue that same child instead of creating another.
|
||||
- If the accepted child work requires a file deliverable, include the approved scope, document requirements, and requested filenames in a self-contained child description. Delegate the work to the child's own Paperclip execution, even when the child is assigned to yourself. That execution must save the document and publish the requested file on the child before marking it done. Do not create or register that file from the onboarding run, or mark the child done before its execution delivers the file. Native `register_deliverable` is bound to its current task.
|
||||
- Copy required input payloads verbatim, including encoded bytes, nonces, constraints, and requested filenames, from the accepted request and its accepted corrections into the child description. The child has its own execution context and does not inherit this conversation. Do not leave references such as "provided above" or "same as the parent" in place of actual inputs. When an input is not inline, include its actual retrievable source reference and ensure the child is authorized to read it. Keep credentials and secrets out of handoff text.
|
||||
- Parent-child linkage is not a dependency. Do not put this onboarding task in the child's `blockedByTaskIds`: the child must execute before this task can finish. Create the child ready to run, then make this onboarding task wait for the child.
|
||||
- While waiting for that file-producing child, preserve existing blocker IDs and add the child as a dependency. Native runners use `set_dependencies`, then `paperclip_block` with the child agent as owner and child completion as the unblock action; legacy runners use the normal issue API with `blockedByIssueIds` and a blocked disposition. End the turn so the child can execute. Do not poll for its result while holding the onboarding run.
|
||||
- When child completion resumes the onboarding task, verify the saved child document and retrievable file, then reply with the actual child task and download links. Reuse the recorded result instead of producing another file on the parent or creating another child.
|
||||
- For document-only child work, you may execute it directly. Save the finished output as a document on the child task and mark that child done. Link it from the onboarding conversation. Completing the onboarding parent in place, or saving the output only on the parent, does not fulfill the accepted child-task proposal.
|
||||
|
||||
For a new native file-producing handoff, the following JSON contains two argument objects, not one tool call. Call `create_task` with `createTask` first. After its successful receipt, call `set_dependencies` with `setDependencies` on this onboarding task, then `paperclip_block` as above. Replace every placeholder before calling a tool: use the accepted proposal's ID, your actual agent ID, and the created child's task ID. Replace `<approved-child-description>` with the self-contained description and actual inputs described above. Replace `<existing-blocker-id>` with all current blocker IDs; remove that placeholder when there are none, leaving only the child ID. Never replace it with this onboarding task's own ID. Keep each blocker ID only once. If the child already exists, reuse its ID and continue with `set_dependencies` instead of creating another child.
|
||||
|
||||
```json
|
||||
{
|
||||
"createTask": {
|
||||
"idempotencyKey": "first-task-child:<proposal-id>",
|
||||
"title": "<child-title>",
|
||||
"description": "<approved-child-description>",
|
||||
"assigneeActorId": "<self-agent-id>",
|
||||
"status": "todo",
|
||||
"blockedByTaskIds": []
|
||||
},
|
||||
"setDependencies": {
|
||||
"idempotencyKey": "first-task-wait:<proposal-id>",
|
||||
"blockedByTaskIds": ["<existing-blocker-id>", "<child-task-id>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -10,7 +10,7 @@ import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notific
|
||||
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
||||
import { notifyApprovalWork } from "./approval-work-notifications.js";
|
||||
import { notifyDecisionTargetWork, notifyDecisionTargetIssueRemoval } from "./decision-targets.js";
|
||||
import { assertOnboardingFirstTaskCreationAllowed } from "./onboarding-first-task-admission.js";
|
||||
import { assertOnboardingFirstTaskCreationAllowed, assertOnboardingFirstTaskDependenciesAllowed } from "./onboarding-first-task-admission.js";
|
||||
import { resolveConnectionIntentContinuationSource, resolveItemVerdictContinuationWake } from "./chat-interaction-continuation.js";
|
||||
import { insertIssueComment } from "./issue-comment-writes.js";
|
||||
import { measureRunTransaction } from "./execution-transaction-timing.js";
|
||||
@@ -10240,6 +10240,7 @@ export function issueService(db: Db) {
|
||||
companyId,
|
||||
actorAgentId: issueData.createdByAgentId,
|
||||
actorRunId,
|
||||
blockedByIssueIds,
|
||||
});
|
||||
|
||||
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
||||
@@ -11407,6 +11408,15 @@ export function issueService(db: Db) {
|
||||
});
|
||||
assertLegacyRunTaskOwnership(actorRun, receiptExisting);
|
||||
}
|
||||
if (blockedByIssueIds !== undefined) {
|
||||
await assertOnboardingFirstTaskDependenciesAllowed(tx as Db, {
|
||||
companyId: receiptExisting.companyId,
|
||||
actorAgentId,
|
||||
actorRunId,
|
||||
blockedByIssueIds,
|
||||
parentIssueId: receiptExisting.parentId,
|
||||
});
|
||||
}
|
||||
if (actorAgentId && patch.status === "done") {
|
||||
const [review] = await tx.select({ id: toolActionRequests.id }).from(toolActionRequests).where(and(eq(toolActionRequests.companyId, existing.companyId), eq(toolActionRequests.issueId, id), inArray(toolActionRequests.status, ["pending", "approved", "executing"]))).limit(1);
|
||||
if (review) throw conflict("This task is waiting for a connection review. Finish unrelated work, then yield in_review without retrying the governed call.", { code: "tool_review_pending", actionRequestId: review.id });
|
||||
|
||||
@@ -2086,6 +2086,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
const updated = await issueService(tx).update(this.binding.issueId, {
|
||||
blockedByIssueIds,
|
||||
actorAgentId: this.binding.agentId,
|
||||
actorRunId: this.binding.runId,
|
||||
}, tx);
|
||||
if (!updated) throw new Error("paperclip_runner_task_not_found");
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { agents, documents, heartbeatRuns, issueComments, issueDocuments, issues, issueThreadInteractions, type Db } from "@paperclipai/db";
|
||||
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND, requestCheckboxConfirmationPayloadSchema, requestCheckboxConfirmationResultSchema,
|
||||
requestConfirmationPayloadSchema, requestConfirmationResultSchema } from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { badRequest, forbidden } from "../errors.js";
|
||||
|
||||
const object = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
@@ -28,30 +28,53 @@ const acceptanceRequired = () => forbidden(
|
||||
{ code: "onboarding_first_task_acceptance_required" },
|
||||
);
|
||||
|
||||
/**
|
||||
* Creation admission for a run serving the server-owned onboarding task. This is
|
||||
* deliberately independent of the requested parent and client origin metadata.
|
||||
* Call inside the creation transaction, after a side-effect-free replay check.
|
||||
*/
|
||||
export async function assertOnboardingFirstTaskCreationAllowed(tx: Db, input: {
|
||||
type FirstTaskMutationInput = {
|
||||
companyId: string;
|
||||
actorAgentId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!input.actorAgentId || !input.actorRunId) return;
|
||||
blockedByIssueIds?: readonly string[];
|
||||
};
|
||||
|
||||
async function readAuthenticatedFirstTaskSources(tx: Db, input: FirstTaskMutationInput, lockSource: boolean, parentIssueId?: string | null) {
|
||||
if (!input.actorAgentId || !input.actorRunId) return [];
|
||||
const [observedRun] = await tx.select(runOriginFields).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, input.actorRunId), eq(heartbeatRuns.companyId, input.companyId),
|
||||
eq(heartbeatRuns.agentId, input.actorAgentId),
|
||||
));
|
||||
if (!observedRun) throw forbidden("Authenticated agent run not found");
|
||||
const sourceIssueId = runIssueId(observedRun);
|
||||
if (!sourceIssueId) return;
|
||||
const sourceIssueIds = [runIssueId(observedRun), parentIssueId].filter((id): id is string => Boolean(id));
|
||||
if (!sourceIssueIds.length) return [];
|
||||
// Let the UUID column compare identities, including PostgreSQL's accepted
|
||||
// uppercase, unhyphenated and braced forms. Do not compare raw input strings.
|
||||
const isRequestedBlocker = input.blockedByIssueIds?.length
|
||||
? sql<boolean>`${inArray(issues.id, [...input.blockedByIssueIds])}`
|
||||
: sql<boolean>`false`;
|
||||
const query = tx.select({ id: issues.id, isRequestedBlocker }).from(issues).where(and(
|
||||
inArray(issues.id, sourceIssueIds), eq(issues.companyId, input.companyId),
|
||||
eq(issues.originKind, ONBOARDING_FIRST_TASK_ORIGIN_KIND),
|
||||
));
|
||||
return lockSource ? await query.for("update") : await query;
|
||||
}
|
||||
|
||||
function assertNotBlockedByFirstTaskSource(source: { isRequestedBlocker: boolean } | undefined) {
|
||||
if (source?.isRequestedBlocker) {
|
||||
throw badRequest(
|
||||
"First-task execution tasks cannot be blocked by their onboarding task. Remove the onboarding task from blockedByTaskIds (REST: blockedByIssueIds), preserve other required blockers, then use set_dependencies on the onboarding task to wait for the child.",
|
||||
{ code: "onboarding_first_task_parent_blocker" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation admission for a run serving the server-owned onboarding task. This is
|
||||
* deliberately independent of the requested parent and client origin metadata.
|
||||
* Call inside the creation transaction, after a side-effect-free replay check.
|
||||
*/
|
||||
export async function assertOnboardingFirstTaskCreationAllowed(tx: Db, input: FirstTaskMutationInput): Promise<void> {
|
||||
if (!input.actorAgentId || !input.actorRunId) return;
|
||||
// Match the native authority/interaction lifecycle: issue, then run, then
|
||||
// interaction/document. Ordinary tasks acquire no additional row locks.
|
||||
const [source] = await tx.select({ id: issues.id }).from(issues).where(and(
|
||||
eq(issues.id, sourceIssueId), eq(issues.companyId, input.companyId),
|
||||
eq(issues.originKind, ONBOARDING_FIRST_TASK_ORIGIN_KIND),
|
||||
)).for("update");
|
||||
const [source] = await readAuthenticatedFirstTaskSources(tx, input, true);
|
||||
if (!source) return;
|
||||
const [run] = await tx.select(runOriginFields).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, input.actorRunId), eq(heartbeatRuns.companyId, input.companyId),
|
||||
@@ -60,6 +83,18 @@ export async function assertOnboardingFirstTaskCreationAllowed(tx: Db, input: {
|
||||
if (!run || runIssueId(run) !== source.id) throw acceptanceRequired();
|
||||
|
||||
if (await readOnboardingFirstTaskDecision(tx, { companyId: input.companyId, issueId: source.id, outcomes: ["accepted"] }) !== "accepted") throw acceptanceRequired();
|
||||
assertNotBlockedByFirstTaskSource(source);
|
||||
}
|
||||
|
||||
/** Blocker edits retain their own authorization; they do not consume a proposal. */
|
||||
export async function assertOnboardingFirstTaskDependenciesAllowed(tx: Db, input: FirstTaskMutationInput & { parentIssueId: string | null }): Promise<void> {
|
||||
if (!input.blockedByIssueIds?.length) return;
|
||||
// The update owner already holds the actor-run authorization lock. A read is
|
||||
// sufficient for source identity and the locked target's structural parent;
|
||||
// do not lock a parent after a child. A child's own run has a different source
|
||||
// but must still not wait on its server-owned onboarding parent.
|
||||
const sources = await readAuthenticatedFirstTaskSources(tx, input, false, input.parentIssueId);
|
||||
assertNotBlockedByFirstTaskSource(sources.find(source => source.isRequestedBlocker));
|
||||
}
|
||||
|
||||
/** Shared persisted-decision proof; creation and completion retain different authority. */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "@paperclipai/paperclip-runner";
|
||||
import {
|
||||
ONBOARDING_FIRST_TASK_OPENING_INTERVIEW_OPTION_ID,
|
||||
ONBOARDING_FIRST_TASK_OPENING_QUESTION_ID,
|
||||
@@ -113,6 +115,40 @@ describe("first-task proposal mode policy", () => {
|
||||
expect(skill).toContain("actual child task and download links");
|
||||
});
|
||||
|
||||
it("ships native child handoff arguments that let the child run before its waiting parent", async () => {
|
||||
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
|
||||
const recipe = [...skill.matchAll(/```json\s+([\s\S]*?)```/g)]
|
||||
.map((match) => JSON.parse(match[1]!) as Record<string, Record<string, unknown>>)
|
||||
.find((value) => value.createTask && value.setDependencies);
|
||||
if (!recipe) throw new Error("Shipped first-task skill is missing the native child handoff recipe");
|
||||
expect(Object.keys(recipe).sort()).toEqual(["createTask", "setDependencies"]);
|
||||
expect(recipe.createTask).toEqual({
|
||||
idempotencyKey: "first-task-child:<proposal-id>",
|
||||
title: "<child-title>",
|
||||
description: "<approved-child-description>",
|
||||
assigneeActorId: "<self-agent-id>",
|
||||
status: "todo",
|
||||
blockedByTaskIds: [],
|
||||
});
|
||||
expect(recipe.setDependencies).toEqual({
|
||||
idempotencyKey: "first-task-wait:<proposal-id>",
|
||||
blockedByTaskIds: ["<existing-blocker-id>", "<child-task-id>"],
|
||||
});
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
|
||||
for (const [operationId, input] of [["create_task", recipe.createTask], ["set_dependencies", recipe.setDependencies]] as const) {
|
||||
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find((tool) => tool.operationId === operationId);
|
||||
if (!descriptor) throw new Error(`Missing native tool descriptor: ${operationId}`);
|
||||
const validate = ajv.compile(descriptor.inputSchema);
|
||||
expect(validate(input), JSON.stringify(validate.errors)).toBe(true);
|
||||
expect(validate({ ...input, notAToolArgument: true })).toBe(false);
|
||||
}
|
||||
expect(skill).toContain("Copy required input payloads verbatim");
|
||||
expect(skill).toContain("encoded bytes, nonces, constraints, and requested filenames");
|
||||
expect(skill).toContain('Do not leave references such as "provided above"');
|
||||
expect(skill).toContain("Parent-child linkage is not a dependency");
|
||||
});
|
||||
|
||||
it("keeps plan-only authorization and document-only child execution distinct from file handoff", async () => {
|
||||
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
|
||||
expect(skill).toContain("For a plan-only request, retain the accepted plan on this task");
|
||||
|
||||
Reference in new issue
Block a user