fix: restore coarse running busy signal (#2023)

This commit is contained in:
Carlos Florêncio
2026-07-29 08:11:51 +01:00
committed by GitHub
parent 5fb7575e60
commit e8d4b7d69e
43 changed files with 1426 additions and 942 deletions
@@ -103,3 +103,25 @@ func TestAsyncSubagentForegroundFramesMatchClaudeOrdering(t *testing.T) {
t.Fatalf("fifth update must be final same-prompt assistant output, got %#v", got[4])
}
}
func TestBackgroundWorkEmitsForegroundIdleBoundary(t *testing.T) {
e, updates := newTestEmitter()
emitBackgroundWork(e, "/background 1ms")
got := updates.getUpdates()
idleIndex := -1
for i, update := range got {
usage := update.notification.Update.UsageUpdate
if usage == nil {
continue
}
origin := usage.Meta[claudeOriginMetaKey].(map[string]any)
if origin["kind"] == claudeOriginHuman {
idleIndex = i
break
}
}
if idleIndex < 0 {
t.Fatal("/background must emit a human-origin foreground-idle boundary")
}
}
+1
View File
@@ -381,6 +381,7 @@ func emitBackgroundWork(e *emitter, cmd string) {
"Background exploration",
"Explore the codebase while the foreground stays idle",
"general-purpose")
e.foregroundIdle()
// Hold the turn open with NO foreground output so the session stays in the
// background-idle substate for the whole window.
@@ -72,6 +72,8 @@ func provideOrchestrator(
agentManagerClient := newLifecycleAdapter(lifecycleMgr, agentRegistry, log)
serviceCfg := orchestrator.DefaultServiceConfig()
serviceCfg.ClaudeBackgroundPromptHandoff =
cfg != nil && cfg.Features.ClaudeBackgroundPromptHandoff
namespace := resolveEventNamespace(cfg)
serviceCfg.QueueGroup = "orchestrator." + namespace
busMode := "memory"
@@ -397,6 +397,12 @@ type FeaturesConfig struct {
// Toggles). Off by default so kandev stays single-user until a deployment
// opts in.
Auth bool `mapstructure:"auth" json:"auth"`
// ClaudeBackgroundPromptHandoff gates the high-risk experiment that lets a
// claude-acp session accept a successor prompt after an adapter-attested
// foreground handoff while background work remains live. It is off in every
// embedded profile and must fail closed for every other provider.
ClaudeBackgroundPromptHandoff bool `mapstructure:"claude_background_prompt_handoff" json:"claudeBackgroundPromptHandoff"`
}
// LoggingConfig holds logging configuration.
@@ -228,6 +228,18 @@ func TestFeatures_PluginsEnabledByEnv(t *testing.T) {
}
}
func TestFeatures_ClaudeBackgroundPromptHandoffEnabledByEnv(t *testing.T) {
t.Setenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", "true")
cfg, err := LoadWithPath(t.TempDir())
if err != nil {
t.Fatalf("LoadWithPath: %v", err)
}
if !cfg.Features.ClaudeBackgroundPromptHandoff {
t.Error("Features.ClaudeBackgroundPromptHandoff = false, want true")
}
}
func TestFeatures_AppStatusBarDisabledByEnv(t *testing.T) {
t.Setenv("KANDEV_FEATURES_APP_STATUS_BAR", "false")
@@ -422,13 +434,18 @@ func TestServerHostsFromConfigWhenHostUnset(t *testing.T) {
// without a tag) would surface as a capitalized JSON key and break the
// frontend's case-sensitive read in apps/web/app/actions/features.ts.
func TestFeaturesConfig_JSONShape(t *testing.T) {
cfg := FeaturesConfig{Office: true, Plugins: true, AppStatusBar: true, Auth: true}
cfg := FeaturesConfig{
Office: true,
Plugins: true,
AppStatusBar: true,
Auth: true,
}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
got := string(raw)
want := `{"office":true,"plugins":true,"appStatusBar":true,"auth":true}`
want := `{"office":true,"plugins":true,"appStatusBar":true,"auth":true,"claudeBackgroundPromptHandoff":false}`
if got != want {
t.Errorf("FeaturesConfig JSON = %s; want %s — missing or wrong `json:` struct tag", got, want)
}
@@ -33,7 +33,7 @@ func (b *blockingRetirementEventBus) Publish(
) error {
if subject == eventtypes.TaskSessionActivityChanged {
data, _ := event.Data.(map[string]interface{})
if value, present := data["foreground_activity"]; present && value == nil {
if count, present := data["active_subagent_count"]; present && count == 0 {
b.once.Do(func() { close(b.retirementEntered) })
<-b.releaseRetirement
}
@@ -99,7 +99,7 @@ func TestActiveSubagentCount_DerivesOnlyLiveSubagentRegistrations(t *testing.T)
}
}
func TestBackgroundCompletion_IntermediateSubagentPublishesBackground(t *testing.T) {
func TestBackgroundCompletion_IntermediateSubagentPublishesGenerating(t *testing.T) {
repo := setupTestRepo(t)
const taskID, sessionID, executionID = "task-intermediate", "session-intermediate", "execution-intermediate"
seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
@@ -126,14 +126,86 @@ func TestBackgroundCompletion_IntermediateSubagentPublishesBackground(t *testing
t.Fatalf("published events = %d, want 1", len(recorded.events))
}
data, _ := recorded.events[0].event.Data.(map[string]interface{})
if got := data["foreground_activity"]; got != string(v1.ForegroundActivityBackground) {
t.Fatalf("published foreground activity = %#v, want background", got)
if got := data["foreground_activity"]; got != string(v1.ForegroundActivityGenerating) {
t.Fatalf("published foreground activity = %#v, want generating", got)
}
if got := data["active_subagent_count"]; got != 1 {
t.Fatalf("published active subagent count = %#v, want 1", got)
}
}
func TestBackgroundCompletion_SettledSessionClearsActivityForCountOnlyUpdate(t *testing.T) {
repo := setupTestRepo(t)
const taskID, sessionID, executionID = "task-settled-intermediate", "session-settled-intermediate", "execution-settled-intermediate"
seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateWaitingForInput)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
recorded := &recordingEventBus{}
svc.eventBus = recorded
svc.registerBackgroundWorkKind(
sessionID, "tool-one", executionID, "child-one", streams.BackgroundWorkKindSubagent,
)
svc.registerBackgroundWorkKind(
sessionID, "tool-two", executionID, "child-two", streams.BackgroundWorkKindSubagent,
)
svc.markForegroundIdle(sessionID)
publication, changed := svc.completeBackgroundWorkSnapshot(
sessionID, executionID, "child-one", string(v1.ForegroundActivityBackground),
)
if !changed {
t.Fatal("intermediate settled completion must publish the count-only transition")
}
svc.publishForegroundActivitySnapshot(t.Context(), taskID, sessionID, publication)
if len(recorded.events) != 1 {
t.Fatalf("published events = %d, want 1", len(recorded.events))
}
data, _ := recorded.events[0].event.Data.(map[string]interface{})
if got := data["foreground_activity"]; got != nil {
t.Fatalf("settled session activity = %#v, want nil", got)
}
if got := data["active_subagent_count"]; got != 1 {
t.Fatalf("settled session active subagent count = %#v, want 1", got)
}
}
func TestBackgroundCompletion_EnabledClaudeSettledSessionPublishesBackground(t *testing.T) {
repo := setupTestRepo(t)
const taskID, sessionID, executionID = "task-settled-claude", "session-settled-claude", "execution-settled-claude"
seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateWaitingForInput)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
enableClaudeBackgroundPromptHandoffForTest(t, svc)
setSessionAgentNameForTest(t, svc, sessionID, "claude-acp")
recorded := &recordingEventBus{}
svc.eventBus = recorded
svc.registerBackgroundWorkKind(
sessionID, "tool-one", executionID, "child-one", streams.BackgroundWorkKindSubagent,
)
svc.registerBackgroundWorkKind(
sessionID, "tool-two", executionID, "child-two", streams.BackgroundWorkKindSubagent,
)
svc.markForegroundIdle(sessionID)
publication, changed := svc.completeBackgroundWorkSnapshot(
sessionID, executionID, "child-one", string(v1.ForegroundActivityBackground),
)
if !changed {
t.Fatal("intermediate enabled Claude completion must publish the count-only transition")
}
svc.publishForegroundActivitySnapshot(t.Context(), taskID, sessionID, publication)
if len(recorded.events) != 1 {
t.Fatalf("published events = %d, want 1", len(recorded.events))
}
data, _ := recorded.events[0].event.Data.(map[string]interface{})
if got := data["foreground_activity"]; got != string(v1.ForegroundActivityBackground) {
t.Fatalf("enabled Claude settled activity = %#v, want background", got)
}
if got := data["active_subagent_count"]; got != 1 {
t.Fatalf("enabled Claude settled active subagent count = %#v, want 1", got)
}
}
func TestBackgroundCompletion_IdentifiedRetiresExactWorkAndDuplicateIsHarmless(t *testing.T) {
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
const taskID, sessionID, executionID = "task-accounting", "session-accounting", "execution-accounting"
@@ -201,7 +273,7 @@ func TestBackgroundCompletion_UnidentifiedRetiresOldestOfMultipleWorkloadsFIFO(t
if !svc.hasBackgroundTask(sessionID, "tool-two") {
t.Fatal("unidentified completion must leave the remaining workload live")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("completion with a live remainder changed visible activity to %q", got)
}
}
@@ -241,7 +313,7 @@ func TestBackgroundCompletion_TwoUnidentifiedCompletionsRetireBothWorkloadsFIFO(
if !svc.hasBackgroundTask(sessionID, "tool-second") {
t.Fatal("first ID-less completion retired the second-registered workload out of FIFO order")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("session left background-idle with a live remainder, got %q", got)
}
@@ -251,7 +323,7 @@ func TestBackgroundCompletion_TwoUnidentifiedCompletionsRetireBothWorkloadsFIFO(
if svc.hasBackgroundTask(sessionID, "tool-second") {
t.Fatal("second ID-less completion did not retire the last remaining workload")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("session did not leave background after final completion, got %q", got)
}
}
@@ -279,18 +351,18 @@ func TestBackgroundCompletion_UnidentifiedSuccessorCycleRetiresSoleSessionWork(t
t.Fatal("ID-less successor-cycle notification did not retire sole session workload")
}
activityClears := 0
activityUpdates := 0
for _, record := range recorded.events {
if record.subject != eventtypes.TaskSessionActivityChanged {
continue
}
data, _ := record.event.Data.(map[string]interface{})
if value, present := data["foreground_activity"]; present && value == nil {
activityClears++
if count, present := data["active_subagent_count"]; present && count == 0 {
activityUpdates++
}
}
if activityClears != 1 || len(taskEvents.activityTaskIDs) != 2 {
t.Fatalf("sole completion clear cardinality: session=%d task=%v", activityClears, taskEvents.activityTaskIDs)
if activityUpdates != 1 || len(taskEvents.activityTaskIDs) != 2 {
t.Fatalf("sole completion update cardinality: session=%d task=%v", activityUpdates, taskEvents.activityTaskIDs)
}
// The same ID-less notification re-delivered after retirement is a no-op.
@@ -385,7 +457,7 @@ func TestExecutionTeardown_StaleToolOwnershipIsNotVisibleToReusedSuccessorID(t *
},
})
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("stale predecessor tool ownership changed successor activity to %q", got)
}
}
@@ -477,7 +549,7 @@ func TestExecutionStop_RetiresOnlyOwnedBackgroundWorkAndPublishesFinalTransition
); got != toolOwnershipChild {
t.Fatalf("successor tool ownership = %d, want child", got)
}
if got := svc.ForegroundActivity("session-stop-accounting"); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue("session-stop-accounting"); got != v1.ForegroundActivityBackground {
t.Fatalf("successor background work should remain visible, got %q", got)
}
@@ -487,7 +559,7 @@ func TestExecutionStop_RetiresOnlyOwnedBackgroundWorkAndPublishesFinalTransition
if svc.hasBackgroundTask("session-stop-accounting", "tool-new") {
t.Fatal("final stopped execution left its background registration behind")
}
if got := svc.ForegroundActivity("session-stop-accounting"); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue("session-stop-accounting"); got != v1.ForegroundActivityGenerating {
t.Fatalf("final execution cleanup left stale background activity: %q", got)
}
if _, ok := turnActivityRecord(t, svc, "session-stop-accounting"); ok {
@@ -507,7 +579,7 @@ func TestExecutionStop_RetiresOnlyOwnedBackgroundWorkAndPublishesFinalTransition
activityValues = append(activityValues, data["foreground_activity"])
subagentCounts = append(subagentCounts, data["active_subagent_count"].(int))
}
wantValues := []interface{}{"generating", "generating", "background", nil}
wantValues := []interface{}{"generating", "generating", "generating", nil}
if !slices.Equal(activityValues, wantValues) {
t.Fatalf("execution cleanup activity values = %#v, want %#v", activityValues, wantValues)
}
@@ -596,8 +668,8 @@ func TestExecutionCleanup_DelayedPublicationCannotOverwriteSuccessorActivity(t *
data, _ := record.event.Data.(map[string]interface{})
values = append(values, data["foreground_activity"])
}
if len(values) != 1 || values[0] != string(v1.ForegroundActivityBackground) {
t.Fatalf("activity publications = %#v, want only successor background", values)
if len(values) != 1 || values[0] != nil {
t.Fatalf("activity publications = %#v, want only successor clear", values)
}
if len(taskEvents.activityTaskIDs) != 1 {
t.Fatalf("task aggregate publications = %v, want successor only", taskEvents.activityTaskIDs)
@@ -666,7 +738,10 @@ func TestExecutionCleanup_PublicationDeliveryIsOrderedAcrossRecordGenerations(t
values = append(values, data["foreground_activity"])
counts = append(counts, data["active_subagent_count"].(int))
}
want := []interface{}{nil, string(v1.ForegroundActivityBackground)}
want := []interface{}{
nil,
nil,
}
if !slices.Equal(values, want) {
t.Fatalf("cross-generation publications = %#v, want %#v", values, want)
}
@@ -817,8 +892,8 @@ func TestExecutionCleanup_DelayedNullCannotOverwriteClaimlessSuccessorStart(t *t
values = append(values, data["foreground_activity"])
}
}
if len(values) != 1 || values[0] != string(v1.ForegroundActivityGenerating) {
t.Fatalf("activity publications = %#v, want only successor generating", values)
if len(values) != 1 || values[0] != nil {
t.Fatalf("activity publications = %#v, want only successor clear", values)
}
}
@@ -861,8 +936,8 @@ func TestBackgroundCompletion_IDLessSingletonDelayedNullCannotOverwriteSuccessor
values = append(values, data["foreground_activity"])
}
}
if len(values) != 1 || values[0] != string(v1.ForegroundActivityGenerating) {
t.Fatalf("activity publications = %#v, want only successor generating", values)
if len(values) != 1 || values[0] != nil {
t.Fatalf("activity publications = %#v, want only successor clear", values)
}
}
@@ -905,10 +980,10 @@ func TestExecutionCleanup_AfterClaimlessBeginCannotCreateSuccessorNull(t *testin
values = append(values, data["foreground_activity"])
}
}
if len(values) != 1 || values[0] != string(v1.ForegroundActivityGenerating) {
t.Fatalf("activity publications = %#v, want only successor generating", values)
if len(values) != 1 || values[0] != nil {
t.Fatalf("activity publications = %#v, want only successor clear", values)
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("cleanup after begin displaced successor ownership: got %q", got)
}
}
@@ -952,10 +1027,10 @@ func TestBackgroundCompletion_AfterClaimlessBeginCannotCreateSuccessorNull(t *te
values = append(values, data["foreground_activity"])
}
}
if len(values) != 1 || values[0] != string(v1.ForegroundActivityGenerating) {
t.Fatalf("activity publications = %#v, want only successor generating", values)
if len(values) != 1 || values[0] != nil {
t.Fatalf("activity publications = %#v, want only successor clear", values)
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("completion after begin displaced successor ownership: got %q", got)
}
}
@@ -1026,25 +1101,29 @@ func TestStopSessionPathPublishesStateAndTeardownActivityClears(t *testing.T) {
TaskID: taskID, SessionID: sessionID, AgentExecutionID: executionID,
})
var stateClears, activityClears int
var stateClears, activityRetirements int
for _, record := range recorded.events {
data, ok := record.event.Data.(map[string]interface{})
if !ok {
continue
}
value, present := data["foreground_activity"]
if !present || value != nil {
continue
}
switch record.subject {
case eventtypes.TaskSessionStateChanged:
stateClears++
if value, present := data["foreground_activity"]; present && value == nil {
stateClears++
}
case eventtypes.TaskSessionActivityChanged:
activityClears++
if count, present := data["active_subagent_count"]; present && count == 0 {
activityRetirements++
}
}
}
if stateClears != 1 || activityClears != 1 {
t.Fatalf("session.stop clear cardinality: state=%d activity=%d, want 1/1", stateClears, activityClears)
if stateClears != 1 || activityRetirements != 1 {
t.Fatalf(
"session.stop retirement cardinality: state=%d activity=%d, want 1/1",
stateClears,
activityRetirements,
)
}
// Two task-aggregate recomputes: the teardown activity clear, plus the
// republish when the session leaves RUNNING (republishTaskActivityOnSettle),
@@ -1131,7 +1210,7 @@ func countActivityClears(recorded *recordingEventBus) int {
continue
}
data, _ := record.event.Data.(map[string]interface{})
if value, present := data["foreground_activity"]; present && value == nil {
if count, present := data["active_subagent_count"]; present && count == 0 {
clears++
}
}
@@ -1356,7 +1435,7 @@ func TestBackgroundActivity_ClaudeMetadataOnlyChildUpdateDoesNotReopenForeground
svc.registerBackgroundTask(sessionID, "async-agent")
svc.markForegroundIdle(sessionID)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("precondition activity = %q, want background", got)
}
@@ -1373,7 +1452,7 @@ func TestBackgroundActivity_ClaudeMetadataOnlyChildUpdateDoesNotReopenForeground
},
})
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("metadata-only child update changed activity to %q, want background", got)
}
}
@@ -1413,7 +1492,7 @@ func TestBackgroundActivity_ClaudeCachedChildUpdateDoesNotReopenForeground(t *te
},
})
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("cached child update changed activity to %q, want background", got)
}
}
@@ -1435,7 +1514,7 @@ func TestBackgroundActivity_UnknownToolUpdatePreservesActivity(t *testing.T) {
},
})
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("unknown tool update changed activity to %q, want background", got)
}
}
@@ -1475,7 +1554,7 @@ func TestBackgroundWork_RegisteredFromInitialToolCallIsRetiredOnExecutionTeardow
},
})
svc.markForegroundIdle(sessionID)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("initial tool_call registration did not surface as background, got %q", got)
}
@@ -1486,7 +1565,7 @@ func TestBackgroundWork_RegisteredFromInitialToolCallIsRetiredOnExecutionTeardow
if svc.hasBackgroundTask(sessionID, "tool-initial-call") {
t.Fatal("execution teardown left the initial tool_call registration behind")
}
if got := svc.ForegroundActivity(sessionID); got == v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got == v1.ForegroundActivityBackground {
t.Fatalf("session still reports background after owning execution died, got %q", got)
}
}
@@ -456,10 +456,8 @@ func (s *Service) handleStreamingEventKind(
// handleMessageStreamingEvent handles streaming message events for real-time text updates.
// It creates a new message on first chunk (IsAppend=false) or appends to existing (IsAppend=true).
func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
// Streamed foreground output means the agent is actively generating again,
// even if a background task is still outstanding — narrows the busy signal.
// Only genuine output flips the state: an empty/invalid frame is discarded by
// handleStreamingEventKind, so it must not spuriously reclose the prompt gate.
// Keep the private ownership estimate current for accounting. Only genuine
// output flips it; empty/invalid frames are discarded below.
if payload.Data.Text != "" && s.markForegroundGenerating(payload.SessionID, payload.ExecutionID) {
s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
}
@@ -471,9 +469,8 @@ func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *life
// handleThinkingStreamingEvent handles streaming thinking events for real-time reasoning updates.
// It creates a new thinking message on first chunk (IsAppend=false) or appends to existing (IsAppend=true).
func (s *Service) handleThinkingStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
// Streamed foreground reasoning means the agent is actively generating again.
// Only genuine output flips the state — an empty/invalid frame is discarded
// downstream, so it must not spuriously reclose the prompt gate.
// Keep the private ownership estimate current for accounting. Empty/invalid
// frames are discarded downstream.
if payload.Data.Text != "" && s.markForegroundGenerating(payload.SessionID, payload.ExecutionID) {
s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
}
@@ -514,10 +511,8 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.
s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
}
// Background-work bookkeeping for the finer-grained busy signal runs
// regardless of message persistence — mirrors handleToolCallEvent — so a
// run-in-background shell or Monitor watch still drives the prompt gate even
// when no messageCreator is wired (tests, minimal configs).
// Background-work bookkeeping runs regardless of message persistence so
// accounting remains available even when no messageCreator is wired.
s.trackBackgroundToolUpdate(ctx, payload, ownership)
s.persistToolUpdateMessage(ctx, payload)
@@ -593,9 +588,9 @@ func (s *Service) persistToolUpdateMessage(ctx context.Context, payload *lifecyc
}
}
// trackBackgroundToolUpdate maintains the fine-grained busy signal's background
// hold from a top-level tool_call_update: a terminal status clears the hold and
// the first recognizable non-terminal frame registers it. Child tool calls
// trackBackgroundToolUpdate maintains best-effort background accounting from a
// top-level tool_call_update: a terminal status clears the hold and the first
// recognizable non-terminal frame registers it. Child tool calls
// (ParentToolCallID set) are a subagent's own internal work, not a new
// background task, so they never touch the hold.
func (s *Service) trackBackgroundToolUpdate(
@@ -1099,8 +1094,12 @@ func (s *Service) publishTaskSessionStateChanged(
}
}
var foregroundActivity interface{}
if nextState == models.TaskSessionStateRunning || nextState == models.TaskSessionStateWaitingForInput {
foregroundActivity = string(s.foregroundActivityValue(sessionID))
if nextState == models.TaskSessionStateRunning {
foregroundActivity = string(s.ForegroundActivity(sessionID))
} else if activity := s.ForegroundActivity(sessionID); activity == v1.ForegroundActivityBackground {
// Only the enabled Claude experiment can return background here.
// Preserve detached-work visibility as the coarse foreground settles.
foregroundActivity = string(activity)
}
eventData := map[string]interface{}{
metaKeyTaskID: taskID,
@@ -1111,9 +1110,9 @@ func (s *Service) publishTaskSessionStateChanged(
metaKeyAgentProfileID: agentProfileID,
"agent_profile_snapshot": session.AgentProfileSnapshot,
"is_passthrough": session.IsPassthrough,
// Carry activity only while the session can own foreground/background
// work. Every other state gets an explicit null so partial client-store
// merges clear a previously-live substate during session.stop/teardown.
// Carry activity only while the durable session is RUNNING. Every other
// state gets an explicit null so partial client-store merges clear a
// previously-live busy signal during settlement or teardown.
"foreground_activity": foregroundActivity,
"active_subagent_count": s.ActiveSubagentCount(sessionID),
}
@@ -271,6 +271,34 @@ func TestUpdateTaskSessionStatePublishesPersistedUpdatedAt(t *testing.T) {
require.Equal(t, session.UpdatedAt.UTC().Format(time.RFC3339Nano), data["updated_at"])
}
func TestUpdateTaskSessionState_EnabledClaudePublishesSettledBackground(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "task-state-claude", "session-state-claude", "step1")
eb := &recordingEventBus{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
enableClaudeBackgroundPromptHandoffForTest(t, svc)
setSessionAgentNameForTest(t, svc, "session-state-claude", "claude-acp")
svc.eventBus = eb
svc.registerBackgroundTask("session-state-claude", "background-1")
svc.markForegroundIdle("session-state-claude")
svc.updateTaskSessionState(
ctx,
"task-state-claude",
"session-state-claude",
models.TaskSessionStateWaitingForInput,
"",
false,
)
require.Len(t, eb.events, 1)
require.Equal(t, events.TaskSessionStateChanged, eb.events[0].subject)
data, ok := eb.events[0].event.Data.(map[string]interface{})
require.True(t, ok)
require.Equal(t, string(v1.ForegroundActivityBackground), data["foreground_activity"])
}
func TestTransitionTaskSessionStateReportsAcceptedWrite(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
@@ -64,7 +64,7 @@ func TestFreshLoad_SettledSessionOmitsSubstate(t *testing.T) {
}
}
func TestFreshLoad_SettledSessionWithDetachedWorkSerializesBackground(t *testing.T) {
func TestFreshLoad_SettledSessionWithDetachedWorkOmitsActivity(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
@@ -73,15 +73,15 @@ func TestFreshLoad_SettledSessionWithDetachedWorkSerializesBackground(t *testing
svc.markForegroundIdle(sessionID)
sessionDTO := &taskdto.TaskSessionDTO{ID: sessionID, State: models.TaskSessionStateWaitingForInput}
taskdto.EnrichForegroundActivity(sessionDTO, svc)
if got := marshalField(t, sessionDTO); got != string(v1.ForegroundActivityBackground) {
t.Fatalf("settled detached session foreground_activity: got %q, want background", got)
if body := marshalDTO(t, sessionDTO); strings.Contains(body, "foreground_activity") {
t.Fatalf("settled detached session must omit foreground_activity, got %s", body)
}
taskDTO := &taskdto.TaskDTO{ID: "task-detached"}
sessions := []*models.TaskSession{{ID: sessionID, State: models.TaskSessionStateWaitingForInput}}
taskdto.EnrichTaskForegroundActivity(taskDTO, sessions, svc)
if got := marshalField(t, taskDTO); got != string(v1.ForegroundActivityBackground) {
t.Fatalf("detached task foreground_activity: got %q, want background", got)
if body := marshalDTO(t, taskDTO); strings.Contains(body, "foreground_activity") {
t.Fatalf("settled detached task must omit foreground_activity, got %s", body)
}
}
@@ -1,6 +1,7 @@
package orchestrator
import (
"errors"
"testing"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
@@ -71,18 +72,18 @@ func TestForegroundActivitySignal_FollowUpPromptKeepsIndependentCompletionIdenti
svc.completeTurnForTaskSession(t.Context(), taskID, sessionID)
svc.completeTurnForTaskSession(t.Context(), taskID, sessionID)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("follow-up completion was mistaken for its predecessor: activity = %q", got)
}
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("follow-up left background-only session unpromptable: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("follow-up RUNNING session must remain unpromptable: %v", err)
}
got := activityValues(eb)
want := []string{
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityGenerating),
}
if len(got) != len(want) {
t.Fatalf("activity publications = %v, want exactly %v", got, want)
@@ -145,18 +146,18 @@ func TestForegroundActivitySignal_PreDispatchEventsUseCurrentPromptCycle(t *test
); err != nil {
t.Fatalf("dispatch follow-up prompt: %v", err)
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("pre-callback completion did not own the new prompt cycle: activity = %q", got)
}
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("detached work after pre-callback completion must be promptable: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("detached work must not make a RUNNING session promptable: %v", err)
}
got := activityValues(eb)
want := []string{
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityGenerating),
}
if len(got) != len(want) {
t.Fatalf("activity publications = %v, want exactly %v", got, want)
@@ -212,25 +213,13 @@ func TestForegroundActivitySignal_ClaimedPreDispatchCompletionReconcilesOnAccept
}
}
if _, err := svc.PromptTask(t.Context(), taskID, sessionID, "follow up", "", false, nil, false); err != nil {
t.Fatalf("dispatch claimed follow-up: %v", err)
if _, err := svc.PromptTask(
t.Context(), taskID, sessionID, "follow up", "", false, nil, false,
); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("RUNNING session must reject claimed follow-up: %v", err)
}
got := activityValues(eb)
if tt.keepWork {
want := []string{string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityBackground)}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("claimed pre-callback publications = %v, want %v", got, want)
}
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("accepted completed foreground with detached work must be promptable: %v", err)
}
return
}
if len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) {
t.Fatalf("no-work completion publications = %v, want generating claim only", got)
}
if gotActivity := svc.ForegroundActivity(sessionID); gotActivity != v1.ForegroundActivityGenerating {
t.Fatalf("no-work completion must settle without background hold, got %q", gotActivity)
if len(activityValues(eb)) != 0 {
t.Fatalf("rejected follow-up must not publish an activity change: %v", activityValues(eb))
}
})
}
@@ -176,7 +176,7 @@ func TestForegroundActivitySignal_PublishesOnFlips(t *testing.T) {
got := activityValues(eb)
want := []string{
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityGenerating),
}
if len(got) != len(want) {
@@ -246,8 +246,8 @@ func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) {
want := []string{
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityGenerating),
}
if len(got) != len(want) {
@@ -260,7 +260,7 @@ func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) {
}
}
func TestForegroundActivitySignal_ClaimReleasePublishesRestoredBackground(t *testing.T) {
func TestForegroundActivitySignal_ClaimReleasePublishesCoarseGenerating(t *testing.T) {
repo := setupTestRepo(t)
agentMgr := &mockAgentManager{}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
@@ -276,17 +276,19 @@ func TestForegroundActivitySignal_ClaimReleasePublishesRestoredBackground(t *tes
svc.registerBackgroundTask(sessionID, "background-1")
svc.markForegroundIdle(sessionID)
// Admission claims the foreground, but the missing executor row makes
// ensureSessionRunning fail before the prompt reaches the agent. Releasing
// that claim must tell every client that background-idle was restored.
if _, err := svc.PromptTask(context.Background(), taskID, sessionID, "retry", "", false, nil, false); err == nil {
t.Fatal("expected prompt preflight to fail without an executor record")
claim := svc.claimForegroundTurn(sessionID)
if claim == nil {
t.Fatal("expected dormant tracker to allow an internal foreground claim")
}
svc.releaseForegroundClaimOnFailure(context.Background(), taskID, sessionID, claim)
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("released internal claim did not restore background tracking: %q", got)
}
got := activityValues(eb)
want := []string{string(v1.ForegroundActivityBackground)}
want := []string{string(v1.ForegroundActivityGenerating)}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("expected restored background activity broadcast %v, got %v", want, got)
t.Fatalf("expected coarse generating activity broadcast %v, got %v", want, got)
}
}
@@ -320,22 +322,19 @@ func TestForegroundActivitySignal_ModelSwitchPublishesClaimedGenerating(t *testi
svc.registerBackgroundTask(sessionID, "background-1")
svc.markForegroundIdle(sessionID)
result, err := svc.PromptTask(context.Background(), taskID, sessionID, "continue", "new-model", false, nil, false)
if err != nil {
t.Fatalf("model-switch prompt failed: %v", err)
}
if result == nil || result.StopReason != "model_switched" {
t.Fatalf("expected restart-based model switch result, got %#v", result)
if _, err := svc.PromptTask(
context.Background(), taskID, sessionID, "continue", "new-model", false, nil, false,
); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("model-switch prompt must remain blocked while RUNNING: %v", err)
}
got := activityValues(eb)
want := []string{string(v1.ForegroundActivityGenerating)}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("expected claimed foreground activity broadcast %v, got %v", want, got)
if len(got) != 0 {
t.Fatalf("rejected model-switch prompt published activity: %v", got)
}
}
func TestForegroundActivitySignal_DispatchPublishesBackgroundRegisteredDuringClaim(t *testing.T) {
func TestForegroundActivitySignal_DispatchPublishesCoarseActivity(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
eb := &recordingEventBus{}
@@ -352,7 +351,7 @@ func TestForegroundActivitySignal_DispatchPublishesBackgroundRegisteredDuringCla
t.Fatal("prompt must claim the background-idle turn")
}
svc.registerBackgroundTask(sessionID, "background-2")
if s := svc.ForegroundActivity(sessionID); s != v1.ForegroundActivityGenerating {
if s := svc.foregroundActivityValue(sessionID); s != v1.ForegroundActivityGenerating {
t.Fatalf("active claim must remain generating, got %q", s)
}
@@ -362,12 +361,15 @@ func TestForegroundActivitySignal_DispatchPublishesBackgroundRegisteredDuringCla
if !svc.markForegroundIdle(sessionID) {
t.Fatal("foreground idle must expose work registered during admission")
}
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("precondition: private activity = %q, want background", got)
}
svc.publishForegroundActivityChanged(context.Background(), taskID, sessionID)
got := activityValues(eb)
want := []string{string(v1.ForegroundActivityBackground)}
want := []string{string(v1.ForegroundActivityGenerating)}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("expected dispatch-time background activity broadcast %v, got %v", want, got)
t.Fatalf("expected coarse dispatch-time activity broadcast %v, got %v", want, got)
}
}
@@ -436,7 +438,7 @@ func TestForegroundActivitySignal_PropagatesToTaskLevel(t *testing.T) {
}
}
func TestForegroundActivitySignal_TurnCompletionPublishesBackgroundExactlyOnce(t *testing.T) {
func TestForegroundActivitySignal_TurnCompletionPublishesCoarseExactlyOnce(t *testing.T) {
tests := []struct {
name string
act func(*Service, string, string)
@@ -489,9 +491,9 @@ func TestForegroundActivitySignal_TurnCompletionPublishesBackgroundExactlyOnce(t
tt.act(svc, taskID, sessionID)
got := activityValues(eb)
want := []string{string(v1.ForegroundActivityBackground)}
want := []string{string(v1.ForegroundActivityGenerating)}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("expected exactly one session background publication %v, got %v", want, got)
t.Fatalf("expected exactly one coarse session publication %v, got %v", want, got)
}
if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskID {
t.Fatalf("expected exactly one task aggregate publication for %q, got %v", taskID, taskEvents.activityTaskIDs)
@@ -602,18 +604,20 @@ func TestForegroundActivitySignal_SamePromptOutputAfterIdleDoesNotInvalidateComp
})
svc.completeTurnForTaskSession(t.Context(), taskID, sessionID)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("same-prompt %s made completion stale: got activity %q", tt.outputType, got)
}
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("background-only session must accept immediate input: %v", err)
if err := svc.checkSessionPromptable(
taskID, sessionID, models.TaskSessionStateRunning,
); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("background-only RUNNING session must reject immediate input: %v", err)
}
got := activityValues(eb)
want := []string{
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityGenerating),
}
if len(got) != len(want) {
t.Fatalf("activity publications = %v, want exactly %v", got, want)
@@ -676,7 +680,7 @@ func TestForegroundActivitySignal_DetachedLaunchTerminalOutputStaysBackground(t
},
})
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("terminal launch card must not impersonate foreground output: got %q", got)
}
if !svc.hasBackgroundTask(sessionID, toolID) {
@@ -684,7 +688,7 @@ func TestForegroundActivitySignal_DetachedLaunchTerminalOutputStaysBackground(t
}
if got := activityValues(eb); !slices.Equal(got, []string{
string(v1.ForegroundActivityGenerating),
string(v1.ForegroundActivityBackground),
string(v1.ForegroundActivityGenerating),
}) {
t.Fatalf("detached launch activity sequence = %v", got)
}
@@ -716,7 +720,7 @@ func TestForegroundActivitySignal_SynchronousDispatchFailureReleasesCycleClaim(t
); err == nil {
t.Fatal("expected synchronous provider dispatch failure")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("failed dispatch must release its foreground claim, got %q", got)
}
if retryClaim := svc.claimForegroundTurn(sessionID); retryClaim == nil {
@@ -747,7 +751,7 @@ func TestForegroundActivitySignal_TurnCompletionPreservesFinalBackgroundCompleti
})
got := activityValues(eb)
want := []string{string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating)}
want := []string{string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityGenerating)}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("expected completion and final-background publications %v, got %v", want, got)
}
@@ -785,7 +789,7 @@ func TestForegroundActivitySignal_DelayedOldCompletionCannotYieldSuccessor(t *te
// has already claimed and begun generating in the same session.
svc.completeTurnForSession(t.Context(), sessionID)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("delayed old completion yielded successor foreground: got %q", got)
}
if got := activityValues(eb); len(got) != 0 {
@@ -826,7 +830,7 @@ func TestForegroundActivitySignal_OldCompletionBeforeSuccessorLeavesSuccessorCom
// the successor is current and must expose the still-running background task.
svc.completeTurnForSession(t.Context(), sessionID)
if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityBackground) {
if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) {
t.Fatalf("current successor completion must publish background once, got %v", got)
}
if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskID {
@@ -851,7 +855,7 @@ func TestForegroundActivitySignal_TaskLookupFailureDoesNotConsumeCompletionYield
svc.repo = &failOnceGetTaskSessionRepo{repoStore: baseRepo, err: errors.New("transient lookup failure")}
svc.completeTurnForSession(t.Context(), sessionID)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("failed identity lookup must not consume transition, got %q", got)
}
if got := activityValues(eb); len(got) != 0 {
@@ -859,7 +863,7 @@ func TestForegroundActivitySignal_TaskLookupFailureDoesNotConsumeCompletionYield
}
svc.completeTurnForSession(t.Context(), sessionID)
if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityBackground) {
if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) {
t.Fatalf("retry must publish preserved background transition once, got %v", got)
}
if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskID {
@@ -888,13 +892,13 @@ func TestForegroundActivitySignal_TurnCompletionIsSessionIsolated(t *testing.T)
svc.completeTurnForSession(t.Context(), sessionA)
if got := svc.ForegroundActivity(sessionA); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionA); got != v1.ForegroundActivityBackground {
t.Fatalf("completed session A must become background, got %q", got)
}
if got := svc.ForegroundActivity(sessionB); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionB); got != v1.ForegroundActivityGenerating {
t.Fatalf("completion for session A mutated session B: got %q", got)
}
if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityBackground) {
if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) {
t.Fatalf("expected one session A background event, got %v", got)
}
if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskA {
@@ -934,7 +938,7 @@ func TestForegroundActivitySignal_SettleRepublishesTaskAggregate(t *testing.T) {
// left behind after retirement, whose foreground activity safely defaults to
// generating.
seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("untracked RUNNING session must default generating, got %q", got)
}
taskEvents.activityTaskIDs = nil
@@ -1014,7 +1018,7 @@ func TestForegroundActivitySignal_DelayedOldProviderIdleCannotYieldSuccessor(t *
},
})
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("delayed old provider idle yielded successor foreground: got %q", got)
}
if got := activityValues(eb); len(got) != 0 {
@@ -1035,10 +1039,10 @@ func TestForegroundActivitySignal_DelayedOldProviderIdleCannotYieldSuccessor(t *
PromptGeneration: 2,
},
})
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("current provider idle must yield promptly, got %q", got)
}
if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityBackground) {
if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityGenerating) {
t.Fatalf("current provider idle must publish background once, got %v", got)
}
if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskID {
@@ -11,6 +11,30 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
func enableClaudeBackgroundPromptHandoffForTest(t *testing.T, svc *Service) {
t.Helper()
svc.config.ClaudeBackgroundPromptHandoff = true
}
func setSessionAgentNameForTest(
t *testing.T,
svc *Service,
sessionID, agentName string,
) {
t.Helper()
session, err := svc.repo.GetTaskSession(t.Context(), sessionID)
if err != nil {
t.Fatalf("GetTaskSession(%q): %v", sessionID, err)
}
session.AgentProfileSnapshot = map[string]interface{}{
"agent_id": "persisted-agent-record-id",
"agent_name": agentName,
}
if err := svc.repo.UpdateTaskSession(t.Context(), session); err != nil {
t.Fatalf("UpdateTaskSession(%q): %v", sessionID, err)
}
}
func emitForegroundIdle(svc *Service, taskID, sessionID string) {
svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
TaskID: taskID, SessionID: sessionID,
@@ -18,18 +42,11 @@ func emitForegroundIdle(svc *Service, taskID, sessionID string) {
})
}
// TestCheckSessionPromptable_BackgroundTaskAcceptsInput is the red
// characterization test for ADR-0049: a session whose
// foreground turn is idle while a spawned background task is still running must
// accept a new message, not be rejected as "already running".
//
// Before the fix, checkSessionPromptable rejected ANY RUNNING session with
// ErrAgentPromptInProgress, so an operator whose agent kicked off background
// work was locked out of the conversation. After the fix, a RUNNING session
// with an outstanding background task (and no active foreground generation) is
// promptable, while a RUNNING session that is genuinely generating in the
// foreground is still gated.
func TestCheckSessionPromptable_BackgroundTaskAcceptsInput(t *testing.T) {
// TestCheckSessionPromptable_BackgroundTaskRemainsBusy pins the conservative
// admission contract from ADR-2026-07-28: provider background-work inference
// may remain useful for accounting, but it cannot relax a RUNNING session's
// prompt gate.
func TestCheckSessionPromptable_BackgroundTaskRemainsBusy(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
@@ -44,9 +61,13 @@ func TestCheckSessionPromptable_BackgroundTaskAcceptsInput(t *testing.T) {
svc.registerBackgroundTask(sessionID, "tool-subagent-1")
svc.markForegroundIdle(sessionID)
// The session must now accept a new message even though its state is RUNNING.
if err := svc.checkSessionPromptable("task1", sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("RUNNING session waiting only on background work must be promptable, got: %v", err)
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("precondition: private tracker activity = %q, want background", got)
}
// Background accounting must not make the RUNNING session promptable.
if err := svc.checkSessionPromptable("task1", sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("RUNNING session waiting on background work must remain gated, got: %v", err)
}
// Once the background task finishes, the (still open) turn is once again a
@@ -57,6 +78,59 @@ func TestCheckSessionPromptable_BackgroundTaskAcceptsInput(t *testing.T) {
}
}
func TestCheckSessionPromptable_ClaudeExperimentAcceptsBackgroundIdle(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
enableClaudeBackgroundPromptHandoffForTest(t, svc)
const taskID = "task-claude-experiment"
const sessionID = "session-claude-experiment"
seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
setSessionAgentNameForTest(t, svc, sessionID, "claude-acp")
svc.registerBackgroundTask(sessionID, "tool-subagent-1")
svc.markForegroundIdle(sessionID)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("enabled Claude activity = %q, want background", got)
}
if err := svc.checkSessionPromptable(
taskID,
sessionID,
models.TaskSessionStateRunning,
); err != nil {
t.Fatalf("enabled Claude background-idle session rejected: %v", err)
}
}
func TestCheckSessionPromptable_ClaudeExperimentRejectsNonClaude(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
enableClaudeBackgroundPromptHandoffForTest(t, svc)
const taskID = "task-codex-experiment"
const sessionID = "session-codex-experiment"
seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
setSessionAgentNameForTest(t, svc, sessionID, "codex-acp")
// Deliberately forge the private tracker state. Provider gating must remain
// a separate fail-closed boundary even if a future normalizer regression
// incorrectly attests a non-Claude workload.
svc.registerBackgroundTask(sessionID, "tool-subagent-1")
svc.markForegroundIdle(sessionID)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("enabled non-Claude activity = %q, want generating", got)
}
if err := svc.checkSessionPromptable(
taskID,
sessionID,
models.TaskSessionStateRunning,
); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("enabled non-Claude background-idle session must remain gated, got: %v", err)
}
}
func TestForegroundToolCallClosesBackgroundIdleGate(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
@@ -82,14 +156,8 @@ func TestForegroundToolCallClosesBackgroundIdleGate(t *testing.T) {
}
}
// TestTurnActivity_ForegroundBackgroundTransitions locks in the state machine
// behind isForegroundTurnGenerating.
// TestForegroundActivity_ExportedValue covers the seam the page-load / list
// serialization layer depends on (ADR-0049): the exported
// ForegroundActivity mirror of the in-memory tracker. An untracked session — which
// includes every session after a backend restart, since a restart ends the turn —
// must report the safe "generating" default so a stale "you may type" can never be
// serialized.
// TestForegroundActivity_ExportedValue proves the public seam stays coarse
// while the dormant tracker retains its fine-grained value.
func TestForegroundActivity_ExportedValue(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
@@ -105,8 +173,11 @@ func TestForegroundActivity_ExportedValue(t *testing.T) {
t.Fatalf("registration must not override foreground activity, got %q", got)
}
svc.markForegroundIdle(s)
if got := svc.ForegroundActivity(s); got != v1.ForegroundActivityBackground {
t.Fatalf("after foreground idle with background work, got %q, want background", got)
if got := svc.foregroundActivityValue(s); got != v1.ForegroundActivityBackground {
t.Fatalf("private activity after foreground idle = %q, want background", got)
}
if got := svc.ForegroundActivity(s); got != v1.ForegroundActivityGenerating {
t.Fatalf("public activity after foreground idle = %q, want generating", got)
}
svc.completeBackgroundTask(s, "t1")
@@ -238,9 +309,8 @@ func TestBackgroundCompleteEventRetiresOneOutstandingTask(t *testing.T) {
}
// TestForegroundBusySignal_WiredThroughStreamEvents drives the real agent
// stream-event dispatch to prove the WS1 producer → WS2 gate wiring end to end:
// a subagent tool_call arriving on the stream flips the promptable gate from
// "reject (agent running)" to "accept (only background work outstanding)".
// stream-event dispatch to prove background accounting does not relax the
// coarse RUNNING gate.
func TestForegroundBusySignal_WiredThroughStreamEvents(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
@@ -269,9 +339,9 @@ func TestForegroundBusySignal_WiredThroughStreamEvents(t *testing.T) {
})
emitForegroundIdle(svc, taskID, sessionID)
// The gate now accepts a new message even though the session state is RUNNING.
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("after a background subagent tool_call the session must be promptable, got: %v", err)
// The private tracker yields, but the public gate remains closed.
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("background subagent work must not make RUNNING promptable, got: %v", err)
}
// A child tool_call from inside the subagent (ParentToolCallID set) is the
@@ -288,8 +358,8 @@ func TestForegroundBusySignal_WiredThroughStreamEvents(t *testing.T) {
Normalized: streams.NewShellExec("ls", "", "", 0, false),
},
})
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("a subagent-internal child tool_call must not re-gate input, got: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("a subagent-internal child tool_call must leave RUNNING gated, got: %v", err)
}
}
@@ -309,7 +379,7 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGate(t *testing.T) {
sessionID = "session-stream"
)
// A top-level subagent tool_call opens the gate.
// A top-level subagent tool_call is tracked without opening the coarse gate.
svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
TaskID: taskID,
SessionID: sessionID,
@@ -321,8 +391,8 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGate(t *testing.T) {
},
})
emitForegroundIdle(svc, taskID, sessionID)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("precondition: background subagent tool_call should open the gate, got: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("background subagent tool_call must leave RUNNING gated, got: %v", err)
}
// The subagent's own terminal tool_update arrives on the stream.
@@ -360,7 +430,7 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGateByIDNotKind(t *testi
sessionID = "session-stream"
)
// A top-level subagent tool_call opens the gate.
// A top-level subagent tool_call is tracked without opening the coarse gate.
svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
TaskID: taskID,
SessionID: sessionID,
@@ -372,8 +442,8 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGateByIDNotKind(t *testi
},
})
emitForegroundIdle(svc, taskID, sessionID)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("precondition: background subagent tool_call should open the gate, got: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("background subagent tool_call must leave RUNNING gated, got: %v", err)
}
// The terminal update for the SAME tool-call ID carries a plain, non-background
@@ -408,7 +478,7 @@ func TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen(t *te
sessionID = "session-stream"
)
// A top-level subagent tool_call opens the gate.
// A top-level subagent tool_call is tracked without opening the coarse gate.
svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
TaskID: taskID,
SessionID: sessionID,
@@ -420,8 +490,8 @@ func TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen(t *te
},
})
emitForegroundIdle(svc, taskID, sessionID)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("precondition: background subagent tool_call should open the gate, got: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("background subagent tool_call must leave RUNNING gated, got: %v", err)
}
// A terminal tool_update for an ID that was never registered as a background
@@ -437,8 +507,8 @@ func TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen(t *te
},
})
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("a terminal update for an unregistered tool-call ID must not re-gate input while background work is still outstanding, got: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("an unrelated terminal update must leave RUNNING gated, got: %v", err)
}
}
@@ -539,8 +609,8 @@ func TestForegroundBusySignal_BackgroundShellViaUpdate(t *testing.T) {
},
})
emitForegroundIdle(svc, taskID, sessionID)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("a run_in_background shell tool_update must open the gate, got: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("a background shell update must leave RUNNING gated, got: %v", err)
}
// Terminal update closes the launch card, not the detached workload.
@@ -554,8 +624,8 @@ func TestForegroundBusySignal_BackgroundShellViaUpdate(t *testing.T) {
Normalized: attestedBackgroundShellPayload("npm run dev"),
},
})
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("terminal shell launch update must preserve background promptability, got: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("terminal shell launch update must leave RUNNING gated, got: %v", err)
}
svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
@@ -648,8 +718,8 @@ func TestForegroundBusySignal_MonitorViaUpdate(t *testing.T) {
},
})
emitForegroundIdle(svc, taskID, sessionID)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("an active Monitor registration tool_update must open the gate, got: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("an active Monitor update must leave RUNNING gated, got: %v", err)
}
// sweepMonitorsOnPromptEnd emits a terminal "complete" tool_update with the
@@ -701,8 +771,8 @@ func TestForegroundBusySignal_UpdateDoesNotReYieldAfterForeground(t *testing.T)
// A background run_in_background shell is recognized on its first update.
bgUpdate()
emitForegroundIdle(svc, taskID, sessionID)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
t.Fatalf("precondition: background shell update should open the gate, got: %v", err)
if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("background shell update must leave RUNNING gated, got: %v", err)
}
// The foreground resumes generating (streamed message chunk).
@@ -12,16 +12,9 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// ADR-0049 narrowed the busy gate so a RUNNING session whose foreground turn has
// yielded to background work accepts a new prompt. checkSessionPromptable only
// *reads* that substate, though, and PromptTask does real work between the read
// and the point the turn is marked generating (session reload, ensureSessionRunning,
// an optionally network-bound model switch). Two prompts arriving in that window —
// a double-send, or two tabs on the same session — would both pass the read and
// both reach executor.Prompt, starting overlapping turns on one ACP session.
//
// claimForegroundTurn closes the window: the check and the claim happen under one
// lock, so exactly one caller wins.
// ADR-0049 introduced tracker-side admission claims. The coarse policy no
// longer reaches them from operator prompt admission, but the dormant mechanism
// remains race-safe for a future protocol-backed design.
// TestClaimForegroundTurn_OnlyOneConcurrentPromptWins is the regression test for
// that race. Every claim but one must lose, no matter how many race in at once.
@@ -179,18 +172,18 @@ func TestPromptTask_ConcurrentPromptsIntoBackgroundIdleStartOneTurn(t *testing.T
start.Done()
done.Wait()
if accepted != 1 {
t.Fatalf("exactly one concurrent prompt may open a turn, got %d accepted (%d rejected busy)", accepted, rejectedBusy)
if accepted != 0 {
t.Fatalf("no concurrent prompt may enter a RUNNING turn, got %d accepted", accepted)
}
if rejectedBusy != prompters-1 {
t.Fatalf("every prompt that lost the claim must be rejected with ErrAgentPromptInProgress, got %d of %d", rejectedBusy, prompters-1)
if rejectedBusy != prompters {
t.Fatalf("every prompt must be rejected with ErrAgentPromptInProgress, got %d of %d", rejectedBusy, prompters)
}
// The decisive assertion: only one turn was actually started on the agent.
agentMgr.mu.Lock()
captured := len(agentMgr.capturedPrompts)
agentMgr.mu.Unlock()
if captured != 1 {
t.Fatalf("overlapping turns reached the agent: %d prompts forwarded, want exactly 1", captured)
if captured != 0 {
t.Fatalf("RUNNING-session prompts reached the agent: %d forwarded, want 0", captured)
}
}
@@ -216,10 +209,10 @@ func TestPromptTask_SupersededQueuedDispatchReleasesForegroundClaim(t *testing.T
_, err := svc.promptTask(
context.Background(), taskID, sessionID, "queued prompt", "", false, nil, false, "stale-entry",
)
if !errors.Is(err, errQueuedDispatchSuperseded) {
t.Fatalf("a dispatch without the current ownership token must be superseded, got: %v", err)
if !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("a queued dispatch into a RUNNING session must be rejected, got: %v", err)
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("a superseded dispatch must restore the background-idle gate, got %q", got)
}
if svc.claimForegroundTurn(sessionID) == nil {
@@ -336,7 +329,7 @@ func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
if claim == nil {
t.Fatal("the first prompt must win the claim")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("a claimed turn reads as generating, got %q", got)
}
@@ -347,7 +340,7 @@ func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
// Background work is still outstanding, so the session is background-idle again
// and the operator can retry.
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("a released claim must return the turn to background-idle, got %q", got)
}
if svc.claimForegroundTurn(sessionID) == nil {
@@ -377,7 +370,7 @@ func TestReleaseForegroundClaim_DoesNotReopenGateWithoutBackgroundWork(t *testin
t.Fatal("with no background work outstanding the release must not reopen the gate")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("with no background work outstanding the turn must read as generating, got %q", got)
}
}
@@ -444,7 +437,7 @@ func TestForegroundDispatch_FailedDispatchRollsBackForRetry(t *testing.T) {
if restored := <-rollbackDone; !restored {
t.Fatal("an unobserved synchronous dispatch failure must restore background-idle")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("failed dispatch must reopen immediate input, got %q", got)
}
@@ -464,7 +457,7 @@ func TestForegroundDispatch_FailedDispatchRollsBackForRetry(t *testing.T) {
t.Fatal("accepting a retry does not expose background work before foreground idle")
}
svc.markForegroundIdle(sessionID)
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("accepted retry must retain its own completion/idle identity, got %q", got)
}
}
@@ -503,7 +496,7 @@ func TestForegroundDispatch_StaleRollbackCannotClobberRetry(t *testing.T) {
if !svc.markForegroundIdle(sessionID) {
t.Fatal("retry's current idle event must expose its background work")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("stale rollback clobbered retry activity: got %q", got)
}
}
@@ -516,13 +509,13 @@ func TestForegroundDispatch_ClaimlessRollbackRestoresOnlyExactUnobservedStart(t
svc.registerBackgroundTask(sessionID, "background-1")
svc.markForegroundIdle(sessionID)
dispatch := svc.beginForegroundDispatch(sessionID, nil)
if dispatch == nil || svc.ForegroundActivity(sessionID) != v1.ForegroundActivityGenerating {
if dispatch == nil || svc.foregroundActivityValue(sessionID) != v1.ForegroundActivityGenerating {
t.Fatal("begin must atomically establish claimless successor ownership")
}
if !svc.rollbackForegroundDispatch(dispatch) {
t.Fatal("exact unobserved failure must restore prior background idle")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("rollback restored %q, want background", got)
}
})
@@ -538,7 +531,7 @@ func TestForegroundDispatch_ClaimlessRollbackRestoresOnlyExactUnobservedStart(t
if svc.rollbackForegroundDispatch(dispatch) {
t.Fatal("provider-observed cycle must not roll back")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("provider output lost successor ownership: got %q", got)
}
})
@@ -560,7 +553,7 @@ func TestForegroundDispatch_ClaimlessRollbackRestoresOnlyExactUnobservedStart(t
if svc.rollbackForegroundDispatch(first) {
t.Fatal("stale failure token must not restore over retry")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
if got := svc.foregroundActivityValue(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("stale rollback displaced retry: got %q", got)
}
})
@@ -11,38 +11,11 @@ import (
"github.com/kandev/kandev/internal/task/models"
)
// TestPromptTask_BackgroundWorkAcceptsInput is the falsifiable acceptance proof
// for ADR-0049, driven through
// the REAL operator entrypoint (PromptTask) rather than checkSessionPromptable
// in isolation — it reproduces the operator-lockout→fixed transition end to end
// for both of Claude's background-work wire shapes.
//
// The symptom: an operator whose agent launched background work (a Monitor
// watch or a run_in_background shell) is locked out — PromptTask rejects the
// next message with ErrAgentPromptInProgress because the session state still
// reads RUNNING, and the only recovery is a service restart. Both shapes only
// become recognizable on a tool_call_update (the Monitor registration banner
// seeds its Generic view; the run_in_background flag and command are streamed
// after the initial empty tool_call), so this exercises the full producer path:
// a live stream update → background registration → the prompt gate opening.
//
// Why #1600 does not already cover this: the upstream idle-turn completion
// (#1600) only synthesizes a turn-complete after async content has been idle
// for a ~5s debounce, and a chatty Monitor re-extends that debounce on every
// event burst — so the synthetic completion never fires while the watch is
// active. The monitor case below drives repeated event bursts and asserts the
// session is STILL RUNNING (the #1600 window is open, not closed) before it
// sends the mid-burst prompt: without the fine-grained gate, that prompt is
// rejected even though #1600 is armed. See the RED/GREEN note in the batch plan.
//
// Deterministic backend integration harness chosen over Playwright by design:
// surfacing the fine-grained signal to the web composer is a documented
// follow-up (Batch 2 — the composer still derives "busy" from state ===
// RUNNING), so a UI test cannot isolate this fix without a timing-flaky
// assertion. This harness drives PromptTask against a live-agent mock and
// asserts the message is forwarded to the agent, which is exactly the
// acceptance the operator sees.
func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) {
// TestPromptTask_BackgroundWorkStaysBusy drives the real operator entrypoint
// for both recognized background-work wire shapes. Accounting remains live,
// but neither a background shell nor a chatty Monitor can bypass the coarse
// RUNNING gate.
func TestPromptTask_BackgroundWorkStaysBusy(t *testing.T) {
cases := []struct {
name string
toolCallID string
@@ -120,10 +93,8 @@ func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) {
}
emitForegroundIdle(svc, taskID, sessionID)
// The session is still RUNNING: #1600's synthetic turn-complete has not
// fired (the bursts kept its debounce alive), so the durable state alone
// would keep the operator locked out. This is the window the
// fine-grained gate must open.
// The session is still RUNNING, so durable state remains the admission
// authority regardless of the private background estimate.
refreshed, err := repo.GetTaskSession(context.Background(), sessionID)
if err != nil {
t.Fatalf("reload session: %v", err)
@@ -132,14 +103,100 @@ func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) {
t.Fatalf("expected session to remain RUNNING while background work is outstanding, got %s", refreshed.State)
}
// Fixed: the same message now goes through — the session accepts input
// while only background work is outstanding, and it is forwarded to the
// live agent instead of being dropped as "busy".
if _, err := svc.PromptTask(context.Background(), taskID, sessionID, "are you still working?", "", false, nil, false); err != nil {
t.Fatalf("session with only background work outstanding must accept input, got: %v", err)
if _, err := svc.PromptTask(
context.Background(), taskID, sessionID, "are you still working?", "", false, nil, false,
); !errors.Is(err, ErrAgentPromptInProgress) {
t.Fatalf("RUNNING session with background work must reject input, got: %v", err)
}
if len(agentMgr.capturedPrompts) != 0 {
t.Fatalf("rejected prompt reached the agent, captured=%d", len(agentMgr.capturedPrompts))
}
})
}
}
func TestPromptTask_ClaudeExperimentAdmitsRecognizedBackgroundWork(t *testing.T) {
cases := []struct {
name string
toolCallID string
normalized func() *streams.NormalizedPayload
}{
{
name: "async subagent",
toolCallID: "agent-1",
normalized: func() *streams.NormalizedPayload {
payload := attestedSubagentPayload("background work", "do it", "general-purpose")
payload.SubagentTask().IsAsync = true
payload.SetBackgroundWorkIdentity(
streams.BackgroundWorkKindSubagent,
"child-1",
true,
false,
)
return payload
},
},
{
name: "run_in_background shell",
toolCallID: "bash-1",
normalized: func() *streams.NormalizedPayload {
return attestedBackgroundShellPayload("npm run dev")
},
},
{
name: "monitor",
toolCallID: "monitor-1",
normalized: func() *streams.NormalizedPayload {
return monitorGenericPayload(false)
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := setupTestRepo(t)
agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
svc.messageCreator = &mockMessageCreator{}
enableClaudeBackgroundPromptHandoffForTest(t, svc)
const (
taskID = "task1"
sessionID = "session1"
)
seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
setSessionAgentNameForTest(t, svc, sessionID, "claude-acp")
session, err := repo.GetTaskSession(context.Background(), sessionID)
if err != nil {
t.Fatalf("load session: %v", err)
}
session.AgentExecutionID = "exec-1"
seedExecutorRunning(t, repo, sessionID, taskID, "exec-1")
if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
t.Fatalf("update session: %v", err)
}
svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
TaskID: taskID,
SessionID: sessionID,
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: "tool_update",
ToolCallID: tc.toolCallID,
ToolStatus: "in_progress",
Normalized: tc.normalized(),
},
})
emitForegroundIdle(svc, taskID, sessionID)
if _, err := svc.PromptTask(
context.Background(), taskID, sessionID, "are you still working?", "", false, nil, false,
); err != nil {
t.Fatalf("enabled Claude background handoff rejected input: %v", err)
}
if len(agentMgr.capturedPrompts) != 1 {
t.Fatalf("accepted prompt must be forwarded to the agent, captured=%d", len(agentMgr.capturedPrompts))
t.Fatalf("accepted prompt count = %d, want 1", len(agentMgr.capturedPrompts))
}
})
}
@@ -49,9 +49,10 @@ var (
// ServiceConfig holds orchestrator service configuration
type ServiceConfig struct {
Scheduler scheduler.SchedulerConfig
QueueSize int
QueueGroup string
Scheduler scheduler.SchedulerConfig
QueueSize int
QueueGroup string
ClaudeBackgroundPromptHandoff bool
}
// DefaultServiceConfig returns default configuration
@@ -459,8 +460,8 @@ type Service struct {
// foregroundActivity tracks, per session, whether the open turn is actively
// generating in the foreground or only waiting on a spawned background task
// (subagent / run-in-background shell). Keyed sessionID -> *turnActivity;
// see turn_activity.go. Consulted by checkSessionPromptable so a session
// that kicked off background work still accepts operator input.
// see turn_activity.go. This is best-effort accounting state and does not
// relax the coarse RUNNING admission policy.
// foregroundActivityMu protects record identity: lookups lock the selected
// record before releasing it, and execution teardown uses the same order
// before detaching an unused record.
@@ -1130,8 +1131,8 @@ func (s *Service) completeTurnForSession(ctx context.Context, sessionID string)
func (s *Service) completeTurnForTaskSession(ctx context.Context, taskID, sessionID string) {
// Foreground ownership ends with the turn, but detached background work can
// outlive it. Preserve those registrations and expose them as background-idle;
// full cleanup belongs to execution/session teardown paths.
// outlive it. Preserve those registrations for accounting; full cleanup
// belongs to execution/session teardown paths.
s.yieldForegroundAndPublish(ctx, taskID, sessionID, foregroundYieldTurnCompletion)
if s.turnService == nil {
@@ -2975,8 +2975,9 @@ func (s *Service) loadPromptableSession(ctx context.Context, taskID, sessionID s
return session, nil
}
// claimForegroundForPrompt closes the gap between reading a RUNNING session's
// background-idle substate and dispatching its next prompt.
// claimForegroundForPrompt serializes two callers racing to take the
// background-idle foreground turn. For non-experiment RUNNING sessions,
// checkSessionPromptable already rejects the prompt before this helper is reached.
func (s *Service) claimForegroundForPrompt(taskID, sessionID string, session *models.TaskSession) (*foregroundClaim, error) {
if session.State != models.TaskSessionStateRunning {
return nil, nil
@@ -3175,16 +3176,12 @@ func (s *Service) checkSessionPromptable(taskID, sessionID string, state models.
models.TaskSessionStateIdle:
return nil
case models.TaskSessionStateRunning:
// Narrow the busy signal: a session that kicked off background work and
// is otherwise idle in the foreground should still accept a new message
// rather than reporting "running" and dropping it.
//
// This is a *read*, not a claim — DrainQueuedMessage and the STARTING wait
// both call it without going on to drive a turn themselves. PromptTask,
// which does, follows a passing read with claimForegroundTurn to close the
// check-then-act window against a second concurrent prompt.
if !s.isForegroundTurnGenerating(sessionID) {
s.logger.Debug("accepting prompt: foreground turn idle, only background work outstanding",
// The safe default is coarse: every RUNNING session is busy. A
// deployment may explicitly opt a persisted Claude Code session into
// ADR-0049's adapter-attested background handoff experiment. Every
// missing identity and non-Claude provider still fails closed.
if s.ForegroundActivity(sessionID) == v1.ForegroundActivityBackground {
s.logger.Debug("accepting prompt: enabled Claude foreground handoff is background-idle",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
return nil
@@ -9,6 +9,7 @@ import (
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -16,18 +17,14 @@ import (
// independently. Foreground activity always has precedence; background work is
// visible only after an explicit foreground-idle boundary.
//
// It is the finer-grained signal behind checkSessionPromptable: a session whose
// foreground turn has yielded to background work should accept a new message
// even though its DB state still reads RUNNING. The single-scalar session state
// cannot tell "the foreground agent is generating" from "the foreground turn is
// idle but a spawned background task (a subagent, a run-in-background shell) is
// still running", which is how a long background job used to lock the operator
// out of the conversation.
// It is a best-effort accounting signal only. The default admission and public
// activity contracts never trust it. A high-risk, default-off experiment may
// use it for persisted Claude Code sessions, whose adapter supplies the
// recognized lifecycle attestations below.
//
// The absent/zero state means "foreground generating": callers default to the
// pre-existing behaviour (reject a new prompt while RUNNING) unless a background
// task has been explicitly registered for the session, so nothing changes for a
// session that has no background work outstanding.
// The absent/zero internal state means "foreground generating". A recognized
// registration plus an explicit idle boundary can move only this private
// estimate to background.
type turnActivity struct {
mu sync.Mutex
revision uint64 // invalidates delayed publications after newer mutations
@@ -821,10 +818,8 @@ func (s *Service) clearTurnActivity(sessionID string) {
// yielded to an outstanding background task. An untracked session defaults to
// true, preserving the historical "reject a new prompt while RUNNING" contract.
//
// This is a pure predicate: checkSessionPromptable and the DTO/WS serializers
// call it to *report* promptability. A caller that is about to actually drive a
// turn on the strength of the answer must use claimForegroundTurn instead, or it
// races every other prompt reading the same window.
// This is a pure internal predicate. The coarse default admission and
// serialization seams deliberately do not use it.
func (s *Service) isForegroundTurnGenerating(sessionID string) bool {
ta := s.lockTurnActivity(sessionID, false)
if ta == nil {
@@ -845,26 +840,15 @@ func (ta *turnActivity) generatingLocked() bool {
return !ta.yielded
}
// claimForegroundTurn is the check-and-claim half of the background-idle gate.
// claimForegroundTurn is the retained check-and-claim mechanism for the private
// background-idle estimate.
// It atomically verifies the session's foreground turn has yielded to background
// work and, if so, takes it for the caller by flipping it back to generating —
// under the same lock, so exactly one caller can win.
//
// checkSessionPromptable only *reads* the substate, which leaves a wide
// check-then-act window in PromptTask: between the gate and the point the turn is
// finally marked generating sit a session reload, ensureSessionRunning, and an
// optional (network-bound) model switch. Two prompts arriving in that window —
// a double-send, or two browser tabs onto the same background-idle session —
// would both pass the read-only gate and both reach executor.Prompt, starting
// overlapping turns on one ACP session. Claiming closes that window: the first
// prompt in wins, and every prompt behind it sees a generating foreground and is
// rejected with ErrAgentPromptInProgress exactly as it would have been before
// ADR-0049.
//
// The claim is held until agentctl accepts the prompt (beginForegroundDispatch
// followed by acceptForegroundDispatch — see promptTask in task_operations.go)
// or it is handed back (releaseForegroundClaim). The returned token binds both
// operations to this activity record and admission generation.
// The public coarse gate rejects RUNNING sessions before this is reached unless
// the Claude-only experiment is enabled. Atomic claims preserve serialization
// for the experimental path.
//
// Returns nil for an untracked session — no background work is outstanding, so
// there is nothing to claim and the historical reject-while-RUNNING default
@@ -973,7 +957,7 @@ func (s *Service) beginForegroundDispatch(
}
}
// acceptForegroundDispatch closes a background-idle admission claim after
// acceptForegroundDispatch closes a retained background-idle claim after
// agentctl acknowledges the prompt. The cycle itself was already established by
// beginForegroundDispatch, so pre-callback provider events own the right token.
func (s *Service) acceptForegroundDispatch(dispatch *foregroundDispatch) bool {
@@ -1139,11 +1123,10 @@ func (s *Service) releaseForegroundClaim(claim *foregroundClaim) bool {
return true
}
// foregroundActivityValue reports the fine-grained busy substate of a session
// for the operator-facing signal: "generating" when the foreground turn is
// actively producing output (the default), "background" when it has yielded to
// outstanding spawned work. Background activity can remain meaningful after the
// coarse session state settles because detached work can outlive a turn.
// foregroundActivityValue reports the best-effort internal activity estimate.
// ACP providers do not expose background work with consistent enough lifecycle
// semantics for this estimate to control operator-facing admission or busy
// state. Keep it available for dormant tracking and diagnostics only.
func (s *Service) foregroundActivityValue(sessionID string) v1.ForegroundActivity {
if s.isForegroundTurnGenerating(sessionID) {
return v1.ForegroundActivityGenerating
@@ -1151,22 +1134,50 @@ func (s *Service) foregroundActivityValue(sessionID string) v1.ForegroundActivit
return v1.ForegroundActivityBackground
}
// ForegroundActivity exposes the in-memory fine-grained busy substate so the
// page-load / list serialization layer can stamp it onto a session DTO
// (ADR-0049). This is the same value that drives the
// live task_session.activity_changed WS event, read straight from the in-memory
// tracker — the single source of truth. There is no persisted copy. Callers emit
// generating only for RUNNING sessions, but may emit background for a settled
// session while its connected execution still has detached work.
func (s *Service) ForegroundActivity(sessionID string) v1.ForegroundActivity {
return s.foregroundActivityValue(sessionID)
const (
claudeACPProviderID = "claude-acp"
mockAgentProviderID = "mock-agent"
)
// claudeBackgroundPromptHandoffEnabled reports whether sessionID is allowed to
// use the high-risk fine-grained policy. Provider identity comes from the
// persisted launch snapshot rather than a client-supplied activity value. A
// missing session, snapshot, or provider fails closed.
func (s *Service) claudeBackgroundPromptHandoffEnabled(sessionID string) bool {
if !s.config.ClaudeBackgroundPromptHandoff || s.repo == nil || sessionID == "" {
return false
}
session, err := s.repo.GetTaskSession(context.Background(), sessionID)
if err != nil || session == nil {
return false
}
return s.claudeBackgroundPromptHandoffEnabledForSession(session)
}
// publishForegroundActivityChanged emits the fine-grained busy signal so the
// web composer and status indicator can distinguish "generating" from "waiting
// on background work" without a coarse session-state transition. Callers invoke
// it only when a flip actually happened (the mark/register/complete helpers
// return that), so it never fires per background frame.
func (s *Service) claudeBackgroundPromptHandoffEnabledForSession(
session *models.TaskSession,
) bool {
if !s.config.ClaudeBackgroundPromptHandoff || session == nil {
return false
}
agentName, _ := session.AgentProfileSnapshot["agent_name"].(string)
return agentName == claudeACPProviderID || agentName == mockAgentProviderID
}
// ForegroundActivity exposes the conservative operator-facing busy signal by
// default. A deployment that explicitly enables the experiment may expose the
// private tracker value only for a persisted Claude Code session (or the mock
// provider used to exercise Claude lifecycle frames in E2E).
func (s *Service) ForegroundActivity(sessionID string) v1.ForegroundActivity {
if s.claudeBackgroundPromptHandoffEnabled(sessionID) {
return s.foregroundActivityValue(sessionID)
}
return v1.ForegroundActivityGenerating
}
// publishForegroundActivityChanged emits a provider-aware refresh when the
// internal tracker changes. publishForegroundActivityNow applies the coarse
// default or the explicitly enabled Claude experiment before broadcasting.
func (s *Service) publishForegroundActivityChanged(ctx context.Context, taskID, sessionID string) {
publicationLock, releasePublication := s.acquireActivityPublicationGuard(sessionID)
defer releasePublication()
@@ -1239,7 +1250,7 @@ func (s *Service) publishForegroundActivityNow(
eventData := map[string]interface{}{
metaKeyTaskID: taskID,
metaKeySessionID: sessionID,
"foreground_activity": value,
"foreground_activity": s.publicForegroundActivityValue(ctx, sessionID, value),
"active_subagent_count": activeSubagentCount,
}
if err := s.eventBus.Publish(ctx, events.TaskSessionActivityChanged,
@@ -1255,6 +1266,32 @@ func (s *Service) publishForegroundActivityNow(
s.publishTaskActivityIfChanged(ctx, taskID)
}
func (s *Service) publicForegroundActivityValue(
ctx context.Context,
sessionID string,
value interface{},
) interface{} {
if value == nil {
return nil
}
publicValue := interface{}(string(v1.ForegroundActivityGenerating))
if s.repo == nil {
return publicValue
}
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil || session == nil {
return publicValue
}
if s.claudeBackgroundPromptHandoffEnabledForSession(session) &&
value == string(v1.ForegroundActivityBackground) {
return value
}
if session.State != models.TaskSessionStateRunning {
return nil
}
return publicValue
}
// normalizedIsBackgroundTask reports whether the adapter attested a live
// workload whose complete lifecycle Kandev recognizes.
//
@@ -67,6 +67,13 @@ features:
dev: "false"
e2e: "false"
# High-risk Claude-only background prompt handoff experiment. Off in every
# profile so it is exercised only by contributors who deliberately opt in.
KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF:
prod: "false"
dev: "false"
e2e: "false"
# ── Mock providers ────────────────────────────────────────────────
# Swap real third-party services for in-memory fakes. Production
# leaves all of these unset (real APIs). Dev opts into the mock agent
@@ -22,8 +22,8 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) {
t.Errorf("env = %q, want %q (no selector env vars set)", env, EnvProd)
}
// Prod writes each registered feature flag with its safe shipped default.
if count != 4 {
t.Errorf("ApplyProfile wrote %d vars in prod; want 4", count)
if count != 5 {
t.Errorf("ApplyProfile wrote %d vars in prod; want 5", count)
}
if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "false" {
t.Errorf("KANDEV_FEATURES_OFFICE = %q after prod ApplyProfile; want %q", v, "false")
@@ -37,6 +37,13 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) {
if v := os.Getenv("KANDEV_FEATURES_AUTH"); v != "false" {
t.Errorf("KANDEV_FEATURES_AUTH = %q after prod ApplyProfile; want %q", v, "false")
}
if v := os.Getenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"); v != "false" {
t.Errorf(
"KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF = %q after prod ApplyProfile; want %q",
v,
"false",
)
}
}
// TestApplyProfile_DevUsesDevelopmentDefaults verifies the mixed dev profile:
@@ -67,6 +74,13 @@ func TestApplyProfile_DevUsesDevelopmentDefaults(t *testing.T) {
if v := os.Getenv("KANDEV_FEATURES_AUTH"); v != "false" {
t.Errorf("KANDEV_FEATURES_AUTH = %q in dev; want %q", v, "false")
}
if v := os.Getenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"); v != "false" {
t.Errorf(
"KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF = %q in dev; want %q",
v,
"false",
)
}
}
// TestApplyProfile_E2EWinsOverDev checks the documented detection
@@ -92,6 +106,13 @@ func TestApplyProfile_E2EWinsOverDev(t *testing.T) {
if v := os.Getenv("AGENTCTL_AUTO_APPROVE_PERMISSIONS"); v != "true" {
t.Errorf("AGENTCTL_AUTO_APPROVE_PERMISSIONS = %q in e2e; want %q", v, "true")
}
if v := os.Getenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"); v != "false" {
t.Errorf(
"KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF = %q in e2e; want %q",
v,
"false",
)
}
}
// TestApplyProfile_ExistingEnvWins is the precedence guarantee that
@@ -154,6 +175,13 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) {
if _, ok := defaults["app_status_bar"]; !ok {
t.Errorf("FeatureFlagDefaults missing %q key; got %#v", "app_status_bar", defaults)
}
if _, ok := defaults["claude_background_prompt_handoff"]; !ok {
t.Errorf(
"FeatureFlagDefaults missing %q key; got %#v",
"claude_background_prompt_handoff",
defaults,
)
}
}
func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) {
@@ -181,7 +209,15 @@ func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) {
// hell out of whoever debugs it.
func TestProfilesYAML_ContainsRequiredSections(t *testing.T) {
yaml := string(ProfilesYAML())
for _, section := range []string{"features:", "mocks:", "debug:", "KANDEV_FEATURES_OFFICE:", "KANDEV_FEATURES_PLUGINS:", "KANDEV_FEATURES_APP_STATUS_BAR:"} {
for _, section := range []string{
"features:",
"mocks:",
"debug:",
"KANDEV_FEATURES_OFFICE:",
"KANDEV_FEATURES_PLUGINS:",
"KANDEV_FEATURES_APP_STATUS_BAR:",
"KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF:",
} {
if !strings.Contains(yaml, section) {
t.Errorf("embedded profiles.yaml missing section/key %q; embed broken or file truncated", section)
}
+28 -20
View File
@@ -9,18 +9,20 @@ import (
)
const (
featureOfficeKey = "features.office"
featurePluginsKey = "features.plugins"
featureAppStatusBarKey = "features.appStatusBar"
featureAuthKey = "features.auth"
debugDevModeKey = "debug.devMode"
envFeaturesOffice = "KANDEV_FEATURES_OFFICE"
envFeaturesPlugins = "KANDEV_FEATURES_PLUGINS"
envFeaturesAppStatusBar = "KANDEV_FEATURES_APP_STATUS_BAR"
envFeaturesAuth = "KANDEV_FEATURES_AUTH"
envDebugDevMode = "KANDEV_DEBUG_DEV_MODE"
envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED"
envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES"
featureOfficeKey = "features.office"
featurePluginsKey = "features.plugins"
featureAppStatusBarKey = "features.appStatusBar"
featureAuthKey = "features.auth"
featureClaudeBackgroundPromptHandoffKey = "features.claudeBackgroundPromptHandoff"
debugDevModeKey = "debug.devMode"
envFeaturesOffice = "KANDEV_FEATURES_OFFICE"
envFeaturesPlugins = "KANDEV_FEATURES_PLUGINS"
envFeaturesAppStatusBar = "KANDEV_FEATURES_APP_STATUS_BAR"
envFeaturesAuth = "KANDEV_FEATURES_AUTH"
envFeaturesClaudeBackgroundPromptHandoff = "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"
envDebugDevMode = "KANDEV_DEBUG_DEV_MODE"
envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED"
envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES"
)
func OptionsFromConfig(cfg *config.Config) Options {
@@ -32,9 +34,12 @@ func OptionsFromConfig(cfg *config.Config) Options {
envFeaturesPlugins: isTruthy(os.Getenv(envFeaturesPlugins)),
envFeaturesAppStatusBar: isTruthy(os.Getenv(envFeaturesAppStatusBar)),
envFeaturesAuth: isTruthy(os.Getenv(envFeaturesAuth)),
envDebugDevMode: isTruthy(os.Getenv(envDebugDevMode)),
envDebugPprofEnabled: isTruthy(os.Getenv(envDebugPprofEnabled)),
envDebugAgentMessages: isTruthy(os.Getenv(envDebugAgentMessages)),
envFeaturesClaudeBackgroundPromptHandoff: isTruthy(
os.Getenv(envFeaturesClaudeBackgroundPromptHandoff),
),
envDebugDevMode: isTruthy(os.Getenv(envDebugDevMode)),
envDebugPprofEnabled: isTruthy(os.Getenv(envDebugPprofEnabled)),
envDebugAgentMessages: isTruthy(os.Getenv(envDebugAgentMessages)),
},
IsExplicitEnv: func(name string) bool {
_, ok := os.LookupEnv(name)
@@ -46,11 +51,12 @@ func OptionsFromConfig(cfg *config.Config) Options {
func ValuesFromConfig(cfg *config.Config) map[string]bool {
debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled
return map[string]bool{
featureOfficeKey: cfg.Features.Office,
featurePluginsKey: cfg.Features.Plugins,
featureAppStatusBarKey: cfg.Features.AppStatusBar,
featureAuthKey: cfg.Features.Auth,
debugDevModeKey: debugEnabled,
featureOfficeKey: cfg.Features.Office,
featurePluginsKey: cfg.Features.Plugins,
featureAppStatusBarKey: cfg.Features.AppStatusBar,
featureAuthKey: cfg.Features.Auth,
featureClaudeBackgroundPromptHandoffKey: cfg.Features.ClaudeBackgroundPromptHandoff,
debugDevModeKey: debugEnabled,
}
}
@@ -65,6 +71,8 @@ func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) {
cfg.Features.AppStatusBar = state.EffectiveValue
case featureAuthKey:
cfg.Features.Auth = state.EffectiveValue
case featureClaudeBackgroundPromptHandoffKey:
cfg.Features.ClaudeBackgroundPromptHandoff = state.EffectiveValue
case debugDevModeKey:
cfg.Debug.DevMode = state.EffectiveValue
cfg.Debug.PprofEnabled = state.EffectiveValue
@@ -2,6 +2,7 @@ package runtimeflags
import (
"os"
"reflect"
"testing"
"github.com/kandev/kandev/internal/common/config"
@@ -80,6 +81,17 @@ func TestOptionsFromConfigParsesUppercaseTruthyEnvForAppStatusBar(t *testing.T)
}
}
func TestOptionsFromConfigParsesClaudeBackgroundPromptHandoffEnv(t *testing.T) {
preserveEnv(t, "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF")
t.Setenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", "TRUE")
opts := OptionsFromConfig(&config.Config{})
if !opts.EnvValues["KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"] {
t.Fatal("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF TRUE parsed false, want true")
}
}
func TestValuesFromConfigIncludesPlugins(t *testing.T) {
cfg := &config.Config{}
cfg.Features.Plugins = true
@@ -125,6 +137,34 @@ func TestValuesFromConfigIncludesAppStatusBar(t *testing.T) {
}
}
func TestValuesFromConfigIncludesClaudeBackgroundPromptHandoff(t *testing.T) {
cfg := &config.Config{}
field := reflect.ValueOf(&cfg.Features).Elem().FieldByName("ClaudeBackgroundPromptHandoff")
if !field.IsValid() {
t.Fatal("FeaturesConfig.ClaudeBackgroundPromptHandoff field missing")
}
field.SetBool(true)
values := ValuesFromConfig(cfg)
if !values["features.claudeBackgroundPromptHandoff"] {
t.Fatal("ValuesFromConfig did not surface features.claudeBackgroundPromptHandoff = true")
}
}
func TestApplyStatesToConfigSetsClaudeBackgroundPromptHandoff(t *testing.T) {
cfg := &config.Config{}
ApplyStatesToConfig(cfg, []RuntimeFlagState{{
Key: "features.claudeBackgroundPromptHandoff",
EffectiveValue: true,
}})
field := reflect.ValueOf(&cfg.Features).Elem().FieldByName("ClaudeBackgroundPromptHandoff")
if !field.IsValid() || !field.Bool() {
t.Fatal("ApplyStatesToConfig did not set Features.ClaudeBackgroundPromptHandoff = true")
}
}
func TestApplyStatesToConfigMarksImpliedDebugEnvAsApplied(t *testing.T) {
for _, name := range []string{
"KANDEV_DEBUG_PPROF_ENABLED",
@@ -56,6 +56,20 @@ var definitions = []RuntimeFlagDefinition{
RestartRequired: true,
Mutable: true,
},
{
Key: featureClaudeBackgroundPromptHandoffKey,
EnvVar: envFeaturesClaudeBackgroundPromptHandoff,
Kind: KindFeature,
Label: "Claude background prompt handoff",
Description: "Allows Claude Code to accept a new prompt after its foreground yields while recognized background work remains active.",
Stability: StabilityExperimental,
RiskLevel: RiskHigh,
RiskDescription: "Claude ACP background lifecycle signals can be missing, delayed, duplicated, or ambiguous. " +
"Enabling this experiment can misclassify session activity or dispatch overlapping prompts. " +
"Use it only for controlled testing and disable it if a session behaves unexpectedly.",
RestartRequired: true,
Mutable: true,
},
{
Key: debugDevModeKey,
EnvVar: envDebugDevMode,
@@ -53,6 +53,34 @@ func TestDefinitionsIncludeAppStatusBarMetadata(t *testing.T) {
}
}
func TestDefinitionsIncludeClaudeBackgroundPromptHandoffMetadata(t *testing.T) {
def, ok := DefinitionByKey("features.claudeBackgroundPromptHandoff")
if !ok {
t.Fatal("features.claudeBackgroundPromptHandoff definition missing")
}
if def.EnvVar != "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF" {
t.Fatalf(
"EnvVar = %q, want KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF",
def.EnvVar,
)
}
if def.Stability != StabilityExperimental {
t.Fatalf("Stability = %q, want %q", def.Stability, StabilityExperimental)
}
if def.RiskLevel != RiskHigh {
t.Fatalf("RiskLevel = %q, want %q", def.RiskLevel, RiskHigh)
}
if def.RiskDescription == "" {
t.Fatal("RiskDescription empty")
}
if !def.RestartRequired {
t.Fatal("RestartRequired = false, want true")
}
if !def.Mutable {
t.Fatal("Mutable = false, want true")
}
}
func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) {
def, ok := DefinitionByKey("debug.devMode")
if !ok {
@@ -32,20 +32,9 @@ type OrchestratorService interface {
StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment, references []v1.EntityReference) error
ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error
StepRequiresCompletionSignal(ctx context.Context, taskID string) bool
// ForegroundActivity reports the fine-grained busy substate of a RUNNING
// session (ADR-0049): "background" when the foreground turn has yielded to
// spawned background work and can accept a new message.
//
// Deliberately a hard dependency here, unlike TaskHandlers — which reaches the
// same method through an optional type-assertion on OrchestratorStarter and
// simply omits the field when it isn't there. The asymmetry is the difference
// in blast radius, not an oversight: this is the admission gate, so an
// orchestrator that silently lacked the method would fall back to rejecting
// every RUNNING session and quietly un-ship the feature. Requiring it makes
// that a compile error. TaskHandlers only enriches a DTO with the substate; if
// it's absent the client just falls back to the safe "generating" default, so
// there a soft dependency costs nothing. Resist "fixing" the inconsistency by
// making this one optional.
// ForegroundActivity is already filtered by the orchestrator's runtime flag
// and persisted provider identity. Background therefore means this exact
// session is eligible for the experimental direct-admission path.
ForegroundActivity(sessionID string) v1.ForegroundActivity
}
@@ -436,11 +425,8 @@ func (h *MessageHandlers) resolveSessionAfterTurnStart(
func (h *MessageHandlers) errorForBlockedMessageSession(msg *ws.Message, sessionID string, state models.TaskSessionState) *ws.Message {
switch state {
case models.TaskSessionStateRunning:
// A RUNNING session whose foreground turn has yielded to spawned
// background work (ADR-0049) accepts a new message: it flows on to
// PromptTask, which owns the same foreground-idle gate and forwards it.
// Only a foreground-generating turn is blocked here.
if h.orchestrator != nil && h.orchestrator.ForegroundActivity(sessionID) == v1.ForegroundActivityBackground {
if h.orchestrator != nil &&
h.orchestrator.ForegroundActivity(sessionID) == v1.ForegroundActivityBackground {
return nil
}
wsErr, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Agent is currently processing. Please wait for the current operation to complete.", nil)
@@ -644,8 +644,8 @@ func TestWSAddMessageFailsWhenSessionReloadAfterOnTurnStartFails(t *testing.T) {
assert.Empty(t, orch.getForwardedSession())
}
// fgActivityOrchestrator is a minimal OrchestratorService whose ForegroundActivity
// is configurable, for testing the ADR-0049 message-add gate.
// fgActivityOrchestrator retains a configurable legacy activity value to prove
// message admission ignores it.
type fgActivityOrchestrator struct {
activity v1.ForegroundActivity
}
@@ -697,7 +697,7 @@ func TestWSAddMessage_ForegroundActivityAdmissionWiring(t *testing.T) {
wantPrompt bool
}{
{
name: "running background dispatches prompt",
name: "running eligible background is accepted",
state: models.TaskSessionStateRunning,
activity: v1.ForegroundActivityBackground,
wantResponse: ws.MessageTypeResponse,
@@ -772,11 +772,11 @@ func TestWSAddMessage_ForegroundActivityAdmissionWiring(t *testing.T) {
}
}
// TestErrorForBlockedMessageSession_BackgroundIdleAccepts is the ADR-0049
// message-add gate: a RUNNING session whose foreground turn has yielded to
// background work must NOT be blocked at the message.add layer (it flows on to
// PromptTask), while a foreground-generating RUNNING session stays blocked.
func TestErrorForBlockedMessageSession_BackgroundIdleAccepts(t *testing.T) {
// TestErrorForBlockedMessageSession_RunningAlwaysBlocks proves a legacy
// TestErrorForBlockedMessageSession_UsesOrchestratorActivityPolicy proves the
// transport accepts only the provider/flag-filtered background value returned
// by the orchestrator while retaining the coarse block for generating turns.
func TestErrorForBlockedMessageSession_UsesOrchestratorActivityPolicy(t *testing.T) {
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
require.NoError(t, err)
msg := &ws.Message{ID: "1", Action: ws.ActionMessageAdd}
@@ -788,7 +788,7 @@ func TestErrorForBlockedMessageSession_BackgroundIdleAccepts(t *testing.T) {
wantBlock bool
}{
{"running + generating is blocked", v1.ForegroundActivityGenerating, models.TaskSessionStateRunning, true},
{"running + background is accepted", v1.ForegroundActivityBackground, models.TaskSessionStateRunning, false},
{"running + eligible background is accepted", v1.ForegroundActivityBackground, models.TaskSessionStateRunning, false},
{"waiting is accepted regardless", v1.ForegroundActivityGenerating, models.TaskSessionStateWaitingForInput, false},
{"failed stays blocked", v1.ForegroundActivityBackground, models.TaskSessionStateFailed, true},
}
+128 -207
View File
@@ -2,24 +2,10 @@ import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { typeWhileBusy } from "../../helpers/type-while-busy";
import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store";
import { typeWhileBusy } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
// ---------------------------------------------------------------------------
// ADR-0049 — operator-visible surfacing.
//
// The mock `/detached-background <dur>` command returns its foreground response
// immediately while the launched workload continues, so this suite exercises
// work that genuinely outlives the foreground turn.
//
// The distinguishing observable of the background-idle window (b) is that the
// agent status still reads "running" (the working affordance) WHILE the composer
// shows its idle/accept placeholder — condition (a), a genuinely generating
// turn, keeps the "Queue more instructions…" busy placeholder and diverts input
// to the queue.
// ---------------------------------------------------------------------------
async function seedTaskAndWaitForIdle(
testPage: Page,
apiClient: ApiClient,
@@ -39,18 +25,16 @@ async function seedTaskAndWaitForIdle(
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
return session;
}
test.describe("Fine-grained busy signal — composer + status", () => {
test.describe("Coarse RUNNING busy signal", () => {
test.describe.configure({ retries: 1 });
test("async subagent accepts a new foreground turn and clears on singleton ID-less completion", async ({
test("held-open background turn remains busy across input and reload", async ({
testPage,
apiClient,
seedData,
@@ -61,194 +45,39 @@ test.describe("Fine-grained busy signal — composer + status", () => {
testPage,
apiClient,
seedData,
"Async subagent lifecycle",
"Coarse busy signal",
);
// This replay is deliberately ordered like Claude's async Agent path:
// Agent launch (with agentId) -> human-origin foreground idle -> final
// same-prompt thought/message -> prompt completion -> Claude's ID-less
// task-notification completion. It is not the shell-background ordering
// covered below. Exact-ID completion is pinned by backend unit coverage;
// this browser path exercises the safe singleton fallback Claude requires.
await session.sendMessage("/async-subagent-lifecycle 20s");
await expect(testPage.getByText("Foreground response after async launch.")).toBeVisible({
timeout: 20_000,
await session.sendMessage("/background 30s");
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByText("Kicking off background work")).toBeVisible({
timeout: 15_000,
});
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
const backgroundIndicator = session
.sidebarTaskItem("Async subagent lifecycle")
.getByTestId("task-state-background-running");
await expect(backgroundIndicator).toBeVisible();
await expect(backgroundIndicator).toHaveClass(/text-violet-500/);
await expect(backgroundIndicator).toHaveClass(/animate-spin/);
await expect(backgroundIndicator.locator("..")).toHaveAttribute(
"aria-label",
"Background work is running",
);
// The foreground-idle frame follows this text in the mock's ordered ACP
// stream. Allow the subsequent WS publication to settle before asserting
// the stable composer contract.
await testPage.waitForTimeout(500);
// A prompt submitted while only the async child remains must be admitted
// immediately. Foreground activity temporarily wins over the child.
await session.sendMessage("/slow 2s");
await expect(testPage.getByText("/slow 2s")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("queue-chip")).not.toBeVisible();
// The private tracker may identify background work, but the public
// contract remains coarse for the entire RUNNING turn.
await waitForActiveSessionForegroundActivity(testPage, "generating");
// The older async child remains visible after the second foreground yields,
// then its singleton ID-less task-notification removes the final registration.
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
await waitForActiveSessionForegroundActivity(testPage, null);
await expect(backgroundIndicator).not.toBeVisible();
});
test("execution teardown clears an async child whose completion never arrives", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Async subagent teardown",
seedData.agentProfileId,
{
description: "/async-subagent-teardown",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("auto-started task did not return a session id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await expect(session.agentStatus()).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "background");
// No child completion is emitted by this scenario. The terminal execution
// boundary must reconcile its owned registration instead.
await apiClient.stopSession({
session_id: task.session_id,
reason: "e2e teardown",
force: true,
});
await expect(session.agentStatus()).not.toBeVisible({ timeout: 20_000 });
await waitForActiveSessionForegroundActivity(testPage, null);
await expect(session.agentStatus()).not.toBeVisible();
});
test("background-idle session shows working AND accepts input, then flips to done", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const session = await seedTaskAndWaitForIdle(testPage, apiClient, seedData, "Busy signal (b)");
// Launch work whose foreground prompt closes immediately.
await session.sendMessage("/detached-background 12s");
// The turn is working…
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
// …and once it yields to background work the composer flips to accept-input
// (idle placeholder) even though the status still reads "running": the two
// independent facts — "you may type" AND "work is still in progress" — are
// both visible at once. This is the whole point of the fine-grained signal.
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await expect(session.agentStatus()).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "background");
// The working affordance must NOT be the done state while background runs.
// Once the detached workload completes, the working affordance clears.
await expect(session.agentStatus()).not.toBeVisible({ timeout: 40_000 });
await expect(session.idleInput()).toBeVisible({ timeout: 10_000 });
});
test("input typed during the background-idle window is sent, not queued", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const session = await seedTaskAndWaitForIdle(testPage, apiClient, seedData, "Busy signal send");
await session.sendMessage("/detached-background 20s");
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
// Wait for the background-idle window (accept-input while still working).
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await expect(session.agentStatus()).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "background");
// Type and submit a foreground turn — it is accepted and posted, not
// diverted, and foreground busy temporarily takes absolute precedence.
const editor = testPage.locator(".tiptap.ProseMirror").first();
await editor.click();
await editor.fill("/slow 3s");
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await editor.press(`${modifier}+Enter`);
// It appears in the conversation as a sent user message…
await expect(testPage.getByText("/slow 3s")).toBeVisible({ timeout: 15_000 });
// …and was NOT silently diverted to the queue.
await expect(testPage.getByTestId("queue-chip")).not.toBeVisible();
// While that foreground is active, the instant-send placeholder disappears
// even though the older detached workload remains registered.
await expect(session.idleInput()).not.toBeVisible({ timeout: 2_000 });
await expect(session.idleInput()).not.toBeVisible();
await expect(testPage.locator('[data-placeholder^="Queue"]')).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "generating");
// Foreground completion reveals the still-running detached work again.
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
await expect(session.agentStatus()).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "background");
});
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible");
await typeWhileBusy(testPage, editor, "queue this follow-up");
await testPage.getByTestId("submit-message-button").click();
await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 });
test("background-idle substate survives a fresh page reload (boot payload, no WS flip)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const session = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"Busy signal reload",
);
// Open a long background window so it is still held after the reload lands.
await session.sendMessage("/detached-background 20s");
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
// Reach the background-idle window: accept-input while still working.
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await expect(session.agentStatus()).toBeVisible();
// Reload: this is a fresh client that loads MID background-window. The
// substate is not persisted and no activity_changed WS flip is due, so the
// only way the composer can
// show accept-input + working here is if the boot payload carried the
// fine-grained substate. This is the exact gap this batch closes: before it,
// a reload showed the coarse "Queue more instructions…" busy affordance until
// the next flip — which, for a genuinely idle-on-background turn, never comes.
await testPage.reload();
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
await expect(session.agentStatus()).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "generating");
await expect(session.idleInput()).not.toBeVisible();
await expect(testPage.locator('[data-placeholder^="Queue"]')).toBeVisible();
});
test("a turn with no background work keeps the composer gated (queues input)", async ({
test("foreground generation continues to queue input", async ({
testPage,
apiClient,
seedData,
@@ -259,24 +88,116 @@ test.describe("Fine-grained busy signal — composer + status", () => {
testPage,
apiClient,
seedData,
"Busy signal gated",
"Coarse busy foreground",
);
// A plain slow turn generates in the foreground the whole time — no
// recognized background work — so the composer stays gated exactly as today.
await session.sendMessage("/slow 10s");
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
await waitForActiveSessionForegroundActivity(testPage, "generating");
// The accept-input (idle) placeholder must NOT appear while generating.
await expect(session.idleInput()).not.toBeVisible();
const editor = testPage.locator(".tiptap.ProseMirror").first();
await typeWhileBusy(testPage, editor, "should queue");
const submitBtn = testPage.getByTestId("submit-message-button");
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
await submitBtn.click();
// Diverted to the queue, not posted — the historical contract is unchanged.
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible");
await typeWhileBusy(testPage, editor, "queue foreground follow-up");
await testPage.getByTestId("submit-message-button").click();
await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 });
});
});
test.describe.serial("Claude background prompt handoff experiment", () => {
test.describe.configure({ retries: 1 });
test.beforeAll(async ({ backend }) => {
await backend.restart({
KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: "true",
});
const response = await fetch(`${backend.baseUrl}/api/v1/features`);
expect(response.ok).toBeTruthy();
expect(await response.json()).toMatchObject({
claudeBackgroundPromptHandoff: true,
});
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("async subagent accepts a foreground turn and clears on completion", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const session = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"Experimental async subagent",
);
const agentName = await testPage.evaluate(() => {
const store = (
window as Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
tasks: { activeSessionId: string | null };
taskSessions: { items: Record<string, Record<string, unknown>> };
};
};
}
).__KANDEV_E2E_STORE__;
const state = store?.getState();
const sessionID = state?.tasks.activeSessionId;
const snapshot = sessionID
? (state?.taskSessions.items[sessionID]?.agent_profile_snapshot as
| Record<string, unknown>
| undefined)
: undefined;
return snapshot?.agent_name;
});
expect(agentName).toBe("mock-agent");
await session.sendMessage("/async-subagent-lifecycle 12s");
await expect(testPage.getByText("Foreground response after async launch.")).toBeVisible({
timeout: 20_000,
});
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
await session.sendMessage("/slow 2s");
await expect(testPage.getByText("/slow 2s")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("queue-chip")).not.toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "generating");
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
await waitForActiveSessionForegroundActivity(testPage, null);
});
test("detached work accepts input and preserves the background state on reload", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const session = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"Experimental detached work",
);
await session.sendMessage("/detached-background 20s");
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
await testPage.reload();
await session.waitForLoad();
await expect(session.agentStatus()).toBeVisible();
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
await session.sendMessage("/slow 2s");
await expect(testPage.getByText("/slow 2s")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("queue-chip")).not.toBeVisible();
});
});
@@ -1,114 +1,24 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store";
import { typeWhileBusy } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
// Mobile (Pixel 5) coverage for ADR-0049. Filename matches
// /mobile-.*\.spec\.ts/ so the `mobile-chrome` project picks it up. The composer
// gating and the working affordance derive from the same shared hooks the
// desktop path uses, so this asserts the operator-visible outcome holds at
// mobile width: a background-idle session shows "working" while the composer
// accepts input, then flips to done once the background task finishes.
test.describe("Mobile fine-grained busy signal", () => {
test.describe("Mobile coarse RUNNING busy signal", () => {
test.describe.configure({ retries: 1 });
test("async subagent allows instant mobile send and clears on singleton ID-less completion", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(150_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile async subagent lifecycle",
seedData.agentProfileId,
{
description: "/async-subagent-lifecycle 25s",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(testPage.getByText("Foreground response after async launch.")).toBeVisible({
timeout: 25_000,
});
await expect(session.idleInput()).toBeVisible({ timeout: 25_000 });
await expect(session.agentStatus()).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "background");
// Use the shipped Pixel 5 composer button path; the prompt must be posted,
// not queued, and foreground must take visual precedence over the child.
await session.sendMessageViaButton("/slow 3s");
await expect(testPage.getByText("/slow 3s")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("queue-chip")).not.toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "generating");
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
await expect(session.agentStatus()).not.toBeVisible({ timeout: 45_000 });
await waitForActiveSessionForegroundActivity(testPage, null);
await expect(session.agentStatus()).not.toBeVisible();
});
test("execution teardown clears a missing async completion on mobile", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(100_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile async subagent teardown",
seedData.agentProfileId,
{
description: "/async-subagent-teardown",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("auto-started task did not return a session id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 25_000 });
await expect(session.agentStatus()).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "background");
await apiClient.stopSession({
session_id: task.session_id,
reason: "e2e teardown",
force: true,
});
await expect(session.agentStatus()).not.toBeVisible({ timeout: 20_000 });
await waitForActiveSessionForegroundActivity(testPage, null);
await expect(session.agentStatus()).not.toBeVisible();
});
test("background-idle session shows working AND accepts input at mobile width", async ({
test("held-open background turn remains busy across input and reload", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
// Drive the background window via the auto-started first turn (the task
// description) rather than a sendMessage follow-up: the live turn reliably
// reaches the mobile client while it is still running (same approach as
// mobile-empty-turn.spec.ts).
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile busy signal",
"Mobile coarse busy signal",
seedData.agentProfileId,
{
description: "/detached-background 30s",
description: "/background 30s",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
@@ -118,50 +28,56 @@ test.describe("Mobile fine-grained busy signal", () => {
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// The auto-started turn is working…
await expect(session.agentStatus()).toBeVisible({ timeout: 20_000 });
await expect(testPage.getByText("Kicking off background work")).toBeVisible({
timeout: 20_000,
});
await testPage.waitForTimeout(500);
// …and once it yields to background work the composer flips to accept-input
// (idle placeholder) while the status still reads "working" — both facts
// visible at once at mobile width.
await expect(session.idleInput()).toBeVisible({ timeout: 25_000 });
await expect(session.agentStatus()).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "background");
// A mobile button submission during the background-only window must start
// a new foreground turn immediately, never divert the prompt to the queue.
await session.sendMessageViaButton("/slow 5s");
await expect(testPage.getByText("/slow 5s")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("queue-chip")).not.toBeVisible();
await expect(session.idleInput()).not.toBeVisible({ timeout: 5_000 });
await waitForActiveSessionForegroundActivity(testPage, "generating");
await expect(session.idleInput()).not.toBeVisible();
await expect(testPage.locator('[data-placeholder^="Queue"]')).toBeVisible();
// Foreground completion exposes the older detached work again until its
// own lifecycle completion arrives.
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible");
await typeWhileBusy(testPage, editor, "queue this mobile follow-up");
await testPage.getByTestId("submit-message-button").tap();
await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 });
await testPage.reload();
await session.waitForLoad();
await expect(session.agentStatus()).toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "background");
await waitForActiveSessionForegroundActivity(testPage, "generating");
await expect(session.idleInput()).not.toBeVisible();
await expect(testPage.locator('[data-placeholder^="Queue"]')).toBeVisible();
});
});
// After the detached task completes, the working affordance clears.
await expect(session.agentStatus()).not.toBeVisible({ timeout: 40_000 });
await expect(session.idleInput()).toBeVisible({ timeout: 10_000 });
test.describe.serial("Mobile Claude background prompt handoff experiment", () => {
test.describe.configure({ retries: 1 });
test.beforeAll(async ({ backend }) => {
await backend.restart({
KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: "true",
});
});
test("background-idle substate survives a reload at mobile width", async ({
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("background-only work keeps working visible while input sends immediately", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
test.setTimeout(120_000);
// Long background window so it is still held after the reload lands.
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile busy signal reload",
"Mobile experimental background handoff",
seedData.agentProfileId,
{
description: "/detached-background 20s",
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
@@ -171,19 +87,22 @@ test.describe("Mobile fine-grained busy signal", () => {
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
// Reach the background-idle window.
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible");
await editor.tap();
await editor.fill("/detached-background 20s");
await testPage.getByTestId("submit-message-button").tap();
await expect(session.agentStatus()).toBeVisible({ timeout: 20_000 });
await expect(session.idleInput()).toBeVisible({ timeout: 25_000 });
await expect(session.agentStatus()).toBeVisible();
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
// Reload mid-window: a fresh mobile client. The accept-input + working
// affordance must come straight from the boot payload (no persisted value,
// no activity_changed WS flip due) — ADR-0049.
await testPage.reload();
await session.waitForLoad();
await editor.tap();
await editor.fill("/slow 2s");
await testPage.getByTestId("submit-message-button").tap();
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
await expect(session.agentStatus()).toBeVisible();
await expect(testPage.getByText("/slow 2s")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("queue-chip")).not.toBeVisible();
await waitForActiveSessionForegroundActivity(testPage, "generating");
});
});
@@ -24,24 +24,34 @@ describe("features slice", () => {
expect(store.getState().features.plugins).toBe(false);
expect(defaultFeaturesState.features).toHaveProperty("appStatusBar", false);
expect(defaultFeaturesState.features).toHaveProperty("auth", false);
expect(defaultFeaturesState.features).toHaveProperty("claudeBackgroundPromptHandoff", false);
});
it("setFeatures replaces the whole flag map", () => {
const store = makeStore();
store.getState().setFeatures({ office: true, plugins: true, appStatusBar: true, auth: true });
store.getState().setFeatures({
office: true,
plugins: true,
appStatusBar: true,
auth: true,
claudeBackgroundPromptHandoff: true,
});
expect(store.getState().features.office).toBe(true);
expect(store.getState().features.plugins).toBe(true);
expect(store.getState().features.appStatusBar).toBe(true);
expect(store.getState().features.auth).toBe(true);
expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(true);
store.getState().setFeatures({
office: false,
plugins: false,
appStatusBar: false,
auth: false,
claudeBackgroundPromptHandoff: false,
});
expect(store.getState().features.office).toBe(false);
expect(store.getState().features.plugins).toBe(false);
expect(store.getState().features.appStatusBar).toBe(false);
expect(store.getState().features.auth).toBe(false);
expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(false);
});
});
@@ -10,6 +10,7 @@ export const defaultFeaturesState: FeaturesSliceState = {
plugins: false,
appStatusBar: false,
auth: false,
claudeBackgroundPromptHandoff: false,
},
};
@@ -9,6 +9,7 @@ export type FeatureFlags = {
plugins: boolean;
appStatusBar: boolean;
auth: boolean;
claudeBackgroundPromptHandoff: boolean;
};
export type FeatureName = keyof FeatureFlags;
@@ -1,6 +1,6 @@
# 0049: Fine-grained foreground-idle busy signal
**Status:** accepted (amended 2026-07-26)
**Status:** superseded by 2026-07-28-coarse-running-busy-signal
**Date:** 2026-07-11
**Area:** backend, frontend, protocol
**Related:** [Background work liveness spec](../specs/platform/background-work-liveness.md)
@@ -0,0 +1,74 @@
# ADR-2026-07-28-coarse-running-busy-signal: Default to Coarse Running Prompt Admission
**Status:** accepted
**Date:** 2026-07-28
**Area:** backend, frontend, protocol
## Context
ADR-0049 allowed a `RUNNING` session to accept input when Kandev inferred that
the foreground had yielded to recognized background work. In practice, ACP
providers do not report subagent and background-work lifecycles with enough
consistent identity, nesting, or terminal-state detail to make that inference a
reliable admission boundary. Misclassification can enable overlapping prompts,
show an incorrect background status, or leave a session transition waiting on
an event the provider never emits.
## Decision
Restore the conservative pre-ADR-0049 operator contract as the shipped default:
- Every `RUNNING` session rejects direct prompt admission and routes composer
input through the existing queued-message path.
- Every `RUNNING` session is exposed as `foreground_activity=generating`.
Settled sessions do not expose a background activity tier.
- `session.activity_changed`, `session.state_changed`, boot payloads, REST
session DTOs, and task aggregates follow the same coarse activity policy.
- The in-memory background-work tracker and adapter attestations remain in place
as accounting infrastructure. They may continue to supply
`active_subagent_count`, but they do not relax admission or select an
operator-visible background activity tier by default.
Retain ADR-0049's complete Claude Code behavior behind the default-off
`features.claudeBackgroundPromptHandoff` runtime toggle. The exception is
effective only for `claude-acp` sessions (plus the mock provider in E2E):
- adapter-attested Claude subagents, background shells, and Monitor watches may
expose the background activity tier;
- a generation-matched Claude foreground handoff may admit the next prompt
while the durable session remains `RUNNING`; and
- every other provider remains coarse even when the toggle is enabled.
The toggle is experimental, high risk, restart-required, and off in every
embedded profile. Missing provider identity fails closed. Its purpose is to let
designated contributors gather protocol fixtures and refine the behavior
without changing the release default.
## Consequences
Prompt admission is safe and consistent across ACP providers by default, and
the composer cannot advertise direct input that the coarse lifecycle considers
busy. An operator may need to queue a follow-up while a held-open foreground
turn waits on background work. Detached work that outlives a completed
foreground turn is not shown as a distinct background-running tier unless the
Claude experiment is deliberately enabled.
The opt-in path preserves a focused real-world test bed without exposing known
ACP lifecycle uncertainty to every user. Enabling it accepts the original
risks: missing, delayed, duplicated, or ID-less Claude lifecycle frames can
misclassify activity or promptability. The environment variable or persisted
runtime override provides a kill switch, but it does not make those signals
authoritative.
## Alternatives Considered
- **Revert ADR-0049 and all implementation commits.** Rejected because later
lifecycle hardening and accounting work spans many subsystems; removing it
immediately creates more release risk than disabling the policy boundary.
- **Disable only prompt admission.** Rejected because the UI would still
advertise direct input or a background state that no longer controls
admission.
- **Remove the fine-grained path completely.** Rejected because a default-off,
Claude-scoped experiment gives the original contributor a controlled way to
reproduce and improve all supported background modes without changing the
release default.
+2 -1
View File
@@ -56,7 +56,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0046 | [Settings route save coordinator](0046-settings-route-save-coordinator.md) | accepted | frontend | 2026-07-14 |
| 0047 | [Plugins read conversation content via a capability-gated Host RPC](0047-plugin-host-conversation-reads.md) | accepted | backend, protocol | 2026-07-21 |
| 0048 | [Plugins invoke a settings-selectable utility agent](0048-plugin-host-utility-agent-invoke.md) | accepted | backend, frontend, protocol | 2026-07-21 |
| 0049 | [Fine-grained foreground-idle busy signal](0049-fine-grained-foreground-idle-busy-signal.md) | accepted (amended 2026-07-26) | backend, frontend, protocol | 2026-07-11 |
| 0049 | [Fine-grained foreground-idle busy signal](0049-fine-grained-foreground-idle-busy-signal.md) | superseded by 2026-07-28-coarse-running-busy-signal | backend, frontend, protocol | 2026-07-11 |
| 0050 | [Plugins provide OIDC/SAML login via a capability-gated, host-minted session](0050-plugin-external-auth-capability.md) | accepted | backend, security, protocol | 2026-07-26 |
| 2026-07-23-opencode-review-evidence-trust | [Trusted OpenCode Review Evidence](2026-07-23-opencode-review-evidence-trust.md) | accepted | workflow, infra | 2026-07-23 |
| 2026-07-23-planner-direct-small-work | [Planner Direct Small Work](2026-07-23-planner-direct-small-work.md) | superseded by 2026-07-26 | workflow | 2026-07-23 |
@@ -83,3 +83,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-07-27-task-git-credential-policy | [Separate GitHub Automation From Task Git Credential Policy](2026-07-27-task-git-credential-policy.md) | accepted | backend, frontend, security | 2026-07-27 |
| 2026-07-27-legacy-add-branch-live-rescan | [Preserve Live Rescan for Legacy Add Branch](2026-07-27-legacy-add-branch-live-rescan.md) | accepted | backend, protocol | 2026-07-27 |
| 2026-07-28-visible-wip-overflow-queues | [Separate Visible Queueing From WIP Admission](2026-07-28-visible-wip-overflow-queues.md) | proposed | backend, frontend, protocol, workflow | 2026-07-28 |
| 2026-07-28-coarse-running-busy-signal | [Restore Coarse Running Prompt Admission](2026-07-28-coarse-running-busy-signal.md) | accepted | backend, frontend, protocol | 2026-07-28 |
@@ -0,0 +1,75 @@
---
spec: docs/specs/platform/background-work-liveness.md
created: 2026-07-28
status: completed
---
# Implementation Plan: Coarse Running Default with Claude Experiment
## Overview
Restore the pre-ADR-0049 admission and display contract as the default while
leaving ADR-0049's complete Claude behavior available behind a default-off,
high-risk runtime flag. Pin both sides of the contract with focused backend
tests and desktop/mobile browser coverage, then update the public configuration
and API documentation.
## Backend
### Admission and exposed activity
- Update `apps/backend/internal/orchestrator/task_operations.go` so
`checkSessionPromptable` rejects every `RUNNING` session with
`ErrAgentPromptInProgress`.
- Update `apps/backend/internal/orchestrator/turn_activity.go` so
`ForegroundActivity` and activity publications expose only the coarse
generating policy while the private tracker retains its fine-grained value.
- Update `publishTaskSessionStateChanged` in
`apps/backend/internal/orchestrator/event_handlers_streaming.go` to use the
same public policy and omit activity after `RUNNING`.
- Add `features.claudeBackgroundPromptHandoff` and require both that flag and an
exact `claude-acp` provider snapshot before the private tracker can relax
admission or expose the background tier. The mock provider follows this path
only for E2E coverage.
## Frontend
The existing composer, activity store, and status components already consume
the backend's `foreground_activity`. Add the flag to the shared feature shape;
the backend remains authoritative for whether a specific session is eligible.
## Tests
- **What:** a `RUNNING` background-idle session remains unpromptable.
**File:** `apps/backend/internal/orchestrator/foreground_busy_signal_test.go`.
**How:** drive the real tracker into background-idle, assert the private value
remains available, then assert the public admission and activity seams remain
coarse.
- **What:** live activity publications never advertise the dormant background
tier. **File:**
`apps/backend/internal/orchestrator/foreground_activity_signal_test.go`.
**How:** publish a background-idle transition through the recording event bus
and assert `generating`.
## E2E Tests
- **Scenario:** GIVEN a held-open background turn on desktop, WHEN the operator
submits during its background-idle window, THEN the composer queues the
message and remains generating across reload.
**File:** `apps/web/e2e/tests/chat/busy-signal.spec.ts`.
- **Scenario:** the same user outcome through the existing mobile composer
button on Pixel 5.
**File:** `apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts`.
- **Scenario:** with the flag explicitly enabled, the complete desktop and
mobile Claude-mock suite covers async subagents, detached work, foreground
precedence, reload hydration, and touch submission. Backend prompt-entry
tests cover every recognized Claude mode, including `run_in_background`
shells and Monitor.
No layout, navigation, scroll, or touch composition changes are required. Both
viewports reuse the shipped session composer and status surfaces.
## Implementation Waves
- [x] [Task 01: Restore coarse running policy](task-01-restore-coarse-running-policy.md)
- [x] [Task 02: Add Claude experiment flag](task-02-add-claude-experiment-flag.md)
@@ -0,0 +1,75 @@
---
id: "01-restore-coarse-running-policy"
title: "Restore coarse running policy"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/platform/background-work-liveness.md"
---
# Task 01: Restore coarse running policy
## Acceptance
- Every `RUNNING` session rejects direct prompt admission even when the private
tracker reports foreground-idle background work.
- Boot/REST/WS/task activity surfaces expose `generating` for `RUNNING` and do
not expose a settled background tier.
- Desktop and mobile composer flows queue input during a held-open background
turn and retain that behavior after a fresh reload.
## Verification
- `make -C apps/backend test`
- `cd apps && pnpm --filter @kandev/web e2e:run -- tests/chat/busy-signal.spec.ts -- --grep "held-open background turn remains busy"`
- `cd apps && pnpm --filter @kandev/web e2e:run -- tests/chat/mobile-busy-signal.spec.ts -- --grep "held-open background turn remains busy"`
- `make fmt`
- `make typecheck test lint`
## Files likely touched
- `apps/backend/internal/orchestrator/task_operations.go`
- `apps/backend/internal/orchestrator/turn_activity.go`
- `apps/backend/internal/orchestrator/event_handlers_streaming.go`
- `apps/backend/internal/orchestrator/foreground_busy_signal_test.go`
- `apps/backend/internal/orchestrator/foreground_activity_signal_test.go`
- `apps/web/e2e/tests/chat/busy-signal.spec.ts`
- `apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts`
- `docs/specs/platform/background-work-liveness.md`
- `docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md`
- `docs/decisions/2026-07-28-coarse-running-busy-signal.md`
- `docs/decisions/INDEX.md`
- `docs/public/tasks-and-workflows.md`
- `docs/public/websocket-api.md`
## Dependencies
None.
## Parallelism
Sequential. Admission and all activity surfaces share one policy boundary.
## Inputs
- Background work liveness spec: coarse admission and display scenarios.
- ADR-2026-07-28: retain dormant accounting while disabling policy use.
- Existing tracker and busy-signal tests around ADR-0049.
## Output contract
Report the root cause, files changed, RED/GREEN evidence, E2E results, required
verification results, commit receipt, remaining risks, and task/plan status.
## Results
- RED: targeted orchestrator tests failed because background-idle was admitted
and exported as `background`; desktop and mobile E2E timed out waiting for the
coarse `generating` value.
- GREEN: targeted orchestrator and mock-agent regressions passed.
- GREEN: desktop Chromium and mobile Chrome held-open background scenarios
queued input and remained generating across reload.
- GREEN: `make fmt`.
- GREEN: `make typecheck test lint`.
- GREEN: public documentation validation tests and source validation.
@@ -0,0 +1,74 @@
---
id: "02-add-claude-experiment-flag"
title: "Add Claude background prompt handoff experiment"
status: completed
wave: 2
depends_on: ["01-restore-coarse-running-policy"]
plan: "plan.md"
spec: "../../specs/platform/background-work-liveness.md"
---
# Task 02: Add Claude background prompt handoff experiment
## Acceptance
- `features.claudeBackgroundPromptHandoff` is experimental, high risk,
restart-required, and off in prod, dev, and E2E unless explicitly overridden.
- Flag off preserves Task 01's coarse admission and activity behavior.
- Flag on restores every ADR-0049 background mode for `claude-acp` sessions.
- Flag on never relaxes admission or activity for another real provider.
- Desktop and mobile Playwright cover both the default and opt-in contracts.
## Verification
- Targeted config, runtime-flag, orchestrator, handler, and frontend tests.
- Targeted desktop and mobile Playwright for coarse and experimental behavior.
- `make fmt`
- `make typecheck test lint`
- Public documentation validation.
## Files likely touched
- `apps/backend/internal/profiles/profiles.yaml`
- `apps/backend/internal/common/config/config.go`
- `apps/backend/internal/runtimeflags/`
- `apps/backend/internal/backendapp/orchestrator.go`
- `apps/backend/internal/orchestrator/`
- `apps/backend/internal/task/handlers/message_handlers.go`
- `apps/web/lib/state/slices/features/`
- `apps/web/e2e/tests/chat/`
- `docs/decisions/2026-07-28-coarse-running-busy-signal.md`
- `docs/specs/platform/background-work-liveness.md`
- `docs/public/configuration.md`
- `docs/public/tasks-and-workflows.md`
- `docs/public/websocket-api.md`
## Dependencies
Task 01 supplies the safe default and retains the internal tracker.
## Parallelism
Sequential. The config, admission, publication, and client contracts must use
one policy and be proven together.
## Verification Results
- `make fmt` — passed.
- `make typecheck test lint` — passed:
- Go suite, including orchestrator concurrency tests.
- Web: 922 files passed; 7,050 tests passed; 4 skipped.
- CLI: 30 files and 280 tests passed.
- Backend, web, and harness lint passed.
- `cd apps && pnpm --filter @kandev/web e2e:run --host tests/chat/busy-signal.spec.ts`
— default-off desktop
cases passed; the first opt-in run found and drove the persisted provider
discriminator fix (`agent_name`, not the database UUID in `agent_id`).
- `cd apps && pnpm --filter @kandev/web e2e:run --host --no-build tests/chat/busy-signal.spec.ts -- --grep "Claude background prompt handoff experiment"`
— 2 passed.
- `cd apps && pnpm --filter @kandev/web e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-busy-signal.spec.ts`
— default-off mobile passed; the opt-in test exposed a desktop-keyboard test
helper mismatch.
- `cd apps && pnpm --filter @kandev/web e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-busy-signal.spec.ts -- --grep "background-only work"`
— 1 passed after switching the mobile flow to touch submission.
- Public documentation validation passed as part of `make test`.
+3 -1
View File
@@ -118,6 +118,7 @@ The launcher starts `agentctl`, performs a one-time nonce handshake, and supplie
| `features.office` | `KANDEV_FEATURES_OFFICE` | `false` in production | Experimental Office UI, routes, services, and automation. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. |
| `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. |
| `features.claude_background_prompt_handoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | `false` | High-risk experiment that lets Claude Code accept a new prompt after its foreground yields while adapter-attested background work remains active. Other providers keep the coarse busy gate. |
Do not infer security from `auth.jwtSecret`: setting it currently does not turn the local server into an authenticated public service. Office's JWT key has a narrower, active purpose. Store both active secrets and third-party API keys in your deployment secret manager; never commit them in `config.yaml`.
@@ -294,11 +295,12 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym
| `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. |
| `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only. |
| `features.claudeBackgroundPromptHandoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | off | High-risk Claude Code experiment that exposes recognized background-only activity and admits a successor prompt. |
| `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` (also locked by explicit legacy/debug-message vars) | off | High-risk diagnostic endpoints and ACP frame logging. |
UI changes are persisted in the database and require a restart. An explicitly set environment value wins and locks the UI control. Otherwise a database override wins over the embedded profile/default. Resetting a toggle removes its database override.
The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar remains opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment.
The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment.
## Credentials and product settings
+2 -1
View File
@@ -100,9 +100,10 @@ Released builds can lag this page. Check **Settings > System > About** or `kande
| Statistics and host resource metrics | Supported | Workspace stats and on-demand CPU/memory/disk display are shipped. Execution-environment metrics depend on executor/runtime support. |
| SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots under the data root. Retention, free space, file permissions, and off-host copies remain operator responsibilities. |
| Update checking and applying updates | Dependency-bound | Checking is shipped. Package-manager installs update through their package manager; backend self-update is service-install-specific; desktop updater support varies by artifact/platform. |
| Feature Toggle settings | Supported | The page exposes Office, Plugins, and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. |
| Feature Toggle settings | Supported | The page exposes Office, Plugins, Claude background prompt handoff, and Debug overrides, environment locks, and restart-required state. Claude background handoff and Debug are high risk. A toggle being present does not promote its target feature to supported. |
| Office mode | In progress | Production defaults `KANDEV_FEATURES_OFFICE=false`; development and E2E enable it for implementation/testing. Its routes, agents, labels, documents, dependencies, routines, skills, routing, costs, and approvals may change between releases. |
| Plugin system | In progress | Production defaults `KANDEV_FEATURES_PLUGINS=false`; development and E2E enable it for implementation/testing. Loaded plugin code runs with backend privileges; manifest schema, capabilities, and host API may change between releases. |
| Claude background prompt handoff | Experimental | Defaults `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF=false` in every profile. When explicitly enabled, only Claude Code sessions can expose recognized background-only activity and accept a successor prompt. ACP lifecycle gaps can still misclassify activity or overlap prompts. |
| Mock providers and E2E-only routes | Internal | They are selected only by development/test runtime profiles and must not be exposed as product integrations. |
## Publication boundary
+1
View File
@@ -267,6 +267,7 @@ These metrics are lightweight UI observability. Set alerts, retention, CPU/memor
- **Office mode** — experimental, medium risk, and off in the production profile by default.
- **App status bar** — stable, low risk, and off in the production profile by default. Enabling it adds the desktop/tablet bar and phone Status entry after restart; disabling it again does not stop connections, metrics collection requested by other clients, or plugins.
- **Claude background prompt handoff** — experimental, high risk, and off in every profile by default. Enabling it lets Claude Code accept another prompt after its foreground yields while recognized async subagent, `run_in_background` shell, or Monitor work remains active. ACP lifecycle gaps can misclassify activity or overlap prompts; use it only for controlled testing.
- **Debug mode** — high risk; enables diagnostic endpoints and agent-message logging that can contain sensitive content.
Each requires restart. A value supplied explicitly by its environment variable locks the UI control; the debug toggle is also locked by explicit legacy/debug-message environment variables. Otherwise the UI stores an override in the database. The page can request restart only when the native local supervisor is available. A normal Unix `kandev` terminal launch is supervised; Desktop, a service, a container, a directly started backend, a deploy preview, or Windows requires a manual application restart.
+1 -1
View File
@@ -144,7 +144,7 @@ Pending inline comments are scoped to the current review session but persist onl
## Generate a walkthrough
Select **Walkthrough** from Changes or Review. Kandev sends the built-in `changes-walkthrough` prompt to the active session. If the agent is actively generating, the request queues; if it is idle — or only waiting on background work it spawned — it starts a new turn immediately. The agent must have task MCP and must call `show_walkthrough_kandev` with an ordered list of file and line anchors.
Select **Walkthrough** from Changes or Review. Kandev sends the built-in `changes-walkthrough` prompt to the active session. If the agent is actively generating, the request queues; if it is idle, it starts a new turn immediately. A running Claude Code session that is only waiting on recognized background work also starts immediately when the high-risk **Claude background prompt handoff** experiment is enabled; the default behavior queues it. The agent must have task MCP and must call `show_walkthrough_kandev` with an ordered list of file and line anchors.
<DocsVideo
webm="./media/feature-guides/code-walkthrough.webm"
+13 -8
View File
@@ -133,13 +133,14 @@ A task created with **Create without starting agent** opens in a prepared workbe
If the selected profile is unhealthy or incompatible with the executor, fix that configuration before launch. Starting an agent is separate from moving the task through its workflow; entry actions and turn-complete transitions can move or restart work afterward.
When a session shows **Working in background**, its foreground turn has ended
but recognized child work is still live. You can send a follow-up without
waiting for that work to finish. Providers with an attested handoff, including
Claude ACP, receive the follow-up immediately while the child may continue
streaming. While the session still shows **Generating**, the provider retains
foreground ownership and Kandev keeps another message queued until that
ownership is released.
By default, a running session keeps the coarse **Generating** state and queues
another message even if Kandev detects background work. Operators can opt into
the high-risk **Claude background prompt handoff** feature toggle for controlled
testing. With that experiment enabled, a Claude Code session shows **Working in
background** after its foreground yields while a recognized async subagent,
`run_in_background` shell, or Monitor remains active. A follow-up is then sent
immediately and the child may continue streaming. Other providers and
foreground-generating Claude turns retain the coarse queueing behavior.
## Find and organize tasks
@@ -296,7 +297,11 @@ To restore a task, open **List**, enable **Show archived**, and choose unarchive
Delete is permanent. If **Also delete _N_ subtasks** is left unchecked, direct children become root tasks. If selected, descendants are deleted. The operation cannot be undone, and executor cleanup follows the same asynchronous, best-effort rules as archive.
When a task still has an agent generating or background work outstanding, the confirmation dialog adds a still-working warning: proceeding discards work that is in progress. Delete always shows this warning; archive shows it only when the archive confirmation is enabled.
When a task still has a `RUNNING` agent, the confirmation dialog adds a
still-working warning: proceeding discards work that is in progress. Delete
always shows this warning; archive shows it only when the archive confirmation
is enabled. Best-effort detached-work accounting does not independently keep a
settled task in the still-working state.
## Troubleshooting
+14 -8
View File
@@ -702,14 +702,20 @@ system.job.update
Current lifecycle code broadcasts `session.turn.started` and `session.turn.completed` globally even though their payloads identify a session. `task.subscribe` does not change this global routing.
`session.activity_changed` carries `task_id`, `session_id`, `foreground_activity`,
and `active_subagent_count`. The activity is `generating`, `background`, or
`null`; the count is always an integer and is zero when no adapter-attested
subagent is live for that session. Task lifecycle notifications expose the same count
aggregated across
the task's sessions as `active_subagent_count`. Count-only changes may emit
`session.activity_changed` and `task.updated` even when the foreground activity
and coarse lifecycle state do not change.
`session.activity_changed` carries `task_id`, `session_id`,
`foreground_activity`, and `active_subagent_count`. The activity is
`generating` while the session is `RUNNING` and otherwise clears with the
coarse lifecycle by default; background-work accounting does not expose a
separate activity tier. When
`KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF=true`, a persisted Claude
Code session may instead expose `background` while its foreground has yielded
and adapter-attested async subagent, background shell, or Monitor work remains
active. Other providers remain coarse. The count is always an integer and is
zero when no adapter-attested subagent is live for that session. Task lifecycle
notifications expose the same count aggregated across the task's sessions as
`active_subagent_count`. Count-only changes may emit
`session.activity_changed` and `task.updated` even when the coarse lifecycle
state does not change.
Office workspace notifications are also global; clients filter on `workspace_id`:
+86 -189
View File
@@ -1,7 +1,7 @@
---
status: shipped
created: 2026-07-21
updated: 2026-07-26
updated: 2026-07-28
owner: kandev
---
@@ -9,228 +9,125 @@ owner: kandev
## Why
Operators need to know whether an agent is actively generating, is available
for another prompt while recognized work continues, or has finished. Collapsing
those conditions into a coarse running state both hides active background work
and incorrectly prevents prompt delivery.
ACP providers do not yet expose a consistent, accountable lifecycle for
subagents and other background work. Kandev can account for recognized work,
but that best-effort signal is not reliable enough to decide whether a
`RUNNING` agent can safely receive another prompt by default. Prompt admission
and operator-visible busy state therefore use the durable coarse session
lifecycle unless a deployment deliberately enables the Claude-only experiment.
## What
- A session distinguishes foreground generation from recognized background work.
Foreground activity takes precedence over background work; neither condition
may appear as done.
- A foreground-idle session with recognized background work accepts a new
prompt. A session with foreground activity continues to reject it.
- When a provider-attested, generation-bearing foreground-idle boundary also
guarantees prompt handoff, the next human prompt reaches the provider
immediately even if the prior prompt RPC remains open for detached work.
Synthetic wakeups remain serialized behind the owning RPC. Providers that do
not attest handoff keep the follow-up queued until they release ownership.
- Only adapter-attested workloads relax prompt admission. An adapter opts in a
workload only when Kandev has fixture-tested both its launch and accountable
terminal lifecycle. Claude subagents, background shells, and Monitor watches
are initially supported. Codex and other ACP agents remain conservatively
foreground-busy until their providers reliably emit the terminal lifecycle
Kandev needs.
- The runtime retains one registration per live subagent and derives an active
subagent count from those registrations. Background shells and Monitor
watches contribute to background liveness but not to the subagent count.
- Task and session status surfaces distinguish generating,
background-running, and done with text or accessible labels in addition to
color. The compact sidebar uses the same dashed spinner for both running
states: yellow for foreground generation and violet for background work, with
a distinct background-work tooltip and accessible label. A pending
permission request takes precedence over a pending clarification request,
which in turn takes precedence over the work-state indicator for a current,
input-capable session only; stale pending flags on starting or terminal
sessions do not override the work-state indicator.
- A task aggregates its sessions with most-active-wins: generating outranks
background-running, which outranks existing settled-state rendering.
- A terminal asynchronous launch result closes its tool card but does not end
the launched workload. Work remains live until accountable completion or
execution teardown.
- Every `RUNNING` session is busy, rejects direct prompt admission, and routes
composer input through the queued-message path by default.
- Every `RUNNING` session is shown as generating. Background-work accounting
does not select a separate operator-visible activity tier by default.
- A settled session follows its coarse state and does not remain visually busy
solely because detached work is still registered.
- The runtime retains one registration per recognized live subagent and derives
`active_subagent_count` from those registrations. Background shells and
Monitor watches may remain internally tracked but do not contribute to the
subagent count.
- Adapter attestation is accounting evidence only unless the
`features.claudeBackgroundPromptHandoff` experiment is enabled for a
`claude-acp` session.
- When that experiment is enabled, all Claude modes covered by ADR-0049 retain
their fine-grained behavior: subagents, `run_in_background` shells, and
Monitor watches may expose background activity and a generation-matched
foreground handoff may admit the next prompt.
- Non-Claude providers remain coarse even when the experiment is enabled.
- The experiment is off in every embedded profile, high risk,
restart-required, and available through
`KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF`.
- Terminal execution teardown retires every tool-ownership and background-work
entry owned by that execution. It releases the whole session activity record
when no successor execution or in-flight prompt/dispatch token still owns it.
- The delete confirmation warns when a task has foreground or recognized
background work. Archive uses the same warning only when archive confirmation
is enabled.
## API surface
- Session records and boot payloads expose `foreground_activity` as
`generating`, `background`, or absent when no fine-grained activity is known.
`background` can be present after the coarse session state settles.
- A `RUNNING` session record, boot payload, activity notification, or state
notification exposes `foreground_activity=generating` by default. An
eligible session in the enabled Claude experiment may expose `background`.
- Settled session records omit `foreground_activity`; task records aggregate
only coarse running activity.
- `session.activity_changed` carries `task_id`, `session_id`,
`foreground_activity`, and `active_subagent_count`. Its activity is
`generating` while the session is running; count-only changes may publish
without an activity-tier change.
- Session records, boot payloads, activity/state notifications, task records,
and `task.updated` always expose `active_subagent_count` as an integer. It is
zero when no adapter-attested subagent is live and may be positive while
`foreground_activity` is either `generating` or `background`.
- `session.activity_changed` publishes the changed fine-grained session value
and active subagent count; a count-only transition is publishable even when
the activity tier does not change. `session.state_changed` carries both with
coarse state changes.
- Task records and `task.updated` carry the most-active-wins
`foreground_activity` aggregate and the sum of active subagents across its
sessions. A task update is emitted when either aggregate changes, including a
generating-to-background or count-only transition with no coarse state
change.
and `task.updated` expose `active_subagent_count` as an integer. It is zero
when no adapter-attested subagent is live.
## State machine
| State | Entry | Exit |
| Coarse state | Prompt admission | Operator activity |
| --- | --- | --- |
| generating | A prompt is claimed or dispatched, or known top-level foreground work emits output. | The current prompt yields or completes, unless more foreground activity arrives. |
| background-running | The foreground is idle and one or more recognized workloads remain registered. | A foreground prompt/output takes precedence, or the final workload completes or is torn down. |
| idle/done | Neither foreground ownership nor recognized background work remains. | A foreground prompt/output or recognized workload begins. |
| `RUNNING` | Queue/reject by default; enabled Claude handoff may accept | generating by default; enabled Claude may show background |
| `WAITING_FOR_INPUT`, `COMPLETED`, or `IDLE` | Accept | omitted |
| `STARTING`, `CREATED`, `FAILED`, or `CANCELLED` | Reject as not promptable | omitted |
Prompt admission is atomic: only one prompt can claim foreground ownership for
a session. Delayed release or completion from an earlier prompt cycle cannot
mutate a later accepted claim. Prompt delivery is serialized per agent
execution except for one generation-matched human ownership transfer at an
adapter-attested provider handoff. The transfer finalizes the earlier lifecycle
buffer/completion owner before the successor starts; synthetic wakeups cannot
consume it, and a delayed response from the earlier RPC cannot finalize the
successor. Prompt-end cleanup for that successor excludes predecessor background
work and its nested tool/Monitor lineage, while explicit cancellation and session
replacement still clear all ownership.
The background-work tracker may still transition internally between foreground
ownership and recognized background liveness. Those transitions do not change
the table above.
## Failure modes
- An unknown activity value for an in-flight `RUNNING` session is rendered as
generating, not done.
- A foreground-idle signal without a nonzero matching prompt generation and
adapter-attested handoff capability does not relax transport serialization.
The session remains visibly foreground-owned and the follow-up remains
queued rather than being reported as delivered.
- A normalized tool-card shape cannot attest to background capability. An
unsupported adapter that emits a subagent-looking card remains foreground-busy
until its launch, yield, and terminal lifecycle are explicitly supported.
- The development/E2E mock provider may replay captured Claude lifecycle
metadata through the same attested path. This is a deterministic test seam,
not an additional production-agent capability.
- Codex child thread/session identity may correlate launch and activity cards
for presentation, but Codex subagents do not attest background liveness.
Observed Codex ACP streams can omit accountable terminal child activity, so a
started card must not create a live registration or nonzero subagent count.
- Tool-call ownership is established by the initial call and retained across
incremental updates from the same execution. Ownership includes the execution
identity because provider-local tool-call IDs may be reused after rotation.
An update with unknown ownership preserves the current activity; missing
parent metadata is not evidence that background-child work became foreground
work.
- A task aggregate that cannot be recomputed preserves its last-known value
rather than publishing a spurious done reading.
- When provider completion identifies a workload, only that registration is
retired; duplicate completion is harmless. An uncorrelated completion retires
only one outstanding registration and leaves an ambiguous remainder live.
- With the experiment off, missing, delayed, duplicated, or provider-specific
background lifecycle frames cannot make a `RUNNING` session promptable.
- With the experiment on, only adapter-attested Claude lifecycle frames can
relax the gate. Missing provider identity and every non-Claude provider fail
closed.
- A normalized tool-card shape cannot attest to promptability.
- Codex child thread/session identity and Claude task notifications may be used
for presentation or accounting, but neither changes the coarse admission
rule.
- Count drift is prevented by deriving `active_subagent_count` from the live
registration map rather than maintaining an independent counter.
- Execution completion, stop, failure, cancellation, crash cleanup, forced
cleanup, and session removal retire all activity owned by that execution.
Cleanup is idempotent and must preserve activity already claimed by a
successor on the same session. Session removal quiesces any still-live
lifecycle execution and rejects deletion if that stop fails, except when the
runtime reports that the exact execution is already absent. Per-session
activity validation and delivery remain ordered across record replacement;
each publication snapshots its activity value and active-subagent count
together. Per-task publication is FIFO: a newly computed activity value must
not be published ahead of an earlier value for the same task, and a detached
predecessor record or stale publication must not overwrite a newer session or
task aggregate.
cleanup, and session removal retire activity owned by that execution.
## Persistence guarantees
Fine-grained activity is in memory and is authoritative only while the owning
agent execution remains connected. A backend or agent-execution restart does
not reconstruct detached work or active subagent counts; it must not preserve a
stale live reading. Counts are derived from the live registration map rather
than persisted or incremented independently. Execution teardown releases the
record when it has no successor owner; completed session history does not retain
an empty activity record. Durable coarse state continues to survive as before.
Background-work accounting is in memory and authoritative only while the owning
agent execution remains connected. A restart does not reconstruct detached work
or active subagent counts. Durable coarse session state remains the source of
truth for prompt admission and operator-visible activity.
## Scenarios
- **GIVEN** a foreground-idle session with a recognized background workload,
**WHEN** the operator sends a prompt, **THEN** the prompt is accepted while
the status remains background-running until foreground activity begins.
- **GIVEN** an attested provider keeps a prompt RPC open for a detached child
after emitting foreground-idle for the matching generation, **WHEN** the
operator sends a follow-up, **THEN** that human prompt reaches the provider
immediately and the detached child may continue streaming.
- **GIVEN** a synthetic wakeup is waiting when an attested human handoff occurs,
**WHEN** a human follow-up is submitted, **THEN** the human prompt inherits
ownership and the wakeup remains serialized until the human turn completes.
- **GIVEN** predecessor background tools or Monitors are still live after the
follow-up completes, **WHEN** the successor runs prompt-end cleanup, **THEN**
those predecessor-owned streams remain tracked until their authoritative
terminal event, explicit cancellation, or session replacement.
- **GIVEN** the provider has not authoritatively released the foreground,
**WHEN** the operator submits a follow-up, **THEN** Kandev reports it as
queued and does not send it concurrently.
- **GIVEN** the experiment is off and a `RUNNING` session's tracker reports
foreground-idle with recognized background work, **WHEN** the operator
submits a prompt, **THEN** Kandev queues it.
- **GIVEN** the experiment is on for a `claude-acp` session and its tracker
reports adapter-attested background work plus a generation-matched foreground
handoff, **WHEN** the operator submits a prompt, **THEN** Kandev dispatches the
successor without queueing it.
- **GIVEN** the experiment is on for any non-Claude provider, **WHEN** the same
tracker-shaped activity is observed, **THEN** Kandev still queues the prompt.
- **GIVEN** the same session is rendered or freshly reloaded, **WHEN** its
activity is serialized, **THEN** it is shown as generating rather than
background-running.
- **GIVEN** two adapter-attested subagents and one background shell are live,
**WHEN** the session is serialized or publishes an activity update, **THEN**
`active_subagent_count` is two while all three workloads still contribute to
background liveness.
- **GIVEN** one generating session with one live subagent and another session
with two live subagents, **WHEN** the task is serialized, **THEN** its
`foreground_activity` is generating and its `active_subagent_count` is three.
- **GIVEN** a Codex subagent launch card without an accountable terminal
lifecycle, **WHEN** its prompt remains in flight or later settles, **THEN**
Kandev may render the card but reports no recognized background work and the
active subagent count remains zero.
- **GIVEN** an unsupported ACP adapter emits a normalized subagent-shaped tool
card, **WHEN** its prompt remains in flight, **THEN** Kandev reports the
foreground as generating and the active subagent count remains zero.
- **GIVEN** a task with one generating session and one background-running
session, **WHEN** its aggregate is rendered, **THEN** it shows generating.
- **GIVEN** a task with no generating session and one background-running
session, **WHEN** its aggregate is rendered on a board, list, graph, header,
or sidebar, **THEN** it does not show done.
- **GIVEN** a detached workload launch completes, **WHEN** no workload terminal
signal has arrived, **THEN** the session stays background-running.
- **GIVEN** a child tool call was attributed to recognized background work,
**WHEN** an incremental update temporarily omits its parent metadata,
**THEN** the session remains background-running.
- **GIVEN** a freshly loaded page or a second browser tab, **WHEN** a connected
session has current background activity, **THEN** its task and session
surfaces show background-running without waiting for a transition.
- **GIVEN** an activity transition for a task, **WHEN** a later transition is
computed before earlier publication completes, **THEN** observers never see
the later value followed by the stale earlier value.
**WHEN** activity is serialized, **THEN** `active_subagent_count` is two while
the session's operator activity still follows its coarse state.
- **GIVEN** the session leaves `RUNNING`, **WHEN** its DTO or task aggregate is
serialized, **THEN** foreground activity is omitted even if detached work
remains registered.
- **GIVEN** an execution terminates with orphaned tool ownership and background
work and no successor exists, **WHEN** teardown runs, **THEN** all owned state
and the session activity record are released.
- **GIVEN** execution B has claimed the same session before delayed teardown for
execution A, **WHEN** A is retired, **THEN** B's prompt claim, prompt
generation, foreground state, tools, and background work remain unchanged.
- **GIVEN** execution B reuses a tool-call ID left nonterminal by execution A,
**WHEN** B emits an ownership-incomplete update, **THEN** A's classification
is not observable as ownership evidence for B.
- **GIVEN** execution A's cleanup publication is delayed until after execution B
claims the session, **WHEN** the delayed publication resumes, **THEN** it
cannot recreate A's record or overwrite B's activity.
- **GIVEN** a task with active foreground or recognized background work,
**WHEN** the operator deletes it, **THEN** the confirmation warns that work is
still in progress.
- **GIVEN** a task with active foreground or recognized background work,
**WHEN** the operator archives it with archive confirmation enabled, **THEN**
the archive confirmation carries the same warning.
- **GIVEN** archive confirmation is disabled, **WHEN** the operator archives a
task with active foreground or recognized background work, **THEN** no archive
dialog is shown.
work, **WHEN** teardown runs, **THEN** its owned accounting state is released.
## Out of scope
- Mid-turn steering for agents without concurrent-prompt capability.
- Reconstructing detached-work liveness after backend or agent-execution
restart.
- Rendering the active subagent count or individual subagent details in the UI.
- Changing Office autonomous-agent status vocabulary.
- Changing archive behavior when the operator has disabled archive
confirmation.
- Reconstructing detached-work liveness after restart.
- Rendering active subagent counts or individual subagent details in the UI.
## Decision record
[ADR 0049 — Fine-grained foreground-idle busy signal](../../decisions/0049-fine-grained-foreground-idle-busy-signal.md) owns the architectural reasoning: ownership and admission rules, provider attestation, and the per-task publication-order invariant.
[ADR-2026-07-28 — Restore Coarse Running Prompt Admission](../../decisions/2026-07-28-coarse-running-busy-signal.md)
supersedes the operator policy in
[ADR 0049 — Fine-grained foreground-idle busy signal](../../decisions/0049-fine-grained-foreground-idle-busy-signal.md).
## Implementation plan
[Background work liveness implementation plan](../../plans/background-work-liveness/plan.md)
[Coarse running busy signal fix plan](../../plans/coarse-running-busy-signal/plan.md)