mstro-app 0.4.0 → 0.4.2

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.
@@ -22,6 +22,9 @@ import type { Issue } from './types.js';
22
22
 
23
23
  export type ExecutionStatus = 'idle' | 'starting' | 'executing' | 'paused' | 'stopping' | 'complete' | 'error';
24
24
 
25
+ /** Max teammates per wave. Agent Teams docs recommend 3-5; beyond 5-6 returns diminish. */
26
+ const MAX_WAVE_SIZE = 5;
27
+
25
28
  export interface ExecutionMetrics {
26
29
  issuesCompleted: number;
27
30
  issuesAttempted: number;
@@ -73,23 +76,34 @@ export class PlanExecutor extends EventEmitter {
73
76
 
74
77
  const startTime = Date.now();
75
78
 
76
- try {
77
- this.status = 'executing';
78
- this.emit('statusChanged', this.status);
79
+ this.status = 'executing';
80
+ this.emit('statusChanged', this.status);
81
+
82
+ let consecutiveZeroCompletions = 0;
79
83
 
80
- while (!this.shouldStop && !this.shouldPause) {
81
- const readyIssues = this.pickReadyIssues();
82
- if (readyIssues.length === 0) break;
84
+ while (!this.shouldStop && !this.shouldPause) {
85
+ const readyIssues = this.pickReadyIssues();
86
+ if (readyIssues.length === 0) break;
83
87
 
84
- // Always use wave execution with Agent Teams even for single issues.
85
- // Each teammate runs as a separate process with its own context window,
86
- // bouncer coverage via .mcp.json + PreToolUse hook, and disk persistence.
87
- await this.executeWave(readyIssues);
88
+ // Cap wave size per Agent Teams best practices (3-5 teammates optimal).
89
+ // Remaining issues will be picked up in subsequent waves.
90
+ const waveIssues = readyIssues.slice(0, MAX_WAVE_SIZE);
91
+
92
+ const completedCount = await this.executeWave(waveIssues);
93
+
94
+ if (completedCount > 0) {
95
+ consecutiveZeroCompletions = 0;
96
+ } else {
97
+ consecutiveZeroCompletions++;
98
+ // Stop after 3 consecutive waves with zero completions to avoid
99
+ // retrying the same failing issues indefinitely.
100
+ if (consecutiveZeroCompletions >= 3) {
101
+ this.metrics.totalDuration = Date.now() - startTime;
102
+ this.status = 'error';
103
+ this.emit('statusChanged', this.status);
104
+ return;
105
+ }
88
106
  }
89
- } catch (error) {
90
- this.status = 'error';
91
- this.emit('error', error instanceof Error ? error.message : String(error));
92
- return;
93
107
  }
94
108
 
95
109
  this.metrics.totalDuration = Date.now() - startTime;
@@ -120,9 +134,10 @@ export class PlanExecutor extends EventEmitter {
120
134
 
121
135
  // ── Wave execution (Agent Teams) ──────────────────────────────
122
136
 
123
- private async executeWave(issues: Issue[]): Promise<void> {
137
+ private async executeWave(issues: Issue[]): Promise<number> {
124
138
  const waveStart = Date.now();
125
139
  const waveIds = issues.map(i => i.id);
140
+ const waveLabel = `wave[${waveIds.join(',')}]`;
126
141
  this.metrics.currentWaveIds = waveIds;
127
142
  this.metrics.issuesAttempted += issues.length;
128
143
  this.emit('waveStarted', { issueIds: waveIds });
@@ -147,17 +162,22 @@ export class PlanExecutor extends EventEmitter {
147
162
 
148
163
  const prompt = this.buildCoordinatorPrompt(issues);
149
164
 
165
+ let completedCount = 0;
166
+
150
167
  try {
151
168
  const runner = new HeadlessRunner({
152
169
  workingDir: this.workingDir,
153
170
  directPrompt: prompt,
154
- stallKillMs: 3_600_000, // 60 min — waves run longer
155
- stallHardCapMs: 7_200_000, // 2 hr hard cap
171
+ stallWarningMs: 1_800_000, // 30 min — Agent Teams leads are silent while teammates work
172
+ stallKillMs: 3_600_000, // 60 min waves run longer
173
+ stallHardCapMs: 7_200_000, // 2 hr hard cap
174
+ stallMaxExtensions: 10, // Agent Teams waves need many extensions
175
+ verbose: process.env.MSTRO_VERBOSE === '1',
156
176
  extraEnv: {
157
177
  CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
158
178
  },
159
179
  outputCallback: (text: string) => {
160
- this.emit('output', { issueId: `wave[${waveIds.join(',')}]`, text });
180
+ this.emit('output', { issueId: waveLabel, text });
161
181
  },
162
182
  });
163
183
 
@@ -171,7 +191,7 @@ export class PlanExecutor extends EventEmitter {
171
191
  }
172
192
 
173
193
  // Check which issues the agents actually completed by reading disk
174
- this.reconcileWaveResults(issues);
194
+ completedCount = this.reconcileWaveResults(issues);
175
195
 
176
196
  } catch (error) {
177
197
  this.emit('waveError', {
@@ -185,17 +205,44 @@ export class PlanExecutor extends EventEmitter {
185
205
  this.uninstallTeammatePermissions();
186
206
  }
187
207
 
188
- // Reconcile STATE.md and sprint statuses after wave
189
- reconcileState(this.workingDir);
190
- this.emit('stateUpdated');
208
+ this.finalizeWave(issues, waveStart, waveLabel);
209
+ this.metrics.currentWaveIds = [];
210
+ return completedCount;
211
+ }
191
212
 
192
- // Copy confirmed-done outputs to user-specified output_file paths
193
- this.publishOutputs(issues);
213
+ /**
214
+ * Post-wave operations wrapped individually so a failure in one
215
+ * (e.g. reconcileState hitting a concurrent write from PlanWatcher)
216
+ * doesn't prevent the others or kill the while loop in start().
217
+ */
218
+ private finalizeWave(issues: Issue[], waveStart: number, waveLabel: string): void {
219
+ try {
220
+ reconcileState(this.workingDir);
221
+ this.emit('stateUpdated');
222
+ } catch (err) {
223
+ this.emit('output', {
224
+ issueId: waveLabel,
225
+ text: `Warning: state reconciliation failed: ${err instanceof Error ? err.message : String(err)}`,
226
+ });
227
+ }
194
228
 
195
- // Append progress log entry
196
- this.appendProgressEntry(issues, waveStart);
229
+ try {
230
+ this.publishOutputs(issues);
231
+ } catch (err) {
232
+ this.emit('output', {
233
+ issueId: waveLabel,
234
+ text: `Warning: output publishing failed: ${err instanceof Error ? err.message : String(err)}`,
235
+ });
236
+ }
197
237
 
198
- this.metrics.currentWaveIds = [];
238
+ try {
239
+ this.appendProgressEntry(issues, waveStart);
240
+ } catch (err) {
241
+ this.emit('output', {
242
+ issueId: waveLabel,
243
+ text: `Warning: progress log update failed: ${err instanceof Error ? err.message : String(err)}`,
244
+ });
245
+ }
199
246
  }
200
247
 
201
248
  /**
@@ -204,9 +251,11 @@ export class PlanExecutor extends EventEmitter {
204
251
  * Output doc existence is NOT used as a proxy — code-focused issues
205
252
  * (bug fixes, refactors) don't produce docs but are still valid completions.
206
253
  */
207
- private reconcileWaveResults(issues: Issue[]): void {
254
+ private reconcileWaveResults(issues: Issue[]): number {
208
255
  const pmDir = resolvePmDir(this.workingDir);
209
- if (!pmDir) return;
256
+ if (!pmDir) return 0;
257
+
258
+ let completed = 0;
210
259
 
211
260
  for (const issue of issues) {
212
261
  const fullPath = join(pmDir, issue.path);
@@ -217,6 +266,7 @@ export class PlanExecutor extends EventEmitter {
217
266
 
218
267
  if (currentStatus === 'done') {
219
268
  this.metrics.issuesCompleted++;
269
+ completed++;
220
270
  this.emit('issueCompleted', issue);
221
271
  } else {
222
272
  // Not done — revert to prior status
@@ -230,6 +280,8 @@ export class PlanExecutor extends EventEmitter {
230
280
  this.emit('issueError', { issueId: issue.id, error: 'Could not read issue file after wave' });
231
281
  }
232
282
  }
283
+
284
+ return completed;
233
285
  }
234
286
 
235
287
  // ── Issue picking ─────────────────────────────────────────────
@@ -320,13 +372,17 @@ ${files}${predecessorSection}
320
372
 
321
373
  const outputFile = this.resolveOutputPath(issue);
322
374
 
375
+ const fileOwnership = issue.filesToModify.length > 0
376
+ ? `\n> FILE OWNERSHIP: You may ONLY modify these files: ${issue.filesToModify.join(', ')}. Do not touch files owned by other teammates.`
377
+ : '';
378
+
323
379
  return `Spawn teammate **${issue.id.toLowerCase()}** using the **Agent** tool with \`team_name: "${teamName}"\` and \`name: "${issue.id.toLowerCase()}"\`:
324
380
  > ${predInstr}Work on issue ${issue.id}: ${issue.title}.
325
381
  > Read the full spec at ${pmDir ? join(pmDir, issue.path) : issue.path}.
326
382
  > Execute all acceptance criteria.
327
383
  > CRITICAL: Write ALL output/results to ${outputFile} — this is the handoff artifact for downstream issues.
328
384
  > After writing output, update the issue front matter: change \`status: in_progress\` to \`status: done\`.
329
- > Do not modify STATE.md. Do not work on anything outside this issue's scope.`;
385
+ > Do not modify STATE.md. Do not work on anything outside this issue's scope.${fileOwnership}`;
330
386
  }).join('\n\n');
331
387
 
332
388
  return `You are the team lead coordinating ${issues.length} issue${issues.length > 1 ? 's' : ''} using Agent Teams.
@@ -354,28 +410,39 @@ CRITICAL: After spawning, you MUST remain active and wait for every single teamm
354
410
  Track completion against this checklist — ALL must report idle before you proceed:
355
411
  ${issues.map(i => `- [ ] ${i.id.toLowerCase()}`).join('\n')}
356
412
 
413
+ **Exact teammate names for SendMessage** (use these EXACTLY — messages to wrong names are silently lost):
414
+ ${issues.map(i => `- \`${i.id.toLowerCase()}\``).join('\n')}
415
+
357
416
  While waiting:
358
417
  - As each teammate goes idle, verify their output file exists on disk using the **Read** tool
359
- - If a teammate has not gone idle after 15 minutes, use **SendMessage** to check on them
360
- - Do NOT proceed to Step 3 until you have received idle notifications from ALL ${issues.length} teammates
418
+ - If a teammate has not gone idle after 15 minutes, send them a message using **SendMessage** with \`recipient: "{exact name from list above}"\` to check on their progress
419
+ - If a teammate does not respond within 5 more minutes after your SendMessage, assume they stalled: check their output file and issue status on disk. If the output exists and status is done, mark them complete. If not, update the issue status yourself based on whatever partial work exists on disk, then proceed.
420
+ - Do NOT proceed to Step 3 until all ${issues.length} teammates have either gone idle or been confirmed stalled and handled
361
421
 
362
422
  WARNING: The #1 failure mode is exiting before all teammates finish. If you exit early, all teammate processes are killed and their work is permanently lost. When in doubt, keep waiting. Err on the side of waiting too long rather than exiting too early.
363
423
 
364
424
  ### Step 3: Verify outputs
365
425
 
366
- Once every teammate has gone idle:
426
+ Once every teammate has gone idle or been handled:
367
427
  1. Verify each output file exists in ${outDir}/ using **Read** or **Glob**
368
428
  2. Verify each issue's front matter status is \`done\`
369
429
  3. If any teammate failed to write output or update status, do it yourself
370
430
  4. Do NOT modify STATE.md — the orchestrator handles that
371
431
 
432
+ ### Step 4: Clean up
433
+
434
+ After all outputs are verified, tell the team to shut down:
435
+ - Use **SendMessage** to send each remaining active teammate a shutdown message
436
+ - Then exit — the orchestrator will handle the next wave
437
+
372
438
  ## Critical Rules
373
439
 
374
- - The team is created implicitly when you spawn the first teammate with \`team_name\`, and cleaned up automatically when all teammates exit. Your only job is to spawn teammates, wait, and verify.
440
+ - The team is created implicitly when you spawn the first teammate with \`team_name\`, and cleaned up when all teammates exit or the lead exits.
375
441
  - You MUST wait for idle notifications from ALL ${issues.length} teammates before exiting. Exiting early kills all teammate processes and permanently loses their work.
376
442
  - Each teammate MUST write its output to disk — research only in conversation is LOST.
377
443
  - Each teammate MUST update the issue front matter status to \`done\`.
378
- - One issue per teammate — no cross-issue work.`;
444
+ - One issue per teammate — no cross-issue work.
445
+ - NEVER send a SendMessage to a teammate name that is not in the exact list above — misaddressed messages are silently dropped.`;
379
446
  }
380
447
 
381
448
  /**
@@ -591,39 +658,42 @@ Once every teammate has gone idle:
591
658
  if (!pmDir) return;
592
659
 
593
660
  for (const issue of issues) {
594
- if (!issue.outputFile) continue;
661
+ this.publishSingleOutput(issue, pmDir);
662
+ }
663
+ }
595
664
 
596
- // Only publish for confirmed-done issues
597
- try {
598
- const content = readFileSync(join(pmDir, issue.path), 'utf-8');
599
- if (!content.match(/^status:\s*done$/m)) continue;
600
- } catch { continue; }
601
-
602
- const srcPath = this.resolveOutputPath(issue);
603
- if (!existsSync(srcPath)) continue;
604
-
605
- // Guard against path traversal — output_file must resolve within workingDir
606
- const destPath = resolve(this.workingDir, issue.outputFile);
607
- if (!destPath.startsWith(this.workingDir + '/') && destPath !== this.workingDir) {
608
- this.emit('output', {
609
- issueId: issue.id,
610
- text: `Warning: output_file "${issue.outputFile}" escapes project directory — skipping`,
611
- });
612
- continue;
613
- }
665
+ /** Copy a single confirmed-done output to the user-specified output_file path. */
666
+ private publishSingleOutput(issue: Issue, pmDir: string): void {
667
+ if (!issue.outputFile) return;
614
668
 
615
- try {
616
- // Ensure destination directory exists
617
- const destDir = join(destPath, '..');
618
- if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
619
- copyFileSync(srcPath, destPath);
620
- } catch {
621
- // Non-fatal canonical artifact is safe in .pm/out/
622
- this.emit('output', {
623
- issueId: issue.id,
624
- text: `Warning: could not copy output to ${issue.outputFile}`,
625
- });
626
- }
669
+ // Only publish for confirmed-done issues
670
+ try {
671
+ const content = readFileSync(join(pmDir, issue.path), 'utf-8');
672
+ if (!content.match(/^status:\s*done$/m)) return;
673
+ } catch { return; }
674
+
675
+ const srcPath = this.resolveOutputPath(issue);
676
+ if (!existsSync(srcPath)) return;
677
+
678
+ // Guard against path traversal output_file must resolve within workingDir
679
+ const destPath = resolve(this.workingDir, issue.outputFile);
680
+ if (!destPath.startsWith(`${this.workingDir}/`) && destPath !== this.workingDir) {
681
+ this.emit('output', {
682
+ issueId: issue.id,
683
+ text: `Warning: output_file "${issue.outputFile}" escapes project directory — skipping`,
684
+ });
685
+ return;
686
+ }
687
+
688
+ try {
689
+ const destDir = join(destPath, '..');
690
+ if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
691
+ copyFileSync(srcPath, destPath);
692
+ } catch {
693
+ this.emit('output', {
694
+ issueId: issue.id,
695
+ text: `Warning: could not copy output to ${issue.outputFile}`,
696
+ });
627
697
  }
628
698
  }
629
699
 
@@ -692,7 +762,7 @@ Once every teammate has gone idle:
692
762
 
693
763
  try {
694
764
  const existing = readFileSync(progressPath, 'utf-8');
695
- writeFileSync(progressPath, existing.trimEnd() + '\n' + lines.join('\n'), 'utf-8');
765
+ writeFileSync(progressPath, `${existing.trimEnd()}\n${lines.join('\n')}`, 'utf-8');
696
766
  } catch {
697
767
  // Non-fatal
698
768
  }
@@ -54,6 +54,18 @@ function parseYamlValue(v: string): unknown {
54
54
  return v;
55
55
  }
56
56
 
57
+ /** Consume indented YAML list items starting after the current index. Returns [items, newIndex]. */
58
+ function consumeIndentedList(lines: string[], startIdx: number): [string[], number] {
59
+ const items: string[] = [];
60
+ let i = startIdx;
61
+ while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) {
62
+ i++;
63
+ const item = lines[i].trim().replace(/^-\s+/, '');
64
+ items.push(stripQuotes(item));
65
+ }
66
+ return [items, i];
67
+ }
68
+
57
69
  function parseFrontMatter(content: string): ParsedFile {
58
70
  const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
59
71
  if (!match) {
@@ -71,14 +83,9 @@ function parseFrontMatter(content: string): ParsedFile {
71
83
  const key = trimmed.slice(0, colonIdx).trim();
72
84
  const rawValue = trimmed.slice(colonIdx + 1).trim();
73
85
 
74
- // Handle multi-line indented YAML lists (key:\n - item1\n - item2)
75
86
  if (!rawValue) {
76
- const items: string[] = [];
77
- while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) {
78
- i++;
79
- const item = lines[i].trim().replace(/^-\s+/, '');
80
- items.push(stripQuotes(item));
81
- }
87
+ const [items, newIdx] = consumeIndentedList(lines, i);
88
+ i = newIdx;
82
89
  frontMatter[key] = items.length > 0 ? items : null;
83
90
  } else {
84
91
  frontMatter[key] = parseYamlValue(rawValue);