groove-dev 0.27.159 → 0.27.163

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 (57) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +1 -0
  4. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  5. package/node_modules/@groove-dev/daemon/src/process.js +227 -105
  6. package/node_modules/@groove-dev/daemon/src/routes/teams.js +2 -0
  7. package/node_modules/@groove-dev/daemon/src/scheduler.js +1 -0
  8. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +4 -2
  9. package/node_modules/@groove-dev/gui/dist/assets/index-BJVNpGIp.css +1 -0
  10. package/node_modules/@groove-dev/gui/dist/assets/index-BlNh_lRS.js +1025 -0
  11. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  12. package/node_modules/@groove-dev/gui/package.json +1 -1
  13. package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
  14. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +20 -12
  15. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +5 -2
  16. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +2 -19
  17. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +176 -0
  18. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-content.jsx +135 -0
  19. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +86 -0
  20. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +216 -0
  21. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  22. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +9 -1
  23. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +1 -1
  24. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +4 -0
  25. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +71 -1
  26. package/node_modules/@groove-dev/gui/src/views/fleet.jsx +15 -0
  27. package/package.json +1 -1
  28. package/packages/cli/package.json +1 -1
  29. package/packages/daemon/package.json +1 -1
  30. package/packages/daemon/src/gateways/manager.js +1 -0
  31. package/packages/daemon/src/index.js +3 -0
  32. package/packages/daemon/src/process.js +227 -105
  33. package/packages/daemon/src/routes/teams.js +2 -0
  34. package/packages/daemon/src/scheduler.js +1 -0
  35. package/packages/daemon/src/tunnel-manager.js +4 -2
  36. package/packages/gui/dist/assets/index-BJVNpGIp.css +1 -0
  37. package/packages/gui/dist/assets/index-BlNh_lRS.js +1025 -0
  38. package/packages/gui/dist/index.html +2 -2
  39. package/packages/gui/package.json +1 -1
  40. package/packages/gui/src/App.jsx +2 -0
  41. package/packages/gui/src/components/agents/diff-viewer.jsx +20 -12
  42. package/packages/gui/src/components/agents/spawn-wizard.jsx +5 -2
  43. package/packages/gui/src/components/agents/workspace-mode.jsx +2 -19
  44. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +176 -0
  45. package/packages/gui/src/components/fleet/fleet-content.jsx +135 -0
  46. package/packages/gui/src/components/fleet/fleet-pane.jsx +86 -0
  47. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +216 -0
  48. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  49. package/packages/gui/src/components/layout/welcome-splash.jsx +9 -1
  50. package/packages/gui/src/components/settings/quick-connect.jsx +1 -1
  51. package/packages/gui/src/stores/slices/agents-slice.js +4 -0
  52. package/packages/gui/src/stores/slices/ui-slice.js +71 -1
  53. package/packages/gui/src/views/fleet.jsx +15 -0
  54. package/node_modules/@groove-dev/gui/dist/assets/index-Bij9o_dc.js +0 -1020
  55. package/node_modules/@groove-dev/gui/dist/assets/index-Dzofq3wS.css +0 -1
  56. package/packages/gui/dist/assets/index-Bij9o_dc.js +0 -1020
  57. package/packages/gui/dist/assets/index-Dzofq3wS.css +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.159",
3
+ "version": "0.27.163",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.159",
3
+ "version": "0.27.163",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -874,6 +874,7 @@ export class GatewayManager {
874
874
  teamId: defaultTeamId,
875
875
  })),
876
876
  });
877
+ this.daemon.processes._persistPendingPhase2();
877
878
  }
878
879
  }
879
880
 
@@ -588,6 +588,9 @@ export class Daemon {
588
588
  console.log(` ${resumableIds.size} agent-loop session(s) marked as resumable`);
589
589
  }
590
590
 
591
+ // Restore pending phase 2 groups from disk
592
+ this.processes.loadPendingPhase2();
593
+
591
594
  // Migrate old agents without teamId to default team
592
595
  this.teams.migrateAgents();
593
596
 
