Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

333 KiB

Troubleshooting: Agent Sessions And Lifecycle

Agent runtime index · All troubleshooting

A newly opened Agent window shows processing but no transcript

  • Symptom: A Session is already running, but opening it in another window shows only the processing status and omits the submitted user message. The original window may still show the complete conversation.
  • Quick checks: Correlate the new renderer's selected Session with its first canonical detail and message reads. If the message read fails or times out while the Session projection still reports active work, the transcript is temporarily unhydrated rather than deleted.
  • Root cause: The focused Session reconcile reached a terminal transport failure, but the synchronization lease did not observe that failure or request recovery. The new window therefore retained only the live processing projection.
  • Fix: Preserve timeout as an explicit reconcile failure. While the Session remains focused, report the failure and perform one bounded state-and-message retry. Stop focus-owned recovery when the window releases the synchronization lease.
  • Validation: Make the first focused message hydration fail, then return a canonical user message on the retry. Assert exactly two reads, one reported initial failure, and a restored transcript. Separately cover the reducer's timeout outcome so it remains observable.
  • References: sessionReconcile.reducer.ts, workspaceAgentActivityReconcileBridge.ts

Claude cancellation reaches context deadline exceeded

  • Symptom: Canceling a Claude Code Turn, closing its Session, or leaving a room waits until the caller deadline and reports context deadline exceeded. Repeating the action leaves the same Turn active.
  • Quick checks: Filter daemon logs by CLAUDE_CODE_CANCEL_DIAGNOSTIC, then group by requestId, agentSessionId, turnId, and generationId. Compare interrupt_started with interrupt_succeeded, interrupt_timed_out, or interrupt_failed; then require query_close_succeeded and either consumption_settled or consumption_timed_out. The payload deliberately excludes prompts, tool inputs, credentials, and provider output.
  • Root cause: The SDK interrupt is a control request whose Promise has no native timeout. A wedged Claude Code process may never return its control response. A caller-side Go deadline only stops the RPC waiter and cannot terminate that Promise or the provider process.
  • Fix: Treat cancellation as a Query-lifecycle protocol. Revoke the exact generation, bound cooperative interrupt, close the owned SDK transport when ACK is missing or fails, and separately bound consumer drain before settling canonical Turns. Do not increase only the outer RPC timeout.
  • Validation: Cover an interrupt Promise that never settles, immediate interrupt rejection, and a consumer Promise that never drains. The first two must close transport and cancel the Turn; the last must return an explicit bounded failure.
  • References: queryGeneration.ts, sessionRuntime.ts, claude_sdk_execution.go

Claude Goal stays active after the model becomes idle

  • Symptom: Claude has stopped producing output and the Session becomes idle, but the Goal banner remains active indefinitely. The elapsed time may also reset after leaving and reopening the workspace.
  • Quick checks: Inspect sidecar events and the root Claude transcript. A failed evaluator has an active active_goal or goal_status.met=false, a root session_state_changed(state=idle), and no later goal_status.met=true.
  • Root cause: Claude's SDK stream may omit top-level goal_status attachments. A native evaluator timeout can then reach idle without a terminal verdict. Separately, a timer based on React mount time cannot survive workspace navigation.
  • Fix: Tail only new root transcript rows, drain once more at provider idle, and transition a still-active Goal to canonical blocked. Persist one startedAtUnixMs per Goal generation and derive active elapsed time from it.
  • Validation: Cover both boundaries: terminal transcript evidence written immediately before idle must win, while idle without terminal evidence must emit blocked. Unmount and remount the Goal banner with the same canonical start and require the elapsed value not to reset.
  • References: goalProjection.ts, messageRouter.ts, AgentGoalBanner.tsx

Older extension session fails because its launch identity is incomplete

  • Symptom: Sending a new message to a previously created extension session fails with session runtime snapshot is unavailable: launch identity is incomplete, while a newly created session can still launch.
  • Quick checks: Compare the persisted Session provider with internal_runtime_context.sessionRuntimeSnapshot.provider. The historical defect has an open extension provider such as acp:kimi-code on the Session, an empty provider in the snapshot, and a provider-native fingerprint produced with the same empty provider. An intermediate build may retain the provider field while still carrying that legacy empty-provider fingerprint.
  • Root cause: Registered-only provider normalization was used when writing the snapshot and its provider-native model fingerprint. Extension-owned ACP provider IDs were therefore collapsed to empty even though the durable Session retained the correct provider.
  • Fix: Persist and fingerprint provider-native configurations with open provider normalization. For existing records, recover only when the Session carries a valid extension-owned provider and the snapshot exactly matches the legacy empty-provider fingerprint; keep malformed, registered-provider, plan, and fingerprint-mismatched snapshots fail-closed.
  • Validation: Prove a newly written extension snapshot binds its provider, the exact historical shape resumes through its current enabled Agent Target, a strict context-only read still rejects the incomplete identity, and altered fingerprints or registered-provider fallbacks remain unavailable.
  • References: session_runtime_snapshot.go, model_plan_binding.go

Initial Goal session remains unnamed

  • Symptom: A new session created from /goal <objective> keeps an empty canonical title and the UI shows the localized unnamed-conversation label after the optimistic activation disappears.
  • Quick checks: Read workspace_agent_sessions.title and the first workspace_agent_messages row. The affected shape has kind=session_audit, role=user, goalControl=true, and no normal text Turn.
  • Root cause: Typed initial Goal intentionally skips the ordinary initial Turn. Title derivation existed only in the normal initial-content Exec path, so the session was persisted without a title even though the Goal audit contained the submitted command.
  • Fix: Derive the title in Host before provider startup from InitialDisplayPrompt; when that field is absent, synthesize /goal <objective> or /goal <action>. Mark the derived title established so later runtime state cannot replace it.
  • Validation: Run the shared initial-Goal conformance scenario through both direct Host and service-adapter drivers. Assert the returned canonical title and verify the zero-Turn Goal operation still replays without a second provider startup.
  • References: packages/agent/host/README.md lifecycle.go session_lifecycle_scenarios.go

Goal-control row has no copy action

  • Symptom: A visible /goal ... user bubble has no copy button on hover, while ordinary user text bubbles have one.
  • Quick checks: Inspect the row kind. goal-control renders through AgentGoalControlRow, not AgentMessageBlock; search direct AgentRichTextReadonly uses for a renderer that bypasses AgentCopyableMessageGroup.
  • Root cause: The special row reused the read-only rich-text renderer but not the shared message action wrapper.
  • Fix: Wrap the row in AgentCopyableMessageGroup and write through the host clipboard, with the browser clipboard as the renderer fallback.
  • Validation: Render a durable goal-control row, hover it, assert the copy button exists, click it, and verify the exact command body is written.
  • References: AgentGoalControlRow.tsx AgentMessageActions.tsx

Replay startup reports only building isolated tuttid

  • Symptom: Replay fails before the isolated Workspace becomes ready, but the toast shows only the runner's first progress line instead of the daemon startup failure.
  • Quick checks: Inspect the managed Replay failure status. New builds store errorCause.code and errorCause.message; the outer errorMessage remains the supervision summary.
  • Root cause: The former UI formatter selected the first line from one combined process-log string. The actionable daemon stderr appeared on a later line and was discarded.
  • Fix: Preserve daemon startup stderr as a structured {code, message} cause across Desktop startup, the Replay runner event, and primary Desktop status. Render the structured cause before the outer summary.
  • Validation: Make isolated tuttid reject a Cassette during startup and require the toast to show the daemon rejection while logs retain both the outer startup error and structured cause.

Replay Workspace stays on the starting toast

  • Symptom: The source Desktop keeps showing the Replay Workspace starting toast. The isolated Desktop may appear only after the daemon startup timeout.
  • Quick checks: Inspect the isolated runtime under .tmp/agent-session-replay-workspace-*. The listener must appear at state/run/tuttid.listener.json; inspect logs/desktop.log and the managed child output before debugging renderer readiness.
  • Root cause: The runner waited for the obsolete state/cassette/tuttid.listener.json path after the daemon moved its listener metadata into the canonical runtime run directory.
  • Fix: Resolve Replay daemon discovery through state/run/tuttid.listener.json, matching every other managed daemon consumer. Keep the path in one runner helper and cover it directly.
  • Validation: Launch a managed Replay Workspace and prove its isolated daemon becomes discoverable without waiting for the startup timeout.

Replay fails because Desktop and tuttid use different event catalogs

  • Symptom: Replay stops during Desktop startup with desktop/tuttid event stream catalog mismatch (fail-fast), or the isolated Desktop log contains Event stream catalog revision mismatch.
  • Quick checks: Compare the revisions reported by the runner for the prepared apps/desktop/out bundle, the event-protocol source, and the tuttid binary. A prepared Electron bundle can be stale even when the current source checkout and daemon build match.
  • Root cause: The Desktop renderer and daemon perform a strict event-stream catalog handshake. Reusing a prepared renderer or daemon from a different generated-protocol revision makes the handshake fail before Replay can drive a Session.
  • Fix: For an unmanaged launch, the runner falls back to pnpm-dev-desktop when the current event-protocol source matches tuttid. For a managed launch, rebuild apps/desktop/out and tuttid from the same checkout, or clear the prepared Electron environment before retrying.
  • Validation: Confirm the runner reports matching revisions before launch, then run the Replay scenario and require the first checkpoint to become ready without a catalog mismatch in desktop.log.
  • References: event-stream-catalog.mjs, agent-session-replay.md

Replay starts but the first Turn never becomes idle

  • Symptom: The isolated Replay Workspace is ready, but playback waits on the first Session until transport verification reports unconsumed connections.
  • Quick checks: Query the Cassette-scoped transport playback state and verification endpoint. If the provider tape still has outbound frames while the current UI no longer emits the recorded probe sequence, the Cassette is incompatible with the current strict transport contract.
  • Root cause: Session Replay intentionally validates provider traffic in order. Benign-looking UI probe or request-order changes can therefore make an older Cassette unable to reach its recorded completion frame.
  • Fix: Record a new Cassette with the current architecture. Do not weaken transport ordering or add a compatibility fallback unless the product contract explicitly changes.
  • Validation: The new Cassette must consume every recorded provider connection, reach the expected stable checkpoints, and pass final transport verification.

Replay checkpoint waits forever with observation:null while the session detail is visible

  • Symptom: Replay playback dispatches intents and streams provider frames normally, but a checkpoint wait stalls on {"ready":false,"observation":null,"hasDetail":true} and the coordinator binding reports mounted:true, selectedAgentSessionId:null, detailHydrated:false.
  • Quick checks: Enumerate [data-workbench-window-id] elements over CDP. If two AgentGUI nodes exist — one showing the replayed session and one empty — the cassette binding is attached to the wrong node.
  • Root cause: Something launched an AgentGUI surface before the Replay Workspace bootstrap (for example a dock-launch preparation step). The replay driver then interacts with the pre-launched node through the DOM while the canonical-observation binding waits on the coordinator-launched node, which never selects a session.
  • Fix: In replay mode, never open AgentGUI surfaces outside bridge.bootstrap. The coordinator-launched node must be the only AgentGUI node; the runner waits for its composer after bootstrap instead of dock-launching first.
  • Validation: During replay, exactly one AgentGUI node exists, its binding reports the selected session, and the checkpoint wait resolves within the recorded timing.

Replay fails because a recorded SQLite column does not exist

  • Symptom: Replay startup fails with an error such as table tutti_mode_turn_snapshots has no column named speed.
  • Quick checks: Confirm the Cassette schema. Schema v5 contains initial-state.json/expected-state.json; it never contains table rows or SQL column names.
  • Root cause: An obsolete row-fixture experiment recorded SELECT * output from a database migrated by a different checkout. Import coupled Replay to that checkout's physical SQLite schema.
  • Fix: Re-record with the current schema. Do not add column projection, a legacy reader, or a migration fallback. The daemon restores typed semantic state before Host recovery.
  • Validation: Add or reorder an unrelated SQLite column and prove semantic capture bytes and Replay behavior are unchanged.

Managed Replay leaves an orphan Desktop that crashes with EPIPE

  • Symptom: Closing or replacing a managed Replay run leaves an isolated Electron process behind. It later shows an Electron main-process JavaScript error with Error: write EPIPE from the desktop log sink.
  • Quick checks: Inspect the Electron process parent PID and open files. PPID=1, a Replay workspace log under .tmp/agent-session-replay-workspace-*, and disconnected stdout/stderr identify an orphaned managed Replay process. Map the built stack location back to apps/desktop/src/main/logging.ts before attributing the crash to cassette data or renderer code.
  • Root cause: The multi-cassette managed Replay path launched a detached Desktop with piped output but did not bind the existing managed shutdown lifecycle. When the owner exited, Electron and its managed daemon survived while their output pipe had no reader. A later log write emitted an unhandled EPIPE.
  • Fix: Bind every managed Replay Desktop to the original owner PID and stop its process tree when that owner disappears or its output pipe breaks. Treat desktop and forwarded daemon console output as best effort so a closed diagnostic pipe cannot crash the Electron main process.
  • Validation: Prove the shutdown binding stops Desktop once when the owner is gone, coalesces a concurrent EPIPE, and removes its listeners on disposal. Exercise the process log sink with a writable stream that emits EPIPE, then verify later writes are ignored without an uncaught exception.
  • References: run-agent-session-replay.mjs, logging.ts, tuttidManager.ts

Replay shows the user prompt but never releases assistant output

  • Symptom: Replay creates the Session and persists the user prompt, but the provider tape stops before turn/started or assistant notifications.
  • Quick checks: Compare the next outbound provider frame with daemon RPC logs. If the tape expects thread/read or thread/goal/get, check whether that observer query was issued in this renderer lifecycle. Also compare recorded and replay JSON-RPC request IDs separately from method and params.
  • Root cause: Observer-only provider queries and their auxiliary probe connections are timing-dependent. Treating them as causal byte-stream frames deadlocks replay when the current renderer observes at a different moment. Earlier skipped queries can also shift later request IDs.
  • Fix: Keep causal method/params matching strict, map recorded request IDs to replay request IDs, and allow only the declared observer RPCs and query-only auxiliary connections to be absent. Suppress only their paired responses; preserve interleaved provider notifications. If a causal provider response arrives while replay is waiting briefly for a declared observer RPC, skip that observer immediately and match the causal response against the next recorded frame.
  • Validation: Replay a real three-Turn Cassette through a fresh Replay Workspace, verify every checkpoint and assistant response, require transport exhaustion, and compare final durable state.

One hung provider startup blocks unrelated Agent sessions

  • Symptom: One provider process starts but never reaches runtime-ready, then new or resumed Sessions for other providers also wait without starting their own provider processes.
  • Quick checks: Order agent_session.process.start.sent with Host runtime_started and runtime_session_ready lifecycle steps. If later, unrelated Sessions accumulate wait time before their own process-start event, inspect Controller startup-lock ownership separately from the provider that first stopped making progress.
  • Root cause: The runtime Controller held one process-wide startMu across Adapter.Start and Adapter.Resume. A slow or unbounded provider handshake therefore formed a lock convoy across every room, Session, and provider.
  • Fix: Serialize explicit startup requests by (roomID, agentSessionID). Keep the legacy no-ID start path idempotent with a narrower room-and-provider key, and let startup-lock waiters return when their context is canceled. Provider credential coordination remains a separate Host gate.
  • Validation: Block each of Start and Resume for one provider and prove both operations still complete for an independent provider. Also verify all same-Session combinations remain serialized, anonymous start replay reuses one Session, canceled waiters release their lock references, and repeated shuffled race runs stay clean.
  • References: controller_session_lifecycle.go, controller_test.go

A shared-device connection banner looks terminal while the host is still retrying

  • Symptom: AgentGUI keeps showing a strong connection-lost notice for a shared device, so the user cannot tell whether recovery is still running.
  • Quick checks: Inspect host logs for shared-agent-query.caller.connection.retry, shared-agent-query.caller.connection.dormant, and shared-agent-query.caller.connection.dormant-summary. Correlate them with desktop.device_link.diagnostic when distinguishing direct, rendezvous, and relay transport attempts. A dormant state means the host has moved from short retry delays to low-frequency recovery; it is not a terminal failure.
  • Root cause: The query channel correctly blocked runtime-dependent commands, but its recoverable unavailable state was projected directly as a terminal target-connection error. The target event also omitted retry progress, so an unchanged banner made periodic recovery invisible.
  • Fix: Keep Session runtime availability as the command-safety gate. Separately project target-scoped connection status with a retry attempt. Present recoverable unavailable states as neutral reconnecting progress, reserve the strong unavailable notice for explicitly non-retryable failures, and remove the notice silently after recovery.
  • Validation: Force the query channel into dormant recovery and verify each failed probe increments the target retry attempt while the composer remains blocked. Confirm the reconnecting notice updates without restarting its visibility delay, a non-retryable failure is still immediate, and recovery produces no success banner.
  • References: agent-gui-node.md

Deleting an Agent Session leaves a Tutti plan active or emits duplicate removal events

  • Symptom: Deleting one Session removes the conversation but its pending Tutti Mode Plan remains visible, or clients receive duplicate/missing session_deleted and workspace.workflow.updated invalidations. A workspace clear may leave orphan workflows in an active state.
  • Quick checks: Confirm production wiring assigns the Tutti Mode Plan service as the Agent service's source-session deletion coordinator. Inspect the transaction result for the complete removed Session closure and affected workflow/current-checkpoint identities. Verify workflow events are published only after the transaction returns and that the activity projection is not also running its persistence deleter on the coordinator path.
  • Root cause: Session persistence, activation cleanup, and workflow cancellation form one cross-aggregate use case. Letting data/workspace choose cancellable states hides business policy in the data layer and cannot publish the canonical post-commit workflow event. Calling a service coordinator and the legacy projection deleter together instead splits the transaction and duplicates session_deleted events.
  • Fix: Keep cancellation policy in service/tuttimodeplan: allowed source states, canceled targets, actor, reason, and timestamp are all explicit. Pass one command to the workspace store so Session closure, Tutti activation/Turn snapshots, and workflow transitions commit or roll back together. Return affected identities, then publish workflow and Session invalidations from the respective services after commit. Reserve persistence-only deletion and standalone activation cleanup for isolated tests or legacy orphan fallback.
  • Validation: Force the workflow update to fail and assert the Session and activation still exist. Cover single, descendant batch, runtime-only, and workspace-clear scopes; exact cancellation-state selection; no event on rollback; one workflow event per reported change; and one session_deleted event per unique removed Session.
  • References: workspace-workflows.md, source_session_deletion.go, source_session_deletion.go

Tutti badge is active but no Tutti Mode Plan panel opens

  • Symptom: The composer shows the Tutti badge and the Agent may even emit a provider-native Plan, but no Tutti Mode Plan review panel appears.
  • Quick checks: Read GET /v1/workspaces/{workspaceID}/agent-sessions/{agentSessionID}/tutti-mode-activation and confirm the current revision is active. Then inspect whether the Agent actually invoked tutti plan propose with a valid absolute Markdown file and Agent Session context. Query GET /v1/workspaces/{workspaceID}/workflows?sourceSessionId=... for a pending authoritative snapshot. Do not inspect transcript markers or provider Plan card or historical capabilityRefs as current activation or workflow evidence.
  • Root cause: TuttiModeActivation and WorkspaceWorkflow are independent Tutti-owned roots. The badge records a user preference that is snapshotted into each new Turn's Host Context; it does not fabricate a proposal. Durable review state begins only when the Agent invokes the always-available Tutti CLI and commits a WorkspaceWorkflow, immutable revision, and pending checkpoint. A missing CLI invocation, invalid tutti-mode-plan/v1 document, wrong source Session, or disconnected desktop workflow runtime leaves nothing recoverable to render.
  • Fix: Keep /plan and /tutti as compatible independent modifiers. Let AgentSessionEngine project the daemon activation and let the Agent freely invoke the Tutti CLI. AgentGUI must list workflows by active source Session and re-pull after workspace.workflow.updated; it must never synthesize a panel from Agent text, a provider Plan card, the Tutti badge, or capability audit records. User decisions go only through the checkpoint HTTP endpoint.
  • Validation: Cover activation create/update/reload, immutable per-Turn snapshot binding before provider dispatch, CLI proposal persistence, immutable revision verification, Session-scoped list recovery, advisory-event refresh, accept/reject/cancel decisions, and Plan plus Tutti badge coexistence. Verify an accepted task graph materializes exactly one Issue and reports issue_created rather than asking the Agent to create it again.
  • References: workspace-workflows.md, commands.go, useTuttiModePlanPanels.ts

Existing Session shows Tutti active but the Agent reports Default mode

  • Symptom: Tutti Mode is enabled on an already-existing Session, but the next Turn says the conversation is still in provider Default mode or has not entered Tutti Mode because plan propose has not run.
  • Quick checks: Confirm the activation revision is active in the exported Session or activation endpoint, then confirm the affected Turn owns an active TuttiModeTurnSnapshot. For Codex, inspect that Turn's provider rollout turn_context: the collaboration mode may legitimately remain default, while its developer instructions must contain <tutti-host-context> with "state":"active". If both facts are present, existing-Session snapshot delivery works and the response is an interpretation failure. If the host context is absent, trace snapshot preparation and runtime dispatch instead.
  • Root cause: Tutti activation, provider Default/Plan collaboration mode, and Tutti workflow existence are three independent facts. An instruction that says a workflow exists only after plan propose can make a provider incorrectly use the missing workflow as evidence that activation is inactive, even though the active snapshot reached the reused provider Session.
  • Fix: Make the Host Context's snapshot state the sole authority for reporting Tutti Mode status. Provider Default/Plan mode and workflow existence remain independent facts and cannot override it. A clear plan request must still run plan propose instead of returning a chat-only plan.
  • Validation: On an existing Session, activate Tutti Mode and send a new Turn while the provider collaboration mode remains Default. Verify the provider receives the active Host Context, a status-only question reports active without proposing a workflow, and a clear plan-generation request invokes plan propose. Repeat with an inactive revision and verify it reports inactive.
  • References: workspace-workflows.md, tutti_mode_host_context.go, tutti_mode_host_context_test.go

Tutti Mode review only offers request changes after preference drift

  • Symptom: A pending plan review shows only a request-changes action, or accepting an existing plan is silently converted into a rejection after the Composer effect or speed changes. The plan may also contain values that never matched the Turn that produced it.
  • Quick checks: Compare three distinct values: the producing Turn's TuttiModeTurnSnapshot, the current revision's explicit execution.effect/execution.speed, and the Session's current Composer preferences. If the first two differ, inspect the plan propose or plan revise response for tutti_mode_preference_snapshot_mismatch. If only the current Composer preferences differ, the drift happened legitimately after proposal and both review decisions must remain available.
  • Root cause: Range validation alone allowed an Agent to persist plausible but invented preference values. A hardcoded Host Context example could teach the Agent different values than the frozen Turn snapshot. AgentGUI then treated every post-proposal preference difference as implicit rejection, so the user lost a direct way to accept the already-reviewed document.
  • Fix: Render Host Context examples from the exact Turn snapshot. Require Agent CLI proposals and revisions to carry that active Turn ID, and have the plan service validate both explicit values through the activation-service snapshot reader before allocating IDs, writing revision content, or mutating workflow state. In AgentGUI, expose Request changes beside Accept only when both frozen values prove a real preference change; otherwise expose only Accept. Accept must keep the visible checkpoint identity and never convert into request-changes feedback.
  • Validation: Prove mismatched, missing-value, and missing-snapshot documents leave revision content and workflow state untouched; prove a matching document succeeds. In the UI, verify the divergent state exposes both actions, request-changes rejects with current-preference feedback, explicit accept records accepted, and matching empty-send still accepts.
  • References: workspace-workflows.md, agent-gui-node.md, service.go, tutti_mode_host_context.go, useAgentGUITuttiWorkflow.ts

Tutti Mode Plan stops loading after a task-graph revision

  • Symptom: The configuration review panel works and tutti plan revise returns a pending task-review checkpoint, but AgentGUI replaces the panel with its load-failure state.
  • Quick checks: Call the session-scoped workspace-workflow list endpoint and inspect every task in the returned revision. Required collection fields, especially dependsOn, must be JSON arrays even when empty. A successful CLI mutation and HTTP 200 do not prove that the response still satisfies the generated client contract.
  • Root cause: A Go transport projection copied an empty dependency slice onto a nil slice. encoding/json emitted null, violating the non-nullable OpenAPI array contract. The generated TypeScript client therefore exposed a value typed as string[] whose runtime value was null, and the desktop adapter failed while cloning it before panel projection.
  • Fix: Normalize required collections at the daemon API boundary so empty values serialize as []. Keep the OpenAPI field non-nullable and do not move the repair into AgentGUI business projection, where it would only conceal a malformed wire response.
  • Validation: Exercise the real HTTP route, decode its JSON, and assert an empty task dependency list is exactly [], not null. Then verify a task-graph revision with a root task can be listed and rendered.
  • References: workspace-workflows.md, daemon_workspace_workflows.go, desktopWorkspaceWorkflowRuntime.ts

Retrying a Tutti Mode Plan mutation duplicates or rejects the revision

  • Symptom: A retry after a CLI timeout creates another workflow/revision, or intentionally submitting the same Markdown again with a new request ID fails with a revision/document-path uniqueness error.
  • Quick checks: Confirm plan propose or plan revise received a stable --request-id. Read workspace_workflow_mutations for the exact workspace, source Session, mutation kind, workflow scope, and request ID. Compare its input_sha256 and committed result IDs. For upgraded local databases, check that workspace_workflow_plan_revisions no longer has a unique index on document_path.
  • Root cause: Transport response loss is not the same as repeated content. Without a caller mutation key, the daemon cannot distinguish a retry from intentional reapplication. Conversely, a content-addressed document path may legitimately be shared by multiple immutable revision metadata rows, so it cannot be revision identity.
  • Fix: Reuse the same request ID only for a retry of the same bytes. Use a new request ID for an intentional mutation, even when the bytes are equal. Keep the ledger claim and workflow/revision/checkpoint write in one SQLite transaction. Preserve revision-ID and sequence uniqueness, and let the corrective migration remove legacy document-path uniqueness.
  • Validation: Cover concurrent same-key claims, same key plus same digest replay, same key plus different digest conflict, new key plus identical bytes creating the next sequence, and upgrade fixtures that retain checkpoints, operations, mutations, and a clean PRAGMA foreign_key_check.

One corrupt Tutti Mode Plan prevents daemon startup recovery

  • Symptom: The daemon cannot finish startup after a task graph was accepted, especially when its immutable Markdown file is missing or corrupt and its create_issue operation is pending or failed.
  • Quick checks: Inspect accepted workflows with pending/failed create_issue operations, verify the referenced revision path and SHA-256, and check whether the operation received a durable startup_recovery_failed outcome. Do not require an Agent wait call to start recovery.
  • Root cause: Returning the first operation-local read, parse, validation, or materialization error from the startup scan makes one damaged workflow a process-wide availability failure.
  • Fix: Run one recovery scan after workflow and Issue service composition but before exposing the daemon. Isolate each operation: persist a failed outcome and continue. Abort startup only when the scan fails or the daemon cannot write a durable outcome. Keep deterministic Issue materialization and do not add a background workflow worker.
  • Validation: Cover pending and failed recovery, failed-before-retry state, missing/corrupt revisions, a later operation succeeding after an earlier failure, second-scan non-duplication, and outcome-write failure stopping startup.

A stuck edit-and-retry operation crashes the daemon on every launch

  • Symptom: After updating, tuttid exits immediately on every launch and the desktop reports tuttid exited before it published its listener info; the daemon log shows recover agent host: process runtime operation <id>: agent session not found (or ... agent history was rolled back but the edited turn still needs to be resent). The app never opens.
  • Quick checks: Look for a durable edit_retry runtime operation stuck at the resend_pending checkpoint — the last Turn was rolled back on the provider but the replacement was never re-sent. While the daemon runs, the live worker only logs this at 1 Hz; the fatality appears only on the next cold restart. The affected session's workspace_agent_session_history.recovery_state is fenced (non-ready).
  • Root cause: The cold-recovery pass (RecoverRuntimeOperations) treats a per-operation error as fatal to build tuttid server, so a persisted edit-retry operation that can never make progress becomes a boot poison pill. The live worker tolerates the same error, which is why the app worked until the restart.
  • Fix: Durable edit-and-retry is disabled (Config.EditRetryDisabled, set in production wiring). Recovery quarantines any leftover edit_retry operation — marks it failed AND clears the session's effective-history fence back to ready so the conversation can still send — and returns non-fatally, so existing poison pills self-heal on the next boot. New edit-retries are refused at the entry points. Sessions fenced before the neutralization (owning operation already failed, so recovery cannot see it) self-heal at the send gate: the first send clears an abandoned fence instead of rejecting. The durable rule: never let one runtime operation's error abort daemon startup.
  • Rescue: For an install that cannot update yet, quit Tutti and run tools/scripts/rescue-edit-retry-poison-pill.sh, which quarantines the stuck rows and clears the session fence in ~/.tutti/tuttid.db after backing it up.
  • Validation: packages/agent/host/edit_retry_disabled_test.go builds a genuinely stuck operation, asserts enabled recovery is boot-fatal, and asserts the disabled path quarantines it, returns nil, and leaves the session at recovery_state = ready.

