mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
Merge origin/master and preserve monitor wait regressions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
7fe2417e40
77 files changed
+3682
-402
No files matched your search
@@ -153,7 +153,7 @@ results in place; keep failures actionable inline. Notifications for other work
|
||||
remain useful. Repeated delivery of the same run outcome must refresh cached state
|
||||
without repeating its toast, including after reconnecting. A terminal outcome
|
||||
delivered more than five minutes after the run finished is historical and should
|
||||
refresh state silently. Expected cancellation is neutral gray, not an error. The composer's Stop action stops the current response and leaves the composer available for a new message. Pause work is a separate explicit task or subtree action. A paused task replaces the composer with an amber takeover. It says “Task is
|
||||
refresh state silently. Expected cancellation is neutral gray, not an error. A busy workspace is a transient system wait with no user action. Show it only as a quiet status in the composer strip and the Workspace property row, never as a pinned banner, conversation notice or toast. The composer's Stop action stops the current response and leaves the composer available for a new message. Pause work is a separate explicit task or subtree action. A paused task replaces the composer with an amber takeover. It says “Task is
|
||||
paused.” and “Resume this task to send a message.” with a “Resume task” action.
|
||||
Subtrees use “Subtree is paused.” and “Resume subtree.” The takeover cannot be
|
||||
dismissed, retains drafts, and hides message inputs until the pause is released.
|
||||
|
||||
@@ -1919,6 +1919,37 @@ that the whole web server is disposable or that all execution and recovery paths
|
||||
survive web-server replacement. Run-log/trace storage and producer retention are
|
||||
separate changes.
|
||||
|
||||
### Managed environments
|
||||
|
||||
The `environments` list in `PAPERCLIP_MANAGED_CONFIG` declares instance-level
|
||||
environments that boot provisions and tenants cannot edit. It holds at most one
|
||||
sandbox entry (no `driver`, or `driver: "sandbox"`) and any number of
|
||||
`driver: "plugin"` entries. A plugin entry names a plugin by its manifest id
|
||||
(`pluginKey`) and one of its environment drivers:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Remote Host",
|
||||
"description": "Runs the agent on a remote host",
|
||||
"driver": "plugin",
|
||||
"pluginKey": "example.remote-host-plugin",
|
||||
"driverKey": "tasks",
|
||||
"driverConfig": {}
|
||||
}
|
||||
```
|
||||
|
||||
Names and `pluginKey`–`driverKey` pairs must be unique, and `driverConfig` must
|
||||
not contain secret-like keys. Any other key refuses startup. `plugins.autoInstall`
|
||||
holds catalog keys, not manifest ids, so boot also refuses to start unless an
|
||||
auto-installed bundled or distribution catalog entry installs the entry's
|
||||
`pluginKey`.
|
||||
|
||||
Boot validates the driver config through the plugin, marks the row
|
||||
`managedPluginEnvironment: "<pluginKey>:<driverKey>"`, and adopts an existing
|
||||
unmarked row for the same plugin and driver. While the plugin is down, the row is
|
||||
archived; it comes back when the worker is ready. Removing an entry never archives
|
||||
its row. Plugin entries do not change the instance default.
|
||||
|
||||
### Experimental native remote workspace storage
|
||||
|
||||
`experimental.remoteWorkspaceStorageEnabled` is a managed feature, on by default
|
||||
|
||||
@@ -13,6 +13,13 @@ unattributed operation in the same shared workspace does not remove its wait.
|
||||
Normal readiness, duplicate-wake, interaction, pause, and execution gates still
|
||||
apply. A board transition into blocked keeps the existing recovery behavior.
|
||||
|
||||
A dependency completion can arrive while the assignee answers a question about
|
||||
the blocked task. Its saved wake waits behind that turn. Deferred-receipt recovery
|
||||
reconstructs these dependency wakes after the turn and its cleanup finish, even
|
||||
when they contain no comment or interaction response. It uses the existing
|
||||
release and dispatch checks; task cancellation, agent pause, and pending cleanup
|
||||
still prevent execution.
|
||||
|
||||
Cancelling a run or pausing its agent suppresses immediate stranded-work recovery.
|
||||
A queued user message remains a separate continuation request. The release path
|
||||
still preserves independently authorized deferred input; stopping the old run
|
||||
|
||||
@@ -2415,8 +2415,11 @@ run, release its environment, or start another provider. Lease expiry alone does
|
||||
not authorize that stale preparation to take ownership; normal recovery must
|
||||
establish a new claim first.
|
||||
|
||||
An exact local Codex restart can restore the conversation after losing its active
|
||||
turn. Runnerd records this as `provider_turn_lost_on_restore`, with no invented
|
||||
An exact local Codex, OpenCode, or ACPX restart can restore the conversation after
|
||||
losing its active turn. ACPX first requires the original provider lifetime to lose
|
||||
its fence quorum; without that proof, or with an accepted result, unanswered
|
||||
runtime request, or active goal, it closes the session as before. Cursor Cloud
|
||||
agents run remotely and are excluded. Runnerd records this as `provider_turn_lost_on_restore`, with no invented
|
||||
task result. The admitted restart attempt may send one continuation in that same
|
||||
conversation. It preserves the workspace and asks the agent to reconcile unfinished
|
||||
commands and external actions before proceeding. It does not resend the task or
|
||||
@@ -3630,6 +3633,34 @@ re-read dependency readiness, since new dependencies need not change the
|
||||
displayed task status. Old blocked rows without a receipt remain held; no
|
||||
historical status backfill is performed.
|
||||
|
||||
Older native executors recorded the local callback-listener startup guard as
|
||||
`native_session_interrupted`. A saved exact guard outcome can enter the same
|
||||
bounded safe-replacement path after restart. This requires the first attempt,
|
||||
an unused provider-session identity, matching saved execution bindings, no
|
||||
provider events, checkpoint, admission receipt, tool call, or cancellation, and
|
||||
the existing current snapshot and confirmed stop receipts. A generic error or
|
||||
a pending provider-pack receipt alone grants no recovery authority. The same
|
||||
transaction can retire that run's completed file-preservation hold only when
|
||||
its bound recovery and finalization receipts prove completion. Unrelated holds,
|
||||
later task blocks, normal dispatch admission, and the three-attempt budget remain
|
||||
effective.
|
||||
|
||||
Historical replacement discovery reads at most 100 candidates per pass and
|
||||
keeps a cursor only for the remaining scan. A work notification preserves forward
|
||||
progress and requests a final rescan, so held earlier rows cannot hide a later
|
||||
eligible run. Completed scans of held history become quiet. Final snapshot
|
||||
publication, verified lease cleanup, local process-stop proof, completed workspace
|
||||
recovery, and dispatch eligibility changes notify the existing replacement worker
|
||||
in their transactions. A cleared historical interaction only requests another
|
||||
proof check; it grants no new continuation authority. Source provider effects,
|
||||
task pause or cancellation, and the original authority checks still prevent replay.
|
||||
Temporary decision failures retain the existing worker's retry and advance the
|
||||
bounded cursor, with a forced rescan of earlier failures. A failed page cannot
|
||||
hide later ready runs. During drain, the initialized replacement worker can read
|
||||
readiness once per notification generation to discharge an empty notification
|
||||
without starting replacement. Concrete pending work and changes arriving during
|
||||
that read remain outstanding. Restart reconstructs the scan from durable records.
|
||||
|
||||
For legacy GitHub input without a saved App ID or its own response receipt,
|
||||
Task Retry may prove the endpoint's earlier non-null App claim from a processed
|
||||
response ledger created before that input. The proof stays within the same
|
||||
|
||||
+12
-1
@@ -348,13 +348,24 @@ that ledger records `remote_execution_never_claimed` with a
|
||||
`bootstrap_incomplete` disposition. The provider attempt remains zero during
|
||||
classification; the executor increments it before starting provider work.
|
||||
|
||||
For a local Codex turn lost while runnerd was stopped, the canonical `turn.failed`
|
||||
For a local Codex, OpenCode, or ACPX turn lost while runnerd was stopped, the canonical `turn.failed`
|
||||
event retains `error.code: "provider_turn_lost_on_restore"` and
|
||||
`error.recoverable: true`. It records the interrupted turn, not a final task
|
||||
outcome. There is no synthesized result for that interruption. An admitted
|
||||
same-session continuation emits its own `turn.submitted`, accepted turn ID, and
|
||||
terminal events; only the final outcome closes the run. Reconstructed thread
|
||||
history retains the original error so reconciliation and live delivery agree.
|
||||
An ACPX session records this only after the original provider lifetime loses
|
||||
its fence quorum and the same conversation is restored. Otherwise its
|
||||
`turn.failed` carries `error.code: "acpx_active_turn_recovery_closed"` with
|
||||
`error.recoverable: false`, and the run closes with an honest restart message.
|
||||
After an ACPX restore, the controller can still deliver results for semantic
|
||||
calls of the lost turn. Runnerd acknowledges each one as `retired` without
|
||||
sending it to the restored provider, so the calls settle and live semantic
|
||||
dispatch resumes for the continuation. A restored ACPX provider is a new process
|
||||
whose MCP request ids restart, so its semantic `callId` values carry a
|
||||
per-process prefix (`r<hex>:<id>`). They never reuse an earlier call's id in
|
||||
the same run.
|
||||
|
||||
## Native Local Process Stop Evidence
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::generated_acpx_profiles::{
|
||||
acpx_release_profile, ACPX_DRIVER_KIND, QUALIFIED_ACPX_VERSION,
|
||||
};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
|
||||
use std::fs::{self, DirBuilder, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
@@ -70,6 +70,8 @@ pub const ACPX_PROVIDER_STATE_FILE: &str = "acpx-provider-state.json";
|
||||
const ACPX_PROVIDER_STATE_SCHEMA: &str = "paperclip.runner.acpx-provider-state.v3";
|
||||
const MAX_PROVIDER_STATE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const MAX_PENDING_EVENTS: usize = 8_320;
|
||||
// Matches the bounded number of pending ACPX tool calls.
|
||||
const MAX_OPEN_SEMANTIC_CALLS: usize = 4_096;
|
||||
const MAX_EVENTS_PER_POLL: usize = 128;
|
||||
const PROVIDER_LIFETIME_CONFIRMATION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PROVIDER_LIFETIME_CONFIRMATION_RETRY: Duration = Duration::from_millis(10);
|
||||
@@ -597,6 +599,13 @@ struct AcpxDurableState {
|
||||
pending_runtime_requests: BTreeMap<String, Value>,
|
||||
#[serde(default = "initial_event_sequence")]
|
||||
next_event_sequence: u64,
|
||||
/// Semantic tool calls emitted to the controller without a result yet.
|
||||
#[serde(default)]
|
||||
open_semantic_calls: BTreeSet<String>,
|
||||
/// Open calls of a turn lost to a runner restart. The restored provider
|
||||
/// never sees their results; the controller may still deliver them.
|
||||
#[serde(default)]
|
||||
retired_semantic_calls: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl AcpxDurableState {
|
||||
@@ -623,6 +632,8 @@ impl AcpxDurableState {
|
||||
pending_events: VecDeque::new(),
|
||||
pending_runtime_requests: BTreeMap::new(),
|
||||
next_event_sequence: initial_event_sequence(),
|
||||
open_semantic_calls: BTreeSet::new(),
|
||||
retired_semantic_calls: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,6 +800,31 @@ impl AcpxDurableState {
|
||||
if let Some(id) = event.payload.get("requestId").and_then(Value::as_str) {
|
||||
self.pending_runtime_requests.remove(id);
|
||||
}
|
||||
} else if let Some(call_id) = event
|
||||
.payload
|
||||
.pointer("/semantic_tool/callId")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
// Remember semantic calls the controller may still answer. A lost
|
||||
// turn's open calls become retired calls on restore, so a later
|
||||
// controller result for them settles instead of failing.
|
||||
match event.event_type.as_str() {
|
||||
"semantic_tool.input"
|
||||
if self.open_semantic_calls.len() < MAX_OPEN_SEMANTIC_CALLS =>
|
||||
{
|
||||
self.open_semantic_calls.insert(call_id.to_owned());
|
||||
}
|
||||
"semantic_tool.result" => {
|
||||
self.open_semantic_calls.remove(call_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if matches!(
|
||||
event.event_type.as_str(),
|
||||
"turn.completed" | "turn.failed" | "turn.cancelled" | "turn.interrupted"
|
||||
) {
|
||||
// A provider-observed terminal settles its calls in the live bridge.
|
||||
self.open_semantic_calls.clear();
|
||||
}
|
||||
self.next_event_sequence = next_sequence;
|
||||
self.pending_events.push_back(PolledEvent {
|
||||
@@ -1116,6 +1152,9 @@ impl AcpxCommandExecutor {
|
||||
let previous_turn = state.active_turn_id.clone();
|
||||
if unsafe_active {
|
||||
self.context.provider_turn_id = None;
|
||||
if self.restore_conversation_after_lost_turn(previous_turn.clone())? {
|
||||
return Ok(());
|
||||
}
|
||||
let state = self
|
||||
.state
|
||||
.as_mut()
|
||||
@@ -1134,6 +1173,13 @@ impl AcpxCommandExecutor {
|
||||
"providerTerminalObserved": false,
|
||||
"code": "acpx_active_turn_recovery_closed",
|
||||
"providerShutdownFailed": true,
|
||||
// Keep the cause through the controller facade, which
|
||||
// forwards only the structured error of a failed turn.
|
||||
"error": {
|
||||
"code": "acpx_active_turn_recovery_closed",
|
||||
"recoverable": false,
|
||||
"message": "The runner restarted during this turn and could not restore the conversation.",
|
||||
},
|
||||
}),
|
||||
})?;
|
||||
state.push(NormalizedProviderEvent {
|
||||
@@ -1171,6 +1217,82 @@ impl AcpxCommandExecutor {
|
||||
self.save_state()
|
||||
}
|
||||
|
||||
/// Restores the conversation of a turn that was active when the runner
|
||||
/// died, without inventing its outcome. The original provider lifetime
|
||||
/// must have lost its fence quorum first, so no second provider can act on
|
||||
/// the same session. The lost turn is recorded with a recoverable
|
||||
/// `provider_turn_lost_on_restore` cause and the run stays open, so the
|
||||
/// controller can send its single same-conversation continuation. Returns
|
||||
/// false, leaving state untouched, when restoration is not safe.
|
||||
fn restore_conversation_after_lost_turn(
|
||||
&mut self,
|
||||
previous_turn: Option<String>,
|
||||
) -> Result<bool, DurableRunnerError> {
|
||||
let Some(state) = self.state.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// An accepted result, an unanswered runtime request, or an active goal
|
||||
// needs its own reconciliation; a plain continuation cannot settle it.
|
||||
let Some(identity) = state.identity.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
if previous_turn.is_none()
|
||||
|| state.provider_exit_unconfirmed
|
||||
|| state.semantic_result.is_some()
|
||||
|| !state.pending_runtime_requests.is_empty()
|
||||
|| !(state.goal_projection.is_null() || goal_is_dormant(&state.goal_projection))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
match await_provider_lifetime_fence(identity.provider_lifetime_fence_candidates) {
|
||||
// Release the quorum so the restored provider can inherit it.
|
||||
Ok(listeners) => drop(listeners),
|
||||
Err(_) => return Ok(false),
|
||||
}
|
||||
let Ok(session) = self.start_session(true) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let identity = session.identity().clone();
|
||||
let process_id = session.process_id();
|
||||
let turn_controls = session.turn_control_capabilities();
|
||||
let state = self
|
||||
.state
|
||||
.as_mut()
|
||||
.expect("ACPX state remains available during recovery");
|
||||
state.lifecycle = "session_open".to_owned();
|
||||
state.active_turn_id = None;
|
||||
state.turn_controls = turn_controls;
|
||||
// The controller may still answer the lost turn's semantic calls.
|
||||
// The restored provider never issued them, so settle them on arrival.
|
||||
let open = std::mem::take(&mut state.open_semantic_calls);
|
||||
state.retired_semantic_calls.extend(open);
|
||||
state.push(NormalizedProviderEvent {
|
||||
event_type: "session.resumed".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: session_event_payload(&state.descriptor, &identity, process_id, turn_controls),
|
||||
})?;
|
||||
state.push(NormalizedProviderEvent {
|
||||
event_type: "turn.failed".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "acpx",
|
||||
"providerTurnId": previous_turn,
|
||||
"status": "failed",
|
||||
"providerTerminalObserved": false,
|
||||
"code": "provider_turn_lost_on_restore",
|
||||
"error": {
|
||||
"code": "provider_turn_lost_on_restore",
|
||||
"recoverable": true,
|
||||
"message": "The runner restored the conversation after process loss, but the previous turn is no longer active.",
|
||||
},
|
||||
}),
|
||||
})?;
|
||||
self.recovery_pending_effects = None;
|
||||
self.session = Some(session);
|
||||
self.save_state()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn start_session(&self, recovering: bool) -> Result<AcpxProviderSession, DurableRunnerError> {
|
||||
let state = self
|
||||
.state
|
||||
@@ -2123,6 +2245,20 @@ impl AcpxCommandExecutor {
|
||||
let result: ToolResult = serde_json::from_value(payload.clone()).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("semantic tool result is invalid: {error}"))
|
||||
})?;
|
||||
if let Some(state) = self.state.as_ref() {
|
||||
if state.retired_semantic_calls.contains(&result.call_id) {
|
||||
// The call belonged to a turn lost to a runner restart. Its
|
||||
// provider process is gone; acknowledge the controller result
|
||||
// so the call settles, without sending it to the new provider.
|
||||
// The retained receipt makes this idempotent: a delivery left
|
||||
// indeterminate by a crash replays to the same acknowledgement.
|
||||
return Ok(CommandExecution::result(json!({
|
||||
"status": "delivered",
|
||||
"callId": result.call_id,
|
||||
"retired": true,
|
||||
})));
|
||||
}
|
||||
}
|
||||
self.session
|
||||
.as_mut()
|
||||
.ok_or_else(|| DurableRunnerError::invalid("ACPX session is unavailable"))?
|
||||
@@ -2521,6 +2657,18 @@ fn goal_terminal_disposition<'a>(
|
||||
}
|
||||
|
||||
impl CommandExecutor for AcpxCommandExecutor {
|
||||
/// A restored session keeps durable receipts for the lost turn's calls.
|
||||
/// Acknowledging one is idempotent and never reaches the provider or the
|
||||
/// business operation, so an indeterminate delivery may replay. Live
|
||||
/// calls have no durable receipt; without retired receipts this stays off.
|
||||
fn can_reconcile_result_delivery(&mut self) -> Result<bool, DurableRunnerError> {
|
||||
self.restore()?;
|
||||
Ok(self
|
||||
.state
|
||||
.as_ref()
|
||||
.is_some_and(|state| !state.retired_semantic_calls.is_empty()))
|
||||
}
|
||||
|
||||
fn host_park_ready(&mut self) -> Result<bool, DurableRunnerError> {
|
||||
self.ensure_persistence_healthy()?;
|
||||
let Some(state) = self.state.as_ref() else {
|
||||
@@ -5020,6 +5168,14 @@ mod tests {
|
||||
assert_eq!(events[0].payload["replayAllowed"], false);
|
||||
assert_eq!(events[1].event_type, "turn.failed");
|
||||
assert_eq!(events[1].payload["providerShutdownFailed"], true);
|
||||
// An unanswered runtime request (and an unproven original lifetime)
|
||||
// keeps the close path. Its cause survives the controller facade and
|
||||
// is not offered for the restart continuation.
|
||||
assert_eq!(
|
||||
events[1].payload["error"]["code"],
|
||||
"acpx_active_turn_recovery_closed"
|
||||
);
|
||||
assert_eq!(events[1].payload["error"]["recoverable"], false);
|
||||
assert_eq!(events[2].event_type, "run.terminal");
|
||||
assert_eq!(events[2].payload["schema"], "paperclip.prp.terminal.v1");
|
||||
assert_eq!(events[2].payload["turnTerminalState"], "failed");
|
||||
@@ -5049,6 +5205,74 @@ mod tests {
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_turn_restoration_refuses_unreconciled_state_before_touching_the_provider() {
|
||||
let directory = temporary_directory("lost-turn-guards");
|
||||
let provider_descriptor: AcpxProviderDescriptor =
|
||||
serde_json::from_value(descriptor("claude")).unwrap();
|
||||
let operations = Vec::new();
|
||||
let tool_set = AuthorizedToolSet {
|
||||
schema: TOOL_SET_SCHEMA.to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
};
|
||||
let identity = AcpxProviderSessionIdentity {
|
||||
kind: "acpx".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
acpx_record_id: "record-1".to_owned(),
|
||||
backend_session_id: "backend-1".to_owned(),
|
||||
agent_session_id: "agent-1".to_owned(),
|
||||
profile_digest: provider_descriptor.command_digest.clone(),
|
||||
workspace_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
requested_model: provider_descriptor.model.clone(),
|
||||
effective_model: provider_descriptor.model.clone(),
|
||||
permission_mode: Some(provider_descriptor.permission_mode),
|
||||
mode: provider_descriptor.mode.clone(),
|
||||
pi_thinking_level: provider_descriptor.pi_thinking_level,
|
||||
claude_effort: provider_descriptor.claude_effort.clone(),
|
||||
grok_effort: provider_descriptor.grok_effort.clone(),
|
||||
grok_disable_web_search: provider_descriptor.grok_disable_web_search.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let guards: [(&str, fn(&mut AcpxDurableState)); 4] = [
|
||||
("provider_exit_unconfirmed", |state| {
|
||||
state.provider_exit_unconfirmed = true
|
||||
}),
|
||||
("accepted_semantic_result", |state| {
|
||||
state.semantic_result = Some(json!({"reportedWorkDisposition": "done"}))
|
||||
}),
|
||||
("unanswered_runtime_request", |state| {
|
||||
state
|
||||
.pending_runtime_requests
|
||||
.insert("input-1".to_owned(), pending_input_request("input-1"));
|
||||
}),
|
||||
("active_goal", |state| {
|
||||
state.goal_projection = json!({"goal": {"status": "active"}})
|
||||
}),
|
||||
];
|
||||
for (label, seed) in guards {
|
||||
let mut state =
|
||||
AcpxDurableState::new(provider_descriptor.clone(), tool_set.clone(), "d".into());
|
||||
state.lifecycle = "turn_active".to_owned();
|
||||
state.identity = Some(identity.clone());
|
||||
state.active_turn_id = Some("turn-1".to_owned());
|
||||
seed(&mut state);
|
||||
let mut executor =
|
||||
AcpxCommandExecutor::with_runner_config(&directory, &test_config(&directory, None));
|
||||
executor.state = Some(state.clone());
|
||||
assert!(
|
||||
!executor
|
||||
.restore_conversation_after_lost_turn(Some("turn-1".to_owned()))
|
||||
.unwrap(),
|
||||
"{label} must keep the close path"
|
||||
);
|
||||
assert!(executor.session.is_none(), "{label} started a provider");
|
||||
assert_eq!(executor.state, Some(state), "{label} changed saved state");
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspension_waits_for_the_original_provider_lifetime_quorum() {
|
||||
let (candidates, original_lifetime_fence) = reserve_provider_lifetime_fence();
|
||||
|
||||
@@ -421,6 +421,14 @@ fn semantic_result_event(
|
||||
}
|
||||
}
|
||||
|
||||
/// Local Codex app-server and OpenCode server restart their own process and
|
||||
/// restore the same conversation after runner loss, so a turn that vanished
|
||||
/// meanwhile is a restart interruption the controller may continue once. A
|
||||
/// Cursor Cloud agent runs remotely; its lost turn is not a process restart.
|
||||
fn restart_lost_turn_is_continuable(provider: &str) -> bool {
|
||||
matches!(provider, "codex" | "opencode")
|
||||
}
|
||||
|
||||
fn facade_provider_name(provider: &str) -> &'static str {
|
||||
match provider {
|
||||
"opencode" => "OpenCode",
|
||||
@@ -2693,6 +2701,7 @@ impl CodexCommandExecutor {
|
||||
// trustworthy success notification to replay. Terminate it
|
||||
// conservatively so the controller cannot wait forever or
|
||||
// mistake an unknown outcome for success.
|
||||
let restart_continuable = restart_lost_turn_is_continuable(&provider_label);
|
||||
let provider_terminal = NormalizedProviderEvent {
|
||||
event_type: "turn.failed".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
@@ -2701,14 +2710,14 @@ impl CodexCommandExecutor {
|
||||
"providerTurnId": previous_active_turn_id,
|
||||
"status": "failed",
|
||||
"providerTerminalObserved": false,
|
||||
"error": if provider_label == "codex" { json!({
|
||||
"error": if restart_continuable { json!({
|
||||
"code": "provider_turn_lost_on_restore",
|
||||
"recoverable": true,
|
||||
"message": "The runner restored the conversation after process loss, but the previous turn is no longer active.",
|
||||
}) } else { Value::Null },
|
||||
}),
|
||||
};
|
||||
if provider_label == "codex" && !state.active_terminal_tool_rejected {
|
||||
if restart_continuable && !state.active_terminal_tool_rejected {
|
||||
// No provider result was observed. Preserve the lost
|
||||
// turn as a fact, but leave the run open for the
|
||||
// controller's bounded same-conversation recovery.
|
||||
@@ -5700,6 +5709,13 @@ impl CommandExecutor for CodexCommandExecutor {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn restart_lost_turns_continue_only_for_locally_restored_providers() {
|
||||
assert!(super::restart_lost_turn_is_continuable("codex"));
|
||||
assert!(super::restart_lost_turn_is_continuable("opencode"));
|
||||
assert!(!super::restart_lost_turn_is_continuable("cursor_cloud"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_permission_requests_use_durable_turn_and_item_identity() {
|
||||
let identity = ProviderEventIdentity {
|
||||
|
||||
@@ -166,3 +166,168 @@ fn checkpoints_the_sidecar_and_rebinds_consecutive_warm_runs_before_accepting_wo
|
||||
assert_eq!(started.result["status"], "accepted");
|
||||
executor.shutdown().unwrap();
|
||||
}
|
||||
|
||||
fn unused_fence_ports() -> [u16; 3] {
|
||||
// Lifetime fence candidates must be dynamic ports (49152 and above). An
|
||||
// OS-assigned port can be lower, for example on Linux.
|
||||
let mut listeners = Vec::new();
|
||||
for port in (49_152..=u16::MAX).rev() {
|
||||
if let Ok(listener) = std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
listeners.push(listener);
|
||||
if listeners.len() == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ports = listeners
|
||||
.iter()
|
||||
.map(|listener| listener.local_addr().unwrap().port())
|
||||
.collect::<Vec<_>>();
|
||||
[ports[0], ports[1], ports[2]]
|
||||
}
|
||||
|
||||
/// A runner that dies during an active ACPX turn (for example, when the
|
||||
/// control plane restarts) must restore the same conversation once the old
|
||||
/// provider lifetime is proven gone. It records a recoverable restart-loss
|
||||
/// cause instead of a terminal provider failure.
|
||||
#[test]
|
||||
fn restores_the_conversation_after_losing_an_active_turn_to_a_runner_restart() {
|
||||
let fixture =
|
||||
Fixture(std::env::temp_dir().join(format!("acpx-restart-{}", uuid::Uuid::new_v4())));
|
||||
fs::create_dir_all(&fixture.0).unwrap();
|
||||
fs::set_permissions(&fixture.0, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let sidecar = fixture.0.join("sidecar");
|
||||
fs::copy(env!("CARGO_BIN_EXE_fake-acpx-sidecar"), &sidecar).unwrap();
|
||||
fs::set_permissions(&sidecar, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let ports = unused_fence_ports();
|
||||
let args = vec![
|
||||
"--mode".to_owned(),
|
||||
"suspend".to_owned(),
|
||||
"--profile-digest".to_owned(),
|
||||
PROFILE_DIGEST.to_owned(),
|
||||
"--lifetime-fence-ports".to_owned(),
|
||||
format!("{},{},{}", ports[0], ports[1], ports[2]),
|
||||
];
|
||||
let config = DurableRunnerConfig {
|
||||
connect_url: "ws://127.0.0.1/runner".to_owned(),
|
||||
ca_bundle_path: None,
|
||||
state_dir: fixture.0.clone(),
|
||||
runner_instance_id: "runner-1".to_owned(),
|
||||
environment_lease_id: "lease-1".to_owned(),
|
||||
run_id: "run-1".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
turn_id: "turn-1".to_owned(),
|
||||
item_id: "item-1".to_owned(),
|
||||
runner_version: "0.0.0".to_owned(),
|
||||
runner_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
acpx_launch_profile: Some(AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: sidecar.clone(),
|
||||
args: args.clone(),
|
||||
artifacts: vec![QualifiedLaunchArtifact {
|
||||
path: sidecar.clone(),
|
||||
sha256: format!("sha256:{:x}", Sha256::digest(fs::read(&sidecar).unwrap())),
|
||||
}],
|
||||
}),
|
||||
opencode_launch_profile: None,
|
||||
cursor_cloud_launch_profile: None,
|
||||
max_outbox_bytes: 1024 * 1024,
|
||||
p0_reserve_bytes: 64 * 1024,
|
||||
max_frame_bytes: 1024 * 1024,
|
||||
reconnect_delay: Duration::from_millis(1),
|
||||
reconnect_grace: None,
|
||||
max_runtime: Duration::from_secs(30),
|
||||
};
|
||||
let descriptor = json!({
|
||||
"kind": "acpx", "provider": "acpx", "driver": "acpx_runtime",
|
||||
"providerVersion": "0.13.1", "agent": "codex", "model": "gpt-5.6-sol",
|
||||
"acpxVersion": "0.13.1", "agentServerPackage": "@agentclientprotocol/codex-acp",
|
||||
"agentServerVersion": "1.6.2", "agentRuntimePackage": "@openai/codex",
|
||||
"agentRuntimeVersion": "0.160.0", "commandDigest": PROFILE_DIGEST,
|
||||
"sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0,
|
||||
"normalizedSessionId": "session-1", "runId": "run-1", "cwd": fixture.0,
|
||||
"instructions": "Complete the supplied work.",
|
||||
"permissionMode": "approve-all", "permissionModePinned": true,
|
||||
});
|
||||
let mut original = AcpxCommandExecutor::with_runner_config(&fixture.0, &config);
|
||||
original
|
||||
.execute(&command(1, "run.prepare", json!({"provider": descriptor})))
|
||||
.unwrap();
|
||||
let opened = original
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
let provider_session = opened.result["providerSessionId"].clone();
|
||||
let started = original
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"turnId": "provider-turn-1", "text": "Do the work."}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(started.result["status"], "accepted");
|
||||
// The runner process dies with the turn still active, after the turn sent
|
||||
// the controller a semantic call that has no result yet.
|
||||
drop(original);
|
||||
let state_path = fixture.0.join("acpx-provider-state.json");
|
||||
let mut saved: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap();
|
||||
saved["openSemanticCalls"] = json!(["call-lost"]);
|
||||
fs::write(&state_path, serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
|
||||
let mut restored = AcpxCommandExecutor::with_runner_config(&fixture.0, &config);
|
||||
let snapshot = restored
|
||||
.execute(&command(4, "session.snapshot", json!({})))
|
||||
.unwrap();
|
||||
assert_ne!(snapshot.result["status"], "closed");
|
||||
assert_eq!(snapshot.result["providerSessionId"], provider_session);
|
||||
let events = restored.poll_events().unwrap();
|
||||
let failed = events
|
||||
.iter()
|
||||
.find(|event| event.event_type == "turn.failed")
|
||||
.expect("the lost turn is terminated");
|
||||
assert_eq!(failed.payload["providerTurnId"], "provider-turn-1");
|
||||
assert_eq!(failed.payload["providerTerminalObserved"], false);
|
||||
assert_eq!(
|
||||
failed.payload["error"]["code"],
|
||||
"provider_turn_lost_on_restore"
|
||||
);
|
||||
assert_eq!(failed.payload["error"]["recoverable"], true);
|
||||
// The run stays open for the controller's single same-conversation continuation.
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|event| event.event_type == "run.terminal"));
|
||||
// The controller can still answer the lost call. That result settles the
|
||||
// call instead of failing against the restored provider, which never saw it.
|
||||
let lost_result = json!({
|
||||
"callId": "call-lost", "operationId": "register_deliverable",
|
||||
"result": {"ok": true}, "isError": false,
|
||||
});
|
||||
let settled = restored
|
||||
.execute(&command(5, "semantic_tool.result", lost_result.clone()))
|
||||
.unwrap();
|
||||
assert_eq!(settled.result["retired"], true);
|
||||
// The receipt is durable and idempotent, so a delivery left indeterminate
|
||||
// by a crash may replay to the same acknowledgement.
|
||||
assert!(restored.can_reconcile_result_delivery().unwrap());
|
||||
let replayed = restored
|
||||
.execute(&command(6, "semantic_tool.result", lost_result))
|
||||
.unwrap();
|
||||
assert_eq!(replayed.result["retired"], true);
|
||||
// Calls the lost turn never opened still fail closed.
|
||||
let unknown = json!({
|
||||
"callId": "call-unknown", "operationId": "register_deliverable",
|
||||
"result": {"ok": true}, "isError": false,
|
||||
});
|
||||
assert!(restored
|
||||
.execute(&command(7, "semantic_tool.result", unknown))
|
||||
.is_err());
|
||||
let continued = restored
|
||||
.execute(&command(
|
||||
8,
|
||||
"turn.start",
|
||||
json!({"turnId": "provider-turn-2", "text": "Continue."}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(continued.result["status"], "accepted");
|
||||
restored.shutdown().unwrap();
|
||||
}
|
||||
+104
-5
@@ -7,7 +7,7 @@ use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use paperclip_runner_core::durable::{
|
||||
AcpxLaunchProfile, Command, CommandExecutor, CursorCloudLaunchProfile, DurableRunnerConfig,
|
||||
OpenCodeLaunchProfile, QualifiedLaunchArtifact, PROVIDER_STATE_INCOMPATIBLE,
|
||||
OpenCodeLaunchProfile, PolledEvent, QualifiedLaunchArtifact, PROVIDER_STATE_INCOMPATIBLE,
|
||||
};
|
||||
use paperclip_runner_core::native_provider_backend::NativeProviderCommandExecutor;
|
||||
use paperclip_runner_core::provider_bridge::{authorized_tool_catalog_digest, AuthorizedTool};
|
||||
@@ -579,6 +579,30 @@ fn acpx_runtime_resolution(mode: &str, request_id: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// A turn lost to a runner restart is recorded once as a recoverable loss.
|
||||
fn assert_restart_lost_turn(events: &[PolledEvent]) {
|
||||
let lost = events
|
||||
.iter()
|
||||
.filter(|event| event.event_type == "turn.failed")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(lost.len(), 1);
|
||||
assert_eq!(
|
||||
lost[0].payload["error"]["code"],
|
||||
"provider_turn_lost_on_restore"
|
||||
);
|
||||
assert_eq!(lost[0].payload["error"]["recoverable"], true);
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|event| event.event_type == "run.terminal"));
|
||||
}
|
||||
|
||||
/// Restarting the restored, idle session again replays no turn or request.
|
||||
fn assert_no_replayed_turn_or_request(events: &[PolledEvent]) {
|
||||
assert!(events
|
||||
.iter()
|
||||
.all(|event| event.event_type == "session.resumed"));
|
||||
}
|
||||
|
||||
fn shutdown_recovered_acpx_fixture(executor: &mut NativeProviderCommandExecutor, directory: &Path) {
|
||||
// The fake sidecar advertises the same three lifetime-fence ports for all
|
||||
// fixtures. Parallel recovered shutdowns hold a quorum through state fsync,
|
||||
@@ -679,20 +703,23 @@ fn acpx_response_delivery_survives_crash_before_journaling_without_replaying_the
|
||||
.execute(&command(6, "request.resolve", resolve.payload))
|
||||
.is_err());
|
||||
assert_eq!(recovered.poll_events().unwrap(), events);
|
||||
// The lost turn's conversation is restored for one continuation. Its
|
||||
// outcome is a recoverable restart loss; the response is not replayed.
|
||||
assert_eq!(
|
||||
recovered
|
||||
.execute(&command(7, "session.snapshot", json!({})))
|
||||
.unwrap()
|
||||
.result["status"],
|
||||
"closed"
|
||||
"session_open"
|
||||
);
|
||||
assert_restart_lost_turn(&events);
|
||||
// Polling is not an ACK; only the journal's ACK retires the event.
|
||||
recovered.acknowledge_events(events.len()).unwrap();
|
||||
assert!(recovered.poll_events().unwrap().is_empty());
|
||||
drop(recovered);
|
||||
let mut recovered_again =
|
||||
NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
assert!(recovered_again.poll_events().unwrap().is_empty());
|
||||
assert_no_replayed_turn_or_request(&recovered_again.poll_events().unwrap());
|
||||
shutdown_recovered_acpx_fixture(&mut recovered_again, &directory);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
@@ -864,14 +891,15 @@ fn acpx_failed_settlement_ack_replays_the_same_delivered_event_without_expiry()
|
||||
.execute(&command(6, "request.resolve", response))
|
||||
.is_err());
|
||||
assert_eq!(recovered.poll_events().unwrap(), events);
|
||||
assert_restart_lost_turn(&events);
|
||||
recovered.acknowledge_events(events.len()).unwrap();
|
||||
assert!(recovered.poll_events().unwrap().is_empty());
|
||||
shutdown_recovered_acpx_fixture(&mut recovered, &directory);
|
||||
drop(recovered);
|
||||
let mut recovered_again =
|
||||
NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
assert!(recovered_again.poll_events().unwrap().is_empty());
|
||||
recovered_again.shutdown().unwrap();
|
||||
assert_no_replayed_turn_or_request(&recovered_again.poll_events().unwrap());
|
||||
shutdown_recovered_acpx_fixture(&mut recovered_again, &directory);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2278,6 +2306,77 @@ fn rejects_a_mutable_opencode_command_outside_the_runner_launch_profile() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn restart_lost_opencode_turn_records_a_recoverable_cause() {
|
||||
// A local OpenCode server restarts with runnerd, like Codex app-server:
|
||||
// the lost turn keeps its recoverable cause and the run stays open for
|
||||
// the controller's single same-conversation continuation.
|
||||
let directory = temporary_directory("opencode-restart-lost-turn");
|
||||
let mut config = opencode_config(&directory);
|
||||
let proxy_script = directory.join("qualified-opencode-proxy-script");
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(&proxy_script, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::write(
|
||||
&proxy_script,
|
||||
format!(
|
||||
"#!/bin/sh\nexec '{}' --state-file '{}' --call-log '{}' --require-completion-contract --durable-turn-ids --linger-after-turn-start\n",
|
||||
env!("CARGO_BIN_EXE_fake-codex-app-server"),
|
||||
directory.join("fake-opencode-state.json").display(),
|
||||
directory.join("fake-opencode-calls.log").display(),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(&proxy_script, fs::Permissions::from_mode(0o500)).unwrap();
|
||||
config
|
||||
.opencode_launch_profile
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.proxy_script = qualified_artifact(proxy_script);
|
||||
let mut first = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
first
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
opencode_prepare_payload(&directory),
|
||||
))
|
||||
.unwrap();
|
||||
first
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
first
|
||||
.execute(&command(3, "turn.start", json!({"text": "Edit the file."})))
|
||||
.unwrap();
|
||||
drop(first);
|
||||
let fake_state = directory.join("fake-opencode-state.json");
|
||||
let mut saved: Value = serde_json::from_slice(&fs::read(&fake_state).unwrap()).unwrap();
|
||||
saved["activeTurnId"] = Value::Null;
|
||||
fs::write(&fake_state, serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
|
||||
let mut restored = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
restored
|
||||
.execute(&command(4, "session.snapshot", json!({})))
|
||||
.unwrap();
|
||||
let events = restored.poll_events().unwrap();
|
||||
let lost = events
|
||||
.iter()
|
||||
.find(|event| event.event_type == "turn.failed")
|
||||
.expect("the lost turn is terminated");
|
||||
assert_eq!(
|
||||
lost.payload["error"]["code"],
|
||||
"provider_turn_lost_on_restore"
|
||||
);
|
||||
assert_eq!(lost.payload["error"]["recoverable"], true);
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|event| event.event_type == "run.terminal"));
|
||||
assert_eq!(opencode_call_count(&directory, "turn/start"), 1);
|
||||
restored.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn rejects_opencode_launch_profile_drift_across_fresh_recovery() {
|
||||
let directory = temporary_directory("opencode-profile-recovery");
|
||||
let config = opencode_config(&directory);
|
||||
|
||||
@@ -467,6 +467,30 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a restored provider's restarted call ids distinct from earlier calls in the run", async () => {
|
||||
// A restored session starts a new provider process whose MCP request ids
|
||||
// restart. The controller keys calls by run and call id, so a reused id
|
||||
// would be mistaken for an earlier, already answered call.
|
||||
const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } };
|
||||
const tools = new Map<string, any>(), emitted: any[] = [];
|
||||
const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, callIdNamespace: "rgeneration1" });
|
||||
const commands = loadActualToolCommands(tools);
|
||||
const bridge = await startRunnerToolBridge({ handler, captureSemanticReceipt: () => () => {} });
|
||||
try {
|
||||
const response = fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }) });
|
||||
await vi.waitFor(() => expect(tools.has("rgeneration1:2")).toBe(true));
|
||||
expect(emitted).toEqual([expect.objectContaining({ callId: "rgeneration1:2", operationId: "paperclip_finish" })]);
|
||||
await commands.resolve({ callId: "rgeneration1:2", turnId: "test-turn", result: { accepted: true }, error: null });
|
||||
const body = await (await response).json() as ToolCallResponseBody;
|
||||
expect(body.result.isError).not.toBe(true);
|
||||
} finally {
|
||||
for (const pending of tools.values()) pending.cleanup();
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([["monitor", true], ["monitor", false], ["same_agent", true], ["same_agent", false]] as const)("resolves an omitted continuation before the production sidecar validator with saved %s authority=%s", async (kind, eligible) => {
|
||||
const raw = { reportedWorkDisposition: "yielded", summary: "The review fix is published.", evidence: [], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: false, criteria: [], remainingWork: [{ description: "Wait for checks.", blocksCompletion: true }] } };
|
||||
@@ -1752,6 +1776,7 @@ function loadWaitForTool(input: {
|
||||
emitted: unknown[];
|
||||
validate?: typeof validatePrpStructuredRunResult;
|
||||
emit?: (eventType: string, payload: unknown) => boolean;
|
||||
callIdNamespace?: string | null;
|
||||
}): (call: RunnerToolCall) => Promise<unknown> {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)),
|
||||
@@ -1764,7 +1789,7 @@ function loadWaitForTool(input: {
|
||||
const factory = new Function(
|
||||
"boundedIdentity", "tools", "turnId", "emit", "PRP_COMPLETION_TOOL_NAME",
|
||||
"PRP_BLOCK_TOOL_NAME", "validatePrpStructuredRunResult", "boundedSidecarValue", "record", "MAX_PENDING_TOOLS", "stringifyAcpxSidecarFrame",
|
||||
"resolveCompletionContinuation", "runId", "normalizingTools", "randomUUID",
|
||||
"resolveCompletionContinuation", "runId", "normalizingTools", "randomUUID", "callIdNamespace",
|
||||
`return (${functionSource});`,
|
||||
);
|
||||
return factory(
|
||||
@@ -1786,6 +1811,7 @@ function loadWaitForTool(input: {
|
||||
"test-run",
|
||||
new Set<string>(),
|
||||
randomUUID,
|
||||
input.callIdNamespace ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,12 @@ let openParams: AcpxSidecarOpenParams | null = null;
|
||||
let claudeCompactionCount = 0;
|
||||
let runId: string | null = null;
|
||||
let turnId: string | null = null;
|
||||
/**
|
||||
* A restored session runs in a new provider process whose MCP request ids
|
||||
* restart. The controller keys semantic calls by run and call id, so prefix
|
||||
* this process's ids to keep them distinct from calls earlier in the run.
|
||||
*/
|
||||
let callIdNamespace: string | null = null;
|
||||
let activeCopilotEvidence: CopilotToolEvidence | undefined;
|
||||
let sequence = 0;
|
||||
let requestSequence = 0;
|
||||
@@ -374,6 +380,9 @@ async function dispatch(
|
||||
);
|
||||
host = openedHost;
|
||||
openParams = params;
|
||||
callIdNamespace = params.expectedIdentity === undefined
|
||||
? null
|
||||
: `r${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
emit("runtime.process", {
|
||||
role: "sidecar",
|
||||
pid: process.pid,
|
||||
@@ -821,7 +830,10 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
if (!activeTurnId || !activeRunId || call.signal.aborted) {
|
||||
throw new Error("ACPX tool call is not bound to an active turn");
|
||||
}
|
||||
const callId = boundedIdentity(call.callId, "callId");
|
||||
const providerCallId = boundedIdentity(call.callId, "callId");
|
||||
const callId = callIdNamespace === null
|
||||
? providerCallId
|
||||
: boundedIdentity(`${callIdNamespace}:${providerCallId}`, "callId");
|
||||
if (tools.has(callId) || normalizingTools.has(callId)) throw new Error("ACPX tool call is duplicated");
|
||||
const operationId = boundedIdentity(call.tool, "operationId");
|
||||
if (tools.size + normalizingTools.size >= MAX_PENDING_TOOLS) {
|
||||
|
||||
@@ -73,6 +73,7 @@ it.each([["codex", undefined], ["acpx", "claude"]])("routes %s/%s with only its
|
||||
expect(config).toContain('env_key = "PAPERCLIP_AI_PROVIDER_KEY"');
|
||||
expect(config).toContain("requires_openai_auth = false");
|
||||
expect(config).toContain("shell_snapshot = false");
|
||||
expect(config).toContain("image_generation = false");
|
||||
expect(config).toContain('web_search = "disabled"');
|
||||
expect(config).not.toContain(token);
|
||||
expect((await stat(join(env.CODEX_HOME!, "config.toml"))).mode & 0o777).toBe(0o600);
|
||||
|
||||
@@ -76,7 +76,9 @@ export async function prepareEvalGatewayRuntime(
|
||||
'name = "Paperclip eval controller"',
|
||||
`base_url = ${JSON.stringify(descriptor.baseUrl + "/v1")}`,
|
||||
'wire_api = "responses"', 'requires_openai_auth = false', 'env_key = "PAPERCLIP_AI_PROVIDER_KEY"',
|
||||
'[features]', 'shell_snapshot = false', '',
|
||||
// The controller gateway admits no hosted tools, so qualified evals keep
|
||||
// Codex's hosted image tool off even though product runs enable it.
|
||||
'[features]', 'shell_snapshot = false', 'image_generation = false', '',
|
||||
].join("\n"), { mode: 0o600, flag: "wx" });
|
||||
} else {
|
||||
Object.assign(environment, {
|
||||
|
||||
@@ -95,11 +95,15 @@ export function isNativeRestartInterruption(error: unknown): boolean {
|
||||
return failure.code === NATIVE_RESTART_INTERRUPTION_CODE && failure.recoverable === true;
|
||||
}
|
||||
|
||||
const RESTART_CONTINUATION_DRIVER_KINDS: ReadonlySet<string> = new Set(["codex_app_server", "acpx_runtime", "opencode_server"]);
|
||||
|
||||
/** The terminal fingerprint survives a crash between interruption and continuation. */
|
||||
export function nativeRestartInterruptedTurnId(snapshot: Pick<PersistedNativeSession,
|
||||
"driverKind" | "activeTurnId" | "semanticResult" | "terminalTurns" | "dispositionOnlyRecoveryConsumed" | "pendingRuntimeRequests" | "goal"
|
||||
>): string | null {
|
||||
if (snapshot.driverKind !== "codex_app_server" || snapshot.activeTurnId != null ||
|
||||
// Runnerd records this cause for Codex app-server and ACPX sessions after
|
||||
// proving the old provider lifetime ended and restoring the conversation.
|
||||
if (!RESTART_CONTINUATION_DRIVER_KINDS.has(snapshot.driverKind ?? "") || snapshot.activeTurnId != null ||
|
||||
snapshot.semanticResult != null || snapshot.dispositionOnlyRecoveryConsumed ||
|
||||
snapshot.goal != null || snapshot.pendingRuntimeRequests?.length) return null;
|
||||
const terminal = Array.isArray(snapshot.terminalTurns) ? snapshot.terminalTurns.at(-1) : null;
|
||||
|
||||
-3
@@ -129,7 +129,6 @@ describe("Codex app-server Codex driver", () => {
|
||||
"skills.include_instructions": false,
|
||||
include_apps_instructions: false,
|
||||
include_collaboration_mode_instructions: true,
|
||||
"features.image_generation": false,
|
||||
},
|
||||
permissions: "paperclip-runner-workspace-only",
|
||||
runtimeWorkspaceRoots: [WORKSPACE],
|
||||
@@ -160,8 +159,6 @@ describe("Codex app-server Codex driver", () => {
|
||||
expect.stringContaining(
|
||||
'shell_environment_policy.set={PATH="/bin",LANG="C.UTF-8"}',
|
||||
),
|
||||
"--disable",
|
||||
"image_generation",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { resolvePinnedCodexCommand } from "./codex-command.js";
|
||||
import { evalProviderTransportOptions } from "../../cli/eval-provider-runtime.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -358,10 +360,59 @@ describe("Codex security configuration", () => {
|
||||
"features.plugins": false,
|
||||
"features.multi_agent": false,
|
||||
"features.memories": false,
|
||||
"features.image_generation": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("N-12: lets an API-key Codex agent generate images with its existing provider key instead of asking for another", () => {
|
||||
// Codex API path: the agent's OpenAI key reaches only the trusted
|
||||
// app-server process. Codex's built-in image tool uses that provider auth;
|
||||
// with it disabled the agent falls back to an OPENAI_API_KEY script that
|
||||
// the sandboxed shell can never see, so it asks the user for a second key.
|
||||
const source = {
|
||||
HOME: "/host/home", CODEX_HOME: "/host/codex", PATH: "/safe/bin",
|
||||
OPENAI_API_KEY: "agent-provider-key",
|
||||
};
|
||||
const args = createIsolatedCodexAppServerArgs(source);
|
||||
const serialized = args.join("\n");
|
||||
expect(args).not.toContain("image_generation");
|
||||
for (const mode of ["default", "plan"] as const) {
|
||||
expect(createSecuredCodexThreadParams("/workspace", mode, true, false, source).config)
|
||||
.not.toHaveProperty("features.image_generation");
|
||||
}
|
||||
// The tool saves results under CODEX_HOME/generated_images; the agent must
|
||||
// be able to read (and so copy) them into the workspace, nothing else there.
|
||||
for (const profile of ["paperclip-runner-workspace-only", "paperclip-runner-workspace-read-only"]) {
|
||||
const rules = args.find((arg) => arg.startsWith(`permissions.${profile}.filesystem=`))!;
|
||||
expect(rules).toContain('"/host/codex"="none"');
|
||||
expect(rules).toContain('"/host/codex/generated_images"="read"');
|
||||
}
|
||||
// The existing key is reused by the provider, never exposed to the shell.
|
||||
expect(serialized).not.toContain("OPENAI_API_KEY");
|
||||
expect(serialized).not.toContain("agent-provider-key");
|
||||
});
|
||||
|
||||
it("N-12: grants generated images in preconfigured mode only when CODEX_HOME is outside HOME", () => {
|
||||
const inside = createIsolatedCodexAppServerArgs({ HOME: "/users/executor", CODEX_HOME: "/users/executor/.codex", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" }).join("\n");
|
||||
expect(inside).not.toContain("generated_images");
|
||||
const outside = createIsolatedCodexAppServerArgs({ HOME: "/users/executor", CODEX_HOME: "/var/lib/codex-home", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" }).join("\n");
|
||||
expect(outside.match(/"\/var\/lib\/codex-home\/generated_images"="read"/g)).toHaveLength(2);
|
||||
expect(outside).not.toContain('"/var/lib/codex-home"=');
|
||||
});
|
||||
|
||||
it("N-12: the pinned Codex binary keeps built-in image generation enabled under the isolated launch", () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), "paperclip-n12-codex-home-"));
|
||||
try {
|
||||
const args = createIsolatedCodexAppServerArgs({ PATH: process.env.PATH, HOME: codexHome, CODEX_HOME: codexHome });
|
||||
const output = execFileSync(resolvePinnedCodexCommand(), [...args.slice(0, -1), "features", "list"], {
|
||||
env: { PATH: process.env.PATH, HOME: codexHome, CODEX_HOME: codexHome },
|
||||
encoding: "utf8", timeout: 30_000, maxBuffer: 256 * 1024,
|
||||
});
|
||||
expect(output.split("\n").find((line) => /^image_generation\s/.test(line))).toMatch(/\strue\s*$/);
|
||||
} finally {
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps automatic execution inside the workspace without host credentials and with normal network access", () => {
|
||||
const args = createIsolatedCodexAppServerArgs(
|
||||
{
|
||||
|
||||
@@ -190,7 +190,10 @@ const SKILLLESS_BASE_CONFIG = {
|
||||
"features.plugins": false,
|
||||
"features.multi_agent": false,
|
||||
"features.memories": false,
|
||||
"features.image_generation": false,
|
||||
// Image generation stays on: Codex's built-in tool runs in the trusted
|
||||
// app-server with the agent's existing provider auth. Disabling it makes the
|
||||
// agent fall back to an OPENAI_API_KEY script the sandboxed shell cannot see,
|
||||
// so it asks the user for a second key (N-12).
|
||||
} as const;
|
||||
|
||||
export function codexCommandEnvironment(
|
||||
@@ -288,6 +291,16 @@ export function createIsolatedCodexAppServerArgs(
|
||||
.map((value) => resolve(value)),
|
||||
),
|
||||
];
|
||||
// Codex saves built-in image results under CODEX_HOME/generated_images and
|
||||
// tells the agent to copy them into the workspace. Expose only that folder.
|
||||
// A preconfigured HOME is already granted, so add the rule only when the
|
||||
// Codex home sits outside it (never narrow an existing HOME write grant).
|
||||
const generatedImagesRoot = typeof source.CODEX_HOME === "string" && source.CODEX_HOME.trim()
|
||||
? join(resolve(source.CODEX_HOME), "generated_images") : undefined;
|
||||
const preconfiguredHome = usesPreconfiguredHome(source) && source.HOME ? resolve(source.HOME) : undefined;
|
||||
const generatedImageRoots = generatedImagesRoot
|
||||
&& !(preconfiguredHome && codexPathInside(preconfiguredHome, generatedImagesRoot))
|
||||
? [generatedImagesRoot] : [];
|
||||
const filesystemRules = [
|
||||
`":root"="none"`,
|
||||
`":minimal"="read"`,
|
||||
@@ -296,6 +309,7 @@ export function createIsolatedCodexAppServerArgs(
|
||||
...protectedPaths.map((path) => `${tomlString(resolve(path))}="none"`),
|
||||
...(usesPreconfiguredHome(source) && source.HOME ? [`${tomlString(source.HOME)}="write"`] : []),
|
||||
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
|
||||
...generatedImageRoots.map((path) => `${tomlString(path)}="read"`),
|
||||
...gitRoots.read.map((path) => `${tomlString(path)}="read"`),
|
||||
...gitRoots.write.map((path) => `${tomlString(path)}="write"`),
|
||||
...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="write"`] : []),
|
||||
@@ -311,6 +325,7 @@ export function createIsolatedCodexAppServerArgs(
|
||||
...protectedPaths.map((path) => `${tomlString(resolve(path))}="none"`),
|
||||
...(usesPreconfiguredHome(source) && source.HOME ? [`${tomlString(source.HOME)}="read"`] : []),
|
||||
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
|
||||
...generatedImageRoots.map((path) => `${tomlString(path)}="read"`),
|
||||
...[...gitRoots.read, ...gitRoots.write].map((path) => `${tomlString(path)}="read"`),
|
||||
...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="read"`] : []),
|
||||
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
|
||||
@@ -358,8 +373,6 @@ export function createIsolatedCodexAppServerArgs(
|
||||
// as the turn plan (`turn/plan/updated` -> `plan.updated`).
|
||||
"-c",
|
||||
CODEX_UPDATE_PLAN_TOOL_CONFIG,
|
||||
"--disable",
|
||||
"image_generation",
|
||||
...(externalRunnerSandbox
|
||||
? ["--dangerously-bypass-approvals-and-sandbox"]
|
||||
: []),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
NativeSessionCloseUnrecoverableError,
|
||||
NativeSessionCleanupQuarantinedError,
|
||||
NativeSessionProtocolIntegrityError,
|
||||
nativeRestartInterruptedTurnId,
|
||||
} from "./contracts/native-session-backend.js";
|
||||
import { PROVIDER_STATE_INCOMPATIBLE } from "./contracts/harness-driver.js";
|
||||
import type {
|
||||
@@ -508,6 +509,71 @@ describe("executeNativeSession recovery", () => {
|
||||
expect(checkpoints.some(snapshot => snapshot.terminalTurns?.some(turn => turn.turnId === "turn-recovery"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["acpx_runtime", "opencode_server"] as const)("continues a restart-lost %s turn once in the same conversation", async (driverKind) => {
|
||||
const qualified = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5");
|
||||
const acpxInput: NativeExecutionInputV1 = (driverKind === "acpx_runtime" ? {
|
||||
...input,
|
||||
session: { ...input.session, driverKind },
|
||||
provider: { kind: "acpx", agent: "claude", model: "claude-sonnet-5", permissionPolicy: "interactive", profile: {
|
||||
driverKind: qualified.driverKind, protocolVersion: qualified.protocolVersion, acpxVersion: qualified.acpxVersion,
|
||||
agent: qualified.agent, agentProfileVersion: qualified.agentProfileVersion,
|
||||
agentServerPackage: qualified.agentServerPackage, agentServerVersion: qualified.agentServerVersion,
|
||||
agentRuntimePackage: qualified.agentRuntimePackage, agentRuntimeVersion: qualified.agentRuntimeVersion,
|
||||
commandDigest: qualified.commandDigest,
|
||||
} },
|
||||
} : {
|
||||
...input,
|
||||
session: { ...input.session, driverKind },
|
||||
provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
}) as NativeExecutionInputV1;
|
||||
const failure = { code: "provider_turn_lost_on_restore", recoverable: true };
|
||||
const checkpoint: PersistedNativeSession = {
|
||||
backendKind: "runner", driverKind, sessionId: "record-recovery", identity,
|
||||
providerSessionId: "provider-recovery", cursor: "0", activeTurnId: "turn-recovery",
|
||||
terminalTurns: [], semanticResult: null, pendingRuntimeRequests: [], lineage: [],
|
||||
};
|
||||
const interrupted: PersistedNativeSession = {
|
||||
...checkpoint, activeTurnId: null,
|
||||
terminalTurns: [{ turnId: "turn-recovery", fingerprint: JSON.stringify({ terminalState: "failed", error: failure, result: null }) }],
|
||||
};
|
||||
const startTurn = vi.fn<NativeSession["startTurn"]>(async () => ({ turnId: "turn-continuation" }));
|
||||
const session: NativeSession = {
|
||||
identity: () => identity,
|
||||
async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; },
|
||||
async *events() {
|
||||
yield runnerEvent(1, "turn.failed", { status: "failed", error: failure });
|
||||
if (!startTurn.mock.calls.length) return;
|
||||
yield { ...runnerEvent(2, "turn.completed", { status: "completed" }), turnId: "turn-continuation" };
|
||||
},
|
||||
startTurn,
|
||||
async result() { return startTurn.mock.calls.length ? { result, terminal, turnId: "turn-continuation" } : null; },
|
||||
async snapshot() { return structuredClone(interrupted); },
|
||||
async close() {},
|
||||
};
|
||||
const backend: NativeSessionBackend = {
|
||||
async descriptor() { return { kind: "runner", name: "restart", version: "1", capabilities: await session.capabilities() }; },
|
||||
async openSession() { throw new Error("must preserve the provider session"); },
|
||||
async recoverSession() { return { recovered: true, session }; },
|
||||
};
|
||||
const port: ControlPlanePort = {
|
||||
async openRun() {},
|
||||
async checkpointSession() {},
|
||||
async appendEvent(event: PrpEvent) { return { cursor: event.sourceSeq, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" }; },
|
||||
async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; },
|
||||
async completeRun() {},
|
||||
};
|
||||
await expect(executeNativeSession({
|
||||
input: acpxInput, backend, controlPlane: port, persistedSession: checkpoint,
|
||||
runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery",
|
||||
resumeInterruptedTurn: true,
|
||||
})).resolves.toMatchObject({ result, turnId: "turn-continuation" });
|
||||
expect(startTurn).toHaveBeenCalledOnce();
|
||||
const submitted = startTurn.mock.calls[0]![0] as Parameters<NativeSession["startTurn"]>[0];
|
||||
expect(submitted.continuation).toBe(true);
|
||||
expect(submitted.message.text).toContain("Reconcile any unfinished tool or command");
|
||||
expect(nativeRestartInterruptedTurnId(interrupted)).toBe("turn-recovery");
|
||||
});
|
||||
|
||||
it("never continues task work when a restart loses a compaction turn", async () => {
|
||||
const failure = { code: "provider_turn_lost_on_restore", recoverable: true };
|
||||
const checkpoint: PersistedNativeSession = {
|
||||
|
||||
@@ -2448,7 +2448,9 @@ export async function executeNativeSession(
|
||||
const resumeInterruptedTurn = async (event: PrpEvent, signal: AbortSignal) => {
|
||||
if (restartContinuationStarted || !recovered || !options.resumeInterruptedTurn ||
|
||||
// A compaction-only run never continues task work after a restart.
|
||||
input.provider.kind !== "codex" || options.sessionCompaction || options.sessionGoalControl || options.resumeSessionGoalHeartbeat ||
|
||||
// Runnerd records a restart-lost turn only for these providers, after
|
||||
// proving the old process ended and restoring the same conversation.
|
||||
!["codex", "acpx", "opencode"].includes(input.provider.kind) || options.sessionCompaction || options.sessionGoalControl || options.resumeSessionGoalHeartbeat ||
|
||||
recoveredSnapshot.goal || persistedSession?.semanticResult || persistedSession?.pendingRuntimeRequests?.length ||
|
||||
event.eventType !== "turn.failed" || !isNativeRestartInterruption(event.payload.error) ||
|
||||
event.turnId !== persistedSession?.activeTurnId) return false;
|
||||
|
||||
@@ -191,7 +191,8 @@ export const createToolConnectionSchema = z.object({
|
||||
status: toolConnectionStatusSchema.optional(),
|
||||
connectionKind: toolConnectionKindSchema.default("managed"),
|
||||
config: toolTransportConfigSchema.optional(),
|
||||
transportConfig: toolTransportConfigSchema.default({}),
|
||||
// No default: when the caller omits it, the service stores `config` here.
|
||||
transportConfig: toolTransportConfigSchema.optional(),
|
||||
credentialRefs: z.array(mcpConnectionCredentialRefSchema).optional(),
|
||||
credentialSecretRefs: z.array(toolCredentialSecretRefSchema).default([]),
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -15,7 +15,7 @@ import { issueTreeControlService } from "../services/issue-tree-control.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agents, agentWakeupRequests, authUsers, chatActions, environmentLeases, nativeRunFinalizations, chatEndpoints, toolApplications, toolConnections, companies, companyMemberships, costEvents, createDb, projects, reconcileDatabaseWork, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
|
||||
import { activityLog, agents, agentWakeupRequests, authUsers, chatActions, environmentLeases, nativeRunFinalizations, chatEndpoints, toolApplications, toolConnections, companies, companyMemberships, costEvents, createDb, projects, reconcileDatabaseWork, heartbeatRuns, issueComments, issueRelations, issues } from "@paperclipai/db";
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
@@ -158,6 +158,96 @@ describe("deferred receipt durable dispatch", () => {
|
||||
source: "on_demand", status: "completed", reason: "task_retry", payload: { issueId: f.issue.id } });
|
||||
await heartbeat.registerDeferredReceiptWork(coordinator, () => !drain).ready; await quiet();
|
||||
});
|
||||
async function strandedDependency() {
|
||||
const f = await fixture();
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issue.id,
|
||||
status: "succeeded", errorCode: null, contextSnapshot: { issueId: f.issue.id,
|
||||
wakeReason: "issue_commented", dependencyBlockedInteraction: true } }).where(eq(heartbeatRuns.id, f.source.id));
|
||||
await db.insert(nativeRunFinalizations).values({ companyId: f.companyId, issueId: f.issue.id,
|
||||
runId: f.source.id, phase: "committed" });
|
||||
await db.update(issues).set({ status: "blocked", executionRunId: null, checkoutRunId: null }).where(eq(issues.id, f.issue.id));
|
||||
const [blocker] = await db.insert(issues).values({ companyId: f.companyId, title: "Completed review",
|
||||
status: "done", completedAt: new Date() }).returning();
|
||||
await db.insert(issueRelations).values({ companyId: f.companyId, issueId: blocker.id,
|
||||
relatedIssueId: f.issue.id, type: "blocks" });
|
||||
const [intent] = await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agent.id,
|
||||
source: "automation", reason: "issue_blockers_resolved", status: "coalesced",
|
||||
requestedByActorType: "system", requestedByActorId: "native-status-committer",
|
||||
payload: { issueId: f.issue.id, resolvedBlockerIssueId: blocker.id },
|
||||
}).returning();
|
||||
const [receipt] = await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agent.id,
|
||||
source: "automation", reason: "issue_execution_deferred", status: "deferred_issue_execution",
|
||||
requestedByActorType: "system", requestedByActorId: `native-status-wake-dispatch:${intent.id}`,
|
||||
payload: { issueId: f.issue.id, resolvedBlockerIssueId: blocker.id, _paperclipWakeContext: {
|
||||
issueId: f.issue.id, wakeReason: "issue_blockers_resolved", statusDecisionSource: "native_status_decision",
|
||||
nativeStatusWakeIntentId: intent.id } },
|
||||
}).returning();
|
||||
return { ...f, receipt, blocker };
|
||||
}
|
||||
|
||||
it.each(["restart", "cleanup"] as const)("recovers a dependency wake after native audit %s into one continuation", async event => {
|
||||
const f = await strandedDependency();
|
||||
const { receipt } = f;
|
||||
const [lease] = event === "cleanup" ? await db.insert(environmentLeases).values({ companyId: f.companyId,
|
||||
heartbeatRunId: f.source.id, issueId: f.issue.id, provider: "daytona", providerLeaseId: "dependency-finalizing",
|
||||
status: "active", leasePolicy: "reuse_by_environment" }).returning() : [];
|
||||
await heartbeat.registerDeferredReceiptWork(coordinator, () => !drain).ready;
|
||||
await heartbeat.registerRunDispatchWork(coordinator, () => !drain).ready;
|
||||
if (lease) {
|
||||
await waitForReal(async () => {
|
||||
await Promise.allSettled([...passes]);
|
||||
expect(coordinator.nextWakeAt()).toBeNull(); expect(idleWorkSnapshot().active).toBe(0);
|
||||
}, { timeout: 10_000 });
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)))[0])
|
||||
.toMatchObject({ status: "deferred_issue_execution", runId: null });
|
||||
await environmentService(db).releaseLease(lease.id, "retained", { cleanupStatus: "success" });
|
||||
}
|
||||
await waitForReal(async () => {
|
||||
await Promise.allSettled([...passes]); await heartbeat.drainActiveRunExecutions();
|
||||
const [saved] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id));
|
||||
const pending = saved.runId ? (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, saved.runId)))[0] : null;
|
||||
expect(saved.status, JSON.stringify({ status: pending?.status, error: pending?.error, errors })).toBe("completed");
|
||||
expect(saved.runId).not.toBeNull();
|
||||
}, { timeout: 15_000 });
|
||||
await finish(f.issue.id);
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId));
|
||||
const dependencyRuns = runs.filter(run => run.contextSnapshot?.wakeReason === "issue_blockers_resolved");
|
||||
expect(dependencyRuns).toHaveLength(1);
|
||||
const [resumed] = dependencyRuns;
|
||||
expect(resumed).toMatchObject({ status: "succeeded", stdoutExcerpt: expect.stringContaining("receipt-proof"),
|
||||
contextSnapshot: { wakeReason: "issue_blockers_resolved" } });
|
||||
expect(resumed.contextSnapshot?.dependencyBlockedInteraction).not.toBe(true);
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
const after = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId));
|
||||
expect(after.filter(run => run.contextSnapshot?.wakeReason === "issue_blockers_resolved")).toHaveLength(1);
|
||||
expect(after.find(run => run.id === f.source.id)?.status).toBe("succeeded");
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issue.id)))[0])
|
||||
.toMatchObject({ status: "done", executionRunId: null, checkoutRunId: null });
|
||||
await quiet();
|
||||
});
|
||||
it.each(["paused", "cleanup", "cancelled"] as const)("keeps the %s gate when recovering a dependency wake", async gate => {
|
||||
const f = await strandedDependency();
|
||||
if (gate === "paused") await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agent.id));
|
||||
if (gate === "cancelled") await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issue.id));
|
||||
if (gate === "cleanup") await db.insert(environmentLeases).values({ companyId: f.companyId,
|
||||
heartbeatRunId: f.source.id, issueId: f.issue.id, provider: "daytona", providerLeaseId: "dependency-cleanup",
|
||||
status: "active", leasePolicy: "reuse_by_environment" });
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
const [saved] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, f.receipt.id));
|
||||
expect(saved).toMatchObject({ status: gate === "cancelled" ? "cancelled" : "deferred_issue_execution", runId: null });
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
|
||||
});
|
||||
it("checks unresolved blockers before a recovered dependency wake can execute", async () => {
|
||||
const f = await strandedDependency();
|
||||
await db.update(issues).set({ status: "blocked", completedAt: null }).where(eq(issues.id, f.blocker.id));
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, f.receipt.id));
|
||||
expect(receipt).toMatchObject({ status: "skipped", runId: expect.any(String) });
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, receipt.runId!));
|
||||
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_dependencies_blocked", stdoutExcerpt: null });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issue.id)))[0].status).toBe("blocked");
|
||||
});
|
||||
it.each([false, true])("reconsiders every saved receipt during repeated company eligibility changes (expired wait: %s)", async expiredWait => {
|
||||
const f = await fixture();
|
||||
const ids = Array.from({ length: 178 }, (_, index) => `${(index + 1).toString(16).padStart(8, "0")}-0000-4000-8000-000000000000`);
|
||||
|
||||
@@ -1022,6 +1022,20 @@ describe("environment routes", () => {
|
||||
|
||||
describe("managed plugin environment rows", () => {
|
||||
const PLUGIN_KEY = "example.remote-host-plugin";
|
||||
const MANAGED_CONFIG_WITH_PLUGIN_ENTRY = JSON.stringify({
|
||||
v: 1,
|
||||
mode: "cloud",
|
||||
catalogVersion: "2026.1106.0",
|
||||
features: {},
|
||||
plugins: { autoInstall: ["remote-host"] },
|
||||
environments: [{
|
||||
name: "Remote Host",
|
||||
driver: "plugin",
|
||||
pluginKey: PLUGIN_KEY,
|
||||
driverKey: "tasks",
|
||||
driverConfig: {},
|
||||
}],
|
||||
});
|
||||
const pluginRow = () => ({
|
||||
...createPlatformSandboxEnvironment(),
|
||||
id: "env-plugin-1",
|
||||
@@ -1058,20 +1072,46 @@ describe("environment routes", () => {
|
||||
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
||||
});
|
||||
|
||||
it("allows the marker clear while no managed-config entry can own the row", async () => {
|
||||
// Nothing provisions marked plugin rows yet, so a marker is stale and
|
||||
// stays recoverable. The managed-config plugin entry PR refuses this
|
||||
// while the document declares the row's entry.
|
||||
const row = pluginRow();
|
||||
mockEnvironmentService.getById.mockResolvedValue(row);
|
||||
mockEnvironmentService.update.mockResolvedValue({ ...row, metadata: {} });
|
||||
it("refuses a marker clear while the document declares the entry", async () => {
|
||||
process.env.PAPERCLIP_MANAGED_CONFIG = MANAGED_CONFIG_WITH_PLUGIN_ENTRY;
|
||||
try {
|
||||
mockEnvironmentService.getById.mockResolvedValue(pluginRow());
|
||||
const app = createApp(ownerAdminActor);
|
||||
|
||||
const res = await request(createApp(ownerAdminActor))
|
||||
.patch("/api/environments/env-plugin-1")
|
||||
.send({ metadata: { managedByPaperclip: null, managedPluginEnvironment: null } });
|
||||
for (const metadata of [
|
||||
{ managedByPaperclip: false },
|
||||
{ managedPluginEnvironment: null },
|
||||
{ managedByPaperclip: null, managedPluginEnvironment: null },
|
||||
]) {
|
||||
const res = await request(app).patch("/api/environments/env-plugin-1").send({ metadata });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
||||
}
|
||||
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
delete process.env.PAPERCLIP_MANAGED_CONFIG;
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEnvironmentService.update).toHaveBeenCalled();
|
||||
it("allows the marker clear once the entry left the document", async () => {
|
||||
process.env.PAPERCLIP_MANAGED_CONFIG = JSON.stringify({
|
||||
...JSON.parse(MANAGED_CONFIG_WITH_PLUGIN_ENTRY),
|
||||
environments: [],
|
||||
});
|
||||
try {
|
||||
const row = pluginRow();
|
||||
mockEnvironmentService.getById.mockResolvedValue(row);
|
||||
mockEnvironmentService.update.mockResolvedValue({ ...row, metadata: {} });
|
||||
|
||||
const res = await request(createApp(ownerAdminActor))
|
||||
.patch("/api/environments/env-plugin-1")
|
||||
.send({ metadata: { managedByPaperclip: null, managedPluginEnvironment: null } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEnvironmentService.update).toHaveBeenCalled();
|
||||
} finally {
|
||||
delete process.env.PAPERCLIP_MANAGED_CONFIG;
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a client that stamps the plugin marker on its own row", async () => {
|
||||
|
||||
@@ -61,6 +61,41 @@ describe("execution-control lifecycle", () => {
|
||||
expect(run).toHaveBeenCalledTimes(runs + 1);
|
||||
expect(c.nextWakeAt()).toBeNull();
|
||||
});
|
||||
it.each(["empty", "pending", "concurrent"] as const)("settles only proved-empty replacement notifications during drain (%s)", async kind => {
|
||||
vi.useFakeTimers(); const owner = fakeOwner(); let draining = false;
|
||||
const c = coordinator(owner, () => !draining);
|
||||
const run = vi.fn(async () => {}), next = vi.fn(async (): Promise<number | null> => null);
|
||||
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionReplacement, run, next,
|
||||
settleEmptyWhilePaused: true }]).ready;
|
||||
const before = idleWorkSnapshot().active;
|
||||
draining = true;
|
||||
let release: (() => void) | undefined;
|
||||
if (kind === "pending") next.mockResolvedValue(Date.now() + 15_000);
|
||||
if (kind === "concurrent") next.mockImplementationOnce(() => new Promise(resolve => {
|
||||
release = () => resolve(null);
|
||||
}));
|
||||
await owner.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.executionReplacement));
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
if (kind === "concurrent") {
|
||||
expect(release).toBeTypeOf("function");
|
||||
next.mockResolvedValue(Date.now() + 15_000);
|
||||
await owner.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.executionReplacement));
|
||||
release!(); await vi.advanceTimersByTimeAsync(1);
|
||||
}
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(idleWorkSnapshot().active).toBe(before + (kind === "empty" ? 0 : 1));
|
||||
const reads = next.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(next).toHaveBeenCalledTimes(reads);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
if (kind !== "empty") {
|
||||
draining = false; next.mockResolvedValue(null);
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
expect(idleWorkSnapshot().active).toBe(before);
|
||||
expect(c.nextWakeAt()).toBeNull();
|
||||
});
|
||||
it("keeps a change arriving during a sweep and does not overlap it", async () => {
|
||||
vi.useFakeTimers(); const owner = fakeOwner(); const c = coordinator(owner);
|
||||
let release!: () => void;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { join, resolve } from "node:path";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { completionContracts, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
|
||||
import { expect } from "vitest";
|
||||
import { createRunDispatch } from "../../modules/run-dispatch/index.js";
|
||||
import { prepareNativeHeartbeatRun } from "../../services/native-runtime/prepare-native-run.js";
|
||||
import { buildNativeExecutionInput } from "../../services/native-runtime/native-execution-input.js";
|
||||
import { buildNativeCompletionContract } from "../../services/native-runtime/completion-contracts.js";
|
||||
import { nativeRuntimeContextFixture } from "../../services/native-runtime/runtime-context.test-fixture.js";
|
||||
import { materializeAsset } from "../../services/native-runtime/runtime-context.js";
|
||||
import { executePaperclipNativeSession } from "../../services/native-runtime/native-session-executor.js";
|
||||
import { reconcileNativeFinalizations } from "../../services/native-runtime/native-finalization-reconciler.js";
|
||||
import { canonicalNativeRuntimeContextDigest, createNativeSessionBackend, createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../../vendor/paperclip-runner/index.js";
|
||||
import { registerLocalRuntimeApiServer } from "../../runtime-api.js";
|
||||
import { registerRunnerPrpAuthority, runnerPrpWebSocketInternals, setupRunnerPrpWebSocketServer } from "../../realtime/runner-prp-ws.js";
|
||||
|
||||
async function prepare(db: Db, runId: string, cwd: string, environmentLeaseId: string) {
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, run.nativeIssueId ?? String(run.contextSnapshot?.issueId)));
|
||||
await db.update(issues).set({ executionRunId: run.id }).where(eq(issues.id, issue.id));
|
||||
const native = await prepareNativeHeartbeatRun({ db, run, issue, environmentLeaseId });
|
||||
const [contract] = await db.select().from(completionContracts).where(eq(completionContracts.issueId, issue.id));
|
||||
const runtimeContext = nativeRuntimeContextFixture();
|
||||
runtimeContext.instructions.bundle = await materializeAsset([{ path: "AGENTS.md", mode: 0o444,
|
||||
content: Buffer.from("Complete the saved task once.\n") }]);
|
||||
runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(runtimeContext);
|
||||
const execution = buildNativeExecutionInput({ companyId: run.companyId, agentId: run.agentId, runId, issue,
|
||||
taskPrompt: "Preserve the saved file and complete the task once.", provider: "codex",
|
||||
workspace: { id: issue.executionWorkspaceId!, cwd, repoUrl: null, repoRef: null, branchName: null },
|
||||
normalizedSessionId: native.normalizedSessionId, runtimeContext,
|
||||
completionContract: { id: contract.id, sha256: contract.canonicalSha256, schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: buildNativeCompletionContract(issue, { revision: contract.revision }) },
|
||||
});
|
||||
const [current] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...run.runnerProfileJson, ...current.runnerProfileJson, nativeExecutionInput: execution } })
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
return { run: current, native, execution };
|
||||
}
|
||||
|
||||
export async function recordHistoricalListenerFailure(input: {
|
||||
db: Db; connectionString: string; runId: string; cwd: string; environmentId: string; leaseId: string;
|
||||
}) {
|
||||
await input.db.update(heartbeatRuns).set({ status: "running", finishedAt: null, error: null, errorCode: null })
|
||||
.where(eq(heartbeatRuns.id, input.runId));
|
||||
const { execution, native } = await prepare(input.db, input.runId, input.cwd, input.leaseId);
|
||||
await input.db.insert(nativeRunFinalizations).values({ runId: input.runId, companyId: execution.binding.companyId,
|
||||
issueId: execution.binding.issueId, phase: "observed" });
|
||||
const child = spawn(process.execPath, ["--import", createRequire(import.meta.url).resolve("tsx"),
|
||||
resolve(import.meta.dirname, "native-drain-controller.test-worker.ts")], { stdio: ["ignore", "pipe", "pipe", "ipc"], env: process.env });
|
||||
const logs: string[] = [];
|
||||
child.stdout?.on("data", data => logs.push(String(data)));
|
||||
child.stderr?.on("data", data => logs.push(String(data)));
|
||||
const exited = new Promise(resolveExit => child.once("exit", resolveExit));
|
||||
try {
|
||||
const result = await new Promise((resolveResult, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error(logs.join("").slice(-3000))), 60_000);
|
||||
child.once("error", reject);
|
||||
child.on("message", (message: { ready?: boolean; done?: boolean; error?: string }) => {
|
||||
if (message.ready) child.send({ connectionString: input.connectionString, options: { execution,
|
||||
runnerInstanceId: native.runnerInstanceId, useRunnerd: true,
|
||||
runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: input.environmentId,
|
||||
leaseId: input.leaseId, remoteCwd: input.cwd } } });
|
||||
if (message.done || message.error) { clearTimeout(timeout); message.error ? reject(new Error(message.error)) : resolveResult(message); }
|
||||
});
|
||||
});
|
||||
expect(result).toMatchObject({ commands: 0, errorMessage: "local_runtime_api_listener_unavailable" });
|
||||
expect(await exited).toBe(0);
|
||||
const [failed] = await input.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, input.runId));
|
||||
expect(failed).toMatchObject({ phase: "terminal_failure", attempt: 1, failureCode: "native_session_interrupted",
|
||||
failureDetail: { message: "local_runtime_api_listener_unavailable", recoveryMode: "ambiguous_state",
|
||||
providerSessionEstablished: false, providerEventsExist: false, checkpointExists: false } });
|
||||
} finally { child.kill("SIGKILL"); await exited; }
|
||||
}
|
||||
|
||||
export async function completeHistoricalListenerSuccessor(input: { db: Db; runId: string; root: string;
|
||||
prepareWorkspace: () => Promise<{ cwd: string; finalize: () => Promise<void> }> }) {
|
||||
const [scheduled] = await input.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId));
|
||||
const dispatch = createRunDispatch(input.db);
|
||||
expect(await dispatch.promoteScheduledRetry({ companyId: scheduled.companyId, runId: scheduled.id,
|
||||
now: new Date(scheduled.scheduledRetryAt!.getTime() + 1) })).toMatchObject({ outcome: "promoted" });
|
||||
expect(await dispatch.cancelStaleQueuedRun({ companyId: scheduled.companyId, runId: scheduled.id, expectedStatus: "queued" }))
|
||||
.toMatchObject({ outcome: "not_stale" });
|
||||
await input.db.update(heartbeatRuns).set({ status: "running", runtimeMode: "native",
|
||||
nativeIssueId: String(scheduled.contextSnapshot?.issueId) }).where(eq(heartbeatRuns.id, scheduled.id));
|
||||
await input.db.update(issues).set({ executionRunId: scheduled.id }).where(eq(issues.id, String(scheduled.contextSnapshot?.issueId)));
|
||||
const workspace = await input.prepareWorkspace();
|
||||
const { execution, native } = await prepare(input.db, scheduled.id, workspace.cwd, `controlled-${scheduled.id}`);
|
||||
await input.db.insert(nativeRunFinalizations).values({ runId: scheduled.id, companyId: scheduled.companyId,
|
||||
issueId: execution.binding.issueId, phase: "observed" });
|
||||
const home = join(input.root, "codex-home"), report = join(input.root, "result.json"), calls = join(input.root, "calls.log");
|
||||
await mkdir(home, { recursive: true });
|
||||
await writeFile(report, JSON.stringify({ schema: "paperclip.run_result.v1", reportedWorkDisposition: "done",
|
||||
summary: "Saved task completed after historical controller failure.", completionClaim: {
|
||||
contractRevision: execution.completionContract.contract.revision, objectiveSatisfied: true,
|
||||
criteria: execution.completionContract.contract.criteria.map(criterion => ({ criterionId: criterion.id, status: "satisfied", evidenceRefs: [] })),
|
||||
remainingWork: [] }, evidence: [], verification: [], artifacts: [], attentionRequests: [] }));
|
||||
const server = createServer();
|
||||
registerLocalRuntimeApiServer(server);
|
||||
await new Promise<void>(resolveListen => server.listen(0, "127.0.0.1", resolveListen));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("TCP listener required");
|
||||
setupRunnerPrpWebSocketServer(server, { apiUrl: `http://127.0.0.1:${address.port}` });
|
||||
const bundle = createRunnerdCodexTransport({ runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: resolve(import.meta.dirname, "../../../../packages/paperclip-runner/runner/target/debug/fake-codex-app-server"),
|
||||
codexArgs: ["--state-file", join(input.root, "provider.json"), "--terminal-result-file", report, "--call-log", calls, "--record-process-start"],
|
||||
stateDirectory: input.root, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
runtimeContext: execution.runtimeContext, runnerRuntimeContext: execution.runtimeContext,
|
||||
sourceCodexHome: home, environment: { CODEX_HOME: home, PATH: process.env.PATH },
|
||||
prpIdentity: { runnerInstanceId: native.runnerInstanceId, environmentLeaseId: native.environmentLeaseId,
|
||||
runId: scheduled.id, normalizedSessionId: native.normalizedSessionId, turnId: native.turnId, itemId: native.itemId },
|
||||
controlPlaneRegistration: authority => registerRunnerPrpAuthority({ companyId: scheduled.companyId, runId: scheduled.id, authority }),
|
||||
});
|
||||
const backend = createNativeSessionBackend(execution, { runnerInstanceId: native.runnerInstanceId,
|
||||
environment: { CODEX_HOME: home, PATH: process.env.PATH }, codexTransportFactory: () => bundle.transport });
|
||||
try {
|
||||
expect(await executePaperclipNativeSession({ db: input.db, execution, backend, useRunnerd: true,
|
||||
runnerInstanceId: native.runnerInstanceId, turnTimeoutMs: 15_000 })).toMatchObject({ exitCode: 0, timedOut: false });
|
||||
await workspace.finalize();
|
||||
await reconcileNativeFinalizations(input.db, [scheduled.id]);
|
||||
const [finished] = await input.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, scheduled.id));
|
||||
expect(finished, JSON.stringify(finished.failureDetail)).toMatchObject({ phase: "committed", attempt: 2 });
|
||||
expect(await input.db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, scheduled.id)))
|
||||
.toMatchObject([{ schemaStatus: "accepted" }]);
|
||||
expect((await readFile(calls, "utf8")).split("\n").filter(line => line === "process-start")).toHaveLength(1);
|
||||
expect(await readFile(join(workspace.cwd, "saved.txt"), "utf8")).toBe("work survives a failed provider session\n");
|
||||
} finally {
|
||||
await bundle.transport.close(); runnerPrpWebSocketInternals.resetForTests();
|
||||
server.closeAllConnections(); await new Promise<void>(resolveClose => server.close(() => resolveClose()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createServer } from "node:http";
|
||||
import { closeRegisteredClients, createDb, heartbeatRunEvents } from "@paperclipai/db";
|
||||
import { registerLocalRuntimeApiServer } from "../../runtime-api.js";
|
||||
import { setStartupRecoveryPhase } from "../../startup-recovery-state.js";
|
||||
import { heartbeatService } from "../../services/heartbeat.js";
|
||||
import { executePaperclipNativeSession } from "../../services/native-runtime/native-session-executor.js";
|
||||
|
||||
process.send?.({ ready: true });
|
||||
process.once("message", async (input: {
|
||||
connectionString: string;
|
||||
options: Omit<Parameters<typeof executePaperclipNativeSession>[0], "db">;
|
||||
providerEvidence?: boolean;
|
||||
}) => {
|
||||
const api = createServer((_request, response) => response.writeHead(404).end());
|
||||
registerLocalRuntimeApiServer(api);
|
||||
let commands = 0;
|
||||
let errorName: string | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
try {
|
||||
await new Promise<void>(resolve => api.listen(0, "127.0.0.1", resolve));
|
||||
const db = createDb(input.connectionString);
|
||||
try {
|
||||
await executePaperclipNativeSession({ ...input.options, db,
|
||||
runnerExecutionTarget: { ...input.options.runnerExecutionTarget!,
|
||||
runner: { execute: async () => { commands++; throw new Error("Drained controller launched remote work"); } },
|
||||
} as never,
|
||||
onEvent: async event => {
|
||||
if (event.payload?.span !== "native.coordinator.claim") return;
|
||||
if (input.providerEvidence) await db.insert(heartbeatRunEvents).values({
|
||||
companyId: input.options.execution.binding.companyId, agentId: input.options.execution.binding.agentId,
|
||||
runId: input.options.execution.binding.runId, seq: 1_000_000, eventType: "turn.started",
|
||||
sourceEventId: "controlled-provider-evidence" });
|
||||
setStartupRecoveryPhase("draining");
|
||||
await new Promise<void>((resolve, reject) => api.close(error => error ? reject(error) : resolve()));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
errorName = error instanceof Error ? error.name : String(error);
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const heartbeat = heartbeatService(db);
|
||||
try {
|
||||
await heartbeat.drainRunningRunsForShutdown("SIGTERM", new Date(), [input.options.execution.binding.runId]);
|
||||
} finally { await heartbeat.drainActiveRunExecutions(); }
|
||||
await closeRegisteredClients(input.connectionString, { scope: "database" });
|
||||
process.send?.({ done: true, commands, errorName, errorMessage }, () => process.exit(0));
|
||||
} catch (error) {
|
||||
process.send?.({ error: error instanceof Error ? error.stack : String(error) }, () => process.exit(1));
|
||||
}
|
||||
});
|
||||
@@ -17,6 +17,7 @@ function managedConfig(features: ManagedInstanceConfig["features"] = {}): Manage
|
||||
features,
|
||||
plugins: { autoInstall: [] },
|
||||
environments: [],
|
||||
pluginEnvironments: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("instance settings service", () => {
|
||||
}
|
||||
expect(applyExperimentalSettingsPatch({}, { enableAgentChat: false }).enableAgentChat).toBe(true);
|
||||
const config: ManagedInstanceConfig = {
|
||||
v: 1, mode: "cloud", catalogVersion: "test", plugins: { autoInstall: [] }, environments: [],
|
||||
v: 1, mode: "cloud", catalogVersion: "test", plugins: { autoInstall: [] }, environments: [], pluginEnvironments: [],
|
||||
features: { enableAgentChat: false },
|
||||
};
|
||||
const result = applyManagedExperimentalOverlay(normalizeExperimentalSettings({ enableAgentChat: false }), config);
|
||||
@@ -54,7 +54,7 @@ describe("instance settings service", () => {
|
||||
{ stored: { remoteWorkspaceStorageEnabled: true }, managed: false, expected: false },
|
||||
])("respects Cloud enrollment defaults and managed choices: $expected", ({ stored, managed, expected }) => {
|
||||
const config: ManagedInstanceConfig = {
|
||||
v: 1, mode: "cloud", catalogVersion: "test", plugins: { autoInstall: [] }, environments: [],
|
||||
v: 1, mode: "cloud", catalogVersion: "test", plugins: { autoInstall: [] }, environments: [], pluginEnvironments: [],
|
||||
features: managed === undefined ? {} : { remoteWorkspaceStorageEnabled: managed },
|
||||
};
|
||||
const normalized = normalizeExperimentalSettings(stored);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MANAGED_CONFIG_ENV_KEY,
|
||||
MANAGED_ENVIRONMENT_DRIVERS,
|
||||
getManagedInstanceConfig,
|
||||
managedFeatureKeySet,
|
||||
parseManagedConfigEnv,
|
||||
@@ -110,6 +111,7 @@ describe("parseManagedConfigEnv", () => {
|
||||
features: { enableApps: false, enableStatusCards: true },
|
||||
plugins: { autoInstall: ["daytona", "kubernetes"] },
|
||||
environments: [],
|
||||
pluginEnvironments: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +134,7 @@ describe("parseManagedConfigEnv", () => {
|
||||
features: {},
|
||||
plugins: { autoInstall: [] },
|
||||
environments: [],
|
||||
pluginEnvironments: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,12 +345,17 @@ describe("parseManagedConfigEnv environments section", () => {
|
||||
).toThrow(/"environments\[0\]" must be an object/);
|
||||
});
|
||||
|
||||
it("rejects more than one entry (single managed sandbox slot)", () => {
|
||||
it("rejects more than one sandbox entry (single managed sandbox slot)", () => {
|
||||
expect(() =>
|
||||
parseManagedConfigEnv(
|
||||
envWith(validDoc({ environments: [entry(), entry({ name: "Other", provider: "kubernetes" })] })),
|
||||
),
|
||||
).toThrow(/at most one entry/);
|
||||
).toThrow(/at most one sandbox entry/);
|
||||
expect(() =>
|
||||
parseManagedConfigEnv(
|
||||
envWith(validDoc({ environments: [entry(), entry({ name: "Other", driver: "sandbox", provider: "kubernetes" })] })),
|
||||
),
|
||||
).toThrow(/at most one sandbox entry/);
|
||||
});
|
||||
|
||||
it("rejects unknown entry keys", () => {
|
||||
@@ -406,6 +414,94 @@ describe("parseManagedConfigEnv environments section", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseManagedConfigEnv plugin environment entries", () => {
|
||||
const PLUGIN_KEY = "example.remote-host-plugin";
|
||||
const remoteHost = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: "Remote Host",
|
||||
description: "Runs the agent on a remote host",
|
||||
driver: "plugin",
|
||||
pluginKey: PLUGIN_KEY,
|
||||
driverKey: "tasks",
|
||||
driverConfig: {},
|
||||
...overrides,
|
||||
});
|
||||
const sandbox = { name: "Managed Sandbox", provider: "daytona", config: { target: "us" } };
|
||||
// `plugins.autoInstall` holds catalog keys ("remote-host"); the entry's
|
||||
// `pluginKey` is the plugin's manifest id, which boot maps through the catalog.
|
||||
const docWith = (environments: unknown[], autoInstall = ["daytona", "remote-host"]) =>
|
||||
envWith(validDoc({ plugins: { autoInstall }, environments }));
|
||||
|
||||
it("lists the drivers an entry may declare", () => {
|
||||
expect(MANAGED_ENVIRONMENT_DRIVERS).toEqual(["sandbox", "plugin"]);
|
||||
});
|
||||
|
||||
it("parses an old document's sandbox entry unchanged", () => {
|
||||
const config = parseManagedConfigEnv(docWith([sandbox]));
|
||||
expect(config?.environments).toEqual([{ name: "Managed Sandbox", provider: "daytona", config: { target: "us" } }]);
|
||||
expect(config?.pluginEnvironments).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses the sandbox entry beside a plugin entry", () => {
|
||||
const config = parseManagedConfigEnv(docWith([sandbox, remoteHost()]));
|
||||
expect(config?.environments).toEqual([{ name: "Managed Sandbox", provider: "daytona", config: { target: "us" } }]);
|
||||
expect(config?.pluginEnvironments).toEqual([{
|
||||
driver: "plugin",
|
||||
name: "Remote Host",
|
||||
description: "Runs the agent on a remote host",
|
||||
pluginKey: PLUGIN_KEY,
|
||||
driverKey: "tasks",
|
||||
driverConfig: {},
|
||||
}]);
|
||||
expect(Object.isFrozen(config?.pluginEnvironments)).toBe(true);
|
||||
expect(Object.isFrozen(config?.pluginEnvironments[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("parses a plugin entry alone, with optional fields omitted", () => {
|
||||
const config = parseManagedConfigEnv(docWith([{ name: "Remote", driver: "plugin", pluginKey: PLUGIN_KEY, driverKey: "tasks" }]));
|
||||
expect(config?.environments).toEqual([]);
|
||||
expect(config?.pluginEnvironments[0]).toMatchObject({ driverConfig: {} });
|
||||
expect(config?.pluginEnvironments[0]?.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not compare the manifest id with autoInstall catalog keys (boot checks the catalog)", () => {
|
||||
expect(parseManagedConfigEnv(docWith([remoteHost()]))?.pluginEnvironments[0]?.pluginKey).toBe(PLUGIN_KEY);
|
||||
});
|
||||
|
||||
it("refuses duplicate names, including across drivers", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([sandbox, remoteHost({ name: "Managed Sandbox" })])))
|
||||
.toThrow(/"environments\[1\].name" duplicates another entry's name/);
|
||||
});
|
||||
|
||||
it("refuses a duplicate pluginKey and driverKey pair", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost(), remoteHost({ name: "Second" })])))
|
||||
.toThrow(/duplicates another plugin entry's pluginKey and driverKey/);
|
||||
});
|
||||
|
||||
it("refuses secret-like driverConfig keys at any depth", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverConfig: { apiToken: "t" } })])))
|
||||
.toThrow(/"environments\[0\].driverConfig" key "apiToken" looks secret-bearing/);
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverConfig: { auth: [{ password: "p" }] } })])))
|
||||
.toThrow(/auth\[0\].password/);
|
||||
});
|
||||
|
||||
it("refuses unknown keys, including sandbox keys on a plugin entry and the reverse", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ envVars: {} })]))).toThrow(/unknown key "envVars"/);
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ provider: "daytona" })]))).toThrow(/unknown key "provider"/);
|
||||
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, pluginKey: PLUGIN_KEY }]))).toThrow(/unknown key "pluginKey"/);
|
||||
// Not supported by this build on either entry.
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ instanceDefault: true })]))).toThrow(/unknown key "instanceDefault"/);
|
||||
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, instanceDefault: false }]))).toThrow(/unknown key "instanceDefault"/);
|
||||
});
|
||||
|
||||
it("refuses an unknown driver and malformed plugin fields", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driver: "ssh" })]))).toThrow(/"environments\[0\].driver" must be one of "sandbox", "plugin"/);
|
||||
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, driver: null }]))).toThrow(/"environments\[0\].driver" must be one of "sandbox", "plugin" when present \(got null\)/);
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverKey: "Tasks" })]))).toThrow(/"environments\[0\].driverKey" must start/);
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverKey: undefined })]))).toThrow(/"environments\[0\].driverKey" must be a non-empty string/);
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverConfig: [] })]))).toThrow(/"environments\[0\].driverConfig" must be an object/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getManagedInstanceConfig", () => {
|
||||
it("caches by raw env value and reparses when it changes", () => {
|
||||
const raw = validDoc();
|
||||
|
||||
@@ -7,15 +7,42 @@ import {
|
||||
companies,
|
||||
createDb,
|
||||
environments,
|
||||
instanceSettings,
|
||||
plugins,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { MANAGED_CONFIG_ENV_KEY, parseManagedConfigEnv } from "../services/managed-config.js";
|
||||
import { applyManagedEnvironments } from "../services/managed-environments.js";
|
||||
|
||||
const PLUGIN_KEY = "example.remote-host-plugin";
|
||||
const MARKER = `${PLUGIN_KEY}:tasks`;
|
||||
|
||||
type ReadyListener = (payload: { pluginId: string }) => void;
|
||||
|
||||
function managedConfig(entries: unknown[]) {
|
||||
return parseManagedConfigEnv({
|
||||
[MANAGED_CONFIG_ENV_KEY]: JSON.stringify({
|
||||
v: 1,
|
||||
mode: "cloud",
|
||||
catalogVersion: "2026.1106.0",
|
||||
features: {},
|
||||
plugins: { autoInstall: ["remote-host"] },
|
||||
environments: entries,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const remoteHostEntry = {
|
||||
name: "Remote Host",
|
||||
description: "Runs the agent on a remote host",
|
||||
driver: "plugin",
|
||||
pluginKey: PLUGIN_KEY,
|
||||
driverKey: "tasks",
|
||||
driverConfig: {},
|
||||
};
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("managed plugin environment rows", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
@@ -24,7 +51,17 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
let running: boolean;
|
||||
const call = vi.fn(async (_id: string, method: string, input: { config: Record<string, unknown> }) =>
|
||||
method === "environmentValidateConfig" ? { ok: true, normalizedConfig: { ...input.config, normalized: true } } : null);
|
||||
const workerManager = { isRunning: vi.fn(() => running), call };
|
||||
let readyListeners: ReadyListener[] = [];
|
||||
const workerManager = {
|
||||
isRunning: vi.fn(() => running),
|
||||
call,
|
||||
getWorker: vi.fn(() => ({
|
||||
on: (_event: "ready", listener: ReadyListener) => { readyListeners.push(listener); },
|
||||
off: (_event: "ready", listener: ReadyListener) => {
|
||||
readyListeners = readyListeners.filter((candidate) => candidate !== listener);
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-managed-plugin-environment-rows-");
|
||||
@@ -33,9 +70,11 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
beforeEach(async () => {
|
||||
running = true;
|
||||
readyListeners = [];
|
||||
call.mockClear();
|
||||
await db.delete(activityLog);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(environments);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
@@ -175,4 +214,42 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
metadata: { managedPluginEnvironment: MARKER },
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
|
||||
describe("boot (applyManagedEnvironments)", () => {
|
||||
const boot = (entries: unknown[] = [remoteHostEntry]) =>
|
||||
applyManagedEnvironments(db, managedConfig(entries), { env: {}, workerManager, autoInstalledPluginKeys: [PLUGIN_KEY] });
|
||||
const instanceDefault = async () =>
|
||||
(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, "default")))[0]?.defaultEnvironmentId ?? null;
|
||||
|
||||
it("creates the row on the first boot and changes nothing on the second", async () => {
|
||||
const first = await boot();
|
||||
expect(first).toMatchObject({ ensured: 1, added: 1, failed: 0 });
|
||||
const [row] = await pluginRows();
|
||||
expect(row).toMatchObject({ name: "Remote Host", status: "active", metadata: { managedByPaperclip: true, managedPluginEnvironment: MARKER } });
|
||||
|
||||
const second = await boot();
|
||||
expect(second).toMatchObject({ ensured: 1, added: 0, unchanged: 1 });
|
||||
const after = await pluginRows();
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!.updatedAt.getTime()).toBe(row!.updatedAt.getTime());
|
||||
expect(await instanceDefault()).toBeNull();
|
||||
});
|
||||
|
||||
it("archives the row while the plugin is down and reactivates it when the worker recovers", async () => {
|
||||
await boot();
|
||||
running = false;
|
||||
expect(await boot()).toMatchObject({ ensured: 0, failed: 1 });
|
||||
expect((await pluginRows())[0]!.status).toBe("archived");
|
||||
|
||||
running = true;
|
||||
for (const listener of [...readyListeners]) listener({ pluginId });
|
||||
await vi.waitFor(async () => expect((await pluginRows())[0]?.status).toBe("active"));
|
||||
});
|
||||
|
||||
it("never archives a row whose entry left the document", async () => {
|
||||
await boot();
|
||||
expect(await boot([])).toBeNull();
|
||||
expect((await pluginRows())[0]!.status).toBe("active");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ it.each([false, true])("keeps execution queue consumers intact and emits only a
|
||||
const write = vi.fn(async () => "written");
|
||||
expect(await writeExecutionControl(owner as never, DELIVERY_QUEUES.workspaceRecovery, write, scope)).toBe("written");
|
||||
expect(write).toHaveBeenCalledOnce();
|
||||
expect(new Set(settled.map(row => row.queue))).toEqual(new Set([DELIVERY_QUEUES.issueMonitors, DELIVERY_QUEUES.workspaceRecovery,
|
||||
expect(new Set(settled.map(row => row.queue))).toEqual(new Set([DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.issueMonitors, DELIVERY_QUEUES.workspaceRecovery,
|
||||
...(scoped ? [DELIVERY_QUEUES.statusCardEvaluation] : [])]));
|
||||
} finally { for (const stop of unsubscribe) stop(); }
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, agentWakeupRequests, companies, createDb, applyPendingMigrations, heartbeatRuns, issues, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { agents, agentWakeupRequests, companies, createDb, applyPendingMigrations, heartbeatRuns, issues, nativeRunFinalizations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { taskInterruptionPresentation, taskInterruptionPresentations } from "../services/task-interruption-presentation.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
@@ -24,6 +24,28 @@ describe.skipIf(!support.supported)("task interruption latest run query", () =>
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
||||
|
||||
it("shows a pending native same-run resume instead of a competing Retry", async () => {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Resuming task", issuePrefix: `R${companyId.slice(0, 7)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Resuming agent", adapterType: "paperclip_runner" });
|
||||
const [issue] = await db.insert(issues).values({ companyId, title: "Resume after attach failure",
|
||||
status: "in_progress", assigneeAgentId: agentId }).returning();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issue.id, scopeKind: "issue",
|
||||
status: "failed", runtimeMode: "native", errorCode: "native_session_interrupted", finishedAt: new Date() });
|
||||
const resumeAt = new Date(Date.now() + 30_000);
|
||||
await db.insert(nativeRunFinalizations).values({ companyId, issueId: issue.id, runId, phase: "retryable_failure",
|
||||
attempt: 1, nextAttemptAt: resumeAt });
|
||||
for (const presentation of [(await taskInterruptionPresentations(db, companyId, [issue])).get(issue.id),
|
||||
await taskInterruptionPresentation(db, issue)]) {
|
||||
expect(presentation).toMatchObject({ state: "retry_scheduled", canRetry: false, nextRetryAt: resumeAt.toISOString() });
|
||||
}
|
||||
// Exhausted recovery is terminal: exactly one Retry returns.
|
||||
await db.update(nativeRunFinalizations).set({ phase: "terminal_failure", nextAttemptAt: null })
|
||||
.where(eq(nativeRunFinalizations.runId, runId));
|
||||
expect((await taskInterruptionPresentations(db, companyId, [issue])).get(issue.id))
|
||||
.toMatchObject({ state: "failed", canRetry: true });
|
||||
});
|
||||
|
||||
it("redacts workspace preparation errors on task detail and list reads", async () => {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "a".repeat(64));
|
||||
try {
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
GOOGLE_WORKSPACE_DEFAULT_SELECTION,
|
||||
getAvailableConnectionMethod,
|
||||
getConnectableAppDefinition,
|
||||
createToolConnectionSchema,
|
||||
type CreateToolConnection,
|
||||
type GoogleWorkspaceConnectorProfileId,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -3363,6 +3364,42 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
.where(eq(companySecrets.companyId, company.id))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "channel purpose", input: { connectionPurpose: "channel" as const, transport: "local_stdio" as const } },
|
||||
{ name: "chat transport", input: { transport: "chat_sdk" as const } },
|
||||
{ name: "voice transport", input: { transport: "voice" as const } },
|
||||
])("rejects a $name before it writes any row", async ({ input }) => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
||||
await expect(service.createConnection(company.id, connectionInput({
|
||||
name: "Not a tool",
|
||||
config: { templateId: "paperclip.echo-calculator-time" },
|
||||
...input,
|
||||
}))).rejects.toMatchObject({ status: 422 });
|
||||
|
||||
expect(await db.select().from(toolConnections)
|
||||
.where(eq(toolConnections.companyId, company.id))).toEqual([]);
|
||||
expect(await db.select().from(toolApplications)
|
||||
.where(eq(toolApplications.companyId, company.id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("stores config as transportConfig when the request leaves transportConfig out", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
const body = createToolConnectionSchema.parse({
|
||||
name: "Local echo config only",
|
||||
transport: "local_stdio",
|
||||
config: { templateId: "paperclip.echo-calculator-time" },
|
||||
});
|
||||
|
||||
const connection = await service.createConnection(company.id, body);
|
||||
|
||||
const [stored] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
|
||||
expect(stored?.config).toMatchObject({ templateId: "paperclip.echo-calculator-time" });
|
||||
expect(stored?.transportConfig).toEqual(stored?.config);
|
||||
});
|
||||
|
||||
it("registers an approved local stdio template and exposes its runtime slot", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
||||
@@ -1348,6 +1348,9 @@ export async function createApp(
|
||||
logger.error({ err }, "Failed to load ready plugins on startup");
|
||||
});
|
||||
app.locals.bundledPluginsStartup = trackIdleWork(bundledPluginsStartup);
|
||||
// Manifest ids the managed auto-install list resolves to; the managed
|
||||
// environments bootstrap checks plugin entries against them.
|
||||
app.locals.managedBundledPluginKeys = managedBundledPluginKeys;
|
||||
void bundledPluginsStartup.then(() => { lifecyclePluginsReady = true; resourceLifecycleDelivery.wake(); agentLifecycleDelivery.wake(); }).catch(() => logger.warn("Agent lifecycle recovery failed; retrying."));
|
||||
// The shutdown hook runs at most once. It caches the in-flight promise, so a
|
||||
// second caller (for example the `exit` handler) awaits the same completion
|
||||
|
||||
+10
-8
@@ -81,7 +81,7 @@ import { embeddedPostgresOwnerPort } from "./embedded-postgres-owner.js";
|
||||
import { deliverExecutionStatuses } from "./services/execution-status-delivery.js";
|
||||
import { deliverReconciledExecutions, settleUnrecoverableExecutions } from "./services/execution-recovery-resolution.js";
|
||||
import { deliverPendingPauseResumes } from "./services/issue-tree-resume.js";
|
||||
import { reconcileSafeNativeReplacements } from "./services/native-runtime/native-safe-replacement.js";
|
||||
import { invalidateNativeReplacementDiscovery, reconcileSafeNativeReplacements } from "./services/native-runtime/native-safe-replacement.js";
|
||||
import { reconcileAbandonedExecutionControl } from "./services/execution-control-reconciliation.js";
|
||||
import { acknowledgeNativeStartupCleanup } from "./services/native-runtime/native-startup-cleanup.js";
|
||||
import { executionControlSchedule, registerExecutionControlWork } from "./services/execution-control-work.js";
|
||||
@@ -1175,8 +1175,8 @@ async function startServerWithDatabaseTeardown(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Ensure sandbox environments declared in the managed-config document
|
||||
// (`environments` section) before the heartbeat resumes queued runs. The
|
||||
// Ensure the sandbox and plugin environments declared in the managed-config
|
||||
// document (`environments` section) before the heartbeat resumes queued runs. The
|
||||
// document already parsed fail-closed above; the ensure step itself is
|
||||
// fail-safe per entry (a degraded boot beats a fleet-wide crash loop), but
|
||||
// a contradictory deployment that also forces PAPERCLIP_EXECUTION_MODE
|
||||
@@ -1186,14 +1186,16 @@ async function startServerWithDatabaseTeardown(
|
||||
// additionally gates each entry on a live plugin worker (and archives the
|
||||
// row of a provider that did not come up).
|
||||
try {
|
||||
const bundledPluginsStartup = (app as { locals?: { bundledPluginsStartup?: Promise<unknown> } })
|
||||
.locals?.bundledPluginsStartup;
|
||||
const appLocals = (app as {
|
||||
locals?: { bundledPluginsStartup?: Promise<unknown>; managedBundledPluginKeys?: readonly string[] };
|
||||
}).locals;
|
||||
const managedEnvironmentsResult = await applyManagedEnvironments(db as any, managedConfig, {
|
||||
pluginsReady: bundledPluginsStartup,
|
||||
pluginsReady: appLocals?.bundledPluginsStartup,
|
||||
autoInstalledPluginKeys: appLocals?.managedBundledPluginKeys,
|
||||
workerManager: pluginWorkerManager,
|
||||
});
|
||||
if (managedEnvironmentsResult) {
|
||||
logger.warn(managedEnvironmentsResult, "managed sandbox environments ensured from managed config");
|
||||
logger.warn(managedEnvironmentsResult, "managed environments ensured from managed config");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "failed to apply managed environments from managed config");
|
||||
@@ -1342,7 +1344,7 @@ async function startServerWithDatabaseTeardown(
|
||||
{ queue: DELIVERY_QUEUES.executionStatus, run: () => deliverExecutionStatuses(db), next: executionSchedule.status },
|
||||
{ queue: DELIVERY_QUEUES.executionDisposition, run: () => settleUnrecoverableExecutions(db), next: executionSchedule.disposition },
|
||||
...(heartbeat ? [
|
||||
{ queue: DELIVERY_QUEUES.executionReplacement, run: () => reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession: run => verifyStoppedNativeSessionForReplacement(db, run) }), next: executionSchedule.replacement },
|
||||
{ queue: DELIVERY_QUEUES.executionReplacement, run: () => reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession: run => verifyStoppedNativeSessionForReplacement(db, run) }), next: executionSchedule.replacement, onSettled: () => invalidateNativeReplacementDiscovery(db), settleEmptyWhilePaused: true },
|
||||
{ queue: DELIVERY_QUEUES.executionContinuation, run: async () => {
|
||||
await deliverReconciledExecutions(db, heartbeat.wakeup);
|
||||
await deliverPendingPauseResumes(db, heartbeat.wakeup);
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
import { conflict, forbidden, unprocessable } from "../errors.js";
|
||||
import { isCloudManagedInstance } from "../services/cloud-instance.js";
|
||||
import { getManagedInstanceConfig, SECRET_LIKE_CONFIG_KEY_PATTERN } from "../services/managed-config.js";
|
||||
import { MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY } from "../services/managed-plugin-environments.js";
|
||||
import {
|
||||
MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY,
|
||||
managedPluginEnvironmentMarker,
|
||||
} from "../services/managed-plugin-environments.js";
|
||||
import { parseExecutionPolicyBootstrapEnv } from "../services/execution-policy-bootstrap.js";
|
||||
import { isExecutionForcedToKubernetes } from "../services/execution-allowlist.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
@@ -182,6 +185,20 @@ function isManagedSandboxProvisioningConfigured(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the managed-config document declares the plugin entry this row's
|
||||
* marker names. Fails closed like `isManagedSandboxProvisioningConfigured`.
|
||||
*/
|
||||
function isManagedPluginEnvironmentDeclared(marker: string): boolean {
|
||||
try {
|
||||
return (getManagedInstanceConfig()?.pluginEnvironments ?? []).some(
|
||||
(spec) => managedPluginEnvironmentMarker(spec) === marker,
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this row occupies a provisioner-owned environment slot whose
|
||||
* platform markers are LIVE state — i.e. some platform code path converges
|
||||
@@ -196,6 +213,9 @@ function isManagedSandboxProvisioningConfigured(): boolean {
|
||||
* owned only while a managed-sandbox bootstrap path is configured —
|
||||
* `ensureManagedSandboxEnvironment` then adopts and refreshes whichever
|
||||
* row holds the slot on every boot.
|
||||
* - The row of a managed-config plugin entry (its
|
||||
* `managedPluginEnvironment` marker) while the document declares that
|
||||
* entry: `applyManagedEnvironments` converges the markers on it every boot.
|
||||
* - Any sandbox row bearing the legacy kubernetes marker while the
|
||||
* persisted instance execution policy forces kubernetes:
|
||||
* `findKubernetesEnvironment` selects marked rows (newest first) for
|
||||
@@ -214,6 +234,10 @@ async function isPlatformSlotEnvironment(
|
||||
options: { isForcedKubernetesExecution: () => Promise<boolean> },
|
||||
): Promise<boolean> {
|
||||
if (environment.driver === "local") return true;
|
||||
if (environment.driver === "plugin") {
|
||||
const pluginMarker = environment.metadata?.[MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY];
|
||||
return typeof pluginMarker === "string" && isManagedPluginEnvironmentDeclared(pluginMarker);
|
||||
}
|
||||
if (environment.driver !== "sandbox") return false;
|
||||
if (
|
||||
environment.metadata?.managedByPaperclip === true &&
|
||||
|
||||
@@ -25,7 +25,7 @@ export function createDeliveryWorkCoordinator(input: {
|
||||
// do not postpone the obligation indefinitely.
|
||||
coalesceMs?: number;
|
||||
run: (signal: AbortSignal) => Promise<unknown>;
|
||||
hasPending: () => Promise<boolean>;
|
||||
hasPending: (runAllowed?: boolean) => Promise<boolean>;
|
||||
// Scoped hints belong to the outer transaction, including uncertainty recovery.
|
||||
onSettled?: (hints?: ReadonlySet<string>) => void;
|
||||
// Optional maintenance/retry deadline restored from durable state.
|
||||
@@ -75,7 +75,7 @@ export function createDeliveryWorkCoordinator(input: {
|
||||
let runFailed = false;
|
||||
controller = attempt;
|
||||
running = withDatabaseOperation(`delivery.${queue}`, () => Promise.resolve().then(() => reconcileDatabaseWork(input.owner, queue))
|
||||
.then(() => workerStopped || !(input.canRun() || (task.runDuringDrain && input.canReconcile?.())) ? undefined : task.run(attempt.signal).catch(error => { runFailed = true; throw error; })).then(() => workerStopped ? false : task.hasPending()).then(pending => {
|
||||
.then(() => workerStopped || !(input.canRun() || (task.runDuringDrain && input.canReconcile?.())) ? undefined : task.run(attempt.signal).catch(error => { runFailed = true; throw error; })).then(() => workerStopped ? false : task.hasPending(input.canRun() || Boolean(task.runDuringDrain && input.canReconcile?.()))).then(pending => {
|
||||
pendingWork = pending || databaseWorkPending(input.owner, queue);
|
||||
if (pendingWork) schedule(task.retryMs);
|
||||
else { finishIntent?.(); finishIntent = undefined; }
|
||||
|
||||
@@ -31,7 +31,10 @@ export async function notifyEnvironmentCleanup(transaction: object, scope?: Envi
|
||||
await notifyNativeEnvironmentSettlement(transaction, scope);
|
||||
await notifyQueuedRunDispatch(transaction, scope);
|
||||
// A settled run lease can make that run's abandoned native owner releasable.
|
||||
if (scope?.runId) await notifyDeliveryWork(transaction, DELIVERY_QUEUES.executionFinalization);
|
||||
if (scope?.runId) {
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.executionFinalization);
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.executionReplacement);
|
||||
}
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.environmentCleanup);
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.historicalCleanup);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.
|
||||
import { type DeliveryQueue } from "./delivery-work-notifications.js";
|
||||
import { EXECUTION_RECONCILIATION_INTERVAL_MS } from "./execution-control-deadline.js";
|
||||
import { obsoleteExecutionConversationHold, unresolvedExecutionDispositions } from "./execution-recovery-resolution.js";
|
||||
import { nativeReplacementPending } from "./native-runtime/native-safe-replacement.js";
|
||||
import { hasPendingNativeReplacements } from "./native-runtime/native-safe-replacement.js";
|
||||
import { nextAbandonedNativeOwnerReleaseAt } from "./native-runtime/native-failed-run-owner-release.js";
|
||||
import { hasPendingPauseResume } from "./issue-tree-resume.js";
|
||||
|
||||
@@ -16,6 +16,7 @@ export function registerExecutionControlWork(
|
||||
run: () => Promise<unknown>;
|
||||
next: () => Promise<number | null>;
|
||||
onSettled?: () => void;
|
||||
settleEmptyWhilePaused?: boolean;
|
||||
}>,
|
||||
canRun: () => boolean = () => true,
|
||||
) {
|
||||
@@ -24,6 +25,7 @@ export function registerExecutionControlWork(
|
||||
let reconciled = 0;
|
||||
let next: number | null = null;
|
||||
let earliestRunAt = 0;
|
||||
let pausedGeneration = 0, pausedPending = true;
|
||||
const worker = coordinator.register(duty.queue, {
|
||||
retryMs: EXECUTION_RECONCILIATION_INTERVAL_MS,
|
||||
pausePassiveMaintenance: true,
|
||||
@@ -42,7 +44,18 @@ export function registerExecutionControlWork(
|
||||
next = deadline === null ? null : Math.max(deadline, earliestRunAt);
|
||||
reconciled = generation;
|
||||
},
|
||||
hasPending: async () => reconciled !== requested,
|
||||
hasPending: async (runAllowed = true) => {
|
||||
if (reconciled === requested) return false;
|
||||
if (runAllowed || !duty.settleEmptyWhilePaused || !canRun() || reconciled === 0) return true;
|
||||
if (pausedGeneration !== requested) {
|
||||
const generation = requested;
|
||||
const deadline = await duty.next();
|
||||
if (generation !== requested) return true;
|
||||
pausedGeneration = generation; pausedPending = deadline !== null; next = deadline;
|
||||
if (!pausedPending) reconciled = generation;
|
||||
}
|
||||
return pausedPending;
|
||||
},
|
||||
nextRunAt: () => next,
|
||||
});
|
||||
return { ...worker, wake: () => { requested += 1; worker.wake(); } };
|
||||
@@ -74,10 +87,7 @@ export function executionControlSchedule(db: 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))
|
||||
.where(nativeReplacementPending()).limit(1);
|
||||
return rows.length ? retry() : null;
|
||||
return await hasPendingNativeReplacements(db) ? retry() : null;
|
||||
},
|
||||
async continuation() {
|
||||
const rows = await db.select({ id: issueRecoveryActions.id }).from(issueRecoveryActions).where(and(
|
||||
|
||||
@@ -1586,7 +1586,7 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
|
||||
await persistHeartbeatRunProcessMetadata(db, source.id, { pid: 999999999, processGroupId: null, startedAt: new Date().toISOString() });
|
||||
await db.update(heartbeatRuns).set({ processPid: null }).where(eq(heartbeatRuns.id, source.id));
|
||||
expect(await admit(f, true)).toBeNull();
|
||||
expect(await recordNativeLocalProcessStop(db, source)).toBe(true);
|
||||
expect(await db.transaction(tx => recordNativeLocalProcessStop(tx as unknown as typeof db, source))).toBe(true);
|
||||
await appendHeartbeatRunEvent(db, { companyId: f.companyId, runId: source.id, agentId: f.agentId,
|
||||
eventType: PROCESS_START_REQUESTED });
|
||||
// No PID was stored for the new launch, as when the server dies after spawn.
|
||||
@@ -2440,7 +2440,7 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
|
||||
await db.update(issues).set({ conversationAgentId: f.agentId, conversationUserId: "board", conversationState: "active", conversationBoundaryCommentId: boundaryId }).where(eq(issues.id, f.issueId));
|
||||
await db.update(issueRecoveryActions).set({ createdAt: new Date("2026-09-11T09:00:00Z") }).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
|
||||
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
expect(await recordNativeLocalProcessStop(db, source)).toBe(true);
|
||||
expect(await db.transaction(tx => recordNativeLocalProcessStop(tx as unknown as typeof db, source))).toBe(true);
|
||||
await db.update(heartbeatRuns).set({ processPid: null }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
if (scenario === "after_reset_workspace_hold") {
|
||||
await db.update(heartbeatRuns).set({ processPid: 999999999, resultJson: { workspaceRestoreFailure: "restore_unsafe_archive" } }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
import type { DeferredExecutionScope, DeferredReceiptRecoveryOptions } from "../deferred-execution-notifications.js";
|
||||
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
|
||||
import { issueBlockersResolvedWakePayloadSql } from "../issue-dependency-wakeups.js";
|
||||
|
||||
type Entry = { id: string; companyId: string; dueAt: number; immediate: boolean };
|
||||
type NativeIntentOptions = DeferredExecutionScope & {
|
||||
@@ -74,6 +75,7 @@ export function registerDeferredReceiptWork(
|
||||
sql`jsonb_typeof(${agentWakeupRequests.payload}#>'{_paperclipWakeContext,wakeCommentIds}') = 'array'
|
||||
and ${agentWakeupRequests.payload}#>'{_paperclipWakeContext,wakeCommentIds}' <> '[]'::jsonb`,
|
||||
sql`${agentWakeupRequests.payload}->>'mutation' = 'interaction'`,
|
||||
issueBlockersResolvedWakePayloadSql,
|
||||
sql`exists (select 1 from heartbeat_runs r where r.company_id = ${agentWakeupRequests.companyId}
|
||||
and r.runtime_mode = 'legacy' and r.status = 'cancelled'
|
||||
and r.result_json->>'queuedCommentInterruptQueueId' = ${agentWakeupRequests.id}::text)`,
|
||||
|
||||
@@ -144,7 +144,11 @@ import {
|
||||
isThrottleCandidateIssueRewake,
|
||||
} from "../issue-rewake-throttle.js";
|
||||
import { logActivity } from "../activity-log.js";
|
||||
import { ISSUE_BLOCKERS_RESOLVED_WAKE_REASON } from "../issue-dependency-wakeups.js";
|
||||
import {
|
||||
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
|
||||
isIssueBlockersResolvedWakePayload,
|
||||
issueBlockersResolvedWakePayloadSql,
|
||||
} from "../issue-dependency-wakeups.js";
|
||||
import { resumeIfPauseAlreadyReleased } from "../issue-tree-resume.js";
|
||||
import {
|
||||
ISSUE_PAUSED_ERROR_CODE,
|
||||
@@ -1757,7 +1761,8 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
or(and(
|
||||
sql`jsonb_typeof(${agentWakeupRequests.payload} #> '{_paperclipWakeContext,wakeCommentIds}') = 'array'`,
|
||||
sql`${agentWakeupRequests.payload} #> '{_paperclipWakeContext,wakeCommentIds}' <> '[]'::jsonb`,
|
||||
), sql`${agentWakeupRequests.payload}->>'mutation' = 'interaction'`),
|
||||
), sql`${agentWakeupRequests.payload}->>'mutation' = 'interaction'`,
|
||||
issueBlockersResolvedWakePayloadSql),
|
||||
sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is null`,
|
||||
sql`NOT EXISTS (SELECT 1 FROM heartbeat_runs live_issue_run
|
||||
WHERE live_issue_run.company_id = ${issues.companyId}
|
||||
@@ -1768,7 +1773,8 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
.orderBy(asc(agentWakeupRequests.updatedAt)).limit(50);
|
||||
const releasedNativeSources = new Set<string>();
|
||||
for (const { wake } of strandedQueues) {
|
||||
if (!queuedCommentIdsFromWakePayload(wake.payload).length &&
|
||||
const dependencyWake = isIssueBlockersResolvedWakePayload(wake.payload);
|
||||
if (!dependencyWake && !queuedCommentIdsFromWakePayload(wake.payload).length &&
|
||||
!await readQueuedInteractionResponse(db, wake.companyId, String(wake.payload?.issueId), wake.payload)) continue;
|
||||
const [latest] = await db.select({
|
||||
id: heartbeatRuns.id,
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentWakeupRequests } from "@paperclipai/db";
|
||||
|
||||
export const ISSUE_BLOCKERS_RESOLVED_WAKE_REASON = "issue_blockers_resolved";
|
||||
|
||||
export const issueBlockersResolvedWakePayloadSql =
|
||||
sql`${agentWakeupRequests.payload}#>>'{_paperclipWakeContext,wakeReason}' = ${ISSUE_BLOCKERS_RESOLVED_WAKE_REASON}`;
|
||||
|
||||
export function isIssueBlockersResolvedWakePayload(payload: Record<string, unknown> | null | undefined): boolean {
|
||||
const context = payload?._paperclipWakeContext;
|
||||
return context !== null && typeof context === "object" && !Array.isArray(context)
|
||||
&& (context as Record<string, unknown>).wakeReason === ISSUE_BLOCKERS_RESOLVED_WAKE_REASON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether moving an issue into `blocked` should immediately wake its assignee
|
||||
* because every recorded blocker is already resolved ("blockers restored").
|
||||
|
||||
@@ -9,12 +9,23 @@
|
||||
* "mode": "cloud",
|
||||
* "catalogVersion": "2026.720.0",
|
||||
* "features": { "<feature-key>": true | false, ... },
|
||||
* "plugins": { "autoInstall": ["daytona", "kubernetes"] },
|
||||
* "plugins": { "autoInstall": ["daytona", "remote-host"] },
|
||||
* "environments": [
|
||||
* { "name": "Daytona", "provider": "daytona", "config": { "target": "us" } }
|
||||
* { "name": "Daytona", "provider": "daytona", "config": { "target": "us" } },
|
||||
* {
|
||||
* "name": "Remote Host",
|
||||
* "driver": "plugin",
|
||||
* "pluginKey": "example.remote-host-plugin",
|
||||
* "driverKey": "tasks",
|
||||
* "driverConfig": {}
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* An `environments` entry without `driver` is the single sandbox entry; any
|
||||
* number of `driver: "plugin"` entries may sit beside it (see
|
||||
* `MANAGED_ENVIRONMENT_DRIVERS`).
|
||||
*
|
||||
* Parsing follows the `execution-policy-bootstrap.ts` doctrine: a pure
|
||||
* function over `Record<string, string | undefined>`, strict, and fail
|
||||
* closed — bad JSON, an unsupported `v`, an unknown feature key, a feature
|
||||
@@ -54,8 +65,47 @@ export interface ManagedInstanceConfig {
|
||||
readonly workerEnv?: Readonly<Record<string, readonly string[]>>;
|
||||
readonly enabledCompanies?: Readonly<Record<string, readonly string[]>>;
|
||||
};
|
||||
/** Sandbox environments the control plane provisions at boot (empty when the section is absent). */
|
||||
/**
|
||||
* The sandbox entry of the `environments` section: zero or one, since it
|
||||
* provisions the single Paperclip-managed sandbox slot (empty when the
|
||||
* section is absent or declares only plugin entries).
|
||||
*/
|
||||
environments: readonly ManagedEnvironmentSpec[];
|
||||
/** The `driver: "plugin"` entries of the `environments` section (empty when none are declared). */
|
||||
pluginEnvironments: readonly ManagedPluginEnvironmentSpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment drivers a managed-config `environments` entry may declare.
|
||||
* An entry without `driver` is the sandbox entry, so documents written
|
||||
* before plugin entries existed parse unchanged.
|
||||
*/
|
||||
export const MANAGED_ENVIRONMENT_DRIVERS = ["sandbox", "plugin"] as const;
|
||||
export type ManagedEnvironmentDriver = (typeof MANAGED_ENVIRONMENT_DRIVERS)[number];
|
||||
|
||||
/**
|
||||
* One declared managed plugin environment: an instance-level `plugin`-driver
|
||||
* environment row served by an auto-installed plugin's environment driver.
|
||||
* The row is
|
||||
* identified by its `pluginKey`–`driverKey` pair, which boot stamps on the
|
||||
* row as `metadata.managedPluginEnvironment`.
|
||||
*/
|
||||
export interface ManagedPluginEnvironmentSpec {
|
||||
driver: "plugin";
|
||||
/** Display name of the instance-level environment row (unique per instance). */
|
||||
name: string;
|
||||
description?: string;
|
||||
/**
|
||||
* The serving plugin's manifest id (for example
|
||||
* `example.remote-host-plugin`), not its `plugins.autoInstall` catalog key.
|
||||
* Boot requires a catalog entry
|
||||
* whose `key` is in `plugins.autoInstall` to install this id.
|
||||
*/
|
||||
pluginKey: string;
|
||||
/** The plugin's environment driver key. */
|
||||
driverKey: string;
|
||||
/** Driver config, validated by the plugin at ensure time; never carries secrets. */
|
||||
driverConfig: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,6 +192,155 @@ function describeJsonValue(value: unknown): string {
|
||||
return `${JSON.stringify(value)}`;
|
||||
}
|
||||
|
||||
const SANDBOX_ENTRY_KEYS = ["name", "description", "driver", "provider", "config"] as const;
|
||||
const PLUGIN_ENTRY_KEYS = [
|
||||
"name",
|
||||
"description",
|
||||
"driver",
|
||||
"pluginKey",
|
||||
"driverKey",
|
||||
"driverConfig",
|
||||
] as const;
|
||||
/** Same rule as the plugin environment config schema in `environment-config.ts`. */
|
||||
const PLUGIN_DRIVER_KEY_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
|
||||
function requireTrimmedString(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
|
||||
fail(`"${path}" must be a non-empty string without surrounding whitespace (got ${describeJsonValue(value)})`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readOptionalDescription(value: unknown, path: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
fail(`"${path}" must be a non-empty string when present (got ${describeJsonValue(value)})`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readSecretFreeObject(value: unknown, path: string): Record<string, unknown> {
|
||||
if (value === undefined) return {};
|
||||
if (!isPlainObject(value)) {
|
||||
fail(`"${path}" must be an object (got ${describeJsonValue(value)})`);
|
||||
}
|
||||
const secretLikeKey = findSecretLikeConfigKey(value, "");
|
||||
if (secretLikeKey) {
|
||||
fail(
|
||||
`"${path}" key "${secretLikeKey}" looks secret-bearing; credentials are delivered to managed instances as process environment variables (the provider's documented env fallback), never in the managed-config document`,
|
||||
);
|
||||
}
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `environments` section: at most one sandbox entry (no `driver`,
|
||||
* or `driver: "sandbox"`) plus any number of `driver: "plugin"` entries.
|
||||
* Names are unique across the section, and plugin entries are unique by
|
||||
* their `pluginKey`–`driverKey` pair.
|
||||
*/
|
||||
function parseManagedEnvironmentsSection(
|
||||
raw: unknown,
|
||||
autoInstall: readonly string[],
|
||||
): { sandbox: ManagedEnvironmentSpec[]; plugin: ManagedPluginEnvironmentSpec[] } {
|
||||
const sandbox: ManagedEnvironmentSpec[] = [];
|
||||
const plugin: ManagedPluginEnvironmentSpec[] = [];
|
||||
if (raw === undefined) return { sandbox, plugin };
|
||||
if (!Array.isArray(raw)) {
|
||||
fail(`"environments" must be an array of environment objects (got ${describeJsonValue(raw)})`);
|
||||
}
|
||||
const names = new Set<string>();
|
||||
const pluginDrivers = new Set<string>();
|
||||
for (const [index, entry] of raw.entries()) {
|
||||
const at = `environments[${index}]`;
|
||||
if (!isPlainObject(entry)) {
|
||||
fail(`"${at}" must be an object (got ${describeJsonValue(entry)})`);
|
||||
}
|
||||
// Only an absent `driver` means the sandbox entry; an explicit `null` is malformed.
|
||||
const driver = entry.driver === undefined ? "sandbox" : entry.driver;
|
||||
if (!(MANAGED_ENVIRONMENT_DRIVERS as readonly unknown[]).includes(driver)) {
|
||||
fail(
|
||||
`"${at}.driver" must be one of ${MANAGED_ENVIRONMENT_DRIVERS.map((value) => JSON.stringify(value)).join(", ")} when present (got ${describeJsonValue(entry.driver)})`,
|
||||
);
|
||||
}
|
||||
const allowedKeys: readonly string[] = driver === "plugin" ? PLUGIN_ENTRY_KEYS : SANDBOX_ENTRY_KEYS;
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
fail(`"${at}" has unknown key "${key}" (allowed for a ${driver} entry: ${allowedKeys.join(", ")})`);
|
||||
}
|
||||
}
|
||||
const name = requireTrimmedString(entry.name, `${at}.name`);
|
||||
if (names.has(name)) {
|
||||
fail(`"${at}.name" duplicates another entry's name "${name}"; environment names are unique per instance`);
|
||||
}
|
||||
names.add(name);
|
||||
const description = readOptionalDescription(entry.description, `${at}.description`);
|
||||
|
||||
if (driver === "sandbox") {
|
||||
// The DB enforces at most ONE Paperclip-managed sandbox row per instance
|
||||
// (partial unique index `environments_managed_sandbox_idx`); every
|
||||
// sandbox entry provisions that row, so a second one can never be
|
||||
// satisfied.
|
||||
if (sandbox.length > 0) {
|
||||
fail(
|
||||
`"environments" supports at most one sandbox entry: each sandbox entry provisions the single Paperclip-managed sandbox environment (DB invariant environments_managed_sandbox_idx)`,
|
||||
);
|
||||
}
|
||||
const provider = requireTrimmedString(entry.provider, `${at}.provider`);
|
||||
// Coherence: on a managed instance the control plane is the only plugin
|
||||
// install path, so an environment whose provider plugin is not
|
||||
// auto-installed could never serve a lease. Catch the skew at parse time.
|
||||
if (!autoInstall.includes(provider)) {
|
||||
fail(
|
||||
`"${at}.provider" is "${provider}", which is not in "plugins.autoInstall"; a managed environment requires its provider plugin to be provisioned`,
|
||||
);
|
||||
}
|
||||
if (isPlainObject(entry.config) && entry.config.provider !== undefined) {
|
||||
fail(`"${at}.config" must not set "provider"; it is forced from the entry's provider key`);
|
||||
}
|
||||
const config = readSecretFreeObject(entry.config, `${at}.config`);
|
||||
sandbox.push(
|
||||
Object.freeze({
|
||||
name,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
provider,
|
||||
config: Object.freeze(config),
|
||||
}) as ManagedEnvironmentSpec,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// `pluginKey` is the plugin's manifest id (what the row's config stores),
|
||||
// not a `plugins.autoInstall` catalog key, so this pure parser cannot map
|
||||
// it. `applyManagedEnvironments` refuses startup when no auto-installed
|
||||
// catalog entry installs this manifest id.
|
||||
const pluginKey = requireTrimmedString(entry.pluginKey, `${at}.pluginKey`);
|
||||
const driverKey = requireTrimmedString(entry.driverKey, `${at}.driverKey`);
|
||||
if (!PLUGIN_DRIVER_KEY_PATTERN.test(driverKey)) {
|
||||
fail(
|
||||
`"${at}.driverKey" must start with a lowercase letter or digit and contain only lowercase letters, digits, dots, hyphens, or underscores (got ${describeJsonValue(driverKey)})`,
|
||||
);
|
||||
}
|
||||
const pair = `${pluginKey}:${driverKey}`;
|
||||
if (pluginDrivers.has(pair)) {
|
||||
fail(`"${at}" duplicates another plugin entry's pluginKey and driverKey ("${pair}"); each plugin environment driver is declared once`);
|
||||
}
|
||||
pluginDrivers.add(pair);
|
||||
const driverConfig = readSecretFreeObject(entry.driverConfig, `${at}.driverConfig`);
|
||||
plugin.push(
|
||||
Object.freeze({
|
||||
driver: "plugin",
|
||||
name,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
pluginKey,
|
||||
driverKey,
|
||||
driverConfig: Object.freeze(driverConfig),
|
||||
}) as ManagedPluginEnvironmentSpec,
|
||||
);
|
||||
}
|
||||
return { sandbox, plugin };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `PAPERCLIP_MANAGED_CONFIG` from a raw env map. Returns null only when
|
||||
* the variable is absent (self-hosted). Throws with a precise error on a
|
||||
@@ -320,79 +519,10 @@ export function parseManagedConfigEnv(env: ManagedConfigEnv): ManagedInstanceCon
|
||||
// absence is not a dropped security control — it simply declares no managed
|
||||
// environments. When present, the section is validated fail-closed like
|
||||
// everything else.
|
||||
const environmentSpecs: ManagedEnvironmentSpec[] = [];
|
||||
if (doc.environments !== undefined) {
|
||||
if (!Array.isArray(doc.environments)) {
|
||||
fail(`"environments" must be an array of environment objects (got ${describeJsonValue(doc.environments)})`);
|
||||
}
|
||||
// The DB enforces at most ONE Paperclip-managed sandbox row per instance
|
||||
// (partial unique index `environments_managed_sandbox_idx`); every entry
|
||||
// here provisions that row, so a longer list can never be satisfied.
|
||||
if (doc.environments.length > 1) {
|
||||
fail(
|
||||
`"environments" supports at most one entry: each entry provisions the single Paperclip-managed sandbox environment (DB invariant environments_managed_sandbox_idx)`,
|
||||
);
|
||||
}
|
||||
for (const [index, entry] of doc.environments.entries()) {
|
||||
if (!isPlainObject(entry)) {
|
||||
fail(`"environments[${index}]" must be an object (got ${describeJsonValue(entry)})`);
|
||||
}
|
||||
const allowedEntryKeys = new Set(["name", "description", "provider", "config"]);
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!allowedEntryKeys.has(key)) {
|
||||
fail(`"environments[${index}]" has unknown key "${key}" (allowed: name, description, provider, config)`);
|
||||
}
|
||||
}
|
||||
if (typeof entry.name !== "string" || entry.name.length === 0 || entry.name.trim() !== entry.name) {
|
||||
fail(
|
||||
`"environments[${index}].name" must be a non-empty string without surrounding whitespace (got ${describeJsonValue(entry.name)})`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
entry.description !== undefined
|
||||
&& (typeof entry.description !== "string" || entry.description.trim().length === 0)
|
||||
) {
|
||||
fail(`"environments[${index}].description" must be a non-empty string when present (got ${describeJsonValue(entry.description)})`);
|
||||
}
|
||||
if (typeof entry.provider !== "string" || entry.provider.length === 0 || entry.provider.trim() !== entry.provider) {
|
||||
fail(
|
||||
`"environments[${index}].provider" must be a non-empty string without surrounding whitespace (got ${describeJsonValue(entry.provider)})`,
|
||||
);
|
||||
}
|
||||
// Coherence: on a managed instance the control plane is the only plugin
|
||||
// install path, so an environment whose provider plugin is not
|
||||
// auto-installed could never serve a lease. Catch the skew at parse time.
|
||||
if (!autoInstall.includes(entry.provider)) {
|
||||
fail(
|
||||
`"environments[${index}].provider" is "${entry.provider}", which is not in "plugins.autoInstall"; a managed environment requires its provider plugin to be provisioned`,
|
||||
);
|
||||
}
|
||||
const config: Record<string, unknown> = {};
|
||||
if (entry.config !== undefined) {
|
||||
if (!isPlainObject(entry.config)) {
|
||||
fail(`"environments[${index}].config" must be an object (got ${describeJsonValue(entry.config)})`);
|
||||
}
|
||||
if (entry.config.provider !== undefined) {
|
||||
fail(`"environments[${index}].config" must not set "provider"; it is forced from the entry's provider key`);
|
||||
}
|
||||
const secretLikeKey = findSecretLikeConfigKey(entry.config, "");
|
||||
if (secretLikeKey) {
|
||||
fail(
|
||||
`"environments[${index}].config" key "${secretLikeKey}" looks secret-bearing; credentials are delivered to managed instances as process environment variables (the provider's documented env fallback), never in the managed-config document`,
|
||||
);
|
||||
}
|
||||
Object.assign(config, entry.config);
|
||||
}
|
||||
environmentSpecs.push(
|
||||
Object.freeze({
|
||||
name: entry.name,
|
||||
...(entry.description !== undefined ? { description: entry.description } : {}),
|
||||
provider: entry.provider,
|
||||
config: Object.freeze(config),
|
||||
}) as ManagedEnvironmentSpec,
|
||||
);
|
||||
}
|
||||
}
|
||||
const { sandbox: environmentSpecs, plugin: pluginEnvironmentSpecs } = parseManagedEnvironmentsSection(
|
||||
doc.environments,
|
||||
autoInstall,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
v: SUPPORTED_MANAGED_CONFIG_VERSION,
|
||||
@@ -405,6 +535,7 @@ export function parseManagedConfigEnv(env: ManagedConfigEnv): ManagedInstanceCon
|
||||
...(enabledCompanies ? { enabledCompanies: Object.freeze(enabledCompanies) } : {}),
|
||||
}),
|
||||
environments: Object.freeze(environmentSpecs),
|
||||
pluginEnvironments: Object.freeze(pluginEnvironmentSpecs),
|
||||
}) as ManagedInstanceConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { applyManagedEnvironments } from "./managed-environments.js";
|
||||
import {
|
||||
PLUGIN_KEY,
|
||||
environmentsSeam,
|
||||
instanceSettingsSeam,
|
||||
noDb,
|
||||
parsedConfig,
|
||||
pluginEnvironmentRow,
|
||||
readyDriverResolver,
|
||||
reconciliationResult,
|
||||
recoveryHandle,
|
||||
tick,
|
||||
type InstanceSettingsSeam,
|
||||
type RecoveryHandle,
|
||||
} from "./managed-environments.test-support.js";
|
||||
|
||||
describe("applyManagedEnvironments plugin entries", () => {
|
||||
const pluginEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: "Remote Host",
|
||||
description: "Runs the agent on a remote host",
|
||||
driver: "plugin",
|
||||
pluginKey: PLUGIN_KEY,
|
||||
driverKey: "tasks",
|
||||
driverConfig: {},
|
||||
...overrides,
|
||||
});
|
||||
const configWith = (environments: unknown[], features: Record<string, unknown> = {}) => parsedConfig({
|
||||
plugins: { autoInstall: ["daytona", "remote-host"] },
|
||||
features,
|
||||
environments,
|
||||
});
|
||||
const readyPlugin = (status = "ready", declaresDriver = true) => vi.fn(async () => ({
|
||||
plugin: { id: "remote-host-plugin", pluginKey: PLUGIN_KEY, status },
|
||||
declaresDriver,
|
||||
}));
|
||||
const workerManager = (running = true, handle?: RecoveryHandle) => ({
|
||||
isRunning: vi.fn(() => running),
|
||||
call: vi.fn(),
|
||||
getWorker: vi.fn(() => handle),
|
||||
});
|
||||
|
||||
it("ensures a plugin entry with the plugin validator's worker manager", async () => {
|
||||
const environments = environmentsSeam();
|
||||
const result = await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
|
||||
expect(environments.ensureManagedPluginEnvironment).toHaveBeenCalledWith({
|
||||
name: "Remote Host",
|
||||
description: "Runs the agent on a remote host",
|
||||
pluginKey: PLUGIN_KEY,
|
||||
driverKey: "tasks",
|
||||
driverConfig: {},
|
||||
workerManager: { isRunning: expect.any(Function), call: expect.any(Function) },
|
||||
});
|
||||
expect(result).toMatchObject({ ensured: 1, added: 1, failed: 0 });
|
||||
expect(result?.pluginOutcomes).toEqual([{
|
||||
environmentId: "plugin-env",
|
||||
name: "Remote Host",
|
||||
pluginKey: PLUGIN_KEY,
|
||||
driverKey: "tasks",
|
||||
action: "added",
|
||||
}]);
|
||||
expect(environments.ensureManagedSandboxEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the sandbox entry first", async () => {
|
||||
const order: string[] = [];
|
||||
const environments = environmentsSeam({
|
||||
ensureManagedSandboxEnvironment: vi.fn(async () => {
|
||||
order.push("sandbox");
|
||||
return reconciliationResult() as never;
|
||||
}),
|
||||
ensureManagedPluginEnvironment: vi.fn(async () => {
|
||||
order.push("plugin");
|
||||
return { environment: pluginEnvironmentRow(), action: "added" as const };
|
||||
}),
|
||||
});
|
||||
await applyManagedEnvironments(noDb, configWith([{ name: "Daytona", provider: "daytona" }, pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(order).toEqual(["sandbox", "plugin"]);
|
||||
});
|
||||
|
||||
it("refuses startup when no auto-installed catalog entry installs the entry's plugin", async () => {
|
||||
const environments = environmentsSeam();
|
||||
await expect(applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: ["paperclip.daytona-sandbox-provider"],
|
||||
})).rejects.toThrow(/uses plugin "example.remote-host-plugin", which no "plugins.autoInstall" catalog entry installs/);
|
||||
expect(environments.ensureManagedPluginEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the auto-installed manifest ids from the catalog when the caller passes none", async () => {
|
||||
// "remote-host" is not in this build's bundled catalog and the test root
|
||||
// has no distribution catalog, so the catalog lookup refuses.
|
||||
await expect(applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: { PAPERCLIP_BUNDLED_PLUGIN_ROOT: "/nonexistent-paperclip-catalog" },
|
||||
workerManager: workerManager(),
|
||||
environments: environmentsSeam(),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
})).rejects.toThrow(/auto-install key "remote-host" is not in the bundled catalog/);
|
||||
});
|
||||
|
||||
it("does not claim the sandbox slot, so a forced execution mode can coexist with plugin-only entries", async () => {
|
||||
await expect(applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: { PAPERCLIP_EXECUTION_MODE: "kubernetes" },
|
||||
workerManager: workerManager(),
|
||||
environments: environmentsSeam(),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
})).resolves.toMatchObject({ ensured: 1 });
|
||||
});
|
||||
|
||||
it("archives the row of a plugin that is down and reactivates it on worker recovery", async () => {
|
||||
const { handle, fireReady } = recoveryHandle();
|
||||
const environments = environmentsSeam({
|
||||
archiveManagedPluginEnvironment: vi.fn().mockResolvedValue(pluginEnvironmentRow({ status: "archived" })),
|
||||
});
|
||||
const manager = workerManager(false, handle);
|
||||
const result = await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
||||
expect(environments.archiveManagedPluginEnvironment).toHaveBeenCalledWith({ pluginKey: PLUGIN_KEY, driverKey: "tasks" });
|
||||
expect(environments.ensureManagedPluginEnvironment).not.toHaveBeenCalled();
|
||||
|
||||
manager.isRunning.mockReturnValue(true);
|
||||
fireReady();
|
||||
fireReady();
|
||||
await tick();
|
||||
|
||||
expect(environments.ensureManagedPluginEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(handle.off).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps recovery armed after a worker failure and stops after one success", async () => {
|
||||
const { handle, fireReady } = recoveryHandle();
|
||||
const ensureManagedPluginEnvironment = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("worker crashed during environmentValidateConfig"))
|
||||
.mockResolvedValue({ environment: pluginEnvironmentRow(), action: "updated" });
|
||||
const environments = environmentsSeam({ ensureManagedPluginEnvironment });
|
||||
const manager = workerManager(false, handle);
|
||||
await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
manager.isRunning.mockReturnValue(true);
|
||||
|
||||
fireReady();
|
||||
await tick();
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(handle.off).not.toHaveBeenCalled();
|
||||
|
||||
fireReady();
|
||||
await tick();
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(2);
|
||||
expect(handle.off).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireReady();
|
||||
await tick();
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("queues one more attempt when the worker becomes ready during an attempt", async () => {
|
||||
const { handle, fireReady } = recoveryHandle();
|
||||
let failFirst!: (err: Error) => void;
|
||||
const ensureManagedPluginEnvironment = vi.fn()
|
||||
.mockImplementationOnce(() => new Promise((_resolve, reject) => { failFirst = reject; }))
|
||||
.mockResolvedValue({ environment: pluginEnvironmentRow(), action: "updated" });
|
||||
const manager = workerManager(false, handle);
|
||||
await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments: environmentsSeam({ ensureManagedPluginEnvironment }),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
manager.isRunning.mockReturnValue(true);
|
||||
|
||||
fireReady();
|
||||
fireReady();
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(1);
|
||||
failFirst(new Error("worker crashed"));
|
||||
await tick();
|
||||
await tick();
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(2);
|
||||
expect(handle.off).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops recovery when the plugin permanently rejects the config", async () => {
|
||||
const { handle, fireReady } = recoveryHandle();
|
||||
const ensureManagedPluginEnvironment = vi.fn().mockRejectedValue(unprocessable("driverConfig.region is not supported"));
|
||||
const manager = workerManager(false, handle);
|
||||
await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments: environmentsSeam({ ensureManagedPluginEnvironment }),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
manager.isRunning.mockReturnValue(true);
|
||||
|
||||
fireReady();
|
||||
await tick();
|
||||
fireReady();
|
||||
await tick();
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(handle.off).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("archives and arms recovery when the worker dies during the boot ensure", async () => {
|
||||
const { handle, fireReady } = recoveryHandle();
|
||||
const manager = workerManager(true, handle);
|
||||
const ensureManagedPluginEnvironment = vi.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
manager.isRunning.mockReturnValue(false);
|
||||
throw new Error("worker crashed during environmentValidateConfig");
|
||||
})
|
||||
.mockResolvedValue({ environment: pluginEnvironmentRow(), action: "updated" });
|
||||
const environments = environmentsSeam({ ensureManagedPluginEnvironment });
|
||||
const result = await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
||||
expect(environments.archiveManagedPluginEnvironment).toHaveBeenCalledWith({ pluginKey: PLUGIN_KEY, driverKey: "tasks" });
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.isRunning.mockReturnValue(true);
|
||||
fireReady();
|
||||
await tick();
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(2);
|
||||
expect(handle.off).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the row and retries when the ensure fails while the worker keeps running", async () => {
|
||||
const { handle, fireReady } = recoveryHandle();
|
||||
const ensureManagedPluginEnvironment = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("environmentValidateConfig timed out"))
|
||||
.mockRejectedValueOnce(new Error("environmentValidateConfig timed out"))
|
||||
.mockResolvedValue({ environment: pluginEnvironmentRow(), action: "unchanged" });
|
||||
const environments = environmentsSeam({ ensureManagedPluginEnvironment });
|
||||
const result = await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: workerManager(true, handle),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
||||
expect(environments.archiveManagedPluginEnvironment).not.toHaveBeenCalled();
|
||||
// The immediate retry also failed; the next `ready` succeeds.
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(2);
|
||||
fireReady();
|
||||
await tick();
|
||||
expect(ensureManagedPluginEnvironment).toHaveBeenCalledTimes(3);
|
||||
expect(handle.off).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("neither archives nor retries when the boot ensure gets a permanent config rejection", async () => {
|
||||
const { handle } = recoveryHandle();
|
||||
const environments = environmentsSeam({
|
||||
ensureManagedPluginEnvironment: vi.fn().mockRejectedValue(unprocessable("driverConfig.region is not supported")),
|
||||
});
|
||||
const manager = workerManager(true, handle);
|
||||
const result = await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
||||
expect(environments.archiveManagedPluginEnvironment).not.toHaveBeenCalled();
|
||||
expect(manager.getWorker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a missing plugin, a plugin without the driver, or a worker manager without call as unavailable", async () => {
|
||||
for (const [resolveEnvironmentPlugin, manager] of [
|
||||
[vi.fn(async () => null), workerManager()],
|
||||
[readyPlugin("ready", false), workerManager()],
|
||||
[readyPlugin("error"), workerManager()],
|
||||
[readyPlugin(), { isRunning: vi.fn(() => true), getWorker: vi.fn(() => undefined) }],
|
||||
] as const) {
|
||||
const environments = environmentsSeam();
|
||||
const result = await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin,
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
||||
expect(environments.archiveManagedPluginEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(environments.ensureManagedPluginEnvironment).not.toHaveBeenCalled();
|
||||
expect(manager.getWorker).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("never archives a row whose entry was removed from the document", async () => {
|
||||
const environments = environmentsSeam();
|
||||
expect(await applyManagedEnvironments(noDb, configWith([]), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
environments,
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
})).toBeNull();
|
||||
expect(environments.archiveManagedPluginEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never changes the instance default, and the sandbox stamp never replaces a plugin default", async () => {
|
||||
const settings = instanceSettingsSeam({
|
||||
get: vi.fn().mockResolvedValue({ defaultEnvironmentId: "plugin-env" }) as InstanceSettingsSeam["get"],
|
||||
});
|
||||
await applyManagedEnvironments(noDb, configWith(
|
||||
[{ name: "Daytona", provider: "daytona" }, pluginEntry()],
|
||||
{ enableManagedSandboxOnly: true },
|
||||
), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
instanceSettings: settings,
|
||||
environments: environmentsSeam({ getById: vi.fn().mockResolvedValue(pluginEnvironmentRow()) }),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(settings.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is fail-safe: an ensure failure is logged and counted, not thrown", async () => {
|
||||
const result = await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
environments: environmentsSeam({
|
||||
ensureManagedPluginEnvironment: vi.fn().mockRejectedValue(new Error("validator refused")),
|
||||
}),
|
||||
instanceSettings: instanceSettingsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(result).toMatchObject({ ensured: 0, failed: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Shared fixtures and service seams for the `applyManagedEnvironments` tests
|
||||
* (`managed-environments.test.ts` for the sandbox entry and instance default,
|
||||
* `managed-environments-plugin-entries.test.ts` for plugin entries).
|
||||
*/
|
||||
import { vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { MANAGED_CONFIG_ENV_KEY, parseManagedConfigEnv } from "./managed-config.js";
|
||||
import type { ApplyManagedEnvironmentsOptions } from "./managed-environments.js";
|
||||
|
||||
export const noDb = null as unknown as Db;
|
||||
|
||||
export function parsedConfig(overrides: Record<string, unknown> = {}) {
|
||||
const config = parseManagedConfigEnv({
|
||||
[MANAGED_CONFIG_ENV_KEY]: JSON.stringify({
|
||||
v: 1,
|
||||
mode: "cloud",
|
||||
catalogVersion: "2026.720.0",
|
||||
features: {},
|
||||
plugins: { autoInstall: ["daytona"] },
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
if (!config) throw new Error("expected a parsed managed config");
|
||||
return config;
|
||||
}
|
||||
|
||||
export function environmentRow(overrides: Record<string, unknown> = {}) {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: "env-1",
|
||||
name: "Daytona",
|
||||
description: null,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: {},
|
||||
envVars: {},
|
||||
metadata: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function reconciliationResult(
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
environment: environmentRow(),
|
||||
action: "added" as const,
|
||||
stockStatus: "missing" as const,
|
||||
updateAvailable: false,
|
||||
stockHash: "sha256:stock",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function readyDriverResolver(status = "ready") {
|
||||
return vi.fn(async () => ({
|
||||
plugin: { id: "plugin-1", pluginKey: "sandbox-providers/daytona", status },
|
||||
}));
|
||||
}
|
||||
|
||||
export function runningWorkerManager(running = true) {
|
||||
return { isRunning: vi.fn(() => running), getWorker: vi.fn(() => undefined) };
|
||||
}
|
||||
|
||||
export type WorkerManagerSeam = NonNullable<ApplyManagedEnvironmentsOptions["workerManager"]>;
|
||||
export type RecoveryHandle = NonNullable<ReturnType<WorkerManagerSeam["getWorker"]>>;
|
||||
|
||||
/** A worker handle fake that records `ready` listeners so tests can fire them. */
|
||||
export function recoveryHandle() {
|
||||
const readyListeners: Array<(payload: { pluginId: string }) => void> = [];
|
||||
const handle: RecoveryHandle = {
|
||||
on: vi.fn((_event, listener) => {
|
||||
readyListeners.push(listener);
|
||||
}),
|
||||
off: vi.fn((_event, listener) => {
|
||||
const at = readyListeners.indexOf(listener);
|
||||
if (at !== -1) readyListeners.splice(at, 1);
|
||||
}),
|
||||
};
|
||||
return {
|
||||
handle,
|
||||
fireReady: () => {
|
||||
for (const listener of [...readyListeners]) listener({ pluginId: "plugin-1" });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Lets the fire-and-forget recovery ensure settle. */
|
||||
export function tick() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
export type EnvironmentsSeam = NonNullable<ApplyManagedEnvironmentsOptions["environments"]>;
|
||||
export type InstanceSettingsSeam = NonNullable<ApplyManagedEnvironmentsOptions["instanceSettings"]>;
|
||||
|
||||
export function environmentsSeam(overrides: Partial<EnvironmentsSeam> = {}): EnvironmentsSeam {
|
||||
return {
|
||||
ensureManagedSandboxEnvironment:
|
||||
overrides.ensureManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(reconciliationResult()),
|
||||
archiveManagedSandboxEnvironment:
|
||||
overrides.archiveManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(null),
|
||||
getById: overrides.getById ?? vi.fn().mockResolvedValue(null),
|
||||
update: overrides.update ?? vi.fn().mockResolvedValue(environmentRow()),
|
||||
findManagedSandboxEnvironment:
|
||||
overrides.findManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(null),
|
||||
ensureManagedPluginEnvironment:
|
||||
overrides.ensureManagedPluginEnvironment
|
||||
?? vi.fn().mockResolvedValue({ environment: pluginEnvironmentRow(), action: "added" }),
|
||||
archiveManagedPluginEnvironment:
|
||||
overrides.archiveManagedPluginEnvironment ?? vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
export const PLUGIN_KEY = "example.remote-host-plugin";
|
||||
|
||||
export function pluginEnvironmentRow(overrides: Record<string, unknown> = {}) {
|
||||
return environmentRow({
|
||||
id: "plugin-env",
|
||||
name: "Remote Host",
|
||||
driver: "plugin",
|
||||
config: { pluginKey: PLUGIN_KEY, driverKey: "tasks", driverConfig: {} },
|
||||
metadata: { managedByPaperclip: true, managedPluginEnvironment: `${PLUGIN_KEY}:tasks` },
|
||||
...overrides,
|
||||
}) as never;
|
||||
}
|
||||
|
||||
export function instanceSettingsSeam(overrides: Partial<InstanceSettingsSeam> = {}): InstanceSettingsSeam {
|
||||
return {
|
||||
get:
|
||||
overrides.get ??
|
||||
(vi.fn().mockResolvedValue({ defaultEnvironmentId: null }) as InstanceSettingsSeam["get"]),
|
||||
update:
|
||||
overrides.update ??
|
||||
(vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-1" }) as InstanceSettingsSeam["update"]),
|
||||
};
|
||||
}
|
||||
@@ -1,122 +1,18 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { MANAGED_CONFIG_ENV_KEY, parseManagedConfigEnv } from "./managed-config.js";
|
||||
import { applyManagedEnvironments } from "./managed-environments.js";
|
||||
import {
|
||||
applyManagedEnvironments,
|
||||
type ApplyManagedEnvironmentsOptions,
|
||||
} from "./managed-environments.js";
|
||||
|
||||
const noDb = null as unknown as Db;
|
||||
|
||||
function parsedConfig(overrides: Record<string, unknown> = {}) {
|
||||
const config = parseManagedConfigEnv({
|
||||
[MANAGED_CONFIG_ENV_KEY]: JSON.stringify({
|
||||
v: 1,
|
||||
mode: "cloud",
|
||||
catalogVersion: "2026.720.0",
|
||||
features: {},
|
||||
plugins: { autoInstall: ["daytona"] },
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
if (!config) throw new Error("expected a parsed managed config");
|
||||
return config;
|
||||
}
|
||||
|
||||
function environmentRow(overrides: Record<string, unknown> = {}) {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: "env-1",
|
||||
name: "Daytona",
|
||||
description: null,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: {},
|
||||
envVars: {},
|
||||
metadata: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function reconciliationResult(
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
environment: environmentRow(),
|
||||
action: "added" as const,
|
||||
stockStatus: "missing" as const,
|
||||
updateAvailable: false,
|
||||
stockHash: "sha256:stock",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function readyDriverResolver(status = "ready") {
|
||||
return vi.fn(async () => ({
|
||||
plugin: { id: "plugin-1", pluginKey: "sandbox-providers/daytona", status },
|
||||
}));
|
||||
}
|
||||
|
||||
function runningWorkerManager(running = true) {
|
||||
return { isRunning: vi.fn(() => running), getWorker: vi.fn(() => undefined) };
|
||||
}
|
||||
|
||||
type WorkerManagerSeam = NonNullable<ApplyManagedEnvironmentsOptions["workerManager"]>;
|
||||
type RecoveryHandle = NonNullable<ReturnType<WorkerManagerSeam["getWorker"]>>;
|
||||
|
||||
/** A worker handle fake that records `ready` listeners so tests can fire them. */
|
||||
function recoveryHandle() {
|
||||
const readyListeners: Array<(payload: { pluginId: string }) => void> = [];
|
||||
const handle: RecoveryHandle = {
|
||||
on: vi.fn((_event, listener) => {
|
||||
readyListeners.push(listener);
|
||||
}),
|
||||
off: vi.fn((_event, listener) => {
|
||||
const at = readyListeners.indexOf(listener);
|
||||
if (at !== -1) readyListeners.splice(at, 1);
|
||||
}),
|
||||
};
|
||||
return {
|
||||
handle,
|
||||
fireReady: () => {
|
||||
for (const listener of [...readyListeners]) listener({ pluginId: "plugin-1" });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Lets the fire-and-forget recovery ensure settle. */
|
||||
function tick() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
type EnvironmentsSeam = NonNullable<ApplyManagedEnvironmentsOptions["environments"]>;
|
||||
type InstanceSettingsSeam = NonNullable<ApplyManagedEnvironmentsOptions["instanceSettings"]>;
|
||||
|
||||
function environmentsSeam(overrides: Partial<EnvironmentsSeam> = {}): EnvironmentsSeam {
|
||||
return {
|
||||
ensureManagedSandboxEnvironment:
|
||||
overrides.ensureManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(reconciliationResult()),
|
||||
archiveManagedSandboxEnvironment:
|
||||
overrides.archiveManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(null),
|
||||
getById: overrides.getById ?? vi.fn().mockResolvedValue(null),
|
||||
update: overrides.update ?? vi.fn().mockResolvedValue(environmentRow()),
|
||||
findManagedSandboxEnvironment:
|
||||
overrides.findManagedSandboxEnvironment ?? vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
function instanceSettingsSeam(overrides: Partial<InstanceSettingsSeam> = {}): InstanceSettingsSeam {
|
||||
return {
|
||||
get:
|
||||
overrides.get ??
|
||||
(vi.fn().mockResolvedValue({ defaultEnvironmentId: null }) as InstanceSettingsSeam["get"]),
|
||||
update:
|
||||
overrides.update ??
|
||||
(vi.fn().mockResolvedValue({ defaultEnvironmentId: "env-1" }) as InstanceSettingsSeam["update"]),
|
||||
};
|
||||
}
|
||||
environmentRow,
|
||||
environmentsSeam,
|
||||
instanceSettingsSeam,
|
||||
noDb,
|
||||
parsedConfig,
|
||||
readyDriverResolver,
|
||||
reconciliationResult,
|
||||
recoveryHandle,
|
||||
runningWorkerManager,
|
||||
tick,
|
||||
type InstanceSettingsSeam,
|
||||
} from "./managed-environments.test-support.js";
|
||||
|
||||
describe("applyManagedEnvironments", () => {
|
||||
it("no-ops for self-hosted instances and for documents without environments", async () => {
|
||||
|
||||
@@ -43,9 +43,17 @@
|
||||
* sandbox provider falls back to its documented process environment variable
|
||||
* (e.g. `DAYTONA_API_KEY`) when `config` omits the key, so the deployment
|
||||
* delivers secrets as env vars and the managed document stays secret-free.
|
||||
*
|
||||
* `driver: "plugin"` entries take the same readiness path after the sandbox
|
||||
* entry: await `pluginsReady`, require the plugin `ready`, declaring the
|
||||
* entry's driver, and running a worker; else archive the entry's row and
|
||||
* re-ensure it on the worker's `ready`. Each entry owns one row (see
|
||||
* `managed-plugin-environments.ts`). Plugin entries never touch the instance
|
||||
* default.
|
||||
*/
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import type { ManagedSandboxEnvironmentReconcileAction } from "./environments.js";
|
||||
@@ -54,7 +62,10 @@ import type { ManagedInstanceConfig } from "./managed-config.js";
|
||||
import type { ManagedResourceStockStatus } from "./managed-resource-drift.js";
|
||||
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
|
||||
import { resolvePluginSandboxProviderDriverByKey } from "./plugin-environment-driver.js";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import { resolveBundledCatalogRoot, resolveBundledPluginInstalls } from "./bundled-plugins.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import type { ManagedPluginEnvironmentReconcileAction } from "./managed-plugin-environments.js";
|
||||
|
||||
export interface ApplyManagedEnvironmentsOptions {
|
||||
env?: Record<string, string | undefined>;
|
||||
@@ -75,8 +86,11 @@ export interface ApplyManagedEnvironmentsOptions {
|
||||
* subscription used for post-archive recovery: a worker in crash-restart
|
||||
* backoff re-emits `ready` when the manager respawns it, and the listener
|
||||
* re-ensures the archived environment (see `applyManagedEnvironments`).
|
||||
*
|
||||
* Plugin entries also need `call`, to run the plugin's own config
|
||||
* validation; without it every plugin entry is treated as unavailable.
|
||||
*/
|
||||
workerManager?: Pick<PluginWorkerManager, "isRunning"> & {
|
||||
workerManager?: Pick<PluginWorkerManager, "isRunning"> & Partial<Pick<PluginWorkerManager, "call">> & {
|
||||
getWorker(pluginId: string):
|
||||
| {
|
||||
on(event: "ready", listener: (payload: { pluginId: string }) => void): void;
|
||||
@@ -92,6 +106,8 @@ export interface ApplyManagedEnvironmentsOptions {
|
||||
| "getById"
|
||||
| "update"
|
||||
| "findManagedSandboxEnvironment"
|
||||
| "ensureManagedPluginEnvironment"
|
||||
| "archiveManagedPluginEnvironment"
|
||||
>;
|
||||
/** Test seam: overrides the instance-settings service built from `db`. */
|
||||
instanceSettings?: Pick<ReturnType<typeof instanceSettingsService>, "get" | "update">;
|
||||
@@ -100,6 +116,32 @@ export interface ApplyManagedEnvironmentsOptions {
|
||||
db: Db;
|
||||
driverKey: string;
|
||||
}) => Promise<{ plugin: { id: string; pluginKey: string; status: string } } | null>;
|
||||
/**
|
||||
* Test seam: overrides the lookup of a plugin entry's plugin. Returns null
|
||||
* when the plugin is not installed; `declaresDriver` is false when its
|
||||
* manifest does not declare the entry's environment driver.
|
||||
*/
|
||||
resolveEnvironmentPlugin?: (input: {
|
||||
db: Db;
|
||||
pluginKey: string;
|
||||
driverKey: string;
|
||||
}) => Promise<{ plugin: { id: string; pluginKey: string; status: string }; declaresDriver: boolean } | null>;
|
||||
/**
|
||||
* Manifest ids (`pluginKey`) that the `plugins.autoInstall` catalog keys
|
||||
* resolve to: `app.locals.managedBundledPluginKeys`, from
|
||||
* `resolveBundledPluginInstalls` over the bundled and distribution
|
||||
* catalogs. A plugin entry whose `pluginKey` is not in this list refuses
|
||||
* startup. When omitted, the list is resolved here the same way.
|
||||
*/
|
||||
autoInstalledPluginKeys?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ManagedPluginEnvironmentReconciliationOutcome {
|
||||
environmentId: string;
|
||||
name: string;
|
||||
pluginKey: string;
|
||||
driverKey: string;
|
||||
action: ManagedPluginEnvironmentReconcileAction;
|
||||
}
|
||||
|
||||
export interface ManagedEnvironmentReconciliationOutcome {
|
||||
@@ -123,6 +165,28 @@ export interface ApplyManagedEnvironmentsResult {
|
||||
/** Preserve-only reconciliation never creates replacement backups. */
|
||||
backedUp: number;
|
||||
outcomes: ManagedEnvironmentReconciliationOutcome[];
|
||||
pluginOutcomes: ManagedPluginEnvironmentReconciliationOutcome[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin refused the entry's config (`HttpError` 422 from
|
||||
* `normalizeEnvironmentConfig` or `validatePluginEnvironmentDriverConfig`).
|
||||
* Retrying cannot help until the config changes. Every other failure, such
|
||||
* as a worker that crashed mid-call, is treated as transient.
|
||||
*/
|
||||
function isPermanentConfigRejection(err: unknown): boolean {
|
||||
return err instanceof HttpError && err.status === 422;
|
||||
}
|
||||
|
||||
async function resolveEnvironmentPluginByKey(input: { db: Db; pluginKey: string; driverKey: string }) {
|
||||
const plugin = await pluginRegistryService(input.db).getByKey(input.pluginKey);
|
||||
if (!plugin) return null;
|
||||
return {
|
||||
plugin: { id: plugin.id, pluginKey: plugin.pluginKey, status: plugin.status },
|
||||
declaresDriver: plugin.manifestJson.environmentDrivers?.some(
|
||||
(driver) => driver.driverKey === input.driverKey && driver.kind !== "sandbox_provider",
|
||||
) ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,19 +240,38 @@ export async function applyManagedEnvironments(
|
||||
}
|
||||
}
|
||||
|
||||
if (managedConfig.environments.length === 0) return null;
|
||||
const pluginSpecs = managedConfig.pluginEnvironments;
|
||||
if (managedConfig.environments.length === 0 && pluginSpecs.length === 0) return null;
|
||||
|
||||
// The forced-execution-mode bootstrap (`PAPERCLIP_EXECUTION_MODE=kubernetes`)
|
||||
// and this one both own the single Paperclip-managed sandbox row
|
||||
// (`environments_managed_sandbox_idx`). Configuring both is contradictory;
|
||||
// refuse startup rather than let bootstrap ordering pick a winner.
|
||||
// refuse startup rather than let bootstrap ordering pick a winner. Plugin
|
||||
// entries do not touch that slot.
|
||||
const env = opts.env ?? process.env;
|
||||
if (parseExecutionPolicyBootstrapEnv(env)) {
|
||||
if (managedConfig.environments.length > 0 && parseExecutionPolicyBootstrapEnv(env)) {
|
||||
throw new Error(
|
||||
`PAPERCLIP_EXECUTION_MODE and the PAPERCLIP_MANAGED_CONFIG "environments" section are mutually exclusive: both manage the single instance sandbox environment`,
|
||||
);
|
||||
}
|
||||
|
||||
// Coherence, fail closed like the parser: a plugin entry names its plugin by
|
||||
// manifest id, and only `plugins.autoInstall` provisions plugins on a
|
||||
// managed instance, so some auto-installed catalog entry must install it.
|
||||
if (pluginSpecs.length > 0) {
|
||||
const autoInstalledPluginKeys = opts.autoInstalledPluginKeys ?? resolveBundledPluginInstalls(
|
||||
managedConfig.plugins.autoInstall,
|
||||
{ catalogRoot: resolveBundledCatalogRoot(env), env, enforceCatalogRoot: true },
|
||||
).map((install) => install.pluginKey);
|
||||
for (const spec of pluginSpecs) {
|
||||
if (!autoInstalledPluginKeys.includes(spec.pluginKey)) {
|
||||
throw new Error(
|
||||
`PAPERCLIP_MANAGED_CONFIG "environments" entry "${spec.name}" uses plugin "${spec.pluginKey}", which no "plugins.autoInstall" catalog entry installs; a managed plugin environment requires its plugin to be provisioned`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The heartbeat resumes queued runs right after this bootstrap step, and
|
||||
// lease acquisition fails hard on a provider whose plugin is missing or not
|
||||
// ready. Wait for the bundled-plugin startup pass to finish, then refuse to
|
||||
@@ -266,10 +349,7 @@ export async function applyManagedEnvironments(
|
||||
// worker recovered between the gate check and the subscription. No handle
|
||||
// means no respawn is coming (activation never started a worker), so the
|
||||
// degraded-until-next-boot posture stands.
|
||||
const scheduleRecoveryReactivation = (
|
||||
spec: ManagedInstanceConfig["environments"][number],
|
||||
pluginId: string,
|
||||
): void => {
|
||||
const scheduleRecoveryReactivation = (pluginId: string, reactivateEntry: () => Promise<void>): void => {
|
||||
const manager = opts.workerManager;
|
||||
const handle = manager?.getWorker(pluginId);
|
||||
if (!handle) return;
|
||||
@@ -278,39 +358,41 @@ export async function applyManagedEnvironments(
|
||||
if (reactivated) return;
|
||||
reactivated = true;
|
||||
handle.off("ready", reactivate);
|
||||
void environments
|
||||
.ensureManagedSandboxEnvironment({
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
provider: spec.provider,
|
||||
config: { ...spec.config },
|
||||
stockVersion: managedConfig.catalogVersion,
|
||||
platformFullyManaged: true,
|
||||
})
|
||||
.then((result) => {
|
||||
logger.info(
|
||||
{
|
||||
environmentId: result.environment.id,
|
||||
name: spec.name,
|
||||
provider: spec.provider,
|
||||
action: result.action,
|
||||
stockStatus: result.stockStatus,
|
||||
updateAvailable: result.updateAvailable,
|
||||
},
|
||||
"managed sandbox environment reactivated after provider worker recovery",
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.error(
|
||||
{ err, name: spec.name, provider: spec.provider },
|
||||
"failed to reactivate managed sandbox environment after provider worker recovery (degraded: environment unavailable until next boot)",
|
||||
);
|
||||
});
|
||||
void reactivateEntry();
|
||||
};
|
||||
handle.on("ready", reactivate);
|
||||
if (manager?.isRunning(pluginId)) reactivate();
|
||||
};
|
||||
|
||||
const reactivateSandboxEntry = async (spec: ManagedInstanceConfig["environments"][number]): Promise<void> => {
|
||||
try {
|
||||
const result = await environments.ensureManagedSandboxEnvironment({
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
provider: spec.provider,
|
||||
config: { ...spec.config },
|
||||
stockVersion: managedConfig.catalogVersion,
|
||||
platformFullyManaged: true,
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
environmentId: result.environment.id,
|
||||
name: spec.name,
|
||||
provider: spec.provider,
|
||||
action: result.action,
|
||||
stockStatus: result.stockStatus,
|
||||
updateAvailable: result.updateAvailable,
|
||||
},
|
||||
"managed sandbox environment reactivated after provider worker recovery",
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, name: spec.name, provider: spec.provider },
|
||||
"failed to reactivate managed sandbox environment after provider worker recovery (degraded: environment unavailable until next boot)",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let ensured = 0;
|
||||
let failed = 0;
|
||||
let added = 0;
|
||||
@@ -355,7 +437,7 @@ export async function applyManagedEnvironments(
|
||||
// manager restarts crashed workers, but nothing (re)installs a missing
|
||||
// or non-ready plugin at runtime.
|
||||
if (resolved && resolved.plugin.status === "ready") {
|
||||
scheduleRecoveryReactivation(spec, resolved.plugin.id);
|
||||
scheduleRecoveryReactivation(resolved.plugin.id, () => reactivateSandboxEntry(spec));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -407,6 +489,179 @@ export async function applyManagedEnvironments(
|
||||
);
|
||||
}
|
||||
}
|
||||
const resolvePlugin = opts.resolveEnvironmentPlugin ?? resolveEnvironmentPluginByKey;
|
||||
const pluginOutcomes: ManagedPluginEnvironmentReconciliationOutcome[] = [];
|
||||
|
||||
const ensurePluginEntry = (
|
||||
spec: ManagedInstanceConfig["pluginEnvironments"][number],
|
||||
workerManager: Pick<PluginWorkerManager, "isRunning" | "call">,
|
||||
) =>
|
||||
environments.ensureManagedPluginEnvironment({
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
pluginKey: spec.pluginKey,
|
||||
driverKey: spec.driverKey,
|
||||
driverConfig: { ...spec.driverConfig },
|
||||
workerManager,
|
||||
});
|
||||
|
||||
type PluginSpec = ManagedInstanceConfig["pluginEnvironments"][number];
|
||||
type PluginWorkerAccess = Pick<PluginWorkerManager, "isRunning" | "call">;
|
||||
|
||||
const archivePluginEntry = (spec: PluginSpec) =>
|
||||
environments
|
||||
.archiveManagedPluginEnvironment({ pluginKey: spec.pluginKey, driverKey: spec.driverKey })
|
||||
.catch((archiveErr: unknown) => {
|
||||
logger.error(
|
||||
{ err: archiveErr, name: spec.name, pluginKey: spec.pluginKey, driverKey: spec.driverKey },
|
||||
"failed to archive the managed plugin environment of an unavailable plugin",
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-ensure a plugin entry each time its worker reports `ready`, until one
|
||||
* ensure succeeds or the plugin permanently rejects the entry's config. A
|
||||
* worker that crashes during an attempt (for example inside
|
||||
* `environmentValidateConfig`) leaves the listener armed, so its next
|
||||
* respawn retries. A `ready` that arrives during an attempt queues one more
|
||||
* attempt. No handle means no respawn is coming (activation never started a
|
||||
* worker), so the degraded-until-next-boot posture stands.
|
||||
*/
|
||||
const schedulePluginRecovery = (spec: PluginSpec, pluginId: string, workerAccess: PluginWorkerAccess): void => {
|
||||
const manager = opts.workerManager;
|
||||
const handle = manager?.getWorker(pluginId);
|
||||
if (!handle) return;
|
||||
const logBase = { name: spec.name, pluginKey: spec.pluginKey, driverKey: spec.driverKey };
|
||||
let settled = false;
|
||||
let inFlight = false;
|
||||
let queued = false;
|
||||
const settle = (): void => {
|
||||
settled = true;
|
||||
handle.off("ready", attempt);
|
||||
};
|
||||
function attempt(): void {
|
||||
if (settled) return;
|
||||
if (inFlight) {
|
||||
queued = true;
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
void ensurePluginEntry(spec, workerAccess)
|
||||
.then((result) => {
|
||||
settle();
|
||||
logger.info(
|
||||
{ ...logBase, environmentId: result.environment.id, action: result.action },
|
||||
"managed plugin environment reactivated after plugin worker recovery",
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (isPermanentConfigRejection(err)) {
|
||||
settle();
|
||||
logger.error(
|
||||
{ err, ...logBase },
|
||||
"the plugin rejected the managed plugin environment's config; not retrying (degraded: environment unavailable until the config changes)",
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err, ...logBase },
|
||||
"failed to reactivate managed plugin environment; retrying when the plugin worker is ready again",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false;
|
||||
if (queued && !settled) {
|
||||
queued = false;
|
||||
attempt();
|
||||
}
|
||||
});
|
||||
}
|
||||
handle.on("ready", attempt);
|
||||
if (manager?.isRunning(pluginId)) attempt();
|
||||
};
|
||||
|
||||
for (const spec of pluginSpecs) {
|
||||
const logBase = { name: spec.name, pluginKey: spec.pluginKey, driverKey: spec.driverKey };
|
||||
let recoverable: { pluginId: string; workerAccess: PluginWorkerAccess } | null = null;
|
||||
try {
|
||||
const resolved = await resolvePlugin({ db, pluginKey: spec.pluginKey, driverKey: spec.driverKey });
|
||||
const manager = opts.workerManager;
|
||||
const call = manager?.call;
|
||||
// Only a `ready` record that declares the driver can recover without
|
||||
// another boot: the worker manager restarts crashed workers, but nothing
|
||||
// (re)installs a missing or non-ready plugin at runtime.
|
||||
if (resolved && resolved.plugin.status === "ready" && resolved.declaresDriver && manager && call) {
|
||||
recoverable = {
|
||||
pluginId: resolved.plugin.id,
|
||||
workerAccess: { isRunning: manager.isRunning.bind(manager), call: call.bind(manager) },
|
||||
};
|
||||
}
|
||||
const workerRunning = resolved != null && (manager?.isRunning(resolved.plugin.id) ?? false);
|
||||
if (!recoverable || !workerRunning) {
|
||||
failed += 1;
|
||||
const archived = await archivePluginEntry(spec);
|
||||
logger.error(
|
||||
{
|
||||
...logBase,
|
||||
pluginStatus: resolved?.plugin.status ?? null,
|
||||
declaresDriver: resolved?.declaresDriver ?? null,
|
||||
workerRunning,
|
||||
archivedEnvironmentId: archived?.id ?? null,
|
||||
},
|
||||
"managed plugin environment's plugin is not installed, ready, declaring its driver, and running; skipping ensure and archiving any previously provisioned row (degraded: environment unavailable)",
|
||||
);
|
||||
if (recoverable) schedulePluginRecovery(spec, recoverable.pluginId, recoverable.workerAccess);
|
||||
continue;
|
||||
}
|
||||
const result = await ensurePluginEntry(spec, recoverable.workerAccess);
|
||||
ensured += 1;
|
||||
if (result.action === "added") added += 1;
|
||||
else if (result.action === "unchanged") unchanged += 1;
|
||||
else updated += 1;
|
||||
pluginOutcomes.push({
|
||||
environmentId: result.environment.id,
|
||||
name: result.environment.name,
|
||||
pluginKey: spec.pluginKey,
|
||||
driverKey: spec.driverKey,
|
||||
action: result.action,
|
||||
});
|
||||
logger.info(
|
||||
{ ...logBase, environmentId: result.environment.id, action: result.action },
|
||||
"managed plugin environment reconciled",
|
||||
);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
if (recoverable && !isPermanentConfigRejection(err)) {
|
||||
if (!recoverable.workerAccess.isRunning(recoverable.pluginId)) {
|
||||
// The worker died after the readiness check (for example inside
|
||||
// `environmentValidateConfig`): treat it like a plugin that is
|
||||
// down, so the row is not left active, and retry on its `ready`.
|
||||
const archived = await archivePluginEntry(spec);
|
||||
logger.error(
|
||||
{ err, ...logBase, archivedEnvironmentId: archived?.id ?? null },
|
||||
"managed plugin environment's worker stopped during ensure; archived any previously provisioned row and retrying when the plugin worker is ready again (degraded: environment unavailable)",
|
||||
);
|
||||
} else {
|
||||
// The worker is still running (for example the call timed out).
|
||||
// Keep any previously provisioned row as it is, so agents keep a
|
||||
// working environment; retry once now and on the worker's next
|
||||
// `ready`, and otherwise at the next boot.
|
||||
logger.error(
|
||||
{ err, ...logBase },
|
||||
"failed to ensure managed plugin environment while its worker is running; keeping any previously provisioned row and retrying (degraded: declaration not applied)",
|
||||
);
|
||||
}
|
||||
schedulePluginRecovery(spec, recoverable.pluginId, recoverable.workerAccess);
|
||||
} else {
|
||||
logger.error(
|
||||
{ err, ...logBase },
|
||||
"failed to ensure managed plugin environment; continuing boot (degraded: environment unavailable)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ensured,
|
||||
failed,
|
||||
@@ -417,5 +672,6 @@ export async function applyManagedEnvironments(
|
||||
removed: 0,
|
||||
backedUp: 0,
|
||||
outcomes,
|
||||
pluginOutcomes,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
||||
import { nativeSessionProviderEvidence } from "./native-runtime/native-session-resume.js";
|
||||
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import os from "node:os";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export const PROCESS_START_REQUESTED = "native.process_start_requested";
|
||||
export const PROCESS_IDENTITY_RECORDED = "native.process_identity_recorded";
|
||||
@@ -56,6 +57,8 @@ export async function recordNativeLocalProcessStop(db: Db, run: Pick<typeof hear
|
||||
if (leases.some(lease => lease.provider !== "local")) return false;
|
||||
if ((run.processPid && !absent(run.processPid)) ||
|
||||
(run.processGroupId && !absent(-run.processGroupId))) return false;
|
||||
await notifyDeliveryWork(db, DELIVERY_QUEUES.executionReplacement,
|
||||
JSON.stringify({ companyId: run.companyId, runId: run.id }));
|
||||
await appendHeartbeatRunEvent(db, {
|
||||
companyId: run.companyId,
|
||||
runId: run.id,
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("native persisted case handoff runtime", () => {
|
||||
expect(await isCaseHandoffSourceReleased(db, f.input)).toBe(false);
|
||||
const exited = once(child, "exit"); child.kill("SIGTERM"); await exited;
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
expect(await recordNativeLocalProcessStop(db, run!)).toBe(true);
|
||||
expect(await db.transaction(tx => recordNativeLocalProcessStop(tx as unknown as typeof db, run!))).toBe(true);
|
||||
// A new worker has no process-local state and reconstructs the release.
|
||||
expect(await isCaseHandoffSourceReleased(db, f.input)).toBe(true);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]?.resultJson).toHaveProperty("caseHandoffExecutionReleased");
|
||||
|
||||
+154
-3
@@ -1,11 +1,21 @@
|
||||
import { startNativeSessionExecutionLeaseRenewal, type NativeExecutionLeaseRenewal } from "./native-execution-lease.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { resolve, join } from "node:path";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { environmentLeases, environments, heartbeatRuns, nativeRunFinalizations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { environmentLeases, environments, heartbeatRuns, nativeRunFinalizations, issueRecoveryActions, issues, completionContracts, nativeRunResults, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { beforeAll, expect, it, vi } from "vitest";
|
||||
import { DurablePrpControlPlane } from "../../vendor/paperclip-runner/index.js";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { DurablePrpControlPlane, createNativeSessionBackend, createRunnerdCodexTransport, defaultCapabilityRunnerdBinary, canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js";
|
||||
import { buildNativeExecutionInput } from "./native-execution-input.js";
|
||||
import { buildNativeCompletionContract } from "./completion-contracts.js";
|
||||
import { reconcileNativeFinalizations, claimNativeSessionResumptions } from "./native-finalization-reconciler.js";
|
||||
import { prepareNativeWorkspaceSync } from "./native-workspace-sync.js";
|
||||
import { workspaceOperationService } from "../workspace-operations.js";
|
||||
import type { EnvironmentLease } from "@paperclipai/shared";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
import { materializeAsset } from "./runtime-context.js";
|
||||
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
|
||||
import { executePaperclipNativeSession } from "./native-session-executor.js";
|
||||
import { registerNativeRunnerCheckpoint } from "./native-runner-checkpoint.js";
|
||||
@@ -21,6 +31,147 @@ describeEmbeddedPostgres("native runner restart recovery: preparation", () => {
|
||||
let successor: typeof environment.current.successor;
|
||||
beforeAll(() => { ({ runtimeRoot, companyId, agentId, successor } = environment.current); });
|
||||
|
||||
it.each(["recover", "provider event", "cancelled task"] as const)("reconciles the drained remote backend with %s", async mode => {
|
||||
const fixture = await seedRun(`DRAINED-BACKEND-${mode.replaceAll(" ", "-")}`);
|
||||
const environmentId = randomUUID(), leaseId = randomUUID();
|
||||
await fixture.db.insert(environments).values({ id: environmentId, name: `Drained backend ${environmentId}`, driver: "sandbox" });
|
||||
const [lease] = await fixture.db.insert(environmentLeases).values({ id: leaseId, companyId, environmentId,
|
||||
heartbeatRunId: fixture.runId, issueId: fixture.issueId, status: "active",
|
||||
provider: "fixture", providerLeaseId: `drained-backend-${leaseId}` }).returning();
|
||||
const [issue] = await fixture.db.select().from(issues).where(eq(issues.id, fixture.issueId));
|
||||
const [contract] = await fixture.db.select().from(completionContracts).where(eq(completionContracts.issueId, fixture.issueId));
|
||||
const runtimeContext = nativeRuntimeContextFixture();
|
||||
runtimeContext.instructions.bundle = await materializeAsset([{ path: "AGENTS.md", mode: 0o444,
|
||||
content: Buffer.from("Complete the controlled restart fixture task.\n") }]);
|
||||
runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(runtimeContext);
|
||||
const sandbox = join(runtimeRoot, `workspace-${fixture.runId}`);
|
||||
await mkdir(sandbox, { recursive: true });
|
||||
const savedFile = join(sandbox, "saved-before-drain.txt");
|
||||
await writeFile(savedFile, "Preserve admitted work.\n");
|
||||
const execution = buildNativeExecutionInput({ companyId, agentId, runId: fixture.runId,
|
||||
issue: { id: fixture.issueId, identifier: "NRR-DRAINED", title: "Continue after drain", description: null, workMode: "standard" },
|
||||
taskPrompt: "Continue the admitted work once.", provider: "codex",
|
||||
workspace: { id: fixture.runId, cwd: sandbox, repoUrl: null, repoRef: null, branchName: null },
|
||||
normalizedSessionId: fixture.native.normalizedSessionId, runtimeContext,
|
||||
completionContract: { id: contract.id, sha256: contract.canonicalSha256, schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: buildNativeCompletionContract(issue, { revision: contract.revision }) },
|
||||
});
|
||||
await fixture.db.update(heartbeatRuns).set({ runnerProfileJson: {
|
||||
...fixture.preparedProfile, nativeExecutionInput: execution,
|
||||
} }).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
const remoteSandbox = join(runtimeRoot, `remote-${fixture.runId}`);
|
||||
const shell: CommandManagedRuntimeRunner = { execute: async input => {
|
||||
const startedAt = new Date().toISOString();
|
||||
const child = spawn(input.command, input.args ?? [], { cwd: input.cwd,
|
||||
env: { ...process.env, ...input.env }, stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stdout = "", stderr = "", timedOut = false;
|
||||
child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; });
|
||||
child.stderr.setEncoding("utf8").on("data", chunk => { stderr += chunk; });
|
||||
child.stdin.on("error", () => undefined);
|
||||
child.stdin.end(input.stdin);
|
||||
const timeout = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, input.timeoutMs ?? 30_000);
|
||||
try { return await new Promise((resolveResult, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (exitCode, signal) => resolveResult({ stdout, stderr, exitCode, signal, timedOut,
|
||||
pid: child.pid ?? null, startedAt }));
|
||||
}); } finally { clearTimeout(timeout); }
|
||||
} };
|
||||
const workspaceSync = await prepareNativeWorkspaceSync({ db: fixture.db, runId: fixture.runId, companyId,
|
||||
workspaceId: fixture.runId, workspaceLocalDir: sandbox, lease: lease as unknown as EnvironmentLease,
|
||||
target: { kind: "remote", transport: "sandbox", environmentId, leaseId, remoteCwd: remoteSandbox, runner: shell } });
|
||||
expect(workspaceSync).not.toBeNull();
|
||||
const child = spawn(process.execPath, ["--import", createRequire(import.meta.url).resolve("tsx"),
|
||||
resolve(import.meta.dirname, "../../__tests__/helpers/native-drain-controller.test-worker.ts")],
|
||||
{ stdio: ["ignore", "pipe", "pipe", "ipc"], env: process.env });
|
||||
const logs: string[] = [];
|
||||
child.stdout?.on("data", data => logs.push(String(data)));
|
||||
child.stderr?.on("data", data => logs.push(String(data)));
|
||||
const exited = new Promise<number | null>(resolveExit => child.once("exit", resolveExit));
|
||||
try {
|
||||
const result = await new Promise<{ commands: number; errorName: string; errorMessage: string }>((resolveResult, reject) => {
|
||||
const deadline = setTimeout(() => reject(new Error(`Controller handoff timed out: ${logs.join("").slice(-2000)}`)), 60_000);
|
||||
child.once("error", error => { clearTimeout(deadline); reject(error); });
|
||||
child.once("exit", code => { clearTimeout(deadline); reject(new Error(`Controller exited before handoff (${code}): ${logs.join("").slice(-2000)}`)); });
|
||||
child.on("message", (message: { ready?: boolean; done?: boolean; commands: number; errorName: string; errorMessage: string; error?: string }) => {
|
||||
if (message.ready) child.send({ connectionString: environment.current.temporary.connectionString, providerEvidence: mode === "provider event",
|
||||
options: { execution, runnerInstanceId: fixture.native.runnerInstanceId, useRunnerd: true,
|
||||
runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId, leaseId, remoteCwd: remoteSandbox } } });
|
||||
if (message.done) { clearTimeout(deadline); resolveResult(message); }
|
||||
if (message.error) { clearTimeout(deadline); reject(new Error(message.error)); }
|
||||
});
|
||||
});
|
||||
expect(result).toMatchObject({ commands: 0, errorMessage: "local_runtime_api_listener_unavailable" });
|
||||
expect(await exited).toBe(0);
|
||||
const [handoff] = await fixture.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, fixture.runId));
|
||||
if (mode === "provider event") {
|
||||
expect(handoff).toMatchObject({ phase: "terminal_failure", nextAttemptAt: null,
|
||||
failureDetail: { recoveryMode: "ambiguous_state", providerSessionEstablished: true } });
|
||||
expect(await claimNativeSessionResumptions({ db: fixture.db, runnerInstanceId: "no-provider-replay",
|
||||
runIds: [fixture.runId], deferIneligible: false })).toHaveLength(0);
|
||||
return;
|
||||
}
|
||||
expect(handoff).toMatchObject({ phase: "retryable_failure", attempt: 1,
|
||||
failureDetail: { originalFailureCode: "local_runtime_api_listener_unavailable", recoveryMode: "bootstrap_retry",
|
||||
providerSessionEstablished: false, providerEventsExist: false, checkpointExists: false } });
|
||||
if (mode === "cancelled task") {
|
||||
await fixture.db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, fixture.issueId));
|
||||
}
|
||||
const claims = (await Promise.all(["successor-a", "successor-b"].map(runnerInstanceId =>
|
||||
claimNativeSessionResumptions({ db: fixture.db, runnerInstanceId, runIds: [fixture.runId],
|
||||
now: new Date(handoff.nextAttemptAt!.getTime() + 1), deferIneligible: false }),
|
||||
))).flat();
|
||||
if (mode === "cancelled task") { expect(claims).toHaveLength(0); return; }
|
||||
expect(claims).toHaveLength(1);
|
||||
const [run] = await fixture.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
expect(run).toMatchObject({ status: "running", nativeSessionId: fixture.native.normalizedSessionId });
|
||||
const [action] = await fixture.db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, fixture.issueId));
|
||||
expect(action).toMatchObject({ ownerType: "agent", ownerAgentId: agentId });
|
||||
if (process.env.PAPERCLIP_TEST_DRAIN_CONTINUATION === "1") {
|
||||
const claim = claims[0];
|
||||
const root = join(runtimeRoot, `continued-${fixture.runId}`);
|
||||
const home = join(root, "codex-home");
|
||||
await mkdir(home, { recursive: true });
|
||||
const report = join(root, "result.json");
|
||||
const calls = join(root, "calls.log");
|
||||
await writeFile(report, JSON.stringify({ schema: "paperclip.run_result.v1", reportedWorkDisposition: "done",
|
||||
summary: "Admitted work completed after controller restart.", completionClaim: {
|
||||
contractRevision: execution.completionContract.contract.revision, objectiveSatisfied: true,
|
||||
criteria: execution.completionContract.contract.criteria.map(criterion => ({ criterionId: criterion.id,
|
||||
status: "satisfied", evidenceRefs: [] })), remainingWork: [] },
|
||||
evidence: [], verification: [], artifacts: [], attentionRequests: [] }));
|
||||
const bundle = createRunnerdCodexTransport({ ...transportOptions(fixture, root),
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexArgs: ["--state-file", join(root, "provider.json"), "--terminal-result-file", report,
|
||||
"--call-log", calls, "--record-process-start"],
|
||||
runtimeContext: execution.runtimeContext, runnerRuntimeContext: execution.runtimeContext,
|
||||
sourceCodexHome: home, environment: { CODEX_HOME: home, PATH: process.env.PATH },
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
});
|
||||
const backend = createNativeSessionBackend(execution, { runnerInstanceId: fixture.native.runnerInstanceId,
|
||||
environment: { CODEX_HOME: home, PATH: process.env.PATH }, codexTransportFactory: () => bundle.transport });
|
||||
try {
|
||||
expect(await executePaperclipNativeSession({ db: fixture.db, execution, backend, useRunnerd: true,
|
||||
runnerInstanceId: fixture.native.runnerInstanceId, leaseOwner: claim.leaseOwner,
|
||||
runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId, leaseId, remoteCwd: remoteSandbox,
|
||||
runner: { execute: async () => { throw new Error("Unexpected remote preparation after the controlled backend owns execution"); } } as never },
|
||||
turnTimeoutMs: 15_000 })).toMatchObject({ exitCode: 0, timedOut: false });
|
||||
const recorder = workspaceOperationService(fixture.db).createRecorder({ companyId,
|
||||
issueId: fixture.issueId, heartbeatRunId: fixture.runId });
|
||||
const saved = await recorder.recordOperation({ phase: "workspace_finalize", cwd: sandbox,
|
||||
run: async () => { await workspaceSync!.restoreWorkspace(); return { status: "succeeded", exitCode: 0 }; } });
|
||||
expect(saved.status).toBe("succeeded");
|
||||
await reconcileNativeFinalizations(fixture.db, [fixture.runId]);
|
||||
expect(await fixture.db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, fixture.runId)))
|
||||
.toMatchObject([{ schemaStatus: "accepted" }]);
|
||||
const [finished] = await fixture.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, fixture.runId));
|
||||
expect(finished, JSON.stringify(finished.failureDetail)).toMatchObject({ phase: "committed", attempt: 2 });
|
||||
expect(await readFile(savedFile, "utf8")).toBe("Preserve admitted work.\n");
|
||||
expect((await readFile(calls, "utf8")).split("\n").filter(line => line === "process-start")).toHaveLength(1);
|
||||
} finally { await bundle.transport.close(); }
|
||||
}
|
||||
} finally { child.kill("SIGKILL"); await exited; await workspaceSync?.cleanup(); }
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
it.each(["preparation", "claim lock"])("gives a fresh remote claim its full lease through blocked %s", async (blockedPhase) => {
|
||||
const fixture = await seedRun(`FRESH-SLOW-${blockedPhase === "claim lock" ? "CLAIM-LOCK" : "PREPARATION"}`);
|
||||
const environmentId = randomUUID(), leaseId = randomUUID();
|
||||
|
||||
+8
-3
@@ -2220,10 +2220,15 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
|
||||
},
|
||||
);
|
||||
|
||||
it.each([2, 3])("recovers a checkpointed restart interruption within provider budget %s", async (attempt) => {
|
||||
const fixture = await seedRun(`INTERRUPTED-${attempt}`);
|
||||
it.each([
|
||||
{ attempt: 2, driverKind: "codex_app_server" },
|
||||
{ attempt: 3, driverKind: "codex_app_server" },
|
||||
{ attempt: 2, driverKind: "acpx_runtime" },
|
||||
{ attempt: 2, driverKind: "opencode_server" },
|
||||
])("recovers a checkpointed $driverKind restart interruption within provider budget $attempt", async ({ attempt, driverKind }) => {
|
||||
const fixture = await seedRun(`INTERRUPTED-${driverKind}-${attempt}`);
|
||||
await fixture.db.update(heartbeatRuns).set({ runnerProfileJson: { sessionCheckpoint: {
|
||||
driverKind: "codex_app_server",
|
||||
driverKind,
|
||||
sessionId: fixture.native.normalizedSessionId,
|
||||
identity: { companyId, issueId: fixture.issueId, runId: fixture.runId, agentId, sessionId: fixture.native.normalizedSessionId },
|
||||
providerSessionId: "interrupted-provider",
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
deliverReconciledExecutions,
|
||||
} from "../execution-recovery-resolution.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { persistHeartbeatRunProcessMetadata } from "../heartbeat/run-lifecycle.js";
|
||||
import { hasNativeLocalProcessStop, releaseSettledNativeRunProcess } from "../native-local-process-stop.js";
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
import { executionControlSchedule } from "../execution-control-work.js";
|
||||
import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
@@ -26,6 +31,7 @@ import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
subscribeDatabaseWork,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueRecoveryActions,
|
||||
@@ -41,7 +47,7 @@ import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { reconcileSafeNativeReplacements } from "./native-safe-replacement.js";
|
||||
import { invalidateNativeReplacementDiscovery, reconcileSafeNativeReplacements } from "./native-safe-replacement.js";
|
||||
import { reconcileAbandonedExecutionControl } from "../execution-control-reconciliation.js";
|
||||
const externalDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
const support = externalDatabaseUrl
|
||||
@@ -219,6 +225,103 @@ const support = externalDatabaseUrl
|
||||
}
|
||||
},
|
||||
);
|
||||
it("wakes a completed replacement scan after actual local stop proof commits", async () => {
|
||||
const source = await seed(2);
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-late-stop-"));
|
||||
const child = spawn(process.execPath, ["-e", "process.exit(0)"], { detached: true, stdio: "ignore" });
|
||||
const pid = child.pid!;
|
||||
await new Promise<void>((resolve, reject) => { child.once("exit", () => resolve()); child.once("error", reject); });
|
||||
await persistHeartbeatRunProcessMetadata(db, source.runId, { pid, processGroupId: pid,
|
||||
startedAt: new Date().toISOString(), targetKind: "local" });
|
||||
await db.update(heartbeatRuns).set({ finishedAt: new Date(), runnerProfileJson: {
|
||||
nativeExecutionInput: { provider: { kind: "codex" }, workspace: { cwd: workspace } },
|
||||
} }).where(eq(heartbeatRuns.id, source.runId));
|
||||
await db.update(nativeRunFinalizations).set({ failureCode: "provider_transport_failed" })
|
||||
.where(eq(nativeRunFinalizations.runId, source.runId));
|
||||
await appendHeartbeatRunEvent(db, { companyId: source.companyId, runId: source.runId, agentId: source.agentId,
|
||||
eventType: "harness.ready", stream: "system", message: "Provider started before its transport failed." });
|
||||
const verifyStoppedSession = async () => await hasNativeLocalProcessStop(db, source.companyId, source.runId)
|
||||
? { evidence: {}, retire: () => true } : null;
|
||||
invalidateNativeReplacementDiscovery(db);
|
||||
expect(await reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession })).toMatchObject({ scheduled: 0 });
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
let wakes = 0;
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.executionReplacement, event => {
|
||||
if (event === "settled") { wakes++; invalidateNativeReplacementDiscovery(db); }
|
||||
});
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
expect(await releaseSettledNativeRunProcess(tx as unknown as typeof db, source.runId)).toBe("released");
|
||||
expect(wakes).toBe(0);
|
||||
});
|
||||
expect(wakes).toBeGreaterThan(0);
|
||||
expect(await executionControlSchedule(db).replacement()).not.toBeNull();
|
||||
expect(await reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession })).toMatchObject({ scheduled: 1 });
|
||||
expect(await reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession })).toMatchObject({ scheduled: 0 });
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
const successors = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, source.runId));
|
||||
expect(successors).toHaveLength(1);
|
||||
} finally { unsubscribe(); await rm(workspace, { recursive: true, force: true }); }
|
||||
});
|
||||
it("wakes completed recovery when the real task becomes eligible", async () => {
|
||||
const source = await seed();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-late-eligibility-"));
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { recoveryEventInventoryVersion: 1,
|
||||
nativeExecutionInput: { provider: { kind: "codex" }, workspace: { cwd: workspace } },
|
||||
} }).where(eq(heartbeatRuns.id, source.runId));
|
||||
await issueService(db).update(source.issueId, { status: "backlog" });
|
||||
invalidateNativeReplacementDiscovery(db);
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scheduled: 0 });
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
let wakes = 0;
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.executionReplacement, event => {
|
||||
if (event === "settled") { wakes++; invalidateNativeReplacementDiscovery(db); }
|
||||
});
|
||||
try {
|
||||
await issueService(db).update(source.issueId, { status: "in_progress" });
|
||||
expect(wakes).toBeGreaterThan(0);
|
||||
expect(await executionControlSchedule(db).replacement()).not.toBeNull();
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scheduled: 1 });
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scheduled: 0 });
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, source.runId))).toHaveLength(1);
|
||||
} finally { unsubscribe(); await rm(workspace, { recursive: true, force: true }); }
|
||||
});
|
||||
it("advances past a failed full page and retries it after later work progresses", async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-failed-page-"));
|
||||
const sources: Awaited<ReturnType<typeof seed>>[] = [];
|
||||
for (let index = 0; index < 26; index++) {
|
||||
const source = await seed(); sources.push(source);
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: {
|
||||
nativeExecutionInput: { provider: { kind: "codex" }, workspace: { cwd: workspace } },
|
||||
} }).where(eq(heartbeatRuns.id, source.runId));
|
||||
await db.update(nativeRunFinalizations).set({ failureCode: "provider_transport_failed" })
|
||||
.where(eq(nativeRunFinalizations.runId, source.runId));
|
||||
}
|
||||
sources.sort((a, b) => a.runId.localeCompare(b.runId));
|
||||
const later = sources.at(-1)!;
|
||||
let failing = true;
|
||||
const verifyStoppedSession = vi.fn(async (run: typeof heartbeatRuns.$inferSelect) => {
|
||||
if (failing && run.id !== later.runId) throw new Error("Controlled stopped-session verifier failure");
|
||||
return { evidence: {}, retire: () => true };
|
||||
});
|
||||
invalidateNativeReplacementDiscovery(db);
|
||||
try {
|
||||
await expect(reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession }))
|
||||
.rejects.toThrow("native_replacement_decision_retry_required");
|
||||
expect(verifyStoppedSession).toHaveBeenCalledTimes(25);
|
||||
expect(await executionControlSchedule(db).replacement()).not.toBeNull();
|
||||
expect(await reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession })).toMatchObject({ scheduled: 1 });
|
||||
expect(verifyStoppedSession).toHaveBeenCalledTimes(26);
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, later.runId))).toHaveLength(1);
|
||||
failing = false;
|
||||
expect(await reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession })).toMatchObject({ scheduled: 25 });
|
||||
expect(await reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession })).toMatchObject({ scheduled: 0 });
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
const successors = await db.select().from(heartbeatRuns).where(inArray(heartbeatRuns.retryOfRunId, sources.map(source => source.runId)));
|
||||
expect(successors).toHaveLength(26);
|
||||
} finally { await rm(workspace, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
it.each(["unproven", "changed", "verified"] as const)("requires stopped-session proof through commit (%s)", async (mode) => {
|
||||
const source = await seed(2);
|
||||
await db.update(nativeRunFinalizations).set({ failureCode: "native_session_cleanup_quarantined" }).where(eq(nativeRunFinalizations.runId, source.runId));
|
||||
@@ -235,8 +338,11 @@ const support = externalDatabaseUrl
|
||||
eventType: "tool.execution.started", stream: "system",
|
||||
payload: { name: "paperclip_finish", executionId: "completion", transport: "process" },
|
||||
});
|
||||
await reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession });
|
||||
await reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession });
|
||||
for (let sweep = 0; sweep < 2; sweep++) {
|
||||
const decision = reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession });
|
||||
if (mode === "changed") await expect(decision).rejects.toThrow("native_replacement_decision_retry_required");
|
||||
else await decision;
|
||||
}
|
||||
const successors = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, source.runId));
|
||||
expect(successors).toHaveLength(mode === "verified" ? 1 : 0);
|
||||
const [hold] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { notifyExecutionRunWork } from "../execution-control-notifications.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentWakeupRequests,
|
||||
environmentLeases,
|
||||
@@ -27,6 +27,8 @@ import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { buildExecutionContinuation } from "../execution-continuation.js";
|
||||
import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js";
|
||||
import { hasRemoteTerminationReceipt } from "../remote-execution-termination.js";
|
||||
import { NATIVE_PROVIDER_DURABLE_EVENT_TYPES } from "./native-retired-backup-source.js";
|
||||
import { completedWorkspaceRecoveryCondition } from "../workspace-recovery-receipt.js";
|
||||
|
||||
export const NATIVE_SAFE_REPLACEMENT_REASON = "native_safe_replacement";
|
||||
const record = (v: unknown): Record<string, unknown> =>
|
||||
@@ -45,6 +47,66 @@ const processAlive = (pid: number | null) => {
|
||||
|
||||
type ReplacementDb = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
const listenerUnavailable = "local_runtime_api_listener_unavailable";
|
||||
function historicalListenerGuardPending() {
|
||||
const profile = heartbeatRuns.runnerProfileJson, detail = nativeRunFinalizations.failureDetail;
|
||||
const providerEvents = sql.join([...NATIVE_PROVIDER_DURABLE_EVENT_TYPES].map(value => sql`${value}`), sql`, `);
|
||||
return and(
|
||||
eq(nativeRunFinalizations.failureCode, "native_session_interrupted"),
|
||||
eq(heartbeatRuns.errorCode, "native_session_interrupted"), eq(heartbeatRuns.error, listenerUnavailable),
|
||||
sql`${detail}->>'message' = ${listenerUnavailable}`,
|
||||
sql`${detail}->>'originalFailureCode' = 'native_session_interrupted'`,
|
||||
sql`${detail}->>'recoveryMode' = 'ambiguous_state'`,
|
||||
sql`${detail}->'providerSessionEstablished' = 'false'::jsonb`,
|
||||
sql`${detail}->'providerEventsExist' = 'false'::jsonb`,
|
||||
sql`${detail}->'checkpointExists' = 'false'::jsonb`,
|
||||
eq(nativeRunFinalizations.attempt, 1),
|
||||
isNull(nativeRunFinalizations.leaseOwner), isNull(nativeRunFinalizations.leaseExpiresAt),
|
||||
isNull(nativeRunFinalizations.assessmentId), isNull(nativeRunFinalizations.decisionId),
|
||||
isNull(heartbeatRuns.processPid), isNull(heartbeatRuns.processGroupId),
|
||||
isNull(heartbeatRuns.sessionIdBefore), isNull(heartbeatRuns.sessionIdAfter), isNull(heartbeatRuns.retryOfRunId),
|
||||
sql`not (coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) ?| array['cancellation', 'nativeCancellation'])`,
|
||||
sql`coalesce(${profile}->'sessionCheckpoint', 'null'::jsonb) = 'null'::jsonb`,
|
||||
sql`coalesce(${profile}->'nativeProviderPackAdmission', 'null'::jsonb) = 'null'::jsonb`,
|
||||
sql`coalesce(${profile}#>>'{nativeProviderPackAdmissionState,state}', 'pending') = 'pending'`,
|
||||
sql`${profile}#>>'{nativeExecutionInput,binding,runId}' = ${heartbeatRuns.id}::text`,
|
||||
sql`${profile}#>>'{nativeExecutionInput,binding,companyId}' = ${heartbeatRuns.companyId}::text`,
|
||||
sql`${profile}#>>'{nativeExecutionInput,binding,agentId}' = ${heartbeatRuns.agentId}::text`,
|
||||
sql`${profile}#>>'{nativeExecutionInput,binding,issueId}' = ${nativeRunFinalizations.issueId}::text`,
|
||||
sql`${profile}#>>'{nativeExecutionInput,session,normalizedSessionId}' = ${heartbeatRuns.nativeSessionId}::text`,
|
||||
sql`not exists (select 1 from ${heartbeatRuns} prior where prior.company_id = ${heartbeatRuns.companyId}
|
||||
and prior.native_session_id = ${heartbeatRuns.nativeSessionId} and prior.id <> ${heartbeatRuns.id})`,
|
||||
sql`not exists (select 1 from ${heartbeatRunEvents} event where event.company_id = ${heartbeatRuns.companyId}
|
||||
and event.run_id = ${heartbeatRuns.id} and (event.source_event_id is not null
|
||||
or event.event_type in (${providerEvents}) or event.event_type ~ '^(turn|item|tool|semantic_tool|run[.]result)[.]'))`,
|
||||
sql`not exists (select 1 from ${toolInvocations} invocation where invocation.company_id = ${heartbeatRuns.companyId}
|
||||
and invocation.run_id = ${heartbeatRuns.id})`,
|
||||
sql`not exists (select 1 from ${issueRecoveryActions}
|
||||
where ${issueRecoveryActions.companyId} = ${heartbeatRuns.companyId}
|
||||
and ${issueRecoveryActions.sourceIssueId} = ${nativeRunFinalizations.issueId}
|
||||
and ${issueRecoveryActions.cause} = 'native_continuation_requires_reconciliation'
|
||||
and ${issueRecoveryActions.evidence}->>'runId' = ${heartbeatRuns.id}::text
|
||||
and ${issueRecoveryActions.evidence}->'automaticRecovery'->>'replay' = 'blocked'
|
||||
and not coalesce((${completedWorkspaceRecoveryCondition(heartbeatRuns.id)}), false))`,
|
||||
sql`exists (select 1 from ${issues} task where task.company_id = ${heartbeatRuns.companyId}
|
||||
and task.id = ${nativeRunFinalizations.issueId} and task.assignee_agent_id = ${heartbeatRuns.agentId}
|
||||
and task.status in ('todo', 'in_progress', 'in_review', 'blocked')
|
||||
and (task.status <> 'blocked' or exists (select 1 from ${issueRecoveryActions} hold
|
||||
where hold.company_id = task.company_id and hold.source_issue_id = task.id and hold.kind = 'active_run_watchdog'
|
||||
and hold.cause = ${nativeRunFinalizations.failureCode} and hold.evidence->>'runId' = ${heartbeatRuns.id}::text
|
||||
and hold.evidence#>>'{nativeFailureBlock,runId}' = ${heartbeatRuns.id}::text
|
||||
and hold.evidence#>'{nativeFailureBlock,statusVersion}' = to_jsonb(task.status_version))))`,
|
||||
);
|
||||
}
|
||||
|
||||
async function historicalListenerGuardEvidence(db: ReplacementDb, run: typeof heartbeatRuns.$inferSelect) {
|
||||
const [evidence] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
|
||||
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id)).where(and(
|
||||
eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.id, run.id), historicalListenerGuardPending(),
|
||||
)).limit(1);
|
||||
return Boolean(evidence);
|
||||
}
|
||||
|
||||
/** A saved remote source is proved by its current ledger, never a controller path. */
|
||||
async function remoteWorkspaceReplacementEvidence(
|
||||
db: ReplacementDb,
|
||||
@@ -113,16 +175,95 @@ export function nativeReplacementPending() {
|
||||
return and(
|
||||
eq(heartbeatRuns.status, "failed"),
|
||||
eq(nativeRunFinalizations.phase, "terminal_failure"),
|
||||
inArray(
|
||||
or(inArray(
|
||||
nativeRunFinalizations.failureCode,
|
||||
["native_provider_terminal_failed", "native_session_cleanup_quarantined", "provider_transport_failed"],
|
||||
),
|
||||
), historicalListenerGuardPending()),
|
||||
isNull(nativeRunFinalizations.resultId),
|
||||
sql`coalesce(${nativeRunFinalizations.failureDetail}->>'successorRunId', '') = ''`,
|
||||
sql`coalesce(${nativeRunFinalizations.failureDetail}->>'replacementDenied', '') = ''`,
|
||||
);
|
||||
}
|
||||
|
||||
const REPLACEMENT_SCAN_LIMIT = 100;
|
||||
const replacementDiscovery = new WeakMap<object, { after: string | undefined; complete: boolean; generation: number; rescan: boolean }>();
|
||||
function replacementDiscoveryState(db: Db) {
|
||||
let state = replacementDiscovery.get(db);
|
||||
if (!state) { state = { after: undefined, complete: false, generation: 0, rescan: false }; replacementDiscovery.set(db, state); }
|
||||
return state;
|
||||
}
|
||||
|
||||
export function invalidateNativeReplacementDiscovery(db: Db) {
|
||||
const state = replacementDiscoveryState(db);
|
||||
state.complete = false; state.rescan = true; state.generation++;
|
||||
}
|
||||
|
||||
async function replacementHistoryReady(db: Db, run: typeof heartbeatRuns.$inferSelect, issueId: string, retryDatabaseErrors = false) {
|
||||
try {
|
||||
const continuation = await buildExecutionContinuation({ db, companyId: run.companyId, issueId,
|
||||
agentId: run.agentId, context: record(run.contextSnapshot), summary: null, exposeLowTrustRaw: false });
|
||||
return continuation.unresolvedInteractionIds.length === 0;
|
||||
} catch (error) {
|
||||
let cause: unknown = error;
|
||||
for (let depth = 0; retryDatabaseErrors && depth < 4 && cause && typeof cause === "object"; depth++) {
|
||||
const failure = cause as { code?: unknown; cause?: unknown };
|
||||
if (typeof failure.code === "string" && /^[A-Z0-9_]+$/.test(failure.code)) throw error;
|
||||
cause = failure.cause;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverNativeReplacements(db: Db, after: string | undefined, limit = 25) {
|
||||
const candidates: Array<{ run: typeof heartbeatRuns.$inferSelect; coordinator: typeof nativeRunFinalizations.$inferSelect }> = [];
|
||||
let scanned = 0;
|
||||
for (;;) {
|
||||
const rows = await db.select({ run: heartbeatRuns, coordinator: nativeRunFinalizations }).from(heartbeatRuns)
|
||||
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
||||
.where(and(nativeReplacementPending(), after ? gt(heartbeatRuns.id, after) : undefined))
|
||||
.orderBy(asc(heartbeatRuns.id)).limit(Math.min(25, REPLACEMENT_SCAN_LIMIT - scanned));
|
||||
if (!rows.length) return { candidates, scanned, after: undefined };
|
||||
for (const row of rows) {
|
||||
after = row.run.id; scanned++;
|
||||
let ready = true;
|
||||
if (row.coordinator.failureCode === "native_session_interrupted") {
|
||||
ready = false;
|
||||
if (row.run.runnerProfileJson?.remoteWorkspace != null) {
|
||||
const leases = await db.select().from(environmentLeases).where(and(
|
||||
eq(environmentLeases.companyId, row.run.companyId), eq(environmentLeases.heartbeatRunId, row.run.id),
|
||||
));
|
||||
if (leases.every(lease => lease.releasedAt !== null)) {
|
||||
const evidence = await remoteWorkspaceReplacementEvidence(db, row.run, row.coordinator.issueId, leases);
|
||||
ready = Boolean(evidence?.workspacePreserved && evidence.providerStopped)
|
||||
&& await replacementHistoryReady(db, row.run, row.coordinator.issueId, true) === true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ready) candidates.push(row);
|
||||
if (candidates.length >= limit || scanned >= REPLACEMENT_SCAN_LIMIT) return { candidates, scanned, after };
|
||||
}
|
||||
if (rows.length < 25) return { candidates, scanned, after: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function settleReplacementDiscovery(state: ReturnType<typeof replacementDiscoveryState>, after: string | undefined, generation: number) {
|
||||
const rescan = state.rescan || generation !== state.generation;
|
||||
state.after = after; state.complete = !after && !rescan;
|
||||
state.rescan = Boolean(after && rescan);
|
||||
}
|
||||
|
||||
export async function hasPendingNativeReplacements(db: Db) {
|
||||
const state = replacementDiscoveryState(db);
|
||||
if (state.complete) return false;
|
||||
if (state.after) return true;
|
||||
state.rescan = false;
|
||||
const generation = state.generation;
|
||||
const result = await discoverNativeReplacements(db, undefined, 1);
|
||||
if (result.candidates.length) return true;
|
||||
settleReplacementDiscovery(state, result.after, generation);
|
||||
return !state.complete;
|
||||
}
|
||||
|
||||
/** Retryable outbox: terminal_failure plus a new-format failure code is the durable candidate. */
|
||||
export async function reconcileSafeNativeReplacements(
|
||||
db: Db,
|
||||
@@ -136,18 +277,14 @@ export async function reconcileSafeNativeReplacements(
|
||||
failpoint?: (phase: "successor_inserted" | "lineage_committed") => void;
|
||||
} = {},
|
||||
) {
|
||||
const candidates = await db
|
||||
.select({ run: heartbeatRuns, coordinator: nativeRunFinalizations })
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(
|
||||
nativeRunFinalizations,
|
||||
eq(nativeRunFinalizations.runId, heartbeatRuns.id),
|
||||
)
|
||||
.where(
|
||||
nativeReplacementPending(),
|
||||
)
|
||||
.limit(25);
|
||||
let scheduled = 0;
|
||||
const discovery = replacementDiscoveryState(db);
|
||||
if (discovery.complete) discovery.after = undefined;
|
||||
discovery.complete = false;
|
||||
if (!discovery.after) discovery.rescan = false;
|
||||
const generation = discovery.generation;
|
||||
const selection = await discoverNativeReplacements(db, discovery.after);
|
||||
const candidates = selection.candidates;
|
||||
let scheduled = 0, pendingFailures = 0;
|
||||
for (const { run, coordinator } of candidates) {
|
||||
try {
|
||||
if (
|
||||
@@ -170,11 +307,13 @@ export async function reconcileSafeNativeReplacements(
|
||||
);
|
||||
// Teardown is still in progress. The next sweep rechecks its durable outcome.
|
||||
if (leases.some((lease) => lease.releasedAt === null)) continue;
|
||||
const stoppedSession = coordinator.failureCode !== "native_provider_terminal_failed"
|
||||
const historicalListener = coordinator.failureCode === "native_session_interrupted";
|
||||
if (historicalListener && !await historicalListenerGuardEvidence(db, run)) continue;
|
||||
const stoppedSession = coordinator.failureCode !== "native_provider_terminal_failed" && !historicalListener
|
||||
? await options.verifyStoppedSession?.(run) ?? null : null;
|
||||
// A transport label alone is not evidence. Keep inspecting these candidates
|
||||
// as process cleanup and the final transcript become durable.
|
||||
if (coordinator.failureCode !== "native_provider_terminal_failed" && !stoppedSession) continue;
|
||||
if (coordinator.failureCode !== "native_provider_terminal_failed" && !historicalListener && !stoppedSession) continue;
|
||||
const invocations = await db
|
||||
.select()
|
||||
.from(toolInvocations)
|
||||
@@ -263,23 +402,11 @@ export async function reconcileSafeNativeReplacements(
|
||||
? [String(p.kind ?? p.type)]
|
||||
: [];
|
||||
});
|
||||
let historyComplete = false;
|
||||
try {
|
||||
const continuation = await buildExecutionContinuation({
|
||||
db,
|
||||
companyId: run.companyId,
|
||||
issueId: coordinator.issueId,
|
||||
agentId: run.agentId,
|
||||
context: record(run.contextSnapshot),
|
||||
summary: null,
|
||||
exposeLowTrustRaw: false,
|
||||
});
|
||||
if (continuation.unresolvedInteractionIds.length > 0) continue;
|
||||
historyComplete = true;
|
||||
} catch {
|
||||
/* The named operator outcome retains incomplete context. */
|
||||
}
|
||||
const history = await replacementHistoryReady(db, run, coordinator.issueId);
|
||||
if (history === false) continue;
|
||||
const historyComplete = history === true;
|
||||
const remoteWorkspace = await remoteWorkspaceReplacementEvidence(db, run, coordinator.issueId, leases);
|
||||
if (historicalListener && (!remoteWorkspace?.workspacePreserved || !remoteWorkspace.providerStopped || invocations.length)) continue;
|
||||
const decision = decideNativeReplacement({
|
||||
failedSession: true,
|
||||
// A facade transport failure can conceal an authoritative protocol
|
||||
@@ -413,6 +540,13 @@ export async function reconcileSafeNativeReplacements(
|
||||
current.attempt >= 3
|
||||
)
|
||||
return false;
|
||||
let historicalRun: typeof heartbeatRuns.$inferSelect | undefined;
|
||||
if (historicalListener) {
|
||||
const [currentRun] = await tx.select().from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId),
|
||||
)).for("update");
|
||||
historicalRun = currentRun;
|
||||
}
|
||||
if (task.status === "blocked") {
|
||||
const failureHolds = await tx.select().from(issueRecoveryActions).where(and(
|
||||
eq(issueRecoveryActions.companyId, run.companyId),
|
||||
@@ -427,6 +561,14 @@ export async function reconcileSafeNativeReplacements(
|
||||
});
|
||||
if (!ownsBlock) return false;
|
||||
}
|
||||
const preservationHolds = historicalListener ? await tx.select().from(issueRecoveryActions).where(and(
|
||||
eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, task.id),
|
||||
eq(issueRecoveryActions.kind, "active_run_watchdog"),
|
||||
eq(issueRecoveryActions.cause, "native_continuation_requires_reconciliation"),
|
||||
sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`,
|
||||
sql`${issueRecoveryActions.evidence}->'automaticRecovery'->>'replay' = 'blocked'`,
|
||||
)).for("update") : [];
|
||||
if (historicalListener && (!historicalRun || !await historicalListenerGuardEvidence(tx, historicalRun))) return false;
|
||||
if (remoteWorkspace) {
|
||||
const currentWorkspace = await remoteWorkspaceReplacementEvidence(tx, run, coordinator.issueId, leases, true);
|
||||
if (!currentWorkspace?.workspacePreserved || !currentWorkspace.providerStopped) return false;
|
||||
@@ -548,6 +690,7 @@ export async function reconcileSafeNativeReplacements(
|
||||
eq(issueRecoveryActions.cause, current.failureCode!),
|
||||
sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`,
|
||||
)).for("update");
|
||||
holds.push(...preservationHolds);
|
||||
if (holds.some(hold => hold.status === "active" || hold.status === "escalated")) {
|
||||
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: run.companyId, issueId: task.id });
|
||||
}
|
||||
@@ -587,11 +730,17 @@ export async function reconcileSafeNativeReplacements(
|
||||
}
|
||||
} catch (error) {
|
||||
if (options.failpoint) throw error;
|
||||
pendingFailures++;
|
||||
logger.warn(
|
||||
{ runId: run.id },
|
||||
"Native replacement remains pending; another sweep will retry its durable decision",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { scanned: candidates.length, scheduled };
|
||||
settleReplacementDiscovery(discovery, selection.after, generation);
|
||||
if (pendingFailures) {
|
||||
discovery.complete = false; discovery.rescan = true;
|
||||
throw new Error("native_replacement_decision_retry_required");
|
||||
}
|
||||
return { scanned: selection.scanned, scheduled };
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { pathToFileURL } from "node:url";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { activityLog, agents, agentTaskSessions, agentWakeupRequests, applyPendingMigrations, closeRegisteredClients, companies, createDb, environmentLeases, environments,
|
||||
executionWorkspaceRepositories, executionWorkspaces, executionWorkspaceSnapshots, heartbeatRunEvents, heartbeatRuns, issueRecoveryActions, issues, livenessDb, nativeRunFinalizations, projects, projectWorkspaces, workspaceOperations } from "@paperclipai/db";
|
||||
subscribeDatabaseWork, executionWorkspaceRepositories, executionWorkspaces, executionWorkspaceSnapshots, heartbeatRunEvents, heartbeatRuns, issueRecoveryActions, issueThreadInteractions, issues, livenessDb, nativeRunFinalizations, projects, projectWorkspaces, workspaceOperations } from "@paperclipai/db";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
import { buildProjectMentionHref, type Environment, type EnvironmentLease } from "@paperclipai/shared";
|
||||
@@ -25,12 +25,13 @@ import { createSavedWorkspaceLoader, prepareRemoteWorkspace, readRemoteWorkspace
|
||||
clearRemoteWorkspaceLocationForReleasedLease, createRemoteWorkspaceGitAuth, prepareRemoteWorkspaceRepository,
|
||||
reserveRemoteWorkspace, saveRemoteWorkspace, shouldUseRemoteWorkspaces } from "./remote-workspaces.js";
|
||||
import type { createGitRemoteAuthProvider } from "./git-credentials.js";
|
||||
import { reconcileSafeNativeReplacements } from "./native-runtime/native-safe-replacement.js";
|
||||
import { invalidateNativeReplacementDiscovery, reconcileSafeNativeReplacements } from "./native-runtime/native-safe-replacement.js";
|
||||
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import { RemoteWorkspaceRestoreError } from "./remote-workspace-transport.js";
|
||||
import { readRemoteRunnerState } from "./native-runtime/native-session-executor.js";
|
||||
import { notFound } from "../errors.js";
|
||||
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
||||
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
|
||||
import { createRemoteWorkspaceFiles } from "./remote-workspace-files.js";
|
||||
import { workspaceFileResourceService } from "./workspace-file-resources.js";
|
||||
import { nativeWorkspaceExportPendingForLease, preserveRemoteWorkspacePreparationFailure } from "./native-runtime/native-workspace-finalization-state.js";
|
||||
@@ -47,6 +48,10 @@ import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { resolveRemoteWorkspaceSource } from "./heartbeat/remote-workspace-source.js";
|
||||
import { toExecutionWorkspace } from "./execution-workspaces.js";
|
||||
import { recoveryPostgresSupport, startRecoveryPostgres } from "../__tests__/helpers/recovery-postgres.js";
|
||||
import { getExecutionBlocker } from "./execution-blocker.js";
|
||||
import { executionControlSchedule } from "./execution-control-work.js";
|
||||
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import { createSystemRecovery } from "./system-recovery-state.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
let databaseUrl = process.env.PAPERCLIP_REMOTE_WORKSPACE_TEST_DATABASE_URL;
|
||||
@@ -82,6 +87,7 @@ suite("remote workspace lifecycle with real Postgres and independent filesystem
|
||||
await db.insert(issues).values({ id: ids.issue, companyId: ids.company, title: "Use a remote workspace", status: "in_progress", assigneeAgentId: ids.agent });
|
||||
await db.insert(heartbeatRuns).values({ id: ids.run, companyId: ids.company, agentId: ids.agent, nativeIssueId: ids.issue,
|
||||
runtimeMode: "native", status: "running" });
|
||||
invalidateNativeReplacementDiscovery(db);
|
||||
await db.insert(environmentLeases).values({ id: ids.lease, companyId: ids.company, environmentId: ids.environment,
|
||||
heartbeatRunId: ids.run, issueId: ids.issue, provider: "daytona", providerLeaseId: `allocation-${ids.lease}`, status: "active" });
|
||||
});
|
||||
@@ -91,6 +97,10 @@ suite("remote workspace lifecycle with real Postgres and independent filesystem
|
||||
? (row.metadata.uploads as { objectKey: string }[]).map(item => item.objectKey) : [])].filter((value): value is string => !!value))) {
|
||||
await storage.deleteObject(ids.company, key);
|
||||
}
|
||||
if (temporary && process.env.PAPERCLIP_TEST_HISTORICAL_LISTENER === "1") await db.transaction(async tx => {
|
||||
await tx.execute(sql`select set_config('client_min_messages', 'warning', true)`);
|
||||
await tx.execute(sql`TRUNCATE companies CASCADE`);
|
||||
});
|
||||
await db.delete(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.companyId, ids.company));
|
||||
await db.delete(workspaceOperations).where(eq(workspaceOperations.companyId, ids.company));
|
||||
await db.delete(activityLog).where(eq(activityLog.companyId, ids.company));
|
||||
@@ -1712,14 +1722,20 @@ suite("remote workspace lifecycle with real Postgres and independent filesystem
|
||||
} finally { await recovered.cleanup(); }
|
||||
}, 60_000);
|
||||
|
||||
it.each(["saved", "dirty", "unsaved", "foreign_snapshot", "delete_requested", "deletion_race", "controller_claim_race", "stale_epoch", "unconfirmed_stop", "unknown_action"] as const)(
|
||||
"checks durable remote work at the native safe-replacement decision (%s)", async scenario => {
|
||||
it.each(["saved", "dirty", "unsaved", "foreign_snapshot", "delete_requested", "deletion_race", "controller_claim_race", "stale_epoch", "unconfirmed_stop", "unknown_action", "historical_listener_guard", "historical_listener_provider_event", "historical_listener_checkpoint", "historical_listener_resumed", "historical_listener_other_message", "historical_listener_cancelled", "historical_listener_prior_session", "historical_listener_unconfirmed_stop", "historical_listener_dirty", "historical_listener_stale_block", "historical_listener_unknown_event", "historical_listener_budget", "historical_listener_bad_preservation", "historical_listener_absent", "historical_listener_later_candidate", "historical_listener_late_proof", "historical_listener_lock_timeout", "historical_listener_late_interaction", "historical_listener_interaction_paused", "historical_listener_own_question"] as const)(
|
||||
"checks durable remote work at the native safe-replacement decision (%s)", async kind => {
|
||||
const historicalListener = kind.startsWith("historical_listener_");
|
||||
const processAcceptance = kind === "historical_listener_guard" && process.env.PAPERCLIP_TEST_HISTORICAL_LISTENER === "1";
|
||||
const scenario = kind === "historical_listener_late_proof" ? "dirty" : kind === "historical_listener_unconfirmed_stop" ? "unconfirmed_stop"
|
||||
: kind === "historical_listener_dirty" ? "dirty" : historicalListener ? "saved" : kind;
|
||||
const shouldSchedule = scenario === "saved" && (!historicalListener || ["historical_listener_guard", "historical_listener_later_candidate", "historical_listener_lock_timeout"].includes(kind));
|
||||
const reserved = await reserve({ kind: "empty" }), first = await target("native-replacement-sandbox");
|
||||
const prepared = await prepareRemoteWorkspace({ db, storage, workspace: reserved, runId: ids.run,
|
||||
lease: await lease(), target: first, assertOwned: async () => undefined });
|
||||
await fs.writeFile(path.join(prepared.remoteRoot, "saved.txt"), "work survives a failed provider session\n");
|
||||
if (scenario !== "unsaved") await saveRemoteWorkspace({ db, storage, workspace: prepared.workspace,
|
||||
runId: ids.run, lease: prepared.lease, target: first, assertOwned: async () => undefined });
|
||||
runId: ids.run, lease: prepared.lease, target: first, assertOwned: async () => undefined,
|
||||
boundary: kind === "historical_listener_late_proof" ? "initial" : "final" });
|
||||
const current = await workspace(reserved.id), authority = readRemoteWorkspaceAuthority(current.metadata)!;
|
||||
if (scenario === "dirty" || scenario === "stale_epoch") await db.update(executionWorkspaces).set({ metadata: {
|
||||
...current.metadata, remoteWorkspace: { ...authority, ...(scenario === "dirty" ? { dirty: true, writerRunId: ids.run }
|
||||
@@ -1729,29 +1745,137 @@ suite("remote workspace lifecycle with real Postgres and independent filesystem
|
||||
.where(eq(executionWorkspaceSnapshots.id, authority.snapshotId!));
|
||||
if (scenario === "delete_requested") await db.update(executionWorkspaceSnapshots).set({ status: "delete_requested" })
|
||||
.where(eq(executionWorkspaceSnapshots.id, authority.snapshotId!));
|
||||
const stopped = (await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success",
|
||||
if (processAcceptance) {
|
||||
const { recordHistoricalListenerFailure } = await import("../__tests__/helpers/historical-listener-continuation.js");
|
||||
await recordHistoricalListenerFailure({ db, connectionString: databaseUrl!, runId: ids.run, cwd: prepared.remoteRoot,
|
||||
environmentId: ids.environment, leaseId: ids.lease });
|
||||
}
|
||||
const stopped = kind === "historical_listener_late_proof" ? prepared.lease : (await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success",
|
||||
metadata: { ...prepared.lease.metadata, ...(scenario !== "unconfirmed_stop" ? {
|
||||
remoteExecutionTermination: remoteTerminationReceipt(prepared.lease, {
|
||||
providerLeaseId: prepared.lease.providerLeaseId, state: "destroyed",
|
||||
}),
|
||||
} : {}) },
|
||||
}).where(eq(environmentLeases.id, ids.lease)).returning())[0]! as EnvironmentLease;
|
||||
if (scenario === "saved") {
|
||||
if (shouldSchedule) {
|
||||
await db.transaction(tx => clearRemoteWorkspaceLocationForReleasedLease({ db: tx, lease: stopped }));
|
||||
await fs.rm(prepared.remoteRoot, { recursive: true, force: true });
|
||||
await expect(fs.stat(prepared.remoteRoot)).rejects.toThrow();
|
||||
}
|
||||
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, ids.run));
|
||||
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date(), runnerProfileJson: {
|
||||
const sessionId = randomUUID();
|
||||
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date(),
|
||||
...(historicalListener && !processAcceptance ? { nativeSessionId: sessionId } : {}),
|
||||
...(historicalListener ? { error: "local_runtime_api_listener_unavailable", errorCode: "native_session_interrupted" } : {}), runnerProfileJson: {
|
||||
...source!.runnerProfileJson, recoveryEventInventoryVersion: 1,
|
||||
nativeExecutionInput: { provider: { kind: "codex" }, workspace: { cwd: prepared.remoteRoot } },
|
||||
nativeExecutionInput: processAcceptance ? source!.runnerProfileJson!.nativeExecutionInput : { provider: { kind: "codex" }, workspace: { cwd: prepared.remoteRoot },
|
||||
...(historicalListener ? { binding: { runId: ids.run, companyId: ids.company, agentId: ids.agent, issueId: ids.issue },
|
||||
session: { normalizedSessionId: sessionId } } : {}) },
|
||||
} }).where(eq(heartbeatRuns.id, ids.run));
|
||||
await db.insert(nativeRunFinalizations).values({ runId: ids.run, companyId: ids.company, issueId: ids.issue,
|
||||
phase: "terminal_failure", attempt: 1, failureCode: "native_provider_terminal_failed",
|
||||
failureDetail: { originalFailureCode: "fixture_checkpoint_unusable" } });
|
||||
if (!processAcceptance) await db.insert(nativeRunFinalizations).values({ runId: ids.run, companyId: ids.company, issueId: ids.issue,
|
||||
phase: "terminal_failure", attempt: 1, failureCode: historicalListener ? "native_session_interrupted" : "native_provider_terminal_failed",
|
||||
failureDetail: historicalListener ? { originalFailureCode: "native_session_interrupted", message: "local_runtime_api_listener_unavailable",
|
||||
recoveryMode: "ambiguous_state", providerSessionEstablished: false, providerEventsExist: false, checkpointExists: false }
|
||||
: { originalFailureCode: "fixture_checkpoint_unusable" } });
|
||||
if (historicalListener && !processAcceptance) {
|
||||
const blocked = await issueService(db).update(ids.issue, { status: "blocked" });
|
||||
await db.insert(issueRecoveryActions).values({ companyId: ids.company, sourceIssueId: ids.issue,
|
||||
kind: "active_run_watchdog", cause: "native_session_interrupted", ownerType: "board", status: "active",
|
||||
fingerprint: createHash("sha256").update(`${ids.run}:native_session_interrupted`).digest("hex"),
|
||||
evidence: { runId: ids.run, nativeFailureBlock: { runId: ids.run, statusVersion: blocked!.statusVersion } },
|
||||
nextAction: "Reconcile the original startup failure." });
|
||||
}
|
||||
if (kind === "historical_listener_guard" || kind === "historical_listener_bad_preservation") {
|
||||
const actionId = randomUUID(), preservationId = randomUUID();
|
||||
await db.insert(issueRecoveryActions).values({ id: actionId, companyId: ids.company, sourceIssueId: ids.issue,
|
||||
kind: "active_run_watchdog", cause: "native_continuation_requires_reconciliation", ownerType: "system", status: "resolved", outcome: "blocked",
|
||||
fingerprint: `native-workspace:${ids.run}`, evidence: { runId: ids.run,
|
||||
workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [ids.lease] },
|
||||
automaticRecovery: { replay: "blocked" }, continuationDelivery: "invalidated",
|
||||
systemRecovery: { ...createSystemRecovery(ids.run), state: "complete", preservationOperationIds: [preservationId] },
|
||||
workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: ids.run, status: "recovered", stage: "saving_receipts", attempt: 1, requestIds: [] },
|
||||
}, nextAction: "Files were preserved without replay." });
|
||||
await db.insert(workspaceOperations).values([{ id: preservationId, companyId: ids.company, heartbeatRunId: ids.run, issueId: ids.issue,
|
||||
executionWorkspaceId: reserved.id, phase: "workspace_recovery", status: kind === "historical_listener_bad_preservation" ? "failed" : "succeeded", startedAt: new Date(),
|
||||
metadata: { recoveryActionId: actionId, snapshotId: authority.snapshotId } },
|
||||
{ companyId: ids.company, heartbeatRunId: ids.run, issueId: ids.issue, phase: "workspace_finalize", status: "succeeded", startedAt: new Date(),
|
||||
metadata: { owningService: "workspace_recovery", preservationOperationIds: [preservationId] } }]);
|
||||
}
|
||||
if (kind === "historical_listener_absent") await db.update(heartbeatRuns).set({
|
||||
runnerProfileJson: sql`${heartbeatRuns.runnerProfileJson} - 'remoteWorkspace'`,
|
||||
}).where(eq(heartbeatRuns.id, ids.run));
|
||||
if (kind === "historical_listener_stale_block") await issueService(db).update(ids.issue, { status: "blocked", actorUserId: "operator" });
|
||||
if (kind === "historical_listener_unknown_event") await db.insert(heartbeatRunEvents).values({ companyId: ids.company, runId: ids.run,
|
||||
agentId: ids.agent, seq: 100, eventType: "future.provider.activity", sourceEventId: "source-provider-event" });
|
||||
if (kind === "historical_listener_budget") await db.update(nativeRunFinalizations).set({ attempt: 3 }).where(eq(nativeRunFinalizations.runId, ids.run));
|
||||
if (kind === "historical_listener_checkpoint") await db.update(heartbeatRuns).set({ runnerProfileJson: {
|
||||
...((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, ids.run)))[0]!.runnerProfileJson), sessionCheckpoint: { providerSessionId: "already-started" },
|
||||
} }).where(eq(heartbeatRuns.id, ids.run));
|
||||
if (kind === "historical_listener_resumed") await db.update(heartbeatRuns).set({ sessionIdBefore: "prior-session" }).where(eq(heartbeatRuns.id, ids.run));
|
||||
if (kind === "historical_listener_prior_session") await db.insert(heartbeatRuns).values({ id: randomUUID(), companyId: ids.company,
|
||||
agentId: ids.agent, status: "succeeded", nativeSessionId: sessionId });
|
||||
if (kind === "historical_listener_other_message") await db.update(nativeRunFinalizations).set({ failureDetail: {
|
||||
message: "provider reported local_runtime_api_listener_unavailable", originalFailureCode: "native_session_interrupted", recoveryMode: "ambiguous_state",
|
||||
providerSessionEstablished: false, providerEventsExist: false, checkpointExists: false,
|
||||
} }).where(eq(nativeRunFinalizations.runId, ids.run));
|
||||
if (kind === "historical_listener_cancelled") await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, ids.issue));
|
||||
if (kind === "historical_listener_provider_event") await appendHeartbeatRunEvent(db, { companyId: ids.company, runId: ids.run,
|
||||
agentId: ids.agent, eventType: "turn.started", stream: "system" });
|
||||
const interactionWake = ["historical_listener_late_interaction", "historical_listener_interaction_paused", "historical_listener_own_question"].includes(kind);
|
||||
const interactionId = randomUUID();
|
||||
if (interactionWake) {
|
||||
const priorRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: priorRunId, companyId: ids.company, agentId: ids.agent,
|
||||
nativeIssueId: ids.issue, status: "succeeded", contextSnapshot: { issueId: ids.issue } });
|
||||
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: ids.company, issueId: ids.issue,
|
||||
kind: "request_confirmation", status: "pending", sourceRunId: kind === "historical_listener_own_question" ? ids.run : priorRunId,
|
||||
payload: { version: 1, prompt: "An obsolete question from earlier work." } });
|
||||
if (kind === "historical_listener_interaction_paused") await issueService(db).update(ids.issue, { status: "backlog" });
|
||||
if (kind === "historical_listener_own_question") await appendHeartbeatRunEvent(db, { companyId: ids.company, runId: ids.run,
|
||||
agentId: ids.agent, eventType: "tool.execution.started", stream: "system",
|
||||
payload: { name: "request_confirmation", executionId: "source-question", transport: "process" } });
|
||||
}
|
||||
if (scenario === "unknown_action") await appendHeartbeatRunEvent(db, { companyId: ids.company, runId: ids.run,
|
||||
agentId: ids.agent, eventType: "tool.execution.started", stream: "system",
|
||||
payload: { name: "send_email", executionId: "unconfirmed-external-action", transport: "process" } });
|
||||
if (kind === "historical_listener_later_candidate") {
|
||||
const sourceNumber = BigInt(`0x${ids.run.replaceAll("-", "")}`);
|
||||
const parked = Array.from({ length: 100 }, (_, index) => {
|
||||
const hex = (sourceNumber - BigInt(index + 1)).toString(16).padStart(32, "0");
|
||||
return { runId: `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`,
|
||||
issueId: randomUUID(), sessionId: randomUUID() };
|
||||
});
|
||||
await db.insert(issues).values(parked.map(row => ({ id: row.issueId, companyId: ids.company,
|
||||
title: "Preserve unavailable source", status: "in_progress", assigneeAgentId: ids.agent })));
|
||||
await db.insert(heartbeatRuns).values(parked.map(row => ({ id: row.runId, companyId: ids.company, agentId: ids.agent,
|
||||
nativeIssueId: row.issueId, nativeSessionId: row.sessionId, runtimeMode: "native", status: "failed",
|
||||
errorCode: "native_session_interrupted", error: "local_runtime_api_listener_unavailable", runnerProfileJson: {
|
||||
nativeExecutionInput: { binding: { runId: row.runId, companyId: ids.company, agentId: ids.agent, issueId: row.issueId },
|
||||
session: { normalizedSessionId: row.sessionId } },
|
||||
} })));
|
||||
await db.insert(nativeRunFinalizations).values(parked.map(row => ({ runId: row.runId, companyId: ids.company, issueId: row.issueId,
|
||||
phase: "terminal_failure", attempt: 1, failureCode: "native_session_interrupted", failureDetail: {
|
||||
message: "local_runtime_api_listener_unavailable", originalFailureCode: "native_session_interrupted", recoveryMode: "ambiguous_state",
|
||||
providerSessionEstablished: false, providerEventsExist: false, checkpointExists: false,
|
||||
} })));
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scanned: 100, scheduled: 0 });
|
||||
expect(await executionControlSchedule(db).replacement()).not.toBeNull();
|
||||
invalidateNativeReplacementDiscovery(db);
|
||||
}
|
||||
if (kind === "historical_listener_lock_timeout") {
|
||||
let release!: () => void, announce!: () => void;
|
||||
const locked = new Promise<void>(resolve => { announce = resolve; });
|
||||
const released = new Promise<void>(resolve => { release = resolve; });
|
||||
const holder = db.transaction(async tx => {
|
||||
await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, ids.issue)).for("update");
|
||||
announce(); await released;
|
||||
});
|
||||
await locked;
|
||||
try {
|
||||
await expect(reconcileSafeNativeReplacements(db)).rejects.toThrow();
|
||||
expect(await executionControlSchedule(db).replacement()).not.toBeNull();
|
||||
} finally { release(); await holder; }
|
||||
}
|
||||
let replacement;
|
||||
if (scenario === "deletion_race" || scenario === "controller_claim_race") {
|
||||
let release!: () => void, announce!: () => void;
|
||||
@@ -1783,12 +1907,102 @@ suite("remote workspace lifecycle with real Postgres and independent filesystem
|
||||
expect(blocked).toBe(true);
|
||||
} finally { release(); await deletion; replacement = await pending; }
|
||||
} else replacement = await reconcileSafeNativeReplacements(db);
|
||||
expect(replacement).toMatchObject({ scheduled: scenario === "saved" ? 1 : 0 });
|
||||
expect(replacement).toMatchObject({ scheduled: shouldSchedule ? 1 : 0 });
|
||||
const successors = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, ids.run));
|
||||
expect(successors).toHaveLength(scenario === "saved" ? 1 : 0);
|
||||
if (scenario === "saved") expect(successors[0]).toMatchObject({ status: "scheduled_retry",
|
||||
expect(successors).toHaveLength(shouldSchedule ? 1 : 0);
|
||||
if (shouldSchedule) expect(successors[0]).toMatchObject({ status: "scheduled_retry",
|
||||
contextSnapshot: { retryOfRunId: ids.run, forceFreshSession: true } });
|
||||
else expect(await fs.readFile(path.join(prepared.remoteRoot, "saved.txt"), "utf8"))
|
||||
if (kind === "historical_listener_later_candidate") {
|
||||
for (let pass = 0; pass < 3 && await executionControlSchedule(db).replacement() !== null; pass++) {
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scheduled: 0 });
|
||||
}
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
}
|
||||
if (kind === "historical_listener_guard") expect(await getExecutionBlocker(db, ids.company, ids.issue)).toBeNull();
|
||||
if (["historical_listener_provider_event", "historical_listener_checkpoint", "historical_listener_resumed", "historical_listener_other_message",
|
||||
"historical_listener_cancelled", "historical_listener_prior_session", "historical_listener_unknown_event", "historical_listener_budget", "historical_listener_stale_block", "historical_listener_bad_preservation", "historical_listener_dirty", "historical_listener_unconfirmed_stop", "historical_listener_absent", "historical_listener_late_proof"].includes(kind)) {
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
}
|
||||
if (interactionWake) {
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
let wakes = 0;
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.executionReplacement, event => {
|
||||
if (event === "settled") { wakes++; invalidateNativeReplacementDiscovery(db); }
|
||||
});
|
||||
try {
|
||||
await issueThreadInteractionService(db).skipInteraction({ id: ids.issue, companyId: ids.company,
|
||||
status: kind === "historical_listener_interaction_paused" ? "backlog" : "blocked" }, interactionId, {}, { userId: "local-board" });
|
||||
expect(wakes).toBeGreaterThan(0);
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scheduled: kind === "historical_listener_late_interaction" ? 1 : 0 });
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scheduled: 0 });
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, ids.run)))
|
||||
.toHaveLength(kind === "historical_listener_late_interaction" ? 1 : 0);
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
} finally { unsubscribe(); }
|
||||
}
|
||||
if (kind === "historical_listener_late_proof") {
|
||||
let wakes = 0;
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.executionReplacement, event => {
|
||||
if (event === "settled") { wakes++; invalidateNativeReplacementDiscovery(db); }
|
||||
});
|
||||
try {
|
||||
const saved = await saveRemoteWorkspace({ db, storage, workspace: await workspace(reserved.id), runId: ids.run,
|
||||
lease: await lease(), target: first, assertOwned: async () => undefined });
|
||||
expect(wakes).toBeGreaterThan(0);
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
const priorWakes = wakes, active = await lease(), pluginId = randomUUID();
|
||||
await db.update(environmentLeases).set({ metadata: { ...active.metadata, driver: "sandbox", pluginId,
|
||||
provider: "daytona", sandboxProviderPlugin: true } }).where(eq(environmentLeases.id, ids.lease));
|
||||
const runtime = environmentRuntimeService(db, { pluginWorkerManager: {
|
||||
isRunning: () => true, getWorker: () => ({ supportedMethods: ["environmentReleaseLease"] }),
|
||||
call: async (_id: string, method: string) => {
|
||||
expect(method).toBe("environmentReleaseLease");
|
||||
return { providerLeaseId: active.providerLeaseId, state: "destroyed" };
|
||||
},
|
||||
} as never });
|
||||
await runtime.releaseRunLeases(ids.run, "failed");
|
||||
expect(wakes).toBeGreaterThan(priorWakes);
|
||||
expect(await executionControlSchedule(db).replacement()).not.toBeNull();
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scheduled: 1 });
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scheduled: 0 });
|
||||
expect(await executionControlSchedule(db).replacement()).toBeNull();
|
||||
const restored = await createSavedWorkspaceLoader(db, storage)({ companyId: ids.company, workspaceId: reserved.id, snapshotId: saved.snapshotId });
|
||||
try { expect(await fs.readFile(path.join(restored.root, "saved.txt"), "utf8")).toBe("work survives a failed provider session\n"); }
|
||||
finally { await restored.cleanup(); }
|
||||
} finally { unsubscribe(); }
|
||||
}
|
||||
if (kind === "historical_listener_bad_preservation") {
|
||||
expect(await getExecutionBlocker(db, ids.company, ids.issue)).toMatchObject({ cause: "native_continuation_requires_reconciliation" });
|
||||
|
||||
}
|
||||
if (processAcceptance) {
|
||||
expect(await reconcileSafeNativeReplacements(db)).toMatchObject({ scheduled: 0 });
|
||||
const { completeHistoricalListenerSuccessor } = await import("../__tests__/helpers/historical-listener-continuation.js");
|
||||
await completeHistoricalListenerSuccessor({ db, runId: successors[0]!.id, root: path.join(root, "successor-runtime"),
|
||||
prepareWorkspace: async () => {
|
||||
const leaseId = randomUUID(), runId = successors[0]!.id;
|
||||
const [nextLease] = await db.insert(environmentLeases).values({ id: leaseId, companyId: ids.company,
|
||||
environmentId: ids.environment, heartbeatRunId: runId, issueId: ids.issue, provider: "daytona",
|
||||
providerLeaseId: `allocation-${leaseId}`, status: "active" }).returning();
|
||||
const next = await reserveRemoteWorkspace({ db, storage, companyId: ids.company, issueId: ids.issue,
|
||||
runId, agentId: ids.agent, environmentId: ids.environment, projectId: null, projectWorkspaceId: null,
|
||||
existingWorkspaceId: reserved.id, source: { kind: "empty" } });
|
||||
const nextTarget = { ...await target("historical-successor"), leaseId, sandboxId: nextLease.providerLeaseId! };
|
||||
const nextPrepared = await prepareRemoteWorkspace({ db, storage, workspace: next, runId,
|
||||
lease: nextLease as EnvironmentLease, target: nextTarget });
|
||||
return { cwd: nextPrepared.remoteRoot, finalize: async () => {
|
||||
const { workspaceOperationService } = await import("./workspace-operations.js");
|
||||
const recorder = workspaceOperationService(db).createRecorder({ companyId: ids.company, issueId: ids.issue, heartbeatRunId: runId });
|
||||
const saved = await recorder.recordOperation({ phase: "workspace_finalize", cwd: nextPrepared.remoteRoot, run: async () => {
|
||||
await saveRemoteWorkspace({ db, storage, workspace: nextPrepared.workspace, runId, lease: nextPrepared.lease,
|
||||
target: nextTarget, assertOwned: async () => undefined });
|
||||
return { status: "succeeded", exitCode: 0 };
|
||||
} });
|
||||
expect(saved.status).toBe("succeeded");
|
||||
} };
|
||||
} });
|
||||
}
|
||||
if (!shouldSchedule) expect(await fs.readFile(path.join(prepared.remoteRoot, "saved.txt"), "utf8"))
|
||||
.toBe("work survives a failed provider session\n");
|
||||
if (scenario === "unknown_action") {
|
||||
const [decision] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, ids.run));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { workspaceAllocationFiles } from "./remote-workspace-allocation.js";
|
||||
import { readNativeWorkspaceMigration } from "./native-runtime/native-workspace-migration.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -763,7 +764,10 @@ export async function saveRemoteWorkspace(input: {
|
||||
eq(executionWorkspaceSnapshots.executionWorkspaceId, workspace.id), eq(executionWorkspaceSnapshots.status, "uploaded"))).returning({ id: executionWorkspaceSnapshots.id });
|
||||
if (!committed) throw new Error("remote_workspace_snapshot_publication_changed");
|
||||
await notifyRemoteWorkspaceSnapshots(tx, `workspace:${workspace.id}`);
|
||||
if (boundary === "final") await notifyTerminalWorkspaceWork(tx, `workspace:${workspace.id}`);
|
||||
if (boundary === "final") {
|
||||
await notifyTerminalWorkspaceWork(tx, `workspace:${workspace.id}`);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionReplacement);
|
||||
}
|
||||
const receipt = object(workspace.metadata?.remoteWorkspaceSnapshotRepositories);
|
||||
const priorRepositories = receipt.snapshotId === current.snapshotId && Array.isArray(receipt.repositories)
|
||||
? remoteWorkspaceSourceSchema.parse({ kind: "empty", repositories: receipt.repositories }).repositories ?? [] : [];
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function notifyRunDispatch(transaction: object, scope?: RunDispatch
|
||||
await notifyIssueRecovery(transaction, scope ? { companyId: scope.companyId } : undefined);
|
||||
const hint = scope ? JSON.stringify({ companyId: scope.companyId }) : "*";
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.nativeProcessSessions, hint);
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.executionReplacement, hint);
|
||||
await notifyQueuedRunDispatch(transaction, scope);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,24 @@ describe("unadmitted chat Retry presentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The native coordinator resumes the same run itself after an attach failure.
|
||||
// A manual Retry offered meanwhile would race that automatic resume.
|
||||
it("shows a pending native same-run resume instead of a competing Retry", () => {
|
||||
const now = Date.parse("2026-11-06T15:25:50.000Z");
|
||||
const failed = { ...run, runtimeMode: "native" as const, errorCode: "native_session_interrupted", resultJson: null };
|
||||
const resumeAt = new Date("2026-11-06T15:26:18.687Z");
|
||||
expect(projectTaskInterruption(issue, { run: { ...failed, nativeResumeAt: resumeAt }, agent, paused: false, approval: false }, now))
|
||||
.toMatchObject({ state: "retry_scheduled", canRetry: false, nextRetryAt: resumeAt.toISOString() });
|
||||
// A due resume is being dispatched; it is still not the user's turn.
|
||||
expect(projectTaskInterruption(issue, { run: { ...failed, nativeResumeAt: new Date(now - 30_000) }, agent, paused: false, approval: false }, now))
|
||||
.toMatchObject({ state: "working", canRetry: false });
|
||||
// A resume that never ran, or no resume at all, leaves exactly one Retry.
|
||||
expect(projectTaskInterruption(issue, { run: { ...failed, nativeResumeAt: new Date(now - 10 * 60_000) }, agent, paused: false, approval: false }, now))
|
||||
.toMatchObject({ state: "failed", canRetry: true });
|
||||
expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false }, now))
|
||||
.toMatchObject({ state: "failed", canRetry: true });
|
||||
});
|
||||
|
||||
it("explains a persisted Retry wait without exposing the saved payload", () => {
|
||||
const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null };
|
||||
const wait = { id: "retry", requestedAt: new Date(), sourceRunId: failed.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm";
|
||||
import { agents, agentWakeupRequests, approvals, chatActions, companies, heartbeatRuns, issueApprovals, issueRecoveryActions,
|
||||
issues, issueTreeHoldMembers, issueTreeHolds, projects, type Db } from "@paperclipai/db";
|
||||
issues, issueTreeHoldMembers, issueTreeHolds, nativeRunFinalizations, projects, type Db } from "@paperclipai/db";
|
||||
import { unionAll } from "drizzle-orm/pg-core";
|
||||
import { HEARTBEAT_RUN_STATUSES, readProviderFailure, type TaskInterruptionPresentation } from "@paperclipai/shared";
|
||||
import { captureTaskInterruptionFailure } from "../sentry.js";
|
||||
@@ -26,11 +26,17 @@ import { AI_CONNECTION_BUSY_RETRY_REASON, AI_CONNECTION_POOL_WAIT_RETRY_REASON,
|
||||
const resourceWaitRetryReasons = new Set<string>([WORKSPACE_BUSY_RETRY_REASON, AI_CONNECTION_BUSY_RETRY_REASON,
|
||||
AI_CONNECTION_POOL_WAIT_RETRY_REASON, RUNNER_REVISION_WAIT_RETRY_REASON]);
|
||||
|
||||
// A due same-run resume dispatches within seconds; past this, offer Retry.
|
||||
const NATIVE_RESUME_DISPATCH_GRACE_MS = 2 * 60_000;
|
||||
|
||||
type ProjectionIssue = Pick<typeof issues.$inferSelect, "id" | "companyId" | "status" | "assigneeAgentId" | "projectId">;
|
||||
type ProjectionRun = Pick<typeof heartbeatRuns.$inferSelect,
|
||||
"id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> &
|
||||
Partial<Pick<typeof heartbeatRuns.$inferSelect, "agentId" | "error" | "companyId" | "nativeIssueId" | "runtimeMode" | "startedAt"
|
||||
| "scheduledRetryReason">>;
|
||||
| "scheduledRetryReason">> & {
|
||||
/** Due time of the native coordinator's own same-run resume, if one is pending. */
|
||||
nativeResumeAt?: Date | null;
|
||||
};
|
||||
type ProjectionWait = { id: string; requestedAt: Date; sourceRunId?: string | null; executionWaitReason?: string | null;
|
||||
preparationStatus?: string | null; preparationRetryAt?: number | string | null };
|
||||
type ProjectionState = {
|
||||
@@ -148,10 +154,16 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
|
||||
}
|
||||
if (run.status === "scheduled_retry" && resourceWaitRetryReasons.has(run.scheduledRetryReason ?? "") && !system)
|
||||
return { ...base, state: "working" };
|
||||
const retryAt = system?.nextAttemptAt ?? run.scheduledRetryAt?.getTime();
|
||||
// The native coordinator resumes this same run by itself. A manual Retry
|
||||
// would race it, so show the resume until it is due and briefly while its
|
||||
// dispatch runs. A resume that never ran falls through to one Retry.
|
||||
const nativeResumeAt = run.nativeResumeAt?.getTime();
|
||||
const retryAt = system?.nextAttemptAt ?? nativeResumeAt ?? run.scheduledRetryAt?.getTime();
|
||||
if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled",
|
||||
nextRetryAt: new Date(retryAt).toISOString(), canRetry: !preparationPending,
|
||||
nextRetryAt: new Date(retryAt).toISOString(), canRetry: !preparationPending && !nativeResumeAt,
|
||||
...(system?.nextAttemptAt && recoveryStep ? { recoveryStep } : {}) };
|
||||
if (nativeResumeAt && !system && now - nativeResumeAt < NATIVE_RESUME_DISPATCH_GRACE_MS)
|
||||
return { ...base, state: "working" };
|
||||
if (recoveryRunning) return { ...base, state: "working", ...(recoveryStep ? { recoveryStep } : {}) };
|
||||
if (retryWait?.sourceRunId === run.id && retryWait.executionWaitReason) {
|
||||
const reason = Object.hasOwn(retryWaitMessages, retryWait.executionWaitReason) ? retryWait.executionWaitReason : "execution_recovery";
|
||||
@@ -203,6 +215,10 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
|
||||
or ${heartbeatRuns.errorCode} = 'workspace_validation_failed' then ${heartbeatRuns.error} else null end`,
|
||||
errorCode: heartbeatRuns.errorCode, livenessState: heartbeatRuns.livenessState, scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
|
||||
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
|
||||
nativeResumeAt: sql<Date | null>`(select ${nativeRunFinalizations.nextAttemptAt} from ${nativeRunFinalizations}
|
||||
where ${nativeRunFinalizations.companyId} = ${heartbeatRuns.companyId} and ${nativeRunFinalizations.runId} = ${heartbeatRuns.id}
|
||||
and ${heartbeatRuns.runtimeMode} = 'native' and ${heartbeatRuns.status} = 'failed'
|
||||
and ${nativeRunFinalizations.resultId} is null and ${nativeRunFinalizations.phase} = 'retryable_failure')`.mapWith(value => value == null ? null : new Date(value)),
|
||||
// The run whose resultJson supplied providerFailure. A scheduled retry
|
||||
// shows its predecessor's reason, so redaction must use that run's registry.
|
||||
providerFailureRunId: sql<string>`case when ${heartbeatRuns.resultJson}->'providerFailure' is null
|
||||
|
||||
@@ -4530,6 +4530,13 @@ export function toolAccessService(
|
||||
let applicationNamespace = input.applicationName ?? input.name;
|
||||
const transport = input.transport;
|
||||
if (!transport) throw badRequest("Tool connection transport is required");
|
||||
// This path writes only tool connections. Channel setup has its own
|
||||
// services. Reject other purposes here, before any row is written, so the
|
||||
// database purpose/transport check never turns them into a 500.
|
||||
if (input.connectionPurpose !== undefined && input.connectionPurpose !== "tool")
|
||||
throw unprocessable("Only tool connections can be created here");
|
||||
if (transport === "chat_sdk" || transport === "voice")
|
||||
throw unprocessable("Chat and voice connections cannot be created here");
|
||||
const config = normalizeGoogleSheetsConnectionConfig(
|
||||
input.config ?? input.transportConfig ?? {},
|
||||
);
|
||||
|
||||
@@ -390,6 +390,7 @@ export async function recoverRetainedWorkspace(input: Source & { db: Db; environ
|
||||
const activity = await db.transaction(async tx => {
|
||||
await notifyNativeResultCommit(tx, input);
|
||||
await notifyNativeWorkspaceFinalization(tx, input);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionReplacement);
|
||||
await notifyNativeStatusReassessment(tx, input);
|
||||
await tx.insert(workspaceOperations).values({ companyId: input.companyId, issueId: input.issueId,
|
||||
heartbeatRunId: input.runId, phase: "workspace_finalize", status: "succeeded", finishedAt: new Date(),
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { test, expect, request as pwRequest, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E (N-13): the in-app announcement well ("Introducing Connectors" upsell)
|
||||
*
|
||||
* stays hidden while a new company's onboarding first task is open, so it
|
||||
* cannot cover the onboarding questions or the account menu in the user's
|
||||
* first session. Placement (never covering the account menu once shown) is
|
||||
* covered by announcement-placement.spec.ts.
|
||||
*
|
||||
* The public feed is mocked so the spec does not depend on what the live feed
|
||||
* is publishing; the card mirrors the real 2026-09-14-connectors entry.
|
||||
*/
|
||||
|
||||
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
|
||||
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||
const ANNOUNCEMENT_ID = "e2e-n13-connectors";
|
||||
const SETTLE_WAIT_MS = 4_500; // ANNOUNCEMENT_SETTLE_MS (3 s) plus slack.
|
||||
const ONE_PIXEL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
async function createCompany(board: APIRequestContext, name: string) {
|
||||
const res = await board.post(`${BASE_URL}/api/companies`, { data: { name: `${name}-${Date.now()}` } });
|
||||
if (!res.ok()) throw new Error(`POST /api/companies → ${res.status()}: ${await res.text()}`);
|
||||
const company = await res.json();
|
||||
// A company without agents lands on "Create your first agent"; add one so the normal shell renders.
|
||||
const agentRes = await board.post(`${BASE_URL}/api/companies/${company.id}/agents`, {
|
||||
data: { name: "Chief of staff", role: "ceo", adapterType: "process", adapterConfig: { command: "true" } },
|
||||
});
|
||||
if (!agentRes.ok()) throw new Error(`POST agents → ${agentRes.status()}: ${await agentRes.text()}`);
|
||||
return { id: company.id as string, prefix: (company.issuePrefix ?? company.prefix) as string };
|
||||
}
|
||||
|
||||
async function createIssue(board: APIRequestContext, companyId: string, data: Record<string, unknown>) {
|
||||
const res = await board.post(`${BASE_URL}/api/companies/${companyId}/issues`, { data });
|
||||
if (!res.ok()) throw new Error(`POST issues → ${res.status()}: ${await res.text()}`);
|
||||
return res.json() as Promise<{ id: string; identifier: string; originKind: string | null }>;
|
||||
}
|
||||
|
||||
async function mockAnnouncementFeed(page: Page) {
|
||||
await page.route("**/api/announcements/current", (route) => route.fulfill({ json: {
|
||||
id: ANNOUNCEMENT_ID,
|
||||
eyebrow: "Introducing Connectors",
|
||||
title: "Give your agents the access they need",
|
||||
description: "Connect Gmail, GitHub, Notion, and more. Choose which agents get access, which accounts they use, and which actions they can take.",
|
||||
image: { path: `assets/${"a".repeat(64)}.png`, alt: "Paperclip agents with their own sets of connected apps." },
|
||||
primaryAction: { kind: "route", label: "Explore connectors", path: "/apps" },
|
||||
} }));
|
||||
await page.route(`**/api/announcements/${ANNOUNCEMENT_ID}/image`, (route) =>
|
||||
route.fulfill({ contentType: "image/png", body: ONE_PIXEL_PNG }));
|
||||
await page.route(`**/api/announcements/${ANNOUNCEMENT_ID}/dismiss`, (route) => route.fulfill({ status: 204 }));
|
||||
await page.route("**/api/workspace-preferences/me", (route) => route.fulfill({ json: {
|
||||
userId: "local-board", coordinatorWorkspaceEnabled: false, updatedAt: null,
|
||||
} }));
|
||||
await page.addInitScript(() => {
|
||||
for (const key of Object.keys(window.localStorage)) {
|
||||
if (key.includes("announcement")) window.localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Announcement well (N-13)", () => {
|
||||
let board: APIRequestContext;
|
||||
const companyIds: string[] = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
board = await pwRequest.newContext({ baseURL: BASE_URL });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
for (const id of companyIds) await board.delete(`${BASE_URL}/api/companies/${id}`).catch(() => {});
|
||||
await board.dispose();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockAnnouncementFeed(page);
|
||||
});
|
||||
|
||||
test("a brand-new company with its onboarding task open does not show the upsell", async ({ page }) => {
|
||||
const company = await createCompany(board, "E2E-N13-Onboarding");
|
||||
companyIds.push(company.id);
|
||||
const firstTask = await createIssue(board, company.id, {
|
||||
title: "Plan our launch", status: "todo", onboardingFirstTask: true,
|
||||
});
|
||||
expect(firstTask.originKind).toBe("onboarding_first_task");
|
||||
|
||||
await page.goto(`/${company.prefix}/issues/${firstTask.identifier}`);
|
||||
await expect(page.getByRole("button", { name: "Open account menu" })).toBeVisible();
|
||||
await page.waitForTimeout(SETTLE_WAIT_MS);
|
||||
await page.screenshot({ path: test.info().outputPath("onboarding-task.png") });
|
||||
const well = page.getByRole("complementary", { name: "Paperclip announcements" });
|
||||
await expect(well).toHaveCount(0);
|
||||
|
||||
// Positive control on the same page: once onboarding finishes, the same
|
||||
// mocked announcement renders without a reload. This proves the absence
|
||||
// above comes from the onboarding gate, not from a broken feed mock.
|
||||
const finish = await board.patch(`${BASE_URL}/api/issues/${firstTask.id}`, { data: { status: "done" } });
|
||||
if (!finish.ok()) throw new Error(`PATCH issue → ${finish.status()}: ${await finish.text()}`);
|
||||
await expect(well).toBeVisible({ timeout: 20_000 });
|
||||
await expect(well.getByRole("link", { name: "Explore connectors" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HealthStatus } from "@/api/health";
|
||||
import { AnnouncementWell } from "./AnnouncementWell";
|
||||
import { announcementPreview } from "@/lib/announcement-preview";
|
||||
|
||||
// N-13: the connectors announcement must not appear in a new user's first
|
||||
// session, i.e. while the company's onboarding first task is still open.
|
||||
const state = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "alice", settled: true }) }));
|
||||
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company", loading: false }) }));
|
||||
vi.mock("@/context/DialogContext", () => ({ useDialogState: () => ({ onboardingOpen: false }) }));
|
||||
vi.mock("@/context/ToastContext", () => ({ useOptionalToastActions: () => null, useOptionalToastState: () => [] }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { list: state.list } }));
|
||||
vi.mock("@/hooks/useAnnouncement", () => ({ useAnnouncement: (options: { enabled: boolean }) => ({
|
||||
announcement: options.enabled ? announcementPreview : null, dismiss: vi.fn(),
|
||||
}) }));
|
||||
vi.mock("./AnnouncementCard", () => ({ AnnouncementCard: () => <p>Announcement fixture</p> }));
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const firstTask = (status: string) => ({ id: "issue-1", companyId: "company", originKind: "onboarding_first_task", status });
|
||||
|
||||
describe("announcement well during onboarding", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const health = { deploymentMode: "authenticated" } as HealthStatus;
|
||||
const visible = () => Boolean(container.querySelector('[aria-label="Paperclip announcements"]'));
|
||||
let client: QueryClient;
|
||||
const flush = async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); };
|
||||
const render = async () => {
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><AnnouncementWell health={health} /></QueryClientProvider>));
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
||||
};
|
||||
beforeEach(() => {
|
||||
state.list.mockReset();
|
||||
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
|
||||
|
||||
it("stays hidden while the onboarding first task is open", async () => {
|
||||
state.list.mockResolvedValue([firstTask("todo")]);
|
||||
await render();
|
||||
expect(visible()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows once the onboarding first task is finished, or when the company never had one", async () => {
|
||||
state.list.mockResolvedValue([firstTask("done")]);
|
||||
await render();
|
||||
expect(visible()).toBe(true);
|
||||
await act(async () => root.unmount()); root = createRoot(container);
|
||||
state.list.mockResolvedValue([]);
|
||||
await render();
|
||||
expect(visible()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows without a reload once the open onboarding task finishes and company issues refresh", async () => {
|
||||
state.list.mockResolvedValue([firstTask("todo")]);
|
||||
await render();
|
||||
expect(visible()).toBe(false);
|
||||
state.list.mockResolvedValue([firstTask("done")]);
|
||||
// Issue live updates invalidate the company's issue queries by this prefix.
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: ["issues", "company"] }); });
|
||||
await flush();
|
||||
expect(visible()).toBe(true);
|
||||
});
|
||||
|
||||
it("stays hidden when onboarding state cannot be read", async () => {
|
||||
state.list.mockRejectedValue(new Error("offline"));
|
||||
await render();
|
||||
expect(visible()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HealthStatus } from "@/api/health";
|
||||
import { AnnouncementWell } from "./AnnouncementWell";
|
||||
@@ -18,6 +19,7 @@ vi.mock("@/hooks/useAnnouncement", () => ({ useAnnouncement: (options: { enabled
|
||||
state.hook(options);
|
||||
return { announcement: options.enabled ? announcementPreview : null, dismiss: state.dismiss };
|
||||
} }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { list: vi.fn().mockResolvedValue([]) } }));
|
||||
vi.mock("./AnnouncementCard", () => ({ AnnouncementCard: ({ onDismiss }: { onDismiss: () => void }) => <button onClick={onDismiss}>Dismiss fixture</button> }));
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -25,12 +27,16 @@ describe("announcement placement gates", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let health: HealthStatus;
|
||||
const render = async () => { await act(async () => root.render(<AnnouncementWell health={health} />)); };
|
||||
let client: QueryClient;
|
||||
const render = async () => { await act(async () => root.render(<QueryClientProvider client={client}><AnnouncementWell health={health} /></QueryClientProvider>)); };
|
||||
const visible = () => Boolean(container.querySelector('[aria-label="Paperclip announcements"]'));
|
||||
beforeEach(() => {
|
||||
Object.assign(state, { userId: "alice", settled: true, companyId: "company", loading: false, onboardingOpen: false, toasts: [] });
|
||||
vi.clearAllMocks();
|
||||
health = { deploymentMode: "authenticated" } as HealthStatus;
|
||||
// Onboarding is finished (no open first task); onboarding gating is covered in AnnouncementWell.onboarding.test.tsx.
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||
client.setQueryData(["issues", "company", "onboarding-first-task"], []);
|
||||
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
|
||||
import { useAccountIdentity } from "@/api/companies-query";
|
||||
import type { HealthStatus } from "@/api/health";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useDialogState } from "@/context/DialogContext";
|
||||
import { useOptionalToastActions, useOptionalToastState } from "@/context/ToastContext";
|
||||
import { useAnnouncement } from "@/hooks/useAnnouncement";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AnnouncementCard } from "./AnnouncementCard";
|
||||
|
||||
// Includes the command palette, sheets and dialogs created outside DialogContext.
|
||||
@@ -22,16 +26,36 @@ function useModalOpen(enabled: boolean) {
|
||||
return open;
|
||||
}
|
||||
|
||||
const FINISHED_ONBOARDING_STATUSES = new Set(["done", "cancelled"]);
|
||||
|
||||
/**
|
||||
* A new user's first session is the onboarding first task. Announcements wait
|
||||
* until that task is finished so they cannot cover its questions. Unknown
|
||||
* state (loading or a failed read) keeps the optional card hidden.
|
||||
*/
|
||||
function useOnboardingInProgress(companyId: string | null) {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.issues.onboardingFirstTask(companyId ?? "__none__"),
|
||||
queryFn: ({ signal }) => issuesApi.list(companyId!, { originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND, limit: 1 }, { signal }),
|
||||
enabled: Boolean(companyId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
if (!companyId) return false;
|
||||
if (!query.isSuccess) return true;
|
||||
return query.data.some((issue) => issue.originKind === ONBOARDING_FIRST_TASK_ORIGIN_KIND && !FINISHED_ONBOARDING_STATUSES.has(issue.status));
|
||||
}
|
||||
|
||||
export function AnnouncementWell({ health }: { health?: HealthStatus }) {
|
||||
const { userId: accountId, settled } = useAccountIdentity();
|
||||
const { selectedCompanyId, loading } = useCompany();
|
||||
const { onboardingOpen } = useDialogState();
|
||||
const toastActions = useOptionalToastActions();
|
||||
const toasts = useOptionalToastState();
|
||||
const onboardingInProgress = useOnboardingInProgress(selectedCompanyId);
|
||||
const userId = health?.deploymentMode === "local_trusted" ? "local-board" : (settled ? accountId : null);
|
||||
const { announcement, dismiss } = useAnnouncement({
|
||||
userId, companyId: selectedCompanyId,
|
||||
enabled: Boolean(userId && !loading && selectedCompanyId && !onboardingOpen),
|
||||
enabled: Boolean(userId && !loading && selectedCompanyId && !onboardingOpen && !onboardingInProgress),
|
||||
onSaveFailure: (savedLocally) => toastActions?.pushToast({
|
||||
title: savedLocally ? "Dismissed in this browser" : "Dismissed for this visit",
|
||||
body: "Couldn’t save across devices. We’ll retry when you reconnect or return.",
|
||||
|
||||
@@ -263,13 +263,23 @@ describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => {
|
||||
} finally { flushSync(() => root.unmount()); }
|
||||
});
|
||||
|
||||
it("explains automatic workspace waiting without promising that a reply bypasses the lock", () => {
|
||||
it("does not raise a pinned banner for a workspace wait the user cannot act on (N-14)", () => {
|
||||
const issue = {
|
||||
status: "todo", scheduledRetry: { status: "scheduled_retry", scheduledRetryReason: "workspace_busy", scheduledRetryAt: NOW.toISOString() },
|
||||
} as Issue;
|
||||
const root = createRoot(container);
|
||||
flushSync(() => root.render(<IssueMonitorBanner issue={issue} onCheckNow={vi.fn()} />));
|
||||
expect(container.textContent).toBe("");
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the workspace wait as a quiet composer status without promising that a reply bypasses the lock", () => {
|
||||
const issue = {
|
||||
status: "todo", scheduledRetry: { status: "scheduled_retry", scheduledRetryReason: "workspace_busy", scheduledRetryAt: NOW.toISOString() },
|
||||
} as Issue;
|
||||
const root = createRoot(container);
|
||||
flushSync(() => root.render(<><IssueMonitorBanner issue={issue} onCheckNow={vi.fn()} /><IssueMonitorComposerStrip issue={issue} onCheckNow={vi.fn()} /></>));
|
||||
expect(container.textContent).toContain("Waiting for workspace");
|
||||
expect(container.querySelector('[data-testid="issue-monitor-composer-strip"]')?.textContent).toContain("Waiting for workspace");
|
||||
expect(container.textContent).toContain("You can keep sending instructions while the agent waits.");
|
||||
expect(container.textContent).not.toContain("wakes the agent now");
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
|
||||
@@ -296,6 +296,10 @@ export function IssueMonitorBanner({
|
||||
const copy = useMonitorSurfaceCopy(issue, reviews.length, hasActiveRun);
|
||||
if (!copy) return null;
|
||||
|
||||
// A busy workspace is a transient system wait the user cannot act on. It stays
|
||||
// a quiet status (composer strip, properties row), never a pinned notice.
|
||||
if (copy.workspaceWait) return null;
|
||||
|
||||
const canCancel = onCancelMonitor && deriveMonitorState(issue).source === "monitor";
|
||||
|
||||
return (
|
||||
|
||||
@@ -121,11 +121,9 @@ function getRetryNowButton() {
|
||||
}
|
||||
|
||||
describe("IssueScheduledRetryCard", () => {
|
||||
it("shows workspace contention as an automatic wait without failure or retry controls", () => {
|
||||
it("raises no conversation notice for workspace contention the user cannot act on (N-14)", () => {
|
||||
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={{ ...baseRetry, scheduledRetryReason: "workspace_busy" }} />);
|
||||
expect(container.textContent).toContain("Waiting for workspace");
|
||||
expect(container.textContent).toContain("Work starts automatically");
|
||||
expect(container.textContent).not.toMatch(/failed|Retry|Attempt|Replaces run/);
|
||||
expect(container.textContent).toBe("");
|
||||
expect(getRetryNowButton()).toBeNull();
|
||||
});
|
||||
it("surfaces model capacity and the automatic retry without a generic failure", () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { formatRetryReason } from "@/lib/runRetryState";
|
||||
import type { IssueScheduledRetry } from "@paperclipai/shared";
|
||||
import { useRetryNowMutation, type RetryNowError } from "../hooks/useRetryNowMutation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { describeError } from "@/api/errors";
|
||||
|
||||
const MAX_TURN_CONTINUATION = "max_turns_continuation";
|
||||
@@ -34,13 +33,9 @@ export function IssueScheduledRetryCard({
|
||||
if (!scheduledRetry || !issueId) return null;
|
||||
if (scheduledRetry.status !== "scheduled_retry") return null;
|
||||
|
||||
if (scheduledRetry.scheduledRetryReason === "workspace_busy") {
|
||||
return (
|
||||
<InlineBanner tone="info" icon={Clock} title="Waiting for workspace" className="mb-3">
|
||||
Another task is using this workspace. Work starts automatically when it is available.
|
||||
</InlineBanner>
|
||||
);
|
||||
}
|
||||
// Workspace contention is a transient wait with nothing for the user to do.
|
||||
// The composer strip and properties row carry the quiet status instead.
|
||||
if (scheduledRetry.scheduledRetryReason === "workspace_busy") return null;
|
||||
|
||||
const continuation = isContinuationReason(scheduledRetry.scheduledRetryReason);
|
||||
const dueAtIso = scheduledRetry.scheduledRetryAt
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceE
|
||||
import { CoordinatorTaskCommitment } from "./coordinator/CoordinatorTaskCommitment";
|
||||
|
||||
import { DecisionCardFooter } from "./DecisionCardFooter";
|
||||
import { ToolActionAuditHashCopy } from "./ToolActionAuditHashCopy";
|
||||
|
||||
const OTHER_ANSWER_ID = "__paperclip_other__";
|
||||
|
||||
@@ -1847,12 +1848,12 @@ function RequestToolActionCard({
|
||||
{isPending ? inspectableArguments ? (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
|
||||
Arguments and audit hash
|
||||
Arguments
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-foreground">
|
||||
{payload.argumentsSummaryJson}
|
||||
</pre>
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">{payload.argumentsHash}</p>
|
||||
<ToolActionAuditHashCopy hash={payload.argumentsHash} />
|
||||
</details>
|
||||
) : <p className="mt-3 text-xs text-muted-foreground">Request arguments are unavailable.</p> : null}
|
||||
{isPending ? (
|
||||
|
||||
@@ -2138,6 +2138,30 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
return { root, queryClient };
|
||||
}
|
||||
|
||||
// N-16: the gateway is an advanced route, not the next thing after the key.
|
||||
// It sat directly under the API key field, inside the key card; it belongs
|
||||
// in the step's Advanced disclosure, hidden until that is opened.
|
||||
it.each([["Claude API", "claude_local", /Claude.*API/], ["Codex API", "codex_local", /OpenAI.*API/]] as const)("keeps Use a custom gateway in the closed Advanced disclosure on the %s key step", async (_path, adapterType, source) => {
|
||||
const handles = await openStep4({ adapterType, runner: "paperclip", credentialMode: "api" });
|
||||
try {
|
||||
await pickSource(source);
|
||||
const keyField = document.body.querySelector('input[type="password"]') as HTMLInputElement;
|
||||
expect(keyField, "the API key field should be showing").toBeTruthy();
|
||||
const gateway = () => [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Use a custom gateway");
|
||||
expect(gateway(), "the custom gateway control should still exist").toBeTruthy();
|
||||
const advanced = gateway()!.closest("details");
|
||||
expect(advanced, "Use a custom gateway should live inside the Advanced disclosure").toBeTruthy();
|
||||
expect(advanced!.querySelector(":scope > summary")?.textContent?.trim()).toBe("Advanced");
|
||||
expect(advanced!.open, "Advanced should start closed").toBe(false);
|
||||
expect(advanced!.contains(keyField), "the key field stays outside Advanced").toBe(false);
|
||||
await act(async () => { advanced!.open = true; });
|
||||
await flushReact();
|
||||
await act(async () => gateway()!.click());
|
||||
await flushReact();
|
||||
expect(document.querySelector('input[aria-label="Provider URL"]'), "the gateway dialog should still open from Advanced").toBeTruthy();
|
||||
} finally { await act(async () => handles.root.unmount()); }
|
||||
});
|
||||
|
||||
it.each([["successful refresh", false], ["failed refresh and retry", true]] as const)("saves a custom gateway without probing, then explicitly tests and hires with its selected model: %s", async (_scenario, failRefresh) => {
|
||||
const gatewayAccounts: import("@paperclipai/shared").AiConnectionList = {
|
||||
currentUserId: "user-1", canManageConnections: true, connections: [],
|
||||
|
||||
@@ -3157,15 +3157,6 @@ function OnboardingWizardInner({
|
||||
onChange={setModel} open={modelOpen} onOpenChange={setModelOpen}
|
||||
allowDefault={false} required groupByProvider={false}
|
||||
creatable={connectionModels.models.length === 0} presentation="native" />}
|
||||
{createdCompanyId && (adapterType === "codex_local" || adapterType === "claude_local") && <OnboardingGatewaySetup
|
||||
companyId={createdCompanyId} agentId={setupAgentId ?? undefined} environmentId={resolvedLoginEnvironmentId ?? undefined}
|
||||
adapterType={adapterType} disabled={loading || adapterEnvLoading}
|
||||
onComplete={binding => {
|
||||
setGatewayConnection({ companyId: createdCompanyId, adapterType, binding });
|
||||
setApiKey("");
|
||||
setModel("");
|
||||
setError(null);
|
||||
}} />}
|
||||
{(hasGatewaySelection || connectionModels) && <Button type="button" variant="ghost" disabled={loading || adapterEnvLoading} onClick={() => {
|
||||
setGatewayConnection(undefined);
|
||||
setSelectedSavedKey({ companyId: createdCompanyId!, envKey: apiKeyEnvKeyFor(adapterType), id: "" });
|
||||
@@ -3280,6 +3271,22 @@ function OnboardingWizardInner({
|
||||
|
||||
{sourceSelected && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
{/* The gateway is an alternative route, not the next
|
||||
thing after the key, so it waits here rather than
|
||||
sitting under the API key field (N-16). Same gates as
|
||||
the key card it used to live in. */}
|
||||
{connectCardLive && connectCardMounted && !connectProgress && credentialMode === "api" && createdCompanyId && (adapterType === "codex_local" || adapterType === "claude_local") && <OnboardingGatewaySetup
|
||||
companyId={createdCompanyId} agentId={setupAgentId ?? undefined} environmentId={resolvedLoginEnvironmentId ?? undefined}
|
||||
adapterType={adapterType} disabled={loading || adapterEnvLoading}
|
||||
// Ghost padding pulled back so the label lines up with
|
||||
// the Advanced summary above it.
|
||||
className="-ml-3"
|
||||
onComplete={binding => {
|
||||
setGatewayConnection({ companyId: createdCompanyId, adapterType, binding });
|
||||
setApiKey("");
|
||||
setModel("");
|
||||
setError(null);
|
||||
}} />}
|
||||
<AgentRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
|
||||
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error ? describeError(runnerDiscovery.error).body : undefined} onRetry={() => { void runnerDiscovery.refetch(); }}
|
||||
defaultRunner={runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const copyTextToClipboard = vi.fn<(text: string) => Promise<void>>();
|
||||
vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: (text: string) => copyTextToClipboard(text) }));
|
||||
|
||||
import { ToolActionAuditHashCopy } from "./ToolActionAuditHashCopy";
|
||||
|
||||
const hash = "8ca5312877235a4c0f6e9b1d2a7c3e5f9081b2c4d6e8fa1b3c5d7e9f0a2b4c6d";
|
||||
|
||||
describe("ToolActionAuditHashCopy", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
copyTextToClipboard.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("copies the hash without showing it", async () => {
|
||||
copyTextToClipboard.mockResolvedValue(undefined);
|
||||
await act(async () => root.render(<ToolActionAuditHashCopy hash={hash} />));
|
||||
expect(container.textContent).toBe("Copy audit hash");
|
||||
await act(async () => container.querySelector("button")!.click());
|
||||
expect(copyTextToClipboard).toHaveBeenCalledWith(hash);
|
||||
expect(container.textContent).toBe("Copied");
|
||||
});
|
||||
|
||||
it("shows the hash as selectable text when the clipboard is blocked", async () => {
|
||||
copyTextToClipboard.mockRejectedValue(new Error("blocked"));
|
||||
await act(async () => root.render(<ToolActionAuditHashCopy hash={hash} />));
|
||||
await act(async () => container.querySelector("button")!.click());
|
||||
expect(container.textContent).toContain("Couldn’t copy");
|
||||
expect(container.textContent).toContain(hash);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, X } from "lucide-react";
|
||||
import { useCopyAction } from "@/lib/use-copy-action";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
/**
|
||||
* The reviewed-arguments hash identifies exactly what an approval covers, which
|
||||
* matters for audits but means nothing to the person approving. Keep it off the
|
||||
* card and reachable through a copy action instead of printing the raw digest.
|
||||
* When the clipboard is blocked, show the hash as selectable text so the audit
|
||||
* value never becomes unreachable.
|
||||
*/
|
||||
export function ToolActionAuditHashCopy({ hash }: { hash: string }) {
|
||||
const { status, copy } = useCopyAction();
|
||||
const [copyFailed, setCopyFailed] = useState(false);
|
||||
const label = status === "copied" ? "Copied" : status === "failed" ? "Couldn’t copy" : "Copy audit hash";
|
||||
const Icon = status === "copied" ? Check : status === "failed" ? X : Copy;
|
||||
return (
|
||||
<div className="mt-1 space-y-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
title="Copy the hash that identifies these exact arguments"
|
||||
onClick={() => {
|
||||
void copy(hash).then(result => {
|
||||
if (result === "failed") setCopyFailed(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
{label}
|
||||
</Button>
|
||||
{copyFailed ? (
|
||||
<p className="select-all break-all font-mono text-xs text-muted-foreground" aria-label="Audit hash">
|
||||
{hash}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export function OnboardingGatewaySetup({
|
||||
environmentId,
|
||||
adapterType,
|
||||
disabled,
|
||||
className,
|
||||
onComplete,
|
||||
}: {
|
||||
companyId: string;
|
||||
@@ -17,11 +18,12 @@ export function OnboardingGatewaySetup({
|
||||
environmentId?: string;
|
||||
adapterType: "codex_local" | "claude_local";
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onComplete: (binding: Exclude<AiConnectionBinding, { mode: "responsible_user" }>) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <>
|
||||
<Button type="button" variant="ghost" disabled={disabled} onClick={() => setOpen(true)}>
|
||||
<Button type="button" variant="ghost" className={className} disabled={disabled} onClick={() => setOpen(true)}>
|
||||
Use a custom gateway
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { InteractionPreparationNotice } from "@/components/InteractionPreparationNotice";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import { ToolActionAuditHashCopy } from "@/components/ToolActionAuditHashCopy";
|
||||
import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import type { MentionOption } from "@/components/MarkdownEditor";
|
||||
@@ -573,9 +574,7 @@ function ReceiptDisclosure({
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-xs">
|
||||
{interaction.payload.toolAction.argumentsSummaryJson}
|
||||
</pre>
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||
{interaction.payload.toolAction.argumentsHash}
|
||||
</p>
|
||||
<ToolActionAuditHashCopy hash={interaction.payload.toolAction.argumentsHash} />
|
||||
</div>
|
||||
) : null}
|
||||
{interaction.payload.secretProposal ? (
|
||||
@@ -934,14 +933,12 @@ function ConfirmationCard({
|
||||
</MarkdownBody>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
|
||||
Arguments and audit hash
|
||||
Arguments
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-foreground">
|
||||
{interaction.payload.toolAction.argumentsSummaryJson}
|
||||
</pre>
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||
{interaction.payload.toolAction.argumentsHash}
|
||||
</p>
|
||||
<ToolActionAuditHashCopy hash={interaction.payload.toolAction.argumentsHash} />
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { act, forwardRef, useImperativeHandle, useState, type ForwardedRef } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({
|
||||
useCoordinatorWorkspaceEnabled: () => ({ enabled: false, loaded: true }),
|
||||
@@ -148,7 +148,8 @@ describe("TaskChatInteractionCard", () => {
|
||||
expect(container.querySelector("pre")?.textContent).toBe(interaction.payload.toolAction!.argumentsSummaryJson);
|
||||
expect(details.textContent).toContain("November 8, 2030");
|
||||
expect(details.textContent).toContain("[redacted]");
|
||||
expect(details.textContent).toContain(interaction.payload.toolAction!.argumentsHash);
|
||||
expect(details.textContent).not.toContain(interaction.payload.toolAction!.argumentsHash);
|
||||
expect(details.textContent).toContain("Copy audit hash");
|
||||
await act(async () => details.querySelector("summary")!.click());
|
||||
expect(details.open).toBe(true);
|
||||
expect(accept).not.toHaveBeenCalled();
|
||||
@@ -160,6 +161,64 @@ describe("TaskChatInteractionCard", () => {
|
||||
expect(reject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps raw tool ids and the audit hash out of the tool approval card text", async () => {
|
||||
// Shape of the GitHub create_repository approval from the Nov 5 staging run.
|
||||
const rawToolName = "mcp.app-gallery-github-42e51e3f-9377-447b-ad45-333beda8a50a-b873f178:create-repository";
|
||||
const argumentsHash = "8ca5312877235a4c0f6e9b1d2a7c3e5f9081b2c4d6e8fa1b3c5d7e9f0a2b4c6d";
|
||||
const base = structuredClone(pendingToolActionLongProposalInteraction);
|
||||
const interaction: RequestConfirmationInteraction = {
|
||||
...base,
|
||||
title: "Approve tool action",
|
||||
summary: "This action needs your approval before it runs.",
|
||||
payload: {
|
||||
...base.payload,
|
||||
prompt: "Approve create_repository?",
|
||||
detailsMarkdown: `Tool: \`${rawToolName}\`\nRisk: \`write\``,
|
||||
target: { type: "custom", key: "tool-action:2f92393c-c044-4367-8b6e-b81e2e732165", revisionId: argumentsHash, label: rawToolName },
|
||||
toolAction: {
|
||||
...base.payload.toolAction!,
|
||||
toolName: rawToolName,
|
||||
toolDisplayName: "create_repository",
|
||||
appDisplayName: "GitHub",
|
||||
previewMarkdown: "create_repository · Name: brainrot-storefront-prototype · Private: true",
|
||||
argumentsSummaryJson: JSON.stringify({ name: "brainrot-storefront-prototype", private: true }),
|
||||
argumentsHash,
|
||||
},
|
||||
},
|
||||
};
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
|
||||
onTestFinished(() => {
|
||||
if (originalClipboard) Object.defineProperty(navigator, "clipboard", originalClipboard);
|
||||
else delete (navigator as { clipboard?: unknown }).clipboard;
|
||||
});
|
||||
function ReviewSurface() {
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
return <TaskChatInteractionCard item={interactionItem(interaction)}
|
||||
presentation={reviewing ? "takeover" : "timeline"}
|
||||
onReviewRequest={() => setReviewing(true)}
|
||||
onAcceptInteraction={vi.fn()} onRejectInteraction={vi.fn()} />;
|
||||
}
|
||||
flushSync(() => root.render(<TooltipProvider><ThemeProvider><ReviewSurface /></ThemeProvider></TooltipProvider>));
|
||||
const hashLike = /[0-9a-f]{12,}/i;
|
||||
expect(container.textContent).not.toMatch(hashLike);
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Review request")!.click();
|
||||
});
|
||||
const details = container.querySelector("details")!;
|
||||
await act(async () => details.querySelector("summary")!.click());
|
||||
expect(details.open).toBe(true);
|
||||
expect(container.textContent).toContain("brainrot-storefront-prototype");
|
||||
expect(container.textContent).not.toMatch(hashLike);
|
||||
expect(container.textContent).not.toContain("app-gallery");
|
||||
// The audit hash stays reachable for anyone who needs it, through a copy action.
|
||||
const copy = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Copy audit hash");
|
||||
expect(copy).toBeDefined();
|
||||
await act(async () => copy!.click());
|
||||
expect(writeText).toHaveBeenCalledWith(argumentsHash);
|
||||
});
|
||||
|
||||
it("does not render a pending confirmation in the timeline", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
|
||||
@@ -311,6 +311,8 @@ export const queryKeys = {
|
||||
recoveryActions: (id: string, actionId: string | null, runId: string | null) =>
|
||||
["issues", id, "recovery-actions", actionId, runId] as const,
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
onboardingFirstTask: (companyId: string) =>
|
||||
["issues", companyId, "onboarding-first-task"] as const,
|
||||
mentionPool: (companyId: string) =>
|
||||
["issues", companyId, "mention-pool"] as const,
|
||||
search: (
|
||||
|
||||
Reference in new issue
Block a user