Skip to content

[Bug] ACP cancellation is finalized before agent confirmation, so Pi can keep running and late output is lost #364

Description

@Zker67

Summary

Codeg currently treats an ACP cancellation as completed immediately after enqueueing session/cancel, before the agent confirms cancellation through the original session/prompt response.

With Pi via pi-acp, this can produce a visible state split:

  • Codeg marks the conversation cancelled and the UI stops showing the turn.
  • The Pi process can continue running.
  • Pi may later produce a complete final answer in its session file, but Codeg no longer surfaces it.

This issue is specifically about Codeg's cancellation confirmation and late-result reconciliation. The upstream model/API stream failure that initially caused the long stall is a separate problem.

Environment

  • Codeg: 0.21.4
  • OS: Windows 11
  • Pi: 0.81.1
  • pi-acp: 0.0.31
  • Agent transport: ACP over stdio, Pi RPC child process

Observed timeline

Local time UTC+8, July 22, 2026:

  1. A long Pi turn encountered upstream Responses streaming failures, including an EOF and a truncated SSE JSON event.
  2. At 14:38:30, Codeg persisted the conversation as cancelled.
  3. Pi continued working after that state transition:
    • 14:38:48: Pi started another operation/compaction step.
    • 14:42:54: Pi recorded another truncated SSE error.
    • 14:43:43: Pi completed a normal final assistant response (stopReason: stop) and wrote it to its JSONL session.
  4. The final response did not appear in Codeg because the Codeg-side turn had already been finalized as cancelled.
  5. A process sample confirmed that the Pi RPC worker was responsive and mostly idle afterward; this was not a local CPU dead loop.

The Codeg database and Pi JSONL session therefore disagree about whether the turn was actually cancelled.

Source analysis

At commit f073b659e8732745fed42adae83d31219175b278:

  1. ConnectionManager::cancel() enqueues ConnectionCommand::Cancel and then eagerly changes the conversation row from InProgress to Cancelled:

pub async fn cancel(&self, db: &DatabaseConnection, conn_id: &str) -> Result<(), AcpError> {
let (cmd_tx, state_arc, emitter) = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
(
conn.cmd_tx.clone(),
conn.state.clone(),
conn.emitter.clone(),
)
};
cmd_tx
.send(ConnectionCommand::Cancel)
.await
.map_err(|_| AcpError::ProcessExited)?;
// Eagerly flip the row to `Cancelled` so the sidebar/tabs leave the
// "running" state immediately. The agent typically replies with
// `TurnComplete{cancelled}` which the lifecycle subscriber ignores,
// and stays connected (so `handle_terminal_event` doesn't fire either)
// — without this write the row would strand on `InProgress`.
// CAS-guarded so we don't overwrite a `PendingReview`/`Completed`
// status if the turn happened to end just before the user clicked.
let conversation_id = state_arc.read().await.conversation_id;
if let Some(cid) = conversation_id {
match conversation_service::update_status_if(
db,
cid,
ConversationStatus::InProgress,
ConversationStatus::Cancelled,
)
.await
{
Ok(true) => {
emit_with_state(
&state_arc,
&emitter,
AcpEvent::ConversationStatusChanged {
conversation_id: cid,
status: ConversationStatus::Cancelled,
},
)
.await;
}
Ok(false) => {}
Err(e) => {
tracing::error!(
"[ACP][ERROR] failed to mark conversation {cid} cancelled \
on user cancel (conn={conn_id}): {e}"
);
}
}
}
Ok(())

  1. The active-turn cancel handler sends CancelNotification, but immediately emits a synthetic TurnComplete { stop_reason: "cancelled" } without waiting for the agent:

Some(ConnectionCommand::Cancel) => {
// Send CancelNotification to agent to stop the current turn
let _ = cx.send_notification_to(
Agent,
CancelNotification::new(sid.clone()),
);
// Also terminate any command runtimes created for this
// session so cancellation does not hang on long-running
// terminal tools.
terminal_runtime
.release_all_for_session(sid.0.as_ref())
.await;
tracked_terminal_tool_calls.clear();
// Also cancel any pending permission requests
let mut locked = perms.lock().await;
for (_, responder) in locked.drain() {
let _ = responder.respond(RequestPermissionResponse::new(
RequestPermissionOutcome::Cancelled,
));
}
drop(locked);
// Immediately emit TurnComplete so the frontend
// transitions out of "prompting" and the user can
// send new messages. Don't wait for the agent --
// it may be slow to respond or not respond at all.
emit_with_state(
state,
emitter,
AcpEvent::TurnComplete {
session_id: sid.0.to_string(),
stop_reason: "cancelled".into(),
agent_type: agent_type.to_string(),
},
)
.await;

  1. It then drains the actual session/prompt response in a detached task and ignores its result:

// Drain the prompt response in the background so
// the SACP library doesn't log "receiver dropped"
// errors when the agent eventually responds.
tokio::spawn(async move {
let _ = prompt_response.await;
});

This means Codeg cannot distinguish these outcomes:

  • The agent really stopped and returned StopReason::Cancelled.
  • The cancel notification was delayed, ignored, or failed internally.
  • The original prompt later returned end_turn with valid output.
  • The ACP transport disconnected before cancellation was confirmed.

ACP behavior

The ACP schema says that after session/cancel, the agent should stop work and respond to the original session/prompt request with StopReason::Cancelled. The stop reason is the semantic confirmation of successful cancellation:

https://github.com/agentclientprotocol/agent-client-protocol/blob/d37661852765b7e3f6e0a8f076d5f528dad92425/agent-client-protocol-schema/src/v1/agent.rs#L3347-L3353

https://github.com/agentclientprotocol/agent-client-protocol/blob/d37661852765b7e3f6e0a8f076d5f528dad92425/agent-client-protocol-schema/src/v1/agent.rs#L5218-L5227

Codeg currently synthesizes that confirmation client-side instead of observing it from the agent.

Expected behavior

One possible design:

  1. After sending session/cancel, enter a temporary cancelling state.
  2. Continue awaiting the original session/prompt response for a bounded interval.
  3. Finalize as cancelled only after receiving StopReason::Cancelled.
  4. If the agent returns end_turn or sends late output, reconcile and surface the actual result instead of discarding it.
  5. If the acknowledgement times out or the transport closes, show a distinct cancel_unconfirmed / disconnected error and log it clearly.

The UI can stop accepting updates immediately if needed, but persisted status should distinguish requested cancellation from confirmed cancellation.

Related upstream signal

A similar adapter-level symptom has been reported in pi-acp: the UI stops receiving updates while Pi continues working in the backend:

svkozak/pi-acp#71

That may explain why the agent did not stop in this particular case, but Codeg's eager synthetic completion makes the mismatch invisible and guarantees that any late real result is discarded.

Diagnostic limitation

The current Codeg process did not append logs after startup on this machine, so I could not prove from runtime logs whether the session/cancel notification reached pi-acp. The source-level issue remains: Codeg reports cancellation as confirmed without observing the required prompt result, and it intentionally ignores that later result.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions