Skip to content

Daemon-side session-id fallback for hosted Claude agents#283

Open
stktung wants to merge 1 commit into
mainfrom
fix/daemon-session-id-fallback
Open

Daemon-side session-id fallback for hosted Claude agents#283
stktung wants to merge 1 commit into
mainfrom
fix/daemon-session-id-fallback

Conversation

@stktung

@stktung stktung commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Problem

A hosted agent's web chat can only attach once the server's registry entry for the agent has a SessionId. Today the only source of that link is the spawned Claude's session-start hook POSTing agent_host_id to /hooks/session-start. If that hook fails — the production case is an expired kcap token, so every /hooks POST 401s (/hooks requires auth) — the link is never made:

  • the daemon's AgentStatusChangedAsync calls always send SessionId = null (agent.SessionId was never assigned anywhere in the daemon), and
  • the server's recovery path (FindAgentSessionIdAsync) reads an AgentRun heartbeat that only the same hook handler writes.

Result: the agent page shows "Waiting for session to start..." forever while the terminal works fine, and there is no fallback.

Design

Make the daemon itself discover the spawned Claude's session id and report it over its own authenticated SignalR connection — hook-independent:

  1. Poll the Claude project dir — after a claude-vendor agent spawns, a per-agent background task (cancelled by the agent's existing CTS) polls {ClaudePaths.Projects}/{project-dir-hash(worktree)} every 2 s for up to 3 min for *.jsonl transcripts written at/after the spawn time.
  2. Verify candidates by cwd — the daemon symlinks the worktree's project dir to the SOURCE repo's (ClaudeLauncher.SymlinkClaudeProjectDir), which is shared with the user's own sessions in that repo, so a new .jsonl is not necessarily this agent's. Each candidate's first lines (read with FileShare.ReadWrite, partial/invalid JSONL lines tolerated, capped at 50 lines) must have a JSON "cwd" equal to the agent's worktree path (case-insensitive on Windows, separator/trailing-slash tolerant). The worktree is created per-agent, so a cwd match is definitive.
  3. Report both ways — on a match the id (dashless-normalized, matching NormalizeGuidField) is set on the agent and sent via AgentStatusChanged(agentId, status, sessionId) (live registry link — the server's CapacitorHub.AgentStatusChanged in kcap-server already accepts a sessionId) and AppendAgentRunEvent(new AgentRunHeartbeat(sessionId)), so the server's restart-recovery path works too. Once agent.SessionId is set, the existing 30 s heartbeat loop and reconnect re-registration keep re-sending it, so a transient report failure self-heals.
  4. Best-effort throughout — detection stops if the id arrives by other means (hook succeeded) or the agent exits; all failures are logged and never break the launch. Claude vendor only (other vendors have different transcript layouts).

The pure decision logic (cwd matching, filename → session-id parsing, spawn-time filtering) is extracted into SessionTranscriptLocator and unit-tested without a filesystem.

This complements server-side handling in kcap-server (CapacitorHub.AgentStatusChanged already accepts and stores a sessionId); no server change is required for the fallback to take effect.

Testing

  • Capacitor.Cli.Tests.Unit: 2489 passed, 0 failed (includes 21 new SessionTranscriptLocatorTests covering cwd match/mismatch, Windows case-insensitivity, trailing-separator and mixed-separator tolerance, invalid/partial JSON lines skipped, line cap, dashed→dashless id normalization, and spawn-time filtering).
  • AOT: the ILC compile phase completed with zero IL2026/IL3050 warnings locally; the final native link step fails on this dev machine (missing MSVC linker / vswhere.exe), so CI's AOT publish checks must confirm the publish end-to-end.

No GitHub or Linear issue exists for this; this description is self-contained.

🤖 Generated with Claude Code

…ranscript

A hosted agent's web chat can only attach once the server registry entry
has a SessionId, and the only source of that link was the spawned
Claude's session-start hook POSTing agent_host_id to /hooks/session-start.
When that hook fails (production case: expired kcap token, so every
/hooks POST 401s), the link is never made: the daemon always sent
SessionId = null, and the server's recovery path reads an AgentRun
heartbeat that only the same hook handler writes. The agent page then
shows "Waiting for session to start..." forever while the terminal works.

Make the daemon discover the session id itself: after spawning a
claude-vendor agent, poll the Claude project dir for the worktree
(every 2 s, up to 3 min) for a *.jsonl transcript written at/after the
spawn time. Because the daemon symlinks the worktree's project dir to
the SOURCE repo's (shared with the user's own sessions), each candidate
is verified by reading its first lines (FileShare.ReadWrite, tolerating
partial/invalid lines) and requiring the JSON "cwd" to equal the agent's
per-agent worktree path. On a match, the id (dashless-normalized, as in
NormalizeGuidField) is set on the agent and reported over the daemon's
own authenticated connection via AgentStatusChanged AND an
AgentRunHeartbeat, so the server's restart-recovery path
(FindAgentSessionIdAsync) works too. Once set, the existing 30 s
heartbeat loop and reconnect re-registration keep re-sending it, so a
transient report failure self-heals. Best-effort throughout; never
breaks the launch; cancelled with the agent.

The pure decision logic lives in SessionTranscriptLocator (cwd matching
with Windows case-insensitivity and separator tolerance, filename ->
session id parsing, spawn-time filtering) and is unit-tested without a
filesystem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stktung stktung marked this pull request as ready for review July 7, 2026 18:05
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@stktung stktung requested a review from alexeyzimarev July 7, 2026 18:05
@alexeyzimarev

Copy link
Copy Markdown
Member

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Fallback reports not independent 🐞 Bug ☼ Reliability
Description
In DetectClaudeSessionIdAsync, an exception from the awaited SignalR call AgentStatusChangedAsync
will skip the subsequent AppendAgentRunEventAsync(AgentRunHeartbeat), delaying the server’s recovery
signal until the normal heartbeat/reconnect paths run. This weakens the “report both ways” behavior
specifically during transient connection instability (one of the main scenarios this fallback should
tolerate).
Code

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[R1365-1368]

+                    if (!agent.IsPrivate) {
+                        await _server.AgentStatusChangedAsync(agent.Id, agent.Status, sessionId);
+                        await _server.AppendAgentRunEventAsync(agent.Id, new AgentRunHeartbeat(sessionId));
+                    }
Evidence
The new fallback reports via SignalR first and only then enqueues the agent-run heartbeat; since the
SignalR method can throw while the enqueue is non-throwing, a transient SignalR failure prevents the
heartbeat append from happening in that detection attempt. The existing heartbeat loop does
eventually enqueue heartbeats, but that’s on its own schedule, so the fallback becomes less
immediate precisely under reconnect/error conditions.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1357-1371]
src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[475-477]
src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[647-655]
src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1386-1404]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`DetectClaudeSessionIdAsync` awaits `_server.AgentStatusChangedAsync(...)` and then awaits `_server.AppendAgentRunEventAsync(...)` in the same block. If the first call throws (e.g., reconnect in progress), the second call is skipped and the method exits via the outer catch, which delays/weakens the fallback’s intended “report both ways” behavior.