@@ -349,6 +349,7 @@ export class ProcessManager {
349
349
  this._reviewTriggered = new Set(); // teamIds that have had review triggered (one round only)
350
350
  this._pendingReviews = new Map(); // teamId -> { reviewAgentId }
351
351
  this._reviewPending = new Set(); // teamIds with review in progress (blocks preview launch)
352
+ this._phase2Debounce = new Map(); // groupIndex -> timer (300ms settle delay)
352
353
 
353
354
  this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
354
355
  if (this._stallWatchdog.unref) this._stallWatchdog.unref();
@@ -1850,6 +1851,7 @@ For normal file edits within your scope, proceed without review.
1850
1851
  teamId: defaultTeamId,
1851
1852
  })),
1852
1853
  });
1854
+ this._persistPendingPhase2();
1853
1855
  }
1854
1856
 
1855
1857
  // Clean up the file
@@ -1867,133 +1869,253 @@ For normal file edits within your scope, proceed without review.
1867
1869
  /**
1868
1870
  * Check if a completed/crashed agent was the last phase 1 agent in a team.
1869
1871
  * If so, auto-spawn the phase 2 (QC/finisher) agents.
1872
+ *
1873
+ * Uses a 300ms debounce so rapid parallel completions settle before the
1874
+ * allDone check, and defers splicing the pending group until spawns succeed.
1870
1875
  */
