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 <noreply@paperclip.ing>
Co-authored-by: Paperclip <paperclip@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-10-21 11:21:55 -05:00
1 parent 3ce8d976a9
commit eece07a747
53 files changed
+1313 -78

No files matched your search

@@ -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.
+2 -2
View File
@@ -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": {
@@ -659,6 +659,11 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
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<Value, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
}
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<dyn std::error::Error>> {
"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",
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:6441ff7f3fc664064bff9e7ae80252ddec7a39236f9a4b9d402785eb8b03b2c9",
"sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -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]
@@ -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",
@@ -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<string, any>(), emitted: any[] = [];
const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult });
const commands = loadActualToolCommands(tools);
@@ -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);
});
@@ -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,
@@ -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", () => {
@@ -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": {
@@ -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<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
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],
};
@@ -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()
@@ -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 {
@@ -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.",
},
],
};
@@ -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" } },
@@ -5,7 +5,8 @@ export async function resolveCompletionContinuation(
readTaskContext?: () => Promise<unknown>,
): Promise<unknown> {
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<string, unknown>;
@@ -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 } };
}
@@ -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) =>
@@ -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<ReturnType<typeof driver.openSession>> | 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);
@@ -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"
}
}
@@ -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"
}
}
@@ -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<void>(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();
@@ -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 => {
@@ -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", () => {
@@ -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",
@@ -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<typeof createDb>;
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" });
@@ -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);
@@ -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)
@@ -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;
});
+1
View File
@@ -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"],
@@ -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);
@@ -162,12 +162,23 @@ export async function materializeManagedProjectWorkspace(
}
export async function ensureManagedRepositoriesIgnored(cwd: string): Promise<void> {
export async function ensureManagedRepositoriesIgnored(cwd: string, options: { plainTaskDirectory?: boolean } = {}): Promise<void> {
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 });
@@ -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",
@@ -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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : {};
@@ -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.",
);
}
}
@@ -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"),
@@ -19,6 +19,10 @@ export const taskRepositoryRunColumns = {
};
type TaskRepositoryRunIdentity = Pick<typeof heartbeatRuns.$inferSelect, "nativeIssueId" | "issueId" | "contextSnapshot">;
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<typeof issues.$inferSelect> = [];
for (let depth = 0; parentId && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth++) {
+2 -2
View File
@@ -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 } }));
+14 -5
View File
@@ -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<Db, "select">, input: {
companyId: string; issueId: string; runId: string; workspaceId: string | null;
export async function pendingTaskRepository(db: Pick<Db, "select">, 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<Db, "select">, 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") {
+16 -4
View File
@@ -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<string, unknown> | 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<void>>;
persistContentsRestoredAt?: (at: string | null) => Promise<void>;
@@ -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,
+27
View File
@@ -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
@@ -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<string, any>;
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<typeof gradeAutomaticRepository>): void;
capture(id: string, label: string, file: string): Promise<void>;
evidence(name: string, value: unknown): Promise<void>;
}) {
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<Row>(`/api/issues/${issue.id}`);
const listed = await api.get<Row[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
runs = (await Promise.all(listed.map(row => api.get<Row>(`/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<Row>(`/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<Row>(`/api/companies/${fixtures.company.id}`);
const agentBudget = await api.get<Row>(`/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<Row>(`/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<Row>(`/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<Row>(`/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<Row[]>(`/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;
}
}
@@ -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<Partial<AutomaticRepositoryObservation>>([
{ 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);
});
+98
View File
@@ -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<string | null>;
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");
}
+3 -3
View File
@@ -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);
+11
View File
@@ -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.",
+2 -1
View File
@@ -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",
@@ -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<typeof import("../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js")>(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");
});
@@ -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();
});
+10
View File
@@ -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,
+3
View File
@@ -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 }),
+31
View File
@@ -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();
});
+17
View File
@@ -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,
});
}
+1
View File
@@ -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"