@the-open-engine/zeroshot 6.10.2 → 6.12.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/commands/providers.js +13 -13
  2. package/cli/index.js +77 -35
  3. package/cli/lib/first-run.js +12 -11
  4. package/cli/lib/update-checker.js +517 -132
  5. package/lib/agent-cli-provider/adapters/opencode.d.ts.map +1 -1
  6. package/lib/agent-cli-provider/adapters/opencode.js +3 -0
  7. package/lib/agent-cli-provider/adapters/opencode.js.map +1 -1
  8. package/lib/agent-cli-provider/types.d.ts +1 -0
  9. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  10. package/lib/agent-cli-provider/types.js.map +1 -1
  11. package/lib/cluster/client.cjs +146 -0
  12. package/lib/cluster/client.d.ts +44 -0
  13. package/lib/cluster/client.mjs +141 -0
  14. package/lib/cluster/connection.cjs +382 -0
  15. package/lib/cluster/connection.d.ts +41 -0
  16. package/lib/cluster/connection.mjs +378 -0
  17. package/lib/cluster/errors.cjs +44 -0
  18. package/lib/cluster/errors.d.ts +23 -0
  19. package/lib/cluster/errors.mjs +32 -0
  20. package/lib/cluster/frames.cjs +49 -0
  21. package/lib/cluster/frames.d.ts +12 -0
  22. package/lib/cluster/frames.mjs +45 -0
  23. package/lib/cluster/generated/protocol-schema.cjs +5 -0
  24. package/lib/cluster/generated/protocol-schema.d.ts +1 -0
  25. package/lib/cluster/generated/protocol-schema.mjs +2 -0
  26. package/lib/cluster/generated/protocol.cjs +22 -0
  27. package/lib/cluster/generated/protocol.d.ts +781 -0
  28. package/lib/cluster/generated/protocol.mjs +19 -0
  29. package/lib/cluster/index.cjs +40 -0
  30. package/lib/cluster/index.d.ts +10 -0
  31. package/lib/cluster/index.mjs +6 -0
  32. package/lib/cluster/queue.cjs +71 -0
  33. package/lib/cluster/queue.d.ts +15 -0
  34. package/lib/cluster/queue.mjs +67 -0
  35. package/lib/cluster/socket.cjs +15 -0
  36. package/lib/cluster/socket.d.ts +11 -0
  37. package/lib/cluster/socket.mjs +12 -0
  38. package/lib/cluster/subscriptions.cjs +270 -0
  39. package/lib/cluster/subscriptions.d.ts +69 -0
  40. package/lib/cluster/subscriptions.mjs +264 -0
  41. package/lib/cluster/validators.cjs +46 -0
  42. package/lib/cluster/validators.d.ts +3 -0
  43. package/lib/cluster/validators.mjs +42 -0
  44. package/lib/settings.js +195 -23
  45. package/lib/setup-apply.js +45 -32
  46. package/lib/setup-undo.js +25 -16
  47. package/package.json +25 -3
  48. package/scripts/build-cluster.js +43 -0
  49. package/scripts/generate-cluster-types.js +203 -0
  50. package/scripts/release-dry-run.js +6 -6
  51. package/scripts/rust-distribution.js +933 -0
  52. package/src/agent/agent-hook-executor.js +14 -6
  53. package/src/agent/agent-lifecycle.js +25 -8
  54. package/src/agent/agent-task-executor.js +711 -139
  55. package/src/agent/output-reformatter.js +54 -41
  56. package/src/agent/pr-verification.js +11 -3
  57. package/src/agent/task-execution-handle.js +373 -0
  58. package/src/agent-cli-provider/adapters/opencode.ts +3 -0
  59. package/src/agent-cli-provider/types.ts +1 -0
  60. package/src/agent-wrapper.js +5 -2
  61. package/src/cluster/client.ts +199 -0
  62. package/src/cluster/connection.ts +300 -0
  63. package/src/cluster/errors.ts +32 -0
  64. package/src/cluster/frames.ts +44 -0
  65. package/src/cluster/generated/protocol-schema.ts +2 -0
  66. package/src/cluster/generated/protocol.ts +183 -0
  67. package/src/cluster/index.ts +38 -0
  68. package/src/cluster/queue.ts +84 -0
  69. package/src/cluster/socket.ts +31 -0
  70. package/src/cluster/subscriptions.ts +256 -0
  71. package/src/cluster/validators.ts +56 -0
  72. package/src/cluster/ws.d.ts +7 -0
  73. package/src/isolation-manager.js +16 -0
  74. package/src/issue-providers/jira-provider.js +1 -1
  75. package/src/issue-providers/linear-provider.js +4 -4
  76. package/task-lib/runner.js +3 -0
@@ -11,8 +11,13 @@
11
11
  */
12
12
 
13
13
  const { spawn, spawnSync } = require('child_process');
14
+ const { randomUUID } = require('crypto');
14
15
  const path = require('path');
15
16
  const fs = require('fs');
17
+ const {
18
+ getNestedExecutionRegistry,
19
+ TaskExecutionHandle,
20
+ } = require('./task-execution-handle');
16
21
  const os = require('os');
17
22
  const { parseProviderChunk, getProvider } = require('../providers');
18
23
  const { getTask, getTaskBySpawnOwnershipToken } = require('../../task-lib/store.js');
@@ -48,6 +53,207 @@ const {
48
53
  validateCompletedResumeIdentity,
49
54
  } = require('./provider-session');
50
55
  const { extractClaudeVertexModelError } = require('./output-extraction');
56
+ 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
+ }
51
257
 
