mstro-app 0.4.20 → 0.4.21

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 (38) hide show
  1. package/dist/server/cli/headless/headless-logger.js +1 -1
  2. package/dist/server/cli/headless/headless-logger.js.map +1 -1
  3. package/dist/server/mcp/bouncer-integration.d.ts +2 -2
  4. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  5. package/dist/server/mcp/bouncer-integration.js +12 -20
  6. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  7. package/dist/server/services/pathUtils.d.ts.map +1 -1
  8. package/dist/server/services/pathUtils.js +33 -1
  9. package/dist/server/services/pathUtils.js.map +1 -1
  10. package/dist/server/services/plan/composer.d.ts.map +1 -1
  11. package/dist/server/services/plan/composer.js +4 -0
  12. package/dist/server/services/plan/composer.js.map +1 -1
  13. package/dist/server/services/plan/executor.d.ts +1 -1
  14. package/dist/server/services/plan/executor.d.ts.map +1 -1
  15. package/dist/server/services/plan/executor.js +6 -8
  16. package/dist/server/services/plan/executor.js.map +1 -1
  17. package/dist/server/services/plan/issue-retry.d.ts +23 -0
  18. package/dist/server/services/plan/issue-retry.d.ts.map +1 -0
  19. package/dist/server/services/plan/issue-retry.js +215 -0
  20. package/dist/server/services/plan/issue-retry.js.map +1 -0
  21. package/dist/server/services/plan/review-gate.js +1 -1
  22. package/dist/server/services/plan/review-gate.js.map +1 -1
  23. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  24. package/dist/server/services/websocket/handler.js +8 -0
  25. package/dist/server/services/websocket/handler.js.map +1 -1
  26. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  27. package/dist/server/services/websocket/settings-handlers.js +17 -21
  28. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  29. package/package.json +1 -1
  30. package/server/cli/headless/headless-logger.ts +1 -1
  31. package/server/mcp/bouncer-integration.ts +11 -17
  32. package/server/services/pathUtils.ts +35 -1
  33. package/server/services/plan/composer.ts +4 -0
  34. package/server/services/plan/executor.ts +6 -9
  35. package/server/services/plan/issue-retry.ts +294 -0
  36. package/server/services/plan/review-gate.ts +1 -1
  37. package/server/services/websocket/handler.ts +6 -0
  38. package/server/services/websocket/settings-handlers.ts +18 -22
