rebind the Claude pane on an in-place workspace switch (T-269)
Separate clide windows are isolated (own process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows were already fine. But switching the workspace in place (Open Project / Open Folder) only emitted ProjectOpened — nothing rebound the Claude session, so the primary pane kept the PREVIOUS repo's conversation. Two compounding causes, fixed in layers: - ClaudeSessionOrchestrator.spawn() was idempotent on the literal key 'primary' without checking cwd, so it handed the old repo's session to the new repo. It now reuses a cached session only when its cwd matches the spec; a mismatch tears the stale one down and spawns fresh. - The primary ClaudePane is built once behind a GlobalKey and spawns once, so it never re-resolved. It now listens for ProjectOpened and rebinds: close its orchestrator entry, drop the cached session id + repo root, and respawn against the now-active repo. Secondaries don't self-rebind. - ClaudeSessionHost drops the old repo's secondary/fork tabs on a switch, so a switched workspace starts like a fresh launch (lone primary). - The extension closes any remaining sessions whose cwd != the new root, catching team/non-pane sessions no pane owns. Tested at the orchestrator: cwd-aware idempotency (reuse on cwd match, teardown + respawn on mismatch). Pane/host widget coverage is intentionally deferred — claude_pane.dart has no widget-test harness yet and pulling it into coverage piecemeal would drop the gate; tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -764,3 +764,5 @@ Refs: D-78 (interaction zone / display-only conversation widgets). Built on T-16
|
||||
SEQUENCING UPDATE (after T-266 refinement): the shared container/holder primitive now lives in T-266 and is CONSUMED by T-264 (nested agent run), so T-266 is no longer "last" — its primitive lands before/with T-264. T-264 is now blocked by T-266. Revised order: T-262 / T-263 (fold success result, fold agent prompt) → T-266 (shared holder/container primitive + activity-card restyle) → T-264 (nest the whole agent run on that primitive) → T-265 (relabel sidechain prose) can land anytime alongside.', NULL, '2026-06-07 08:49:16', '2026-06-07 08:49:16', '2026-06-07 08:49:16', NULL, '7795b980e57c7bf096b495b3f61c8289', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-255', 'status', 'in_progress', 'done', NULL, '2026-06-07 08:51:08', '2026-06-07 08:51:08', '2026-06-07 08:51:08', NULL, '68cf2ebb539ca57c1c8165acecf3c111', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-268', 'status', 'backlog', 'done', NULL, '2026-06-07 09:36:59', '2026-06-07 09:36:59', '2026-06-07 09:36:59', NULL, 'dcce34ebf831a8836fe31bcad6bab92c', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-269', 'status', 'backlog', 'done', NULL, '2026-06-07 09:51:32', '2026-06-07 09:51:32', '2026-06-07 09:51:32', NULL, '3fec60e8de272979ad791c313eac76f8', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'status', 'backlog', 'ready', NULL, '2026-06-07 10:03:36', '2026-06-07 10:03:36', '2026-06-07 10:03:36', NULL, '643716825d71326f6066fb04cbb67077', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -1597,3 +1597,27 @@ Fix: /clear in the PRIMARY pane should empty the deterministic session in place
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-268', 'bug', NULL, 'primary /clear must clear the deterministic session in place, not orphan to a fresh id', 'The primary Claude pane is anchored to a deterministic session id derived from the repo path (primarySessionId), so it resumes the same transcript across restarts (D-77/T-146). But clide-owned /clear (T-156) respawns on a fresh RANDOM id (freshSessionId). Next clide launch recomputes the deterministic id, finds its old transcript on disk, and resumes the PRE-clear conversation — the cleared session is orphaned. Continuity appears broken.
|
||||
|
||||
Fix: /clear in the PRIMARY pane should empty the deterministic session in place — delete its <id>.jsonl (and sidecar <id>/ dir) so ''claude --session-id'' re-creates it empty, then respawn on the SAME deterministic id. Secondary panes are throwaway and keep the fresh-random behavior. Factor the transcript path/dir construction (duplicated in claude_pane._spawn and _resumeFlow) into session_naming helpers (claudeProjectDir/claudeTranscriptPath/clearSessionTranscript) so the clear logic is DRY and unit-testable.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:35:03', '2026-06-07 09:36:59', NULL, '824de390dc6da465575d998291976377', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-269', 'bug', NULL, 'primary Claude pane keeps the previous repo''s session after an in-place workspace switch', 'Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine. The bug is the in-place workspace switch (Open Project / Open Folder -> kernel.project.open, app.dart:456/474): it only emits ProjectOpened and updates settings (project.dart:147) — nothing tears down Claude sessions.
|
||||
|
||||
Two compounding causes:
|
||||
1. ClaudeSessionOrchestrator.spawn() is idempotent on the literal key ''primary'' WITHOUT checking cwd (session_orchestrator.dart:192-194) — it returns the old repo''s session for the new repo.
|
||||
2. ClaudePane spawns once behind a _spawned guard, under a GlobalKey host (claude_pane.dart:140, extension.dart:33/52) — so after an in-place switch it never re-resolves; it keeps the previous repo''s session/conversation.
|
||||
|
||||
Fix (T-269):
|
||||
- Orchestrator safety net: spawn() must reuse an existing session only when its cwd matches spec.cwd; a cwd mismatch is a stale cross-workspace binding -> close + spawn fresh.
|
||||
- ClaudePane (primary) listens for ProjectOpened and rebinds: close its orch entry, null out _sessionId/_repoRoot, respawn for the new repo. Secondaries do not self-rebind.
|
||||
- ClaudeSessionHost listens for ProjectOpened and drops secondary/fork tabs (the old repo''s), keeping just the primary — a switched workspace starts like a fresh launch.
|
||||
- Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'backlog', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:44:17', NULL, 'e2da4b11d8f64cfeddeab64f6407004a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-269', 'bug', NULL, 'primary Claude pane keeps the previous repo''s session after an in-place workspace switch', 'Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine. The bug is the in-place workspace switch (Open Project / Open Folder -> kernel.project.open, app.dart:456/474): it only emits ProjectOpened and updates settings (project.dart:147) — nothing tears down Claude sessions.
|
||||
|
||||
Two compounding causes:
|
||||
1. ClaudeSessionOrchestrator.spawn() is idempotent on the literal key ''primary'' WITHOUT checking cwd (session_orchestrator.dart:192-194) — it returns the old repo''s session for the new repo.
|
||||
2. ClaudePane spawns once behind a _spawned guard, under a GlobalKey host (claude_pane.dart:140, extension.dart:33/52) — so after an in-place switch it never re-resolves; it keeps the previous repo''s session/conversation.
|
||||
|
||||
Fix (T-269):
|
||||
- Orchestrator safety net: spawn() must reuse an existing session only when its cwd matches spec.cwd; a cwd mismatch is a stale cross-workspace binding -> close + spawn fresh.
|
||||
- ClaudePane (primary) listens for ProjectOpened and rebinds: close its orch entry, null out _sessionId/_repoRoot, respawn for the new repo. Secondaries do not self-rebind.
|
||||
- ClaudeSessionHost listens for ProjectOpened and drops secondary/fork tabs (the old repo''s), keeping just the primary — a switched workspace starts like a fresh launch.
|
||||
- Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:51:32', NULL, '9b92ed68f5c352d5461c01d7e98c0b50', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'story', NULL, 'application menu bar (Zed-style)', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:30:09', '2026-06-07 10:03:36', NULL, 'e09873c62cfab523b2b4048d9681c9ae', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-270', 'story', NULL, 'make sidebar filter boxes addressable via MessageBus (clide ui filter)', NULL, 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-07 10:23:13', '2026-06-07 10:23:13', NULL, '0d79acd088da9d0b240ee5473c3a21c4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -154,6 +154,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching the workspace in place (Open Project/Folder) now rebinds the Claude
|
||||
pane to the new repo's session instead of keeping the previous repo's
|
||||
conversation, and drops the old repo's secondary tabs. Separate windows were
|
||||
already isolated — this only affected reusing one window for another repo.
|
||||
(T-269)
|
||||
- `/clear` in the primary Claude pane now clears that session **in place** —
|
||||
it empties the pane's deterministic, restart-stable session instead of
|
||||
starting a throwaway random one. Previously a cleared primary was orphaned:
|
||||
|
||||
@@ -67,6 +67,7 @@ class ClaudePane extends StatefulWidget {
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
StreamSubscription<SessionStatus>? _statusSub;
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
ConversationController? _conversation;
|
||||
StreamJsonSession? _session;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
@@ -145,12 +146,19 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
unawaited(activeClaudeConfig?.ensureProbe());
|
||||
// Reflect skills/config changes in the status line (T-154).
|
||||
activeClaudeConfig?.addListener(_onConfigChanged);
|
||||
// Rebind to the new repo's session when the workspace is switched in
|
||||
// place (Open Project/Folder). This pane is built once behind a
|
||||
// GlobalKey and spawns once, so without this it would keep the previous
|
||||
// repo's session after a switch (T-269).
|
||||
_projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
activeClaudeConfig?.removeListener(_onConfigChanged);
|
||||
_projectSub?.cancel();
|
||||
_projectSub = null;
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
// The orchestrator owns the session, so disposing this pane does NOT kill
|
||||
@@ -185,6 +193,37 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
return _spawn();
|
||||
}
|
||||
|
||||
/// Rebind to the active workspace when the project is switched in place
|
||||
/// (T-269). Only the primary rebinds — secondaries/forks belong to the old
|
||||
/// repo and are dropped by the host. A no-op when the path is unchanged or
|
||||
/// the session hasn't resolved its repo yet.
|
||||
void _onProjectChanged(ProjectOpened e) {
|
||||
if (!mounted || !widget.isPrimary) return;
|
||||
if (_repoRoot == null || e.path == _repoRoot) return;
|
||||
unawaited(_rebindToActiveProject());
|
||||
}
|
||||
|
||||
/// Tear down the current session and respawn against the now-active
|
||||
/// workspace: drop the cached session id and repo root so [_spawn]
|
||||
/// re-resolves both for the new repo (T-269).
|
||||
Future<void> _rebindToActiveProject() async {
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
|
||||
_conversation = null;
|
||||
_session = null;
|
||||
_sessionId = null;
|
||||
_repoRoot = null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_status = const SessionStatus();
|
||||
_error = null;
|
||||
_statusLine = 'starting…';
|
||||
});
|
||||
}
|
||||
await _spawn();
|
||||
}
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
final ipc = _ipc();
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -21,6 +24,9 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
late final MultitabController<_Session> _controller;
|
||||
int _nextSecondary = 1;
|
||||
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
String? _projectRoot;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -39,8 +45,34 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_projectSub ??= ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
|
||||
}
|
||||
|
||||
/// Reset to a lone primary tab when the workspace is switched in place
|
||||
/// (T-269): the old repo's secondaries/forks don't belong in the new
|
||||
/// workspace. Removing them disposes their panes, which close their sessions
|
||||
/// through the orchestrator. The primary tab stays and rebinds itself.
|
||||
void _onProjectChanged(ProjectOpened e) {
|
||||
final prev = _projectRoot;
|
||||
_projectRoot = e.path;
|
||||
if (prev == null || prev == e.path) return; // initial open / no change
|
||||
if (!mounted) return;
|
||||
final stale = _controller.entries.where((x) => x.id != _primaryId).map((x) => x.id).toList();
|
||||
if (stale.isEmpty) return;
|
||||
setState(() {
|
||||
for (final id in stale) {
|
||||
_controller.remove(id);
|
||||
}
|
||||
_nextSecondary = 1;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_projectSub?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ class ClaudeExtension extends ClideExtension {
|
||||
ClaudeSessionOrchestrator? _orchestrator;
|
||||
final List<StreamSubscription<dynamic>> _subs = [];
|
||||
|
||||
/// Active workspace root, tracked so an in-place project switch can tear
|
||||
/// down the previous repo's sessions (T-269).
|
||||
String? _projectRoot;
|
||||
|
||||
/// App-wide Claude environment (skills, commands, settings, permissions,
|
||||
/// slash list). Built and loaded at activation (D-76, T-151).
|
||||
ClaudeConfig? get config => _config;
|
||||
@@ -324,12 +328,34 @@ class ClaudeExtension extends ClideExtension {
|
||||
_orchestrator = ClaudeSessionOrchestrator();
|
||||
activeSessionOrchestrator = _orchestrator;
|
||||
|
||||
// An in-place workspace switch (Open Project/Folder) must not leave the
|
||||
// previous repo's sessions running — including team/non-pane sessions the
|
||||
// panes don't own. Close every session that doesn't belong to the new
|
||||
// root; panes rebind themselves to the new repo (T-269).
|
||||
_subs.add(ctx.events.on<ProjectOpened>().listen(_onProjectChanged));
|
||||
|
||||
// `clide image show <path>` (T-249): the dispatcher resolves + publishes an
|
||||
// 'image' message; we inject the matching card into the conversation the
|
||||
// user is looking at (the primary lead, else the first visible session).
|
||||
_subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow));
|
||||
}
|
||||
|
||||
/// Close every session that doesn't belong to the newly-active workspace
|
||||
/// after an in-place project switch (T-269). The initial open (no previous
|
||||
/// root) and a no-op re-open are skipped. Panes rebind to the new repo on
|
||||
/// their own; this catches team/orphan sessions no pane owns.
|
||||
void _onProjectChanged(ProjectOpened e) {
|
||||
final prev = _projectRoot;
|
||||
_projectRoot = e.path;
|
||||
if (prev == null || prev == e.path) return;
|
||||
final orch = _orchestrator;
|
||||
if (orch == null) return;
|
||||
final stale = orch.sessions.where((m) => m.cwd != e.path).map((m) => m.id).toList();
|
||||
for (final id in stale) {
|
||||
unawaited(orch.close(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject an [ImageMessage] from a published `image` bus message (T-249).
|
||||
/// Dropped silently if no live conversation is available — the CLI already
|
||||
/// reported success at publish time, and a missing pane is transient.
|
||||
|
||||
@@ -187,11 +187,20 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
|
||||
ManagedSession? byId(String id) => _sessions[id];
|
||||
|
||||
/// Spawn and register a session. Idempotent on [SpawnSpec.id] — a repeat
|
||||
/// call returns the existing session rather than starting a second process.
|
||||
/// Spawn and register a session. Idempotent on [SpawnSpec.id] *within a
|
||||
/// workspace* — a repeat call for the same [SpawnSpec.cwd] returns the
|
||||
/// existing session rather than starting a second process (the fast path that
|
||||
/// lets a hidden/kept-alive pane keep its session). A repeat call with the
|
||||
/// SAME id but a DIFFERENT cwd means the workspace was switched in place
|
||||
/// (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 {
|
||||
final existing = _sessions[spec.id];
|
||||
if (existing != null) return existing;
|
||||
if (existing != null) {
|
||||
if (existing.cwd == spec.cwd) return existing;
|
||||
await close(spec.id);
|
||||
}
|
||||
|
||||
// Team sessions host the clide-team MCP server and get a roster + role
|
||||
// injected into their system prompt (T-170). Register the member before
|
||||
|
||||
@@ -47,11 +47,11 @@ ClaudeSessionOrchestrator _orch(List<_FakeProc> created) {
|
||||
);
|
||||
}
|
||||
|
||||
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath}) => SpawnSpec(
|
||||
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath, String cwd = '/repo'}) => SpawnSpec(
|
||||
id: id,
|
||||
role: id,
|
||||
sessionId: '$id-uuid',
|
||||
cwd: '/repo',
|
||||
cwd: cwd,
|
||||
resume: resume,
|
||||
transcriptPath: transcriptPath,
|
||||
);
|
||||
@@ -140,6 +140,40 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- in-place workspace switch (T-269) ---------------------------------
|
||||
|
||||
group('spawn — cwd-aware idempotency (T-269)', () {
|
||||
late List<_FakeProc> created;
|
||||
late ClaudeSessionOrchestrator orch;
|
||||
|
||||
setUp(() {
|
||||
created = [];
|
||||
orch = _orch(created);
|
||||
});
|
||||
|
||||
tearDown(() => orch.dispose());
|
||||
|
||||
test('same id + same cwd reuses the session (no second process)', () async {
|
||||
final a = await orch.spawn(_spec('primary', cwd: '/repo-a'));
|
||||
final b = await orch.spawn(_spec('primary', cwd: '/repo-a'));
|
||||
expect(identical(a, b), isTrue);
|
||||
expect(created, hasLength(1));
|
||||
});
|
||||
|
||||
test('same id + different cwd tears down the old session and spawns fresh', () async {
|
||||
// Workspace switched in place: the new repo must NOT inherit the old
|
||||
// repo's 'primary' session.
|
||||
final a = await orch.spawn(_spec('primary', cwd: '/repo-a'));
|
||||
final b = await orch.spawn(_spec('primary', cwd: '/repo-b'));
|
||||
expect(identical(a, b), isFalse);
|
||||
expect(b.cwd, '/repo-b');
|
||||
expect(created, hasLength(2));
|
||||
expect(created.first.killed, isTrue, reason: 'old repo session is killed');
|
||||
expect(orch.sessions, hasLength(1));
|
||||
expect(orch.byId('primary')!.cwd, '/repo-b');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- /resume — bind to an existing session via orchestrator -------------
|
||||
|
||||
group('/resume — resume an existing session via orchestrator', () {
|
||||
|
||||
Reference in New Issue
Block a user