mstro-app 0.4.38 → 0.4.43

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 (198) hide show
  1. package/bin/commands/login.js +17 -7
  2. package/bin/commands/logout.js +14 -6
  3. package/bin/commands/status.js +9 -3
  4. package/bin/commands/whoami.js +10 -4
  5. package/bin/mstro.js +11 -1
  6. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  7. package/dist/server/cli/headless/claude-invoker-stream.js +1 -0
  8. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  9. package/dist/server/cli/headless/index.d.ts +1 -0
  10. package/dist/server/cli/headless/index.d.ts.map +1 -1
  11. package/dist/server/cli/headless/index.js +2 -0
  12. package/dist/server/cli/headless/index.js.map +1 -1
  13. package/dist/server/cli/headless/resilient-runner.d.ts +47 -0
  14. package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -0
  15. package/dist/server/cli/headless/resilient-runner.js +234 -0
  16. package/dist/server/cli/headless/resilient-runner.js.map +1 -0
  17. package/dist/server/cli/headless/retry-strategies.d.ts +44 -0
  18. package/dist/server/cli/headless/retry-strategies.d.ts.map +1 -0
  19. package/dist/server/cli/headless/retry-strategies.js +262 -0
  20. package/dist/server/cli/headless/retry-strategies.js.map +1 -0
  21. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.js +5 -0
  23. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  24. package/dist/server/cli/headless/tool-watchdog.d.ts +2 -0
  25. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  26. package/dist/server/cli/headless/tool-watchdog.js +31 -4
  27. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  28. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  29. package/dist/server/cli/improvisation-retry.js +1 -30
  30. package/dist/server/cli/improvisation-retry.js.map +1 -1
  31. package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
  32. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  33. package/dist/server/cli/improvisation-session-manager.js +16 -3
  34. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  35. package/dist/server/cli/prompt-builders.d.ts.map +1 -1
  36. package/dist/server/cli/prompt-builders.js +31 -13
  37. package/dist/server/cli/prompt-builders.js.map +1 -1
  38. package/dist/server/index.js +1 -9
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/server/mcp/bouncer-cli.js +5 -4
  41. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  42. package/dist/server/mcp/bouncer-haiku.js +1 -1
  43. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  44. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  45. package/dist/server/mcp/bouncer-integration.js +14 -8
  46. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  47. package/dist/server/mcp/security-patterns.js +1 -1
  48. package/dist/server/mcp/security-patterns.js.map +1 -1
  49. package/dist/server/services/plan/composer.d.ts.map +1 -1
  50. package/dist/server/services/plan/composer.js +19 -9
  51. package/dist/server/services/plan/composer.js.map +1 -1
  52. package/dist/server/services/plan/executor.d.ts +6 -1
  53. package/dist/server/services/plan/executor.d.ts.map +1 -1
  54. package/dist/server/services/plan/executor.js +158 -76
  55. package/dist/server/services/plan/executor.js.map +1 -1
  56. package/dist/server/services/plan/front-matter.d.ts +1 -0
  57. package/dist/server/services/plan/front-matter.d.ts.map +1 -1
  58. package/dist/server/services/plan/front-matter.js +6 -0
  59. package/dist/server/services/plan/front-matter.js.map +1 -1
  60. package/dist/server/services/plan/issue-classification.d.ts +11 -0
  61. package/dist/server/services/plan/issue-classification.d.ts.map +1 -0
  62. package/dist/server/services/plan/issue-classification.js +20 -0
  63. package/dist/server/services/plan/issue-classification.js.map +1 -0
  64. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  65. package/dist/server/services/plan/issue-prompt-builder.js +10 -5
  66. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  67. package/dist/server/services/plan/issue-retry.d.ts +0 -5
  68. package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
  69. package/dist/server/services/plan/issue-retry.js +12 -241
  70. package/dist/server/services/plan/issue-retry.js.map +1 -1
  71. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  72. package/dist/server/services/plan/parser-core.js +1 -0
  73. package/dist/server/services/plan/parser-core.js.map +1 -1
  74. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  75. package/dist/server/services/plan/review-gate.js +9 -6
  76. package/dist/server/services/plan/review-gate.js.map +1 -1
  77. package/dist/server/services/plan/types.d.ts +1 -0
  78. package/dist/server/services/plan/types.d.ts.map +1 -1
  79. package/dist/server/services/platform-credentials.d.ts.map +1 -1
  80. package/dist/server/services/platform-credentials.js +11 -4
  81. package/dist/server/services/platform-credentials.js.map +1 -1
  82. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  83. package/dist/server/services/terminal/pty-manager.js +7 -1
  84. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  85. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  86. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  87. package/dist/server/services/websocket/handler.d.ts +2 -0
  88. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  89. package/dist/server/services/websocket/handler.js +18 -7
  90. package/dist/server/services/websocket/handler.js.map +1 -1
  91. package/dist/server/services/websocket/plan-execution-handlers.js +6 -6
  92. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  93. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
  94. package/dist/server/services/websocket/quality-fix-agent.js +90 -42
  95. package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
  96. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  97. package/dist/server/services/websocket/quality-handlers.js +48 -7
  98. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  99. package/dist/server/services/websocket/quality-persistence.d.ts +22 -0
  100. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  101. package/dist/server/services/websocket/quality-persistence.js +48 -1
  102. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  103. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  104. package/dist/server/services/websocket/quality-review-agent.js +74 -32
  105. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  106. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  107. package/dist/server/services/websocket/quality-tools.js +18 -18
  108. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  109. package/dist/server/services/websocket/skill-handlers.d.ts +3 -1
  110. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  111. package/dist/server/services/websocket/skill-handlers.js +52 -41
  112. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  113. package/dist/server/services/websocket/skill-watcher.d.ts +17 -0
  114. package/dist/server/services/websocket/skill-watcher.d.ts.map +1 -0
  115. package/dist/server/services/websocket/skill-watcher.js +85 -0
  116. package/dist/server/services/websocket/skill-watcher.js.map +1 -0
  117. package/dist/server/services/websocket/types.d.ts +2 -268
  118. package/dist/server/services/websocket/types.d.ts.map +1 -1
  119. package/dist/server/services/websocket/types.js +0 -4
  120. package/dist/server/services/websocket/types.js.map +1 -1
  121. package/package.json +1 -1
  122. package/server/cli/headless/claude-invoker-stream.ts +1 -0
  123. package/server/cli/headless/index.ts +2 -0
  124. package/server/cli/headless/resilient-runner.ts +354 -0
  125. package/server/cli/headless/retry-strategies.ts +330 -0
  126. package/server/cli/headless/stall-assessor.ts +5 -0
  127. package/server/cli/headless/tool-watchdog.ts +40 -4
  128. package/server/cli/improvisation-retry.ts +1 -32
  129. package/server/cli/improvisation-session-manager.ts +17 -3
  130. package/server/cli/prompt-builders.ts +33 -12
  131. package/server/index.ts +1 -9
  132. package/server/mcp/bouncer-cli.ts +5 -4
  133. package/server/mcp/bouncer-haiku.ts +1 -1
  134. package/server/mcp/bouncer-integration.ts +15 -8
  135. package/server/mcp/security-patterns.ts +1 -1
  136. package/server/services/plan/agents/code-review.md +109 -0
  137. package/server/services/plan/agents/commit-message.md +26 -0
  138. package/server/services/plan/agents/execute-issue.md +10 -1
  139. package/server/services/plan/agents/fix-quality.md +24 -0
  140. package/server/services/plan/agents/pr-description.md +28 -0
  141. package/server/services/plan/composer.ts +20 -9
  142. package/server/services/plan/executor.ts +160 -76
  143. package/server/services/plan/front-matter.ts +7 -0
  144. package/server/services/plan/issue-classification.ts +21 -0
  145. package/server/services/plan/issue-prompt-builder.ts +11 -5
  146. package/server/services/plan/issue-retry.ts +15 -330
  147. package/server/services/plan/parser-core.ts +1 -0
  148. package/server/services/plan/review-gate.ts +9 -6
  149. package/server/services/plan/types.ts +3 -0
  150. package/server/services/platform-credentials.ts +10 -4
  151. package/server/services/terminal/pty-manager.ts +7 -1
  152. package/server/services/websocket/handler-context.ts +2 -0
  153. package/server/services/websocket/handler.ts +18 -8
  154. package/server/services/websocket/plan-execution-handlers.ts +7 -7
  155. package/server/services/websocket/quality-fix-agent.ts +86 -44
  156. package/server/services/websocket/quality-handlers.ts +48 -7
  157. package/server/services/websocket/quality-persistence.ts +75 -1
  158. package/server/services/websocket/quality-review-agent.ts +70 -31
  159. package/server/services/websocket/quality-tools.ts +16 -14
  160. package/server/services/websocket/skill-handlers.ts +50 -40
  161. package/server/services/websocket/skill-watcher.ts +79 -0
  162. package/server/services/websocket/types.ts +0 -311
  163. package/dist/server/services/deploy/ai-broker.d.ts +0 -63
  164. package/dist/server/services/deploy/ai-broker.d.ts.map +0 -1
  165. package/dist/server/services/deploy/ai-broker.js +0 -360
  166. package/dist/server/services/deploy/ai-broker.js.map +0 -1
  167. package/dist/server/services/deploy/board-execution-handler.d.ts +0 -114
  168. package/dist/server/services/deploy/board-execution-handler.d.ts.map +0 -1
  169. package/dist/server/services/deploy/board-execution-handler.js +0 -621
  170. package/dist/server/services/deploy/board-execution-handler.js.map +0 -1
  171. package/dist/server/services/deploy/credentials.d.ts +0 -35
  172. package/dist/server/services/deploy/credentials.d.ts.map +0 -1
  173. package/dist/server/services/deploy/credentials.js +0 -177
  174. package/dist/server/services/deploy/credentials.js.map +0 -1
  175. package/dist/server/services/deploy/deploy-ai-service.d.ts +0 -107
  176. package/dist/server/services/deploy/deploy-ai-service.d.ts.map +0 -1
  177. package/dist/server/services/deploy/deploy-ai-service.js +0 -294
  178. package/dist/server/services/deploy/deploy-ai-service.js.map +0 -1
  179. package/dist/server/services/deploy/headless-session-handler.d.ts +0 -94
  180. package/dist/server/services/deploy/headless-session-handler.d.ts.map +0 -1
  181. package/dist/server/services/deploy/headless-session-handler.js +0 -266
  182. package/dist/server/services/deploy/headless-session-handler.js.map +0 -1
  183. package/dist/server/services/websocket/deploy-handlers.d.ts +0 -14
  184. package/dist/server/services/websocket/deploy-handlers.d.ts.map +0 -1
  185. package/dist/server/services/websocket/deploy-handlers.js +0 -409
  186. package/dist/server/services/websocket/deploy-handlers.js.map +0 -1
  187. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +0 -11
  188. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +0 -1
  189. package/dist/server/services/websocket/handlers/deploy-handlers.js +0 -176
  190. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +0 -1
  191. package/server/cli/headless/RESEARCH.md +0 -627
  192. package/server/services/deploy/ai-broker.ts +0 -512
  193. package/server/services/deploy/board-execution-handler.ts +0 -847
  194. package/server/services/deploy/credentials.ts +0 -200
  195. package/server/services/deploy/deploy-ai-service.ts +0 -401
  196. package/server/services/deploy/headless-session-handler.ts +0 -414
  197. package/server/services/websocket/deploy-handlers.ts +0 -544
  198. package/server/services/websocket/handlers/deploy-handlers.ts +0 -228