Many stopped Tutti Mode conversations start again when the app opens

  • Symptom: Starting the desktop app makes several old Tutti Mode source conversations begin new Turns even though their tasks were previously stopped or already terminal.
  • Quick checks: Correlate the startup time with workspace_tutti_mode_legacy_repair_v5, listener-ready wake recovery, and new canonical Turns. In the execution tables, look for a migration checkpoint whose creation_reason is legacy_execution_startup_repair, an idle execution changed to awaiting_main, and a prepared or dispatched main wake. Do not attribute the behavior to generic Agent Host resume unless the exact Session already had a recoverable active Turn.
  • Root cause: The former V5 legacy backfill treated ambiguous idle plans as live executions and created a durable main wake. Listener readiness later delivered those historical wakes into the source conversations. Startup Run settlement repair could also create another checkpoint for an execution that was already terminal.
  • Fix: Make legacy migration side-effect-free: keep an execution running only when an authoritative Run is running, classify idle history as completed, and create no migration wake. Suppress settlement checkpoints for terminal/orphaned executions. For databases that already applied V5, run the exact-fingerprint V6 cleanup to cancel the old recovery chain and normalize execution status from current Runs.
  • Validation: Cover fresh idle and running legacy fixtures with zero migration wakes, a V5-corrupted idle fixture repaired to completed, a V5-corrupted running fixture retained as running, and terminal settlement replay producing no checkpoint or wake. Re-run V6 to prove idempotency.
  • References: issue-execution.md, migrations_tutti_mode_legacy.go, sqlite_tutti_mode_settlement.go

Tutti capability audit persistence races the first Turn projection

  • Symptom: Create or SendInput fails with sql: no rows in result set immediately after runtime.submitted, or a live turn_update frame is rejected because its outcome is null.
  • Quick checks: Verify capability provenance is carried on the controller-owned submitted event or a lifecycle-neutral same-Turn guidance report. Check that no second post-Exec writer tries to update a Turn before the asynchronous projection creates it. Confirm the event schema accepts a null outcome while a Turn is live.
  • Root cause: capabilityRefs are historical submission audit data, not current Tutti activation. Writing them through a second store call races the canonical Turn projection. Attaching a synthetic lifecycle phase to guidance can also corrupt the running Turn. A bounded reporter queue that falls back to inline reporting may reorder projection work or deadlock on reporter re-entry.
  • Fix: Persist provenance with the submitted TurnTransition; use a lifecycle-neutral state report for same-Turn guidance. Route synchronous reporter work through one non-blocking, single-consumer FIFO. Queue pressure must neither report inline nor block a reporter observer that can re-enter the Controller.
  • Validation: Verify runtime acceptance may precede Turn projection, live null-outcome events validate, later transitions retain references, and a backlog beyond the former bounded queue plus reporter re-entry preserves FIFO order without deadlocking. Unsupported capability/source values must fail at HTTP ingress and event publication without changing activation.

tutti agent send reports unknown delivery after Codex accepted the prompt

  • Symptom: tutti agent send returns agent_submit_delivery_unknown with an error saying the workspace, Session, Turn, and client submit IDs are required. The prompt and attachment may still appear in the transcript and Codex may complete the Turn, so retrying the command can duplicate the work.
  • Quick checks: Correlate the command timestamp with the target Session in the durable store and runtime logs. If the user message, provider Turn, and assistant response exist but the submission provenance has no clientSubmitId, the runtime accepted the command and only the post-dispatch delivery confirmation failed.
  • Root cause: The Agent service requires a caller-stable client submit ID to reconcile ambiguous delivery after runtime dispatch. The CLI Agent provider populated workspace and Session context but omitted the typed ClientSubmitID on both SendInput and initial Session creation.
  • Fix: Generate one UUID per CLI command invocation and pass it through the typed ClientSubmitID field. Keep Turn ID allocation in Agent Host; the CLI identity exists only to make one semantic submission idempotent and recoverable.
  • Validation: Exercise both agent start and agent send through the CLI provider and assert each service input contains a valid UUID client submit identity.
  • References: session_commands.go

Codex finishes but AgentGUI keeps showing the Session as working

  • Symptom: Codex has emitted its final assistant message, but AgentGUI keeps showing a busy spinner and the Session still has an active Turn. The UI may settle several minutes later without a restart.
  • Quick checks: Search tuttid.log for agent_session.activity_report.queue_backlog and compare its queue_depth with the final message's occurredAtUnixMs and canonical updatedAtUnixMs. A large gap means provider completion reached the runtime before canonical persistence. When prod and dev are both running, identify them by listener, PID, state, and workspace paths; a shared log file alone does not prove they share a database.
  • Root cause: Streaming text, reasoning, and tool-output snapshots entered the unbounded report FIFO before coalescing. When the single reporter was slower than producers, thousands of superseded snapshots accumulated ahead of the same Session's terminal report. Tool-call output snapshots were not eligible for coalescing at all.
  • Fix: Coalesce pending streaming snapshots by Session and message while they are enqueued. Preserve the latest cumulative tool output, original tool input, and earliest start time. Keep terminal and submit-provenance reports as same-Session FIFO barriers.
  • Validation: Enqueue at least 2048 text and tool-output snapshots and prove each pending Session occupies one queue slot. Verify the latest snapshot is retained, terminal and submit-provenance barriers stay ordered, reporter re-entry remains non-blocking, and focused race tests pass.
  • References: controller_report_queue.go, report_coalescer.go, controller_report_queue_test.go

Claude fails before provider Turn identity but AgentGUI keeps thinking

  • Symptom: Claude returns no assistant result and the provider invocation has already failed, but AgentGUI keeps showing the Turn as processing. Later sends may be rejected because the runtime still reports an active Turn.
  • Quick checks: Correlate the canonical Turn ID across the submit report, Claude sidecar event, and runtime controller. The characteristic sequence has a durable submitted Turn, turn_failed without providerTurnId, dispatch disposition applied_without_provider_turn, and a canonical failed report, while HasActiveTurn remains true. Do not interpret applied_without_provider_turn itself as a failure; cancel-before-acceptance legitimately uses the same admission result.
  • Root cause: Provider acceptance and canonical completion are independent contracts. The acceptance wrapper treated an exact providerless turn.failed as premature provider output, while the blocking controller released its active-Turn fence only for failures carrying explicit rejection metadata. A non-rejection failure could therefore be durable but never close the runtime fence.
  • Fix: Hold an exact canonical terminal behind the acceptance barrier and return it to the controller without inventing provider identity. Classify completion from the typed terminal event for the exact Turn, never from the dispatch disposition or error text. Partition every pre-acceptance batch so an exact terminal cannot carry provider-dependent assistant/tool output through the barrier. Release the active-Turn fence only after the terminal crosses the synchronous durable-report barrier. If that commit fails or its acknowledgement is lost, retain the terminal and retry it idempotently until the commit succeeds or daemon reconciliation proves the Turn already settled. Provider-root completion with an identity continues through canonical aggregation.
  • Validation: Cover Claude translation of a providerless turn_failed, mixed terminal/provider-output batches, controller settlement after a successful terminal report, transient report failure, commit-success/ACK-loss, explicit rejection, cancel-before-acceptance, and outcome-unknown. Run the Host conformance scenario for initial and ordinary idempotent submissions. Retain a root-provider lifecycle test proving provider-root completion does not clear the canonical Turn before daemon reconciliation.
  • References: claude_sdk_execution.go, controller_turn_completion.go, controller_turn_exec.go

Tutti mode is active in the composer but disappears after the first submit

  • Symptom: The home composer shows Tutti enabled and the submit trace records tutti_mode_active=true, but the created Session has no activation and its first tutti_mode_turn_snapshots row is inactive.
  • Quick checks: Trace initialTuttiModeActivation at the composer submit, controller activation, desktop activity service, renderer HTTP adapter, and daemon create ingress. Log only a presence boolean at each boundary. Compare the Session export with the durable activation and Turn snapshot rows; capabilityRefs do not prove current activation.
  • Root cause: Session creation crosses several adapters that reconstruct object literals. A manually projected create input can omit initialTuttiModeActivation or its Tutti capabilityRefs even when the upstream type carries them. Reading mutable draft state after submit can also lose the exact composer selection. AgentGUI treats capabilityMenuState.tuttiMode.enabled === true as the host capability for the hero toggle, badge activation, and /tutti; Tutti Desktop always supplies it, while hosts that omit it or set enabled: false fail closed.
  • Fix: Snapshot active/inactive state and orchestration intensity atomically with the composer submit. Preserve both activation and capability provenance through every create projection, and use the same host capability as the visible control for slash actions.
  • Validation: Keep boundary tests for the service, engine host, and HTTP adapter. In a real development launch, submit once with Tutti enabled and verify the HTTP boundary sees the activation, the activation revision is active, and the first Turn snapshot retains its source and intensity.

A new Tutti conversation briefly reports session not found after submit

  • Symptom: The optimistic conversation appears after the first submit and briefly shows workspace agent session not found, then recovers after the provider starts and the durable Session and Turn complete normally.
  • Quick checks: Correlate the Session ID across desktop and daemon submit traces. The characteristic order is renderer_adapter.create.http_requested, then agent.activity.reconcile_session_absent, then renderer_adapter.create.resolved and api.create.completed. Confirm the missing reconcile occurs while the engine still has a requested or uncertain new-session activation.
  • Root cause: Initial activation can publish activity or mode updates before the create transaction is query-visible or its HTTP response returns. The renderer treated those independent signals as permission to reconcile an exact Session, so a normal not-yet-visible window became a transient 404 even though the create command was still in flight. A topic-specific guard covered only one of those signals and left the admission boundary fragmented.
  • Fix: Keep the new Session behind the admission fence while its activation is still requested. The activity-core reconcile reducer records demand but defers transport reads whenever the exact Session has no canonical record and the create command still owns visibility. If the create result becomes uncertain or its confirmation expires, enqueue one authoritative recovery reconcile; that recovery is allowed to discover a Session that was committed even though the create response was lost. A normal successful create still releases the merged demand through session/upserted, regardless of whether the trigger was an activity event, a mode update, or direct Session synchronization. Do not broadly swallow reconcile 404s: existing Sessions and settled activations still report real failures. Tombstone only from explicit deletion evidence such as session_deleted or a successful delete command.
  • Validation: Hold create in flight, publish both agent.activity.updated and workspace.tuttimode.updated, and request direct Session synchronization. Verify no Session read is called while activation is requested. Then simulate a lost create response and verify one recovery reconcile is admitted, discovers the canonical Session, and confirms the activation. Also prove an existing Session still reconciles and preserves its not-found diagnostic, while an explicit session_deleted event tombstones it.
  • References: sessionReconcile.reducer.ts rootReducer.ts workspaceAgentActivityReconcileBridge.ts

A Tutti submission remains delivery is still being confirmed

  • Symptom: Retrying the same composer submission keeps returning agent submit delivery is still being confirmed. The Session may already exist and the Tutti badge may remain active, but the daemon does not start a second Turn.
  • Quick checks: Read the workspace_agent_submit_claims row for the exact workspace, Session, and clientSubmitId. Compare its immutable canonical_turn_id with tutti_mode_turn_snapshots.turn_id and with the Turn found by durable message provenance for that clientSubmitId. Do not change the claim to a runtime-returned mismatched Turn ID. If the barrier reports conflicts with durable submit provenance, compare the JSON-encoded existing and projected message payloads as well as their Go container types. In particular, compare payload.seq and occurred_at_unix_ms from the ordinary runtime report and the durable provenance report for the same clientSubmitId. Identical JSON with []any on the SQLite-decoded side and []map[string]any on the fresh-report side is an in-memory normalization defect, not conflicting submit evidence. Different sequence values for the same canonical message mean the two report paths reconstructed its occurrence independently.
  • Root cause: Provider handoff crossed an ambiguity boundary: the daemon reserved and snapshotted a canonical Turn before dispatch, but did not durably confirm the exact accepted Turn. The ordinary reporter may have persisted its Session/Turn state while the atomic client-submit message barrier failed, or the process may have stopped around that barrier. A host wired only to the compatibility ActivityReporter cannot satisfy the runtime's required DurableActivityReporter contract. The barrier can also falsely reject an ordinary message replay when a fresh JSON payload retains typed Go slices but the same payload decoded from SQLite uses []any, and idempotency compares those concrete types directly. Retrying the provider call would risk duplicate work. Separately, the runtime adapter and durable provenance barrier can construct the same user message on different goroutines. If each path samples its own transient event timestamp, whichever path reaches SQLite second carries a different derived payload.seq; the strict provenance guard correctly rejects it. Scheduling determines whether the ordinary report exists before the barrier, so this form is intermittent. A compatibility reporter can still supply different transport-only sequence, source, or submission timestamps for the same protected user-message provenance.
  • Fix: Preserve the prepared submit claim, Tutti snapshot, activation, and Session. Ensure the host supplies DurableActivityReporter; decorators should embed or otherwise preserve that required interface instead of probing an optional capability. Its provenance barrier must run outside the Session lifecycle lock, after earlier same-FIFO reports, and atomically write the stable user message against an existing Turn. Normalize every incoming message payload through the durable JSON representation before projection, merge, and idempotency comparison so fresh reports and SQLite-decoded rows share one in-memory shape. Do not special-case individual typed slices or bypass genuine payload conflicts. For the two canonical user-message paths, preserve the durable submit claim's immutable creation time through the typed Host and Runtime inputs and derive both reports' occurrence and sequence from it. Use clientSubmitId only as the idempotency identity, not as a numeric sequence. Build both messages with the shared canonical user-message constructor. Keep the store's exact-payload conflict check for all genuine conflicts; the protected provenance replay may accept only transport-only metadata differences after Turn, role, kind, status, semantics, clientSubmitId, content, content mode, display prompt, and text all match. Reconcile only from exact durable clientSubmitId provenance. If it resolves to the reserved Turn, idempotently accept the snapshot and claim; if it is absent or resolves elsewhere, keep delivery unknown and never re-dispatch that client submission. Definite pre-dispatch rejection is the only path allowed to abandon the prepared evidence.
  • Validation: Cover exact acceptance, accepted Turn-ID mismatch, snapshot and claim acceptance failures, message-write rollback, process interruption after snapshot binding, reporter re-entry, duplicate replay without a new message version, and multiple guidance submissions sharing one active Turn. Include an ordinary-message-first replay whose payload contains nested typed slices and integer values; assert the provenance barrier reuses the existing message version while a real content change still conflicts. Exercise both runtime-message-first and provenance-first orderings and assert their complete message updates, occurrence times, and derived payload sequences are identical. Reopen the submit-claim store and prove an idempotent retry retains the original claim creation time. Exercise the narrow protected replay with differing transport sequence, source, and submission time, while proving a real content or semantics change still conflicts. Assert the unknown paths execute the provider zero additional times and never close the provisional Session or delete its activation.
  • References: workspace-workflows.md, submit_claims.go, service_tutti_mode_activation.go

Turn state, loading, cancel, restore, file-change undo, rail projection, event updates, imports, and performance.

AgentGUI restores a provisional conversation after creation fails

  • Symptom: A new conversation fails to start, but a renderer reload selects the same Session ID again. The Rail may show an optimistic row while the canonical store has no matching Session.
  • Quick checks: Correlate the activation request, Workbench lastActiveAgentSessionId, renderer reload, and canonical Session lookup by exact ID. If navigation persistence precedes canonical activation confirmation and the lookup returns typed session.not_found, this is a provisional-selection leak. A bounded Rail page that omits the Session is not enough evidence.
  • Root cause: The new-conversation controller persisted its generated Session ID when it selected the optimistic row. A crash or reload could therefore restore that ID before Host committed a canonical Session. Reconcile errors also lost their typed code in the frontend engine, so AgentGUI could not distinguish a proven missing Session from a transient read failure.
  • Fix: Keep provisional selection in mounted UI only. Persist it after the engine confirms activation from canonical Session state. Preserve reconcile errorCode; clear only the exact global and per-target navigation memories when the active reconcile settles with session.not_found. Preserve them for timeout, transport failure, and bounded-list absence.
  • Validation: Verify a requested activation performs no persistence, canonical confirmation writes once, create failure plus reload cannot restore the provisional ID, typed session.not_found clears only the matching memories, and a transient reconcile error keeps the selection.
  • References: agent-gui-node.md sessionReconcile.reducer.ts useAgentGUIConversationSelectionController.ts

New Goal spinner stops before the first reply, then starts after it appears

  • Symptom: Creating a Session with an initial Goal starts the processing indicator, then stops it as soon as the canonical Session appears. The provider continues working without an indicator. When the first assistant message and canonical Turn arrive, the indicator starts again until the Turn settles. The inverse symptom is a completed or failed Turn whose rail status has settled while the Composer action still spins.
  • Quick checks: Compare the Claude SDK session_state_changed lifecycle log, activity stream connection, Engine runtime activity, canonical Session, and canonical latest Turn. The characteristic gap has SDK state running, a canonical Session, no latest Turn, and Engine runtime activity still idle. Do not use assistant message arrival as Turn identity. If the desktop log reports Event stream catalog revision mismatch, fully restart dev:desktop; renderer HMR cannot replace the already running Go daemon binary.
  • Root cause: Claude emits an exact session-level running observation before the first provider Turn identity, but the daemon previously logged and discarded it. Goal-only creation correctly has initialTurnExpected = false, so neither a pending prompt nor a canonical Turn can bridge that interval. For the inverse symptom, AgentGUI bypassed the Engine's occurrence-time fence and read the stale raw running flag directly after a canonical Turn had settled.
  • Fix: Normalize the SDK observation to provider-neutral running/idle runtime activity, publish it as an ephemeral activity-stream event, and let the workspace Engine drive AgentGUI and rail busy projection. Once a canonical Session exists, AgentGUI must consume the Engine's fenced display status; use raw runtime activity only before that projection exists. Clear ephemeral runtime activity on disconnect. Keep Goal turnless and do not invent lifecycle state, provider-specific timers, or synthetic Turn IDs.
  • Validation: Cover SDK projection without Turn identity, post-commit event publication, activity-stream ingestion before canonical Session hydration, AgentGUI busy projection, idle, disconnect cleanup, and both completed and failed Turns remaining settled when an older raw runtime observation still says running.
  • References: claude_sdk_events.go workspaceEventCoordinator.ts useAgentGUISessionPresentation.ts agent-gui-node.md

AgentGUI detail misses or misplaces a canonical Turn error

  • Symptom: The AgentGUI rail marks a conversation failed, but opening the conversation shows only the preceding tool or assistant rows. Reloading the session does not reveal why the Turn failed. A related symptom is a failure from a much older Turn appearing at the bottom of the currently loaded conversation, immediately before current processing UI.
  • Quick checks: Inspect the canonical Turn snapshot before debugging React state. Confirm the owning Turn is terminal with outcome = failed or interrupted and has a non-empty error.message. Then inspect that Turn's timeline messages for a structured visibleError or a plain assistant message with the same error text. Finally, distinguish the session-wide Turn list from the hydrated message window: check whether any currently loaded timeline item has the exact owning turnId and whether older message pages remain.
  • Root cause: Turn outcome and error are durable canonical state, while provider transcript messages are optional evidence. If detail rendering only projects transcript messages, a runtime that settles AgentActivityTurn.error without emitting a visible-error message leaves the rail and detail inconsistent. Reading only the active Turn also loses the error as soon as settlement clears activeTurnId. Conversely, treating the session-wide Turn list as transcript membership manufactures an empty Turn for an error whose messages are outside the current page, appends it after newer Turns, and makes an old failure look current.
  • Fix: Reconcile terminal AgentActivityTurn.error in the shared transcript projection by exact turnId. Reuse a structured visible error, upgrade a matching plain assistant failure, or add one view-only row with a stable (agentSessionId, turnId) identity. Normally the owning Turn must already exist in the hydrated transcript projection. The exact latest failed Turn is the narrow exception: if it emitted no transcript item, create its view-only error row so the current failure reason remains visible. Historical Turns outside the message window still wait for an older page to supply an anchor. Do not restore session lastError, let session-operation selectors fall back to Turn errors, reinterpret a successful attach as activation failure, persist a duplicate message, or add component-local failure state.
  • Validation: Cover a failed Turn with no provider error message, a matching plain failure, and an existing structured visible error. The first must render one fallback row when its Turn is loaded, and the latter two must remain single rows. With a full canonical Turn list and a newest-page-only transcript window, an older failed or interrupted Turn must not create a row or change Turn order. After prepending the older page, its error must appear exactly once on the owning Turn. Also cover the exact latest failed Turn with zero hydrated transcript items producing one error row, plus an older failed Turn with a newer active Turn whose processing ownership remains current.
  • References: workspaceAgentTurnErrorProjection.ts workspaceAgentTurnErrorProjection.spec.ts workspaceAgentTimelineCanonical.ts agent-gui-node.md

Mid-turn provider failure renders a generic "request failed" card

  • Symptom: A root-provider-lifecycle turn (Codex, Cursor, OpenCode) fails mid-turn — for example quota exhaustion — but the conversation shows only a generic "request failed" card with no detail and no classification code. The real reason appears only in the durable Turn record and daemon logs.
  • Quick checks: Confirm the canonical Turn settled with a non-empty error.message. If the durable Turn carries the reason but the live card does not, inspect the synthesized daemonRootTurnSettlement event metadata, not the provider adapter — the adapter already reported the failure correctly.
  • Root cause: Root-provider adapters do not emit EventTurnFailed; the daemon synthesizes it in ReconcileRootTurnSettlement after the durable settle commits. The settlement previously forwarded only turnId and outcome, so the synthesized event had an empty detail and the visible-error projection classified it as unknown instead of (for example) quota_or_rate_limit.
  • Fix: Forward Turn.ErrorMessage through RootTurnSettlement and stamp it on the synthesized event metadata under the error / errorMessage keys read by BestEffortErrorMessage. Keep classification text-based in visibleFailureCode; do not special-case providers at this layer.
  • Validation: TestReconcileRootTurnSettlementPublishesFailureDetail settles a failed root turn with a quota message and asserts the live stream emits a quota_or_rate_limit visible error carrying the original detail.
  • References: controller_root_turn.go visible_error.go agent_runtime_adapter.go

Failed Claude Turn looks like no reply or renders raw 522 payload

  • Symptom: Claude produces no normal assistant reply, and the eventual failed message is a raw API Error: 522 payload instead of the standard timeout card.
  • Quick checks: Confirm the canonical Turn settled as failed with request_timed_out, while its persisted assistant message is plain failed text containing the explicit 522 signature rather than a structured visible error.
  • Root cause: The SDK can report a gateway timeout as a failed assistant text message. The generic failed-message recovery recognized network socket markers but not the unambiguous HTTP 522 signature, so the raw provider payload reached Markdown.
  • Fix: Classify only explicit 522 markers as request_timed_out in the shared failed message presentation. Keep generic timeout prose unclassified to avoid rewriting ordinary assistant content.
  • Validation: Cover both classification and rendered output: the timeout card is visible and the raw payload is absent.
  • References: agentErrorPresentation.ts AgentMessageBlock.tsx

Codex WebSocket reconnect rejects a long prompt metadata header

  • Symptom: A long-running Codex turn disconnects, then its WebSocket reconnect fails with did not receive a valid HTTP request; Separator is found, but chunk is longer than limit. The same prompt may succeed when the original connection stays open.
  • Quick checks: Inspect the Codex app-server turn/start params without logging prompt text. Compare prompt byte count with any client metadata byte count. An unbounded prompt copy in responsesapiClientMetadata can cross a WebSocket HTTP parser's roughly 32 KiB line limit during reconnect.
  • Root cause: Codex may forward Responses API client metadata through the WebSocket HTTP handshake. Duplicating the full prompt into that metadata turns prompt size into header size. Reconnect then fails before the provider can process the normal input payload.
  • Fix: Keep the full materialized prompt in turn/start.input. Do not duplicate it into auxiliary metadata, truncate it in AgentGUI, or compensate with reconnect retries. Provider-specific metadata stays inside the provider adapter and must follow a demonstrated provider contract.
  • Validation: Cover a prompt larger than 32 KiB. Assert turn/start.input preserves it exactly and responsesapiClientMetadata contains no prompt copy. Run go test ./packages/agent/daemon/runtime.
  • References: codex_appserver_event_params.go codex_appserver_adapter_test.go agent-gui-node.md

Opening a historical Codex session starts two short provider processes

  • Symptom: Selecting one settled Codex conversation logs two process starts, each with initialize followed by thread/read, even though no turn was resumed.
  • Quick checks: Confirm neither sequence contains thread/resume. Enable reconcile trace diagnostics temporarily and count complete state_and_messages commands. Two discovery/message/final sequences indicate two reconciles; one sequence with two probes indicates that both detail projections resolved capabilities.
  • Root cause: There are two independent boundaries to verify. Combined reconciliation intentionally reads detail twice to close races while messages load, but its discovery read only needs hierarchy and messageVersion; using the full Session projection there adds an accidental provider-history capability probe. Separately, automatic selection restoration can be replayed while the first reconcile is running. Treating that ordinary selection as force: true appends a second complete reconcile, whose final detail read launches another probe.
  • Fix: Preserve the two-read race fence. Send the first request with the messageHydration projection and the final request with full; only the latter resolves provider-backed lifecycle capabilities. Model automatic selection as idempotent “ensure hydrated” work, leaving force refresh as an explicit intent. Do not mask either symptom with a TTL cache, single-flight delay, or by removing the final read.
  • Validation: Select a historical root-only Codex Session with no child hierarchy through make dev-gui. One combined reconcile should issue two detail requests and one message-list request, but only the final detail request should emit the short initialize/thread/read capability probe. Replaying the same selection while that reconcile is pending must not start another combined reconcile. Sessions with child hierarchy or additional message pages may issue more than one message-list request; each request must still follow the shared executor's Session and cursor policy.
  • References: sessionReconcileExecutor.ts useAgentConversationSelection.ts service_turns.go codex_appserver_fork.go

Codex turn stays working before any reply or tool activity

  • Symptom: A submitted Codex turn stays working without assistant text, reasoning, or tool activity. Stop may return quickly, but the next prompt on the same conversation can stall in the same way. This also occurs when a conversation is left offline and its first later submit loses the app-server connection.
  • Quick checks: Correlate agent.submit.trace records by client_submit_id, turn_id, and agent_session_id. A turn.start.requested without turn.start.succeeded means the immediate app-server acknowledgement did not arrive. After the bounded failure, confirm agent_session.app_server.turn_start.client_invalidated appears and the next submit starts a new local process with thread/resume. An immediate turn.start.failed with error=EOF is an outcome-unknown transport loss and must still project one visible failed assistant message.
  • Root cause: Codex turn/start should acknowledge immediately and stream the actual work through notifications, but the adapter previously called it with no client deadline. A graceful Stop could acknowledge turn/interrupt without proving that the unacknowledged turn/start connection was healthy, so the adapter retained and reused the same bad process. The live-session probe also treated a retained client pointer as live after its Done channel closed. Finally, a pre-acceptance turn/start failure was buffered as provider output even though it had no provider Turn identity, hiding the canonical terminal from the controller and GUI.
  • Fix: Bound only the turn/start acknowledgement to 30 seconds; do not bound the subsequent running turn. Treat deadline, cancellation before acknowledgement, or transport disconnect as an unhealthy client, remove exactly that client from the live-session registry, and close it. Do not automatically replay the prompt because delivery is unknown. The next explicit submit uses the existing session recovery path to start a process and resume the provider thread. A live-session probe must also reject terminated clients. Publish the exact canonical turn.failed directly when turn/start fails before provider acceptance, while dropping unaccepted provider-dependent output; this reuses the normal visible-error projection without inventing provider identity. Bound turn/steer acknowledgement separately to 10 seconds.
  • Validation: Run go test ./packages/agent/daemon/runtime -run 'Test(CodexAppServerAdapter(Turn(StartAckTimeoutInvalidatesClient|StartEOFProjectsVisibleFailureBeforeAcceptance|StartCancelBeforeAckInvalidatesClient|StartAckTimeoutDoesNotBoundRunningTurn|SteerTimesOut)|CanResumeAfterTurnStartAckTimeout|HasLiveSessionRejectsClosedClient)|StandardACPAdapterHasLiveSessionRejectsClosedClient)$'. Cover timeout, EOF before acceptance, terminated-client liveness, pre-ack cancellation, a long post-ack turn, bounded guidance, and successful thread/resume followed by a completed turn.
  • References: codex_appserver_turn.go codex_appserver_registry.go codex_appserver_turn_timeout_test.go

AgentGUI turn actions return plain-text route 404s

  • Symptom: A turn-scoped action such as Stop sends the documented OpenAPI URL but gets 404 page not found with text/plain, even though nearby daemon APIs work.
  • Quick checks: Distinguish the default mux response from a domain 404. A route-level miss is plain text; a matched daemon handler returns the structured API error schema. Then compare the operation in the OpenAPI document and generated server with services/tuttid/api/routes.go.
  • Root cause: The daemon currently mounts generated handlers through a hand-maintained RegisterRoutes table. Adding an operation to OpenAPI and regenerating the server does not automatically add it to that runtime table, so the handler can compile and pass direct tests while remaining unreachable over HTTP.
  • Fix: Register every new generated operation in RegisterRoutes, including its exact method and path pattern. For related protocol operations introduced together, audit the whole group rather than only the first reported URL.
  • Validation: Add a mux-level test that calls RegisterRoutes, sends the real method/path, asserts path parameters reach the service, and checks the structured response. Rebuild and restart the dev daemon, then verify the live endpoint no longer returns the default plain-text 404.
  • References: routes.go daemon_test.go tuttid.v1.yaml

