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.
- package/PRIVACY.md +19 -2
- package/dist/server/cli/improvisation-session-manager.js +1 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +8 -0
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +134 -70
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/parser.d.ts.map +1 -1
- package/dist/server/services/plan/parser.js +13 -7
- package/dist/server/services/plan/parser.js.map +1 -1
- package/package.json +3 -2
- package/server/cli/improvisation-session-manager.ts +1 -1
- package/server/services/plan/executor.ts +137 -67
- package/server/services/plan/parser.ts +14 -7
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
this.status = 'executing';
|
|
80
|
+
this.emit('statusChanged', this.status);
|
|
81
|
+
|
|
82
|
+
let consecutiveZeroCompletions = 0;
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
while (!this.shouldStop && !this.shouldPause) {
|
|
85
|
+
const readyIssues = this.pickReadyIssues();
|
|
86
|
+
if (readyIssues.length === 0) break;
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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<
|
|
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
|
-
|
|
155
|
-
|
|
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:
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
208
|
+
this.finalizeWave(issues, waveStart, waveLabel);
|
|
209
|
+
this.metrics.currentWaveIds = [];
|
|
210
|
+
return completedCount;
|
|
211
|
+
}
|
|
191
212
|
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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[]):
|
|
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,
|
|
360
|
-
-
|
|
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
|
|
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
|
-
|
|
661
|
+
this.publishSingleOutput(issue, pmDir);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
595
664
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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()
|
|
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
|
|
77
|
-
|
|
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);
|