mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix(runner): preserve task replies and warm process continuity (#13738)
## Thinking Path > - Paperclip lets people manage AI agents and their work. > - The Runner connects provider sessions to task state, replies, and delegated work. > - Full-stack tests found lost final replies, rejected helper calls that stopped the parent, and unnecessary process restarts. > - A completed child could also receive a new assignment wake that the scheduler then cancelled. > - This change fixes those boundaries and gives agents clearer teammate instructions. > - The tests retain strict completion and process-continuity requirements. ## Linked Issues or Issue Description **What happened?** A generated attachment comment could suppress an agent's final reply. A known Codex helper could stop its parent when it requested a Paperclip tool. Native Daytona processes restarted between turns because Paperclip minted an unused GitHub broker token. Reassigning a completed child queued a run that immediately cancelled. Revision instructions also allowed agents to do work assigned to a named teammate themselves. **Expected behavior** Keep the final reply. Reject helper tool requests without borrowing parent authority or stopping the parent. Keep an unconfigured sandbox process alive between turns. Treat assignment-only changes to completed tasks as metadata changes. Preserve explicit teammate assignments during revisions. **Steps to reproduce** Run the retained Runner E2E cases for file handoff, teammate reuse, Daytona warm continuity, and Legacy Claude interview/plan acceptance. The focused regression tests reproduce the reply, helper, process-lifetime, and assignment-wake defects without provider calls. **Paperclip version or commit** The live lifetime and completion campaign used `db3857807`. This PR replays the changes on master `c65fc9e3c`. See Verification for the limits of that evidence. **Deployment mode** Isolated local instances and native Runner sessions in Daytona sandboxes. Related work: #13546 handles a different queued-run issue after an issue-lock compare-and-set failure. This PR prevents the unnecessary assignment wake earlier. #13410 covers retained user services; this PR covers the provider process. No duplicate fix was found. ## What Changed - Exclude generated deliverable-binding comments from final-reply deduplication. Preserve the attachment and explicit user-facing replies. - Reject Paperclip tool and input requests from known Codex helper threads without terminating the parent. Keep unknown-thread rejection intact. - Explain how to hire or reuse a persistent teammate and preserve named delegation on revisions. Update generated protocol fixtures. - Use stable, token-free GitHub wrappers for unconfigured native sandboxes. Preserve credential isolation, configured-account rotation, and cleanup after partial staging failures. - Do not queue assignment-only wakes for done or cancelled tasks. Keep explicit reopening behavior. - Make warm-continuity fixtures create real review cards. Read the persisted final response selected by production presentation logic. Missing selected evidence still fails. ## Verification - Before rebase: 560 focused route, native-executor, and launcher tests passed. The new regressions were reproduced before their fixes. - Live E2E: Legacy Claude interview/plan acceptance passed 3/3 repetitions. Daytona warm continuity passed 2/3 full repetitions. Each successful run retained one process and provider session for all three turns. - The remaining Daytona repetition stopped after a same-URL browser reload left the page blank. Both completed turns retained the same process. Its failed verdict remains unchanged; this PR does not claim the blank-page cause is fixed. - Reports: https://pages.paperclip.ing/runner-e2e-lifetime-race-20260920/investigation.html and https://pages.paperclip.ing/runner-e2e-behavior-followups-20260919-results/investigation.html - Post-rebase `pnpm build` and `pnpm -r typecheck` passed. All 414 Runner E2E harness unit tests and its typecheck passed. Codex protocol tests: 88 passed, 2 ignored. - Latest-head CI: 55 successful checks and 2 intentional skips. Greptile: 5/5 with no review threads. The unchanged workspace exposure tests hit a fixed-port collision on the first CI attempt; their local suite passed (25 tests, 3 platform skips), and the CI shard passed on one retry. - The duplicate local `pnpm test:run` was stopped after the full hosted general and serialized test shards passed. It did not finish locally and is not counted as a local full-suite pass. ## Risks Configured GitHub accounts retain run-scoped credential rotation and can still restart warm processes. That limitation requires a separate design. Known provider helpers cannot use Paperclip coordination tools directly; they must return findings to the parent. The delegation prompt is an instruction, not an enforced guarantee; Codex Mini hiring/reuse failures remain open. No schema or workflow changes are included. ## Model Used OpenAI GPT-6 through Codex, with repository tools, code execution, and parallel coding agents. The exact deployment suffix and context-window size are not exposed in this session. ## 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 issues and shared reports) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run focused tests locally and they pass; full hosted test shards also 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
29d6b35096
commit
0f5fafe16b
23 files changed
+502
-42
No files matched your search
@@ -1 +1 @@
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
@@ -66,7 +66,7 @@
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.",
|
||||
"description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.",
|
||||
"effect": "read",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:3e8ece5670302fc3dac8782793f59f98f4992d78542565caae1ccb2a37cc0988",
|
||||
"catalogSha256": "sha256:73321806bf90df701a9677a7a9527c5b3d39485078e4d3fa4bb3a244a74a0c3b",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "09e442ca5f4adb9237d765601d8341f53b43607532390704cacd7c4167eb4e02",
|
||||
"sha256": "ab9247489ae2427fec5a86c54b55698ac3e211bd65d93cf4e973e363050455fa",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
@@ -840,9 +840,33 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut delayed_interrupt_terminal_scheduled = false;
|
||||
let mut answered_questions = 0u8;
|
||||
let mut replayed_completed_tool_calls = 0_u64;
|
||||
let mut rejected_helper_requests = 0_u8;
|
||||
|
||||
for line in io::stdin().lock().lines() {
|
||||
let message: Value = serde_json::from_str(&line?)?;
|
||||
if message.get("method").is_none()
|
||||
&& message
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| id.starts_with("helper-request-"))
|
||||
{
|
||||
if message.pointer("/result/success") != Some(&json!(false))
|
||||
&& message.get("error").is_none()
|
||||
{
|
||||
return Err("helper request was granted root authority".into());
|
||||
}
|
||||
rejected_helper_requests += 1;
|
||||
log_call(call_log.as_deref(), "helper-request:rejected")?;
|
||||
if rejected_helper_requests == 3 {
|
||||
send(
|
||||
json!({"id":"tool-request-1", "method":"item/tool/call", "params":{
|
||||
"threadId":state.thread_id, "turnId":state.active_turn_id,
|
||||
"callId":"root-after-helper", "tool":"get_task_context", "arguments":{}
|
||||
}}),
|
||||
)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if message.get("method").is_none()
|
||||
&& message
|
||||
.get("id")
|
||||
@@ -1447,6 +1471,32 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"method": "turn/started",
|
||||
"params": {"turn": {"id": provider_turn_id}}
|
||||
}))?;
|
||||
if args.iter().any(|value| value == "--helper-tool-requests") {
|
||||
if !args.iter().any(|value| value == "--foreign-helper-tool") {
|
||||
send(json!({"method":"item/completed", "params":{
|
||||
"threadId":state.thread_id, "turnId":provider_turn_id,
|
||||
"item":{"id":"spawn-helper", "type":"collabAgentToolCall",
|
||||
"tool":"spawnAgent", "status":"completed",
|
||||
"senderThreadId":state.thread_id, "receiverThreadIds":["helper-thread"]}
|
||||
}}))?;
|
||||
}
|
||||
for (index, tool) in ["get_task_context", "paperclip_finish"].iter().enumerate()
|
||||
{
|
||||
send(
|
||||
json!({"id":format!("helper-request-{index}"), "method":"item/tool/call", "params":{
|
||||
"threadId":"helper-thread", "turnId":"helper-turn",
|
||||
"callId":format!("helper-call-{index}"), "tool":tool, "arguments":{}
|
||||
}}),
|
||||
)?;
|
||||
}
|
||||
send(
|
||||
json!({"id":"helper-request-question", "method":"item/tool/requestUserInput", "params":{
|
||||
"threadId":"helper-thread", "turnId":"helper-turn", "itemId":"helper-question",
|
||||
"questions":[]
|
||||
}}),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
if descendant_notifications {
|
||||
// Real Codex announces a child's MCP startup before its
|
||||
// thread/started notification establishes parent lineage.
|
||||
|
||||
@@ -1981,6 +1981,31 @@ impl CodexProvider {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_descendant_request(
|
||||
&mut self,
|
||||
rpc_id: Value,
|
||||
method: &str,
|
||||
) -> Result<Option<CodexProviderEvent>, LocalRunnerError> {
|
||||
// A recognized helper is part of the provider conversation, but has no
|
||||
// Paperclip task binding. Reject its RPC without borrowing root authority
|
||||
// or quarantining the root run. Unknown foreign threads still fail closed.
|
||||
let message = "Paperclip tools are authorized only for the parent task. Return your findings to the parent agent; it must perform Paperclip coordination and ask the user questions.";
|
||||
let response = if method == "item/tool/call" {
|
||||
json!({"id": rpc_id, "result": codex_tool_failure(message)})
|
||||
} else {
|
||||
json!({"id": rpc_id, "error": {"code": -32000, "message": message}})
|
||||
};
|
||||
self.send_frame(&response)?;
|
||||
if self.notification_identity_diagnostics >= 32 {
|
||||
return Ok(None);
|
||||
}
|
||||
self.notification_identity_diagnostics += 1;
|
||||
Ok(Some(CodexProviderEvent::Notification {
|
||||
method: "warning".to_owned(),
|
||||
params: json!({"message": message, "providerMethod": bounded_method(method)}),
|
||||
}))
|
||||
}
|
||||
|
||||
fn reject_post_terminal_request(
|
||||
&mut self,
|
||||
rpc_id: Value,
|
||||
@@ -2278,6 +2303,13 @@ impl CodexProvider {
|
||||
if method == "item/tool/call" {
|
||||
let params = message.get("params").cloned().unwrap_or(Value::Null);
|
||||
if params.get("threadId").and_then(Value::as_str) != Some(self.thread_id.as_str()) {
|
||||
if params
|
||||
.get("threadId")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| self.descendant_thread_ids.contains(id))
|
||||
{
|
||||
return self.reject_descendant_request(rpc_id, method);
|
||||
}
|
||||
return Ok(Some(self.identity_failure(
|
||||
method,
|
||||
¶ms,
|
||||
@@ -2402,6 +2434,13 @@ impl CodexProvider {
|
||||
&& params.get("threadId").and_then(Value::as_str)
|
||||
!= Some(self.thread_id.as_str())
|
||||
{
|
||||
if params
|
||||
.get("threadId")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| self.descendant_thread_ids.contains(id))
|
||||
{
|
||||
return self.reject_descendant_request(rpc_id, method);
|
||||
}
|
||||
return Ok(Some(self.identity_failure(
|
||||
method,
|
||||
¶ms,
|
||||
|
||||
@@ -654,6 +654,75 @@ fn rejected_codex_goal_activation_restores_turn_reconciliation() {
|
||||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_tool_requests_do_not_terminate_or_borrow_root_authority() {
|
||||
for foreign in [false, true] {
|
||||
let directory = temporary_directory("helper-tool-requests");
|
||||
let mut flags = vec!["--helper-tool-requests"];
|
||||
if foreign {
|
||||
flags.push("--foreign-helper-tool");
|
||||
}
|
||||
let config = provider_config(&directory, &flags);
|
||||
let mut provider =
|
||||
CodexProvider::start_with_tools(&config, [task_context_tool()], None).unwrap();
|
||||
provider
|
||||
.start_turn("Hire a persistent teammate.", &config.cwd)
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let mut completed = false;
|
||||
let mut root_tool_delivered = false;
|
||||
while std::time::Instant::now() < deadline {
|
||||
match provider.poll().unwrap() {
|
||||
Some(CodexProviderEvent::ProtocolFailure { diagnostic }) => {
|
||||
assert!(foreign, "recognized helper crashed the root: {diagnostic}");
|
||||
assert_eq!(diagnostic["code"], "thread_binding_mismatch");
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
Some(CodexProviderEvent::RuntimeRequest { .. }) => {
|
||||
panic!("helper borrowed root question authority")
|
||||
}
|
||||
Some(CodexProviderEvent::ToolCall {
|
||||
call_id,
|
||||
operation_id,
|
||||
..
|
||||
}) => {
|
||||
assert!(!foreign);
|
||||
assert_eq!(
|
||||
call_id, "root-after-helper",
|
||||
"helper borrowed root tool authority"
|
||||
);
|
||||
provider
|
||||
.deliver_tool_result(&ToolResult {
|
||||
call_id,
|
||||
operation_id,
|
||||
result: json!({"ok":true,"task":{"id":"task-1"}}),
|
||||
is_error: false,
|
||||
})
|
||||
.unwrap();
|
||||
root_tool_delivered = true;
|
||||
}
|
||||
Some(CodexProviderEvent::Notification { method, .. })
|
||||
if method == "turn/completed" =>
|
||||
{
|
||||
assert!(!foreign);
|
||||
assert!(root_tool_delivered);
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
_ => std::thread::sleep(std::time::Duration::from_millis(1)),
|
||||
}
|
||||
}
|
||||
assert!(completed, "helper requests did not settle");
|
||||
assert_eq!(
|
||||
call_count(&directory, "helper-request:rejected"),
|
||||
if foreign { 0 } else { 3 }
|
||||
);
|
||||
provider.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_dynamic_tool_round_trips_through_the_provider_boundary() {
|
||||
let directory = temporary_directory("dynamic-tool");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const NATIVE_RUNTIME_ASSET_SCHEMA = "paperclip.runtime-asset.v1" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v3" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. Hire persistent teammates through Paperclip hiring; provider helper threads do not create Paperclip agents. Delegate with create_task. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When a task needs an external service, use installed tools if available; otherwise use connections_search to discover catalog services or authorized configured connections, then connection_request with the returned service identifier. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v4" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. To hire or reuse a persistent teammate, use list_agents, then search_api for agent-hires and call_api if a hire is needed. Provider helper threads do not create Paperclip agents. When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When a task needs an external service, use installed tools if available; otherwise use connections_search to discover catalog services or authorized configured connections, then connection_request with the returned service identifier. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const;
|
||||
|
||||
export interface NativeRuntimeAssetReference {
|
||||
schema: typeof NATIVE_RUNTIME_ASSET_SCHEMA;
|
||||
|
||||
@@ -28,7 +28,7 @@ export const searchApiAction = {
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Search the Paperclip API",
|
||||
"description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.",
|
||||
"description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.",
|
||||
"note": "Production HTTP fallback; does not grant privileges or replace dedicated tools."
|
||||
},
|
||||
"examples": {
|
||||
@@ -51,7 +51,7 @@ export const searchApiAction = {
|
||||
"operationId": "search_api",
|
||||
"version": 1,
|
||||
"title": "Search the Paperclip API",
|
||||
"description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.",
|
||||
"description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.",
|
||||
"exposure": "optional",
|
||||
"requiredClaims": [
|
||||
"api:discover"
|
||||
|
||||
@@ -902,6 +902,35 @@ describe("projectHistoricalHeartbeatRunComment", () => {
|
||||
});
|
||||
|
||||
describe("findHeartbeatRunCompletionComment", () => {
|
||||
it.each(["applied", "duplicate"])("does not let a %s file-preparation receipt hide the final reply", (disposition) => {
|
||||
const prepared = { id: "prepared-comment", body: "Prepared Continuity file for this response." };
|
||||
const explicit = { id: "explicit-comment", body: "An intentional agent reply." };
|
||||
const resultJson = {
|
||||
semanticToolReceipts: {
|
||||
file: {
|
||||
operationId: "register_deliverable",
|
||||
result: {
|
||||
commandId: "deliverable-prepared:attachment-1",
|
||||
disposition,
|
||||
attachmentId: "attachment-1",
|
||||
entityRefs: ["attachment-1", "work-product-1", prepared.id],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const existingComment = findHeartbeatRunCompletionComment([prepared], resultJson);
|
||||
expect(existingComment).toBeNull();
|
||||
expect(resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
existingComment,
|
||||
finalAgentMessage: { text: "PAPERCLIP_E2E_WARM_T3", sourceEventId: "final-event", channel: "final" },
|
||||
})).toMatchObject({ text: "PAPERCLIP_E2E_WARM_T3", decision: { commentAction: "create" } });
|
||||
// A final reply already materialized on a retry keeps precedence.
|
||||
expect(findHeartbeatRunCompletionComment([prepared, explicit], resultJson)).toEqual(explicit);
|
||||
// The body alone does not mark an ordinary agent comment as generated.
|
||||
expect(findHeartbeatRunCompletionComment([prepared], {})).toEqual(prepared);
|
||||
});
|
||||
|
||||
it("does not let semantic progress satisfy the final comment", () => {
|
||||
const progress = { id: "progress-comment" };
|
||||
const final = { id: "final-comment" };
|
||||
|
||||
@@ -285,6 +285,45 @@ describe("issue update comment wakeups", () => {
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["done", "cancelled"])(
|
||||
"keeps %s assignment-only updates from waking completed work",
|
||||
async (status) => {
|
||||
// A parent may restore the assignee after releasing a completed child.
|
||||
// That is recordkeeping, not a request to execute the child again.
|
||||
const existing = makeIssue({ status, assigneeAgentId: null, assigneeUserId: null });
|
||||
const updated = makeIssue({ status, assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null });
|
||||
mockIssueService.getById.mockResolvedValue(existing);
|
||||
mockIssueService.update.mockResolvedValue(updated);
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({ assigneeAgentId: ASSIGNEE_AGENT_ID });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ status, assigneeAgentId: ASSIGNEE_AGENT_ID });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])("still wakes explicitly reopened work (reassigned: %s)", async (reassigned) => {
|
||||
const existing = makeIssue({ status: "done", assigneeAgentId: PREVIOUS_AGENT_ID, assigneeUserId: null });
|
||||
const agentId = reassigned ? ASSIGNEE_AGENT_ID : PREVIOUS_AGENT_ID;
|
||||
mockIssueService.getById.mockResolvedValue(existing);
|
||||
mockIssueService.update.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: agentId, assigneeUserId: null }));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({ status: "todo", ...(reassigned ? { assigneeAgentId: agentId } : {}) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(agentId, expect.objectContaining({
|
||||
reason: reassigned ? "issue_assigned" : "issue_status_changed",
|
||||
payload: expect.objectContaining({ issueId: existing.id }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("includes the new comment in assignment wakes from issue updates", async () => {
|
||||
const existing = makeIssue();
|
||||
const updated = makeIssue({
|
||||
|
||||
@@ -14551,6 +14551,9 @@ export function issueRoutes(
|
||||
assigneeChanged &&
|
||||
issue.assigneeAgentId &&
|
||||
issue.status !== "backlog" &&
|
||||
// Restoring an assignee on completed work is not a reopen request.
|
||||
// Explicit reopen/resume transitions are already reflected in issue.status.
|
||||
!isClosedIssueStatus(issue.status) &&
|
||||
deferWakeForGoal !== true
|
||||
) {
|
||||
addWakeup(issue.assigneeAgentId, {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { cleanupGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js";
|
||||
|
||||
const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" };
|
||||
|
||||
describe("heartbeat GitHub launcher lifetime", () => {
|
||||
it("keeps anonymous native sandbox launchers stable without issuing a run capability", async () => {
|
||||
const createBrokerToken = vi.fn(() => "run-secret");
|
||||
const prepareLaunchers = vi.fn(async (input) => input.env);
|
||||
const inputs = { native: true, githubConfigured: false, agentId: "agent-a", target,
|
||||
cwd: "/workspace", env: { GH_TOKEN: "ambient-secret" }, brokerUrl: "https://paperclip.test", createBrokerToken };
|
||||
const first = await prepareHeartbeatGitHubLaunchers({ ...inputs, runId: "run-one" }, prepareLaunchers);
|
||||
const second = await prepareHeartbeatGitHubLaunchers({ ...inputs, runId: "run-two" }, prepareLaunchers);
|
||||
expect(createBrokerToken).not.toHaveBeenCalled();
|
||||
expect(prepareLaunchers.mock.calls.map(([input]) => input.runId)).toEqual([expect.stringMatching(/^anonymous-agent-a-/), prepareLaunchers.mock.calls[0]?.[0].runId]);
|
||||
expect(first.cleanupLocation).toBeNull();
|
||||
expect(second.cleanupLocation).toBeNull();
|
||||
expect(first.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "", PAPERCLIP_GITHUB_BROKER_URL: "" });
|
||||
});
|
||||
it.each([false, true])("cleans partial run-scoped staging and preserves its error (cleanup fails: %s)", async (cleanupFails) => {
|
||||
const stagingError = new Error("remote launcher staging failed");
|
||||
const prepareLaunchers = vi.fn(async () => { throw stagingError; });
|
||||
const cleanupLaunchers = vi.fn(async () => {
|
||||
if (cleanupFails) throw new Error("cleanup unavailable");
|
||||
});
|
||||
await expect(prepareHeartbeatGitHubLaunchers({
|
||||
native: true, githubConfigured: true, agentId: "agent-a", target,
|
||||
runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test",
|
||||
createBrokerToken: () => "current-run-secret",
|
||||
}, prepareLaunchers, cleanupLaunchers)).rejects.toBe(stagingError);
|
||||
expect(cleanupLaunchers).toHaveBeenCalledExactlyOnceWith({ runId: "failed-run", target });
|
||||
});
|
||||
|
||||
it("does not remove shared anonymous wrappers when staging a later run fails", async () => {
|
||||
const stagingError = new Error("remote launcher staging failed");
|
||||
const cleanupLaunchers = vi.fn(async () => undefined);
|
||||
await expect(prepareHeartbeatGitHubLaunchers({
|
||||
native: true, githubConfigured: false, agentId: "agent-a", target,
|
||||
runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test",
|
||||
createBrokerToken: () => { throw new Error("must not mint a capability"); },
|
||||
}, async () => { throw stagingError; }, cleanupLaunchers)).rejects.toBe(stagingError);
|
||||
expect(cleanupLaunchers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ native: true, githubConfigured: true, target },
|
||||
{ native: false, githubConfigured: false, target },
|
||||
{ native: true, githubConfigured: false, target: null },
|
||||
])("preserves run-scoped managed capabilities outside anonymous native sandboxes: %j", async (mode) => {
|
||||
const createBrokerToken = vi.fn(() => "current-run-secret");
|
||||
const prepareLaunchers = vi.fn(async (input) => input.env);
|
||||
const result = await prepareHeartbeatGitHubLaunchers({ ...mode, agentId: "agent-a", runId: "run-one",
|
||||
cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test", createBrokerToken }, prepareLaunchers);
|
||||
expect(createBrokerToken).toHaveBeenCalledOnce();
|
||||
expect(prepareLaunchers.mock.calls[0]?.[0].runId).toBe("run-one");
|
||||
expect(result.cleanupLocation).toEqual({ runId: "run-one", target: mode.target });
|
||||
expect(result.env.PAPERCLIP_GITHUB_BROKER_TOKEN).toBe("current-run-secret");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("keeps anonymous wrappers usable after run cleanup and excludes sandbox image credentials", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "native-anonymous-github-"));
|
||||
try {
|
||||
const bin = path.join(root, "bin");
|
||||
await mkdir(bin);
|
||||
await mkdir(path.join(root, ".config", "gh"), { recursive: true });
|
||||
await writeFile(path.join(root, ".config", "gh", "hosts.yml"), "ambient-image-secret");
|
||||
await writeFile(path.join(bin, "gh"), `#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
process.stdout.write(JSON.stringify({token:process.env.GH_TOKEN || '', githubToken:process.env.GITHUB_TOKEN || '', ssh:process.env.SSH_AUTH_SOCK, global:process.env.GIT_CONFIG_GLOBAL, imageConfig:fs.existsSync(process.env.GH_CONFIG_DIR + '/hosts.yml')}));`, { mode: 0o700 });
|
||||
const imageEnv = { HOME: root, PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, GH_TOKEN: "ambient-token", GITHUB_TOKEN: "ambient-other-token", SSH_AUTH_SOCK: "/ambient/socket" };
|
||||
const execute: CommandManagedRuntimeRunner["execute"] = async (input) => {
|
||||
const startedAt = new Date().toISOString();
|
||||
const child = promisify(execFile)(input.command, input.args ?? [], { cwd: input.cwd ?? root, env: { ...imageEnv, ...input.env }, timeout: 15_000 });
|
||||
child.child.stdin?.on("error", () => undefined);
|
||||
child.child.stdin?.end(input.stdin ?? "");
|
||||
const result = await child;
|
||||
return { ...result, exitCode: 0, signal: null, timedOut: false, pid: null, startedAt };
|
||||
};
|
||||
const sandboxTarget = { ...target, remoteCwd: root, runner: { execute } };
|
||||
const base = { native: true, githubConfigured: false, agentId: "agent-a", target: sandboxTarget,
|
||||
cwd: root, env: imageEnv, brokerUrl: "https://unused.invalid", createBrokerToken: () => { throw new Error("must not mint a capability"); } };
|
||||
const first = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-one" });
|
||||
await cleanupGitHubOperationLaunchers({ runId: "run-one", target: sandboxTarget });
|
||||
const second = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-two" });
|
||||
expect(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR).toBe(second.env.PAPERCLIP_GITHUB_LAUNCHER_DIR);
|
||||
expect(await readFile(path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain("ambient-token");
|
||||
// A still-live provider uses its first-turn environment, not the new one.
|
||||
const command = await execute({ command: path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), env: first.env });
|
||||
expect(JSON.parse(command.stdout)).toEqual({ token: "", githubToken: "", ssh: "", global: "/dev/null", imageConfig: false });
|
||||
const changedPath = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-three", env: { ...imageEnv, PATH: `${imageEnv.PATH}:/extra` } });
|
||||
expect(changedPath.env.PAPERCLIP_GITHUB_LAUNCHER_DIR).not.toBe(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
|
||||
import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
type LauncherInput = Parameters<typeof prepareGitHubOperationLaunchers>[0];
|
||||
|
||||
export async function prepareHeartbeatGitHubLaunchers(
|
||||
input: LauncherInput & {
|
||||
native: boolean;
|
||||
githubConfigured: boolean;
|
||||
agentId: string;
|
||||
brokerUrl: string;
|
||||
createBrokerToken: () => string;
|
||||
},
|
||||
prepareLaunchers = prepareGitHubOperationLaunchers,
|
||||
cleanupLaunchers = cleanupGitHubOperationLaunchers,
|
||||
) {
|
||||
// An unconfigured sandbox has no managed GitHub identity to broker. Its
|
||||
// token-free wrappers still isolate image credentials, but may live as long
|
||||
// as the workspace so a warm provider never inherits a deleted run path.
|
||||
// Configured identities keep their run-scoped capability/retirement rules.
|
||||
const anonymous = input.native && !input.githubConfigured &&
|
||||
input.target?.kind === "remote" && input.target.transport === "sandbox";
|
||||
const location = {
|
||||
runId: anonymous
|
||||
? `anonymous-${input.agentId}-${createHash("sha256").update(input.env.PATH ?? "").digest("hex").slice(0, 16)}`
|
||||
: input.runId,
|
||||
target: input.target,
|
||||
};
|
||||
const env = githubBrokerEnvironment(input.env, {
|
||||
url: anonymous ? "" : input.brokerUrl,
|
||||
token: anonymous ? "" : input.createBrokerToken(),
|
||||
});
|
||||
try {
|
||||
return {
|
||||
env: await prepareLaunchers({ ...location, cwd: input.cwd, env }),
|
||||
cleanupLocation: anonymous ? null : location,
|
||||
};
|
||||
} catch (error) {
|
||||
// The caller cannot retain a cleanup location until staging returns. Clean
|
||||
// partial run-specific files here, preserving the original staging failure.
|
||||
// Anonymous files may still belong to a live warm provider; never delete them.
|
||||
if (!anonymous) await cleanupLaunchers(location).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -565,22 +565,38 @@ export function findHeartbeatRunCompletionComment<T extends { id: string }>(
|
||||
return comments[0] ?? null;
|
||||
}
|
||||
|
||||
const progressCommentIds = new Set<string>();
|
||||
const generatedCommentIds = new Set<string>();
|
||||
for (const receipt of Object.values(receipts)) {
|
||||
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt))
|
||||
continue;
|
||||
const receiptRecord = receipt as Record<string, unknown>;
|
||||
if (receiptRecord.operationId !== "report_progress") continue;
|
||||
const result = receiptRecord.result;
|
||||
if (!result || typeof result !== "object" || Array.isArray(result))
|
||||
continue;
|
||||
const commentId = (result as Record<string, unknown>).commentId;
|
||||
if (typeof commentId === "string" && commentId.length > 0) {
|
||||
progressCommentIds.add(commentId);
|
||||
const receiptResult = result as Record<string, unknown>;
|
||||
if (receiptRecord.operationId === "report_progress") {
|
||||
const commentId = receiptResult.commentId;
|
||||
if (typeof commentId === "string" && commentId.length > 0) {
|
||||
generatedCommentIds.add(commentId);
|
||||
}
|
||||
} else if (
|
||||
receiptRecord.operationId === "register_deliverable" &&
|
||||
typeof receiptResult.attachmentId === "string" &&
|
||||
receiptResult.commandId === `deliverable-prepared:${receiptResult.attachmentId}` &&
|
||||
["applied", "duplicate"].includes(String(receiptResult.disposition)) &&
|
||||
Array.isArray(receiptResult.entityRefs)
|
||||
) {
|
||||
// File preparation binds an attachment to a generated comment. That
|
||||
// receipt is not the agent's final answer. Use the server-issued entity
|
||||
// references, never the comment's wording, to exclude its comment from
|
||||
// final-response deduplication. The attachment and binding stay intact.
|
||||
for (const ref of receiptResult.entityRefs) {
|
||||
if (typeof ref === "string") generatedCommentIds.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
comments.find((comment) => !progressCommentIds.has(comment.id)) ?? null
|
||||
comments.find((comment) => !generatedCommentIds.has(comment.id)) ?? null
|
||||
);
|
||||
}
|
||||
@@ -45,10 +45,9 @@ import {
|
||||
unadmittedChatWakeupCondition,
|
||||
type DurableChatWakeupRequest,
|
||||
} from "./durable-chat-wakeup.js";
|
||||
import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
|
||||
import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js";
|
||||
import {
|
||||
cleanupGitHubOperationLaunchers,
|
||||
prepareGitHubOperationLaunchers,
|
||||
prepareGitHubExecutionEnvironment,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
@@ -22270,27 +22269,25 @@ export function heartbeatService(
|
||||
for (const key of MANAGED_GITHUB_TOKEN_KEYS) secretKeys.add(key);
|
||||
context.githubAuthenticationMode = useHostGitHub ? "host" : "managed";
|
||||
if (!useHostGitHub) {
|
||||
const githubBrokerToken = createRuntimeToolsToken({
|
||||
const githubLaunchers = await prepareHeartbeatGitHubLaunchers({
|
||||
native: agent.adapterType === "paperclip_runner",
|
||||
githubConfigured: githubSelection.configured,
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
responsibleUserId: responsibleUserId ?? "",
|
||||
scope: "github_credentials",
|
||||
});
|
||||
const githubBrokerEnv = githubBrokerEnvironment(gitExecutionEnv, {
|
||||
url: configuredPaperclipApiBaseUrl() ?? "",
|
||||
token: githubBrokerToken?.token ?? "",
|
||||
});
|
||||
githubLauncherLocation = { runId: run.id, target: executionTarget };
|
||||
runtimeConfig = {
|
||||
...runtimeConfig,
|
||||
env: await prepareGitHubOperationLaunchers({
|
||||
target: executionTarget,
|
||||
cwd: executionWorkspace.cwd,
|
||||
env: gitExecutionEnv,
|
||||
brokerUrl: configuredPaperclipApiBaseUrl() ?? "",
|
||||
createBrokerToken: () => createRuntimeToolsToken({
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
target: executionTarget,
|
||||
cwd: executionWorkspace.cwd,
|
||||
env: githubBrokerEnv,
|
||||
}),
|
||||
};
|
||||
responsibleUserId: responsibleUserId ?? "",
|
||||
scope: "github_credentials",
|
||||
})?.token ?? "",
|
||||
});
|
||||
githubLauncherLocation = githubLaunchers.cleanupLocation;
|
||||
runtimeConfig = { ...runtimeConfig, env: githubLaunchers.env };
|
||||
secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN");
|
||||
}
|
||||
context.paperclipEnvironment = {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { createLocalDiskStorageProvider } from "../../storage/local-disk-provide
|
||||
import { createStorageService } from "../../storage/service.js";
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { findHeartbeatRunCompletionComment, resolveHeartbeatRunResponse } from "../heartbeat-run-summary.js";
|
||||
import {
|
||||
renderNativeRunnerStagedAttachmentPrompt,
|
||||
stageNativeRunnerWakeAttachments,
|
||||
@@ -298,6 +299,20 @@ describe("native runner file handoff", () => {
|
||||
downloadPath: first.downloadPath,
|
||||
});
|
||||
|
||||
// Exercise real persisted comments with the actual applied/duplicate receipts.
|
||||
// Preparing the attachment must not consume the agent's final reply slot.
|
||||
const preparedComments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
for (const receipt of [first, replayWithNewKey]) {
|
||||
const resultJson = { semanticToolReceipts: { file: { operationId: "register_deliverable", result: receipt } } };
|
||||
const existingComment = findHeartbeatRunCompletionComment(preparedComments, resultJson);
|
||||
expect(existingComment).toBeNull();
|
||||
expect(resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
existingComment,
|
||||
finalAgentMessage: { text: "Here is the requested answer.", channel: "final", sourceEventId: "final-1" },
|
||||
})).toMatchObject({ text: "Here is the requested answer.", decision: { commentAction: "create" } });
|
||||
}
|
||||
|
||||
await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`])))
|
||||
.resolves.toContain("Completion report accepted");
|
||||
const otherIssueId = "00000000-0000-4000-8000-000000009111";
|
||||
|
||||
@@ -100,7 +100,7 @@ canonical Plan revision, capture its pending UI, approve in the browser, and
|
||||
prove exactly two successful runs. `warm_three_turn` provides exactly two
|
||||
browser follow-up messages, preserves one project/execution-workspace scope,
|
||||
verifies host file contents after every turn, and finishes within three
|
||||
ten-minute turn deadlines.
|
||||
ten-minute turn deadlines. Native turns 1 and 2 include an actionable human review in the completion report's `attentionRequests`. Paperclip creates the review gate from that report. An explicit question-tool wait yields the turn and suppresses its final prose, so it is not interchangeable with this completion-review fixture. Turn 3 reports Done without another review.
|
||||
|
||||
Every selected case runs in its own isolated Paperclip process, and independent
|
||||
cases may run concurrently. Follow-up turns inside one case retain their shared
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePrpResultSignals } from "../../packages/paperclip-runner/src/protocol/result-normalization.js";
|
||||
import {
|
||||
connectionReviewSuite,
|
||||
runnerEnvironments,
|
||||
@@ -25,6 +26,19 @@ import {
|
||||
} from "./selectors.js";
|
||||
|
||||
describe("runner E2E catalog", () => {
|
||||
it("supplies an actionable human review in native warm completion examples", () => {
|
||||
const prompts = [daytonaWarmContinuityTask.buildPrompt("nonce"), ...daytonaWarmContinuityTask.buildFollowupMessages!("nonce")];
|
||||
for (const [index, prompt] of prompts.entries()) {
|
||||
const match = prompt.match(/attentionRequests:(\[.*?\]),evidence:/);
|
||||
expect(match).not.toBeNull();
|
||||
const signals = normalizePrpResultSignals({ attentionRequests: JSON.parse(match![1]) });
|
||||
expect(signals.ignoredAttentionRequests).toEqual([]);
|
||||
expect(signals.actionableAttentionRequests).toHaveLength(index === 2 ? 0 : 1);
|
||||
if (index < 2) expect(signals.actionableAttentionRequests[0]).toMatchObject({kind:"review", ownerClass:"human"});
|
||||
expect(prompt).not.toContain("call request_human_input");
|
||||
}
|
||||
});
|
||||
|
||||
it("defines sixteen local connection-review journeys without expanding the default matrix", () => {
|
||||
expect(connectionReviewSuite.expectedMatrixSize).toBe(16);
|
||||
expect(new Set(connectionReviewSuite.profiles.map(profile => profile.id))).toEqual(new Set(["runner-codex", "runner-acpx-claude", "legacy-codex", "legacy-claude"]));
|
||||
|
||||
@@ -811,6 +811,10 @@ function warmTurnInstructions(turn: 1 | 2 | 3, nonce: string) {
|
||||
);
|
||||
const marker = warmTurnMarker(turn, nonce);
|
||||
const finalTurn = turn === 3;
|
||||
const reviewRequests = finalTurn ? [] : [{
|
||||
kind: "review", ownerClass: "human",
|
||||
summary: `Review the verified turn ${turn} workspace file and decide whether this task is ready to complete or needs another revision.`,
|
||||
}];
|
||||
const legacyCompletion = finalTurn
|
||||
? `In a legacy runner, make exactly one public-API completion write after verification: PATCH /api/issues/$PAPERCLIP_TASK_ID with {"status":"done","comment":"${marker}"}. Include Authorization and X-Paperclip-Run-Id. Do not POST a separate comment.`
|
||||
: `In a legacy runner, after verification POST exactly one request_confirmation to /api/issues/$PAPERCLIP_TASK_ID/interactions with {"kind":"request_confirmation","idempotencyKey":"daytona-warm-review-T${turn}-${nonce}","resolverPolicy":"human_only","title":"Warm continuity turn ${turn}","summary":"Review completed warm continuity turn ${turn}.","continuationPolicy":"wake_assignee","payload":{"version":1,"prompt":"Is this warm continuity task ready to complete after turn ${turn}?","acceptLabel":"Approve completion","rejectLabel":"Continue work","rejectRequiresReason":true,"allowDeclineReason":true,"supersedeOnUserComment":false,"target":{"type":"custom","key":"daytona_warm_turn_${turn}","revisionId":"${nonce}-T${turn}","label":"Warm continuity turn ${turn}"}}}. Capture the returned interaction id. Then make exactly one issue PATCH with {"status":"in_review","comment":"${marker}","reviewInteractionId":"<returned interaction id>"}. Include Authorization and X-Paperclip-Run-Id on both writes. If the issue PATCH fails, retry only that PATCH and never create another interaction. Do not POST a separate comment. After both writes succeed, end the response and heartbeat immediately; do not wait or poll because the reviewer action will start the next turn.`;
|
||||
@@ -820,7 +824,7 @@ function warmTurnInstructions(turn: 1 | 2 | 3, nonce: string) {
|
||||
? `Create ${file} with exactly this one line followed by a newline: ${lines[0]}`
|
||||
: `Before changing anything, read ${file} and verify its content is exactly ${lines.slice(0, -1).join("\\n")} followed by a newline. Then append exactly ${lines.at(-1)} followed by a newline.`,
|
||||
`After the write, verify ${file} contains exactly these lines, once each and in order: ${lines.join(" | ")}.`,
|
||||
`In a native runner, use the current turn completion revision and criterion IDs (CURRENT_TURN_REVISION below is a placeholder, not a literal), then call paperclip_finish exactly once with {reportedWorkDisposition:"${finalTurn ? "done" : "needs_review"}",summary:"${marker}",completionClaim:{contractRevision:CURRENT_TURN_REVISION,objectiveSatisfied:true,criteria:[{criterionId:"objective",status:"satisfied",evidenceRefs:[]}],remainingWork:[]},evidence:[],verification:[{commandOrCheck:"read ${file}",status:"passed"}]}. Wait for that tool call to succeed, then emit exactly ${marker} once as the complete user-facing final response.`,
|
||||
`In a native runner, use the current turn completion revision and criterion IDs (CURRENT_TURN_REVISION below is a placeholder, not a literal), then call paperclip_finish exactly once with {reportedWorkDisposition:"${finalTurn ? "done" : "needs_review"}",summary:"${marker}",completionClaim:{contractRevision:CURRENT_TURN_REVISION,objectiveSatisfied:true,criteria:[{criterionId:"objective",status:"satisfied",evidenceRefs:[]}],remainingWork:[]},attentionRequests:${JSON.stringify(reviewRequests)},evidence:[],verification:[{commandOrCheck:"read ${file}",status:"passed"}]}. Wait for that tool call to succeed, then emit exactly ${marker} once as the complete user-facing final response.`,
|
||||
legacyCompletion,
|
||||
`In a legacy runner, the PATCH comment is the complete visible response. After its 2xx response, finish silently: do not print, echo, or emit ${marker} again as assistant text.`,
|
||||
`Do not include ${marker} in any other visible response or write. Do not recreate, truncate, reorder, or duplicate prior lines.`,
|
||||
|
||||
@@ -166,3 +166,20 @@ export async function evaluateMatchers(
|
||||
matchers.map((matcher) => evaluateMatcher(matcher, observation)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function persistedFinalRunMessage(
|
||||
comments: Array<{ id: string; createdByRunId?: string | null; body?: string | null }>,
|
||||
run: { id: string; resultJson?: Record<string, unknown> | null },
|
||||
): string {
|
||||
const runComments = comments.filter(comment => comment.createdByRunId === run.id);
|
||||
const decision = run.resultJson?.presentationDecision;
|
||||
const selectedId = decision && typeof decision === "object" && !Array.isArray(decision)
|
||||
? (decision as Record<string, unknown>).commentId : null;
|
||||
// Read the persisted comment selected for the user, not a finish summary or
|
||||
// attachment preparation message. Missing selected evidence must still fail.
|
||||
if (typeof selectedId === "string") {
|
||||
return runComments.find(comment => comment.id === selectedId)?.body ?? "";
|
||||
}
|
||||
return runComments.map(comment => comment.body ?? "").join("\n");
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { classifyFailure } from "./failure-classifier.js";
|
||||
import { runnerE2EServerControlPaths } from "./harness-env.js";
|
||||
import { setupConnectionReview } from "./connection-reviews.js";
|
||||
import { setupLiveFixtures, type LiveFixtureValues } from "./live-fixtures.js";
|
||||
import { evaluateMatcher, type MatcherResult } from "./matchers.js";
|
||||
import { evaluateMatcher, persistedFinalRunMessage, type MatcherResult } from "./matchers.js";
|
||||
import {
|
||||
acceptedPlanSessionResetFailures,
|
||||
hasTerminalMalformedPlanConfirmation,
|
||||
@@ -1707,10 +1707,7 @@ for (const execution of executions) {
|
||||
const message = agentComments
|
||||
.map((comment) => comment.body ?? "")
|
||||
.join("\n");
|
||||
const finalRunMessage = agentComments
|
||||
.filter((comment) => comment.createdByRunId === finalRun.id)
|
||||
.map((comment) => comment.body ?? "")
|
||||
.join("\n");
|
||||
const finalRunMessage = persistedFinalRunMessage(agentComments, finalRun);
|
||||
const pendingInteractions = terminal.interactions.filter(
|
||||
(interaction) => interaction.status === "pending",
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "./harness-env.js";
|
||||
import { runnerExecutionById, runnerMatrix } from "./catalog.js";
|
||||
import { assertEmbeddedDatabaseIsolation } from "./instance-isolation.js";
|
||||
import { evaluateMatchers } from "./matchers.js";
|
||||
import { evaluateMatchers, persistedFinalRunMessage } from "./matchers.js";
|
||||
import {
|
||||
assertSecretFree,
|
||||
findSecretLeak,
|
||||
@@ -1310,3 +1310,24 @@ describe("runner E2E macOS shared-memory cleanup", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("persisted final response selection", () => {
|
||||
const comments = [
|
||||
{ id: "attachment-comment", body: "Prepared file for this response.", createdByRunId: "run-1" },
|
||||
{ id: "reply", body: "FINAL", createdByRunId: "run-1" },
|
||||
{ id: "other-run", body: "unrelated", createdByRunId: "run-2" },
|
||||
];
|
||||
const run = { id: "run-1", resultJson: { presentationDecision: { commentId: "reply" } } };
|
||||
it("grades the real final comment independently from an attachment's preparation comment", () => {
|
||||
expect(persistedFinalRunMessage(comments, run)).toBe("FINAL");
|
||||
});
|
||||
it("fails closed when the selected final comment is missing or belongs to another run", () => {
|
||||
expect(persistedFinalRunMessage(comments.slice(0, 1), run)).toBe("");
|
||||
expect(persistedFinalRunMessage(comments, { ...run, resultJson: { presentationDecision: { commentId: "other-run" } } })).toBe("");
|
||||
});
|
||||
it("keeps legacy fallback and does not replace absent visible text with a summary", () => {
|
||||
expect(persistedFinalRunMessage(comments, { id: "run-1" })).toBe("Prepared file for this response.\nFINAL");
|
||||
expect(persistedFinalRunMessage([], { id: "run-1", resultJson: { summary: "FINAL" } })).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user