Skip to content

Commit d431a04

Browse files
committed
fix(opencode): reuse task runtime for output recovery
1 parent d4235fb commit d431a04

5 files changed

Lines changed: 300 additions & 175 deletions

File tree

src/agent-wrapper.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -476,8 +476,8 @@ class AgentWrapper {
476476
* Spawn claude-zeroshots process and stream output via message bus
477477
* @private
478478
*/
479-
_spawnClaudeTask(context) {
480-
return spawnClaudeTask(this, context);
479+
_spawnClaudeTask(context, options = {}) {
480+
return spawnClaudeTask(this, context, options);
481481
}
482482

483483
/**

src/agent/agent-task-executor.js

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -589,9 +589,10 @@ function ensureDangerousGitHook(targetClaudeDir = null) {
589589
* Spawn claude-zeroshots process and stream output via message bus
590590
* @param {Object} agent - Agent instance
591591
* @param {String} context - Context to pass to Claude
592+
* @param {{skipStructuredResultCheck?: boolean}} [options] - Internal nested-task controls
592593
* @returns {Promise<Object>} Result object { success, output, error }
593594
*/
594-
async function spawnClaudeTask(agent, context) {
595+
async function spawnClaudeTask(agent, context, options = {}) {
595596
const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
596597
const modelSpec = resolveAgentModelSpec(agent);
597598

@@ -628,7 +629,7 @@ async function spawnClaudeTask(agent, context) {
628629

629630
// MOCK SUPPORT: Use injected mock function if provided
630631
if (agent.mockSpawnFn) {
631-
return agent.mockSpawnFn(args, { context });
632+
return agent.mockSpawnFn(args, { context, options });
632633
}
633634

634635
// SAFETY: Fail hard if testMode=true but no mock (should be caught in constructor)
@@ -641,7 +642,7 @@ async function spawnClaudeTask(agent, context) {
641642

642643
// ISOLATION MODE: Run inside Docker container
643644
if (agent.isolation?.enabled) {
644-
return spawnClaudeTaskIsolated(agent, context);
645+
return spawnClaudeTaskIsolated(agent, context, options);
645646
}
646647

647648
// NON-ISOLATION MODE: For Claude, use user's existing Claude config
@@ -703,7 +704,7 @@ async function spawnClaudeTask(agent, context) {
703704
}
704705

705706
// Now follow the logs and stream output
706-
return followClaudeTaskLogs(agent, taskId);
707+
return followClaudeTaskLogs(agent, taskId, options);
707708
}
708709

709710
function resolveAgentModelSpec(agent) {
@@ -1212,7 +1213,9 @@ function buildFailureContext({ agent, taskId, providerName, state, stdout }) {
12121213
}
12131214

12141215
async function buildCompletionResult({ agent, taskId, providerName, state, stdout, success }) {
1215-
const classified = await evaluateStructuredSuccess({ agent, taskId, state, success });
1216+
const classified = state.skipStructuredResultCheck
1217+
? { success, error: null }
1218+
: await evaluateStructuredSuccess({ agent, taskId, state, success });
12161219
let errorContext = classified.error;
12171220
if (!errorContext && !classified.success) {
12181221
errorContext = buildFailureContext({ agent, taskId, providerName, state, stdout });
@@ -1401,9 +1404,17 @@ function buildKillHandler({ agent, taskId, state, providerName, resolve }) {
14011404
};
14021405
}
14031406

1404-
function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
1407+
function createLogFollower({
1408+
agent,
1409+
taskId,
1410+
fsModule,
1411+
ctPath,
1412+
providerName,
1413+
skipStructuredResultCheck = false,
1414+
}) {
14051415
return new Promise((resolve) => {
14061416
const state = createLogFollowState();
1417+
state.skipStructuredResultCheck = skipStructuredResultCheck;
14071418

14081419
state.logFilePath = lookupLogFilePath(ctPath, taskId);
14091420
if (state.logFilePath) {
@@ -1463,12 +1474,19 @@ function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
14631474
* @param {String} taskId - Task ID to follow
14641475
* @returns {Promise<Object>} Result object { success, output, error }
14651476
*/
1466-
function followClaudeTaskLogs(agent, taskId) {
1477+
function followClaudeTaskLogs(agent, taskId, options = {}) {
14671478
const fsModule = require('fs');
14681479
const ctPath = getClaudeTasksPath();
14691480
const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
14701481

1471-
return createLogFollower({ agent, taskId, fsModule, ctPath, providerName });
1482+
return createLogFollower({
1483+
agent,
1484+
taskId,
1485+
fsModule,
1486+
ctPath,
1487+
providerName,
1488+
skipStructuredResultCheck: options.skipStructuredResultCheck === true,
1489+
});
14721490
}
14731491

14741492
// Cache zeroshot path at module load time (when PATH is correct)
@@ -1506,9 +1524,10 @@ function getClaudeTasksPath() {
15061524
* Runs Claude CLI inside the container for full isolation
15071525
* @param {Object} agent - Agent instance
15081526
* @param {String} context - Context to pass to Claude
1527+
* @param {{skipStructuredResultCheck?: boolean}} [options] - Internal nested-task controls
15091528
* @returns {Promise<Object>} Result object { success, output, error }
15101529
*/
1511-
async function spawnClaudeTaskIsolated(agent, context) {
1530+
async function spawnClaudeTaskIsolated(agent, context, options = {}) {
15121531
const { manager, clusterId } = agent.isolation;
15131532
const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
15141533
const modelSpec = resolveAgentModelSpec(agent);
@@ -1626,7 +1645,7 @@ async function spawnClaudeTaskIsolated(agent, context) {
16261645

16271646
// STEP 2: Install the lifecycle-owned handle before liveness monitoring can
16281647
// observe the task, then follow the task's log file inside the container.
1629-
const execution = followClaudeTaskLogsIsolated(agent, taskId);
1648+
const execution = followClaudeTaskLogsIsolated(agent, taskId, options);
16301649
if (agent.enableLivenessCheck) {
16311650
agent.taskStartedAt = Date.now();
16321651
agent.lastOutputTime = agent.taskStartedAt;
@@ -1658,7 +1677,7 @@ async function spawnClaudeTaskIsolated(agent, context) {
16581677
* - Status checks reduced to every 2 seconds (not every poll)
16591678
* - Result: 10-20% overall latency reduction
16601679
*/
1661-
function createIsolatedLogState() {
1680+
function createIsolatedLogState(skipStructuredResultCheck = false) {
16621681
return {
16631682
taskExited: false,
16641683
resolved: false,
@@ -1670,6 +1689,7 @@ function createIsolatedLogState() {
16701689
statusCheckInterval: null,
16711690
timeoutTimer: null,
16721691
lineBuffer: '',
1692+
skipStructuredResultCheck,
16731693
};
16741694
}
16751695

@@ -1816,7 +1836,9 @@ function settleIsolatedTerminalStatus({
18161836
},
18171837
})
18181838
: null;
1819-
const parsedResult = await agent._parseResultOutput(state.fullOutput);
1839+
const parsedResult = state.skipStructuredResultCheck
1840+
? null
1841+
: await agent._parseResultOutput(state.fullOutput);
18201842

18211843
settleIsolatedFollower({
18221844
agent,
@@ -2056,7 +2078,7 @@ function startIsolatedStatusChecks({
20562078
}, 2000);
20572079
}
20582080

2059-
function followClaudeTaskLogsIsolated(agent, taskId) {
2081+
function followClaudeTaskLogsIsolated(agent, taskId, options = {}) {
20602082
const { isolation } = agent;
20612083
if (!isolation?.manager) {
20622084
throw new Error('followClaudeTaskLogsIsolated: isolation manager not found');
@@ -2067,7 +2089,7 @@ function followClaudeTaskLogsIsolated(agent, taskId) {
20672089
const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
20682090

20692091
return new Promise((resolve, reject) => {
2070-
const state = createIsolatedLogState();
2092+
const state = createIsolatedLogState(options.skipStructuredResultCheck === true);
20712093
const cleanup = buildIsolatedCleanup(state);
20722094
const onLine = (line) => broadcastIsolatedLine({ agent, providerName, taskId, line });
20732095
state.lifecycleHandle = buildIsolatedLifecycleHandle({
@@ -2203,6 +2225,8 @@ async function parseResultOutput(agent, output) {
22032225
schema: agent.config.jsonSchema,
22042226
providerName,
22052227
isCancelled: () => agent.running === false || agent.state === 'stopped',
2228+
runReformat: (prompt) =>
2229+
agent._spawnClaudeTask(prompt, { skipStructuredResultCheck: true }),
22062230
onAttempt: (attempt, lastError) => {
22072231
if (lastError) {
22082232
console.warn(`[Agent ${agent.id}] Reformat attempt ${attempt}: ${lastError}`);
@@ -2214,6 +2238,7 @@ async function parseResultOutput(agent, output) {
22142238
},
22152239
});
22162240
} catch (reformatError) {
2241+
if (reformatError.code === 'REFORMAT_CANCELLED') throw reformatError;
22172242
// Reformatting failed - fall through to error below
22182243
console.error(`[Agent ${agent.id}] Reformatting failed: ${reformatError.message}`);
22192244
}

src/agent/output-reformatter.js

Lines changed: 14 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -10,96 +10,13 @@
1010

1111
const DEFAULT_MAX_ATTEMPTS = 3;
1212

13-
const childProcess = require('child_process');
14-
15-
// Provider used to reformat non-JSON agent output into the target schema.
16-
// opencode is used as the reformatting backend because it reliably emits clean
17-
// JSON via `--format json`. This makes the reformat fallback work for agents
18-
// whose own output isn't directly parseable (e.g. an opencode planner that
19-
// spends its turn on tool-calls instead of emitting the final JSON block).
20-
const REFORMAT_PROVIDER_BIN = 'opencode';
21-
const REFORMAT_TIMEOUT_MS = 180000;
2213

2314
function createCancellationError() {
2415
const error = new Error('Output reformatting cancelled');
2516
error.code = 'REFORMAT_CANCELLED';
2617
return error;
2718
}
2819

29-
/**
30-
* Call the opencode CLI with a prompt.
31-
* Stdin is ignored because opencode otherwise waits for EOF.
32-
*
33-
* @param {string} prompt
34-
* @param {{isCancelled?: () => boolean}} [options]
35-
* @returns {Promise<string|null>}
36-
*/
37-
function callReformatModel(prompt, { isCancelled = () => false } = {}) {
38-
if (isCancelled()) return Promise.reject(createCancellationError());
39-
40-
return new Promise((resolve, reject) => {
41-
const child = childProcess.spawn(
42-
REFORMAT_PROVIDER_BIN,
43-
['run', '--format', 'json', prompt],
44-
{
45-
stdio: ['ignore', 'pipe', 'pipe'],
46-
}
47-
);
48-
let stdout = '';
49-
let stderr = '';
50-
let settled = false;
51-
let timeoutTimer;
52-
let cancellationTimer;
53-
54-
const finish = (error, value = null) => {
55-
if (settled) return;
56-
settled = true;
57-
clearTimeout(timeoutTimer);
58-
clearInterval(cancellationTimer);
59-
if (error) {
60-
reject(error);
61-
} else {
62-
resolve(value);
63-
}
64-
};
65-
66-
timeoutTimer = setTimeout(() => {
67-
child.kill('SIGKILL');
68-
finish(new Error(`opencode reformat timed out after ${REFORMAT_TIMEOUT_MS}ms`));
69-
}, REFORMAT_TIMEOUT_MS);
70-
71-
cancellationTimer = setInterval(() => {
72-
if (!isCancelled()) return;
73-
child.kill('SIGKILL');
74-
finish(createCancellationError());
75-
}, 50);
76-
77-
child.stdout.on('data', (data) => {
78-
stdout += data.toString();
79-
});
80-
child.stderr.on('data', (data) => {
81-
stderr += data.toString();
82-
});
83-
child.on('close', (code, signal) => {
84-
if (isCancelled()) {
85-
finish(createCancellationError());
86-
} else if (code !== 0) {
87-
const detail = stderr.trim().slice(-500);
88-
finish(
89-
new Error(
90-
`opencode reformat exited with code ${code}${signal ? ` (${signal})` : ''}` +
91-
(detail ? `: ${detail}` : '')
92-
)
93-
);
94-
} else {
95-
finish(null, stdout || null);
96-
}
97-
});
98-
child.on('error', (error) => {
99-
finish(error);
100-
});
101-
});
102-
}
10320

10421
/**
10522
* Build the reformatting prompt
@@ -157,6 +74,7 @@ Fix this issue in your response.`;
15774
* @param {number} [options.maxAttempts=3] - Maximum reformatting attempts
15875
* @param {Function} [options.onAttempt] - Callback for each attempt (attempt, error)
15976
* @param {Function} [options.isCancelled] - Returns true after agent cancellation
77+
* @param {Function} options.runReformat - Runs the prompt in the active agent execution context
16078
* @returns {Promise<Object>} The reformatted JSON object
16179
* @throws {Error} If the provider is unsupported, cancellation occurs, or attempts fail
16280
*/
@@ -167,13 +85,18 @@ async function reformatOutput({
16785
maxAttempts = DEFAULT_MAX_ATTEMPTS,
16886
onAttempt,
16987
isCancelled = () => false,
88+
runReformat,
17089
}) {
17190
if (providerName !== 'opencode') {
17291
throw new Error(
17392
`Output reformatting not available for provider "${providerName}". ` +
17493
`Agent output must be valid JSON. Raw output (last 200 chars): ${(rawOutput || '').slice(-200)}`
17594
);
17695
}
96+
if (typeof runReformat !== 'function') {
97+
throw new Error('Output reformatting requires the active agent execution context');
98+
}
99+
177100

178101
const { extractJsonFromOutput } = require('./output-extraction');
179102
let lastError = null;
@@ -187,9 +110,15 @@ async function reformatOutput({
187110
const prompt = buildReformatPrompt(rawOutput, schema, lastError);
188111

189112
try {
190-
const output = await callReformatModel(prompt, { isCancelled });
113+
const result = await runReformat(prompt);
114+
if (isCancelled()) throw createCancellationError();
115+
if (!result?.success) {
116+
lastError = result?.error || 'reformat task failed';
117+
continue;
118+
}
119+
const output = result.output;
191120
if (!output) {
192-
lastError = 'reformat model returned no output';
121+
lastError = 'reformat task returned no output';
193122
continue;
194123
}
195124

0 commit comments

Comments
 (0)