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:
- A long Pi turn encountered upstream Responses streaming failures, including an EOF and a truncated SSE JSON event.
- At
14:38:30, Codeg persisted the conversation as cancelled.
- 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.
- The final response did not appear in Codeg because the Codeg-side turn had already been finalized as cancelled.
- 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:
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(()) |
- 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; |
- 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:
- After sending
session/cancel, enter a temporary cancelling state.
- Continue awaiting the original
session/prompt response for a bounded interval.
- Finalize as
cancelled only after receiving StopReason::Cancelled.
- If the agent returns
end_turn or sends late output, reconcile and surface the actual result instead of discarding it.
- 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.
Summary
Codeg currently treats an ACP cancellation as completed immediately after enqueueing
session/cancel, before the agent confirms cancellation through the originalsession/promptresponse.With Pi via
pi-acp, this can produce a visible state split:cancelledand the UI stops showing the turn.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
0.21.40.81.1pi-acp:0.0.31Observed timeline
Local time UTC+8, July 22, 2026:
14:38:30, Codeg persisted the conversation ascancelled.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.The Codeg database and Pi JSONL session therefore disagree about whether the turn was actually cancelled.
Source analysis
At commit
f073b659e8732745fed42adae83d31219175b278:ConnectionManager::cancel()enqueuesConnectionCommand::Canceland then eagerly changes the conversation row fromInProgresstoCancelled:codeg/src-tauri/src/acp/manager.rs
Lines 1160 to 1215 in f073b65
CancelNotification, but immediately emits a syntheticTurnComplete { stop_reason: "cancelled" }without waiting for the agent:codeg/src-tauri/src/acp/connection.rs
Lines 4890 to 4924 in f073b65
session/promptresponse in a detached task and ignores its result:codeg/src-tauri/src/acp/connection.rs
Lines 4956 to 4961 in f073b65
This means Codeg cannot distinguish these outcomes:
StopReason::Cancelled.end_turnwith valid output.ACP behavior
The ACP schema says that after
session/cancel, the agent should stop work and respond to the originalsession/promptrequest withStopReason::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:
session/cancel, enter a temporarycancellingstate.session/promptresponse for a bounded interval.cancelledonly after receivingStopReason::Cancelled.end_turnor sends late output, reconcile and surface the actual result instead of discarding it.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/cancelnotification reachedpi-acp. The source-level issue remains: Codeg reports cancellation as confirmed without observing the required prompt result, and it intentionally ignores that later result.