Goal banner shows Delete but no Restart action

  • Symptom: A provider supports goal pause/resume and the action is initially visible, but a paused or blocked Goal later shows only Delete.
  • Quick checks: Compare provider composer capabilities with the selected Session's typed capabilities. capabilities: null means no authoritative session snapshot is available because it is not yet reported or legacy-ambiguous; only then may presentation fall back to the provider composer descriptor. A non-null object is complete. Inspect the private persistence compatibility carrier for capabilitiesReported before treating an empty legacy list as authoritative.
  • Root cause: Legacy persistence could not distinguish an unknown capability list from an explicitly reported empty snapshot. Partial RuntimeContext reports could also replace the full map and erase capability metadata. The API then closed the missing list into false, which overrode the provider composer value.
  • Fix: Carry capabilities as a typed optional snapshot outside RuntimeContext. Persist explicit report presence, preserve omitted snapshots on partial updates, and use typed set/unset runtime-context patches. In presentation, fall back to composer capabilities only for a null session snapshot; preserve explicit session false. No database schema migration is required because the report-presence marker uses the existing metadata JSON column and legacy rows are normalized when decoded.
  • Validation: Cover legacy empty and non-empty metadata, reported empty snapshots, partial state updates, API null/closed projections, and AgentGUI resolution. Verify a Codex live snapshot contains goalPause after goal pause, cancel, and session recovery.
  • References: provider.go session_metadata.go capabilities.ts

AgentGUI rejects a pasted image as unsupported before send

  • Symptom: Pasting or dropping a supported PNG, JPEG, or WebP into a provider that advertises imageInput fails with agent prompt image input is unsupported. Desktop diagnostics may show agent.gui.composer.image_upload.resolved with hasPath = true, followed by a daemon failure at service.send.prompt_validated; no provider turn starts.
  • Quick checks: Confirm the submitted image block is path-backed after the desktop host archives the draft. If the block has path but no data, url, or attachmentId, verify the controller is using preflight validation rather than the strict runtime validator before PersistRequestContent runs.
  • Root cause: A managed desktop path is an ingress staging source. The daemon must accept it during capability preflight, then copy and hydrate it before runtime execution. Applying the strict provider-content validator during preflight rejects the path before the attachment store can canonicalize it.
  • Fix: Keep separate preflight and runtime image validators. Preflight accepts and preserves the managed path for adapter capability checks. Runtime execution remains strict and receives only the hydrated image representation. Do not retain base64 in the renderer or move attachment persistence before provider capability checks.
  • Validation: Cover the full path-backed chain: controller preflight accepts the path, service execution receives hydrated data without a path, direct runtime execution still rejects path-only content, and unsupported providers create no attachment files. Run go test ./packages/agent/daemon/runtime ./services/tuttid/service/agent.
  • References: prompt_content.go controller.go service_send_input.go

New extension conversation renders a pasted image as a file

  • Symptom: A new Agent Extension conversation renders a pasted PNG, JPEG, or WebP as a file mention instead of an image preview, while an existing conversation may render the same image correctly. Direct ACP initialization still advertises image prompt support, but target-scoped Composer Options returns imageInput = false.
  • Quick checks: Compare the ACP initialize response, the daemon runtime SessionStateSnapshot.Capabilities, the service-facing Session, and the exact target-scoped Composer Options response. If ACP advertises promptCapabilities.image = true but the service-facing Session has an unknown capability snapshot, inspect the daemon-to-service runtime adapter before changing the renderer paste path.
  • Root cause: ACP negotiation correctly stored capabilities in the daemon runtime's typed Session state, but the tuttid runtime adapter copied settings and untyped runtime context without copying Capabilities. New extension conversations use that adapter result for hidden Composer discovery, so image support was lost before the signed extension capability intersection. Existing conversations could appear healthy because their persisted activity projection supplied the typed snapshot through a different path.
  • Fix: Preserve the daemon runtime's typed capability snapshot at the tuttid runtime adapter boundary, then project the exact live Session snapshot into Composer runtime evidence before reading or caching it. Treat a non-nil snapshot as authoritative even when empty: an explicit empty live or persisted snapshot must stop cache fallback, and an empty discovery snapshot is already a ready result rather than a reason to poll until timeout. Retain exact Target, installation, project, and settings matching and the signed-profile intersection.
  • Validation: Cover adapter preservation, live and persisted capability projection, explicit empty live and persisted snapshots overriding stale untyped/cache values, empty discovery returning without polling, and mismatched extension identities remaining fail-closed. Confirm repeated core, capabilities, and full target-scoped Composer Options requests return imageInput = true for both Kimi Code and Hermes when their ACP runtimes and signed profiles advertise it.
  • References: agent_runtime_adapter.go composer_runtime_context.go composer_live_model_discovery.go

Historical Standard ACP session cannot send after its process was released

  • Symptom: Continuing an idle historical Session fails before a new Turn starts. An image submission may report agent prompt image input is unsupported even though the same Session previously advertised image input. A text submission may reconnect but then fail a redundant settings RPC, or the following prompt may end without assistant, thought, or tool output.
  • Quick checks: Confirm the canonical Session still has a provider Session ID while the adapter has no live process. For images, look for validation failure without a preceding provider process start. For text, inspect initialize: an ACP agent may advertise legacy agentCapabilities.loadSession: true together with object-form sessionCapabilities.resume: {} (some agents place that object under agentCapabilities.sessionCapabilities). If Tutti then calls session/load, historical updates are replayed even though Tutti already has the transcript. Also check whether an unchanged persisted permission/Plan selection is followed by session/set_mode.
  • Root cause: Image capability is negotiated by the live ACP handshake, but preflight used the released adapter state before reconnecting. Separately, the ACP capability parser only recognized boolean/string values. ACP declares newer session capabilities by object presence, so {} was treated as unsupported and the legacy session/load path won over session/resume. Kimi's current response uses the nested placement, which is accepted by the compatibility parser. Load replays history; resume reattaches without replay. Resume also reasserted an unchanged mode even though the provider Session owns restored state, which fails for non-idempotent mode implementations.
  • Fix: Reconnect a released Standard ACP process before image capability preflight, so unsupported images are still rejected before attachment persistence using the fresh handshake. Recognize object-form session capabilities and prefer session/resume when advertised. Use Tutti's persisted permission and Plan selection to avoid reasserting the same mode after reattachment, while still sending a setting changed while detached. Keep the behavior provider- and platform-neutral; do not match a provider ID or error string.
  • Validation: Release a resumable Standard ACP Session and prove that image preflight starts one replacement process before validation. Given an initialize response with both legacy load support and sessionCapabilities.resume: {} in standard and nested placement, prove Tutti selects session/resume. Prove the persisted unchanged mode is not reasserted but an actual offline permission or Plan change is. Use a native Windows-shaped CWD in the image case and cross-compile the runtime test package for Windows in addition to executing the focused tests on POSIX.
  • References: controller_live_sessions.go acp_update_events.go standard_acp_settings.go standard_acp_session.go

Remote Agent image reaches the provider as an unsupported URL

  • Symptom: A URL-backed PNG, JPEG, or WebP is accepted and appears in the user activity, but Codex rejects the turn with remote image URLs are not supported; use an inline data URL instead. Claude or standard ACP may instead receive no usable image data.
  • Quick checks: Confirm the durable prompt block intentionally contains an HTTPS url, then inspect the final provider payload. Codex must receive a data: URL; Claude SDK and standard ACP must receive base64 data. Search every provider send and guidance path for materializeProviderPromptImagesAtBoundary; the helper and its unit test can remain green even when a refactor removes all production callers. If the visible failure detail is download remote prompt image: request failed, compare the URL with a normal system HTTP client and inspect its DNS result. A URL that succeeds normally but resolves to a VPN or transparent-proxy synthetic address indicates that the runtime is bypassing or second-guessing the system network path.
  • Root cause: Remote image URLs are the durable transport representation, while current Codex app-server, Claude SDK, and standard ACP provider wires require inline data. A provider-adapter refactor can preserve the materialization helper but omit its call sites in newly split turn files. Separately, pre-resolving and classifying destination IPs at this boundary is incompatible with VPN, TUN, split-DNS, and Fake-IP network stacks: their synthetic address is intentionally not the public origin address, but the system transport still knows how to reach the origin.
  • Fix: Materialize only at the final provider boundary. Keep the original URL-backed content for activity projection, and convert the provider-only copy after local slash/control handling but immediately before Codex turn/start or turn/steer, Claude SDK exec or guide, and standard ACP session/prompt. Use the repository proxy-aware HTTP client and leave DNS, proxy, VPN, and connect-target selection to the system network stack; do not pre-resolve, pin, or reject resolved IP ranges at this provider compatibility boundary. Continue accepting only HTTPS URLs, require every redirect hop to remain HTTPS, strip redirect Referer headers so signed URL query parameters cannot cross hops, and keep timeout, response-size, and image MIME checks. Admit an asynchronous Codex guidance continuation before publishing its provisional provider turn, and settle that exact attempt when preparation ends without starting a real provider turn. For standard ACP, publish the original user activity and provider-turn start before remote materialization; if preparation fails, complete that same provider turn as failed so the message remains durable and root settlement cannot strand.
  • Validation: Exercise URL-only content through the real adapter request paths and assert the captured provider payload contains inline data. Cover Codex send and guidance, Claude SDK exec and guide, and standard ACP exec; do not rely only on direct helper tests. Verify the production client can materialize through a system-routed reserved address, reject a redirect that downgrades to HTTP, and assert redirects omit signed-URL referrers. Cover failed and concurrent guidance continuation admission so every published provisional attempt either yields a real provider lifecycle or receives its matching provider-turn completion. Also fail standard ACP materialization and assert the original user message, turn start, and failed provider-turn completion are all emitted without sending session/prompt.
  • References: prompt_content.go codex_appserver_turn.go claude_sdk_execution.go standard_acp_turn.go

AgentGUI Stop reports no active turn after cancel succeeds

  • Symptom: Pressing Stop settles the AgentGUI turn as canceled, but the renderer also logs a workspace_operation_failed/502 error whose daemon cause is agent session has no active turn.
  • Quick checks: Compare daemon agent_session.cancel.adapter_failed with nearby activity state patches. If the same turn reports turnPhase = settled and outcome = canceled at the same timestamp, the cancel result won the event race while the synchronous cancel RPC still observed a stale controller turn record.
  • Root cause: The runtime controller and provider adapter keep separate active-turn views. During cancel-after-settle races, the controller can still have a turn record while the Codex app-server adapter has already cleared its active turn and returns ErrSessionNoActiveTurn.
  • Fix: Treat ErrSessionNoActiveTurn from the controller active-turn cancel path as an idempotent settled-turn result: clear the stale controller turn record, reconcile any still-blocked view, and return without surfacing a 502.
  • Validation: Add controller coverage where controller.turns still has a record, the stored session is already settled/canceled, and the adapter returns ErrSessionNoActiveTurn.
  • References: controller.go controller_test.go

AgentGUI send blocked by active_turn after settled snapshot

  • Symptom: AgentGUI shows turnPhase = settled and no activeTurnId, but a follow-up prompt fails with agent session already has an active turn, or the runtime snapshot still reports submitAvailabilityState = blocked with reason = active_turn.
  • Quick checks: Compare renderer state with tuttid.log submit traces. If api.send.failed reports agent session already has an active turn after a settled/available state patch, inspect whether the controller still has an in-memory c.turns entry while the adapter lifecycle snapshot has already settled. For Codex app-server sessions, also compare turn/completed notification timing with the triggering turn/start RPC result; a stale provider turn id with no active turn object indicates that the late result rebound an already-settled slot.
  • Root cause: The controller's async turn registry is separate from adapter lifecycle projection. Async execution must clear c.turns when the owning adapter publishes a non-live TurnLifecycleSnapshot, even if the event type is not a terminal turn.completed/turn.failed event. The settled session and the registry cleanup must also become visible together; storing ready first leaves a follow-up rejection window. Separately, app-server notifications can settle a turn before the turn/start response is applied, so binding that response without checking turn identity can recreate a stale active id.
  • Fix: Treat same-turn non-live lifecycle snapshots as async turn completion, in addition to terminal event types and steered prompt messages. Clear the matching controller turn record before publishing/storing the terminal session view. Bind a provider turn id only while the exact active-turn object that issued the request still owns the adapter slot.
  • Validation: Add controller coverage where an async adapter emits only a settled lifecycle snapshot for the turn and no terminal event, then verify a follow-up Exec no longer returns ErrSessionActiveTurn. Also cover a terminal snapshot that waits for an open call and assert ready is never observable with an active controller turn. For Codex, deliver turn/completed before the turn/start result and verify the late result cannot restore the provider turn id. Run go test ./packages/agent/daemon/runtime.
  • References: controller.go controller_test.go codex_appserver_adapter.go codex_appserver_adapter_test.go

AgentGUI loading disappears before active turn settles

  • Symptom: AgentGUI loses the in-progress/loading affordance while the app-server turn is still active, normal composer sends hit the active-turn guard instead of queueing, or a later terminal event arrives for a turn that the runtime already cleared.
  • Quick checks: First compare the renderer runtimeSession and sessionState diagnostics. If runtimeSession.turnLifecycle.activeTurnId is non-empty with a live phase but sessionState.turnLifecycle.phase = settled and submitAvailabilityState = available, the bug is in AgentGUI derived state: the active composer/projection is trusting stale selected-session control state over the runtime snapshot. Separately inspect app-server terminal payloads. A turn/completed or turn/failed notification with an empty provider turn id must not settle a bound active turn unless that turn was explicitly adopted from a preceding goal-continuation turn/started.
  • Root cause: There are two distinct failure modes. In AgentGUI, the runtime activity snapshot can be live while the selected session view/control state is stale; composer loading, projection turn lifecycle, canSubmit, and local queue decisions must prefer the runtime live lifecycle. In the Codex app-server adapter, settleActiveTurn is allowed to adopt a mismatched provider turn id only for the steer case where turn/start returned an unconfirmed stub id and codex later completes the running turn with a non-empty provider id. Treating an empty terminal id as a wildcard clears active turn state too early and removes loading.
  • Fix: In AgentGUI, drive active projection, active live state, submit blocking, and queue decisions from the live AgentSessionEngine returned by AgentGUIRuntime.getSessionEngine(...) before falling back to activeSessionState. Ordinary composer sends while busy should queue; explicit send-now intents must use capability-selected native guidance or exact-turn cancel-then-send. In the daemon, keep the steer exception, but require the terminal provider id to be non-empty and drop empty-id terminal notifications for bound active turns. Keep the narrow exception for goal-adopted turns whose ownership came from turn/started.
  • Validation: Keep tests for both sides: a stale settled sessionState plus live runtime lifecycle should still render processing/loading, set canSubmit = false, allow local queueing, and avoid direct exec; steered stub turns must settle on the running turn's non-empty completion id; empty-id terminal notifications must not settle confirmed or unconfirmed active turns; goal continuation must still complete its adopted turn.
  • References: useAgentGUINodeController.ts useAgentGUINodeController.spec.tsx codex_appserver_turn_machine.go codex_appserver_adapter_test.go

Codex app-server Start/Resume waits after a lifecycle notification

  • Symptom: Session start or resume reaches the provider, but Tutti waits for the lifecycle RPC timeout even though Codex sent thread/started. A required MCP server can show the same symptom when the only failure evidence is mcpServer/startupStatus/updated with status = failed and there is no stderr or JSON-RPC response.
  • Quick checks: Inspect the app-server wire trace for thread/started or mcpServer/startupStatus/updated before the missing thread/start or thread/resume response. Distinguish this from a completely silent provider: without an authoritative lifecycle notification, process termination, or transport error, the call must still time out rather than manufacture success. The Codex adapter allows 90 seconds for thread/start; thread/resume and the turn/start acknowledgement retain their 30-second budgets.
  • Root cause: App-server notifications and the response for their triggering RPC have no safe application-order guarantee. Waiting only on the response leaves the lifecycle call blocked when Codex has already published the authoritative thread snapshot. A required MCP failure is terminal only when Codex reports it through the lifecycle RPC itself. MCP stderr and startup-status notifications are diagnostic state, not independent lifecycle failures: optional MCP startup can fail while the Codex session is still usable. Child terminal notifications can arrive before receiverThreadIds registers their thread; dropping those unknown-thread terminal events loses the only completion fact.
  • Fix: Complete only the active method-matched thread/start or thread/resume wait from a valid thread/started snapshot. Do not fail that wait from MCP stderr or mcpServer/startupStatus/updated; record the warning and keep the session alive. A real Codex lifecycle RPC error, process exit, or transport error remains terminal and is still propagated. Keep ordinary foreign-thread progress dropped, but retain a bounded terminal notification until child registration and replay it with child identity. Warnings without a turnId use a session audit event instead of a turn-scoped message. Keep confirmed provider turn-id fences; only the existing unconfirmed steer stub and goal-adopted exceptions may settle from a different or empty id.
  • Validation: Run the notification-only Start/Resume wire test, the delayed MCP failed-status tests, the turnless warning projection test, and the child terminal-before-registration replay test. Run the focused app-server suite with -race and the full go test ./packages/agent/daemon/runtime package suite.
  • References: codex_appserver_client.go codex_appserver_event_routing.go codex_appserver_turn_machine.go codex_appserver_lifecycle_test.go codex_appserver_events_test.go

Busy-turn message insertion fails or ends without sending the prompt

  • Symptom: Sending a prompt with the composer guidance shortcut or choosing “send now” on a queued prompt fails for ACP-backed agents, reports a turn-scoped cancellation/guidance error, or cancels the turn without sending the prompt.
  • Quick checks: Inspect the canonical session capabilities and engine commands. Codex and Claude sessions should advertise activeTurnGuidance; standard ACP sessions should advertise interrupt without activeTurnGuidance. Confirm that no renderer branch selects behavior from a provider ID.
  • Root cause: Message insertion is one product intent with two transport realizations. Treating every provider as native guidance sends a same-turn request that the standard ACP protocol does not define. Treating every provider as cancel-then-send discards the same-Turn semantics offered by Codex and the Claude SDK. Native guidance semantics are provider-specific: Codex steering deliberately leaves the current response running, while the Claude SDK must interrupt its active Query before it can reliably enqueue guidance.
  • Fix: Keep the prompt queue in the workspace AgentSessionEngine. Resolve send-now from typed runtime capabilities: use native guidance when activeTurnGuidance is true, with no canonical Turn cancel. The provider adapter must preserve its native semantics on the same canonical Turn. Claude uses its SDK interrupt before enqueueing the prompt and closes the old response projections. Codex/Tutti Agent sends turn/steer with the exact active provider Turn ID and does not interrupt or start a replacement Turn. If no provider response remains but the canonical Turn is still active for child work, Codex may start a provider continuation through turn/start. Otherwise use exact-turn cancel when interrupt is true, retain the prompt in the frontend queue, and send it normally only after validated cancellation or authoritative turn settlement. Route both the composer shortcut and queued-item action through the same atomic engine transition.
  • Validation: Cover both entry points and both capability combinations. Native guidance must emit a guidance send with no cancel. ACP fallback must emit cancel with no prompt send, then emit one normal prompt send after cancellation settles. At the Claude provider boundary, assert that the old response terminal and all old thinking-stream terminals precede guided output. For Codex, assert that active-response guidance sends exactly one turn/steer with the expected provider Turn ID and sends neither turn/interrupt nor another turn/start.
  • References: promptQueue.reducer.ts sessionLifecycle.reducer.ts controller_exec.go

Queued AgentGUI prompt stalls after no-active-turn failure

  • Symptom: A prompt submitted while an AgentGUI turn is busy appears in the local queue or as an optimistic user row, but does not start after the previous turn settles. Submit traces show the same clientSubmitId first failing with errorReason = agent.no_active_turn, then succeeding only after a later manual retry or another queue-draining trigger.
  • Quick checks: Search desktop and daemon logs for the queued clientSubmitId. A local queue acceptance has send_input.requested with queued=true and optimistic_user_message_painted. The failure pattern is a delayed renderer_adapter.send.failed with errorCode=invalid_request and errorReason=agent.no_active_turn, plus daemon runtime_adapter.exec.failed with agent session has no active turn.
  • Root cause: The daemon exposes the domain-specific reason as the protocol error reason, while the Agent session engine previously kept only the generic errorCode. The queue reducer therefore treated the race like a permanent send failure, set failedPromptId, and stopped automatic drain until send-now or another retry path cleared the failure.
  • Fix: Preserve protocol reason on EngineCommandResultIntent. For queue/sendPrompt failures whose reason is agent.no_active_turn, clear the in-flight send, request a session reconcile, and skip same-reducer drain so the queued prompt retries only after canonical state refresh. Keep ordinary send failures blocked until explicit send-now retry.
  • Validation: Add reducer coverage where a queued send fails with errorReason = agent.no_active_turn: it should emit one session/reconcile, leave the prompt queued without failedPromptId, and avoid an immediate second queue/sendPrompt until a later canonical lifecycle update. Keep the existing generic failure test blocked until send-now.
  • References: promptQueue.reducer.ts effectExecutor.ts daemon_agent_submit_handlers.go

Cursor or OpenCode turn settles before late ACP activity arrives

  • Symptom: A Cursor or OpenCode turn appears complete, then a delayed tool or permission event is projected onto the old turn; the composer may relock, persistence may reject a settled-to-running transition, or a synthetic turn may appear. A Cursor background Task may also appear launched successfully while its detached child later requests permissions that never reach the UI.
  • Quick checks: Correlate session/prompt response timing with later session/update or session/request_permission messages. If the prompt response arrived first, the late event no longer has an active canonical turn owner. Also verify the terminal report is root_provider_turn.completed, not a direct canonical turn.completed from the ACP adapter. For Cursor Task/subagent probes, correlate agent_session.cursor.task_tool_update with agent_session.cursor.task_extension; the latter records only redacted identity, ordering, field-presence, and duration facts. A background Task tool result with isBackground=true and a very short duration is a launch acknowledgement, not child terminal evidence. Permission requests arriving after the root prompt result confirm the child is still running out of scope.
  • Root cause: Standard ACP has one active prompt handler and a session-level fallback handler. Reusing a recent turn ID in the fallback path treats temporal proximity as ownership. That can reopen a settled root or fabricate a turn, and ordinary tool display fields do not supply the stable child identity and terminal lifecycle required for a provider-native child session. Cursor's background Task implementation records eventual completion in an internal work registry, but Cursor ACP 2026.07.01-41b2de7 does not expose that terminal to Tutti.
  • Fix: Route every Standard ACP prompt terminal through the daemon-owned root provider lifecycle. Drop turn-scoped tool/message updates outside the active prompt call and reject out-of-band permission callbacks; never synthesize a canonical turn. Keep Cursor/OpenCode root-only until their ACP transports expose stable child, parent, and child-terminal facts. Cursor Agent 2026.07.01-41b2de7 does not merge --plugin-dir hooks into ACP, so the dormant preToolUse Task guard is deliberately not advertised or materialized. Do not treat background Task as supported or blocked, do not write hooks into user/project configuration, and do not settle a detached child from a guessed timeout.
  • Validation: Cover Cursor and OpenCode normal completion with root_provider_turn.started/completed and no canonical terminal from the adapter. Deliver a late tool update and late permission after prompt return and verify neither creates a turn, interaction, or child session. Make a session/cancel write fail and verify the error reaches the caller. Also fail an automatic permission-response write and verify the adapter does not report a false approval while the provider is still waiting. Verify the dormant Cursor hook allows foreground Task inputs, rejects snake-case and camel-case background flags, does not match flag-like text inside the Task prompt, and fails closed for malformed input; separately verify the current ACP plugin manifest does not advertise or materialize that hook.
  • References: standard_acp_turn.go standard_acp_stream.go provider-native subagents

Standard ACP prompts stay queued after a canceled turn

  • Symptom: A Standard ACP Agent finishes or cancels a visible turn, but every later message reports that it was queued for the next turn and is never consumed. The provider process remains alive and may still report its previous prompt as running even though Tutti already presents the Session as idle.
  • Quick checks: Correlate Tutti's session/cancel notification, the outstanding session/prompt response, and the next session/prompt request. If the next request is dispatched before the canceled prompt returns, the provider may queue it behind state that no worker will drain. Distinguish this provider queue from AgentGUI's local queue by confirming that the later request crossed the ACP transport.
  • Root cause: session/cancel is only cancellation delivery; it is not proof that the outstanding session/prompt call has settled. Releasing Tutti's local turn fence immediately after writing the notification lets another prompt enter the same provider process while its internal running state is still true.
  • Fix: Keep a per-Session active-prompt drain barrier. Capture the exact active prompt, deliver session/cancel, and mark that same prompt canceled only after delivery succeeds; a failed notification must leave the prompt's eventual result authoritative. Once cancellation is accepted, check it before any retriable-error auto-continue branch and wait for the outstanding prompt result before accepting another turn. If the provider does not settle within the bounded drain window, close only the transport and let the next command resume the provider Session in a fresh process. Do not call destructive session/close during this recovery.
  • Validation: Use a deterministic ACP transport fixture whose first prompt returns only after session/cancel. Assert that Cancel cannot return before that prompt drains, that the first turn settles as canceled, and that a second prompt on the Session completes. Also inject notification failure and prove the active prompt remains uncanceled; race cancellation with a retriable tail and prove no second prompt is sent. Run the focused test under Go's race detector.
  • References: standard_acp_turn.go standard_acp_turn_test.go

Codex goal stops after a turn while the goal remains active

  • Symptom: A /goal completes or fails one turn, remains active, but never starts a continuation turn. The conversation looks idle even though the goal banner still says it is running.
  • Quick checks: Enable TUTTI_TEST_LOGS=1 for a focused runtime reproduction and compare agent_session.app_server.goal.status_changed with agent_session.app_server.goal.continuation_nudge. If the first turn settles before the initial thread/goal/set response records the active goal, an eager scheduling check can return without ever creating the continuation timer.
  • Root cause: App-server notifications and the response for their triggering RPC do not have a safe application-order guarantee. A turn/completed notification may settle the first goal turn while thread/goal/set is still in flight, so local goal state can still be empty when the settle path schedules its nudge.
  • Fix: Before sending a goal-setting RPC that can start a turn, record a local active goal snapshot with the requested objective. This makes goal activation causally visible to terminal notifications that overtake the RPC response. Restore the previous local goal if the RPC fails; on success, replace the provisional snapshot with the authoritative response. The continuation timer must still re-read both active-turn and goal state after its grace window before sending a nudge.
  • Validation: Keep a scripted protocol test that deliberately delivers goal turn notifications before the thread/goal/set result, then verify the next turn is adopted. Cover both a clean first turn and a failed mid-goal turn with repeated focused runs; use event channels rather than polling shared slices.
  • References: codex_appserver_goal.go codex_appserver_adapter_test.go

Codex goal shows active but produces no response

  • Symptom: Sending /goal … updates the Goal banner to active, but no assistant text or tool activity appears. Logs show turn/started, followed shortly by agent_session.app_server.goal.turn_provenance_unproven and an exact turn/interrupt.
  • Quick checks: Inspect the installed Codex app-server schema and the raw thread/goal/updated payload. In affected versions, turnId is optional and the external thread/goal/set update omits it. Verify the Goal operation and generation fingerprint were persisted successfully before investigating transport or model failures.
  • Root cause: Tutti required thread/goal/updated.turnId as the only Goal-to-Turn correlation signal. Codex externally applies the Goal, emits a session-scoped update without turnId, then starts the continuation Turn. The adapter therefore treated valid work as unproven and deliberately interrupted it after the provenance grace window.
  • Fix: Keep exact turn-scoped Goal evidence as the preferred path. For versions that omit turnId, use an adapter-local, single-use continuation claim scoped to the successful Goal RPC's immutable operation/revision. Chain the next claim only from settlement of an adopted Goal Turn, serialize Goal control against ordinary submit setup, and invalidate the claim on revision change, inactive status, restart, or multiple unowned Turns. Never bind an arbitrary unowned Turn from the mutable current Goal snapshot and do not fix this by merely extending the provenance timeout.
  • Validation: Reproduce the real protocol order goal/set response -> goal/updated without turnId -> turn/started and verify assistant output is persisted without an interrupt. Also cover chained continuation Turns without another Goal RPC and a delayed old Turn after a newer revision, which must still be interrupted.
  • References: codex_appserver_goal_provenance.go codex_appserver_goal.go controller_exec.go codex_appserver_adapter_test.go

Active Codex goal has no elapsed timer

  • Symptom: The Goal banner shows an active objective but no elapsed value, even though Codex continues working and its Goal database reports non-zero usage time.
  • Root cause: Codex exposes timeUsedSeconds and tokensUsed, while Tutti's canonical session Goal contract exposes durationMs and tokens. Passing provider fields through runtime context causes the typed durable session boundary to discard them. A newly created Goal may also omit canonical durationMs because zero is an optional counter.
  • Fix: Normalize provider counters in the Codex adapter before publishing session state or Goal observations. The renderer reads only durationMs, hides time while the Goal is an optimistic projection, and starts only after canonical provider state arrives. If that canonical state omits the optional zero baseline, start at zero and advance locally between server updates.
  • Validation: Verify provider-to-canonical normalization with non-zero counters, verify an optimistic Goal shows no time, verify canonical confirmation adopts the provider duration and advances, and verify a paused Goal without a duration still omits the timer.
  • References: codex_appserver_goal.go AgentGoalBanner.tsx

Codex goal reappears after pause, edit, or clear

  • Symptom: A goal control succeeds, but the banner shortly returns to an older objective or status; a cleared goal can reappear as paused.
  • Quick checks: Compare the startup background thread/goal/get with later goal-control RPCs. If the get began before the control and completed afterwards, inspect whether its older snapshot was applied unconditionally.
  • Root cause: Startup restores the persisted thread goal asynchronously. Its response can race with newer user controls or provider goal notifications, so arrival order is not a safe freshness signal.
  • Fix: Version the session goal state. Capture the session identity and revision before the startup fetch, and apply its result only when both are unchanged; increment the revision on every update and clear.
  • Validation: Capture a startup refresh guard, clear the goal, then attempt to apply the older paused snapshot and verify it is rejected.
  • References: codex_appserver_adapter.go codex_appserver_events.go codex_appserver_adapter_test.go