@@ -0,0 +1,354 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Resilient Runner
6
+ *
7
+ * Wraps HeadlessRunner with composable retry strategies for tool timeouts,
8
+ * signal crashes, context loss, and premature completions. Configurable via
9
+ * policy levels: FULL (all strategies + best-result selection), STANDARD
10
+ * (basic retry without context-loss detection), or NONE (single attempt).
11
+ */
12
+
13
+ import { hlog, runWithFileLogger } from './headless-logger.js';
14
+ import {
15
+ createRetryState,
16
+ isResponseAbandoned,
17
+ type RetryConfig,
18
+ type RetryDecision,
19
+ type RetryState,
20
+ scoreResult,
21
+ tryContextLoss,
22
+ tryPrematureCompletion,
23
+ trySignalCrash,
24
+ tryToolTimeout,
25
+ } from './retry-strategies.js';
26
+ import { HeadlessRunner } from './runner.js';
27
+ import { assessBestResult } from './stall-assessor.js';
28
+ import type { ExecutionCheckpoint, SessionResult, ToolUseEvent } from './types.js';
29
+
30
+ export type RetryPolicy = 'FULL' | 'STANDARD' | 'NONE';
31
+
32
+ export interface ResilientRunnerConfig {
33
+ workingDir: string;
34
+ prompt: string;
35
+
36
+ policy?: RetryPolicy;
37
+ maxRetries?: number;
38
+
39
+ stallWarningMs?: number;
40
+ stallKillMs?: number;
41
+ stallHardCapMs?: number;
42
+ stallMaxExtensions?: number;
43
+
44
+ outputCallback?: (text: string) => void;
45
+ thinkingCallback?: (text: string) => void;
46
+ toolUseCallback?: (event: ToolUseEvent) => void;
47
+ tokenUsageCallback?: (usage: { inputTokens: number; outputTokens: number }) => void;
48
+
49
+ logLabel?: string;
50
+ logDir?: string;
51
+
52
+ abortSignal?: AbortSignal;
53
+ verbose?: boolean;
54
+ model?: string;
55
+ extraEnv?: Record<string, string>;
56
+
57
+ maxAutoContinues?: number;
58
+
59
+ onRetry?: (info: { retryNumber: number; maxRetries: number; path: string; reason: string }) => void;
60
+ }
61
+
62
+ function resolveRetryConfig(policy: RetryPolicy, verbose: boolean): RetryConfig {
63
+ switch (policy) {
64
+ case 'FULL':
65
+ return { enableContextLossDetection: true, enableBestResultSelection: true, verbose };
66
+ case 'STANDARD':
67
+ return { enableContextLossDetection: false, enableBestResultSelection: false, verbose };
68
+ case 'NONE':
69
+ return { enableContextLossDetection: false, enableBestResultSelection: false, verbose };
70
+ }
71
+ }
72
+
73
+ function wireAbortSignal(runner: HeadlessRunner, abortSignal?: AbortSignal): (() => void) | null {
74
+ if (!abortSignal) return null;
75
+ const handler = () => { runner.cleanup(); };
76
+ abortSignal.addEventListener('abort', handler, { once: true });
77
+ return () => abortSignal.removeEventListener('abort', handler);
78
+ }
79
+
80
+ function abortedFallback(bestResult: SessionResult | null): SessionResult {
81
+ return bestResult ?? {
82
+ completed: false,
83
+ needsHandoff: false,
84
+ totalTokens: 0,
85
+ sessionId: '',
86
+ error: 'Execution stopped by user',
87
+ };
88
+ }
89
+
90
+ function buildResultSummary(r: SessionResult): {
91
+ successfulToolCalls: number;
92
+ responseLength: number;
93
+ hasThinking: boolean;
94
+ responseTail: string;
95
+ } {
96
+ return {
97
+ successfulToolCalls: r.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
98
+ responseLength: r.assistantResponse?.length ?? 0,
99
+ hasThinking: !!r.thinkingOutput,
100
+ responseTail: (r.assistantResponse ?? '').slice(-500),
101
+ };
102
+ }
103
+
104
+ export class ResilientRunner {
105
+ private readonly config: ResilientRunnerConfig;
106
+
107
+ constructor(config: ResilientRunnerConfig) {
108
+ this.config = config;
109
+ }
110
+
111
+ async run(): Promise<SessionResult> {
112
+ const { logLabel, logDir } = this.config;
113
+ if (logLabel) {
114
+ return runWithFileLogger(logLabel, () => this.execute(), logDir);
115
+ }
116
+ return this.execute();
117
+ }
118
+
119
+ private async execute(): Promise<SessionResult> {
120
+ const policy = this.config.policy ?? 'STANDARD';
121
+ const maxRetries = this.config.maxRetries ?? 3;
122
+ const verbose = this.config.verbose ?? true;
123
+ const maxAutoContinues = this.config.maxAutoContinues ?? (policy === 'NONE' ? 0 : 1);
124
+ const retryConfig = resolveRetryConfig(policy, verbose);
125
+
126
+ if (policy === 'NONE') {
127
+ return this.runSingle(this.config.prompt, false, undefined);
128
+ }
129
+
130
+ const state = createRetryState(this.config.prompt, maxRetries);
131
+ const loopResult = await this.retryLoop(state, retryConfig, policy, maxRetries);
132
+
133
+ let result: SessionResult;
134
+ if (retryConfig.enableBestResultSelection && state.retryNumber > 0 && state.bestResult && loopResult) {
135
+ result = await this.selectBestResult(state.bestResult, loopResult, this.config.prompt);
136
+ } else {
137
+ result = loopResult ?? abortedFallback(state.bestResult);
138
+ }
139
+
140
+ return this.autoContinue(result, retryConfig, policy, maxRetries, maxAutoContinues);
141
+ }
142
+
143
+ private async retryLoop(
144
+ state: RetryState,
145
+ retryConfig: RetryConfig,
146
+ policy: RetryPolicy,
147
+ maxRetries: number,
148
+ initialPrompt?: string,
149
+ initialResumeSessionId?: string,
150
+ ): Promise<SessionResult | undefined> {
151
+ let lastResult: SessionResult | undefined;
152
+ let currentPrompt = initialPrompt ?? this.config.prompt;
153
+ let resumeSessionId = initialResumeSessionId;
154
+ let useResume = !!initialResumeSessionId;
155
+
156
+ while (state.retryNumber <= maxRetries) {
157
+ if (this.config.abortSignal?.aborted) return undefined;
158
+
159
+ state.checkpoint = null;
160
+ const result = await this.runAttempt(currentPrompt, useResume, resumeSessionId, state);
161
+ if (this.config.abortSignal?.aborted) return state.bestResult ?? result;
162
+
163
+ if (!state.bestResult || scoreResult(result) > scoreResult(state.bestResult)) {
164
+ state.bestResult = result;
165
+ }
166
+ lastResult = result;
167
+
168
+ const decision = await this.evaluateStrategies(result, state, retryConfig, policy);
169
+ if (!decision) break;
170
+
171
+ state.checkpoint = null;
172
+ currentPrompt = decision.nextPrompt;
173
+ resumeSessionId = decision.resumeSessionId;
174
+ useResume = decision.useResume;
175
+
176
+ hlog(`[RESILIENT] Retry ${state.retryNumber}/${maxRetries}: ${decision.path} — ${decision.reason}`);
177
+ this.config.onRetry?.({
178
+ retryNumber: state.retryNumber,
179
+ maxRetries,
180
+ path: decision.path,
181
+ reason: decision.reason,
182
+ });
183
+ }
184
+
185
+ return lastResult;
186
+ }
187
+
188
+ private async runAttempt(
189
+ prompt: string,
190
+ useResume: boolean,
191
+ resumeSessionId: string | undefined,
192
+ state: RetryState,
193
+ ): Promise<SessionResult> {
194
+ const runner = this.createHeadlessRunner(prompt, useResume, resumeSessionId, state);
195
+ const removeAbortListener = wireAbortSignal(runner, this.config.abortSignal);
196
+ const result = await runner.run();
197
+ removeAbortListener?.();
198
+ return result;
199
+ }
200
+
201
+ private createHeadlessRunner(
202
+ prompt: string,
203
+ continueSession: boolean,
204
+ claudeSessionId: string | undefined,
205
+ state: RetryState,
206
+ ): HeadlessRunner {
207
+ return new HeadlessRunner({
208
+ workingDir: this.config.workingDir,
209
+ directPrompt: prompt,
210
+ stallWarningMs: this.config.stallWarningMs,
211
+ stallKillMs: this.config.stallKillMs,
212
+ stallHardCapMs: this.config.stallHardCapMs,
213
+ stallMaxExtensions: this.config.stallMaxExtensions,
214
+ verbose: this.config.verbose ?? true,
215
+ continueSession: continueSession || undefined,
216
+ claudeSessionId,
217
+ outputCallback: this.config.outputCallback,
218
+ thinkingCallback: this.config.thinkingCallback,
219
+ toolUseCallback: this.config.toolUseCallback,
220
+ tokenUsageCallback: this.config.tokenUsageCallback,
221
+ model: this.config.model,
222
+ extraEnv: this.config.extraEnv,
223
+ onToolTimeout: (cp: ExecutionCheckpoint) => {
224
+ state.checkpoint = cp;
225
+ },
226
+ });
227
+ }
228
+
229
+ private async evaluateStrategies(
230
+ result: SessionResult,
231
+ state: RetryState,
232
+ retryConfig: RetryConfig,
233
+ policy: RetryPolicy,
234
+ ): Promise<RetryDecision | null> {
235
+ const toolTimeout = tryToolTimeout(result, state);
236
+ if (toolTimeout) return toolTimeout;
237
+
238
+ const signalCrash = trySignalCrash(result, state);
239
+ if (signalCrash) return signalCrash;
240
+
241
+ if (policy === 'FULL') {
242
+ const contextLoss = await tryContextLoss(result, state, retryConfig);
243
+ if (contextLoss) return contextLoss;
244
+ }
245
+
246
+ const premature = await tryPrematureCompletion(result, state, retryConfig);
247
+ if (premature) return premature;
248
+
249
+ return null;
250
+ }
251
+
252
+ private async selectBestResult(
253
+ bestResult: SessionResult,
254
+ finalResult: SessionResult,
255
+ originalPrompt: string,
256
+ ): Promise<SessionResult> {
257
+ if (bestResult === finalResult) return finalResult;
258
+
259
+ try {
260
+ const verdict = await assessBestResult(
261
+ {
262
+ originalPrompt,
263
+ resultA: buildResultSummary(bestResult),
264
+ resultB: buildResultSummary(finalResult),
265
+ },
266
+ process.env.CLAUDE_COMMAND || 'claude',
267
+ this.config.verbose ?? true,
268
+ );
269
+
270
+ hlog(`[RESILIENT] Best result selection: ${verdict.winner} — ${verdict.reason}`);
271
+ return verdict.winner === 'A' ? bestResult : finalResult;
272
+ } catch (err) {
273
+ hlog(`[RESILIENT] Best result assessment failed, using score: ${err}`);
274
+ return scoreResult(bestResult) >= scoreResult(finalResult) ? bestResult : finalResult;
275
+ }
276
+ }
277
+
278
+ private shouldAutoContinue(result: SessionResult): boolean {
279
+ if (!result.claudeSessionId) return false;
280
+
281
+ if (result.signalName) return true;
282
+
283
+ if (result.completed && !result.signalName && result.stopReason === 'end_turn') {
284
+ return isResponseAbandoned(result);
285
+ }
286
+
287
+ return false;
288
+ }
289
+
290
+ private async autoContinue(
291
+ initialResult: SessionResult,
292
+ retryConfig: RetryConfig,
293
+ policy: RetryPolicy,
294
+ maxRetries: number,
295
+ maxAutoContinues: number,
296
+ ): Promise<SessionResult> {
297
+ let result = initialResult;
298
+
299
+ for (let count = 0; count < maxAutoContinues; count++) {
300
+ if (this.config.abortSignal?.aborted) break;
301
+ if (!this.shouldAutoContinue(result)) break;
302
+
303
+ const reason = result.signalName ? 'Process stalled' : 'Response appears incomplete';
304
+ hlog(`[RESILIENT] Auto-continue ${count + 1}/${maxAutoContinues}: ${reason}`);
305
+ this.config.onRetry?.({
306
+ retryNumber: count + 1,
307
+ maxRetries: maxAutoContinues,
308
+ path: 'AutoContinue',
309
+ reason,
310
+ });
311
+
312
+ const continueState = createRetryState('continue', maxRetries);
313
+ const continueResult = await this.retryLoop(
314
+ continueState, retryConfig, policy, maxRetries,
315
+ 'continue', result.claudeSessionId,
316
+ );
317
+
318
+ if (!continueResult) break;
319
+ result = scoreResult(continueResult) >= scoreResult(result) ? continueResult : result;
320
+ }
321
+
322
+ return result;
323
+ }
324
+
325
+ private async runSingle(
326
+ prompt: string,
327
+ continueSession: boolean,
328
+ claudeSessionId: string | undefined,
329
+ ): Promise<SessionResult> {
330
+ const runner = new HeadlessRunner({
331
+ workingDir: this.config.workingDir,
332
+ directPrompt: prompt,
333
+ stallWarningMs: this.config.stallWarningMs,
334
+ stallKillMs: this.config.stallKillMs,
335
+ stallHardCapMs: this.config.stallHardCapMs,
336
+ stallMaxExtensions: this.config.stallMaxExtensions,
337
+ verbose: this.config.verbose ?? true,
338
+ continueSession: continueSession || undefined,
339
+ claudeSessionId,
340
+ outputCallback: this.config.outputCallback,
341
+ thinkingCallback: this.config.thinkingCallback,
342
+ toolUseCallback: this.config.toolUseCallback,
343
+ tokenUsageCallback: this.config.tokenUsageCallback,
344
+ model: this.config.model,
345
+ extraEnv: this.config.extraEnv,
346
+ });
347
+
348
+ const removeAbortListener = wireAbortSignal(runner, this.config.abortSignal);
349
+ const result = await runner.run();
350
+ removeAbortListener?.();
351
+
352
+ return result;
353
+ }
354
+ }
@@ -0,0 +1,330 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Shared retry strategy functions for headless execution.
6
+ *
7
+ * Pure decision functions that evaluate a SessionResult and RetryState,
8
+ * returning a RetryDecision when a retry should happen or null to skip.
9
+ * Used by both PM board execution (issue-retry) and Chat view (improvisation-retry).
10
+ */
11
+
12
+ import {
13
+ buildFreshRecoveryPrompt,
14
+ buildResumeRetryPrompt,
15
+ buildRetryPrompt,
16
+ buildSignalCrashRecoveryPrompt,
17
+ } from '../prompt-builders.js';
18
+ import { hlog } from './headless-logger.js';
19
+ import { assessContextLoss, assessPrematureCompletion } from './stall-assessor.js';
20
+ import type { ExecutionCheckpoint, SessionResult } from './types.js';
21
+
22
+ export interface ToolRecord {
23
+ toolName: string;
24
+ toolId: string;
25
+ toolInput: Record<string, unknown>;
26
+ result?: string;
27
+ isError?: boolean;
28
+ duration?: number;
29
+ }
30
+
31
+ export interface RetryState {
32
+ retryNumber: number;
33
+ maxRetries: number;
34
+ originalPrompt: string;
35
+ accumulatedToolResults: ToolRecord[];
36
+ timedOutTools: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>;
37
+ checkpoint: ExecutionCheckpoint | null;
38
+ bestResult: SessionResult | null;
39
+ }
40
+
41
+ export interface RetryDecision {
42
+ nextPrompt: string;
43
+ useResume: boolean;
44
+ resumeSessionId?: string;
45
+ path: string;
46
+ reason: string;
47
+ }
48
+
49
+ export interface RetryConfig {
50
+ enableContextLossDetection: boolean;
51
+ enableBestResultSelection: boolean;
52
+ verbose: boolean;
53
+ }
54
+
55
+ const MAX_ACCUMULATED_RESULTS = 50;
56
+
57
+ export function createRetryState(originalPrompt: string, maxRetries: number): RetryState {
58
+ return {
59
+ retryNumber: 0,
60
+ maxRetries,
61
+ originalPrompt,
62
+ accumulatedToolResults: [],
63
+ timedOutTools: [],
64
+ checkpoint: null,
65
+ bestResult: null,
66
+ };
67
+ }
68
+
69
+ export function accumulateToolResults(result: SessionResult, state: RetryState): void {
70
+ if (!result.toolUseHistory) return;
71
+ for (const t of result.toolUseHistory) {
72
+ if (t.result !== undefined) {
73
+ state.accumulatedToolResults.push({
74
+ toolName: t.toolName,
75
+ toolId: t.toolId,
76
+ toolInput: t.toolInput,
77
+ result: t.result,
78
+ isError: t.isError,
79
+ duration: t.duration,
80
+ });
81
+ }
82
+ }
83
+ if (state.accumulatedToolResults.length > MAX_ACCUMULATED_RESULTS) {
84
+ state.accumulatedToolResults = state.accumulatedToolResults.slice(-MAX_ACCUMULATED_RESULTS);
85
+ }
86
+ }
87
+
88
+ export function scoreResult(r: SessionResult): number {
89
+ const toolCount = r.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
90
+ const responseLen = Math.min((r.assistantResponse?.length ?? 0) / 50, 100);
91
+ const hasThinking = r.thinkingOutput ? 20 : 0;
92
+ return toolCount * 10 + responseLen + hasThinking;
93
+ }
94
+
95
+ export function isResponseAbandoned(result: SessionResult): boolean {
96
+ const thinkingLen = result.thinkingOutput?.length ?? 0;
97
+ const responseLen = result.assistantResponse?.length ?? 0;
98
+ const toolCallsInResponse = result.toolUseHistory?.filter(t => t.result !== undefined).length ?? 0;
99
+
100
+ if (thinkingLen < 500 || responseLen > 1000) return false;
101
+ if (toolCallsInResponse > 0 && responseLen > 200) return false;
102
+
103
+ return thinkingLen >= responseLen * 3;
104
+ }
105
+
106
+ export function extractFinalTextBlock(response: string, maxLen: number): string {
107
+ const lastBreak = response.lastIndexOf('\n\n');
108
+ if (lastBreak !== -1 && response.length - lastBreak > 20) {
109
+ return response.slice(lastBreak + 2).slice(-maxLen);
110
+ }
111
+ return response.slice(-maxLen);
112
+ }
113
+
114
+ export function tryToolTimeout(_result: SessionResult, state: RetryState): RetryDecision | null {
115
+ if (!state.checkpoint || state.retryNumber >= state.maxRetries) return null;
116
+
117
+ const cp = state.checkpoint;
118
+ state.retryNumber++;
119
+
120
+ state.timedOutTools.push({
121
+ toolName: cp.hungTool.toolName,
122
+ input: cp.hungTool.input ?? {},
123
+ timeoutMs: cp.hungTool.timeoutMs,
124
+ });
125
+
126
+ const canResume = cp.inProgressTools.length === 0 && !!cp.claudeSessionId;
127
+ const reason = `${cp.hungTool.toolName} timed out after ${Math.round(cp.hungTool.timeoutMs / 1000)}s, ${cp.completedTools.length} tools completed, ${canResume ? 'resuming' : 'fresh start'}`;
128
+
129
+ hlog(`[RETRY] Tool timeout: ${reason} (retry ${state.retryNumber}/${state.maxRetries})`);
130
+
131
+ if (canResume) {
132
+ return {
133
+ nextPrompt: buildResumeRetryPrompt(cp, state.timedOutTools),
134
+ useResume: true,
135
+ resumeSessionId: cp.claudeSessionId,
136
+ path: 'ToolTimeout',
137
+ reason,
138
+ };
139
+ }
140
+
141
+ return {
142
+ nextPrompt: buildRetryPrompt(cp, state.originalPrompt, state.timedOutTools),
143
+ useResume: false,
144
+ path: 'ToolTimeout',
145
+ reason,
146
+ };
147
+ }
148
+
149
+ export function trySignalCrash(result: SessionResult, state: RetryState): RetryDecision | null {
150
+ const isSignalCrash = !!result.signalName;
151
+ const exitCodeSignal = !result.completed && !result.signalName && result.error?.match(/exited with code (1[2-9]\d|[2-9]\d{2})/);
152
+ if (!isSignalCrash && !exitCodeSignal) return null;
153
+ if (state.retryNumber >= state.maxRetries) return null;
154
+ if (state.checkpoint) return null;
155
+
156
+ accumulateToolResults(result, state);
157
+ state.retryNumber++;
158
+
159
+ const signalInfo = result.signalName || 'unknown signal';
160
+ const useResume = !!result.claudeSessionId && state.retryNumber === 1;
161
+ const reason = `Process killed (${signalInfo}), ${state.accumulatedToolResults.length} tools preserved, ${useResume ? 'resuming' : 'fresh start'}`;
162
+
163
+ hlog(`[RETRY] Signal crash: ${reason} (retry ${state.retryNumber}/${state.maxRetries})`);
164
+
165
+ if (useResume) {
166
+ return {
167
+ nextPrompt: buildSignalCrashRecoveryPrompt(state.originalPrompt, true),
168
+ useResume: true,
169
+ resumeSessionId: result.claudeSessionId,
170
+ path: 'SignalCrash',
171
+ reason,
172
+ };
173
+ }
174
+
175
+ return {
176
+ nextPrompt: buildSignalCrashRecoveryPrompt(state.originalPrompt, false, state.accumulatedToolResults),
177
+ useResume: false,
178
+ path: 'SignalCrash',
179
+ reason,
180
+ };
181
+ }
182
+
183
+ function detectContextLossHeuristic(result: SessionResult, verbose: boolean): boolean {
184
+ if (!result.assistantResponse || result.assistantResponse.trim().length === 0) {
185
+ if (verbose) hlog('[RETRY] Context loss heuristic: null/empty response');
186
+ return true;
187
+ }
188
+ if (result.resumeBufferedOutput !== undefined) {
189
+ if (verbose) hlog('[RETRY] Context loss heuristic: buffer never flushed (no thinking/tools)');
190
+ return true;
191
+ }
192
+ if (
193
+ (!result.toolUseHistory || result.toolUseHistory.length === 0) &&
194
+ !result.thinkingOutput &&
195
+ result.assistantResponse.length < 500
196
+ ) {
197
+ if (verbose) hlog('[RETRY] Context loss heuristic: no tools, no thinking, short response');
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+
203
+ function computeEffectiveTimeouts(result: SessionResult): number {
204
+ const nativeTimeouts = result.nativeTimeoutCount ?? 0;
205
+ if (nativeTimeouts === 0) return 0;
206
+
207
+ const succeededIds = new Set<string>();
208
+ const allIds = new Set<string>();
209
+ for (const t of result.toolUseHistory ?? []) {
210
+ allIds.add(t.toolId);
211
+ if (t.result !== undefined) succeededIds.add(t.toolId);
212
+ }
213
+ const toolsWithoutResult = Array.from(allIds).filter(id => !succeededIds.has(id)).length;
214
+ return Math.max(nativeTimeouts, toolsWithoutResult);
215
+ }
216
+
217
+ async function detectContextLossViaHaiku(
218
+ result: SessionResult,
219
+ effectiveTimeouts: number,
220
+ verbose: boolean,
221
+ ): Promise<boolean> {
222
+ if (effectiveTimeouts === 0 || !result.assistantResponse) return false;
223
+
224
+ const writeToolNames = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
225
+ const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
226
+ try {
227
+ const verdict = await assessContextLoss({
228
+ assistantResponse: result.assistantResponse,
229
+ effectiveTimeouts,
230
+ nativeTimeoutCount: result.nativeTimeoutCount ?? 0,
231
+ successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
232
+ thinkingOutputLength: result.thinkingOutput?.length ?? 0,
233
+ hasSuccessfulWrite: result.toolUseHistory?.some(
234
+ t => writeToolNames.has(t.toolName) && t.result !== undefined && !t.isError
235
+ ) ?? false,
236
+ }, claudeCmd, verbose);
237
+ if (verbose) hlog(`[RETRY] Haiku context loss verdict: ${verdict.contextLost ? 'LOST' : 'OK'} — ${verdict.reason}`);
238
+ return verdict.contextLost;
239
+ } catch {
240
+ if (verbose) hlog('[RETRY] Haiku context loss assessment failed, assuming OK');
241
+ return false;
242
+ }
243
+ }
244
+
245
+ export async function tryContextLoss(
246
+ result: SessionResult,
247
+ state: RetryState,
248
+ config: RetryConfig,
249
+ ): Promise<RetryDecision | null> {
250
+ if (!config.enableContextLossDetection) return null;
251
+ if (state.checkpoint || state.retryNumber >= state.maxRetries) return null;
252
+
253
+ const heuristicLost = detectContextLossHeuristic(result, config.verbose);
254
+ const haikuLost = heuristicLost
255
+ ? false
256
+ : await detectContextLossViaHaiku(result, computeEffectiveTimeouts(result), config.verbose);
257
+
258
+ if (!heuristicLost && !haikuLost) return null;
259
+
260
+ accumulateToolResults(result, state);
261
+ state.retryNumber++;
262
+
263
+ const reason = `Context lost, ${state.accumulatedToolResults.length} tools preserved`;
264
+ hlog(`[RETRY] Context loss: ${reason} (retry ${state.retryNumber}/${state.maxRetries})`);
265
+
266
+ return {
267
+ nextPrompt: buildFreshRecoveryPrompt(state.originalPrompt, state.accumulatedToolResults, state.timedOutTools),
268
+ useResume: false,
269
+ path: 'ContextLoss',
270
+ reason,
271
+ };
272
+ }
273
+
274
+ async function isEndTurnIncomplete(result: SessionResult, verbose: boolean): Promise<boolean> {
275
+ if (isResponseAbandoned(result)) {
276
+ if (verbose) hlog('[RETRY] Response abandoned heuristic triggered');
277
+ return true;
278
+ }
279
+ if (!result.assistantResponse) return false;
280
+
281
+ const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
282
+ try {
283
+ const verdict = await assessPrematureCompletion({
284
+ responseTail: extractFinalTextBlock(result.assistantResponse, 800),
285
+ successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
286
+ hasThinking: !!result.thinkingOutput,
287
+ responseLength: result.assistantResponse.length,
288
+ }, claudeCmd, verbose);
289
+ if (verbose) {
290
+ hlog(`[RETRY] Premature completion verdict: ${verdict.isIncomplete ? 'INCOMPLETE' : 'COMPLETE'} — ${verdict.reason}`);
291
+ }
292
+ return verdict.isIncomplete;
293
+ } catch {
294
+ if (verbose) hlog('[RETRY] Premature completion assessment failed, assuming complete');
295
+ return false;
296
+ }
297
+ }
298
+
299
+ function isPrematureCompletionCandidate(result: SessionResult, state: RetryState): boolean {
300
+ if (!result.completed || result.signalName || !result.stopReason) return false;
301
+ if (state.retryNumber >= state.maxRetries) return false;
302
+ if (state.checkpoint) return false;
303
+ if (!result.claudeSessionId) return false;
304
+ return result.stopReason === 'max_tokens' || result.stopReason === 'end_turn';
305
+ }
306
+
307
+ export async function tryPrematureCompletion(
308
+ result: SessionResult,
309
+ state: RetryState,
310
+ config: RetryConfig,
311
+ ): Promise<RetryDecision | null> {
312
+ if (!isPrematureCompletionCandidate(result, state)) return null;
313
+
314
+ const isMaxTokens = result.stopReason === 'max_tokens';
315
+
316
+ if (!isMaxTokens && !(await isEndTurnIncomplete(result, config.verbose))) return null;
317
+
318
+ state.retryNumber++;
319
+ const reason = isMaxTokens ? 'Output limit reached' : 'Task appears unfinished (AI assessment)';
320
+
321
+ hlog(`[RETRY] Premature completion: ${reason}, resuming session (retry ${state.retryNumber}/${state.maxRetries})`);
322
+
323
+ return {
324
+ nextPrompt: 'continue',
325
+ useResume: true,
326
+ resumeSessionId: result.claudeSessionId,
327
+ path: 'PrematureCompletion',
328
+ reason,
329
+ };
330
+ }
@@ -239,6 +239,11 @@ export async function assessToolTimeout(
239
239
  Task: 'spawns a subagent that runs autonomously with its own tools',
240
240
  Agent: 'spawns a subagent that runs autonomously with its own tools',
241
241
  Bash: 'executes a shell command',
242
+ Write: 'writes file content that may be very large; content streams token-by-token through stdio before disk write',
243
+ Edit: 'applies string replacements to a file; streams through stdio protocol',
244
+ Read: 'reads file contents that may be very large; result streams through stdio protocol',
245
+ Grep: 'searches file contents with regex; large codebases produce large result streams',
246
+ Glob: 'finds files by pattern; large directory trees take time to scan',
242
247
  };
243
248
  const toolDesc = toolDescriptions[toolName] || `executes the ${toolName} tool`;
244
249