@the-open-engine/zeroshot 6.17.0 → 6.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/cli/index.js +9 -13
  2. package/lib/agent-cli-provider/adapters/claude.d.ts +2 -2
  3. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
  4. package/lib/agent-cli-provider/adapters/claude.js +30 -0
  5. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  6. package/lib/agent-cli-provider/adapters/codex.d.ts +2 -2
  7. package/lib/agent-cli-provider/adapters/codex.d.ts.map +1 -1
  8. package/lib/agent-cli-provider/adapters/codex.js +37 -0
  9. package/lib/agent-cli-provider/adapters/codex.js.map +1 -1
  10. package/lib/agent-cli-provider/adapters/gemini.d.ts +2 -2
  11. package/lib/agent-cli-provider/adapters/gemini.d.ts.map +1 -1
  12. package/lib/agent-cli-provider/adapters/gemini.js +115 -4
  13. package/lib/agent-cli-provider/adapters/gemini.js.map +1 -1
  14. package/lib/agent-cli-provider/adapters/opencode.d.ts +2 -2
  15. package/lib/agent-cli-provider/adapters/opencode.d.ts.map +1 -1
  16. package/lib/agent-cli-provider/adapters/opencode.js +108 -0
  17. package/lib/agent-cli-provider/adapters/opencode.js.map +1 -1
  18. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  19. package/lib/agent-cli-provider/contract-options.js +12 -0
  20. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  21. package/lib/agent-cli-provider/index.d.ts +2 -2
  22. package/lib/agent-cli-provider/index.d.ts.map +1 -1
  23. package/lib/agent-cli-provider/index.js +2 -1
  24. package/lib/agent-cli-provider/index.js.map +1 -1
  25. package/lib/agent-cli-provider/provider-registry.d.ts +19 -7
  26. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  27. package/lib/agent-cli-provider/provider-registry.js +4 -0
  28. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  29. package/lib/agent-cli-provider/single-agent-runtime.d.ts.map +1 -1
  30. package/lib/agent-cli-provider/single-agent-runtime.js +35 -4
  31. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
  32. package/lib/agent-cli-provider/types.d.ts +30 -3
  33. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  34. package/lib/agent-cli-provider/types.js.map +1 -1
  35. package/lib/cluster/errors.cjs +4 -1
  36. package/lib/cluster/errors.d.ts +2 -0
  37. package/lib/cluster/errors.mjs +2 -0
  38. package/lib/cluster/index.cjs +8 -1
  39. package/lib/cluster/index.d.ts +4 -1
  40. package/lib/cluster/index.mjs +4 -1
  41. package/lib/cluster/json-source.cjs +61 -0
  42. package/lib/cluster/json-source.d.ts +4 -0
  43. package/lib/cluster/json-source.mjs +55 -0
  44. package/lib/cluster/payload-value.cjs +76 -0
  45. package/lib/cluster/payload-value.d.ts +7 -0
  46. package/lib/cluster/payload-value.mjs +72 -0
  47. package/lib/cluster/validators.cjs +21 -0
  48. package/lib/cluster/validators.d.ts +4 -1
  49. package/lib/cluster/validators.mjs +19 -1
  50. package/lib/provider-names.js +5 -0
  51. package/package.json +2 -2
  52. package/src/agent/agent-task-executor.js +194 -410
  53. package/src/agent/output-extraction.js +39 -14
  54. package/src/agent/output-reformatter.js +154 -111
  55. package/src/agent-cli-provider/adapters/claude.ts +39 -2
  56. package/src/agent-cli-provider/adapters/codex.ts +56 -2
  57. package/src/agent-cli-provider/adapters/gemini.ts +102 -6
  58. package/src/agent-cli-provider/adapters/opencode.ts +85 -2
  59. package/src/agent-cli-provider/contract-options.ts +16 -0
  60. package/src/agent-cli-provider/index.ts +4 -0
  61. package/src/agent-cli-provider/provider-registry.ts +23 -3
  62. package/src/agent-cli-provider/single-agent-runtime.ts +50 -4
  63. package/src/agent-cli-provider/types.ts +36 -2
  64. package/src/agent-wrapper.js +2 -2
  65. package/src/cluster/errors.ts +1 -0
  66. package/src/cluster/index.ts +4 -0
  67. package/src/cluster/json-source.ts +71 -0
  68. package/src/cluster/payload-value.ts +84 -0
  69. package/src/cluster/validators.ts +31 -2
  70. package/src/orchestrator.js +33 -13
  71. package/task-lib/attachable-watcher.js +1 -0
  72. package/task-lib/command-spec-cleanup.js +57 -11
  73. package/task-lib/commands/run.js +1 -0
  74. package/task-lib/provider-session-capture.js +8 -0
  75. package/task-lib/runner.js +9 -6
  76. package/task-lib/watcher.js +1 -0
@@ -11,13 +11,9 @@
11
11
  */
12
12
 
13
13
  const { spawn, spawnSync } = require('child_process');
14
- const { randomUUID } = require('crypto');
15
14
  const path = require('path');
16
15
  const fs = require('fs');
17
- const {
18
- getNestedExecutionRegistry,
19
- TaskExecutionHandle,
20
- } = require('./task-execution-handle');
16
+ const { getNestedExecutionRegistry, TaskExecutionHandle } = require('./task-execution-handle');
21
17
  const os = require('os');
