coalesce concurrent session spawns onto one future (T-374)

Orchestrator.spawn() check-then-acts on the session registry across
two awaits (transcript-tail read, process start) — two racing callers
for the same id both passed the check and the loser's live claude
process was orphaned, never killed, never observed. The first caller
now installs the spawn future synchronously; later callers await the
same future, and a failed spawn clears the entry so a retry proceeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:08:33 +02:00
co-authored by Claude Fable 5
parent 5d9443d9f7
commit 51957eb0ac
5 changed files with 65 additions and 1 deletions
@@ -188,7 +188,28 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
/// (T-269): the existing session belongs to the old repo, so it is torn down
/// and a fresh one spawned for the new repo — a pane must never inherit
/// another workspace's conversation.
Future<ManagedSession> spawn(SpawnSpec spec) async {
Future<ManagedSession> spawn(SpawnSpec spec) {
// Serialize concurrent spawns per id (T-374): the body check-then-acts
// on _sessions across two awaits, so two racing callers would both
// pass the check and the loser's live claude process would be orphaned.
// The first caller installs the future synchronously; the rest await
// it. (A racing different-cwd spawn for the same id also coalesces —
// the workspace-switch flow is sequential, so that pair never races.)
final inFlight = _spawning[spec.id];
if (inFlight != null) return inFlight;
final f = _spawn(spec);
_spawning[spec.id] = f;
unawaited(
f.then<void>((_) {}, onError: (Object _) {}).whenComplete(() {
if (identical(_spawning[spec.id], f)) _spawning.remove(spec.id);
}),
);
return f;
}
final Map<String, Future<ManagedSession>> _spawning = {};
Future<ManagedSession> _spawn(SpawnSpec spec) async {
final existing = _sessions[spec.id];
if (existing != null) {
if (existing.cwd == spec.cwd) return existing;