### Issue Context
- `AgentStatusChangedAsync` is a SignalR `InvokeAsync` and can throw on disconnect / reconnect windows.
- `AppendAgentRunEventAsync` is a local enqueue into a bounded channel and is effectively non-throwing.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1360-1368]

Suggested implementation approach:
- Ensure the HTTP-backed run-event path is executed even if the SignalR call fails:
 - Either enqueue `AppendAgentRunEventAsync(... new AgentRunHeartbeat(sessionId))` *before* calling `AgentStatusChangedAsync`, and/or
 - Wrap each call in its own try/catch so one failure doesn’t prevent the other.
- Keep the method best-effort (no rethrow), but avoid losing the run-event append when the status update fails.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Repeated transcript directory scans 🐞 Bug ➹ Performance
Description
DetectClaudeSessionIdAsync polls every 2 seconds for up to 3 minutes, and each tick
SessionTranscriptLocator.TryLocate enumerates all *.jsonl files and may open/read up to 50 lines
per candidate, which can create avoidable sustained IO/CPU overhead in large shared Claude project
directories. This can degrade daemon responsiveness during the polling window on machines with many
transcript files.
Code

src/Capacitor.Cli.Daemon/Services/SessionTranscriptLocator.cs[R55-66]

+        foreach (var file in Directory.EnumerateFiles(projectDir, "*.jsonl")) {
+            try {
+                if (SessionIdFromFileName(file) is not { } sessionId) continue;
+
+                if (!IsNewEnough(File.GetCreationTimeUtc(file), File.GetLastWriteTimeUtc(file), spawnedAtUtc)) continue;
+
+                if (TryMatchTranscript(ReadFirstLines(file), worktreePath)) return sessionId;
+            } catch {
+                // Candidate vanished mid-scan, is locked, or is otherwise unreadable — skip it;
+                // the caller polls, so a transiently unreadable match is retried next tick.
+            }
+        }
Evidence
The new daemon task polls frequently (every 2s for up to 3 minutes) and calls TryLocate each time.
TryLocate currently iterates all transcript files in the directory and for each candidate may read
up to MaxLinesToInspect lines from disk, which can multiply into significant repeated work when many
transcript files exist in the shared per-repo Claude project directory.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1330-1374]
src/Capacitor.Cli.Daemon/Services/SessionTranscriptLocator.cs[35-43]
src/Capacitor.Cli.Daemon/Services/SessionTranscriptLocator.cs[52-66]
src/Capacitor.Cli.Daemon/Services/SessionTranscriptLocator.cs[153-164]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The fallback performs repeated full-directory scans of `*.jsonl` transcripts (and potentially reads up to 50 lines from many files) every 2 seconds for up to 3 minutes. On large project dirs, this can cause unnecessary repeated work and noticeable IO/CPU churn.

