Skip to content

Commit 2ab4f18

Browse files
committed
feat(workflows): add interactive step control for workflow execution
Implement interactive behavior resolution for workflow steps with 8 defined scenarios. Adds new `interactive` flag to step configuration to control waiting behavior (true=wait for input, false=auto-advance). Includes autonomous prompt loop handling for non-interactive steps in auto mode. The changes allow: - Controller-driven execution in auto mode - User-driven execution in manual mode - Fully autonomous execution for non-interactive steps - Proper handling of invalid configurations
1 parent 54b3a19 commit 2ab4f18

File tree

11 files changed

+309
-76
lines changed

11 files changed

+309
-76
lines changed
Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1 @@
1-
You are Test Agent 1.
2-
3-
Your task is to write a checkpoint directive to pause the workflow for human review.
4-
5-
Write the following JSON to the file `.codemachine/memory/directive.json`:
6-
7-
```json
8-
{
9-
"action": "checkpoint",
10-
"reason": "Test checkpoint from Agent 1 - pausing for human review"
11-
}
12-
```
13-
14-
After writing the file, say "Checkpoint directive written. Workflow should pause for review."
1+
Calculate 2+2= ? then say your name.
Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1 @@
1-
You are Test Agent 2. You will test parallel orchestration by running:
2-
3-
bun run dev run "frontend-dev 'say your name' & frontend-dev 'say your name' & frontend-dev 'say your name'"
4-
2- MUST modify .codemachine/memory/directive.json to make it exactly:
5-
{
6-
"action": "checkpoint",
7-
"reason": "Your plan needs modifications please check file .codemachine/memory/directive.json"
8-
}
9-
no more no less
10-
This tests the orchestrator with parallel execution (all agents run simultaneously).
1+
Calculate 4+4= ? then say your name.

src/workflows/runner/wait.ts

Lines changed: 124 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AgentLoggerService } from '../../agents/monitoring/index.js';
1010
import type { InputContext } from '../input/index.js';
1111
import { getUniqueAgentId } from '../context/index.js';
1212
import { runStepResume } from '../step/run.js';
13+
import { resolveInteractiveBehavior } from '../step/interactive.js';
1314
import type { RunnerContext } from './types.js';
1415

