Skip to content
This repository was archived by the owner on Jun 25, 2026. It is now read-only.

Commit 2ba6d2c

Browse files
committed
feat(runner): add disk-backed Goose session resume across runs
Add task-scoped Goose session persistence with mode-gated resume behavior and safe fallback to fresh sessions when resume state is missing/corrupt. - Add server config/env support for session mode/root/TTL - Derive deterministic task session key/name (sanitized + hash) - Mount durable task session dirs into runner containers in resume mode - Switch runner Goose invocation between stateless and named resume flows - Add resume-failure classification + fresh-session fallback guardrails - Add best-effort TTL cleanup for stale task session dirs - Add observability logs for mode/key/name/resume/fallback - Add retry trigger wiring for CLI retry runs - Update docs and tests for config, docker args/env, runner args, fallback, and cleanup Run: run_7e07960e352ceb2b
1 parent 675084f commit 2ba6d2c

19 files changed

Lines changed: 1164 additions & 252 deletions

File tree

cmd/rascal-runner/main.go

Lines changed: 182 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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

6470
type 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+
426471
func 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

515560
func 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

550697
func loadAgentCommitMessage(path string) (title, body string, err error) {

0 commit comments

Comments
 (0)