Clearing a goal hides Stop or appends a provider acknowledgement

  • Symptom: Clearing a goal while its current turn is still running leaves the composer on a non-clickable send spinner. The transcript treats /goal clear as a user Turn followed by a new processing row even though no new user work started. Claude Code may instead append a native Goal cleared: … assistant message at the bottom of the transcript after the interrupted turn.
  • Quick checks: Inspect the AgentGUI clear handler and the engine pending-submit records. If clear calls executePrompt with an immediate /goal clear, the control has entered the normal message pipeline and received a pseudo turn identity. For Claude Code, correlate the bottom assistant message's turn ID with the adapter-generated turn carrying the native clear command.
  • Root cause: Goal clear changes thread metadata rather than submitting user work. For a provider such as Codex that leaves the active turn running, submitting clear as a prompt creates a pending submit without a real provider turn. That local submit owns the send spinner and its visible user message becomes the last timeline turn, so canonical processing is projected under the wrong item. Claude Code instead interrupts the live goal turn and requires a separate native command turn to execute clear; projecting that control turn's provider acknowledgement as ordinary assistant content creates an unrelated transcript row at the bottom.
  • Fix: Route every goal action, including clear, through the dedicated runtime goal-control API. Do not create a user Turn message, pending submit, or pseudo turn. A surface may project the durable session audit as a dedicated goal-control row, but that row must carry no Turn ID and must not affect processing ownership or Turn counts. If the provider needs an internal clear-command turn, register its generated turn ID and suppress only that turn's assistant/thinking acknowledgement at the runtime-adapter boundary before persistence. Do not filter by localized acknowledgement text and do not move the message into the interrupted turn. Preserve goal/session updates and terminal cleanup, but do not register the internal command as a root provider turn or feed its terminal into canonical root settlement. Keep Stop and processing derived from the canonical active turn, and report a successful clear with a localized transient toast. Render that toast in an AgentGUI detail-scoped viewport and use UI System themed surface, foreground, and border tokens so it centers within the content area and follows the active light or dark theme instead of using the inverted neutral toast style.
  • Validation: Clear a goal while a turn is running and verify the goal-control API is called without an engine submit dispatch. If the clear control is visible, it must be a goal-control row with no Turn ID; the original processing row must remain in place, and Stop must remain clickable until the active turn settles or is interrupted. For Claude Code, verify the native acknowledgement is absent both live and after reload, while identical text from an ordinary assistant turn remains visible.
  • References: useAgentGUISubmitInteractionActions.ts claude_sdk_goal.go claude_sdk_events.go agent-gui-node.md

Agent session stays loading after a completed turn

  • Symptom: AgentGUI shows the assistant response as completed, but the conversation or sidebar remains in a loading/running state. Desktop logs may contain agent.activity.store.session_version_regression where the previous session is settled/available and the next session is older running/active_turn.
  • Quick checks: Compare the desktop reconcile.state_fetch.resolved session timestamp with the latest inline state_patch timestamp. In tuttid.log, check whether runtime emitted a terminal turn_phase=settled event before the fetch response was applied.
  • Root cause: Activity projection can accept and broadcast a newer completed state while GetWorkspaceAgentSession still prefers an older live runtime snapshot for the same session. The projection store has timestamp regression protection, but the service read path can bypass it when a runtime session is present.
  • Fix: In service read paths, compare persisted projection freshness against the runtime snapshot. If persisted state is newer, return the projected session state and synthesize non-live turn lifecycle/submit availability instead of exposing the stale runtime active turn.
  • Validation: Add service coverage where runtime reports working/running/active_turn with an older UpdatedAtUnixMS, while persisted state reports completed/idle/available with a newer LastEventUnixMS. Validate both Get and List do not return the old active turn. Run go test ./services/tuttid/service/agent.
  • References: service_session.go service.go service_session_list.go

Remote session stays planning while the provider already replied

  • Symptom: A remote or cloud-backed AgentGUI conversation remains on its planning or working placeholder after the provider has already settled the Turn. Reading the authoritative Session directly reports ready with no active Turn, and opening the Session event stream immediately reveals the missing assistant response.
  • Quick checks: First compare authoritative Session state with the renderer projection. Then inspect host access logs for the exact Session event-stream subscription. If the provider settled but the AgentGUI surface never requested the stream, increasing HTTP or activation timeouts cannot repair the stale projection.
  • Root cause: The focused conversation was hydrated once but did not retain the host's optional Session synchronization lease. Hosts that use that lease to keep a per-Session event stream open therefore receive no terminal state or message events until another read happens to reconcile the Session.
  • Fix: Keep synchronization ownership in the shared focused-conversation controller. Acquire the exact Session lease on focus, keep repeated selection idempotent, release the previous lease on switch or clear, and release the final lease on disposal. Keep the host responsible for transport and authoritative reconciliation; do not add provider-specific polling or longer timeouts.
  • Validation: Add controller coverage for acquire, repeated selection, switch, clear, and disposal. In the affected host, verify that selecting the conversation opens the exact Session stream and that a terminal event updates both state and transcript without a manual refresh.
  • References: agentConversationMessageController.ts useAgentConversationMessagePaging.ts agent-activity-packages.md

AgentGUI pin or unpin appears stuck for a live session

  • Symptom: Pinning or unpinning a conversation backed by a live runtime succeeds in the daemon, but the rail does not move the row or update its action until a later list refresh. Old conversations without a live runtime update immediately. In another variant, the pin command completes quickly but the whole rail becomes disabled or changes to a skeleton while section membership refreshes. A third variant renders the mutation in two steps: unpin first removes the pinned row, then removes the pinned section and inserts the ordinary row; delete first shrinks the section, then shrinks and refills it.
  • Quick checks: Correlate pin_result with agent.activity.store.session_version_regression. Compare the command response's updatedAtUnixMs with the engine's current session version, then inspect the exported session for a newer pinnedAtUnixMs. A fast command carrying the new pin value but an older updatedAtUnixMs identifies a stale runtime projection, not a slow database write. If the pin value applies promptly, correlate agent_gui.conversation_rail.first_pages_slow with workspace.agent_session.sections.list_slow. The renderer event should report refreshReason=membership_change; the daemon event separates projects_ms, store_ms, and hydrate_ms and reports current/non-empty project counts, target-scoped visible sessions, and returned first-page rows.
  • Root cause: Durable metadata updates advance the persisted session timestamp. When a live runtime session is also present, the service merges persisted metadata such as pinnedAtUnixMs into the runtime projection. If that merge keeps the older runtime timestamp, the frontend's monotonic session reducer correctly rejects the whole stale response, including the new pin value. When the entity update is accepted, pin membership still requires authoritative pinned and ordinary first-page reads. Triggering an aggregate section query or workspace activity load turns that targeted revalidation into a multi-second blocking reload on large histories. Rendering the updated or tombstoned canonical entity against the previous page membership creates the two-step variant: entity projection changes before the authoritative page replacement knows the final bounded list and refill row.
  • Fix: Merge session freshness monotonically across runtime and persistence using the newer timestamp. Pin responses that advance the session version must also include protocol-v2 active/latest turn state so accepting the metadata update cannot clear a running turn. Do not weaken frontend version checks or hide the mismatch behind delayed refetches. For an accepted membership update, compare canonical before/after membership and request only pinned plus the exact ordinary section page. Route pin and delete through engine mutation intents; the command result and canonical follow-up must drain before subscribers are notified. Make the rail query controller the sole publisher of a composite entity-plus-membership snapshot, retain its previous committed value while targeted reads run, and publish the complete next value once. The view must not subscribe to live engine rows separately or keep stale section snapshots. Do not inspect mutation history from the Rail; compare canonical membership before and after each engine notification. Keep the committed snapshot on targeted failure and lock membership actions until a scoped authoritative refresh succeeds. Do not call listSessionSections or workspace load from delete, pin/unpin, or rename. Lock scope-sensitive actions only while displayed membership belongs to a different or unresolved scope; do not patch daemon-owned membership locally.
  • Validation: Cover a live runtime session whose persisted pin update is newer, a newer runtime snapshot that must not regress, and a running turn that remains attached to the pin response. Run go test ./services/tuttid/service/agent plus daemon lint, tests, and build when daemon persistence changed. Add reducer interleaving coverage proving command success and canonical follow-up share one engine notification. Add controller coverage proving same-scope pin/unpin calls only the pinned and exact ordinary page endpoints, keeps the old composite projection until both resolve, publishes once, and never calls the aggregate section endpoint.
  • References: service.go service_session.go sessionEntities.reducer.ts AgentGUIConversationRailQueryController.ts

AgentGUI Batch delete sessions does nothing

  • Symptom: The project or Chats overflow menu opens, but choosing Batch delete sessions neither opens the confirmation dialog nor sends a delete request.
  • Quick checks: Look for agent.gui.conversation_batch_delete.capability_incomplete in the host's AgentGUI diagnostics. Its missingMethods field identifies whether the host omitted listSessionSectionDeletionCandidates or deleteSessionsBatch. If the event is absent and the action is enabled, correlate the candidate-list and batch-delete transport requests before investigating the view.
  • Root cause: Batch deletion is a two-stage runtime capability: AgentGUI first resolves the authoritative session ids for the rail section and only then submits the selected ids. A host that manually assembled AgentGUIRuntime could expose the mutation but omit the candidate query. The old optional-method checks then returned an empty candidate list, making a valid click look like a no-op.
  • Fix: Install the complete runtime cohort from @tutti-os/agent-gui/conversation-rail-runtime and keep only transport DTO mapping in the host adapter. Do not add view-local candidate discovery or infer membership from loaded rows. AgentGUI treats a partial two-method contract as unavailable, disables the action, and reports the missing method.
  • Validation: Cover the host composition with both batch-deletion methods, verify the shared factory forwards the exact candidate and delete inputs, and cover a partial runtime contract as disabled with one diagnostic. Run the Agent GUI package tests and the host activity-runtime composition tests.
  • References: agentConversationRailRuntime.ts useAgentGUIConversationRailQuery.ts createDesktopAgentActivityRuntime.ts

AgentGUI model switch changes defaults but not the active session

  • Symptom: A user selects a different AgentGUI model, but the next provider call still uses the previous model. The target-default patch may be acknowledged while workspace_agent_sessions.settings_json, runtimeContext.model, or app-server turn/start still show the old model. For an Agent Extension, the selected model may also change back to Auto as soon as a new session is created, even though the durable session row contains the requested model.
  • Quick checks: Search desktop and daemon logs for the full settings chain: agent.gui.composer_settings.default_only, agent.gui.composer_settings.update_requested, workspace.agent_session.settings.update_requested, agent_session.settings.update.requested, agent_session.app_server.settings.applied, and agent_session.app_server.turn_start.params. Also distinguish the dedicated defaults patch intent from the Session update. If only the defaults ack is present, the UI remembered a future target default but did not update the active Session. If daemon settings update completed but turn_start.params.model is old or empty, inspect the app-server adapter path. If persistence and the provider request both contain the selected model but the daemon session response omits settings.model, inspect the service projection before debugging the renderer selector.
  • Root cause: AgentGUI has two distinct composer surfaces. The target home composer writes remembered defaults and a sparse local display draft. An active conversation composer must additionally call updateSessionSettings; Codex app-server providers then apply model changes as per-turn overrides on the next turn/start, not to an already-running turn. If the daemon applies the settings but the update response still reports the old model, check the service merge path: serviceSessionWithPersistedFreshness must not let a newer activity projection snapshot overwrite live runtime settings after an explicit settings update. For extension-owned open provider IDs, established runtime and persisted sessions must use open-provider-aware normalization. Applying the closed built-in composer registry to an ID such as acp:<extension> produces an empty built-in provider, clamps the model, and makes the UI correctly render Auto from an already-corrupted session projection.
  • Fix: Preserve the dedicated target-default patch path, but make active-session model changes observable at every layer. Do not conclude that a provider ignored the model until the logs show the active session settings update reached the daemon and the following turn/start carried the requested model. Keep closed normalization for unverified composer requests, but preserve provider-owned settings when projecting or resuming a session that was already authorized through an Agent Target.
  • Validation: Reproduce by switching a model in a running session and sending a follow-up. Confirm the logs include the update chain above and that workspace.agent_session.settings.update_completed reports the requested model and the next turn/start carries it. If the persisted workspace_agent_sessions.settings_json.model is older while the runtime is live, Get responses should still expose live runtime settings instead of the stale projection value. Add a service regression with a generic open provider ID and assert serviceSession retains its model; also assert an invalid provider still loses stale settings.
  • References: useAgentGUINodeController.ts createDesktopAgentActivityRuntime.ts workspaceAgentActivityService.ts service_session.go controller.go codex_appserver_adapter.go

AgentGUI shows the selected settings but a new session does not inherit them

  • Symptom: The home composer continues to show the selected model, permission, reasoning effort, or speed, but closing the Agent window, opening another window, restarting Tutti, or creating a Session restores an older value. A running Session may still use the selected value, which can make the problem look provider-specific.
  • Quick checks: Start with the exact agentTargetId, not only the provider. Confirm the preferences.agent.composer.defaults.patch.requested intent receives an ack, then inspect desktop_preferences.agent_composer_defaults_by_agent_target_json for that target and field. Confirm a preferences.agent.composer.defaults.changed event carries only the same target id. Finally request target-scoped composer options and verify effectiveSettings, then create a Session without explicit overrides and inspect the daemon's resolved create settings. For an Agent Extension model, confirm the daemon first observed that value in a live catalog for the exact target; a catalog observed only for another target cannot validate the patch.
  • Root cause: Target defaults and current Session settings are separate durable concerns. The renderer's home draft can display an optimistic selection even when a defaults write failed. Conversely, a Session settings update can succeed while the future-default patch fails. A stale renderer preferences snapshot must never be merged and written back as the defaults map; options snapshots must never sanitize a newly selected menu value before persistence.
  • Fix: Keep rememberAgentComposerDefaultsForAgentTarget on the dedicated patch intent. Merge its sparse fields only in the tuttid SQLite transaction, publish target invalidation after success, and reread defaults through composer-options. Keep Create Session inheritance in agent.Service.Create; if AgentGUI forwards a presented inherited model or reasoning value, it must also carry modelExplicit=false / reasoningEffortExplicit=false through Engine and the HTTP request; explicit overrides carry true. Do not repair this with debounce, localStorage, node/workbench overlays, or another full preferences write. Do not add workspace/cwd to the target-default patch. Extension model validation uses the daemon-observed last-known-good catalog for the exact target. Its evidence survives the workspace/cwd display-cache TTL and is cleared by explicit provider invalidation; Create performs the separate actual-workspace/cwd validation.
  • Validation: Change different fields from two windows and confirm both survive. Repeat the same SET, then change the same field again and confirm daemon acceptance order determines the result. Force an options refresh with an older permission or model list and confirm the explicit selection remains visible and is still patched. Exercise A-to-B-to-A on one field and confirm only the exact latest generation can leave the optimistic layer. Fail the first options reload after a successful patch, then confirm a later successful target invalidation read converges the acknowledged draft. Reopen the window and restart the app; effectiveSettings and a new Session must resolve the remembered values. Open a historical Session and confirm its settings do not change future defaults.
  • References: service.go sqlite_preferences.go composer_options.go desktopPreferencesService.ts useAgentGUIComposerSettingsActions.ts

Agent interaction remains waiting after the user already responded

  • Symptom: An approval or question remains at an ask-user stop point after the user submitted a response. The session may continue after Cancel or an app restart, while retrying by request id alone can target an older Turn or be rejected as ambiguous.
  • Quick checks: Inspect the canonical Interaction identity without logging its prompt or response payload. Compare workspace_id, agent_session_id, turn_id, and request_id across the UI/CLI command, Interaction row, and durable runtime operation. A provider request id reused by two Turns is valid. An operation id whose stored tuple differs from the command tuple is an invariant failure.
  • Root cause: Provider request ids are transport-local and are not session-wide durable identities. Selecting an Interaction or deduplicating a runtime operation by request id alone can bind a response to historical Turn state, leaving the live Turn waiting.
  • Fix: Carry the typed exact tuple (workspaceId, agentSessionId, turnId, requestId) into Host and select the Interaction atomically by that tuple. Scope runtime-operation idempotency to the same tuple. Keep the provider request id unchanged. Do not scan, guess, auto-deduplicate, or rewrite historical rows. The V4 runtime-operation migration removes the lossy subject_id, creates partial unique indexes for each operation kind, verifies copied row distributions and foreign keys, and rolls the whole transaction back when a conflicting durable identity exists.
  • Recovery: Existing affected sessions may be canceled and retried; restart settlement may also interrupt a stale Turn. This forward fix deliberately does not mutate historical Interaction rows.
  • Validation: Cover the same provider request id in two Turns, exact CLI response routing, same-answer idempotency, different-answer supersession, operation identity mismatch failure, lossless V1-to-V4 migration, and V4 preflight rollback.
  • References: host README runtime_operations.go migrations_runtime_operations_v4.go tutti-cli-contract.md

Approval feedback stops the Turn or reports a failed response

  • Symptom: Entering feedback on a denied approval aborts the provider Turn, loses the feedback text, or lets the provider continue while the response API reports interactive request is no longer live.
  • Quick checks: Inspect the canonical Interaction options and response. Confirm feedback uses the non-terminal deny option, retains payload.denyMessage, and targets the exact child Session and Turn. For Codex, confirm the approval response is decline and the following turn/steer uses the child's provider thread and provider Turn IDs.
  • Root cause: Treating abort as an ordinary feedback-capable deny ends the Turn before text can be delivered. Dropping the payload at the adapter boundary has the same visible result. Blocking Interaction completion on the provider's steer acknowledgement can also race request cleanup and falsely fail an already answered approval.
  • Fix: Keep deny and abort distinct in presentation. Preserve the payload through the runtime adapter. Complete the approval response as answered, then deliver feedback through provider-native active-turn guidance without replacing it with a new root or child Exec.
  • Validation: Cover option selection, payload preservation, exact child provider thread/Turn steering, a delayed steer acknowledgement, and a real record-audit-replay cassette whose tool is rejected and whose child reports the feedback.
  • References: interactivePromptPresentation.tsx codex_appserver_event_interactive.go

Historical AgentGUI permission changes time out or stop responding

  • Symptom: Changing permission mode in a historical conversation appears to do nothing, or the first selection times out and later selections are ignored. Logs may show a 30-second settings request with no provider settings application.
  • Quick checks: Confirm the selected session is absent from the live runtime but present in workspace_agent_sessions. Check whether one menu choice emitted two settings requests. After a timeout, inspect the engine settings operation: unknown plus a later request without retry explains a silent drop.
  • Root cause: Historical settings were routed through runtime preparation, so a metadata change could block on provider resume and sidecar startup. The permission menu also handled both item pointer-down and Select value-change, duplicating one user action. When the first command timed out, the engine correctly kept uncertainty but the controller did not mark a later explicit choice as a retry.
  • Fix: Send one settings intent from Select value-change. Read the current engine operation at action time and mark an explicit choice as retry when its status is unknown. In the daemon, update inactive-session settings through the durable activity projection and publish reconciliation; reserve provider runtime updates for sessions that are already live. Serialize runtime resume with durable settings read-modify-write per session, preventing stale resume inputs and lost concurrent partial patches. Do not copy an active session setting into target defaults.
  • Validation: Cover one pointer selection producing one callback, timeout followed by an explicit retry, and a historical Claude Code settings update that persists while runtime resume and live adapter update counts remain zero. Run the AgentGUI, activity-core, store-sqlite, and agent-service focused tests.
  • References: AgentComposerSettingsMenus.tsx useAgentGUIComposerSettingsActions.ts service_settings.go agent-gui-node.md

Agent GUI provider tab shows fused or stale conversations

  • Symptom: Switching the Agent GUI aggregation rail between All, Cursor, Codex, or Claude makes the selected row disappear, collapses a loaded page, or briefly flashes missing Show more controls. The same switch may remain visibly blocked even when each individual session query takes only tens of milliseconds, because the first-page bootstrap repeats count, sort, entity projection, and turn / interaction hydration once per current project section. Five page rows plus one selected overlay may show Show more/Show less even when only six sessions exist, or a nine-session section may ignore the first Show more click. Restart can reproduce the same selected-row loss. On cold startup, All may also show section headers without session rows until selecting Codex and then returning to All.
  • Quick checks: Inspect workspace_agent_sessions.agent_target_id and rail_section_key. Confirm section requests carry the selected agentTargetId before pagination and responses preserve totalCount, hasMore, and nextCursor. In the renderer, distinguish daemon-owned section membership ids from engine-owned session entities; activating or hydrating one session must not rewrite the loaded membership page. For latency, count repository section reads per bootstrap: production must issue one ListSessionSections batch read, not one ListSessionSection call per project plus pinned and Chats. Run EXPLAIN QUERY PLAN and confirm ordinary branches use idx_workspace_agent_sessions_rail_section_page, pinned branches use idx_workspace_agent_sessions_pinned_page, and selected page entities load by the session primary key after narrow id trimming. A target-filtered rail must instead use idx_workspace_agent_sessions_rail_section_target_page and idx_workspace_agent_sessions_pinned_target_page with an exact target predicate; an optional OR predicate cannot narrow the index range. For a rail that shows only the active/new session, inspect *_failed events for no such index, then compare those required indexes with sqlite_master even when their migration markers exist. Store startup must idempotently restore missing rail pagination indexes; do not weaken INDEXED BY or fall back to a full session scan. For a reported slow switch, correlate agent_gui.conversation_rail.first_pages_slow with workspace.agent_session.sections.list_slow: the renderer event separates request and controller-apply time, while the daemon event separates current projects, store, and hydration time. Corresponding *_failed events record real failures. Successful requests below 250 ms, aborted requests, and stale responses are intentionally silent; these diagnostics must not log project paths, section keys, session titles, or prompts. Use refreshReason to distinguish attach, scope change, and membership invalidation. Interpret rail_visible_session_count as the target-scoped total across requested sections and returned_session_count as only the bounded first-page rows; neither requires another full-workspace count query. For provider switching, inspect agent_gui.provider_switch.completed or agent_gui.provider_switch.failed. They report source/target ids, fresh/stale/ miss cache status, request time, controller-apply time, and total controller readiness time. Correlate target and timestamp with agent.composer_options.load, which records composer transport duration and status. A fresh rail hit with no composer transport indicates both caches were reused; a long rail request isolates section loading, while a long composer event isolates target option discovery. For the cold-start empty-row variant, compare agent.gui.runtime.snapshot_changed session counts with agent_gui.conversation_rail.first_pages_slow. If historical reconciliation finishes while the first-page request is unresolved, confirm no section-page request is emitted with refreshReason=membership_change.
  • Root cause: A second React summary cache mixed entity data, section membership, active selection, and visible-item limits. Effects manually patched section rows from changing conversation summaries, so provider/detail reconciliation could collapse pages or synthesize membership. Counting the active overlay as a pageable row also corrupted Show more decisions. Bounded engine snapshots can recreate the loss if omission is treated as deletion. The latency variant is an N-section daemon read: current projects are a small requested set, while the workspace DB retains history for removed projects. Scanning that full history or repeating canonical turn / interaction hydration per section makes the rail wait scale with project count even when every leaf query looks fast. A database opened by different worktrees or intermediate builds can retain a migration marker after a required physical index disappears; treating the marker alone as proof of the schema invariant makes every section bootstrap fail while an active-session overlay can misleadingly remain visible. Historical session/snapshotReceived hydration can also be mistaken for a live rail membership mutation, causing a targeted section refresh to race the unresolved bootstrap request. Caching session payloads or a separate ingestion marker makes that race worse by splitting entity ownership from the engine.
  • Fix: Keep page sessions in the workspace engine. Cache only ordered membership ids, cursor, hasMore, and totalCount in the controller query, then join ids to engine entities with a pure model projection. Keep active, pending, and all exact-target in-progress root sessions as display overlays outside pagination until canonical membership catches up. Preserve old scope chrome and metadata atomically while a provider refetch is pending. Engine snapshots merge monotonically; only explicit session/removed owns deletion. Keep first-page bootstrap as a required narrow repository seam: one requested-section-driven batch query, independent pinned and ordinary index branches, count/sort/limit on narrow session ids, then one cross-section canonical entity hydration. Reassert required rail indexes with idempotent CREATE INDEX IF NOT EXISTS during every store migration pass, including when the corresponding marker is already recorded, so schema drift repairs itself without rewriting session data. While workspace reconciliation is loading, update only the rail membership comparison baseline; do not target-refresh pages for historical snapshot hydration. Upsert first-page response entities into the workspace engine before publishing their membership ids. Resolved query cache entries must not contain session entities or ingestion ownership state. Do not add one UNION ALL arm per section; that restores section-count scaling and inherits SQLite's compound-select term limit.
  • Validation: Run pnpm --filter @tutti-os/agent-gui test, pnpm --filter @tutti-os/agent-activity-core test, and pnpm check:agent-activity-runtime-boundaries. Also run cd packages/agent/store-sqlite && go test ./... -run 'SessionSection|TurnsBackfill' and cd services/tuttid && go test ./service/agent ./api -run 'ListPage|SessionList|SessionSection' so cursor metadata and daemon ordering are covered. Run cd packages/agent/store-sqlite && go test -run '^$' -bench 'BenchmarkStoreListSessionSectionsLargeRemovedProjectHistory' -benchmem to compare the batch reader with the serial reference across sparse and dense requested-section histories. Cover Codex -> All -> Codex, client restart restore, active row outside first page, five-plus-active totals, nine-session Show more, slow provider refetch, and bounded snapshot omission. Add a cold-start ordering case where historical workspace hydration arrives before the first section response and assert that no targeted membership refresh is issued.
  • References: useAgentGUIConversationRailQuery.ts agentGuiConversationRail.ts AgentGUIConversationRailSection.tsx sessionEntities.reducer.ts service_session_sections.go

Agent GUI sessions appear under the wrong user project

  • Symptom: A conversation started with the "No project" selection appears in the Agent GUI rail under a parent user-project group such as the user's home directory. Imported Codex or Claude Code conversations with cwd equal to $HOME, and claude.ai data-export conversations that have no cwd at all, can show the same symptom even though the user never selected a project. A related nested- project variant shows an imported conversation under a broad parent folder while its explicitly selected child-project section says No chats yet. Switching provider filters may briefly retain the previous scope's row before the exact persisted membership makes the child section empty again. Delegated Issue tasks, Agent CLI handoffs, Automation follow-ups, or AgentGUI “New conversation” / “Continue in new conversation” can show a related variant: the new Session appears in Chats or under a temporary worktree instead of the source project, and Git commands no longer operate on the intended checkout.
  • Quick checks: Inspect the session cwd from the activity snapshot. Generated no-project sessions should carry runtimeContext.noProject: true in the daemon report before cwd is matched against parent user-project paths. If the create request contains that marker but the reported state does not, inspect runtime.Controller.State: it must preserve the session launch context while adding provider adapter state. For imported sessions, inspect runtimeContext for the daemon-owned externalImportNoProject marker. Claude data-export sessions should also carry externalImportResumeSupported: false. Check both the in-memory rememberNoProjectPath path and the restart fallback that recognizes Documents/tutti/session-<uuid>. Codex external history can also record its own scratch cwd under Documents/Codex/<yyyy-mm-dd>/<conversation>. For the nested-project variant, compare cwd, rail_project_path, and rail_section_key in workspace_agent_sessions. If the cwd is inside a registered child project but the persisted key names an ancestor, check whether that child was registered only after the original import completed. For a newly derived Session, compare those three fields with the source Session and inspect whether the create adapter supplied both cwd and RailPlacement. If an Issue has source_session_id but no Run was created, verify that the source Session still resolves; dispatch intentionally waits instead of guessing. For project Sessions with an empty cwd, verify that the adapter used rail_project_path as the runtime fallback.
  • Root cause: Rail membership is classified once by the daemon when the session is first persisted, using cwd, runtime no-project markers, and current user projects. If a generated no-project marker is lost before that write, longest-parent matching can assign the immutable railSectionKey to a broad project such as $HOME. External import has a similar trap because provider transcripts may record $HOME or a provider-owned scratch working directory as the cwd when no project was selected; that intent must reach initial persistence as session metadata. A second loss point is runtime state projection: rebuilding runtimeContext from only cwd, title, permissions, and visibility, or replacing it wholesale with StateAdapter output, drops launch-scoped markers such as noProject before durable rail classification runs. For project-backed import, registering user projects only after valid session writes creates another ordering trap: the store may see an existing parent project but not the selected child while assigning the session's first, normally immutable rail key. Derived-Session entry points add the inverse trap: copying only runtime cwd loses logical ownership when the cwd is an isolated worktree, while copying only rail placement can leave the Agent without the source checkout. Looking up the source twice during one dispatch can also mix two different snapshots, and silently accepting a missing source turns an invalid handoff into a detached allocator-backed Session.
  • Fix: Build runtime state from a clone of the session launch RuntimeContext, overlay canonical session fields, and merge provider StateAdapter context as a patch instead of replacing the map. Provider values win on collisions, while launch-only markers remain available to the durable classifier. Persist Agent GUI rail grouping in daemon-owned workspace_agent_sessions.rail_section_* fields from the shared services/tuttid/data/workspace classifier. Migration and session-state upsert should both use that classifier, matching exact user projects first, then preserving no-project/provider scratch cwd shapes as conversations, then applying longest parent-project matches. Project-backed external import must pass its exact matched selection into the initial session report, independent of whether API-level project registration has completed. The store validates that this is an imported, project-backed session whose cwd is contained by the selected path. An idempotent re-import may repair only Chats or an ancestor assignment to that explicit descendant; it must not infer a migration from the current project inventory or move an ordinary runtime session. Apart from this evidence-backed import correction, preserve valid existing rail keys across later cwd and project-list changes. A successful Create response must synchronously read back the persisted session and its nonblank key rather than racing the runtime's asynchronous activity reporter. AgentGUI must project sessions only by exact key equality and must not retain a cwd-based grouping fallback. For handoff, Issue, Automation, and AgentGUI new-conversation flows, carry runtime cwd and canonical rail placement independently. Resolve project identity by exact section key, use the canonical project path when a project-backed source cwd is empty, take one source snapshot per dispatch, and fail closed when a required source Session cannot be resolved.
  • Validation: Run pnpm --filter @tutti-os/agent-gui test -- agent-gui/agentGuiNode/model/agentGuiConversationModel.spec.ts, cd packages/agent/daemon && go test ./runtime, cd services/tuttid && go test ./service/agent ./api -run 'ExternalImport|ParseCodex|ParseClaude', go test ./packages/agent/store-sqlite -run 'ImportedRail|ClassifiesRail', node --import ./test/register-asset-stub.mjs --test --experimental-strip-types ./src/renderer/src/features/workspace-user-project/services/internal/desktopWorkspaceUserProjectService.test.ts from apps/desktop, then run pnpm check:changed. For derived-Session regressions, also run the focused AgentGUI new-conversation tests plus go test ./service/workspace ./service/automationrule.
  • References: controller_state.go controller_state_test.go external_import_parse.go external_import_projects.go external_import.go rail.go agentGuiConversationModel.ts desktopWorkspaceUserProjectService.ts agentGuiConversationProjectResolver.ts useAgentGuiConversationList.ts issue_sequential_dispatch.go daemon_executor.go