22
18
  const { parseProviderChunk, getProvider } = require('../providers');
23
19
  const { getTask, getTaskBySpawnOwnershipToken } = require('../../task-lib/store.js');
@@ -54,207 +50,6 @@ const {
54
50
  } = require('./provider-session');
55
51
  const { extractClaudeVertexModelError } = require('./output-extraction');
56
52
  const TASK_TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed', 'stale']);
57
- const OPENCODE_CONFIG_CONTENT_ENV = 'OPENCODE_CONFIG_CONTENT';
58
- const OPENCODE_AGENT_ENV = 'ZEROSHOT_OPENCODE_AGENT';
59
- const REFORMATTER_AGENT_PREFIX = 'zeroshot-output-reformatter-';
60
-
61
- function parseOpenCodeJsonc(content) {
62
- let withoutComments = '';
63
- let inString = false;
64
- let escaped = false;
65
- let cursor = 0;
66
- while (cursor < content.length) {
67
- const char = content[cursor];
68
- const next = content[cursor + 1];
69
- if (inString) {
70
- withoutComments += char;
71
- if (escaped) {
72
- escaped = false;
73
- } else if (char === '\\') {
74
- escaped = true;
75
- } else if (char === '"') {
76
- inString = false;
77
- }
78
- cursor += 1;
79
- continue;
80
- }
81
- if (char === '"') {
82
- inString = true;
83
- withoutComments += char;
84
- cursor += 1;
85
- continue;
86
- }
87
- if (char === '/' && next === '/') {
88
- cursor += 2;
89
- while (cursor < content.length && content[cursor] !== '\n') cursor += 1;
90
- withoutComments += '\n';
91
- cursor += 1;
92
- continue;
93
- }
94
- if (char === '/' && next === '*') {
95
- cursor += 2;
96
- while (
97
- cursor < content.length &&
98
- !(content[cursor] === '*' && content[cursor + 1] === '/')
99
- ) {
100
- if (content[cursor] === '\n') withoutComments += '\n';
101
- cursor += 1;
102
- }
103
- if (cursor >= content.length) {
104
- throw new SyntaxError('Unterminated block comment in OpenCode inline config');
105
- }
106
- cursor += 2;
107
- continue;
108
- }
109
- withoutComments += char;
110
- cursor += 1;
111
- }
112
-
113
- let withoutTrailingCommas = '';
114
- inString = false;
115
- escaped = false;
116
- for (let index = 0; index < withoutComments.length; index++) {
117
- const char = withoutComments[index];
118
- if (inString) {
119
- withoutTrailingCommas += char;
120
- if (escaped) {
121
- escaped = false;
122
- } else if (char === '\\') {
123
- escaped = true;
124
- } else if (char === '"') {
125
- inString = false;
126
- }
127
- continue;
128
- }
129
- if (char === '"') {
130
- inString = true;
131
- withoutTrailingCommas += char;
132
- continue;
133
- }
134
- if (char === ',') {
135
- let nextIndex = index + 1;
136
- while (/\s/.test(withoutComments[nextIndex] || '')) nextIndex++;
137
- if (withoutComments[nextIndex] === '}' || withoutComments[nextIndex] === ']') {
138
- continue;
139
- }
140
- }
141
- withoutTrailingCommas += char;
142
- }
143
- return JSON.parse(withoutTrailingCommas);
144
- }
145
-
146
- function ensureFormatterLaunchOptions(providerName, options) {
147
- if (providerName !== 'opencode' || options.disableTools !== true) return options;
148
- if (options.formatterAgentName) return options;
149
- return {
150
- ...options,
151
- formatterAgentName: `${REFORMATTER_AGENT_PREFIX}${randomUUID()}`,
152
- };
153
- }
154
-
155
- function applyOpenCodeToolBoundary(env, providerName, options = {}) {
156
- if (providerName !== 'opencode' || options.disableTools !== true) return env;
157
- if (!options.formatterAgentName) {
158
- throw new Error('Tool-disabled OpenCode formatter launch is missing its unique agent identity');
159
- }
160
-
161
- let config = {};
162
- const existingContent = env[OPENCODE_CONFIG_CONTENT_ENV];
163
- if (existingContent) {
164
- try {
165
- config = parseOpenCodeJsonc(existingContent);
166
- } catch (error) {
167
- throw new Error(
168
- `Cannot install tool-disabled OpenCode formatter profile: invalid ${OPENCODE_CONFIG_CONTENT_ENV}: ${error.message}`
169
- );
170
- }
171
- }
172
-
173
- const existingAgents =
174
- config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent)
175
- ? config.agent
176
- : {};
177
- const existingModes =
178
- config.mode && typeof config.mode === 'object' && !Array.isArray(config.mode)
179
- ? config.mode
180
- : {};
181
- const formatterProfile = {
182
- description: 'Convert supplied text to schema-valid JSON without external actions',
183
- mode: 'primary',
184
- permission: { '*': 'deny' },
185
- tools: { '*': false },
186
- };
187
- env[OPENCODE_AGENT_ENV] = options.formatterAgentName;
188
- env[OPENCODE_CONFIG_CONTENT_ENV] = JSON.stringify({
189
- ...config,
190
- default_agent: options.formatterAgentName,
191
- permission: 'deny',
192
- tools: { '*': false },
193
- agent: {
194
- ...existingAgents,
195
- [options.formatterAgentName]: formatterProfile,
196
- },
197
- mode: {
198
- ...existingModes,
199
- [options.formatterAgentName]: formatterProfile,
200
- },
201
- });
202
- return env;
203
- }
204
-
205
- function resolveIsolatedOpenCodeConfigContent(manager, clusterId, providerName) {
206
- if (
207
- providerName === 'opencode' &&
208
- typeof manager.getContainerEnvironmentValue === 'function'
209
- ) {
210
- return manager.getContainerEnvironmentValue(clusterId, OPENCODE_CONFIG_CONTENT_ENV);
211
- }
212
- return process.env[OPENCODE_CONFIG_CONTENT_ENV] || null;
213
- }
214
-
215
- async function resolveIsolatedOpenCodeConfigUnderOwnership({
216
- manager,
217
- clusterId,
218
- providerName,
219
- executionHandle,
220
- }) {
221
- if (!executionHandle) {
222
- return resolveIsolatedOpenCodeConfigContent(manager, clusterId, providerName);
223
- }
224
-
225
- let setupPending = true;
226
- let rejectSetup;
227
- const setupFailure = new Promise((_resolve, reject) => {
228
- rejectSetup = reject;
229
- });
230
- executionHandle.setCancelAction((reason, details = {}) => {
231
- if (setupPending) {
232
- const error = new Error(reason || 'Nested task setup cancelled');
233
- error.code = details.code || 'REFORMAT_CANCELLED';
234
- error.nestedExecutionCancellation = true;
235
- error.nestedExecutionLifecycle = true;
236
- rejectSetup(error);
237
- }
238
- return { forced: true, beforeLaunch: true };
239
- });
240
- executionHandle.setFailClosedAction((error) => {
241
- if (setupPending) rejectSetup(error);
242
- });
243
-
244
- try {
245
- const config = await Promise.race([
246
- Promise.resolve(resolveIsolatedOpenCodeConfigContent(manager, clusterId, providerName)),
247
- setupFailure,
248
- ]);
249
- if (executionHandle.isCancelled) {
250
- throw createNestedCancellationError(executionHandle);
251
- }
252
- return config;
253
- } finally {
254
- setupPending = false;
255
- }
256
- }
257
-
258
53
  function runCommandWithTimeout(command, args, options = {}, callback = null) {
259
54
  const timeout = options.timeout ?? 30000;
260
55
  if (timeout <= 0) {
@@ -326,9 +121,6 @@ function runCommandSync(command, args, options = {}) {
326
121
  return result.stdout?.toString() || '';
327
122
  }
328
123
 
329
- // Schema utilities for normalizing LLM output
330
- const { normalizeEnumValues } = require('./schema-utils');
331
-
332
124
  /**
333
125
  * Build Claude-specific environment variables for task spawning
334
126
  * Consolidates auth resolution and model mapping logic used by both isolated and non-isolated modes
@@ -740,6 +532,7 @@ async function spawnClaudeTask(agent, context, options = {}) {
740
532
  providerName,
741
533
  modelSpec,
742
534
  runOutputFormat,
535
+ structuredOutputRecovery: options.structuredOutputRecovery === true,
743
536
  });
744
537
 
745
538
  maybeLogStreamJsonNotice(agent, runOutputFormat);
@@ -766,7 +559,6 @@ async function spawnClaudeTask(agent, context, options = {}) {
766
559
  if (agent.isolation?.enabled) {
767
560
  return spawnClaudeTaskIsolated(agent, context, options);
768
561
  }
769
- options = ensureFormatterLaunchOptions(providerName, options);
770
562
 
771
563
  const claudeSettingsPath =
772
564
  providerName === 'claude'
@@ -781,11 +573,7 @@ async function spawnClaudeTask(agent, context, options = {}) {
781
573
  let pendingLaunch;
782
574
  try {
783
575
  try {
784
- const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec, {
785
- claudeSettingsPath,
786
- disableTools: options.disableTools === true,
787
- formatterAgentName: options.formatterAgentName,
788
- });
576
+ const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec, { claudeSettingsPath });
789
577
  taskId = await spawnTaskProcess({
790
578
  agent,
791
579
  ctPath,
@@ -866,11 +654,7 @@ async function spawnClaudeTask(agent, context, options = {}) {
866
654
  if (error.terminationExhausted === true && error.retainTaskHandle === true) {
867
655
  throw error;
868
656
  }
869
- if (
870
- nested &&
871
- handle.isCancelled &&
872
- handle.cancelDetails.code !== 'NESTED_SETUP_FAILED'
873
- ) {
657
+ if (nested && handle.isCancelled && handle.cancelDetails.code !== 'NESTED_SETUP_FAILED') {
874
658
  const termination = await handle.waitForCancellation();
875
659
  const cancellationError = createNestedCancellationError(handle);
876
660
  if (!isTerminationConfirmed(termination)) {
@@ -907,28 +691,32 @@ function resolveOutputFormatConfig(agent) {
907
691
  return { desiredOutputFormat, strictSchema, runOutputFormat };
908
692
  }
909
693
 
910
- function buildTaskRunArgs({ agent, providerName, modelSpec, runOutputFormat }) {
694
+ function buildTaskRunArgs({
695
+ agent,
696
+ providerName,
697
+ modelSpec,
698
+ runOutputFormat,
699
+ structuredOutputRecovery = false,
700
+ }) {
911
701
  const args = ['task', 'run', '--output-format', runOutputFormat, '--provider', providerName];
912
702
  const modelSpecSource = agent._resolveModelSpecSource
913
703
  ? agent._resolveModelSpecSource()
914
704
  : 'direct';
915
705
  appendTaskRunModelArgs(args, modelSpec, modelSpecSource);
916
706
 
917
- // Add verification mode flag if configured
918
707
  if (agent.config.verificationMode) {
919
708
  args.push('-v');
920
709
  }
921
710
 
922
- // Add JSON schema if specified in agent config.
923
- // If we are running stream-json for live logs (strictSchema=false), do NOT pass schema to CLI.
924
711
  if (agent.config.jsonSchema && runOutputFormat === 'json') {
925
- const schema = JSON.stringify(agent.config.jsonSchema);
926
- args.push('--json-schema', schema);
712
+ args.push('--json-schema', JSON.stringify(agent.config.jsonSchema));
713
+ }
714
+
715
+ if (structuredOutputRecovery) {
716
+ args.push('--structured-output-recovery');
717
+ return args;
927
718
  }
928
719
 
929
- // MCP servers: explicitly forward the repo config through the task CLI.
930
- // Claude receives the path; providers such as Copilot receive inlined JSON
931
- // so it survives container path translation.
932
720
  for (const mcpArg of resolveMcpConfigArgs(agent, providerName)) {
933
721
  args.push(mcpArg);
934
722
  }
@@ -1056,7 +844,7 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
1056
844
  worktreePath: agent.worktree?.path || null,
1057
845
  });
1058
846
 
1059
- return applyOpenCodeToolBoundary(spawnEnv, providerName, options);
847
+ return spawnEnv;
1060
848
  }
1061
849
 
1062
850
  function parseTaskIdFromOutput(stdout) {
@@ -1073,6 +861,27 @@ function assignDurableTaskId(agent, taskId) {
1073
861
  });
1074
862
  }
1075
863
 
864
+ async function terminatePendingTaskWrapper(proc, waitForWrapperClose, isWrapperClosed) {
865
+ if (isWrapperClosed()) return;
866
+ if (!proc.pid) {
867
+ await new Promise((resolve) => {
868
+ let settled = false;
869
+ const finish = () => {
870
+ if (settled) return;
871
+ settled = true;
872
+ proc.off('spawn', finish);
873
+ proc.off('error', finish);
874
+ resolve();
875
+ };
876
+ proc.once('spawn', finish);
877
+ proc.once('error', finish);
878
+ if (proc.pid || isWrapperClosed()) finish();
879
+ });
880
+ }
881
+ if (!isWrapperClosed()) proc.kill('SIGKILL');
882
+ await waitForWrapperClose;
883
+ }
884
+
1076
885
  function createPendingTaskLaunchHandle({
1077
886
  agent,
1078
887
  proc,
@@ -1112,10 +921,7 @@ function createPendingTaskLaunchHandle({
1112
921
  };
1113
922
  if (taskId) await cancelTask(taskId);
1114
923
 
1115
- if (!isWrapperClosed()) {
1116
- proc.kill('SIGKILL');
1117
- await waitForWrapperClose;
1118
- }
924
+ await terminatePendingTaskWrapper(proc, waitForWrapperClose, isWrapperClosed);
1119
925
 
1120
926
  taskId = taskId || (await findLateTaskId());
1121
927
  if (taskId && !commandAttempted) await cancelTask(taskId);
@@ -1165,6 +971,7 @@ function spawnTaskProcess({
1165
971
  spawnTimeoutMs = 30000,
1166
972
  handle = null,
1167
973
  nested = false,
974
+ spawnProcess = spawn,
1168
975
  }) {
1169
976
  // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it.
1170
977
  const SPAWN_TIMEOUT_MS = spawnTimeoutMs;
@@ -1174,7 +981,7 @@ function spawnTaskProcess({
1174
981
  const findPersistedTaskId = () => getTaskBySpawnOwnershipToken(ownershipToken)?.id || null;
1175
982
 
1176
983
  return new Promise((resolve, reject) => {
1177
- const proc = spawn(ctPath, safeArgs, {
984
+ const proc = spawnProcess(ctPath, safeArgs, {
1178
985
  cwd,
1179
986
  stdio: ['ignore', 'pipe', 'pipe'],
1180
987
  env: { ...spawnEnv, [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken },
@@ -1543,7 +1350,7 @@ function requiresStructuredResult(agent) {
1543
1350
  return outputFormat !== 'text' || !!agent?.config?.jsonSchema;
1544
1351
  }
1545
1352
 
1546
- async function evaluateStructuredSuccess({ agent, taskId, state, success }) {
1353
+ async function evaluateStructuredSuccess({ agent, taskId, state, success, allowRecovery }) {
1547
1354
  if (!success || !requiresStructuredResult(agent)) {
1548
1355
  return { success, error: null };
1549
1356
  }
@@ -1556,10 +1363,16 @@ async function evaluateStructuredSuccess({ agent, taskId, state, success }) {
1556
1363
  // Cache the validated parsed object so completion hooks and {{result.*}}
1557
1364
  // substitution consume it directly instead of re-parsing (which could
1558
1365
  // trigger a second recovery model call).
1559
- state._cachedParsedResult = await agent._parseResultOutput(state.output);
1366
+ state._cachedParsedResult = await agent._parseResultOutput(state.output, { allowRecovery });
1560
1367
  return { success: true, error: null };
1561
1368
  } catch (error) {
1562
- if (isNestedLifecycleError(error)) throw error;
1369
+ if (
1370
+ isNestedLifecycleError(error) ||
1371
+ error?.permanent === true ||
1372
+ error?.recoveryAbort === true
1373
+ ) {
1374
+ throw error;
1375
+ }
1563
1376
  const errorContext = sanitizeErrorMessage(error.message);
1564
1377
  console.warn(
1565
1378
  `[Agent ${agent.id}] Task ${taskId} reported completed but produced invalid structured output; ` +
@@ -1595,9 +1408,16 @@ async function buildCompletionResult({
1595
1408
  success,
1596
1409
  taskInfo = getTask(taskId),
1597
1410
  }) {
1411
+ const { isCompleted } = parseStatusFlags(stripAnsiCodes(stdout));
1598
1412
  const classified = state.skipStructuredResultCheck
1599
1413
  ? { success, error: null }
1600
- : await evaluateStructuredSuccess({ agent, taskId, state, success });
1414
+ : await evaluateStructuredSuccess({
1415
+ agent,
1416
+ taskId,
1417
+ state,
1418
+ success,
1419
+ allowRecovery: isCompleted,
1420
+ });
1601
1421
  const resumeIdentityError = classified.success ? validateCompletedResumeIdentity(taskInfo) : null;
1602
1422
  if (resumeIdentityError) {
1603
1423
  classified.success = false;
@@ -1802,18 +1622,7 @@ function handleStatusCompletion({
1802
1622
  success,
1803
1623
  })
1804
1624
  .then(resolve)
1805
- .catch((error) => {
1806
- if (isNestedLifecycleError(error)) {
1807
- reject(error);
1808
- return;
1809
- }
1810
- resolve({
1811
- success: false,
1812
- output: state.output,
1813
- error: sanitizeErrorMessage(error.message),
1814
- tokenUsage: extractTokenUsage(state.output, providerName),
1815
- });
1816
- });
1625
+ .catch(reject);
1817
1626
  }, 500);
1818
1627
 
1819
1628
  return true;
@@ -2034,7 +1843,6 @@ async function spawnClaudeTaskIsolated(agent, context, options = {}) {
2034
1843
  async function spawnClaudeTaskIsolatedExecution(agent, context, options = {}) {
2035
1844
  const { manager, clusterId } = agent.isolation;
2036
1845
  const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
2037
- options = ensureFormatterLaunchOptions(providerName, options);
2038
1846
  const modelSpec = resolveAgentModelSpec(agent);
2039
1847
  const modelSpecSource = agent._resolveModelSpecSource
2040
1848
  ? agent._resolveModelSpecSource()
@@ -2050,6 +1858,7 @@ async function spawnClaudeTaskIsolatedExecution(agent, context, options = {}) {
2050
1858
  providerName,
2051
1859
  modelSpec,
2052
1860
  runOutputFormat,
1861
+ structuredOutputRecovery: options.structuredOutputRecovery === true,
2053
1862
  }),
2054
1863
  ];
2055
1864
  maybeLogStreamJsonNotice(agent, runOutputFormat);
@@ -2074,26 +1883,10 @@ async function spawnClaudeTaskIsolatedExecution(agent, context, options = {}) {
2074
1883
  const ownershipToken = createTaskSpawnOwnershipToken();
2075
1884
  // Auth env vars are injected by IsolationManager; the launch token is the
2076
1885
  // only authoritative bridge back to the detached task row in the container.
2077
- const effectiveOpenCodeConfig =
2078
- options.disableTools === true
2079
- ? await resolveIsolatedOpenCodeConfigUnderOwnership({
2080
- manager,
2081
- clusterId,
2082
- providerName,
2083
- executionHandle: options.executionHandle,
2084
- })
2085
- : null;
2086
- const isolatedEnv = applyOpenCodeToolBoundary(
2087
- {
2088
- ...(providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {}),
2089
- ...(effectiveOpenCodeConfig
2090
- ? { [OPENCODE_CONFIG_CONTENT_ENV]: effectiveOpenCodeConfig }
2091
- : {}),
2092
- [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken,
2093
- },
2094
- providerName,
2095
- options
2096
- );
1886
+ const isolatedEnv = {
1887
+ ...(providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {}),
1888
+ [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken,
1889
+ };
2097
1890
 
2098
1891
  if (options.executionHandle?.isCancelled) {
2099
1892
  throw createNestedCancellationError(options.executionHandle);
@@ -2156,8 +1949,7 @@ async function spawnClaudeTaskIsolatedExecution(agent, context, options = {}) {
2156
1949
  rejection.terminationExhausted = true;
2157
1950
  rejection.terminationAttempts = 1;
2158
1951
  if (options.nested) options.executionHandle?.retainOwnership();
2159
- rejection.taskId =
2160
- isolatedTaskId || (!options.nested ? agent.currentTaskId : null) || null;
1952
+ rejection.taskId = isolatedTaskId || (!options.nested ? agent.currentTaskId : null) || null;
2161
1953
  } else if (!options.nested && agent.currentTask === isolatedPendingLaunch) {
2162
1954
  agent.currentTask = null;
2163
1955
  }
@@ -2184,14 +1976,10 @@ async function spawnClaudeTaskIsolatedExecution(agent, context, options = {}) {
2184
1976
  commandError = error;
2185
1977
  }
2186
1978
 
2187
- if (!wrapperClosed) {
2188
- proc.kill('SIGKILL');
2189
- await waitForWrapperClose;
2190
- }
1979
+ await terminatePendingTaskWrapper(proc, waitForWrapperClose, () => wrapperClosed);
2191
1980
 
2192
1981
  try {
2193
- const persistedTaskId =
2194
- isolatedTaskId || (await findLatePersistedTaskId());
1982
+ const persistedTaskId = isolatedTaskId || (await findLatePersistedTaskId());
2195
1983
  if (persistedTaskId && !termination) {
2196
1984
  termination = await terminateIsolatedTask(manager, clusterId, persistedTaskId);
2197
1985
  commandError = null;
@@ -2214,8 +2002,8 @@ async function spawnClaudeTaskIsolatedExecution(agent, context, options = {}) {
2214
2002
  if (!resolved) {
2215
2003
  const error =
2216
2004
  (sourceError instanceof Error &&
2217
- sourceError.code === 'unsupported-capability' &&
2218
- sourceError.permanent === true
2005
+ sourceError.code === 'unsupported-capability' &&
2006
+ sourceError.permanent === true
2219
2007
  ? sourceError
2220
2008
  : null) ||
2221
2009
  timeoutError ||
@@ -2456,8 +2244,7 @@ async function terminateIsolatedTask(manager, clusterId, taskId) {
2456
2244
  );
2457
2245
  }
2458
2246
  return {
2459
- alreadyTerminal:
2460
- Boolean(beforeStatus) || Boolean(afterStatus && afterStatus !== 'killed'),
2247
+ alreadyTerminal: Boolean(beforeStatus) || Boolean(afterStatus && afterStatus !== 'killed'),
2461
2248
  forced: !beforeStatus && afterStatus === 'killed',
2462
2249
  status: beforeStatus || afterStatus,
2463
2250
  };
@@ -2526,29 +2313,49 @@ function settleIsolatedTerminalStatus({
2526
2313
  useVertex: process.env.CLAUDE_CODE_USE_VERTEX === '1',
2527
2314
  })
2528
2315
  : null;
2529
- const success = status === 'completed' && !vertexModelError;
2530
- const errorContext = !success
2531
- ? extractErrorContext({
2532
- output: state.fullOutput,
2533
- statusOutput: status ? `Status: ${status}` : '',
2534
- taskId,
2535
- isNotFound,
2536
- debug: {
2537
- agentId: agent.id,
2538
- providerName,
2539
- pid: agent.processPid,
2540
- cwd: agent.config.cwd || process.cwd(),
2541
- worktreePath: agent.worktree?.path || null,
2542
- isolation: true,
2543
- clusterId,
2544
- logFilePath,
2545
- },
2546
- })
2547
- : null;
2548
- const parsedResult =
2549
- state.skipStructuredResultCheck || vertexModelError
2550
- ? null
2316
+ const staleCandidate =
2317
+ status === 'stale' &&
2318
+ !vertexModelError &&
2319
+ determineStaleSuccess({ agent, output: state.fullOutput, providerName, taskId });
2320
+ let success = (status === 'completed' || staleCandidate) && !vertexModelError;
2321
+ let structuredError = null;
2322
+ if (staleCandidate && !state.skipStructuredResultCheck) {
2323
+ const evaluated = await evaluateStructuredSuccess({
2324
+ agent,
2325
+ taskId,
2326
+ state,
2327
+ success,
2328
+ allowRecovery: false,
2329
+ });
2330
+ success = evaluated.success;
2331
+ structuredError = evaluated.error;
2332
+ }
2333
+ const errorContext =
2334
+ structuredError ||
2335
+ (!success
2336
+ ? extractErrorContext({
2337
+ output: state.fullOutput,
2338
+ statusOutput: status ? `Status: ${status}` : '',
2339
+ taskId,
2340
+ isNotFound,
2341
+ debug: {
2342
+ agentId: agent.id,
2343
+ providerName,
2344
+ pid: agent.processPid,
2345
+ cwd: agent.config.cwd || process.cwd(),
2346
+ worktreePath: agent.worktree?.path || null,
2347
+ isolation: true,
2348
+ clusterId,
2349
+ logFilePath,
2350
+ },
2351
+ })
2352
+ : null);
2353
+ let parsedResult = null;
2354
+ if (success && !state.skipStructuredResultCheck && !vertexModelError) {
2355
+ parsedResult = staleCandidate
2356
+ ? state._cachedParsedResult
2551
2357
  : await agent._parseResultOutput(state.fullOutput);
2358
+ }
2552
2359
 
2553
2360
  settleIsolatedFollower({
2554
2361
  agent,
@@ -2844,8 +2651,7 @@ function followClaudeTaskLogsIsolated(agent, taskId, options = {}) {
2844
2651
  options.nested === true
2845
2652
  );
2846
2653
  const cleanup = buildIsolatedCleanup(state);
2847
- const onLine = (line) =>
2848
- broadcastIsolatedLine({ agent, providerName, taskId, state, line });
2654
+ const onLine = (line) => broadcastIsolatedLine({ agent, providerName, taskId, state, line });
2849
2655
  state.lifecycleHandle = buildIsolatedLifecycleHandle({
2850
2656
  agent,
2851
2657
  manager,
@@ -2962,65 +2768,72 @@ function followClaudeTaskLogsIsolated(agent, taskId, options = {}) {
2962
2768
  * @param {String} output - Raw output from agent
2963
2769
  * @returns {Promise<Object>} Parsed result data
2964
2770
  */
2965
- async function parseResultOutput(agent, output) {
2966
- // Empty outputs = FAIL
2771
+ async function parseResultOutput(agent, output, { allowRecovery = true } = {}) {
2967
2772
  if (!output || !output.trim()) {
2968
2773
  throw new Error('Task execution failed - no output');
2969
2774
  }
2970
2775
 
2971
2776
  const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
2972
2777
  const {
2973
- extractJsonFromOutput,
2974
2778
  extractCliError,
2779
+ extractJsonFromOutput,
2780
+ extractModelTextFromOutput,
2975
2781
  hasFatalStandaloneOutput,
2976
2782
  } = require('./output-extraction');
2977
-
2978
- // Check for CLI errors FIRST - surface the actual error message
2979
- const cliError = extractCliError(output);
2783
+ const cliError = extractCliError(output, providerName);
2980
2784
  if (cliError) {
2981
2785
  throw new Error(`CLI error (${cliError.provider}): ${cliError.error}`);
2982
2786
  }
2983
2787
 
2984
- // Use clean extraction pipeline
2985
- let parsed = extractJsonFromOutput(output, providerName);
2788
+ const parsed = extractJsonFromOutput(output, providerName);
2789
+ const schema = agent.config.jsonSchema;
2790
+ if (!schema) {
2791
+ if (parsed) return parsed;
2792
+ throw new Error(`Agent ${agent.id} output missing required JSON block`);
2793
+ }
2986
2794
 
2987
- // If extraction failed but we have a schema, attempt reformatting
2988
- if (!parsed && agent.config.jsonSchema) {
2989
- const { reformatOutput } = require('./output-reformatter');
2795
+ const { createStructuredOutputValidator, reformatOutput } = require('./output-reformatter');
2796
+ const validateCandidate = createStructuredOutputValidator(schema);
2797
+ const directValidation = parsed ? validateCandidate(parsed) : null;
2798
+ if (directValidation?.valid) {
2799
+ return directValidation.value;
2800
+ }
2990
2801
 
2991
- try {
2992
- parsed = await reformatOutput({
2993
- rawOutput: output,
2994
- schema: agent.config.jsonSchema,
2995
- providerName,
2996
- isCancelled: () => agent.running === false || agent.state === 'stopped',
2997
- runReformat: (prompt) =>
2998
- agent._spawnClaudeTask(prompt, {
2999
- skipStructuredResultCheck: true,
3000
- nested: true,
3001
- disableTools: true,
3002
- }),
3003
- onAttempt: (attempt, lastError) => {
3004
- if (lastError) {
3005
- console.warn(`[Agent ${agent.id}] Reformat attempt ${attempt}: ${lastError}`);
3006
- } else {
3007
- console.warn(
3008
- `[Agent ${agent.id}] JSON extraction failed, reformatting (attempt ${attempt})...`
3009
- );
3010
- }
3011
- },
3012
- });
3013
- } catch (reformatError) {
3014
- if (
3015
- reformatError.code === 'REFORMAT_CANCELLED' ||
3016
- reformatError.code === 'AGENT_TASK_TIMEOUT' ||
3017
- reformatError.permanent === true ||
3018
- isNestedLifecycleError(reformatError)
3019
- ) {
3020
- throw reformatError;
3021
- }
3022
- // Reformatting failed - fall through to error below
3023
- console.error(`[Agent ${agent.id}] Reformatting failed: ${reformatError.message}`);
2802
+ const schemaIsNonEmpty =
2803
+ typeof schema === 'string'
2804
+ ? schema.trim() !== '' && schema.trim() !== '{}'
2805
+ : typeof schema === 'object' && schema !== null && Object.keys(schema).length > 0;
2806
+ const { providerSupportsOutputReformatting } = require('../../lib/provider-names');
2807
+ let recovery = null;
2808
+ if (
2809
+ schemaIsNonEmpty &&
2810
+ !hasFatalStandaloneOutput(output) &&
2811
+ allowRecovery &&
2812
+ providerSupportsOutputReformatting(providerName)
2813
+ ) {
2814
+ const modelText = extractModelTextFromOutput(output, providerName);
2815
+ recovery = await reformatOutput({
2816
+ rawOutput: modelText || output,
2817
+ schema,
2818
+ providerName,
2819
+ initialError: directValidation?.error || null,
2820
+ validateCandidate,
2821
+ isCancelled: () => agent.running === false || agent.state === 'stopped',
2822
+ runReformat: (prompt) =>
2823
+ agent._spawnClaudeTask(prompt, {
2824
+ skipStructuredResultCheck: true,
2825
+ nested: true,
2826
+ structuredOutputRecovery: true,
2827
+ }),
2828
+ onAttempt: (attempt, lastError) => {
2829
+ const reason = lastError ? `: ${lastError}` : '';
2830
+ console.warn(
2831
+ `[Agent ${agent.id}] Correcting structured output (attempt ${attempt})${reason}`
2832
+ );
2833
+ },
2834
+ });
2835
+ if (recovery.status === 'recovered') {
2836
+ return recovery.value;
3024
2837
  }
3025
2838
  }
3026
2839
 
@@ -3028,69 +2841,40 @@ async function parseResultOutput(agent, output) {
3028
2841
  if (hasFatalStandaloneOutput(output)) {
3029
2842
  throw new Error('Task execution failed - no output');
3030
2843
  }
3031
- const trimmedOutput = output.trim();
3032
- console.error(`\n${'='.repeat(80)}`);
3033
- console.error(`🔴 AGENT OUTPUT MISSING REQUIRED JSON BLOCK`);
3034
- console.error(`${'='.repeat(80)}`);
3035
- console.error(`Agent: ${agent.id}, Role: ${agent.role}, Provider: ${providerName}`);
3036
- console.error(`Output (last 500 chars): ${trimmedOutput.slice(-500)}`);
3037
- console.error(`${'='.repeat(80)}\n`);
3038
- throw new Error(`Agent ${agent.id} output missing required JSON block`);
2844
+ const recoveryDetail = recovery?.lastError ? ` Recovery exhausted: ${recovery.lastError}.` : '';
2845
+ throw new Error(`Agent ${agent.id} output missing required JSON block.${recoveryDetail}`);
3039
2846
  }
3040
2847
 
3041
- // If a JSON schema is configured, validate parsed output locally.
3042
- // This preserves schema enforcement even when we run stream-json for live logs.
3043
- // IMPORTANT: For non-validator agents we warn but do not fail the cluster.
3044
- if (agent.config.jsonSchema) {
3045
- // Normalize enum values BEFORE validation (handles case mismatches, common variations)
3046
- // This is provider-agnostic - works for Claude CLI, Gemini, Codex, etc.
3047
- normalizeEnumValues(parsed, agent.config.jsonSchema);
3048
-
3049
- const Ajv = require('ajv');
3050
- const ajv = new Ajv({
3051
- allErrors: true,
3052
- strict: false,
3053
- coerceTypes: false, // STRICT: Reject type mismatches (e.g., null instead of array)
3054
- useDefaults: true,
3055
- removeAdditional: true,
3056
- });
3057
- const validate = ajv.compile(agent.config.jsonSchema);
3058
- const valid = validate(parsed);
3059
- if (!valid) {
3060
- const errorList = (validate.errors || [])
3061
- .slice(0, 5)
3062
- .map((e) => `${e.instancePath || e.schemaPath} ${e.message}`)
3063
- .join('; ');
3064
- const msg =
3065
- `Agent ${agent.id} output failed JSON schema validation: ` +
3066
- (errorList || 'unknown schema error');
3067
-
3068
- // Validators stay strict (they already have auto-approval fallback on crash).
3069
- if (agent.role === 'validator') {
3070
- throw new Error(msg);
3071
- }
2848
+ if (!allowRecovery) {
2849
+ const detail = directValidation?.error || 'required structured output is missing';
2850
+ throw new Error(`Agent ${agent.id} output is not valid for non-completed task: ${detail}`);
2851
+ }
3072
2852
 
3073
- // Non-validators: emit warning and continue with best-effort parsed data.
3074
- console.warn(`⚠️ ${msg}`);
3075
- agent._publish({
3076
- topic: 'AGENT_SCHEMA_WARNING',
3077
- receiver: 'broadcast',
3078
- content: {
3079
- text: msg,
3080
- data: {
3081
- agent: agent.id,
3082
- role: agent.role,
3083
- iteration: agent.iteration,
3084
- errors: validate.errors || [],
3085
- },
3086
- },
3087
- });
3088
- }
2853
+ const errorDetail = directValidation?.error || 'unknown schema error';
2854
+ const message = `Agent ${agent.id} output failed JSON schema validation: ${errorDetail}`;
2855
+ if (agent.role === 'validator') {
2856
+ throw new Error(
2857
+ recovery?.status === 'exhausted'
2858
+ ? `${message}. Recovery exhausted after ${recovery.attempts} attempts: ${recovery.lastError}`
2859
+ : message
2860
+ );
3089
2861
  }
3090
2862
 
3091
- // Return whatever the agent produced - no hardcoded field requirements
3092
- // Template substitution will validate that required fields exist
3093
- return parsed;
2863
+ console.warn(`⚠️ ${message}`);
2864
+ agent._publish({
2865
+ topic: 'AGENT_SCHEMA_WARNING',
2866
+ receiver: 'broadcast',
2867
+ content: {
2868
+ text: message,
2869
+ data: {
2870
+ agent: agent.id,
2871
+ role: agent.role,
2872
+ iteration: agent.iteration,
2873
+ errors: directValidation?.errors || [],
2874
+ },
2875
+ },
2876
+ });
2877
+ return directValidation?.value || parsed;
3094
2878
  }
3095
2879
 
3096
2880
  /**