consume the fork source on first bind (T-375)

widget.forkSourceId took precedence over the fresh/resume logic on
EVERY (re)bind, so /clear in a fork pane re-forked the original
conversation instead of clearing, and /resume re-forked the same way.
The source is now copied into one-shot pane state and cleared after
the first successful fork spawn; later respawns operate on the pane's
own session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:11:17 +02:00
co-authored by Claude Fable 5
parent 51957eb0ac
commit bbc6899df2
5 changed files with 43 additions and 1 deletions
@@ -4001,3 +4001,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCP03EJ9CDBGZGRPD19N8W', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:06:26', '2026-06-11 23:06:26', '2026-06-11 23:06:26', NULL, '4e1cc82f703d717f7592890e230caf00', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCP03EJ9CDBGZGRPD19N8W', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:06:26', '2026-06-11 23:06:26', '2026-06-11 23:06:26', NULL, '4e1cc82f703d717f7592890e230caf00', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:07:01', '2026-06-11 23:07:01', '2026-06-11 23:07:01', NULL, '795c139edb5acd0d2a187dac6a3066c7', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:07:01', '2026-06-11 23:07:01', '2026-06-11 23:07:01', NULL, '795c139edb5acd0d2a187dac6a3066c7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:08:13', '2026-06-11 23:08:13', '2026-06-11 23:08:13', NULL, 'f83a2fc13f3839070957bea6ac7fdc5f', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:08:13', '2026-06-11 23:08:13', '2026-06-11 23:08:13', NULL, 'f83a2fc13f3839070957bea6ac7fdc5f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:08:40', '2026-06-11 23:08:40', '2026-06-11 23:08:40', NULL, 'd61ecda5f46b3ad0e15ea566bc83a52c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:10:56', '2026-06-11 23:10:56', '2026-06-11 23:10:56', NULL, '4b505b885dc39785651e259370fbc97b', 2) ON CONFLICT(hash) DO NOTHING;
+10
View File
@@ -4109,3 +4109,13 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
Fix: hold a Map<String, Future<ManagedSession>> first caller installs the future synchronously, later callers await the same future; remove the entry on failure. Fix: hold a Map<String, Future<ManagedSession>> first caller installs the future synchronously, later callers await the same future; remove the entry on failure.
Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 23:08:13', NULL, 'fde3f9e912ea1d25256003ef56dcf491', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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); Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 23:08:13', NULL, 'fde3f9e912ea1d25256003ef56dcf491', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id.
Fix: treat forkSourceId as a one-shot spawn parameter consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session.
Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 23:08:40', NULL, '8e8a63e0617c3707db1cf5621f181b63', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id.
Fix: treat forkSourceId as a one-shot spawn parameter consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session.
Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 23:10:56', NULL, '1cd70120bb752815eeaebb58e57b1a1c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+5
View File
@@ -99,6 +99,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
check and the loser's live process was orphaned; spawns for an id are now check and the loser's live process was orphaned; spawns for an id are now
coalesced onto one in-flight future. (T-374) coalesced onto one in-flight future. (T-374)
- **`/clear` in a fork pane clears instead of re-forking.** The fork source
took precedence on every respawn, so clearing a fork tab silently branched
the original conversation again; the source now seeds only the first
bind. (T-375)
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.** - **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
session out of plan mode, but clide's tracked permission mode didn't follow, session out of plan mode, but clide's tracked permission mode didn't follow,
+9 -1
View File
@@ -81,6 +81,10 @@ class _ClaudePaneState extends State<ClaudePane> {
String? _error; String? _error;
String _statusLine = 'starting…'; String _statusLine = 'starting…';
/// One-shot fork source: seeds the first bind, then cleared so /clear,
/// /resume, and respawns operate on this pane's own session (T-375).
late String? _forkSource = widget.forkSourceId;
bool _spawned = false; bool _spawned = false;
/// Per-session composer draft (text + caret), held here so an unsent /// Per-session composer draft (text + caret), held here so an unsent
@@ -272,7 +276,7 @@ class _ClaudePaneState extends State<ClaudePane> {
} }
final ManagedSession managed; final ManagedSession managed;
final forkSource = widget.forkSourceId; final forkSource = _forkSource;
if (forkSource != null) { if (forkSource != null) {
// Fork pane: branch source session into a new clide-managed session. // Fork pane: branch source session into a new clide-managed session.
// The clide-internal id is a fresh UUID; the real claude session id is // The clide-internal id is a fresh UUID; the real claude session id is
@@ -286,6 +290,10 @@ class _ClaudePaneState extends State<ClaudePane> {
if (mounted) setState(() => _error = 'Could not start fork: $e'); if (mounted) setState(() => _error = 'Could not start fork: $e');
return; return;
} }
// One-shot: the fork source seeds only the FIRST bind. Leaving it set
// made /clear re-fork the original conversation instead of clearing —
// every later respawn must operate on this pane's own session (T-375).
_forkSource = null;
if (!mounted) return; if (!mounted) return;
setState(() => _statusLine = 'fork of $forkSource'); setState(() => _statusLine = 'fork of $forkSource');
} else { } else {
+17
View File
@@ -52,15 +52,18 @@ void main() {
late ClaudeSessionOrchestrator orch; late ClaudeSessionOrchestrator orch;
late String root; late String root;
final created = <_FakeProc>[]; final created = <_FakeProc>[];
final spawnArgs = <List<String>>[];
setUp(() async { setUp(() async {
f = await KernelFixture.create(); f = await KernelFixture.create();
created.clear(); created.clear();
spawnArgs.clear();
root = '/repo-a'; root = '/repo-a';
orch = ClaudeSessionOrchestrator( orch = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async { processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _FakeProc(); final p = _FakeProc();
created.add(p); created.add(p);
spawnArgs.add(sessionArgs);
return p; return p;
}, },
); );
@@ -175,6 +178,20 @@ void main() {
expect(orch.byId('primary')!.sessionId, id); expect(orch.byId('primary')!.sessionId, id);
}); });
testWidgets('/clear in a fork pane clears instead of re-forking (T-375)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false, isPrimary: false, secondaryIndex: 1, forkSourceId: 'source-session-uuid'));
// First bind forks from the source.
expect(spawnArgs.single, containsAll(['--fork-session', 'source-session-uuid']));
await act(tester, () => composer(tester).onSubmit('/clear'));
// The respawn must NOT fork the original again — the fork source is a
// one-shot spawn parameter consumed by the first bind.
expect(spawnArgs, hasLength(2));
expect(spawnArgs.last, isNot(contains('--fork-session')));
expect(spawnArgs.last, isNot(contains('source-session-uuid')));
});
testWidgets('/fork delegates to the onFork callback with the session id', (tester) async { testWidgets('/fork delegates to the onFork callback with the session id', (tester) async {
String? forkedWith; String? forkedWith;
await mount(tester, ClaudePane(showChrome: false, onFork: (sid) => forkedWith = sid)); await mount(tester, ClaudePane(showChrome: false, onFork: (sid) => forkedWith = sid));