AgentGUI new conversation does nothing after leaving a Chats session

  • Symptom: After using an ordinary Chats Session, the toolbar new-conversation action opens the home composer but submitting by Enter or the send button creates no visible Session and produces no session/activate command. Enabling Tutti Mode before submitting can make the problem look mode-specific even though activation is blocked before provider or Tutti Mode execution starts.
  • Quick checks: Inspect the active conversation projection before the new-conversation request. If railSectionKey is conversations while cwd is a generated runtime directory, confirm that directory is not copied into the home composer's selected project. A subsequent activation that resolves the path against user projects and returns no placement explains the missing session/activate.
  • Root cause: Composer presentation intentionally exposes the active Session cwd for file mentions, Git operations, and missing-directory checks. Treating that presentation field as the user's home project selection conflates runtime working directory with rail placement. The generated Chats directory then enters project resolution and fails closed because it has no canonical project section.
  • Fix: Normalize default project selection at the AgentGUI controller's new-conversation command. Explicit section actions remain authoritative; an active Chats Session replaces the home selection with no project, an active project Session resolves its immutable section key back to the canonical registered project path, and an action already on Home preserves the user's explicit selection. The continuation action shares this resolver before it moves the source mention draft to Home. Views forward the intent without interpreting composer presentation fields.
  • Validation: Cover the command through final session/activate for three P0 scenarios: active Chats clears a generated cwd, active Project with a nested/worktree cwd preserves its canonical placement, Continue uses that same project, and Home preserves an explicit project selection.
  • References: agentGuiNewConversationRequest.ts useAgentGUIOperationActions.ts agentGuiNewConversationRequest.spec.tsx

Extension history becomes non-resumable after daemon restart

  • Symptom: An Agent Extension conversation works until tuttid restarts. Its history remains visible, but AgentGUI says it cannot resume on this device and only offers continuing through an @ mention. In an affected live extension session, the tutti shim can be present on PATH while many public commands, including tutti agent list, return command_not_found. After restart, sending to the same session can fail with session runtime snapshot is unavailable: launch identity is incomplete.
  • Quick checks: Confirm the persisted session still has provider_session_id and agent_target_id. If the Target remains enabled and names a fixed extension installation, compare list-time resumable calculation with the actual Resume path. An empty process-local adapter registry after restart is not evidence that the session cannot be restored. For the CLI variant, first distinguish discovery from routing: look for tutti cli shim ready in Desktop logs and path_contains_tutti_bin=true in the provider process diagnostics. If both are present but the provider tool reports unknown command, compare workspace_agent_sessions.provider with internal_runtime_context_json.$.sessionRuntimeSnapshot.provider. An extension provider such as acp:<name> beside an empty snapshot provider, followed by launch identity is incomplete, identifies the durable snapshot path rather than a PATH or listener failure. A second historical shape keeps acp:<name> in both locations but has a provider-native fingerprint computed from an empty provider; it fails later with provider-native fingerprint does not match.
  • Root cause: Dynamic Agent Extension adapters are created on demand and cached only for the daemon lifetime. Computing resumable from that cache maps restart state to a false domain result before Resume can re-resolve the persisted Target. The same false result occurs when an adapter rebuilds the runtime resume input but drops agentTargetId: the fixed Target ref then fails the controller's complete binding check even though persistence and Target resolution are correct. A separate snapshot variant occurs when provider-neutral metadata uses the closed built-in-provider normalizer for an open extension identity. The writer then persists an empty provider and fingerprints the provider-native configuration with that empty value. Session-scoped CLI capability projection validates the snapshot before returning the command catalog; validation failure collapses discovery to an empty catalog, so otherwise valid commands appear unknown. Runtime resume rejects the same incomplete identity after daemon restart.
  • Fix: At the service boundary, re-derive ProviderTargetRef from the persisted session's enabled agentTargetId. At the runtime boundary, validate the provider, Target, and fixed installation binding; when a dynamic resolver is configured, treat that authorized binding as eligible for a Resume attempt. Keep installation validation and ACP session/load in the actual Resume path. Never use an open provider id or adapter-cache presence as launch authority. Every bridge from the Host resume input to the runtime resume input must preserve agentTargetId and ProviderTargetRef together. Cover the complete service-to-adapter-to-controller path instead of testing each endpoint with independently constructed valid inputs. Persist and compare snapshot provider metadata with the open provider normalizer; launch authority still comes exclusively from the exact enabled Agent Target. For already-written empty-provider extension snapshots, recover only when the canonical session provider is a valid unregistered open identity, the snapshot declares provider-native configuration, and its fingerprint exactly matches the historical empty-provider payload. Apply the same narrow compatibility check to the transitional shape that already retained the open provider in the snapshot: the canonical and snapshot providers must match exactly before accepting the historical fingerprint. Keep every other malformed or mismatched snapshot fail-closed, and do not rewrite the database merely to make discovery succeed.
  • Validation: Start from a controller with no cached extension adapter. Assert a persisted Target-bound session is resumable, malformed or mismatched bindings fail closed, and the eligibility check does not launch the provider. Then run go test ./packages/agent/daemon/runtime ./services/tuttid/service/agent. Also cover new extension snapshots preserving acp:*, verified legacy empty-provider recovery, transitional open-provider/legacy-fingerprint recovery, registered or mismatched provider fallback rejection, CLI command projection for the recovered session, and runtime preparation after restart.
  • References: controller_session_registry.go agent_runtime_adapter.go service_session.go session_runtime_snapshot.go agent-extensions.md

An authorized observer loops unavailable while a session is resuming

  • Symptom: A remote or local observer has a durable Agent session ID and passes authorization, but immediately receives session not found or Unavailable. Its caller reconnects rapidly even though Agent Host restores the provider session shortly afterward.
  • Quick checks: Separate durable identity from the Controller registry. Confirm the session exists in canonical persistence while Controller.Session is temporarily absent, then correlate the observer attempt with the later Host EnsureRuntimeSession, runtime Start, or runtime Resume. If observation itself launches the provider, lifecycle authority is inverted.
  • Root cause: A durable session record proves that work may be resumed; it does not prove a live provider or runtime registry entry exists after daemon restart. A one-shot Controller.Subscribe therefore turns a normal restore race into a transport failure, and caller reconnect loops cannot know when the local lifecycle owner has finished restoring ACP.
  • Fix: Use Controller.SubscribeWhenAvailable when the consumer already has an authorized durable session identity and must observe the next live runtime. It waits on a per-session Controller notification and atomically subscribes with the initial state snapshot once Start or Resume registers the session. Keep provider launch and resume in Agent Host; the observation method must never call those lifecycle operations.
  • Validation: Start the observation against a missing runtime, assert that it neither returns nor creates a session, then perform a real Controller Resume and verify the observer receives the initial state snapshot. Also cancel a missing-session wait and verify no waiter or runtime session remains, and cover concurrent observers under the race detector.
  • References: controller_stream.go controller_stream_wait_test.go

Agent session restore breaks when durable snapshot ownership is split

  • Symptom: Workspace agent sessions still appear recoverable after a renderer refresh, but after a tuttid restart the session list is empty, the detail pane falls back to an unavailable state, or a newly reported session overwrites older history for the same workspace.
  • Quick checks: Confirm services/tuttid/data/workspace has a durable snapshot row for the workspace and that services/tuttid/wiring.go hydrates the in-memory agent activity store from it before new runtime reports are applied. If restore reads use a different source than write-time projection, verify both List/Get and message-history queries are reading the same durable snapshot shape. If the durable row has provider_session_id but ACP returns Resource not found, confirm the restore path re-runs the agent sidecar preparer and passes the prepared runtime environment, such as the per-session CODEX_HOME, into runtime resume.
  • Root cause: Agent runtime reports are projected into an in-memory activity store, but restore paths survive daemon restarts only if the projected snapshot is also written to daemon-owned local state and reloaded before the next activity report. If only the renderer cache or only the daemon process memory holds the projection, session metadata and message history diverge after restart.
  • Fix: Make tuttid own a durable agent snapshot in data/workspace, persist it from the activity-store update listener, and hydrate the in-memory activity store from that snapshot on first room tracking. Service-level session/message restore should read from the same durable snapshot source, and runtime mutations should on-demand resume a persisted session before accepting new input. Provider-session resume must use the same prepared sidecar runtime root and env as the original session, because provider ids are often scoped to provider-local state under that root.
  • Validation: Add store round-trip coverage for the snapshot row, service tests that fall back to persisted sessions and resume them into runtime, then run pnpm lint:go plus cd services/tuttid && go test ./... && go build ./....
  • References: service.go wiring.go

Agent activity live updates fail after event schema changes

  • Symptom: AgentGUI stays busy after a turn has finished, while durable workspace_agent_sessions state is already idle and daemon logs show publish workspace agent activity update failed with a decode ... data: json: unknown field error.
  • Quick checks: Compare the new field in packages/events/protocol/definitions/agent/activity.updated.event.json, generated event protocol outputs, and the hand-written strict validators in services/tuttid/service/eventstream/catalog.go.
  • Root cause: The shared business event schema and generated Go/TypeScript protocol files can be current while the daemon event-stream catalog still rejects the same payload through DisallowUnknownFields on a hand-written validation struct. The activity projection may persist the correct session state, but the live agent.activity.updated publish is rejected before the renderer runtime sees the settling patch.
  • Fix: Keep catalog.go validation DTOs in sync with new event fields, especially for agent.activity.updated top-level, session_update, and state_patch payloads. Add a positive validator test for the new field, not only generated protocol checks.
  • Validation: Run go test ./services/tuttid/service/eventstream and pnpm check:event-protocol-generated when event protocol sources changed.
  • References: catalog.go activity.updated.event.json

AgentGUI file-change undo reports a generic failure

  • Symptom: Clicking Undo on a changed-files summary shows a failure even though the target directory is a Git repository and the file appears unchanged since the agent edit. The same loss can appear earlier as a missing changed-files card, missing or incorrect line counts, or a "missing reversible patch data" error after a provider overwrites a file.
  • Quick checks: Search desktop and daemon logs for the agent-git-patch diagnostic family. Inspect errorCode, Git stderr, the diff byte count and hash, and the affected paths. For invalid-patch, inspect the durable tool output for malformed unified-diff control markers. A no-newline marker must begin with \, not a leading context-space followed by \. For a synthesized whole-file edit, verify that an empty old or new side has a hunk count of 0; @@ -1,1 +1,N @@ with no old or context line is corrupt. If only one side of a modified file was persisted, Undo must be unavailable instead of guessing that the file was created. For patch-does-not-apply, compare the recorded after-state with the current file rather than assuming the original turn is still the latest writer. For missing patch data, check whether Standard ACP logged an unsupported fs/write_text_file request, whether Codex emitted item/fileChange/patchUpdated, and whether a modified canonical file entry contains only one of oldString and newString.
  • Root cause: Provider display diffs can contain syntax that a viewer tolerates but git apply rejects. Treating that display payload as executable patch data produces corrupt hunks. A separate failure occurs when the patch is valid but later edits changed its context. On Windows, leaving an absolute drive path in either a synthesized patch or an existing unified-diff header also violates Git's cwd-relative patch contract and can report that an untracked created file does not exist in the index. Historical raw tool payloads can also contradict the canonical fileChanges classification. Giving that raw payload priority can turn a created file into a one-sided modified edit; forcing an empty side to count as one then produces a syntactically corrupt patch. created file does not exist in the index. Patch data is also lost when a Standard ACP host advertises file writes as unsupported and therefore cannot snapshot the old content, or when an app-server adapter ignores the authoritative incremental file-change patch notification. Synthesizing the missing side of a modified file as empty then produces a plausible-looking but non-reversible patch.
  • Fix: Canonicalize provider file-change metadata at the runtime adapter boundary before persistence, and canonicalize historical no-newline markers on read. AgentGUI must make synthesized paths and existing unified-diff headers relative to the patch cwd, using case-insensitive path identity for Windows. Executable batches prefer canonical per-call fileChanges; incomplete raw modified changes fail closed. Undo/Reapply is atomic for the settled Turn: every canonical visible path and every source-batch change must have an executable patch, otherwise the whole action stays unavailable. When both modified-file sides are known, hunk counts use the actual line counts, including zero, and text synthesis rejects NUL-containing binary content. Standard ACP hosts that advertise writeTextFile must implement the matching fs/write_text_file request, capture the exact old and new content, and emit canonical turn file changes. App-server adapters must preserve authoritative incremental patch notifications in the same canonical turn state. AgentGUI must fail closed instead of synthesizing a modified-file patch when either side is absent. The daemon must preflight with git apply --check using the same execution options, return invalid-patch for syntax failures and patch-does-not-apply for state mismatch, and avoid mutating the worktree on either result.
  • Validation: Cover leading-whitespace no-newline markers, historical activity projection, corrupt-patch preflight without mutation, worktree divergence, reverse application, cwd-relative Windows drive paths for synthesized and complete diffs, zero-sided whole-file edits with LF and CRLF input, binary fail-closed behavior, canonical-versus-raw conflicts, and the existing diffs, Standard ACP overwrite capture and relative-path rejection, Codex incremental patch projection, one-sided modified metadata, and the existing untracked-created-file behavior.
  • References: claude_sdk_activity.go standard_acp_filesystem.go codex_appserver_reducer.go agentTurnSummaryPatchDiff.ts agentPatchMetadata.ts agentTurnSummaryProjection.ts agentTurnSummaryPatchDiff.ts git_patch.go

AgentGUI changed-files summary shows negative lines for a new file

  • Symptom: The file-edit row reports additions, but the settled changed-files summary reports deletions, often matching the number of Markdown list items that begin with -. The worktree file itself is present and complete.
  • Quick checks: Compare the final file's line count with the tool row, then inspect the canonical turn.fileChanges.files[] entry. A real unified diff has a hunk header such as @@ -0,0 +1,13 @@; a file body stored in unifiedDiff is malformed metadata, not a diff.
  • Root cause: The summary projection used any non-empty unifiedDiff as a valid patch and counted every line beginning with - as a removal. Its content fallback also discarded blank lines, so the summary and tool row could use different line totals for the same write.
  • Fix: Normalize fileChanges at the runtime and durable-payload boundaries: a created file's raw body becomes newString, while unifiedDiff is retained only when it has a real hunk. AgentGUI also requires a hunk before parsing historical payloads and shares the line-count helper with tool render data, so blank lines are counted consistently.
  • Validation: Cover a created Markdown body containing list items and blank lines at both canonicalization boundaries, then run the Agent runtime and AgentGUI tests and typechecks.
  • References: tool_file_changes.go tool_payload.go agentUnifiedDiff.ts AgentTurnSummaryRow.tsx

Cursor deleted files appear as created or modified

  • Symptom: After Cursor deletes a file, the settled turn's changed-files summary or tool detail labels it as created or modified instead of deleted.
  • Quick checks: Inspect the original completed ACP tool payload, its following turn.updated event, and the durable turn row. Cursor reports deletion with ACP kind = delete, but its canonical tool name is still Write. The turn.updated.metadata.fileChanges.files[] entry and durable file_changes_json must both say change = deleted.
  • Root cause: The canonical tool name describes the file-writing tool family, not the change direction. Inferring every Write as creation discards Cursor's explicit ACP delete semantic. A second failure mode is normalizing the tool call correctly but omitting canonical fileChanges from the subsequent turn state patch, leaving AgentGUI without its authoritative response-tail input.
  • Fix: Normalize recognized provider change kinds inside the shared runtime file-change projector, accumulate the result per turn, and persist it through turn.updated. The session-detail response must return all durable root turns, and the desktop reconcile bridge must insert them into the existing activity engine turn store. AgentGUI reads only the matching canonical turn's fileChanges; do not add Cursor/ACP/Codex/Claude field inference to conversation projection. Keep tool changes payloads independently for Undo/Reapply patch batches. Do not backfill sessions whose historical turns have no canonical file changes.
  • Validation: Cover the Cursor completed-call to turn.updated path, durable turn state, and canonical AgentGUI projection. Also cover Codex changes[].kind.type and Claude Code started/completed tool merging because all three providers share the same turn-level contract.
  • References: tool_file_changes.go acp_turn_normalizer.go reporter_message.go reporter_state.go agentTurnSummaryProjection.ts

Remote agent cancel does not stop the local turn

  • Symptom: A cancel request returns successfully and the provider adapter logs a remote cancel notification, but the session remains running and continues to emit model output.
  • Quick checks: Inspect the runtime controller path for the active turn's local context.CancelFunc. A provider-level cancel or ACP notification is not enough if the local Exec goroutine is still waiting on the original turn context.
  • Root cause: Some providers treat cancel as a notification and may return no immediate terminal events. If the controller does not also cancel the local active turn context, runExecTurn cannot converge through its context-canceled path.
  • Fix: Once an active turn is found, cancel its local context as part of the controller cancel flow, then call the provider adapter cancel hook so both local and remote paths are interrupted.
  • Validation: Add a controller test with an adapter that returns no cancel events and only exits when its Exec context is canceled. A direct API smoke should return HTTP 200 and final session status canceled.
  • References: controller.go controller_test.go

Claude Code completes but the Turn has no Fork entry

  • Symptom: A Claude Code Turn is settled and has assistant output, but AgentGUI does not offer Fork. The session capability reports forkThroughTurn=false, or its supported provider Turn list is empty even though the Claude transcript is readable. On a newly created Session, the same missing binding can make the completed reply roll back to the new-conversation screen with provider turn was not durably accepted.
  • Quick checks: Compare the canonical Turn's root_provider_turn_id with the UUID of the matching root user message returned by the official Claude SDK getSessionMessages API. If they differ, inspect the live sidecar event sequence for provider_turn_identity_resolved and the canonical activity sequence for root_provider_turn.started. Do not infer identity from transcript position or substitute the canonical Tutti Turn ID.
  • Root cause: The outbound user-message UUID is only a prompt correlation value. Claude Code may rewrite that UUID before persisting the transcript. Publishing the caller-generated value as canonical provider identity makes strict prefix verification correctly reject the Turn, which removes the Fork capability. The SDK can also omit the persisted root user echo from a successful live query. Activating on assistant output while waiting exclusively for that echo can deadlock when approval or user input occurs before result: the Provider waits for the user, the sidecar waits for result-time recovery, and the Host waits for durable acceptance.
  • Fix: Mark the next root prompt echo as causally expected before submitting it. Bind provider Turn identity from that observed root user-message UUID. Route every root assistant, stream, tool, approval, user-input, and result path through the shared single-flight identity barrier. Without an echo, resolve exactly one root user UUID and checkpoint through the official getSessionMessages transcript read with bounded cancellable retries. Emit provider_turn_identity_resolved, synchronously persist the acceptance binding in the Host, then publish canonical root_provider_turn.started before any interaction or streaming event. Never substitute the outbound correlation UUID. Keep historical Turns without observed provider identity fail-closed rather than guessing or backfilling them.
  • Validation: Cover a query whose root user echo rewrites the outbound UUID. Assert provider_turn_identity_resolved, checkpoint, and terminal events all carry the persisted UUID. Cover successful assistant/result, approval, AskUserQuestion, and ExitPlanMode sequences with no root user echo. Assert canonical root_provider_turn.started is durable and ordered before every interaction, then verify Fork accepts the same identity. Restart the daemon/sidecar with an accepted incomplete Turn and confirm it is recovered without re-dispatch. Historical affected Turns remain intentionally non-forkable.
  • References: sessionRuntime.ts turnLifecycle.ts claude_sdk_execution.go claude_sdk_events.go

A completed Turn loses its Fork entry after a rail refresh

  • Symptom: A selected Session detail initially offers Fork on its latest completed Turn, but switching provider scope, refreshing the conversation rail, or receiving a section page removes the action. The canonical Turn still has root_provider_turn_id and provider_turn_binding_json; a full Session detail reports providerForkBindingState=bound, while the list projection reports recovery_required for the same Turn and timestamp.
  • Quick checks: Compare the list and full-detail payloads for the exact Session and Turn. Then inspect the activity-engine Turn before and after session/snapshotReceived. An image attachment is unrelated unless the provider binding itself is absent.
  • Root cause: Session list and section responses batch-project the latest Turn. Treating a non-persisted provider-binding availability flag as canonical without resolving it returns a fail-closed value. The rail then merges that lightweight latest-Turn entity into the same canonical Turn as the authoritative detail. Equal timestamps and outcomes permit the whole Turn replacement, so the fail-closed list value overwrites bound.
  • Fix: Resolve settled latest/active Turn forkability in the shared batch response projector and cache duplicate probes within the request. Keep list Session capabilities lightweight, but mark full capability projections explicitly. In Activity Core, a lightweight Session snapshot may upgrade a provider Turn binding but must not downgrade an already bound Turn. Keep message and TuttiMode revisions monotonic for the same reason; Goal synchronization needs its own update version because one Goal revision has multiple sync states.
  • Validation: Cover the list entry point and duplicate active/latest projections through the shared batch projector used by list and section reads, asserting one cached provider probe per exact Turn and fail-closed provider errors. Apply a lightweight recovery_required snapshot after an authoritative bound snapshot and assert the canonical Turn remains bound. Also verify an actual full projection can still supply authoritative lifecycle state.
  • References: service_turns.go sessionEntities.reducer.ts

Claude Code Fork fails after the action is clicked

  • Symptom: Fork is available for a completed Claude Code Turn, but clicking it produces a failed operation. The durable operation becomes unknown, no target canonical Session is committed, and the target provider Session is absent.
  • Quick checks: Inspect the latest workspace_agent_session_fork_operations row and query the target UUID with the official SDK getSessionInfo and getSessionMessages. If both are empty, check whether the sidecar used query({forkSession: true}) with an empty prompt and treated initializationResult() as provider mutation completion.
  • Root cause: Query initialization only completes the SDK/CLI handshake. Without a prompt, it does not durably create the requested child transcript. Unit tests can hide the defect if their fake marks the child created inside initializationResult(). A catch that replaces the original exception with a generic Fork error also erases whether failure occurred during source validation, provider mutation, or child verification.
  • Fix: Use the official forkSession(source, {upToMessageId, title}) transcript mutation. Accept its provider-generated child UUID, advertise the driver as non-deterministic, and let Host keep only the canonical target Session ID deterministic. Preserve the failure stage and original SDK reason. After provider mutation starts, any failure is unknown and must never be replayed.
  • Validation: Exercise the official SDK with an in-memory SessionStore, including a Turn whose inclusive checkpoint ends in a system message. Verify the provider child is independently readable, root user UUIDs are remapped, the canonical child resumes by the returned provider Session ID, and a second Fork from that child succeeds. Cover pre-mutation not_started, post-mutation unknown, and Host's no-replay behavior for non-deterministic drivers.
  • References: sessionFork.ts sessionFork.test.ts claude_sdk_fork.go session_fork.go

A provider-accepted Session Fork prevents Tutti from starting

  • Symptom: Tutti exits during cold startup with materialize accepted session fork and a provider-owned binding count that differs from the frozen canonical provider-bound Turn count. The source Session and its history still exist, while the operation remains provider_accepted.
  • Root cause: Claude transcripts represent tool results as top-level user messages even though they continue the current canonical Turn. Treating every top-level Claude user message as a root Turn creates extra provider bindings. More generally, after any provider accepts a Fork, recovery correctly refuses to dispatch it again. Canonical materialization also correctly refuses to invent missing provider Turn bindings, but the deterministic mismatch was returned as an ordinary recovery error. One unrecoverable operation therefore poisoned Host recovery and daemon construction on every restart.
  • Fix: Keep Claude tool_result messages inside the preceding root Turn checkpoint instead of creating independent bindings. As a separate final safety net, classify only deterministic provider-owned materialization evidence mismatches as permanent. Atomically fail that accepted operation, retain its provider acceptance evidence and the complete source history, remove any incomplete target, and release its reservation and boundary barrier. Do not re-dispatch the provider. Keep transient SQLite errors and failures to persist the quarantine startup-fatal. After quarantine persists, emit a content-free agent_session.fork.materialization_quarantined warning to tuttid.log with operation identities, driver kind, returned binding count, and the mismatch.
  • Validation: Feed the Claude Fork adapter a real transcript shape containing root prompts, assistant tool calls, and top-level tool-result messages. Require one binding per root prompt and require the tool result to extend the preceding Turn's checkpoint. Separately, seed a real temporary SQLite database with 29 provider-bound canonical Turns, three returned bindings, and a provider_accepted operation. Recover through Host.Recover, verify the source still has 29 Turns, the target is absent, provider evidence remains, isolation rows are gone, no provider Fork call was made, and a later valid operation commits. Inject quarantine persistence failure as the negative control. Start the real tuttidd binary from a poisoned state directory and require listener publication plus healthy /v1/health.
  • References: session_fork.go session_fork_operations.go session_fork_conformance_test.go blackbox_test.go

Fork reports only agent_session_fork_conflict

  • Symptom: A through-Turn Fork returns HTTP 409 before any provider thread/fork request. Desktop diagnostics contain only reason=agent_session_fork_conflict or a developer-message length, so provenance, attachment, descendant-lane, and provider-Turn failures are indistinguishable.
  • Quick checks: Read error.params.forkBoundaryReason from the 409 response or the promoted Desktop diagnostic reason. For example, agent_session_fork_turn_sequence_unverified means the selected Turn has unverified sequence provenance, while agent_session_fork_prefix_sequence_unverified identifies an earlier Turn in the inclusive prefix. The developer message carries the observed phase, provenance, sequence, or identity condition without message content.
  • Root cause: CheckSessionForkThroughTurn collapsed every fail-closed boundary branch to supported=false; Host replaced it with one generic Turn-state error, and the service formatted the nested error with %v, which discarded the error chain before transport classification.
  • Fix: Preserve one stable boundary rejection reason from the Store through Host and Service. Keep the public 409 reason agent_session_fork_conflict for compatibility, add the exact stable code as error.params.forkBoundaryReason, and promote that code only for Desktop diagnostics. Do not log transcript payloads or attachment contents.
  • Validation: Cover unverified selected/prefix sequences, duplicate provider Turn IDs, descendant lanes, and session-local attachments at the Store. Verify Service wrapping preserves both the generic conflict and typed boundary error, the API includes the structured parameter, and Desktop promotes it to the diagnostic reason.
  • References: session_fork.go session_fork_types.go daemon_agent_session_fork.go desktopAgentActivityAdapter.ts

Claude Code cancel leaves Write/tool cards or thinking stuck in progress

  • Symptom: User stops a Claude Code turn while a tool such as Write is running, or while the assistant is still in a thinking disclosure. The turn settles as canceled/interrupted, but the transcript still shows the tool as in progress or thinking as forever-"thinking".
  • Quick checks: Compare durable tool-call / assistant_thinking message status with turn outcome. If the turn is interrupted/canceled and an open tool_call is still running, or thinking is still streaming/working, the Claude SDK turn lifecycle did not finish dangling normalizer-owned rows. Confirm Codex/ACP cancel of the same shape closes open tools and thinking via acpTurnNormalizer.FinishInterrupted.
  • Root cause: Claude Code SDK first projected tool events without owning the shared turn event lifecycle (acpTurnNormalizer), so cancel settled the turn without Finish* and open tools never received terminal call.failed. A follow-on gap kept thinking/assistant snapshots off that same normalizer: only tools were tracked, so Stop could fail open Write cards while leaving an in-flight thinking row at streamState=streaming.
  • Fix: Attach per-turn acpTurnNormalizer on the Claude SDK session. Track call.started/completed/failed against that normalizer, route thinking and assistant snapshots through the same normalizer, and call FinishInterrupted / FinishFailed / FinishCompleted as part of turn terminalization (Cancel, sidecar turn_*, reader failure). Drop late tool events after the turn is already settled. Also: controller Cancel cancels the Exec context before adapter.Cancel. Claude Exec unregisters its waiter on that context cancel, so Cancel must finish open tools/streams from the turn-normalizer map (not only live waiters). The controller Exec context-canceled path must retain those adapter-produced close events via retainTurnCallLifecycleEvents — not only call.failed, but also failed/completed assistant/thinking message snapshots — instead of replacing the whole event slice with a bare turn.canceled. Otherwise FinishInterrupted runs in Exec, then the controller drops the thinking settlement and the durable row stays streaming.
  • Validation: go test ./packages/agent/daemon/runtime -run 'TestClaudeCodeSDKAdapter(CancelFailsOpenToolCalls|CancelFailsOpenToolsAfterWaiterUnregistered|TurnCanceledFailsOpenToolCalls|CancelFailsOpenThinking|MapsThinkingEvents)|TestRetainTurnCallLifecycleEvents'. Manually: rebuild/restart desktop so tuttid includes the fix, then start Claude Code, stop during thinking and during a long Write; confirm thinking leaves the active state and the tool card leaves "in progress". In ~/.tutti-dev/tuttid.db, the reasoning message status should leave streaming after cancel.
  • References: claude_sdk_turn.go claude_sdk_events.go claude_sdk_execution.go controller_turn_exec.go controller_turn_state.go acp_turn_normalizer.go acp_turn_normalizer_snapshots.go

