@@ -3,6 +3,7 @@ import { expect, it, vi } from "vite-plus/test";
33import { it as effectIt } from "@effect/vitest" ;
44import {
55 CheckpointScopeId ,
6+ GitCommandError ,
67 MessageId ,
78 NodeId ,
89 ProviderSessionId ,
@@ -33,6 +34,8 @@ import * as Schema from "effect/Schema";
3334import * as ServerConfig from "../config.ts" ;
3435import { withWorkspaceLease } from "../workspace/workspaceLease.ts" ;
3536import * as GitWorkflow from "../git/GitWorkflowService.ts" ;
37+ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts" ;
38+ import { layerRealGit } from "./ProviderTurnStartService.testkit.ts" ;
3639import * as ProjectService from "../project/ProjectService.ts" ;
3740import * as ProviderAuthService from "../provider/ProviderAuthService.ts" ;
3841import * as ServerSettings from "../serverSettings.ts" ;
@@ -121,6 +124,7 @@ function startWithMissingWorktree(
121124 const startRootRun = vi . fn ( ( ) => Effect . void ) ;
122125 const pruneWorktrees = vi . fn ( ( ) => Effect . void ) ;
123126 const createWorktree = vi . fn ( ( ) => Effect . succeed ( { } as never ) ) ;
127+ const listLocalBranchNames = vi . fn ( ( ) => Effect . succeed ( [ "feature/restore" ] ) ) ;
124128 const layer = ProviderTurnStart . layer . pipe (
125129 Layer . provide (
126130 Layer . mergeAll (
@@ -142,7 +146,11 @@ function startWithMissingWorktree(
142146 ) ,
143147 } as never ) ,
144148 layerWorktreeLocation ( ) ,
145- Layer . mock ( GitWorkflow . GitWorkflowService ) ( { pruneWorktrees, createWorktree } ) ,
149+ Layer . mock ( GitWorkflow . GitWorkflowService ) ( {
150+ pruneWorktrees,
151+ createWorktree,
152+ listLocalBranchNames,
153+ } ) ,
146154 Layer . mock ( ProjectService . ProjectService ) ( {
147155 getById : ( ) =>
148156 Effect . succeedSome ( { workspaceRoot : "/tmp/provider-turn-start-project" } as never ) ,
@@ -257,6 +265,8 @@ function makeLocalCommandHarness(input: {
257265 readonly text : string ;
258266 readonly worktreePath ?: string ;
259267 readonly worktreesDir ?: string ;
268+ readonly workspaceRoot ?: string ;
269+ readonly gitWorkflow ?: GitWorkflow . GitWorkflowService [ "Service" ] ;
260270 readonly fileSystem ?: FileSystem . FileSystem ;
261271 readonly previousNativeSession ?: boolean ;
262272 readonly previousMessages ?: ReadonlyArray < string > ;
@@ -664,7 +674,9 @@ function makeLocalCommandHarness(input: {
664674 ( { thread } : Parameters < RuntimePolicy . RuntimePolicyV2Shape [ "resolve" ] > [ 0 ] ) =>
665675 Effect . succeed ( { cwd : thread . worktreePath ?? "/tmp/native-account-command" } as never ) ,
666676 ) ;
667- const createWorktree = vi . fn ( ( ) => Effect . succeed ( { } as never ) ) ;
677+ const createWorktree = vi . fn < GitWorkflow . GitWorkflowService [ "Service" ] [ "createWorktree" ] > ( ( ) =>
678+ Effect . succeed ( { } as never ) ,
679+ ) ;
668680 const getThreadRecords = vi . fn < ProjectionStore . ProjectionStoreV2Shape [ "getThreadRecords" ] > ( ( ) =>
669681 Effect . succeed ( projection ) ,
670682 ) ;
@@ -690,13 +702,18 @@ function makeLocalCommandHarness(input: {
690702 input . fileSystem === undefined
691703 ? FileSystem . layerNoop ( { exists : ( ) => Effect . succeed ( true ) } )
692704 : Layer . succeed ( FileSystem . FileSystem , input . fileSystem ) ,
693- Layer . mock ( GitWorkflow . GitWorkflowService ) ( {
694- pruneWorktrees : ( ) => Effect . void ,
695- createWorktree,
696- } ) ,
705+ input . gitWorkflow === undefined
706+ ? Layer . mock ( GitWorkflow . GitWorkflowService ) ( {
707+ pruneWorktrees : ( ) => Effect . void ,
708+ listLocalBranchNames : ( ) => Effect . succeed ( [ "feature/restore" ] ) ,
709+ createWorktree,
710+ } )
711+ : Layer . succeed ( GitWorkflow . GitWorkflowService , input . gitWorkflow ) ,
697712 Layer . mock ( ProjectService . ProjectService ) ( {
698713 getById : ( ) =>
699- Effect . succeedSome ( { workspaceRoot : "/tmp/native-account-project" } as never ) ,
714+ Effect . succeedSome ( {
715+ workspaceRoot : input . workspaceRoot ?? "/tmp/native-account-project" ,
716+ } as never ) ,
700717 } ) ,
701718 Layer . mock ( ProjectionStore . ProjectionStoreV2 ) ( {
702719 getThreadRecords,
@@ -770,6 +787,167 @@ function makeLocalCommandHarness(input: {
770787 } ;
771788}
772789
790+ effectIt . effect (
791+ "recovers the thread branch tip, falls back to the current project branch, and rejects detached HEAD" ,
792+ ( ) =>
793+ Effect . gen ( function * ( ) {
794+ const fileSystem = yield * FileSystem . FileSystem ;
795+ const path = yield * Path . Path ;
796+ const gitWorkflow = yield * GitWorkflow . GitWorkflowService ;
797+ const driver = yield * GitVcsDriver . GitVcsDriver ;
798+ const root = yield * fileSystem . makeTempDirectoryScoped ( { prefix : "t3-recreate-repo-" } ) ;
799+ const workspaceRoot = path . join ( root , "project" ) ;
800+ const worktreesDir = path . join ( root , "worktrees" ) ;
801+ const worktreePath = path . join ( worktreesDir , "project" , "restore" ) ;
802+ yield * fileSystem . makeDirectory ( workspaceRoot ) ;
803+ const git = ( cwd : string , args : ReadonlyArray < string > ) =>
804+ driver
805+ . execute ( { operation : "test.worktreeRecovery" , cwd, args } )
806+ . pipe ( Effect . map ( ( result ) => result . stdout . trim ( ) ) ) ;
807+ yield * git ( workspaceRoot , [ "init" , "-b" , "main" ] ) ;
808+ yield * git ( workspaceRoot , [ "config" , "user.name" , "Test" ] ) ;
809+ yield * git ( workspaceRoot , [ "config" , "user.email" , "test@example.com" ] ) ;
810+ yield * git ( workspaceRoot , [ "commit" , "--allow-empty" , "-m" , "base" ] ) ;
811+ yield * git ( workspaceRoot , [ "worktree" , "add" , "-b" , "feature/restore" , worktreePath ] ) ;
812+ yield * fileSystem . writeFileString ( path . join ( worktreePath , "thread.txt" ) , "thread work" ) ;
813+ yield * git ( worktreePath , [ "add" , "." ] ) ;
814+ yield * git ( worktreePath , [ "commit" , "-m" , "thread tip" ] ) ;
815+ const threadTip = yield * git ( worktreePath , [ "rev-parse" , "HEAD" ] ) ;
816+ yield * git ( workspaceRoot , [ "switch" , "-c" , "project-current" ] ) ;
817+ yield * fileSystem . writeFileString (
818+ path . join ( workspaceRoot , "project.txt" ) ,
819+ "new project code" ,
820+ ) ;
821+ yield * git ( workspaceRoot , [ "add" , "." ] ) ;
822+ yield * git ( workspaceRoot , [ "commit" , "-m" , "project tip" ] ) ;
823+ const projectTip = yield * git ( workspaceRoot , [ "rev-parse" , "HEAD" ] ) ;
824+ expect ( projectTip ) . not . toBe ( threadTip ) ;
825+ // Tags must neither hide the thread branch nor select a different recovery tip.
826+ yield * git ( workspaceRoot , [ "tag" , "feature/restore" , projectTip ] ) ;
827+ yield * git ( workspaceRoot , [ "tag" , "project-current" , threadTip ] ) ;
828+
829+ // Reuse the workflow across turns so recovery cannot select a cached project branch.
830+ for ( const scenario of [ "retained" , "deleted" , "detached" ] as const ) {
831+ yield * fileSystem . remove ( worktreePath , { recursive : true } ) ;
832+ if ( scenario !== "retained" ) {
833+ yield * git ( workspaceRoot , [ "worktree" , "prune" ] ) ;
834+ yield * git ( workspaceRoot , [ "branch" , "-D" , "feature/restore" ] ) ;
835+ }
836+ if ( scenario === "detached" ) {
837+ yield * git ( workspaceRoot , [ "switch" , "--detach" ] ) ;
838+ expect ( yield * git ( workspaceRoot , [ "branch" , "--show-current" ] ) ) . toBe ( "" ) ;
839+ }
840+ const harness = makeLocalCommandHarness ( {
841+ text : "Continue" ,
842+ workspaceRoot,
843+ worktreePath,
844+ worktreesDir,
845+ fileSystem,
846+ gitWorkflow,
847+ failReadsAfterRunning : true ,
848+ } ) ;
849+ const open = harness . open . getMockImplementation ( ) ! ;
850+ harness . open . mockImplementation ( ( ) =>
851+ Effect . gen ( function * ( ) {
852+ expect ( yield * git ( worktreePath , [ "branch" , "--show-current" ] ) ) . toBe ( "feature/restore" ) ;
853+ expect ( yield * git ( worktreePath , [ "rev-parse" , "HEAD" ] ) ) . toBe (
854+ scenario === "retained" ? threadTip : projectTip ,
855+ ) ;
856+ expect (
857+ yield * fileSystem . readFileString (
858+ path . join ( worktreePath , scenario === "retained" ? "thread.txt" : "project.txt" ) ,
859+ ) ,
860+ ) . toBe ( scenario === "retained" ? "thread work" : "new project code" ) ;
861+ return yield * open ( ) ;
862+ } ) . pipe ( Effect . orDie ) ,
863+ ) ;
864+
865+ yield * harness . start ;
866+
867+ if ( scenario === "detached" ) {
868+ expect ( harness . open ) . not . toHaveBeenCalled ( ) ;
869+ expect ( harness . startRootRun ) . not . toHaveBeenCalled ( ) ;
870+ expect ( harness . projection ( ) . runs . at ( - 1 ) ?. status ) . toBe ( "failed" ) ;
871+ expect ( harness . projection ( ) . turnItems ) . toMatchObject ( [
872+ { type : "error" , failure : { message : expect . stringContaining ( "no current branch" ) } } ,
873+ ] ) ;
874+ expect ( yield * fileSystem . exists ( worktreePath ) ) . toBe ( false ) ;
875+ expect ( yield * git ( workspaceRoot , [ "branch" , "--list" , "feature/restore" ] ) ) . toBe ( "" ) ;
876+ } else {
877+ expect ( harness . open ) . toHaveBeenCalledOnce ( ) ;
878+ expect ( harness . startRootRun ) . toHaveBeenCalledWith (
879+ expect . objectContaining ( {
880+ runtimePolicy : expect . objectContaining ( { cwd : worktreePath } ) ,
881+ } ) ,
882+ ) ;
883+ expect ( harness . projection ( ) . runs . at ( - 1 ) ?. status ) . toBe ( "running" ) ;
884+ }
885+ }
886+ } ) . pipe ( Effect . scoped , Effect . provide ( layerRealGit ) ) ,
887+ ) ;
888+
889+ effectIt . effect (
890+ "preserves recreation interrupts and retries, then fails the final attempt before provider start" ,
891+ ( ) =>
892+ Effect . gen ( function * ( ) {
893+ const fileSystem = yield * FileSystem . FileSystem ;
894+ const path = yield * Path . Path ;
895+ const worktreesDir = yield * fileSystem . makeTempDirectoryScoped ( {
896+ prefix : "t3-recreate-failure-" ,
897+ } ) ;
898+ const worktreePath = path . join ( worktreesDir , "project" , "restore" ) ;
899+ const harness = makeLocalCommandHarness ( {
900+ text : "Continue" ,
901+ worktreesDir,
902+ worktreePath,
903+ fileSystem,
904+ failReadsAfterRunning : true ,
905+ } ) ;
906+ harness . createWorktree . mockImplementationOnce ( ( ) => Effect . interrupt ) ;
907+ harness . createWorktree . mockImplementation ( ( ) =>
908+ Effect . fail (
909+ new GitCommandError ( {
910+ operation : "worktree-add" ,
911+ command : "git worktree add" ,
912+ cwd : "/tmp/native-account-project" ,
913+ detail : "fatal: private git diagnostic" ,
914+ } ) ,
915+ ) ,
916+ ) ;
917+
918+ const interrupted = yield * Effect . exit ( harness . start ) ;
919+ expect ( interrupted . _tag ) . toBe ( "Failure" ) ;
920+ if ( interrupted . _tag === "Failure" )
921+ expect ( Cause . hasInterruptsOnly ( interrupted . cause ) ) . toBe ( true ) ;
922+ expect ( harness . events ) . toEqual ( [ ] ) ;
923+ expect ( harness . projection ( ) . runs . at ( - 1 ) ?. status ) . toBe ( "starting" ) ;
924+
925+ const retry = yield * Effect . result ( harness . startWithRetry ) ;
926+ expect ( retry ) . toMatchObject ( { _tag : "Failure" , failure : { _tag : "ProviderTurnStartError" } } ) ;
927+ expect ( harness . events ) . toEqual ( [ ] ) ;
928+ expect ( harness . projection ( ) . runs . at ( - 1 ) ?. status ) . toBe ( "starting" ) ;
929+
930+ yield * harness . start ;
931+ expect ( harness . createWorktree ) . toHaveBeenCalledTimes ( 3 ) ;
932+ expect ( harness . writeIfRunCurrent ) . toHaveBeenCalledOnce ( ) ;
933+ expect ( harness . open ) . not . toHaveBeenCalled ( ) ;
934+ expect ( harness . startRootRun ) . not . toHaveBeenCalled ( ) ;
935+ const projection = harness . projection ( ) ;
936+ expect ( projection . runs . at ( - 1 ) ) . toMatchObject ( { status : "failed" , startedAt : null } ) ;
937+ expect ( projection . attempts [ 0 ] ) . toMatchObject ( { status : "failed" , startedAt : null } ) ;
938+ expect ( projection . nodes [ 0 ] ) . toMatchObject ( { status : "failed" , startedAt : null } ) ;
939+ expect ( projection . turnItems ) . toMatchObject ( [
940+ {
941+ type : "error" ,
942+ failure : {
943+ message : `Could not recreate the worktree for branch 'feature/restore' at ${ worktreePath } .` ,
944+ } ,
945+ } ,
946+ ] ) ;
947+ expect ( JSON . stringify ( harness . events ) ) . not . toContain ( "private git diagnostic" ) ;
948+ } ) . pipe ( Effect . scoped , Effect . provide ( NodeServices . layer ) ) ,
949+ ) ;
950+
773951effectIt . effect ( "recovers an abandoned failed turn before continuing its native conversation" , ( ) =>
774952 Effect . gen ( function * ( ) {
775953 const harness = makeLocalCommandHarness ( {
0 commit comments