Skip to content

Commit c61c449

Browse files
committed
feat(workflows): add controller conversation state handling
Add controllerConversationActive flag to prevent runner from overwriting input state during return-to-controller Expose controller info for deferred emission to enable return-to-controller during step execution
1 parent 81769a3 commit c61c449

5 files changed

Lines changed: 62 additions & 6 deletions

File tree

‎src/workflows/controller/view.ts‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,22 @@ export interface ControllerViewOptions {
2727
eventBus: WorkflowEventBus;
2828
}
2929

30+
export interface ControllerInfo {
31+
id: string;
32+
name: string;
33+
engine: string;
34+
model?: string;
35+
}
36+
3037
export interface ControllerViewResult {
3138
/** Whether controller view ran (false if skipped) */
3239
ran: boolean;
3340
/** Controller agent ID if ran */
3441
agentId?: string;
3542
/** Monitoring ID for log viewing */
3643
monitoringId?: number;
44+
/** Controller info for deferred emission (after workflow:started) */
45+
controllerInfo?: ControllerInfo;
3746
}
3847

3948
/**
@@ -65,20 +74,32 @@ export async function runControllerView(
6574
const definition = template.controller;
6675
debug('[ControllerView] Controller definition found: %s', definition.agentId);
6776

77+
// Find the controller agent from available agents (needed for both paths)
78+
const allAgents = await collectAgentDefinitions(cwd);
79+
const controller = allAgents.find(a => a.id === definition.agentId);
80+
6881
// Check if controller session already exists
6982
const existingConfig = await loadControllerConfig(cmRoot);
7083
if (existingConfig?.controllerConfig?.sessionId) {
7184
debug('[ControllerView] Controller session already exists, skipping init');
85+
86+
// Return controller info for deferred emission (after workflow:started)
87+
// This enables the 'c' key to return to controller even while step agent executes
88+
const controllerInfo = controller ? {
89+
id: definition.agentId,
90+
name: (controller.name as string | undefined) ?? controller.id,
91+
engine: existingConfig.controllerConfig.engine || 'unknown',
92+
model: existingConfig.controllerConfig.model,
93+
} : undefined;
94+
7295
// Transition to executing view with autonomous mode
7396
await setAutonomousMode(cmRoot, 'true');
7497
await setControllerView(cmRoot, false);
7598
emitter.setWorkflowView('executing');
76-
return { ran: false };
99+
return { ran: false, controllerInfo };
77100
}
78101

79-
// Find the controller agent from available agents
80-
const allAgents = await collectAgentDefinitions(cwd);
81-
const controller = allAgents.find(a => a.id === definition.agentId);
102+
// Validate controller agent exists (for init path)
82103
if (!controller) {
83104
debug('[ControllerView] Controller agent not found: %s', definition.agentId);
84105
throw new Error(`Controller agent not found: ${definition.agentId}`);

‎src/workflows/mode/mode.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { ModeProviders, ModeEvent, ModeEventListener } from './types.js';
1313
export class WorkflowMode {
1414
private _autoMode: boolean = false;
1515
private _paused: boolean = false;
16+
private _controllerConversationActive: boolean = false;
1617
private readonly providers: ModeProviders;
1718
private readonly listeners: Set<ModeEventListener> = new Set();
1819

@@ -34,6 +35,22 @@ export class WorkflowMode {
3435
return this._paused;
3536
}
3637

38+
/**
39+
* Whether controller conversation is active (return-to-controller)
40+
* When true, runner should skip processing to avoid overwriting controller input state
41+
*/
42+
get controllerConversationActive(): boolean {
43+
return this._controllerConversationActive;
44+
}
45+
46+
/**
47+
* Set controller conversation active state
48+
*/
49+
setControllerConversationActive(active: boolean): void {
50+
debug('[WorkflowMode] Controller conversation active: %s', active);
51+
this._controllerConversationActive = active;
52+
}
53+
3754
/**
3855
* Whether user input should be used (derived from autoMode and paused)
3956
*/

‎src/workflows/mode/types.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export interface WorkflowModeState {
1616

1717
/** Whether workflow is paused */
1818
paused: boolean;
19+
20+
/** Whether controller conversation is active (return-to-controller) */
21+
controllerConversationActive: boolean;
1922
}
2023

2124
/**

‎src/workflows/runner/core.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,13 @@ export async function handleState(
155155
callbacks: ModeHandlerCallbacks,
156156
fsmState: 'awaiting' | 'delegated'
157157
): Promise<void> {
158+
// Skip processing if controller conversation is active (return-to-controller)
159+
// This prevents overwriting controller's input state with step agent's state
160+
if (ctx.mode.controllerConversationActive) {
161+
debug('[Runner:core] Skipping handleState - controller conversation active');
162+
return;
163+
}
164+
158165
const machineCtx = ctx.machine.context;
159166
const stepIndex = machineCtx.currentStepIndex;
160167
const step = ctx.moduleSteps[stepIndex];

‎src/workflows/signals/handlers/return.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,14 @@ export async function handleReturnToControllerSignal(ctx: SignalContext): Promis
7676
const divider = formatUserInput('━━━ RETURNING TO CONTROLLER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
7777
AgentLoggerService.getInstance().write(config.monitoringId, `\n${divider}\n`)
7878

79+
// Mark controller conversation as active to prevent runner from overwriting input state
80+
ctx.mode.setControllerConversationActive(true)
81+
7982
debug('[ReturnToControllerSignal] Entering conversation loop')
8083

81-
// Inlined conversation loop (replaces runControllerConversation)
82-
await new Promise<void>((resolve, reject) => {
84+
try {
85+
// Inlined conversation loop (replaces runControllerConversation)
86+
await new Promise<void>((resolve, reject) => {
8387
const cleanup = () => {
8488
;(process as NodeJS.EventEmitter).off('workflow:input', onInput)
8589
;(process as NodeJS.EventEmitter).off('workflow:controller-continue', onContinue)
@@ -147,6 +151,10 @@ export async function handleReturnToControllerSignal(ctx: SignalContext): Promis
147151
;(process as NodeJS.EventEmitter).on('workflow:input', onInput)
148152
;(process as NodeJS.EventEmitter).on('workflow:controller-continue', onContinue)
149153
})
154+
} finally {
155+
// Clear controller conversation flag to allow runner to process again
156+
ctx.mode.setControllerConversationActive(false)
157+
}
150158

151159
debug('[ReturnToControllerSignal] Conversation ended, resuming workflow')
152160

0 commit comments

Comments
 (0)