### Issue Context
The logic is time-bounded (3 minutes) and per-file line-bounded (50), but it is not bounded by number of files inspected per tick, and it does not avoid re-checking files already known to be non-matches.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1330-1374]
- src/Capacitor.Cli.Daemon/Services/SessionTranscriptLocator.cs[52-66]

Suggested implementation approach (pick one or combine):
- Prefer inspecting newest files first and cap per tick (e.g., order by `LastWriteTimeUtc` desc and `Take(N)`), since the target transcript should be among the newest after spawn.
- Cache negative candidates across ticks (e.g., keep a `HashSet<string>` of inspected filenames or track `lastSeenWriteTime`) to avoid re-opening the same files repeatedly.
- If you keep a time filter, apply it before opening files, and short-circuit scanning as soon as you hit sufficiently old files when iterating in descending timestamp order.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +1365 to +1368
if (!agent.IsPrivate) {
await _server.AgentStatusChangedAsync(agent.Id, agent.Status, sessionId);
await _server.AppendAgentRunEventAsync(agent.Id, new AgentRunHeartbeat(sessionId));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Fallback reports not independent 🐞 Bug ☼ Reliability

In DetectClaudeSessionIdAsync, an exception from the awaited SignalR call AgentStatusChangedAsync
will skip the subsequent AppendAgentRunEventAsync(AgentRunHeartbeat), delaying the server’s recovery
signal until the normal heartbeat/reconnect paths run. This weakens the “report both ways” behavior
specifically during transient connection instability (one of the main scenarios this fallback should
tolerate).
Agent Prompt
### Issue description
`DetectClaudeSessionIdAsync` awaits `_server.AgentStatusChangedAsync(...)` and then awaits `_server.AppendAgentRunEventAsync(...)` in the same block. If the first call throws (e.g., reconnect in progress), the second call is skipped and the method exits via the outer catch, which delays/weakens the fallback’s intended “report both ways” behavior.

### Issue Context
- `AgentStatusChangedAsync` is a SignalR `InvokeAsync` and can throw on disconnect / reconnect windows.
- `AppendAgentRunEventAsync` is a local enqueue into a bounded channel and is effectively non-throwing.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1360-1368]

Suggested implementation approach:
- Ensure the HTTP-backed run-event path is executed even if the SignalR call fails:
  - Either enqueue `AppendAgentRunEventAsync(... new AgentRunHeartbeat(sessionId))` *before* calling `AgentStatusChangedAsync`, and/or
  - Wrap each call in its own try/catch so one failure doesn’t prevent the other.
- Keep the method best-effort (no rethrow), but avoid losing the run-event append when the status update fails.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +55 to +66
foreach (var file in Directory.EnumerateFiles(projectDir, "*.jsonl")) {
try {
if (SessionIdFromFileName(file) is not { } sessionId) continue;

if (!IsNewEnough(File.GetCreationTimeUtc(file), File.GetLastWriteTimeUtc(file), spawnedAtUtc)) continue;

if (TryMatchTranscript(ReadFirstLines(file), worktreePath)) return sessionId;
} catch {
// Candidate vanished mid-scan, is locked, or is otherwise unreadable — skip it;
// the caller polls, so a transiently unreadable match is retried next tick.
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Repeated transcript directory scans 🐞 Bug ➹ Performance

DetectClaudeSessionIdAsync polls every 2 seconds for up to 3 minutes, and each tick
SessionTranscriptLocator.TryLocate enumerates all *.jsonl files and may open/read up to 50 lines
per candidate, which can create avoidable sustained IO/CPU overhead in large shared Claude project
directories. This can degrade daemon responsiveness during the polling window on machines with many
transcript files.
Agent Prompt
### Issue description
The fallback performs repeated full-directory scans of `*.jsonl` transcripts (and potentially reads up to 50 lines from many files) every 2 seconds for up to 3 minutes. On large project dirs, this can cause unnecessary repeated work and noticeable IO/CPU churn.

### Issue Context
The logic is time-bounded (3 minutes) and per-file line-bounded (50), but it is not bounded by number of files inspected per tick, and it does not avoid re-checking files already known to be non-matches.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs[1330-1374]
- src/Capacitor.Cli.Daemon/Services/SessionTranscriptLocator.cs[52-66]

Suggested implementation approach (pick one or combine):
- Prefer inspecting newest files first and cap per tick (e.g., order by `LastWriteTimeUtc` desc and `Take(N)`), since the target transcript should be among the newest after spawn.
- Cache negative candidates across ticks (e.g., keep a `HashSet<string>` of inspected filenames or track `lastSeenWriteTime`) to avoid re-opening the same files repeatedly.
- If you keep a time filter, apply it before opening files, and short-circuit scanning as soon as you hit sufficiently old files when iterating in descending timestamp order.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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.

2 participants