1871
1876
  _checkPhase2(completedAgentId) {
1872
1877
  const pending = this.daemon._pendingPhase2;
1873
- if (!pending || pending.length === 0) return;
1874
-
1875
- const registry = this.daemon.registry;
1878
+ if (!pending || pending.length === 0) {
1879
+ console.log(`[Groove:Phase2] _checkPhase2(${completedAgentId}): no pending groups, skipping`);
1880
+ return;
1881
+ }
1876
1882
 
1877
1883
  for (let i = pending.length - 1; i >= 0; i--) {
1878
1884
  const group = pending[i];
1879
1885
  if (!group.waitFor.includes(completedAgentId)) continue;
1880
1886
 
1881
- // Check if ALL phase 1 agents in this group are done
1882
- const allDone = group.waitFor.every((id) => {
1887
+ // Debounce: cancel any existing timer for this group and restart.
1888
+ // This lets parallel agent completions (50-100ms apart) settle
1889
+ // before we evaluate the allDone condition.
1890
+ const debounceKey = group.waitFor.join(',');
1891
+ const existingTimer = this._phase2Debounce.get(debounceKey);
1892
+ if (existingTimer) clearTimeout(existingTimer);
1893
+
1894
+ const groupRef = group;
1895
+ const timer = setTimeout(() => {
1896
+ this._phase2Debounce.delete(debounceKey);
1897
+ this._executePhase2Check(completedAgentId, groupRef);
1898
+ }, 300);
1899
+ this._phase2Debounce.set(debounceKey, timer);
1900
+ console.log(`[Groove:Phase2] _checkPhase2(${completedAgentId}): debounce scheduled for group [${debounceKey}]`);
1901
+ }
1902
+ }
1903
+
1904
+ _executePhase2Check(completedAgentId, group) {
1905
+ const pending = this.daemon._pendingPhase2;
1906
+ if (!pending || !pending.includes(group)) {
1907
+ console.log(`[Groove:Phase2] _executePhase2Check: group no longer in pending queue, skipping`);
1908
+ return;
1909
+ }
1910
+
1911
+ if (group._spawning) {
1912
+ console.log(`[Groove:Phase2] _executePhase2Check: group already spawning, skipping`);
1913
+ return;
1914
+ }
1915
+
1916
+ const registry = this.daemon.registry;
1917
+ const allDone = group.waitFor.every((id) => {
1918
+ const a = registry.get(id);
1919
+ return !a || a.status === 'completed' || a.status === 'crashed' || a.status === 'stopped' || a.status === 'killed';
1920
+ });
1921
+
1922
+ if (!allDone) {
1923
+ const still = group.waitFor.filter((id) => {
1883
1924
  const a = registry.get(id);
1884
- return !a || a.status === 'completed' || a.status === 'crashed' || a.status === 'stopped' || a.status === 'killed';
1925
+ return a && a.status !== 'completed' && a.status !== 'crashed' && a.status !== 'stopped' && a.status !== 'killed';
1885
1926
  });
1927
+ console.log(`[Groove:Phase2] _executePhase2Check(${completedAgentId}): not all done, still waiting on [${still.join(', ')}]`);
1928
+ return;
1929
+ }
1930
+
1931
+ console.log(`[Groove:Phase2] All phase 1 agents done for group [${group.waitFor.join(', ')}], spawning phase 2`);
1932
+
1933
+ // Mark group as in-flight (don't splice yet — wait for spawns to succeed)
1934
+ group._spawning = true;
1886
1935
 
1887
- if (allDone) {
1888
- // Remove from pending
1889
- pending.splice(i, 1);
1890
-
1891
- // Check if phase 1 agents did any real work by looking at file modifications.
1892
- // If no agent modified any files, there's nothing to QC.
1893
- const journalist = this.daemon.journalist;
1894
- const phase1Idle = group.waitFor.every((id) => {
1895
- const a = registry.get(id);
1896
- if (!a) return true;
1897
- const files = journalist?.getAgentFiles(a) || [];
1898
- return files.length === 0;
1936
+ const journalist = this.daemon.journalist;
1937
+ const phase1Idle = group.waitFor.every((id) => {
1938
+ const a = registry.get(id);
1939
+ if (!a) return true;
1940
+ const files = journalist?.getAgentFiles(a) || [];
1941
+ return files.length === 0;
1942
+ });
1943
+
1944
+ const groupTeamId = group.agents[0]?.teamId || this.daemon.teams.getDefault()?.id || null;
1945
+ if (!this._phase2Spawning) this._phase2Spawning = new Map();
1946
+ const spawnPromises = [];
1947
+ let reusedCount = 0;
1948
+
1949
+ for (const config of group.agents) {
1950
+ if (phase1Idle) config.prompt = '';
1951
+
1952
+ const teamId = config.teamId || this.daemon.teams.getDefault()?.id || null;
1953
+ const existing = teamId ? registry.getAll().find((a) =>
1954
+ a.teamId === teamId && a.role === config.role && a.id !== undefined
1955
+ ) : null;
1956
+
1957
+ if (existing && (existing.status === 'running' || existing.status === 'starting')) {
1958
+ if (config.prompt) {
1959
+ this.sendMessage(existing.id, config.prompt, 'planner').catch((err) => {
1960
+ console.error(`[Groove:Phase2] Reuse message failed for ${existing.name}: ${err.message}`);
1961
+ });
1962
+ }
1963
+ this.daemon.audit.log('phase2.reuse', { id: existing.id, name: existing.name, role: existing.role });
1964
+ this.daemon.broadcast({
1965
+ type: 'phase2:spawned',
1966
+ agentId: existing.id,
1967
+ name: existing.name,
1968
+ role: existing.role,
1969
+ reused: true,
1899
1970
  });
1971
+ reusedCount++;
1972
+ console.log(`[Groove:Phase2] Reused existing agent ${existing.name} (${existing.id}) for role ${config.role}`);
1973
+ continue;
1974
+ }
1900
1975
 
1901
- // Track that phase 2 spawns are in-flight for this team so
1902
- // _checkPreviewReady doesn't race ahead of the async spawn calls.
1903
- const groupTeamId = group.agents[0]?.teamId || this.daemon.teams.getDefault()?.id || null;
1904
- if (!this._phase2Spawning) this._phase2Spawning = new Map();
1905
- const spawnPromises = [];
1906
-
1907
- // Auto-spawn phase 2 agents — if phase 1 was idle, clear the prompt
1908
- // so QC also waits for instructions instead of auditing nothing
1909
- for (const config of group.agents) {
1910
- if (phase1Idle) config.prompt = '';
1911
-
1912
- // Dedup: check if team already has an agent with this role
1913
- const teamId = config.teamId || this.daemon.teams.getDefault()?.id || null;
1914
- const existing = teamId ? registry.getAll().find((a) =>
1915
- a.teamId === teamId && a.role === config.role && a.id !== undefined
1916
- ) : null;
1917
-
1918
- if (existing && (existing.status === 'running' || existing.status === 'starting')) {
1919
- // Agent already active — reuse it instead of spawning a duplicate
1920
- if (config.prompt) {
1921
- this.sendMessage(existing.id, config.prompt, 'planner').catch((err) => {
1922
- console.error(`[Groove] Phase 2 reuse message failed for ${existing.name}: ${err.message}`);
1923
- });
1924
- }
1925
- this.daemon.audit.log('phase2.reuse', { id: existing.id, name: existing.name, role: existing.role });
1976
+ if (existing && (existing.status === 'completed' || existing.status === 'stopped' || existing.status === 'crashed' || existing.status === 'killed')) {
1977
+ config.name = existing.name;
1978
+ registry.remove(existing.id);
1979
+ this.daemon.locks.release(existing.id);
1980
+ }
1981
+
1982
+ try {
1983
+ const validated = validateAgentConfig(config);
1984
+ if (!validated.teamId) validated.teamId = this.daemon.teams.getDefault()?.id || null;
1985
+ validated.metadata = { ...(validated.metadata || {}), isQcPhase2: true };
1986
+ const existingId = existing?.id || null;
1987
+ const p = this.spawn(validated).then((agent) => {
1988
+ registry.update(agent.id, { metadata: { ...(agent.metadata || {}), isQcPhase2: true } });
1989
+ this.daemon.broadcast({
1990
+ type: 'phase2:spawned',
1991
+ agentId: agent.id,
1992
+ oldAgentId: existingId,
1993
+ name: agent.name,
1994
+ role: agent.role,
1995
+ });
1996
+ if (existingId) {
1926
1997
  this.daemon.broadcast({
1927
- type: 'phase2:spawned',
1928
- agentId: existing.id,
1929
- name: existing.name,
1930
- role: existing.role,
1931
- reused: true,
1998
+ type: 'rotation:complete',
1999
+ agentId: agent.id,
2000
+ oldAgentId: existingId,
2001
+ agentName: agent.name,
2002
+ reason: 'phase2_respawn',
1932
2003
  });
1933
- continue;
1934
2004
  }
2005
+ this.daemon.audit.log('phase2.autoSpawn', { id: agent.id, name: agent.name, role: agent.role });
2006
+ console.log(`[Groove:Phase2] Spawned ${agent.name} (${agent.id}) for role ${validated.role}`);
2007
+ return { status: 'spawned', agentId: agent.id };
2008
+ }).catch((err) => {
2009
+ console.error(`[Groove:Phase2] Spawn failed for ${config.role}: ${err.message}`);
2010
+ this.daemon.broadcast({
2011
+ type: 'phase2:failed',
2012
+ role: config.role,
2013
+ error: err.message,
2014
+ });
2015
+ throw err;
2016
+ });
2017
+ spawnPromises.push(p);
2018
+ } catch (err) {
2019
+ console.error(`[Groove:Phase2] Config invalid for ${config.role}: ${err.message}`);
2020
+ this.daemon.broadcast({
2021
+ type: 'phase2:failed',
2022
+ role: config.role,
2023
+ error: err.message,
2024
+ });
2025
+ }
2026
+ }
1935
2027
 
1936
- if (existing && (existing.status === 'completed' || existing.status === 'stopped' || existing.status === 'crashed' || existing.status === 'killed')) {
1937
- // Previous agent finished remove it and respawn with the same name
1938
- config.name = existing.name;
1939
- registry.remove(existing.id);
1940
- this.daemon.locks.release(existing.id);
1941
- }
2028
+ if (spawnPromises.length === 0 && reusedCount === 0) {
2029
+ console.error(`[Groove:Phase2] No spawns attempted and no reuses for group [${group.waitFor.join(', ')}]`);
2030
+ this.daemon.broadcast({
2031
+ type: 'phase2:failed',
2032
+ role: group.agents.map(a => a.role).join(', '),
2033
+ error: 'No phase 2 agents could be spawned or reused',
2034
+ });
2035
+ // Remove failed group from pending and persist
2036
+ const idx = pending.indexOf(group);
2037
+ if (idx !== -1) pending.splice(idx, 1);
2038
+ this._persistPendingPhase2();
2039
+ return;
2040
+ }
1942
2041
 
1943
- try {
1944
- const validated = validateAgentConfig(config);
1945
- if (!validated.teamId) validated.teamId = this.daemon.teams.getDefault()?.id || null;
1946
- validated.metadata = { ...(validated.metadata || {}), isQcPhase2: true };
1947
- const existingId = existing?.id || null;
1948
- const p = this.spawn(validated).then((agent) => {
1949
- registry.update(agent.id, { metadata: { ...(agent.metadata || {}), isQcPhase2: true } });
1950
- this.daemon.broadcast({
1951
- type: 'phase2:spawned',
1952
- agentId: agent.id,
1953
- oldAgentId: existingId,
1954
- name: agent.name,
1955
- role: agent.role,
1956
- });
1957
- if (existingId) {
1958
- this.daemon.broadcast({
1959
- type: 'rotation:complete',
1960
- agentId: agent.id,
1961
- oldAgentId: existingId,
1962
- agentName: agent.name,
1963
- reason: 'phase2_respawn',
1964
- });
1965
- }
1966
- this.daemon.audit.log('phase2.autoSpawn', { id: agent.id, name: agent.name, role: agent.role });
1967
- }).catch((err) => {
1968
- console.error(`[Groove] Phase 2 spawn failed for ${config.role}: ${err.message}`);
1969
- this.daemon.broadcast({
1970
- type: 'phase2:failed',
1971
- role: config.role,
1972
- error: err.message,
1973
- });
1974
- });
1975
- spawnPromises.push(p);
1976
- } catch (err) {
1977
- console.error(`[Groove] Phase 2 config invalid for ${config.role}: ${err.message}`);
1978
- this.daemon.broadcast({
1979
- type: 'phase2:failed',
1980
- role: config.role,
1981
- error: err.message,
1982
- });
1983
- }
1984
- }
2042
+ if (spawnPromises.length > 0 && groupTeamId) {
2043
+ this._phase2Spawning.set(groupTeamId, (this._phase2Spawning.get(groupTeamId) || 0) + spawnPromises.length);
2044
+ }
1985
2045
 
1986
- // Mark this team as having phase 2 spawns in-flight. Cleared once
1987
- // all spawn promises settle so _checkPreviewReady won't race ahead.
1988
- if (spawnPromises.length > 0 && groupTeamId) {
1989
- this._phase2Spawning.set(groupTeamId, (this._phase2Spawning.get(groupTeamId) || 0) + spawnPromises.length);
1990
- Promise.allSettled(spawnPromises).then(() => {
1991
- const remaining = (this._phase2Spawning.get(groupTeamId) || 0) - spawnPromises.length;
1992
- if (remaining <= 0) this._phase2Spawning.delete(groupTeamId);
1993
- else this._phase2Spawning.set(groupTeamId, remaining);
2046
+ if (spawnPromises.length > 0) {
2047
+ Promise.allSettled(spawnPromises).then((results) => {
2048
+ const succeeded = results.filter(r => r.status === 'fulfilled').length;
2049
+ const failed = results.filter(r => r.status === 'rejected').length;
2050
+
2051
+ console.log(`[Groove:Phase2] Spawn results: ${succeeded} succeeded, ${failed} failed, ${reusedCount} reused`);
2052
+
2053
+ if (succeeded > 0 || reusedCount > 0) {
2054
+ // At least one spawn succeeded — remove group from pending
2055
+ const idx = pending.indexOf(group);
2056
+ if (idx !== -1) pending.splice(idx, 1);
2057
+ this._persistPendingPhase2();
2058
+ console.log(`[Groove:Phase2] Group removed from pending queue`);
2059
+ } else {
2060
+ // All spawns failed — keep group in pending for retry, broadcast failure
2061
+ group._spawning = false;
2062
+ console.error(`[Groove:Phase2] All spawns failed for group [${group.waitFor.join(', ')}], keeping in queue for retry`);
2063
+ this.daemon.broadcast({
2064
+ type: 'phase2:failed',
2065
+ role: group.agents.map(a => a.role).join(', '),
2066
+ error: 'All phase 2 spawn attempts failed',
2067
+ retryable: true,
1994
2068
  });
1995
2069
  }
2070
+
2071
+ // Clear in-flight counter
2072
+ if (groupTeamId) {
2073
+ const remaining = (this._phase2Spawning.get(groupTeamId) || 0) - spawnPromises.length;
2074
+ if (remaining <= 0) this._phase2Spawning.delete(groupTeamId);
2075
+ else this._phase2Spawning.set(groupTeamId, remaining);
2076
+ }
2077
+ });
2078
+ } else {
2079
+ // All agents were reused, no async spawns — remove group now
2080
+ const idx = pending.indexOf(group);
2081
+ if (idx !== -1) pending.splice(idx, 1);
2082
+ this._persistPendingPhase2();
2083
+ console.log(`[Groove:Phase2] Group removed from pending queue (all reused)`);
2084
+ }
2085
+ }
2086
+
2087
+ _persistPendingPhase2() {
2088
+ try {
2089
+ const pending = this.daemon._pendingPhase2 || [];
2090
+ const saveable = pending.filter(g => !g._spawning);
2091
+ const filePath = resolve(this.daemon.grooveDir, 'pending-phase2.json');
2092
+ if (saveable.length === 0) {
2093
+ try { unlinkSync(filePath); } catch { /* already gone */ }
2094
+ } else {
2095
+ writeFileSync(filePath, JSON.stringify(saveable, null, 2));
2096
+ }
2097
+ } catch (err) {
2098
+ console.error(`[Groove:Phase2] Failed to persist pending-phase2.json: ${err.message}`);
2099
+ }
2100
+ }
2101
+
2102
+ loadPendingPhase2() {
2103
+ try {
2104
+ const filePath = resolve(this.daemon.grooveDir, 'pending-phase2.json');
2105
+ if (!existsSync(filePath)) return;
2106
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
2107
+ if (Array.isArray(data) && data.length > 0) {
2108
+ this.daemon._pendingPhase2 = this.daemon._pendingPhase2 || [];
2109
+ for (const group of data) {
2110
+ const isDupe = this.daemon._pendingPhase2.some(g =>
2111
+ g.waitFor.join(',') === group.waitFor.join(',')
2112
+ );
2113
+ if (!isDupe) this.daemon._pendingPhase2.push(group);
2114
+ }
2115
+ console.log(`[Groove:Phase2] Loaded ${data.length} pending phase 2 group(s) from disk`);
1996
2116
  }
2117
+ } catch (err) {
2118
+ console.error(`[Groove:Phase2] Failed to load pending-phase2.json: ${err.message}`);
1997
2119
  }
1998
2120
  }
1999
2121
 
@@ -418,6 +418,7 @@ export function registerTeamRoutes(app, daemon) {
418
418
  teamId: defaultTeamId,
419
419
  })),
420
420
  });
421
+ daemon.processes._persistPendingPhase2();
421
422
  }
422
423
  }
423
424
 
@@ -632,6 +633,7 @@ export function registerTeamRoutes(app, daemon) {
632
633
  teamId: defaultTeamId,
633
634
  })),
634
635
  });