1516
export interface WaitCallbacks {
@@ -18,48 +19,76 @@ export interface WaitCallbacks {
1819

1920
/**
2021
* Handle waiting state - get input from provider
22+
*
23+
* Uses resolveInteractiveBehavior() for all 8 scenarios:
24+
* - Scenarios 1-4: Wait for controller/user input (shouldWait=true)
25+
* - Scenario 5: Run autonomous prompt loop (runAutonomousLoop=true)
26+
* - Scenario 6: Auto-advance to next step (queue exhausted after autonomous loop)
27+
* - Scenarios 7-8: Invalid cases forced to interactive:true (shouldWait=true)
2128
*/
2229
export async function handleWaiting(ctx: RunnerContext, callbacks: WaitCallbacks): Promise<void> {
2330
const machineCtx = ctx.machine.context;
2431

2532
debug('[Runner] Handling waiting state, autoMode=%s, paused=%s, promptQueue=%d items, queueIndex=%d',
2633
ctx.mode.autoMode, ctx.mode.paused, ctx.indexManager.promptQueue.length, ctx.indexManager.promptQueueIndex);
2734

28-
// Get provider from WorkflowMode (single source of truth)
29-
// WorkflowMode.getActiveProvider() automatically handles paused and autoMode state
30-
const provider = ctx.mode.getActiveProvider();
31-
if (ctx.mode.paused) {
32-
debug('[Runner] Workflow is paused, using user input provider');
33-
} else if (!ctx.mode.autoMode) {
34-
debug('[Runner] Manual mode, using user input provider');
35-
}
36-
3735
// Get queue state from session (uses indexManager as single source of truth)
3836
const session = ctx.getCurrentSession();
3937
const hasChainedPrompts = session
4038
? !session.isQueueExhausted
4139
: !ctx.indexManager.isQueueExhausted();
4240

43-
if (!ctx.mode.paused && !hasChainedPrompts && ctx.mode.autoMode) {
44-
// Check if we're resuming a step (sessionId exists but no completedAt)
45-
const stepData = await ctx.indexManager.getStepData(machineCtx.currentStepIndex);
46-
const isResumingStep = stepData?.sessionId && !stepData.completedAt;
41+
// Get current step and resolve interactive behavior
42+
const step = ctx.moduleSteps[machineCtx.currentStepIndex];
43+
const stepUniqueAgentId = getUniqueAgentId(step, machineCtx.currentStepIndex);
4744

48-
if (isResumingStep) {
49-
// Resuming incomplete step - let controller decide
50-
debug('[Runner] Resuming step in auto mode, letting controller handle it');
51-
} else {
52-
// No chained prompts, not paused, and in auto mode - auto-advance to next step
53-
debug('[Runner] No chained prompts (auto mode), auto-advancing to next step');
54-
await ctx.indexManager.stepCompleted(machineCtx.currentStepIndex);
55-
ctx.machine.send({ type: 'INPUT_RECEIVED', input: '' });
56-
return;
45+
// Resolve interactive behavior using single source of truth
46+
const behavior = resolveInteractiveBehavior({
47+
step,
48+
autoMode: ctx.mode.autoMode,
49+
hasChainedPrompts,
50+
stepIndex: machineCtx.currentStepIndex,
51+
});
52+
53+
debug('[Runner] Scenario=%d, shouldWait=%s, runAutonomousLoop=%s, wasForced=%s',
54+
behavior.scenario, behavior.shouldWait, behavior.runAutonomousLoop, behavior.wasForced);
55+
56+
// Handle Scenarios 7-8: interactive:false in manual mode
57+
// Behave like normal manual mode: ensure agent is awaiting and show prompt box
58+
if (behavior.wasForced) {
59+
ctx.emitter.logMessage(stepUniqueAgentId, 'Manual mode active. Waiting for your input to continue. Use auto mode for fully autonomous execution.');
60+
ctx.emitter.updateAgentStatus(stepUniqueAgentId, 'awaiting');
61+
}
62+
63+
// Handle Scenario 5: Fully autonomous prompt loop (interactive:false + autoMode + chainedPrompts)
64+
if (!ctx.mode.paused && behavior.runAutonomousLoop) {
65+
debug('[Runner] Running autonomous prompt loop (Scenario 5)');
66+
await runAutonomousPromptLoop(ctx);
67+
return;
68+
}
69+
70+
// Handle Scenario 6: Auto-advance (interactive:false + autoMode + no chainedPrompts)
71+
// This can happen when queue is exhausted after autonomous loop
72+
if (!ctx.mode.paused && !behavior.shouldWait && !behavior.runAutonomousLoop) {
73+
debug('[Runner] Auto-advancing to next step (Scenario 6)');
74+
if (session) {
75+
await session.complete();
5776
}
77+
ctx.emitter.updateAgentStatus(stepUniqueAgentId, 'completed');
78+
ctx.indexManager.resetQueue();
79+
await ctx.indexManager.stepCompleted(machineCtx.currentStepIndex);
80+
ctx.machine.send({ type: 'INPUT_RECEIVED', input: '' });
81+
return;
5882
}
5983

60-
// Build input context
61-
const step = ctx.moduleSteps[machineCtx.currentStepIndex];
62-
const stepUniqueAgentId = getUniqueAgentId(step, machineCtx.currentStepIndex);
84+
// Get provider from WorkflowMode (single source of truth)
85+
// WorkflowMode.getActiveProvider() automatically handles paused and autoMode state
86+
const provider = ctx.mode.getActiveProvider();
87+
if (ctx.mode.paused) {
88+
debug('[Runner] Workflow is paused, using user input provider');
89+
} else if (!ctx.mode.autoMode) {
90+
debug('[Runner] Manual mode, using user input provider');
91+
}
6392

6493
// Get queue state from session if available, otherwise from indexManager
6594
const queueState = session
@@ -131,6 +160,76 @@ export async function handleWaiting(ctx: RunnerContext, callbacks: WaitCallbacks
131160
}
132161
}
133162

163+
/**
164+
* Run autonomous prompt loop (Scenario 5)
165+
*
166+
* Automatically sends the next chained prompt without controller/user involvement.
167+
* Each prompt runs through the state machine naturally - when it completes,
168+
* handleWaiting is called again and this function sends the next prompt.
169+
*
170+
* Used when interactive:false + autoMode + hasChainedPrompts.
171+
*/
172+
async function runAutonomousPromptLoop(ctx: RunnerContext): Promise<void> {
173+
const machineCtx = ctx.machine.context;
174+
const stepIndex = machineCtx.currentStepIndex;
175+
const step = ctx.moduleSteps[stepIndex];
176+
const uniqueAgentId = getUniqueAgentId(step, stepIndex);
177+
const session = ctx.getCurrentSession();
178+
179+
// Check if queue is exhausted
180+
const isExhausted = session
181+
? session.isQueueExhausted
182+
: ctx.indexManager.isQueueExhausted();
183+
184+
if (isExhausted) {
185+
// All prompts sent - complete step and advance to next
186+
debug('[Runner:autonomous] Queue exhausted, completing step %d', stepIndex);
187+
ctx.emitter.updateAgentStatus(uniqueAgentId, 'completed');
188+
ctx.indexManager.resetQueue();
189+
await ctx.indexManager.stepCompleted(stepIndex);
190+
ctx.machine.send({ type: 'INPUT_RECEIVED', input: '' });
191+
return;
192+
}
193+
194+
// Get next prompt
195+
const nextPrompt = ctx.indexManager.getCurrentQueuedPrompt();
196+
if (!nextPrompt) {
197+
// No more prompts - complete step and advance
198+
debug('[Runner:autonomous] No more prompts, completing step %d', stepIndex);
199+
ctx.emitter.updateAgentStatus(uniqueAgentId, 'completed');
200+
ctx.indexManager.resetQueue();
201+
await ctx.indexManager.stepCompleted(stepIndex);
202+
ctx.machine.send({ type: 'INPUT_RECEIVED', input: '' });
203+
return;
204+
}
205+
206+
// Send the next prompt
207+
const chainIndex = ctx.indexManager.promptQueueIndex;
208+
debug('[Runner:autonomous] Sending prompt %d: %s...', chainIndex, nextPrompt.content.slice(0, 50));
209+
210+
// Advance queue
211+
if (session) {
212+
session.advanceQueue();
213+
} else {
214+
ctx.indexManager.advanceQueue();
215+
}
216+
217+
// Track chain completion
218+
await ctx.indexManager.chainCompleted(stepIndex, chainIndex);
219+
220+
// Resume step with the prompt - when it completes, state machine will
221+
// transition back to awaiting and handleWaiting will be called again
222+
ctx.machine.send({ type: 'RESUME' });
223+
await runStepResume(ctx, {
224+
resumePrompt: nextPrompt.content,
225+
resumeMonitoringId: machineCtx.currentMonitoringId,
226+
source: 'controller',
227+
});
228+
// After runStepResume completes, machine goes back to awaiting state
229+
// and handleWaiting will be called again - it will detect Scenario 5
230+
// and call this function again to send the next prompt
231+
}
232+
134233
/**
135234
* Handle resume with input - delegates to step/run.ts
136235
*/

src/workflows/step/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@ export { selectEngine, EngineAuthCache, authCache } from './engine.js';
1212
export { beforeRun, afterRun, cleanupRun, type BeforeRunOptions, type AfterRunResult } from './hooks.js';
1313
export { runStepFresh, runStepResume, type RunStepOptions, type RunStepResult } from './run.js';
1414
export { shouldSkipStep, logSkipDebug, type ActiveLoop, type SkipCheckOptions } from './skip.js';
15+
export {
16+
resolveInteractiveBehavior,
17+
type InteractiveBehavior,
18+
type InteractiveScenario,
19+
type ResolveInteractiveOptions,
20+
} from './interactive.js';

src/workflows/step/interactive.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Interactive Behavior Resolution
3+
*
4+
* Single source of truth for determining step interactive behavior.
5+
* Handles all 8 scenarios defined in the interactive flag specification.
6+
*
7+
* VALID SCENARIOS:
8+
* | # | interactive | autoMode | chainedPrompts | Behavior |
9+
* |---|-------------|----------|----------------|---------------------------------------------|
10+
* | 1 | true | true | yes | Controller drives with prompts |
11+
* | 2 | true | true | no | Controller drives single step |
12+
* | 3 | true | false | yes | User drives with prompts |
13+
* | 4 | true | false | no | User drives each step |
14+
* | 5 | false | true | yes | FULLY AUTONOMOUS - auto-send ALL prompts |
15+
* | 6 | false | true | no | Auto-advance to next step |
16+
*
17+
* INVALID SCENARIOS (force interactive:true + log warning):
18+
* | # | interactive | autoMode | chainedPrompts | Handling |
19+
* |---|-------------|----------|----------------|---------------------------------------------|
20+
* | 7 | false | false | yes | Force interactive:true, warn, -> case 3 |
21+
* | 8 | false | false | no | Force interactive:true, warn, -> case 4 |
22+
*/
23+
24+
import { debug } from '../../shared/logging/logger.js';
25+
import type { ModuleStep } from '../templates/types.js';
26+
27+
export type InteractiveScenario = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
28+
29+
export interface InteractiveBehavior {
30+
/** The resolved scenario number (1-8) */
31+
scenario: InteractiveScenario;
32+
/** Whether to wait for input (user or controller) */
33+
shouldWait: boolean;
34+
/** Whether to run autonomous prompt loop (Scenario 5) */
35+
runAutonomousLoop: boolean;
36+
/** Whether interactive was forced due to invalid config */
37+
wasForced: boolean;
38+
}
39+
40+
export interface ResolveInteractiveOptions {
41+
step: ModuleStep;
42+
autoMode: boolean;
43+
hasChainedPrompts: boolean;
44+
stepIndex: number;
45+
}
46+
47+
/**
48+
* Resolve interactive behavior for a workflow step
49+
*
50+
* Determines the correct behavior based on:
51+
* - step.interactive value (true, false, or undefined)
52+
* - autoMode state (controller available or manual mode)
53+
* - hasChainedPrompts (whether step has prompts to process)
54+
*
55+
* For invalid cases (interactive:false + manual mode), forces interactive:true
56+
* and logs a warning since manual mode requires user interaction.
57+
*/
58+
export function resolveInteractiveBehavior(
59+
options: ResolveInteractiveOptions
60+
): InteractiveBehavior {
61+
const { step, autoMode, hasChainedPrompts, stepIndex } = options;
62+
const interactive = step.interactive;
63+
64+
// Handle undefined interactive (default behavior based on chainedPrompts)
65+
if (interactive === undefined) {
66+
const effectiveInteractive = hasChainedPrompts;
67+
return resolveInteractiveBehavior({
68+
step: { ...step, interactive: effectiveInteractive },
69+
autoMode,
70+
hasChainedPrompts,
71+
stepIndex,
72+
});
73+
}
74+
75+
// interactive === true
76+
if (interactive === true) {
77+
if (autoMode) {
78+
// Scenarios 1-2: Controller drives
79+
return {
80+
scenario: hasChainedPrompts ? 1 : 2,
81+
shouldWait: true,
82+
runAutonomousLoop: false,
83+
wasForced: false,
84+
};
85+
} else {
86+
// Scenarios 3-4: User drives
87+
return {
88+
scenario: hasChainedPrompts ? 3 : 4,
89+
shouldWait: true,
90+
runAutonomousLoop: false,
91+
wasForced: false,
92+
};
93+
}
94+
}
95+
96+
// interactive === false
97+
if (autoMode) {
98+
// Valid: Scenarios 5-6
99+
if (hasChainedPrompts) {
100+
// Scenario 5: Fully autonomous - auto-send ALL prompts
101+
return {
102+
scenario: 5,
103+
shouldWait: false,
104+
runAutonomousLoop: true,
105+
wasForced: false,
106+
};
107+
} else {
108+
// Scenario 6: Auto-advance to next step
109+
return {
110+
scenario: 6,
111+
shouldWait: false,
112+
runAutonomousLoop: false,
113+
wasForced: false,
114+
};
115+
}
116+
} else {
117+
// Invalid: Scenarios 7-8 - force interactive:true
118+
debug(
119+
'[interactive] Step %d has interactive:false in manual mode. ' +
120+
'Forcing interactive:true. Use auto mode for non-interactive steps.',
121+
stepIndex
122+
);
123+
return {
124+
scenario: hasChainedPrompts ? 7 : 8,
125+
shouldWait: true,
126+
runAutonomousLoop: false,
127+
wasForced: true,
128+
};
129+
}
130+
}

0 commit comments

Comments
 (0)