@@ -0,0 +1,294 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Issue Retry — Retry loop for PM issue execution.
6
+ *
7
+ * Brings the same resilience as Chat view (improvisation-retry.ts) to PM agents:
8
+ * - Tool timeout checkpoint recovery (preserves completed tools, skips hung tool)
9
+ * - Signal crash recovery (preserves accumulated results across retries)
10
+ * - Premature completion handling (max_tokens / end_turn → resume with "continue")
11
+ *
12
+ * Unlike Chat's retry system, PM agents don't maintain session continuity across
13
+ * prompts — each issue is independent — so we skip inter-movement recovery and
14
+ * simplify the resume strategy.
15
+ */
16
+
17
+ import { hlog } from '../../cli/headless/headless-logger.js';
18
+ import { HeadlessRunner } from '../../cli/headless/index.js';
19
+ import { assessPrematureCompletion } from '../../cli/headless/stall-assessor.js';
20
+ import type { ExecutionCheckpoint, SessionResult } from '../../cli/headless/types.js';
21
+ import {
22
+ buildResumeRetryPrompt,
23
+ buildRetryPrompt,
24
+ buildSignalCrashRecoveryPrompt,
25
+ } from '../../cli/prompt-builders.js';
26
+
27
+ /** Max retries per issue execution (tool timeout, signal crash, premature completion combined) */
28
+ const MAX_ISSUE_RETRIES = 3;
29
+
30
+ /** Max accumulated tool results to carry across retries */
31
+ const MAX_ACCUMULATED_RESULTS = 50;
32
+
33
+ /** Lightweight tool record for accumulation across retries */
34
+ interface ToolRecord {
35
+ toolName: string;
36
+ toolId: string;
37
+ toolInput: Record<string, unknown>;
38
+ result?: string;
39
+ isError?: boolean;
40
+ duration?: number;
41
+ }
42
+
43
+ interface IssueRetryState {
44
+ currentPrompt: string;
45
+ retryNumber: number;
46
+ checkpoint: ExecutionCheckpoint | null;
47
+ accumulatedToolResults: ToolRecord[];
48
+ timedOutTools: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>;
49
+ /** Session ID from a prior run — enables --resume for premature completion */
50
+ lastSessionId: string | undefined;
51
+ bestResult: SessionResult | null;
52
+ }
53
+
54
+ export interface IssueRunnerConfig {
55
+ workingDir: string;
56
+ /** Original enriched prompt for this issue */
57
+ prompt: string;
58
+ /** Stall detection timeouts (ms) */
59
+ stallWarningMs: number;
60
+ stallKillMs: number;
61
+ stallHardCapMs: number;
62
+ stallMaxExtensions: number;
63
+ /** Callback for streaming output to executor event bus */
64
+ outputCallback?: (text: string) => void;
65
+ }
66
+
67
+ /**
68
+ * Execute a PM issue with retry logic.
69
+ *
70
+ * This wraps HeadlessRunner.run() with the same retry strategies as Chat view:
71
+ * 1. Tool timeout → checkpoint recovery with accumulated results
72
+ * 2. Signal crash → fresh start with preserved tool results
73
+ * 3. Premature completion → resume session with "continue"
74
+ */
75
+ export async function runIssueWithRetry(config: IssueRunnerConfig): Promise<SessionResult> {
76
+ const state: IssueRetryState = {
77
+ currentPrompt: config.prompt,
78
+ retryNumber: 0,
79
+ checkpoint: null,
80
+ accumulatedToolResults: [],
81
+ timedOutTools: [],
82
+ lastSessionId: undefined,
83
+ bestResult: null,
84
+ };
85
+
86
+ let result: SessionResult | undefined;
87
+
88
+ while (state.retryNumber <= MAX_ISSUE_RETRIES) {
89
+ // Clear checkpoint from prior iteration
90
+ state.checkpoint = null;
91
+
92
+ // Determine resume strategy
93
+ const useResume = !!state.lastSessionId;
94
+ const resumeSessionId = state.lastSessionId;
95
+ state.lastSessionId = undefined;
96
+
97
+ const runner = new HeadlessRunner({
98
+ workingDir: config.workingDir,
99
+ directPrompt: state.currentPrompt,
100
+ stallWarningMs: config.stallWarningMs,
101
+ stallKillMs: config.stallKillMs,
102
+ stallHardCapMs: config.stallHardCapMs,
103
+ stallMaxExtensions: config.stallMaxExtensions,
104
+ verbose: true,
105
+ continueSession: useResume,
106
+ claudeSessionId: resumeSessionId,
107
+ outputCallback: config.outputCallback,
108
+ onToolTimeout: (cp: ExecutionCheckpoint) => {
109
+ state.checkpoint = cp;
110
+ },
111
+ });
112
+
113
+ result = await runner.run();
114
+
115
+ // Track best result for fallback selection
116
+ if (!state.bestResult || scoreResult(result) > scoreResult(state.bestResult)) {
117
+ state.bestResult = result;
118
+ }
119
+
120
+ // Evaluate retry strategies in priority order
121
+ if (tryToolTimeoutRetry(state, result, config)) continue;
122
+ if (trySignalCrashRetry(state, result, config)) continue;
123
+ if (await tryPrematureCompletionRetry(state, result, config)) continue;
124
+
125
+ // No retry needed — break out
126
+ break;
127
+ }
128
+
129
+ return result ?? state.bestResult ?? {
130
+ completed: false,
131
+ needsHandoff: false,
132
+ totalTokens: 0,
133
+ sessionId: '',
134
+ error: 'No result produced after retries',
135
+ };
136
+ }
137
+
138
+ // ========== Retry Strategies ==========
139
+
140
+ /**
141
+ * Strategy 1: Tool timeout checkpoint recovery.
142
+ * When a tool times out, we have a checkpoint with all completed tools.
143
+ * Build a new prompt injecting those results and skip the hung resource.
144
+ */
145
+ function tryToolTimeoutRetry(
146
+ state: IssueRetryState,
147
+ _result: SessionResult,
148
+ config: IssueRunnerConfig,
149
+ ): boolean {
150
+ if (!state.checkpoint || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
151
+
152
+ const cp = state.checkpoint;
153
+ state.retryNumber++;
154
+
155
+ state.timedOutTools.push({
156
+ toolName: cp.hungTool.toolName,
157
+ input: cp.hungTool.input ?? {},
158
+ timeoutMs: cp.hungTool.timeoutMs,
159
+ });
160
+
161
+ const canResume = cp.inProgressTools.length === 0 && !!cp.claudeSessionId;
162
+
163
+ hlog(`[PM-RETRY] Tool timeout: ${cp.hungTool.toolName} after ${Math.round(cp.hungTool.timeoutMs / 1000)}s, ${cp.completedTools.length} tools completed, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES} (${canResume ? 'resume' : 'fresh'})`);
164
+
165
+ if (canResume) {
166
+ state.lastSessionId = cp.claudeSessionId;
167
+ state.currentPrompt = buildResumeRetryPrompt(cp, state.timedOutTools);
168
+ } else {
169
+ state.currentPrompt = buildRetryPrompt(cp, config.prompt, state.timedOutTools);
170
+ }
171
+
172
+ config.outputCallback?.(`\n[PM-RETRY] Auto-retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}: ${canResume ? 'Resuming session' : 'Continuing'} with ${cp.completedTools.length} results, skipping failed ${cp.hungTool.toolName}.\n`);
173
+
174
+ return true;
175
+ }
176
+
177
+ /**
178
+ * Strategy 2: Signal crash recovery.
179
+ * Process was killed by signal (SIGTERM/SIGKILL from stall watchdog or OS).
180
+ * Accumulate completed tools and retry with preserved context.
181
+ */
182
+ function trySignalCrashRetry(
183
+ state: IssueRetryState,
184
+ result: SessionResult,
185
+ config: IssueRunnerConfig,
186
+ ): boolean {
187
+ const isSignalCrash = !!result.signalName;
188
+ const exitCodeSignal = !result.completed && !result.signalName && result.error?.match(/exited with code (1[2-9]\d|[2-9]\d{2})/);
189
+ if ((!isSignalCrash && !exitCodeSignal) || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
190
+ // Don't double-handle if a checkpoint was already captured (tool timeout takes priority)
191
+ if (state.checkpoint) return false;
192
+
193
+ accumulateToolResults(result, state);
194
+ state.retryNumber++;
195
+
196
+ const signalInfo = result.signalName || 'unknown signal';
197
+ const useResume = !!result.claudeSessionId && state.retryNumber === 1;
198
+
199
+ hlog(`[PM-RETRY] Signal crash: ${signalInfo}, ${state.accumulatedToolResults.length} tools preserved, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES} (${useResume ? 'resume' : 'fresh'})`);
200
+
201
+ if (useResume) {
202
+ state.lastSessionId = result.claudeSessionId;
203
+ state.currentPrompt = buildSignalCrashRecoveryPrompt(config.prompt, true);
204
+ } else {
205
+ state.currentPrompt = buildSignalCrashRecoveryPrompt(
206
+ config.prompt,
207
+ false,
208
+ state.accumulatedToolResults,
209
+ );
210
+ }
211
+
212
+ config.outputCallback?.(`\n[PM-RETRY] Signal recovery ${state.retryNumber}/${MAX_ISSUE_RETRIES}: ${useResume ? 'Resuming' : 'Restarting'} with ${state.accumulatedToolResults.length} preserved results.\n`);
213
+
214
+ return true;
215
+ }
216
+
217
+ /** Check if an end_turn result is actually incomplete using Haiku assessment. */
218
+ async function isEndTurnIncomplete(result: SessionResult): Promise<boolean> {
219
+ if (!result.assistantResponse) return false;
220
+ const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
221
+ try {
222
+ const verdict = await assessPrematureCompletion({
223
+ responseTail: result.assistantResponse.slice(-800),
224
+ successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
225
+ hasThinking: !!result.thinkingOutput,
226
+ responseLength: result.assistantResponse.length,
227
+ }, claudeCmd, true);
228
+
229
+ hlog(`[PM-RETRY] Premature completion check: ${verdict.isIncomplete ? 'INCOMPLETE' : 'COMPLETE'} — ${verdict.reason}`);
230
+ return verdict.isIncomplete;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Strategy 3: Premature completion.
238
+ * Claude hit max_tokens or ended early without finishing work.
239
+ * Resume the session with "continue".
240
+ */
241
+ async function tryPrematureCompletionRetry(
242
+ state: IssueRetryState,
243
+ result: SessionResult,
244
+ config: IssueRunnerConfig,
245
+ ): Promise<boolean> {
246
+ if (!result.completed || result.signalName || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
247
+ if (state.checkpoint) return false;
248
+ if (!result.claudeSessionId || !result.stopReason) return false;
249
+
250
+ const isMaxTokens = result.stopReason === 'max_tokens';
251
+ const isEndTurn = result.stopReason === 'end_turn';
252
+ if (!isMaxTokens && !isEndTurn) return false;
253
+
254
+ // max_tokens always continues; end_turn requires AI assessment
255
+ if (isEndTurn && !(await isEndTurnIncomplete(result))) return false;
256
+
257
+ state.retryNumber++;
258
+ state.lastSessionId = result.claudeSessionId;
259
+ state.currentPrompt = 'continue';
260
+
261
+ const reason = isMaxTokens ? 'Output limit reached' : 'Task appears unfinished';
262
+ hlog(`[PM-RETRY] Premature completion: ${reason}, resuming session, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}`);
263
+ config.outputCallback?.(`\n[PM-RETRY] ${reason} — resuming session (retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}).\n`);
264
+
265
+ return true;
266
+ }
267
+
268
+ // ========== Helpers ==========
269
+
270
+ function accumulateToolResults(result: SessionResult, state: IssueRetryState): void {
271
+ if (!result.toolUseHistory) return;
272
+ for (const t of result.toolUseHistory) {
273
+ if (t.result !== undefined) {
274
+ state.accumulatedToolResults.push({
275
+ toolName: t.toolName,
276
+ toolId: t.toolId,
277
+ toolInput: t.toolInput,
278
+ result: t.result,
279
+ isError: t.isError,
280
+ duration: t.duration,
281
+ });
282
+ }
283
+ }
284
+ if (state.accumulatedToolResults.length > MAX_ACCUMULATED_RESULTS) {
285
+ state.accumulatedToolResults = state.accumulatedToolResults.slice(-MAX_ACCUMULATED_RESULTS);
286
+ }
287
+ }
288
+
289
+ function scoreResult(r: SessionResult): number {
290
+ const toolCount = r.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
291
+ const responseLen = Math.min((r.assistantResponse?.length ?? 0) / 50, 100);
292
+ const hasThinking = r.thinkingOutput ? 20 : 0;
293
+ return toolCount * 10 + responseLen + hasThinking;
294
+ }
@@ -53,7 +53,7 @@ export async function reviewIssue(options: ReviewIssueOptions): Promise<ReviewRe
53
53
  stallWarningMs: REVIEW_STALL_WARNING_MS,
54
54
  stallKillMs: REVIEW_STALL_KILL_MS,
55
55
  stallHardCapMs: REVIEW_STALL_HARD_CAP_MS,
56
- verbose: false,
56
+ verbose: true,
57
57
  outputCallback: onOutput ? (text: string) => onOutput(`Review: ${text}`) : undefined,
58
58
  });
59
59
 
@@ -234,6 +234,12 @@ export class WebSocketImproviseHandler implements HandlerContext {
234
234
  this.allConnections.delete(ws);
235
235
  cleanupTerminalSubscribers(this, ws);
236
236
 
237
+ // Kill any active search processes to prevent resource leaks
238
+ for (const [key, process] of this.activeSearches) {
239
+ try { process.kill(); } catch { /* ignore */ }
240
+ this.activeSearches.delete(key);
241
+ }
242
+
237
243
  // Clean up file upload handler when no connections remain
238
244
  if (this.allConnections.size === 0 && this.fileUploadHandler) {
239
245
  this.fileUploadHandler.destroy();
@@ -78,6 +78,17 @@ Respond with ONLY the summary text, nothing else.`;
78
78
 
79
79
  let stdout = '';
80
80
  let stderr = '';
81
+ let responseSent = false;
82
+
83
+ const sendSummaryOnce = (summary: string) => {
84
+ if (responseSent) return;
85
+ responseSent = true;
86
+ ctx.send(ws, {
87
+ type: 'notificationSummary',
88
+ tabId,
89
+ data: { summary }
90
+ });
91
+ };
81
92
 
82
93
  claude.stdout?.on('data', (data: Buffer) => {
83
94
  stdout += data.toString();
@@ -94,42 +105,27 @@ Respond with ONLY the summary text, nothing else.`;
94
105
  // Ignore cleanup errors
95
106
  }
96
107
 
97
- let summary: string;
98
108
  if (code === 0 && stdout.trim()) {
99
- summary = stdout.trim().slice(0, 150);
109
+ sendSummaryOnce(stdout.trim().slice(0, 150));
100
110
  } else {
101
111
  console.error('[WebSocketImproviseHandler] Claude error:', stderr || 'Unknown error');
102
- summary = createFallbackSummary(userPrompt);
112
+ sendSummaryOnce(createFallbackSummary(userPrompt));
103
113
  }
104
-
105
- ctx.send(ws, {
106
- type: 'notificationSummary',
107
- tabId,
108
- data: { summary }
109
- });
110
114
  });
111
115
 
112
116
  claude.on('error', (err: Error) => {
113
117
  console.error('[WebSocketImproviseHandler] Failed to spawn Claude:', err);
114
- const summary = createFallbackSummary(userPrompt);
115
- ctx.send(ws, {
116
- type: 'notificationSummary',
117
- tabId,
118
- data: { summary }
119
- });
118
+ sendSummaryOnce(createFallbackSummary(userPrompt));
120
119
  });
121
120
 
122
121
  // Timeout after 10 seconds
123
- setTimeout(() => {
122
+ const timeout = setTimeout(() => {
124
123
  claude.kill();
125
- const summary = createFallbackSummary(userPrompt);
126
- ctx.send(ws, {
127
- type: 'notificationSummary',
128
- tabId,
129
- data: { summary }
130
- });
124
+ sendSummaryOnce(createFallbackSummary(userPrompt));
131
125
  }, 10000);
132
126
 
127
+ claude.on('close', () => { clearTimeout(timeout); });
128
+
133
129
  } catch (error) {
134
130
  console.error('[WebSocketImproviseHandler] Error generating summary:', error);
135
131
  const summary = createFallbackSummary(userPrompt);