Claude Code starts another command after Stop

  • Symptom: User stops a Claude Code turn after a Bash command becomes a background task. The turn settles as canceled, but background completion is followed by a new assistant continuation, approval, or Bash command such as ls. The new provider id may be synthetic even though no sub-agent exists.
  • Quick checks: Correlate provider session id, canonical root turn id, and sidecar events. If a background task_notification arrives after turn_canceled, followed by synthetic turn_started or approval_requested, inspect SDK Query lifetime. Confirm the exported session has no child-session relation before classifying the continuation as a sub-agent.
  • Root cause: Claude Agent SDK interrupt() stops current query execution but does not terminate the Query or all background resources. Reusing that Query lets a late background completion trigger another root inference. Filtering the synthetic event in AgentGUI or daemon persistence is too late: provider code may already have requested or executed the tool, especially under bypassPermissions.
  • Fix: Treat each SDK Query as an execution generation. Cancel revokes the generation before calling the SDK, rejects its pending interactions, awaits the interrupt acknowledgment, then closes it in cleanup. Fence messages, hooks, and canUseTool by generation identity. Give the next real user prompt a fresh prompt queue and Query using resume: providerSessionId. Consume a canceled generation's replayed terminal task notification and paired result before they can settle the new canonical Turn. Keep normal non-canceled child completion continuations enabled. As defense in depth, reject a new pending Interaction whose canonical owning Turn is already settled.
  • Validation: Run pnpm --filter @tutti-os/claude-sdk-sidecar test and go test ./packages/agent/store-sqlite -run 'TestUpsertInteractionRejectsNewPendingRequestOnSettledTurn'. Cover default and bypassPermissions, assert no synthetic turn, approval, or tool permission after cancel, then assert a new real prompt resumes the same provider session.
  • References: sessionRuntime.ts queryGeneration.ts activity_turns.go

Claude Code follow-up, settings, or cancel-resend becomes silent

  • Symptom: A completed Claude Code conversation accepts a follow-up but emits no SDK frames. Changing model, reasoning, or speed can time out and make later sends appear disabled. Canceling immediately after submit can leave the next send rejected as provider_session_not_established even though the provider Session exists.
  • Quick checks: Compare the sidecar request with the SDK Query generation. A follow-up on the same quiet post-result generation, or apply_settings waiting inside that generation, identifies the idle-query path. For cancel-resend, compare the canonical Turn settlement with its durable provider-Turn binding; settlement before the binding explains HasSettledTurn && !Established.
  • Root cause: A naturally completed SDK iterator was treated as a terminal Session, while follow-up and live settings reused or mutated the now-quiet Query. Separately, cancel could settle the canonical Turn before provider acceptance crossed the durable identity barrier. The frontend could then race another send against an unsettled settings write.
  • Fix: Keep Session lifetime separate from Query-generation lifetime. Retire an idle post-turn Query and resume a fresh generation for the next prompt; serialize settings flag application with turn dispatch and retire the idle generation before a live settings mutation. The workspace Engine and composer gate both block send while the settings operation is unsettled. For cancel, carry the exact Turn ID to the sidecar and dispatch the native cancel before consulting acceptance state. The sidecar returns pre_accept, provider_active, absent, or mismatch: locally remove only an undispatched queue item; after dispatch, publish the canceled terminal only after the bounded Query shutdown protocol either receives the SDK interrupt acknowledgment or closes the owned transport and drains its consumer. Wait for the exact Turn's durable provider-acceptance outcome only for provider_active. Treat only absent as authoritative not-found; mismatch, unknown disposition, drain failure, and acceptance failure stay fail-closed.
  • Validation: Cover follow-up resume, settings timeout/retry gating, exact targeted cancel, same-tick Goal cancellation, cancel before dispatch, interrupt acknowledgment, missing acknowledgment, interrupt failure, consumer-drain timeout, durable acceptance success and failure, mismatched/absent targets, and cancel followed by a new send. Native guidance must interrupt active tool work before enqueueing the steering prompt.
  • References: sessionRuntime.ts claude_sdk_execution.go controller_exec.go promptQueue.reducer.ts agentGuiComposerGate.ts

AgentGUI freezes when session history is large

  • Symptom: The workspace renderer freezes, tears visually in screen recordings, or feels stuck while opening AgentGUI or submitting an agent prompt in a workspace with a long agent history.
  • Quick checks: Inspect developer logs for agent.gui.runtime.snapshot_changed diagnostics. If sessionCount is in the hundreds or thousands, check whether the desktop adapter is calling listWorkspaceAgentSessions without a limit.
  • Root cause: Unbounded session-list loads push every historical agent session into AgentGUIRuntime, and each live event can make AgentGuiNode rebuild conversation projections for history the visible rail does not need.
  • Fix: Keep broad runtime session-list requests bounded at the desktop adapter or daemon API boundary. Use targeted message/session fetches for the selected detail rather than widening the runtime snapshot.
  • Validation: Reproduce with a large session table and confirm runtime diagnostics report a bounded sessionCount. Run the desktop adapter tests and pnpm check:changed for mixed AgentGUI/desktop changes.
  • References: desktopAgentActivityAdapter.ts createDesktopAgentActivityRuntime.ts

AgentGUI @ Sessions tab is empty

  • Symptom: Opening the composer @ palette (default Sessions tab) shows no session rows, even though the workspace has agent history.
  • Quick checks: In tuttid.log, look for event=workspace.agent_session.api.list_completed from GET /v1/workspaces/{workspaceID}/agent-sessions (the Sessions-tab source). Check session_count. Do not confuse it with workspace.agent_session.messages.api.list_* or workspace.agent_session.section.list_failed.
  • Root cause: The Sessions tab loads through listWorkspaceAgentSessions. Successful calls previously left no durable log, so empty palettes could not be distinguished from "API never ran" or "API returned zero sessions" in exported logs.
  • Fix: Successful list responses now emit workspace.agent_session.api.list_completed with session_count. If the event is missing, the client never hit the endpoint; if session_count=0, the daemon truly returned an empty list.
  • Validation: Click the composer @ button, confirm a workspace.agent_session.api.list_completed line appears with a non-zero session_count when sessions exist.
  • References: daemon_agent_session_list.go desktopRichTextAtAgentContributors.ts agent-gui-node.md

AgentGUI @ browse fails after reopening

  • Symptom: The AgentGUI @ palette opens once, then shows a search failure after the palette is closed and reopened, even though the workspace request itself is healthy.
  • Quick checks: Inspect the AgentGUI lifecycle diagnostics for a browse.fetch.start entry followed by AbortError on the next open. The failing request usually has the same browse key as the request canceled when the first palette closed.
  • Root cause: A shared browse request was kept in its deduplication map until its promise's asynchronous finally cleanup ran. A new consumer could arrive in that window and attach to the already-aborted request, turning expected palette cancellation into a visible search failure.
  • Fix: Use AbortableSingleFlight for shared abortable requests. Evict the entry synchronously when its final consumer leaves, and make late cleanup delete only the exact request instance. An operation owner may also abort a stuck request immediately without leaving a retryable stale entry.
  • Validation: Cover concurrent deduplication, one-consumer cancellation, immediate retry after final-consumer cancellation, operation-owner timeout cancellation, and late completion of the old request after a replacement starts.
  • References: abortableSingleFlight.ts AgentMentionSearchCache.ts

Agent diagnostics flood while a turn is streaming

  • Symptom: Exported developer logs are dominated by agent diagnostics and the app feels sluggish while a streaming turn is active or while switching AgentGUI sessions.
  • Quick checks: Count repeated agent submit trace, agent.activity.reconcile.trace, and agent.gui.node.render_state_changed lines before blaming one visible click. Runtime event emissions should appear as runtime.events_emitted.summary/runtime.async_events_emitted.summary; the old runtime.events_emitted/runtime.async_events_emitted names must not appear at the default level. Successful reconcile steps, message-page reads, ACP transport frames, and unchanged CuaDriver polls should appear only when debug logging is enabled. A streaming message-version change alone must not emit agent.gui.node.render_state_changed or agent.gui.runtime.snapshot_changed.
  • Root cause: Per-token runtime events and renderer inline reconcile commits can produce thousands of diagnostic writes. Those writes compete with rendering and also inflate trace/log exports enough to obscure the actual session-switch work. A later AgentGUI refactor can reintroduce this problem by replacing turn summaries with per-batch logs or by adding message cursors to diagnostic change keys.
  • Fix: Aggregate runtime emissions once per turn. Keep successful reconcile, message-page, and ACP frame diagnostics at debug. Build renderer snapshot and render-state keys from semantic lifecycle/interaction state rather than streaming cursors. Log unchanged permission-poll results at debug, and keep desktop log writes ordered through the asynchronous file writer.
  • Validation: Reproduce a streaming turn at the default info level. Confirm each turn has at most one runtime emission summary, semantic render/snapshot diagnostics do not advance for token-only updates, and reconcile/ACP frame diagnostics are absent. Repeat with debug enabled when per-frame evidence is needed. Submit, reconcile, and protocol failures must remain visible.
  • References: controller_turn_exec.go workspaceAgentActivityReconcileBridge.ts desktopAgentRuntimeStateDiagnostics.ts useAgentGUISessionPresentation.ts

Claude export leaks hidden data, flattens branches, or resumes as Claude Code

  • Symptom: Imported claude.ai history shows internal thinking/tool payload text, mixes mutually exclusive edited/retried messages into one timeline, or the composer tries to resume an imported web conversation as if its UUID were a local Claude Code session id.
  • Quick checks: Inspect the source message shape without logging its content. Visible text must come from ordered content[type=text] blocks; legacy human messages with no content blocks may fall back to top-level message.text, but assistant messages never may (that field mixes in hidden thinking and tool material). Inspect the persisted runtime context for externalImportResumeSupported: false, and confirm the source path is not forwarded as an external Claude Code rollout path. For a branched fixture, confirm every imported message belongs to the selected latest leaf's ancestor path and carries the same sourceBranchLeafId.
  • Root cause: Claude data exports use top-level message.text as a convenience aggregate that can include hidden thinking and tool material. Their conversation UUIDs belong to claude.ai, not the local Claude Code runtime, and referenced files in conversations.json do not imply that file payloads exist in the ZIP. chat_messages is a parent graph rather than a guaranteed linear list, so timestamp-sorting every node can combine incompatible sibling branches.
  • Fix: Parse only the exact root conversations.json entry without extracting the archive. Project visible text from text blocks, keep file-only messages as unavailable references, select one deterministic latest root-to-leaf branch, seed persisted message ids from source UUIDs, and include the selected sibling choices in imported session identity so a future retry-branch change cannot append into the old branch. Place sessions in the no-project Chats section and mark them non-resumable while preserving the normal continue-in-new-chat recovery action.
  • Validation: Run cd services/tuttid && go test ./service/agent ./api -run 'ExternalImport|ClaudeExport|ExternalRollout', then run pnpm --filter @tutti-os/desktop test and pnpm check:i18n.
  • References: external_import_claude_export.go ExternalAgentSessionImportWizard.tsx service_session.go

Imported long Turns show one disclosure per tool call

  • Symptom: A long imported Codex or Claude Code execution shows many adjacent 1 tool call disclosures instead of one grouped Turn, especially after opening or paging into the middle of the conversation.
  • Quick checks: Inspect the affected workspace_agent_messages rows without printing payload text. If the user/tool/assistant rows all have turn_id = NULL, then compare the loaded page with the source transcript. A page that starts after the initiating user message cannot recover the Turn boundary locally.
  • Root cause: The external importer historically used the turnless compatibility path for every message. AgentGUI can make a temporary presentation group when the leading user row is loaded, but that grouping cannot survive a page boundary; assigning each orphan row from its current page position would manufacture lifecycle identity in the renderer.
  • Fix: At the provider transcript boundary, start a stable historical Turn from each retained real user message and carry it through following assistant and tool messages. Persist the messages with settled backfilled Turns atomically. The forward-only store migration repairs existing imported rows, while a later re-import applies the same identity to any still-turnless rows. Content before the first trustworthy user boundary remains turnless.
  • Validation: Run go test ./packages/agent/store-sqlite -run HistoricalImport, then cd services/tuttid && go test ./service/agent -run 'ServiceImportsExternalAgentSessionsByProject|ServiceReimportRepairsLegacyTurnlessExternalMessages'.
  • References: external_import.go activity_historical_import_turns.go agent-gui-node.md

Imported sessions trigger fresh-completion indicators

  • Symptom: After importing Codex or Claude Code history, Agent GUI conversation rows show unread-completion lamps, or Message Center's priority view briefly shows many items under the recently-completed group, even though those sessions are historical imports rather than newly finished local runs.
  • Quick checks: Inspect the session runtimeContext. Imported sessions should carry imported: true. Conversation summaries and Message Center items derived from them should preserve that marker before unread-completion or priority grouping is derived.
  • Root cause: Agent GUI unread-completion lamps and Message Center's recently-completed group are notification-style surfaces. Imported history is persisted as completed agent activity, so if projection models treat imported sessions the same as live runtime completions, a bulk import can look like a burst of fresh completed work.
  • Fix: Keep imported sessions visible in Agent GUI, Message Center, and completed filters, but exclude runtimeContext.imported items from unread-completion lamps and recently-completed groups.
  • Validation: For Agent GUI rail projection changes, run pnpm --dir packages/agent/gui exec vitest run --environment jsdom contexts/workspace/presentation/renderer/agentGuiConversationList/useAgentGuiConversationList.spec.tsx agent-gui/agentGuiNode/model/agentGuiConversationModel.spec.ts. For Message Center grouping changes, run pnpm --dir packages/agent/gui exec vitest run --environment jsdom agent-message-center/workspaceAgentMessageCenterModel.spec.ts agent-message-center/workspaceAgentMessageCenterViewModel.spec.ts.
  • References: useAgentGuiConversationList.ts workspaceAgentMessageCenterModel.ts workspaceAgentMessageCenterViewModel.ts

Realtime agent completion does not show unread attention

  • Symptom: A turn settles while Agent GUI is open, but its conversation row never shows unread-completion attention. Historical sessions may behave correctly and must not acquire attention merely because their snapshot was loaded.
  • Quick checks: Trace the event path into the activity engine. A realtime turn_update should enter as one atomic Turn projection. When the Session is cached, the same Engine transition must update the Turn and update or clear Session.activeTurnId without rewriting Session timestamps from the event envelope. Attention must observe the post-lifecycle canonical Turn, not the raw event. A delayed settled event may clear only the matching active Turn. The event should also request a state-only session reconcile with realtime provenance. Initial, restored, and imported history should enter through session/snapshotReceived only. Also confirm the projected desktop session carries the shared local Agent GUI user id used by read-state actions.
  • Root cause: Realtime and historical data lost their provenance when both were folded into a mutable controller snapshot and re-emitted as session/snapshotReceived. The attention reducer correctly treats snapshots as non-live, so it recorded the settled completion without producing unread attention; a later live update with the same completion key could no longer recover the transition.
  • Fix: Keep the activity engine as the single mutable owner. Feed pull/bootstrap results through session/snapshotReceived, and feed each realtime turn_update through one intent that atomically projects the Turn and cached Session reference. Reject a projection whose live/settled phase disagrees with activeTurnId; reconcile instead of applying half of the wire fact. Fence stale Session snapshots with canonical Turn versions rather than event-envelope time, and let attention consume only a Turn that the lifecycle reducer accepted. Keep the realtime marker on the follow-up reconcile so an uncached Session can hydrate and replay the latest Turn with live attention semantics. Use inline message events only for message deltas and one shared local identity for session projection and read-state commands.
  • Validation: Run pnpm --filter @tutti-os/desktop test -- workspaceAgentActivityService.test.ts and verify the service integration coverage proves that realtime completion becomes unread while a settled historical load creates no attention record.
  • References: workspaceAgentActivityReconcileBridge.ts workspaceAgentActivityService.test.ts attentionReadState.reducer.ts

Unfocused AgentGUI Session completion becomes read

  • Symptom: A running Session settles after the user focuses another AgentGUI node or window, but the original Session immediately loses its unread-completion lamp.
  • Quick checks: Inspect agent.gui.attention_read.decision. The diagnostic identifies the node, completion key, host focus, document exposure, visibility, and whether the completion was read or preserved. A retained node with isSurfaceActive=false, isSurfaceVisible=false, or isSurfaceDocumentExposed=false must report decision=preserve_unread; its local activeConversationId is not evidence that the user is reading it.
  • Root cause: AgentGUI selection is local to each mounted controller, while attention/read state is shared by the workspace engine. Treating every controller's active Session as visually selected allowed a hidden or unfocused retained node to dispatch the shared attention/read intent. Separately, historical Turns without a durable marker were assigned isUnread=false and persisted into readIds; a stale list/detail response could therefore turn an unseen live completion into durable read state.
  • Fix: Use unread-only attention state. Only a live completion transition or an explicit unread request creates an unread record; attention/read removes that record, and historical snapshots never mutate attention. Hydration ignores legacy readIds and the next write clears those buckets. Pass host-projected focus, host visibility, and renderer document focus/visibility into the selection controller, and require all three before dispatching attention/read. Keep the existing manual unread provenance rule so a user-marked unread completion stays unread until the Session is selected again.
  • Validation: Keep two AgentGUI nodes mounted, run a Turn in the unfocused node's active Session, and verify its completion key remains in completed.unreadIds. Focusing and exposing that node should remove the exact key while leaving completed.readIds empty.
  • References: AgentGUINode.tsx useAgentGUIConversationSelectionController.ts

Completed agent session stays activating and disables the composer

  • Symptom: A new conversation visibly completes and its assistant reply is present, but opening it leaves the composer disabled. Roughly one activation-expiry window later, AgentGUI reports that the agent session could not be started.
  • Quick checks: Correlate activation diagnostics with the authoritative session updates. If the session create and turn both succeeded while the presentation remains activating until engine/intentExpired, inspect which session intent reached the pending-activation reducer. Also check that a failed realtime session fetch preserves live: true on the Engine's pending reconcile record before a retry.
  • Root cause: An engine migration introduced session/upserted for authoritative mutation and realtime results, while pending activation still confirmed only from the historical session/snapshotReceived path. The canonical session therefore existed and could render, but the independent activation intent expired and overrode the composer with a false failure. Consuming realtime provenance before a fallible fetch can produce a related retry-only mismatch.
  • Fix: Confirm activation from both authoritative session intents. Preserve the semantic distinction only where it matters: historical snapshots remain neutral for unread attention, while realtime reconciliation additionally emits the live turn update. Carry realtime provenance on the Engine-owned reconcile command, merge it into in-flight demand, and restore it to pending demand after fetch failure until a live session is applied or the session is deleted. Hosts must not keep a parallel marker set.
  • Validation: Cover the reducer with a pending activation followed by session/upserted. At the desktop service boundary, run a real engine activation through the create result, manually expire its old deadline, and verify the presentation remains active. Also fail the first realtime reconciliation, retry it, and verify the settled turn still gains unread attention.
  • References: pendingIntents.reducer.ts workspaceAgentActivityReconcileBridge.ts workspaceAgentActivityService.test.ts

Forked conversation disappears from the Rail after completion

  • Symptom: A newly forked conversation is visible and selected while its first Turn is running, but disappears from the Rail as soon as that Turn settles. Opening the exact Session still works, and a later full Rail reload may restore it.
  • Quick checks: Correlate session/forkThroughTurnRequested, the canonical child session/upserted, and the bounded listSessionSectionPage request. If the child becomes canonical while selected but no exact request is made for its inherited railSectionKey, inspect pending-creation membership rather than the fork persistence path. Also verify that the child was initially visible only through the active or running overlay.
  • Root cause: Fork selected the child immediately, while membership reconciliation skipped every newly observed active Session to avoid refreshing for historical detail hydration. The fork child therefore never entered its bounded authoritative section page. Its active/running overlay temporarily hid that omission and settlement exposed it by removing the overlay row.
  • Fix: Project in-flight fork targets and pending activations as pending-creation reconciliation evidence. Keep that semantic marker separate from render provenance. When the same identity becomes canonical, refresh its exact persisted Rail section even when selected; continue skipping ordinary selected historical hydration when no pending-creation evidence exists.
  • Validation: Drive a real AgentSessionEngine and AgentGUIConversationRailQueryController through a held fork command, select the target before canonical upsert, then verify the controller requests the inherited section and publishes the child in authoritative membership. Retain the existing selected historical-Session no-refresh coverage.
  • References: agentGuiConversationRailMembershipRecords.ts agentGuiConversationRailMembershipRefresh.ts AgentGUIConversationRailQueryController.spec.ts

Shared Agent composer stays disabled after the target connects

  • Symptom: A shared Agent target reaches connected, and diagnostics show submission readiness has recovered, but the entire composer remains disabled and cannot receive focus or input. This differs from an empty draft disabling only the send button.
  • Quick checks: Inspect one rendered Composer gate snapshot. Its runtime, editor, and submission branches must agree: a ready submission cannot coexist with a target-connection runtime block. If logs instead compare fields from separate Composer and readiness projections, inspect view-model memoization before debugging P2P transport.
  • Root cause: Connection and submission facts were projected into independent memoized slices. A missing dependency could retain the old target-connection block while publishing the new submission-ready value, and a downstream detail model then recombined those two render-time generations into a torn composerDisabled decision.
  • Fix: Derive the canonical Composer gate once at the Session-presentation boundary. Keep editor editability, submission readiness/queue/blocking, and runtime-command availability in that one object, then pass it through one view-model slice to the editor, send button, shortcuts, Stop control, and Interaction paths. Keep draft-empty and upload conditions submission-local.
  • Validation: Drive an exact shared target from connecting to connected and assert the same resulting snapshot reports runtime ready, editor editable, and submission ready. Also cover busy queue behavior, collaborator read-only behavior, and the invariant that submission ready never retains a runtime connection block.
  • References: agentGuiComposerGate.ts useAgentGUISessionPresentation.ts useAgentGUIViewModel.ts

AgentGUI submit clears the composer but creates no session or turn

  • Symptom: Sending from AgentGUI clears or switches the composer, but the conversation rail and transcript do not change. Renderer diagnostics stop at renderer_adapter.create.http_requested or renderer_adapter.send.http_requested, and the daemon has no matching clientSubmitId.
  • Quick checks: Correlate clientSubmitId across the desktop and daemon logs. If the engine records an immediate failed activation while the daemon has no create/send business log, compare the adapter's exact JSON body with the generated request type and the OpenAPI schema, including additionalProperties.
  • Root cause: A conditional object spread can add a stale property to an otherwise typed request without triggering excess-property checking. Strict OpenAPI request validation then rejects the body before the business handler, while eager composer clearing makes the failed request look successful for an instant.
  • Fix: Keep clientSubmitId as the top-level idempotency field and carry optional evidence through the typed submitDiagnostics contract from AgentGUI through the session engine to the desktop adapter. Assign the final body to the generated request type before sending. Clear a draft only after the engine queues, accepts, or confirms the exact submitted content; failed sends retain the draft.
  • Validation: Assert the adapter's complete create/send body with generated request types, verify the generated client serializes submitDiagnostics, and cover that Composer does not clear before its parent applies engine acknowledgment.
  • References: agent-gui-node.md desktopAgentActivityAdapter.ts useAgentGUISubmitInteractionActions.ts

AgentGUI send reports workspace_agent.send_response_turn_required

  • Symptom: Sending or continuing a conversation reaches the provider, but the renderer rejects the HTTP result with workspace_agent.send_response_turn_required. A realtime reconcile may make the conversation recover moments later, so the failure can appear release- or timing-dependent.
  • Quick checks: Correlate one clientSubmitId across runtime.submitted, api.send.completed, and the following turn_update. If the API completes with a submitted runtime Turn while its session projection is still settled, then the response raced the asynchronous activity report. Inspect the raw response: a Turn-producing branch missing either turnId or turn violates the protocol even if realtime state arrives afterward.
  • Root cause: RuntimeController.Exec queued the submitted Turn report asynchronously, started provider work, and returned. The service immediately refreshed the older durable Session, while the API conditionally copied session.activeTurn. The exact Turn therefore disappeared from an otherwise successful response.
  • Fix: Make the submitted Turn report a synchronous durable-acceptance barrier. Commit the session pointer and Turn atomically before publishing or starting provider work; roll runtime state back on report failure. Read the exact Turn by the runtime-returned turnId in the service, and model send output as an OpenAPI discriminated union whose Turn branch requires both fields. Keep Goal control Turn-less. Do not add renderer polling, sleeps, or synthetic Turns.
  • Validation: Block the activity reporter and prove both Exec and provider startup wait; fail it and prove no provider execution or active Turn survives. Cover stale Session plus exact-Turn service reads, required API Turn output, and the Turn-less Goal-control branch. Run API generation drift checks and the Agent session engine suite.
  • References: agent-gui-node.md controller_exec.go service_send_input.go daemon_agent_submit_handlers.go

AgentGUI new session times out and appears completed without a reply

  • Symptom: A new conversation shows the optimistic user prompt, then becomes idle or completed with no assistant reply. The session has no canonical turn or messages.
  • Quick checks: Correlate the create clientSubmitId. A characteristic sequence is desktop renderer_adapter.create.failed errorCode=ETIMEDOUT at 30 seconds, followed by daemon provider_runtime_status=failed, agent session is not connected, and a rejected turnless visible-error message.
  • Root cause: Runtime command-guide construction asked the CLI registry to run live capability filters. The agent-context filter probes provider availability, so static guide construction could consume the entire create-request budget. Cancellation then reached provider startup. The failed startup was also published as a durable session plus a message without a turn, creating a phantom session that the UI could only project as idle/completed.
  • Fix: Build runtime command guides from static capability registration with live capability filters skipped. Treat provider startup as transactional: return a typed runtime error when the provider fails to start; for create-with-prompt, keep the runtime Session provisional until the first Turn is accepted. A failed or rolled-back attempt must not publish or store a canonical Session, Turn, command/config snapshot, or turnless message.
  • Validation: Cover the non-blocking command-catalog context, typed failed-start mapping, provisional provider callback isolation and rollback, and controller behavior proving that startup failure returns diagnostics but creates no canonical session or activity report.
  • References: command_catalog.go controller_session_lifecycle.go agent_runtime_adapter.go

AgentGUI cannot stop Cursor immediately after the first message

  • Symptom: The first Cursor message enters an activating state, but Stop is absent or does nothing until a canonical session and Turn appear. Codex and Claude Code often appear unaffected because their startup completes before a user can hit the same window.
  • Quick checks: Correlate the activation request with process spawn, ACP initialize, session/new, and the first Turn. If Stop becomes available only after the Turn id is known, the UI and engine are treating cancellation as turn-only. Also inspect failed-create cleanup after request cancellation for provisional runtime state left under the session id.
  • Root cause: New-session activation is an abortable engine command before it becomes a canonical Turn. A turn-only Stop gate cannot target that command, and a cancel operation stored only under existing session entities is lost on an empty reconciliation snapshot. Cleanup that reuses the canceled request context can then fail to close the provisional runtime.
  • Fix: Dispatch one provider-neutral session Stop intent. Abort the exact activation command by command id, preserve a detached workspace/session-scoped awaitingTurn cancel through snapshots, and convert it to exact-turn cancel when the first Turn arrives. Expire the detached operation after a bounded window. Run failed-create close and cleanup with a bounded context detached from request cancellation.
  • Validation: Cover immediate activation abort, empty-snapshot preservation, late first-Turn cancel, expiry before an unrelated future Turn, and identical lifecycle behavior for Cursor, Codex, and Claude Code. Verify AgentGUI exposes Stop while new-conversation activation is still submitting.
  • References: createAgentSessionEngine.ts sessionLifecycle.reducer.ts pendingIntents.reducer.ts useAgentGUIDetailModel.tsx service.go

Cursor session/new is canceled before its 30-second timeout

  • Symptom: Sending the first message to Cursor leaves AgentGUI activating until Tutti reports a timeout. Cursor logs show session/new started, but it is canceled substantially before the ACP runtime's configured 30-second deadline.
  • Quick checks: Correlate service.create.entered, provider-status completion, runtime_start_requested, ACP initialize, and session/new. If a full provider-status probe runs inside Service.Create, or the renderer activation expires 30 seconds after the click rather than 30 seconds after session/new starts, the protocol call is receiving only a leftover budget.
  • Root cause: Provider readiness was treated as a per-session precondition even though it performs application-scoped binary, version, and auth detection. Cursor auth fallback commands can consume several seconds before ACP starts. An outer 30-second activation timer then propagates cancellation into the daemon and truncates the independent 30-second session/new timeout.
  • Fix: Cache provider readiness in tuttid per provider with completion-time TTL and single-flight refresh. Explicit setup refreshes bypass that cache; ordinary session creation does not run status, version, auth, network, or hidden model discovery. Let the actual process spawn and ACP handshake be authoritative. Give new-session activation a larger outer safety deadline while preserving the ACP runtime's own 30-second session/new deadline.
  • Validation: Cover cache reuse across all-provider and single-provider requests, forced refresh, concurrent single-flight reads, absence of availability checks from Service.Create, and a new-session activation timeout larger than 30 seconds.
  • References: status_cache.go service.go pendingIntents.reducer.ts acp_shared.go

