Skip to content

Keep a chat's agent process between its turns - #129

Closed
RestartFU wants to merge 6 commits into
masterfrom
persistent-agent-session
Closed

Keep a chat's agent process between its turns#129
RestartFU wants to merge 6 commits into
masterfrom
persistent-agent-session

Conversation

@RestartFU

Copy link
Copy Markdown
Owner

xd ran claude -p <prompt>, which answers once and exits. Everything the agent started went with it — a background shell, a watch, a build — and the next message met a process that had never heard of any of it.

--input-format stream-json takes turns on stdin and stays up between them. Verified against the real CLI before any of this was written: two turns down one pipe, one process, one session id, both answered.

Shape

  • backend.h gains encode_turn. A backend that provides it is kept; one that does not takes its prompt in argv and ends with the turn.
  • Only claude implements it. codex and cerebras are untouchedcodex exec reads stdin as the initial prompt only, so there is no equivalent short of the experimental app-server.
  • The daemon and the window each hold their chats' sessions and lend them to turns. A turn is still short-lived; the process answering it is not.

What forces a new process

Model, effort, access, working directory and backend are all argv, fixed once the process is up. can_continue refuses those turns and the caller starts a session instead — which is what it did for every turn before, so nothing that worked stops working. Switching model mid-chat behaves exactly as it does today; it just does not get the new benefit on that turn.

Three things the shape forced

  • The result line, not the exit, ends a turn. It is acted on after the line is parsed: one line carries several events, and whoever handles finished may drop the turn underneath the parser.
  • dispose must kill the process even when the turn finished, since "finished" no longer means nothing is running.
  • Neither caller took its listeners off a session, because the session used to die with the turn. Leaving them on points a live process at a freed turn.

Verification

22/22 suites. The new test counts launches, not anything the session reports: a session that quietly restarted between turns looks identical from the outside, and that is the failure worth catching. One launch, two turns.

The eight stand-in CLIs in the remote suite now read stdin before answering, which is the contract the real one has.

Not done

Cancel is still SIGINT, so Stop ends the process and the next message starts a fresh one — today's behaviour exactly. The CLI advertises interrupt_receipt_v1, so an in-band interrupt could later keep the process across a stop, but that control format is not in --help and I would not guess at it.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@RestartFU, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a452f3c-3cac-4512-8c5e-c678a00a67ef

📥 Commits

Reviewing files that changed from the base of the PR and between 1dba92a and aaa632e.

📒 Files selected for processing (10)
  • src/backend/backend.h
  • src/backend/claude-backend.c
  • src/chat/chat-session.c
  • src/chat/chat-session.h
  • src/chat/chat-view.c
  • src/remote/server.c
  • src/remote/turn.c
  • src/remote/turn.h
  • tests/test-remote.c
  • tests/test-session.c
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch persistent-agent-session

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@RestartFU
RestartFU marked this pull request as ready for review July 29, 2026 01:23
RestartFU and others added 5 commits July 28, 2026 21:25
Groundwork, not yet the behaviour: the callers still make a session per
turn, so nothing survives one yet. What exists now is a session that can
be asked for a second turn without restarting anything.

xd ran claude as "claude -p <prompt>", which answers once and exits. So
everything the agent started went with it -- a background shell, a watch,
a build -- and the next message met a process that had never heard of any
of it. The CLI's --input-format stream-json takes turns on stdin instead
and stays up between them.

Verified against the real CLI before writing any of this: two turns down
one pipe, one process, one session id, both answered.

Three things the shape forces:

Model, effort, access and working directory are argv, fixed once the
process is up. can_continue refuses a turn that changed any of them, and
the caller starts a session instead -- which is what it did for every
turn before.

The result line, not the exit, is what ends a turn now. It is noted and
acted on after the line is parsed: one line carries several events, and
whoever handles "finished" is entitled to drop the turn underneath the
parser.

Dispose kills the process even when the turn finished, because "finished"
no longer means there is nothing left running.

The stand-in CLIs in the remote suite now read stdin before answering,
which is the contract the real one has.

Cancel is still SIGINT, so stopping ends the process and the next message
starts a fresh one -- exactly today's behaviour. The CLI does advertise
interrupt_receipt_v1, so an in-band interrupt could keep the process
across a stop later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A session belongs to the CLI it was made for, and can_continue was
answering without looking at that -- so a chat switched from claude to
codex could have found a healthy process that was the wrong one to say
anything to, and a codex turn would have gone to claude in claude's own
wire format.

