Merge commit '1c892ff4640370f2a658beab3339080fd4a86de4' into codex/runner-hosted-admission

* commit '1c892ff4640370f2a658beab3339080fd4a86de4':
  fix(recovery): preserve native workspaces after control deadlines (#17788)
  fix(evals): share bounded request policy with qualification (#17765)
  fix(tasks): hide empty message history disclosure (#17751)
  perf(runner): keep event journal writes off the HTTP thread (#17745)
  Bound native OpenCode assigned-tool catalog projection (#17740)
  fix(ui): keep iPhone task chat stable while typing and streaming (#17232)
  fix(runner): preserve disabled Codex web search (#17744)
  fix(ui): identify accounts in AI connection choices (#17738)
  fix(recovery): wake retained workspace preservation after Retry (#17741)
  fix(chat): admit task Retry after GitHub reconnect (#17733)
  fix(runner): allow bounded controller admission before welcome (#17735)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 14:22:48 -05:00
commit dbce6f0260
119 files changed
+3583 -803

No files matched your search

+6 -2
View File
@@ -60,8 +60,12 @@ title sizes remain larger.
The bottom navigation responds to accumulated scrolling, ignoring small
reversals and Safari's edge bounce. It glides out and eases back in with shared
motion tokens; the task composer follows the same motion. Keep page padding
stable while the navigation moves, and honor reduced-motion preferences.
motion tokens. Keep page padding stable while the navigation moves, and honor
reduced-motion preferences.
Mobile task conversations size their shell to the visible viewport, with one
message scroller and a normal flex footer for the composer. Navigation stays at
the layout viewport bottom, behind the keyboard.
Cold task opens reveal the instruction and recent conversation independently of
plans, related metadata, and historical run activity. Show the latest human
+8
View File
@@ -1983,6 +1983,14 @@ runs; immutable run descriptors remain readable. The **Paperclip Runner**
experimental setting applies to other native providers. Native Codex, OpenCode,
ACPX Claude, and ACPX Pi do not require it. Company access checks still apply.
Native Codex preserves an explicit `adapterConfig.search: false` when converting
or saving an agent. The disabled hosted-search policy is recorded in the native
execution input and launch requirements, so prepared launches and recovery keep
it. An absent setting retains the provider default; this does not add support
for converting a Legacy agent with `search: true`. Authentication hello probes
always disable hosted web search because they only verify the selected account
and model.
OpenCode requires an explicit `provider/model` before testing or finishing setup.
An inactive setup draft can be saved before choosing a model. Automatic native
selection uses `allow` permissions and a `per_turn` lifecycle. Native `ask` and
@@ -1,6 +1,24 @@
# Copilot 1.0.88 rich ACP capability audit
Current integration (2026-10-17): **Copilot profile v21 remains pending** with
Current source declaration (2026-10-26): **Copilot profile v61** binds controller
journal retirement and the historical profile decoder. Its digest is
`sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3`.
The v60 declaration remains unchanged and readable as history. This source
identity update changes no Copilot executable, arguments, permission policy,
tool receipts, or qualification flags. Offline compatibility checks supply no
new live qualification; prior live outcomes retain their original identities.
Historical source declaration (2026-10-26): **Copilot profile v60** binds the shared
sources that carry Codex's explicit hosted-search restriction. Its digest is
`sha256:ca3c2b38ed5f6e25cf1c96c1f73781a86cc90c1c0add93f64716c66f28b3f3aa`.
The v59 declaration remains unchanged and readable as history. Exact-current
launch admission still applies. The Copilot executable, arguments, permissions,
receipts, and qualification flags are unchanged. Copilot authentication probes
do not carry the Codex-only setting. Offline compatibility checks do not supply
new live qualification; prior live outcomes remain attached to their original
source identities.
Historical integration (2026-10-17): **Copilot profile v21 remained pending** with
digest `sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a`.
It binds the shared provider completion contract, direct driver, production sidecar, and controller
continuation resolver. Installation verification requires this generated v21
+6
View File
@@ -59,6 +59,12 @@ the selected connection owns its provider routing.
Changing those separately may make a binding incompatible; saving then requires
a compatible choice. Agent configuration cannot grant access to another account.
Agent account choices and legacy-adoption confirmations show the sign-in method,
personal or company-shared scope, and available account or owner identity beside
the connection name. Accounts with the same provider name can be distinguished
before selection. These labels use existing authorized account metadata, never
credentials or authentication paths.
Personal defaults are unique per company, user, and provider. A Claude bot can use one user’s subscription and another user’s API key without changing its harness or model. Explicit shared selections remain pinned to the selected account and method.
The first successful personal connection sets a default only when none exists.
The additive `ai_provider_defaults` table preserves the legacy per-method preferences. Migration selects each user’s most recently updated provider preference (including unavailable accounts), and rerunning it never overwrites a provider default. New writes maintain the legacy table for older servers. A database trigger propagates older servers’ explicit default updates to the provider default. Inserting an additional method default does not replace an existing provider default.
+52 -6
View File
@@ -730,7 +730,16 @@ recovery path with the specific scan cause and an operator action. Generic
stranded-work recovery must not grant another budget for these errors. This
does not automatically replay historical generic `setup_failed` runs.
### ACP startup handshake bound
### Runner authentication and ACP startup handshake bounds
The native Runner waits up to the ten-second connection setup budget for the
authenticated welcome. The current execution or reconnect deadline can shorten
that wait. Controller admission and durable lease persistence happen before the
welcome, so a short internal response limit must not discard an otherwise valid
bootstrap ticket during that work. A failed welcome still makes the one-use
ticket unsafe to replay. Authentication proofs, credential expiry, and identity
checks remain required. Steady-state reads return to their 250 ms polling bound
after the welcome.
An adapter-backed live path also requires that the ACP startup handshake itself cannot hang forever. The engine bounds the handshake with a fixed startup deadline and a poll of the duplex control-channel disposition. Either condition ends the handshake and reports a closed, typed code, so the issue can reach a settled disposition instead of staying `in_progress` with no observable next action.
@@ -1327,6 +1336,13 @@ does not reset the provider-attempt budget.
Provider execution and control-plane finalization have different clocks. A healthy provider can think or execute a long tool without output. Once execution settles, recovery and finalization control steps have a 60-second deadline, checked on startup and every 15 seconds. With a healthy database and scheduler, an abandoned transition must be repaired or surfaced within 90 seconds. Terminal persistence must not wait on provider cleanup or publication; a late finalizer cannot change a reassigned or closed task or release another run's locks. Historical ambiguous runs are never automatically replayed after an upgrade.
When that deadline fails a native run with a prepared workspace, the same
transaction records the exact source's file-preservation obligation. Existing
stop-and-retain recovery contains the provider and saves the files before
ordinary Retry can continue. Finalized workspaces, mismatched sources and
accepted results keep their existing settlement paths. Reassignment does not
discard the stopped run's files or grant authority over the new assignee.
Every continuation carries the triggering request, ordered user direction, interaction outcomes, completed work, and explicit history coverage. A delivered message remains part of the task's request after its connection or approval resolves. The original title is background; a completed Notion read does not satisfy a later Gmail request. Author and source-trust boundaries survive rendering into both native and legacy prompts. Missing required history must be fetched before dispatch rather than described as complete.
### Interrupted conversation continuation
@@ -1376,14 +1392,25 @@ accepted native result. A successful `workspace_recovery` operation and matching
recovery record prove persistence; adapter output or a user repair note cannot.
The failed run and unknown external effects remain historical facts.
Startup and periodic recovery discover these obligations through the retained
workspace index. Two background exports per server keep unrelated recovery
responsive. Automatic file recovery has three total attempts with a 30-second
delay. An idempotent board request through
Startup and the retained-workspace sweep reconstruct these obligations through
the retained workspace index. Committed Retry, recovery admission, and recovery
progress writes wake a separate preservation worker. It follows the persisted
retry deadline and becomes idle when no eligible recovery remains. Two background
exports per server keep unrelated execution finalization responsive. A resolved
exact-source files-only obligation remains eligible; unrelated resolved or
cancelled recovery history does not keep this worker active. The persisted
attempt budget applies across restarts. An idempotent board request through
`POST /api/issues/:id/recovery-actions/recover-workspace` admits one additional
attempt after a transient failure. It requires task mutation access, accepts no
infrastructure certification, and grants no agent or provider execution authority.
Restarts do not reset the attempt limit. Missing stop or descriptor proof keeps
Each completed export frees its own slot and wakes the next scan. A slow export
does not hold a free slot. Completed cleanup and saved-input obligations receive
one bounded pass through unvisited sources. The pass stops at its tail so paused
input does not keep the worker active; a committed change starts a fresh pass.
Shutdown joins this worker's exports before closing the database. Repeated Retry
during physical preservation binds the canonical intent
without changing the attempt's generation or revision. Progress writes preserve
that newer intent so its fenced completion can still commit. Missing stop or descriptor proof keeps
the source allocated and exposes the specific service blocker.
File recovery alone never requests another agent turn. Existing saved human
@@ -1995,6 +2022,25 @@ not grant execution authority: calls still use the existing gateway policy,
approval, revocation, and audit paths.
Native OpenCode sessions use the server-owned assigned-tool relay for local and
remote execution. The provider receives only bounded search and call schemas,
even when its protocol can fit the full catalog. This bounds provider grammar
compilation independently of the transport's tool-count and byte limits. Search
returns pinned tool aliases and schemas, intersects fresh grants, and respects
planning/ask restrictions. OpenCode discovery pages use a 32 KiB budget; larger
schemas use 16K-character chunks so JSON escaping and the result envelope fit
its HTTP MCP bridge. Other harnesses keep the existing PRP discovery budgets.
Calls retain the original gateway binding and its
argument validation, approvals, revocation, and audit rules; prompt arguments
cannot choose a gateway or credential. Assigned gateway launch credentials stay
on the controller. The MCP bridge preserves exact declared operation names,
including names that start with `paperclip_`; only a catalog-bound transport
prefix may resolve to that declared name. Shortened or unknown names do not
gain authority. OpenCode sessions with the former direct catalog rotate their
provider checkpoint before reuse; other providers retain their existing tool
contract fingerprints.
### Use real connection requests (2026-09-14)
When a user asks to connect a known service, the agent searches for that service
+20 -2
View File
@@ -15,8 +15,26 @@ new generation; a repeated HTTP request cannot.
The existing exact-run retry endpoint retains its source-bound contract.
Connected conversations use their existing inbound authorization owner. A board
Retry changes the workspace strategy, not the external principal, source batch,
conversation access or publication authority.
Retry preserves external principal, conversation access and publication checks.
A GitHub Retry selects already-saved, undelivered input before replaying the
failed run's older request. The selected receipts transfer to the retry owner
under the dispatch transaction. They link to its one successor, including after
a restart or a lost dispatch response. Rejected, unadmitted retries release the
saved input for another deliberate Retry.
A deliberate GitHub Retry can admit saved input after the same App reconnects.
It verifies the App binding in the accepted delivery or its historical working
comment receipt, then checks current sender, repository, conversation and task
access. The retry records the current runtime generation and credential-reference
fingerprint. Another reconnect invalidates that admission. Historical deliveries,
stale webhook callbacks and the exact-run retry contract retain their fences.
Task Retry keeps the complete saved-input batch, including batches larger than
the exact-run endpoint's limit. Source rows, receipts and invalidations are read
in batches. Within one transaction, messages with the same principal and automatic
policy context share a current-access check; every message still proves its App,
destination, comment link and immutable source. Different automatic policy contexts
receive separate access checks. Input in another conversation remains queued.
A connected-conversation Retry can save its exact request while the matching
system recovery generation verifies the old executor. Saving that wait grants
+18
View File
@@ -4,6 +4,17 @@ Product map: [Recovery](../feature-map/recovery.md).
Feature ID: `recovery`.
Native finalization deadline reconciliation records unfinished workspace
preservation through the existing server-owned recovery path. It grants no
additional agent API, provider-stop authority or permission to replay work.
Ordinary board Retry can reauthorize saved GitHub input for the same App after
reconnect. It pins current runtime authority and preserves sender, repository,
conversation and task checks. Pending input takes precedence over the older
failed request and transfers once to the retry owner. This grants no new agent
Retry API or stale webhook authority. Local PostgreSQL and HTTP-route regressions
cover these transitions; hosted execution and delivery remain separate acceptance.
Source revision: `3bbc2104b65caf9f7763938a01260a5673e12f7a`.
Review depth and executed proof are separate. The operation records enumerate the named behavior and access boundaries. Their evidence states describe the reviewed sources; run results and environment limits belong in the PR or verification artifacts. A listed API or source test does not establish a live authorized journey. Follow the [map contract](../README.md).
@@ -225,6 +236,13 @@ Gaps: An acknowledged retry can remain pending or fail; report actual useful res
Capability ID: `recovery/workspace`.
Committed authorized Retry and workspace-recovery requests notify the internal
preservation worker. Startup reconstructs its source-bound obligations and saved
retry deadlines. A duplicate request during physical preservation does not
replace the owner's generation or revision. Resolved files-only recovery retains
its no-replay restriction. The local PostgreSQL regression checks the producer,
worker, and physical source receipt; it is not deployed provider acceptance.
The internal preservation resume path grants no board or agent execution API.
It requires the exact source action, company, run, lease and environment, a
held physical workspace owner, and its current stop-only cleanup claim. The
+12
View File
@@ -162,6 +162,18 @@ No grant, token, authority cache or provider dispatch bypass is added. The
[executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
remain separate from live latency and permitted/denied agent acceptance.
Scoped OpenCode assigned-tool source evidence: local and remote native sessions
use the server-owned authenticated relay. The provider sees bounded
`paperclip_search_assigned_tools` and `paperclip_call_assigned_tool` schemas,
then uses pinned aliases. Search intersects fresh grants and work modes; calls
retain the run's original gateway policy, validation, approvals, revocation and
audit path. Prompt arguments cannot select gateway authority. Assigned gateway
launch credentials stay on the controller. Former direct-catalog OpenCode
checkpoints refresh; other provider fingerprints stay unchanged. The
[projection controls](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts)
and [wiring controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
are source evidence, not live permitted/denied sandbox acceptance.
For a linked Codex subscription with server-resolved managed credential provenance,
setup checks the resolved owner and stages a private probe copy before local or
remote isolation. Without a selected API key, an unbound linked `CODEX_HOME` is
+8
View File
@@ -128,6 +128,14 @@ from the hiring picker; API fixture creation does not prove this UI journey.
Automated: [instruction service](../../server/src/__tests__/agent-instructions-service.test.ts) and [instruction revisions](../../server/src/__tests__/agent-instruction-revisions.test.ts) cover file/revision contracts.
The [AI account field regression](../../ui/src/components/ai-connections/AiConnectionField.test.tsx)
checks duplicate provider names, account identity in choices and adoption confirmation,
the exact selected grant, and disabled unavailable accounts with authorized metadata fixtures.
Manually compare a personal API key and shared subscription with the same name in
Harness / Runtime. Confirm their method, scope and available identity before saving.
Reload the saved choice and verify the next eligible task uses it; labels alone
do not establish credential validity or change account access.
Manual: Save a harmless instruction edit, reload, and verify its revision and effect in the next eligible run. Pause and resume the disposable agent; confirm pause prevents new work and does not erase previous runs.
For failed setup, use an unavailable model or invalid command on a disposable agent.
+28
View File
@@ -33,6 +33,8 @@ Agent access: [companion](../agent-access-map/recovery.md).
workspace link when the destination is disabled. Local PostgreSQL and real
filesystem regressions cover native and legacy recovery and one-time delivery;
hosted provider recovery remains separate acceptance.
Native finalization deadline failures also retain the exact prepared source
for the same stop-and-save workflow before Retry admits another executor.
- `unknown-outcome`: `recovery/unknown-outcome` — uncertain external side effects require inspection and
an explicit decision, not automatic replay.
- `user-continuation`: `recovery/user-continuation` — saved user messages after a stop are reconsidered once
@@ -89,6 +91,14 @@ The connected-chat database regression in
exercises admission and queue continuation. It does not establish hosted
provider recovery or successful delivery.
For GitHub, save a follow-up behind a failed run, then reconnect the same App.
Click ordinary Retry and verify that one successor receives the saved follow-up
instead of replaying the older request. Reload and repeat the same request key;
there must be one successor and no duplicate publication. A second reconnect
after admission, changed App, revoked sender or changed repository must prevent
dispatch. An unadmitted rejection keeps the follow-up available for another
deliberate Retry. Historical deliveries must remain unchanged.
### `post-stop-message`
After a stop, send a new instruction on the same task or inspect one already
@@ -175,6 +185,13 @@ provider restart. For that journey, interrupt a disposable deployment after
workspace preparation and before its first executor claim, then confirm one
provider turn starts on the original run through the normal admission gates.
The [Runner transport regressions](../../packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs)
exercise a real WebSocket welcome delayed beyond two seconds. Authentication
uses the ten-second connection setup budget, clipped by the current lifecycle
deadline. The cases also retain failed-welcome ticket fencing, identity checks,
and reconnect replay without repeated provider effects. These transport cases
do not establish hosted provider acceptance.
Active remote process supervision distinguishes a failed health read from a
confirmed exit. The [executor tests](../../server/src/services/native-runtime/native-session-executor.test.ts)
exercise timeout recovery with a real listening process, unchanged ownership,
@@ -242,6 +259,17 @@ gap.
### `operator-retry`
The [workspace recovery regression](../../server/src/services/workspace-recovery.test.ts)
uses the real Retry producer and a quiet delivery coordinator. It checks that a
committed request starts one physical source preservation, duplicate Retry keeps
the active owner's receipt valid, and startup reconstructs a resolved files-only
obligation without provider replay. The [execution-control lifecycle suite](../../server/src/__tests__/execution-control-work.test.ts)
checks independent finalization, stored deadlines, and a bounded pass through
completed obligations followed by quiet paused input.
The physical export regression holds one export while another completes, then
verifies a third Retry can use the free slot and shutdown joins the held work.
These local checks do not prove recovery of a deployed allocation.
Automated: [disposition notices](../../ui/src/components/DispositionRecoveryNotice.test.tsx)
tests pending/error/gated states and waiting for the real retry acknowledgement.
The recovery-card suite covers workspace divergence, reissue, reconcile, and
+10
View File
@@ -145,6 +145,16 @@ is released before failure returns. Repeat a warm follow-up and retain its actua
reply time. The [executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
cover ordering, failure drain and credential projection; they do not qualify a
live response-time target.
For native OpenCode with assigned connections, inspect the provider tool surface
on both local and sandbox execution. It should contain bounded assigned-tool
search/call schemas. Discover an allowed tool, call its returned alias, revoke
its grant, and confirm discovery and execution enforce that revocation. Repeat
in planning/ask mode with a write tool and confirm denial. The controller must
retain the assigned gateway URL and token. An older direct-catalog OpenCode
checkpoint must refresh without changing another provider's fingerprint. The
[assigned-tool](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts)
and executor controls cover source behavior; live sandbox discovery and a real
call remain separate acceptance evidence.
Missing dependencies, failed authentication, invalid models and
incompatible custom settings must remain actionable errors without silent runner
switching. Saved unchanged execution can still be tested without applying new
+1 -1
View File
@@ -39,7 +39,7 @@ Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect.
### `task-detail`
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **More messages** loads earlier messages and run activity; incoming messages remain visible while the task stays open.
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **More messages** appears only when earlier comments or runs are omitted and loads that history. Fresh tasks with no omitted history have no disclosure. A single legacy run with a truncated log offers **More run output** instead. **More task actions → Show task activity** loads full activity even for a single native run; incoming messages remain visible while the task stays open.
### `task-sharing`
@@ -36,6 +36,11 @@ function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigVa
}
describe("buildCodexLocalConfig", () => {
it("preserves explicitly disabled hosted search in native Codex configuration", () => {
expect(buildPaperclipRunnerConfig(makeValues({ search: false }))).toHaveProperty("search", false);
expect(buildPaperclipRunnerConfig(makeValues({ search: true }))).not.toHaveProperty("search");
});
describe("qualified Copilot config", () => {
it("round-trips Copilot with the exact selected model", () => {
const first = buildPaperclipRunnerConfig(makeValues({ model: "gpt-5.6-luna", adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } }));
@@ -171,7 +176,6 @@ describe("buildPaperclipRunnerConfig", () => {
"engine",
"agentCommand",
"stateDir",
"search",
"fastMode",
"dangerouslyBypassApprovalsAndSandbox",
"command",
@@ -249,6 +249,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
provider,
...(provider === "codex"
? { model: resolvePaperclipRunnerModel("codex", config.model),
...(v.search === false ? { search: false } : {}),
...(configuredEffort ? { modelReasoningEffort: configuredEffort } : {}) }
: {}),
codexPermissionMode: "never",
+2 -2
View File
@@ -32,12 +32,12 @@
"requiresProviderPolicy": true
},
"copilot": {
"agentProfileVersion": 59,
"agentProfileVersion": 61,
"agentServerPackage": "@github/copilot",
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:2f39ffe54a6af832d044b5098c31df9e08d4d27aee0ac675ba4b0c9da7445c40",
"commandDigest": "sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3",
"requiresProviderPolicy": true
},
"claude": {
@@ -175,6 +175,22 @@ The ACK means that every source event through sequence 9 is committed or
deduplicated. The runner rejects an ACK that moves backward or beyond its
produced source cursor. It removes only events at or below a valid ACK.
The controller stages `item.delta` output snapshots with asynchronous file writes and file
flushes. A slow volume does not block unrelated HTTP requests during those
operations. The controller still publishes the snapshot atomically, flushes its
directory, and waits for persistence before acknowledging an event.
A newer command or authenticated connection can save the
same event while staging is pending; the older snapshot cannot replace that
newer durable state. A failed write fences further authority until reload.
Lifecycle, control, authentication, and semantic tool events retain synchronous
persistence before transport readers can observe their state. Semantic tool
admission and dispatch cannot yield to a replacement connection between those operations.
Controller replacement must still stop ingress and drain admitted processing
before a successor reuses the journal. External control routes stop their
reconnect loop before the controller joins admitted work. An incomplete drain
cannot certify a reusable checkpoint. Snapshots use compact JSON; existing
indented snapshots remain readable.
For the lost-ACK fault, the mock commits an event, drops the ACK and socket, and
reports the prior cursor once after reconnect. The runner sends the same bytes
again. The mock increments delivery count, keeps one logical event, and sends
@@ -26,7 +26,7 @@ use super::{
const SECURE_FRAME_SCHEMA: &str = "paperclip.runner.secure-frame.v1";
const AUTH_TIMEOUT: Duration = Duration::from_secs(2);
const WELCOME_TIMEOUT: Duration = Duration::from_secs(2);
const WELCOME_TIMEOUT: Duration = CONNECT_TOTAL_TIMEOUT;
const RUNTIME_READ_TIMEOUT: Duration = Duration::from_millis(250);
const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(3);
const CONNECT_TOTAL_TIMEOUT: Duration = Duration::from_secs(10);
@@ -1106,8 +1106,9 @@ impl AuthenticatedTransport {
max_frame_bytes: config.max_frame_bytes,
};
// Ticket validation and lease persistence happen before the server
// emits welcome. Keep that work bounded without assuming a loaded
// control-plane event loop can always respond within 250 ms.
// emits welcome. Give that durable admission the connection setup
// budget; a two-second wait can discard a consumed ticket under
// controller load. The execution/reconnect deadline still wins.
let welcome_deadline = bounded_operation_deadline(connect_deadline, WELCOME_TIMEOUT)?;
let welcome_timeout = welcome_deadline.saturating_duration_since(Instant::now());
if welcome_timeout.is_zero() {
@@ -2358,6 +2359,115 @@ mod tests {
assert!(validate_control_identity(&envelope, &state, Some(&connection)).is_err());
}
#[test]
fn bootstrap_welcome_waits_for_bounded_controller_admission() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let mut config = config(port);
config.max_runtime = Duration::from_secs(10);
let state = test_state(&config);
let server_config = config.clone();
let server_state = state.clone();
let server = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut socket = accept(stream).unwrap();
let expires = current_unix_ms().unwrap() + 60_000;
let mut secure = server_authenticate(
&mut socket,
&server_config,
&server_state,
ServerCredential {
token: "bootstrap-secret",
kind: "bootstrap",
lease_id: None,
expires_at_unix_ms: expires,
revocation_epoch: 0,
},
);
// The authority has the client proof. Durable admission and lease
// persistence may take longer than the old two-second welcome wait.
thread::sleep(Duration::from_secs(3));
send_secure(
&mut socket,
&mut secure,
&server_config,
&welcome(
&server_state,
"connection_1",
Some("lease-secret"),
expires,
0,
vec![],
),
);
});
let endpoint = RunnerTransportEndpoint::new(&config.connect_url, &config.run_id).unwrap();
let ticket = BootstrapTicket::new("bootstrap-secret".to_owned()).unwrap();
let result = AuthenticatedTransport::connect(
&endpoint,
&config,
&state,
Some(&ticket),
None,
Instant::now() + config.max_runtime,
);
server.join().unwrap();
let (transport, greeting) = result.unwrap().unwrap();
assert_eq!(greeting.lease.unwrap().expose().unwrap(), "lease-secret");
assert_eq!(
transport.socket.read_timeout().unwrap(),
Some(RUNTIME_READ_TIMEOUT)
);
}
#[test]
fn bootstrap_welcome_retains_the_shorter_lifecycle_deadline() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let config = config(port);
let state = test_state(&config);
let server_config = config.clone();
let server_state = state.clone();
let (release, held) = mpsc::channel();
let server = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut socket = accept(stream).unwrap();
server_authenticate(
&mut socket,
&server_config,
&server_state,
ServerCredential {
token: "bootstrap-secret",
kind: "bootstrap",
lease_id: None,
expires_at_unix_ms: current_unix_ms().unwrap() + 60_000,
revocation_epoch: 0,
},
);
held.recv_timeout(Duration::from_secs(2)).unwrap();
});
let endpoint = RunnerTransportEndpoint::new(&config.connect_url, &config.run_id).unwrap();
let ticket = BootstrapTicket::new("bootstrap-secret".to_owned()).unwrap();
let started = Instant::now();
let result = AuthenticatedTransport::connect(
&endpoint,
&config,
&state,
Some(&ticket),
None,
started + Duration::from_secs(1),
);
release.send(()).unwrap();
server.join().unwrap();
let failure = match result {
Err(error) => error,
Ok(_) => panic!("welcome authenticated after the lifecycle deadline"),
};
assert!(failure.bootstrap_maybe_consumed);
assert!(started.elapsed() < Duration::from_secs(2));
assert!(failure.error.to_string().contains("deadline elapsed"));
}
#[test]
fn authenticates_bootstrap_and_receives_bound_lease() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -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:2f39ffe54a6af832d044b5098c31df9e08d4d27aee0ac675ba4b0c9da7445c40",
"sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -8,6 +8,7 @@ import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const runtime = process.env.PAPERCLIP_CONTROLLER_TEST_RUNTIME
? pathToFileURL(resolve(process.env.PAPERCLIP_CONTROLLER_TEST_RUNTIME) + '/') : import.meta.url;
const { canonical, costRuntime, observabilityBoundConfiguration, requestBindingSha256, sha256, validateManifest } = await import(new URL('./manifest.mjs', runtime));
@@ -18,6 +19,8 @@ const { readRegularFile, replaceEvidenceFile } = await import(new URL('./evidenc
import { aggregateProtocolEvalCampaign, sanitizeProtocolEvalRuns } from '../runner-protocol-eval-campaign.mjs';
const { build } = createRequire(new URL('../../../../package.json', import.meta.url))('esbuild');
const childScript = options => `
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
@@ -54,10 +57,7 @@ if(args.has('--observability-config')) {
}
nativeObservability={mode:'controller_otlp',configured:true,verification:'unverified',projectId:config.projectId,traceId:config.traceId,spanId:config.spanId,descriptorSha256:createHash('sha256').update(raw).digest('hex')};
if(${Boolean(options.realObservabilityValidator)}) {
const {createRequire}=await import('node:module');
const require=createRequire(${JSON.stringify(fileURLToPath(new URL('../../../../server/package.json', import.meta.url)))});
const {tsImport}=await import(require.resolve('tsx/esm/api'));
const {prepareEvalObservabilityRuntime}=await tsImport(${JSON.stringify(fileURLToPath(new URL('../../src/cli/eval-observability-runtime.ts', import.meta.url)))},import.meta.url);
const {prepareEvalObservabilityRuntime}=await import(${JSON.stringify(options.observabilityValidatorModule ?? '')});
await mkdir(process.env.HOME+'/codex',{recursive:true});
const prepared=await prepareEvalObservabilityRuntime(args.get('--observability-config'),request,
{evidence:{campaignId:descriptor.campaignId,executionId:descriptor.executionId},transportOptions:{environment:{CODEX_HOME:process.env.HOME+'/codex'}}},process.env);
@@ -90,6 +90,11 @@ async function fixture(t, { count = 1, allocation = '0.01', providerProfile = 'c
const root = await mkdtemp(join(tmpdir(), 'runner-controller-test-'));
t.after(() => rm(root, { recursive: true, force: true }));
const cli = join(root, 'fake-cli.mjs'), binary = join(root, 'runnerd'), archive = join(root, 'runner.tgz');
if (options.realObservabilityValidator) {
options.observabilityValidatorModule = join(root, 'eval-observability-runtime.mjs');
await build({ entryPoints: [fileURLToPath(new URL('../../src/cli/eval-observability-runtime.ts', import.meta.url))],
outfile: options.observabilityValidatorModule, bundle: true, platform: 'node', format: 'esm', target: 'node24', logLevel: 'silent' });
}
await writeFile(cli, childScript(options)); await writeFile(binary, 'synthetic daemon'); await writeFile(archive, 'synthetic package');
const provenance = async path => ({ path, sha256: sha256(await readFile(path)) });
const now = Date.now(), verifiedAt = new Date(now - 1000).toISOString(), expiresAt = new Date(now + 86_400_000).toISOString();
@@ -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-v59-identity.json");
const fixture = resolve(root, "test/fixtures/copilot-profile-v61-identity.json");
const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8"));
const declaration = { ...historical.declaration, agentProfileVersion: 59 };
const declaration = { ...historical.declaration, agentProfileVersion: 61 };
delete declaration.permissionIdentitySourceSha256;
const sources = {
displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts",
@@ -154,6 +154,8 @@ function createTransportBackedNativeSessionBackend(
return new HarnessDriverBackend(
new CodexAppServerDriver({
...(input.provider.model ? { model: input.provider.model } : {}),
...(input.provider.kind === "codex" && "webSearch" in input.provider && input.provider.webSearch === "disabled"
? { disableWebSearch: true } : {}),
...(input.provider.kind === "codex" && "reasoningEffort" in input.provider && input.provider.reasoningEffort
? { reasoningEffort: input.provider.reasoningEffort }
: {}),
@@ -2,7 +2,8 @@ import { QUALIFIED_ACPX_PROFILES, resolveQualifiedAcpxProfile } from "../drivers
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as codexSecurity from "../drivers/codex/codex-security-config.js";
import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInput } from "../contracts/native-execution.js";
import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, nativeRuntimePromptDigest, canonicalNativeRuntimeContextDigest, composeNativeSystemInstructions } from "../contracts/runtime-context.js";
@@ -210,6 +211,26 @@ function managedExecution(
}
describe("native backend factory", () => {
it.each([undefined, "disabled"] as const)("honors saved webSearch=%s with the default process transport", async webSearch => {
const input = preparedExecution(execution());
if (input.provider.kind !== "codex") throw new Error("Codex fixture required");
if (webSearch) Object.assign(input.provider, { webSearch });
const buildArgs = codexSecurity.createIsolatedCodexAppServerArgs;
let generated: string[] | undefined;
const spy = vi.spyOn(codexSecurity, "createIsolatedCodexAppServerArgs").mockImplementation((...args) => {
generated = buildArgs(...args);
throw new Error("offline-stop-before-process-spawn");
});
try {
const backend = createNativeSessionBackend(input, { environment: {} });
await expect(backend.openSession({ identity: { runId: "run", sessionId: "session", companyId: "company", agentId: "agent" },
workingDirectory: WORKSPACE })).rejects.toThrow("offline-stop-before-process-spawn");
expect(generated).toBeDefined();
if (webSearch) expect(generated?.slice(-3)).toEqual(["-c", 'web_search="disabled"', "app-server"]);
else expect(generated).not.toContain('web_search="disabled"');
} finally { spy.mockRestore(); }
});
it("constructs the Codex backend without starting its transport", async () => {
const backend = createNativeSessionBackend(execution(), {
codexTransportFactory: () => {
@@ -637,6 +658,7 @@ describe("native backend factory", () => {
["cursor", "../../test/fixtures/cursor-acp/profile-v7-identity.json"],
["cursor", "../../test/fixtures/cursor-acp/profile-v10-identity.json"],
["copilot", "../../test/fixtures/copilot-profile-v14-identity.json"],
["copilot", "../../test/fixtures/copilot-profile-v60-identity.json"],
["pi", "../../test-fixtures/pi-acp/profile-v13-identity.json"],
["pi", "../../test-fixtures/pi-acp/profile-v14-identity.json"],
["pi", "../../test-fixtures/pi-acp/profile-v18-identity.json"],
@@ -194,6 +194,13 @@ describe("NativeExecutionInputV1", () => {
});
expect(withEffort.provider).toMatchObject({ kind: "codex", reasoningEffort: "ultra" });
expect(parseNativeExecutionInput(withEffort)).toEqual(withEffort);
const disabledSearch = parseNativeExecutionInput({ ...withEffort, provider: { ...withEffort.provider, webSearch: "disabled" } });
expect(disabledSearch.provider).toHaveProperty("webSearch", "disabled");
expect(parseNativeExecutionInput(JSON.parse(JSON.stringify(disabledSearch)))).toEqual(disabledSearch);
for (const webSearch of [true, false, "live", "cached", null]) {
expect(() => parseNativeExecutionInput({ ...withEffort, provider: { ...withEffort.provider, webSearch } })).toThrow("webSearch");
}
expect(() => parseNativeExecutionInput({
...withEffort,
provider: { kind: "codex", model: "gpt-6-astra", approvalPolicy: "on-request", reasoningEffort: "impossible" },
@@ -160,7 +160,7 @@ export type NativeProviderConfigV4 =
export type NativeProviderConfigV5 =
| Exclude<NativeProviderConfigV4, { kind: "codex" }>
| (Extract<NativeProviderConfigV4, { kind: "codex" }> & { reasoningEffort?: string });
| (Extract<NativeProviderConfigV4, { kind: "codex" }> & { reasoningEffort?: string; webSearch?: "disabled" });
export interface NativeExecutionInputV1 {
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V1;
@@ -571,7 +571,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
: provider.kind === "acpx"
? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : []), ...(isV9 ? ["claudeEffort"] : [])]
: provider.kind === "codex" && isV4
? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])]
? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort", "webSearch"] : [])]
: provider.kind === "opencode" && isV4
? ["kind", "model", "permissionMode"]
: ["kind", "model"],
@@ -780,6 +780,9 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
if (isV5 && provider.reasoningEffort !== undefined && !["minimal", "low", "medium", "high", "xhigh", "max", "ultra"].includes(String(provider.reasoningEffort))) {
throw new NativeExecutionInputError("input.provider.reasoningEffort is unsupported");
}
if (isV5 && provider.webSearch !== undefined && provider.webSearch !== "disabled") {
throw new NativeExecutionInputError("input.provider.webSearch must be disabled when specified");
}
parsedProvider = {
kind: "codex",
model: providerModel,
@@ -789,6 +792,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
...(isV5 && provider.reasoningEffort !== undefined
? { reasoningEffort: text(provider.reasoningEffort, "input.provider.reasoningEffort") }
: {}),
...(isV5 && provider.webSearch === "disabled" ? { webSearch: "disabled" as const } : {}),
};
}
if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) {
@@ -13,6 +13,8 @@ import {
} from "node:fs";
import { connect, type Socket } from "node:net";
import nodeFs from "node:fs";
import fsPromises from "node:fs/promises";
import { createServer } from "node:http";
import { syncBuiltinESMExports } from "node:module";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
@@ -47,6 +49,117 @@ const identity: DurableRecoveryIdentity = {
const expectedRunnerVersion = "0.3.0";
const expectedRunnerDigest = `sha256:${"a".repeat(64)}`;
it.each(["event", "newer_command", "replacement", "write_failure", "drain", "semantic_replacement"] as const)(
"keeps HTTP responsive during a durable event write and preserves %s", async (mode) => {
const root = mkdtempSync(resolve(tmpdir(), "runner-event-io-"));
const dispatch = vi.fn(async () => {
expect(JSON.parse(readFileSync(resolve(root, "control-plane-state.json"), "utf8")).ackedSourceSeq).toBe(1);
return { result: { ok: true } };
});
const core = new DurablePrpControlPlane({ stateDirectory: root, identity,
expectedRunnerVersion, expectedRunnerDigest, onCommittedEvent: async () => {}, onSemanticToolInput: dispatch });
const http = createServer((_req, res) => res.end("responsive"));
let release!: () => void;
const barrier = new Promise<void>(resolveWrite => { release = resolveWrite; });
const originalOpen = fsPromises.open;
let staged = false;
let holdFirst = true;
let client: AuthenticatedClient | null = null;
let replacement: AuthenticatedClient | null = null;
const open = vi.spyOn(fsPromises, "open").mockImplementation(async (...args) => {
const file = await originalOpen(...args);
if (holdFirst && String(args[0]).includes(".control-plane-state.json.")) {
holdFirst = false;
const sync = file.sync.bind(file);
file.sync = async () => {
staged = true;
await barrier;
if (mode === "write_failure") throw new Error("controlled disk failure");
await sync();
};
}
return file;
});
try {
await core.start();
await new Promise<void>(resolveListen => http.listen(0, "127.0.0.1", resolveListen));
client = (await authenticate(core, core.issueBootstrapTicket()))!;
const event = semanticInputEvent();
const payload = event.payload as Record<string, unknown>;
if (mode !== "semantic_replacement") {
payload.eventType = "item.delta";
payload.priority = 2;
payload.payload = { delta: "output while the volume is slow" };
}
expect(validatePrpEvent(payload)).toMatchObject({ ok: true });
sendSecure(client, event);
await vi.waitFor(() => expect(staged || dispatch.mock.calls.length > 0).toBe(true));
if (mode === "semantic_replacement") {
replacement = (await authenticate(core, client.leaseToken!))!;
release();
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(core.store.state.commands.some(command => command.type === "semantic_tool.result")).toBe(true));
return;
}
// The actual socket request completes while the real file's fsync is held.
const address = http.address() as { port: number };
expect(await (await fetch(`http://127.0.0.1:${address.port}`)).text()).toBe("responsive");
expect(JSON.parse(readFileSync(core.store.path, "utf8")).ackedSourceSeq).toBe(0);
if (mode === "newer_command") core.queueCommand("session.snapshot", {}, "command_during_event");
let drained = false;
const retirement = mode === "drain" ? (async () => {
await core.stop();
await core.drainPendingConnectionProcessing();
drained = true;
})() : null;
if (retirement) {
await new Promise<void>(resolveTurn => setImmediate(resolveTurn));
expect(drained).toBe(false);
}
if (mode === "replacement") {
replacement = (await authenticate(core, client.leaseToken!))!;
const second = structuredClone(event);
const next = second.payload as Record<string, unknown>;
next.sourceSeq = 2;
next.sourceEventId = "output-event-2";
sendSecure(replacement, second);
expect(await receiveSecure(replacement)).toMatchObject({ kind: "ack", payload: { ackedSourceSeq: 2 } });
}
release();
if (retirement) {
expect(await receiveSecure(client)).toBeNull();
await retirement;
expect(drained).toBe(true);
} else if (mode === "write_failure") {
expect(await receiveSecure(client)).toBeNull();
expect(() => core.queueCommand("session.snapshot")).toThrow("indeterminate");
} else if (mode !== "replacement") {
expect(await receiveSecure(client)).toMatchObject({ kind: "ack", payload: { ackedSourceSeq: 1 } });
}
await core.stop();
await core.drainPendingConnectionProcessing();
const restored = new DurablePrpControlPlane({ stateDirectory: root, identity,
expectedRunnerVersion, expectedRunnerDigest });
const expectedEvents = mode === "replacement" ? 2 : mode === "write_failure" ? 0 : 1;
expect(restored.store.state.ackedSourceSeq).toBe(expectedEvents);
expect(restored.store.state.committedEvents).toHaveLength(expectedEvents);
if (mode === "newer_command") {
expect(restored.store.state.commands.some(command => command.commandId === "command_during_event")).toBe(true);
}
expect(nodeFs.readdirSync(root).filter(name => name.endsWith(".tmp"))).toEqual([]);
} finally {
release();
open.mockRestore();
client?.socket.destroy();
replacement?.socket.destroy();
await core.stop();
await core.drainPendingConnectionProcessing();
await new Promise<void>(resolveClose => http.close(() => resolveClose()));
rmSync(root, { recursive: true, force: true });
}
},
);
it("commits credential updates before ACK and preserves replay identity", async () => {
const root = mkdtempSync(resolve(tmpdir(), "runner-credential-update-"));
let release!: () => void;
@@ -23,12 +23,13 @@ import {
renameSync,
unlinkSync,
writeFileSync,
type Stats,
} from "node:fs";
import { createServer, type IncomingMessage, type Server } from "node:http";
import { dirname, resolve } from "node:path";
import type { Duplex } from "node:stream";
import { fileURLToPath } from "node:url";
import { withStagedPrivateWrite } from "./staged-private-write.js";
import { verifyPrivateRegularFile } from "./private-state-file.js";
import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js";
import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES, CLAUDE_ROUTING_ENV_KEYS, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js";
@@ -1122,22 +1123,6 @@ function verifyPrivateDirectory(path: string): void {
}
}
function verifyPrivateRegularFile(file: Stats, path: string): void {
if (!file.isFile()) {
throw new Error(`Private state path is not a regular file: ${path}`);
}
if (process.platform !== "win32") {
if ((file.mode & 0o777) !== 0o600) {
throw new Error(`Private state file does not use mode 0600: ${path}`);
}
if (process.geteuid !== undefined && file.uid !== process.geteuid()) {
throw new Error(
`Private state file is not owned by the daemon user: ${path}`,
);
}
}
}
export function readPrivateFile(path: string): string | null {
let descriptor: number;
try {
@@ -1218,6 +1203,8 @@ class DurableCoreStore {
readonly path: string;
#state: StoredCoreState;
#writeIndeterminate = false;
#revision = 0;
#savedRevision = 0;
constructor(directory: string, identity: DurableRecoveryIdentity) {
try {
@@ -1255,7 +1242,33 @@ class DurableCoreStore {
save(): void {
this.assertWritable();
atomicPrivateWrite(this.path, `${JSON.stringify(this.#state, null, 2)}\n`);
const revision = ++this.#revision;
try {
atomicPrivateWrite(this.path, `${JSON.stringify(this.#state)}\n`);
this.#savedRevision = revision;
} catch (error) {
this.#writeIndeterminate = true;
throw error;
}
}
async saveEvent(): Promise<void> {
this.assertWritable();
const revision = ++this.#revision;
try {
await withStagedPrivateWrite(this.path, `${JSON.stringify(this.#state)}\n`, (temporary) => {
this.assertWritable();
// A command or replacement connection can persist this event and newer
// authority while its file is staged. Never roll that state back.
if (this.#savedRevision >= revision) return;
renameSync(temporary, this.path);
syncParentDirectory(this.path);
this.#savedRevision = revision;
});
} catch (error) {
this.#writeIndeterminate = true;
throw error;
}
}
assertWritable(): void {
@@ -1268,9 +1281,11 @@ class DurableCoreStore {
/** Persist a complete candidate before publishing any new authority in memory. */
commit(candidate: StoredCoreState): void {
this.assertWritable();
const revision = ++this.#revision;
try {
atomicPrivateWrite(this.path, `${JSON.stringify(candidate, null, 2)}\n`);
atomicPrivateWrite(this.path, `${JSON.stringify(candidate)}\n`);
this.#state = candidate;
this.#savedRevision = revision;
} catch (error) {
// Rename may already have succeeded before directory fsync failed.
// Never overwrite that possibly durable receipt using stale memory.
@@ -3292,7 +3307,14 @@ export class DurablePrpControlPlane {
});
this.#store.state.ackedSourceSeq = sourceSeq;
}
this.#store.save();
// Output deltas have no lifecycle or control effects. Other events must
// become durable before transport readers can observe them in memory.
if (eventType === "item.delta") {
await this.#store.saveEvent();
if (this.#eventOwner !== connection || this.#identity !== identity || this.#protocolIntegrityError !== null) return;
} else {
this.#store.save();
}
if (
isSemanticInput &&
@@ -0,0 +1,15 @@
import type { Stats } from "node:fs";
export function verifyPrivateRegularFile(file: Stats, path: string): void {
if (!file.isFile()) {
throw new Error(`Private state path is not a regular file: ${path}`);
}
if (process.platform !== "win32") {
if ((file.mode & 0o777) !== 0o600) {
throw new Error(`Private state file does not use mode 0600: ${path}`);
}
if (process.geteuid !== undefined && file.uid !== process.geteuid()) {
throw new Error(`Private state file is not owned by the daemon user: ${path}`);
}
}
}
@@ -0,0 +1,33 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import fs from "node:fs/promises";
import { basename, dirname, resolve } from "node:path";
import { verifyPrivateRegularFile } from "./private-state-file.js";
/** Stage durable bytes off the event loop; the owner publishes them without yielding. */
export async function withStagedPrivateWrite(
path: string,
contents: string,
publish: (temporary: string) => void,
): Promise<void> {
const temporary = resolve(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
const file = await fs.open(temporary, constants.O_WRONLY | constants.O_CREAT |
constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
try {
try {
if (process.platform !== "win32") await file.chmod(0o600);
const metadata = await file.stat();
verifyPrivateRegularFile(metadata, temporary);
await file.writeFile(contents, "utf8");
await file.sync();
} finally {
await file.close();
}
publish(temporary);
} finally {
// Publication can either rename this file or retain a newer durable write.
await fs.unlink(temporary).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
}
@@ -87,9 +87,39 @@ 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(59);
expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(61);
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined();
expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:2f39ffe54a6af832d044b5098c31df9e08d4d27aee0ac675ba4b0c9da7445c40");
expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3");
});
it("preserves v60 while binding controller journal retirement in v61", async () => {
const priorBytes = readFileSync(new URL("../../../test/fixtures/copilot-profile-v60-identity.json", import.meta.url));
expect(createHash("sha256").update(priorBytes).digest("hex")).toBe("e3c45b0311517d05f06ed10f357db713ae157cfa87e4a8becf087c5b6dfa7003");
const prior = JSON.parse(priorBytes.toString("utf8"));
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v61-identity.json", import.meta.url), "utf8"));
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([
"agentProfileVersion", "profileCompatibilitySourceSha256", "runtimeRequestBridgeSourceSha256",
]);
expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort());
expect(isSupportedAcpxProfileVersion("copilot", 60)).toBe(true);
expect(current.commandDigest).not.toBe(prior.commandDigest);
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot,
agentProfileVersion: 60, commandDigest: prior.commandDigest })).rejects.toThrow("exact pinned");
});
it("preserves v59 while binding the Codex-only hosted search restriction in v60", async () => {
const priorBytes = readFileSync(new URL("../../../test/fixtures/copilot-profile-v59-identity.json", import.meta.url));
expect(createHash("sha256").update(priorBytes).digest("hex")).toBe("a9116d0981cb0feebec31f61cd23d16b3a263fe6a797b1ba1436c6e41198538c");
const prior = JSON.parse(priorBytes.toString("utf8"));
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v60-identity.json", import.meta.url), "utf8"));
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([
"agentProfileVersion", "nativeBackendSourceSha256", "nativeExecutionSourceSha256", "profileCompatibilitySourceSha256", "providerLaunchSourceSha256", "runtimeRequestBridgeSourceSha256",
]);
expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort());
expect(isSupportedAcpxProfileVersion("copilot", 59)).toBe(true);
expect(current.commandDigest).not.toBe(prior.commandDigest);
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot,
agentProfileVersion: 59, commandDigest: prior.commandDigest })).rejects.toThrow("exact pinned");
});
it("preserves v58 while binding the separate remote process reconnect policy in v59", () => {
@@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
"protocolVersion": 1,
"acpxVersion": "0.13.1",
"agent": "copilot",
"agentProfileVersion": 59,
"agentProfileVersion": 61,
"agentServerPackage": "@github/copilot",
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:2f39ffe54a6af832d044b5098c31df9e08d4d27aee0ac675ba4b0c9da7445c40",
"commandDigest": "sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3",
"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 | 53 | 54 | 55 | 56 | 57 | 58;
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 | 55 | 56 | 57 | 58 | 59 | 60;
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, 28], 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, 53, 54, 55, 56, 57, 58],
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, 55, 56, 57, 58, 59, 60],
cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
};
@@ -710,7 +710,7 @@ export class CodexAppServerDriver implements HarnessDriver {
this.#options.transportFactory?.(context) ??
new ProcessCodexAppServerTransport({
workingDirectory,
args: createIsolatedCodexAppServerArgs(this.#options.environment, codexExecutableReadOnlyRoots(this.#options.environment ?? process.env), this.#options.instructionWorkingCopyRoot),
args: createIsolatedCodexAppServerArgs(this.#options.environment, codexExecutableReadOnlyRoots(this.#options.environment ?? process.env), this.#options.instructionWorkingCopyRoot, [], this.#options.disableWebSearch),
environment: createSanitizedCodexEnvironment(this.#options.environment),
onDiagnostic: message => this.#options.onDiagnostic?.(redactObservabilityDiagnostic(message, this.#options.environment, this.#options.observability?.otelConfig)),
processGroup: true,
@@ -19,6 +19,8 @@ export interface CodexAppServerDriverOptions {
model?: string;
/** Per-run reasoning effort sent with each Codex turn, including resumed turns. */
reasoningEffort?: string;
/** Saved hosted-search restriction for the default process transport. */
disableWebSearch?: boolean;
approvalPolicy?: "never" | "on-request" | "untrusted";
/** Paperclip runtime instructions. Codex receives these as additive developer
* instructions; the historical option name remains compatible with callers. */
@@ -254,6 +254,7 @@ export function createIsolatedCodexAppServerArgs(
/** Server-registered run copy, never an environment/config-supplied root. */
instructionWorkingCopyRoot?: string,
protectedPaths: string[] = [],
disableWebSearch = false,
): string[] {
const explicitSource = source;
source ??= process.env;
@@ -356,6 +357,7 @@ export function createIsolatedCodexAppServerArgs(
...(externalRunnerSandbox
? ["--dangerously-bypass-approvals-and-sandbox"]
: []),
...(disableWebSearch ? ["-c", 'web_search="disabled"'] : []),
"app-server",
];
}
@@ -94,6 +94,33 @@ describe("OpenCode MCP bridge", () => {
expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } });
});
it("preserves declared prefixed operation IDs through HTTP MCP and rejects undeclared aliases", async () => {
const names = ["paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "search_assigned_tools"];
const handler = vi.fn(async call => ({ operation: call.tool }));
const bridge = await startOpenCodeMcpBridge({ tools: names.map(name => ({ name, inputSchema: { type: "object" } })), handler });
bridges.push(bridge);
const listed = await (await rpc(bridge, { id: "list", method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } };
expect(listed.result.tools.map(tool => tool.name)).toEqual([...names, "paperclip_finish", "paperclip_block"]);
for (const [i, name] of names.entries()) {
for (const [j, prefix] of ["", "paperclip_", "paperclip__", "paperclip."].entries()) {
const called = prefix === "paperclip_" && name === "search_assigned_tools" ? name : prefix + name;
const result = await (await rpc(bridge, { id: `call-${i}-${j}`, method: "tools/call", params: { name: called, arguments: {} } })).json();
expect(result).toMatchObject({ result: { content: [{ text: JSON.stringify({ operation: name }) }] } });
}
}
const before = handler.mock.calls.length;
expect(await (await rpc(bridge, { id: "unknown", method: "tools/call", params: { name: "paperclip_unassigned_tool", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
expect(handler).toHaveBeenCalledTimes(before);
});
it("does not authorize a shortened operation when only its prefixed name is declared", async () => {
const handler = vi.fn(async () => ({ ok: true }));
const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "paperclip_search_assigned_tools", inputSchema: { type: "object" } }], handler });
bridges.push(bridge);
expect(await (await rpc(bridge, { id: "short", method: "tools/call", params: { name: "search_assigned_tools", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
expect(handler).not.toHaveBeenCalled();
});
it("normalizes all supported OpenCode MCP prefixes", () => {
expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read");
expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read");
@@ -108,7 +108,7 @@ function normalizeTools(
const tools: OpenCodeMcpToolDefinition[] = [];
const seen = new Set<string>();
for (const raw of input) {
const name = typeof raw.name === "string" ? canonicalOpenCodeMcpToolName(raw.name) : "";
const name = typeof raw.name === "string" ? raw.name.trim() : "";
if (!name || seen.has(name)) continue;
const inputSchema = isRecord(raw.inputSchema) ? structuredClone(raw.inputSchema) : { type: "object" };
tools.push({
@@ -218,7 +218,12 @@ async function handleRequest(
}
const params = isRecord(message.params) ? message.params : {};
const rawName = typeof params.name === "string" ? params.name : "";
const tool = canonicalOpenCodeMcpToolName(rawName);
// A declared operation may itself start with paperclip_. Preserve that
// authority identity before accepting an OpenCode transport prefix alias.
const exactName = rawName.trim();
const tool = context.admittedTools.some(entry => entry.name === exactName)
? exactName
: canonicalOpenCodeMcpToolName(exactName);
if (!context.admittedTools.some((entry) => entry.name === tool)) {
writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] });
return;
@@ -2219,6 +2219,27 @@ describe("OpenCodeServerDriver", () => {
} finally { await session.close({ reason: "monitor default test complete" }); }
});
it("keeps assigned discovery operation IDs at the authenticated MCP and controller boundary", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-assigned-"));
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
roots.push(root, workspace);
const calls: string[] = [];
const operation = "paperclip_search_assigned_tools";
const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4.1-flash-1017", runtimeDirectory: root, command: fixture,
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
dynamicTools: [{ name: operation, inputSchema: { type: "object" } }],
dynamicToolHandler: async call => { calls.push(call.tool); if (call.tool !== operation) throw new Error("unauthorized operation"); return { tools: [] }; },
});
const session = await driver.openSession({ runId: "assigned-name", normalizedSessionId: "assigned-name", workingDirectory: workspace });
try {
await session.startTurn({ message: { role: "user", text: "Discover assigned tools." } });
const events = await collectTurnEvents(session.events());
expect(calls).toEqual([operation]);
expect(events.some(event => event.eventType === "item.completed" && (event.payload as { item?: { is_error?: boolean } }).item?.is_error)).toBe(false);
} finally { await session.close({ reason: "assigned operation identity test" }); }
});
it("normalizes a structured block result", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
@@ -1026,7 +1026,9 @@ class OpenCodeHarnessSession implements HarnessSession {
callId: string;
arguments: unknown;
}): Promise<unknown> {
const tool = canonicalOpenCodeMcpToolName(call.tool);
// The authenticated MCP bridge has resolved this against its closed
// declared catalog. A second prefix strip changes legitimate operation IDs.
const tool = call.tool;
const turnId = this.#activeTurnId;
if (turnId === null)
throw new Error("OpenCode tool call is not bound to an active turn");
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createRunnerProviderLaunch, preparedRunnerLaunchWorkingDirectory, runnerLaunchRequirementsDigest, validatePreparedRunnerLaunch,
type RunnerProviderLaunchEnvironment, type RunnerProviderLaunchRequirements } from "./runner-provider-launch.js";
runnerProviderLaunchRequirements, type RunnerProviderLaunchEnvironment, type RunnerProviderLaunchRequirements } from "./runner-provider-launch.js";
const requirements: RunnerProviderLaunchRequirements = {
schema: "paperclip.runner.launch-requirements.v1",
@@ -19,6 +19,42 @@ const environment: RunnerProviderLaunchEnvironment = {
};
describe("built-in provider launch preparation", () => {
it("binds disabled hosted search to the prepared launch and overrides execution-host arguments", () => {
const disabled = runnerProviderLaunchRequirements({ provider: "codex", disableCodexWebSearch: true },
requirements.identity, { model: requirements.model }, null, "");
expect(disabled.settings).toHaveProperty("disableCodexWebSearch", true);
expect(runnerLaunchRequirementsDigest(disabled)).not.toBe(runnerLaunchRequirementsDigest({ ...disabled, settings: {} }));
const launch = createRunnerProviderLaunch(disabled, { ...environment,
options: { codexArgs: ["-c", 'web_search="live"', "app-server"] } });
expect(launch.args).toEqual(["-c", 'web_search="live"', "-c", 'web_search="disabled"', "app-server"]);
const prepared = { configurationId: "search-policy", requirementsDigest: runnerLaunchRequirementsDigest(disabled), provider: launch };
expect(() => validatePreparedRunnerLaunch(disabled, prepared)).not.toThrow();
expect(() => validatePreparedRunnerLaunch(disabled, { ...prepared,
provider: { ...launch, args: ["app-server"] } })).toThrow("runner_prepared_launch_requirements_mismatch");
for (const leading of ["--", "--search", "--search=true"]) {
expect(() => createRunnerProviderLaunch(disabled, { ...environment,
options: { codexArgs: [leading, "app-server"] } })).toThrow("runner_codex_web_search_policy_override");
expect(() => validatePreparedRunnerLaunch(disabled, { ...prepared,
provider: { ...launch, args: [leading, ...launch.args as string[]] } })).toThrow("runner_prepared_launch_requirements_mismatch");
}
expect(() => validatePreparedRunnerLaunch(disabled, { ...prepared,
provider: { ...launch, args: ["--", ...launch.args as string[]] } })).toThrow("runner_prepared_launch_requirements_mismatch");
for (const tail of [["-c", 'web_search="live"'], ['-cweb_search="live"'], ["--search"], ['--config=web_search="live"'], ["--"]]) {
expect(() => createRunnerProviderLaunch(disabled, { ...environment,
options: { codexArgs: ["app-server", ...tail] } })).toThrow("runner_codex_web_search_policy_override");
expect(() => validatePreparedRunnerLaunch(disabled, { ...prepared,
provider: { ...launch, args: [...launch.args as string[], ...tail] } })).toThrow("runner_prepared_launch_requirements_mismatch");
}
});
it.each([undefined, false])("preserves default hosted search when disable is %s", disableCodexWebSearch => {
const request = runnerProviderLaunchRequirements({ provider: "codex", disableCodexWebSearch },
requirements.identity, { model: requirements.model }, null, "");
const launch = createRunnerProviderLaunch(request, environment);
expect(launch.args).toEqual(["app-server"]);
});
it("keeps Core's selection and policy when the execution environment changes", () => {
const before = structuredClone(requirements);
const local = createRunnerProviderLaunch(requirements, environment);
@@ -30,6 +66,17 @@ describe("built-in provider launch preparation", () => {
expect(requirements).toEqual(before);
});
it("preserves exact Copilot requirements and launch descriptors when Codex search disabling is absent or false", () => {
const selected = { provider: "acpx" as const, acpxAgent: "copilot" as const, acpxPermissionMode: "deny-all" as const };
const params = { model: "gpt-5.6-luna", approvalPolicy: "never", permissions: "paperclip-runner-workspace-read-only" };
const baseline = runnerProviderLaunchRequirements(selected, requirements.identity, params, null, "Copilot policy");
const scopedProbe = runnerProviderLaunchRequirements({ ...selected, disableCodexWebSearch: false },
requirements.identity, params, null, "Copilot policy");
expect(scopedProbe).toEqual(baseline);
expect(runnerLaunchRequirementsDigest(scopedProbe)).toBe(runnerLaunchRequirementsDigest(baseline));
expect(createRunnerProviderLaunch(scopedProbe, environment)).toEqual(createRunnerProviderLaunch(baseline, environment));
});
it("preserves the ACPX profile, run identity, and permission policy", () => {
const launch = createRunnerProviderLaunch({ ...requirements, provider: "acpx",
model: "claude-selected", settings: { acpxAgent: "claude", acpxPermissionMode: "deny-all" } }, environment);
@@ -23,7 +23,7 @@ export interface RunnerProviderLaunchRequirements {
includeCollaborationModeInstructions: boolean;
settings: Pick<CapabilityRunnerdCodexTransportOptions,
"acpxAgent" | "acpxMode" | "acpxPermissionMode" | "acpxPermissionModePinned" |
"piThinkingLevel" | "claudeEffort" | "managedProfile" | "agentCoreProfile" | "opencodePermissionMode">;
"piThinkingLevel" | "claudeEffort" | "managedProfile" | "agentCoreProfile" | "opencodePermissionMode" | "disableCodexWebSearch">;
instructions: string;
runtimeContext: NativeRuntimeContextSnapshot | null;
}
@@ -59,6 +59,7 @@ export function runnerProviderLaunchRequirements(
piThinkingLevel: options.piThinkingLevel, claudeEffort: options.claudeEffort,
managedProfile: options.managedProfile, agentCoreProfile: options.agentCoreProfile,
opencodePermissionMode: options.opencodePermissionMode,
...(options.disableCodexWebSearch === true ? { disableCodexWebSearch: true } : {}),
},
approvalPolicy: params.approvalPolicy === "on-request" || params.approvalPolicy === "untrusted"
? params.approvalPolicy : "never",
@@ -109,6 +110,14 @@ export function validatePreparedRunnerLaunch(
if (descriptor.kind !== requirements.provider || descriptor.model !== requirements.model) reject();
if (requirements.provider === "codex" && (descriptor.approvalPolicy !== requirements.approvalPolicy
|| Boolean(descriptor.readOnly) !== requirements.readOnly)) reject();
if (requirements.provider === "codex" && requirements.settings.disableCodexWebSearch === true) {
if (!Array.isArray(descriptor.args) || descriptor.args.some(arg => typeof arg !== "string")) reject();
const args = descriptor.args as string[];
const commandIndex = args.indexOf("app-server");
const boundary = commandIndex < 0 ? args.length : commandIndex;
if (args.some(arg => arg === "--" || arg === "--search" || arg.startsWith("--search=")) || (commandIndex >= 0 && commandIndex !== args.length - 1)
|| args[boundary - 2] !== "-c" || args[boundary - 1] !== 'web_search="disabled"') reject();
}
if (requirements.provider === "acpx") {
const policy = descriptor.providerPolicy as Record<string, unknown> | undefined;
if (descriptor.agent !== (requirements.settings.acpxAgent ?? "codex")
@@ -303,8 +312,9 @@ export function createRunnerProviderLaunch(
args:
provider === "opencode"
? [opencodeProxyPath]
: (input.options.codexArgs ??
createRunnerdCodexAppServerArgs({
: createRunnerdCodexAppServerArgs({
args: input.options.codexArgs,
disableWebSearch: settings.disableCodexWebSearch,
environment: input.options.environment,
codexHome,
workingDirectory: input.cwd,
@@ -326,7 +336,7 @@ export function createRunnerProviderLaunch(
]
: []),
],
})),
}),
cwd: input.cwd,
model: requirements.model,
approvalPolicy:
@@ -408,12 +418,14 @@ export function createRunnerdCodexAppServerArgs(input: {
readOnlyRoots?: string[];
instructionWorkingCopyRoot?: string;
disableProjectInstructions?: boolean;
disableWebSearch?: boolean;
args?: string[];
}): string[] {
// The filesystem policy denies HOME and CODEX_HOME to keep credentials and
// runner state outside provider reach. Always bind those names to the actual
// isolated runner home; a stale controller environment must never cause the
// execution workspace itself to become an explicit deny root.
const args = [
const args = input.args ? [...input.args] : [
...(input.disableProjectInstructions ? ["-c", "project_doc_max_bytes=0"] : []),
...createIsolatedCodexAppServerArgs(
{
@@ -426,8 +438,20 @@ export function createRunnerdCodexAppServerArgs(input: {
usesPreconfiguredHome(input.environment) && input.runtimeStateRoot ? [input.runtimeStateRoot] : [],
),
];
if (usesPreconfiguredHome(input.environment) && input.workingDirectory) {
if (!input.args && usesPreconfiguredHome(input.environment) && input.workingDirectory) {
args.unshift("-c", `projects={${JSON.stringify(input.workingDirectory)}={trust_level="trusted"}}`);
}
if (input.disableWebSearch) {
// Apply Core's restriction after host overrides. Keep the override before
// the Codex subcommand and after any earlier provider-home configuration.
const commandIndex = args.indexOf("app-server");
// Restricted launches use the owned app-server argument shape. Refuse
// trailing subcommand flags and option terminators rather than guessing
// whether another CLI spelling could override or neutralize this policy.
if (args.some(arg => arg === "--" || arg === "--search" || arg.startsWith("--search=")) || (commandIndex >= 0 && commandIndex !== args.length - 1)) {
throw new Error("runner_codex_web_search_policy_override");
}
args.splice(commandIndex < 0 ? args.length : commandIndex, 0, "-c", 'web_search="disabled"');
}
return args;
}
@@ -27,6 +27,7 @@ vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal)
}
async start() {}
async stop() {}
async drainPendingConnectionProcessing() {}
disconnectActiveRunner() {}
persistRunAttachTemplate() {}
issueBootstrapTicket() { return {}; }
@@ -25,6 +25,7 @@ vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal)
}
async start() {}
async stop() {}
async drainPendingConnectionProcessing() {}
disconnectActiveRunner() {}
persistRunAttachTemplate() {}
issueBootstrapTicket() { return {}; }
@@ -3343,6 +3343,29 @@ const fakeCodex = resolve(
"../../runner/target/debug/fake-codex-app-server",
);
it("keeps the Copilot authentication launch requirements free of Codex search policy", async () => {
const root = await mkdtemp(join(tmpdir(), "runnerd-copilot-probe-policy-"));
let captured: RunnerProviderLaunchRequirements | undefined;
const launch = vi.fn(() => { throw new Error("provider process must not start"); });
try {
await expect(probeNativeRunnerEnvironment({ runtimeDirectory: root, provider: "acpx", model: "gpt-5.6-luna",
environment: {}, timeoutMs: 1_000,
transportOptions: { acpxAgent: "copilot", acpxCandidateProfile: "copilot", disableCodexWebSearch: true,
runnerArtifact: { version: "offline-fixture", digest: `sha256:${"a".repeat(64)}` },
runnerProcessLauncher: launch,
controlPlaneRegistration: async () => { throw new Error("registration must not start"); },
prepareLaunch: async requirements => {
captured = requirements;
throw new Error("offline-stop-at-Copilot-preparation");
},
},
})).rejects.toThrow("offline-stop-at-Copilot-preparation");
expect(captured).toBeDefined();
expect(captured?.settings).not.toHaveProperty("disableCodexWebSearch");
expect(launch).not.toHaveBeenCalled();
} finally { await rm(root, { recursive: true, force: true }); }
});
it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooperative-release", "launch-failure", "launch-failure-sandbox", "launch-failure-unsandboxed"] as const)(
"bounds setup registration teardown and retains unconfirmed state (%s)",
async mode => {
@@ -3381,7 +3404,7 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper
...(mode === "launch-failure-sandbox" ? { externallySandboxed: true } : mode === "launch-failure-unsandboxed" ? { externallySandboxed: false } : {}),
runnerBinary: resolve(import.meta.dirname, "../../package.json"),
codexCommand: resolve(import.meta.dirname, "../../package.json"),
sourceCodexHome: "", runnerProcessLauncher: launch,
sourceCodexHome: "", runnerProcessLauncher: launch, disableCodexWebSearch: false,
controlPlaneRegistration: async (_authority, _identity, signal) => {
template = structuredClone(_authority.store.state.runAttachTemplate!);
registrationSignal = signal;
@@ -3405,6 +3428,8 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper
expect(template).toMatchObject({ provider: { externallySandboxed: mode === "launch-failure-sandbox", readOnly: true, approvalPolicy: "never", collaborationMode: "plan" } });
const args = (template!.provider as Record<string, unknown>).args as string[];
expect(args).toContain("project_doc_max_bytes=0");
expect(args).toContain('web_search="disabled"');
expect(args.indexOf('web_search="disabled"')).toBeLessThan(args.indexOf("app-server"));
expect(args[args.indexOf("project_doc_max_bytes=0") - 1]).toBe("-c");
expect(args.indexOf("project_doc_max_bytes=0")).toBeLessThan(args.indexOf("app-server"));
expect(args).not.toContain("--dangerously-bypass-approvals-and-sandbox");
@@ -726,6 +726,7 @@ async function releaseRunnerProcessOwnership(input: {
((settlement: "settled" | "unsettled") => Promise<void> | void) | null;
forceKill: () => Promise<void> | void;
release: (() => Promise<void> | void) | null;
retireController?: () => Promise<void>;
}): Promise<void> {
let releaseFailure: unknown;
try {
@@ -735,6 +736,9 @@ async function releaseRunnerProcessOwnership(input: {
}
let checkpointFailure: unknown;
try {
// External ingress must stop reconnecting before admitted journal writes
// can be joined. A failed retirement cannot authorize a new checkpoint.
await input.retireController?.();
if (input.checkpoint !== null) {
// Release only the authenticated control route first. In provider-ingress
// mode this stops its reconnect loop; it does not release the sandbox
@@ -1287,6 +1291,8 @@ export interface CapabilityRunnerdCodexTransportOptions extends RunnerExecutionD
codexCommand?: string;
codexArgs?: string[];
disableCodexProjectInstructions?: boolean;
/** Restrict hosted search without changing the provider default for other runs. */
disableCodexWebSearch?: boolean;
/** Controller-visible Codex home used only to seed the isolated runner home. */
sourceCodexHome?: string | null;
opencodeCommand?: string;
@@ -3567,6 +3573,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
readonly #closedSignal: Promise<void>;
#resolveClosedSignal!: () => void;
#closePromise: Promise<void> | null = null;
#detachPromise: Promise<void> | null = null;
#startupOperation: Promise<unknown> | null = null;
#attachmentOperation: Promise<void> | null = null;
#startupDrainTimedOut = false;
@@ -4464,7 +4471,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
close(reason?: string): Promise<void> {
// Detachment relinquishes process ownership. A late execution finalizer
// must not suspend or signal the runner now owned by the next controller.
if (this.#controllerDetachedForRestart) return Promise.resolve();
if (this.#controllerDetachedForRestart)
return this.#detachPromise ?? Promise.resolve();
if (reason) {
this.#diagnostic(
`runner transport close requested: ${reason.replaceAll(/[\r\n]/g, " ").slice(0, 1_000)}`,
@@ -4474,8 +4482,33 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
return this.#closePromise;
}
async detachControllerForRestart(): Promise<void> {
detachControllerForRestart(): Promise<void> {
this.#detachPromise ??= this.#detachControllerOnce();
return this.#detachPromise;
}
async #stopAndDrainAuthority(deadline: number): Promise<void> {
const core = this.#core;
if (!core) return;
await core.stop();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
core.drainPendingConnectionProcessing(),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(
"Controller journal drain timed out; preserve the session before replacing its writer.",
)), Math.max(0, deadline - Date.now()));
}),
]);
} finally {
clearTimeout(timer);
}
}
async #detachControllerOnce(): Promise<void> {
if (this.#closed) return;
const deadline = Date.now() + runnerCloseGraceMs(this.options);
this.#controllerDetachedForRestart = true;
this.#closed = true;
this.#resolveClosedSignal();
@@ -4493,13 +4526,15 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
`controller route release failed during restart detach: ${String(error)}`,
);
});
await this.#core?.stop().catch((error: unknown) => {
this.#diagnostic(
`controller authority stop failed during restart detach: ${String(error)}`,
);
});
// A successor must not share this journal with an admitted async write.
// A timeout rejects detachment, including repeated calls, instead of
// reporting that the old writer has retired.
try {
await this.#stopAndDrainAuthority(deadline);
} finally {
this.#queue.close();
}
this.#handle = null;
this.#queue.close();
this.#diagnostic(
"controller authority detached for restart; durable runner left alive",
);
@@ -4723,6 +4758,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#evidence.runnerSignal = result.signal as NodeJS.Signals | null;
},
release: this.#controlPlaneRelease,
retireController: () => this.#stopAndDrainAuthority(closeDeadline),
});
} finally {
await this.#core?.stop();
@@ -5529,8 +5565,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
// Keep the durable provider profile and thread identity unchanged.
const recordedCommand = record(runAttachTemplate.provider).command;
runAttachTemplate.runtimeLaunchArgs =
this.options.codexArgs ??
createRunnerdCodexAppServerArgs({
args: this.options.codexArgs,
disableWebSearch: this.options.disableCodexWebSearch,
environment: this.options.environment,
codexHome,
workingDirectory: this.options.resumeWorkingDirectory,
@@ -7287,6 +7324,7 @@ export async function probeNativeRunnerEnvironment(options: {
environment,
externallySandboxed: options.transportOptions?.externallySandboxed === true,
disableCodexProjectInstructions: true,
disableCodexWebSearch: options.provider === "codex",
closeGraceMs: 10_000,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
});
@@ -0,0 +1,150 @@
import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { expect, it, vi } from "vitest";
import { DurablePrpControlPlane } from "../control-plane/durable-prp-control-plane.js";
import { createCapabilityRunnerdCodexTransport, defaultCapabilityRunnerdBinary, readRunnerdArtifactBinding, runnerdRecoveryInternals } from "./runnerd-codex-transport.js";
it.each(["resolved", "rejected"] as const)("keeps the checkpoint behind controller retirement (%s)", async outcome => {
const steps: string[] = [];
let release!: () => void;
let reject!: (error: Error) => void;
const barrier = new Promise<void>((resolveDrain, rejectDrain) => { release = resolveDrain; reject = rejectDrain; });
const operation = runnerdRecoveryInternals.releaseRunnerProcessOwnership({
runnerSettled: true,
release: () => { steps.push("route"); },
retireController: async () => { steps.push("drain"); await barrier; },
checkpoint: () => { steps.push("checkpoint"); },
forceKill: () => { steps.push("containment"); },
});
await new Promise<void>(resolveTurn => setImmediate(resolveTurn));
expect(steps).toEqual(["route", "drain"]);
if (outcome === "rejected") {
const failed = expect(operation).rejects.toThrow("journal unavailable");
reject(new Error("journal unavailable"));
await failed;
expect(steps).toEqual(["route", "drain", "containment"]);
} else {
release();
await operation;
expect(steps).toEqual(["route", "drain", "checkpoint", "containment"]);
}
});
it.each(["drain", "timeout", "external_close"] as const)("joins an output flush before controller retirement permits a successor writer (%s)", async mode => {
const root = await fs.mkdtemp(join(tmpdir(), "runnerd-detach-write-"));
await fs.mkdir(join(root, "diagnostics"), { mode: 0o700 });
const binary = defaultCapabilityRunnerdBinary();
const artifact = readRunnerdArtifactBinding(binary);
let core!: DurablePrpControlPlane;
const start = DurablePrpControlPlane.prototype.start;
const startSpy = vi.spyOn(DurablePrpControlPlane.prototype, "start").mockImplementation(function (port) {
core = this;
return start.call(this, port);
});
let release!: () => void;
const barrier = new Promise<void>(resolveWrite => { release = resolveWrite; });
let staged = false;
const originalOpen = fs.open;
const openSpy = vi.spyOn(fs, "open").mockImplementation(async (...args) => {
const file = await originalOpen(...args);
if (!staged && String(args[0]).includes(".control-plane-state.json.")) {
const sync = file.sync.bind(file);
file.sync = async () => { staged = true; await barrier; await sync(); };
}
return file;
});
let child: ChildProcess | undefined;
let exit: Promise<void> | undefined;
const closeSteps: string[] = [];
let stopSpy: ReturnType<typeof vi.spyOn> | undefined;
const bundle = createCapabilityRunnerdCodexTransport({
runnerBinary: binary,
codexCommand: resolve(import.meta.dirname, "../../runner/target/debug/fake-codex-app-server"),
codexArgs: ["--state-file", join(root, "provider-state.json"), "--split-event-burst", "--split-event-prefix-count", "1"],
stateDirectory: root,
closeGraceMs: mode === "timeout" ? 200 : 1_500,
...(mode === "external_close" ? { controlPlaneRegistration: async (authority: DurablePrpControlPlane) => {
await authority.start();
const stop = authority.stop.bind(authority);
stopSpy = vi.spyOn(authority, "stop").mockImplementation(async () => {
closeSteps.push("stop");
release();
await stop();
});
return { connectUrl: authority.connectUrl, release: () => { closeSteps.push("release"); } };
} } : {}),
runnerProcessLauncher: spec => {
child = spawn(spec.command, [...spec.args], { cwd: spec.cwd, env: spec.environment, stdio: ["ignore", "pipe", "pipe"] });
const owned = child;
let stderr = "";
owned.stderr?.on("data", chunk => { stderr += String(chunk); });
exit = new Promise(resolveExit => owned.once("exit", () => resolveExit()));
const completion = new Promise<{ code: number | null; signal: string | null; stdout: string; stderr: string }>((resolveExit, reject) => {
owned.once("error", reject);
owned.once("exit", (code, signal) => resolveExit({ code, signal, stdout: "", stderr }));
});
return { child: owned, completion };
},
});
bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
let detached: Promise<void> | undefined;
try {
await bundle.transport.request("thread/start", { cwd: root, dynamicTools: [{
name: "get_task_context", description: "Read fixture context.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
}] });
await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Exercise a held output flush." }] });
await vi.waitFor(() => expect(staged).toBe(true), { timeout: 5_000 });
if (mode === "external_close") {
await bundle.transport.close().catch(error => {
expect(error.message).toMatch(/journal drain timed out|did not durably suspend/);
});
expect(closeSteps.indexOf("release")).toBeGreaterThanOrEqual(0);
expect(closeSteps.indexOf("release")).toBeLessThan(closeSteps.indexOf("stop"));
return;
}
let settled = false;
detached = bundle.detachControllerForRestart().then(() => { settled = true; });
await vi.waitFor(() => expect(core.activeRunnerConnectionCount()).toBe(0));
await new Promise<void>(resolveTurn => setImmediate(resolveTurn));
expect(settled).toBe(false);
if (mode === "timeout") {
await expect(detached).rejects.toThrow("journal drain timed out");
await expect(bundle.detachControllerForRestart()).rejects.toThrow("journal drain timed out");
await expect(bundle.transport.close()).rejects.toThrow("journal drain timed out");
release();
await core.drainPendingConnectionProcessing();
} else {
release();
await detached;
}
const successor = new DurablePrpControlPlane({ stateDirectory: join(root, "control-plane"),
identity: core.store.state.identity, expectedRunnerVersion: artifact.version, expectedRunnerDigest: artifact.digest });
successor.queueCommand("session.snapshot", {}, "successor_command");
await new Promise<void>(resolveTurn => setImmediate(resolveTurn));
const persisted = JSON.parse(await fs.readFile(successor.store.path, "utf8"));
expect(persisted.commands.some((command: { commandId: string }) => command.commandId === "successor_command")).toBe(true);
} catch (error) {
const diagnostic = await fs.readFile(join(root, "diagnostics", "runnerd.stderr.log"), "utf8").catch(() => "");
throw new Error(`${String(error)}\n${diagnostic}`, { cause: error });
} finally {
release();
await detached?.catch(() => undefined);
openSpy.mockRestore();
startSpy.mockRestore();
await bundle.detachControllerForRestart().catch(() => undefined);
const providerPid = bundle.evidence().codexPid;
if (providerPid) {
try { process.kill(providerPid, "SIGKILL"); } catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
}
child?.kill("SIGKILL");
await exit;
await core?.drainPendingConnectionProcessing();
stopSpy?.mockRestore();
await fs.rm(root, { recursive: true, force: true });
}
}, 15_000);
@@ -0,0 +1,65 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { chmod, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { expect, it } from "vitest";
import { codexSemanticToolSpecs, dynamicToolResponse } from "../drivers/codex/codex-driver-values.js";
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js";
const runner = defaultCapabilityRunnerdBinary();
const proxy = resolve("dist/cli/opencode-app-server-proxy.cjs");
const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
it.skipIf(process.platform === "win32" || !existsSync(runner) || !existsSync(proxy))(
"retains assigned discovery authority across Rust, the proxy, and HTTP MCP",
async () => {
const root = await mkdtemp(join(tmpdir(), "runnerd-opencode-assigned-"));
const workspace = join(root, "workspace");
const executable = join(root, "fake-opencode-server.mjs");
await mkdir(workspace);
await copyFile(resolve("test/fixtures/fake-opencode-server.mjs"), executable);
await chmod(executable, 0o755);
const operation = "paperclip_search_assigned_tools";
const seen: string[] = [];
const { transport } = createRunnerdCodexTransport({
provider: "opencode", runnerBinary: runner, stateDirectory: join(root, "state"), sourceCodexHome: "",
providerNodeCommand: process.execPath, providerNodeCommandSha256: digest(process.execPath),
opencodeCommand: executable, opencodeCommandSha256: digest(executable),
opencodeProxyPath: proxy, opencodeProxySha256: digest(proxy),
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "synthetic-fixture-key" },
});
transport.setServerRequestHandler(async request => {
expect(request.method).toBe("item/tool/call");
const tool = request.params.tool;
expect([operation, "paperclip_finish"]).toContain(tool);
seen.push(String(tool));
return dynamicToolResponse(tool === operation ? { tools: [] } : "Accepted completion.");
});
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await transport.request("thread/start", {
cwd: workspace, model: "openrouter/deepseek/deepseek-v4.1-flash-1017",
completionContract: { revision: "assigned-tools-v1", criterionIds: ["objective"] },
dynamicTools: [{ name: operation, description: "Search assigned synthetic tools.", inputSchema: { type: "object" } }, ...codexSemanticToolSpecs()],
});
const completed = (async () => {
for await (const notification of transport.notifications()) {
if (notification.method === "turn/completed") return;
if (notification.method === "turn/failed") throw new Error("Native OpenCode turn failed");
}
throw new Error("Native OpenCode notifications ended before completion");
})();
await transport.request("turn/start", { input: [{ type: "text", text: "Discover assigned synthetic tools." }] });
await Promise.race([completed, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Native assigned-tool smoke timed out")), 20_000);
})]);
expect(seen).toEqual([operation, "paperclip_finish"]);
} finally {
if (timer) clearTimeout(timer);
await transport.close();
await rm(root, { recursive: true, force: true });
}
},
30_000,
);
@@ -0,0 +1,62 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { expect, it, vi } from "vitest";
import { createCapabilityRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js";
it("starts one provider after delayed bootstrap admission and retains it for a second turn", async () => {
const root = await mkdtemp(join(tmpdir(), "runnerd-delayed-welcome-"));
const calls = join(root, "provider-calls.log");
let admissionDelayed = false;
let admissionTimer: ReturnType<typeof setTimeout> | undefined;
const bundle = createCapabilityRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: resolve(import.meta.dirname, "../../runner/target/debug/fake-codex-app-server"),
codexArgs: ["--state-file", join(root, "provider.json"), "--call-log", calls, "--record-process-start", "--durable-turn-ids"],
stateDirectory: join(root, "state"),
controlPlaneRegistration: async authority => {
const attach = authority.attachWireConnection.bind(authority);
vi.spyOn(authority, "attachWireConnection").mockImplementation(wire => attach({
sendJson: value => wire.sendJson(value),
close: code => wire.close(code),
onClose: listener => wire.onClose(listener),
onJson: listener => wire.onJson(value => {
if (!admissionDelayed && (value as { kind?: string }).kind === "auth_response") {
admissionDelayed = true;
admissionTimer = setTimeout(() => listener(value), 3_000);
} else listener(value);
}),
}));
await authority.start();
return { connectUrl: authority.connectUrl, release: () => authority.stop() };
},
});
const terminalTurns: unknown[] = [];
try {
const opened = await bundle.transport.request("thread/start", { cwd: root, dynamicTools: [] });
expect(admissionDelayed).toBe(true);
for (const text of ["Complete the first turn.", "Continue the same session."]) {
await bundle.transport.request("turn/start", { input: [{ type: "text", text }] });
for await (const event of bundle.transport.notifications()) {
if (event.method === "turn/completed") {
expect(event.params).toMatchObject({ threadId: opened.thread.id });
terminalTurns.push(event.params);
break;
}
}
}
await bundle.transport.close();
const methods = (await readFile(calls, "utf8")).trim().split("\n");
expect(methods.filter(method => method === "process-start")).toHaveLength(1);
expect(methods.filter(method => method === "thread/start")).toHaveLength(1);
expect(methods.filter(method => method === "turn/start")).toHaveLength(2);
expect(terminalTurns).toHaveLength(2);
const runner = JSON.parse(await readFile(join(root, "state/runner/runner-state.json"), "utf8"));
expect(runner.lifecycle).toBe("suspended");
expect(runner.outbox).toEqual([]);
} finally {
if (admissionTimer) clearTimeout(admissionTimer);
await bundle.transport.close();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
+1
View File
@@ -9,6 +9,7 @@ export * from "./index.js";
export * from "./conformance/control-plane-port.js";
export * from "./conformance/capability-semantic-conformance.js";
export * from "./conformance/harness-driver.js";
export { startOpenCodeMcpBridge } from "./drivers/opencode/mcp-bridge.js";
export * from "./conformance/semantic-conformance.js";
export * from "./mock-core/deterministic-harness-driver.js";
export * from "./mock-core/mock-control-plane-adapter.js";
@@ -0,0 +1,85 @@
{
"commandDigest": "sha256:ca3c2b38ed5f6e25cf1c96c1f73781a86cc90c1c0add93f64716c66f28b3f3aa",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "copilot",
"agentProfileVersion": 60,
"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": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5",
"toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706",
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
"semanticSidecarSourceSha256": "17361d1782c0dcebb2b7972635f45c890f2a4081f8bc0ef2d2276550e0b91629",
"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": "3e61c4cdd4b78e38fcb16eaadae4f1fd65ce3c7127184ea7a80948c8e5d77f88",
"attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2",
"singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f",
"nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce",
"nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842",
"nativeRuntimePromptsSourceSha256": "103b83066da72434602e9e461e6278dfb40bb993d2f3b49b2e93df81bbb08b72",
"nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90",
"harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412",
"harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249",
"harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94",
"transportContractSourceSha256": "03fca93d36a89b0cc0917335e293ad097fdf04356d21b307827bb0ea4c13f523",
"nativeBackendSourceSha256": "e7b12d4418c7a71f5f2474b046697d3009eae3613b284bec381df5e9965a1c37",
"nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44",
"nativeInstructionsSourceSha256": "9173ebccf09cb56a781d2bf5c7eb27fdafb13d3450d99342c6aff86b42d6b43a",
"providerLaunchSourceSha256": "fa8db1c42c378898287c141d8cea9069166981e369c9b9c51422cb6177de5a2c",
"launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296",
"runtimeRequestBridgeSourceSha256": "8ddd5d26854f4a153a9559ea8105aef95a3ec94f03209e9a797c95932c162e2a",
"runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295",
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
"guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f",
"nativeExecutionSourceSha256": "3a30d52bffad68c7f02349193be84761050eb99039fc0556c2b449891a36e918",
"profileCompatibilitySourceSha256": "8503e7f2f8175e413b42e85a9ff259a00f2278cd966bbeafc9e9e85417cf7dbb",
"installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d",
"assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c",
"assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f",
"credentialBindingSourceSha256": "7586d44b47c83395498dc8806287cd3538ef58177ba7c5949f5766ed105a0161",
"sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80",
"profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456",
"metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727",
"permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
"semanticContinuationSourceSha256": "d248e47a0fe7f2536d1e64cea0b0b410f34104800cd643d995b4473104ecc8e0",
"runtimeHostSourceSha256": "94a73749267f5a80b5df19413a65b4b9263c00a9bf7e98fda00f00b8f9fd32ec",
"runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8",
"runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea",
"cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca",
"agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba",
"nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b"
}
}
@@ -0,0 +1,85 @@
{
"commandDigest": "sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "copilot",
"agentProfileVersion": 61,
"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": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5",
"toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706",
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
"semanticSidecarSourceSha256": "17361d1782c0dcebb2b7972635f45c890f2a4081f8bc0ef2d2276550e0b91629",
"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": "3e61c4cdd4b78e38fcb16eaadae4f1fd65ce3c7127184ea7a80948c8e5d77f88",
"attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2",
"singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f",
"nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce",
"nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842",
"nativeRuntimePromptsSourceSha256": "103b83066da72434602e9e461e6278dfb40bb993d2f3b49b2e93df81bbb08b72",
"nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90",
"harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412",
"harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249",
"harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94",
"transportContractSourceSha256": "03fca93d36a89b0cc0917335e293ad097fdf04356d21b307827bb0ea4c13f523",
"nativeBackendSourceSha256": "e7b12d4418c7a71f5f2474b046697d3009eae3613b284bec381df5e9965a1c37",
"nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44",
"nativeInstructionsSourceSha256": "9173ebccf09cb56a781d2bf5c7eb27fdafb13d3450d99342c6aff86b42d6b43a",
"providerLaunchSourceSha256": "fa8db1c42c378898287c141d8cea9069166981e369c9b9c51422cb6177de5a2c",
"launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296",
"runtimeRequestBridgeSourceSha256": "e6c98128d98a4177c42d68a04c1f749554fd5ec844da1bbbdb3f682670189445",
"runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295",
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
"guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f",
"nativeExecutionSourceSha256": "3a30d52bffad68c7f02349193be84761050eb99039fc0556c2b449891a36e918",
"profileCompatibilitySourceSha256": "f9670b63d68e8b84cb79ab18425028f1ff781adb398cb9955ca80b8e0c9264c7",
"installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d",
"assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c",
"assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f",
"credentialBindingSourceSha256": "7586d44b47c83395498dc8806287cd3538ef58177ba7c5949f5766ed105a0161",
"sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80",
"profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456",
"metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727",
"permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
"semanticContinuationSourceSha256": "d248e47a0fe7f2536d1e64cea0b0b410f34104800cd643d995b4473104ecc8e0",
"runtimeHostSourceSha256": "94a73749267f5a80b5df19413a65b4b9263c00a9bf7e98fda00f00b8f9fd32ec",
"runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8",
"runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea",
"cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca",
"agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba",
"nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b"
}
}
@@ -39,6 +39,7 @@ export const environmentRunnerLaunchRequirementsSchema = z.object({
acpxPermissionModePinned: z.boolean().optional(), piThinkingLevel: z.enum(["off", "low", "high", "max"]).optional(),
claudeEffort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(),
opencodePermissionMode: z.enum(["allow", "ask", "deny"]).optional(),
disableCodexWebSearch: z.literal(true).optional(),
managedProfile: z.record(z.string(), z.unknown()).optional(), agentCoreProfile: z.record(z.string(), z.unknown()).optional(),
}).strict(),
approvalPolicy: z.enum(["never", "on-request", "untrusted"]), readOnly: z.boolean(),
@@ -11,3 +11,37 @@ describe("task lifecycle policy",()=>{
expect(environmentTaskOperationSchema.safeParse({...submit,launch:{...submit.launch,lifecyclePolicy:{mode:"warm",idleTimeoutMs}}}).success).toBe(false);
});
});
const prepare = {
kind: "prepare", projectIds: [], requirements: {
schema: "paperclip.runner.launch-requirements.v1",
identity: { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "run",
normalizedSessionId: "session", turnId: "turn", itemId: "item" },
provider: "codex", model: "selected-model", instructions: "Verify the selected account.",
runtimeContext: null, settings: {}, approvalPolicy: "never", readOnly: true,
preparedConversation: false, includeCollaborationModeInstructions: false,
},
};
describe("task hosted search restriction", () => {
it("preserves Core's explicit restriction through plugin launch preparation", () => {
const operation = { ...prepare, requirements: { ...prepare.requirements,
settings: { disableCodexWebSearch: true } } };
expect(environmentTaskOperationSchema.parse(operation)).toEqual(operation);
});
it("keeps the provider default when the restriction is absent", () => {
expect(environmentTaskOperationSchema.parse(prepare)).toEqual(prepare);
});
it.each([false, null, 0, "true", "disabled"])("rejects a non-restricting flag value %s", disableCodexWebSearch => {
expect(environmentTaskOperationSchema.safeParse({ ...prepare, requirements: { ...prepare.requirements,
settings: { disableCodexWebSearch } } }).success).toBe(false);
});
it("continues rejecting unreviewed launch settings", () => {
expect(environmentTaskOperationSchema.safeParse({ ...prepare, requirements: { ...prepare.requirements,
settings: { disableCodexWebSearch: true, hostedSearchBillingMode: "unmetered" } } }).success).toBe(false);
});
});
@@ -131,6 +131,7 @@ import { deliverReconciledExecutions } from "../services/execution-recovery-reso
import {
unadmittedChatWakeupCondition,
authorizeFailedChatRunRetryWake,
createDurableChatWakeupRequest,
} from "../services/durable-chat-wakeup.js";
import {
createChatWebhookDiagnostics,
@@ -60661,6 +60662,358 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
return { ...context, issue, action, receipt, runId };
}
async function currentGitHubTaskRetryFixture(withPending = false, pendingCount = 1) {
const f = await failedChatRetryFixture("github", "42", true);
const companyId = f.fixture.companyId, issueId = f.issue.id;
await githubResponseCommentService(db, f.providerFetch).tryAcknowledge(f.action.deliveryId!);
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: issueId,
nativePhase: "terminal_failure", runnerProfileJson: {} }).where(eq(heartbeatRuns.id, f.runId));
await db.insert(nativeRunFinalizations).values({ companyId, issueId, runId: f.runId, phase: "terminal_failure" });
await db.insert(heartbeatRunEvents).values({ companyId, agentId: f.fixture.assignedAgentId, runId: f.runId,
seq: 1, eventType: "native.local_process_stopped", payload: { processPid: 999999999, processGroupId: null } });
let pending: typeof chatActions.$inferSelect | undefined;
if (withPending) {
for (let index = 0; index < pendingCount; index++) {
await deliverMessage({ callbacks: f.runtime.configurations.get(f.endpoint.id)!.callbacks,
endpointId: f.endpoint.id, provider: "github", thread: f.thread.thread,
message: makeMessage({ id: String(990092 + index), text: `@maya continue saved follow-up ${index}`, userId: "42", mentioned: true }),
trigger: "mention" });
const [{ source }] = await db.select({ source: chatActions }).from(chatActions)
.innerJoin(chatDeliveries, eq(chatDeliveries.id, chatActions.deliveryId)).where(and(
eq(chatActions.conversationId, f.conversation.id), eq(chatActions.kind, "inbound_wakeup"),
eq(chatDeliveries.providerEventId, `${f.thread.thread.id}:${990092 + index}`)));
pending ??= source;
await db.update(agentWakeupRequests).set({ status: "deferred_issue_execution", runId: null })
.where(eq(agentWakeupRequests.id, source.id));
await githubResponseCommentService(db, f.providerFetch).tryAcknowledge(source.deliveryId!);
}
}
const [originalDelivery] = await db.select().from(chatDeliveries).where(eq(chatDeliveries.id, f.action.deliveryId!));
await db.update(agents).set({ adapterType: "paperclip_runner", status: "running",
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } })
.where(eq(agents.id, f.fixture.assignedAgentId));
await db.insert(heartbeatRuns).values({ companyId, agentId: f.fixture.assignedAgentId,
status: "running", startedAt: new Date() });
f.wakeup.mockImplementation(heartbeatService(db).wakeup);
return { ...f, pending, originalDelivery };
}
it.each(["reconnected", "newer_input", "reconnected_newer_input"] as const)(
"admits current GitHub task Retry without replaying obsolete input: %s", async kind => {
const f = await currentGitHubTaskRetryFixture(kind.includes("newer_input"));
const { pending, originalDelivery } = f;
try {
const companyId = f.fixture.companyId, issueId = f.issue.id;
if (kind.includes("reconnected")) await f.service.configure(f.endpoint.id, { action: "reconnect" }, "owner-user");
const input = { db, companyId, issueId, actorId: "owner-user", actorSource: "cloud_tenant",
observedRunId: f.runId, idempotencyKey: randomUUID(), prepareChatRetry: f.service.prepareFailedChatRunRetry };
const request = await requestTaskRetry(input);
expect(request.accepted).toBe(true);
expect((await requestTaskRetry(input)).requestId).toBe(request.requestId);
expect(await f.service.processFailedChatRunRetry(request.chatActionId!)).toMatchObject({ status: "queued" });
const [wake] = await readAgentWakeupRequestsById(request.chatActionId!);
expect(wake.runId).toBeTruthy();
const expectedComment = pending?.payload.commentId ?? f.action.payload.commentId;
const [run] = await readHeartbeatRunsById(wake.runId!);
expect(run).toMatchObject({ status: "queued", retryOfRunId: f.runId,
contextSnapshot: { wakeCommentIds: [expectedComment], taskRetryRequestId: request.requestId } });
if (pending) expect((await readAgentWakeupRequestsById(pending.id))[0])
.toMatchObject({ status: "coalesced", runId: wake.runId });
await f.service.processFailedChatRunRetry(request.chatActionId!);
await heartbeatService(db).resumeQueuedRuns();
expect((await readHeartbeatRunsByCompanyId(companyId)).filter(r => r.contextSnapshot?.taskRetryRequestId === request.requestId))
.toHaveLength(1);
expect((await db.select().from(chatDeliveries).where(eq(chatDeliveries.id, originalDelivery.id)))[0].normalizedEvent)
.toEqual(originalDelivery.normalizedEvent);
} finally { await f.service.shutdown(); }
},
);
it.each(["legacy_receipt", "concurrent_clicks", "lost_dispatch_reply", "restart", "retry_failed_successor", "http", "handoff_rollback", "board_permission_wait", "peer_conversation", "backlog_51", "consumed_backlog_52"] as const)(
"preserves one current GitHub Retry across %s", async mode => {
const f = await currentGitHubTaskRetryFixture(true, mode.includes("backlog") ? 51 : mode === "peer_conversation" ? 2 : 1);
try {
const companyId = f.fixture.companyId, issueId = f.issue.id;
if (mode === "consumed_backlog_52") {
const sources = await db.select().from(chatActions).where(and(eq(chatActions.conversationId, f.conversation.id),
eq(chatActions.kind, "inbound_wakeup"))).orderBy(asc(chatActions.createdAt), asc(chatActions.id));
expect(sources).toHaveLength(52);
const commentIds = sources.map(source => source.payload.commentId);
await db.update(heartbeatRuns).set({ finishedAt: new Date(), contextSnapshot: {
...(await readHeartbeatRunsById(f.runId))[0].contextSnapshot,
wakeCommentIds: commentIds, wakeCommentId: commentIds.at(-1),
} }).where(eq(heartbeatRuns.id, f.runId));
await db.update(agentWakeupRequests).set({ runId: f.runId, status: "coalesced",
payload: sql`${agentWakeupRequests.payload} || ${JSON.stringify({ coalescedIntoWakeupRequestId: f.action.id })}::jsonb`,
}).where(and(inArray(agentWakeupRequests.id, sources.map(source => source.id)),
sql`${agentWakeupRequests.id} <> ${f.action.id}::uuid`));
}
let peerInput: typeof chatActions.$inferSelect | undefined;
if (mode === "peer_conversation") {
const originId = randomUUID();
await db.insert(chatConversations).values({ ...f.conversation, id: originId, sessionGeneration: 2 });
await db.update(chatActions).set({ conversationId: originId,
payload: sql`jsonb_set(${chatActions.payload}, '{sessionGeneration}', '2'::jsonb)`,
}).where(inArray(chatActions.id, [f.action.id, f.pending!.id]));
await db.update(chatDeliveries).set({ conversationId: originId })
.where(inArray(chatDeliveries.id, [f.action.deliveryId!, f.pending!.deliveryId!]));
await db.update(chatMessageLinks).set({ conversationId: originId })
.where(inArray(chatMessageLinks.deliveryId, [f.action.deliveryId!, f.pending!.deliveryId!]));
[peerInput] = await db.select().from(chatActions).where(and(eq(chatActions.conversationId, f.conversation.id),
eq(chatActions.kind, "inbound_wakeup")));
expect(peerInput).toBeDefined();
}
if (mode === "legacy_receipt") {
const [originalEndpoint] = await readChatEndpointsById(f.endpoint.id);
await db.insert(chatActions).values({ companyId, endpointId: f.endpoint.id,
conversationId: f.conversation.id, deliveryId: f.pending!.deliveryId,
kind: "github_response_comment", providerActionId: `github_response:${f.pending!.deliveryId}`,
payload: { botExternalId: originalEndpoint.botExternalId, agentId: f.fixture.assignedAgentId },
status: "processed" }).onConflictDoNothing();
await db.update(chatDeliveries).set({
normalizedEvent: sql`jsonb_set(${chatDeliveries.normalizedEvent}, '{githubAuthority}',
(${chatDeliveries.normalizedEvent}->'githubAuthority') - 'botExternalId')`,
}).where(eq(chatDeliveries.id, f.pending!.deliveryId!));
}
await f.service.configure(f.endpoint.id, { action: "reconnect" }, "owner-user");
const input = { db, companyId, issueId, actorId: "owner-user", actorSource: "cloud_tenant",
observedRunId: f.runId, idempotencyKey: randomUUID(), prepareChatRetry: f.service.prepareFailedChatRunRetry };
if (mode === "board_permission_wait") {
input.actorId = `retry-operator-${companyId}`;
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: input.actorId,
status: "active", membershipRole: "operator" });
}
const queryEvents: Record<string, unknown>[] = [];
const capture = mode === "backlog_51" ? createDatabaseResponseCapture({ start: Date.now() - 1,
end: Date.now() + 59_000 }, event => queryEvents.push(event)) : undefined;
if (capture) installDatabaseResponseCapture(db, { chatActions, chatDeliveries, chatMessageLinks, issueComments,
agentWakeupRequests, chatEndpoints, chatConversations }, capture);
let one: Awaited<ReturnType<typeof requestTaskRetry>>, two: typeof one;
if (mode === "http") {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = boardActor(companyId); next(); });
app.use("/api", issueRoutes(db, createStorageService().storage, { chatRunRetries: f.service }));
app.use(errorHandler);
const send = () => request(app).post(`/api/issues/${issueId}/retry`).send({
idempotencyKey: input.idempotencyKey, observedRunId: input.observedRunId,
}).expect(202);
const replies = await Promise.all([send(), send()]);
[one, two] = replies.map(response => response.body);
} else [one, two] = await Promise.all([requestTaskRetry(input), requestTaskRetry({ ...input,
idempotencyKey: mode === "concurrent_clicks" ? randomUUID() : input.idempotencyKey })]);
if (capture) {
capture.flush();
expect(Number(queryEvents.find(event => event.event === "paperclip.db_response_capture.summary.v1")?.startedQueries))
.toBeLessThan(180);
}
expect(two.requestId).toBe(one.requestId);
expect(one).toMatchObject({ accepted: true, chatActionId: expect.any(String) });
let service = f.service;
if (mode === "restart") {
await service.shutdown();
service = createService(f.runtime, f.providerFetch, { wakeup: heartbeatService(db).wakeup }).service;
}
if (mode === "lost_dispatch_reply") f.wakeup.mockImplementationOnce(async (...args) => {
await heartbeatService(db).wakeup(...args);
throw new Error("Simulated response loss after dispatch commit");
});
if (mode === "handoff_rollback") f.wakeup.mockImplementationOnce(async (agentId, options) => {
const original = options!.durableChatRequest!;
return heartbeatService(db).wakeup(agentId, { ...options, durableChatRequest: createDurableChatWakeupRequest({
...original, onAdmitted: async (tx, receipt) => {
await original.onAdmitted!(tx, receipt);
throw new Error("Simulated interruption before admission commit");
},
}) });
});
if (mode === "board_permission_wait") await db.update(companyMemberships).set({ membershipRole: "viewer" })
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, input.actorId)));
await Promise.all([service.processFailedChatRunRetry(one.chatActionId!),
service.processFailedChatRunRetry(one.chatActionId!)]);
if (mode === "handoff_rollback" || mode === "board_permission_wait") {
expect(await readAgentWakeupRequestsById(one.chatActionId!)).toHaveLength(0);
expect((await readAgentWakeupRequestsById(f.pending!.id))[0])
.toMatchObject({ status: "deferred_issue_execution", runId: null });
expect((await readHeartbeatRunsByCompanyId(companyId))
.filter(run => run.contextSnapshot?.taskRetryRequestId === one.requestId)).toHaveLength(0);
}
if (mode === "board_permission_wait") await db.update(companyMemberships).set({ membershipRole: "operator" })
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, input.actorId)));
await vi.waitFor(async () => {
await service.processFailedChatRunRetry(one.chatActionId!);
expect((await readAgentWakeupRequestsById(one.chatActionId!))[0]?.runId,
JSON.stringify((await db.select().from(chatActions).where(eq(chatActions.id, one.chatActionId!)))[0]?.result)).toBeTruthy();
}, { timeout: 10_000, interval: 100 });
const [parent] = await readAgentWakeupRequestsById(one.chatActionId!);
expect(parent.runId).toBeTruthy();
expect((await readAgentWakeupRequestsById(f.pending!.id))[0]).toMatchObject({
status: "coalesced", runId: mode === "consumed_backlog_52" ? f.runId : parent.runId,
});
if (peerInput) expect((await readAgentWakeupRequestsById(peerInput.id))[0])
.toMatchObject({ status: "deferred_issue_execution", runId: null });
if (mode === "backlog_51") {
const saved = await db.select({ id: chatActions.id, payload: chatActions.payload,
status: agentWakeupRequests.status, runId: agentWakeupRequests.runId }).from(chatActions)
.innerJoin(agentWakeupRequests, eq(agentWakeupRequests.id, chatActions.id)).where(and(
eq(chatActions.conversationId, f.conversation.id), eq(chatActions.kind, "inbound_wakeup"),
sql`${chatActions.id} <> ${f.action.id}::uuid`,
)).orderBy(asc(chatActions.createdAt), asc(chatActions.id));
expect(saved).toHaveLength(51);
expect(saved.every(source => source.status === "coalesced" && source.runId === parent.runId)).toBe(true);
expect((await readHeartbeatRunsById(parent.runId!))[0].contextSnapshot?.wakeCommentIds)
.toEqual(saved.map(source => source.payload.commentId));
}
await service.processFailedChatRunRetry(one.chatActionId!);
expect((await readHeartbeatRunsByCompanyId(companyId))
.filter(run => run.contextSnapshot?.taskRetryRequestId === one.requestId)).toHaveLength(1);
expect(await db.select().from(chatActions).where(and(eq(chatActions.companyId, companyId),
eq(chatActions.kind, "failed_run_retry")))).toHaveLength(1);
if (mode === "retry_failed_successor" || mode === "backlog_51") {
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date(),
errorCode: "adapter_failed", runtimeMode: "native", nativeIssueId: issueId, nativePhase: "terminal_failure",
}).where(eq(heartbeatRuns.id, parent.runId!));
await db.update(agentWakeupRequests).set({ status: "failed" }).where(eq(agentWakeupRequests.id, one.chatActionId!));
await db.update(issues).set({ executionRunId: null, status: "blocked" }).where(eq(issues.id, issueId));
await db.insert(nativeRunFinalizations).values({ companyId, issueId, runId: parent.runId!, phase: "terminal_failure" });
await db.insert(heartbeatRunEvents).values({ companyId, agentId: f.fixture.assignedAgentId, runId: parent.runId!,
seq: 1, eventType: "native.local_process_stopped", payload: { processPid: 999999998, processGroupId: null } });
await service.configure(f.endpoint.id, { action: "reconnect" }, "owner-user");
const next = await requestTaskRetry({ ...input, observedRunId: parent.runId!, idempotencyKey: randomUUID(),
prepareChatRetry: service.prepareFailedChatRunRetry });
expect(next.requestId).not.toBe(one.requestId);
expect(await service.processFailedChatRunRetry(next.chatActionId!)).toMatchObject({ status: "queued" });
const [second] = await readAgentWakeupRequestsById(next.chatActionId!);
expect((await readHeartbeatRunsById(second.runId!))[0]).toMatchObject({
retryOfRunId: parent.runId, contextSnapshot: {
wakeCommentIds: (await readHeartbeatRunsById(parent.runId!))[0].contextSnapshot!.wakeCommentIds,
},
});
}
} finally { await f.service.shutdown(); }
},
);
it("leaves saved GitHub input pending when a persisted Retry wait loses board authority", async () => {
const f = await currentGitHubTaskRetryFixture(true);
try {
const companyId = f.fixture.companyId, issueId = f.issue.id, actorId = `retry-operator-${companyId}`;
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: actorId,
status: "active", membershipRole: "operator" });
const intent = await requestTaskRetry({ db, companyId, issueId, actorId, actorSource: "cloud_tenant",
observedRunId: f.runId, idempotencyKey: randomUUID(), prepareChatRetry: f.service.prepareFailedChatRunRetry });
let savedOptions: Parameters<typeof f.wakeup>[1];
f.wakeup.mockImplementationOnce(async (_agentId, options) => {
savedOptions = options;
const request = options!.durableChatRequest!;
// Persist the pre-handoff wait as a server interrupted before queue adoption would.
await db.insert(agentWakeupRequests).values({ id: request.id, companyId, agentId: request.agentId,
source: options!.source, triggerDetail: options!.triggerDetail, reason: options!.reason,
requestedByActorType: request.requestedByActorType, requestedByActorId: request.requestedByActorId,
requestedAt: request.requestedAt, idempotencyKey: request.idempotencyKey,
status: "deferred_issue_execution", payload: { ...options!.payload,
taskRetryRequestId: intent.requestId, _paperclipWakeContext: options!.contextSnapshot } });
return null;
});
expect(await f.service.processFailedChatRunRetry(intent.chatActionId!)).toMatchObject({ status: "deferred" });
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(
eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, actorId)));
expect(await heartbeatService(db).wakeup(f.fixture.assignedAgentId, savedOptions!)).toBeNull();
expect((await readAgentWakeupRequestsById(f.pending!.id))[0])
.toMatchObject({ status: "deferred_issue_execution", runId: null });
expect((await readHeartbeatRunsByCompanyId(companyId))
.filter(run => run.contextSnapshot?.taskRetryRequestId === intent.requestId)).toHaveLength(0);
await db.update(companyMemberships).set({ membershipRole: "operator" }).where(and(
eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, actorId)));
await heartbeatService(db).wakeup(f.fixture.assignedAgentId, savedOptions!);
const [parent] = await readAgentWakeupRequestsById(intent.chatActionId!);
expect(parent.runId).toBeTruthy();
expect((await readAgentWakeupRequestsById(f.pending!.id))[0]).toMatchObject({ status: "coalesced", runId: parent.runId });
} finally { await f.service.shutdown(); }
});
it.each(["changed_bot", "disabled_repository", "retargeted_repository", "revoked_sender", "paused_endpoint",
"edited_source", "deleted_source", "reassigned_task", "second_reconnect", "later_message_wrong_bot", "later_message_deleted", "later_message_revoked_policy"] as const)(
"keeps current GitHub task Retry authority gated: %s", async gate => {
const f = await currentGitHubTaskRetryFixture(true, gate.startsWith("later_message") ? 2 : 1);
try {
const companyId = f.fixture.companyId, issueId = f.issue.id;
await f.service.configure(f.endpoint.id, { action: "reconnect" }, "owner-user");
const input = { db, companyId, issueId, actorId: "owner-user", actorSource: "cloud_tenant",
observedRunId: f.runId, idempotencyKey: randomUUID(), prepareChatRetry: f.service.prepareFailedChatRunRetry };
if (gate === "later_message_revoked_policy") {
const [resource] = await db.select().from(chatEndpointResources).where(eq(chatEndpointResources.id, f.conversation.resourceId!));
await db.update(chatEndpointResources).set({ availability: "available",
metadata: { ...resource.metadata, providerRepositoryId: "418" },
}).where(eq(chatEndpointResources.id, resource.id));
await db.insert(chatGitHubConfigurations).values({ companyId, endpointId: f.endpoint.id,
updatedByUserId: "owner-user", configuration: { version: 1, toolsEnabled: true, responsibleUserId: "owner-user",
memberAccess: "all_linked", people: [], repositories: {},
defaults: { ...defaultGitHubReviewPolicy(), invocation: "linked_authors", events: ["opened", "synchronize"] },
} });
const sources = await db.select().from(chatActions).where(and(eq(chatActions.conversationId, f.conversation.id),
eq(chatActions.kind, "inbound_wakeup"), sql`${chatActions.id} <> ${f.action.id}::uuid`))
.orderBy(asc(chatActions.createdAt), asc(chatActions.id));
for (const [index, source] of sources.entries()) {
const context = { event: index === 0 ? "opened" : "synchronize", deliveryId: source.deliveryId,
repositoryId: "418", repository: resource.providerResourceId,
pullNumber: 7, title: "Saved review request", body: "", baseSha: "a".repeat(40), headSha: "b".repeat(40),
baseBranch: "master", author: { id: "42", login: "octocat", isBot: false },
sender: { id: "42", login: "octocat" }, draft: false, labels: [] };
await db.update(chatDeliveries).set({
normalizedEvent: sql`${chatDeliveries.normalizedEvent} || ${JSON.stringify({ githubAutomatic: { context } })}::jsonb`,
}).where(eq(chatDeliveries.id, source.deliveryId!));
}
}
const accepted = await requestTaskRetry(input);
if (gate.startsWith("later_message")) {
const [later] = await db.select().from(chatActions).where(and(eq(chatActions.conversationId, f.conversation.id),
eq(chatActions.kind, "inbound_wakeup"), sql`${chatActions.id} not in (${f.action.id}::uuid, ${f.pending!.id}::uuid)`));
if (gate === "later_message_wrong_bot") await db.update(chatDeliveries).set({
normalizedEvent: sql`jsonb_set(${chatDeliveries.normalizedEvent}, '{githubAuthority,botExternalId}', ${JSON.stringify(randomUUID())}::jsonb)`,
}).where(eq(chatDeliveries.id, later.deliveryId!));
else if (gate === "later_message_deleted") await db.update(issueComments).set({ deletedAt: new Date() })
.where(eq(issueComments.id, String(later.payload.commentId)));
else await db.update(chatGitHubConfigurations).set({ revision: sql`${chatGitHubConfigurations.revision} + 1`,
configuration: sql`jsonb_set(${chatGitHubConfigurations.configuration}, '{defaults,events}', '["opened"]'::jsonb)`,
}).where(eq(chatGitHubConfigurations.endpointId, f.endpoint.id));
}
if (gate === "changed_bot") await db.update(chatEndpoints).set({ botExternalId: randomUUID() })
.where(eq(chatEndpoints.id, f.endpoint.id));
if (gate === "disabled_repository") await db.update(chatEndpointResources).set({ enabled: false })
.where(eq(chatEndpointResources.id, f.conversation.resourceId!));
if (gate === "retargeted_repository") await db.update(chatEndpointResources).set({ providerResourceId: "another/repository" })
.where(eq(chatEndpointResources.id, f.conversation.resourceId!));
if (gate === "revoked_sender") await db.update(chatIdentityLinks).set({ status: "revoked" })
.where(and(eq(chatIdentityLinks.endpointId, f.endpoint.id), eq(chatIdentityLinks.principalId, f.pending!.principalId!)));
if (gate === "paused_endpoint") await db.update(chatEndpoints).set({ status: "paused" })
.where(eq(chatEndpoints.id, f.endpoint.id));
if (gate === "edited_source") await db.update(issueComments).set({ body: "Changed after Retry", updatedAt: new Date() })
.where(eq(issueComments.id, String(f.pending!.payload.commentId)));
if (gate === "deleted_source") await db.update(issueComments).set({ deletedAt: new Date() })
.where(eq(issueComments.id, String(f.pending!.payload.commentId)));
if (gate === "reassigned_task") await db.update(issues).set({ assigneeAgentId: f.fixture.replacementAgentId })
.where(eq(issues.id, issueId));
if (gate === "second_reconnect") await f.service.configure(f.endpoint.id, { action: "reconnect" }, "owner-user");
expect(await f.service.processFailedChatRunRetry(accepted.chatActionId!)).toMatchObject({ status: "failed" });
expect((await readAgentWakeupRequestsById(f.pending!.id))[0]).toMatchObject({ status: "deferred_issue_execution", runId: null });
expect((await readHeartbeatRunsByCompanyId(companyId))
.filter(run => run.contextSnapshot?.taskRetryRequestId === accepted.requestId)).toHaveLength(0);
const [settled] = await db.select().from(chatActions).where(eq(chatActions.id, accepted.chatActionId!));
expect(settled.result?.taskRetryInputReconciled).toBe(true);
const [intentReceipt] = await readAgentWakeupRequestsById(accepted.requestId!);
await f.service.processFailedChatRunRetry(accepted.chatActionId!);
expect((await readAgentWakeupRequestsById(accepted.requestId!))[0].updatedAt).toEqual(intentReceipt.updatedAt);
if (gate === "second_reconnect") {
const next = await requestTaskRetry({ ...input, idempotencyKey: randomUUID() });
expect(next.requestId).not.toBe(accepted.requestId);
expect(await f.service.processFailedChatRunRetry(next.chatActionId!)).toMatchObject({ status: "queued" });
}
} finally { await f.service.shutdown(); }
},
);
it.each([
"settled",
"distinct_account",
@@ -4,7 +4,7 @@ import { localAiLoginService } from "../services/local-ai-login.js";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import { agents, companies, createDb, heartbeatRuns, issues, nativeRunFinalizations, applyPendingMigrations } from "@paperclipai/db";
import { agents, agentWakeupRequests, companies, createDb, heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, applyPendingMigrations } from "@paperclipai/db";
import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signals.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
@@ -16,7 +16,13 @@ import { terminalizeLegacyExecution } from "../services/legacy-execution-recover
import { reconcileAbandonedExecutionControl } from "../services/execution-control-reconciliation.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import { markExecutionReconciliation, deliverReconciledExecutions, settleUnrecoverableExecutions } from "../services/execution-recovery-resolution.js";
import { nextWorkspaceRecoveryAt } from "../services/system-recovery.js";
import { createSystemRecovery } from "../services/system-recovery-state.js";
import { retainedWorkspaceCandidates } from "../services/workspace-recovery.js";
import { registerWorkspaceRecoveryWork } from "../services/workspace-recovery-work.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
import { heartbeatService } from "../services/heartbeat.js";
import { requestTaskRetry } from "../services/task-retry.js";
const coordinators: ReturnType<typeof createDeliveryWorkCoordinator>[] = [];
afterEach(async () => { for (const worker of coordinators.splice(0)) await worker.stop(); vi.useRealTimers(); });
@@ -94,13 +100,13 @@ describe("execution-control lifecycle", () => {
expect(run).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalledTimes(1);
await c.stop(); expect(c.nextWakeAt()).toBeNull();
});
it("keeps other obligations responsive while one is waiting, and awaits shutdown", async () => {
it.each([DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.workspaceRecovery])("keeps finalization responsive while %s is waiting, and awaits shutdown", async heldQueue => {
vi.useFakeTimers(); const owner = fakeOwner(); const c = coordinator(owner);
let release!: () => void; const gate = new Promise<void>(resolve => { release = resolve; });
const status = vi.fn(async () => {});
const started = registerExecutionControlWork(c, [
{ queue: DELIVERY_QUEUES.executionReplacement, run: () => gate, next: async () => null },
{ queue: DELIVERY_QUEUES.executionStatus, run: status, next: async () => null },
{ queue: heldQueue, run: () => gate, next: async () => null },
{ queue: DELIVERY_QUEUES.executionFinalization, run: status, next: async () => null },
]);
await vi.advanceTimersByTimeAsync(1); expect(status).toHaveBeenCalledTimes(1);
let stopped = false; const stopping = c.stop().then(() => { stopped = true; });
@@ -120,6 +126,7 @@ describe.skipIf(!support.supported)("execution-control durable work", () => {
if (external) await applyPendingMigrations(external);
else temporary = await startEmbeddedPostgresTestDatabase("execution-sleep-");
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6 });
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.05)`)));
}, 90000);
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
beforeEach(async () => {
@@ -145,22 +152,140 @@ describe.skipIf(!support.supported)("execution-control durable work", () => {
await settle(); expect(c.nextWakeAt()).toBeNull(); const before = calls;
await vi.advanceTimersByTimeAsync(90_000); expect(calls).toBe(before);
}
async function runRow(status = "running") {
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status, runtimeMode: "legacy", contextSnapshot: { issueId } }).returning();
async function runRow(status = "running", sourceIssueId = issueId) {
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status, runtimeMode: "legacy", contextSnapshot: { issueId: sourceIssueId } }).returning();
return run!;
}
it("stops all six pending/deadline scans with empty queues and completed history", async () => {
async function pausedRecoveredSource(actionId = randomUUID()) {
const sourceIssueId = randomUUID();
await db.insert(issues).values({ id: sourceIssueId, companyId, title: "Recovered execution",
status: "in_progress", assigneeAgentId: agentId });
const run = await runRow("failed", sourceIssueId);
await db.update(heartbeatRuns).set({ resultJson: { startupCancellation: { beforeNativeSelection: true } } })
.where(eq(heartbeatRuns.id, run.id));
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId));
await db.insert(issueRecoveryActions).values({ id: actionId, companyId, sourceIssueId,
kind: "active_run_watchdog", ownerType: "system", cause: "native_workspace_sync_out_retry_exhausted",
fingerprint: run.id, nextAction: "Saved work recovered", evidence: { runId: run.id,
systemRecovery: { ...createSystemRecovery(run.id), state: "complete" },
workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: run.id,
status: "recovered", stage: "saving_receipts", attempt: 1, attemptLimit: 6, requestIds: [] } } });
const retry = await requestTaskRetry({ db, companyId, issueId: sourceIssueId, actorId: "board",
observedRunId: run.id, idempotencyKey: randomUUID() });
expect(retry.accepted).toBe(true);
return { run, retry };
}
it("stops all pending/deadline scans with empty queues and completed history", async () => {
await runRow("succeeded");
const schedule = executionControlSchedule(observed);
const c = coordinator(db, undefined, error => { errors.push(error); });
const topics = [DELIVERY_QUEUES.executionFinalization, DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.executionContinuation,
const topics = [DELIVERY_QUEUES.executionFinalization, DELIVERY_QUEUES.workspaceRecovery, DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.executionContinuation,
DELIVERY_QUEUES.executionStatus, DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.localLoginCleanup];
const runs = [() => reconcileAbandonedExecutionControl(observed), () => reconcileSafeNativeReplacements(observed),
const runs = [() => reconcileAbandonedExecutionControl(observed), async () => {}, () => reconcileSafeNativeReplacements(observed),
() => deliverReconciledExecutions(observed, vi.fn(async () => null)), () => deliverExecutionStatuses(observed),
() => settleUnrecoverableExecutions(observed), () => localAiLoginService(observed).reapExpired()];
await registerExecutionControlWork(c, Object.values(schedule).map((next, i) => ({ queue: topics[i]!, run: runs[i]!, next }))).ready;
await quiet(c);
});
it("schedules only a current unfinished workspace recovery and becomes quiet with retained history", async () => {
const run = await runRow("failed");
const state = createSystemRecovery(run.id);
const evidence = { runId: run.id, systemRecovery: state,
workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: run.id,
status: "queued", stage: "verifying_stop", attempt: 0, attemptLimit: 6, requestIds: [] } };
const [action] = await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId,
kind: "active_run_watchdog", ownerType: "system", cause: "native_workspace_sync_out_retry_exhausted",
fingerprint: run.id, nextAction: "Save work", evidence }).returning();
expect(await nextWorkspaceRecoveryAt(db)).toBe(0);
const due = Date.now() + 60_000;
await db.update(issueRecoveryActions).set({ evidence: { ...evidence, systemRecovery: { ...state, nextAttemptAt: due } } })
.where(eq(issueRecoveryActions.id, action!.id));
expect(await executionControlSchedule(db).workspaceRecovery()).toBe(due);
for (const status of ["cancelled", "resolved"] as const) {
await db.update(issueRecoveryActions).set({ status }).where(eq(issueRecoveryActions.id, action!.id));
expect(await nextWorkspaceRecoveryAt(db)).toBeNull();
expect(await retainedWorkspaceCandidates(db)).toEqual([]);
}
for (const system of [{ ...state, sourceRunId: randomUUID() }, { ...state, state: "complete" }, { ...state, state: "exhausted" }]) {
await db.update(issueRecoveryActions).set({ status: "active", evidence: { ...evidence, systemRecovery: system } })
.where(eq(issueRecoveryActions.id, action!.id));
expect(await nextWorkspaceRecoveryAt(db)).toBeNull();
}
await db.update(issueRecoveryActions).set({ evidence, cause: "agent_unreachable" })
.where(eq(issueRecoveryActions.id, action!.id));
expect(await nextWorkspaceRecoveryAt(db)).toBeNull();
await db.update(issueRecoveryActions).set({ cause: "native_workspace_sync_out_retry_exhausted",
evidence: { ...evidence, workspaceRecovery: { ...evidence.workspaceRecovery, runId: randomUUID() } } })
.where(eq(issueRecoveryActions.id, action!.id));
expect(await nextWorkspaceRecoveryAt(db)).toBeNull();
expect(await retainedWorkspaceCandidates(db)).toEqual([]);
await db.update(issueRecoveryActions).set({ status: "cancelled", evidence: { ...evidence,
systemRecovery: { ...state, state: "running" } } }).where(eq(issueRecoveryActions.id, action!.id));
const c = coordinator(db, undefined, error => { errors.push(error); });
const worker = registerWorkspaceRecoveryWork(observed, c,
{} as Parameters<typeof registerWorkspaceRecoveryWork>[2], async () => {});
try { await worker.ready; await quiet(c); } finally { await worker.stop(); }
});
it.each([1, 3, 7])("visits all %i paused Retry sources once and becomes quiet after file recovery", async count => {
const sources = [];
for (let index = 0; index < count; index++) sources.push(await pausedRecoveredSource());
const resume = vi.fn(heartbeatService(observed).resumeRemoteStopComments);
const c = coordinator(db, undefined, error => { errors.push(error); });
const worker = registerWorkspaceRecoveryWork(observed, c,
{} as Parameters<typeof registerWorkspaceRecoveryWork>[2], resume);
try {
await worker.ready;
for (let pass = 0; pass < 10 && resume.mock.calls.length < count; pass++) await settle();
expect(resume).toHaveBeenCalledTimes(count);
await quiet(c);
for (const result of resume.mock.results) {
await expect(result.value).rejects.toThrow("Agent is not invokable in its current state");
}
for (const { retry } of sources) {
const [saved] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, retry.requestId!));
expect(saved).toMatchObject({ status: "deferred_issue_execution", runId: null });
}
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(count);
expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0]?.status).toBe("paused");
} finally { await c.stop(); await worker.stop(); }
});
it("visits a newly committed earlier Retry during a busy recovered-source pass, then becomes quiet", async () => {
const sources = [];
for (let index = 0; index < 3; index++) {
sources.push(await pausedRecoveredSource(`ffffffff-000${index}-4000-8000-${randomUUID().slice(-12)}`));
}
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const heartbeat = heartbeatService(observed);
const resume = vi.fn(async (run: Parameters<typeof heartbeat.resumeRemoteStopComments>[0]) => {
await gate;
return heartbeat.resumeRemoteStopComments(run);
});
const c = coordinator(db, undefined, error => { errors.push(error); });
const worker = registerWorkspaceRecoveryWork(observed, c,
{} as Parameters<typeof registerWorkspaceRecoveryWork>[2], resume);
try {
await worker.ready;
await vi.waitFor(() => expect(resume).toHaveBeenCalledTimes(2));
const earlier = await pausedRecoveredSource(`00000000-0000-4000-8000-${randomUUID().slice(-12)}`);
sources.push(earlier);
release();
for (let pass = 0; pass < 10 && sources.some(({ run }) =>
!resume.mock.calls.some(([visited]) => visited.id === run.id)); pass++) await settle();
expect(resume.mock.calls.map(([run]) => run.id)).toContain(earlier.run.id);
await quiet(c);
for (const { run, retry } of sources) {
expect(resume.mock.calls.map(([visited]) => visited.id)).toContain(run.id);
const [saved] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, retry.requestId!));
expect(saved).toMatchObject({ status: "deferred_issue_execution", runId: null });
}
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(4);
expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0]?.status).toBe("paused");
} finally { release(); await c.stop(); await worker.stop(); }
});
it("bounds real producer bursts with another company's completed history present", async () => {
const otherCompany = randomUUID(), otherAgent = randomUUID();
await db.insert(companies).values({ id: otherCompany, name: "Other tenant", issuePrefix: "OTH" });
+5
View File
@@ -1,3 +1,4 @@
import { registerWorkspaceRecoveryWork } from "./services/workspace-recovery-work.js";
import { registerEnvironmentCleanupWork } from "./services/environment-cleanup-work.js";
import { loginCleanupSchedule } from "./services/login-cleanup-state.js";
import { registerLoginCleanupWork } from "./services/login-cleanup-work.js";
@@ -1342,6 +1343,9 @@ async function startServerWithDatabaseTeardown(
});
const deliveryWork = app.locals.deliveryWork as ReturnType<typeof createDeliveryWorkCoordinator>;
const executionSchedule = executionControlSchedule(db);
const workspaceRecoveryRuntime = environmentRuntimeService(db, { pluginWorkerManager });
const workspaceRecoveryWork = registerWorkspaceRecoveryWork(db, deliveryWork, workspaceRecoveryRuntime,
accountingHeartbeat.resumeRemoteStopComments, () => !heartbeatSchedulerStopped);
registerExecutionControlWork(deliveryWork, [
{ queue: DELIVERY_QUEUES.executionFinalization, run: () => reconcileAbandonedExecutionControl(db), next: executionSchedule.finalization },
{ queue: DELIVERY_QUEUES.executionStatus, run: () => deliverExecutionStatuses(db), next: executionSchedule.status },
@@ -2103,6 +2107,7 @@ async function startServerWithDatabaseTeardown(
await skillSourceSync.stop();
unsubscribeChatCompletions();
await deliveryWork.stop();
await workspaceRecoveryWork.stop();
unregisterTerminalWorkspaceProbe?.();
unsubscribeNativeWorkspaceSyncRetention?.();
if (heartbeatSchedulerInterval) {
@@ -74,7 +74,7 @@ describe("server-owned Codex runner selection", () => {
const input = { adapterType: "codex_local", target, adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md", workspaceStrategy: { type: "git_worktree" }, timeoutSec: 60, search: false, fastMode: false, dangerouslyBypassApprovalsAndSandbox: true } };
const resolved = resolveNewAgentRunner(input);
expect(resolved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", codexPermissionMode: "never", modelReasoningEffort: "high", env: input.adapterConfig.env, workspaceStrategy: input.adapterConfig.workspaceStrategy, timeoutSec: 60 } });
expect(resolved.adapterConfig).not.toHaveProperty("search");
expect(resolved.adapterConfig).toHaveProperty("search", false);
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
expect(input.adapterConfig).toHaveProperty("search", false);
});
@@ -270,7 +270,7 @@ function resolveAgentRunner(input: AgentRunnerSelection): { adapterType: string;
incompatible(["env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS"]);
}
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
for (const key of ["engine", "command", "extraArgs", "args", "agentCommand", "mode", "nonInteractivePermissions", "stateDir", "warmHandleIdleMs", "dangerouslyBypassApprovalsAndSandbox", "dangerouslyBypassSandbox", "search", "fastMode", "reasoningEffort", "effort"]) delete config[key];
for (const key of ["engine", "command", "extraArgs", "args", "agentCommand", "mode", "nonInteractivePermissions", "stateDir", "warmHandleIdleMs", "dangerouslyBypassApprovalsAndSandbox", "dangerouslyBypassSandbox", "fastMode", "reasoningEffort", "effort"]) delete config[key];
if (config.model !== undefined && typeof config.model !== "string") incompatible(["model"]);
config = { ...config, provider: "codex", model: typeof config.model === "string" && config.model.trim() ? config.model.trim() : PAPERCLIP_RUNNER_DEFAULT_MODELS.codex, codexPermissionMode: config.codexPermissionMode ?? "never", lifecycleMode: config.lifecycleMode ?? "per_turn", ...(effort === undefined ? {} : { modelReasoningEffort: effort }) };
try { resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: config }); }
+3 -15
View File
@@ -1,3 +1,4 @@
import { credentialFingerprint } from "./chat-channels/credential-fingerprint.js";
import { createChatSdkMessageReceiver } from "./chat-channels/runtime/sdk-messages.js";
import { admitDiscordSdkMessage } from "./chat-channels/discord/messages.js";
import { resolveInboundConversationBinding } from "./chat-channels/inbound/conversation-binding.js";
@@ -312,20 +313,6 @@ function runtimeGeneration(setup: ChatEndpointSetupState): number {
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
}
function credentialFingerprint(refs: ToolCredentialSecretRef[]): string {
const stable = refs
.map((ref) => ({
configPath: ref.configPath,
secretId: ref.secretId,
versionSelector: ref.versionSelector ?? "latest",
}))
.sort((left, right) =>
`${left.configPath}:${left.secretId}:${left.versionSelector}`.localeCompare(
`${right.configPath}:${right.secretId}:${right.versionSelector}`,
),
);
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
}
type ConversationRow = typeof chatConversations.$inferSelect;
type DeliveryRow = typeof chatDeliveries.$inferSelect;
@@ -847,7 +834,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
const { lockTelegramCallbackNoticeAuthority } = createTelegramCallbackNoticeAuthority({ nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization });
const { stageProviderEffect, processProviderEffect, processPendingProviderEffects } = createProviderEffects(db, { endpointRecord, runtimeCallbackEndpoint, withCredentialMutationLease, runtimeFor, invalidateRuntime, lockCurrentPrincipalAuthorization, redactError, MAX_ERROR_TEXT, nonDirectDestinationAllowed, lockTelegramCallbackNoticeAuthority });
const { publicationFiles } = createOutboundAttachments(db, { get storage() { return options.storage; } });
const { authorizeInboundWakeup, authorizeInboundWorkStart, authorizeFastResponse } = createInboundWakeAuthority(db, { runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization });
const { authorizeInboundWakeup, authorizeGitHubTaskRetryBatch, authorizeInboundWorkStart, authorizeFastResponse } = createInboundWakeAuthority(db, { runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization });
const { ingestAttachments, attachmentOmissionDetail } = createInboundAttachments(db, { authorizeInboundWakeup, lifecycleRuntimeFence, sanitizeFilename, issuesSvc, isExternalActionAuthorizationChange, redactError, get storage() { return options.storage; } });
const { stageInboundWakeup, acceptInboundWakeup, settleRejectedInboundWakeups, processInboundWakeup } = createInboundWakeups(db, { authorizeInboundWakeup, authorizeInboundWorkStart, attachmentOmissionDetail, isExternalActionAuthorizationChange, redactError, fetchImpl, get heartbeat() { return options.heartbeat; } });
const { settleTelegramAttachmentOnlyFailure } = createTelegramAttachmentFailure(db, {
@@ -1203,6 +1190,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
pendingFailedRetries,
} = createChatFailedRunRetry(db, {
authorizeInboundWakeup,
authorizeGitHubTaskRetryBatch,
isExternalActionAuthorizationChange,
inboundWakeScope,
lifecycleRuntimeFence,
@@ -0,0 +1,10 @@
import { createHash } from "node:crypto";
import type { ToolCredentialSecretRef } from "@paperclipai/shared";
export function credentialFingerprint(refs: ToolCredentialSecretRef[]): string {
const stable = refs.map(ref => ({ configPath: ref.configPath, secretId: ref.secretId,
versionSelector: ref.versionSelector ?? "latest" })).sort((left, right) =>
`${left.configPath}:${left.secretId}:${left.versionSelector}`.localeCompare(
`${right.configPath}:${right.secretId}:${right.versionSelector}`));
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
}
@@ -3,6 +3,8 @@ import { readTaskChatRetryIntent, taskChatRetryCanAwaitRecovery, taskChatRetryEx
import { notifyChatActionWork, notifyChatPublicationWork } from "../chat-work-notifications.js";
import { FAILED_RUN_RETRY_STALE_MS, dueChatAction } from "../chat-action-work.js";
import { readChatAuthorizationRun } from "./authorization-run-read.js";
import { selectTaskRetryInput, claimTaskRetryInput, settleTaskRetryInput, releaseUnadmittedTaskRetryInput, taskRetryInputSchema, type TaskRetryInput } from "./task-retry-input.js";
import type { TaskRetryWakeAuthority } from "./github/task-retry-authorization.js";
import {
createHash,
@@ -93,14 +95,16 @@ type RetryRuntimeFence = { credentialFingerprint: string; generation: number };
export type ChatFailedRetrySource = Awaited<ReturnType<ReturnType<typeof createChatFailedRunRetry>["failedChatRetrySource"]>>;
type ChatFailedRunRetryOptions = {
authorizeGitHubTaskRetryBatch: ReturnType<typeof import("./inbound/wakeup-authorization.js").createInboundWakeAuthority>["authorizeGitHubTaskRetryBatch"];
authorizeInboundWakeup: (
database: DbOrTransaction,
action: typeof chatActions.$inferSelect,
notice?: { nonblocking: true; terminal: boolean },
notice?: { nonblocking: true; terminal: boolean; taskRetry?: TaskRetryWakeAuthority },
) => Promise<{
conversation: Pick<typeof chatConversations.$inferSelect, "sessionGeneration">;
endpoint: Pick<typeof chatEndpoints.$inferSelect, "provider">;
delivery: DeliveryRow;
retryRuntimeFence?: RetryRuntimeFence;
}>;
isExternalActionAuthorizationChange: (error: unknown) => boolean;
inboundWakeScope: (action: typeof chatActions.$inferSelect) => string;
@@ -112,6 +116,7 @@ type ChatFailedRunRetryOptions = {
export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOptions) {
const {
authorizeInboundWakeup,
authorizeGitHubTaskRetryBatch,
isExternalActionAuthorizationChange,
inboundWakeScope,
lifecycleRuntimeFence,
@@ -131,14 +136,14 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
}
}
function retryCommentIds(context: Record<string, unknown>): string[] {
function retryCommentIds(context: Record<string, unknown>, taskRetryInput?: TaskRetryInput): string[] {
const ids =
context.wakeCommentIds ??
(context.wakeCommentId ? [context.wakeCommentId] : []);
if (
!Array.isArray(ids) ||
ids.length === 0 ||
ids.length > 50 ||
(!taskRetryInput && ids.length > 50) ||
ids.some((id) => typeof id !== "string" || !isUuidLike(id)) ||
new Set(ids).size !== ids.length ||
(context.wakeCommentId !== undefined &&
@@ -168,6 +173,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
failedRunId: string;
sourceScopeSha256: string;
}>;
taskRetryInput?: TaskRetryInput;
sourceScopeSha256: string;
};
function hashFailedRetrySource(
@@ -651,6 +657,8 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
runId?: string;
publication?: boolean;
taskRetryIntentId?: string;
taskRetryInput?: TaskRetryInput;
taskRetryReauthorization?: TaskRetryWakeAuthority;
/** Internal selector for already committed output, never retry admission. */
committedResponse?: {
resultId: string;
@@ -734,7 +742,22 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
(context.taskKey !== issue.identifier && context.taskKey !== issue.id)
)
throw failedChatRetryDenied();
const commentIds = retryCommentIds(context);
const selection = input.taskRetryInput && taskRetryInputSchema.safeParse(input.taskRetryInput);
if (selection && !selection.success) throw failedChatRetryDenied();
const selectedInput = selection?.success ? selection.data : undefined;
if (selectedInput && (!input.taskRetryIntentId || provider !== "github")) throw failedChatRetryDenied();
const reauthorization = input.taskRetryReauthorization ?? (selectedInput ? {
intentId: input.taskRetryIntentId!, sourceRunId: failedRun.id, runtimeFence: selectedInput.runtimeFence,
} : undefined);
const pendingSources = selectedInput?.sourceActionIds.length ? await tx.select().from(chatActions).where(and(
eq(chatActions.companyId, input.companyId), eq(chatActions.kind, "inbound_wakeup"),
sql`${chatActions.payload}->>'issueId' = ${issue.id}`,
inArray(chatActions.id, selectedInput.sourceActionIds),
)) : null;
if (pendingSources && pendingSources.length !== selectedInput!.sourceActionIds.length) throw failedChatRetryDenied();
const pendingById = new Map(pendingSources?.map(source => [source.id, source]));
const commentIds = pendingSources ? selectedInput!.sourceActionIds.map(id =>
String(pendingById.get(id)!.payload.commentId)) : retryCommentIds(context, selectedInput);
const [accepted] = await tx
.select()
.from(nativeRunResults)
@@ -868,7 +891,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
answerDelivery
)
throw failedChatRetryDenied();
if (context.chatFailedRunRetry) {
if (context.chatFailedRunRetry && !pendingSources) {
const hint = context.chatFailedRunRetry as Record<string, unknown>;
const [priorIntent] = await tx
.select()
@@ -914,6 +937,9 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
committedResponse: undefined,
taskRetryIntentId: typeof priorIntent.payload.taskRetryIntentId === "string"
? priorIntent.payload.taskRetryIntentId : undefined,
taskRetryInput: priorIntent.payload.taskRetryInput as TaskRetryInput | undefined,
retryActionId: priorIntent.id,
taskRetryReauthorization: reauthorization,
},
ancestors,
);
@@ -945,6 +971,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
return hashFailedRetrySource({
...parentScope,
failedRunId: failedRun.id,
...(selectedInput ? { taskRetryInput: { ...selectedInput, runtimeFence: reauthorization?.runtimeFence } } : {}),
retryAncestors: [
...parent.retryAncestors,
{
@@ -958,20 +985,21 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
// Include every receipt that fed this run, including a deferred owner's
// coalesced children. A mutable latest-comment hint cannot drop a sibling.
const admittedBatch =
await tx.execute(sql`select source.id, source.payload->>'commentId' as comment_id
pendingSources ? [] : await tx.execute(sql`select source.id, source.payload->>'commentId' as comment_id
from agent_wakeup_requests receipt join chat_actions source on source.id = receipt.id
where receipt.company_id = ${input.companyId}::uuid and receipt.agent_id = ${input.agentId}::uuid
and (receipt.run_id = ${failedRun.id}::uuid or receipt.id = ${failedRun.wakeupRequestId}::uuid
or receipt.payload->>'coalescedIntoWakeupRequestId' = ${failedRun.wakeupRequestId})
and source.company_id = ${input.companyId}::uuid and source.kind = 'inbound_wakeup' limit 51`);
and source.company_id = ${input.companyId}::uuid and source.kind = 'inbound_wakeup'
${selectedInput ? sql`` : sql`limit 51`}`);
if (
admittedBatch.length !== commentIds.length ||
!pendingSources && (admittedBatch.length !== commentIds.length ||
admittedBatch.some(
(entry) => !commentIds.includes(String(entry.comment_id)),
)
))
)
throw failedChatRetryDenied();
const sources = await tx
const sources = pendingSources ?? await tx
.select()
.from(chatActions)
.where(
@@ -986,8 +1014,9 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
),
);
if (sources.length !== commentIds.length) throw failedChatRetryDenied();
const sourcesByCommentId = new Map(sources.map(source => [source.payload.commentId, source]));
const ordered = commentIds.map(
(id) => sources.find((action) => action.payload.commentId === id)!,
(id) => sourcesByCommentId.get(id)!,
);
const first = ordered[0]!;
if (
@@ -1011,39 +1040,53 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
);
if (!identity[0]?.locked)
throw new NativeChatReviewPresentationContentionError();
const actionIds = ordered.map(action => action.id);
const currentActions = await tx.select().from(chatActions).where(and(eq(chatActions.companyId, input.companyId),
inArray(chatActions.id, actionIds))).orderBy(asc(chatActions.id)).for("update", { noWait: true });
const deliveries = await tx.select().from(chatDeliveries).where(and(eq(chatDeliveries.companyId, input.companyId),
inArray(chatDeliveries.id, ordered.map(action => action.deliveryId!))))
.orderBy(asc(chatDeliveries.id)).for("update", { noWait: true });
const comments = await tx.select().from(issueComments).where(and(eq(issueComments.companyId, input.companyId),
eq(issueComments.issueId, issue.id), inArray(issueComments.id, commentIds)))
.orderBy(asc(issueComments.id)).for("share", { noWait: true });
const receipts = await tx.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, input.companyId),
inArray(agentWakeupRequests.id, [...actionIds, failedRun.wakeupRequestId!, ...(input.retryActionId ? [input.retryActionId] : [])])))
.orderBy(asc(agentWakeupRequests.id)).for("share", { noWait: true });
const actionsById = new Map(currentActions.map(action => [action.id, action]));
const deliveriesById = new Map(deliveries.map(delivery => [delivery.id, delivery]));
const commentsById = new Map(comments.map(comment => [comment.id, comment]));
const receiptsById = new Map(receipts.map(receipt => [receipt.id, receipt]));
const lifecycleRows = await tx.select({
target: sql<string>`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId'`,
context: sql<Record<string, unknown>>`${chatDeliveries.normalizedEvent}->'runtimeContext'`,
}).from(chatDeliveries).where(and(eq(chatDeliveries.companyId, input.companyId),
eq(chatDeliveries.endpointId, first.endpointId),
or(isNull(chatDeliveries.conversationId), eq(chatDeliveries.conversationId, first.conversationId)),
ne(chatDeliveries.state, "filtered"), inArray(chatDeliveries.eventKind, ["message_updated", "message_deleted", "message_restored"]),
inArray(sql<string>`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId'`, deliveries.map(delivery => delivery.providerEventId))));
const lifecycleKey = (target: string, context: Record<string, unknown> | null) =>
JSON.stringify([target, Object.entries(context ?? {}).sort(([left], [right]) => left.localeCompare(right))]);
const invalidated = new Set(lifecycleRows.map(row => lifecycleKey(row.target, row.context)));
const batch = reauthorization ? await authorizeGitHubTaskRetryBatch(tx, ordered, {
nonblocking: true, terminal: input.publication === true, taskRetry: reauthorization,
}).catch(error => { if (isExternalActionAuthorizationChange(error)) throw failedChatRetryDenied(); throw error; }) : null;
const canonicalSources: Array<Record<string, unknown>> = [];
let destination: Awaited<ReturnType<typeof authorizeInboundWakeup>> | null =
null;
for (const action of ordered) {
destination = await authorizeInboundWakeup(tx, action, {
destination = batch?.get(action.id) ?? await authorizeInboundWakeup(tx, action, {
nonblocking: true,
terminal: input.publication === true,
...(reauthorization ? { taskRetry: reauthorization } : {}),
}).catch((error) => {
if (isExternalActionAuthorizationChange(error))
throw failedChatRetryDenied();
throw error;
});
const [delivery] = await tx
.select()
.from(chatDeliveries)
.where(eq(chatDeliveries.id, action.deliveryId!))
.for("update", { noWait: true });
const [current] = await tx
.select()
.from(chatActions)
.where(eq(chatActions.id, action.id))
.for("update", { noWait: true });
const [comment] = await tx
.select()
.from(issueComments)
.where(
and(
eq(issueComments.id, String(action.payload.commentId)),
eq(issueComments.companyId, input.companyId),
eq(issueComments.issueId, issue.id),
),
)
.for("share", { noWait: true });
if (reauthorization && !reauthorization.runtimeFence) reauthorization.runtimeFence = destination.retryRuntimeFence;
const delivery = deliveriesById.get(action.deliveryId!);
const current = actionsById.get(action.id);
const comment = commentsById.get(String(action.payload.commentId));
if (
!current ||
current.status !== "processed" ||
@@ -1053,43 +1096,17 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
!comment ||
comment.deletedAt ||
comment.updatedAt.getTime() !== comment.createdAt.getTime() ||
comment.updatedAt >
(!pendingSources && comment.updatedAt >
(input.committedResponse
? accepted!.createdAt
: failedRun.finishedAt!) ||
: failedRun.finishedAt!)) ||
destination.endpoint.provider !== provider ||
JSON.stringify(lifecycleRuntimeFence(delivery)) !==
JSON.stringify(lifecycleRuntimeFence(destination.delivery))
)
throw failedChatRetryDenied();
const [lifecycle] = await tx
.select({ id: chatDeliveries.id })
.from(chatDeliveries)
.where(
and(
eq(chatDeliveries.companyId, input.companyId),
eq(chatDeliveries.endpointId, first.endpointId),
or(
isNull(chatDeliveries.conversationId),
eq(chatDeliveries.conversationId, first.conversationId),
),
ne(chatDeliveries.state, "filtered"),
sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(delivery))}::jsonb`,
inArray(chatDeliveries.eventKind, [
"message_updated",
"message_deleted",
"message_restored",
]),
sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`,
),
)
.limit(1);
if (lifecycle) throw failedChatRetryDenied();
const [receipt] = await tx
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, action.id))
.for("share", { noWait: true });
if (invalidated.has(lifecycleKey(delivery.providerEventId, lifecycleRuntimeFence(delivery)))) throw failedChatRetryDenied();
const receipt = receiptsById.get(action.id);
if (!receipt) throw failedChatRetryDenied();
assertFailedRetryReceipt(
createDurableChatWakeupRequest({
@@ -1107,6 +1124,17 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
}),
receipt,
);
if (pendingSources) {
const direct = receipt.status === "deferred_issue_execution" && !receipt.runId
&& receipt.payload?.coalescedIntoWakeupRequestId === undefined;
const owned = input.retryActionId && receipt.status === "coalesced"
&& receipt.payload?.coalescedIntoWakeupRequestId === input.retryActionId;
if (!direct && !owned) throw failedChatRetryDenied();
if (owned && receipt.runId) {
const owner = receiptsById.get(input.retryActionId!);
if (owner?.runId !== receipt.runId) throw failedChatRetryDenied();
}
}
let owner = receipt;
const seen = new Set<string>();
for (
@@ -1126,25 +1154,16 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
)
throw failedChatRetryDenied();
seen.add(ownerId);
const [next] = await tx
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.id, ownerId),
eq(agentWakeupRequests.companyId, input.companyId),
),
)
.for("share", { noWait: true });
const next = receiptsById.get(ownerId);
if (!next) throw failedChatRetryDenied();
owner = next;
}
if (
owner.id !== failedRun.wakeupRequestId ||
(!pendingSources && (owner.id !== failedRun.wakeupRequestId ||
owner.runId !== failedRun.id ||
owner.agentId !== input.agentId ||
owner.requestedByActorType !== receipt.requestedByActorType ||
owner.requestedByActorId !== receipt.requestedByActorId ||
owner.requestedByActorId !== receipt.requestedByActorId)) ||
["skipped", "cancelled"].includes(receipt.status)
)
throw failedChatRetryDenied();
@@ -1158,7 +1177,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
bodySha256: createHash("sha256").update(comment.body).digest("hex"),
omissionReasons: action.payload.attachmentOmissionReasons ?? {},
receiptId: receipt.id,
ownerId: owner.id,
ownerId: pendingSources ? action.id : owner.id,
});
}
if (!destination) throw failedChatRetryDenied();
@@ -1204,6 +1223,8 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
| "system",
requestedByActorId: String(first.payload.requestedByActorId),
retryAncestors: [],
...(selectedInput ? { taskRetryInput: { ...selectedInput,
runtimeFence: selectedInput.runtimeFence ?? destination.retryRuntimeFence } } : {}),
};
return hashFailedRetrySource(scope);
}
@@ -1240,7 +1261,8 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
retryIntentId?: string;
},
): Promise<{ actionId: string; issueId: string }> {
const source = await failedChatRetrySource(tx, { ...input, taskRetryIntentId: input.retryIntentId });
const taskRetryInput = input.retryIntentId ? await selectTaskRetryInput(tx, input) : undefined;
const source = await failedChatRetrySource(tx, { ...input, taskRetryIntentId: input.retryIntentId, taskRetryInput });
const providerActionId = input.retryIntentId
? `task-retry:${input.retryIntentId}` : `failed-run-retry:${source.failedRunId}`;
const [existing] = await tx
@@ -1351,6 +1373,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
failedRunId: snapshot.payload.failedRunId,
taskRetryIntentId: typeof snapshot.payload.taskRetryIntentId === "string"
? snapshot.payload.taskRetryIntentId : undefined,
taskRetryInput: snapshot.payload.taskRetryInput as TaskRetryInput | undefined,
});
if (typeof snapshot.payload.taskRetryIntentId === "string" && !await readTaskChatRetryIntent(tx as unknown as Db, {
companyId: input.companyId, issueId: input.issueId, agentId: input.agentId,
@@ -1444,7 +1467,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
context.source !== `chat:${source.provider}` ||
context.taskKey !== source.taskKey ||
context.retryOfRunId !== source.failedRunId ||
JSON.stringify(retryCommentIds(context)) !==
JSON.stringify(retryCommentIds(context, source.taskRetryInput)) !==
JSON.stringify(source.commentIds) ||
hint?.version !== 1 ||
hint.actionId !== source.intent.id ||
@@ -1518,8 +1541,19 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
};
}
async function reconcileFailedTaskRetry(action: typeof chatActions.$inferSelect) {
if (!["failed", "cancelled"].includes(action.status) || !action.payload.taskRetryIntentId ||
action.result?.taskRetryInputReconciled === true) return action;
await releaseUnadmittedTaskRetryInput(db, { companyId: action.companyId, retryActionId: action.id });
const [settled] = await db.update(chatActions).set({
result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || '{"taskRetryInputReconciled":true}'::jsonb`,
}).where(and(eq(chatActions.companyId, action.companyId), eq(chatActions.id, action.id),
inArray(chatActions.status, ["failed", "cancelled"]))).returning();
return settled ?? action;
}
async function claimFailedChatRunRetry(actionId: string) {
const [candidate] = await db
const [snapshot] = await db
.select()
.from(chatActions)
.where(
@@ -1528,7 +1562,10 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
eq(chatActions.kind, "failed_run_retry"),
),
);
if (!candidate) throw notFound("Chat retry request not found");
if (!snapshot) throw notFound("Chat retry request not found");
// Reconcile once at settlement; a crash before the marker is safely
// retried here. Status projection itself performs no writes or row locks.
const candidate = await reconcileFailedTaskRetry(snapshot);
const now = new Date();
if (
isShuttingDown() ||
@@ -1571,7 +1608,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
)
.returning();
});
return failedChatRetryReceipt(current ?? claimed);
return failedChatRetryReceipt(await reconcileFailedTaskRetry(current ?? claimed));
};
try {
const scope = {
@@ -1644,6 +1681,12 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
true, // The durable wake callback validates admission; later phases recheck containment.
);
},
onAdmitted: async (tx, receipt) => {
if (!source.taskRetryInput) return;
await claimTaskRetryInput(tx, { companyId: claimed.companyId,
retryActionId: claimed.id, sourceActionIds: source.taskRetryInput.sourceActionIds });
await settleTaskRetryInput(tx, { companyId: claimed.companyId, retryActionId: claimed.id, runId: receipt.runId });
},
}),
});
}
@@ -1652,6 +1695,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, claimed.id));
if (!receipt) throw new Error("chat_retry_receipt_not_committed");
await settleTaskRetryInput(db, { companyId: claimed.companyId, retryActionId: claimed.id, runId: receipt.runId });
return settle(
["skipped", "failed", "cancelled"].includes(receipt.status)
? "failed"
@@ -37,7 +37,7 @@ export async function retainGitHubDeliveryAuthority(
linkedUserId: string | null, taskUserId: string | null,
): Promise<void> {
const guest = !linkedUserId;
await taskTx.update(chatDeliveries).set({ normalizedEvent: sql`${chatDeliveries.normalizedEvent} || ${JSON.stringify({ githubAuthority: { guest, responsibleUserId: taskUserId, sponsorUserId: guest ? taskEndpoint.sponsorUserId : null } })}::jsonb` }).where(eq(chatDeliveries.id, deliveryId));
await taskTx.update(chatDeliveries).set({ normalizedEvent: sql`${chatDeliveries.normalizedEvent} || ${JSON.stringify({ githubAuthority: { guest, botExternalId: taskEndpoint.botExternalId, responsibleUserId: taskUserId, sponsorUserId: guest ? taskEndpoint.sponsorUserId : null } })}::jsonb` }).where(eq(chatDeliveries.id, deliveryId));
}
export async function retainGitHubReviewReceipt(taskTx: DbOrTransaction, endpoint: EndpointRow, issueId: string, deliveryId: string, githubAutomatic: AutomaticRequest): Promise<void> {
@@ -0,0 +1,126 @@
import { and, eq, inArray } from "drizzle-orm";
import { chatActions, chatConversations, chatDeliveries, chatEndpointResources, chatMessageLinks, issueComments, toolConnections } from "@paperclipai/db";
import { forbidden } from "../../../errors.js";
import type { DbOrTransaction, EndpointRow, LifecycleRuntimeFence } from "../runtime-types.js";
import { credentialFingerprint } from "../credential-fingerprint.js";
import { readTaskChatRetryIntent } from "../task-retry-authority.js";
export type TaskRetryWakeAuthority = {
intentId: string;
sourceRunId: string;
runtimeFence?: LifecycleRuntimeFence;
};
export function githubTaskRetryAppMatches(endpoint: EndpointRow, delivery: typeof chatDeliveries.$inferSelect,
response: { payload: Record<string, unknown> } | undefined) {
const original = delivery.normalizedEvent.githubAuthority as { botExternalId?: unknown } | undefined;
return original?.botExternalId !== undefined ? original.botExternalId === endpoint.botExternalId
: response?.payload.botExternalId === endpoint.botExternalId && response.payload.agentId === endpoint.assignedAgentId;
}
export function githubTaskRetryDestinationMatches(delivery: typeof chatDeliveries.$inferSelect,
conversation: typeof chatConversations.$inferSelect, resource: typeof chatEndpointResources.$inferSelect | null) {
const originalResource = delivery.normalizedEvent.resource as { providerResourceId?: unknown } | undefined;
const originalThread = delivery.normalizedEvent.conversation as { externalThreadId?: unknown } | undefined;
const automatic = (delivery.normalizedEvent.githubAutomatic ?? delivery.normalizedEvent.githubIssue) as
{ context?: { repositoryId?: unknown } } | undefined;
return resource && originalResource?.providerResourceId === resource.providerResourceId
&& originalThread?.externalThreadId === conversation.externalThreadId
&& (automatic?.context?.repositoryId === undefined
|| automatic.context.repositoryId === resource.metadata.providerRepositoryId);
}
export async function githubTaskRetryRuntimeFence(tx: DbOrTransaction, endpoint: EndpointRow,
action: typeof chatActions.$inferSelect, delivery: typeof chatDeliveries.$inferSelect,
authority: TaskRetryWakeAuthority): Promise<LifecycleRuntimeFence> {
const deny = () => forbidden("This accepted chat message is no longer authorized to start work",
{ code: "chat_action_authorization_changed" });
const retry = await readTaskChatRetryIntent(tx as Parameters<typeof readTaskChatRetryIntent>[0], {
companyId: action.companyId, issueId: String(action.payload.issueId),
agentId: String(action.payload.agentId), sourceRunId: authority.sourceRunId, intentId: authority.intentId,
});
if (!retry || endpoint.provider !== "github" || !endpoint.botExternalId || action.kind !== "inbound_wakeup"
|| action.status !== "processed" || delivery.state !== "processed") throw deny();
const [response] = await tx.select({ payload: chatActions.payload }).from(chatActions).where(and(
eq(chatActions.companyId, action.companyId), eq(chatActions.endpointId, endpoint.id),
eq(chatActions.conversationId, action.conversationId!), eq(chatActions.deliveryId, delivery.id),
eq(chatActions.kind, "github_response_comment"),
));
if (!githubTaskRetryAppMatches(endpoint, delivery, response)) throw deny();
const [connection] = await tx.select({ refs: toolConnections.credentialSecretRefs,
enabled: toolConnections.enabled, status: toolConnections.status }).from(toolConnections).where(and(
eq(toolConnections.companyId, endpoint.companyId), eq(toolConnections.id, endpoint.connectionId),
));
if (!connection?.enabled || connection.status !== "active") throw deny();
const generation = Number((endpoint.setup as { runtimeGeneration?: unknown }).runtimeGeneration ?? 0);
if (!Number.isSafeInteger(generation) || generation < 0) throw deny();
return authority.runtimeFence ?? { generation, credentialFingerprint: credentialFingerprint(connection.refs ?? []) };
}
type GitHubRetryBinding = {
endpoint: EndpointRow;
conversation: typeof chatConversations.$inferSelect;
resource: typeof chatEndpointResources.$inferSelect | null;
delivery: typeof chatDeliveries.$inferSelect;
retryRuntimeFence?: LifecycleRuntimeFence;
};
export function createGitHubTaskRetryBatchAuthority<T extends GitHubRetryBinding>(authorize: (
tx: DbOrTransaction, action: typeof chatActions.$inferSelect,
notice: { nonblocking: true; terminal: boolean; taskRetry: TaskRetryWakeAuthority },
) => Promise<T>) {
async function authorizeGitHubTaskRetryBatch(tx: DbOrTransaction, actions: Array<typeof chatActions.$inferSelect>,
notice: { nonblocking: true; terminal: boolean; taskRetry: TaskRetryWakeAuthority }) {
const deny = () => forbidden("This accepted chat message is no longer authorized to start work",
{ code: "chat_action_authorization_changed" });
const first = actions[0];
if (!first) throw deny();
const commonScope = (action: typeof chatActions.$inferSelect) => JSON.stringify([
action.companyId, action.endpointId, action.conversationId, action.principalId, action.kind, action.status,
...["version", "issueId", "agentId", "sessionGeneration", "requestedByActorType", "requestedByActorId"]
.map(key => action.payload[key]),
]);
if (actions.some(action => commonScope(action) !== commonScope(first))) throw deny();
const deliveries = await tx.select().from(chatDeliveries).where(and(eq(chatDeliveries.companyId, first.companyId),
inArray(chatDeliveries.id, actions.map(action => action.deliveryId!))));
const byDelivery = new Map(deliveries.map(delivery => [delivery.id, delivery]));
const legacy = await tx.select().from(chatActions).where(and(eq(chatActions.companyId, first.companyId),
eq(chatActions.endpointId, first.endpointId), eq(chatActions.conversationId, first.conversationId!),
eq(chatActions.kind, "github_response_comment"), inArray(chatActions.deliveryId, deliveries.map(delivery => delivery.id))));
const legacyByDelivery = new Map(legacy.map(action => [action.deliveryId, action]));
const links = await tx.select({ deliveryId: chatMessageLinks.deliveryId, commentId: chatMessageLinks.commentId,
authorUserId: issueComments.authorUserId, deletedAt: issueComments.deletedAt }).from(chatMessageLinks)
.innerJoin(issueComments, and(eq(issueComments.id, chatMessageLinks.commentId),
eq(issueComments.companyId, first.companyId), eq(issueComments.issueId, String(first.payload.issueId))))
.where(and(eq(chatMessageLinks.companyId, first.companyId), eq(chatMessageLinks.endpointId, first.endpointId),
eq(chatMessageLinks.conversationId, first.conversationId!), eq(chatMessageLinks.direction, "inbound"),
inArray(chatMessageLinks.deliveryId, deliveries.map(delivery => delivery.id))));
const expectedUserId = first.payload.requestedByActorType === "user" ? first.payload.requestedByActorId : null;
const linked = new Set(links.filter(link => (!link.deletedAt || notice.terminal) && link.authorUserId === expectedUserId)
.map(link => `${link.deliveryId}:${link.commentId}`));
const groups = new Map<string, T>();
const authorized = new Map<string, T>();
for (const action of actions) {
const delivery = byDelivery.get(action.deliveryId!);
if (!delivery || !linked.has(`${delivery.id}:${action.payload.commentId}`)) throw deny();
// Reuse only the same live grant within this transaction. Automatic review
// policies with different contexts still receive their own full check.
const grant = JSON.stringify(delivery.normalizedEvent.githubAutomatic ?? delivery.normalizedEvent.githubIssue ?? null);
let current = groups.get(grant);
if (!current) {
current = await authorize(tx, action, notice);
if (current.endpoint.provider !== "github") throw deny();
notice.taskRetry.runtimeFence ??= current.retryRuntimeFence;
groups.set(grant, current);
}
if (delivery.state !== "processed" || delivery.endpointId !== current.endpoint.id ||
delivery.conversationId !== current.conversation.id || delivery.principalId !== action.principalId ||
!githubTaskRetryAppMatches(current.endpoint, delivery, legacyByDelivery.get(delivery.id)) ||
!githubTaskRetryDestinationMatches(delivery, current.conversation, current.resource)) throw deny();
authorized.set(action.id, { ...current, delivery });
}
return authorized;
}
return authorizeGitHubTaskRetryBatch;
}
@@ -11,6 +11,7 @@ import { agentWakeupRequests, agents, chatActions, chatConversations, chatDelive
import { forbidden } from "../../../errors.js";
import { resolveGitHubWakeAuthority, isGitHubAutomaticDelivery } from "../github/wake-authority.js";
import { assertSlackWakeMention } from "../slack/wake-authority.js";
import { githubTaskRetryRuntimeFence, githubTaskRetryDestinationMatches, createGitHubTaskRetryBatchAuthority, type TaskRetryWakeAuthority } from "../github/task-retry-authorization.js";
import type { createChatInboundAuthority } from "./authorization.js";
import type { createChatMessageLifecycle } from "./lifecycle.js";
type WakeAuthorityOptions = {
@@ -41,7 +42,7 @@ export function createInboundWakeAuthority(db: Db, options: WakeAuthorityOptions
async function authorizeInboundWakeup(
tx: DbOrTransaction,
action: typeof chatActions.$inferSelect,
notice?: { nonblocking: true; terminal: boolean },
notice?: { nonblocking: true; terminal: boolean; taskRetry?: TaskRetryWakeAuthority },
) {
const payload = action.payload;
const deny = () =>
@@ -106,6 +107,8 @@ export function createInboundWakeAuthority(db: Db, options: WakeAuthorityOptions
.limit(1)
.then((rows) => rows[0] ?? null);
const fence = delivery ? lifecycleRuntimeFence(delivery) : null;
const currentFence = endpoint && delivery && notice?.taskRetry
? await githubTaskRetryRuntimeFence(tx, endpoint, action, delivery, notice.taskRetry) : fence;
if (
!endpoint ||
endpoint.assignedAgentId !== payload.agentId ||
@@ -114,8 +117,8 @@ export function createInboundWakeAuthority(db: Db, options: WakeAuthorityOptions
delivery.endpointId !== endpoint.id ||
delivery.conversationId !== action.conversationId ||
delivery.principalId !== action.principalId ||
!fence ||
!(await runtimeCallbackEndpoint(tx as unknown as DbTransaction, endpoint.id, fence, [
!currentFence ||
!(await runtimeCallbackEndpoint(tx as unknown as DbTransaction, endpoint.id, currentFence, [
"verifying",
"active",
]))
@@ -160,6 +163,7 @@ export function createInboundWakeAuthority(db: Db, options: WakeAuthorityOptions
endpoint,
action.principalId,
);
if (notice?.taskRetry && !githubTaskRetryDestinationMatches(delivery, conversation, resource)) throw deny();
if (endpoint.provider === "github") {
const automatic = await resolveGitHubWakeAuthority(tx, endpoint, delivery);
if (automatic.kind === "denied") throw deny();
@@ -214,9 +218,12 @@ export function createInboundWakeAuthority(db: Db, options: WakeAuthorityOptions
source.authorUserId !== expectedUserId
)
throw deny();
return { issue, delivery, endpoint, conversation };
return { issue, delivery, endpoint, conversation, resource,
...(notice?.taskRetry && currentFence ? { retryRuntimeFence: currentFence } : {}) };
}
const authorizeGitHubTaskRetryBatch = createGitHubTaskRetryBatchAuthority(authorizeInboundWakeup);
async function authorizeInboundWorkStart(
tx: DbOrTransaction,
action: typeof chatActions.$inferSelect,
@@ -241,5 +248,5 @@ export function createInboundWakeAuthority(db: Db, options: WakeAuthorityOptions
attachments: normalized.message?.attachments?.map(a => `${a.name ?? "Attachment"} (${a.mimeType ?? "unknown type"})`)
};
}
return { authorizeInboundWakeup, authorizeInboundWorkStart, authorizeFastResponse };
return { authorizeInboundWakeup, authorizeGitHubTaskRetryBatch, authorizeInboundWorkStart, authorizeFastResponse };
}
@@ -0,0 +1,95 @@
import { z } from "zod";
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
import { agentWakeupRequests, chatActions, chatConversations, chatEndpoints, heartbeatRuns, issues, type Db } from "@paperclipai/db";
import type { DbOrTransaction } from "./runtime-types.js";
export const taskRetryInputSchema = z.object({
// This is a server-selected snapshot of the durable queue, not client input.
// Truncating it strands older requests or makes the newer-input guard reject
// every Retry. Dispatch already carries the full queue with bounded inline text.
sourceActionIds: z.array(z.string().uuid()).refine(ids => new Set(ids).size === ids.length),
runtimeFence: z.object({ generation: z.number().int().nonnegative(),
credentialFingerprint: z.string().regex(/^[a-f0-9]{64}$/) }).optional(),
});
export type TaskRetryInput = z.infer<typeof taskRetryInputSchema>;
export async function selectTaskRetryInput(tx: DbOrTransaction, input: {
companyId: string; issueId: string; agentId: string; failedRunId: string;
}) {
const [conversation] = await tx.select({ id: chatConversations.id }).from(chatConversations)
.innerJoin(chatEndpoints, eq(chatEndpoints.id, chatConversations.endpointId))
.innerJoin(chatActions, and(eq(chatActions.companyId, chatConversations.companyId),
eq(chatActions.conversationId, chatConversations.id)))
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, chatConversations.companyId),
eq(heartbeatRuns.wakeupRequestId, chatActions.id))).where(and(
eq(chatConversations.companyId, input.companyId), eq(chatConversations.issueId, input.issueId),
eq(heartbeatRuns.id, input.failedRunId), eq(heartbeatRuns.agentId, input.agentId),
eq(chatEndpoints.provider, "github"), eq(chatEndpoints.assignedAgentId, input.agentId),
eq(chatEndpoints.externalExecutionPolicy, "restricted"),
)).limit(1);
if (!conversation) return undefined;
const pending = await tx.select({ id: chatActions.id }).from(chatActions)
.innerJoin(agentWakeupRequests, and(eq(agentWakeupRequests.companyId, chatActions.companyId),
eq(agentWakeupRequests.id, chatActions.id))).where(and(
eq(chatActions.companyId, input.companyId), eq(chatActions.conversationId, conversation.id),
eq(chatActions.kind, "inbound_wakeup"), eq(chatActions.status, "processed"),
eq(agentWakeupRequests.agentId, input.agentId), eq(agentWakeupRequests.status, "deferred_issue_execution"),
isNull(agentWakeupRequests.runId),
)).orderBy(asc(chatActions.createdAt), asc(chatActions.id)).for("update", { of: agentWakeupRequests });
return taskRetryInputSchema.parse({ sourceActionIds: pending.map(row => row.id) });
}
export async function claimTaskRetryInput(tx: DbOrTransaction, input: {
companyId: string; retryActionId: string; sourceActionIds: string[];
}) {
if (!input.sourceActionIds.length) return;
await tx.update(agentWakeupRequests).set({ status: "coalesced", updatedAt: new Date(),
payload: sql`coalesce(${agentWakeupRequests.payload}, '{}'::jsonb) || ${JSON.stringify({
coalescedIntoWakeupRequestId: input.retryActionId })}::jsonb`,
}).where(and(eq(agentWakeupRequests.companyId, input.companyId),
inArray(agentWakeupRequests.id, input.sourceActionIds), eq(agentWakeupRequests.status, "deferred_issue_execution"),
isNull(agentWakeupRequests.runId)));
}
export async function settleTaskRetryInput(tx: DbOrTransaction, input: { companyId: string; retryActionId: string; runId: string | null }) {
if (!input.runId) return;
await tx.update(agentWakeupRequests).set({ runId: input.runId, updatedAt: new Date() }).where(and(
eq(agentWakeupRequests.companyId, input.companyId), eq(agentWakeupRequests.status, "coalesced"),
isNull(agentWakeupRequests.runId),
sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${input.retryActionId}`,
));
}
export async function releaseUnadmittedTaskRetryInput(db: Db, input: { companyId: string; retryActionId: string }) {
await db.transaction(async tx => {
const [snapshot] = await tx.select({ payload: chatActions.payload }).from(chatActions).where(and(
eq(chatActions.companyId, input.companyId), eq(chatActions.id, input.retryActionId),
));
const issueId = z.string().uuid().safeParse(snapshot?.payload.issueId);
if (!issueId.success) return;
const [issue] = await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, input.companyId), eq(issues.id, issueId.data),
)).for("update");
if (!issue) return;
const [action] = await tx.select().from(chatActions).where(and(eq(chatActions.companyId, input.companyId),
eq(chatActions.id, input.retryActionId), eq(chatActions.kind, "failed_run_retry"),
sql`${chatActions.payload}->>'issueId' = ${issue.id}`,
inArray(chatActions.status, ["failed", "cancelled"]))).for("update");
if (!action || typeof action.payload.taskRetryIntentId !== "string") return;
const [receipt] = await tx.select({ runId: agentWakeupRequests.runId }).from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, input.companyId), eq(agentWakeupRequests.id, input.retryActionId),
)).for("update");
if (receipt?.runId) return;
await tx.update(agentWakeupRequests).set({ status: "deferred_issue_execution", finishedAt: null,
payload: sql`${agentWakeupRequests.payload} - 'coalescedIntoWakeupRequestId'`, updatedAt: new Date(),
}).where(and(eq(agentWakeupRequests.companyId, input.companyId), eq(agentWakeupRequests.status, "coalesced"),
isNull(agentWakeupRequests.runId),
sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${input.retryActionId}`));
await tx.update(agentWakeupRequests).set({ status: "failed", finishedAt: new Date(), updatedAt: new Date() }).where(and(
eq(agentWakeupRequests.companyId, input.companyId), eq(agentWakeupRequests.id, action.payload.taskRetryIntentId),
eq(agentWakeupRequests.reason, "task_retry"), isNull(agentWakeupRequests.runId),
inArray(agentWakeupRequests.status, ["deferred_issue_execution", "queued", "claimed"]),
sql`${agentWakeupRequests.payload}#>>'{taskRetry,chatActionId}' = ${action.id}`,
));
});
}
@@ -3,6 +3,7 @@ import { signalDatabaseWork, subscribeDatabaseWork } from "@paperclipai/db";
/** Topics for existing durable queues; register intent before their writes. */
export const DELIVERY_QUEUES = {
executionFinalization: "execution-finalization",
workspaceRecovery: "workspace-recovery",
executionReplacement: "execution-replacement",
executionContinuation: "execution-continuation",
executionStatus: "execution-status",
+22 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { agentWakeupRequests, chatActions, chatDeliveries, chatEndpoints, type Db } from "@paperclipai/db";
import { and, eq, or, sql, type SQLWrapper } from "drizzle-orm";
import { and, eq, inArray, or, sql, type SQLWrapper } from "drizzle-orm";
import { HttpError } from "../errors.js";
export interface CommittedChatResponseAuthorizationInput {
@@ -211,6 +211,7 @@ export interface DurableChatWakeupRequest {
readonly idempotencyKey: string;
readonly failedRunRetry?: { readonly failedRunId: string };
readonly authorize: (tx: Db) => Promise<void>;
readonly onAdmitted?: (tx: Db, receipt: { runId: string | null }) => Promise<void>;
}
const trustedRequests = new WeakSet<object>();
@@ -290,6 +291,26 @@ export function assertDurableChatWakeupReceipt(
}
}
export function withDurableChatAdmission<T extends { kind: string; admitted?: boolean }>(request: DurableChatWakeupRequest | undefined,
admit: (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => Promise<T>) {
return async (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => {
const outcome = await admit(tx);
// An existing deferred receipt is not proof this transaction passed its
// gates. Only dispatch or an explicitly persisted wait admits the input.
if (request?.onAdmitted && (outcome.admitted === true || ["queued", "durable", "coalesced"].includes(outcome.kind))) {
const [receipt] = await tx.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, request.companyId), eq(agentWakeupRequests.id, request.id),
inArray(agentWakeupRequests.status, ["queued", "claimed", "deferred_issue_execution", "coalesced"]),
));
if (receipt) {
assertDurableChatWakeupReceipt(request, receipt);
await request.onAdmitted(tx as unknown as Db, { runId: receipt.runId });
}
}
return outcome;
};
}
/** Work-start policy only: ongoing tool and publication authorization must not
* revoke an already-started run when the operator enables mention gating. */
export async function slackMentionAllowsRunStart(
@@ -2,7 +2,7 @@ import { enableRunFailureReporting } from "../__tests__/helpers/run-failure-repo
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, completionContracts, createDb, heartbeatRuns, issues, issueRecoveryActions, issueThreadInteractions, nativeRunResults, statusDecisions, workAssessments } from "@paperclipai/db";
import { agents, companies, completionContracts, createDb, environmentLeases, environments, heartbeatRuns, issues, issueRecoveryActions, issueThreadInteractions, nativeRunFinalizations, nativeRunResults, statusDecisions, workAssessments } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -153,6 +153,65 @@ describeEmbeddedPostgres("reconcileAbandonedExecutionControl reports a genuine f
expect(result.surfaced).toBe(1);
expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toHaveLength(0);
});
it.each(["prepared", "finalized", "mismatched", "reassigned", "accepted"] as const)("preserves only an exact unfinished native workspace after its deadline (%s)", async state => {
const f = await seedAbandonedRunFixture();
const [environment] = await db.insert(environments).values({ name: `Deadline ${f.runId}`, driver: "sandbox" }).returning();
const [lease] = await db.insert(environmentLeases).values({ companyId: f.companyId, issueId: f.issueId,
heartbeatRunId: f.runId, environmentId: environment.id, provider: "daytona", providerLeaseId: randomUUID(),
status: "active", leasePolicy: "reuse_by_environment", metadata: { remoteCwd: "/work" },
}).returning();
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issueId, runnerProfileJson: {
nativeWorkspaceSync: { schema: "paperclip.native-workspace-sync/v3", state: state === "finalized" ? state : "prepared",
descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null,
workspaceId: f.runId, leaseId: lease.id, providerLeaseId: state === "mismatched" ? randomUUID() : lease.providerLeaseId,
remoteCwd: "/work", resourceDisposition: "keep_running" },
} }).where(eq(heartbeatRuns.id, f.runId));
await db.insert(nativeRunFinalizations).values({ runId: f.runId, companyId: f.companyId, issueId: f.issueId,
phase: "observed", leaseOwner: "abandoned-controller", controlDeadlineAt: new Date(Date.now() - 60_000) });
if (state === "reassigned") {
const nextAgentId = randomUUID();
await db.insert(agents).values({ id: nextAgentId, companyId: f.companyId, name: "New owner", adapterType: "codex_local" });
await db.update(issues).set({ assigneeAgentId: nextAgentId, executionRunId: null, checkoutRunId: null })
.where(eq(issues.id, f.issueId));
}
if (state === "accepted") {
const contractId = randomUUID(), resultId = randomUUID();
await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId,
revision: 1, schemaVersion: "1", policyVersion: "test", risk: "low", completionAuthority: "agent_claim_policy",
incompleteCriteriaPolicy: "block", contractJson: {}, canonicalSha256: randomUUID(), createdByActorType: "agent", createdByActorId: f.agentId });
await db.update(heartbeatRuns).set({ completionContractId: contractId }).where(eq(heartbeatRuns.id, f.runId));
await db.insert(nativeRunResults).values({ id: resultId, companyId: f.companyId, issueId: f.issueId, runId: f.runId,
completionContractId: contractId, serverFingerprint: randomUUID(), schemaStatus: "valid", resultJson: {}, canonicalSha256: randomUUID() });
await db.update(nativeRunFinalizations).set({ resultId }).where(eq(nativeRunFinalizations.runId, f.runId));
}
await reconcileAbandonedExecutionControl(db);
await reconcileAbandonedExecutionControl(db);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const [retained] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id));
expect(run).toMatchObject(state === "accepted" ? { status: "running", errorCode: null }
: { status: "failed", errorCode: "execution_finalization_deadline_exceeded" });
if (state === "prepared" || state === "reassigned") {
expect(run.resultJson).toMatchObject({ workspaceRestoreRecovery: { leaseIds: [lease.id] } });
expect(retained).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure",
metadata: { sandboxStopAndRetain: { runId: f.runId, providerLeaseId: lease.providerLeaseId } } });
const recovery = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
expect(recovery.filter(action => action.evidence?.workspaceRestoreRecovery)).toMatchObject([
{ evidence: { runId: f.runId, workspaceRestoreRecovery: { leaseIds: [lease.id] } } },
]);
} else {
expect(run.resultJson ?? {}).not.toHaveProperty("workspaceRestoreRecovery");
expect(retained).toMatchObject({ status: "active", leasePolicy: "reuse_by_environment" });
}
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
if (state === "reassigned") {
expect(task.assigneeAgentId).not.toBe(f.agentId);
expect(task).toMatchObject({ status: "in_progress", executionRunId: null, checkoutRunId: null });
}
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
});
});
describeEmbeddedPostgres("native review execution reconciliation admission", () => {
@@ -14,6 +14,7 @@ import { issueRecoveryActionService } from "./issue-recovery-actions.js";
import { hasLiveLegacyController } from "./legacy-controller-lease.js";
import { reportRunFailure } from "./run-failure-report.js";
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-runtime/native-review-participant.js";
import { preserveNativeFailedWorkspace } from "./native-runtime/native-failed-workspace-recovery.js";
/** Only newly recorded control deadlines are eligible. Upgrades never replay ambiguous historical runs. */
export async function reconcileAbandonedExecutionControl(
@@ -233,8 +234,10 @@ export async function reconcileAbandonedExecutionControl(
["done", "cancelled"].includes(task.status) ||
(task.executionRunId && task.executionRunId !== run.id) ||
(task.checkoutRunId && task.checkoutRunId !== run.id)
)
) {
if (updatedRun) await preserveNativeFailedWorkspace(tx as unknown as Db, updatedRun);
return true;
}
await tx
.update(issues)
.set({ executionRunId: null, checkoutRunId: null, updatedAt: now })
@@ -261,6 +264,7 @@ export async function reconcileAbandonedExecutionControl(
maxAttempts: 3,
supersedeOnIdentityChange: true,
});
if (updatedRun) await preserveNativeFailedWorkspace(tx as unknown as Db, updatedRun);
return true;
});
if (repaired) surfaced += 1;
+10 -3
View File
@@ -1,3 +1,4 @@
import { nextWorkspaceRecoveryAt } from "./system-recovery.js";
import { and, asc, eq, isNotNull, sql } from "drizzle-orm";
import { adapterAuthSessions, heartbeatRuns, issueRecoveryActions, nativeRunFinalizations, type Db } from "@paperclipai/db";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
@@ -12,6 +13,7 @@ export function registerExecutionControlWork(
queue: DeliveryQueue;
run: () => Promise<unknown>;
next: () => Promise<number | null>;
onSettled?: () => void;
}>,
canRun: () => boolean = () => true,
) {
@@ -20,10 +22,10 @@ export function registerExecutionControlWork(
let reconciled = 0;
let next: number | null = null;
let earliestRunAt = 0;
return coordinator.register(duty.queue, {
const worker = coordinator.register(duty.queue, {
retryMs: EXECUTION_RECONCILIATION_INTERVAL_MS,
pausePassiveMaintenance: true,
onSettled: () => { requested += 1; },
onSettled: () => { requested += 1; duty.onSettled?.(); },
run: async () => {
if (!canRun()) { requested += 1; next = null; return; }
const now = Date.now();
@@ -41,8 +43,9 @@ export function registerExecutionControlWork(
hasPending: async () => reconciled !== requested,
nextRunAt: () => next,
});
return { ...worker, wake: () => { requested += 1; worker.wake(); } };
});
return { ready: Promise.all(workers.map(worker => worker.ready)) };
return { ready: Promise.all(workers.map(worker => worker.ready)), wake: () => { for (const worker of workers) worker.wake(); } };
}
export function executionControlSchedule(db: Db) {
@@ -63,6 +66,10 @@ export function executionControlSchedule(db: Db) {
]);
return deadline([native[0]?.at, legacy[0]?.at]);
},
async workspaceRecovery() {
const at = await nextWorkspaceRecoveryAt(db);
return at === null ? null : deadline([new Date(at)]);
},
async replacement() {
const rows = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
@@ -7,6 +7,14 @@ import {
resolvePaperclipRunnerNativeProviderInput,
} from "./native-runtime/provider-profile.js";
describe("native Codex hosted search policy", () => {
it.each([undefined, true, false])("preserves the saved search=%s choice", search => {
const input = resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: { provider: "codex", model: "gpt-5.4-mini", ...(search === undefined ? {} : { search }) } });
if (search === false) expect(input).toHaveProperty("codexWebSearch", "disabled");
else expect(input).not.toHaveProperty("codexWebSearch");
});
});
describe("Paperclip Runner native provider configuration", () => {
it.each([
["gpt-6.1-sol", "0.158.0", "gpt-6-sol"],
+11 -17
View File
@@ -1,5 +1,4 @@
import { authorizationService } from "../authorization.js";
import { assertTaskRetryConversationAllowed, readTaskRetryIntent, retireSupersededTaskRetry } from "../task-retry.js";
import { assertTaskRetryConversationAllowed, readTaskRetryIntent, retireSupersededTaskRetry, taskRetryMutationAllowed } from "../task-retry.js";
import { insertIssueComment } from "../issue-comment-writes.js";
import { withExternalAdmissionGuard, assertNoExternalOverlap } from "../../modules/external-agents/index.js";
import { isAgentAwaitingSetup } from "../../modules/agent-lifecycle/index.js";
@@ -50,6 +49,7 @@ import {
slackMentionAllowsRunStart,
assertDurableChatWakeupReceipt,
assertDurableChatWakeupRequest,
withDurableChatAdmission,
authorizeFailedChatRunRetryWake,
FailedChatRunRetryAuthorizationError,
unadmittedChatWakeupCondition,
@@ -2809,7 +2809,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
const cancelledRunsToEmit: (typeof heartbeatRuns.$inferSelect)[] = [];
const outcome = await db
.transaction(async (tx) => {
.transaction(withDurableChatAdmission(durableRequest, async (tx) => {
await tx.execute(
sql`select id from issues where id = ${issueId} and company_id = ${agent.companyId} for update`,
);
@@ -2847,11 +2847,8 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
if (pending.id !== opts.taskRetryRequestId || retry?.generationId !== pending.id || retry.chatActionId ||
retry.actorId !== opts.requestedByActorId || retry.sourceRunId !== enrichedContextSnapshot.previousRunId)
return { kind: "deferred" as const };
const permission = await authorizationService(tx as unknown as Db).decide({
actor: { type: "board", userId: retry.actorId, source: retry.actorSource }, action: "issue:mutate",
resource: { type: "issue", companyId: agent.companyId, issueId },
});
if (!permission.allowed) return { kind: "deferred" as const };
if (!await taskRetryMutationAllowed(tx as unknown as Db, { companyId: agent.companyId, issueId,
actorId: retry.actorId, actorSource: retry.actorSource })) return { kind: "deferred" as const };
}
if (opts.queuedCommentRequestId) {
const ids = await undeliveredLegacyUserCommentIds(tx as unknown as Db,
@@ -3004,11 +3001,8 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
if (!failedChatRetry || retry?.chatActionId !== durableRequest.id ||
retry.actorId !== taskRetryActorId || retry.sourceRunId !== durableRequest.failedRunRetry?.failedRunId)
throw new FailedChatRunRetryAuthorizationError();
const permission = await authorizationService(tx as unknown as Db).decide({
actor: { type: "board", userId: retry.actorId, source: retry.actorSource }, action: "issue:mutate",
resource: { type: "issue", companyId: agent.companyId, issueId },
});
if (!permission.allowed) return { kind: "deferred" as const };
if (!await taskRetryMutationAllowed(tx as unknown as Db, { companyId: agent.companyId, issueId,
actorId: retry.actorId, actorSource: retry.actorSource })) return { kind: "deferred" as const };
}
} else {
if (
@@ -3311,7 +3305,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{executionWait}', ${JSON.stringify(condition)}::jsonb)`,
updatedAt: new Date(),
}).where(eq(agentWakeupRequests.id, executionWaitRequestId));
return { kind: "deferred" as const };
return { kind: "deferred" as const, admitted: true };
}
if (durableRequest || wakeCommentId ||
hasInteractionContinuationWakeContext(enrichedContextSnapshot) ||
@@ -3348,7 +3342,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
},
});
}
return { kind: "deferred" as const };
return { kind: "deferred" as const, admitted: true };
};
const explicitContinuationRunId = randomUUID();
const executionBlocker = await getExecutionBlocker(
@@ -3974,7 +3968,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
};
}
if (admission.kind === "deferred") {
return { kind: "deferred" as const };
return { kind: "deferred" as const, admitted: true };
}
// admission.kind === "proceed": no active run absorbed this wake,
// so fall through to the ordinary queue path below.
@@ -4357,7 +4351,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
// transitions to "running" — Fix A (lazy locking).
return { kind: "queued" as const, run: newRun };
})
}))
.catch((error) => {
if (isExternalChatWaitAuthorizationContention(error))
return { kind: "deferred" as const };
@@ -971,6 +971,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
nativeToolContractFingerprint:
nativeToolContractFingerprintForTarget(
executionTarget?.kind ?? "local",
nativeExecution!.provider.kind,
),
...(lockedProfile.sessionCheckpoint != null
? { sessionCheckpoint: lockedProfile.sessionCheckpoint }
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
import { createAssignedMcpTools, getAssignedMcpGateway, registerAssignedMcpGateway } from "./assigned-mcp-tools.js";
import { startOpenCodeMcpBridge } from "../../vendor/paperclip-runner/testing.js";
function descriptor(name: string, risk: ToolGatewayDescriptor["risk"] = "read"): ToolGatewayDescriptor {
return { name, displayName: name, description: `Use ${name}`, parametersSchema: { type: "object", properties: { query: { type: "string" } } }, pluginId: "fixture", providerType: "mcp_remote_http", risk };
@@ -18,6 +19,93 @@ describe("assigned MCP runner tools", () => {
const searchName = "paperclip_search_assigned_tools";
const callName = "paperclip_call_assigned_tool";
it.each(["page", "escaped schema"])("keeps on-demand %s results valid through the OpenCode MCP bridge", async variant => {
const schema = { type: "object", description: variant === "page" ? "x".repeat(20_000) : "\"\\\u0000🙂".repeat(30_000) };
const f = fixture(Array.from({ length: variant === "page" ? 5 : 1 }, (_, i) => ({
...descriptor(`large_${i}`), parametersSchema: schema,
})));
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const bridge = await startOpenCodeMcpBridge({ tools: assigned.definitions(), handler: call => assigned.execute(call) });
let id = 0;
const search = async (args: Record<string, unknown>) => {
const response = await fetch(bridge.url, {
method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name: searchName, arguments: args } }),
});
const rpc = await response.json() as { result: { isError?: boolean; content: Array<{ text: string }> } };
expect(rpc.result.isError).not.toBe(true);
return JSON.parse(rpc.result.content[0]!.text);
};
try {
let page = await search({ query: "", limit: 20 });
if (variant === "page") {
const seen = [...page.tools];
while (page.nextOffset !== null) {
page = await search({ query: "", offset: page.nextOffset, limit: 20 });
seen.push(...page.tools);
}
expect(seen).toHaveLength(5);
expect(seen.every(tool => JSON.stringify(tool.inputSchema) === JSON.stringify(schema))).toBe(true);
} else {
const name = page.tools[0]!.inputSchemaRef;
expect(name).toBeTypeOf("string");
let offset: number | null = 0;
let reconstructed = "";
do {
const chunk = await search({ query: "", schemaTool: name, schemaOffset: offset });
reconstructed += chunk.schemaJson;
offset = chunk.nextSchemaOffset;
} while (offset !== null);
expect(JSON.parse(reconstructed)).toEqual(schema);
}
} finally {
await bridge.close();
}
});
it.each([1, 741])("uses bounded on-demand delivery for %i tools even when the direct catalog fits", async count => {
const f = fixture(Array.from({ length: count }, (_, i) => descriptor(`app.operation_${i}`)));
const direct = await createAssignedMcpTools(f);
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const fits = vi.fn(() => true);
expect(assigned.definitions(fits).map(tool => tool.name)).toEqual([searchName, callName]);
expect(fits).toHaveBeenCalledExactlyOnceWith(assigned.definitions());
expect(() => assigned.definitions(() => false)).toThrow("assigned_mcp_tool_catalog_capacity_exceeded");
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 1 } }) as { tools: Array<{ name: string }> };
expect(page.tools[0]!.name).toBe(direct.definitions().map(tool => String(tool.name)).sort()[0]);
await assigned.execute({ tool: callName, arguments: { name: page.tools[0]!.name, arguments: { query: "fixture" }, gatewayPublicId: "untrusted", bearerToken: "untrusted" } });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken }));
f.listToolsForNamedGateway.mockResolvedValue([]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } })).resolves.toEqual({ tools: [], nextOffset: null });
});
it("keeps on-demand aliases pinned, mode restricted, revocable and approval gated", async () => {
const read = descriptor("mcp.first:search");
const write = descriptor("mcp.second:search", "write");
const f = fixture([read, write]);
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const page = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string; description: string }> };
expect(new Set(page.tools.map(tool => tool.name)).size).toBe(2);
const writeAlias = page.tools.find(tool => tool.description.includes(write.name))!.name;
const call = { tool: callName, arguments: { name: writeAlias, arguments: {} } };
await expect(assigned.execute(call, "planning")).rejects.toThrow("paperclip_runner_tool_mode_denied");
await expect(assigned.execute(call, "ask")).rejects.toThrow("paperclip_runner_tool_mode_denied");
expect(f.executeTool).not.toHaveBeenCalled();
const denied = new ToolGatewayHttpError(403, "Approval required", "approval_required");
f.executeTool.mockRejectedValueOnce(denied);
await expect(assigned.execute(call)).rejects.toBe(denied);
f.listToolsForNamedGateway.mockResolvedValue([read, descriptor("newly.granted")]);
const refreshed = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string }> };
expect(refreshed.tools).toHaveLength(1);
expect(refreshed.tools[0]!.name).not.toBe(writeAlias);
// Execution still goes through the original gateway, which reauthorizes
// each call; discovery never grants an added tool to this pinned session.
const revoked = new ToolGatewayHttpError(403, "Revoked", "connection_revoked");
f.executeTool.mockRejectedValueOnce(revoked);
await expect(assigned.execute(call)).rejects.toBe(revoked);
await expect(assigned.execute({ tool: callName, arguments: { name: "newly.granted", arguments: {} } })).rejects.toThrow("assigned_mcp_tool_unknown");
});
it("deduplicates repeated app tools in direct and on-demand catalogs while retaining revocation", async () => {
const read = descriptor("memory.search");
const write = descriptor("memory.store", "write");
@@ -86,6 +86,8 @@ export async function createAssignedMcpTools(input: {
gatewayPublicId: string;
bearerToken: string;
workMode?: WorkMode;
/** Bound provider grammar complexity independently of protocol byte capacity. */
delivery?: "on_demand";
}) {
const listed = await input.gateway.listToolsForNamedGateway({
gatewayPublicId: input.gatewayPublicId,
@@ -103,6 +105,11 @@ export async function createAssignedMcpTools(input: {
tools.set(name, descriptor);
}
const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read";
// OpenCode's HTTP bridge returns at most 64K JSON characters. Leave room
// for the envelope and for JSON escaping when returning a schema chunk.
// Other harnesses retain the larger PRP page and chunk budgets.
const searchPageBytes = input.delivery === "on_demand" ? 32 * 1024 : SEARCH_PAGE_BYTES;
const schemaChunkCharacters = input.delivery === "on_demand" ? 16 * 1024 : SCHEMA_CHUNK_CHARACTERS;
async function search(argumentsValue: unknown, currentWorkMode?: WorkMode) {
const args = object(argumentsValue);
@@ -132,7 +139,7 @@ export async function createAssignedMcpTools(input: {
const selected = authorized.find(([name]) => name === args.schemaTool);
if (!selected) throw new Error("assigned_mcp_tool_unknown");
const schema = JSON.stringify(selected[1].parametersSchema);
const end = schemaOffset + SCHEMA_CHUNK_CHARACTERS;
const end = schemaOffset + schemaChunkCharacters;
return {
name: selected[0], schemaJson: schema.slice(schemaOffset, end),
nextSchemaOffset: end < schema.length ? end : null,
@@ -144,13 +151,13 @@ export async function createAssignedMcpTools(input: {
const page: ToolDefinition[] = [];
for (const [name, tool] of matches.slice(offset, offset + limit)) {
let next = definition(name, tool);
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > SEARCH_PAGE_BYTES) {
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > searchPageBytes) {
// One large schema must not block browsing the tools after it. Expose
// a reference whose schema can be fetched in bounded, reauthorized
// chunks, without weakening the gateway's argument validation.
next = { name, description: String(next.description).slice(0, 2000), inputSchemaRef: name };
}
if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > SEARCH_PAGE_BYTES) {
if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > searchPageBytes) {
break;
}
page.push(next);
@@ -161,9 +168,9 @@ export async function createAssignedMcpTools(input: {
return {
definitions(fits?: (definitions: ToolDefinition[]) => boolean): ToolDefinition[] {
const direct = [...tools].filter(([, tool]) => permits(tool)).map(([name, tool]) => definition(name, tool));
if (!fits || fits(direct)) return direct;
if (input.delivery !== "on_demand" && (!fits || fits(direct))) return direct;
const compact = structuredClone(ON_DEMAND_TOOLS);
if (!fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
if (fits && !fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
return compact;
},
has(name: string): boolean {
@@ -21,12 +21,13 @@ describe("LCA-05 explicit native work mode", () => {
normalizedSessionId: null,
provider: "codex",
codexReasoningEffort: "high",
codexWebSearch: "disabled",
planningContext: workMode === "planning" ? { documentId: null, baseRevisionId: null, baseRevisionNumber: 0, markdown: "", sha256: "a".repeat(64), reviewContext: {} } : null,
completionContract: { id: "70000000-0000-4000-8000-000000000007", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1", contract: { revision: "1", objective: "Deliver the requested work", criteria: [{ id: "output", requirement: "Deliver the requested work" }] } },
runtimeContext: nativeRuntimeContextFixture(),
});
expect(input.task.workMode).toBe(workMode);
expect(input.provider).toMatchObject({ kind: "codex", reasoningEffort: "high" });
expect(input.provider).toMatchObject({ kind: "codex", reasoningEffort: "high", webSearch: "disabled" });
expect(input.executionMode).toBe(workMode === "planning" ? "plan" : "default");
expect(input.task.title).toBe(text);
}
@@ -102,6 +102,7 @@ export interface BuildNativeExecutionInput {
homeMode?: "isolated" | "preconfigured";
codexApprovalPolicy?: NativeCodexApprovalPolicy;
codexReasoningEffort?: string;
codexWebSearch?: "disabled";
opencodePermissionMode?: NativeOpenCodePermissionMode;
acpxPermissionMode?: NativeAcpxPermissionMode;
acpxSessionMode?: "agent" | "plan" | "ask";
@@ -364,6 +365,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
model: input.model ?? null,
approvalPolicy: input.codexApprovalPolicy ?? "never",
...(input.codexReasoningEffort ? { reasoningEffort: input.codexReasoningEffort } : {}),
...(input.codexWebSearch ? { webSearch: input.codexWebSearch } : {}),
},
completionContract: {
id: input.completionContract.id,
@@ -1471,6 +1471,7 @@ describe("remote provider pack manifest", () => {
try {
await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution,
runnerInstanceId: "bundled-image-runner", runnerIngressAuthorized: true,
...(provider === "opencode" ? { runnerEnvironment: { PAPERCLIP_NATIVE_MCP_NAME: "assigned-fixture", PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1/mcp/gateways/assigned-fixture", PAPERCLIP_NATIVE_MCP_TOKEN: "private-fixture" } } : {}),
runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace",
environmentId: "environment", leaseId: "lease", providerKey: "daytona",
effectiveCapabilities: { runnerWebSocketIngress: true }, runner: { execute, syncIn } } as never });
@@ -1478,8 +1479,13 @@ describe("remote provider pack manifest", () => {
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
const transport = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & {
controlPlaneRegistration: (authority: unknown) => Promise<unknown>;
environment?: NodeJS.ProcessEnv;
};
expect(transport.runnerBinary).toBe(state.bundledRunnerBinary);
if (provider === "opencode") {
expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "assigned-fixture", bearerToken: "private-fixture", delivery: "on_demand" }));
for (const name of ["PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_NATIVE_MCP_TOKEN"]) expect(transport.environment?.[name]).toBeUndefined();
}
if (claude) {
expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd: "/workspace", configuredPath: undefined }));
const { bundledRemoteProviderPackManifestPath } = await import("../../vendor/paperclip-runner/index.js");
@@ -7800,7 +7806,7 @@ describe("bounded stopped instruction collection", () => {
describe("native warm session supervision", () => {
it.each(["collect", "close-failure", "stopped", "destroyed", "missing-lease", "foreign-run", "foreign-environment", "deleted-environment", "claim-edit", "claim-add", "claim-remove", "claim-remove-entry", "claim-canonical", "claim-config", "claim-uncertain"])("composes remote warm ownership with real DB successor leases and current-run collection: %s", async ending => {
const tables = await import("@paperclipai/db");
const { eq } = await import("drizzle-orm");
const { eq, sql } = await import("drizzle-orm");
const { randomUUID } = await import("node:crypto");
const { promisify } = await import("node:util");
const { execFile } = await import("node:child_process");
@@ -7813,7 +7819,11 @@ describe("native warm session supervision", () => {
const home = await realpath(await mkdtemp(join(tmpdir(), "warm-remote-db-")));
const previousHome = process.env.PAPERCLIP_HOME;
process.env.PAPERCLIP_HOME = home;
const database = await startEmbeddedPostgresTestDatabase("warm-remote-db-");
const { resolveExternalTestDatabaseUrl } = await import("../../__tests__/helpers/external-test-database.js");
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL");
const database = externalUrl
? { connectionString: externalUrl, cleanup: () => tables.closeRegisteredClients(externalUrl) }
: await startEmbeddedPostgresTestDatabase("warm-remote-db-");
const db = tables.createDb(database.connectionString);
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), environmentId = randomUUID(), issueId = randomUUID();
const { resolveManagedInstructionsRoot } = await import("../agent-instructions.js");
@@ -8049,6 +8059,9 @@ describe("native warm session supervision", () => {
finally {
const cleanupFailures: unknown[] = [];
try { await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "test cleanup" }); } catch (error) { cleanupFailures.push(error); }
if (externalUrl) {
try { await db.execute(sql`TRUNCATE TABLE ${tables.companies}, ${tables.environments}, ${tables.authUsers} CASCADE`); } catch (error) { cleanupFailures.push(error); }
}
try { await database.cleanup(); } catch (error) { cleanupFailures.push(error); }
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
try {
@@ -11828,7 +11841,10 @@ describe("runnerd provider runtime wiring", () => {
} finally {owner.mockRestore();await rm(home,{recursive:true,force:true});}
});
it("keeps assigned MCP credentials on the control plane for remote Codex", async () => {
it.each(["codex", "opencode"] as const)("keeps assigned MCP credentials on the control plane for %s", async (providerKind) => {
const selectedExecution: NativeExecutionInput = providerKind === "opencode"
? { ...execution, provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" } }
: execution;
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector.test");
const exporter = new InMemorySpanExporter();
@@ -11841,13 +11857,13 @@ describe("runnerd provider runtime wiring", () => {
state.createTransport.mockClear();
state.toolAuthorityDefinitions.mockClear();
await createRunnerdBackend({
db: leaseDb(execution), execution, runnerInstanceId: "runner-assigned-mcp",
db: leaseDb(selectedExecution), execution: selectedExecution, runnerInstanceId: "runner-assigned-mcp",
runnerEnvironment: {
PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned",
PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/assigned-test",
PAPERCLIP_NATIVE_MCP_TOKEN: "private-run-token",
},
runnerExecutionTarget: {
runnerExecutionTarget: providerKind === "opencode" ? undefined : {
kind: "remote", transport: "sandbox", providerKey: "daytona",
leaseId: "lease-assigned-mcp", remoteCwd: "/home/daytona/paperclip-workspace",
runner: { execute: vi.fn() },
@@ -11855,6 +11871,7 @@ describe("runnerd provider runtime wiring", () => {
});
expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({
gatewayPublicId: "assigned-test", bearerToken: "private-run-token",
...(providerKind === "opencode" ? { delivery: "on_demand" } : {}),
}));
expect(state.toolAuthorityDefinitions).toHaveBeenCalledWith(expect.objectContaining({ assignedMcpTools }));
state.createBackend.mock.calls[0]![1].codexTransportFactory!();
@@ -5563,7 +5563,7 @@ function nativeSessionConfigDigest(
// changes so an older thread is rotated instead of falsely resuming
// without newly required tools.
nativeToolContractFingerprint:
nativeToolContractFingerprintForTarget(executionTargetKind),
nativeToolContractFingerprintForTarget(executionTargetKind, execution.provider.kind),
runtimeContract: nativeRuntimeContractForProvider(execution.provider),
}),
)
@@ -11828,10 +11828,10 @@ async function createRunnerdBackendWithinSessionClaim(
if (nativeReview && !await getNativeReviewAssignment(input.db, {
...input.execution.binding, contextSnapshot: nativeReview,
})) throw new Error("native_review_assignment_no_longer_available");
// Remote Codex already sends dynamic tool calls over authenticated PRP. Keep
// Codex and OpenCode send dynamic tool calls over authenticated PRP. Keep
// the assigned gateway on the control plane instead of asking the sandbox to
// reach the host's HTTP origin (which may be private or loopback-only).
const relayAssignedMcp = Boolean(pluginTask) || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6";
const relayAssignedMcp = Boolean(pluginTask) || input.execution.provider.kind === "opencode" || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6";
const assignedMcpUrl = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_URL;
const assignedMcpToken = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_TOKEN;
const assignedMcpName = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_NAME;
@@ -11853,6 +11853,7 @@ async function createRunnerdBackendWithinSessionClaim(
gatewayPublicId: assignedGatewayPublicId!,
bearerToken: assignedMcpToken,
workMode: input.execution.task.workMode,
...(input.execution.provider.kind === "opencode" ? { delivery: "on_demand" as const } : {}),
}))
: undefined),
]);
@@ -12012,6 +12013,7 @@ async function createRunnerdBackendWithinSessionClaim(
},
codexTransportFactory: recovery => createRunnerdCodexTransport({ provider: provider.kind, stateDirectory: root,
...owner.driver, runnerArtifact: description.runner,
...(provider.kind === "codex" ? { disableCodexWebSearch: "webSearch" in provider && provider.webSearch === "disabled" } : {}),
...(provider.kind === "acpx" ? { acpxAgent: provider.agent,
acpxCandidateProfile: provider.agent === "pi" ? undefined : resolveAcpxQualification(provider, process.env),
acpxPermissionMode: provider.permissionMode, acpxMode: provider.mode, piThinkingLevel: provider.piThinkingLevel,
@@ -13396,6 +13398,7 @@ async function createRunnerdBackendWithinSessionClaim(
codexTransportFactory: (recoveryContext) =>
createRunnerdCodexTransport({
onSpawn: input.onSpawn,
...(input.execution.provider.kind === "codex" ? { disableCodexWebSearch: "webSearch" in input.execution.provider && input.execution.provider.webSearch === "disabled" } : {}),
homeMode: preconfiguredHome ? "preconfigured" : "isolated",
onCredentialsUpdated: (event) => appendNativeCredentialUpdate(input.db, {
companyId: input.execution.binding.companyId,
@@ -32,7 +32,7 @@ import {
} from "@paperclipai/paperclip-runner";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
startEmbeddedPostgresTestDatabase as startEmbeddedDatabase,
} from "../../__tests__/helpers/embedded-postgres.js";
import {
canonicalNativeRuntimeContextDigest,
@@ -72,6 +72,20 @@ import {
selectNativeSessionResumeRun,
} from "./native-session-resume.js";
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL");
async function startEmbeddedPostgresTestDatabase(prefix: string) {
const externalUrl = externalTestDatabaseUrl;
if (!externalUrl) return startEmbeddedDatabase(prefix);
const { closeRegisteredClients } = await import("@paperclipai/db");
return { connectionString: externalUrl, cleanup: async () => {
const db = createDb(externalUrl);
try { await db.execute(sql`TRUNCATE TABLE companies CASCADE`); }
finally { await closeRegisteredClients(externalUrl); }
} };
}
const companyId = "10000000-0000-4000-8000-000000000001";
const issueId = "20000000-0000-4000-8000-000000000002";
@@ -283,12 +297,13 @@ it("wires exact-session recovery and guarded selected identity into heartbeat pe
});
const embeddedSupport = await getEmbeddedPostgresTestSupport();
const databaseSupported = Boolean(externalTestDatabaseUrl) || embeddedSupport.supported;
const recoveryFakeCodex = resolve(
import.meta.dirname,
"../../../../packages/paperclip-runner/test/fixtures/fake-final-burst-codex-app-server.mjs",
);
(embeddedSupport.supported && existsSync(defaultCapabilityRunnerdBinary())
(databaseSupported && existsSync(defaultCapabilityRunnerdBinary())
? it
: it.skip)(
"keeps actual normalized driver events separate from raw maintenance receipts",
@@ -520,7 +535,7 @@ const recoveryFakeCodex = resolve(
30_000,
);
(embeddedSupport.supported &&
(databaseSupported &&
existsSync(defaultCapabilityRunnerdBinary()) &&
existsSync(recoveryFakeCodex)
? it
@@ -1062,7 +1077,7 @@ const recoveryFakeCodex = resolve(
60_000,
);
(embeddedSupport.supported ? describe : describe.skip)(
(databaseSupported ? describe : describe.skip)(
"native session recovery database orchestration",
() => {
it("uses scoped latest checkpoints, preserves progress barriers, and guards bootstrap persistence after the row lock", async () => {
@@ -1825,6 +1840,23 @@ describe("rebindNativeSessionCheckpoint", () => {
it.each(["local", "remote"] as const)("refreshes only OpenCode's former direct MCP catalog on %s", target => {
const legacy = nativeToolContractFingerprintForTarget(target);
expect(nativeToolContractFingerprintForTarget(target, "codex")).toBe(legacy);
expect(nativeToolContractFingerprintForTarget(target, "acpx")).toBe(legacy);
const current = nativeToolContractFingerprintForTarget(target, "opencode");
expect(current).not.toBe(legacy);
const shortenedCatalog = target === "local"
? "sha256:2ae35faccbb63cfe009d94170ef1ef13f0d57e120af6160540598a3ec72001d7"
: "sha256:ee5285ca5118b8468c0afd1df4ef9c05d85b0934e882bccd0b193dc8722dbb7a";
expect(current).not.toBe(shortenedCatalog);
const currentExecution = { ...execution(currentRunId), provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" }, session: { ...execution(currentRunId).session, driverKind: "opencode_server" } } as NativeExecutionInput;
const previousExecution = { ...execution(previousRunId), provider: currentExecution.provider, session: currentExecution.session };
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: shortenedCatalog, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: legacy, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: current, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).not.toBeNull();
});
it("binds the provider tool catalog to the local or remote execution target", () => {
const remoteFingerprint = nativeToolContractFingerprintForTarget("remote");
expect(remoteFingerprint).not.toBe(NATIVE_TOOL_CONTRACT_FINGERPRINT);
@@ -29,6 +29,7 @@ export function nativeRuntimeContractForProvider(
*/
export function nativeToolContractFingerprintForTarget(
executionTargetKind: NativeToolExecutionTargetKind,
providerKind?: NativeExecutionInput["provider"]["kind"],
): string {
return `sha256:${createHash("sha256")
.update(
@@ -47,6 +48,9 @@ export function nativeToolContractFingerprintForTarget(
structuredHumanInput:
"always_advertised_run_issue_agent_binding_gated_current_task_description.v2",
semanticCompletion: "finish_accessible_deliverable_evidence.v4",
...(providerKind === "opencode"
? { opencodeAssignedMcpTools: "server_gateway_prp_on_demand_exact_names.v2" }
: {}),
connectorTools: "assigned_resources_and_pinned_skill_bundle.v1",
chatTaskTools: "durable_task_binding_and_accepted_origin.v1",
githubResponses: "bound_working_comment_progress_and_final.v1",
@@ -482,7 +486,7 @@ export function rebindNativeSessionCheckpoint(input: {
const previousProfile = record(input.previousRun.runnerProfileJson);
if (
previousProfile.nativeToolContractFingerprint !==
nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local")
nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local", input.currentExecution.provider.kind)
) {
return null;
}
@@ -84,6 +84,7 @@ export type PaperclipRunnerNativeProviderInput =
homeMode?: "isolated" | "preconfigured";
codexApprovalPolicy: "never" | "on-request" | "untrusted";
codexReasoningEffort?: string;
codexWebSearch?: "disabled";
}
| {
provider: "opencode";
@@ -843,5 +844,6 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
config.codexPermissionMode,
) as "never" | "on-request" | "untrusted",
...(typeof effort === "string" && effort ? { codexReasoningEffort: effort } : {}),
...(config.search === false ? { codexWebSearch: "disabled" as const } : {}),
};
}
+41 -13
View File
@@ -1,10 +1,17 @@
import { and, eq, sql } from "drizzle-orm";
import { issueComments, issueRecoveryActions, type Db } from "@paperclipai/db";
import { heartbeatRuns, issueComments, issueRecoveryActions, type Db } from "@paperclipai/db";
import { writeExecutionControl } from "./execution-control-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { createSystemRecovery, readSystemRecovery, type SystemRecoveryState } from "./system-recovery-state.js";
import type { WorkspaceRecoverySourceIdentity } from "./workspace-recovery-source.js";
import { workspaceRecoverySourceEligible, type WorkspaceRecoverySourceIdentity } from "./workspace-recovery-source.js";
export async function adoptSystemRecovery(db: Db, source: WorkspaceRecoverySourceIdentity) {
return db.transaction(async tx => {
const [existing] = await tx.select({ evidence: issueRecoveryActions.evidence }).from(issueRecoveryActions)
.where(and(eq(issueRecoveryActions.companyId, source.companyId), eq(issueRecoveryActions.id, source.actionId),
eq(issueRecoveryActions.sourceIssueId, source.issueId))).for("update");
if (!existing || existing.evidence.systemRecovery) return;
await notifyDeliveryWork(tx, DELIVERY_QUEUES.workspaceRecovery);
const state = createSystemRecovery(source.runId);
// Atomic, once per source. An exhausted previous execution budget is history;
// adoption grants only the explicitly versioned maintenance capability.
@@ -49,17 +56,22 @@ export async function updateSystemRecovery(db: Db, source: WorkspaceRecoverySour
stage: next.phase === "verify_execution" ? "verifying_stop" : next.phase === "preserve_files" ? "restoring_files" : "saving_receipts",
attempt: next.attempts, attemptLimit: 6, retryable: true, retryAt: next.nextAttemptAt ?? undefined,
};
const [updated] = await db.update(issueRecoveryActions).set({
ownerType: "system", ownerAgentId: null, ownerUserId: null,
evidence: sql`${issueRecoveryActions.evidence} || jsonb_build_object('systemRecovery', ${JSON.stringify(next)}::jsonb,
'workspaceRecovery', ${JSON.stringify(compatibility)}::jsonb || jsonb_build_object('requestIds',
coalesce(${issueRecoveryActions.evidence}#>'{workspaceRecovery,requestIds}', '[]'::jsonb)))`,
updatedAt: new Date(),
}).where(and(eq(issueRecoveryActions.companyId, source.companyId), eq(issueRecoveryActions.id, source.actionId),
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,generationId}' = ${previous.generationId}`,
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,revision}' = ${String(previous.revision)}`))
.returning({ id: issueRecoveryActions.id });
return Boolean(updated);
return writeExecutionControl(db, DELIVERY_QUEUES.workspaceRecovery, async tx => {
const [updated] = await tx.update(issueRecoveryActions).set({
ownerType: "system", ownerAgentId: null, ownerUserId: null,
evidence: sql`${issueRecoveryActions.evidence} || jsonb_build_object('systemRecovery', ${JSON.stringify(next)}::jsonb ||
${next.generationId === previous.generationId && next.intentId === previous.intentId
? sql`jsonb_build_object('intentId', ${issueRecoveryActions.evidence}#>'{systemRecovery,intentId}')`
: sql`'{}'::jsonb`},
'workspaceRecovery', ${JSON.stringify(compatibility)}::jsonb || jsonb_build_object('requestIds',
coalesce(${issueRecoveryActions.evidence}#>'{workspaceRecovery,requestIds}', '[]'::jsonb)))`,
updatedAt: new Date(),
}).where(and(eq(issueRecoveryActions.companyId, source.companyId), eq(issueRecoveryActions.id, source.actionId),
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,generationId}' = ${previous.generationId}`,
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,revision}' = ${String(previous.revision)}`))
.returning({ id: issueRecoveryActions.id });
return Boolean(updated);
});
}
export async function loadSystemRecovery(db: Db, source: WorkspaceRecoverySourceIdentity) {
@@ -68,3 +80,19 @@ export async function loadSystemRecovery(db: Db, source: WorkspaceRecoverySource
eq(issueRecoveryActions.id, source.actionId)));
return readSystemRecovery(row?.evidence);
}
export async function nextWorkspaceRecoveryAt(db: Db): Promise<number | null> {
const rows = await db.select({ at: sql<number>`case
when jsonb_typeof(${issueRecoveryActions.evidence}#>'{systemRecovery,nextAttemptAt}') = 'number'
then (${issueRecoveryActions.evidence}#>>'{systemRecovery,nextAttemptAt}')::bigint else 0 end` })
.from(issueRecoveryActions).innerJoin(heartbeatRuns, and(
eq(heartbeatRuns.companyId, issueRecoveryActions.companyId),
eq(heartbeatRuns.id, sql`case when ${issueRecoveryActions.evidence}->>'runId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
then (${issueRecoveryActions.evidence}->>'runId')::uuid end`),
sql`${heartbeatRuns.status} in ('failed', 'interrupted', 'timed_out', 'cancelled', 'succeeded')`,
)).where(and(
workspaceRecoverySourceEligible(),
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,state}' in ('scheduled', 'running', 'awaiting_evidence')`,
)).orderBy(sql`1`).limit(1);
return rows.length ? Number(rows[0].at) : null;
}
+24 -3
View File
@@ -1,3 +1,5 @@
import { writeExecutionControl } from "./execution-control-notifications.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
@@ -8,6 +10,20 @@ import { createSystemRecovery, readSystemRecovery } from "./system-recovery-stat
import { updateSystemRecovery } from "./system-recovery.js";
import { instanceSettingsService } from "./instance-settings.js";
import { notifyChatActionWork } from "./chat-work-notifications.js";
import { authorizationService } from "./authorization.js";
export async function taskRetryMutationAllowed(db: Db, input: {
companyId: string; issueId: string; actorId: string; actorSource: TaskRetryIntent["actorSource"];
}) {
const [issue] = await db.select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId }).from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId)));
if (!issue) return false;
const permission = await authorizationService(db).decide({
actor: { type: "board", userId: input.actorId, source: input.actorSource }, action: "issue:mutate",
resource: { type: "issue", companyId: input.companyId, issueId: input.issueId, ...issue },
});
return permission.allowed;
}
export async function assertTaskRetryConversationAllowed(
db: Db,
@@ -59,7 +75,7 @@ export async function requestTaskRetry(input: {
prepareChatRetry?: (tx: Db, scope: { companyId: string; issueId: string; agentId: string;
failedRunId: string; initiatedByUserId: string; retryIntentId?: string }) => Promise<{ actionId: string }>;
}) {
return input.db.transaction(async tx => {
return writeExecutionControl(input.db, DELIVERY_QUEUES.workspaceRecovery, async tx => {
const db = tx as unknown as Db;
const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, input.companyId),
eq(issues.id, input.issueId))).for("update");
@@ -115,6 +131,7 @@ export async function requestTaskRetry(input: {
const intent: TaskRetryIntent = { schema: "paperclip.task-retry.v1", generationId: active?.id ?? id,
sourceRunId: latest.id, actorId: input.actorId,
actorSource: taskRetryIntentSchema.shape.actorSource.parse(input.actorSource ?? "session"), strategy: "evaluate",
...(active && activeIntent?.chatActionId ? { chatActionId: activeIntent.chatActionId } : {}),
...(active ? { canonicalIntentId: active.id } : {}) };
// Preserve external chat authority through its existing admission owner.
// Generic board Retry must never manufacture a chat-inbound request.
@@ -139,6 +156,11 @@ export async function requestTaskRetry(input: {
for (const action of records) {
const previous = readSystemRecovery(action.evidence);
if (!previous || previous.state === "complete") continue;
if (previous.state === "running") {
await updateSystemRecovery(db, { companyId: issue.companyId, issueId: issue.id, runId: latest.id,
actionId: action.id }, previous, { ...previous, intentId: active?.id ?? id });
continue;
}
// Active repair keeps its attempt and cooldown. An exhausted generation
// gets a new budget only because this click is a new durable intent.
const next = previous.state === "exhausted"
@@ -148,8 +170,7 @@ export async function requestTaskRetry(input: {
: { ...previous, revision: previous.revision + 1, intentId: active?.id ?? id,
// Retry may advance our backoff, never a provider's cooldown and
// never an operation whose physical owner is still running.
nextAttemptAt: previous.state === "running" ? previous.nextAttemptAt
: Math.max(Date.now(), previous.providerCooldownAt ?? 0) };
nextAttemptAt: Math.max(Date.now(), previous.providerCooldownAt ?? 0) };
if (next.generationId !== previous.generationId) await persistActivity(db, {
companyId: issue.companyId, actorType: "system", actorId: "task-retry", entityType: "issue", entityId: issue.id,
action: "issue.recovery_generation_replaced", runId: latest.id,
@@ -10,6 +10,29 @@ export const workspaceExportCauses = ["native_workspace_sync_out_unrecoverable",
export const terminalWorkspaceRunStates = ["failed", "interrupted", "timed_out", "cancelled", "succeeded"];
export type WorkspaceRecoverySourceIdentity = { companyId: string; issueId: string; runId: string; actionId: string };
export function workspaceRecoverySourceEligible(includeCompleted = false) {
const actionEligible = sql`(${issueRecoveryActions.status} in ('active', 'escalated') or
(${issueRecoveryActions.status} = 'resolved' and (
(${issueRecoveryActions.evidence}#>>'{automaticRecovery,policy}' = 'preserve_without_replay_v1'
and ${issueRecoveryActions.evidence}#>>'{automaticRecovery,replay}' = 'blocked'
and ${issueRecoveryActions.evidence}#>>'{automaticRecovery,runId}' = ${heartbeatRuns.id}::text) or
(${issueRecoveryActions.evidence}#>>'{historicalWorkspaceSource,schema}' = 'paperclip.historical-workspace-source.v1'
and ${issueRecoveryActions.evidence}#>>'{historicalWorkspaceSource,runId}' = ${heartbeatRuns.id}::text))))`;
return and(
sql`${issueRecoveryActions.evidence} ? 'systemRecovery'`,
includeCompleted ? sql`(${actionEligible} or ${issueRecoveryActions.evidence}#>>'{systemRecovery,state}' = 'complete')` : actionEligible,
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.issueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${issueRecoveryActions.sourceIssueId}::text`,
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,sourceRunId}' = ${heartbeatRuns.id}::text`,
sql`${issueRecoveryActions.evidence}#>>'{workspaceRecovery,runId}' = ${heartbeatRuns.id}::text`,
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,policyVersion}' = '1'`,
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,schema}' = 'paperclip.system-recovery.v1'`,
sql`${issueRecoveryActions.evidence}#>>'{workspaceRecovery,schema}' = 'paperclip.workspace-recovery.v1'`,
sql`(${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'
or ${issueRecoveryActions.evidence}->'historicalWorkspaceSource'->>'schema' = 'paperclip.historical-workspace-source.v1'
or ${issueRecoveryActions.cause} in ('native_workspace_sync_out_unrecoverable', 'native_workspace_sync_out_retry_exhausted'))`,
);
}
/** The preservation owner may reopen only its exact stop-only allocation.
* This does not release the source's ordinary execution or destruction fence. */
export async function ownedWorkspaceRecoveryLease(db: Db, input: WorkspaceRecoverySourceIdentity & {
@@ -0,0 +1,37 @@
import type { Db } from "@paperclipai/db";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { executionControlSchedule, registerExecutionControlWork } from "./execution-control-work.js";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import { reconcileRetainedWorkspaces } from "./workspace-recovery.js";
export function registerWorkspaceRecoveryWork(db: Db, coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
runtime: EnvironmentRuntimeService, onRecovered: Parameters<typeof reconcileRetainedWorkspaces>[2],
canRun: () => boolean = () => true) {
const pending = new Set<Promise<void>>();
let stopped = false;
let scan: Promise<void> | undefined;
let wakeWhileBusy = false;
let passiveSweep: { cursor?: string } = {};
const schedule = executionControlSchedule(db);
const worker = registerExecutionControlWork(coordinator, [{
queue: DELIVERY_QUEUES.workspaceRecovery,
onSettled: () => { passiveSweep = {}; if (pending.size >= 2) wakeWhileBusy = true; },
run: () => scan = (async () => {
const work = await reconcileRetainedWorkspaces(db, runtime, onRecovered, true, continueSweep => {
if (!stopped && (continueSweep || wakeWhileBusy)) { wakeWhileBusy = false; worker.wake(); }
}, passiveSweep);
for (const job of work ?? []) {
pending.add(job);
const settled = () => { pending.delete(job); };
void job.then(settled, settled);
}
})(),
next: () => pending.size >= 2 ? Promise.resolve(null) : schedule.workspaceRecovery(),
}], () => !stopped && canRun());
return { ready: worker.ready, async stop() {
stopped = true;
await scan?.catch(() => {});
await Promise.all(pending);
} };
}
+192 -6
View File
@@ -20,21 +20,29 @@ import { generateKeyPairSync, randomUUID, sign } from "node:crypto";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { and, eq, inArray, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, approvals, budgetPolicies, costEvents, completionContracts, issueApprovals, issueTreeHolds, issueTreeHoldMembers, companies, createDb, environmentLeases, environments, heartbeatRuns, issueComments, issueRecoveryActions, issues, nativeRunFinalizations, nativeRunResults, agentWakeupRequests, workspaceOperations } from "@paperclipai/db";
import { agents, approvals, budgetPolicies, costEvents, completionContracts, issueApprovals, issueTreeHolds, issueTreeHoldMembers, companies, createDb, applyPendingMigrations, environmentLeases, environments, heartbeatRuns, issueComments, issueRecoveryActions, issues, nativeRunFinalizations, nativeRunResults, agentWakeupRequests, workspaceOperations } from "@paperclipai/db";
import { resumePreservedResultFinalization } from "./preserved-result-finalization.js";
import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { EnvironmentLease } from "@paperclipai/shared";
import { startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "../__tests__/helpers/external-test-database.js";
import { prepareNativeWorkspaceSync } from "./native-runtime/native-workspace-sync.js";
import { nativeWorkspaceFinalizationLockKey } from "./native-runtime/native-workspace-finalization-ownership.js";
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
import { prepareStoppedSandboxForRecovery } from "./sandbox-stop-and-retain.js";
import { recoverRetainedWorkspace, requestWorkspaceRecovery, scheduleRecoveredWorkspaceCleanup, reconcileRetainedWorkspaces, retainedWorkspaceCandidates } from "./workspace-recovery.js";
import * as activity from "./activity-log.js";
import { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { registerWorkspaceRecoveryWork } from "./workspace-recovery-work.js";
import { heartbeatService } from "./heartbeat.js";
import { hasVerifiedWorkspaceRepair, readWorkspaceRecovery } from "./workspace-recovery-receipt.js";
import { getExecutionBlocker } from "./execution-blocker.js";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
import * as workerProof from "./native-runtime/native-workspace-platform-proof.js";
const targets = vi.hoisted(() => new Map<string, AdapterSandboxExecutionTarget>());
vi.mock("./environment-execution-target.js", async importOriginal => {
@@ -51,7 +59,13 @@ describe("automatic retained workspace recovery", () => {
beforeAll(async () => {
root = await mkdtemp(path.join(os.tmpdir(), "workspace-recovery-"));
process.env.PAPERCLIP_HOME = path.join(root, "home"); process.env.PAPERCLIP_INSTANCE_ID = "recovery";
temporary = await startEmbeddedPostgresTestDatabase("workspace-recovery-"); db = createDb(temporary.connectionString);
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_WORKSPACE_RECOVERY_TEST_DATABASE_URL");
if (external) {
await applyPendingMigrations(external);
temporary = { connectionString: external, cleanup: async () => {} };
} else temporary = await startEmbeddedPostgresTestDatabase("workspace-recovery-");
db = createDb(temporary.connectionString);
if (external) await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
}, 30_000);
afterAll(async () => {
await db?.$client.end(); await temporary?.cleanup(); await rm(root, { recursive: true, force: true });
@@ -123,6 +137,178 @@ describe("automatic retained workspace recovery", () => {
const action = async (id: string) => (await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, id)))[0]!;
const run = async (id: string) => (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)))[0]!;
it("wakes physical preservation from HTTP Retry before passive history without replaying the provider", async () => {
for (let index = 0; index < 5; index++) {
const old = await seed();
await recoverRetainedWorkspace(old.input);
await db.update(issueRecoveryActions).set({
id: `00000000-000${index}-4000-8000-${randomUUID().slice(-12)}`,
}).where(eq(issueRecoveryActions.id, old.actionId));
}
const f = await seed();
await adoptSystemRecovery(db, f);
const before = await action(f.actionId);
const exhausted = failSystemRecovery(readSystemRecovery(before.evidence)!,
new SystemRecoveryFailure("source_binding_invalid", "service_required"));
await db.update(issueRecoveryActions).set({ cause: "native_workspace_sync_out_retry_exhausted",
evidence: { ...before.evidence, systemRecovery: exhausted } })
.where(eq(issueRecoveryActions.id, f.actionId));
const errors = vi.fn();
const competingCleanup = await retainedWorkspaceCandidates(db, undefined, false, true);
expect(competingCleanup).toHaveLength(5);
expect(competingCleanup.every(row => row.action.id.startsWith("00000000"))).toBe(true);
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, canReconcile: () => true, onError: errors });
const recovered = vi.fn(async () => {});
const worker = registerWorkspaceRecoveryWork(db, coordinator, f.environmentRuntime, recovered);
try {
await worker.ready;
await db.update(agents).set({ status: "paused" }).where(eq(agents.companyId, f.companyId));
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "board" }; next(); });
app.use("/api", issueRoutes(db, {} as Parameters<typeof issueRoutes>[1]));
app.use(errorHandler);
const body = { idempotencyKey: randomUUID(), observedRunId: f.runId };
const retry = await request(app).post(`/api/issues/${f.issueId}/retry`).send(body).expect(202);
const repeated = await request(app).post(`/api/issues/${f.issueId}/retry`).send(body).expect(202);
const coalesced = await request(app).post(`/api/issues/${f.issueId}/retry`)
.send({ ...body, idempotencyKey: randomUUID() }).expect(202);
expect(repeated.body.requestId).toBe(retry.body.requestId);
expect(coalesced.body.requestId).toBe(retry.body.requestId);
await expect.poll(async () => readSystemRecovery((await action(f.actionId)).evidence)?.state,
{ timeout: 20_000 }).toBe("complete");
expect(await readFile(path.join(f.host, "saved.txt"), "utf8")).toBe("finished work\n");
expect(f.runtime.resumeRunLease.mock.calls.filter(([input]) => input.lease.id === f.lease.id)).toHaveLength(1);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.companyId, f.companyId))).toHaveLength(0);
const reloaded = await request(app).get(`/api/issues/${f.issueId}/recovery-actions`).expect(200);
expect(JSON.stringify(reloaded.body)).toContain('"state":"complete"');
expect(errors).not.toHaveBeenCalled();
} finally { await coordinator.stop(); await worker.stop(); }
}, 30_000);
it("reconstructs a resolved files-only preservation obligation at startup without replay", async () => {
const f = await seed("legacy");
await adoptSystemRecovery(db, f);
const before = await action(f.actionId);
const automaticRecovery = { policy: "preserve_without_replay_v1", runId: f.runId, replay: "blocked" };
await db.update(issueRecoveryActions).set({ status: "resolved", outcome: "blocked",
evidence: { ...before.evidence, automaticRecovery } }).where(eq(issueRecoveryActions.id, f.actionId));
const errors = vi.fn();
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, canReconcile: () => true, onError: errors });
const worker = registerWorkspaceRecoveryWork(db, coordinator, f.environmentRuntime, async () => {});
try {
await worker.ready;
await expect.poll(async () => readSystemRecovery((await action(f.actionId)).evidence)?.state,
{ timeout: 20_000 }).toBe("complete");
expect((await action(f.actionId)).evidence.automaticRecovery).toEqual(automaticRecovery);
expect(f.runtime.resumeRunLease.mock.calls.filter(([input]) => input.lease.id === f.lease.id)).toHaveLength(1);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
expect(errors).not.toHaveBeenCalled();
} finally { await coordinator.stop(); await worker.stop(); }
}, 30_000);
it("fills a free export slot while another export is held and joins both at shutdown", async () => {
const held = await seed(), fast = await seed();
const fixtures = [held, fast];
const runtimeFor = (runId: string) => fixtures.find(fixture => fixture.runId === runId)!.environmentRuntime;
const runtime = { ...held.environmentRuntime,
resumeWorkspaceRecoveryLease: (input: Parameters<typeof held.environmentRuntime.resumeWorkspaceRecoveryLease>[0]) =>
runtimeFor(input.source.runId).resumeWorkspaceRecoveryLease(input),
retryPendingSandboxTeardown: (input: Parameters<typeof held.environmentRuntime.retryPendingSandboxTeardown>[0]) =>
runtimeFor(input.lease.heartbeatRunId!).retryPendingSandboxTeardown(input),
};
let active = 0, maximum = 0, release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const countExport = (fixture: typeof held, pause = false) => {
const resume = fixture.runtime.resumeRunLease.getMockImplementation()!;
fixture.runtime.resumeRunLease.mockImplementation(async input => {
active += 1; maximum = Math.max(maximum, active);
try { if (pause) await gate; return await resume(input); } finally { active -= 1; }
});
};
countExport(held, true); countExport(fast);
const errors = vi.fn();
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: errors });
const worker = registerWorkspaceRecoveryWork(db, coordinator, runtime, async () => {});
try {
await expect.poll(() => held.runtime.resumeRunLease.mock.calls.length, { timeout: 20_000 }).toBe(1);
await expect.poll(async () => readSystemRecovery((await action(fast.actionId)).evidence)?.state,
{ timeout: 20_000 }).toBe("complete");
const third = await seed(); fixtures.push(third); countExport(third);
await requestTaskRetry({ db, companyId: third.companyId, issueId: third.issueId, actorId: "board",
observedRunId: third.runId, idempotencyKey: randomUUID() });
await expect.poll(async () => readSystemRecovery((await action(third.actionId)).evidence)?.state,
{ timeout: 20_000 }).toBe("complete");
expect(readSystemRecovery((await action(held.actionId)).evidence)?.state).toBe("running");
expect(maximum).toBeLessThanOrEqual(2);
expect(await readFile(path.join(third.host, "saved.txt"), "utf8")).toBe("finished work\n");
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, third.companyId))).toHaveLength(1);
let stopped = false;
const stopping = Promise.all([coordinator.stop(), worker.stop()]).then(() => { stopped = true; });
await Promise.resolve(); expect(stopped).toBe(false);
release(); await stopping;
expect(readSystemRecovery((await action(held.actionId)).evidence)?.state).toBe("complete");
expect(errors).not.toHaveBeenCalled();
} finally { release(); await coordinator.stop(); await worker.stop(); }
}, 60_000);
it("rotates past due sources whose physical locks belong to other workers", async () => {
const blocked = [await seed(), await seed()], available = await seed();
for (const [index, fixture] of [...blocked, available].entries()) {
await adoptSystemRecovery(db, fixture);
const id = `00000001-000${index}-4000-8000-${randomUUID().slice(-12)}` as const;
await db.update(issueRecoveryActions).set({ id }).where(eq(issueRecoveryActions.id, fixture.actionId));
fixture.actionId = id; fixture.input.actionId = id;
}
let unlock!: () => void;
const gate = new Promise<void>(resolve => { unlock = resolve; });
const held = blocked.map(fixture => {
let ready!: () => void;
const acquired = new Promise<void>(resolve => { ready = resolve; });
const lock = db.transaction(async tx => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${nativeWorkspaceFinalizationLockKey(fixture.companyId, fixture.runId)}, 0))`);
ready(); await gate;
});
return { acquired, lock };
});
try {
await Promise.all(held.map(item => item.acquired));
await Promise.all(await reconcileRetainedWorkspaces(db, available.environmentRuntime, async () => {}, true) ?? []);
expect(blocked.every(fixture => fixture.runtime.resumeRunLease.mock.calls.length === 0)).toBe(true);
await Promise.all(await reconcileRetainedWorkspaces(db, available.environmentRuntime, async () => {}, true) ?? []);
expect(readSystemRecovery((await action(available.actionId)).evidence)?.state).toBe("complete");
expect(await readFile(path.join(available.host, "saved.txt"), "utf8")).toBe("finished work\n");
expect(blocked.every(fixture => fixture.runtime.resumeRunLease.mock.calls.length === 0)).toBe(true);
} finally { unlock(); await Promise.all(held.map(item => item.lock)); }
}, 30_000);
it("coalesces Retry while preservation owns its physical operation", async () => {
const f = await seed();
await adoptSystemRecovery(db, f);
const resume = f.runtime.resumeRunLease.getMockImplementation()!;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
f.runtime.resumeRunLease.mockImplementation(async input => { await gate; return resume(input); });
const preserving = recoverRetainedWorkspace(f.input);
let requestId: string | null | undefined;
try {
await expect.poll(() => f.runtime.resumeRunLease.mock.calls.length).toBe(1);
const request = { db, companyId: f.companyId, issueId: f.issueId, actorId: "board",
idempotencyKey: randomUUID(), observedRunId: f.runId };
const first = await requestTaskRetry(request);
requestId = first.requestId;
const second = await requestTaskRetry({ ...request, idempotencyKey: randomUUID() });
expect(second.requestId).toBe(first.requestId);
} finally { release(); await preserving; }
expect(readSystemRecovery((await action(f.actionId)).evidence)?.state).toBe("complete");
expect(readSystemRecovery((await action(f.actionId)).evidence)?.intentId).toBe(requestId);
expect(f.runtime.resumeRunLease.mock.calls.filter(([input]) => input.lease.id === f.lease.id)).toHaveLength(1);
expect(await readFile(path.join(f.host, "saved.txt"), "utf8")).toBe("finished work\n");
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
}, 30_000);
it("adopts only the exact historical infrastructure notice, preserving prose and user-action notices", async () => {
const f = await seed("legacy", false);
const metadata = { version: 1 as const, sourceRunId: f.runId,
@@ -285,7 +471,7 @@ describe("automatic retained workspace recovery", () => {
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.companyId, f.companyId))).toHaveLength(0);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
await recoverRetainedWorkspace(f.input);
expect(f.runtime.resumeRunLease).toHaveBeenCalledOnce();
expect(f.runtime.resumeRunLease.mock.calls.filter(([input]) => input.lease.id === f.lease.id)).toHaveLength(1);
expect(await db.select().from(workspaceOperations).where(and(eq(workspaceOperations.companyId, f.companyId), eq(workspaceOperations.phase, "workspace_recovery")))).toHaveLength(1);
}, 30_000);
@@ -304,9 +490,9 @@ describe("automatic retained workspace recovery", () => {
expect(readWorkspaceRecovery((await action(f.actionId)).evidence)?.status).toBe("recovered");
expect(await readFile(path.join(f.host, "saved.txt"), "utf8")).toBe("finished work\n");
expect(await readFile(path.join(f.host, "newer.txt"), "utf8")).toBe("newer local work\n");
expect(f.runtime.resumeRunLease).toHaveBeenCalledOnce();
expect(f.runtime.resumeRunLease.mock.calls.filter(([input]) => input.lease.id === f.lease.id)).toHaveLength(1);
await recoverRetainedWorkspace({ ...f.input, environmentRuntime: runtime });
expect(f.runtime.resumeRunLease).toHaveBeenCalledOnce();
expect(f.runtime.resumeRunLease.mock.calls.filter(([input]) => input.lease.id === f.lease.id)).toHaveLength(1);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
}, 30_000);
@@ -405,7 +591,7 @@ describe("automatic retained workspace recovery", () => {
f.runtime.resumeRunLease.mockImplementationOnce(async () => { ready(); await gate; return { providerLeaseId: f.providerLeaseId, metadata: { remoteCwd: f.remote } }; });
const first = recoverRetainedWorkspace(f.input);
await started;
try { await recoverRetainedWorkspace(f.input); expect(f.runtime.resumeRunLease).toHaveBeenCalledOnce(); }
try { await recoverRetainedWorkspace(f.input); expect(f.runtime.resumeRunLease.mock.calls.filter(([input]) => input.lease.id === f.lease.id)).toHaveLength(1); }
finally { release(); await first; }
expect(readWorkspaceRecovery((await action(f.actionId)).evidence)?.status).toBe("recovered");
}, 30_000);
+41 -24
View File
@@ -1,3 +1,5 @@
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { beginIdleTrackedWork } from "./task-admission.js";
import { writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { resumePreservedResultFinalization } from "./preserved-result-finalization.js";
import { and, asc, eq, gt, inArray, ne, or, sql } from "drizzle-orm";
@@ -19,7 +21,7 @@ import { classifyNativeWorkspaceFailure } from "./native-runtime/native-workspac
import { adoptSystemRecovery, loadSystemRecovery, updateSystemRecovery } from "./system-recovery.js";
import { createSystemRecovery, failSystemRecovery, readSystemRecovery, reserveSystemRecoveryAttempt, SystemRecoveryFailure, type SystemRecoveryState } from "./system-recovery-state.js";
import { inspectWorkspaceRecoverySource } from "./workspace-recovery-source.js";
import { inspectWorkspaceRecoverySource, workspaceRecoverySourceEligible } from "./workspace-recovery-source.js";
import { preserveWorkspaceSnapshot } from "./workspace-recovery-snapshot.js";
import { adoptHistoricalWorkspaceRecoveries } from "./workspace-recovery-backfill.js";
@@ -48,12 +50,13 @@ export async function requestWorkspaceRecovery(input: Source & { db: Db; idempot
requestIds: [...(prior?.requestIds ?? []), input.idempotencyKey] };
const previous = readSystemRecovery(action.evidence);
const active = previous && ["scheduled", "running", "awaiting_evidence"].includes(previous.state);
const system = active ? { ...previous, revision: previous.revision + 1,
nextAttemptAt: previous.state === "running" ? previous.nextAttemptAt : Math.max(Date.now(), previous.providerCooldownAt ?? 0) } : { ...createSystemRecovery(run.id),
const system = previous?.state === "running" ? previous : active ? { ...previous, revision: previous.revision + 1,
nextAttemptAt: Math.max(Date.now(), previous.providerCooldownAt ?? 0) } : { ...createSystemRecovery(run.id),
revision: (previous?.revision ?? -1) + 1,
preservationOperationIds: previous?.preservationOperationIds ?? [],
providerCooldownAt: previous?.providerCooldownAt ?? null,
nextAttemptAt: previous?.providerCooldownAt ?? null };
await notifyDeliveryWork(tx, DELIVERY_QUEUES.workspaceRecovery);
await tx.update(issueRecoveryActions).set({ ownerType: "system", ownerAgentId: null, ownerUserId: null,
evidence: { ...action.evidence, workspaceRecovery: state, systemRecovery: system }, updatedAt: new Date() })
.where(eq(issueRecoveryActions.id, action.id));
@@ -286,6 +289,7 @@ export async function recoverRetainedWorkspace(input: Source & { db: Db; environ
const cursors = new WeakMap<Db, string>();
const workInFlight = new WeakMap<Db, Set<string>>();
const unfinishedCursors = new WeakMap<Db, string>();
/** Destruction is a later cleanup obligation, authorized only by a matching
* saved-file receipt and the original recorded disposition. */
@@ -309,29 +313,31 @@ export async function scheduleRecoveredWorkspaceCleanup(db: Db, run: typeof hear
sql`${environmentLeases.metadata} = ${JSON.stringify(lease.metadata)}::jsonb`,
sql`${environmentLeases.metadata}->>'workspaceRecoveryCleanup' is null`)));
}
export function retainedWorkspaceCandidates(db: Db, cursor?: string) {
export function retainedWorkspaceCandidates(db: Db, cursor?: string, unfinishedOnly = false, obligationsOnly = false) {
const cleanup = sql`(${heartbeatRuns.runnerProfileJson}#>>'{nativeWorkspaceSync,resourceDisposition}' = 'destroy' and exists (
select 1 from ${environmentLeases} where ${environmentLeases.companyId} = ${heartbeatRuns.companyId}
and ${environmentLeases.heartbeatRunId} = ${heartbeatRuns.id}
and ${environmentLeases.id}::text = ${heartbeatRuns.runnerProfileJson}#>>'{nativeWorkspaceSync,leaseId}'
and ${environmentLeases.status} = 'released' and ${environmentLeases.cleanupStatus} = 'success'
and ${environmentLeases.metadata}->'workspaceRecoveryCleanup' is null))`;
const waitingInput = sql`exists (select 1 from ${agentWakeupRequests}
where ${agentWakeupRequests.companyId} = ${issueRecoveryActions.companyId}
and ${agentWakeupRequests.status} = 'deferred_issue_execution' and ${agentWakeupRequests.requestedByActorType} = 'user'
and ${agentWakeupRequests.payload}->>'issueId' = ${issueRecoveryActions.sourceIssueId}::text)`;
return db.select({ action: issueRecoveryActions, run: heartbeatRuns }).from(issueRecoveryActions)
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, issueRecoveryActions.companyId), eq(heartbeatRuns.id,
sql`case when ${issueRecoveryActions.evidence}->>'runId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
then (${issueRecoveryActions.evidence}->>'runId')::uuid end`)))
.where(and(inArray(heartbeatRuns.status, terminal),
sql`(${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'
or ${issueRecoveryActions.evidence}->'historicalWorkspaceSource'->>'schema' = 'paperclip.historical-workspace-source.v1'
or ${issueRecoveryActions.cause} in ('native_workspace_sync_out_unrecoverable', 'native_workspace_sync_out_retry_exhausted'))`,
sql`${issueRecoveryActions.evidence} ? 'systemRecovery'`,
workspaceRecoverySourceEligible(true),
unfinishedOnly ? sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,state}' in ('scheduled', 'running', 'awaiting_evidence')` : undefined,
obligationsOnly ? sql`(${issueRecoveryActions.evidence}#>>'{systemRecovery,state}' in ('scheduled', 'running', 'awaiting_evidence')
or (${issueRecoveryActions.evidence}#>>'{systemRecovery,state}' = 'complete' and ${cleanup}) or ${waitingInput})` : undefined,
sql`(${issueRecoveryActions.evidence}#>>'{systemRecovery,state}' in ('scheduled', 'running', 'awaiting_evidence', 'complete', 'exhausted')
and (${issueRecoveryActions.evidence}#>'{systemRecovery,nextAttemptAt}' = 'null'::jsonb
or ${issueRecoveryActions.evidence}#>'{systemRecovery,nextAttemptAt}' <= to_jsonb(${Date.now()}::bigint)))`,
sql`(${issueRecoveryActions.evidence}#>>'{workspaceRecovery,status}' is distinct from 'recovered' or
(${heartbeatRuns.runnerProfileJson}#>>'{nativeWorkspaceSync,resourceDisposition}' = 'destroy' and exists (
select 1 from ${environmentLeases} where ${environmentLeases.companyId} = ${heartbeatRuns.companyId}
and ${environmentLeases.heartbeatRunId} = ${heartbeatRuns.id}
and ${environmentLeases.id}::text = ${heartbeatRuns.runnerProfileJson}#>>'{nativeWorkspaceSync,leaseId}'
and ${environmentLeases.status} = 'released' and ${environmentLeases.cleanupStatus} = 'success'
and ${environmentLeases.metadata}->'workspaceRecoveryCleanup' is null)) or exists (
select 1 from ${agentWakeupRequests} where ${agentWakeupRequests.companyId} = ${issueRecoveryActions.companyId}
and ${agentWakeupRequests.status} = 'deferred_issue_execution' and ${agentWakeupRequests.requestedByActorType} = 'user'
and ${agentWakeupRequests.payload}->>'issueId' = ${issueRecoveryActions.sourceIssueId}::text))`,
${cleanup} or ${waitingInput})`,
cursor ? gt(issueRecoveryActions.id, cursor) : undefined,
)).orderBy(asc(issueRecoveryActions.id)).limit(5);
}
@@ -339,19 +345,29 @@ export function retainedWorkspaceCandidates(db: Db, cursor?: string) {
/** Bounded discovery shares the periodic recovery lifecycle. A repaired source
* still owes cleanup even without saved input or after task reconciliation. */
export async function reconcileRetainedWorkspaces(db: Db, runtime: EnvironmentRuntimeService,
onRecovered: (run: typeof heartbeatRuns.$inferSelect) => Promise<void>) {
onRecovered: (run: typeof heartbeatRuns.$inferSelect) => Promise<void>, obligationsOnly = false,
onFinished?: (continueSweep: boolean) => void, passiveSweep?: { cursor?: string }) {
await adoptHistoricalWorkspaceRecoveries(db);
let pending = workInFlight.get(db);
if (!pending) { pending = new Set(); workInFlight.set(db, pending); }
if (pending.size >= 2) return;
let rows = await retainedWorkspaceCandidates(db, cursors.get(db));
if (!rows.length) rows = await retainedWorkspaceCandidates(db);
let rows = await retainedWorkspaceCandidates(db, unfinishedCursors.get(db), true);
if (!rows.length) rows = await retainedWorkspaceCandidates(db, undefined, true);
const unfinished = rows.length > 0;
if (!rows.length) rows = await retainedWorkspaceCandidates(db, passiveSweep ? passiveSweep.cursor : cursors.get(db), false, obligationsOnly);
if (!rows.length && !passiveSweep) rows = await retainedWorkspaceCandidates(db, undefined, false, obligationsOnly);
const work: Promise<void>[] = [];
let hasUnvisited = false;
for (const { action, run } of rows) {
if (pending.size >= 2) break;
cursors.set(db, action.id);
if (pending.size >= 2) { hasUnvisited = true; break; }
if (unfinished) unfinishedCursors.set(db, action.id);
else if (passiveSweep) passiveSweep.cursor = action.id;
else cursors.set(db, action.id);
if (pending.has(run.id)) continue;
pending.add(run.id);
void (async () => { try {
const finish = beginIdleTrackedWork();
const physical = ["scheduled", "running", "awaiting_evidence"].includes(readSystemRecovery(action.evidence)?.state ?? "");
work.push((async () => { try {
await recoverRetainedWorkspace({ db, environmentRuntime: runtime, companyId: action.companyId, issueId: action.sourceIssueId, runId: run.id, actionId: action.id });
const [updated] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action.id));
const recovered = readWorkspaceRecovery(updated?.evidence ?? null)?.status === "recovered";
@@ -370,6 +386,7 @@ export async function reconcileRetainedWorkspaces(db: Db, runtime: EnvironmentRu
}
}
} catch { logger.warn({ runId: run.id }, "Retained workspace remains pending"); }
finally { pending.delete(run.id); } })();
finally { pending.delete(run.id); finish(); onFinished?.(physical || hasUnvisited); } })());
}
return work;
}
+4
View File
@@ -26,6 +26,10 @@ const sourceUrl = new URL(
);
const runnerTesting = await import(sourceUrl.href) as RunnerTestingModule;
export const startOpenCodeMcpBridge:
RunnerTestingModule["startOpenCodeMcpBridge"] =
runnerTesting.startOpenCodeMcpBridge;
export const CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS:
RunnerTestingModule["CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS"] =
runnerTesting.CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS;
+11
View File
@@ -506,6 +506,17 @@ select arbitrary destinations. Gateway response caching and fallback are refused
Request journal and usage receipts contain no prompt/response bodies or keys.
The manifest, journal, and receipts are not public report projections.
`boundedEvalPayload` is the shared, credential-free request validator and
normalizer. Qualification callers must apply it before recording budget-denial
proof. The forwarding path applies the same policy before a ledger reservation.
OpenAI tools may use one namespace level around local function or custom tools.
Function and custom leaves require nonblank string names.
Namespace wrappers contain only `type`, `name`, optional `description`, and
`tools`. Nested namespaces, hosted tools, and tool lists attached to leaves fail
closed. The request may contain at most 1,024 tool definitions, counting namespace
wrappers and their children. Anthropic tool policy stays separate. Output limits,
billing-mode restrictions, and multimodal exclusions apply in both paths.
## Configured repository and conversion proof
The repository proof flow seeds only the exact `workspace` child of its resolved
+66 -1
View File
@@ -6,7 +6,7 @@ import { connect } from "node:net";
import { afterEach, expect, it, vi } from "vitest";
import { openEvalCostLedger, readEvalCostLedger } from "./cost-ledger.js";
import { beginEvalAgentPhase, createEvalAttemptPhase, readEvalAttemptPhase } from "./attempt-phase.js";
import { evalRequestReservation, EvalUsageStream, forwardEvalRequest, startEvalGateway, type EvalGatewayRoute, type EvalGatewayReceipt } from "./eval-gateway.js";
import { boundedEvalPayload, evalRequestReservation, EvalUsageStream, forwardEvalRequest, startEvalGateway, type EvalGatewayRoute, type EvalGatewayReceipt } from "./eval-gateway.js";
const directories: string[] = [], closes: Array<() => Promise<void>> = [];
afterEach(async () => { for (const close of closes.splice(0).reverse()) await close(); for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); });
const identity = { executionId: "hello", attemptId: "attempt", phase: "agent" as const };
@@ -135,6 +135,71 @@ it("admits local function and custom tools inside a Codex namespace", async () =
expect(result.outcome).toBe("succeeded");
expect(transport).toHaveBeenCalledOnce();
});
it("validates the native daemon's namespace envelope before reserving and forwarding", async () => {
const f = await fixture();
const localTool = (name: string) => ({ type: "function", name, description: "Synthetic local tool",
parameters: { type: "object", properties: {}, additionalProperties: false }, strict: true });
const tools = [...Array.from({ length: 7 }, (_, index) => localTool(`local_${index}`)),
{ type: "namespace", name: "local_tools", description: "Synthetic local namespace",
tools: Array.from({ length: 5 }, (_, index) => localTool(`operation_${index}`)) }];
const payload = Object.freeze({ ...f.payload, tools });
const normalized = boundedEvalPayload(payload, { provider: route.provider, price: route.price, maximumOutputTokens: 100 });
expect(normalized).toEqual({ ...payload, max_output_tokens: 100, service_tier: "default" });
expect(payload).not.toHaveProperty("service_tier");
expect(payload.max_output_tokens).toBe(500);
expect(f.ledger.snapshot().requests).toHaveLength(0);
const transport = vi.fn<typeof fetch>(async (_url, init) => {
expect(JSON.parse(init!.body as string)).toEqual(normalized);
expect((await readEvalCostLedger(f.file)).state.snapshot().inflight).toBe(1);
return response();
});
const receipt = await forwardEvalRequest({ ...f, payload, transport });
expect(receipt).toMatchObject({ outcome: "succeeded", costUsd: "0.000068000", final: true });
expect(transport).toHaveBeenCalledOnce();
});
it.each([
{ type: "namespace", name: " ", tools: [{ type: "function", name: "local" }] },
{ type: "namespace", name: "local", description: {}, tools: [{ type: "function", name: "local" }] },
{ type: "namespace", name: "local", tools: [] },
{ type: "namespace", name: "local", tools: null },
{ type: "namespace", name: "local", tools: [null] },
{ type: ["function"], name: "local" },
{ type: ["custom"], name: "local" },
{ type: "namespace", name: "local", tools: [{ type: ["function"], name: "local" }] },
{ type: "namespace", name: "local", tools: [{ type: ["custom"], name: "local" }] },
...[undefined, null, 7, "", " "].flatMap(name => ["function", "custom"].flatMap(type =>
[{ type, name }, { type: "namespace", name: "local", tools: [{ type, name }] }])),
{ type: "namespace", name: "local", tools: [{ type: "function", name: "local" }], future_hosted_tool: true },
{ type: "namespace", name: "local", tools: [{ type: "namespace", name: "nested", tools: [{ type: "function", name: "local" }] }] },
...["web_search", "file_search", "image_generation", "code_interpreter", "computer", "mcp", "unknown"].map(type =>
({ type: "namespace", name: "local", tools: [{ type }] })),
])("rejects malformed, nested, or hosted namespace tools before any side effects", async tool => {
const f = await fixture(), transport = vi.fn<typeof fetch>(), persist = vi.fn();
const payload = { ...f.payload, tools: [tool] };
expect(() => boundedEvalPayload(payload, f.route)).toThrow("unqualified_hosted_tool");
await expect(forwardEvalRequest({ ...f, payload, transport, persist })).rejects.toThrow("unqualified_hosted_tool");
expect(transport).not.toHaveBeenCalled(); expect(persist).not.toHaveBeenCalled();
expect(f.ledger.snapshot().requests).toHaveLength(0);
});
it.each(["flat", "namespace"] as const)("bounds the total %s tool definitions including namespace wrappers", async shape => {
const f = await fixture(), transport = vi.fn<typeof fetch>(), persist = vi.fn();
const tool = { type: "function", name: "local_tool" };
const tools = shape === "flat" ? Array.from({ length: 1024 }, () => tool)
: [{ type: "namespace", name: "local", tools: Array.from({ length: 1023 }, () => tool) }];
expect(boundedEvalPayload({ ...f.payload, tools }, f.route).tools).toEqual(tools);
const payload = { ...f.payload, tools: [...tools, tool] };
await expect(forwardEvalRequest({ ...f, payload, transport, persist })).rejects.toThrow("unqualified_hosted_tool");
expect(transport).not.toHaveBeenCalled(); expect(persist).not.toHaveBeenCalled();
expect(f.ledger.snapshot().requests).toHaveLength(0);
});
it("keeps Anthropic custom tools and output limits separate from OpenAI namespaces", () => {
const anthropic = { provider: "anthropic" as const, price: { ...route.price, provider: "anthropic" as const }, maximumOutputTokens: 100 };
const tools = [{ name: "local_tool", input_schema: { type: "object" } }, { type: "custom", name: "other_tool" }];
expect(boundedEvalPayload({ model: "fixture", tools, max_tokens: 500 }, anthropic))
.toEqual({ model: "fixture", tools, max_tokens: 100, service_tier: "standard_only" });
expect(() => boundedEvalPayload({ model: "fixture", tools: [{ type: "namespace", name: "local", tools }] }, anthropic))
.toThrow("unqualified_hosted_tool");
});
it.each([
{ type: "namespace", name: "local", tools: [{ type: "web_search" }] },
{ type: "namespace", name: "local", tools: [{ type: "namespace", name: "nested", tools: [{ type: "web_search" }] }] },
+26 -11
View File
@@ -65,19 +65,34 @@ function modelReservationBound(route: EvalGatewayRoute, reservation: string) {
}) : undefined;
}
function boundedPayload(value: unknown, route: EvalGatewayRoute) {
function qualifiedOpenAiTools(tools: unknown): boolean {
if (!Array.isArray(tools)) return false;
let remaining = 1024;
const leaf = (tool: unknown): boolean => {
const definition = row(tool);
return --remaining >= 0 && (definition.type === "function" || definition.type === "custom")
&& typeof definition.name === "string" && Boolean(definition.name.trim()) && !Object.hasOwn(definition, "tools");
};
return tools.every(tool => {
const definition = row(tool);
if (definition.type !== "namespace") return leaf(tool);
return --remaining >= 0 && typeof definition.name === "string" && Boolean(definition.name.trim())
&& (definition.description === undefined || typeof definition.description === "string")
&& Object.keys(definition).every(key => ["type", "name", "description", "tools"].includes(key))
&& Array.isArray(definition.tools) && definition.tools.length > 0 && definition.tools.every(leaf);
});
}
export function boundedEvalPayload(value: unknown, route: Pick<EvalGatewayRoute, "provider" | "price" | "maximumOutputTokens">): Record<string, unknown> {
const body = row(value);
if (body.model !== route.price.model) throw new EvalRequestError("unqualified_model");
if (body.background === true || body.mcp_servers !== undefined || body.service_tier !== undefined && !["auto", "default", "standard_only"].includes(String(body.service_tier))) throw new EvalRequestError("unqualified_billing_mode");
if (body.speed !== undefined && body.speed !== "standard" || body.inference_geo !== undefined) throw new EvalRequestError("unqualified_billing_mode");
if (body.tools !== undefined && (!Array.isArray(body.tools) || body.tools.some(tool => {
const definition = row(tool), type = definition.type;
if (route.provider !== "openai") return type !== undefined && type !== "custom" || Object.hasOwn(definition, "tools");
if (type === "namespace") return typeof definition.name !== "string" || !definition.name
|| !Array.isArray(definition.tools) || !definition.tools.length
|| definition.tools.some(nested => !["function", "custom"].includes(String(row(nested).type)) || Object.hasOwn(row(nested), "tools"));
return !["function", "custom"].includes(String(type)) || Object.hasOwn(definition, "tools");
}))) throw new EvalRequestError("unqualified_hosted_tool");
if (body.tools !== undefined && (route.provider === "openai" ? !qualifiedOpenAiTools(body.tools)
: !Array.isArray(body.tools) || body.tools.some(tool => {
const definition = row(tool), type = definition.type;
return type !== undefined && type !== "custom" || Object.hasOwn(definition, "tools");
}))) throw new EvalRequestError("unqualified_hosted_tool");
const inspect = (item: unknown): void => {
if (Array.isArray(item)) { item.forEach(inspect); return; }
if (!item || typeof item !== "object") return;
@@ -119,8 +134,8 @@ export async function forwardEvalRequest(input: {
transport?: typeof fetch;
}): Promise<EvalGatewayReceipt> {
const { route, ledger } = input;
let body: ReturnType<typeof boundedPayload>;
try { body = boundedPayload(input.payload, route); }
let body: ReturnType<typeof boundedEvalPayload>;
try { body = boundedEvalPayload(input.payload, route); }
catch (error) { throw error instanceof EvalRequestError ? error : new EvalRequestError("invalid_request_payload"); }
const reservation = evalRequestReservation(route);
if (!route.apiKey || /[\r\n]/.test(route.apiKey)) throw new Error("missing_gateway_credential");
+9 -4
View File
@@ -45,6 +45,7 @@ import { useSidebar } from "../context/SidebarContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport";
import { healthApi } from "../api/health";
import {
resolveArchivedCompanyBounce,
@@ -178,6 +179,7 @@ export function Layout() {
const scrollMemory = useRef(new NavigationScrollMemory());
const activeScrollKey = useRef<string>(location.key);
const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname);
const mobileViewportRef = useMobileVisualViewport(isMobile);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@@ -588,8 +590,10 @@ export function Layout() {
return (
<ChatSetupSidebarProvider>
<div
ref={mobileViewportRef}
data-mobile={isMobile ? "true" : undefined}
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
// overflow-x-clip on mobile keeps a stray wide descendant from making the
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
// computed as visible, so native body scroll + the sticky breadcrumb keep
@@ -609,7 +613,7 @@ export function Layout() {
<DevRestartBanner devServer={health?.devServer} />
<div
className={cn(
"min-h-0 flex-1",
"app-main-row min-h-0 flex-1",
isMobile ? "w-full" : "flex overflow-clip",
)}
>
@@ -663,12 +667,13 @@ export function Layout() {
<div
className={cn(
"flex min-w-0 flex-col",
"app-content-column flex min-w-0 flex-col",
isMobile ? "w-full" : "h-full flex-1",
)}
>
<div
className={cn(
"app-breadcrumb",
isMobile &&
"sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
@@ -681,7 +686,7 @@ export function Layout() {
</div>
) : null}
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<div className={cn("app-content-row", isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
ref={mainContentRef}
+9 -3
View File
@@ -40,6 +40,7 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport";
import { healthApi } from "../api/health";
import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import { useOptionalToastActions } from "../context/ToastContext";
@@ -143,6 +144,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
const scrollMemory = useRef(new NavigationScrollMemory());
const activeScrollKey = useRef<string>(location.key);
const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname);
const mobileViewportRef = useMobileVisualViewport(isMobile);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@@ -574,8 +576,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
return (
<ChatSetupSidebarProvider>
<div
ref={mobileViewportRef}
data-mobile={isMobile ? "true" : undefined}
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
// overflow-x-clip on mobile keeps a stray wide descendant from making the
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
// computed as visible, so native body scroll + the sticky breadcrumb keep
@@ -591,7 +595,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
<div className={cn("app-main-row min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
{isMobile && sidebarOpen && (
<button
type="button"
@@ -656,9 +660,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
</SecondarySidebar>
) : null}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div className={cn("app-content-column flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
"app-breadcrumb",
!isMobile && useStreamlinedTaskDetailShell && "hidden",
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
@@ -672,6 +677,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
) : null}
</div>
<div className={cn(
"app-content-row",
isMobile ? "block" : "flex flex-1 min-h-0",
!isMobile && useStreamlinedTaskDetailShell && "streamlined-task-detail-surface",
)}>
+37 -14
View File
@@ -26,6 +26,7 @@ const transcriptState = vi.hoisted(() => ({
transcriptByRun: new Map(),
isInitialHydrating: false,
hydratedRunIds: undefined as Set<string> | undefined,
truncatedRunIds: new Set<string>(),
}));
const nativeTranscriptState = vi.hoisted(() => ({
transcriptByRun: new Map(),
@@ -35,6 +36,7 @@ const nativeTranscriptState = vi.hoisted(() => ({
}));
const transcriptHookRuns = vi.hoisted(() => ({
legacy: [] as unknown[][],
legacyReadLimits: [] as (number | undefined)[],
native: [] as unknown[][],
}));
const sidebarState = vi.hoisted(() => ({ isMobile: false }));
@@ -51,12 +53,14 @@ const editorCallbacks = vi.hoisted(() => ({ change: (_value: string) => {} }));
const streamlinedState = vi.hoisted(() => ({ enabled: true }));
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: ({ runs }: { runs: unknown[] }) => {
useLiveRunTranscripts: ({ runs, logReadLimitBytes }: { runs: unknown[]; logReadLimitBytes?: number }) => {
transcriptHookRuns.legacy.push(runs);
transcriptHookRuns.legacyReadLimits.push(logReadLimitBytes);
return {
transcriptByRun: new Map(transcriptState.transcriptByRun),
isInitialHydrating: transcriptState.isInitialHydrating,
hydratedRunIds: transcriptState.hydratedRunIds,
truncatedRunIds: transcriptState.truncatedRunIds,
};
},
}));
@@ -122,11 +126,13 @@ beforeEach(() => {
transcriptState.transcriptByRun.clear();
transcriptState.isInitialHydrating = false;
transcriptState.hydratedRunIds = undefined;
transcriptState.truncatedRunIds.clear();
nativeTranscriptState.transcriptByRun.clear();
nativeTranscriptState.errorsByRun.clear();
nativeTranscriptState.hydratedRunIds = undefined;
nativeTranscriptState.terminalHydratedRunIds = undefined;
transcriptHookRuns.legacy.length = 0;
transcriptHookRuns.legacyReadLimits.length = 0;
transcriptHookRuns.native.length = 0;
sidebarState.isMobile = false;
planState.data = null;
@@ -371,6 +377,27 @@ it.each([true, false])("keeps cold-open messages readable while history is pendi
expect(container.querySelector('[data-testid="mock-editor"]')).not.toBeNull();
});
it.each([0, 32_000, 100_000])("offers earlier output only when a single legacy run exceeds the compact window (%s bytes)", (logBytes) => {
const onExpandHistory = vi.fn();
if (logBytes > 32_000) transcriptState.truncatedRunIds.add("legacy-run");
const props = {
issueId: "single-legacy-run", comments: [], onAdd: async () => {}, onExpandHistory,
linkedRuns: [{ runId: "legacy-run", agentId: "agent", status: "succeeded", adapterType: "claude_local", runtimeMode: "legacy" as const, createdAt: "2025-01-01T10:00:00Z", startedAt: "2025-01-01T10:00:00Z", logBytes }],
};
render(<TaskChatThread {...props} compactHistory />);
expect(container.textContent).not.toContain("More messages");
const outputControl = [...container.querySelectorAll("button")].find((button) => button.textContent === "More run output");
expect(Boolean(outputControl)).toBe(logBytes > 32_000);
expect(transcriptHookRuns.legacyReadLimits.at(-1)).toBe(32_000);
if (outputControl) {
act(() => outputControl.click());
expect(onExpandHistory).toHaveBeenCalledOnce();
render(<TaskChatThread {...props} compactHistory={false} />);
expect(container.textContent).not.toContain("More run output");
expect(transcriptHookRuns.legacyReadLimits.at(-1)).toBeUndefined();
}
});
it("defers initial history for a saved conversation with its own draft key", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueId="saved-chat"
conversationMode draftKey="agent-chat:company:saved-agent" initialHistoryPending />);
@@ -3535,7 +3562,7 @@ describe("TaskChatThread composer alignment", () => {
const dock = container
.querySelector('[data-testid="mock-editor"]')
?.closest("div.sticky") as HTMLElement | null;
?.closest('[data-testid="task-chat-composer-dock"]') as HTMLElement | null;
expect(dock?.className).toContain("w-full");
expect(dock?.className).toContain("max-w-(--tc-shell-max-w)");
@@ -4319,26 +4346,22 @@ describe("TaskChatThread Paperclip Runner queue", () => {
});
});
describe("TaskChatThread mobile composer dock (PAP-495)", () => {
it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => {
describe("TaskChatThread mobile composer dock", () => {
it("scrolls messages independently of the composer on mobile", () => {
sidebarState.isMobile = true;
renderThread({
comments: [],
comments: createLongThreadComments(),
onAdd: async () => {},
draftKey: "task-chat-draft:issue-mobile",
});
const dock = container
.querySelector('[data-testid="mock-editor"]')
?.closest("div.sticky") as HTMLElement | null;
const dock = container.querySelector('[data-testid="task-chat-composer-dock"]');
const scroller = container.querySelector('[data-testid="task-chat-scroller"]');
expect(dock).not.toBeNull();
// Bottom offset comes from --tc-composer-bottom (Layout raises it to the nav
// height while the nav is on screen) — NOT the raw safe-area dock, which is
// what let the nav occlude the action row before PAP-495.
expect(dock?.className).toContain("bottom-(--tc-composer-bottom)");
expect(dock?.className).not.toContain("bottom-(--sz-calc-8)");
expect(scroller).not.toBeNull();
expect(scroller?.textContent).toContain("Thread message 1");
expect(scroller?.contains(dock)).toBe(false);
});
});
+17 -19
View File
@@ -110,7 +110,6 @@ import {
useRunnerGoalControl,
} from "@/components/task-chat/RunnerGoalWidget";
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
import { TaskChatWindowScroll } from "@/components/task-chat/useWindowAutoFollow";
import { useSidebar } from "@/context/SidebarContext";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
import { useDismissedTaskQuestions } from "@/hooks/useDismissedTaskQuestions";
@@ -139,6 +138,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { TaskChatPresentationProvider } from "@/components/task-chat/presentation-mode";
const EMPTY_BROWSERS: TaskBrowser[] = [];
const COMPACT_RUN_LOG_READ_LIMIT_BYTES = 32_000;
function toMs(value: Date | string | null | undefined): number {
if (!value) return 0;
@@ -425,6 +425,7 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread> & {
creationActivity?: ActivityEvent[];
compactHistory?: boolean;
historyControl?: ReactNode;
onExpandHistory?: () => void;
initialHistoryPending?: boolean;
initialHistoryError?: boolean;
onRetryInitialHistory?: () => void;
@@ -967,9 +968,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
transcriptByRun: logTranscriptByRun,
isInitialHydrating: logsAreInitiallyHydrating,
hydratedRunIds: hydratedLogRunIds,
truncatedRunIds,
errorsByRun: logErrorsByRun,
retry: retryLogs,
} = useLiveRunTranscripts({ runs: logRuns, companyId, ...(props.compactHistory ? { logReadLimitBytes: 32_000 } : {}) });
} = useLiveRunTranscripts({ runs: logRuns, companyId, ...(props.compactHistory ? { logReadLimitBytes: COMPACT_RUN_LOG_READ_LIMIT_BYTES } : {}) });
const fallbackByRunRef = useRef(
new Map<string, NonNullable<ReturnType<typeof logTranscriptByRun.get>>>(),
);
@@ -2825,9 +2827,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
],
);
const historyControl = props.historyControl ?? (
props.compactHistory && props.onExpandHistory
&& logRuns.some((run) => truncatedRunIds?.has(run.id))
? <Button type="button" variant="ghost" size="sm" onClick={props.onExpandHistory}>More run output</Button>
: null
);
const renderBrief = useCallback(
() => issueBrief ? <><TaskChatDescriptionBubble brief={issueBrief} />{props.historyControl}</> : null,
[issueBrief, props.historyControl],
() => issueBrief ? <><TaskChatDescriptionBubble brief={issueBrief} />{historyControl}</> : null,
[issueBrief, historyControl],
);
const assignedAgentForNotice = useMemo(() => {
@@ -3040,25 +3048,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<TaskChatRequestHistoryContext.Provider value={issueId ?? "unknown"}>
<TaskChatExpansionState.Provider value={expansionState.current}>
<TaskChatScrollReady.Provider value={!historyPending || historyWaitExpired}>
<TaskChatWindowScroll
contentKey={isMobile ? autoFollowContentKey : 0}
enabled={isMobile && historyRevealed}
/>
<TaskChatPresentationProvider
mode={streamlinedUiEnabled ? "streamlined" : "production"}
>
<div
className={cn(
"flex flex-col",
!isMobile && "min-h-0 flex-1",
"flex min-h-0 flex-1 flex-col",
isMobile && !historyRevealed && "task-chat-history-pending",
)}
data-testid="task-chat-thread"
>
<div
className={cn(
"relative flex flex-col",
(!isMobile || !historyRevealed) && "min-h-0 flex-1",
"relative flex min-h-0 flex-1 flex-col",
)}
aria-busy={!historyRevealed}
>
@@ -3093,8 +3095,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<div
data-testid="task-chat-history-content"
className={cn(
"flex flex-col",
!isMobile && "min-h-0 flex-1",
"flex min-h-0 flex-1 flex-col",
!historyRevealed && "invisible",
)}
inert={!historyRevealed}
@@ -3103,9 +3104,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<>
{items.length === 0 && !tailRunId && !optimisticRunnerStartup ? (
<div
className={
isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"
}
className="min-h-0 flex-1 overflow-y-auto"
>
{threadHeaderWithBlockers ? (
<div
@@ -3137,7 +3136,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<TaskChatThreadView
items={items}
attachments={attachments}
header={<>{threadHeaderWithBlockers}{!issueBrief ? props.historyControl : null}</>}
header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}</>}
renderInteraction={renderInteraction}
renderBrief={renderBrief}
renderMessageActions={renderMessageActions}
@@ -3241,7 +3240,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}
contentKey={`${autoFollowContentKey}:${historyRevealed}`}
className={isMobile ? undefined : "pt-3"}
scroll={!isMobile}
/>
)}
</>
@@ -17,7 +17,6 @@ vi.mock("./AiConnectionCredentialStep", () => ({
return <div>Provider sign-in</div>;
},
}));
vi.mock("./AiConnectionManagement", () => ({ AiConnectionLegacyNotice: () => null }));
vi.mock("./AiProviderSetup", () => ({ AiProviderSetup: () => <div>Advanced provider setup</div> }));
vi.mock("@/pages/apps/AppLogo", () => ({ AppLogo: () => null }));
@@ -82,6 +81,33 @@ it("reconnects the unavailable personal default in place", async () => {
expect(onChange).toHaveBeenCalledWith({ provider: "anthropic", method: "subscription", mode: "responsible_user" });
});
it("identifies duplicate-named accounts before adopting the exact personal grant", async () => {
const personal = account({ id: "00000000-0000-4000-8000-000000000001", grantId: "00000000-0000-4000-8000-000000000002", provider: "openai", method: "api_key", name: "OpenAI", ownerName: "Avery", status: "connected", isDefault: false });
const shared = account({ id: "00000000-0000-4000-8000-000000000003", grantId: "00000000-0000-4000-8000-000000000004", provider: "openai", method: "subscription", name: "OpenAI", accountLabel: "engineering@example.test", ownership: "shared", ownerUserId: undefined, status: "connected", isDefault: false });
const sharedKey = account({ id: "00000000-0000-4000-8000-000000000005", grantId: "00000000-0000-4000-8000-000000000006", provider: "openai", method: "api_key", name: "OpenAI", ownerName: "Engineering", ownership: "shared", ownerUserId: undefined, status: "connected", isDefault: false });
const unavailable = account({ id: "expired-account", grantId: "expired-grant", provider: "openai", method: "api_key", name: "Expired account", ownerName: "Avery" });
mocks.list.mockResolvedValue({ currentUserId: "owner", connections: [shared, personal, sharedKey, unavailable], canManageConnections: true });
flushSync(() => root.render(<QueryClientProvider client={client}>
<AiConnectionField companyId="company" agentId="agent" agentName="Nova" adapterType="codex_local" legacy onChange={onChange} />
</QueryClientProvider>));
await settle();
await click("Choose a managed connection");
const trigger = container.querySelector('[role="combobox"][aria-label="Connection"]')!;
flushSync(() => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
await settle();
const choices = [...document.querySelectorAll<HTMLElement>('[role="option"]')];
expect(choices.map(choice => choice.textContent)).toContain("OpenAI · API key · Personal · Avery");
expect(choices.map(choice => choice.textContent)).toContain("OpenAI · ChatGPT subscription · Company shared · engineering@example.test");
expect(choices.map(choice => choice.textContent)).toContain("OpenAI · API key · Company shared · Engineering");
expect(choices.find(choice => choice.textContent?.includes("Expired account"))?.getAttribute("aria-disabled")).toBe("true");
flushSync(() => choices.find(choice => choice.textContent === "OpenAI · API key · Personal · Avery")!.click());
await settle();
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("OpenAI · API key · Personal · Avery");
expect(onChange).not.toHaveBeenCalled();
await click("Use this binding when saved");
expect(onChange).toHaveBeenCalledExactlyOnceWith({ provider: "openai", method: "api_key", mode: "delegated", connectionId: personal.id, grantId: personal.grantId });
});
it("opens provider setup directly when connecting from the advanced mode", async () => {
await mount([], true, true);
await click("Connect another account");
@@ -163,7 +189,7 @@ it("offers Pi account selection before choosing its required model", async () =>
});
it.each(["openai", "anthropic", "google", "openrouter"] as const)("keeps a matching saved Pi %s API-key account selectable", async provider => {
const saved = account({ provider, method: "api_key", name: "Pi provider account", status: "connected" });
const saved = account({ provider, method: "api_key", name: "Pi provider account", accountLabel: "avery@example.test", status: "connected" });
mocks.list.mockResolvedValue({ currentUserId: "owner", connections: [saved], canManageConnections: true });
flushSync(() => root.render(<QueryClientProvider client={client}>
<AiConnectionField companyId="company" agentId="agent" agentName="Pi" adapterType="pi_local" model={`${provider}/provider-model`}
@@ -174,6 +200,7 @@ it.each(["openai", "anthropic", "google", "openrouter"] as const)("keeps a match
expect(container.textContent).not.toContain("incompatible");
expect(container.textContent).not.toContain("does not support");
const trigger = container.querySelector('[role="combobox"][aria-label="Connection"]')!;
expect(trigger.getAttribute("title")).toBe("Pi provider account · API key · Personal · avery@example.test");
flushSync(() => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
await settle();
const choice = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(node => node.textContent?.includes("Pi provider account"));
@@ -14,6 +14,7 @@ import { AiConnectionSelect } from "./AiConnectionSelect";
import { AiProviderSetup } from "./AiProviderSetup";
import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep";
import { AiConnectionLegacyNotice } from "./AiConnectionManagement";
import { aiConnectionDisplayLabel } from "./model";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -89,6 +90,11 @@ export function AiConnectionField({
enabled: Boolean(provider),
});
const personalDefault = accounts.data?.connections.find((account) => account.provider === provider && account.isDefault && account.ownership === "personal" && account.ownerUserId === accounts.data.currentUserId);
const pendingAccount = pendingAdoption?.mode === "responsible_user"
? personalDefault
: pendingAdoption && pendingAdoption.mode !== "router"
? accounts.data?.connections.find(account => account.id === pendingAdoption.connectionId && account.grantId === pendingAdoption.grantId)
: undefined;
const selectDefault = useMutation({
mutationFn: async (result: NonNullable<typeof savedAccount>) => {
// Reconnect retains the existing default and its access. A new account
@@ -177,10 +183,8 @@ export function AiConnectionField({
</DialogHeader>
{pendingAdoption?.mode !== "router" && <p className="text-sm">
{pendingAdoption?.mode === "responsible_user"
? `Responsible user’s default. For you: ${accounts.data?.connections.find((account) => account.isDefault && account.provider === provider)?.name ?? "Not connected"}. Other users use their own default.`
: accounts.data?.connections.find(
(account) => account.id === pendingAdoption?.connectionId,
)?.name}
? `Responsible user’s default. For you: ${pendingAccount ? aiConnectionDisplayLabel(pendingAccount) : "Not connected"}. Other users use their own default.`
: pendingAccount && aiConnectionDisplayLabel(pendingAccount)}
</p>}
{pendingAdoption?.mode !== "router" && <p className="text-xs text-muted-foreground">
After adoption, missing credentials block execution. Previous
@@ -14,15 +14,15 @@ import {
} from "@/components/ui/select";
import { AppLogo } from "@/pages/apps/AppLogo";
import { Button } from "@/components/ui/button";
import { aiConnectionProblem, personalAiDefault } from "./model";
import { aiConnectionDisplayLabel, aiConnectionProblem, personalAiDefault } from "./model";
import type { AiConnectionPickerProps } from "./AiConnectionPicker";
import type { AiConnectionSummary } from "./model";
function ConnectionLabel({ connection, label }: { connection: AiConnectionSummary; label?: string }) {
function ConnectionLabel({ connection, label, wrap = false }: { connection: AiConnectionSummary; label?: string; wrap?: boolean }) {
return (
<span className="flex min-w-0 items-center gap-2">
<AppLogo name={connection.name} brandKey={aiConnectionCatalogSlug(connection.provider, connection.routing)} size={20} compact />
<span className="truncate">{label ?? connection.name}</span>
<span className={wrap ? "min-w-0 break-words whitespace-normal" : "truncate"}>{label ?? aiConnectionDisplayLabel(connection)}</span>
</span>
);
}
@@ -119,7 +119,7 @@ export function AiConnectionSelect({
onValueChange={change}
disabled={readOnly || loading || Boolean(error)}
>
<SelectTrigger id={selectId} aria-label="Connection" className="w-full">
<SelectTrigger id={selectId} aria-label="Connection" title={selected ? aiConnectionDisplayLabel(selected) : undefined} className="w-full">
<SelectValue
placeholder={
loading ? "Loading connections…" : "Choose a connection"
@@ -135,7 +135,7 @@ export function AiConnectionSelect({
<SelectContent>
{incompatible && selected && (
<SelectItem value={selected.id} disabled>
<ConnectionLabel connection={selected} label={`${selected.name} · Incompatible`} />
<ConnectionLabel connection={selected} label={`${aiConnectionDisplayLabel(selected)} · Incompatible`} wrap />
</SelectItem>
)}
{compatible.map((c) => (
@@ -144,7 +144,7 @@ export function AiConnectionSelect({
value={c.id}
disabled={Boolean(aiConnectionProblem(c))}
>
<ConnectionLabel connection={c} label={`${c.name}${c.status !== "connected" ? " · Reconnect" : ""}`} />
<ConnectionLabel connection={c} label={`${aiConnectionDisplayLabel(c)}${c.status !== "connected" ? " · Reconnect" : ""}`} wrap />
</SelectItem>
))}
{(canUseDefault || value?.mode === "responsible_user") && (
+10
View File
@@ -57,6 +57,16 @@ export function aiMethodLabel(provider: AiProvider, method: AiAuthMethod) {
: provider === "github" ? "Personal access token" : "API key";
}
export function aiConnectionDisplayLabel(connection: AiConnectionSummary) {
const personal = connection.ownership === "personal";
return [
connection.name,
aiMethodLabel(connection.provider, connection.method),
personal ? "Personal" : "Company shared",
connection.accountLabel || connection.ownerName,
].filter(Boolean).join(" · ");
}
export function matchesAiRequirement(
connection: AiConnectionSummary,
requirement: AiConnectionRequirement,
@@ -856,7 +856,7 @@ describe("TaskChatComposer", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" mobile />);
expect(editable().dataset.contentClassName).toContain("min-h-(--sz-48px)");
expect(editable().dataset.contentClassName).toContain("max-h-(--sz-28dvh)");
expect(editable().dataset.contentClassName).toContain("max-h-(--tc-composer-editor-max-height)");
});
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more