Claude Code keeps returning ConnectionRefused after network recovery

  • Symptom: A Claude Code Turn loses network access while sending and eventually reports API Error: Unable to connect to API (ConnectionRefused). After the machine network recovers, later sends in the same Session keep failing, while a newly created Claude Session succeeds.
  • Quick checks: Correlate agent_session.claude_sdk.lifecycle_event by Agent Session and provider Session id. An SDK system/api_retry event now records sdk_connection_error, sdk_retry_attempt, sdk_max_retries, and sdk_retry_delay_ms. A terminal result should carry sdk_result_is_error=true; a numeric sdk_api_error_status instead points to an HTTP/auth/provider response rather than a connection failure.
  • Root cause: The Claude Agent SDK owns one long-lived Query and Claude Code subprocess for streaming multi-Turn input. A connection failure can terminate the Turn while leaving that Query unsuitable for later requests. Reusing it preserves the bad per-process state even though host and VM networking have recovered. Older sidecars also treated every result/subtype=success as a completed Turn, ignoring the SDK's independent is_error flag.
  • Fix: Preserve the failed Turn and its provider error. When the SDK terminal result explicitly reports a connection failure, revoke and close that Query. The next user send creates a fresh Query with resume set to the same provider Session id. Do not automatically resend the failed prompt because delivery may be ambiguous. Do not recycle the Query for a transient retry that eventually succeeds or for a terminal HTTP/authentication error. This SDK-owned api_retry behavior is separate from AgentGUI presentation. AgentGUI does not expose manual activation retry for a failed or canceled Session. If authentication rejects the first submit, Host preserves the failed history and discards that startup runtime; the auth notice offers login, after which the user starts a new conversation. Runtime discard keeps cleanup cancellation-independent but re-applies the caller's deadline. If the Claude sidecar does not acknowledge close in time, the adapter forcibly unregisters and closes the connection so a rejected startup cannot remain live in the runtime registry.
  • Validation: Simulate api_retry/error_status=null followed by result/is_error=true/api_error_status=null, then send a second Turn. Require two Query generations, closure of the first, and resume on the second. Also prove that retry-then-success and HTTP 401 failures retain the existing Query. For rejected startup cleanup, block the sidecar close response and require teardown at the caller deadline with no retained runtime Session.
  • References: messageRouter.ts sessionRuntime.ts sessionRuntime.recovery.test.ts

Cursor auto-continue invents interrupted work after a network drop

  • Symptom: After a Cursor RetriableError / TLS drop and Tutti's automatic transport_retry, the agent does not answer the user's last message (for example a simple greeting). Instead it talks about recovering prior context, reading transcripts, or continuing an interrupted task that never started.
  • Quick checks: In the session transcript, confirm the failed attempt produced only the Error: RetriableError: / Error: ConnectError: tail (no useful assistant text and no tool calls) before the retry notice. Check agent_session.acp.exec.auto_continue in tuttid logs for has_useful_progress=false.
  • Root cause: Cursor keeps conversation history on its backend; Tutti can mainly control the synthetic auto-continue session/prompt. Mid-task wording ("Continue exactly where you left off") misleads the model when the attempt died before any useful output.
  • Fix: Branch the auto-continue prompt by useful progress: zero-progress retries ask the model to answer the user's most recent message normally and not invent interrupted work; mid-task retries keep the continue wording. Progress is assistant text after stripping the retriable error tail, or any observed tool call.
  • Validation: cd packages/agent/daemon && go test ./runtime/ -run 'TestACPAutoContinueHasUsefulProgress|TestACPAutoContinuePromptContentBranches|TestCursorAdapterAutoContinuesAfterRetriableTurnError|TestCursorAdapterAutoContinueMidTaskUsesContinuePrompt'. Live: send a short Cursor message that fails before any reply, confirm the retry answers the user instead of recovering a phantom task, and that mid-task drops still resume in place.
  • References: acp_auto_continue.go standard_acp_turn.go acp_auto_continue_test.go

Canceling an old AgentGUI turn stops a newer turn

  • Symptom: A cancelTurn(turnId) request targets an older turn, but a newer turn in the same session stops, or the requested turn is reported canceled even though the runtime continued with a different active turn.
  • Quick checks: Compare requested_turn_id and active_turn_id in agent_session.cancel.turn_mismatch. Also check whether a new Exec entered between the exact-turn lookup and the provider cancel call.
  • Root cause: Session-level provider cancel APIs do not carry a turn id. Validating the id before calling the adapter is insufficient unless validation and cancel are protected by the same session lifecycle lock used to start turns.
  • Fix: Carry the requested turn id through HTTP service, runtime adapter, and controller. Return an idempotent no-op on mismatch, and hold the per-session lifecycle lock across the active-turn comparison and adapter.Cancel so a new turn cannot enter the gap.
  • Validation: Cover both a mismatched active turn (the adapter must not be called) and a blocking adapter cancel (a second lifecycle operation must remain blocked until cancel returns).
  • References: service_turns.go controller_cancel.go controller_test.go

Historical Agent completions notify again when a workspace opens

  • Symptom: Opening a workspace produces completion or failure notifications for turns that settled before the window was opened.
  • Quick checks: Compare notification-controller creation with the first agent activity load. If the engine was empty at subscription time and the first populated snapshot contains settled turns, verify those turns were treated as initial hydration rather than live transitions.
  • Root cause: Taking an empty engine snapshot as the history baseline is insufficient when durable loading happens asynchronously after subscribers are registered. The first hydrated settled turn then looks indistinguishable from a newly settled turn.
  • Fix: Keep outcome notifications behind an explicit hydration boundary. Record all settled turns observed before the initial durable load resolves as baseline history. After hydration, notify only the first observation of each session-scoped turn key; a live non-settled turn can also establish readiness when the initial load is unavailable.
  • Validation: Cover the real startup order: subscribe against an empty engine, hydrate a historical settled turn without notifying, finish hydration, then verify a later running-to-settled turn notifies exactly once.
  • References: workspaceAgentOutcomeNotification.ts workspaceAgentOutcomeNotification.test.ts

Agent GUI context usage is absent or has the wrong total

  • Symptom: The Agent GUI composer never shows context usage even though provider usage logs are present. Alternatively, Claude Code GUI usage shows a 200k context window for a model that should have 1M context, or a 200k model keeps showing the prior 1M total after a model switch. After one Claude Code request runs several tools, the used-token count may also jump from the latest iteration's value to nearly the sum of every iteration in that turn. A related lifecycle symptom is that Claude has rendered its final answer but Agent GUI remains in the working state until a delayed context-usage control request returns.
  • Quick checks: Trace the provider update first: use agent_session.claude_sdk.usage_update for Claude SDK and agent_session.acp.usage_update for ACP providers. Then inspect the daemon session response for its typed usage.contextWindow field and confirm the desktop canonical session preserves it. If provider logs contain nonzero used and total tokens but the API field is null, inspect the runtime-context split into typed session metadata. If the API field is populated but the footer is absent, inspect the desktop adapter and the active canonical session passed to the composer capability projection. For Claude, if the payload keys include modelUsage but raw_total_tokens is 0, the daemon did not parse the model-usage context window. If previous_context_model and current_context_model differ but current_total_tokens equals previous_total_tokens, daemon usage normalization reused a stale context window across models. For a tool-loop spike, compare the streamed normalized_used_tokens values with the final Result update. If the final value equals their sum, the cumulative Result usage was mistaken for current context occupancy. For a stuck working indicator, compare the Claude SDK result lifecycle timestamp with the later context_window usage update and turn_completed; a long result-to-completion gap isolates telemetry on the terminal path rather than unfinished provider work.
  • Root cause: Protocol v2 intentionally removed raw runtimeContext from the public session model. If the refactor removes that legacy field without adding a typed usage field across persistence, API generation, the desktop adapter, and the canonical session, the provider still records correct telemetry but Agent GUI has no public data to render. A wrong Claude total is a separate normalization failure: Claude SDK result messages expose model usage as a map keyed by model id, for example modelUsage["claude-sonnet-5"].contextWindow. If either sidecar or daemon only parses array-shaped modelUsage, the context-window total is missing and daemon normalization falls back to 200k. Claude SDK Result usage is cumulative across the model iterations in a client-side tool loop, whereas each streamed usage update describes one iteration. The authoritative Query.getContextUsage() snapshot must therefore win at Result time. Calling that method after detaching it from the Query object loses its this binding; if the best-effort error is swallowed, the cumulative fallback remains visible as a false context spike. Conversely, awaiting a correctly bound getContextUsage() call before emitting turn_completed makes optional telemetry a lifecycle dependency and leaves the GUI working for the duration of a slow control response.
  • Fix: Define usage in the protocol-v2 OpenAPI contract and carry it as typed durable session metadata through the generated client, desktop adapter, canonical activity session, and composer projection. Keep raw runtime context private to provider recovery; do not restore a GUI runtime-context dependency. Parse modelUsage recursively as both arrays and maps before using fallback context-window values. Track the model associated with a cached context window, and only reuse the previous total for the same model or when the model is unknown. Do not hard-code alias-to-model mappings in Tutti. Invoke getContextUsage() through its owning Query object after the Result has terminalized the turn. Keep the authoritative snapshot and cumulative Result fallback in an asynchronous telemetry path, and invalidate a delayed snapshot when a newer snapshot request starts or a later root user prompt begins so older usage cannot overwrite newer work.
  • Validation: Cover runtime-context splitting and metadata persistence, generated API projection, desktop canonical-session adaptation, activity-core usage resolution, and the composer hook. Add sidecar and daemon coverage with map-shaped modelUsage carrying contextWindow: 1_000_000, plus daemon coverage for Haiku -> Sonnet5 -> Haiku usage updates where the last payload lacks totalTokens. Add a sidecar query fixture whose getContextUsage() depends on this, and give its Result a multi-iteration cumulative usage total; assert that only the authoritative context snapshot is emitted. Also cover a context query that remains pending: turn_completed must be emitted first, and a delayed snapshot must be dropped after a newer snapshot request or the next root user prompt. Then run the Claude SDK sidecar tests, daemon Go tests, AgentGUI tests, and typechecks.
  • References: agent-activity-packages.md session_metadata.go desktopAgentActivityAdapter.ts main.ts main.test.ts claude_sdk_adapter.go compaction.ts messageRouter.ts sessionRuntime.session.test.ts

Claude /compact finishes without a compaction divider

  • Symptom: After Claude Code /compact, AgentGUI shows only the turn duration footer (for example 总用时 22 秒) and never renders the Compacting context / Context compacted. divider. Context usage may still drop correctly.
  • Quick checks: Confirm the provider is Claude Code SDK. Inspect the Claude transcript under ~/.claude/projects/.../<provider-session-id>.jsonl for compact_boundary or <local-command-stdout>Compacted. Check tuttíd for a claude-sdk:compact:<turnId> system notice; if tokens fell but that notice is missing, the sidecar never published compact_started.
  • Root cause: Claude Code 2.1.x often completes manual compaction without streaming status:compacting or compact_boundary to the query iterator (the boundary may exist only on disk, or arrive as local_command / camelCase compactMetadata). Compaction banners were driven only by those signals, so a silent success left no notice for AgentGUI to project. Even when the sidecar emits compact_started immediately on /compact, that assistant system notice arrives before provider-turn acceptance; the Claude acceptance gate previously treated it as premature provider output and dropped it. The daemon already settles an active compact notice when the turn closes; it needs a held compact_started (or an adapter-emitted running notice) first. Flushing those held events after acceptance must not re-observe their earlier provider input units: that either regresses the Replay cursor or coalesces conflicting compaction.status readiness onto the durable turn.working checkpoint.
  • Fix: Emit a running compact notice from the Claude adapter when /compact is selected, allow compact system notices to precede provider-turn acceptance, accept local_command / local_command_output and camelCase boundary metadata in the sidecar. For the pinned Claude Code 2.1.220 contract, treat status.compact_result=failed plus compact_error as the canonical compact failure signal; assistant and local-command text are compatibility fallbacks for streams that omit that status. Map the normalized conversation could not be reduced below the context limit failure to compact_failed before the successful SDK result settles the Turn. When the acceptance barrier later flushes held events, strip their ProviderInputUnit so they publish transcript/state only. If the compact failure specifically reports that the hard context limit was exceeded, project a typed context_handoff_required error. Do not replace the provider session or automatically dispatch the next message. Tell the user to create a new conversation and add an agent-session mention for this conversation, making the handoff explicit while retaining Claude Code's normalized failure detail. For restored Claude sessions, use rawMaxTokens as the fallback hard window and log the SDK maximum, raw maximum, native auto-compact threshold, and effective auto-compact flag separately.
  • Validation: Add daemon coverage that /compact banners stay held until durable acceptance, then flush without provider-input units; add sidecar coverage for silent /compact (result only), local_command failure, and camelCase compactMetadata. Also cover exact overflow classification, raw hard-window diagnostics, the typed handoff error projection, and localized Desktop and Native guidance. Re-run L04-CLAUDE recording and confirm the progress divider appears, then becomes Context compacted. (or the interrupted divider with the failure detail), and that record+replay both pass.
  • References: compaction.ts claude_sdk_execution.go claude_sdk_turn.go AgentMessageBlock.tsx

Inactive Claude resume times out then later sends stay queued

  • Symptom: On a large inactive Claude session, /compact or any send shows working then fails after ~30s with engine command timed out / tuttíd context canceled. Compact never runs. A later send shows 排队中 and never reaches tuttíd (api.send.received missing).
  • Quick checks: Confirm activeLiveState=inactive and resumable=true before send. Order desktop renderer_adapter.send.http_requested → tuttíd process_start.env_diagnostics → api.send.failed (~30s, no runtime.exec). Check Claude transcript size under ~/.claude/projects/.../<provider-session-id>.jsonl. After the timeout, inspect whether a second submit stamps queued:true without a daemon send.
  • Root cause: SendInput blocks on ensureRuntimeSession / Claude Resume→Start until the sidecar emits session_started. On restore, the sidecar previously awaited query.getContextUsage() before that event; the SDK call has no timeout and has hung for minutes. The Engine then treated the client send timeout as uncertainDelivery, which blocks all queue drain and prevents remove/send-now of the timed-out head, so later prompts stay visible as 排队中 forever even after reconcile finds no delivery proof.
  • Fix: Emit session_started before the restore usage snapshot, and refresh usage in the background (same as turn completion). Keep queue send timeout at 90s inside the 120s confirmation window as defense in depth. When an owned queue:reconcile:* completes without exact turn proof, drop the timed-out prompt (definitive non-delivery) or release uncertainty into a retryable failed head so later queued work can drain.
  • Validation: Sidecar coverage that restore start() reaches session_started while getContextUsage never resolves, then still emits usage_updated after resolve. promptQueue.reducer coverage for owned reconcile without turn proof. Manually: open a large inactive Claude session, send /compact, confirm Resume completes without waiting on usage and later messages are not stuck behind uncertain delivery.
  • References: sessionRuntime.ts compaction.ts promptQueue.reducer.ts promptQueue.ownedReconcile.ts promptQueue.drainDecision.ts lifecycle.go claude_sdk_lifecycle.go

AgentGUI compaction timer keeps running after compaction completed

  • Symptom: AgentGUI continues to show an increasing Compacting context duration after the provider finished compaction. The durable compaction message and Turn are already terminal, but the mounted renderer still projects the earlier noticeCommandStatus=running snapshot.
  • Quick checks: Compare the message-list requests in desktop reconcile diagnostics with the durable message versions. If the renderer pulled a running compaction at version N, missed its terminal update at N+1, then next requested afterVersion at a much higher value, the local cache advanced across a version hole. Confirm that the terminal row uses the same messageId; a new compaction row or a timer-specific state bug is a different failure.
  • Root cause: The realtime bridge treated the maximum version of materialized message rows as a contiguous acknowledged change cursor. After an event-stream loss, it applied a later inline message and advanced that maximum past the missed terminal mutation. Every later afterVersion pull then started beyond the mutation, so the authoritative completed snapshot could never repair the cached running snapshot. The timer correctly kept rendering the stale running lifecycle.
  • Fix: Before folding realtime messages inline, compare only their unseen versions with the cached high-water boundary. If the first unseen version is not the next cursor, do not apply any of that event's messages; retain the old cursor and request an authoritative incremental reconcile. After a disconnected event stream reconnects, also incrementally reconcile every session whose messages are already cached; otherwise a missed final mutation has no later event that can reveal its gap. Do not require the materialized cache itself to contain every historical cursor value, because mutable message rows replace older versions.
  • Validation: Cache a user message and a running compaction, omit the next terminal compaction mutation, then deliver a later assistant message. Assert that the later message is not applied inline, reconciliation requests from the pre-gap cursor, and the stable compaction messageId becomes completed from the authoritative response. Also cover valid snapshot gaps already present in the cache plus duplicate and stale event delivery. Finally, omit the terminal mutation as the last event, disconnect and reconnect without another activity event, and verify the reconnect reconcile retrieves it from the pre-disconnect cursor.
  • References: workspaceAgentActivityReconcileBridge.ts sessionReconcileExecutor.ts agent-gui-node.md

Root detail reconciliation repeatedly reloads unchanged child transcripts

  • Symptom: Opening or reconciling one root conversation repeatedly issues message-list requests for every known child Session, including assistant-only and tool-only children whose durable messages have not changed.
  • Quick checks: Compare each child Session's required messageVersion from both root-detail reads with the largest cached message version that also has a durable sequence. If the cache is current but the request still starts at zero, inspect whether child reconciliation reused the root conversation's user-message boundary heuristic.
  • Root cause: The root heuristic intentionally returns zero when cached history has no user message, so it can repair an incomplete root conversation. Applying that heuristic to provider-native child Sessions makes ordinary assistant/tool histories look permanently unhydrated and forces the same reads forever.
  • Fix: Keep root and child cursor policies separate. For a child, derive the cursor only from durable sequenced messages and skip its message request when that cursor has reached the Session messageVersion. Preserve the bounded newest-first initial read, but treat an existing empty child window as the authoritative zero cursor and drain later messages from afterVersion=0. After the first message pass, read root detail again and incrementally fetch newly discovered children plus existing children whose messageVersion advanced during the pass. Do not let optimistic/transient rows advance the durable cursor, and do not add polling; later changes arrive through the existing push-and-reconcile path.
  • Validation: Cover assistant-only and tool-only child caches, a transient row with a higher synthetic version, an unchanged child that performs no request, initial newest-first hydration, an empty known child that gains more than one page, and a child advancing between the two detail reads. Keep a root assistant-only case proving its existing repair still reads from zero.
  • References: workspaceAgentActivityReconcileBridge.ts sessionReconcileExecutor.ts

Completed agent output appears only after switching Sessions

  • Symptom: A Turn finishes in the daemon, but the active AgentGUI timeline does not show the final assistant output until the user selects another Session and returns. This is especially visible after an edit-retry replacement Turn.
  • Quick checks: Follow one exact (workspaceId, agentSessionId, turnId) through durable turn_update publication, the business-event WebSocket, and WorkspaceAgentActivityReconcileBridge. Distinguish a daemon publication gap from a disconnected/stale subscription and from a renderer event that updated Turn state without requesting authoritative messages. Compare the cached message cursor with listWorkspaceAgentSessionMessages.
  • Root cause: A terminal Turn event can be delivered while its final message event is missed, reordered, or not folded into the active cache. Session navigation performs a detail/message hydration and therefore hides the missing reconcile trigger.
  • Fix: Treat realtime events as hints. Every terminal turn_update requests one combined state-and-message reconcile through the existing workspace engine. An accepted edit-retry completion or recovery command remains reconciling and blocks another edit until the same authoritative detail reconciliation confirms its revision and recovery state. Never replace canonical availability with recovery actions inferred by Desktop. Do not add a second WebSocket, component store, or edit-retry-specific polling loop.
  • Validation: Deliver a settled turn_update without the corresponding final message_update; assert that the bridge fetches messages and updates the mounted timeline without Session navigation. Repeat after edit retry and after a transient reconcile failure.
  • References: workspaceAgentActivityReconcileBridge.ts workspaceAgentEditRetry.ts agent-gui-node.md

AgentActivity replication repeatedly rejects message batches as invalid

  • Symptom: A downstream AgentActivity replica repeatedly returns INVALID_ARGUMENT for the same message batch. The source session has a higher messageVersion, but the destination has no messages or stops at an earlier version.
  • Quick checks: Compare the session watermark with the current message rows. Values such as 1,3 or 1,5 are valid when an intermediate snapshot of the same messageId was overwritten. Check whether the destination requires incomingVersion == maxStoredVersion + 1 or treats a message version as immutable identity.
  • Root cause: Message.Version is a per-session change cursor on a mutable snapshot. Each accepted update advances the cursor, and updating the same messageId replaces its prior row. Current rows therefore need not contain every cursor value, and the same message identity legitimately moves to a higher version.
  • Fix: Replicas must accept any positive version for a new message, accept a higher version for an existing messageId, and ignore or reject only stale lower versions. Use a version-guarded atomic upsert so concurrent stale snapshots cannot overwrite newer state. Do not add an event-history table merely to make the current snapshot appear contiguous.
  • Validation: Record message A at v1, message B at v2, then update B to v3. Verify the current snapshot is A@1,B@3, afterVersion=1 returns B at v3, and a rejected projection does not consume another cursor. Downstream replication coverage should also accept an initial v3, update it to v5, and preserve v5 when v4 arrives later.
  • References: activity_messages.go activity_message_read.go repository.go

Goal clear stays planning and leaves the session running

  • Symptom: Immediately clearing a newly set Goal leaves the first response in thinking, /goal clear in planning, or the conversation permanently running.
  • Quick checks: Inspect the Goal control response: its Turn ID must be empty. List persisted Turns and verify no Turn was created solely for the clear action. Inspect the Goal state endpoint for desired, observed, revision, syncStatus, and pendingOperationId. For any real Goal Turn, verify origin and source Goal operation/revision are present.
  • Root cause: The prompt path allocated a Turn ID before classifying /goal. Provider Goal control is session-level and did not start that Turn, but message persistence manufactured a row for the unknown ID. With no real turn_started or terminal event, loading and session-running projections never settled.
  • Fix: Route Goal controls through the typed Goal API before Turn allocation. Persist desired/observed Goal state and an independent operation, reject messages that reference unknown Turns, and adopt only provider-started continuation Turns. Use durable origin and revision correlation so clear does not cancel unrelated user work and stale continuation timers cannot revive an older Goal.
  • Validation: Set then immediately clear a Goal and assert that no control Turn exists, the operation reaches a terminal state, and the session has no phantom active Turn. Cover unknown-Turn message rejection, Claude goal-arm quiescing, Codex adopted continuation provenance, stale observation protection, and revision- guarded continuation nudges.
  • References: Agent Goal Control Design controller_exec.go goal_state.go

Claude Goal completes but the UI remains active

  • Symptom: Claude finishes the requested work and its session JSONL contains an attachment.type=goal_status row with met=true, but Tutti emits no goal_observed; durable desired and observed Goal state remain active.
  • Quick checks: Correlate the Tutti Session with the Claude provider Session ID. Inspect the provider transcript for the final goal_status attachment, then check dev logs for a matching sidecar goal_observed. Do not infer Goal completion from a successful root result or from a successful Stop hook process.
  • Root cause: Local Claude Agent SDK SDKMessage streams through 0.3.222 omit transcript attachments. SDKActiveGoalMessage is also absent from the public SDKMessage union, and local Claude Code only publishes active_goal on its remote path. A test that manually yielded type=attachment therefore proved the projection but not the real SDK boundary.
  • Fix: Locate the provider transcript from root system/init.session_id + cwd using the pinned SDK's project-key algorithm. Start restored Sessions from their already known provider Session ID. Keep SessionStart.transcript_path as an exact-path auxiliary signal, not the required trigger. Read only newly appended complete JSONL rows, forward only goal_status attachments through the existing Goal projection, drain before root Turn completion, and keep following after the SDK result because the native evaluator can flush its terminal row slightly later. Late evidence retains the latest settled root Turn identity. Do not parse evaluator hook stdout and do not configure an alpha SessionStore solely for this signal; both change a larger and less reliable boundary.
  • Validation: Run a query fixture whose SDK iterator emits root system/init, the root user message, and result. Reproduce the observed ordering where transcript met=false precedes turn_completed and met=true lands afterward; require the late completion to retain the original Turn identity. Also cover resume without another init, delegated init exclusion, watcher cleanup, historical-row skipping, partial JSONL rows, and repeated-drain deduplication.
  • References: goalTranscript.ts messageRouter.ts Anthropic SDK issue #336

Accepted Goal clear is sent repeatedly until convergence times out

  • Symptom: One /goal clear produces repeated provider control requests at the Goal worker interval. Each request is accepted, but the durable operation remains dispatched with provider phase accepted, its attempt count keeps rising, and it eventually fails with accepted goal operation exceeded its convergence deadline.
  • Quick checks: Correlate logs by agent_session_id, operation ID, and Goal revision. Verify there is one user control audit but multiple provider action=clear calls. Inspect workspace_agent_goal_control_operations: an accepted operation with increasing attempts and unchanged accepted evidence identifies Host replay, not repeated user input.
  • Root cause: The steady-state Goal worker treated clear's provider idempotence as permission to resubmit an already accepted command. Acceptance had already crossed the delivery boundary, so every later worker claim queued another native clear command while the Host was supposed to wait for applied lifecycle evidence.
  • Fix: For every accepted Goal mutation, make the steady-state worker defer without calling the provider again. Keep the convergence deadline so missing applied evidence still terminates. Reserve replay for startup crash recovery and gate it with the adapter recovery policy; query-incapable adapters may replay an idempotent clear once, while unsafe set replay remains rejected.
  • Validation: Return accepted from a clear, advance the worker clock past its next claim, and step the steady-state worker. The provider call count must remain one, while the operation stays pending and applying. Existing deadline coverage must still fail a lost-evidence operation without another provider call.
  • References: goal_operation_worker.go goal_scenarios.go

Replaced Goal banner keeps the previous objective

  • Symptom: A second /goal <objective> is accepted and runs, but AgentGUI continues to show the first objective. The Goal state table contains the second objective at a newer revision while workspace_agent_sessions.session_metadata_json or a runtime Session snapshot still contains the first.
  • Quick checks: Compare workspace_agent_session_goals.desired_json, revision, and updated_at_unix_ms with the Session metadata Goal. Confirm that the second Goal operation completed before attributing the mismatch to React rendering.
  • Root cause: Durable Goal state and provider Session metadata update on different schedules. Session reads previously exposed the provider metadata Goal and attached only goalSyncState, so a later Session reload could overwrite the correct Goal Control response with an older objective.
  • Fix: Project Host-owned durable Goal state and its update timestamp onto every single and batch Session read. Use desired while convergence is unresolved, use observed after synchronization, and honor the durable tombstone.
  • Validation: Read a Session whose metadata contains objective A beside durable Goal revision N+1 containing objective B. Single and batch projections must return B with a Session timestamp at least as new as the Goal state. A durable tombstone must return no Session Goal; a synchronized terminal observation must remain terminal instead of reverting to active desired state.
  • References: service_turns.go goal_state.go

Cleared Goal reappears as a newer provider-authored Goal

  • Symptom: Goal clear returns success and briefly shows “目标已移除”, but the active Goal banner returns. The Goal table contains a completed clear followed by a new provider-adoption set revision for the same objective. In a projection-only variant, the table remains tombstoned with no later set, while the banner still shows the pre-clear Goal.
  • Quick checks: Correlate workspace_agent_session.goal_control.completed action=clear with agent_session.app_server.goal.provider_adopted. If adoption was scheduled before clear but completed immediately after it, inspect the adoption's expected revision and the canonical revision committed by clear. If no later adoption exists, inspect the Goal Control response: goal must be present as null, and its embedded session.goal must also be null.
  • Root cause: Provider Goal adoption runs off the app-server read loop. An observation captured before clear could wait behind the serialized Goal actor; the old implementation read the revision only after acquiring that actor, so the delayed observation was mistaken for a new provider-authored Goal and cleared the tombstone. The projection-only variant made nullable goal optional on the wire. Go therefore omitted a successful clear, the client fell back to a stale runtime session.goal, and the Engine promoted that stale field after the operation settled.
  • Fix: Capture the canonical Goal revision while scheduling provider adoption and carry it through the runtime, Host, and store boundary. The store compares it with the current revision inside the same transaction that would advance the Goal. Mutable progress snapshots from an already owned provider Goal bind to that Goal's current operation identity instead of entering adoption. Goal Control responses require a nullable goal, project the Host result onto session.goal, and let the Engine normalize a synced Session from that same authoritative field.
  • Validation: Block provider adoption after it captures revision N, complete clear at revision N+1, then release adoption. It must fail as superseded, leave the Goal tombstoned at N+1, create no later set operation, and keep the runtime Goal banner empty. Also return a synced clear beside a deliberately stale Session Goal; JSON must contain "goal": null, and the Engine presentation must remain empty.
  • References: goal_provider_adoption.go codex_appserver_provider_goal_adoption.go daemon_agent_sessions_goal.go sessionGoalControl.validation.ts