52
258
  function runCommandWithTimeout(command, args, options = {}, callback = null) {
53
259
  const timeout = options.timeout ?? 30000;
@@ -449,19 +655,85 @@ function extractTokenUsage(output, providerName = 'claude') {
449
655
  * Spawn claude-zeroshots process and stream output via message bus
450
656
  * @param {Object} agent - Agent instance
451
657
  * @param {String} context - Context to pass to Claude
658
+ * @param {{skipStructuredResultCheck?: boolean}} [options] - Internal nested-task controls
452
659
  * @returns {Promise<Object>} Result object { success, output, error }
453
660
  */
454
- async function spawnClaudeTask(agent, context) {
661
+ function createExecutionHandle(agent, nested) {
662
+ const handle = new TaskExecutionHandle(agent.id);
663
+ if (!nested) return { handle, registry: null };
664
+
665
+ const registry = getNestedExecutionRegistry(agent);
666
+ registry.register(handle);
667
+ if (agent.timeout > 0) {
668
+ handle.armDeadline(agent.timeout);
669
+ }
670
+ return { handle, registry };
671
+ }
672
+
673
+ function isNestedLifecycleError(error) {
674
+ return (
675
+ error?.nestedExecutionLifecycle === true ||
676
+ error?.retainTaskHandle === true ||
677
+ error?.terminationExhausted === true
678
+ );
679
+ }
680
+
681
+ function isTerminationConfirmed(termination) {
682
+ return termination?.forced !== false || termination?.alreadyTerminal === true;
683
+ }
684
+
685
+ function createNestedCancellationError(handle) {
686
+ const error = new Error(handle.cancelReason || 'Nested task cancelled');
687
+ error.code = handle.cancelDetails.code || 'REFORMAT_CANCELLED';
688
+ error.taskId = handle.taskId;
689
+ error.nestedExecutionCancellation = true;
690
+ error.nestedExecutionLifecycle = true;
691
+ return error;
692
+ }
693
+
694
+ function retainUnconfirmedNestedTermination(handle, error, termination) {
695
+ error.nestedExecutionLifecycle = true;
696
+ handle.retainOwnership();
697
+ error.message += ` Nested task cleanup was not confirmed: ${
698
+ termination?.reason || 'termination status unavailable'
699
+ }`;
700
+ error.retainTaskHandle = true;
701
+ error.permanent = true;
702
+ error.restartExhausted = true;
703
+ error.terminationExhausted = true;
704
+ error.terminationAttempts = 1;
705
+ error.taskId = handle.taskId;
706
+ return error;
707
+ }
708
+
709
+ async function terminateNestedSetupFailure(handle, error) {
710
+ let termination;
711
+ try {
712
+ termination = await handle.cancel(error.message, { code: 'NESTED_SETUP_FAILED' });
713
+ } catch (cleanupError) {
714
+ termination = { forced: false, reason: cleanupError.message };
715
+ }
716
+ if (!isTerminationConfirmed(termination)) {
717
+ retainUnconfirmedNestedTermination(handle, error, termination);
718
+ }
719
+ }
720
+
721
+ async function settleRegisteredNestedHandle(registry, handle) {
722
+ await handle.waitForCancellation().catch(() => {
723
+ // A cleanup failure retains the handle for a later shutdown/kill retry.
724
+ });
725
+ handle.finishExecution();
726
+ if (handle.settled) {
727
+ registry.unregister(handle);
728
+ }
729
+ }
730
+
731
+ async function spawnClaudeTask(agent, context, options = {}) {
455
732
  const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
456
733
  const modelSpec = resolveAgentModelSpec(agent);
457
734
 
458
- const ctPath = getClaudeTasksPath();
735
+ const ctPath = agent.taskCliPath || getClaudeTasksPath();
459
736
  const cwd = agent.config.cwd || process.cwd();
460
-
461
- // Build zeroshot task run args.
462
- // CRITICAL: Default to strict schema validation to prevent cluster crashes from parse failures
463
- // strictSchema=true uses Claude CLI's native --json-schema enforcement (no streaming but guaranteed structure)
464
- // strictSchema=false uses stream-json with post-run validation (live logs but fragile)
465
737
  const { desiredOutputFormat, runOutputFormat } = resolveOutputFormatConfig(agent);
466
738
  const args = buildTaskRunArgs({
467
739
  agent,
@@ -470,28 +742,20 @@ async function spawnClaudeTask(agent, context) {
470
742
  runOutputFormat,
471
743
  });
472
744
 
473
- // NOTE: maxRetries is handled by the agent wrapper's internal retry loop,
474
- // not passed to the CLI. See _handleTrigger() for retry logic.
475
-
476
745
  maybeLogStreamJsonNotice(agent, runOutputFormat);
477
746
 
478
- // If schema enforcement is desired but we had to run stream-json for live logs,
479
- // add explicit output instructions so the model still knows the required shape.
480
747
  const finalContext = buildFinalContext({
481
748
  agent,
482
749
  context,
483
750
  desiredOutputFormat,
484
751
  runOutputFormat,
485
752
  });
486
-
487
753
  args.push(finalContext);
488
754
 
489
- // MOCK SUPPORT: Use injected mock function if provided
490
755
  if (agent.mockSpawnFn) {
491
- return agent.mockSpawnFn(args, { context });
756
+ return agent.mockSpawnFn(args, { context, options });
492
757
  }
493
758
 
494
- // SAFETY: Fail hard if testMode=true but no mock (should be caught in constructor)
495
759
  if (agent.testMode) {
496
760
  throw new Error(
497
761
  `AgentWrapper: testMode=true but attempting real Claude API call for agent '${agent.id}'. ` +
@@ -499,16 +763,11 @@ async function spawnClaudeTask(agent, context) {
499
763
  );
500
764
  }
501
765
 
502
- // ISOLATION MODE: Run inside Docker container
503
766
  if (agent.isolation?.enabled) {
504
- return spawnClaudeTaskIsolated(agent, context);
767
+ return spawnClaudeTaskIsolated(agent, context, options);
505
768
  }
769
+ options = ensureFormatterLaunchOptions(providerName, options);
506
770
 
507
- // NON-ISOLATION MODE: Load safety hooks through an additional per-run settings file. Claude
508
- // still reads the user's normal config and the repository's project/local config.
509
- // AskUserQuestion blocking handled via:
510
- // 1. Prompt injection (see agent-context-builder)
511
- // 2. PreToolUse hook (defense-in-depth) - activated by ZEROSHOT_BLOCK_ASK_USER env var
512
771
  const claudeSettingsPath =
513
772
  providerName === 'claude'
514
773
  ? prepareClaudeSettingsOverlay({
@@ -516,61 +775,118 @@ async function spawnClaudeTask(agent, context) {
516
775
  })
517
776
  : null;
518
777
 
778
+ const nested = options.nested === true;
779
+ const { handle, registry } = createExecutionHandle(agent, nested);
519
780
  let taskId;
520
781
  let pendingLaunch;
521
782
  try {
522
- const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec, { claudeSettingsPath });
523
- taskId = await spawnTaskProcess({
524
- agent,
525
- ctPath,
526
- args,
527
- cwd,
528
- spawnEnv,
529
- });
530
- pendingLaunch = agent.currentTask;
531
- } catch (error) {
532
- cleanupCallerOwnedCommand(error, () => cleanupClaudeSettingsOverlay(claudeSettingsPath));
533
- throw error;
534
- }
783
+ try {
784
+ const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec, {
785
+ claudeSettingsPath,
786
+ disableTools: options.disableTools === true,
787
+ formatterAgentName: options.formatterAgentName,
788
+ });
789
+ taskId = await spawnTaskProcess({
790
+ agent,
791
+ ctPath,
792
+ args,
793
+ cwd,
794
+ spawnEnv,
795
+ handle,
796
+ nested,
797
+ });
798
+ pendingLaunch = nested ? handle : agent.currentTask;
799
+ } catch (error) {
800
+ cleanupCallerOwnedCommand(error, () => cleanupClaudeSettingsOverlay(claudeSettingsPath));
801
+ throw error;
802
+ }
535
803
 
536
- // The task ID transfers provider and cleanup ownership to the detached
537
- // watcher. From this point, follower/PID observation failures must not remove
538
- // files that a live provider still reads.
539
- agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId}`);
540
- await waitForTaskReady(agent, taskId);
541
- if (pendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
804
+ if (!nested) {
805
+ agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId}`);
806
+ }
807
+ await waitForTaskReady(agent, taskId);
808
+ if (pendingLaunch?.cancelled || pendingLaunch?.isCancelled) {
809
+ throw nested
810
+ ? createNestedCancellationError(handle)
811
+ : new Error(`Task launch cancelled: ${taskId}`);
812
+ }
542
813
 
543
- const MAX_PID_POLLS = 30;
544
- const PID_POLL_DELAY = 100;
545
- let realPid = null;
546
- let terminalBeforePidObservation = false;
814
+ const MAX_PID_POLLS = 30;
815
+ const PID_POLL_DELAY = 100;
816
+ let realPid = null;
817
+ let terminalBeforePidObservation = false;
547
818
 
548
- for (let i = 0; i < MAX_PID_POLLS; i++) {
549
- const taskInfo = getTask(taskId);
550
- if (taskInfo?.pid) {
551
- realPid = taskInfo.pid;
552
- break;
553
- }
554
- if (taskInfo && ['completed', 'failed', 'killed', 'stale'].includes(taskInfo.status)) {
555
- terminalBeforePidObservation = true;
556
- break;
819
+ for (let i = 0; i < MAX_PID_POLLS; i++) {
820
+ const taskInfo = getTask(taskId);
821
+ if (taskInfo?.pid) {
822
+ realPid = taskInfo.pid;
823
+ break;
824
+ }
825
+ if (taskInfo && TASK_TERMINAL_STATUSES.has(taskInfo.status)) {
826
+ terminalBeforePidObservation = true;
827
+ break;
828
+ }
829
+ await new Promise((resolve) => setTimeout(resolve, PID_POLL_DELAY));
557
830
  }
558
- await new Promise((r) => setTimeout(r, PID_POLL_DELAY));
559
- }
560
831
 
561
- if (pendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
832
+ if (pendingLaunch?.cancelled || pendingLaunch?.isCancelled) {
833
+ throw nested
834
+ ? createNestedCancellationError(handle)
835
+ : new Error(`Task launch cancelled: ${taskId}`);
836
+ }
562
837
 
563
- if (realPid) {
564
- agent.processPid = realPid;
565
- agent._publishLifecycle('PROCESS_SPAWNED', { pid: realPid });
566
- agent._log(`📋 Agent ${agent.id}: Process PID: ${realPid}`);
567
- } else if (terminalBeforePidObservation) {
568
- agent._log(`📋 Agent ${agent.id}: Task finished before PID observation`);
569
- } else {
570
- agent._log(`⚠️ Agent ${agent.id}: PID not available (task may use non-standard watcher)`);
838
+ if (realPid) {
839
+ handle.assignPid(realPid);
840
+ if (!nested) {
841
+ agent.processPid = realPid;
842
+ agent._publishLifecycle('PROCESS_SPAWNED', { pid: realPid });
843
+ agent._log(`📋 Agent ${agent.id}: Process PID: ${realPid}`);
844
+ }
845
+ } else if (!nested && terminalBeforePidObservation) {
846
+ agent._log(`📋 Agent ${agent.id}: Task finished before PID observation`);
847
+ } else if (!nested) {
848
+ agent._log(`⚠️ Agent ${agent.id}: PID not available (task may use non-standard watcher)`);
849
+ }
850
+ const result = await followClaudeTaskLogs(agent, taskId, {
851
+ ...options,
852
+ executionHandle: nested ? handle : null,
853
+ });
854
+ if (nested && !result.success && !handle.isCancelled) {
855
+ const failure = new Error(result.error || `Nested task ${taskId} failed`);
856
+ await terminateNestedSetupFailure(handle, failure);
857
+ if (failure.permanent) throw failure;
858
+ return result;
859
+ }
860
+ if (nested && handle.isCancelled) {
861
+ await handle.waitForCancellation();
862
+ throw createNestedCancellationError(handle);
863
+ }
864
+ return result;
865
+ } catch (error) {
866
+ if (error.terminationExhausted === true && error.retainTaskHandle === true) {
867
+ throw error;
868
+ }
869
+ if (
870
+ nested &&
871
+ handle.isCancelled &&
872
+ handle.cancelDetails.code !== 'NESTED_SETUP_FAILED'
873
+ ) {
874
+ const termination = await handle.waitForCancellation();
875
+ const cancellationError = createNestedCancellationError(handle);
876
+ if (!isTerminationConfirmed(termination)) {
877
+ throw retainUnconfirmedNestedTermination(handle, cancellationError, termination);
878
+ }
879
+ throw cancellationError;
880
+ }
881
+ if (nested && taskId) {
882
+ await terminateNestedSetupFailure(handle, error);
883
+ }
884
+ throw error;
885
+ } finally {
886
+ if (nested) {
887
+ await settleRegisteredNestedHandle(registry, handle);
888
+ }
571
889
  }
572
-
573
- return followClaudeTaskLogs(agent, taskId);
574
890
  }
575
891
 
576
892
  function resolveAgentModelSpec(agent) {
@@ -740,7 +1056,7 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
740
1056
  worktreePath: agent.worktree?.path || null,
741
1057
  });
742
1058
 
743
- return spawnEnv;
1059
+ return applyOpenCodeToolBoundary(spawnEnv, providerName, options);
744
1060
  }
745
1061
 
746
1062
  function parseTaskIdFromOutput(stdout) {
@@ -748,8 +1064,6 @@ function parseTaskIdFromOutput(stdout) {
748
1064
  return match ? match[1] : null;
749
1065
  }
750
1066
 
751
- const TASK_TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed', 'stale']);
752
-
753
1067
  function assignDurableTaskId(agent, taskId) {
754
1068
  if (!taskId || agent.currentTaskId === taskId) return;
755
1069
  agent.currentTaskId = taskId;
@@ -766,6 +1080,7 @@ function createPendingTaskLaunchHandle({
766
1080
  findPersistedTaskId,
767
1081
  waitForWrapperClose,
768
1082
  isWrapperClosed,
1083
+ assignTaskId = (taskId) => assignDurableTaskId(agent, taskId),
769
1084
  }) {
770
1085
  let cancellation = null;
771
1086
  const handle = {
@@ -780,13 +1095,21 @@ function createPendingTaskLaunchHandle({
780
1095
  let commandAttempted = false;
781
1096
  const cancelTask = async (persistedTaskId) => {
782
1097
  commandAttempted = true;
783
- assignDurableTaskId(agent, persistedTaskId);
1098
+ assignTaskId(persistedTaskId);
784
1099
  try {
785
1100
  await runCommandWithTimeout(ctPath, ['kill', persistedTaskId], { timeout: 10000 });
786
1101
  } catch (error) {
787
1102
  commandError = error;
788
1103
  }
789
1104
  };
1105
+ const findLateTaskId = async () => {
1106
+ for (let attempt = 0; attempt < 10; attempt++) {
1107
+ const persistedTaskId = findPersistedTaskId();
1108
+ if (persistedTaskId) return persistedTaskId;
1109
+ await new Promise((resolve) => setTimeout(resolve, 50));
1110
+ }
1111
+ return null;
1112
+ };
790
1113
  if (taskId) await cancelTask(taskId);
791
1114
 
792
1115
  if (!isWrapperClosed()) {
@@ -794,7 +1117,7 @@ function createPendingTaskLaunchHandle({
794
1117
  await waitForWrapperClose;
795
1118
  }
796
1119
 
797
- taskId = taskId || findPersistedTaskId();
1120
+ taskId = taskId || (await findLateTaskId());
798
1121
  if (taskId && !commandAttempted) await cancelTask(taskId);
799
1122
  if (taskId && commandError === null) {
800
1123
  const task = getTask(taskId);
@@ -833,7 +1156,16 @@ function createPendingTaskLaunchHandle({
833
1156
  return handle;
834
1157
  }
835
1158
 
836
- function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs = 30000 }) {
1159
+ function spawnTaskProcess({
1160
+ agent,
1161
+ ctPath,
1162
+ args,
1163
+ cwd,
1164
+ spawnEnv,
1165
+ spawnTimeoutMs = 30000,
1166
+ handle = null,
1167
+ nested = false,
1168
+ }) {
837
1169
  // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it.
838
1170
  const SPAWN_TIMEOUT_MS = spawnTimeoutMs;
839
1171
  // spawn() throws on null bytes in argv; strip them before they get there.
@@ -849,6 +1181,9 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs =
849
1181
  windowsHide: true,
850
1182
  });
851
1183
 
1184
+ // Nested launches retain their own immutable process identity instead of
1185
+ // overwriting the parent agent's lifecycle fields.
1186
+ handle?.attachProcess(proc);
852
1187
  let wrapperClosed = false;
853
1188
  let resolveWrapperClose;
854
1189
  const waitForWrapperClose = new Promise((resolveClose) => {
@@ -868,8 +1203,14 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs =
868
1203
  findPersistedTaskId,
869
1204
  waitForWrapperClose,
870
1205
  isWrapperClosed: () => wrapperClosed,
1206
+ assignTaskId: nested
1207
+ ? (taskId) => handle?.assignTaskId(taskId)
1208
+ : (taskId) => assignDurableTaskId(agent, taskId),
871
1209
  });
872
- agent.currentTask = pendingLaunch;
1210
+ if (nested) {
1211
+ handle.setCancelAction((reason) => pendingLaunch.kill(reason));
1212
+ }
1213
+ if (!nested) agent.currentTask = pendingLaunch;
873
1214
 
874
1215
  // NOTE: Don't emit PROCESS_SPAWNED here - proc.pid is a wrapper that exits immediately.
875
1216
  // Real PID comes from task store after watcher spawns the actual CLI process.
@@ -882,7 +1223,10 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs =
882
1223
  const classifyCleanupOwnership = trackTaskWrapperCleanupOwnership(findPersistedTaskId);
883
1224
  const rejectWithOwnership = async (error) => {
884
1225
  const classifiedError = classifyCleanupOwnership(error);
885
- if (!callerOwnsCommandCleanup(classifiedError) && agent.currentTask === pendingLaunch) {
1226
+ if (
1227
+ !callerOwnsCommandCleanup(classifiedError) &&
1228
+ (nested || agent.currentTask === pendingLaunch)
1229
+ ) {
886
1230
  let termination;
887
1231
  try {
888
1232
  termination = await pendingLaunch.kill(classifiedError.message);
@@ -896,15 +1240,16 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs =
896
1240
  classifiedError.restartExhausted = true;
897
1241
  classifiedError.terminationExhausted = true;
898
1242
  classifiedError.terminationAttempts = 1;
899
- classifiedError.taskId = agent.currentTaskId || null;
900
- } else if (agent.currentTask === pendingLaunch) {
1243
+ if (nested) handle?.retainOwnership();
1244
+ classifiedError.taskId = nested ? handle?.taskId || null : agent.currentTaskId || null;
1245
+ } else if (!nested && agent.currentTask === pendingLaunch) {
901
1246
  agent.currentTask = null;
902
1247
  agent.currentTaskId = null;
903
1248
  agent.processPid = null;
904
1249
  agent.lastOutputTime = null;
905
1250
  agent.taskStartedAt = null;
906
1251
  }
907
- } else if (wrapperClosed && agent.currentTask === pendingLaunch) {
1252
+ } else if (!nested && wrapperClosed && agent.currentTask === pendingLaunch) {
908
1253
  agent.currentTask = null;
909
1254
  }
910
1255
  reject(classifiedError);
@@ -958,10 +1303,11 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs =
958
1303
  return;
959
1304
  }
960
1305
 
961
- assignDurableTaskId(agent, spawnedTaskId);
1306
+ handle?.assignTaskId(spawnedTaskId);
1307
+ if (!nested) assignDurableTaskId(agent, spawnedTaskId);
962
1308
 
963
- // Start liveness monitoring
964
- if (agent.enableLivenessCheck) {
1309
+ // Start liveness monitoring for top-level tasks only.
1310
+ if (!nested && agent.enableLivenessCheck) {
965
1311
  agent.taskStartedAt = Date.now();
966
1312
  agent.lastOutputTime = agent.taskStartedAt;
967
1313
  agent._startLivenessCheck();
@@ -988,7 +1334,7 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs =
988
1334
  * @returns {Promise<void>}
989
1335
  */
990
1336
  async function waitForTaskReady(agent, taskId, maxRetries = 10, delayMs = 200) {
991
- const ctPath = getClaudeTasksPath();
1337
+ const ctPath = agent.taskCliPath || getClaudeTasksPath();
992
1338
 
993
1339
  for (let i = 0; i < maxRetries; i++) {
994
1340
  let exists = false;
@@ -1085,7 +1431,9 @@ function broadcastAgentLine({ agent, providerName, state, line }) {
1085
1431
  const isValidJson = isValidJsonLine(content);
1086
1432
  state.output += content + '\n';
1087
1433
 
1088
- agent.lastOutputTime = Date.now();
1434
+ if (!state.nested) {
1435
+ agent.lastOutputTime = Date.now();
1436
+ }
1089
1437
 
1090
1438
  agent._publish({
1091
1439
  topic: 'AGENT_OUTPUT',
@@ -1199,10 +1547,19 @@ async function evaluateStructuredSuccess({ agent, taskId, state, success }) {
1199
1547
  if (!success || !requiresStructuredResult(agent)) {
1200
1548
  return { success, error: null };
1201
1549
  }
1550
+ // Short-circuit: if a previous pass already validated and cached the parsed
1551
+ // result (e.g. recovery model call), reuse it without a second parse.
1552
+ if (state._cachedParsedResult) {
1553
+ return { success: true, error: null };
1554
+ }
1202
1555
  try {
1203
- await agent._parseResultOutput(state.output);
1556
+ // Cache the validated parsed object so completion hooks and {{result.*}}
1557
+ // substitution consume it directly instead of re-parsing (which could
1558
+ // trigger a second recovery model call).
1559
+ state._cachedParsedResult = await agent._parseResultOutput(state.output);
1204
1560
  return { success: true, error: null };
1205
1561
  } catch (error) {
1562
+ if (isNestedLifecycleError(error)) throw error;
1206
1563
  const errorContext = sanitizeErrorMessage(error.message);
1207
1564
  console.warn(
1208
1565
  `[Agent ${agent.id}] Task ${taskId} reported completed but produced invalid structured output; ` +
@@ -1238,7 +1595,9 @@ async function buildCompletionResult({
1238
1595
  success,
1239
1596
  taskInfo = getTask(taskId),
1240
1597
  }) {
1241
- const classified = await evaluateStructuredSuccess({ agent, taskId, state, success });
1598
+ const classified = state.skipStructuredResultCheck
1599
+ ? { success, error: null }
1600
+ : await evaluateStructuredSuccess({ agent, taskId, state, success });
1242
1601
  const resumeIdentityError = classified.success ? validateCompletedResumeIdentity(taskInfo) : null;
1243
1602
  if (resumeIdentityError) {
1244
1603
  classified.success = false;
@@ -1261,6 +1620,9 @@ async function buildCompletionResult({
1261
1620
  return {
1262
1621
  success: classified.success,
1263
1622
  output: state.output,
1623
+ // Carry the validated parsed object (from recovery or direct extraction)
1624
+ // so downstream hooks and {{result.*}} substitution never re-parse.
1625
+ parsedResult: state._cachedParsedResult || null,
1264
1626
  error: errorContext,
1265
1627
  tokenUsage: extractTokenUsage(state.output, providerName),
1266
1628
  providerSession: providerSessionFromCompletedTask({
@@ -1280,7 +1642,9 @@ function finalizeLogFollow(agent, state) {
1280
1642
  if (state.statusCheckInterval) {
1281
1643
  clearInterval(state.statusCheckInterval);
1282
1644
  }
1283
- agent.currentTask = null;
1645
+ if (!state.nested) {
1646
+ agent.currentTask = null;
1647
+ }
1284
1648
  }
1285
1649
 
1286
1650
  function handleStatusExecError({ agent, state, ctPath, taskId, error, stderr, resolve }) {
@@ -1402,6 +1766,7 @@ function handleStatusCompletion({
1402
1766
  stdout,
1403
1767
  pollLogFile,
1404
1768
  resolve,
1769
+ reject,
1405
1770
  }) {
1406
1771
  const cleanStdout = stripAnsiCodes(stdout);
1407
1772
  const { isCompleted, isFailed, isStale, isKilled } = parseStatusFlags(cleanStdout);
@@ -1438,6 +1803,10 @@ function handleStatusCompletion({
1438
1803
  })
1439
1804
  .then(resolve)
1440
1805
  .catch((error) => {
1806
+ if (isNestedLifecycleError(error)) {
1807
+ reject(error);
1808
+ return;
1809
+ }
1441
1810
  resolve({
1442
1811
  success: false,
1443
1812
  output: state.output,
@@ -1456,7 +1825,9 @@ function buildKillHandler({ agent, taskId, state, providerName, resolve }) {
1456
1825
  if (state.resolved) return;
1457
1826
  state.resolved = true;
1458
1827
  finalizeLogFollow(agent, state);
1459
- agent._stopLivenessCheck();
1828
+ if (!state.nested) {
1829
+ agent._stopLivenessCheck();
1830
+ }
1460
1831
  resolve({
1461
1832
  success: false,
1462
1833
  output: state.output,
@@ -1469,9 +1840,20 @@ function buildKillHandler({ agent, taskId, state, providerName, resolve }) {
1469
1840
  };
1470
1841
  }
1471
1842
 
1472
- function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
1473
- return new Promise((resolve) => {
1843
+ function createLogFollower({
1844
+ agent,
1845
+ taskId,
1846
+ fsModule,
1847
+ ctPath,
1848
+ providerName,
1849
+ skipStructuredResultCheck = false,
1850
+ nested = false,
1851
+ executionHandle = null,
1852
+ }) {
1853
+ return new Promise((resolve, reject) => {
1474
1854
  const state = createLogFollowState();
1855
+ state.skipStructuredResultCheck = skipStructuredResultCheck;
1856
+ state.nested = nested;
1475
1857
 
1476
1858
  state.logFilePath = lookupLogFilePath(ctPath, taskId);
1477
1859
  if (state.logFilePath) {
@@ -1516,12 +1898,34 @@ function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
1516
1898
  stdout,
1517
1899
  pollLogFile,
1518
1900
  resolve,
1901
+ reject,
1519
1902
  });
1520
1903
  }
1521
1904
  );
1522
1905
  }, 1000);
1523
1906
 
1524
- agent.currentTask = buildKillHandler({ agent, taskId, state, providerName, resolve });
1907
+ const killHandler = buildKillHandler({ agent, taskId, state, providerName, resolve });
1908
+ if (nested && executionHandle) {
1909
+ executionHandle.setFailClosedAction((error) => {
1910
+ if (state.resolved) return;
1911
+ state.resolved = true;
1912
+ finalizeLogFollow(agent, state);
1913
+ reject(error);
1914
+ });
1915
+ let cancelPendingLaunch;
1916
+ const cancelExecution = async (reason, details) => {
1917
+ const termination = cancelPendingLaunch
1918
+ ? await cancelPendingLaunch(reason, details)
1919
+ : { forced: true, taskId };
1920
+ if (isTerminationConfirmed(termination)) {
1921
+ killHandler.kill(reason, details);
1922
+ }
1923
+ return termination;
1924
+ };
1925
+ cancelPendingLaunch = executionHandle.setCancelAction(cancelExecution);
1926
+ } else {
1927
+ agent.currentTask = killHandler;
1928
+ }
1525
1929
  });
1526
1930
  }
1527
1931
 
@@ -1532,12 +1936,21 @@ function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
1532
1936
  * @param {String} taskId - Task ID to follow
1533
1937
  * @returns {Promise<Object>} Result object { success, output, error }
1534
1938
  */
1535
- function followClaudeTaskLogs(agent, taskId) {
1939
+ function followClaudeTaskLogs(agent, taskId, options = {}) {
1536
1940
  const fsModule = require('fs');
1537
1941
  const ctPath = agent.taskCliPath || getClaudeTasksPath();
1538
1942
  const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
1539
1943
 
1540
- return createLogFollower({ agent, taskId, fsModule, ctPath, providerName });
1944
+ return createLogFollower({
1945
+ agent,
1946
+ taskId,
1947
+ fsModule,
1948
+ ctPath,
1949
+ providerName,
1950
+ skipStructuredResultCheck: options.skipStructuredResultCheck === true,
1951
+ nested: options.nested === true,
1952
+ executionHandle: options.executionHandle || null,
1953
+ });
1541
1954
  }
1542
1955
 
1543
1956
  // Cache zeroshot path at module load time (when PATH is correct)
@@ -1575,11 +1988,53 @@ function getClaudeTasksPath() {
1575
1988
  * Runs Claude CLI inside the container for full isolation
1576
1989
  * @param {Object} agent - Agent instance
1577
1990
  * @param {String} context - Context to pass to Claude
1991
+ * @param {{skipStructuredResultCheck?: boolean}} [options] - Internal nested-task controls
1578
1992
  * @returns {Promise<Object>} Result object { success, output, error }
1579
1993
  */
1580
- async function spawnClaudeTaskIsolated(agent, context) {
1994
+ async function spawnClaudeTaskIsolated(agent, context, options = {}) {
1995
+ const nested = options.nested === true;
1996
+ if (!nested) {
1997
+ return spawnClaudeTaskIsolatedExecution(agent, context, options);
1998
+ }
1999
+
2000
+ const { handle, registry } = createExecutionHandle(agent, true);
2001
+ try {
2002
+ const result = await spawnClaudeTaskIsolatedExecution(agent, context, {
2003
+ ...options,
2004
+ executionHandle: handle,
2005
+ });
2006
+ if (!result.success && !handle.isCancelled) {
2007
+ const failure = new Error(result.error || `Nested isolated task ${handle.taskId} failed`);
2008
+ await terminateNestedSetupFailure(handle, failure);
2009
+ if (failure.permanent) throw failure;
2010
+ return result;
2011
+ }
2012
+ if (handle.isCancelled) {
2013
+ await handle.waitForCancellation();
2014
+ throw createNestedCancellationError(handle);
2015
+ }
2016
+ return result;
2017
+ } catch (error) {
2018
+ if (error.terminationExhausted === true && error.retainTaskHandle === true) {
2019
+ throw error;
2020
+ }
2021
+ if (handle.isCancelled && handle.cancelDetails.code !== 'NESTED_SETUP_FAILED') {
2022
+ await handle.waitForCancellation();
2023
+ throw createNestedCancellationError(handle);
2024
+ }
2025
+ if (handle.taskId) {
2026
+ await terminateNestedSetupFailure(handle, error);
2027
+ }
2028
+ throw error;
2029
+ } finally {
2030
+ await settleRegisteredNestedHandle(registry, handle);
2031
+ }
2032
+ }
2033
+
2034
+ async function spawnClaudeTaskIsolatedExecution(agent, context, options = {}) {
1581
2035
  const { manager, clusterId } = agent.isolation;
1582
2036
  const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
2037
+ options = ensureFormatterLaunchOptions(providerName, options);
1583
2038
  const modelSpec = resolveAgentModelSpec(agent);
1584
2039
  const modelSpecSource = agent._resolveModelSpecSource
1585
2040
  ? agent._resolveModelSpecSource()
@@ -1619,16 +2074,36 @@ async function spawnClaudeTaskIsolated(agent, context) {
1619
2074
  const ownershipToken = createTaskSpawnOwnershipToken();
1620
2075
  // Auth env vars are injected by IsolationManager; the launch token is the
1621
2076
  // only authoritative bridge back to the detached task row in the container.
1622
- const isolatedEnv = {
1623
- ...(providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {}),
1624
- [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken,
1625
- };
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
+ );
1626
2097
 
2098
+ if (options.executionHandle?.isCancelled) {
2099
+ throw createNestedCancellationError(options.executionHandle);
2100
+ }
1627
2101
  let isolatedPendingLaunch = null;
1628
2102
  const taskId = await new Promise((resolve, reject) => {
1629
2103
  const proc = manager.spawnInContainer(clusterId, command, {
1630
2104
  env: isolatedEnv,
1631
2105
  });
2106
+ options.executionHandle?.attachProcess(proc);
1632
2107
 
1633
2108
  let isolatedTaskId = null;
1634
2109
  let wrapperClosed = false;
@@ -1656,10 +2131,19 @@ async function spawnClaudeTaskIsolated(agent, context) {
1656
2131
  );
1657
2132
  if (persistedTaskId) {
1658
2133
  isolatedTaskId = persistedTaskId;
1659
- assignDurableTaskId(agent, persistedTaskId);
2134
+ options.executionHandle?.assignTaskId(persistedTaskId);
2135
+ if (!options.nested) assignDurableTaskId(agent, persistedTaskId);
1660
2136
  }
1661
2137
  return persistedTaskId;
1662
2138
  };
2139
+ const findLatePersistedTaskId = async () => {
2140
+ for (let attempt = 0; attempt < 10; attempt++) {
2141
+ const persistedTaskId = await findPersistedTaskId();
2142
+ if (persistedTaskId) return persistedTaskId;
2143
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
2144
+ }
2145
+ return null;
2146
+ };
1663
2147
  const rejectLaunch = (error, { retainHandle = false } = {}) => {
1664
2148
  if (resolved) return;
1665
2149
  resolved = true;
@@ -1671,8 +2155,10 @@ async function spawnClaudeTaskIsolated(agent, context) {
1671
2155
  rejection.restartExhausted = true;
1672
2156
  rejection.terminationExhausted = true;
1673
2157
  rejection.terminationAttempts = 1;
1674
- rejection.taskId = isolatedTaskId || agent.currentTaskId || null;
1675
- } else if (agent.currentTask === isolatedPendingLaunch) {
2158
+ if (options.nested) options.executionHandle?.retainOwnership();
2159
+ rejection.taskId =
2160
+ isolatedTaskId || (!options.nested ? agent.currentTaskId : null) || null;
2161
+ } else if (!options.nested && agent.currentTask === isolatedPendingLaunch) {
1676
2162
  agent.currentTask = null;
1677
2163
  }
1678
2164
  reject(rejection);
@@ -1704,7 +2190,8 @@ async function spawnClaudeTaskIsolated(agent, context) {
1704
2190
  }
1705
2191
 
1706
2192
  try {
1707
- const persistedTaskId = isolatedTaskId || (await findPersistedTaskId());
2193
+ const persistedTaskId =
2194
+ isolatedTaskId || (await findLatePersistedTaskId());
1708
2195
  if (persistedTaskId && !termination) {
1709
2196
  termination = await terminateIsolatedTask(manager, clusterId, persistedTaskId);
1710
2197
  commandError = null;
@@ -1737,11 +2224,14 @@ async function spawnClaudeTaskIsolated(agent, context) {
1737
2224
  return termination;
1738
2225
  },
1739
2226
  };
1740
- agent.currentTask = isolatedPendingLaunch;
1741
-
1742
- // Track PID for resource monitoring
1743
- agent.processPid = proc.pid;
1744
- agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
2227
+ if (options.nested && options.executionHandle) {
2228
+ options.executionHandle.setCancelAction((reason) => isolatedPendingLaunch.kill(reason));
2229
+ }
2230
+ if (!options.nested) {
2231
+ agent.currentTask = isolatedPendingLaunch;
2232
+ agent.processPid = proc.pid;
2233
+ agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
2234
+ }
1745
2235
 
1746
2236
  // CRITICAL: Timeout to prevent infinite hang if provider CLI hangs. Timeout
1747
2237
  // uses the same cancellation path so a durable child cannot outlive it.
@@ -1784,7 +2274,8 @@ async function spawnClaudeTaskIsolated(agent, context) {
1784
2274
  persistedTaskId,
1785
2275
  });
1786
2276
  isolatedTaskId = spawnedTaskId;
1787
- assignDurableTaskId(agent, spawnedTaskId);
2277
+ options.executionHandle?.assignTaskId(spawnedTaskId);
2278
+ if (!options.nested) assignDurableTaskId(agent, spawnedTaskId);
1788
2279
  resolved = true;
1789
2280
  resolve(spawnedTaskId);
1790
2281
  } catch (error) {
@@ -1806,12 +2297,14 @@ async function spawnClaudeTaskIsolated(agent, context) {
1806
2297
  });
1807
2298
  if (isolatedPendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
1808
2299
 
1809
- agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId} in container...`);
2300
+ if (!options.nested) {
2301
+ agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId} in container...`);
2302
+ }
1810
2303
 
1811
2304
  // STEP 2: Install the lifecycle-owned handle before liveness monitoring can
1812
2305
  // observe the task, then follow the task's log file inside the container.
1813
- const execution = followClaudeTaskLogsIsolated(agent, taskId);
1814
- if (agent.enableLivenessCheck) {
2306
+ const execution = followClaudeTaskLogsIsolated(agent, taskId, options);
2307
+ if (!options.nested && agent.enableLivenessCheck) {
1815
2308
  agent.taskStartedAt = Date.now();
1816
2309
  agent.lastOutputTime = agent.taskStartedAt;
1817
2310
  agent._startLivenessCheck();
@@ -1842,11 +2335,13 @@ async function spawnClaudeTaskIsolated(agent, context) {
1842
2335
  * - Status checks reduced to every 2 seconds (not every poll)
1843
2336
  * - Result: 10-20% overall latency reduction
1844
2337
  */
1845
- function createIsolatedLogState() {
2338
+ function createIsolatedLogState(skipStructuredResultCheck = false, nested = false) {
1846
2339
  return {
1847
2340
  taskExited: false,
1848
2341
  resolved: false,
1849
2342
  terminationPromise: null,
2343
+ durableTaskTerminal: false,
2344
+ durableTaskStatus: null,
1850
2345
  lifecycleHandle: null,
1851
2346
  logFilePath: null,
1852
2347
  fullOutput: '',
@@ -1854,6 +2349,8 @@ function createIsolatedLogState() {
1854
2349
  statusCheckInterval: null,
1855
2350
  timeoutTimer: null,
1856
2351
  lineBuffer: '',
2352
+ skipStructuredResultCheck,
2353
+ nested,
1857
2354
  };
1858
2355
  }
1859
2356
 
@@ -1879,6 +2376,7 @@ function buildIsolatedCleanup(state) {
1879
2376
  }
1880
2377
 
1881
2378
  function clearIsolatedLifecycleHandle(agent, state) {
2379
+ if (state.nested) return;
1882
2380
  if (agent.currentTask === state.lifecycleHandle) {
1883
2381
  agent.currentTask = null;
1884
2382
  }
@@ -1952,9 +2450,9 @@ async function terminateIsolatedTask(manager, clusterId, taskId) {
1952
2450
  }`
1953
2451
  );
1954
2452
  }
1955
-
1956
2453
  return {
1957
- alreadyTerminal: Boolean(beforeStatus),
2454
+ alreadyTerminal:
2455
+ Boolean(beforeStatus) || Boolean(afterStatus && afterStatus !== 'killed'),
1958
2456
  forced: !beforeStatus && afterStatus === 'killed',
1959
2457
  status: beforeStatus || afterStatus,
1960
2458
  };
@@ -1995,14 +2493,20 @@ function settleIsolatedTerminalStatus({
1995
2493
  if (state.terminalSettlementPromise) return state.terminalSettlementPromise;
1996
2494
 
1997
2495
  state.taskExited = true;
2496
+ if (status) {
2497
+ state.durableTaskTerminal = true;
2498
+ state.durableTaskStatus = status;
2499
+ }
1998
2500
  const settlement = (async () => {
1999
2501
  const logFilePath = await resolveIsolatedLogFilePath(manager, clusterId, taskId, state);
2000
2502
  await new Promise((settle) => setTimeout(settle, 200));
2503
+ if (state.resolved) return;
2001
2504
  const finalReadResult = await manager.execInContainer(clusterId, [
2002
2505
  'sh',
2003
2506
  '-c',
2004
2507
  `cat "${logFilePath}" 2>/dev/null || echo ""`,
2005
2508
  ]);
2509
+ if (state.resolved) return;
2006
2510
 
2007
2511
  if (finalReadResult.code === 0 && finalReadResult.stdout) {
2008
2512
  state.fullOutput = finalReadResult.stdout;
@@ -2036,7 +2540,10 @@ function settleIsolatedTerminalStatus({
2036
2540
  },
2037
2541
  })
2038
2542
  : null;
2039
- const parsedResult = vertexModelError ? null : await agent._parseResultOutput(state.fullOutput);
2543
+ const parsedResult =
2544
+ state.skipStructuredResultCheck || vertexModelError
2545
+ ? null
2546
+ : await agent._parseResultOutput(state.fullOutput);
2040
2547
 
2041
2548
  settleIsolatedFollower({
2042
2549
  agent,
@@ -2047,7 +2554,7 @@ function settleIsolatedTerminalStatus({
2047
2554
  success,
2048
2555
  output: state.fullOutput,
2049
2556
  taskId,
2050
- result: parsedResult,
2557
+ parsedResult,
2051
2558
  error: errorContext,
2052
2559
  tokenUsage: extractTokenUsage(state.fullOutput, providerName),
2053
2560
  vertexModelError,
@@ -2072,12 +2579,40 @@ function buildIsolatedLifecycleHandle({
2072
2579
  reject,
2073
2580
  onLine,
2074
2581
  }) {
2582
+ const settleCancellation = (reason, details) =>
2583
+ settleIsolatedFollower({
2584
+ agent,
2585
+ state,
2586
+ cleanup,
2587
+ resolve,
2588
+ result: {
2589
+ success: false,
2590
+ output: state.fullOutput,
2591
+ error: reason,
2592
+ code: details.code || null,
2593
+ taskId,
2594
+ tokenUsage: extractTokenUsage(state.fullOutput, providerName),
2595
+ },
2596
+ });
2075
2597
  const terminate = (reason = 'Task killed', details = {}) => {
2076
- if (state.resolved || state.taskExited) return;
2598
+ if (state.durableTaskTerminal) {
2599
+ if (state.nested) settleCancellation(reason, details);
2600
+ return Promise.resolve({
2601
+ alreadyTerminal: true,
2602
+ forced: false,
2603
+ status: state.durableTaskStatus,
2604
+ });
2605
+ }
2077
2606
  if (state.terminationPromise) return state.terminationPromise;
2078
2607
 
2079
2608
  const terminationPromise = (async () => {
2080
2609
  const termination = await terminateIsolatedTask(manager, clusterId, taskId);
2610
+ state.durableTaskTerminal = true;
2611
+ state.durableTaskStatus = termination.status;
2612
+ if (!termination.forced && state.nested) {
2613
+ settleCancellation(reason, details);
2614
+ return termination;
2615
+ }
2081
2616
  if (!termination.forced) {
2082
2617
  await settleIsolatedTerminalStatus({
2083
2618
  agent,
@@ -2095,20 +2630,7 @@ function buildIsolatedLifecycleHandle({
2095
2630
  return termination;
2096
2631
  }
2097
2632
 
2098
- settleIsolatedFollower({
2099
- agent,
2100
- state,
2101
- cleanup,
2102
- resolve,
2103
- result: {
2104
- success: false,
2105
- output: state.fullOutput,
2106
- error: reason,
2107
- code: details.code || null,
2108
- taskId,
2109
- tokenUsage: extractTokenUsage(state.fullOutput, providerName),
2110
- },
2111
- });
2633
+ settleCancellation(reason, details);
2112
2634
  return termination;
2113
2635
  })();
2114
2636
  state.terminationPromise = terminationPromise;
@@ -2131,7 +2653,7 @@ function buildIsolatedLifecycleHandle({
2131
2653
  };
2132
2654
  }
2133
2655
 
2134
- function broadcastIsolatedLine({ agent, providerName, taskId, line }) {
2656
+ function broadcastIsolatedLine({ agent, providerName, taskId, state, line }) {
2135
2657
  const timestampMatch = line.match(/^\[(\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*(.*)$/);
2136
2658
  const timestamp = timestampMatch ? new Date(timestampMatch[1]).getTime() : Date.now();
2137
2659
  const content = timestampMatch ? timestampMatch[2] : line;
@@ -2152,7 +2674,9 @@ function broadcastIsolatedLine({ agent, providerName, taskId, line }) {
2152
2674
  timestamp,
2153
2675
  });
2154
2676
 
2155
- agent.lastOutputTime = Date.now();
2677
+ if (!state?.nested) {
2678
+ agent.lastOutputTime = Date.now();
2679
+ }
2156
2680
  }
2157
2681
 
2158
2682
  function appendIsolatedContent(state, content, onLine) {
@@ -2299,7 +2823,7 @@ function startIsolatedStatusChecks({
2299
2823
  }, 2000);
2300
2824
  }
2301
2825
 
2302
- function followClaudeTaskLogsIsolated(agent, taskId) {
2826
+ function followClaudeTaskLogsIsolated(agent, taskId, options = {}) {
2303
2827
  const { isolation } = agent;
2304
2828
  if (!isolation?.manager) {
2305
2829
  throw new Error('followClaudeTaskLogsIsolated: isolation manager not found');
@@ -2310,9 +2834,13 @@ function followClaudeTaskLogsIsolated(agent, taskId) {
2310
2834
  const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
2311
2835
 
2312
2836
  return new Promise((resolve, reject) => {
2313
- const state = createIsolatedLogState();
2837
+ const state = createIsolatedLogState(
2838
+ options.skipStructuredResultCheck === true,
2839
+ options.nested === true
2840
+ );
2314
2841
  const cleanup = buildIsolatedCleanup(state);
2315
- const onLine = (line) => broadcastIsolatedLine({ agent, providerName, taskId, line });
2842
+ const onLine = (line) =>
2843
+ broadcastIsolatedLine({ agent, providerName, taskId, state, line });
2316
2844
  state.lifecycleHandle = buildIsolatedLifecycleHandle({
2317
2845
  agent,
2318
2846
  manager,
@@ -2325,7 +2853,18 @@ function followClaudeTaskLogsIsolated(agent, taskId) {
2325
2853
  reject,
2326
2854
  onLine,
2327
2855
  });
2328
- agent.currentTask = state.lifecycleHandle;
2856
+ // Only register the lifecycle handle on the agent for top-level tasks.
2857
+ if (!options.nested) {
2858
+ agent.currentTask = state.lifecycleHandle;
2859
+ }
2860
+ if (options.nested && options.executionHandle) {
2861
+ options.executionHandle.setFailClosedAction((error) =>
2862
+ rejectIsolatedFollower({ agent, state, cleanup, reject, error })
2863
+ );
2864
+ options.executionHandle.setCancelAction((reason, details) =>
2865
+ state.lifecycleHandle.terminate(reason, details)
2866
+ );
2867
+ }
2329
2868
 
2330
2869
  manager
2331
2870
  .execInContainer(clusterId, ['sh', '-c', `zeroshot get-log-path ${taskId}`])
@@ -2353,7 +2892,11 @@ function followClaudeTaskLogsIsolated(agent, taskId) {
2353
2892
  });
2354
2893
  }
2355
2894
  state.logFilePath = logFilePath;
2356
- if (state.resolved || state.taskExited) {
2895
+ if (
2896
+ state.resolved ||
2897
+ state.taskExited ||
2898
+ (options.nested && options.executionHandle?.isCancelled)
2899
+ ) {
2357
2900
  return;
2358
2901
  }
2359
2902
 
@@ -2382,7 +2925,7 @@ function followClaudeTaskLogsIsolated(agent, taskId) {
2382
2925
  onLine,
2383
2926
  });
2384
2927
 
2385
- if (agent.timeout > 0 && !agent.enableLivenessCheck) {
2928
+ if (agent.timeout > 0 && !agent.enableLivenessCheck && !options.nested) {
2386
2929
  state.timeoutTimer = setTimeout(() => {
2387
2930
  state.lifecycleHandle
2388
2931
  .terminate(`Task timed out after ${agent.timeout}ms`, {
@@ -2445,6 +2988,13 @@ async function parseResultOutput(agent, output) {
2445
2988
  rawOutput: output,
2446
2989
  schema: agent.config.jsonSchema,
2447
2990
  providerName,
2991
+ isCancelled: () => agent.running === false || agent.state === 'stopped',
2992
+ runReformat: (prompt) =>
2993
+ agent._spawnClaudeTask(prompt, {
2994
+ skipStructuredResultCheck: true,
2995
+ nested: true,
2996
+ disableTools: true,
2997
+ }),
2448
2998
  onAttempt: (attempt, lastError) => {
2449
2999
  if (lastError) {
2450
3000
  console.warn(`[Agent ${agent.id}] Reformat attempt ${attempt}: ${lastError}`);
@@ -2456,6 +3006,14 @@ async function parseResultOutput(agent, output) {
2456
3006
  },
2457
3007
  });
2458
3008
  } catch (reformatError) {
3009
+ if (
3010
+ reformatError.code === 'REFORMAT_CANCELLED' ||
3011
+ reformatError.code === 'AGENT_TASK_TIMEOUT' ||
3012
+ reformatError.permanent === true ||
3013
+ isNestedLifecycleError(reformatError)
3014
+ ) {
3015
+ throw reformatError;
3016
+ }
2459
3017
  // Reformatting failed - fall through to error below
2460
3018
  console.error(`[Agent ${agent.id}] Reformatting failed: ${reformatError.message}`);
2461
3019
  }
@@ -2548,6 +3106,19 @@ async function killTask(agent, termination = 'Task killed') {
2548
3106
  const { reason, code } = normalizeTermination(termination);
2549
3107
  const currentTask = agent.currentTask;
2550
3108
  const taskId = agent.currentTaskId;
3109
+ const nestedRegistry = agent.nestedExecutions;
3110
+ const hadNestedExecutions = nestedRegistry?.hasActive === true;
3111
+ let nestedTermination = null;
3112
+
3113
+ if (hadNestedExecutions) {
3114
+ try {
3115
+ nestedTermination = await nestedRegistry.cancelAll(reason, { code });
3116
+ } catch (error) {
3117
+ return { forced: false, reason: error.message };
3118
+ }
3119
+ if (nestedTermination?.forced === false) return nestedTermination;
3120
+ if (!currentTask) return nestedTermination;
3121
+ }
2551
3122
 
2552
3123
  if (currentTask?.pendingLaunch && typeof currentTask.kill === 'function') {
2553
3124
  const pendingTermination = await currentTask.kill(reason, { code });
@@ -2590,6 +3161,7 @@ async function killTask(agent, termination = 'Task killed') {
2590
3161
  agent.processPid = null;
2591
3162
  agent.lastOutputTime = null;
2592
3163
  agent.taskStartedAt = null;
3164
+ return nestedTermination || undefined;
2593
3165
  }
2594
3166
 
2595
3167
  async function killIsolatedTask(agent, currentTask, taskId, reason, code) {