fix: guard passthrough initial prompt turn (#3247) (#3249)

This commit is contained in:
Carlos Florêncio
2026-09-01 12:28:47 +00:00
committed by GitHub
parent aee0c5c3f3
commit 40beea5f2e
14 changed files with 769 additions and 26 deletions
@@ -810,6 +810,11 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE
execution.PassthroughProcessID = processInfo.ID
execution.PassthroughStartedAt = time.Now()
execution.passthroughLaunchUsedResume = false
if requiresPassthroughInitialPrompt(execution, pt) {
execution.passthroughInitialPromptProcessID = processInfo.ID
} else {
execution.passthroughInitialPromptProcessID = ""
}
execution.passthroughLifecycleMu.Unlock()
m.logger.Info("passthrough session started",
@@ -832,7 +837,7 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE
}
}
go m.autoInjectInitialPrompt(execution, pt)
go m.autoInjectInitialPrompt(execution, pt, processInfo.ID)
return nil
}
@@ -947,6 +952,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
// the deliberately-killed process.
oldProcessID := execution.PassthroughProcessID
execution.PassthroughProcessID = ""
execution.passthroughInitialPromptProcessID = ""
execution.PassthroughStartedAt = time.Time{}
if err := interactiveRunner.Stop(ctx, oldProcessID); err != nil {
@@ -969,6 +975,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
execution.PassthroughProcessID = processInfo.ID
execution.PassthroughStartedAt = time.Now()
execution.passthroughLaunchUsedResume = false
execution.passthroughInitialPromptProcessID = ""
m.logger.Info("passthrough process restarted with fresh context",
zap.String("execution_id", execution.ID),
@@ -1070,6 +1077,7 @@ func (m *Manager) resumePassthroughSession(ctx context.Context, sessionID, expec
// (the ID is what routes a status update to this execution), so the flag has
// to be set before the process can exit and publish a status.
execution.passthroughLaunchUsedResume = useResume
execution.passthroughInitialPromptProcessID = ""
execution.PassthroughProcessID = processInfo.ID
m.logger.Info("passthrough session resumed",
@@ -1106,11 +1114,21 @@ func (m *Manager) handlePassthroughTurnComplete(sessionID, processID string) {
zap.String("session_id", sessionID))
return
}
if execution.PassthroughProcessID != processID {
execution.passthroughLifecycleMu.Lock()
activeProcessID := execution.PassthroughProcessID
initialPromptPending := execution.passthroughInitialPromptProcessID == processID
execution.passthroughLifecycleMu.Unlock()
if activeProcessID != processID {
m.logger.Debug("ignoring stale passthrough turn complete",
zap.String("session_id", sessionID),
zap.String("process_id", processID),
zap.String("active_process_id", execution.PassthroughProcessID))
zap.String("active_process_id", activeProcessID))
return
}
if initialPromptPending {
m.logger.Debug("ignoring passthrough turn complete while initial prompt is pending",
zap.String("session_id", sessionID),
zap.String("process_id", processID))
return
}
@@ -1387,6 +1405,11 @@ func (m *Manager) attemptResumeFallbackForProcess(execution *AgentExecution, run
execution.PassthroughStartedAt = time.Now()
execution.passthroughLaunchUsedResume = false
execution.PassthroughProcessID = processInfo.ID
if requiresPassthroughInitialPrompt(execution, pt) {
execution.passthroughInitialPromptProcessID = processInfo.ID
} else {
execution.passthroughInitialPromptProcessID = ""
}
if runner.ConnectSessionWebSocket(processInfo.ID) {
m.logger.Info("passthrough resume fallback succeeded",
@@ -1411,7 +1434,7 @@ func (m *Manager) attemptResumeFallbackForProcess(execution *AgentExecution, run
}
// Fallback path is a fresh session (no --resume) — re-inject the prompt.
go m.autoInjectInitialPrompt(execution, pt)
go m.autoInjectInitialPrompt(execution, pt, processInfo.ID)
}
// attemptPassthroughRestart announces the restart on the terminal, waits the
@@ -1556,21 +1579,46 @@ type passthroughRunner interface {
WriteStdin(processID string, data string) error
}
func requiresPassthroughInitialPrompt(execution *AgentExecution, pt agents.PassthroughConfig) bool {
return pt.PromptFlag.IsEmpty() && getTaskDescriptionFromMetadata(execution) != ""
}
// claimPassthroughInitialPrompt verifies that this goroutine is still
// associated with the active PTY process before proceeding with injection.
// startPassthroughSession pre-sets the marker; this check deliberately does
// not claim a marker for a replacement process.
func claimPassthroughInitialPrompt(execution *AgentExecution, processID string) bool {
execution.passthroughLifecycleMu.Lock()
defer execution.passthroughLifecycleMu.Unlock()
if execution.PassthroughProcessID != processID || execution.passthroughInitialPromptProcessID != processID {
return false
}
return true
}
func clearPassthroughInitialPrompt(execution *AgentExecution, processID string) {
execution.passthroughLifecycleMu.Lock()
defer execution.passthroughLifecycleMu.Unlock()
if execution.passthroughInitialPromptProcessID == processID {
execution.passthroughInitialPromptProcessID = ""
}
}
// autoInjectInitialPrompt writes the task description to PTY stdin once a
// passthrough agent without a PromptFlag is idle (ready for input). Called from
// startPassthroughSession and attemptResumeFallback only — never from
// ResumePassthroughSession (would duplicate the prompt in agent history).
func (m *Manager) autoInjectInitialPrompt(execution *AgentExecution, pt agents.PassthroughConfig) {
func (m *Manager) autoInjectInitialPrompt(execution *AgentExecution, pt agents.PassthroughConfig, processID string) {
runner := m.GetInteractiveRunner()
if runner == nil {
return
}
m.autoInjectInitialPromptWith(runner, execution, pt)
m.autoInjectInitialPromptWith(runner, execution, pt, processID)
}
// autoInjectInitialPromptWith is the testable inner of autoInjectInitialPrompt,
// taking a runner seam so unit tests can avoid spawning a real PTY.
func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, execution *AgentExecution, pt agents.PassthroughConfig) {
func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, execution *AgentExecution, pt agents.PassthroughConfig, processID string) {
if !pt.PromptFlag.IsEmpty() {
// The agent already received the prompt as a CLI flag — no stdin
// delivery. This mirrors promptForPassthroughCommand, which only
@@ -1586,12 +1634,15 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio
// take this exit and never receive spurious stdin.
return
}
processID := execution.PassthroughProcessID
if processID == "" {
m.logger.Warn("autoInjectInitialPrompt called without passthrough process",
zap.String("execution_id", execution.ID))
return
}
if !claimPassthroughInitialPrompt(execution, processID) {
return
}
defer clearPassthroughInitialPrompt(execution, processID)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := runner.WaitForFirstIdle(ctx, processID); err != nil {
@@ -1601,6 +1652,9 @@ func (m *Manager) autoInjectInitialPromptWith(runner passthroughRunner, executio
zap.Error(err))
return
}
if !m.passthroughProcessMatches(execution, processID) {
return
}
// WaitForFirstIdle also unblocks when the process exits — skip the write
// during shutdown so we don't race the lifecycle manager's teardown. If the
// process is already gone, WriteStdin returns "process not found" and the
@@ -7,6 +7,7 @@ import (
"time"
"github.com/kandev/kandev/internal/agent/agents"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// fakePassthroughRunner is a minimal stub of the passthroughRunner seam used
@@ -62,6 +63,7 @@ func newAutoInjectExecution(description string) *AgentExecution {
SessionID: "sess-1",
PassthroughProcessID: "proc-abc",
}
exec.passthroughInitialPromptProcessID = exec.PassthroughProcessID
if description != "" {
exec.setMetadataValue("task_description", description)
}
@@ -81,7 +83,7 @@ func TestAutoInject_disabled_does_nothing(t *testing.T) {
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution(""), agents.PassthroughConfig{
AutoInjectPrompt: false,
SubmitSequence: "\r",
})
}, "proc-abc")
if runner.writeCalled {
t.Fatalf("expected no stdin write when task description is empty, got data=%q", runner.writtenData)
@@ -100,7 +102,7 @@ func TestAutoInject_writes_without_auto_inject_flag(t *testing.T) {
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution("do a thing"), agents.PassthroughConfig{
AutoInjectPrompt: false,
SubmitSequence: "\r",
})
}, "proc-abc")
if !runner.writeCalled {
t.Fatalf("expected WriteStdin to be called for a no-flag custom TUI agent")
@@ -113,16 +115,27 @@ func TestAutoInject_writes_without_auto_inject_flag(t *testing.T) {
func TestAutoInject_skipped_when_PromptFlag_set(t *testing.T) {
mgr := newTestManager(t)
runner := &fakePassthroughRunner{}
execution := newAutoInjectExecution("do a thing")
execution.Status = v1.AgentStatusRunning
execution.passthroughInitialPromptProcessID = ""
if err := mgr.executionStore.Add(execution); err != nil {
t.Fatalf("add execution: %v", err)
}
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution("do a thing"), agents.PassthroughConfig{
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{
AutoInjectPrompt: true,
SubmitSequence: "\r",
PromptFlag: agents.NewParam("--prompt", "{prompt}"),
})
}, execution.PassthroughProcessID)
if runner.writeCalled {
t.Fatalf("expected no stdin write when PromptFlag is set, got data=%q", runner.writtenData)
}
mgr.handlePassthroughTurnComplete(execution.SessionID, execution.PassthroughProcessID)
if execution.Status != v1.AgentStatusReady {
t.Fatalf("prompt-flag completion left status %q, want READY", execution.Status)
}
}
func TestAutoInject_skipped_when_description_empty(t *testing.T) {
@@ -132,7 +145,7 @@ func TestAutoInject_skipped_when_description_empty(t *testing.T) {
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution(""), agents.PassthroughConfig{
AutoInjectPrompt: true,
SubmitSequence: "\r",
})
}, "proc-abc")
if runner.writeCalled {
t.Fatalf("expected no stdin write when task description is empty, got data=%q", runner.writtenData)
@@ -146,7 +159,7 @@ func TestAutoInject_writes_description_plus_submit(t *testing.T) {
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution("hello world"), agents.PassthroughConfig{
AutoInjectPrompt: true,
SubmitSequence: "\r",
})
}, "proc-abc")
if !runner.writeCalled {
t.Fatalf("expected WriteStdin to be called")
@@ -165,14 +178,19 @@ func TestAutoInject_writes_description_plus_submit(t *testing.T) {
func TestAutoInject_returns_when_wait_errors(t *testing.T) {
mgr := newTestManager(t)
runner := &fakePassthroughRunner{waitErr: context.DeadlineExceeded}
execution := newAutoInjectExecution("hello")
execution.Status = v1.AgentStatusRunning
if err := mgr.executionStore.Add(execution); err != nil {
t.Fatalf("add execution: %v", err)
}
done := make(chan struct{})
go func() {
defer close(done)
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution("hello"), agents.PassthroughConfig{
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{
AutoInjectPrompt: true,
SubmitSequence: "\r",
})
}, execution.PassthroughProcessID)
}()
select {
@@ -184,6 +202,11 @@ func TestAutoInject_returns_when_wait_errors(t *testing.T) {
if runner.writeCalled {
t.Fatalf("expected no stdin write on wait timeout, got data=%q", runner.writtenData)
}
mgr.handlePassthroughTurnComplete(execution.SessionID, execution.PassthroughProcessID)
if execution.Status != v1.AgentStatusReady {
t.Fatalf("completion after aborted injection left status %q, want READY", execution.Status)
}
}
// TestAutoInject_SubmitDelay_splits_writes_with_pause is the Claude regression:
@@ -201,7 +224,7 @@ func TestAutoInject_SubmitDelay_splits_writes_with_pause(t *testing.T) {
SubmitSequence: "\r",
DisableBracketedPaste: true,
SubmitDelay: delay,
})
}, "proc-abc")
if got := len(runner.writes); got != 2 {
t.Fatalf("expected 2 writes (body, submit), got %d: %#v", got, runner.writes)
@@ -231,7 +254,7 @@ func TestAutoInject_skipped_when_process_id_missing(t *testing.T) {
mgr.autoInjectInitialPromptWith(runner, exec, agents.PassthroughConfig{
AutoInjectPrompt: true,
SubmitSequence: "\r",
})
}, exec.PassthroughProcessID)
if runner.writeCalled {
t.Fatalf("expected no stdin write when PassthroughProcessID empty, got data=%q", runner.writtenData)
@@ -11,10 +11,13 @@ import (
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/executor"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agentctl/server/process"
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/events"
eventbus "github.com/kandev/kandev/internal/events/bus"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -138,6 +141,48 @@ func TestStartPassthroughSessionSurfacesLaunchFailure(t *testing.T) {
require.NotEmpty(t, execution.ErrorMessage)
}
// @covers AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.1
func TestStartPassthroughSessionClaimsInitialPromptBeforeReadyPublication(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
const agentID = "pending-prompt-test-agent"
require.NoError(t, mgr.registry.Register(&testAgent{
id: agentID,
enabled: true,
StandardPassthrough: agents.StandardPassthrough{Cfg: agents.PassthroughConfig{
Supported: true,
PassthroughCmd: agents.NewCommand("sh", "-c", "sleep 30"),
IdleTimeout: time.Second,
SubmitSequence: "\r",
AutoInjectPrompt: true,
}},
}))
mgr.profileResolver = &mockPassthroughProfileResolver{
agentName: agentID,
cliPassthrough: true,
}
execution.AgentID = agentID
execution.Status = v1.AgentStatusRunning
execution.setMetadataValue("task_description", "Ship the release")
var statusAtReadyPublication v1.AgentStatus
bus := mgr.eventBus.(*MockEventBus)
bus.OnPublish = func(subject string, _ *eventbus.Event) {
if subject != events.AgentctlReady {
return
}
mgr.handlePassthroughTurnComplete(execution.SessionID, execution.PassthroughProcessID)
statusAtReadyPublication = execution.Status
}
require.NoError(t, mgr.startPassthroughSession(context.Background(), execution, nil))
t.Cleanup(func() {
_ = runner.Stop(context.Background(), execution.PassthroughProcessID)
})
require.Equal(t, v1.AgentStatusRunning, statusAtReadyPublication,
"the launch must claim initial prompt delivery before later startup work can publish ready")
}
func TestResumePassthroughSessionSurfacesLaunchFailure(t *testing.T) {
mgr, _, execution := newPassthroughRunnerManager(t)
execution.WorkspacePath = missingWorkspace(t)
@@ -261,6 +306,45 @@ func TestAttemptResumeFallbackAnnouncesAndRelaunchesFresh(t *testing.T) {
require.False(t, execution.passthroughLaunchUsedResume)
}
// @covers AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.4
func TestAttemptResumeFallbackClaimsFreshInitialPrompt(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
const agentID = "fallback-pending-prompt-test-agent"
require.NoError(t, mgr.registry.Register(&testAgent{
id: agentID,
enabled: true,
StandardPassthrough: agents.StandardPassthrough{Cfg: agents.PassthroughConfig{
Supported: true,
PassthroughCmd: agents.NewCommand("sh", "-c", "sleep 30"),
IdleTimeout: time.Second,
SubmitSequence: "\r",
AutoInjectPrompt: true,
}},
}))
mgr.profileResolver = &mockPassthroughProfileResolver{
agentName: agentID,
cliPassthrough: true,
}
execution.AgentID = agentID
execution.Status = v1.AgentStatusRunning
execution.setMetadataValue("task_description", "Ship the release")
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket(execution.SessionID, recorder)
mgr.attemptResumeFallback(execution, runner, execution.SessionID, 1, 400*time.Millisecond)
execution.passthroughLifecycleMu.Lock()
activeProcessID := execution.PassthroughProcessID
pendingProcessID := execution.passthroughInitialPromptProcessID
execution.passthroughLifecycleMu.Unlock()
t.Cleanup(func() {
_ = runner.Stop(context.Background(), activeProcessID)
})
require.NotEmpty(t, activeProcessID)
require.Equal(t, activeProcessID, pendingProcessID,
"a fresh fallback must own its initial prompt before the injection goroutine runs")
}
func TestAttemptResumeFallbackSkipsDuringShutdown(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
mgr.shuttingDown.Store(true)
@@ -503,6 +503,121 @@ func TestHandlePassthroughTurnCompleteUnknownSession(t *testing.T) {
require.Empty(t, bus.PublishedEvents)
}
type pendingInitialPromptRunner struct {
waiting chan struct{}
release chan struct{}
writeCalled bool
}
func (r *pendingInitialPromptRunner) WaitForFirstIdle(context.Context, string) error {
close(r.waiting)
<-r.release
return nil
}
func (r *pendingInitialPromptRunner) WriteStdin(string, string) error {
r.writeCalled = true
return nil
}
// @covers AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.1
func TestHandlePassthroughTurnCompleteSuppressesPendingInitialPrompt(t *testing.T) {
mgr := newTestManager(t)
execution := newAutoInjectExecution("Ship the release")
execution.Status = v1.AgentStatusRunning
require.NoError(t, mgr.executionStore.Add(execution))
runner := &pendingInitialPromptRunner{
waiting: make(chan struct{}),
release: make(chan struct{}),
}
done := make(chan struct{})
go func() {
defer close(done)
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{SubmitSequence: "\r"}, execution.PassthroughProcessID)
}()
released := false
defer func() {
if !released {
close(runner.release)
<-done
}
}()
<-runner.waiting
mgr.handlePassthroughTurnComplete(execution.SessionID, execution.PassthroughProcessID)
require.Equal(t, v1.AgentStatusRunning, execution.Status,
"startup readiness must not complete a turn before initial prompt injection")
bus := mgr.eventBus.(*MockEventBus)
require.Empty(t, bus.PublishedEvents, "startup readiness must not publish a lifecycle event")
close(runner.release)
released = true
<-done
mgr.handlePassthroughTurnComplete(execution.SessionID, execution.PassthroughProcessID)
require.Equal(t, v1.AgentStatusReady, execution.Status)
require.Len(t, bus.PublishedEvents, 1)
require.Equal(t, events.AgentReady, bus.PublishedEvents[0].Type)
}
// @covers AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.5
func TestAutoInjectCleanupDoesNotClearReplacementInitialPrompt(t *testing.T) {
mgr := newTestManager(t)
execution := newAutoInjectExecution("Ship the release")
execution.Status = v1.AgentStatusRunning
require.NoError(t, mgr.executionStore.Add(execution))
runner := &pendingInitialPromptRunner{
waiting: make(chan struct{}),
release: make(chan struct{}),
}
done := make(chan struct{})
go func() {
defer close(done)
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{SubmitSequence: "\r"}, execution.PassthroughProcessID)
}()
defer func() {
select {
case <-done:
default:
close(runner.release)
<-done
}
}()
<-runner.waiting
execution.passthroughLifecycleMu.Lock()
execution.PassthroughProcessID = "proc-replacement"
execution.passthroughInitialPromptProcessID = "proc-replacement"
execution.passthroughLifecycleMu.Unlock()
close(runner.release)
<-done
require.False(t, runner.writeCalled,
"an injection owned by the old process must not write after replacement")
mgr.handlePassthroughTurnComplete(execution.SessionID, "proc-replacement")
require.Equal(t, v1.AgentStatusRunning, execution.Status,
"cleanup from the old injection must not expose the replacement turn boundary")
}
// @covers AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.5
func TestAutoInjectSkipsReplacementBeforeClaim(t *testing.T) {
mgr := newTestManager(t)
execution := newAutoInjectExecution("Ship the release")
execution.PassthroughProcessID = "proc-replacement"
execution.passthroughInitialPromptProcessID = "proc-replacement"
runner := &fakePassthroughRunner{}
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{SubmitSequence: "\r"}, "proc-original")
require.False(t, runner.writeCalled,
"an injector for the replaced process must not write to the replacement")
require.Equal(t, "proc-replacement", execution.passthroughInitialPromptProcessID,
"the replacement marker must remain available to its injector")
}
// TestAutoInjectMarksRunningBeforeWriting pins the ordering that blocks a
// racing composer submission: the execution must be RUNNING before the first
// chunk lands on the PTY, so checkSessionPromptable rejects a concurrent
@@ -516,7 +631,7 @@ func TestAutoInjectMarksRunningBeforeWriting(t *testing.T) {
var statusAtFirstWrite v1.AgentStatus
runner := &statusObservingRunner{onWrite: func() { statusAtFirstWrite = execution.Status }}
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{SubmitSequence: "\r"})
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{SubmitSequence: "\r"}, execution.PassthroughProcessID)
require.Equal(t, []string{"Ship the release\r"}, runner.writes)
require.Equal(t, v1.AgentStatusRunning, statusAtFirstWrite,
@@ -534,10 +649,14 @@ func TestAutoInjectStopsAfterWriteFailure(t *testing.T) {
SubmitSequence: "\r",
DisableBracketedPaste: true,
SubmitDelay: time.Millisecond,
})
}, execution.PassthroughProcessID)
require.Len(t, runner.writes, 1,
"a failed write must abort the chunk loop instead of continuing to the submit byte")
mgr.handlePassthroughTurnComplete(execution.SessionID, execution.PassthroughProcessID)
require.Equal(t, v1.AgentStatusReady, execution.Status,
"a failed injection must release the completion boundary for later manual work")
}
func TestAutoInjectSkipsDuringShutdown(t *testing.T) {
@@ -545,9 +664,10 @@ func TestAutoInjectSkipsDuringShutdown(t *testing.T) {
mgr.shuttingDown.Store(true)
runner := &fakePassthroughRunner{}
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution("Ship it"), agents.PassthroughConfig{
execution := newAutoInjectExecution("Ship it")
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{
SubmitSequence: "\r",
})
}, execution.PassthroughProcessID)
require.False(t, runner.writeCalled, "teardown must not race a stdin write into a dying PTY")
}
@@ -561,7 +681,7 @@ func TestAutoInjectInitialPromptWithoutRunnerIsNoOp(t *testing.T) {
execution.Status = v1.AgentStatusReady
require.NoError(t, mgr.executionStore.Add(execution))
mgr.autoInjectInitialPrompt(execution, agents.PassthroughConfig{SubmitSequence: "\r"})
mgr.autoInjectInitialPrompt(execution, agents.PassthroughConfig{SubmitSequence: "\r"}, execution.PassthroughProcessID)
require.Equal(t, v1.AgentStatusReady, execution.Status,
"without a runner the execution must not be flipped to RUNNING")
@@ -128,6 +128,9 @@ type AgentExecution struct {
passthroughLifecycleMu sync.Mutex
PassthroughProcessID string // Process ID in the interactive runner (empty if not in passthrough mode)
PassthroughStartedAt time.Time // When the current passthrough process was launched; used to detect fast-fail exits and skip auto-restart loops
// passthroughInitialPromptProcessID identifies the fresh PTY process that
// still needs its initial task prompt through stdin.
passthroughInitialPromptProcessID string
// passthroughLaunchUsedResume is true if the current passthrough process was
// launched via ResumePassthroughSession with the resume flag attached. The
// fast-fail handler reads this to decide whether to retry once with a fresh
@@ -191,14 +191,18 @@ func (r *InteractiveRunner) resetIdleTimer(proc *interactiveProcess) {
}
func (r *InteractiveRunner) emitTurnComplete(proc *interactiveProcess) {
// Invoke the callback before releasing firstIdleCh. Callers that inject the
// initial prompt use the callback to consume the startup completion boundary;
// waking the injector first could let it clear that marker before the
// callback observes it.
if r.turnCompleteCallback != nil {
r.turnCompleteCallback(proc.info.SessionID, proc.info.ID)
}
// Close firstIdleCh exactly once — used by callers that want to react to
// the very first idle window (e.g. auto-injecting the task prompt).
proc.firstIdleOnce.Do(func() {
close(proc.firstIdleCh)
})
if r.turnCompleteCallback != nil {
r.turnCompleteCallback(proc.info.SessionID, proc.info.ID)
}
r.logger.Debug("turn complete detected",
zap.String("process_id", proc.info.ID),
zap.String("session_id", proc.info.SessionID))
@@ -92,3 +92,29 @@ func TestEmitTurnComplete_closes_firstIdle_only_once(t *testing.T) {
t.Fatalf("firstIdleCh was not closed after emitTurnComplete")
}
}
func TestEmitTurnComplete_callsCallbackBeforeReleasingFirstIdle(t *testing.T) {
runner, proc := newRunnerWithProcess(t, "proc-order")
callbackCalled := make(chan struct{})
runner.SetTurnCompleteCallback(func(_, _ string) {
select {
case <-proc.firstIdleCh:
t.Fatal("firstIdleCh released before turn-complete callback")
default:
}
close(callbackCalled)
})
runner.emitTurnComplete(proc)
select {
case <-callbackCalled:
default:
t.Fatal("turn-complete callback was not called")
}
select {
case <-proc.firstIdleCh:
default:
t.Fatal("firstIdleCh was not released after turn-complete callback")
}
}
@@ -0,0 +1,60 @@
# ADR-2026-09-01-passthrough-initial-prompt-turn-boundary: Keep Passthrough Initial Prompt State in Lifecycle
**Status:** accepted
**Date:** 2026-09-01
**Area:** backend, workflow
## Context
CLI passthrough uses the first PTY idle window as the readiness signal for
injecting an initial task prompt. The interactive runner also reports every
idle window as a completed turn. On a fresh process, the first callback can
therefore publish `agent.ready` before prompt injection. This event can place
the task in review and evaluate completion consumers before the agent receives
work.
The raw detector cannot determine whether Kandev still owes the process an
initial prompt. That delivery context belongs to lifecycle and differs across
fresh stdin injection, prompt-flag launch, promptless launch, successful resume,
and fresh-start fallback.
## Decision
The lifecycle manager owns an in-memory initial-prompt marker bound to the
active passthrough process identity. Fresh launches and fresh-start fallbacks
install the marker only when a non-empty task prompt must be delivered through
PTY stdin.
Passthrough completion handling ignores every raw completion callback from the
marked process until prompt injection finishes or aborts. Prompt injection
clears only the marker whose process identity it captured. Prompt-flag,
promptless, and resumed processes do not install a marker and retain ordinary
completion behavior.
Agentctl remains a raw signal source. It does not receive task-description or
prompt-delivery policy, and it does not reinterpret the first idle globally.
## Consequences
Startup idle, prompt-pattern, and status signals cannot move a task or trigger
workflow completion before the initial prompt is submitted. Process identity
prevents delayed cleanup or callbacks from affecting a replacement process.
The marker is runtime-only and must be cleared on successful submission,
timeout, shutdown, write failure, and process replacement. Mid-turn idle
accuracy is unchanged and remains a separate concern.
## Alternatives Considered
- Suppress the first idle inside `InteractiveRunner`. The runner lacks prompt
delivery context, and first idle is a real completion for prompt-flag agents.
Suppressing one callback also does not cover a concurrent prompt-pattern or
status callback before injection.
- Publish `agent.boot_ready` for the first idle. Boot-ready handling can make
the session promptable and drain queued work while the initial prompt is
still pending.
- Force an `agent.running` event during injection. That repairs state only
after a false completion and retains a visible state flicker and completion
side effects that cannot be undone.
- Increase the idle timeout. Cold-start duration is unbounded, and timeout
tuning cannot distinguish startup readiness from turn completion.
+1
View File
@@ -228,3 +228,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-31-revoke-merge-approval-after-untrusted-push | [Revoke merge approval after an untrusted pull-request push](2026-08-31-revoke-merge-approval-after-untrusted-push.md) | accepted | workflow, security | 2026-08-31 |
| 2026-08-31-passthrough-running-publication | [Defer Passthrough Running Publication Until Guard Release](2026-08-31-passthrough-running-publication.md) | accepted | backend | 2026-08-31 |
| 2026-08-31-agent-aware-mcp-tool-names | [Preserve Canonical MCP Tool Names Through Agent Namespacing](2026-08-31-agent-aware-mcp-tool-names.md) | accepted | backend, agentctl, protocol | 2026-08-31 |
| 2026-09-01-passthrough-initial-prompt-turn-boundary | [Keep Passthrough Initial Prompt State in Lifecycle](2026-09-01-passthrough-initial-prompt-turn-boundary.md) | accepted | backend, workflow | 2026-09-01 |
@@ -0,0 +1,87 @@
---
created: 2026-09-01
status: done
requirements:
- REQ-TASKS-PASSTHROUGH-INITIAL-TURN-001
system_design:
- ../../specs/tasks/system-design/passthrough-initial-prompt-turn-boundary.md
legacy_specs:
- ../../specs/cli/requirements/cli-mode-parity.md
---
# Implementation Plan: Passthrough Initial Prompt Turn Boundary
## Overview
Add a process-scoped pending-initial-prompt marker to passthrough lifecycle,
then use it to reject completion callbacks until fresh prompt injection ends.
One work order keeps marker ownership, launch and fallback integration, cleanup,
and regression coverage atomic.
## Scope
### In scope
- Mark fresh passthrough processes that still require initial stdin prompt
injection.
- Suppress all completion callback sources while the matching process owns the
marker.
- Clear the marker safely after successful or aborted injection.
- Preserve first-completion behavior for prompt-flag, promptless, and resumed
processes.
- Cover process replacement and fresh-start fallback behavior.
### Out of scope
- Replacing the idle detector or fixing mid-turn idle false positives.
- Changing prompt text, submit chunk planning, or idle timeout values.
- Changing `agent.ready`, `agent.boot_ready`, or workflow event semantics.
- Adding frontend behavior or browser automation for a backend lifecycle gate.
## Technical approach
Extend `AgentExecution` in
`apps/backend/internal/agent/runtime/lifecycle/types.go` with a runtime-only
pending initial-prompt process identity protected by
`passthroughLifecycleMu`. Add narrow helpers that install, query, and clear the
marker by process ID.
In `manager_passthrough.go`, centralize the predicate for stdin initial-prompt
delivery. Install the marker alongside `PassthroughProcessID` for the original
fresh start and the fresh-start resume fallback. Do not install it for
`PromptFlag`, empty-description, or ordinary resume paths.
Have `handlePassthroughTurnComplete` snapshot the active process and marker
under the lifecycle lock. Keep the existing stale-process rejection, then
return with a debug diagnostic when the active process still owns the pending
marker. In `autoInjectInitialPromptWith`, defer identity-checked cleanup after
capturing the process ID so success, timeout, shutdown, and write failure all
release only their own marker.
## Tests
| Acceptance criterion | Evidence |
| --- | --- |
| `AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.1` | A lifecycle regression marks a running PTY as pending and fires turn completion. It asserts that no `agent.ready` event or Ready status occurs. |
| `AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.2` | An auto-injection test submits all chunks, verifies marker cleanup, then fires completion and asserts exactly one ordinary `agent.ready` event. |
| `AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.3` | Existing and extended tests prove unmarked active processes, prompt-flag launches, promptless launches, and resume paths retain ordinary behavior. |
| `AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.4` | The fresh-fallback launch test asserts that the new process receives the marker before its injection goroutine runs. |
| `AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.5` | Timeout/write-failure and replacement tests prove identity-checked cleanup and completion eligibility for the active replacement. |
## Work orders
- [x] [Task 01: Guard the passthrough initial turn](task-01-guard-passthrough-initial-turn.md)
## Verification results
- The focused lifecycle command passed 16 tests.
- The race-detector command passed the same 16 tests.
- The full lifecycle package command passed 2,100 tests.
## Risks
- A marker without process identity could suppress a replacement process.
- Cleanup that runs only on successful writes could leave manual follow-up work
permanently non-completing after an injection failure.
- Installing the marker for prompt-flag or resume paths would hide a genuine
first turn completion.
@@ -0,0 +1,111 @@
---
id: "01-guard-passthrough-initial-turn"
title: "Guard the passthrough initial turn"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-PASSTHROUGH-INITIAL-TURN-001
acceptance_criteria:
- AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.1
- AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.2
- AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.3
- AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.4
- AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.5
system_design:
- ../../specs/tasks/system-design/passthrough-initial-prompt-turn-boundary.md
---
# Task 01: Guard the Passthrough Initial Turn
## Summary
Keep fresh passthrough executions running while Kandev waits to inject their
initial prompt. Bind the pending boundary to one PTY process so successful and
failed delivery, resume fallback, and process replacement remain deterministic.
## In scope
- Add process-scoped initial-prompt state to `AgentExecution`.
- Install it for original fresh launches and fresh-start resume fallbacks that
require stdin prompt delivery.
- Suppress matching completion callbacks until injection finishes or aborts.
- Clear only the captured process's marker on every injection exit path.
- Add focused lifecycle launch, completion, injection, failure, and replacement
regressions.
## Out of scope
- Agentctl detector changes and mid-turn idle detection.
- Orchestrator, workflow-engine, database, API, or frontend changes.
- Prompt formatting, submit sequences, and idle timeout tuning.
## Acceptance
- A startup completion signal for a process with pending initial injection
emits no `agent.ready` event and leaves the execution running.
- After injection clears the matching marker, one eligible signal uses the
existing Ready path.
- Abort and replacement cleanup cannot affect a different process.
- Prompt-flag, promptless, and resume paths never acquire the marker, while a
fresh-start fallback does.
## Verification
```bash
go test -tags fts5 ./internal/agent/runtime/lifecycle -run 'Test(HandlePassthroughTurnComplete|AutoInject|Passthrough.*Fallback)' -count=1
go test -race -tags fts5 ./internal/agent/runtime/lifecycle -run 'Test(HandlePassthroughTurnComplete|AutoInject|Passthrough.*Fallback)' -count=1
go test -tags fts5 ./internal/agent/runtime/lifecycle -count=1
```
Run all commands from `apps/backend`.
## Files likely touched
- `apps/backend/internal/agent/runtime/lifecycle/types.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_autoinject_test.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_launch_test.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_runtime_test.go`
## Dependencies
None.
## Risks
- Process lifecycle fields currently have mixed locking discipline. New marker
reads and writes must use the existing passthrough lifecycle lock without
holding it across event publication or PTY I/O.
## Parallelism
`sequential`
## Inputs
- `docs/specs/tasks/requirements/passthrough-initial-prompt-turn-boundary.md`
- `docs/specs/tasks/system-design/passthrough-initial-prompt-turn-boundary.md`
- `docs/decisions/2026-09-01-passthrough-initial-prompt-turn-boundary.md`
- `docs/specs/cli/requirements/cli-mode-parity.md`
- GitHub issue #3247 and the deterministic failing reproduction recorded in
Kandev task `28542e6c-c81b-4ba2-be6b-f46ee03b337d`.
## Results
- Added an in-memory marker that binds pending initial prompt delivery to one
passthrough process.
- Fresh launches and fresh-start fallbacks install the marker before later
startup work can publish a completion signal.
- Completion callbacks remain inactive until prompt injection finishes or
stops. Prompt-flag and resumed processes keep their existing behavior.
- Cleanup and PTY writes compare process identity. Work from an old process
cannot clear or write to its replacement.
- `go test -tags fts5 ./internal/agent/runtime/lifecycle -run 'Test(HandlePassthroughTurnComplete|AutoInject|Passthrough.*Fallback)' -count=1`
passed 16 tests.
- `go test -race -tags fts5 ./internal/agent/runtime/lifecycle -run 'Test(HandlePassthroughTurnComplete|AutoInject|Passthrough.*Fallback)' -count=1`
passed the same 16 tests.
- `go test -tags fts5 ./internal/agent/runtime/lifecycle -count=1` passed
2,100 tests.
+2
View File
@@ -61,6 +61,7 @@ signals, and task-scoped scheduling contracts.
- [Parent-Child Message Interrupt](requirements/parent-child-message-interrupt.md)
- [Parent-Child Task Stop](requirements/parent-child-task-stop.md)
- [Passthrough Queued Prompt Dispatch](requirements/passthrough-queued-prompt-dispatch.md)
- [Passthrough Initial Prompt Turn Boundary](requirements/passthrough-initial-prompt-turn-boundary.md)
- [Prevent Agent Auto-Start On Open](requirements/prevent-agent-autostart-on-open.md)
- [Prompt attachments](requirements/prompt-attachments.md)
- [Quick Chat Sessions, Persistence, and Expiration](requirements/quick-chat-expiration.md)
@@ -123,6 +124,7 @@ signals, and task-scoped scheduling contracts.
- [MCP Tool Name Stability](system-design/mcp-tool-name-stability.md)
- [Remote Contribution Tasks](system-design/remote-contribution-tasks.md)
- [Passthrough Queued Prompt Dispatch](system-design/passthrough-queued-prompt-dispatch.md)
- [Passthrough Initial Prompt Turn Boundary](system-design/passthrough-initial-prompt-turn-boundary.md)
- [Task Archive Confirmation](system-design/archive-confirmation.md)
- [Task plan write lifecycle](system-design/plan-write-lifecycle.md)
- [Task Runtime Cleanup](system-design/runtime-cleanup.md)
@@ -0,0 +1,62 @@
---
status: active
system: tasks
created: 2026-09-01
owners:
- cfl
---
# Passthrough Initial Prompt Turn Boundary Requirements
## Overview
The task system owns session lifecycle state, turn completion, workflow
triggers, and downstream completion effects. A fresh CLI passthrough process
can become idle before Kandev injects its initial task prompt. That startup
readiness signal must not be interpreted as a completed agent turn.
CLI passthrough owns the PTY transport and readiness detector. The task system
owns whether a detector signal is eligible to complete a turn.
## Terminology
- **Pending initial prompt:** A non-empty task prompt that Kandev must submit
through a fresh passthrough process's PTY because the agent does not receive
it at process launch.
- **Completion signal:** A passthrough prompt-pattern, status, or idle signal
that can normally mark an agent turn ready for follow-up work.
## Requirements
### REQ-TASKS-PASSTHROUGH-INITIAL-TURN-001: Initial prompt owns the first turn boundary
**Intent:** A passthrough task shall remain active until its agent has received
and processed the initial prompt.
#### Acceptance criteria
- **AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.1:** When a fresh passthrough process
has a pending initial prompt, completion signals received before Kandev
finishes submitting that prompt shall not complete a turn. These signals
shall not change session or task state, evaluate `on_turn_complete`, or invoke
other turn-completion consumers.
- **AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.2:** After Kandev finishes submitting
the initial prompt, the next eligible completion signal shall complete the
initial turn through the ordinary passthrough completion path.
- **AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.3:** A passthrough process that
receives its prompt at process launch or has no initial prompt shall retain
ordinary completion behavior. A resumed conversation without reinjection
shall use the same behavior.
- **AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.4:** A fresh-start fallback that must
reinject the initial prompt shall use the same pending boundary as an
original fresh launch.
- **AC-TASKS-PASSTHROUGH-INITIAL-TURN-001.5:** When initial prompt submission
aborts, that process's pending boundary shall end. When its process is
replaced, the old boundary shall not suppress the replacement process or
later user work.
## Out of scope
- Replacing the passthrough idle heuristic for turns that have already started.
- Changing prompt formatting, submit sequences, or per-agent idle timeouts.
- Changing completion semantics for headless prompt-flag agents.
@@ -0,0 +1,106 @@
---
status: current
system: tasks
requirements:
- REQ-TASKS-PASSTHROUGH-INITIAL-TURN-001
---
# Passthrough Initial Prompt Turn Boundary System Design
## Purpose and boundaries
The task system owns whether a raw passthrough completion signal represents a
completed turn. The agentctl interactive runner continues to own PTY output,
prompt-pattern detection, status detection, and idle detection. The lifecycle
manager owns the delivery context needed to decide whether those raw signals
are eligible.
The boundary applies only to fresh passthrough processes whose non-empty task
prompt must be injected through stdin. It does not change prompt-flag launches,
promptless tools, or resumed conversations.
## Requirement mapping
| Requirement | Design sections |
| --- | --- |
| `REQ-TASKS-PASSTHROUGH-INITIAL-TURN-001` | [Runtime contract](#runtime-contract), [Control flow](#control-flow), [Failure and recovery](#failure-and-recovery) |
## Components and responsibilities
- `process.InteractiveRunner` reports raw prompt-pattern, status, and idle
completion signals and exposes the first-idle wait used by prompt injection.
- `lifecycle.AgentExecution` owns an in-memory, process-scoped initial-prompt
marker protected with the passthrough process lifecycle state.
- `lifecycle.Manager.startPassthroughSession` and the fresh-start resume
fallback bind that marker to the newly active PTY process when stdin prompt
delivery is required.
- `lifecycle.Manager.autoInjectInitialPromptWith` waits for readiness, submits
all planned stdin chunks, and clears the matching marker on every exit path.
- `lifecycle.Manager.handlePassthroughTurnComplete` validates process identity
and suppresses completion while the active process owns the marker. Eligible
signals continue through `MarkReady` and the existing orchestrator path.
## Runtime contract
The pending marker contains the active passthrough process identity. It is
runtime-only state and is not persisted. A boolean without process identity is
insufficient. A delayed callback or cleanup from an old process must not affect
the replacement process's boundary.
The marker exists only when all of these conditions hold:
- the launch creates a fresh conversation,
- the agent has no `PromptFlag` delivery path, and
- the execution contains a non-empty task description for stdin injection.
The marker does not emit `agent.boot_ready`, `agent.ready`, or
`agent.running`. The execution is already running while Kandev waits for the
startup idle and injects the prompt.
## Control flow
1. Lifecycle determines whether the fresh process needs stdin prompt delivery.
2. After the interactive runner returns the new process identity, lifecycle
installs that identity as the active process. Lifecycle installs the pending
initial-prompt owner under the same lock.
3. A first-idle, prompt-pattern, or status callback can wake prompt injection.
The completion handler observes the matching pending marker and returns
without calling `MarkReady`.
4. Prompt injection marks the execution running through the existing
idempotent path and writes every planned prompt and submit chunk.
5. Prompt injection clears the marker for that process after the final write.
6. Agent output resets the existing turn detector. The next eligible
completion callback finds no pending marker and follows the ordinary
`MarkReady` path, including workflow and queue consumers.
Prompt-flag launches, promptless processes, and ordinary resumes never install
the marker, so their first completion signal remains eligible.
## Failure and recovery
- Prompt injection clears the matching marker when readiness times out,
shutdown interrupts delivery, or a PTY write fails. This avoids suppressing
later manual work indefinitely.
- Marker cleanup compares process identity. Cleanup from an old process cannot
clear a boundary installed for its replacement.
- A delayed completion callback from a replaced process continues to fail the
existing active-process identity check.
- A fresh-start resume fallback installs a new marker for its new process. A
successful resume does not install one because it does not reinject history.
- Mid-turn idle false positives remain a separate detector-accuracy concern.
## Observability
Lifecycle emits a debug diagnostic when it ignores a completion signal for a
process with a pending initial prompt. Existing timeout and write-failure
warnings continue to expose prompt injection failures. No persisted state or
new metric is required.
Focused lifecycle tests cover startup suppression, ordinary completion after
submission, prompt-flag and promptless behavior, abort cleanup, fresh fallback,
and process replacement.
## Related decisions
- [Keep passthrough initial prompt state in lifecycle](../../../decisions/2026-09-01-passthrough-initial-prompt-turn-boundary.md)
- [Defer passthrough running publication until guard release](../../../decisions/2026-08-31-passthrough-running-publication.md)