| ... | @@ -277,6 +277,9 @@ describe("CheckpointReactor", () => { | ... | @@ -277,6 +277,9 @@ describe("CheckpointReactor", () => { |
| 277 | readonly seedFilesystemCheckpoints?: boolean; | 277 | readonly seedFilesystemCheckpoints?: boolean; |
| 278 | readonly projectWorkspaceRoot?: string; | 278 | readonly projectWorkspaceRoot?: string; |
| 279 | readonly threadWorktreePath?: string | null; | 279 | readonly threadWorktreePath?: string | null; |
| | 280 | readonly threadBranch?: string | null; |
| | 281 | readonly secondThreadSharingWorktree?: boolean; |
| | 282 | readonly localStatusRefName?: string | null; |
| 280 | readonly providerSessionCwd?: string; | 283 | readonly providerSessionCwd?: string; |
| 281 | readonly providerName?: ProviderDriverKind; | 284 | readonly providerName?: ProviderDriverKind; |
| 282 | readonly gitStatusRefreshCalls?: Array<string>; | 285 | readonly gitStatusRefreshCalls?: Array<string>; |
| ... | @@ -315,7 +318,8 @@ describe("CheckpointReactor", () => { | ... | @@ -315,7 +318,8 @@ describe("CheckpointReactor", () => { |
| 315 | isRepo: true, | 318 | isRepo: true, |
| 316 | hasPrimaryRemote: false, | 319 | hasPrimaryRemote: false, |
| 317 | isDefaultRef: true, | 320 | isDefaultRef: true, |
| 318 | refName: "main", | 321 | refName: |
| | 322 | options?.localStatusRefName !== undefined ? options.localStatusRefName : "main", |
| 319 | hasWorkingTreeChanges: false, | 323 | hasWorkingTreeChanges: false, |
| 320 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 324 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 321 | }), | 325 | }), |
| ... | @@ -370,22 +374,45 @@ describe("CheckpointReactor", () => { | ... | @@ -370,22 +374,45 @@ describe("CheckpointReactor", () => { |
| 370 | }), | 374 | }), |
| 371 | ); | 375 | ); |
| 372 | await Effect.runPromise( | 376 | await Effect.runPromise( |
| 373 | engine.dispatch({ | 377 | engine |
| 374 | type: "thread.create", | 378 | .dispatch({ |
| 375 | commandId: CommandId.make("cmd-thread-create"), | 379 | type: "thread.create", |
| 376 | threadId: ThreadId.make("thread-1"), | 380 | commandId: CommandId.make("cmd-thread-create"), |
| 377 | projectId: asProjectId("project-1"), | 381 | threadId: ThreadId.make("thread-1"), |
| 378 | title: "Thread", | 382 | projectId: asProjectId("project-1"), |
| 379 | modelSelection: { | 383 | title: "Thread", |
| 380 | instanceId: ProviderInstanceId.make("codex"), | 384 | modelSelection: { |
| 381 | model: "gpt-5-codex", | 385 | instanceId: ProviderInstanceId.make("codex"), |
| 382 | }, | 386 | model: "gpt-5-codex", |
| 383 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 387 | }, |
| 384 | runtimeMode: "approval-required", | 388 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 385 | branch: null, | 389 | runtimeMode: "approval-required", |
| 386 | worktreePath: options?.threadWorktreePath ?? cwd, | 390 | branch: options?.threadBranch ?? null, |
| 387 | createdAt, | 391 | worktreePath: options?.threadWorktreePath ?? cwd, |
| 388 | }), | 392 | createdAt, |
| | 393 | }) |
| | 394 | .pipe( |
| | 395 | options?.secondThreadSharingWorktree |
| | 396 | ? Effect.andThen( |
| | 397 | engine.dispatch({ |
| | 398 | type: "thread.create", |
| | 399 | commandId: CommandId.make("cmd-thread-create-2"), |
| | 400 | threadId: ThreadId.make("thread-2"), |
| | 401 | projectId: asProjectId("project-1"), |
| | 402 | title: "Thread 2", |
| | 403 | modelSelection: { |
| | 404 | instanceId: ProviderInstanceId.make("codex"), |
| | 405 | model: "gpt-5-codex", |
| | 406 | }, |
| | 407 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| | 408 | runtimeMode: "approval-required", |
| | 409 | branch: null, |
| | 410 | worktreePath: options?.threadWorktreePath ?? cwd, |
| | 411 | createdAt, |
| | 412 | }), |
| | 413 | ) |
| | 414 | : Effect.asVoid, |
| | 415 | ), |
| 389 | ); | 416 | ); |
| 390 | | 417 | |
| 391 | if (options?.seedFilesystemCheckpoints ?? true) { | 418 | if (options?.seedFilesystemCheckpoints ?? true) { |
| ... | @@ -518,6 +545,86 @@ describe("CheckpointReactor", () => { | ... | @@ -518,6 +545,86 @@ describe("CheckpointReactor", () => { |
| 518 | expect(gitStatusRefreshCalls).toEqual([harness.cwd]); | 545 | expect(gitStatusRefreshCalls).toEqual([harness.cwd]); |
| 519 | }); | 546 | }); |
| 520 | | 547 | |
| | 548 | it("adopts a drifted checkout as the thread branch on a dedicated worktree", async () => { |
| | 549 | const harness = await createHarness({ |
| | 550 | seedFilesystemCheckpoints: false, |
| | 551 | threadBranch: "t3code/original-branch", |
| | 552 | localStatusRefName: "t3code/renamed-by-agent", |
| | 553 | }); |
| | 554 | |
| | 555 | harness.provider.emit({ |
| | 556 | type: "turn.completed", |
| | 557 | eventId: EventId.make("evt-turn-completed-branch-drift"), |
| | 558 | provider: ProviderDriverKind.make("codex"), |
| | 559 | createdAt: "2026-01-01T00:00:00.000Z", |
| | 560 | threadId: ThreadId.make("thread-1"), |
| | 561 | turnId: asTurnId("turn-branch-drift"), |
| | 562 | payload: { state: "completed" }, |
| | 563 | }); |
| | 564 | |
| | 565 | await harness.drain(); |
| | 566 | await waitForEvent( |
| | 567 | harness.engine, |
| | 568 | (event) => |
| | 569 | event.type === "thread.meta-updated" && |
| | 570 | (event as unknown as { payload: { branch?: string } }).payload.branch === |
| | 571 | "t3code/renamed-by-agent", |
| | 572 | ); |
| | 573 | |
| | 574 | const snapshot = await harness.readModel(); |
| | 575 | const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| | 576 | expect(thread?.branch).toBe("t3code/renamed-by-agent"); |
| | 577 | }); |
| | 578 | |
| | 579 | it("does not adopt a drifted checkout when the worktree is shared by another thread", async () => { |
| | 580 | const harness = await createHarness({ |
| | 581 | seedFilesystemCheckpoints: false, |
| | 582 | threadBranch: "t3code/original-branch", |
| | 583 | localStatusRefName: "t3code/renamed-by-agent", |
| | 584 | secondThreadSharingWorktree: true, |
| | 585 | }); |
| | 586 | |
| | 587 | harness.provider.emit({ |
| | 588 | type: "turn.completed", |
| | 589 | eventId: EventId.make("evt-turn-completed-branch-drift-shared"), |
| | 590 | provider: ProviderDriverKind.make("codex"), |
| | 591 | createdAt: "2026-01-01T00:00:00.000Z", |
| | 592 | threadId: ThreadId.make("thread-1"), |
| | 593 | turnId: asTurnId("turn-branch-drift-shared"), |
| | 594 | payload: { state: "completed" }, |
| | 595 | }); |
| | 596 | |
| | 597 | await harness.drain(); |
| | 598 | |
| | 599 | const snapshot = await harness.readModel(); |
| | 600 | const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| | 601 | expect(thread?.branch).toBe("t3code/original-branch"); |
| | 602 | }); |
| | 603 | |
| | 604 | it("does not adopt a temporary placeholder checkout as the thread branch", async () => { |
| | 605 | const harness = await createHarness({ |
| | 606 | seedFilesystemCheckpoints: false, |
| | 607 | threadBranch: "t3code/original-branch", |
| | 608 | localStatusRefName: "t3code/0a1b2c3d", |
| | 609 | }); |
| | 610 | |
| | 611 | harness.provider.emit({ |
| | 612 | type: "turn.completed", |
| | 613 | eventId: EventId.make("evt-turn-completed-branch-drift-temp"), |
| | 614 | provider: ProviderDriverKind.make("codex"), |
| | 615 | createdAt: "2026-01-01T00:00:00.000Z", |
| | 616 | threadId: ThreadId.make("thread-1"), |
| | 617 | turnId: asTurnId("turn-branch-drift-temp"), |
| | 618 | payload: { state: "completed" }, |
| | 619 | }); |
| | 620 | |
| | 621 | await harness.drain(); |
| | 622 | |
| | 623 | const snapshot = await harness.readModel(); |
| | 624 | const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| | 625 | expect(thread?.branch).toBe("t3code/original-branch"); |
| | 626 | }); |
| | 627 | |
| 521 | it("ignores auxiliary thread turn completion while primary turn is active", async () => { | 628 | it("ignores auxiliary thread turn completion while primary turn is active", async () => { |
| 522 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); | 629 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); |
| 523 | const createdAt = "2026-01-01T00:00:00.000Z"; | 630 | const createdAt = "2026-01-01T00:00:00.000Z"; |