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:
@@ -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 ('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 ('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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
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.**
|
||||
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
|
||||
session out of plan mode, but clide's tracked permission mode didn't follow,
|
||||
|
||||
@@ -81,6 +81,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
String? _error;
|
||||
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;
|
||||
|
||||
/// Per-session composer draft (text + caret), held here so an unsent
|
||||
@@ -272,7 +276,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
|
||||
final ManagedSession managed;
|
||||
final forkSource = widget.forkSourceId;
|
||||
final forkSource = _forkSource;
|
||||
if (forkSource != null) {
|
||||
// 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
|
||||
@@ -286,6 +290,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
if (mounted) setState(() => _error = 'Could not start fork: $e');
|
||||
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;
|
||||
setState(() => _statusLine = 'fork of $forkSource');
|
||||
} else {
|
||||
|
||||
@@ -52,15 +52,18 @@ void main() {
|
||||
late ClaudeSessionOrchestrator orch;
|
||||
late String root;
|
||||
final created = <_FakeProc>[];
|
||||
final spawnArgs = <List<String>>[];
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
created.clear();
|
||||
spawnArgs.clear();
|
||||
root = '/repo-a';
|
||||
orch = ClaudeSessionOrchestrator(
|
||||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
final p = _FakeProc();
|
||||
created.add(p);
|
||||
spawnArgs.add(sessionArgs);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
@@ -175,6 +178,20 @@ void main() {
|
||||
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 {
|
||||
String? forkedWith;
|
||||
await mount(tester, ClaudePane(showChrome: false, onFork: (sid) => forkedWith = sid));
|
||||
|
||||
Reference in New Issue
Block a user