Skip to content

Commit a4b9dd4

Browse files
committed
fix(workflow): handle chained prompts on resume when promptQueue is empty
fix(agent): only mark agent as completed on fresh execution, not resume refactor(workflow): improve input provider selection logic in wait state feat(mistral): add session ID recovery from files on abort or completion
1 parent 9ca7c80 commit a4b9dd4

File tree

4 files changed

+92
-10
lines changed

4 files changed

+92
-10
lines changed

src/agents/runner/runner.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -433,17 +433,22 @@ export async function executeAgent(
433433

434434
debug(`[AgentRunner] Engine execution completed, outputLength=%d`, totalStdout.length);
435435

436-
// Mark agent as completed
437-
if (monitor && monitoringAgentId !== undefined) {
436+
// Determine if this is a resume (conversational loop) vs fresh execution
437+
const isResume = resumeSessionId !== undefined;
438+
439+
// Mark agent as completed (only for fresh execution, not resume)
440+
// During resume, the agent stays in running/awaiting state for continued conversation
441+
if (!isResume && monitor && monitoringAgentId !== undefined) {
438442
debug(`[AgentRunner] Marking agent %d as completed`, monitoringAgentId);
439443
await monitor.complete(monitoringAgentId);
440444
// Note: Don't close stream here - workflow may write more messages
441445
// Streams will be closed by cleanup handlers or monitoring service shutdown
446+
} else if (isResume) {
447+
debug(`[AgentRunner] Resume mode - agent %d stays running for continued conversation`, monitoringAgentId ?? -1);
442448
}
443449

444450
// Load chained prompts if configured
445-
// Always load on fresh execution; on resume, workflow.ts decides whether to use them
446-
// based on chain resume state (chainResumeInfo)
451+
// Always load - the workflow runner decides whether to use them based on promptQueue state
447452
let chainedPrompts: ChainedPrompt[] | undefined;
448453
debug(`[AgentRunner] ChainedPrompts path: %s`, agentConfig.chainedPromptsPath ?? '(none)');
449454
if (agentConfig.chainedPromptsPath) {

src/infra/engines/providers/mistral/execution/runner.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as path from 'node:path';
2+
import * as fs from 'node:fs';
23
import { homedir } from 'node:os';
34

45
import { spawnProcess } from '../../../../process/spawn.js';
@@ -41,6 +42,43 @@ export interface RunMistralResult {
4142

4243
const ANSI_ESCAPE_SEQUENCE = new RegExp(String.raw`\u001B\[[0-9;?]*[ -/]*[@-~]`, 'g');
4344

45+
/**
46+
* Find the most recent session file created after startTime
47+
* Session files are stored in VIBE_HOME/logs/session/ with format:
48+
* session_{date}_{time}_{short_id}.json
49+
* The full session_id is in the metadata.session_id field inside the file.
50+
*/
51+
function findLatestSessionId(vibeHome: string, startTime: number): string | null {
52+
const sessionDir = path.join(vibeHome, 'logs', 'session');
53+
54+
try {
55+
if (!fs.existsSync(sessionDir)) {
56+
return null;
57+
}
58+
59+
const files = fs.readdirSync(sessionDir)
60+
.filter(f => f.startsWith('session_') && f.endsWith('.json'))
61+
.map(f => ({
62+
name: f,
63+
path: path.join(sessionDir, f),
64+
mtime: fs.statSync(path.join(sessionDir, f)).mtimeMs,
65+
}))
66+
.filter(f => f.mtime >= startTime)
67+
.sort((a, b) => b.mtime - a.mtime);
68+
69+
if (files.length === 0) {
70+
return null;
71+
}
72+
73+
// Read the most recent session file and extract session_id from metadata
74+
const content = fs.readFileSync(files[0].path, 'utf-8');
75+
const json = JSON.parse(content);
76+
return json.metadata?.session_id || null;
77+
} catch {
78+
return null;
79+
}
80+
}
81+
4482
/**
4583
* Build the final resume prompt combining steering instruction with user message
4684
*/
@@ -236,6 +274,9 @@ export async function runMistral(options: RunMistralOptions): Promise<RunMistral
236274
let capturedError: string | null = null;
237275
let sessionIdCaptured = false;
238276

277+
// Record start time to find session files created during this run
278+
const startTime = Date.now();
279+
239280
let result;
240281
try {
241282
result = await spawnProcess({
@@ -315,14 +356,25 @@ export async function runMistral(options: RunMistralOptions): Promise<RunMistral
315356
timeout,
316357
});
317358
} catch (error) {
318-
const err = error as unknown as { code?: string; message?: string };
359+
const err = error as unknown as { code?: string; message?: string; name?: string };
319360
const message = err?.message ?? '';
320361
const notFound = err?.code === 'ENOENT' || /not recognized as an internal or external command/i.test(message) || /command not found/i.test(message);
321362
if (notFound) {
322363
const install = metadata.installCommand;
323364
const name = metadata.name;
324365
throw new Error(`'${command}' is not available on this system. Please install ${name} first:\n ${install}`);
325366
}
367+
368+
// Try to capture session ID even on abort - vibe saves session on exit
369+
// This is important for pause/resume functionality
370+
if (err?.name === 'AbortError' && !sessionIdCaptured && onSessionId) {
371+
const sessionId = findLatestSessionId(vibeHome, startTime);
372+
if (sessionId) {
373+
sessionIdCaptured = true;
374+
onSessionId(sessionId);
375+
}
376+
}
377+
326378
throw error;
327379
}
328380

@@ -377,6 +429,16 @@ export async function runMistral(options: RunMistralOptions): Promise<RunMistral
377429
throw new Error(errorMessage);
378430
}
379431

432+
// If session ID wasn't captured from streaming output, try to find it from session files
433+
// Vibe doesn't include session_id in streaming JSON output, but saves it to session files
434+
if (!sessionIdCaptured && onSessionId) {
435+
const sessionId = findLatestSessionId(vibeHome, startTime);
436+
if (sessionId) {
437+
sessionIdCaptured = true;
438+
onSessionId(sessionId);
439+
}
440+
}
441+
380442
// Log captured telemetry
381443
telemetryCapture.logCapturedTelemetry(result.exitCode);
382444

src/workflows/runner/wait.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,22 @@ export async function handleWaiting(ctx: RunnerContext, callbacks: WaitCallbacks
2929
debug('[Runner] Handling waiting state, autoMode=%s, paused=%s, promptQueue=%d items, queueIndex=%d',
3030
machineCtx.autoMode, machineCtx.paused, machineCtx.promptQueue.length, machineCtx.promptQueueIndex);
3131

32-
// If paused, force user input provider (not controller)
33-
const provider = machineCtx.paused ? ctx.getUserInput() : ctx.getActiveProvider();
32+
// Determine input provider:
33+
// - If paused → always use user input
34+
// - If manual mode (autoMode=false) → always use user input
35+
// - If auto mode (autoMode=true) and not paused → use active provider (controller)
36+
const useUserInput = machineCtx.paused || !machineCtx.autoMode;
37+
const provider = useUserInput ? ctx.getUserInput() : ctx.getActiveProvider();
3438
if (machineCtx.paused) {
3539
debug('[Runner] Workflow is paused, using user input provider');
40+
} else if (!machineCtx.autoMode) {
41+
debug('[Runner] Manual mode, using user input provider');
3642
}
3743

38-
if (!machineCtx.paused && machineCtx.promptQueue.length === 0) {
39-
// No chained prompts and not paused - auto-advance to next step
40-
debug('[Runner] No chained prompts, auto-advancing to next step');
44+
if (!machineCtx.paused && machineCtx.promptQueue.length === 0 && machineCtx.autoMode) {
45+
// No chained prompts, not paused, and in auto mode - auto-advance to next step
46+
// In manual mode, we wait for user input before advancing
47+
debug('[Runner] No chained prompts (auto mode), auto-advancing to next step');
4148
await markStepCompleted(ctx.cmRoot, machineCtx.currentStepIndex);
4249
ctx.machine.send({ type: 'INPUT_RECEIVED', input: '' });
4350
return;

src/workflows/step/run.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,14 @@ export async function runStepResume(
267267
};
268268
machineCtx.currentMonitoringId = output.monitoringId;
269269

270+
// If promptQueue is empty but we got chained prompts, populate it
271+
// This happens when initial execution was aborted before completion
272+
if (machineCtx.promptQueue.length === 0 && output.chainedPrompts && output.chainedPrompts.length > 0) {
273+
debug('[step/run] Resume: Populating promptQueue with %d chained prompts (initial exec was aborted)', output.chainedPrompts.length);
274+
machineCtx.promptQueue = output.chainedPrompts;
275+
machineCtx.promptQueueIndex = 0;
276+
}
277+
270278
const stepOutput: StateStepOutput = {
271279
output: output.output,
272280
monitoringId: output.monitoringId,

0 commit comments

Comments
 (0)