From eece07a7470453c1882a5c75e428eb7107f0af4e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:21:55 -0500 Subject: [PATCH] fix: repair the complete projectless repository setup chain (#17088) ## Thinking Path > - Paperclip lets people manage agents that do useful work. > - A projectless task can prepare an authorized repository in its retained workspace. > - Setup must hand work to a later run without user supervision. > - Real task attempts exposed gaps across admission, completion, and queue dispatch. > - A successful provider exit did not prove that the requested result was delivered. > - This PR repairs the existing lifecycle and checks the complete repository journey. ## Linked Issues or Issue Description Refs #17047. This PR includes the preserved repairs from #17049, #17052, and #17082. **What happened?** A projectless native task could stop after requesting a repository. It could also continue through a timer or generic recovery path and lose its saved conversation lineage. The latest real Codex and Claude attempts delivered correct files, but the native continuation had been folded into the finishing predecessor. Both attempts remain failed measurements. **Expected behavior** Prepare the authorized repository, retain the original context, and dispatch one saved continuation after its predecessor finishes. Deliver the verified result in the originating conversation. Stop, current access, approval, and real blocker rules remain authoritative. **Steps to reproduce** Run the explicit automatic-repository suite for native Codex or Claude. Inspect the downloaded result, original context artifact, both committed run records, and continuation receipts. The suite uses one attempt and does not widen the default paid selection. ## What Changed - Resolve the retained local-board identity through current company membership and workspace authority. - Prepare required source dependencies before the isolated server imports native tool authority. - Explain the saved repository continuation in production completion tools. Correct only the exact pending-checkout blocker backed by a live receipt. Reject an unfinished response wait without a real response source. - Preserve the persisted task-session workspace source and its explicit absence of root repository context. Permit Git's exact no-repository result only for a validated plain task directory. Keep other Git errors and linked metadata strict. - Revalidate server-resolved same-agent continuation values against current ownership and the saved kind, summary, and key. Carry this shape through the internal bridge schema. Keep provider-facing schemas restrictive. - Preserve terminal result authority after a correlated successful controller response. Controller rejection creates no result authority. Bind the shared guidance and transport source in Copilot v55 and preserve historical identities. - Admit native status wakes with the existing no-coalescing contract. The existing deferred queue retains a distinct future dispatch instead of retagging the finishing run. - Recover deferred conversation lineage through the actual admitted dispatch. Accept a null outbox run binding only with matching decision, effect, dispatch actor, idempotency, task, company, agent, and requester evidence. Reject a conflicting nonnull binding. - Independently grade retained random context, actual checkout bytes and HEAD, accessible output, distinct continuation receipts, and admission order. ## Verification - Real database and Git regressions fail before their repairs. Prior focused cohorts cover local-board authority, optional repository waits, plain task directories, and first/reused workspace provenance. - The new valid decision/effect-backed assembled-heartbeat regression reproduces coalescing into its active predecessor before the queue repair. With the repair, it dispatches one successor after the predecessor finishes. Duplicate sweeps create no extra run. - The deferred-outbox conversation regression fails before its repair. The repaired test also rejects a conflicting predecessor binding. - The current queue, continuation, publication, cancellation, stale-admission, and domain cohort passes 344 checks across 11 suites. Server and Product E2E typechecks pass. The strengthened automatic-repository oracle passes 34 calibration checks. - Three omitted/null/resolved forms pass through the compiled Rust runner and controlled provider. The prior shared bridge cohort passes 492 TypeScript checks and 399 Rust core checks. Runner source identity, TypeScript, Rust formatting, and locked Rust typechecks pass. - Six real local development cells passed ordinary repository, conversion, and necessary-question journeys. Independent artifacts and rendered question/result evidence agree. They do not qualify staging or Slack. - Both latest automatic-repository attempts delivered correct files but failed the strict continuation check. They remain failures. A fresh replay of this queue repair is pending. - On the combined candidate `380047f4b923807a8c4f7ef6016e4fe130ab4a3a`, `pnpm -r typecheck` and `pnpm build` pass. Product E2E support passes 3,525 checks with two existing skips, plus 128 source checks. The complete local `pnpm test:run` is running and is not yet a pass. - The first replay selection failed catalog validation due to an operator profile-ID error. A second selection used the wrong default-model suite and was cancelled before any provider job existed. Both setup attempts remain recorded. The corrected campaign selects the recorded GPT-6.1 and Opus 5.5 models plus the local Stop/resume journey. - Exact-head CI, review, and deployment acceptance remain pending. GitHub reports delayed PR updates and webhook delivery; the current head and checks have now propagated. No earlier-head green badge establishes acceptance of this commit. ## Risks This changes several boundaries of repository setup. Full product and deployment acceptance remain required. The queue uses the existing deferred admission and delivery contracts. Batch deduplication remains in place. The provenance repair requires the actual bound dispatch and does not grant publication authority. Current authorization is still checked at publication. No accepted provider result is rewritten, and completed provider work is not rerun to repair persistence or delivery. ## Model Used OpenAI Codex, GPT-6 family, with repository tools and code execution. The runtime does not expose its exact deployment ID or context-window size. Those values are not inferred. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] 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 Co-authored-by: Paperclip --- .../2026-10-20-dependable-everyday-work.md | 77 +++++++++++ packages/paperclip-runner/acpx-profiles.json | 4 +- .../src/bin/fake-codex-app-server.rs | 30 ++++ .../src/generated_acpx_profiles.rs | 2 +- .../runner-core/src/provider_backend.rs | 54 ++++++-- .../scripts/copilot-profile-identity.mjs | 4 +- .../src/cli/acpx-runtime-sidecar.test.ts | 4 +- .../src/contracts/completion-result.test.ts | 19 +++ .../src/contracts/completion-result.ts | 20 ++- .../drivers/acpx/copilot-installation.test.ts | 27 +++- .../src/drivers/acpx/generated-profiles.ts | 4 +- .../src/drivers/acpx/profile-compatibility.ts | 4 +- ...app-server-driver.semantic-results.test.ts | 32 ++++- .../src/drivers/codex/codex-boundaries.ts | 4 +- .../codex/codex-session-server-requests.ts | 4 +- .../drivers/completion-continuation.test.ts | 14 ++ .../src/drivers/completion-continuation.ts | 7 +- .../src/live/runnerd-codex-transport.ts | 3 +- .../live/runnerd-saved-continuation.test.ts | 56 ++++++++ .../copilot-profile-v54-identity.json | 84 ++++++++++++ .../copilot-profile-v55-identity.json | 84 ++++++++++++ .../heartbeat-dependency-scheduling.test.ts | 82 +++++++++++ .../managed-repository-checkout.test.ts | 34 ++++- .../task-workspace-admission.test.ts | 20 ++- .../src/__tests__/task-workspace-api.test.ts | 26 +++- .../task-workspace-repositories-trust.test.ts | 50 ++++++- .../__tests__/task-workspace-tools.test.ts | 4 +- .../chat-monitor-continuation.test.ts | 9 ++ .../execution-workspace-repositories.ts | 8 +- server/src/services/heartbeat/queue.ts | 1 + .../heartbeat/workspace-preparation.test.ts | 35 +++++ .../services/managed-repository-checkout.ts | 17 ++- .../native-completion-feedback.test.ts | 74 +++++++++- .../native-completion-feedback.ts | 26 +++- .../native-continuation-delivery.ts | 4 +- .../services/task-repository-acquisition.ts | 7 +- server/src/services/task-workspace-access.ts | 4 +- server/src/services/task-workspace-tools.ts | 19 ++- server/src/services/workspace-runtime.ts | 20 ++- tests/runner-e2e/README.md | 27 ++++ tests/runner-e2e/automatic-repository-flow.ts | 128 ++++++++++++++++++ tests/runner-e2e/automatic-repository.test.ts | 50 +++++++ tests/runner-e2e/automatic-repository.ts | 98 ++++++++++++++ tests/runner-e2e/catalog.test.ts | 6 +- tests/runner-e2e/catalog.ts | 11 ++ tests/runner-e2e/daytona-image-content.ts | 3 +- .../runner-e2e/harness-env-admission.test.ts | 27 ++++ tests/runner-e2e/pi-controls-evidence.test.ts | 2 +- tests/runner-e2e/runner.spec.ts | 10 ++ tests/runner-e2e/server.ts | 3 + tests/runner-e2e/source-startup.test.ts | 31 +++++ tests/runner-e2e/source-startup.ts | 17 +++ tests/runner-e2e/types.ts | 1 + 53 files changed, 1313 insertions(+), 78 deletions(-) create mode 100644 packages/paperclip-runner/src/live/runnerd-saved-continuation.test.ts create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v54-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json create mode 100644 tests/runner-e2e/automatic-repository-flow.ts create mode 100644 tests/runner-e2e/automatic-repository.test.ts create mode 100644 tests/runner-e2e/automatic-repository.ts create mode 100644 tests/runner-e2e/harness-env-admission.test.ts create mode 100644 tests/runner-e2e/source-startup.test.ts create mode 100644 tests/runner-e2e/source-startup.ts diff --git a/doc/plans/2026-10-20-dependable-everyday-work.md b/doc/plans/2026-10-20-dependable-everyday-work.md index 87df170989d..2dd300b3377 100644 --- a/doc/plans/2026-10-20-dependable-everyday-work.md +++ b/doc/plans/2026-10-20-dependable-everyday-work.md @@ -70,3 +70,80 @@ Server typecheck passes. These results include long conversation lineages, cycles, broken ledger receipts, current-task repository ownership, and completed repository rejection. Explicit completion and review modes remain distinct from an unfinished standard-task yield. They do not establish the live matrix. + +A fresh local provider pilot exposed a separate repository authorization defect: +the native API retained the local-board identity while the workspace fence +expected its older null representation. A native-tool/HTTP/PostgreSQL regression +reproduces the rejection before the fix. The repaired authorization cohort passes +78 checks, including revoked membership, changed sponsor, and restricted child +workspace cases. A committed setup blocker now ends the E2E attempt immediately +instead of consuming its remaining deadline. The failed pilot remains evidence; +these regressions do not replace a fresh provider run. + +The next provider attempt passed that identity boundary and staged its repository, +but then submitted a system blocker instead of yielding. The provider-facing +completion description had omitted the saved workspace continuation. The server +also accepted a `repository_checkout_pending` blocker that cited the exact pending +repository on the active task. The repair clarifies the existing completion tool +and rejects this report before acceptance with the normal yield action. It does +not rewrite an accepted result or create another recovery mechanism. Real access +restrictions, approvals, dependencies, paused tasks, cancelled tasks, changed run +ownership, and failed or unrelated repository receipts retain their gates. + +The real PostgreSQL completion regression fails before the repair. The repaired +completion/context cohort passes 50 checks, the runner cohort passes 107, and +E2E support checks pass 38. Server, runner, and E2E typechecks pass. Copilot v54 +binds the changed shared completion guidance; v53 remains a historical identity. +The three development pilot failures remain retained. These checks do not count +as the required staging matrix, and a fresh provider run remains necessary. + +The fourth development attempt reached a successor through a monitor fallback, +then failed before provider startup while applying Git exclusions to the task +root. It is a failure of the required same-agent journey, even though a successor +started. The log records Git's exact non-repository error. A real-Git and database +reproduction shows the same error for a declared plain task directory containing +inert Git metadata. The original attempt did not retain that metadata's contents +or establish which component created it. + +The next narrow repair lets only an explicitly declared plain task root proceed +past Git's exact non-repository result. It leaves the existing files intact. +Configured checkouts keep strict validation, real Git roots still receive the +exclusion, linked metadata stays rejected, and missing Git stays an error. The +repair's Git and authorization cohort passes locally. Fresh provider +remeasurement and the monitor-fallback defect remain open. + + +The fifth development attempt uses the recorded Codex model. It has no monitor, +but selects a response wake and then reaches productive-continuation recovery. +Its next admission still fails at Git setup. The first plain-root regression had +used a `task_directory` strategy that the runtime does not persist. The production +runtime records `project_primary`, task ownership, and a `task_session` source. +A corrected real-database reproduction fails with that recorded representation. +The repair now checks those current ownership/source fields and absence of a root +repository or configured project source. Shared and isolated task directories are +covered; configured, agent, worktree, and adapter-managed roots stay strict. The +second Git helper caller was inspected, but is not established as this attempt's +cause. The fifth failure remains retained, including the distinct response-wake +selection. Live same-agent acceptance is still open. + + +That fifth attempt also exposes an independent completion-validation gap: a +provider can report an unsatisfied objective, leave remainingWork empty, and yield +to response_wake without any pending response. The existing gate checked only +blocking remainingWork entries. A real-database reproduction accepts that invalid +report before repair. The same gate now also checks an unfinished objective. +Current questions, approvals, pause holds, conversations, and authorized external +response waits retain their earlier checks. No accepted result is rewritten. + + +Review then found that shared-workspace restoration relabeled a retained task +source as project_primary before repository admission. A new regression performs +first workspace preparation and reuse against the real database; it fails at the +source transition before repair. The corrected path retains task_session for an +explicitly repository-free task directory, including its absent repository and +ref instead of inheriting organizational defaults. Configured source pointers +keep project classification. The composed regression also stages a repository +through the real service and prepares verified bytes on the reused task root, +with only remote clone transport replaced by an owned local repository. The +queued sixth campaign was cancelled during its build, before provider execution, +when this review finding made that candidate an uninformative paid retry. diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 3d3e2ec0f47..7f295a51e6b 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 53, + "agentProfileVersion": 55, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:6441ff7f3fc664064bff9e7ae80252ddec7a39236f9a4b9d402785eb8b03b2c9", + "commandDigest": "sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs index 49860a9e06d..07e5cf81451 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -659,6 +659,11 @@ fn run() -> Result<(), Box> { let expected_canonical_task_context_file = argument(&args, "--expected-canonical-task-context-file"); let emit_tool_call = args.iter().any(|value| value == "--emit-tool-call"); + let terminal_result = argument(&args, "--terminal-result-file") + .map(|path| -> Result> { + Ok(serde_json::from_str(&fs::read_to_string(path)?)?) + }) + .transpose()?; let replay_completed_tool_call = args .iter() .any(|value| value == "--replay-completed-tool-call"); @@ -976,6 +981,25 @@ fn run() -> Result<(), Box> { } continue; } + if message.get("method").is_none() + && message.get("id") == Some(&json!("terminal-result-request")) + { + let success = message.pointer("/result/success") == Some(&json!(true)); + log_call( + call_log.as_deref(), + if success { + "terminal-result:accepted" + } else { + "terminal-result:rejected" + }, + )?; + finish_turn( + &state_path, + &mut state, + if success { "completed" } else { "failed" }, + )?; + continue; + } if message.get("method").is_none() && message.get("id") == Some(&json!("tool-request-1")) { if message.pointer("/result/success") == Some(&json!(false)) { log_call(call_log.as_deref(), "tool-response:failure")?; @@ -1706,6 +1730,12 @@ fn run() -> Result<(), Box> { "arguments":{"query":{"PRIVATE_QUERY":"private search"}} }}), )?; + } else if let Some(result) = &terminal_result { + send(json!({ + "id": "terminal-result-request", "method": "item/tool/call", + "params": { "threadId": state.thread_id, "turnId": provider_turn_id, + "callId": "terminal-result-call", "tool": "paperclip_finish", "arguments": result } + }))?; } else if emit_tool_call { send(json!({ "id": "tool-request-1", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index bad6f215806..6d51ac7c1b8 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:6441ff7f3fc664064bff9e7ae80252ddec7a39236f9a4b9d402785eb8b03b2c9", + "sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 6dab8eb9b25..d898f581897 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -557,13 +557,14 @@ fn admit_terminal_tool_authority( "paperclip_finish" => { matches!(disposition.as_str(), "done" | "needs_review") || (disposition == "yielded" - && matches!( - input - .get("continuation") - .and_then(|continuation| continuation.get("kind")) - .and_then(Value::as_str), - Some("response_wake" | "monitor" | "case_handoff") - )) + && (input.get("continuation").map_or(true, Value::is_null) + || matches!( + input + .get("continuation") + .and_then(|continuation| continuation.get("kind")) + .and_then(Value::as_str), + Some("response_wake" | "monitor" | "case_handoff" | "same_agent") + ))) } "paperclip_block" => disposition == "blocked", _ => false, @@ -5311,7 +5312,37 @@ mod tests { } #[test] - fn terminal_tool_authority_rejects_an_unbound_yield() { + fn terminal_tool_authority_preserves_controller_resolved_workspace_yields() { + for continuation in [ + None, + Some(Value::Null), + Some(json!({ + "kind": "same_agent", + "summary": "Continue the prepared repository.", + "idempotencyKey": "repository:task:receipt:run" + })), + ] { + let mut state = opencode_result_state(); + let mut result = valid_opencode_result(); + result["reportedWorkDisposition"] = json!("yielded"); + if let Some(value) = continuation { + result["continuation"] = value; + } + admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, false).unwrap(); + assert_eq!( + state.active_provider_result_disposition.as_deref(), + Some("yielded") + ); + assert_eq!( + state.active_provider_result_fingerprint.as_deref(), + Some(semantic_value_digest(&result).as_str()) + ); + assert!(state.validate().is_ok()); + } + } + + #[test] + fn terminal_tool_authority_does_not_record_a_controller_rejected_yield() { let mut state = opencode_result_state(); let mut result = valid_opencode_result(); result["reportedWorkDisposition"] = json!("yielded"); @@ -5321,9 +5352,10 @@ mod tests { "idempotencyKey": "same-agent-1" }); - assert!( - admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, false,).is_err() - ); + admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, true).unwrap(); + assert!(state.active_provider_result_fingerprint.is_none()); + assert!(state.active_provider_result_disposition.is_none()); + assert!(state.validate().is_ok()); } #[test] diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index 0f6e54c1584..3401ada114c 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v53-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v55-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 53 }; +const declaration = { ...historical.declaration, agentProfileVersion: 55 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index a032ac93e35..248950de9d8 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -317,10 +317,10 @@ describe("qualified ACPX runtime sidecar", () => { } }); - it.each([true, false])("resolves an omitted continuation before the production sidecar validator only with a saved monitor: %s", async eligible => { + it.each([["monitor", true], ["monitor", false], ["same_agent", true], ["same_agent", false]] as const)("resolves an omitted continuation before the production sidecar validator with saved %s authority=%s", async (kind, eligible) => { const raw = { reportedWorkDisposition: "yielded", summary: "The review fix is published.", evidence: [], verification: [], completionClaim: { contractRevision: "1", objectiveSatisfied: false, criteria: [], remainingWork: [{ description: "Wait for checks.", blocksCompletion: true }] } }; - const continuation = { kind: "monitor", summary: "Wait for the saved task monitor.", idempotencyKey: "task-monitor:task:deadline" }; + const continuation = { kind, summary: "Continue from saved task state.", idempotencyKey: "saved:task:receipt" }; const tools = new Map(), emitted: any[] = []; const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult }); const commands = loadActualToolCommands(tools); diff --git a/packages/paperclip-runner/src/contracts/completion-result.test.ts b/packages/paperclip-runner/src/contracts/completion-result.test.ts index ea7382778ba..d230ef4ef21 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.test.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.test.ts @@ -5,6 +5,7 @@ import { PRP_BLOCK_RESULT_OUTPUT_SCHEMA, PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA, PRP_BLOCK_TOOL_DESCRIPTION, + PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA, PRP_COMPLETION_RESULT_OUTPUT_SCHEMA, PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA, PRP_COMPLETION_TOOL_DESCRIPTION, @@ -59,6 +60,21 @@ describe("provider-neutral completion result schema", () => { expect(validate(report)).toBe(false); }); + it("carries a normalized workspace continuation only on the internal bridge", () => { + const bridge = new Ajv2020({ strict: false }).compile(PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA); + const provider = new Ajv2020({ strict: false }).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA); + const report = { ...baseResult, reportedWorkDisposition: "yielded", continuation: { + kind: "same_agent", summary: "Use the staged task checkout.", idempotencyKey: "repository:task:receipt:run" } }; + expect(bridge(report)).toBe(true); + expect(provider(report)).toBe(false); + for (const invalid of [ + { ...report, reportedWorkDisposition: "done" }, + { ...report, continuation: { ...report.continuation, kind: "retry" } }, + { ...report, continuation: { ...report.continuation, idempotencyKey: "" } }, + ]) expect(bridge(invalid)).toBe(false); + expect(bridge(baseResult)).toBe(true); + }); + it("allows done with no verification and no actionable attention", () => { expect(validate(structuredClone(baseResult))).toBe(true); }); @@ -115,6 +131,9 @@ describe("provider-neutral completion result schema", () => { expect(finish.inputSchema).toEqual(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA); expect(block.inputSchema).toEqual(PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA); expect(finish.description).toBe(PRP_COMPLETION_TOOL_DESCRIPTION); + expect(finish.description).toContain("saved repository-preparation continuation"); + expect(finish.inputSchema.properties.continuation.description).toContain("repository-preparation continuation"); + expect(block.description).toContain("Routine repository preparation is not a blocker"); expect(block.description).toBe(PRP_BLOCK_TOOL_DESCRIPTION); }); diff --git a/packages/paperclip-runner/src/contracts/completion-result.ts b/packages/paperclip-runner/src/contracts/completion-result.ts index d5f6c350bde..47862d1fdcb 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.ts @@ -3,9 +3,9 @@ export const PRP_COMPLETION_TOOL_NAME = "paperclip_finish" as const; export const PRP_BLOCK_TOOL_NAME = "paperclip_block" as const; /** Native Runner guidance; legacy adapters use their own skill/API completion paths. */ export const PRP_COMPLETION_TOOL_DESCRIPTION = - "Report completed work (done), work requiring review (needs_review), or an explicit wait (yielded with response_wake for a response, or monitor after set_task_monitor confirms a schedule on this task). Use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome; do not claim completion while gated. Explain any required approval with its supplied link and action, then write the final response and end the turn without further tool calls."; + "Report completed work (done), work requiring review (needs_review), or an explicit wait (yielded with response_wake for a response, monitor after set_task_monitor confirms a schedule on this task, or omit continuation to use the saved repository-preparation continuation). Use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome; do not claim completion while gated. Explain any required approval with its supplied link and action, then write the final response and end the turn without further tool calls."; export const PRP_BLOCK_TOOL_DESCRIPTION = - "Report work that cannot continue because of a concrete blocker. Identify the blocker, its owner, and the action needed to unblock it; use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome, explain the blocker and any required action in the final response, and end the turn without further tool calls."; + "Report work that cannot continue because of a concrete blocker. Routine repository preparation is not a blocker: use paperclip_finish with yielded and omit continuation to resume through the saved workspace continuation. Identify the blocker, its owner, and the action needed to unblock it; use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome, explain the blocker and any required action in the final response, and end the turn without further tool calls."; export const PRP_SEMANTIC_TOOL_NAMES = [ PRP_COMPLETION_TOOL_NAME, PRP_BLOCK_TOOL_NAME, @@ -372,7 +372,7 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = { ...waitContinuationSchema, type: ["object", "null"], description: - "For yielded, omit or use null to reuse this task's eligible saved monitor; the controller reads current task state. Otherwise provide a real response_wake or monitor with summary and idempotencyKey. Use null or omit for done, completed, or needs_review.", + "For yielded, omit or use null to reuse this task's eligible saved monitor or repository-preparation continuation; the controller reads current task state. Otherwise provide a real response_wake or monitor with summary and idempotencyKey. Use null or omit for done, completed, or needs_review.", }, }, // Keep the provider-facing root a concrete object. Codex code-mode renders a @@ -384,6 +384,20 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = { else: { properties: { continuation: { type: "null" } } }, } as const; +export const PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA = { + ...PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA, + properties: { + ...PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA.properties, + continuation: { + ...PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA.properties.continuation, + properties: { + ...waitContinuationSchema.properties, + kind: { type: "string", enum: [...waitContinuationSchema.properties.kind.enum, "same_agent"] }, + }, + }, + }, +} as const; + export const PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA = { type: "object", additionalProperties: true, diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 4eaf4aa0dbc..4b978c6dbf1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,9 +87,32 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi describe("Copilot build-owned installation", () => { it("admits the current profile with environment launch preparation", () => { - expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(53); + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(55); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:6441ff7f3fc664064bff9e7ae80252ddec7a39236f9a4b9d402785eb8b03b2c9"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48"); + }); + + it("preserves v54 while binding current workspace continuation revalidation in v55", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v54-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v55-identity.json", import.meta.url), "utf8")); + expect(prior.commandDigest).toBe("sha256:171d865b9b6ff2eda3926dc957caf2993cd7e1fa1b4359e8b60b83f589bd67fa"); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ + "agentProfileVersion", "profileCompatibilitySourceSha256", "runtimeRequestBridgeSourceSha256", + "semanticCompletionContractSourceSha256", "semanticContinuationSourceSha256", + ]); + expect(isSupportedAcpxProfileVersion("copilot", 54)).toBe(true); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + + it("preserves v53 while binding repository setup completion guidance in v54", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v53-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v54-identity.json", import.meta.url), "utf8")); + expect(prior.commandDigest).toBe("sha256:6441ff7f3fc664064bff9e7ae80252ddec7a39236f9a4b9d402785eb8b03b2c9"); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ + "agentProfileVersion", "profileCompatibilitySourceSha256", "semanticCompletionContractSourceSha256", + ]); + expect(isSupportedAcpxProfileVersion("copilot", 53)).toBe(true); + expect(current.commandDigest).not.toBe(prior.commandDigest); }); it("preserves v52 while binding the workspace continuation source in v53", () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 4f9ab909ce3..0138382b642 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 53, + "agentProfileVersion": 55, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:6441ff7f3fc664064bff9e7ae80252ddec7a39236f9a4b9d402785eb8b03b2c9", + "commandDigest": "sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index b767c2114ce..ab37fdba06a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52; +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52], + grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.semantic-results.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.semantic-results.test.ts index 50f5c0cec5e..8982c48fd5e 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.semantic-results.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.semantic-results.test.ts @@ -46,9 +46,11 @@ import { import { RUNNERD_CANONICAL_ITEM } from "./codex-driver-values.js"; describe("Codex app-server Codex driver", () => { - it("uses a saved monitor when a provider omits continuation", async () => { + it.each([ + ["monitor", false], ["monitor", true], ["same_agent", false], ["same_agent", true], ["same_agent", "explicit"], + ] as const)("uses saved %s continuation with nullable input=%s", async (kind, nullable) => { const transport = new FakeCodexTransport(); - const continuation = { kind: "monitor", summary: "Wait for saved monitor", idempotencyKey: "monitor:task:deadline" }; + const continuation = { kind, summary: "Continue from saved task state", idempotencyKey: "saved:task:receipt" }; const dynamicToolHandler = vi.fn().mockResolvedValue({ run: { id: "monitor-default" }, activeTask: { executionRunId: "monitor-default" }, completionContinuation: continuation }); const feedback = vi.fn().mockResolvedValue("Monitor wait accepted."); @@ -57,7 +59,7 @@ describe("Codex app-server Codex driver", () => { }); try { await session.startTurn({ message: { role: "user", text: "Check the published PR later." } }); - const report = { ...structuredClone(result), reportedWorkDisposition: "yielded" }; + const report = { ...structuredClone(result), reportedWorkDisposition: "yielded", ...(nullable === "explicit" ? { continuation } : nullable ? { continuation: null } : {}) }; const response = await transport.invoke({ id: "finish-monitor", method: "item/tool/call", params: { threadId: "thread-1", turnId: "turn-1", callId: "finish-monitor", tool: "paperclip_finish", arguments: report, } }); @@ -69,6 +71,30 @@ describe("Codex app-server Codex driver", () => { } finally { await session.close(); } }); + it.each([ + { run: { id: "foreign" }, activeTask: { executionRunId: "saved-default" } }, + { run: { id: "saved-default" }, activeTask: { executionRunId: "foreign" } }, + { run: { id: "saved-default" }, activeTask: {} }, + ])("rejects saved continuation without current run ownership %j", async context => { + const transport = new FakeCodexTransport(); + const dynamicToolHandler = vi.fn().mockResolvedValue({ ...context, + completionContinuation: { kind: "same_agent", summary: "Continue checkout", idempotencyKey: "repository:receipt" } }); + const feedback = vi.fn(); + const session = await makeDriver([transport], { dynamicToolHandler, completionFeedback: feedback }).openSession({ + runId: "saved-default", normalizedSessionId: "saved-default", workingDirectory: WORKSPACE, + }); + try { + await session.startTurn({ message: { role: "user", text: "Continue the repository work." } }); + const response = await transport.invoke({ id: "finish-saved", method: "item/tool/call", params: { + threadId: "thread-1", turnId: "turn-1", callId: "finish-saved", tool: "paperclip_finish", + arguments: { ...structuredClone(result), reportedWorkDisposition: "yielded" }, + } }); + expect(response).toMatchObject({ success: false }); + expect(feedback).not.toHaveBeenCalled(); + expect((await session.snapshot()).semanticResult).toBeNull(); + } finally { await session.close(); } + }); + it("returns current approval feedback and permits correcting a rejected completion report", async () => { const transport = new FakeCodexTransport(); const feedback = vi.fn() diff --git a/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts b/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts index 78903a93f1a..a86c6e41a84 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts @@ -259,6 +259,7 @@ export function codexToolAcceptsDisposition( export function codexToolAcceptsResult( tool: string, result: PrpStructuredRunResult, + serverResolvedContinuation = false, ): boolean { if (!codexToolAcceptsDisposition(tool, result.reportedWorkDisposition)) { return false; @@ -266,7 +267,8 @@ export function codexToolAcceptsResult( return result.reportedWorkDisposition !== "yielded" || result.continuation?.kind === "response_wake" || result.continuation?.kind === "monitor" - || result.continuation?.kind === "case_handoff"; + || result.continuation?.kind === "case_handoff" + || (serverResolvedContinuation && result.continuation?.kind === "same_agent"); } export function redactCodexValue(value: unknown, depth = 0): unknown { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts index a8511238664..95d3c329305 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts @@ -208,7 +208,7 @@ async function handleServerRequestBody( }; } if ( - !toolAcceptsResult(tool, validation.result) + !toolAcceptsResult(tool, validation.result, completionInput !== request.params.arguments) ) { return { success: false, @@ -218,7 +218,7 @@ async function handleServerRequestBody( text: tool === CODEX_BLOCK_TOOL_NAME ? "paperclip_block requires reportedWorkDisposition=blocked." - : "paperclip_finish accepts done, needs_review, or yielded with a response_wake or persisted monitor continuation.", + : "paperclip_finish accepts done, needs_review, or yielded with a response, monitor, handoff, or server-resolved workspace continuation.", }, ], }; diff --git a/packages/paperclip-runner/src/drivers/completion-continuation.test.ts b/packages/paperclip-runner/src/drivers/completion-continuation.test.ts index 91c09564866..62051110a60 100644 --- a/packages/paperclip-runner/src/drivers/completion-continuation.test.ts +++ b/packages/paperclip-runner/src/drivers/completion-continuation.test.ts @@ -25,6 +25,20 @@ describe("controller-owned completion continuation", () => { .toEqual({ ...yielded, continuation: preparation }); }); + it("rechecks a sidecar-resolved workspace continuation against current server authority", async () => { + const preparation = { kind: "same_agent", summary: "Continue with the prepared repository.", idempotencyKey: "repository:task:receipt:run" }; + const report = { ...yielded, continuation: preparation }; + const read = vi.fn().mockResolvedValue({ ...context, completionContinuation: preparation }); + const resolved = await resolveCompletionContinuation(report, "run", read); + expect(resolved).toEqual(report); + expect(read).toHaveBeenCalledExactlyOnceWith(); + for (const response of [null, { ...context, completionContinuation: { ...preparation, idempotencyKey: "foreign" } }, + { ...context, completionContinuation: { ...preparation, summary: "changed" } }, + { ...context, completionContinuation: preparation, run: { id: "foreign" } }]) { + await expect(resolveCompletionContinuation(report, "run", async () => response)).rejects.toThrow(); + } + }); + it.each([ null, { ...context, run: { id: "other" } }, { ...context, activeTask: { executionRunId: "other" } }, { ...context, completionContinuation: null }, { ...context, completionContinuation: { kind: "response_wake" } }, diff --git a/packages/paperclip-runner/src/drivers/completion-continuation.ts b/packages/paperclip-runner/src/drivers/completion-continuation.ts index 31606f5c213..05fd4ad8b75 100644 --- a/packages/paperclip-runner/src/drivers/completion-continuation.ts +++ b/packages/paperclip-runner/src/drivers/completion-continuation.ts @@ -5,7 +5,8 @@ export async function resolveCompletionContinuation( readTaskContext?: () => Promise, ): Promise { const report = object(value); - if (report.reportedWorkDisposition !== "yielded" || report.continuation != null) return value; + const supplied = object(report.continuation); + if (report.reportedWorkDisposition !== "yielded" || (report.continuation != null && supplied.kind !== "same_agent")) return value; const response = readTaskContext ? await readTaskContext() : null; const text = object(response).type === "inputText" ? object(response).text : response; let context: Record; @@ -19,6 +20,10 @@ export async function resolveCompletionContinuation( typeof continuation.idempotencyKey !== "string" || !continuation.idempotencyKey.trim()) { throw new Error("Cannot yield without a continuation: no eligible saved monitor or workspace continuation exists on this task. Save a monitor for a deferred check, provide an explicit response_wake for a real response wait, or continue the unfinished work. Do not repeat completed actions."); } + if (report.continuation != null && (continuation.kind !== "same_agent" || supplied.summary !== continuation.summary || + supplied.idempotencyKey !== continuation.idempotencyKey)) { + throw new Error("The supplied workspace continuation does not match current server-owned task state. Read the current task context and preserve its authority."); + } return { ...report, continuation: { kind: continuation.kind, summary: continuation.summary, idempotencyKey: continuation.idempotencyKey } }; } diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index e7b8218256c..bc5046222c0 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,3 +1,4 @@ +import { PRP_COMPLETION_TOOL_NAME, PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA } from "../contracts/completion-result.js"; import { RunnerLaunchPreparationStore } from "./runner-launch-preparation-store.js"; import type { PreparedRunnerProviderLaunch, RunnerProviderLaunchRequirements } from "./runner-provider-launch.js"; import { validatePreparedRunnerLaunch, runnerProviderLaunchRequirements, createRunnerProviderLaunch, createRunnerdCodexAppServerArgs, trustedRuntimeReadOnlyRoots } from "./runner-provider-launch.js"; @@ -3211,7 +3212,7 @@ function authorizedToolSet( operationId: String(tool.name ?? ""), version: 1, description: String(tool.description ?? ""), - inputSchema: record(tool.inputSchema), + inputSchema: tool.name === PRP_COMPLETION_TOOL_NAME ? PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA : record(tool.inputSchema), responseSchema: {}, })) .sort((left, right) => diff --git a/packages/paperclip-runner/src/live/runnerd-saved-continuation.test.ts b/packages/paperclip-runner/src/live/runnerd-saved-continuation.test.ts new file mode 100644 index 00000000000..8f750ea11e7 --- /dev/null +++ b/packages/paperclip-runner/src/live/runnerd-saved-continuation.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { expect, it, vi } from "vitest"; +import { CodexAppServerDriver } from "../drivers/codex/codex-app-server-driver.js"; +import { createCodexTaskEnvelope } from "../contracts/codex.js"; +import { createCapabilityRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js"; +import { collectUntilTerminal } from "../drivers/codex/codex-app-server-driver.test-support.js"; + +it.each(["omitted", "null", "resolved"] as const)("retains a server-owned workspace yield through the actual Rust bridge (%s)", async form => { + const root = await mkdtemp(join(tmpdir(), "runnerd-saved-continuation-")), runId = "run-saved-workspace"; + const continuation = { kind: "same_agent", summary: "Continue with the prepared task repository.", idempotencyKey: "repository:task:receipt:run" }; + const report = { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: "The repository is staged; the requested verification continues.", + completionClaim: { contractRevision: "1", objectiveSatisfied: false, criteria: [{ criterionId: "objective", status: "not_satisfied", evidenceRefs: [] }], + remainingWork: [{ description: "Read the prepared repository and deliver its result.", blocksCompletion: true }] }, + evidence: [], verification: [], attentionRequests: [], artifacts: [], + ...(form === "resolved" ? { continuation } : form === "null" ? { continuation: null } : {}) }; + const reportFile = join(root, "provider-report.json"), callLog = join(root, "provider-calls.txt"); + await writeFile(reportFile, JSON.stringify(report)); + const workspace = join(root, "workspace"); + await Promise.all([mkdir(join(root, "home")), mkdir(workspace)]); + const environment = { PATH: process.env.PATH, HOME: join(root, "home"), CODEX_HOME: join(root, "codex-home"), LANG: "C.UTF-8" }; + const bundle = createCapabilityRunnerdCodexTransport({ + runnerBinary: process.env.PAPERCLIP_ATTACH_TRANSITION_RUNNER ?? defaultCapabilityRunnerdBinary(), + codexCommand: resolve(import.meta.dirname, "../../runner/target/debug/fake-codex-app-server"), + codexArgs: ["--state-file", join(root, "provider-state.json"), "--call-log", callLog, "--terminal-result-file", reportFile], + stateDirectory: root, environment, + prpIdentity: { runnerInstanceId: "runner-saved-workspace", environmentLeaseId: "lease-saved-workspace", runId, normalizedSessionId: "session-saved-workspace", turnId: "turn-saved-workspace", itemId: "item-saved-workspace" }, + }); + const dynamicToolHandler = vi.fn(async () => ({ run: { id: runId }, activeTask: { executionRunId: runId }, completionContinuation: continuation })); + const completionFeedback = vi.fn(async () => "The saved repository continuation is accepted."); + const driver = new CodexAppServerDriver({ approvalPolicy: "never", includeCollaborationModeInstructions: false, environment, + taskEnvelope: createCodexTaskEnvelope({ objective: "Verify the prepared repository.", contractRevision: "1", criteria: [{ id: "objective", requirement: "Deliver the result." }], constraints: [] }), + dynamicTools: [{ name: "get_task_context", description: "Read current task authority.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }], + dynamicToolHandler, completionFeedback, transportFactory: () => bundle.transport, requireProviderSessionIdentity: true }); + let session: Awaited> | undefined; + try { + session = await driver.openSession({ runId, normalizedSessionId: "session-saved-workspace", workingDirectory: workspace }); + await session.startTurn({ message: { role: "user", text: "Continue with the prepared checkout." } }); + const events = await collectUntilTerminal(session.events()); + expect(completionFeedback).toHaveBeenCalledExactlyOnceWith({ ...report, continuation }); + expect(dynamicToolHandler).toHaveBeenCalledOnce(); + expect(events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1); + expect(events.at(-1)?.eventType).toBe("turn.completed"); + expect((await session.snapshot()).semanticResult?.result).toEqual({ ...report, continuation }); + expect(await readFile(callLog, "utf8")).toContain("terminal-result:accepted"); + const journal = JSON.parse(await readFile(join(root, "control-plane/control-plane-state.json"), "utf8")); + const terminals = journal.committedEvents.filter((event: { eventType: string }) => event.eventType === "run.terminal"); + expect(terminals).toHaveLength(1); + expect(terminals[0].envelope.payload).toMatchObject({ runId, eventType: "run.terminal", payload: { runTerminalState: "succeeded", reportedWorkDisposition: "yielded" } }); + } finally { + await session?.close(); + await bundle.transport.close(); + await rm(root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v54-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v54-identity.json new file mode 100644 index 00000000000..1dcef7569af --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v54-identity.json @@ -0,0 +1,84 @@ +{ + "commandDigest": "sha256:171d865b9b6ff2eda3926dc957caf2993cd7e1fa1b4359e8b60b83f589bd67fa", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 54, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "89e3dde25ff35bfd4d7f520fd9d14af3cb9027f182a2d51431d298f1015be580", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "cc90b59bdfe6a82f7fa3e819e8671f71f7b14047e68942e8086618ffbaacf59f", + "semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "b4af7c243a7be3570689fe5506d26b6f093fc893a81a3b5a481f7ee4efd65756", + "semanticValidatorsSourceSha256": "f22d9caf2035b600abc1a6625ae23ca97f1409d7e29ed5b7264bb8f9784c7dc9", + "semanticSchemaBundleSourceSha256": "016391a087cc217aafed4ba1f4efafc6e4881a7525952dce824b7f4d24530603", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "5c9008d121979a3a8987e6b8a5752172eafdc4a2b5d725a73f3bbb8fb83be8ff", + "configuredEnvironmentRustSourceSha256": "85c87fae62b8a9377e4f0a4c00302c7519fae4397b1c48253f8f2b7ba7faf994", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "e8a3bee66a1ff1c6f251b5ae57453ffb908336da9c4a6d98f41f94f7ee94a8e8", + "nativeRuntimeContextSourceSha256": "df93bc8bcfa9dfdcf26ad6a69ea0bd3e56ef186c5e962b243fb2288f7074584b", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "4fa9593226ff3280bc12140bfcbc2c0986e4cc8d4733381c334138dd03b08f46", + "nativeBackendSourceSha256": "2406483fd35a564971c22e10fc2bad26bae16ff5a3fb6c65690fcf156522ab6d", + "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", + "nativeInstructionsSourceSha256": "ee8df5d85b263d5d2d0dc9ede8602cb7daf6388687108fe05e2371f5f56c3492", + "providerLaunchSourceSha256": "cba71368f82ae8d300123efa09f23744e19e82142788fa9ad238338fefec9624", + "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", + "runtimeRequestBridgeSourceSha256": "ec09328c11bb6688269b251a40f84e6c810f32afcb0f31cb79a26ca48be68e73", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "f6d0c33de38259efefa0f4e0f9737cd30cfd3996a8e875e3fbdb8c72dc133dca", + "nativeExecutionSourceSha256": "fc7c6568feb0d8d709e9baa51839420c9b90336b4865a9b8405aba06e4d1e9c4", + "profileCompatibilitySourceSha256": "0f16a260a2db3fe8ae6a1962269a475ac64d9b0ad49700ebee0baa4fa3f9acd8", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "54fd09ad430dac04df98d829061f6701e73b5bf2a0f4de836d2ac36ee511009f", + "sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "1154778050a39178b09e9fe835173592691f6b679348814f30eaae509d84db0e", + "runtimeHostSourceSha256": "60e5454f030e87f2afa8b1fb120b26adb126b6b17d0e5e82f49c77f76f466cbb", + "runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8", + "runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json new file mode 100644 index 00000000000..2e9a43ec690 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json @@ -0,0 +1,84 @@ +{ + "commandDigest": "sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 55, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "89e3dde25ff35bfd4d7f520fd9d14af3cb9027f182a2d51431d298f1015be580", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "cc90b59bdfe6a82f7fa3e819e8671f71f7b14047e68942e8086618ffbaacf59f", + "semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "3343df6362252d44d12b079ff78c8f6539d1544f3ce5ec36ff36db70ab0fac31", + "semanticValidatorsSourceSha256": "f22d9caf2035b600abc1a6625ae23ca97f1409d7e29ed5b7264bb8f9784c7dc9", + "semanticSchemaBundleSourceSha256": "016391a087cc217aafed4ba1f4efafc6e4881a7525952dce824b7f4d24530603", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "5c9008d121979a3a8987e6b8a5752172eafdc4a2b5d725a73f3bbb8fb83be8ff", + "configuredEnvironmentRustSourceSha256": "85c87fae62b8a9377e4f0a4c00302c7519fae4397b1c48253f8f2b7ba7faf994", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "e8a3bee66a1ff1c6f251b5ae57453ffb908336da9c4a6d98f41f94f7ee94a8e8", + "nativeRuntimeContextSourceSha256": "df93bc8bcfa9dfdcf26ad6a69ea0bd3e56ef186c5e962b243fb2288f7074584b", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "4fa9593226ff3280bc12140bfcbc2c0986e4cc8d4733381c334138dd03b08f46", + "nativeBackendSourceSha256": "2406483fd35a564971c22e10fc2bad26bae16ff5a3fb6c65690fcf156522ab6d", + "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", + "nativeInstructionsSourceSha256": "ee8df5d85b263d5d2d0dc9ede8602cb7daf6388687108fe05e2371f5f56c3492", + "providerLaunchSourceSha256": "cba71368f82ae8d300123efa09f23744e19e82142788fa9ad238338fefec9624", + "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", + "runtimeRequestBridgeSourceSha256": "fb2e91e9a7f4b10ae0d936e1e1c74ac9095733ef67fe816491be45791048efa1", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "f6d0c33de38259efefa0f4e0f9737cd30cfd3996a8e875e3fbdb8c72dc133dca", + "nativeExecutionSourceSha256": "fc7c6568feb0d8d709e9baa51839420c9b90336b4865a9b8405aba06e4d1e9c4", + "profileCompatibilitySourceSha256": "d4edec781031a44f7e96a1e4ef398af4675529b7ec0f9c56c4915d9ec81a20cf", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "54fd09ad430dac04df98d829061f6701e73b5bf2a0f4de836d2ac36ee511009f", + "sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "d248e47a0fe7f2536d1e64cea0b0b410f34104800cd643d995b4473104ecc8e0", + "runtimeHostSourceSha256": "60e5454f030e87f2afa8b1fb120b26adb126b6b17d0e5e82f49c77f76f466cbb", + "runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8", + "runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts index 48c922881e8..5e420f50f9e 100644 --- a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts @@ -8,6 +8,12 @@ import { agentRuntimeState, agentWakeupRequests, companySkills, + completionContracts, + nativeRunResults, + nativeRunFinalizations, + workAssessments, + statusDecisions, + statusDecisionEffects, companies, authUsers, companyMemberships, @@ -144,6 +150,12 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = await db.delete(documents); await db.delete(issueRelations); await db.delete(issueTreeHolds); + await db.delete(statusDecisionEffects); + await db.delete(statusDecisions); + await db.delete(workAssessments); + await db.delete(nativeRunFinalizations); + await db.delete(nativeRunResults); + await db.delete(completionContracts); await db.delete(issues); await db.delete(heartbeatRunEvents); await db.delete(activityLog); @@ -259,6 +271,76 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = } }, 45_000); + it("keeps a saved native continuation behind its active predecessor without retagging that run", async () => { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); + let finishPredecessor!: () => void; + const predecessorFinished = new Promise(resolve => { finishPredecessor = resolve; }); + mockAdapterExecute.mockImplementationOnce(async () => { + await predecessorFinished; + return { exitCode: 0, signal: null, timedOut: false, errorMessage: null, + summary: "The repository is staged; continue on the saved admission.", provider: "test", model: "test-model" }; + }); + mockAdapterExecute.mockImplementationOnce(async () => { + await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId)); + return { exitCode: 0, signal: null, timedOut: false, errorMessage: null, + summary: "Verified and delivered the prepared repository.", provider: "test", model: "test-model" }; + }); + await db.insert(companies).values({ id: companyId, name: "Saved repository continuation", + issuePrefix: `N${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); + await db.insert(agents).values({ id: agentId, companyId, name: "Repository worker", role: "engineer", status: "active", + adapterType: "codex_local", adapterConfig: {}, permissions: {}, runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } }); + await db.insert(issues).values({ id: issueId, companyId, title: "Continue repository verification", status: "todo", + assigneeAgentId: agentId, responsibleUserId: "responsible-user" }); + try { + const predecessor = await heartbeat.wakeup(agentId, { source: "assignment", triggerDetail: "system", reason: "issue_assigned", + payload: { issueId }, contextSnapshot: { issueId, source: "initial-repository-request" } }); + expect(predecessor).not.toBeNull(); + expect(await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1, 30_000)).toBe(true); + const contractId = randomUUID(), resultId = randomUUID(), assessmentId = randomUUID(), decisionId = randomUUID(); + await db.insert(completionContracts).values({ id: contractId, companyId, issueId, revision: 1, + schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "standard", + completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal", + contractJson: {}, canonicalSha256: contractId, createdByActorType: "system", createdByActorId: "test" }); + await db.update(heartbeatRuns).set({ nativeIssueId: issueId, completionContractId: contractId }).where(eq(heartbeatRuns.id, predecessor!.id)); + await db.insert(nativeRunResults).values({ id: resultId, companyId, issueId, runId: predecessor!.id, + completionContractId: contractId, serverFingerprint: resultId, schemaStatus: "accepted", resultJson: {}, canonicalSha256: resultId }); + await db.insert(workAssessments).values({ id: assessmentId, companyId, issueId, runId: predecessor!.id, + contractId, resultId, triggerKind: "turn_finished", triggerActorCompanyId: companyId, + priorIssueStatus: "in_progress", priorStatusVersion: 0, policyVersion: "test", assessmentJson: {}, inputDigest: assessmentId }); + await db.insert(statusDecisions).values({ id: decisionId, companyId, issueId, runId: predecessor!.id, + assessmentId, decisionVersion: 1, policyVersion: "test", fromStatus: "in_progress", toStatus: "in_progress", + reasonCode: "live_continuation_registered", decisionJson: {}, decisionDigest: decisionId, applicationState: "applied", appliedAt: new Date() }); + const [intent] = await db.insert(agentWakeupRequests).values({ companyId, agentId, source: "automation", triggerDetail: "system", + reason: "issue_status_changed", requestedByActorType: "system", requestedByActorId: "native-status-committer", + idempotencyKey: `native-status:${decisionId}:continuation`, payload: { issueId, taskId: issueId, nativeDecisionId: decisionId, continuationKind: "same_agent", + _paperclipWakeContext: { issueId, taskId: issueId, source: "native_status_decision", continuationKind: "same_agent" } } }).returning(); + await db.insert(statusDecisionEffects).values({ companyId, issueId, decisionId, ordinal: 0, + effectKind: "enqueue_continuation", targetType: "agent_wakeup_request", targetId: intent!.id, + idempotencyKey: `native-status:${decisionId}:continuation`, payload: { continuationKind: "same_agent" }, deliveryState: "delivered", deliveredAt: new Date() }); + await heartbeat.dispatchPendingNativeStatusWakeups({ companyId }); + await heartbeat.dispatchPendingNativeStatusWakeups({ companyId }); + const queued = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId)); + expect(queued.filter(row => row.status === "deferred_issue_execution"), JSON.stringify(queued.map(row => ({ status: row.status, reason: row.reason, runId: row.runId, error: row.error })))).toHaveLength(1); + expect(queued.find(row => row.id === intent!.id)).toMatchObject({ status: "coalesced", runId: null }); + const [unchanged] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, predecessor!.id)); + expect(unchanged!.contextSnapshot).toMatchObject({ source: "initial-repository-request" }); + expect(unchanged!.contextSnapshot?.nativeStatusWakeIntentId).toBeUndefined(); + expect(mockAdapterExecute).toHaveBeenCalledTimes(1); + finishPredecessor(); + expect(await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 2, 10_000)).toBe(true); + await heartbeat.drainActiveRunExecutions(); + const completed = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId)); + expect(completed).toHaveLength(2); + const successor = completed.find(run => run.id !== predecessor!.id)!; + expect(successor).toMatchObject({ status: "succeeded", contextSnapshot: { source: "native_status_decision", nativeStatusWakeIntentId: intent!.id } }); + expect(successor.startedAt!.getTime()).toBeGreaterThanOrEqual(completed.find(run => run.id === predecessor!.id)!.finishedAt!.getTime()); + await heartbeat.dispatchPendingNativeStatusWakeups({ companyId }); + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(2); + } finally { finishPredecessor(); } + }, 45_000); + it("dispatches and coalesces durable native status wake intents into one heartbeat run", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/managed-repository-checkout.test.ts b/server/src/__tests__/managed-repository-checkout.test.ts index 26c10afa7b2..2767407786e 100644 --- a/server/src/__tests__/managed-repository-checkout.test.ts +++ b/server/src/__tests__/managed-repository-checkout.test.ts @@ -1,14 +1,14 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; -import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat } from "node:fs/promises"; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "../services/managed-repository-checkout.js"; const execFile = promisify(execFileCallback); const directories: string[] = []; -afterEach(async () => { await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); async function fixture() { const root = await mkdtemp(path.join(tmpdir(), "paperclip-repository-publication-")); directories.push(root); @@ -33,6 +33,34 @@ describe("atomic managed repository publication", () => { await ensureManagedRepositoriesIgnored(source); expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toContain("/.paperclip-repositories/"); }); + it("allows a declared plain task directory with inert Git metadata without changing its files", async () => { + const { target } = await fixture(); + await mkdir(path.join(target, ".git", "info"), { recursive: true }); + const file = path.join(target, ".git", "info", "exclude"); + await writeFile(file, "/.paperclip-repositories/\n"); + await expect(ensureManagedRepositoriesIgnored(target, { plainTaskDirectory: true })).resolves.toBeUndefined(); + expect(await readFile(file, "utf8")).toBe("/.paperclip-repositories/\n"); + await expect(ensureManagedRepositoriesIgnored(target)).rejects.toThrow("not a git repository"); + }); + it("still excludes managed repositories when a task directory contains a real checkout", async () => { + const { source } = await fixture(); + await ensureManagedRepositoriesIgnored(source, { plainTaskDirectory: true }); + expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toContain("/.paperclip-repositories/"); + }); + it("retains unexpected Git errors for declared plain task directories", async () => { + const { root, target } = await fixture(); + await mkdir(path.join(target, ".git"), { recursive: true }); + vi.stubEnv("PATH", path.join(root, "missing-git")); + await expect(ensureManagedRepositoriesIgnored(target, { plainTaskDirectory: true })).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("rejects linked Git metadata even for a declared plain task directory", async () => { + const { source, target } = await fixture(); + await mkdir(target); + await symlink(path.join(source, ".git"), path.join(target, ".git"), "dir"); + const original = await readFile(path.join(source, ".git", "info", "exclude"), "utf8"); + await expect(ensureManagedRepositoriesIgnored(target, { plainTaskDirectory: true })).rejects.toThrow("must not be a symlink"); + expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toBe(original); + }); it("writes the pinned receipt inside the clone before making it visible", async () => { const { source, target } = await fixture(); const result = await materializeManagedProjectWorkspace(target, { repoUrl: "https://github.com/test/source", localSource: source, beforePublish: async clone => { diff --git a/server/src/__tests__/task-workspace-admission.test.ts b/server/src/__tests__/task-workspace-admission.test.ts index cb8c3bd14e8..02a43bf5149 100644 --- a/server/src/__tests__/task-workspace-admission.test.ts +++ b/server/src/__tests__/task-workspace-admission.test.ts @@ -22,13 +22,27 @@ describe("task workspace admission authority", () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-source-authority-")); try { const restored = await ensurePersistedExecutionWorkspaceAvailable({ - base: { baseCwd: cwd, source: "project_primary", projectId: "org-project", workspaceId: "org-source", repoUrl: null, repoRef: null }, + base: { baseCwd: cwd, source: "project_primary", projectId: "org-project", workspaceId: "org-source", repoUrl: "https://github.com/organization/source", repoRef: "organization-main" }, workspace: { mode: "shared_workspace", strategyType: "project_primary", cwd, providerRef: cwd, - projectId: null, projectWorkspaceId: null, repoUrl: null, baseRef: null, branchName: null }, + projectId: null, projectWorkspaceId: null, repoUrl: null, baseRef: null, branchName: null, metadata: { source: "task_session" } }, issue: { id: "task", identifier: null, title: "Retain files" }, agent: { id: "agent", companyId: "company", name: "Agent" }, }); - expect(restored).toMatchObject({ cwd, projectId: null, workspaceId: null }); + expect(restored).toMatchObject({ cwd, projectId: null, workspaceId: null, repoUrl: null, repoRef: null, source: "task_session" }); + } finally { await fs.rm(cwd, { recursive: true, force: true }); } + }); + it.each([ + { projectId: "configured-project" }, { projectWorkspaceId: "configured-source" }, { repoUrl: "https://github.com/configured/source" }, + ])("retains configured-source classification despite task_session metadata: %j", async configured => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-configured-source-")); + try { + const restored = await ensurePersistedExecutionWorkspaceAvailable({ + base: { baseCwd: cwd, source: "task_session", projectId: null, workspaceId: null, repoUrl: null, repoRef: null }, + workspace: { mode: "shared_workspace", strategyType: "project_primary", cwd, providerRef: cwd, + projectId: null, projectWorkspaceId: null, repoUrl: null, baseRef: null, branchName: null, metadata: { source: "task_session" }, ...configured }, + issue: { id: "task", identifier: null, title: "Configured source" }, agent: { id: "agent", companyId: "company", name: "Agent" }, + }); + expect(restored?.source).toBe("project_primary"); } finally { await fs.rm(cwd, { recursive: true, force: true }); } }); it("permits new admission to apply explicit pending intent", () => { diff --git a/server/src/__tests__/task-workspace-api.test.ts b/server/src/__tests__/task-workspace-api.test.ts index 48794edaefe..52d6b9be602 100644 --- a/server/src/__tests__/task-workspace-api.test.ts +++ b/server/src/__tests__/task-workspace-api.test.ts @@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto"; import { mkdtemp } from "node:fs/promises"; import { dirname, join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { eq } from "drizzle-orm"; -import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; +import { agents, authUsers, companyMemberships, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces } from "@paperclipai/db"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { startRunnerApiTestServer } from "./helpers/runner-api-server.js"; import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js"; @@ -15,6 +15,28 @@ const support = await getEmbeddedPostgresTestSupport(); const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); }, 60_000); afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; }); + it("prepares a repository through native JWT authority for the local board and rechecks revoked access", async () => { + const f = await server.fixture({ disableWakeOnDemand: true }); + await server.db.insert(authUsers).values({ id: "local-board", name: "Local Board", email: "local-board@example.invalid", + emailVerified: true, createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing(); + await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: "local-board", membershipRole: "owner", status: "active" }); + const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId, + name: "Local task files", cwd: f.workspace, mode: "isolated_workspace", strategyType: "task_directory" }).returning(); + await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id, + responsibleUserId: "local-board" }).where(eq(issues.id, f.issueId)); + await server.db.update(heartbeatRuns).set({ responsibleUserId: "local-board" }).where(eq(heartbeatRuns.id, f.runId)); + await expect(f.authority.execute({ tool: "prepare_repository", callId: randomUUID(), arguments: { + repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "local-board-native" } })) + .resolves.toMatchObject({ kind: "requires_next_admission" }); + await server.db.delete(companyMemberships).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "local-board"))); + const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, "local-board"); + const denied = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/workspace/repositories`, { + method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ repository: { kind: "url", url: "https://github.com/public/other" }, requestKey: "different-sponsor" }), + }); + expect([403, 404]).toContain(denied.status); + expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(1); + }); it("creates an explicit-workspace child through the authenticated low-trust API without repeating its saved policy", async () => { const f = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true }); const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review", diff --git a/server/src/__tests__/task-workspace-repositories-trust.test.ts b/server/src/__tests__/task-workspace-repositories-trust.test.ts index c62d8e407eb..e901e0f4eec 100644 --- a/server/src/__tests__/task-workspace-repositories-trust.test.ts +++ b/server/src/__tests__/task-workspace-repositories-trust.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; import { agents, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositoryAccess, executionWorkspaceRepositories, executionWorkspaces, - heartbeatRuns, issues, projects, projectWorkspaces, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; + heartbeatRuns, issues, projects, projectWorkspaces, projectAccessMembers, companyMemberships, authUsers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js"; import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js"; import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js"; @@ -33,7 +33,7 @@ const support = await getEmbeddedPostgresTestSupport(); let db: ReturnType; let root: string; beforeAll(async () => { ({ db, root } = await context.setup()); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); - afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); }); + afterEach(() => { acquisition.materialize.mockReset(); vi.clearAllMocks(); vi.restoreAllMocks(); }); afterAll(context.cleanup); const { fixture, lowTrustPeer, standardPeer, privateTask, localPrivateClone } = context; @@ -334,14 +334,38 @@ const support = await getEmbeddedPostgresTestSupport(); expect(acquisition.materialize).not.toHaveBeenCalled(); }); - it.each(["sponsored", "local_board", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => { + it.each([ + { name: "missing task owner", patch: { sourceIssueId: null } }, + { name: "configured directory source", patch: { metadata: { source: "project_primary" } } }, + { name: "agent directory source", patch: { metadata: { source: "agent_home" } } }, + { name: "worktree strategy", patch: { strategyType: "git_worktree" } }, + { name: "adapter-managed mode", patch: { mode: "adapter_managed" } }, + { name: "configured root repository", patch: { repoUrl: "https://github.com/public/anchor" } }, + ])("keeps strict Git validation for $name", async ({ patch }) => { + const f = await fixture(); + await f.request({ kind: "url", url: "https://github.com/public/example" }); + await db.update(executionWorkspaces).set({ strategyType: "project_primary", sourceIssueId: f.issueId, + metadata: { source: "task_session" }, ...patch }).where(eq(executionWorkspaces.id, f.workspace.id)); + await mkdir(path.join(f.admission.cwd, ".git", "info"), { recursive: true }); + await writeFile(path.join(f.admission.cwd, ".git", "info", "exclude"), "/.paperclip-repositories/\n"); + await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow("not a git repository"); + expect(acquisition.materialize).not.toHaveBeenCalled(); + }); + + it.each(["sponsored", "sponsored_git_residue", "sponsored_shared_git_residue", "local_board", "local_board_jwt", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => { const f = await fixture(); await db.update(executionWorkspaces).set({ status: "idle" }).where(eq(executionWorkspaces.id, f.workspace.id)); - if (principal === "local_board") await db.update(heartbeatRuns).set({ responsibleUserId: "local-board" }).where(eq(heartbeatRuns.id, f.runId)); + const localBoard = principal === "local_board" || principal === "local_board_jwt"; + if (localBoard) await db.update(heartbeatRuns).set({ responsibleUserId: "local-board" }).where(eq(heartbeatRuns.id, f.runId)); + if (principal === "local_board_jwt") { + await db.insert(authUsers).values({ id: "local-board", name: "Local Board", email: "local-board@example.invalid", + emailVerified: true, createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing(); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: "local-board", membershipRole: "owner", status: "active" }); + } const receipt = await f.service.request({ companyId: f.companyId, issueId: f.issueId, - actor: principal === "local_board" ? { ...f.actor, onBehalfOfUserId: null } : f.actor, + actor: localBoard ? { ...f.actor, onBehalfOfUserId: principal === "local_board_jwt" ? "local-board" : null } : f.actor, request: { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: randomUUID() } }); - const responsibleUserId = principal === "local_board" ? "local-board" : principal === "routine" ? null : f.userId; + const responsibleUserId = localBoard ? "local-board" : principal === "routine" ? null : f.userId; await db.update(heartbeatRuns).set({ responsibleUserId }).where(eq(heartbeatRuns.id, f.runId)); const admission = { ...f.admission, responsibleUserId }; const source = path.join(root, `${f.issueId}-public-source`); @@ -360,6 +384,12 @@ const support = await getEmbeddedPostgresTestSupport(); await exec("git", ["-C", cwd, "remote", "set-url", "origin", input.repoUrl]); return result; }); + if (principal === "sponsored_git_residue" || principal === "sponsored_shared_git_residue") { + await db.update(executionWorkspaces).set({ strategyType: "project_primary", sourceIssueId: f.issueId, + mode: principal === "sponsored_shared_git_residue" ? "shared_workspace" : "isolated_workspace", metadata: { source: "task_session" } }).where(eq(executionWorkspaces.id, f.workspace.id)); + await mkdir(path.join(f.admission.cwd, ".git", "info"), { recursive: true }); + await writeFile(path.join(f.admission.cwd, ".git", "info", "exclude"), "/.paperclip-repositories/\n"); + } const [prepared] = await f.service.prepareForAdmission(admission); expect(prepared.id).toBe(receipt.operationId); expect(await readFile(path.join(prepared.cwd, "public.txt"), "utf8")).toBe("Public fixture bytes\n"); @@ -383,6 +413,14 @@ const support = await getEmbeddedPostgresTestSupport(); expect(acquisition.materialize).not.toHaveBeenCalled(); }); + it.each(["local-board", null, "changed-human"])("rejects the former sponsor after responsibility changes to %s", async responsibleUserId => { + const f = await fixture(); + await db.update(heartbeatRuns).set({ responsibleUserId }).where(eq(heartbeatRuns.id, f.runId)); + await expect(f.request({ kind: "url", url: "https://github.com/public/example" })).rejects.toThrow( + responsibleUserId === null ? "Repository access requires a responsible user" : "Task workspace responsible user changed"); + expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]); + }); + it("reauthorizes a public clone before publication after trust changes during Git", async () => { const f = await fixture(); const receipt = await f.request({ kind: "url", url: "https://github.com/public/example" }); diff --git a/server/src/__tests__/task-workspace-tools.test.ts b/server/src/__tests__/task-workspace-tools.test.ts index 40123b8df48..960a9e46577 100644 --- a/server/src/__tests__/task-workspace-tools.test.ts +++ b/server/src/__tests__/task-workspace-tools.test.ts @@ -21,7 +21,9 @@ describe("task workspace semantic transport", () => { const fetcher = vi.fn(async () => Response.json({ kind: "requires_next_admission" })); vi.stubGlobal("fetch", fetcher); const args = { repository: { kind: "catalog", id: "repo" }, requestKey: "acquire-1" }; - await callTaskWorkspaceTool({ ...base, name: "prepare_repository", arguments: args }); + const result = await callTaskWorkspaceTool({ ...base, name: "prepare_repository", arguments: args }); + expect(result.instruction).toContain('paperclip_finish with reportedWorkDisposition="yielded"'); + expect(result.instruction).toContain("do not call paperclip_block"); expect(fetcher.mock.calls[0]).toEqual(["https://paperclip.example/api/issues/task/workspace/repositories", expect.objectContaining({ method: "POST", body: JSON.stringify(args) })]); await expect(callTaskWorkspaceTool({ ...base, name: "prepare_repository", arguments: { ...args, cwd: "/home" } })).rejects.toThrow(); expect(fetcher).toHaveBeenCalledTimes(1); diff --git a/server/src/services/chat-monitor-continuation.test.ts b/server/src/services/chat-monitor-continuation.test.ts index aaf0a6c7314..01b094800e4 100644 --- a/server/src/services/chat-monitor-continuation.test.ts +++ b/server/src/services/chat-monitor-continuation.test.ts @@ -196,6 +196,15 @@ if (!support.supported) console.warn(`Monitor publication database unavailable: expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.source.id))).toHaveLength(1); }); + it("retains conversation provenance when the admitted dispatch owns the deferred outbox run binding", async () => { + const f = await continuedTaskFixture(); + await db.update(agentWakeupRequests).set({ runId: null }).where(eq(agentWakeupRequests.id, f.continuationWake.id)); + expect(await f.bindings()).toEqual(await f.bindings(f.source.id)); + expect(await f.bindings()).toHaveLength(1); + await db.update(agentWakeupRequests).set({ runId: f.source.id }).where(eq(agentWakeupRequests.id, f.continuationWake.id)); + expect(await f.bindings()).toEqual([]); + }); + it("selects the continued chat run's completed answer over an earlier bookkeeping comment", async () => { const f = await continuedTaskFixture(); const authorization = isChatOriginPublicationCandidate(f.successor.contextSnapshot) diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index c969962a5af..fae172296ff 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -319,7 +319,7 @@ export function executionWorkspaceRepositoryService(db: Db) { return candidatePin; } async function prepareForAdmission(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string; responsibleUserId: string | null }) { - const { acquisitionRestricted } = await publicationBinding(db, input, false); + const { acquisitionRestricted, workspace } = await publicationBinding(db, input, false); const rows = await list(input.companyId, input.workspaceId); if (!rows.length) return []; await publicationBinding(db, input); @@ -362,7 +362,11 @@ export function executionWorkspaceRepositoryService(db: Db) { await fs.writeFile(ignoreTemporary, "*\n", { flag: "wx", mode: 0o600 }); await fs.rename(ignoreTemporary, ignorePath); } finally { await fs.rm(ignoreTemporary, { force: true }); } - await measureRunPhase("workspace.repository.ignore_rules", () => ensureManagedRepositoriesIgnored(root)); + await measureRunPhase("workspace.repository.ignore_rules", () => ensureManagedRepositoriesIgnored(root, { + plainTaskDirectory: workspace.strategyType === "project_primary" && workspace.metadata?.source === "task_session" + && workspace.sourceIssueId === input.issueId && ["shared_workspace", "isolated_workspace"].includes(workspace.mode) + && !workspace.repoUrl && !workspace.projectId && !workspace.projectWorkspaceId, + })); } return stagingParent; }); diff --git a/server/src/services/heartbeat/queue.ts b/server/src/services/heartbeat/queue.ts index 73df5a8b722..a0ff4a0854a 100644 --- a/server/src/services/heartbeat/queue.ts +++ b/server/src/services/heartbeat/queue.ts @@ -4778,6 +4778,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende try { const wakeRun = await enqueueWakeup(candidate.agentId, { + allowRunCoalescing: false, source: candidate.source as WakeupOptions["source"], triggerDetail: (candidate.triggerDetail ?? "system") as WakeupOptions["triggerDetail"], diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 7ab1f9d78f1..b6fcedaab42 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -593,6 +593,41 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", expect((await readIssue(issue.id)).projectId).toBe(organization.id); }); + it("retains task-directory provenance through shared workspace preparation", async () => { + const { input, issue, source } = await fixture(); + await db.update(issues).set({ projectId: null, projectWorkspaceId: null, + workspaceSelection: { version: 1, source: "explicit", selection: { kind: "task_directory" } }, + }).where(eq(issues.id, issue.id)); + input.task.issueRef = await readIssue(issue.id); + input.task.context = { issueId: issue.id }; + const first = await prepareHeartbeatWorkspace(db, input); + expect(first.executionWorkspace.source).toBe("task_session"); + expect(first.persistedExecutionWorkspace).toMatchObject({ mode: "shared_workspace", strategyType: "project_primary", + sourceIssueId: issue.id, projectId: null, projectWorkspaceId: null, repoUrl: null, metadata: { source: "task_session" } }); + await mkdir(join(first.executionWorkspace.cwd, ".git", "info"), { recursive: true }); + await writeFile(join(first.executionWorkspace.cwd, ".git", "info", "exclude"), "/.paperclip-repositories/\n"); + await writeFile(join(first.executionWorkspace.cwd, "original-context.txt"), "Preserve the original task bytes\n"); + await db.update(issues).set({ executionRunId: input.run.id }).where(eq(issues.id, issue.id)); + const taskRepositories = executionWorkspaceService(db); + const receipt = await taskRepositories.requestTaskRepository({ companyId: issue.companyId, issueId: issue.id, + actor: { type: "board", source: "local_implicit", userId: "local-board" }, + request: { repository: { kind: "url", url: "https://github.com/public/source" }, requestKey: "retained-task-directory" } }); + const materialize = managedRepositoryCheckout.materializeManagedProjectWorkspace; + vi.spyOn(managedRepositoryCheckout, "materializeManagedProjectWorkspace").mockImplementation((cwd, options) => + materialize(cwd, { ...options, localSource: source })); + await reuse(input, first.persistedExecutionWorkspace!); + await db.update(issues).set({ executionRunId: input.run.id }).where(eq(issues.id, issue.id)); + input.task.issueRef = await readIssue(issue.id); + const next = await prepareHeartbeatWorkspace(db, input); + expect(next.executionWorkspace.source).toBe("task_session"); + expect(next.persistedExecutionWorkspace!.metadata?.source).toBe("task_session"); + expect(next.persistedExecutionWorkspace!.id).toBe(first.persistedExecutionWorkspace!.id); + expect(await readFile(join(next.executionWorkspace.cwd, "original-context.txt"), "utf8")).toBe("Preserve the original task bytes\n"); + const [repository] = await taskRepositories.listTaskRepositories(issue.companyId, next.persistedExecutionWorkspace!.id); + expect(repository).toMatchObject({ id: receipt.operationId, state: "ready" }); + expect(await readFile(join(next.executionWorkspace.cwd, repository.relativePath, "README.md"), "utf8")).toBe("source content\n"); + }); + it("creates a real isolated worktree and reuses its recorded workspace", async () => { const { input, source, issue } = await fixture(true); const first = await prepareHeartbeatWorkspace(db, input); diff --git a/server/src/services/managed-repository-checkout.ts b/server/src/services/managed-repository-checkout.ts index 3d5dd027c0f..3d18bb49769 100644 --- a/server/src/services/managed-repository-checkout.ts +++ b/server/src/services/managed-repository-checkout.ts @@ -162,12 +162,23 @@ export async function materializeManagedProjectWorkspace( } -export async function ensureManagedRepositoriesIgnored(cwd: string): Promise { +export async function ensureManagedRepositoriesIgnored(cwd: string, options: { plainTaskDirectory?: boolean } = {}): Promise { const gitEntry = await fs.lstat(path.join(cwd, ".git")).catch(() => null); if (!gitEntry) return; if (gitEntry.isSymbolicLink()) throw new Error("Workspace Git metadata must not be a symlink"); - const excludePath = await execFile("git", ["-C", cwd, "rev-parse", "--git-path", "info/exclude"], { timeout: 10_000 }) - .then((result) => path.resolve(cwd, result.stdout.trim())); + let excludePath: string; + try { + const result = await execFile("git", ["-C", cwd, "rev-parse", "--git-path", "info/exclude"], { + timeout: 10_000, env: { ...process.env, LC_ALL: "C" }, + }); + excludePath = path.resolve(cwd, result.stdout.trim()); + } catch (error) { + const failure = error as { code?: unknown; signal?: unknown; stderr?: unknown }; + if (options.plainTaskDirectory && failure.code === 128 && failure.signal == null && + failure.stderr === "fatal: not a git repository (or any of the parent directories): .git\n" && + !process.env.GIT_DIR && !process.env.GIT_WORK_TREE && !process.env.GIT_COMMON_DIR) return; + throw error; + } const exclude = await fs.readFile(excludePath, "utf8").catch(() => ""); if (!exclude.split(/\r?\n/).includes(`/${PROJECT_REPOSITORIES_DIR}/`)) { await fs.mkdir(path.dirname(excludePath), { recursive: true }); diff --git a/server/src/services/native-runtime/native-completion-feedback.test.ts b/server/src/services/native-runtime/native-completion-feedback.test.ts index d49a5cc1339..04452de5cf9 100644 --- a/server/src/services/native-runtime/native-completion-feedback.test.ts +++ b/server/src/services/native-runtime/native-completion-feedback.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { agents, companies, createDb, documentRevisions, heartbeatRuns, issues, issueThreadInteractions, issueDocuments } from "@paperclipai/db"; +import { agents, companies, createDb, documentRevisions, executionWorkspaces, executionWorkspaceRepositories, heartbeatRuns, issues, issueThreadInteractions, issueTreeHolds, issueDocuments } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; import { documentService } from "../documents.js"; @@ -212,6 +212,78 @@ describe("native final-response feedback", () => { const feedback = await nativeCompletionFeedback(db, current.runId, done); expect(feedback).not.toContain("#document-"); expect(feedback).not.toContain("foreign.example"); }); + it("requires a real response wait when an unfinished objective omits remaining work", async () => { + const value = await fixture(); + const report: PrpStructuredRunResult = { ...done, reportedWorkDisposition: "yielded", + completionClaim: { ...done.completionClaim, objectiveSatisfied: false, remainingWork: [], + criteria: [{ criterionId: "objective", status: "not_satisfied", evidenceRefs: [] }] }, + continuation: { kind: "response_wake", summary: "Repository preparation needs the next admission", idempotencyKey: "repository-admission" }, + }; + await expect(nativeCompletionFeedback(db, value.runId, report)).rejects.toThrow("without a pending wait condition"); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId: value.companyId, sourceIssueId: value.issueId, + mode: "shared_workspace", strategyType: "project_primary", metadata: { source: "task_session" }, name: "Task files" }).returning(); + await db.insert(executionWorkspaceRepositories).values({ companyId: value.companyId, executionWorkspaceId: workspace.id, + requestedByIssueId: value.issueId, repositoryIdentity: "public/example", repoUrl: "https://github.com/public/example", + relativePath: ".paperclip-repositories/example", requestKey: "prepare" }); + await db.update(issues).set({ executionWorkspaceId: workspace.id }).where(eq(issues.id, value.issueId)); + await expect(nativeCompletionFeedback(db, value.runId, report)).rejects.toThrow("omit continuation (or use null)"); + const [hold] = await db.insert(issueTreeHolds).values({ companyId: value.companyId, rootIssueId: value.issueId, mode: "pause" }).returning(); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("task is paused"); + await db.update(issueTreeHolds).set({ status: "released" }).where(eq(issueTreeHolds.id, hold.id)); + const [question] = await db.insert(issueThreadInteractions).values({ companyId: value.companyId, issueId: value.issueId, + sourceRunId: value.runId, createdByAgentId: value.agentId, kind: "ask_user_questions", status: "pending", continuationPolicy: "wake_assignee", + payload: { version: 1, questions: [{ id: "branch", prompt: "Which private branch?", selectionMode: "single", required: true, allowOther: true, options: [] }] } }).returning(); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain(`Pending request: ${question.id}`); + }); + it("corrects a repository-admission blocker before accepting the result", async () => { + const value = await fixture(); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId: value.companyId, + sourceIssueId: value.issueId, mode: "isolated", strategyType: "task_directory", name: "Task files" }).returning(); + const [repository] = await db.insert(executionWorkspaceRepositories).values({ companyId: value.companyId, + executionWorkspaceId: workspace.id, requestedByIssueId: value.issueId, repositoryIdentity: "public/example", + repoUrl: "https://github.com/public/example", relativePath: ".paperclip-repositories/example", requestKey: "prepare" }).returning(); + await db.update(issues).set({ executionWorkspaceId: workspace.id }).where(eq(issues.id, value.issueId)); + const report: PrpStructuredRunResult = { ...done, reportedWorkDisposition: "blocked", + completionClaim: { ...done.completionClaim, objectiveSatisfied: false, + remainingWork: [{ description: "Read the prepared checkout and deliver the result", blocksCompletion: true }] }, + evidence: [{ ref: `repository-preparation:${repository.id}` }], + blocker: { reasonCode: "repository_checkout_pending", owner: { kind: "system", name: "Workspace admission" }, + scope: "current_track", unblockAction: "Start the next normal admission" }, + }; + await expect(nativeCompletionFeedback(db, value.runId, report)).rejects.toThrow('paperclip_finish with reportedWorkDisposition="yielded"'); + await expect(nativeCompletionFeedback(db, value.runId, { ...report, completionClaim: { ...report.completionClaim, remainingWork: [] } })) + .rejects.toThrow('paperclip_finish with reportedWorkDisposition="yielded"'); + await expect(nativeCompletionFeedback(db, value.runId, { ...report, completionClaim: { ...report.completionClaim, objectiveSatisfied: true } })) + .rejects.toThrow('paperclip_finish with reportedWorkDisposition="yielded"'); + await expect(nativeCompletionFeedback(db, value.runId, { ...report, evidence: [{ ref: `repository-preparation:${randomUUID()}` }] })) + .resolves.toContain("Blocker report accepted"); + await expect(nativeCompletionFeedback(db, value.runId, { ...report, blocker: { ...report.blocker!, reasonCode: "missing_access" } })) + .resolves.toContain("Blocker report accepted"); + await expect(nativeCompletionFeedback(db, value.runId, { ...report, blocker: { ...report.blocker!, owner: { kind: "user", name: "Owner" } } })) + .resolves.toContain("Blocker report accepted"); + const [hold] = await db.insert(issueTreeHolds).values({ companyId: value.companyId, rootIssueId: value.issueId, mode: "pause" }).returning(); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("Blocker report accepted"); + await db.update(issueTreeHolds).set({ status: "released" }).where(eq(issueTreeHolds.id, hold.id)); + const [question] = await db.insert(issueThreadInteractions).values({ companyId: value.companyId, + issueId: value.issueId, sourceRunId: value.runId, createdByAgentId: value.agentId, + kind: "ask_user_questions", status: "pending", continuationPolicy: "wake_assignee", + payload: { version: 1, questions: [{ id: "branch", prompt: "Which private branch?", selectionMode: "single", required: true, allowOther: true, options: [] }] } }).returning(); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain(`Pending request: ${question.id}`); + await db.update(issueThreadInteractions).set({ status: "answered" }).where(eq(issueThreadInteractions.id, question.id)); + await db.update(executionWorkspaceRepositories).set({ requestedByIssueId: null }).where(eq(executionWorkspaceRepositories.id, repository.id)); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("Blocker report accepted"); + await db.update(executionWorkspaceRepositories).set({ requestedByIssueId: value.issueId, state: "failed" }).where(eq(executionWorkspaceRepositories.id, repository.id)); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("Blocker report accepted"); + await db.update(executionWorkspaceRepositories).set({ state: "pending" }).where(eq(executionWorkspaceRepositories.id, repository.id)); + await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, value.issueId)); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("Blocker report accepted"); + await db.update(issues).set({ executionWorkspaceId: workspace.id, status: "cancelled" }).where(eq(issues.id, value.issueId)); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("already cancelled"); + const newerRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: newerRunId, companyId: value.companyId, agentId: value.agentId, nativeIssueId: value.issueId, status: "running", runtimeMode: "native" }); + await db.update(issues).set({ status: "in_progress", executionRunId: newerRunId }).where(eq(issues.id, value.issueId)); + await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("newer run owns"); + }); it("asks a blocked provider to explain the cause and action instead of describing completed work", async () => { const value = await fixture(); const feedback = await nativeCompletionFeedback(db, value.runId, { ...done, reportedWorkDisposition: "blocked", diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index 6d259bd644c..f69ecdb2d71 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -1,3 +1,4 @@ +import { pendingTaskRepository } from "../task-workspace-tools.js"; import { eligibleIssueMonitorWait } from "../issue-monitors.js"; import { assertNativeCaseHandoffResult } from "./native-case-handoff.js"; import { activeIssueInteractionCondition, ordinaryQuestionCondition } from "../issue-question-context.js"; @@ -26,6 +27,8 @@ import { type PrpStructuredRunResult, } from "../../vendor/paperclip-runner/index.js"; +const repositoryYieldCorrection = 'This task has a saved repository preparation for its next admission. This is routine setup, not a blocker. Call paperclip_finish with reportedWorkDisposition="yielded" and omit continuation (or use null); Paperclip will select the saved workspace continuation. Preserve the completed work and explain what remains. Do not repeat setup, set a timer, or ask the user to restart.'; + function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; @@ -236,6 +239,23 @@ export async function nativeCompletionFeedback( if (readiness.unresolvedBlockerCount > 0) { return `Completion report accepted; this task still has unresolved dependencies. Explain the blockers on [this task](/issues/${issue.identifier ?? issue.id}); do not say the task is done.`; } + const unfinishedWork = !result.completionClaim.objectiveSatisfied || result.completionClaim.remainingWork.some(work => work.blocksCompletion); + if (result.reportedWorkDisposition === "blocked" && result.blocker?.owner.kind === "system" && + result.blocker.scope === "current_track" && result.blocker.reasonCode === "repository_checkout_pending" && + unfinishedWork && + signals.actionableAttentionRequests.length === 0 && issue.workMode === "standard" && + issue.executionRunId === run.id && run.status === "running") { + const repositoryIds = result.evidence.flatMap(evidence => { + if (typeof evidence.ref !== "string") return []; + const match = /^repository-preparation:([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})$/i.exec(evidence.ref); + return match ? [match[1]!] : []; + }); + const repository = await pendingTaskRepository(db, { companyId: run.companyId, issueId: issue.id, workspaceId: issue.executionWorkspaceId, repositoryIds }); + if (repository && + !(await issueTreeControlService(db).getActivePauseHoldGate(run.companyId, issue.id))) { + throw new Error(repositoryYieldCorrection); + } + } if (result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "monitor") { return `Monitor wait accepted. The task remains active and Paperclip will wake its assignee at or after ${issue.monitorNextCheckAt!.toISOString()} with issue_monitor_due. End this turn; do not poll or mark the task done.`; } @@ -243,7 +263,7 @@ export async function nativeCompletionFeedback( !isConversation(issue) && result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "response_wake" && - result.completionClaim.remainingWork.some((entry) => entry.blocksCompletion) && + unfinishedWork && signals.actionableAttentionRequests.length === 0 ) { const pause = await issueTreeControlService(db).getActivePauseHoldGate( @@ -258,8 +278,10 @@ export async function nativeCompletionFeedback( }); // Conversation turns and revoked chat authority retain their own lifecycle. if (chatWait === "not_applicable") { + const repository = await pendingTaskRepository(db, { companyId: run.companyId, issueId: issue.id, workspaceId: issue.executionWorkspaceId }); + if (repository) throw new Error(repositoryYieldCorrection); throw new Error( - "The response_wake report includes blocking remaining work without a pending wait condition. Answering a user comment does not pause the task. Continue the authorized work, register a same_agent continuation, or report the concrete blocker or required question. Do not repeat completed work.", + "The response_wake report includes an unfinished objective or blocking remaining work without a pending wait condition. Answering a user comment does not pause the task. Continue the authorized work, or report the concrete blocker or required question. Do not repeat completed work.", ); } } diff --git a/server/src/services/native-runtime/native-continuation-delivery.ts b/server/src/services/native-runtime/native-continuation-delivery.ts index 72595082530..ee468b9dc5d 100644 --- a/server/src/services/native-runtime/native-continuation-delivery.ts +++ b/server/src/services/native-runtime/native-continuation-delivery.ts @@ -1,5 +1,5 @@ import { agentWakeupRequests, heartbeatRuns, statusDecisions, statusDecisionEffects, type Db } from "@paperclipai/db"; -import { and, eq, inArray, ne, sql } from "drizzle-orm"; +import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { boundRunIssueIdSql } from "../run-task-binding.js"; @@ -14,7 +14,7 @@ export async function readNativeContinuationSource( const sources = await db.select({ runId: source.id }).from(heartbeatRuns) .innerJoin(agentWakeupRequests, and( eq(agentWakeupRequests.companyId, binding.companyId), - eq(agentWakeupRequests.runId, heartbeatRuns.id), + or(isNull(agentWakeupRequests.runId), eq(agentWakeupRequests.runId, heartbeatRuns.id)), eq(agentWakeupRequests.agentId, heartbeatRuns.agentId), eq(agentWakeupRequests.reason, "issue_status_changed"), eq(agentWakeupRequests.status, "coalesced"), diff --git a/server/src/services/task-repository-acquisition.ts b/server/src/services/task-repository-acquisition.ts index 71c0e07e98b..b39f2fcbbe2 100644 --- a/server/src/services/task-repository-acquisition.ts +++ b/server/src/services/task-repository-acquisition.ts @@ -19,6 +19,10 @@ export const taskRepositoryRunColumns = { }; type TaskRepositoryRunIdentity = Pick; +export function taskRepositoryResponsibleUserMatches(actorUserId: string | null | undefined, runUserId: string | null) { + return (actorUserId ?? null) === runUserId || (runUserId === "local-board" && actorUserId == null); +} + // A wake for another task can coalesce into a running run and replace its context // task after admission. The immutable task binding still identifies the admitted work. export function runRetainsIssueIdentity(run: TaskRepositoryRunIdentity, issueId: string) { @@ -85,8 +89,7 @@ export async function assertSelectedWorkspaceTrust(db: Db | DbTransaction, input if (selected.kind !== "low_trust_review" || current.kind !== "low_trust_review" || !isDeepStrictEqual(selected.boundary, current.boundary)) throw incompatible(); if (creation) { - const sponsor = run!.responsibleUserId === "local-board" ? null : run!.responsibleUserId; - if ((input.actor.onBehalfOfUserId ?? null) !== sponsor) throw incompatible(); + if (!taskRepositoryResponsibleUserMatches(input.actor.onBehalfOfUserId, run!.responsibleUserId)) throw incompatible(); let parentId = creation.parentIssueId; const ancestors: Array = []; for (let depth = 0; parentId && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth++) { diff --git a/server/src/services/task-workspace-access.ts b/server/src/services/task-workspace-access.ts index 609d85a450b..b49ba9d9432 100644 --- a/server/src/services/task-workspace-access.ts +++ b/server/src/services/task-workspace-access.ts @@ -5,7 +5,7 @@ import { forbidden } from "../errors.js"; import { accessService } from "./access.js"; import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js"; import { canonicalExecutionUuid } from "./execution-identity.js"; -import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns } from "./task-repository-acquisition.js"; +import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns, taskRepositoryResponsibleUserMatches } from "./task-repository-acquisition.js"; import { resolveCoreTrustPreset } from "./trust-preset-resolver.js"; import { isDeepStrictEqual } from "node:util"; import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared"; @@ -78,7 +78,7 @@ export async function assertTaskWorkspaceExecutionAccess( || run.agentId !== actor.agentId || !runRetainsIssueIdentity(run, task.id)))) { throw forbidden("Task workspace run identity is unavailable"); } - if (run && (actor.onBehalfOfUserId ?? null) !== (run.responsibleUserId === "local-board" ? null : run.responsibleUserId)) { + if (run && !taskRepositoryResponsibleUserMatches(actor.onBehalfOfUserId, run.responsibleUserId)) { throw forbidden("Task workspace responsible user changed"); } const decision = await measureRunPhase("workspace.access.task_authorization", () => access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: task.id } })); diff --git a/server/src/services/task-workspace-tools.ts b/server/src/services/task-workspace-tools.ts index 1ca584d327b..b9a8abfdc09 100644 --- a/server/src/services/task-workspace-tools.ts +++ b/server/src/services/task-workspace-tools.ts @@ -1,18 +1,27 @@ import { prepareWorkspaceRepositorySchema, selectTaskWorkspaceSchema } from "@paperclipai/shared"; import { badRequest } from "../errors.js"; import { executionWorkspaceRepositories, type Db } from "@paperclipai/db"; -import { and, asc, eq } from "drizzle-orm"; +import { and, asc, eq, inArray } from "drizzle-orm"; -export async function pendingTaskRepositoryContinuation(db: Pick, input: { - companyId: string; issueId: string; runId: string; workspaceId: string | null; +export async function pendingTaskRepository(db: Pick, input: { + companyId: string; issueId: string; workspaceId: string | null; repositoryIds?: string[]; }) { + if (input.repositoryIds?.length === 0) return null; if (!input.workspaceId) return null; const [repository] = await db.select({ id: executionWorkspaceRepositories.id }).from(executionWorkspaceRepositories) .where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaceRepositories.executionWorkspaceId, input.workspaceId), eq(executionWorkspaceRepositories.requestedByIssueId, input.issueId), - eq(executionWorkspaceRepositories.state, "pending"))) + eq(executionWorkspaceRepositories.state, "pending"), + ...(input.repositoryIds ? [inArray(executionWorkspaceRepositories.id, input.repositoryIds)] : []))) .orderBy(asc(executionWorkspaceRepositories.createdAt), asc(executionWorkspaceRepositories.id)).limit(1); + return repository ?? null; +} + +export async function pendingTaskRepositoryContinuation(db: Pick, input: { + companyId: string; issueId: string; runId: string; workspaceId: string | null; +}) { + const repository = await pendingTaskRepository(db, input); return repository ? { kind: "same_agent" as const, summary: "Continue with the prepared task repository.", idempotencyKey: `task-repository:${input.issueId}:${repository.id}:${input.runId}` } : null; } @@ -39,7 +48,7 @@ export async function callTaskWorkspaceTool(input: { name: string; arguments: Re if (input.name === "prepare_repository") { const { operationId, ...rest } = result; return { ...rest, ...(result.repository ? { repository: repositoryView(result.repository) } : {}), preparationId: operationId, - ...(result.kind === "requires_next_admission" ? { instruction: "The repository is staged for the next admission. If unfinished work needs this checkout, finish independent work and yield. Paperclip will select the saved workspace continuation; no timer or user restart is needed." } : {}), + ...(result.kind === "requires_next_admission" ? { instruction: "The repository is staged for the next admission. If unfinished work needs this checkout, finish independent work, then call paperclip_finish with reportedWorkDisposition=\"yielded\" and omit continuation (or use null). Paperclip will select the saved workspace continuation. Repository admission is routine setup; do not call paperclip_block or ask the user to restart. No timer is needed." } : {}), }; } if (input.name === "get_workspace") { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 737dddedc68..2ff23d19561 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -3765,6 +3765,17 @@ export async function realizeExecutionWorkspace(input: { }; } +function isPersistedTaskDirectory(workspace: { + strategyType: string | null | undefined; + projectId: string | null | undefined; + projectWorkspaceId: string | null | undefined; + repoUrl: string | null | undefined; + metadata?: Record | null; +}): boolean { + return workspace.strategyType === "project_primary" && workspace.metadata?.source === "task_session" + && workspace.projectId === null && workspace.projectWorkspaceId === null && workspace.repoUrl === null; +} + export async function ensurePersistedExecutionWorkspaceAvailable(input: { prepareRestoredLocalSource?: (sourceCwd: string) => Promise<() => Promise>; persistContentsRestoredAt?: (at: string | null) => Promise; @@ -3802,13 +3813,14 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { if (!cwd) return null; const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary"; + const taskDirectory = isPersistedTaskDirectory(input.workspace); const realized: RealizedExecutionWorkspace = { baseCwd: input.base.baseCwd, - source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session", + source: input.workspace.mode === "shared_workspace" && !taskDirectory ? "project_primary" : "task_session", projectId: input.workspace.projectId === undefined ? input.base.projectId : input.workspace.projectId, workspaceId: input.workspace.projectWorkspaceId === undefined ? input.base.workspaceId : input.workspace.projectWorkspaceId, - repoUrl: input.workspace.repoUrl ?? input.base.repoUrl, - repoRef: input.workspace.baseRef ?? input.base.repoRef, + repoUrl: taskDirectory ? null : input.workspace.repoUrl ?? input.base.repoUrl, + repoRef: taskDirectory ? null : input.workspace.baseRef ?? input.base.repoRef, additionalWorkspaces: input.base.additionalWorkspaces ?? [], strategy, cwd, @@ -8893,7 +8905,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { : null, workspace: { baseCwd: row.cwd, - source: row.mode === "shared_workspace" ? "project_primary" : "task_session", + source: row.mode === "shared_workspace" && !isPersistedTaskDirectory(row) ? "project_primary" : "task_session", projectId: row.projectId, workspaceId: row.projectWorkspaceId ?? null, repoUrl: row.repoUrl ?? null, diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 1ba9e2a9273..7173130bd16 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1,5 +1,32 @@ # Paid runner full-stack E2E +## Automatic repository continuation + +The explicit-only `automatic-repository` suite is an early repair check for +native Codex and Claude on local and Daytona execution. One browser request +must stage its managed repository, continue without a timer or another user +message, and deliver a registered download. The oracle compares downloaded +JSON with the actual checkout and the original task file. The source admission +creates an unpredictable reference and registers its bytes before setup. The +observer must download that source-attributed artifact while only the first +admission is running, before the successor starts. A missing original, late +observation, or different successor-created reference fails qualification. The +successor is instructed to stop on a missing file and never recreate it. Both admissions +must retain one workspace and commit their native results. The successor must +have its own dispatch receipt and saved native intent. It starts after the +predecessor finishes; dispatch must not retag the predecessor. The fixture uses +production hire instructions and records the selected catalog model. These four +development cells do not qualify Slack, staging configurations, migration, +finalization restart, or the complete dependable-work milestone. + +Select it explicitly with `--suite automatic-repository`. Every execution has +one attempt, two expected provider admissions, and 1,000-cent company and agent +hard stops. Automatic infrastructure retries are disabled. Existing campaign +reports retain failures, timings, usage, runtime costs, and source identity. +Source-server startup prepares the existing shared/plugin SDK build dependencies +before importing native tool authority. Installed-package checks never execute +checkout build helpers. + ## Latency observation bounds The explicit `codie-latency-diagnostic` arms its observation recorder before diff --git a/tests/runner-e2e/automatic-repository-flow.ts b/tests/runner-e2e/automatic-repository-flow.ts new file mode 100644 index 00000000000..c676b7345a8 --- /dev/null +++ b/tests/runner-e2e/automatic-repository-flow.ts @@ -0,0 +1,128 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import { realpath } from "node:fs/promises"; +import path from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { pollUntil, type RunnerApi } from "./api.js"; +import { createTaskThroughUi } from "./user-actions.js"; +import { readRegisteredArtifacts } from "./registered-artifact.js"; +import { automaticRepositoryFiles, automaticRepositoryStopReason, gradeAutomaticRepository, type AutomaticRepositoryObservation } from "./automatic-repository.js"; +import type { LiveFixtureValues } from "./live-fixtures.js"; +import type { MatrixExecution } from "./types.js"; + +type Row = Record; +const execFile = promisify(execFileCallback); + +export async function runAutomaticRepositoryFlow(input: { + page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; + temporaryRoot: string; deadlineAt: number; + observe(issue: Row, runs: Row[], checks: ReturnType): void; + capture(id: string, label: string, file: string): Promise; + evidence(name: string, value: unknown): Promise; +}) { + const { page, api, fixtures, execution, nonce } = input; + const files = automaticRepositoryFiles(nonce); + let issue: Row | undefined; + let runs: Row[] = []; + const observation: AutomaticRepositoryObservation = { reference: "", issueDone: false, runCount: 0, + successfulCommittedRuns: 0, sourceContinuation: null, successorSource: null, workspaceIds: [], readyRepository: false, + savedContext: null, firstSavedContext: null, firstContextAttributed: false, + firstContextObservedAt: null, successorStartedAt: null, predecessorFinishedAt: null, predecessorRetagged: true, continuationReceipt: null, + actualReadme: null, actualCommit: null, downloaded: null, downloadVerified: false, + visibleDelivery: false, pendingInteractions: -1, monitorScheduled: false }; + async function refresh() { + if (!issue) return; + issue = await api.get(`/api/issues/${issue.id}`); + const listed = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`); + runs = (await Promise.all(listed.map(row => api.get(`/api/heartbeat-runs/${row.id}`)))) + .filter(row => row.agentId === fixtures.agent.id && [row.nativeIssueId, row.issueId, row.contextSnapshot?.issueId, row.contextSnapshot?.taskId].includes(issue!.id)) + .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)) || a.id.localeCompare(b.id)); + observation.issueDone = issue.status === "done"; + observation.runCount = runs.length; + observation.successfulCommittedRuns = runs.filter(run => run.runtimeMode === "native" && run.status === "succeeded" && + run.nativePhase === "committed" && run.resultJson?.finalizationPhase === "committed").length; + observation.sourceContinuation = runs[0]?.resultJson?.nativeResult?.continuation?.kind ?? null; + observation.successorSource = runs[1]?.contextSnapshot?.source ?? null; + observation.successorStartedAt = runs[1]?.startedAt ? Date.parse(runs[1].startedAt) : null; + observation.predecessorFinishedAt = runs[0]?.finishedAt ? Date.parse(runs[0].finishedAt) : null; + observation.predecessorRetagged = Boolean(runs[0]?.contextSnapshot?.nativeStatusWakeIntentId); + const intentId = runs[1]?.contextSnapshot?.nativeStatusWakeIntentId, dispatchId = runs[1]?.wakeupRequestId; + observation.continuationReceipt = typeof intentId === "string" && typeof dispatchId === "string" ? { intentId, dispatchId } : null; + if (runs.length === 1 && runs[0]?.status === "running" && observation.firstContextObservedAt === null) { + const original = await readRegisteredArtifacts(api, issue.id, runs[0].id, [files.context]).catch(() => null); + if (original?.[0]) { + observation.firstSavedContext = original[0].content; + observation.firstContextAttributed = original[0].contentVerified; + observation.firstContextObservedAt = Date.now(); + } + } + observation.workspaceIds = runs.map(run => run.runnerProfileJson?.nativeExecutionInput?.binding?.executionWorkspaceId ?? null); + observation.monitorScheduled ||= Boolean(issue.monitorNextCheckAt || runs.some(run => String(run.resultJson?.finalizationReasonCode).startsWith("scheduled_monitor_"))); + } + async function save(phase: string) { + const checks = gradeAutomaticRepository(observation); + if (issue) input.observe(issue, runs, checks); + await input.evidence("automatic-repository.json", { schema: "paperclip.e2e.automatic-repository.v1", phase, observation, checks, + runs: runs.map(run => ({ id: run.id, status: run.status, nativePhase: run.nativePhase, source: run.contextSnapshot?.source, + finalizationReason: run.resultJson?.finalizationReasonCode, startedAt: run.startedAt, finishedAt: run.finishedAt })) }); + } + try { + if (fixtures.project || execution.profile.generation !== "native") throw new Error("Automatic repository requires a projectless native fixture"); + const agent = await api.get(`/api/agents/${fixtures.agent.id}`); + if (agent.adapterConfig?.cwd) throw new Error("Automatic repository must not inherit an operator cwd"); + await api.patch(`/api/companies/${fixtures.company.id}/budgets`, { budgetMonthlyCents: 1000 }); + await api.patch(`/api/agents/${fixtures.agent.id}/budgets`, { budgetMonthlyCents: 1000 }); + const companyBudget = await api.get(`/api/companies/${fixtures.company.id}`); + const agentBudget = await api.get(`/api/agents/${fixtures.agent.id}`); + if (companyBudget.budgetMonthlyCents !== 1000 || agentBudget.budgetMonthlyCents !== 1000) + throw new Error("Automatic repository hard-stop budgets were not retained"); + const created = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, + title: execution.task.buildTitle(nonce), prompt: execution.task.buildPrompt(nonce), workMode: "standard", requireExplicitTitle: true }); + issue = await api.get(`/api/issues/${created.issueId}`); + await page.goto(`/${encodeURIComponent(fixtures.company.issuePrefix!)}/issues/${encodeURIComponent(issue.identifier ?? issue.id)}`); + await pollUntil({ label: "repository setup and automatic result delivery", deadlineAt: input.deadlineAt, intervalMs: 750, + load: async () => { await refresh(); return { issue, runs }; }, + accept: () => observation.issueDone && observation.successfulCommittedRuns === 2, + reject: state => state.issue ? automaticRepositoryStopReason({ status: state.issue.status }, state.runs.map(run => ({ status: run.status }))) : undefined }); + const view = await api.get(`/api/issues/${issue.id}/workspace`); + const cwd = await realpath(view.workspace.cwd), isolated = await realpath(input.temporaryRoot); + if (!cwd.startsWith(`${isolated}${path.sep}`) || !cwd.endsWith(`/isolated-workspaces/${fixtures.company.id}/${issue.id}`)) + throw new Error("Repository oracle requires the confined task directory"); + const repositories = view.repositories.filter((repo: Row) => repo.state === "ready" && repo.repoUrl === "https://github.com/octocat/Hello-World"); + if (repositories.length !== 1 || !/^\.paperclip-repositories\/task-repo-[a-f0-9]{24}$/.test(repositories[0].relativePath)) + throw new Error("Expected one contained prepared repository"); + const checkout = await realpath(path.join(cwd, repositories[0].relativePath)); + if (!checkout.startsWith(`${cwd}${path.sep}`)) throw new Error("Prepared repository escaped the task directory"); + const git = async (args: string[]) => (await execFile("git", ["-C", checkout, ...args], { + env: { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0" }, + timeout: 20_000, maxBuffer: 262_144, encoding: "utf8", + })).stdout; + observation.actualCommit = (await git(["rev-parse", "HEAD"])).trim(); + observation.actualReadme = await git(["show", "HEAD:README"]); + observation.readyRepository = repositories[0].pinnedCommit === observation.actualCommit; + const [original] = await readRegisteredArtifacts(api, issue.id, runs[0]!.id, [files.context]); + observation.firstContextAttributed &&= original.contentVerified && original.content === observation.firstSavedContext; + if (/^[a-f0-9]{64}\n$/.test(original.content)) observation.reference = original.content.trimEnd(); + const context = await api.get(`/api/issues/${issue.id}/file-resources/content?workspace=execution&path=${encodeURIComponent(files.context)}`); + observation.savedContext = context.resource?.workspaceKind === "execution_workspace" && typeof context.content?.data === "string" ? context.content.data : null; + const [artifact] = await readRegisteredArtifacts(api, issue.id, runs[1]!.id, [files.result]); + observation.downloaded = JSON.parse(artifact.content); + observation.downloadVerified = artifact.contentVerified; + observation.pendingInteractions = (await api.get(`/api/issues/${issue.id}/interactions`)).filter(row => row.status === "pending").length; + await page.reload(); + await expect(page.getByTestId("task-chat-thread-header").getByTestId("issue-detail-header")).toBeVisible(); + const delivery = page.locator(`a[href*="${artifact.attachmentId}"]`).first(); + await expect(delivery).toBeVisible({ timeout: 30_000 }); + observation.visibleDelivery = true; + await save("completed"); + await input.capture("final-state", "Automatically resumed repository result", "final-state.png"); + const checks = gradeAutomaticRepository(observation); + const failed = checks.filter(check => !check.passed); + if (failed.length) throw new Error(`Automatic repository oracle failure: ${failed.map(check => check.id).join(", ")}`); + return { issue, runs, checks }; + } catch (error) { + await refresh().catch(() => undefined); + await save("failed"); + throw error; + } +} diff --git a/tests/runner-e2e/automatic-repository.test.ts b/tests/runner-e2e/automatic-repository.test.ts new file mode 100644 index 00000000000..4657a5d3598 --- /dev/null +++ b/tests/runner-e2e/automatic-repository.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from "vitest"; +import { automaticRepositoryStopReason, gradeAutomaticRepository, type AutomaticRepositoryObservation } from "./automatic-repository.js"; + +const sha = "a".repeat(40); +const reference = "c".repeat(64); +const valid: AutomaticRepositoryObservation = { reference, issueDone: true, runCount: 2, + successfulCommittedRuns: 2, sourceContinuation: "same_agent", successorSource: "native_status_decision", workspaceIds: ["workspace", "workspace"], + readyRepository: true, savedContext: `${reference}\n`, firstSavedContext: `${reference}\n`, firstContextAttributed: true, + firstContextObservedAt: 1000, successorStartedAt: 2000, predecessorFinishedAt: 1900, predecessorRetagged: false, + continuationReceipt: { intentId: "11111111-1111-4111-8111-111111111111", dispatchId: "22222222-2222-4222-8222-222222222222" }, + actualReadme: "Hello World!\n", actualCommit: sha, + downloaded: { reference, readme: "Hello World!\n", commit: sha }, downloadVerified: true, + visibleDelivery: true, pendingInteractions: 0, monitorScheduled: false }; + +it("accepts independent checkout, retained context, and accessible registered output", () => { + expect(gradeAutomaticRepository(valid).every(check => check.passed)).toBe(true); +}); + +it("reports a committed setup blocker immediately instead of waiting out the attempt deadline", () => { + expect(automaticRepositoryStopReason({ status: "blocked" }, [{ status: "succeeded" }])).toContain("stopped in blocked"); + expect(automaticRepositoryStopReason({ status: "done" }, [{ status: "succeeded" }])).toContain("without its automatic successor"); +}); + +it("keeps waiting for an ordinary queued successor and accepts two completed admissions", () => { + expect(automaticRepositoryStopReason({ status: "in_progress" }, [{ status: "succeeded" }])).toBeUndefined(); + expect(automaticRepositoryStopReason({ status: "in_progress" }, [{ status: "succeeded" }, { status: "queued" }])).toBeUndefined(); + expect(automaticRepositoryStopReason({ status: "done" }, [{ status: "succeeded" }, { status: "succeeded" }])).toBeUndefined(); +}); + +it("rejects unexpected result fields rather than trusting a model's extra claims", () => { + const extra = { ...valid, downloaded: { ...valid.downloaded, claimedPass: true } }; + expect(gradeAutomaticRepository(extra).find(check => check.id === "independent-result")?.passed).toBe(false); +}); + +it.each>([ + { runCount: 1 }, { successfulCommittedRuns: 1 }, { sourceContinuation: "monitor" }, { successorSource: "issue.comment" }, + { workspaceIds: ["old", "new"] }, { savedContext: null }, { firstSavedContext: null }, { firstContextAttributed: false }, + { firstSavedContext: `${"d".repeat(64)}\n` }, { firstSavedContext: "REFERENCE-fresh\n" }, + { firstContextObservedAt: null }, { firstContextObservedAt: 2000 }, { firstContextObservedAt: 3000 }, { successorStartedAt: null }, + { readyRepository: false }, { downloaded: null }, { predecessorFinishedAt: null }, { predecessorFinishedAt: 2001 }, + { predecessorRetagged: true }, { continuationReceipt: null }, + { continuationReceipt: { ...valid.continuationReceipt!, dispatchId: valid.continuationReceipt!.intentId } }, + { continuationReceipt: { ...valid.continuationReceipt!, intentId: "unverified" } }, + { downloaded: { ...valid.downloaded, reference: "REFERENCE-stale" } }, + { downloaded: { ...valid.downloaded, readme: "I inspected it and all checks pass" } }, + { downloaded: { ...valid.downloaded, commit: "b".repeat(40) } }, + { actualCommit: null }, { downloadVerified: false }, { visibleDelivery: false }, { pendingInteractions: 1 }, { monitorScheduled: true }, +])("rejects a plausible incomplete or wrong result %j", fault => { + expect(gradeAutomaticRepository({ ...valid, ...fault }).some(check => !check.passed)).toBe(true); +}); diff --git a/tests/runner-e2e/automatic-repository.ts b/tests/runner-e2e/automatic-repository.ts new file mode 100644 index 00000000000..27140059279 --- /dev/null +++ b/tests/runner-e2e/automatic-repository.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import type { RunnerTaskFixture } from "./types.js"; + +export function automaticRepositoryFiles(nonce: string) { + return { context: `setup-context-${nonce}.txt`, result: `repository-result-${nonce}.json` }; +} + +export const automaticRepositoryTask: RunnerTaskFixture = { + id: "automatic-repository-resume", label: "Repository preparation continues without supervision", groups: [], + flow: "automatic_repository", workMode: "standard", expectedRunCount: 2, + automaticRetryPolicy: "single_attempt", attemptTimeoutMs: { local: 12 * 60_000, daytona: 15 * 60_000 }, + turnTimeoutMs: 5 * 60_000, expectedTerminalState: { issue: "done", run: "succeeded" }, + buildTitle: nonce => `Verify a managed repository ${nonce}`, + buildVisibleMarker: nonce => automaticRepositoryFiles(nonce).result, + buildPrompt(nonce) { + const files = automaticRepositoryFiles(nonce); + return [ + "Verify a repository in this projectless task and deliver the result. Do not create a project or another task.", + `Before repository setup, generate a fresh unpredictable 32-byte hexadecimal reference using the operating system random source. Save that reference followed by one LF in ${files.context} in the task directory. Do not include its value in comments or the final answer.`, + `Before requesting repository preparation, register ${files.context} as a downloadable artifact with that exact title. This records the first admission's original bytes.`, + "Use the task's managed repository capability to prepare the following repository. Keep the task directory as the root.\nhttps://github.com/octocat/Hello-World", + "When the checkout is ready, read its committed README and HEAD commit. Read the saved context file again; preserve its original reference. If the file is missing or invalid, report the failure and stop. Never recreate, overwrite, or recover it from an attachment or earlier conversation.", + `Write ${files.result} as JSON with exactly these fields: reference (the saved reference without its trailing LF), readme (the complete committed README bytes as text), and commit (the full HEAD SHA).`, + `Register ${files.result} as a downloadable artifact work product with that exact title. Deliver its accessible link and finish the verification. Repository setup alone does not complete this request.`, + ].join("\n"); + }, + buildMatchers: () => [], +}; + +export interface AutomaticRepositoryObservation { + reference: string; + issueDone: boolean; + runCount: number; + successfulCommittedRuns: number; + sourceContinuation: string | null; + successorSource: string | null; + workspaceIds: Array; + readyRepository: boolean; + savedContext: string | null; + firstSavedContext: string | null; + firstContextAttributed: boolean; + firstContextObservedAt: number | null; + successorStartedAt: number | null; + predecessorFinishedAt: number | null; + predecessorRetagged: boolean; + continuationReceipt: { intentId: string; dispatchId: string } | null; + actualReadme: string | null; + actualCommit: string | null; + downloaded: { reference?: unknown; readme?: unknown; commit?: unknown } | null; + downloadVerified: boolean; + visibleDelivery: boolean; + pendingInteractions: number; + monitorScheduled: boolean; +} + +export function automaticRepositoryStopReason(issue: { status: string }, runs: readonly { status: string }[]) { + if (runs.length > 2) return "Unexpected extra provider admission"; + if (runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status))) return "A provider admission failed"; + if (runs.length && runs.every(run => run.status === "succeeded")) { + if (["blocked", "in_review", "cancelled"].includes(issue.status)) return `Repository setup stopped in ${issue.status}; see retained task state`; + if (issue.status === "done" && runs.length !== 2) return "Task finished without its automatic successor"; + } +} + +export function gradeAutomaticRepository(value: AutomaticRepositoryObservation) { + const check = (id: string, passed: boolean, detail: string) => ({ id, passed, detail }); + return [ + check("durable-completion", value.issueDone && value.runCount === 2 && value.successfulCommittedRuns === 2, + "Two committed provider admissions must finish the requested result."), + check("server-owned-continuation", value.sourceContinuation === "same_agent" && value.successorSource === "native_status_decision" && !value.monitorScheduled && + !value.predecessorRetagged && value.predecessorFinishedAt !== null && value.successorStartedAt !== null && + value.successorStartedAt >= value.predecessorFinishedAt && value.continuationReceipt !== null && + /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value.continuationReceipt.intentId) && + /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value.continuationReceipt.dispatchId) && + value.continuationReceipt.intentId !== value.continuationReceipt.dispatchId, + "The saved same-agent intent must bind a distinct successor dispatch after the unmodified predecessor finishes, without a timer or user message."), + check("retained-workspace", value.workspaceIds.length === 2 && Boolean(value.workspaceIds[0]) && value.workspaceIds.every(id => id === value.workspaceIds[0]) && value.readyRepository, + "Both admissions must retain the same task workspace and its prepared repository."), + check("retained-context", value.firstContextAttributed && value.firstContextObservedAt !== null && value.successorStartedAt !== null && + value.firstContextObservedAt < value.successorStartedAt && /^[a-f0-9]{64}\n$/.test(value.firstSavedContext ?? "") && + value.firstSavedContext === value.savedContext && value.savedContext === `${value.reference}\n`, + "The first admission's independently downloaded unpredictable reference must survive setup byte for byte."), + check("independent-result", value.downloadVerified && value.downloaded !== null && + Object.keys(value.downloaded).sort().join(",") === "commit,readme,reference" && value.downloaded.reference === value.reference && + typeof value.actualReadme === "string" && value.actualReadme.length > 0 && value.downloaded?.readme === value.actualReadme && + typeof value.actualCommit === "string" && /^[a-f0-9]{40}$/.test(value.actualCommit) && value.downloaded?.commit === value.actualCommit, + "Downloaded result bytes must agree with the independently inspected checkout and original reference."), + check("accessible-delivery", value.visibleDelivery && value.pendingInteractions === 0, "The originating task must render its result link without an unanswered question."), + ]; +} + +export function automaticRepositoryDigest() { + const hash = createHash("sha256"); + for (const file of ["automatic-repository.ts", "automatic-repository-flow.ts", "registered-artifact.ts", "stock-harness.ts", "runner.spec.ts", "../../skills/paperclip/SKILL.md"]) + hash.update(file).update(readFileSync(new URL(file, import.meta.url))); + return hash.digest("hex"); +} diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index afa81bc6458..0a669011c49 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -211,10 +211,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 1, 3, 4, 8, 10, 6, 63, 12, 6, 4, 2, 4, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 13, 10, 48, 16, 10, 2, 1, 1, 3, 116, 2, + 1, 3, 4, 4, 8, 10, 6, 63, 12, 6, 4, 2, 4, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 13, 10, 48, 16, 10, 2, 1, 1, 3, 116, 2, ]); - expect(validateRunnerCatalog()).toHaveLength(736); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(736); + expect(validateRunnerCatalog()).toHaveLength(740); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(740); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(48); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 75995939899..0a24b4b6353 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -1,6 +1,7 @@ import { caseHandoffTask, caseHandoffDefinitionDigest } from "./case-handoffs-cases.js"; import { caseReceiptTask, caseReceiptDefinitionDigest, CASE_RECEIPT_BUDGET_CENTS, CASE_RECEIPT_MAX_TURNS, CASE_RECEIPT_RUN_TIMEOUT_MS } from "./case-receipt-cases.js"; import { TASK_WORKSPACES_SUITE, taskWorkspaceTask, taskWorkspaceProfile, taskWorkspaceDefinitionDigest } from "./task-workspaces-cases.js"; +import { automaticRepositoryTask, automaticRepositoryDigest } from "./automatic-repository.js"; import { nativeActiveStopTasks } from "./native-active-stop-tasks.js"; import { piControlTasks } from "./pi-controls-cases.js"; import { cursorNativeTasks } from "./cursor-native-cases.js"; @@ -1167,6 +1168,16 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ definitionMetadata: { version: 1, definitionDigest: taskWorkspaceDefinitionDigest(), scheduling: "explicit-only", providerTurns: 3, attempts: 1, budgetMonthlyCents: 1000, project: "none", cwd: "unconfigured-task-default", oracle: "public-file-download-and-host-nested-git", remoteLifecycle: "per_turn" }, }, + { + id: "automatic-repository", label: "Automatic repository continuation", manualOnly: true, + description: "A projectless user request stages its managed repository, continues automatically, and delivers independently verified downloaded bytes.", + groups: [], profiles: runnerProfiles.filter(profile => ["runner-codex", "runner-acpx-claude"].includes(profile.id)) + .map(taskWorkspaceProfile).map(productionDefaultHireProfile), + environments: runnerEnvironments, tasks: [automaticRepositoryTask], expectedMatrixSize: 4, + definitionMetadata: { version: 1, definitionDigest: automaticRepositoryDigest(), scheduling: "explicit-only", + providerTurns: 2, attempts: 1, budgetMonthlyCents: 1000, project: "none", instructions: "production-default", + surface: "paperclip", oracle: "registered-download-and-independent-checkout", remoteLifecycle: "per_turn" }, + }, { id: "pi-controls", label: "Pi active controls", manualOnly: true, description: "Pending native-write Stop and browser-originated same-turn steering, with exact control receipts and independent retirement/no-effect evidence.", diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index ff9b9480112..cfd6e80df92 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -58,7 +58,8 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-runner/test/fixtures/copilot-profile-v47-identity.json", "packages/paperclip-runner/test/fixtures/copilot-profile-v48-identity.json", "packages/paperclip-runner/test/fixtures/copilot-profile-v49-identity.json", - "packages/paperclip-runner/test/fixtures/copilot-profile-v53-identity.json", + "packages/paperclip-runner/test/fixtures/copilot-profile-v54-identity.json", + "packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json", "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", "packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs", diff --git a/tests/runner-e2e/harness-env-admission.test.ts b/tests/runner-e2e/harness-env-admission.test.ts new file mode 100644 index 00000000000..29623e28df9 --- /dev/null +++ b/tests/runner-e2e/harness-env-admission.test.ts @@ -0,0 +1,27 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { runnerMatrix } from "./catalog.js"; +import { QUALIFIED_ACPX_PROFILES } from "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js"; + +const profilesModule = "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js"; +const copilot = runnerMatrix.find(cell => cell.suite.id === "extended-harnesses" && cell.profile.qualificationCandidate === "copilot")!; +afterEach(() => { vi.doUnmock(profilesModule); vi.resetModules(); }); + +it("uses normal production admission for the current qualified Copilot profile", async () => { + expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); + const { buildRunnerE2EProcessEnvironment } = await import("./harness-env.js"); + expect(buildRunnerE2EProcessEnvironment({ PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }, [copilot]) + .PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); +}); + +it("retains exact, explicit admission when a future profile is pending", async () => { + const actual = await vi.importActual(profilesModule); + vi.doMock(profilesModule, () => ({ ...actual, QUALIFIED_ACPX_PROFILES: { ...actual.QUALIFIED_ACPX_PROFILES, + copilot: { ...actual.QUALIFIED_ACPX_PROFILES.copilot, qualificationStatus: "pending" } } })); + vi.resetModules(); + const { buildRunnerE2EProcessEnvironment } = await import("./harness-env.js"); + const env = buildRunnerE2EProcessEnvironment({ PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }, [copilot]); + expect(JSON.parse(env.PAPERCLIP_RUNNER_ACPX_QUALIFICATION!)).toEqual([{ agent: "copilot", model: copilot.profile.model }]); + expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...copilot, suite: { ...copilot.suite, manualOnly: false } }])).toThrow("explicit"); + expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...copilot, suite: { ...copilot.suite, id: "unrelated-suite" } }])).toThrow("explicit"); + expect(() => buildRunnerE2EProcessEnvironment({}, [copilot, { ...copilot, profile: { ...copilot.profile, model: "conflicting-model" } }])).toThrow("Conflicting"); +}); diff --git a/tests/runner-e2e/pi-controls-evidence.test.ts b/tests/runner-e2e/pi-controls-evidence.test.ts index 35cd507e08e..38c2a0e3716 100644 --- a/tests/runner-e2e/pi-controls-evidence.test.ts +++ b/tests/runner-e2e/pi-controls-evidence.test.ts @@ -180,7 +180,7 @@ describe("Pi controls catalog admission", () => { expect(cells).toHaveLength(4); expect(cells.every(c => c.profile.qualificationCandidate === "pi" && c.task.expectedRunCount === 1)).toBe(true); expect(cells.map(c => `${c.environment.id}/${c.task.id}`).sort()).toEqual(["daytona/pending-permission-stop", "daytona/same-turn-steering", "local/pending-permission-stop", "local/same-turn-steering"]); expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "pi-controls")).toBe(false); - expect(validateRunnerCatalog()).toHaveLength(736); + expect(validateRunnerCatalog()).toHaveLength(740); for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); expect(() => assertRemoteNativeEvidencePrerequisites(cells, {})).toThrow(); }); diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index 04dfd9f18e6..935780909b4 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -1,6 +1,7 @@ import { runCaseHandoffsFlow } from "./case-handoffs-flow.js"; import { runCaseReceiptFlow } from "./case-receipt-flow.js"; import { runTaskWorkspacesFlow } from "./task-workspaces-flow.js"; +import { runAutomaticRepositoryFlow } from "./automatic-repository-flow.js"; import { runNativeActiveStopFlow as runCopilotActiveStopFlow } from "./copilot-active-stop-flow.js"; import { nativeActiveStopTasks as copilotActiveStopTasks } from "./copilot-active-stop-tasks.js"; import type { RestartRunnerIdentity } from "./process-tree-owner.js"; @@ -1022,6 +1023,15 @@ for (const execution of executions) { matcherResults, }, secrets); await captureScreenshot("final-state", "Final visible task state", "final-state.png"); + } else if (execution.task.flow === "automatic_repository") { + const journey = await runAutomaticRepositoryFlow({ page, api, fixtures, execution, nonce, temporaryRoot, + deadlineAt: startedAtMs + deadlineMs - 30_000, + observe: (currentIssue, currentRuns, checks) => { + issue = currentIssue as IssueRecord; selectedRuns = currentRuns as RunRecord[]; + matcherResults = checks.map(check => ({ matcher: { kind: "json_path" as const, path: `automaticRepository.${check.id}`, expected: true }, passed: check.passed, detail: check.detail })); + }, capture: captureScreenshot, evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets), + }); + issue = journey.issue as IssueRecord; selectedRuns = journey.runs as RunRecord[]; } else if (execution.task.flow === "task_workspaces") { const journey = await runTaskWorkspacesFlow({ page, api, fixtures, execution, nonce, temporaryRoot, deadlineAt: startedAtMs + deadlineMs - 30_000, diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index 1a5ecf34b44..29c44b87d33 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -4,6 +4,7 @@ import { runnerMatrix } from "./catalog.js"; import { createRunnerE2EServerStopper, runnerE2EServerDetached } from "./server-stop.js"; import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js"; import { qualifyLegacyClaudeCli } from "./legacy-claude-cli.js"; +import { prepareSourceServerDependencies } from "./source-startup.js"; import { spawn, type ChildProcess } from "node:child_process"; import { createWriteStream } from "node:fs"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; @@ -98,6 +99,8 @@ const definedServerEnvironment = Object.fromEntries( ), ); +await prepareSourceServerDependencies(repositoryRoot, definedServerEnvironment, usesInstalledCli(process.env)); + await Promise.all([ mkdir(path.dirname(logPath), { recursive: true }), mkdir(controlDirectory, { recursive: true, mode: 0o700 }), diff --git a/tests/runner-e2e/source-startup.test.ts b/tests/runner-e2e/source-startup.test.ts new file mode 100644 index 00000000000..9f167629eba --- /dev/null +++ b/tests/runner-e2e/source-startup.test.ts @@ -0,0 +1,31 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { prepareSourceServerDependencies } from "./source-startup.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +async function fixture(source: string) { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-source-startup-")); roots.push(root); + await mkdir(path.join(root, "scripts")); + await writeFile(path.join(root, "scripts/ensure-plugin-build-deps.mjs"), source); + return root; +} + +it("prepares missing source dependencies before the entry can import them", async () => { + const root = await fixture(`import {writeFileSync} from 'node:fs'; writeFileSync('dependency.mjs', 'export default 42');`); + await expect(readFile(path.join(root, "dependency.mjs"))).rejects.toThrow(); + await prepareSourceServerDependencies(root, {}, false); + expect(await readFile(path.join(root, "dependency.mjs"), "utf8")).toBe("export default 42"); +}); + +it("fails before server admission when dependency preparation fails", async () => { + const root = await fixture("process.exit(7)"); + await expect(prepareSourceServerDependencies(root, {}, false)).rejects.toMatchObject({ code: 7 }); +}); + +it("does not execute checkout helpers for an installed package", async () => { + const root = await fixture("throw new Error('must not execute checkout code')"); + await expect(prepareSourceServerDependencies(root, {}, true)).resolves.toBeUndefined(); +}); diff --git a/tests/runner-e2e/source-startup.ts b/tests/runner-e2e/source-startup.ts new file mode 100644 index 00000000000..6c6aafdcf1f --- /dev/null +++ b/tests/runner-e2e/source-startup.ts @@ -0,0 +1,17 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; + +export async function prepareSourceServerDependencies( + repositoryRoot: string, + environment: NodeJS.ProcessEnv, + installed: boolean, +) { + if (installed) return; + await promisify(execFile)(process.execPath, [path.join(repositoryRoot, "scripts/ensure-plugin-build-deps.mjs")], { + cwd: repositoryRoot, + env: environment, + timeout: 120_000, + maxBuffer: 1024 * 1024, + }); +} diff --git a/tests/runner-e2e/types.ts b/tests/runner-e2e/types.ts index cd4c657aeac..295bf1bea59 100644 --- a/tests/runner-e2e/types.ts +++ b/tests/runner-e2e/types.ts @@ -20,6 +20,7 @@ export type RunnerTaskWorkMode = "standard" | "planning" | "ask"; export type RunnerTaskFlow = | "case_handoff" | "task_workspaces" + | "automatic_repository" | "repository_skills" | "provider_connection" | "plan_task_guidance"