Goal loops after pause/resume and never reaches complete

  • Symptom: Pause and resume succeed, the Goal keeps producing continuation Turns, and the model may even emit the final marker text, but thread/goal/updated never reaches status=complete. Logs show a continuation_nudge after a brief inter-turn idle, then an unbounded stream of adopted Goal Turns.
  • Quick checks: Compare the nudge thread/goal/set params with the original set. If the nudge re-sends objective, Codex may restart Goal work instead of letting the current generation settle. Confirm pause/resume ops completed and the durable generation fingerprint is still owned by the set/adoption operation.
  • Root cause: Pause/resume intentionally keep generation ownership on the set-time fingerprint while advancing the live operation identity. Provider status=complete for that fingerprint was classified as superseded (binding.identity != current), so local goal state stayed active. The per-turn continuation nudge then re-sent thread/goal/set with status=active and revived the finished Goal.
  • Fix: Treat provider progress for the session's current generation fingerprint as known-current after pause/resume (while the Goal is still present). Keep nudges status-only, do not re-bind generation on pause/resume/nudge, and do not schedule a delayed nudge from Resume itself.
  • Validation: Record a short one-digit-per-turn Goal, pause after the first digit, resume, and verify the provider reaches complete with the final marker and no post-complete continuation storm.
  • References: codex_appserver_goal.go codex_appserver_adapter_goal_lifecycle_test.go

Goal resume fails with permanently ambiguous generation fingerprint

  • Symptom: Pause succeeds and the banner shows Resume, but clicking Resume fails. Logs show persist goal provenance: provider goal generation fingerprint is permanently ambiguous. Provider status may already be active while the Host operation stays failed and no continuation Turn starts.
  • Quick checks: Correlate the pause and resume durable operation IDs with workspace_agent_goal_generation_fences for the same fingerprint. Confirm the set/adoption that created the Goal already owns that fingerprint. Inspect Codex thread/goal/set pause/resume responses for a returned Goal payload.
  • Root cause: Pause and resume are status-only controls that reuse the set-time provider generation. Re-binding that fingerprint to the pause/resume operation id collides with the set binding and marks the fingerprint permanently ambiguous, failing closed before the local status mirror and continuation nudge run.
  • Fix: Keep generation binding on durable set/adoption only. For pause/resume, mirror the provider or local Goal status without calling bindGoalGeneration.
  • Validation: With a durable provenance sink, set → pause → resume with distinct operation ids and the same generation fingerprint. Resume must succeed, status must be active, and the fingerprint must remain owned by the set operation.
  • References: codex_appserver_goal.go codex_appserver_goal_provenance.go codex_appserver_adapter_goal_recovery_test.go

Goal disappears after pause or resume

  • Symptom: Pause or resume succeeds, but the Goal banner disappears while the durable Goal still exists.
  • Quick checks: Compare the Goal Control response's top-level goal with state.desired, state.observed, and state.tombstoned. If observed is empty but desired remains populated and not tombstoned, the provider supplied no current observation; it did not clear the Goal.
  • Root cause: The response projected the provider's per-action observation as the visible Goal. Some providers can apply pause or resume while returning no Goal observation, so the required nullable response serialized that absence as an explicit clear.
  • Fix: Host returns the durable desired projection as GoalControlResult.Goal and keeps provider output in GoalState.Observed. The daemon only maps that Host-owned result. Empty observation may produce diverged state, but only a durable tombstone produces goal: null.
  • Validation: Set a Goal, make the provider return no Goal for pause and resume, and verify both responses retain the objective with the expected paused/active status, report divergence, and carry no pending operation.
  • References: goal_control.go goal_scenarios.go daemon_agent_sessions_goal.go

Revoked shared Goal starts again after handoff or desktop restart

  • Symptom: After a shared binding is revoked, the old Goal starts or resumes provider work, may replace the Owner's newer Goal, or an unrelated active Turn is canceled.
  • Quick checks: Resolve the original Goal operation by its stable clientSubmitId. Inspect workspace_agent_goal_generation_fences for the exact operation ID, revision, and repair epoch. pending or processing means Host still owns delivery; absence means the revocation was never durably accepted. For any canceled Turn, compare all three immutable source_goal_* fields with the fence.
  • Root cause: Revocation was treated as a one-shot notification, or code guessed that the session's current active Turn belonged to the revoked binding. A failed notification was lost on restart, and recovery could replay the old prepared Goal before runtime learned the revocation.
  • Fix: Persist an exact Goal-generation fence before provider delivery. Process fences before Goal operations during startup and each worker tick. Restore all durable fences when a provider session resumes, and retain the exact set in the runtime Controller across idle connection release so a replacement adapter is fenced before its first user operation. Never resume an offline provider solely for background fence or cancel delivery. Prepare the local clear with the target revision as a compare-and-swap guard, and call CancelTurn only for a live canonical Turn whose operation ID, revision, and repair epoch all match. Keep the fence pending after cancellation acceptance until the Turn is canonically terminal.
  • Validation: Cover failed delivery followed by restart, a pending target operation and fence recovered together, a newer Owner Goal, repeated runtime ensure, and matching versus unrelated active Turns. Simulate connection replacement and prove fence reinstallation happens before provider dispatch. Simulate a disconnect immediately before cancel and prove background recovery makes zero provider Resume calls. Provider tests must prove that the exact Codex/Claude generation never becomes canonical while later generations remain admissible.
  • References: Agent Host contracts Agent Goal Control Design goal_generation_fence.go

Initial Goal prompt disappears when the Agent starts responding

  • Symptom: A new conversation opened with /goal <objective> first shows the submitted command, then removes it as soon as the durable session or the first provider response arrives. The response and processing state remain visible.
  • Quick checks: Compare the optimistic activation message with the durable session_audit. Verify both carry the same clientSubmitId and inspect the transcript projection after the overlay removes the optimistic twin. If the durable Goal audit is filtered from all presentation output, the replacement leaves no visible row.
  • Root cause: The optimistic activation was projected as an ordinary user message while the canonical Goal control was correctly persisted as a session-level audit. Overlay reconciliation removed the optimistic message by clientSubmitId, then the message projector discarded the durable audit, so two individually correct policies composed into a disappearing row.
  • Fix: Mark the optimistic activation as Goal control when initialGoalControl is present. Project both optimistic and durable forms as a dedicated goal-control row outside the Turn model. Derive the row identity from the audit payload's client-submit message identity before falling back to the durable operation ID, so replacement preserves the renderer key.
  • Validation: Project the optimistic-only state and the durable-after-overlay state. Both must contain one goal-control row with the same ID and zero Turns. Also verify the dedicated row renders, the first real provider Turn remains the sole Turn, and reload produces the same control row.
  • References: workspaceAgentMessageOverlay.ts workspaceAgentMessageProjection.ts workspaceAgentTimelineCanonical.ts

Goal clear duplicates the live Agent work section

  • Symptom: While a Goal Turn is still streaming, clicking clear inserts /goal clear between two identical Agent work sections. Both sections continue updating together even though the daemon executed only one Turn.
  • Quick checks: Inspect canonical Turns and message ownership. If Agent messages on both sides of the turnless Goal audit carry the same Turn ID, compare transcript group keys before inspecting provider retries or duplicate persistence.
  • Root cause: Chronological insertion produced Turn T -> goal-control -> Turn T. A grouping pass treated the turnless row as a hard boundary and created two groups with key T; the work-section map retained the later model for that shared key, so both Turn positions rendered the same rows.
  • Fix: Keep a turnless session row inside the current presentation group only when the next lifecycle-owned row proves that the surrounding Turn ID is unchanged. Preserve the session row's null Turn ID; presentation continuity must not manufacture lifecycle ownership.
  • Validation: Cover T -> goal-control -> T and assert one Turn group, one processed-time section, one Goal control row, and one copy of each Agent row. Also cover T1 -> goal-control -> T2 and verify the Goal control remains an independent orphan between different Turns.
  • References: agentTranscriptModel.ts AgentTranscriptView.tsx

Recording fails when a tool message contains its runtime CWD

  • Symptom: Recording finalization fails with expected_state_export_failed and reports an absolute path at $.agent.sessions[*].messages[*].payload.input.cwd. The provider tape may already be complete.
  • Quick checks: Inspect the failed Recording row and the matching canonical tool message. Distinguish the normalized tool envelope's direct input.cwd from a tool-owned nested argument.
  • Root cause: The provider tape tokenizes the Session CWD, but the semantic-state adapter previously copied the canonical tool message unchanged. The direct input.cwd is provider runtime context, not part of Tutti's portable semantic state.
  • Fix: Project the Agent graph before semantic-state validation. Remove only the runtime input.cwd fields from normalized tool_call messages and Interaction envelopes, including the normalized toolCall.input.cwd. Convert an absolute executable prefix in normalized command/title display fields to its basename. Do not branch on tool name, recursively remove tool-owned arguments, mutate canonical messages, or weaken the absolute-path validator.
  • Validation: Capture a tool message and matching Interaction with absolute normalized runtime CWDs and executable prefixes, plus a nested relative arguments.cwd. Require capture and validation to succeed, retain the tool-owned nested argument, and prove the source Agent graph remains unchanged.
  • References: state.go agent_session_replay_state.go

Cassette replay loses the provider session before the second stimulus

  • Symptom: Replay reaches checkpoint 1, then a later session.send fails with provider session was never established. The replay error also reports that an outbound write arrived while a recorded stdout chunk was next.
  • Quick checks: Inspect provider/frames.jsonl around the reported chunk. If a provider notification immediately follows a successful request response and precedes the next outbound request, compare the reader and writer goroutine timing.
  • Root cause: The process cassette encoded the provider byte-stream order, but replay treated that order as a required Go goroutine schedule. A writer could run before the reader consumed an already-recorded notification, so normal concurrency was rejected as a transport mismatch and provider startup was torn down.
  • Fix: When the next recorded chunk is inbound, block the replay write until the receive path consumes it. Continue strict payload validation when the next recorded outbound chunk becomes current.
  • Validation: Start a replay stream with outbound -> stdout -> outbound. Launch the second send before receiving stdout; it must wait, then succeed after the receive. Keep mismatch, pause, fast-forward, close, and full runtime tests passing.
  • References: process_transport_replay.go process_transport_cassette_test.go

Continue-session replay sends initialize before the recorded turn

  • Symptom: A restored replay Session reaches prompt dispatch, then fails with process cassette outbound mismatch: the expected request is turn/start or session/prompt, while the actual request is initialize. A second form has the same expected and actual turn/start, but the actual request is missing a provider-derived field such as Codex collaborationMode.
  • Quick checks: Inspect provider/manifest.json and the first outbound frame for the failed connection. If recording started on a Session that was already live, its captureOrigin must be attached-live-connection.
  • Root cause: Continue-session recording attached to an initialized provider connection, so its tape correctly began at the next business request. Replay started a new virtual connection but treated every tape as process-start, causing the adapter to repeat initialization and provider-session resume traffic that was outside the captured boundary. If initialization is already skipped but a provider-derived field is missing, the semantic initial-state.json dropped the protocol checkpoint while projecting the canonical historical Session graph.
  • Fix: Persist the connection capture origin in provider tape schema v3. For an attached live connection, restore the provider adapter's protocol checkpoint through the historical Session's narrow providerResumeCheckpoint and skip only the initialization and provider-session bootstrap. Do not export the full private runtime context and do not infer missing state from the first tape frame. Keep strict matching on the first captured business request.
  • Validation: Record a Codex and Cursor Turn after arming capture on an existing live connection. Recreate each adapter over replay transport, resume the historical Session, execute the same Turn, and require complete tape consumption. The regression must cross semantic state JSON capture and Host restore; a test that copies an in-memory runtime context directly into the replay Session does not cover the product boundary. Also keep new-session cold bootstrap tests passing.
  • References: process_cassette.go codex_appserver_session.go standard_acp_session.go

Final ACP Markdown loses spacing between blocks

  • Symptom: A completed ACP answer reaches the transcript, but headings, lists, or fenced code blocks run together even though the provider's session/prompt result contains the expected newlines.
  • Root cause: The final-result extractor trimmed each nested content block before joining them. Whitespace that separated adjacent Markdown blocks was therefore removed before the turn normalizer and GUI received the answer.
  • Fix: Use trimming only to decide whether a content block is empty. Preserve the original non-empty block text while recursively extracting and joining the final assistant content.
  • Validation: Exercise a complete standard-ACP turn whose final result contains a heading, list, and fenced code block. Require the projected assistant snapshot to keep all internal Markdown newlines.
  • References: acp_update_events.go standard_acp_turn_test.go

Cassette replay reports a false final Session state mismatch

  • Symptom: Every recorded stimulus succeeds, but final replay validation reports an exact path such as $.agent.sessions[0].messages[0].id or a Tutti Mode snapshot turnId.
  • Quick checks: Inspect expected-state.json and capture the actual typed semantic state. Confirm that Turns and Messages were captured in canonical sequence rather than UUID order, and that every reference points to the corresponding structural object.
  • Root cause: Replay legitimately regenerates Turn, assistant Message, interaction, and product-object IDs. Comparing those raw values, or ordering related arrays by random UUID, mistakes alpha-equivalent semantic graphs for drift. Message payload seq is also runtime timing metadata rather than a semantic oracle.
  • Fix: Keep original IDs in initial-state.json so Host can restore resumable history. For final verification, alpha-normalize structural IDs and all their references after ordering entities by canonical sequence; omit runtime-only payload sequence values. Continue to compare semantic content exactly.
  • Validation: Cover regenerated Turn and Message IDs, changed payload sequence values, and a real semantic content mismatch. Run both create-session and continue-session current-build record/replay loops.
  • References: run-agent-session-replay.mjs run-agent-session-replay.test.mjs

Replay aborts with Promise was collected (-32000)

  • Symptom: A renderer intent has already started, but the temporary CDP runner aborts while awaiting its result. Provider replay then reports a partially consumed connection during shutdown.
  • Root cause: Chromium may collect the remote Promise backing Runtime.evaluate while the renderer operation is still running. Retrying the renderer command directly can duplicate a non-idempotent intent.
  • Fix: Register the invocation in the renderer by method and Activity Event ID before dispatch. On this CDP error, evaluate the same lookup again and await the stored result instead of invoking the command twice.
  • Validation: Make the first CDP evaluation fail with Promise was collected; the second evaluation must reuse the identical invocation expression and complete the event exactly once.
  • References: run-agent-session-replay.mjs run-agent-session-replay.test.mjs

Cassette replay times out before a recorded queued Turn

  • Symptom: Replay stops on timed out waiting for renderer effect queue/sendPrompt, leaves later activity events untouched, and consumes only the provider frames before a long-running Turn settles. The cassette itself contains the missing intent, effect, and final Turn.
  • Quick checks: Compare adjacent activity-events.jsonl timestamps. If the effect was recorded more than the renderer effect timeout after its intent, check whether replay dispatched both events immediately instead of waiting for their recorded interval.
  • Root cause: Provider frames followed the daemon playback clock, but the runner iterated activity events as fast as JavaScript completed them. Effect verification therefore began while the recorded Turn was still running and expired before the effect could exist.
  • Fix: Advance activity events by relative occurredAtUnixMs using the daemon's playback state. Pause freezes the activity clock, speed scales it, and checkpoint fast-forward skips its remaining wait.
  • Validation: Cover normal recorded delay, processing time between events, pause/resume, selected speed, and fast-forward. Then replay the failing cassette and require the final checkpoint, all provider frames, and every expected Turn to pass.
  • References: run-agent-session-replay.mjs run-agent-session-replay.test.mjs

Edited prompt remains after edit-and-retry

  • Symptom: The replacement answer appears, but the original user prompt remains, reappears after reconnect, or disappears only after switching Sessions.
  • Quick checks: Compare the daemon Session-detail Turn projection and effective message read with the renderer snapshot. If SQLite marks the old Turn retracted, verify Session detail uses ListEffectiveSessionTurns, not the complete audit reader. Then inspect the SessionEngine required/applied history revisions and any terminal optimistic message-delta row from the retracted Turn.
  • Root cause: Incremental reconciliation can add or update rows but cannot delete a cached Turn missed during disconnect. A second variant replaces canonical messages while leaving a confirmed optimistic projection alive; a third lets a realtime event race a full-history read.
  • Fix: Serialize same-Session reads, require a full authoritative replacement for a changed edit-retry revision, page backward from a newest-version anchor, then drain its live tail and apply one composite Session/Turn/Message snapshot. Source the detail projection from effective Turns. Reconcile pending intents, attention, and terminal optimistic message-delta rows from that same Turn set and stable clientSubmitId set; preserve unresolved turnless controls. Retry transient projection failures while connected and recheck cached Sessions on reconnect.
  • Validation: Cover missed events plus reconnect, stale unknown messages, transient failures, revision changes during paging, optimistic initial prompts, and a terminal delta for a retracted Turn. The final detail and transcript must contain only effective Turns and messages while preserving attachments, non-text input, turnless controls, and real filesystem effects.
  • References: workspaceAgentActivityReconcileBridge.ts sessionReconcileExecutor.ts pendingIntents.authoritativeHistory.ts

Goal-mode recording stalls waiting for a /goal palette option

  • Symptom: A Session-Replay recording scenario (or any UI driver) waits forever for agent-gui-composer-slash-command-goal after typing /goal into the composer, while typing / shows only skill options.
  • Quick checks: Inspect goalDraftObjectiveFromPrompt in composerDraftUtils.ts and the isGoalModeActive guard in useComposerPaletteCatalog.ts. Programmatic probes that overwrite the draft via execCommand('insertText') can leave the React palette draft stuck on a previous /goal prefix, which hides all command entries and makes the palette look broken for unrelated queries.
  • Root cause: /goal is a prefix syntax, not a palette command. The moment the draft matches ^\s*\/goal(\s+…)?$, isGoalModeActive becomes true, the slash query is nulled, and the command section of the palette closes. A palette option for the full token can never appear.
  • Fix: Drive goal mode by typing the whole /goal <objective> draft, waiting for the goal badge ([data-agent-goal-badge="true"]) plus an enabled send button, then clicking send (submitGoalPrompt in the record-scenario CDP helpers). Never wait for a /goal palette option.
  • Validation: pnpm e2e:agent-gui -- --record … --scenario l01 proceeds past goal activation (goal turn starts) instead of stalling on the palette wait.
  • References: tutti-agent-session-replay-cases/scenario-runtime/cdp-helpers.mjs composerDraftUtils.ts useComposerPaletteCatalog.ts

Replay transport mismatch on attachmentId

  • Symptom: Replay fails with 409 agent_session_replay_transport_mismatch at a state path like $.agent.sessions[i].messages[j].payload.content[k].attachmentId.
  • Root cause: Attachment identifiers are runtime-generated. Replay re-uploads prompt attachments and receives fresh IDs, so byte-equality against the recorded state can never hold.
  • Fix: Register message-content attachmentId values as alpha-equivalent identities in registerReplayIDs (services/tuttid/biz/agentsessionreplay/state.go), like session/turn/message IDs.
  • Validation: TestCompareTuttiReplayStateTreatsAttachmentIDsAsAlphaEquivalent in state_test.go; a full record→replay of an image-input cassette passes.
  • References: state.go

Replay transport mismatch on Goal Control operationId

  • Symptom: Goal cassette record and UI/provider playback succeed, but final transport verification fails with 409 at $.agent.sessions[i].messages[j].payload.operationId on the goal-control:* session_audit row.
  • Root cause: Goal Control operation identities are minted per run. Replay creates a new durable operation while replaying the same /goal activation, so the audit payload's operationId cannot match byte-for-byte.
  • Fix: Register message-payload operationId values as alpha-equivalent identities in registerReplayIDs, alongside session/turn/message/attachment IDs.
  • Validation: TestCompareTuttiReplayStateTreatsGoalControlOperationIDsAsAlphaEquivalent; record→audit→replay of l01_codex (l01) passes.
  • References: state.go tutti-agent-session-replay-cases/cases/l01/scenario.mjs

Project-session replay never shows the restored session in the rail

  • Symptom: Replaying a project-session cassette stalls waiting for agent-gui-conversation-item-<sessionId>; the conversation rail is empty.
  • Root cause: Recording seeds the project into user_projects before driving the UI, but replay bootstrap did not. Sessions whose railPlacement.kind is project render only under a project rail section, and that section only exists when the project row is present.
  • Fix: During replay preparation, detect a railPlacement.kind === "project" activity payload and seed the same project (seedRecordingUserProject) before launching the desktop; enter the project surface before replaying the activation intent.
  • References: run-agent-session-replay.mjs recording.mjs

Interaction replay times out at an interaction.pending checkpoint

  • Symptom: Replaying a cassette with an approval/question interaction fails with checkpoint_readiness_timeout right before the interaction/responseRequested intent; provider frames stop at the chunk preceding the recorded decision write-back. A concurrent agent_session.process_start log with empty room_id/agent_session_id is the periodic provider availability probe, not a session relaunch — do not chase it.
  • Root cause: Checkpoint readiness predicates compared recorded activity-layer vocabulary (turn.phase == "waiting_approval", call.status == "running") against the canonical store vocabulary (waiting, waiting_approval) by string equality, so interaction-wait checkpoints could never become ready.
  • Fix: Fold both sides to canonical vocabulary before comparing (semantic_readiness.go), and record canonical values into new checkpoint plans (checkpoint_provider_candidates.go). Old cassettes stay compatible through the replay-side fold.
  • Validation: TestCanonicalTurnPhaseFoldsActivityVocabulary and TestDescribeProviderEventFoldsTurnPhaseToCanonicalVocabulary; replaying approval, question, and child-approval cassettes passes.
  • References: semantic_readiness.go checkpoint_provider_candidates.go

Replay times out waiting for idle before a busy-queue submit/requested

  • Symptom: Replaying a cassette that enqueues prompts while a turn is still running (queue edit / remove / automatic drain) fails with timed out waiting for replay Session … to become idle on a submit/requested activity event. The session's activeTurn.phase stays running because the filler turn only settles after later tape actions.
  • Quick checks: Inspect activity-events.jsonl around the failing sequence. Busy-queue submits have no queue/sendPrompt / session/activate effect with causedByEventId equal to that submit's eventId (they are later canceled or drained after the active turn settles). Older cassettes may still show submitDiagnostics.queued: false on those intents.
  • Root cause: The temporary runner treated every submit/requested with submitDiagnostics.queued === false as an immediate send and waited for session idle first. The GUI trace starts with queued: false, and until the engine stamped real queue admission onto the intent, busy-queue submits were recorded with that stale false. Waiting for idle deadlocks behind the turn that caused the queue in the first place.
  • Fix: Stamp submitDiagnostics.queued from engine queue admission before the intent is observed (selectEngineSubmitWouldBeVisibleInQueue). On replay, wait for idle only when the submit caused a send/activate effect (and skip for queued: true, send_now, and immediate). Re-record queue scenarios so new tapes carry the correct diagnostic; older tapes replay via effect causation.
  • Validation: submit idle wait skips busy-queue submits and honors send causation; semantic prompt submission reports visible queue admission for a busy Session; record and replay queue-only c04_codex; record and replay native-guidance c06_codex separately.
  • References: run-agent-session-replay.mjs createAgentSessionEngine.ts promptQueue.admission.ts

Replay deadlocks at a turn.canceled checkpoint of a canceled compaction

  • Symptom: Replay fails with checkpoint_readiness_timeout at the checkpoint after a canceled /compact turn, and the transport reports a deterministic connection ... consumed N of M chunks where the next unconsumed chunk is the inbound frame right before the recorded turn/interrupt outbound. The canceled turn settles only after Host's cancel grace timeout (~2 minutes), the live connection is torn down, and the retry turn fails on a provider relaunch that has no recorded connection.
  • Root cause: Activity-boundary checkpoint cursors were taken from the observation lane (last provider unit that carried checkpoint observation events). A compaction turn's interrupted turn/completed settles canonical state without emitting observations, so the recorded turn.canceled cursor stopped before the interrupt round trip its own readiness (turn.status == "canceled") requires. At replay the input barrier parks the reader at that cursor, the daemon's turn/interrupt send waits behind the unreleased inbound chunk, and readiness can never hold — an unsatisfiable plan, not a replay-transport bug.
  • Fix: The recording transport already reports every completed provider input unit; fold that handled lane into the checkpoint recorder (ObserveProviderInputUnit) and record activity-boundary cursors as the per-connection max of the observation lane and the handled lane (activityBoundaryCursor). Provider-observation checkpoint cursors keep using the observation lane only. Cassettes recorded with the stale cursor must be re-recorded.
  • Validation: TestActivityBoundaryCursorCoversHandledUnitsBeyondObservationLane; re-recording and replaying the compaction-cancel-retry cassette passes, and the cancel-and-resend cassette replay stays green.
  • References: checkpoint_provider_candidates.go checkpoint_activity_boundaries.go

New Agent conversation rejects a model that is no longer offered

  • Symptom: A newly submitted conversation fails with model value is not supported by agent target, although the current model picker does not offer that model. Alternatively, the selected model appears to start but the provider actually continues on its own default.
  • Root cause: A target-scoped remembered composer default outlived a changed model catalog, or a dependent reasoning default remained bound to the retired model after model fallback, or the ACP runtime treated a rejected startup model selection as a best-effort setting and silently retained the provider default. Reapplying a model that session/new already selected can also trigger an unnecessary provider-side reconfiguration failure.
  • Fix: At Create, distinguish a target-scoped persisted default from a model explicitly supplied by the caller. For a provider or Agent Extension, resolve an obsolete persisted default to the current catalog default reported by that same target; never use a different provider. Resolve non-explicit per-model reasoning against that effective model while keeping explicit caller values strict. A strict per-model reasoning catalog must omit an inherited value when it cannot prove that the target model supports it. Treat an explicit ACP model selection as identity-bearing: leave an already-selected model unchanged, and if a real model change is rejected, abort startup rather than falling back.
  • Validation: Cover an obsolete persisted default with a multi-model catalog whose reported current model is not first, a stale dependent reasoning default, and an unsupported explicit selection separately with generic extension fixtures. Inject a session/set_model rejection into the standard ACP transport test.

Agent send is stopped while an earlier provider process is still exiting

  • Symptom: After an idle Standard ACP, Codex, Tutti Agent, or Claude session is released, the next message may reconnect once, but a later Start/Resume returns workspace_operation_failed with reason agent.process_cleanup_pending. The message does not reach the provider; in AgentGUI the submitted draft is restored and a localized retry message is shown.
  • Quick checks: Correlate agent_session.acp.close stages with agent_session.live_resource_cleanup.failed. A close failure followed by one replacement-process start, then no additional start for the rejected retry, confirms cleanup backpressure rather than provider prompt rejection.
  • Root cause: A provider process can ignore graceful termination and fail the bounded transport Close. Dropping that handle allows unbounded orphan processes; allowing its late inbound handler to remain active can also attribute old output or approval requests to the replacement Turn.
  • Fix: Keep failed and replaced clients under their adapter's ownership, quarantine their message handlers, and retry at most one failed Close budget per adapter sweep. Codex and Tutti Agent mark a close-failed current client unusable; Claude also rejects every late event whose physical connection is no longer the current Session generation. Once a failed handle is retired, Start/Resume performs one bounded cleanup attempt and refuses to spawn another process while cleanup remains pending. Preserve the stable reason through the Host/API boundary so AgentGUI can restore the draft and present i18n copy.
  • Validation: Cover release failure followed by one successful replacement, a blocked next Resume with unchanged spawn/prompt counts, startup/load failure retention, stale retired-client output, Plan-mode persistence without an eager spawn, per-adapter sweep budgets, API classification, localized presentation, and failed-submit draft restoration.
  • References: standard_acp_session.go standard_acp_resource_ownership.go codex_appserver_resource_ownership.go claude_sdk_resource_ownership.go AgentGUIEngineSettlementController.ts

Codex rejects turn/start with AbsolutePathBuf deserialized without a base path

  • Symptom: Codex initializes and thread/start succeeds, but the first turn/start fails with JSON-RPC -32600 and AbsolutePathBuf deserialized without a base path. On a managed POSIX runtime, another form accepts the Turn but every tool command, including pwd, fails with Failed to create unified exec process: No such file or directory (os error 2) even though provider-process CWD preflight succeeded.
  • Root cause: Tutti sent the POSIX-only /sandbox-tmp writable root as though it were a portable absolute host path. Codex's Windows AbsolutePathBuf parser rejects it even when the request also carries cwd; the per-turn working-directory override does not make a POSIX-rooted string into a Windows absolute path. Tutti also once omitted the Session cwd from the Turn override. Supplying that field from the raw persisted Session value introduced the inverse POSIX failure: a stored /workspace/<room-id> mount path escaped the managed Agent's logical /workspace view even though thread/start and the provider process had already projected it. Request-shape mocks accepted these invalid combinations without exercising the real process or Rust parser boundary.
  • Fix: Send the non-empty provider-visible Session cwd on every Codex turn/start. Apply the same room-mount-to-logical-workspace projection used by thread/start and provider launch while preserving native Windows paths. Omit the POSIX /sandbox-tmp projection on Windows, and keep it on POSIX hosts where it represents the logical /tmp write target.
  • Validation: Cover a stored room mount root and child path, an already-logical workspace path, and a native Windows path in the emitted turn/start.cwd. Cover Windows and POSIX sandbox policy construction, and run the Windows contract test against a real pinned codex.exe app-server. The contract test submits the historical payload both without and with cwd as negative controls, then verifies that the production payload crosses the same parser without an AbsolutePathBuf error.

A Windows Codex session disappears while auth projection is starting

  • Symptom: session creation stalls until its request is canceled, and no runnable session remains visible. The log ends in Mutagen context canceled during auth projection.
  • Root cause: Mutagen setup inherited the transport request context. Closing or timing out that request killed a partially-created external sync session before runtime publication.
  • Fix: treat auth projection as Host-owned startup work: detach it from request cancellation, bound each Mutagen command to 20 seconds, and serialize setup for the same stable auth source. A guarded copy fallback is allowed only when Mutagen is unavailable or a failed session was successfully terminated; if cleanup cannot be confirmed, fail without starting a second projection.
  • Validation: cancel the caller context before projection and verify that the injected resolver and runner receive a live bounded context. Cover create and flush timeout, cleanup failure, copy reconciliation, and concurrent projections sharing one auth source.