Only claude streams, so the switch is also what turns persistence off:
codex and cerebras take their prompt in argv and end with the turn,
exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The daemon now holds the sessions and lends them to turns, so a turn is
still the short-lived thing it was and the process answering it is not.
Where the CLI can take another turn it is given one; where it cannot -- a
changed model, effort, access, working directory or backend -- a new one
takes its place in the pool and the old one ends.

This is the half that makes the previous commit do something: until now
every turn built its own session, so the streaming protocol was in place
and nothing outlived a turn regardless.

Sessions are owned by the table, so emptying it stops the processes. A
deleted chat drops its own, which would otherwise sit waiting for a
message that cannot come.

The window still runs a session per turn; that caller is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The window now holds its sessions the way the daemon does: a turn borrows
the chat's agent and gives it back, so what the agent started is still
running when the next message arrives. A turn that the process cannot
serve gets its own and replaces it.

turn_free had no reason to take its listeners off the session before,
because the session died with the turn. It does not any more, and leaving
them on pointed a live process at a freed turn.

Deleting a chat drops its agent, which would otherwise wait for a message
that cannot come.

The test is by launch count, not by anything the session reports: a
session that quietly restarted between turns would look identical from
the outside, and that is the failure worth catching. Writing it turned up
the same listener problem in miniature -- one session, two turns, and the
first turn's handlers still attached to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reuse test had the stub shell append to a temporary file and then
read it back, which meant a Windows path being quoted into a shell script
through MSYS. It did not survive the trip, and Windows was the only place
that said so.

build_argv is called once per spawn and never for a continued turn, so
the count is already available in the test's own memory. No file, no
path, no shell to quote one through.

Two of my own bugs went with it: the printf format was written for the
g_strdup_printf that no longer wrapped it, so the stub emitted "%s"
rather than a result line; and each run's watchdog outlived the loop it
guarded, firing into a Run that had already been cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RestartFU
RestartFU force-pushed the persistent-agent-session branch from c8b6dd8 to 22c8f9b Compare July 29, 2026 01:26
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

xd/src/chat/chat-session.c

Lines 486 to 488 in c8b6dd8

return self->streaming && self->process != NULL &&
self->stdin_stream != NULL && !self->stopping &&
self->finished && matches_launch (self, spec);

P1 Badge Restart sessions when agent secrets change

When a user adds, rotates, or removes a global agent secret between Claude turns, this condition reuses the existing process even though secrets were injected only into its launch environment. Subsequent agent commands therefore miss newly added values and retain removed or obsolete credentials indefinitely. Include a snapshot/version of the effective secrets in the launch identity, or evict pooled sessions whenever secrets are saved.


xd/src/chat/chat-session.c

Lines 467 to 470 in c8b6dd8

return g_strcmp0 (self->launched_model, spec->model) == 0 &&
g_strcmp0 (self->launched_workdir, spec->workdir) == 0 &&
self->launched_effort == spec->effort &&
self->launched_access == spec->access;

P2 Badge Restart sessions when system instructions change

When folder or project instructions are edited between Claude turns, both the local and daemon turn paths resolve the new system prompt, but this launch comparison ignores it and reuses the old process. Because Claude receives --append-system-prompt only at process launch, the next turn silently follows the stale instructions. Compare the effective system prompt here or otherwise force a new process when it changes.


/* The docker client is what is killed; what it was told to build stops with
* it, and the next run picks up whatever layers it did finish. */
build->stopped = TRUE;
g_cancellable_cancel (build->cancellable);
g_subprocess_force_exit (build->process);

P2 Badge Terminate the build process tree when stopping

When Stop is pressed during a Docker build, build->process is the sh -c wrapper created above, so g_subprocess_force_exit() kills only that shell rather than its Docker child. The Docker client—and potentially its build—can continue consuming resources until it later notices the closed output pipe. Start the build in a controllable process group and terminate the group, or invoke a cancellation mechanism that reaches Docker itself.


xd/src/chat/chat-session.c

Lines 111 to 115 in c8b6dd8

