From f766d5281cdf8ac0ca89c59d4b1faab7e894e308 Mon Sep 17 00:00:00 2001 From: Nitzan Volman Date: Sun, 3 May 2026 15:30:46 +0300 Subject: [PATCH 1/2] fix: add Claude resume prompt Fixes #30. --- internal/agent/claude.go | 22 ++--- internal/agent/helpers_test.go | 29 ++++++- internal/config/config.go | 7 ++ internal/config/config_test.go | 23 ++++++ internal/config/validate.go | 8 ++ internal/orchestrator/event_loop_test.go | 72 +++++++++++++++++ .../orchestrator/subagents_internal_test.go | 81 +++++++++++++++++++ internal/orchestrator/worker.go | 60 +++++++++++++- site/src/content/docs/configuration.mdx | 1 + 9 files changed, 289 insertions(+), 14 deletions(-) diff --git a/internal/agent/claude.go b/internal/agent/claude.go index b68ccac..681e151 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -108,7 +108,7 @@ func ValidateClaudeCLICommand(command string) error { // RunTurn runs a single claude turn as a subprocess. // // First turn (sessionID == nil): claude -p --output-format stream-json -// Continuation (sessionID != nil): claude --resume --output-format stream-json +// Continuation (sessionID != nil): claude --resume -p --output-format stream-json // // readTimeoutMs is the per-line idle deadline; if no output arrives within that // window the turn is aborted as a stall. turnTimeoutMs is the hard wall-clock @@ -218,17 +218,21 @@ const sharedFlagsStr = " --output-format stream-json --verbose --dangerously-ski var sharedFlagsSlice = []string{"--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions"} // buildDirectArgs returns CLI args for direct (non-shell) invocation. +// +// `-p ` is always emitted. Claude Code ≥ 2.1.119 rejects `--resume` +// with no prompt unless the resumed transcript ends in a deferred-tool marker; +// callers choose the prompt text for resumed turns. func buildDirectArgs(sessionID *string, prompt string) []string { - base := append([]string{}, sharedFlagsSlice...) + args := append([]string{}, sharedFlagsSlice...) if sessionID != nil && *sessionID != "" { - return append(base, "--resume", *sessionID) + args = append(args, "--resume", *sessionID) } - return append(base, "-p", prompt) + return append(args, "-p", prompt) } // buildShellCmd returns the full shell command string for bash/zsh -lc. -// The prompt is passed via a shell variable to avoid quoting issues with -// special characters (backticks, $, !, quotes) in the rendered template. +// The prompt is shell-quoted to preserve special characters (backticks, $, !, +// quotes) in the rendered template. // // Defensive: if command is empty or whitespace, fall back to "claude" and // log a warning. Without this, sharedFlagsStr's leading space would produce @@ -240,11 +244,11 @@ func buildShellCmd(command string, sessionID *string, prompt string) string { slog.Warn("agent: empty command resolved at dispatch — falling back to 'claude'. Check WORKFLOW.md agent.command and any profile.command fields.") command = "claude" } - base := command + sharedFlagsStr + cmd := command + sharedFlagsStr if sessionID != nil && *sessionID != "" { - return base + " --resume " + shellQuote(*sessionID) + cmd += " --resume " + shellQuote(*sessionID) } - return base + " -p " + shellQuote(prompt) + return cmd + " -p " + shellQuote(prompt) } // todoItems parses a TodoWrite input and returns the content of each todo. diff --git a/internal/agent/helpers_test.go b/internal/agent/helpers_test.go index c4d3e1d..3afe362 100644 --- a/internal/agent/helpers_test.go +++ b/internal/agent/helpers_test.go @@ -52,11 +52,34 @@ func TestBuildShellCmdNewSession(t *testing.T) { func TestBuildShellCmdResume(t *testing.T) { id := "sess-abc" - cmd := buildShellCmd("claude", &id, "ignored prompt") + cmd := buildShellCmd("claude", &id, "next-turn prompt") assert.Contains(t, cmd, "--resume") assert.Contains(t, cmd, "sess-abc") - // When resuming, the new-session flag ` -p ` should not appear (note spaces). - assert.NotContains(t, cmd, " -p ") + // Claude Code ≥ 2.1.119 requires `-p` alongside `--resume` when the + // resumed transcript has no deferred-tool marker. Issue #30. + assert.Contains(t, cmd, " -p ") + assert.Contains(t, cmd, "next-turn prompt") +} + +// --- buildDirectArgs --- + +func TestBuildDirectArgsNewSession(t *testing.T) { + args := buildDirectArgs(nil, "do the thing") + joined := strings.Join(args, " ") + assert.Contains(t, joined, "--output-format stream-json") + assert.Contains(t, args, "-p") + assert.Contains(t, args, "do the thing") + assert.NotContains(t, args, "--resume") +} + +func TestBuildDirectArgsResume(t *testing.T) { + id := "sess-abc" + args := buildDirectArgs(&id, "next-turn prompt") + assert.Contains(t, args, "--resume") + assert.Contains(t, args, "sess-abc") + // Issue #30: prompt must be present alongside --resume. + assert.Contains(t, args, "-p") + assert.Contains(t, args, "next-turn prompt") } // Regression: an empty command must not produce a shell line that starts with diff --git a/internal/config/config.go b/internal/config/config.go index b5b2f0d..6a604ae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -78,6 +78,9 @@ Your job: Be concise in your review comments. Focus on real problems, not style nits.` +// DefaultResumePrompt is used for Claude resume turns when resume_prompt is absent from WORKFLOW.md. +const DefaultResumePrompt = "Continue working on the previously assigned issue using the existing session context. Do not restart from scratch; proceed from where the prior turn left off." + // AgentProfile holds settings for a named agent profile. type AgentProfile struct { // Command overrides the default agent CLI command (e.g. "claude --model ..."). @@ -104,6 +107,9 @@ type AgentConfig struct { // Backend optionally overrides runner selection for the default agent command // when it cannot be inferred from the command string alone. Backend string + // ResumePrompt is the Liquid template sent to Claude resume turns instead of + // re-sending the full WORKFLOW.md prompt. Codex resumes keep backend-native behavior. + ResumePrompt string // TurnTimeoutMs is the hard wall-clock limit for an entire agent session // (all turns combined). When the limit is exceeded the subprocess is killed // and the issue is scheduled for retry. Default: 3 600 000 ms (1 hour). @@ -280,6 +286,7 @@ func fromWorkflow(wf *workflow.Workflow) *Config { cfg.Agent.MaxTurns = positiveIntField(agent, "max_turns", 20) cfg.Agent.Command = strField(agent, "command", "claude") cfg.Agent.Backend = strField(agent, "backend", "") + cfg.Agent.ResumePrompt = strField(agent, "resume_prompt", DefaultResumePrompt) cfg.Agent.TurnTimeoutMs = intField(agent, "turn_timeout_ms", 3600000) cfg.Agent.ReadTimeoutMs = positiveIntField(agent, "read_timeout_ms", 30000) cfg.Agent.StallTimeoutMs = intField(agent, "stall_timeout_ms", 300000) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6994792..7bdea96 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -37,6 +37,7 @@ func TestDefaults(t *testing.T) { assert.Equal(t, 5, cfg.Agent.MaxRetries) assert.Equal(t, "", cfg.Tracker.FailedState) assert.Equal(t, "claude", cfg.Agent.Command) + assert.Equal(t, config.DefaultResumePrompt, cfg.Agent.ResumePrompt) assert.Equal(t, 3600000, cfg.Agent.TurnTimeoutMs) assert.Equal(t, 30000, cfg.Agent.ReadTimeoutMs) assert.Equal(t, 300000, cfg.Agent.StallTimeoutMs) @@ -187,6 +188,28 @@ func TestAgentBackendField(t *testing.T) { assert.Equal(t, "codex", cfg.Agent.Backend) } +func TestAgentResumePromptField(t *testing.T) { + content := minimal(`agent: + resume_prompt: "Continue {{ issue.identifier }}" +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + assert.Equal(t, "Continue {{ issue.identifier }}", cfg.Agent.ResumePrompt) +} + +func TestValidateDispatchRejectsInvalidResumePromptTemplate(t *testing.T) { + content := minimal(`agent: + resume_prompt: "{% for %}" +`) + path := workflowWithContent(t, content) + cfg, err := config.Load(path) + require.NoError(t, err) + err = config.ValidateDispatch(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "agent.resume_prompt") +} + func TestWorktreeDefaultsFalse(t *testing.T) { path := workflowWithContent(t, minimal("")) cfg, err := config.Load(path) diff --git a/internal/config/validate.go b/internal/config/validate.go index ff66898..f02dbc4 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -46,6 +46,14 @@ func ValidateDispatch(cfg *Config) error { } } + // Check 6b: resume_prompt is a valid Liquid template. + if rp := cfg.Agent.ResumePrompt; rp != "" { + eng := liquid.NewEngine() + if _, err := eng.ParseTemplate([]byte(rp)); err != nil { + return fmt.Errorf("agent.resume_prompt: invalid Liquid template: %w", err) + } + } + // Check 7: ssh_hosts must not start with '-' or contain whitespace (prevents SSH flag injection) for _, host := range cfg.Agent.SSHHosts { if strings.HasPrefix(host, "-") || strings.ContainsAny(host, " \t") { diff --git a/internal/orchestrator/event_loop_test.go b/internal/orchestrator/event_loop_test.go index 4303bbc..9a9b35c 100644 --- a/internal/orchestrator/event_loop_test.go +++ b/internal/orchestrator/event_loop_test.go @@ -1931,3 +1931,75 @@ func TestManualPauseResumeWithoutSession(t *testing.T) { } } } + +type resumePromptRunner struct { + mu sync.Mutex + once sync.Once + done chan struct{} + prompts []string + sessionIDs []string +} + +func (r *resumePromptRunner) RunTurn(_ context.Context, _ agent.Logger, _ func(agent.TurnResult), sessionID *string, prompt, _, _, _, _ string, _, _ int) (agent.TurnResult, error) { + r.mu.Lock() + sid := "" + if sessionID != nil { + sid = *sessionID + } + r.prompts = append(r.prompts, prompt) + r.sessionIDs = append(r.sessionIDs, sid) + callNum := len(r.prompts) + r.mu.Unlock() + + if callNum == 1 { + return agent.TurnResult{ + SessionID: "agent-session-xyz", + InputTokens: 10, + OutputTokens: 10, + TotalTokens: 20, + ResultText: "first turn", + }, nil + } + + r.once.Do(func() { close(r.done) }) + return agent.TurnResult{ + SessionID: "agent-session-xyz", + ResultText: "session concluded", + }, nil +} + +func (r *resumePromptRunner) snapshot() (prompts []string, sessionIDs []string) { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string{}, r.prompts...), append([]string{}, r.sessionIDs...) +} + +func TestClaudeResumeTurnsUseResumePrompt(t *testing.T) { + cfg := baseConfig() + cfg.Polling.IntervalMs = 20 + cfg.Agent.MaxTurns = 2 + cfg.Agent.Command = "claude" + cfg.Agent.ResumePrompt = "Resume {{ issue.identifier }} from existing context." + cfg.PromptTemplate = "Full workflow prompt for {{ issue.identifier }}." + mt := singleIssueTracker(t, "In Progress") + runner := &resumePromptRunner{done: make(chan struct{})} + orch := orchestrator.New(cfg, mt, runner, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + go orch.Run(ctx) //nolint:errcheck + + select { + case <-runner.done: + case <-ctx.Done(): + t.Fatal("runner did not receive a Claude resume turn within 3s") + } + + prompts, sessionIDs := runner.snapshot() + require.Len(t, prompts, 2) + require.Len(t, sessionIDs, 2) + assert.Equal(t, "Full workflow prompt for ENG-1.", prompts[0]) + assert.Equal(t, "", sessionIDs[0]) + assert.Equal(t, "Resume ENG-1 from existing context.", prompts[1]) + assert.Equal(t, "agent-session-xyz", sessionIDs[1]) +} diff --git a/internal/orchestrator/subagents_internal_test.go b/internal/orchestrator/subagents_internal_test.go index 2a57253..067a5ba 100644 --- a/internal/orchestrator/subagents_internal_test.go +++ b/internal/orchestrator/subagents_internal_test.go @@ -1,12 +1,16 @@ package orchestrator import ( + "context" "log/slog" "testing" "github.com/stretchr/testify/assert" + "github.com/vnovick/itervox/internal/agent" "github.com/vnovick/itervox/internal/config" + "github.com/vnovick/itervox/internal/domain" "github.com/vnovick/itervox/internal/logbuffer" + "github.com/vnovick/itervox/internal/tracker" ) func TestBuildSubAgentContext_UsesCodexToolName(t *testing.T) { @@ -28,6 +32,83 @@ func TestBuildSubAgentContext_SkipsActiveProfile(t *testing.T) { assert.NotContains(t, ctx, "**active**") } +func TestShouldUseClaudeResumePrompt(t *testing.T) { + id := "sess-1" + empty := "" + + assert.True(t, shouldUseClaudeResumePrompt("", &id)) + assert.True(t, shouldUseClaudeResumePrompt("claude", &id)) + assert.False(t, shouldUseClaudeResumePrompt("codex", &id)) + assert.False(t, shouldUseClaudeResumePrompt("claude", &empty)) + assert.False(t, shouldUseClaudeResumePrompt("claude", nil)) +} + +func TestRenderClaudeResumePrompt(t *testing.T) { + got, err := renderClaudeResumePrompt("Continue {{ issue.identifier }}", domain.Issue{Identifier: "ENG-1"}, nil) + assert.NoError(t, err) + assert.Equal(t, "Continue ENG-1", got) +} + +func TestRenderClaudeResumePromptDefault(t *testing.T) { + got, err := renderClaudeResumePrompt("", domain.Issue{Identifier: "ENG-1"}, nil) + assert.NoError(t, err) + assert.Equal(t, config.DefaultResumePrompt, got) +} + +func TestAppendPromptSections(t *testing.T) { + got := appendPromptSections("Resume", "Profile context", "", "## Open PR Context\nPR: https://github.com/acme/repo/pull/1") + + assert.Equal(t, "Resume\n\nProfile context\n\n## Open PR Context\nPR: https://github.com/acme/repo/pull/1", got) +} + +type promptCapturingRunner struct { + prompt string + sessionID string +} + +func (r *promptCapturingRunner) RunTurn(_ context.Context, _ agent.Logger, _ func(agent.TurnResult), sessionID *string, prompt, _, _, _, _ string, _, _ int) (agent.TurnResult, error) { + r.prompt = prompt + if sessionID != nil { + r.sessionID = *sessionID + } + return agent.TurnResult{SessionID: r.sessionID, ResultText: "done"}, nil +} + +func TestManualClaudeResumePromptIncludesFirstTurnContext(t *testing.T) { + cfg := &config.Config{ + PromptTemplate: "Full prompt for {{ issue.identifier }}.", + } + cfg.Tracker.ActiveStates = []string{"In Progress"} + cfg.Tracker.TerminalStates = []string{"Done"} + cfg.Agent.MaxTurns = 1 + cfg.Agent.Command = "claude" + cfg.Agent.ResumePrompt = "Resume {{ issue.identifier }}." + cfg.Agent.AgentMode = "teams" + cfg.Agent.Profiles = map[string]config.AgentProfile{ + "lead": { + Command: "claude", + Prompt: "Lead role for {{ issue.identifier }}.", + }, + "research": { + Command: "claude", + Prompt: "Research helper.", + }, + } + issue := domain.Issue{ID: "id1", Identifier: "ENG-1", Title: "T", State: "In Progress"} + mt := tracker.NewMemoryTracker([]domain.Issue{issue}, cfg.Tracker.ActiveStates, cfg.Tracker.TerminalStates) + runner := &promptCapturingRunner{} + o := New(cfg, mt, runner, nil) + + o.runWorker(context.Background(), issue, 0, "", "claude", "claude", "lead", true, "sess-1") + + assert.Equal(t, "sess-1", runner.sessionID) + assert.Contains(t, runner.prompt, "Resume ENG-1.") + assert.NotContains(t, runner.prompt, "Full prompt for ENG-1.") + assert.Contains(t, runner.prompt, "Lead role for ENG-1.") + assert.Contains(t, runner.prompt, "## Available Sub-Agents") + assert.Contains(t, runner.prompt, "research") +} + // --- formatBufLine / makeBufLine (JSON output) --- func TestFormatBufLine_IncludesLevelAndMessage(t *testing.T) { diff --git a/internal/orchestrator/worker.go b/internal/orchestrator/worker.go index 9baa5cf..830fb30 100644 --- a/internal/orchestrator/worker.go +++ b/internal/orchestrator/worker.go @@ -288,9 +288,14 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp maps.Copy(profilesSnap, o.cfg.Agent.Profiles) o.cfgMu.RUnlock() + var firstResumeContext []string if profileName != "" { if profile, ok := profilesSnap[profileName]; ok && profile.Prompt != "" { - renderedPrompt += "\n\n" + prompt.RenderProfilePrompt(profile.Prompt, issue, attemptPtr) + profilePrompt := prompt.RenderProfilePrompt(profile.Prompt, issue, attemptPtr) + renderedPrompt += "\n\n" + profilePrompt + if turn == 1 { + firstResumeContext = append(firstResumeContext, profilePrompt) + } } } // In teams mode, also append sub-agent roster context so the active backend @@ -298,6 +303,9 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp if agentMode == "teams" { if subCtx := buildSubAgentContext(profilesSnap, profileName, backend); subCtx != "" { renderedPrompt += "\n\n" + subCtx + if turn == 1 { + firstResumeContext = append(firstResumeContext, subCtx) + } } } @@ -305,7 +313,30 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp if turn == 1 { if prBlock := prdetector.FormatPRContext(prCtx); prBlock != "" { renderedPrompt += "\n\n" + prBlock + firstResumeContext = append(firstResumeContext, prBlock) + } + } + + turnPrompt := renderedPrompt + if shouldUseClaudeResumePrompt(backend, claudeSessionID) { + resumePrompt, err := renderClaudeResumePrompt(o.cfg.Agent.ResumePrompt, issue, attemptPtr) + if err != nil { + slog.Warn("worker: resume prompt render failed", + "issue_id", issue.ID, "issue_identifier", issue.Identifier, "error", err) + if o.logBuf != nil { + o.logBuf.Add(issue.Identifier, makeBufLineWithSession("ERROR", fmt.Sprintf("worker: resume prompt render failed: %v", err), runLogID)) + } + if wsPath != "" { + hookCtx, hookCancel := context.WithTimeout(context.Background(), hookFallbackTimeout) + if hookErr := workspace.RunHook(hookCtx, afterRunHook, wsPath, hookTimeoutMs, o.hookLogFn(issue.Identifier, runLogID)); hookErr != nil { + slog.Warn("worker: after_run hook failed (ignored)", "issue_id", issue.ID, "error", hookErr) + } + hookCancel() + } + o.sendExit(ctx, issue, attempt, TerminalFailed, err) + return } + turnPrompt = appendPromptSections(resumePrompt, firstResumeContext...) } // Run agent turn — pass a logger pre-seeded with the issue identifier so @@ -341,7 +372,7 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp if o.agentLogDir != "" { logDir = filepath.Join(o.agentLogDir, workspace.SanitizeKey(issue.Identifier)) } - result, runErr := o.runner.RunTurn(ctx, workerLog, onProgress, claudeSessionID, renderedPrompt, wsPath, + result, runErr := o.runner.RunTurn(ctx, workerLog, onProgress, claudeSessionID, turnPrompt, wsPath, agentCommand, workerHost, logDir, readTimeoutMs, turnTimeoutMs) if result.SessionID != "" { @@ -786,6 +817,31 @@ func (o *Orchestrator) runAfterHook(ctx context.Context, hook string, timeoutMs } } +func shouldUseClaudeResumePrompt(backend string, sessionID *string) bool { + if sessionID == nil || *sessionID == "" { + return false + } + return backend == "" || backend == "claude" +} + +func renderClaudeResumePrompt(template string, issue domain.Issue, attempt *int) (string, error) { + if strings.TrimSpace(template) == "" { + template = config.DefaultResumePrompt + } + return prompt.Render(template, issue, attempt) +} + +func appendPromptSections(base string, sections ...string) string { + out := base + for _, section := range sections { + if strings.TrimSpace(section) == "" { + continue + } + out += "\n\n" + section + } + return out +} + // generateRunID returns a short random ID that is assigned to a worker run // before the agent subprocess starts, enabling all log entries — including // early hook/worker messages — to be tagged with the same session ID. diff --git a/site/src/content/docs/configuration.mdx b/site/src/content/docs/configuration.mdx index 3676b20..3ad0f8d 100644 --- a/site/src/content/docs/configuration.mdx +++ b/site/src/content/docs/configuration.mdx @@ -185,6 +185,7 @@ Controls the agent runner: which CLI to invoke, concurrency limits, timeouts, re | Field | Type | Default | Description | |---|---|---|---| | `agent_mode` | string | `""` | Agent collaboration model. In all modes, if an issue has a profile assigned, that profile's prompt is appended. `""` (solo): agent runs alone. `"subagents"`: agent may use its native helper/subagent tool. `"teams"`: additionally injects a sub-agent roster so the agent knows which specialists it can delegate to by name. | +| `resume_prompt` | string | built-in | Liquid template sent to Claude resume turns instead of re-sending the full workflow prompt. Applies to Claude turn 2+ and manual pause/resume. | | `inline_input` | bool | `false` | When `true`, agent input-required signals are posted as tracker comments; the user replies in the tracker and moves the issue back to an active state to continue. When `false` (default), the dashboard shows a reply UI that posts the response as a tracker comment before resuming. | | `base_branch` | string | `""` | Remote branch used as the base for git diffs when enriching PR context (e.g. `"origin/develop"`). When empty, Itervox auto-detects via `git symbolic-ref refs/remotes/origin/HEAD`, falling back to `"origin/main"`. | | `reviewer_prompt` | string | built-in | (Deprecated) Liquid template for the legacy reviewer. Prefer `reviewer_profile`. | From a213149f67a2c15dcea41bf9639abfcfa27b4af5 Mon Sep 17 00:00:00 2001 From: Nitzan Volman Date: Sun, 3 May 2026 16:10:29 +0300 Subject: [PATCH 2/2] Handle resume prompt render fallback --- internal/agent/helpers_test.go | 4 ++-- internal/orchestrator/event_loop_test.go | 30 ++++++++++++++++++++++++ internal/orchestrator/worker.go | 15 ++---------- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/internal/agent/helpers_test.go b/internal/agent/helpers_test.go index 3afe362..65eeaa7 100644 --- a/internal/agent/helpers_test.go +++ b/internal/agent/helpers_test.go @@ -65,8 +65,8 @@ func TestBuildShellCmdResume(t *testing.T) { func TestBuildDirectArgsNewSession(t *testing.T) { args := buildDirectArgs(nil, "do the thing") - joined := strings.Join(args, " ") - assert.Contains(t, joined, "--output-format stream-json") + assert.Contains(t, args, "--output-format") + assert.Contains(t, args, "stream-json") assert.Contains(t, args, "-p") assert.Contains(t, args, "do the thing") assert.NotContains(t, args, "--resume") diff --git a/internal/orchestrator/event_loop_test.go b/internal/orchestrator/event_loop_test.go index 9a9b35c..95ab5ea 100644 --- a/internal/orchestrator/event_loop_test.go +++ b/internal/orchestrator/event_loop_test.go @@ -2003,3 +2003,33 @@ func TestClaudeResumeTurnsUseResumePrompt(t *testing.T) { assert.Equal(t, "Resume ENG-1 from existing context.", prompts[1]) assert.Equal(t, "agent-session-xyz", sessionIDs[1]) } + +func TestClaudeResumePromptRenderErrorFallsBackToWorkflowPrompt(t *testing.T) { + cfg := baseConfig() + cfg.Polling.IntervalMs = 20 + cfg.Agent.MaxTurns = 2 + cfg.Agent.Command = "claude" + cfg.Agent.ResumePrompt = "Resume {{ issue.missing_field }}." + cfg.PromptTemplate = "Full workflow prompt for {{ issue.identifier }}." + mt := singleIssueTracker(t, "In Progress") + runner := &resumePromptRunner{done: make(chan struct{})} + orch := orchestrator.New(cfg, mt, runner, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + go orch.Run(ctx) //nolint:errcheck + + select { + case <-runner.done: + case <-ctx.Done(): + t.Fatal("runner did not receive a Claude resume turn within 3s") + } + + prompts, sessionIDs := runner.snapshot() + require.Len(t, prompts, 2) + require.Len(t, sessionIDs, 2) + assert.Equal(t, "Full workflow prompt for ENG-1.", prompts[0]) + assert.Equal(t, "", sessionIDs[0]) + assert.Equal(t, "Full workflow prompt for ENG-1.", prompts[1]) + assert.Equal(t, "agent-session-xyz", sessionIDs[1]) +} diff --git a/internal/orchestrator/worker.go b/internal/orchestrator/worker.go index 830fb30..57a93f5 100644 --- a/internal/orchestrator/worker.go +++ b/internal/orchestrator/worker.go @@ -323,20 +323,9 @@ func (o *Orchestrator) runWorker(ctx context.Context, issue domain.Issue, attemp if err != nil { slog.Warn("worker: resume prompt render failed", "issue_id", issue.ID, "issue_identifier", issue.Identifier, "error", err) - if o.logBuf != nil { - o.logBuf.Add(issue.Identifier, makeBufLineWithSession("ERROR", fmt.Sprintf("worker: resume prompt render failed: %v", err), runLogID)) - } - if wsPath != "" { - hookCtx, hookCancel := context.WithTimeout(context.Background(), hookFallbackTimeout) - if hookErr := workspace.RunHook(hookCtx, afterRunHook, wsPath, hookTimeoutMs, o.hookLogFn(issue.Identifier, runLogID)); hookErr != nil { - slog.Warn("worker: after_run hook failed (ignored)", "issue_id", issue.ID, "error", hookErr) - } - hookCancel() - } - o.sendExit(ctx, issue, attempt, TerminalFailed, err) - return + } else { + turnPrompt = appendPromptSections(resumePrompt, firstResumeContext...) } - turnPrompt = appendPromptSections(resumePrompt, firstResumeContext...) } // Run agent turn — pass a logger pre-seeded with the issue identifier so