636
+ daemon.processes._persistPendingPhase2();
635
637
  }
636
638
 
637
639
  daemon.audit.log('team-builder.launch', {
@@ -574,6 +574,7 @@ export class Scheduler {
574
574
  teamId: team.id,
575
575
  })),
576
576
  });
577
+ this.daemon.processes._persistPendingPhase2();
577
578
  }
578
579
 
579
580
  this.runningAgents.set(schedule.id, {
@@ -395,9 +395,9 @@ export class TunnelManager {
395
395
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'waiting', message: 'Remote daemon not running. Start it manually or enable auto-start.' } });
396
396
  }
397
397
 
398
- // Auto-upgrade: check version through tunnel, upgrade if behind (non-blocking)
398
+ // Auto-upgrade: check version through tunnel, upgrade if behind
399
399
  if (remoteAlive && !preConnectHandled) {
400
- this._checkAndUpgradeRunning(id, config, localPort).catch(() => {});
400
+ await this._checkAndUpgradeRunning(id, config, localPort);
401
401
  }
402
402
 
403
403
  const remoteVer = testResult?.remoteVersion || null;
@@ -451,6 +451,8 @@ export class TunnelManager {
451
451
 
452
452
  async _checkAndUpgradeRunning(id, config, localPort) {
453
453
  try {
454
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'checking' } });
455
+
454
456
  // Get remote daemon version
455
457
  const resp = await fetch(`http://localhost:${localPort}/api/status`, {
456
458
  signal: AbortSignal.timeout(5000),