groove-dev 0.27.40 → 0.27.41
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/CLAUDE.md +7 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/adaptive.js +24 -5
- package/node_modules/@groove-dev/daemon/src/api.js +15 -3
- package/node_modules/@groove-dev/daemon/src/preview.js +30 -11
- package/node_modules/@groove-dev/daemon/src/process.js +23 -10
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/adaptive.js +24 -5
- package/packages/daemon/src/api.js +15 -3
- package/packages/daemon/src/preview.js +30 -11
- package/packages/daemon/src/process.js +23 -10
- package/packages/gui/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -263,3 +263,10 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
|
|
|
263
263
|
- Dashboard: routing donut, cache panel, context health gauges
|
|
264
264
|
- Monitor/QC agent mode (stay active, loop)
|
|
265
265
|
- Distribution: demo video, HN launch, Twitter content
|
|
266
|
+
|
|
267
|
+
<!-- GROOVE:START -->
|
|
268
|
+
## GROOVE Orchestration (auto-injected)
|
|
269
|
+
Active agents: 0
|
|
270
|
+
See AGENTS_REGISTRY.md for full agent state.
|
|
271
|
+
**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
|
|
272
|
+
<!-- GROOVE:END -->
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
5
|
import { resolve } from 'path';
|
|
6
|
+
import { minimatch } from 'minimatch';
|
|
7
|
+
|
|
8
|
+
// Treat these scope entries as "unrestricted" — agent can touch any file
|
|
9
|
+
// under its workingDir without counting as a scope violation.
|
|
10
|
+
const UNRESTRICTED_SCOPE_PATTERNS = new Set(['**', '**/*', '*', '']);
|
|
6
11
|
|
|
7
12
|
const DEFAULT_THRESHOLD = 0.75;
|
|
8
13
|
const NUDGE_UP = 0.02; // Good session → allow more context
|
|
@@ -186,14 +191,28 @@ export class AdaptiveThresholds {
|
|
|
186
191
|
signals.toolFailures++;
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
// Scope violations: writes outside declared scope
|
|
194
|
+
// Scope violations: writes outside declared scope. Use real glob matching
|
|
195
|
+
// (the naive substring check flagged every write when scope was `["**"]`
|
|
196
|
+
// because `file.includes("**")` is always false — which tanked the
|
|
197
|
+
// quality score and triggered false-positive rotations). An unrestricted
|
|
198
|
+
// scope (`**`, `**/*`, empty pattern) skips the check entirely.
|
|
190
199
|
if (agentScope && agentScope.length > 0 && entry.input) {
|
|
191
200
|
if (entry.tool === 'Write' || entry.tool === 'Edit') {
|
|
192
201
|
const file = entry.input;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
const unrestricted = agentScope.some((p) => UNRESTRICTED_SCOPE_PATTERNS.has(String(p).trim()));
|
|
203
|
+
if (!unrestricted) {
|
|
204
|
+
const inScope = agentScope.some((pattern) => {
|
|
205
|
+
try {
|
|
206
|
+
if (minimatch(file, pattern, { matchBase: true, dot: true })) return true;
|
|
207
|
+
// Also try matching the basename and any path suffix, since
|
|
208
|
+
// scope patterns are relative to the agent's workingDir and
|
|
209
|
+
// the recorded input may be absolute.
|
|
210
|
+
const idx = file.indexOf('/' + pattern.replace(/\/?\*\*\/?/g, '').replace(/^\//, ''));
|
|
211
|
+
return idx >= 0;
|
|
212
|
+
} catch { return true; } // if the pattern is malformed, don't penalize
|
|
213
|
+
});
|
|
214
|
+
if (!inScope) signals.scopeViolations++;
|
|
215
|
+
}
|
|
197
216
|
}
|
|
198
217
|
}
|
|
199
218
|
}
|
|
@@ -2650,11 +2650,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2650
2650
|
if (!found) {
|
|
2651
2651
|
return res.status(404).json({ error: 'No recommended team found. Run a planner first.' });
|
|
2652
2652
|
}
|
|
2653
|
+
const planPath = found.path;
|
|
2654
|
+
const planContents = readFileSync(planPath, 'utf8');
|
|
2653
2655
|
try {
|
|
2654
|
-
const raw = JSON.parse(
|
|
2656
|
+
const raw = JSON.parse(planContents);
|
|
2655
2657
|
|
|
2656
|
-
// Delete immediately after reading to prevent duplicate launches from poll races
|
|
2657
|
-
|
|
2658
|
+
// Delete immediately after reading to prevent duplicate launches from poll races.
|
|
2659
|
+
// If every spawn below fails, we'll restore the plan from planContents so the
|
|
2660
|
+
// user can retry without re-prompting the planner.
|
|
2661
|
+
try { unlinkSync(planPath); } catch { /* already gone */ }
|
|
2658
2662
|
|
|
2659
2663
|
// Support both old format (bare array) and new format ({ projectDir, agents, preview })
|
|
2660
2664
|
let agentConfigs;
|
|
@@ -2834,6 +2838,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2834
2838
|
daemon.preview.stashPlan(defaultTeamId, previewBlock, projectWorkingDir);
|
|
2835
2839
|
}
|
|
2836
2840
|
|
|
2841
|
+
// Restore the plan if nothing actually spawned or was reused — deleting
|
|
2842
|
+
// it on a total failure leaves the team with no recovery path. A failed
|
|
2843
|
+
// spawn (scope collision, provider unavailable, etc.) should be retryable
|
|
2844
|
+
// once the user fixes the condition.
|
|
2845
|
+
if (spawned.length === 0 && reused.length === 0 && failed.length > 0) {
|
|
2846
|
+
try { writeFileSync(planPath, planContents); } catch { /* best-effort */ }
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2837
2849
|
daemon.audit.log('team.launch', {
|
|
2838
2850
|
phase1: spawned.length, reused: reused.length, phase2Pending: phase2.length, failed: failed.length,
|
|
2839
2851
|
agents: [...spawned, ...reused].map((a) => a.role), projectDir: projectDir || null, preview: !!previewBlock,
|
|
@@ -76,28 +76,46 @@ export class PreviewService {
|
|
|
76
76
|
* preview upfront at launch time and hand it back when the team completes.
|
|
77
77
|
*/
|
|
78
78
|
async launch(teamId, workingDir, preview) {
|
|
79
|
+
this.daemon.audit?.log('preview.attempt', { teamId, workingDir, preview });
|
|
80
|
+
|
|
79
81
|
if (!preview || !preview.kind || preview.kind === 'none' || preview.kind === 'cli') {
|
|
80
|
-
|
|
82
|
+
const result = { launched: false, reason: preview?.kind || 'no_preview' };
|
|
83
|
+
this.daemon.audit?.log('preview.skipped', { teamId, reason: result.reason });
|
|
84
|
+
return result;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
// Kill any existing preview for this team
|
|
84
87
|
await this.kill(teamId);
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// Resolve cwd with a sensible fallback. The planner sometimes names the
|
|
90
|
+
// cwd after projectDir which is applied by api/launch → the actual project
|
|
91
|
+
// root. If that specific subdir doesn't exist, try workingDir itself.
|
|
92
|
+
const root = resolve(workingDir || this.daemon.projectDir);
|
|
93
|
+
const candidates = [];
|
|
94
|
+
if (preview.cwd) candidates.push(resolve(root, preview.cwd));
|
|
95
|
+
candidates.push(root);
|
|
96
|
+
const baseDir = candidates.find((p) => existsSync(p));
|
|
89
97
|
|
|
90
|
-
if (!
|
|
91
|
-
|
|
98
|
+
if (!baseDir) {
|
|
99
|
+
const result = { launched: false, reason: `cwd_missing: tried ${candidates.join(' and ')}` };
|
|
100
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: result.reason });
|
|
101
|
+
return result;
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
let result;
|
|
94
105
|
if (preview.kind === 'static-html') {
|
|
95
|
-
|
|
106
|
+
result = await this._launchStatic(teamId, baseDir, preview);
|
|
107
|
+
} else if (preview.kind === 'dev-server') {
|
|
108
|
+
result = await this._launchDevServer(teamId, baseDir, preview);
|
|
109
|
+
} else {
|
|
110
|
+
result = { launched: false, reason: `unknown_kind: ${preview.kind}` };
|
|
96
111
|
}
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
|
|
113
|
+
if (result.launched) {
|
|
114
|
+
this.daemon.audit?.log('preview.launched', { teamId, url: result.url, kind: result.kind, baseDir });
|
|
115
|
+
} else {
|
|
116
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: result.reason, baseDir });
|
|
99
117
|
}
|
|
100
|
-
return
|
|
118
|
+
return result;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
_launchStatic(teamId, baseDir, preview) {
|
|
@@ -238,6 +256,7 @@ export class PreviewService {
|
|
|
238
256
|
if (entry.server) entry.server.close();
|
|
239
257
|
if (entry.proc) entry.proc.kill('SIGTERM');
|
|
240
258
|
} catch { /* best-effort */ }
|
|
259
|
+
this.daemon.audit?.log('preview.stopped', { teamId });
|
|
241
260
|
this.daemon.broadcast({ type: 'preview:stopped', teamId });
|
|
242
261
|
return true;
|
|
243
262
|
}
|
|
@@ -377,18 +377,21 @@ export class ProcessManager {
|
|
|
377
377
|
}
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
// Scope collision check: refuse to spawn if another running agent
|
|
381
|
-
// claims overlapping files.
|
|
382
|
-
//
|
|
380
|
+
// Scope collision check: refuse to spawn if another running agent in the
|
|
381
|
+
// SAME working directory already claims overlapping files. Scopes are
|
|
382
|
+
// relative patterns rooted at the agent's workingDir, so two agents in
|
|
383
|
+
// different workingDirs can't step on each other even if their patterns
|
|
384
|
+
// look identical. Oversight roles (planner, QC, security) and ambassadors
|
|
385
|
+
// bypass entirely since their job requires broad access.
|
|
383
386
|
const SCOPE_BYPASS_ROLES = new Set(['planner', 'fullstack', 'qc', 'pm', 'supervisor', 'security', 'ambassador']);
|
|
384
387
|
if (config.scope && config.scope.length > 0 && !SCOPE_BYPASS_ROLES.has(config.role) && !config.allowScopeOverlap) {
|
|
385
388
|
const conflict = locks.findOverlappingOwner(config.scope);
|
|
386
389
|
if (conflict.overlap) {
|
|
387
390
|
const owner = registry.get(conflict.owner);
|
|
388
|
-
if (owner && owner.status === 'running') {
|
|
391
|
+
if (owner && owner.status === 'running' && owner.workingDir === config.workingDir) {
|
|
389
392
|
const ownerScope = Array.isArray(conflict.ownerScope) ? conflict.ownerScope.join(', ') : '';
|
|
390
393
|
throw new Error(
|
|
391
|
-
`Scope collision: ${config.role} scope [${config.scope.join(', ')}] overlaps with ${owner.name} (${owner.role}) which owns [${ownerScope}]. ` +
|
|
394
|
+
`Scope collision: ${config.role} scope [${config.scope.join(', ')}] overlaps with ${owner.name} (${owner.role}) which owns [${ownerScope}] in the same workspace. ` +
|
|
392
395
|
`Two agents cannot edit the same files. Either narrow the scope or wait for ${owner.name} to finish.`
|
|
393
396
|
);
|
|
394
397
|
}
|
|
@@ -1098,16 +1101,25 @@ For normal file edits within your scope, proceed without review.
|
|
|
1098
1101
|
* - The daemon has a preview plan stashed for this team (planner wrote one).
|
|
1099
1102
|
* - No pending phase 2 groups for this team (QC hasn't spawned yet).
|
|
1100
1103
|
* - Every non-planner team agent is in a terminal state.
|
|
1101
|
-
* - At least one non-planner agent completed successfully
|
|
1102
|
-
*
|
|
1104
|
+
* - At least one non-planner agent completed successfully.
|
|
1105
|
+
*
|
|
1106
|
+
* The plan is intentionally NOT cleared on failure so the user can hit the
|
|
1107
|
+
* "Launch Preview" button manually after fixing whatever blocked the auto
|
|
1108
|
+
* attempt (wrong cwd, missing deps, etc). We guard against infinite re-fires
|
|
1109
|
+
* with _previewAttempted per teamId.
|
|
1103
1110
|
*/
|
|
1104
1111
|
_checkPreviewReady(teamId) {
|
|
1105
1112
|
const preview = this.daemon.preview;
|
|
1106
1113
|
if (!preview) return;
|
|
1114
|
+
if (!this._previewAttempted) this._previewAttempted = new Set();
|
|
1115
|
+
if (this._previewAttempted.has(teamId)) return;
|
|
1116
|
+
|
|
1107
1117
|
const plan = preview.getPlan(teamId);
|
|
1108
|
-
if (!plan)
|
|
1118
|
+
if (!plan) {
|
|
1119
|
+
this.daemon.audit?.log('preview.skipped', { teamId, reason: 'no_plan_stashed' });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1109
1122
|
|
|
1110
|
-
// If a phase 2 group for this team is still pending, let it spawn first.
|
|
1111
1123
|
const pendingPhase2 = this.daemon._pendingPhase2 || [];
|
|
1112
1124
|
for (const group of pendingPhase2) {
|
|
1113
1125
|
for (const id of group.waitFor) {
|
|
@@ -1123,7 +1135,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1123
1135
|
const anyCompleted = teamAgents.some((a) => a.status === 'completed');
|
|
1124
1136
|
if (!allDone || !anyCompleted) return;
|
|
1125
1137
|
|
|
1126
|
-
|
|
1138
|
+
this._previewAttempted.add(teamId);
|
|
1127
1139
|
const workingDir = plan.workingDir;
|
|
1128
1140
|
preview.launch(teamId, workingDir, plan.preview).then((result) => {
|
|
1129
1141
|
if (!result.launched) {
|
|
@@ -1137,6 +1149,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1137
1149
|
}
|
|
1138
1150
|
}).catch((err) => {
|
|
1139
1151
|
console.error(`[Groove] Preview launch error for team ${teamId}:`, err.message);
|
|
1152
|
+
this.daemon.broadcast({ type: 'preview:failed', teamId, reason: err.message });
|
|
1140
1153
|
});
|
|
1141
1154
|
}
|
|
1142
1155
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.41",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
5
|
import { resolve } from 'path';
|
|
6
|
+
import { minimatch } from 'minimatch';
|
|
7
|
+
|
|
8
|
+
// Treat these scope entries as "unrestricted" — agent can touch any file
|
|
9
|
+
// under its workingDir without counting as a scope violation.
|
|
10
|
+
const UNRESTRICTED_SCOPE_PATTERNS = new Set(['**', '**/*', '*', '']);
|
|
6
11
|
|
|
7
12
|
const DEFAULT_THRESHOLD = 0.75;
|
|
8
13
|
const NUDGE_UP = 0.02; // Good session → allow more context
|
|
@@ -186,14 +191,28 @@ export class AdaptiveThresholds {
|
|
|
186
191
|
signals.toolFailures++;
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
// Scope violations: writes outside declared scope
|
|
194
|
+
// Scope violations: writes outside declared scope. Use real glob matching
|
|
195
|
+
// (the naive substring check flagged every write when scope was `["**"]`
|
|
196
|
+
// because `file.includes("**")` is always false — which tanked the
|
|
197
|
+
// quality score and triggered false-positive rotations). An unrestricted
|
|
198
|
+
// scope (`**`, `**/*`, empty pattern) skips the check entirely.
|
|
190
199
|
if (agentScope && agentScope.length > 0 && entry.input) {
|
|
191
200
|
if (entry.tool === 'Write' || entry.tool === 'Edit') {
|
|
192
201
|
const file = entry.input;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
const unrestricted = agentScope.some((p) => UNRESTRICTED_SCOPE_PATTERNS.has(String(p).trim()));
|
|
203
|
+
if (!unrestricted) {
|
|
204
|
+
const inScope = agentScope.some((pattern) => {
|
|
205
|
+
try {
|
|
206
|
+
if (minimatch(file, pattern, { matchBase: true, dot: true })) return true;
|
|
207
|
+
// Also try matching the basename and any path suffix, since
|
|
208
|
+
// scope patterns are relative to the agent's workingDir and
|
|
209
|
+
// the recorded input may be absolute.
|
|
210
|
+
const idx = file.indexOf('/' + pattern.replace(/\/?\*\*\/?/g, '').replace(/^\//, ''));
|
|
211
|
+
return idx >= 0;
|
|
212
|
+
} catch { return true; } // if the pattern is malformed, don't penalize
|
|
213
|
+
});
|
|
214
|
+
if (!inScope) signals.scopeViolations++;
|
|
215
|
+
}
|
|
197
216
|
}
|
|
198
217
|
}
|
|
199
218
|
}
|
|
@@ -2650,11 +2650,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2650
2650
|
if (!found) {
|
|
2651
2651
|
return res.status(404).json({ error: 'No recommended team found. Run a planner first.' });
|
|
2652
2652
|
}
|
|
2653
|
+
const planPath = found.path;
|
|
2654
|
+
const planContents = readFileSync(planPath, 'utf8');
|
|
2653
2655
|
try {
|
|
2654
|
-
const raw = JSON.parse(
|
|
2656
|
+
const raw = JSON.parse(planContents);
|
|
2655
2657
|
|
|
2656
|
-
// Delete immediately after reading to prevent duplicate launches from poll races
|
|
2657
|
-
|
|
2658
|
+
// Delete immediately after reading to prevent duplicate launches from poll races.
|
|
2659
|
+
// If every spawn below fails, we'll restore the plan from planContents so the
|
|
2660
|
+
// user can retry without re-prompting the planner.
|
|
2661
|
+
try { unlinkSync(planPath); } catch { /* already gone */ }
|
|
2658
2662
|
|
|
2659
2663
|
// Support both old format (bare array) and new format ({ projectDir, agents, preview })
|
|
2660
2664
|
let agentConfigs;
|
|
@@ -2834,6 +2838,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2834
2838
|
daemon.preview.stashPlan(defaultTeamId, previewBlock, projectWorkingDir);
|
|
2835
2839
|
}
|
|
2836
2840
|
|
|
2841
|
+
// Restore the plan if nothing actually spawned or was reused — deleting
|
|
2842
|
+
// it on a total failure leaves the team with no recovery path. A failed
|
|
2843
|
+
// spawn (scope collision, provider unavailable, etc.) should be retryable
|
|
2844
|
+
// once the user fixes the condition.
|
|
2845
|
+
if (spawned.length === 0 && reused.length === 0 && failed.length > 0) {
|
|
2846
|
+
try { writeFileSync(planPath, planContents); } catch { /* best-effort */ }
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2837
2849
|
daemon.audit.log('team.launch', {
|
|
2838
2850
|
phase1: spawned.length, reused: reused.length, phase2Pending: phase2.length, failed: failed.length,
|
|
2839
2851
|
agents: [...spawned, ...reused].map((a) => a.role), projectDir: projectDir || null, preview: !!previewBlock,
|
|
@@ -76,28 +76,46 @@ export class PreviewService {
|
|
|
76
76
|
* preview upfront at launch time and hand it back when the team completes.
|
|
77
77
|
*/
|
|
78
78
|
async launch(teamId, workingDir, preview) {
|
|
79
|
+
this.daemon.audit?.log('preview.attempt', { teamId, workingDir, preview });
|
|
80
|
+
|
|
79
81
|
if (!preview || !preview.kind || preview.kind === 'none' || preview.kind === 'cli') {
|
|
80
|
-
|
|
82
|
+
const result = { launched: false, reason: preview?.kind || 'no_preview' };
|
|
83
|
+
this.daemon.audit?.log('preview.skipped', { teamId, reason: result.reason });
|
|
84
|
+
return result;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
// Kill any existing preview for this team
|
|
84
87
|
await this.kill(teamId);
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// Resolve cwd with a sensible fallback. The planner sometimes names the
|
|
90
|
+
// cwd after projectDir which is applied by api/launch → the actual project
|
|
91
|
+
// root. If that specific subdir doesn't exist, try workingDir itself.
|
|
92
|
+
const root = resolve(workingDir || this.daemon.projectDir);
|
|
93
|
+
const candidates = [];
|
|
94
|
+
if (preview.cwd) candidates.push(resolve(root, preview.cwd));
|
|
95
|
+
candidates.push(root);
|
|
96
|
+
const baseDir = candidates.find((p) => existsSync(p));
|
|
89
97
|
|
|
90
|
-
if (!
|
|
91
|
-
|
|
98
|
+
if (!baseDir) {
|
|
99
|
+
const result = { launched: false, reason: `cwd_missing: tried ${candidates.join(' and ')}` };
|
|
100
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: result.reason });
|
|
101
|
+
return result;
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
let result;
|
|
94
105
|
if (preview.kind === 'static-html') {
|
|
95
|
-
|
|
106
|
+
result = await this._launchStatic(teamId, baseDir, preview);
|
|
107
|
+
} else if (preview.kind === 'dev-server') {
|
|
108
|
+
result = await this._launchDevServer(teamId, baseDir, preview);
|
|
109
|
+
} else {
|
|
110
|
+
result = { launched: false, reason: `unknown_kind: ${preview.kind}` };
|
|
96
111
|
}
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
|
|
113
|
+
if (result.launched) {
|
|
114
|
+
this.daemon.audit?.log('preview.launched', { teamId, url: result.url, kind: result.kind, baseDir });
|
|
115
|
+
} else {
|
|
116
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: result.reason, baseDir });
|
|
99
117
|
}
|
|
100
|
-
return
|
|
118
|
+
return result;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
_launchStatic(teamId, baseDir, preview) {
|
|
@@ -238,6 +256,7 @@ export class PreviewService {
|
|
|
238
256
|
if (entry.server) entry.server.close();
|
|
239
257
|
if (entry.proc) entry.proc.kill('SIGTERM');
|
|
240
258
|
} catch { /* best-effort */ }
|
|
259
|
+
this.daemon.audit?.log('preview.stopped', { teamId });
|
|
241
260
|
this.daemon.broadcast({ type: 'preview:stopped', teamId });
|
|
242
261
|
return true;
|
|
243
262
|
}
|
|
@@ -377,18 +377,21 @@ export class ProcessManager {
|
|
|
377
377
|
}
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
// Scope collision check: refuse to spawn if another running agent
|
|
381
|
-
// claims overlapping files.
|
|
382
|
-
//
|
|
380
|
+
// Scope collision check: refuse to spawn if another running agent in the
|
|
381
|
+
// SAME working directory already claims overlapping files. Scopes are
|
|
382
|
+
// relative patterns rooted at the agent's workingDir, so two agents in
|
|
383
|
+
// different workingDirs can't step on each other even if their patterns
|
|
384
|
+
// look identical. Oversight roles (planner, QC, security) and ambassadors
|
|
385
|
+
// bypass entirely since their job requires broad access.
|
|
383
386
|
const SCOPE_BYPASS_ROLES = new Set(['planner', 'fullstack', 'qc', 'pm', 'supervisor', 'security', 'ambassador']);
|
|
384
387
|
if (config.scope && config.scope.length > 0 && !SCOPE_BYPASS_ROLES.has(config.role) && !config.allowScopeOverlap) {
|
|
385
388
|
const conflict = locks.findOverlappingOwner(config.scope);
|
|
386
389
|
if (conflict.overlap) {
|
|
387
390
|
const owner = registry.get(conflict.owner);
|
|
388
|
-
if (owner && owner.status === 'running') {
|
|
391
|
+
if (owner && owner.status === 'running' && owner.workingDir === config.workingDir) {
|
|
389
392
|
const ownerScope = Array.isArray(conflict.ownerScope) ? conflict.ownerScope.join(', ') : '';
|
|
390
393
|
throw new Error(
|
|
391
|
-
`Scope collision: ${config.role} scope [${config.scope.join(', ')}] overlaps with ${owner.name} (${owner.role}) which owns [${ownerScope}]. ` +
|
|
394
|
+
`Scope collision: ${config.role} scope [${config.scope.join(', ')}] overlaps with ${owner.name} (${owner.role}) which owns [${ownerScope}] in the same workspace. ` +
|
|
392
395
|
`Two agents cannot edit the same files. Either narrow the scope or wait for ${owner.name} to finish.`
|
|
393
396
|
);
|
|
394
397
|
}
|
|
@@ -1098,16 +1101,25 @@ For normal file edits within your scope, proceed without review.
|
|
|
1098
1101
|
* - The daemon has a preview plan stashed for this team (planner wrote one).
|
|
1099
1102
|
* - No pending phase 2 groups for this team (QC hasn't spawned yet).
|
|
1100
1103
|
* - Every non-planner team agent is in a terminal state.
|
|
1101
|
-
* - At least one non-planner agent completed successfully
|
|
1102
|
-
*
|
|
1104
|
+
* - At least one non-planner agent completed successfully.
|
|
1105
|
+
*
|
|
1106
|
+
* The plan is intentionally NOT cleared on failure so the user can hit the
|
|
1107
|
+
* "Launch Preview" button manually after fixing whatever blocked the auto
|
|
1108
|
+
* attempt (wrong cwd, missing deps, etc). We guard against infinite re-fires
|
|
1109
|
+
* with _previewAttempted per teamId.
|
|
1103
1110
|
*/
|
|
1104
1111
|
_checkPreviewReady(teamId) {
|
|
1105
1112
|
const preview = this.daemon.preview;
|
|
1106
1113
|
if (!preview) return;
|
|
1114
|
+
if (!this._previewAttempted) this._previewAttempted = new Set();
|
|
1115
|
+
if (this._previewAttempted.has(teamId)) return;
|
|
1116
|
+
|
|
1107
1117
|
const plan = preview.getPlan(teamId);
|
|
1108
|
-
if (!plan)
|
|
1118
|
+
if (!plan) {
|
|
1119
|
+
this.daemon.audit?.log('preview.skipped', { teamId, reason: 'no_plan_stashed' });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1109
1122
|
|
|
1110
|
-
// If a phase 2 group for this team is still pending, let it spawn first.
|
|
1111
1123
|
const pendingPhase2 = this.daemon._pendingPhase2 || [];
|
|
1112
1124
|
for (const group of pendingPhase2) {
|
|
1113
1125
|
for (const id of group.waitFor) {
|
|
@@ -1123,7 +1135,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1123
1135
|
const anyCompleted = teamAgents.some((a) => a.status === 'completed');
|
|
1124
1136
|
if (!allDone || !anyCompleted) return;
|
|
1125
1137
|
|
|
1126
|
-
|
|
1138
|
+
this._previewAttempted.add(teamId);
|
|
1127
1139
|
const workingDir = plan.workingDir;
|
|
1128
1140
|
preview.launch(teamId, workingDir, plan.preview).then((result) => {
|
|
1129
1141
|
if (!result.launched) {
|
|
@@ -1137,6 +1149,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1137
1149
|
}
|
|
1138
1150
|
}).catch((err) => {
|
|
1139
1151
|
console.error(`[Groove] Preview launch error for team ${teamId}:`, err.message);
|
|
1152
|
+
this.daemon.broadcast({ type: 'preview:failed', teamId, reason: err.message });
|
|
1140
1153
|
});
|
|
1141
1154
|
}
|
|
1142
1155
|
|