self->stdin_stream = NULL;
if (g_subprocess_wait_check_finish (G_SUBPROCESS (source), result, &error))
{
finish (self, TRUE, NULL);

P2 Badge Treat premature clean exits as failed turns

When a streaming Claude process exits with status 0 before emitting an AI_EVENT_RESULT—for example because the CLI terminates unexpectedly during a turn—this path clears its input stream and then reports the unfinished turn as successful. The local and daemon callers consequently persist partial or empty output without an error. For streaming sessions, a clean process exit should still fail any turn whose result has not completed.


xd/src/ui/updater.c

Lines 380 to 382 in c8b6dd8

XdUpdater *self = user_data;
xd_branch_build_dialog_present (GTK_WIDGET (self), on_branch_installed, self);

P2 Badge Prevent branch builds from racing normal updates

When an update is available or already installing, this independently enabled branch action can start a second installer; likewise, the normal update button remains usable while a branch build runs. Both paths eventually remove and replace the same nightly installation directory, so overlapping them can make either installer fail or leave a mixed/unknown installed result. Coordinate the two operations or disable each installation path while the other is active.


gboolean ok = g_subprocess_wait_check_finish (G_SUBPROCESS (source), result,
&error);
build->running = FALSE;
g_clear_object (&build->process);
g_clear_handle_id (&build->flush_id, g_source_remove);
g_clear_pointer (&build->trouble, g_free);
if (!ok)
{
build->trouble = build->stopped
? g_strdup ("Stopped.")
: g_strdup_printf ("%s did not build.", build->label);

P2 Badge Drain build output before reporting failure

When the build exits after writing its final diagnostics, the wait callback can run before the lower-priority asynchronous stdout reader has drained those lines. This immediately renders the failure tail with incomplete output and sets running false; later reads append the missing lines, but flush_output() refuses to update a non-running build, so the user never sees the actual compiler or installer error. Delay final failure rendering until EOF has been observed, or refresh the displayed tail after late reads.


xd/src/chat/chat-session.c

Lines 308 to 312 in c8b6dd8

if (!g_output_stream_write_all (self->stdin_stream, framed, strlen (framed),
NULL, NULL, error))
return FALSE;
return g_output_stream_flush (self->stdin_stream, NULL, error);

P2 Badge Avoid blocking while writing streaming prompts

When a prompt plus handover exceeds the pipe buffer, or when an otherwise live CLI stops consuming stdin, this synchronous write_all runs on the GTK or daemon main context and can block the entire UI/server indefinitely. Prompts are not inherently short, and the call has no cancellable or timeout. Queue an asynchronous write while preserving the existing one-turn-per-chat serialization, and report completion or failure before marking the turn started.


xd/src/chat/chat-view.c

Lines 3026 to 3032 in c8b6dd8

XdChatSession *pooled = g_hash_table_lookup (self->sessions, chat->id);
gboolean continuing =
pooled != NULL && xd_chat_session_can_continue (pooled, backend, &spec);
gboolean started;
turn->session = continuing ? g_object_ref (pooled)
: xd_chat_session_new (backend);

P2 Badge Do not retry continued turns as stale resumes

When a reused Claude process reports a per-turn error before producing text, turn->resumed is still true because a session ID exists in storage even though this turn continued the already-running process rather than launching with --resume. The existing stale-resume recovery then clears that ID and calls start_turn() with the same prompt, and this pooling decision can immediately hand the retry back to the same process, causing an unexpected duplicate request. Mark a turn resumed only when a new process was actually launched with the stored ID, or evict the pooled session before stale-session recovery.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Three from the Codex review, all the same shape: something is fixed when
the process starts, and reusing the process made a change to it look like
it had taken effect when it had not.

Agent secrets are the worst of them, because they are environment rather
than argv and no argv comparison would ever have caught them. A rotated
key leaves every name identical, so the secrets are applied to an empty
environment and hashed: a changed value now shows up the same way a
changed name does, and the chat gets a process that has it.

Instructions were being stored and then not compared. Editing a folder's
instructions between turns left the running process on the old ones,
silently, for as long as the chat stayed open.

And a streaming process that exits cleanly in the middle of a turn was
reported as a successful turn, storing whatever partial text had arrived
as though it were the whole reply. It exits only after a turn, so a clean
exit with one still open is the CLI going away mid-answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aaa632ecc3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/chat/chat-session.c
Comment on lines +669 to 670
if (self->process != NULL && (self->streaming || !self->finished))
g_subprocess_force_exit (self->process);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Explicitly stop streaming sessions before releasing them

When an idle streaming session is removed because the model/settings changed, its chat was deleted, or the view was closed, this cleanup is never reached: both outstanding read_line_async calls hold a reference to self, and the persistent child keeps those reads pending indefinitely. Consequently, releasing the pool's reference cannot invoke dispose, so the old Claude process and anything it started remain alive; repeatedly replacing sessions accumulates orphaned agents. Add an explicit shutdown operation that cancels the reads and terminates the child before removing a pooled session rather than relying on final unref.

Useful? React with 👍 / 👎.

Comment thread src/chat/chat-view.c
Comment on lines +3027 to +3028
gboolean continuing =
pooled != NULL && xd_chat_session_can_continue (pooled, backend, &spec);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not classify continued turns as resumed launches

After the first Claude response, resume_session_id remains stored, so turn->resumed is true even when this branch continues the already-running process instead of launching with --resume. If such a continued turn returns an error before emitting text or a tool, the existing stale-session recovery path clears a valid session ID and resends the prompt; the pooled process can still pass this continuation check, causing the supposed fresh retry to be written back to the same failed process. Set the resumed flag from whether a new process was actually started with the resume ID, and evict the pooled session when performing a stale-session retry.

Useful? React with 👍 / 👎.

Comment thread src/chat/chat-session.c
Comment on lines +361 to +365
if (!g_output_stream_write_all (self->stdin_stream, framed, strlen (framed),
NULL, NULL, error))
return FALSE;

return g_output_stream_flush (self->stdin_stream, NULL, error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move prompt writes off the main loop

When an encoded prompt exceeds the child pipe's available capacity, or the CLI pauses or stops reading stdin, write_all and flush block the application's main loop. A large pasted prompt can therefore freeze the local UI and the remote daemon until the child consumes it; on initial startup, a child that fills stdout before reading stdin can deadlock because stdout reads are not queued until after this function returns. Serialize turns with asynchronous writes or otherwise make the pipe nonblocking instead of performing an unbounded synchronous write.

Useful? React with 👍 / 👎.

Comment thread src/chat/chat-session.c
Comment on lines +330 to +332
qsort (applied, g_strv_length (applied), sizeof (char *), compare_strings);
for (gsize i = 0; applied[i] != NULL; i++)
g_checksum_update (sum, (const guchar *) applied[i], -1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delimit secret entries before hashing them

When the configured secrets change between two valid sets whose concatenated environment strings are identical, this fingerprint falsely reports that the process environment is unchanged. For example, {A: "BC=D"} hashes the same byte sequence as {A: "B", C: "D"} because both feed A=BC=D into the checksum, so the old agent is reused with stale credentials and without the newly requested variable. Hash each entry with an unambiguous length prefix or delimiter.

Useful? React with 👍 / 👎.

Comment thread src/chat/chat-session.c
Comment on lines +527 to +531
const AiRunSpec *spec,
const char *secrets)
{
return g_strcmp0 (self->launched_model, spec->model) == 0 &&
g_strcmp0 (self->launched_workdir, spec->workdir) == 0 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate pooled sessions after external turns

When a daemon or second window answers this chat while its process is idle here, the database's backend last_seen advances but this predicate still reuses the older in-memory process because it compares only launch settings. The next handover is then empty—storage says the backend has already seen the external turn—while the reused process has not actually seen those messages, so it answers from stale conversation state and may overwrite the current session metadata. Track the transcript position represented by each pooled process and reject it when storage has advanced externally.

Useful? React with 👍 / 👎.

@RestartFU RestartFU closed this Jul 30, 2026
RestartFU added a commit that referenced this pull request Aug 8, 2026
Re-does PR #129, whose design was right and whose code was against the C
daemon that no longer exists.

`claude -p <prompt>` answers once and exits, and everything it started
goes with it -- a background shell, a watch, a monitor. That is why a
background task started in one turn is dead by the next, and why a monitor
armed to report on something never reports.

`--input-format stream-json` takes turns on stdin and stays up between
them. Checked against the real CLI before any of this was written: two
turns down one pipe answered ONE and TWO under one session id, and a
`sleep` backgrounded in the first turn answered ALIVE in the second.

The turn is still short-lived; the process answering it is not. Output is
lent to the turn and handed back when it ends, so the reader still owns it
alone. A turn now ends at the result event rather than at end of file --
for a kept process end of file means it died.

A process is reused only for a turn that wants exactly what it was started
with: model, effort, access, working directory, system prompt and
environment are all fixed in argv, so anything else takes a new one.
Cancelling kills it, since there is no way to interrupt one turn and leave
the process fit for the next, and the next message starts fresh.

codex is untouched. `codex exec` reads stdin as the initial prompt only,
so there is no equivalent short of its experimental app-server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant