@@ -59,6 +59,12 @@ type config struct {
5959 PRBodyPath string
6060
6161 GooseDebug bool
62+
63+ GoosePathRoot string
64+ GooseSessionMode string
65+ GooseSessionResume bool
66+ GooseSessionKey string
67+ GooseSessionName string
6268}
6369
6470type prView struct {
@@ -150,6 +156,9 @@ func runWithExecutor(ex commandExecutor) error {
150156 if err := os .MkdirAll (filepath .Join (cfg .MetaDir , "goose" ), 0o755 ); err != nil {
151157 return fmt .Errorf ("create goose dir: %w" , err )
152158 }
159+ if err := os .MkdirAll (cfg .GoosePathRoot , 0o755 ); err != nil {
160+ return fmt .Errorf ("create goose path root: %w" , err )
161+ }
153162 if err := os .MkdirAll (filepath .Join (cfg .MetaDir , "codex" ), 0o755 ); err != nil {
154163 return fmt .Errorf ("create codex dir: %w" , err )
155164 }
@@ -392,25 +401,47 @@ func loadConfig() (config, error) {
392401 }
393402 }
394403
404+ gooseSessionMode := runner .NormalizeGooseSessionMode (os .Getenv ("RASCAL_GOOSE_SESSION_MODE" ))
405+ gooseSessionResume := parseBoolEnv (strings .TrimSpace (os .Getenv ("RASCAL_GOOSE_SESSION_RESUME" )), false )
406+ if gooseSessionMode == runner .GooseSessionModeOff {
407+ gooseSessionResume = false
408+ }
409+ gooseSessionKey := strings .TrimSpace (os .Getenv ("RASCAL_GOOSE_SESSION_KEY" ))
410+ gooseSessionName := strings .TrimSpace (os .Getenv ("RASCAL_GOOSE_SESSION_NAME" ))
411+ if gooseSessionResume {
412+ if gooseSessionKey == "" {
413+ gooseSessionKey = runner .GooseSessionTaskKey (repo , taskID )
414+ }
415+ if gooseSessionName == "" {
416+ gooseSessionName = runner .GooseSessionName (repo , taskID )
417+ }
418+ }
419+ goosePathRoot := firstNonEmptyValue (strings .TrimSpace (os .Getenv ("GOOSE_PATH_ROOT" )), filepath .Join (metaDir , "goose" ))
420+
395421 return config {
396- RunID : runID ,
397- TaskID : taskID ,
398- Task : strings .TrimSpace (os .Getenv ("RASCAL_TASK" )),
399- Repo : repo ,
400- BaseBranch : baseBranch ,
401- HeadBranch : headBranch ,
402- IssueNumber : issueNumber ,
403- Trigger : trigger ,
404- GitHubToken : ghToken ,
405- MetaDir : metaDir ,
406- WorkRoot : workRoot ,
407- RepoDir : repoDir ,
408- GooseLogPath : filepath .Join (metaDir , defaultGooseLogFile ),
409- MetaPath : filepath .Join (metaDir , defaultMetaFile ),
410- InstructionsPath : filepath .Join (metaDir , defaultInstructionsFile ),
411- CommitMsgPath : filepath .Join (metaDir , defaultCommitMsgFile ),
412- PRBodyPath : filepath .Join (metaDir , defaultPRBodyFile ),
413- GooseDebug : debug ,
422+ RunID : runID ,
423+ TaskID : taskID ,
424+ Task : strings .TrimSpace (os .Getenv ("RASCAL_TASK" )),
425+ Repo : repo ,
426+ BaseBranch : baseBranch ,
427+ HeadBranch : headBranch ,
428+ IssueNumber : issueNumber ,
429+ Trigger : trigger ,
430+ GitHubToken : ghToken ,
431+ MetaDir : metaDir ,
432+ WorkRoot : workRoot ,
433+ RepoDir : repoDir ,
434+ GooseLogPath : filepath .Join (metaDir , defaultGooseLogFile ),
435+ MetaPath : filepath .Join (metaDir , defaultMetaFile ),
436+ InstructionsPath : filepath .Join (metaDir , defaultInstructionsFile ),
437+ CommitMsgPath : filepath .Join (metaDir , defaultCommitMsgFile ),
438+ PRBodyPath : filepath .Join (metaDir , defaultPRBodyFile ),
439+ GooseDebug : debug ,
440+ GoosePathRoot : goosePathRoot ,
441+ GooseSessionMode : gooseSessionMode ,
442+ GooseSessionResume : gooseSessionResume ,
443+ GooseSessionKey : gooseSessionKey ,
444+ GooseSessionName : gooseSessionName ,
414445 }, nil
415446}
416447
@@ -423,6 +454,20 @@ func firstNonEmptyValue(values ...string) string {
423454 return ""
424455}
425456
457+ func parseBoolEnv (raw string , fallback bool ) bool {
458+ if strings .TrimSpace (raw ) == "" {
459+ return fallback
460+ }
461+ switch strings .ToLower (strings .TrimSpace (raw )) {
462+ case "1" , "true" , "yes" , "on" :
463+ return true
464+ case "0" , "false" , "no" , "off" :
465+ return false
466+ default :
467+ return fallback
468+ }
469+ }
470+
426471func ensureInstructions (cfg config ) error {
427472 if _ , err := os .Stat (cfg .InstructionsPath ); err == nil {
428473 return nil
@@ -513,38 +558,140 @@ func checkoutRepo(ex commandExecutor, cfg config) error {
513558}
514559
515560func runGoose (ex commandExecutor , cfg config ) (string , error ) {
516- log .Printf ("[%s] running goose (debug=%t)" , nowUTC (), cfg .GooseDebug )
561+ log .Printf ("[%s] running goose (debug=%t session_mode=%s session_key=%s session_name=%s resume=%t path_root=%s)" ,
562+ nowUTC (),
563+ cfg .GooseDebug ,
564+ cfg .GooseSessionMode ,
565+ cfg .GooseSessionKey ,
566+ cfg .GooseSessionName ,
567+ cfg .GooseSessionResume ,
568+ cfg .GoosePathRoot ,
569+ )
570+
571+ firstAttemptArgs := gooseRunArgs (cfg , cfg .GooseSessionResume )
572+ if err := runGooseOnce (ex , cfg , firstAttemptArgs ); err != nil {
573+ if cfg .GooseSessionResume && isSessionResumeFailure (err , cfg .GooseLogPath ) {
574+ log .Printf ("[%s] goose session resume failed; falling back to fresh session name=%s reason=%s" , nowUTC (), cfg .GooseSessionName , strings .TrimSpace (err .Error ()))
575+ if resetErr := resetGooseSessionRoot (cfg .GoosePathRoot ); resetErr != nil {
576+ log .Printf ("[%s] goose session reset warning: %v" , nowUTC (), resetErr )
577+ }
578+ fallbackArgs := gooseRunArgs (cfg , false )
579+ if retryErr := runGooseOnce (ex , cfg , fallbackArgs ); retryErr != nil {
580+ ensureGooseLogHasError (cfg .GooseLogPath )
581+ return "" , fmt .Errorf ("goose run failed after session fallback: %w" , retryErr )
582+ }
583+ log .Printf ("[%s] goose session fallback succeeded; started fresh session name=%s" , nowUTC (), cfg .GooseSessionName )
584+ } else {
585+ ensureGooseLogHasError (cfg .GooseLogPath )
586+ return "" , fmt .Errorf ("goose run failed: %w" , err )
587+ }
588+ }
589+ data , err := os .ReadFile (cfg .GooseLogPath )
590+ if err != nil {
591+ return "" , fmt .Errorf ("read goose log: %w" , err )
592+ }
593+ if strings .TrimSpace (string (data )) == "" {
594+ return "(no goose output captured)" , nil
595+ }
596+ return string (data ), nil
597+ }
517598
599+ func gooseRunArgs (cfg config , resume bool ) []string {
600+ args := []string {"run" }
601+ if cfg .GooseSessionMode != runner .GooseSessionModeOff && cfg .GooseSessionName != "" {
602+ args = append (args , "--session" , cfg .GooseSessionName )
603+ if resume {
604+ args = append (args , "--resume" )
605+ }
606+ } else {
607+ args = append (args , "--no-session" )
608+ }
609+ args = append (args , "-i" , cfg .InstructionsPath , "--output-format" , "stream-json" )
610+ if cfg .GooseDebug {
611+ args = append (args , "--debug" )
612+ }
613+ return args
614+ }
615+
616+ func runGooseOnce (ex commandExecutor , cfg config , args []string ) error {
518617 logFile , err := os .OpenFile (cfg .GooseLogPath , os .O_CREATE | os .O_TRUNC | os .O_WRONLY , 0o644 )
519618 if err != nil {
520- return "" , fmt .Errorf ("open goose log: %w" , err )
619+ return fmt .Errorf ("open goose log: %w" , err )
521620 }
522621 defer logFile .Close ()
523622
524- args := []string {"run" , "--no-session" , "-i" , cfg .InstructionsPath , "--output-format" , "stream-json" }
525623 env := []string {}
526624 if cfg .GooseDebug {
527- args = append (args , "--debug" )
528625 env = append (env , "GOOSE_CODEX_DEBUG=1" )
529626 }
627+ if err := ex .Run (cfg .RepoDir , env , logFile , logFile , "goose" , args ... ); err != nil {
628+ return err
629+ }
630+ return nil
631+ }
530632
531- // Keep goose.ndjson reserved for structured stdout. Goose debug output goes to
532- // stderr, and mixing the two makes token extraction flaky because the final
533- // complete event can be corrupted by interleaved debug lines.
534- if err := ex .Run (cfg .RepoDir , env , logFile , os .Stderr , "goose" , args ... ); err != nil {
535- if stat , statErr := os .Stat (cfg .GooseLogPath ); statErr == nil && stat .Size () == 0 {
536- _ = os .WriteFile (cfg .GooseLogPath , []byte (`{"event":"error","message":"goose run failed"}` + "\n " ), 0o644 )
537- }
538- return "" , fmt .Errorf ("goose run failed: %w" , err )
633+ func ensureGooseLogHasError (path string ) {
634+ if stat , err := os .Stat (path ); err == nil && stat .Size () == 0 {
635+ _ = os .WriteFile (path , []byte (`{"event":"error","message":"goose run failed"}` + "\n " ), 0o644 )
539636 }
540- data , err := os .ReadFile (cfg .GooseLogPath )
637+ }
638+
639+ func resetGooseSessionRoot (path string ) error {
640+ path = strings .TrimSpace (path )
641+ if path == "" {
642+ return nil
643+ }
644+ if err := os .RemoveAll (path ); err != nil && ! errors .Is (err , os .ErrNotExist ) {
645+ return fmt .Errorf ("remove goose session root: %w" , err )
646+ }
647+ if err := os .MkdirAll (path , 0o755 ); err != nil {
648+ return fmt .Errorf ("recreate goose session root: %w" , err )
649+ }
650+ return nil
651+ }
652+
653+ func isSessionResumeFailure (err error , logPath string ) bool {
654+ var b strings.Builder
541655 if err != nil {
542- return "" , fmt . Errorf ( "read goose log: %w" , err )
656+ b . WriteString ( err . Error () )
543657 }
544- if strings .TrimSpace (string (data )) == "" {
545- return "(no goose output captured)" , nil
658+ if data , readErr := os .ReadFile (logPath ); readErr == nil {
659+ if b .Len () > 0 {
660+ b .WriteByte ('\n' )
661+ }
662+ b .Write (data )
663+ }
664+ text := strings .ToLower (b .String ())
665+ if text == "" {
666+ return false
667+ }
668+ hasSessionContext := strings .Contains (text , "session" ) || strings .Contains (text , "resume" )
669+ if ! hasSessionContext {
670+ return false
671+ }
672+ for _ , marker := range []string {
673+ "not found" ,
674+ "no such file" ,
675+ "no existing" ,
676+ "cannot find" ,
677+ "can't find" ,
678+ "does not exist" ,
679+ "missing" ,
680+ "corrupt" ,
681+ "invalid" ,
682+ "malformed" ,
683+ "failed to load" ,
684+ "failed loading" ,
685+ "decode" ,
686+ "deserialize" ,
687+ "unmarshal" ,
688+ "state" ,
689+ } {
690+ if strings .Contains (text , marker ) {
691+ return true
692+ }
546693 }
547- return string ( data ), nil
694+ return false
548695}
549696
550697func loadAgentCommitMessage (path string ) (title , body string , err error ) {
0 commit comments