fix: restore coarse running busy signal (#2023)
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user