fleet-commander-ai 0.0.1
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/LICENSE +202 -0
- package/README.md +159 -0
- package/bin/fleet-commander-mcp.js +27 -0
- package/bin/fleet-commander.js +22 -0
- package/dist/client/assets/index-CHukC8Hq.js +188 -0
- package/dist/client/assets/index-CHukC8Hq.js.map +1 -0
- package/dist/client/assets/index-DvMjcYbg.css +1 -0
- package/dist/client/index.html +13 -0
- package/dist/server/config.d.ts +51 -0
- package/dist/server/config.d.ts.map +1 -0
- package/dist/server/config.js +104 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/db.d.ts +388 -0
- package/dist/server/db.d.ts.map +1 -0
- package/dist/server/db.js +1524 -0
- package/dist/server/db.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +162 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/mcp/index.d.ts +2 -0
- package/dist/server/mcp/index.d.ts.map +1 -0
- package/dist/server/mcp/index.js +112 -0
- package/dist/server/mcp/index.js.map +1 -0
- package/dist/server/mcp/tools/add-project.d.ts +9 -0
- package/dist/server/mcp/tools/add-project.d.ts.map +1 -0
- package/dist/server/mcp/tools/add-project.js +58 -0
- package/dist/server/mcp/tools/add-project.js.map +1 -0
- package/dist/server/mcp/tools/get-team-timeline.d.ts +9 -0
- package/dist/server/mcp/tools/get-team-timeline.d.ts.map +1 -0
- package/dist/server/mcp/tools/get-team-timeline.js +48 -0
- package/dist/server/mcp/tools/get-team-timeline.js.map +1 -0
- package/dist/server/mcp/tools/get-team.d.ts +9 -0
- package/dist/server/mcp/tools/get-team.d.ts.map +1 -0
- package/dist/server/mcp/tools/get-team.js +48 -0
- package/dist/server/mcp/tools/get-team.js.map +1 -0
- package/dist/server/mcp/tools/get-usage.d.ts +9 -0
- package/dist/server/mcp/tools/get-usage.d.ts.map +1 -0
- package/dist/server/mcp/tools/get-usage.js +33 -0
- package/dist/server/mcp/tools/get-usage.js.map +1 -0
- package/dist/server/mcp/tools/launch-team.d.ts +8 -0
- package/dist/server/mcp/tools/launch-team.d.ts.map +1 -0
- package/dist/server/mcp/tools/launch-team.js +49 -0
- package/dist/server/mcp/tools/launch-team.js.map +1 -0
- package/dist/server/mcp/tools/list-issues.d.ts +9 -0
- package/dist/server/mcp/tools/list-issues.d.ts.map +1 -0
- package/dist/server/mcp/tools/list-issues.js +47 -0
- package/dist/server/mcp/tools/list-issues.js.map +1 -0
- package/dist/server/mcp/tools/list-projects.d.ts +9 -0
- package/dist/server/mcp/tools/list-projects.d.ts.map +1 -0
- package/dist/server/mcp/tools/list-projects.js +32 -0
- package/dist/server/mcp/tools/list-projects.js.map +1 -0
- package/dist/server/mcp/tools/list-teams.d.ts +9 -0
- package/dist/server/mcp/tools/list-teams.d.ts.map +1 -0
- package/dist/server/mcp/tools/list-teams.js +43 -0
- package/dist/server/mcp/tools/list-teams.js.map +1 -0
- package/dist/server/mcp/tools/restart-team.d.ts +8 -0
- package/dist/server/mcp/tools/restart-team.d.ts.map +1 -0
- package/dist/server/mcp/tools/restart-team.js +46 -0
- package/dist/server/mcp/tools/restart-team.js.map +1 -0
- package/dist/server/mcp/tools/send-message.d.ts +9 -0
- package/dist/server/mcp/tools/send-message.d.ts.map +1 -0
- package/dist/server/mcp/tools/send-message.js +48 -0
- package/dist/server/mcp/tools/send-message.js.map +1 -0
- package/dist/server/mcp/tools/stop-team.d.ts +8 -0
- package/dist/server/mcp/tools/stop-team.d.ts.map +1 -0
- package/dist/server/mcp/tools/stop-team.js +46 -0
- package/dist/server/mcp/tools/stop-team.js.map +1 -0
- package/dist/server/mcp/tools/system-health.d.ts +9 -0
- package/dist/server/mcp/tools/system-health.d.ts.map +1 -0
- package/dist/server/mcp/tools/system-health.js +32 -0
- package/dist/server/mcp/tools/system-health.js.map +1 -0
- package/dist/server/middleware/error-handler.d.ts +32 -0
- package/dist/server/middleware/error-handler.d.ts.map +1 -0
- package/dist/server/middleware/error-handler.js +65 -0
- package/dist/server/middleware/error-handler.js.map +1 -0
- package/dist/server/routes/events.d.ts +16 -0
- package/dist/server/routes/events.d.ts.map +1 -0
- package/dist/server/routes/events.js +164 -0
- package/dist/server/routes/events.js.map +1 -0
- package/dist/server/routes/issues.d.ts +4 -0
- package/dist/server/routes/issues.d.ts.map +1 -0
- package/dist/server/routes/issues.js +198 -0
- package/dist/server/routes/issues.js.map +1 -0
- package/dist/server/routes/project-groups.d.ts +4 -0
- package/dist/server/routes/project-groups.d.ts.map +1 -0
- package/dist/server/routes/project-groups.js +124 -0
- package/dist/server/routes/project-groups.js.map +1 -0
- package/dist/server/routes/projects.d.ts +4 -0
- package/dist/server/routes/projects.d.ts.map +1 -0
- package/dist/server/routes/projects.js +319 -0
- package/dist/server/routes/projects.js.map +1 -0
- package/dist/server/routes/prs.d.ts +4 -0
- package/dist/server/routes/prs.d.ts.map +1 -0
- package/dist/server/routes/prs.js +186 -0
- package/dist/server/routes/prs.js.map +1 -0
- package/dist/server/routes/query.d.ts +4 -0
- package/dist/server/routes/query.d.ts.map +1 -0
- package/dist/server/routes/query.js +82 -0
- package/dist/server/routes/query.js.map +1 -0
- package/dist/server/routes/state-machine.d.ts +4 -0
- package/dist/server/routes/state-machine.d.ts.map +1 -0
- package/dist/server/routes/state-machine.js +86 -0
- package/dist/server/routes/state-machine.js.map +1 -0
- package/dist/server/routes/stream.d.ts +18 -0
- package/dist/server/routes/stream.d.ts.map +1 -0
- package/dist/server/routes/stream.js +62 -0
- package/dist/server/routes/stream.js.map +1 -0
- package/dist/server/routes/system.d.ts +4 -0
- package/dist/server/routes/system.d.ts.map +1 -0
- package/dist/server/routes/system.js +225 -0
- package/dist/server/routes/system.js.map +1 -0
- package/dist/server/routes/teams.d.ts +4 -0
- package/dist/server/routes/teams.d.ts.map +1 -0
- package/dist/server/routes/teams.js +570 -0
- package/dist/server/routes/teams.js.map +1 -0
- package/dist/server/routes/usage.d.ts +4 -0
- package/dist/server/routes/usage.d.ts.map +1 -0
- package/dist/server/routes/usage.js +80 -0
- package/dist/server/routes/usage.js.map +1 -0
- package/dist/server/schema.sql +267 -0
- package/dist/server/services/cc-query.d.ts +20 -0
- package/dist/server/services/cc-query.d.ts.map +1 -0
- package/dist/server/services/cc-query.js +352 -0
- package/dist/server/services/cc-query.js.map +1 -0
- package/dist/server/services/cleanup.d.ts +15 -0
- package/dist/server/services/cleanup.d.ts.map +1 -0
- package/dist/server/services/cleanup.js +232 -0
- package/dist/server/services/cleanup.js.map +1 -0
- package/dist/server/services/diagnostics-service.d.ts +85 -0
- package/dist/server/services/diagnostics-service.d.ts.map +1 -0
- package/dist/server/services/diagnostics-service.js +242 -0
- package/dist/server/services/diagnostics-service.js.map +1 -0
- package/dist/server/services/event-collector.d.ts +125 -0
- package/dist/server/services/event-collector.d.ts.map +1 -0
- package/dist/server/services/event-collector.js +299 -0
- package/dist/server/services/event-collector.js.map +1 -0
- package/dist/server/services/event-service.d.ts +22 -0
- package/dist/server/services/event-service.d.ts.map +1 -0
- package/dist/server/services/event-service.js +53 -0
- package/dist/server/services/event-service.js.map +1 -0
- package/dist/server/services/github-poller.d.ts +68 -0
- package/dist/server/services/github-poller.d.ts.map +1 -0
- package/dist/server/services/github-poller.js +563 -0
- package/dist/server/services/github-poller.js.map +1 -0
- package/dist/server/services/issue-fetcher.d.ts +231 -0
- package/dist/server/services/issue-fetcher.d.ts.map +1 -0
- package/dist/server/services/issue-fetcher.js +1053 -0
- package/dist/server/services/issue-fetcher.js.map +1 -0
- package/dist/server/services/issue-service.d.ts +102 -0
- package/dist/server/services/issue-service.d.ts.map +1 -0
- package/dist/server/services/issue-service.js +279 -0
- package/dist/server/services/issue-service.js.map +1 -0
- package/dist/server/services/message-template-service.d.ts +39 -0
- package/dist/server/services/message-template-service.d.ts.map +1 -0
- package/dist/server/services/message-template-service.js +87 -0
- package/dist/server/services/message-template-service.js.map +1 -0
- package/dist/server/services/pr-service.d.ts +73 -0
- package/dist/server/services/pr-service.d.ts.map +1 -0
- package/dist/server/services/pr-service.js +231 -0
- package/dist/server/services/pr-service.js.map +1 -0
- package/dist/server/services/project-group-service.d.ts +64 -0
- package/dist/server/services/project-group-service.d.ts.map +1 -0
- package/dist/server/services/project-group-service.js +149 -0
- package/dist/server/services/project-group-service.js.map +1 -0
- package/dist/server/services/project-service.d.ts +161 -0
- package/dist/server/services/project-service.d.ts.map +1 -0
- package/dist/server/services/project-service.js +623 -0
- package/dist/server/services/project-service.js.map +1 -0
- package/dist/server/services/service-error.d.ts +25 -0
- package/dist/server/services/service-error.d.ts.map +1 -0
- package/dist/server/services/service-error.js +49 -0
- package/dist/server/services/service-error.js.map +1 -0
- package/dist/server/services/sse-broker.d.ts +144 -0
- package/dist/server/services/sse-broker.d.ts.map +1 -0
- package/dist/server/services/sse-broker.js +111 -0
- package/dist/server/services/sse-broker.js.map +1 -0
- package/dist/server/services/startup-recovery.d.ts +10 -0
- package/dist/server/services/startup-recovery.d.ts.map +1 -0
- package/dist/server/services/startup-recovery.js +122 -0
- package/dist/server/services/startup-recovery.js.map +1 -0
- package/dist/server/services/stuck-detector.d.ts +20 -0
- package/dist/server/services/stuck-detector.d.ts.map +1 -0
- package/dist/server/services/stuck-detector.js +167 -0
- package/dist/server/services/stuck-detector.js.map +1 -0
- package/dist/server/services/stuck-detector.test.d.ts +2 -0
- package/dist/server/services/stuck-detector.test.d.ts.map +1 -0
- package/dist/server/services/stuck-detector.test.js +363 -0
- package/dist/server/services/stuck-detector.test.js.map +1 -0
- package/dist/server/services/team-manager.d.ts +188 -0
- package/dist/server/services/team-manager.d.ts.map +1 -0
- package/dist/server/services/team-manager.js +1869 -0
- package/dist/server/services/team-manager.js.map +1 -0
- package/dist/server/services/team-service.d.ts +251 -0
- package/dist/server/services/team-service.d.ts.map +1 -0
- package/dist/server/services/team-service.js +707 -0
- package/dist/server/services/team-service.js.map +1 -0
- package/dist/server/services/usage-service.d.ts +42 -0
- package/dist/server/services/usage-service.d.ts.map +1 -0
- package/dist/server/services/usage-service.js +101 -0
- package/dist/server/services/usage-service.js.map +1 -0
- package/dist/server/services/usage-tracker.d.ts +68 -0
- package/dist/server/services/usage-tracker.d.ts.map +1 -0
- package/dist/server/services/usage-tracker.js +220 -0
- package/dist/server/services/usage-tracker.js.map +1 -0
- package/dist/server/utils/build-timeline.d.ts +32 -0
- package/dist/server/utils/build-timeline.d.ts.map +1 -0
- package/dist/server/utils/build-timeline.js +142 -0
- package/dist/server/utils/build-timeline.js.map +1 -0
- package/dist/server/utils/find-git-bash.d.ts +10 -0
- package/dist/server/utils/find-git-bash.d.ts.map +1 -0
- package/dist/server/utils/find-git-bash.js +46 -0
- package/dist/server/utils/find-git-bash.js.map +1 -0
- package/dist/server/utils/hook-installer.d.ts +20 -0
- package/dist/server/utils/hook-installer.d.ts.map +1 -0
- package/dist/server/utils/hook-installer.js +90 -0
- package/dist/server/utils/hook-installer.js.map +1 -0
- package/dist/server/utils/process-utils.d.ts +10 -0
- package/dist/server/utils/process-utils.d.ts.map +1 -0
- package/dist/server/utils/process-utils.js +33 -0
- package/dist/server/utils/process-utils.js.map +1 -0
- package/dist/server/utils/resolve-claude-path.d.ts +4 -0
- package/dist/server/utils/resolve-claude-path.d.ts.map +1 -0
- package/dist/server/utils/resolve-claude-path.js +66 -0
- package/dist/server/utils/resolve-claude-path.js.map +1 -0
- package/dist/server/utils/resolve-message.d.ts +10 -0
- package/dist/server/utils/resolve-message.d.ts.map +1 -0
- package/dist/server/utils/resolve-message.js +27 -0
- package/dist/server/utils/resolve-message.js.map +1 -0
- package/dist/shared/message-templates.d.ts +9 -0
- package/dist/shared/message-templates.d.ts.map +1 -0
- package/dist/shared/message-templates.js +88 -0
- package/dist/shared/message-templates.js.map +1 -0
- package/dist/shared/state-machine.d.ts +28 -0
- package/dist/shared/state-machine.d.ts.map +1 -0
- package/dist/shared/state-machine.js +282 -0
- package/dist/shared/state-machine.js.map +1 -0
- package/dist/shared/types.d.ts +404 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +5 -0
- package/dist/shared/types.js.map +1 -0
- package/hooks/DESIGN.md +562 -0
- package/hooks/on_notification.sh +9 -0
- package/hooks/on_post_tool_use.sh +9 -0
- package/hooks/on_pre_compact.sh +10 -0
- package/hooks/on_session_end.sh +8 -0
- package/hooks/on_session_start.sh +8 -0
- package/hooks/on_stop.sh +8 -0
- package/hooks/on_stop_failure.sh +8 -0
- package/hooks/on_subagent_start.sh +8 -0
- package/hooks/on_subagent_stop.sh +8 -0
- package/hooks/on_teammate_idle.sh +8 -0
- package/hooks/on_tool_error.sh +8 -0
- package/hooks/send_event.sh +101 -0
- package/hooks/settings.json.example +120 -0
- package/package.json +93 -0
- package/prompts/default-prompt.md +16 -0
- package/scripts/install.ps1 +22 -0
- package/scripts/install.sh +229 -0
- package/scripts/launch.js +64 -0
- package/scripts/uninstall.ps1 +22 -0
- package/scripts/uninstall.sh +123 -0
- package/templates/agents/fleet-dev.md +162 -0
- package/templates/agents/fleet-planner.md +263 -0
- package/templates/agents/fleet-reviewer.md +309 -0
- package/templates/archive/fleet-coordinator.md +128 -0
- package/templates/archive/fleet-dev-csharp.md +74 -0
- package/templates/archive/fleet-dev-devops.md +76 -0
- package/templates/archive/fleet-dev-fsharp.md +83 -0
- package/templates/archive/fleet-dev-generic.md +64 -0
- package/templates/archive/fleet-dev-python.md +75 -0
- package/templates/archive/fleet-dev-typescript.md +76 -0
- package/templates/guides/api-design.md +159 -0
- package/templates/guides/csharp-conventions.md +182 -0
- package/templates/guides/devops-conventions.md +192 -0
- package/templates/guides/fsharp-conventions.md +201 -0
- package/templates/guides/python-conventions.md +146 -0
- package/templates/guides/sql-database.md +125 -0
- package/templates/guides/testing-strategies.md +123 -0
- package/templates/guides/typescript-conventions.md +130 -0
- package/templates/workflow.md +513 -0
|
@@ -0,0 +1,1869 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Fleet Commander — Team Manager Service (Spawn / Stop / Resume)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Manages Claude Code agent processes: creates git worktrees, copies hooks,
|
|
5
|
+
// spawns child processes, captures output, and handles lifecycle transitions.
|
|
6
|
+
//
|
|
7
|
+
// Per-project: launch() accepts a projectId and resolves repo path, github
|
|
8
|
+
// repo, and worktree naming from the project record in the database.
|
|
9
|
+
// =============================================================================
|
|
10
|
+
import { spawn, execSync, exec as execCallback } from 'child_process';
|
|
11
|
+
import { promisify } from 'util';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import config from '../config.js';
|
|
15
|
+
import { getDatabase } from '../db.js';
|
|
16
|
+
import { sseBroker } from './sse-broker.js';
|
|
17
|
+
import { findGitBash } from '../utils/find-git-bash.js';
|
|
18
|
+
import { resolveClaudePath } from '../utils/resolve-claude-path.js';
|
|
19
|
+
import { getUsageZone } from './usage-tracker.js';
|
|
20
|
+
import { resolveMessage } from '../utils/resolve-message.js';
|
|
21
|
+
const execAsync = promisify(execCallback);
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const MAX_OUTPUT_LINES = config.outputBufferLines;
|
|
26
|
+
const MAX_PARSED_EVENTS = 1000;
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// summarizeEvent — short text summary for console logging
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
function summarizeEvent(event) {
|
|
31
|
+
switch (event.type) {
|
|
32
|
+
case 'assistant': {
|
|
33
|
+
const content = event.message?.content;
|
|
34
|
+
if (Array.isArray(content)) {
|
|
35
|
+
const text = content.find((c) => c.type === 'text')?.text ?? '';
|
|
36
|
+
return text.substring(0, 100) + (text.length > 100 ? '...' : '');
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
case 'tool_use': {
|
|
41
|
+
const tool = event.tool;
|
|
42
|
+
return `${tool?.name ?? 'unknown'}`;
|
|
43
|
+
}
|
|
44
|
+
case 'tool_result':
|
|
45
|
+
return 'completed';
|
|
46
|
+
case 'result':
|
|
47
|
+
return 'session complete';
|
|
48
|
+
default:
|
|
49
|
+
return event.type;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class TeamManager {
|
|
53
|
+
outputBuffers = new Map();
|
|
54
|
+
childProcesses = new Map();
|
|
55
|
+
stdinPipes = new Map();
|
|
56
|
+
parsedEvents = new Map();
|
|
57
|
+
tokenCounters = new Map();
|
|
58
|
+
_processingQueue = new Set();
|
|
59
|
+
shutdownTimers = new Map();
|
|
60
|
+
/**
|
|
61
|
+
* Per-team map of tool_use_id -> agent name.
|
|
62
|
+
* When the TL spawns a subagent via the "Agent" or "Task" tool, the tool_use
|
|
63
|
+
* content block's `id` becomes the `parent_tool_use_id` on subsequent
|
|
64
|
+
* assistant events from that subagent. We extract the agent name from the
|
|
65
|
+
* tool input (e.g. `input.name` = "dev") and store it here so every stream
|
|
66
|
+
* event can be tagged with the originating agent name.
|
|
67
|
+
*/
|
|
68
|
+
agentMaps = new Map();
|
|
69
|
+
/** Teams currently in extended thinking — tracked in-memory only */
|
|
70
|
+
thinkingTeams = new Set();
|
|
71
|
+
/** Timestamp when thinking started for each team (for duration tracking) */
|
|
72
|
+
thinkingStartTimes = new Map();
|
|
73
|
+
/** Content block index of the active thinking block per team */
|
|
74
|
+
thinkingBlockIndex = new Map();
|
|
75
|
+
// -------------------------------------------------------------------------
|
|
76
|
+
// syncWithOrigin — fetch + pull before creating a worktree
|
|
77
|
+
// -------------------------------------------------------------------------
|
|
78
|
+
/**
|
|
79
|
+
* Sync local repo with origin before creating a worktree.
|
|
80
|
+
* Returns the number of commits the local default branch was behind origin.
|
|
81
|
+
*/
|
|
82
|
+
async syncWithOrigin(repoPath, teamId) {
|
|
83
|
+
let commitsBehind = 0;
|
|
84
|
+
try {
|
|
85
|
+
// Fetch latest from origin
|
|
86
|
+
await execAsync('git fetch origin', {
|
|
87
|
+
cwd: repoPath,
|
|
88
|
+
timeout: 30000,
|
|
89
|
+
});
|
|
90
|
+
// Detect default branch
|
|
91
|
+
let defaultBranch = 'main';
|
|
92
|
+
try {
|
|
93
|
+
const { stdout } = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
|
|
94
|
+
cwd: repoPath,
|
|
95
|
+
timeout: 5000,
|
|
96
|
+
});
|
|
97
|
+
defaultBranch = stdout.trim().replace(/^refs\/remotes\/origin\//, '');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Fallback to 'main'
|
|
101
|
+
}
|
|
102
|
+
// Count commits behind
|
|
103
|
+
try {
|
|
104
|
+
const { stdout } = await execAsync(`git rev-list --count HEAD..origin/${defaultBranch}`, {
|
|
105
|
+
cwd: repoPath,
|
|
106
|
+
timeout: 5000,
|
|
107
|
+
});
|
|
108
|
+
commitsBehind = parseInt(stdout.trim(), 10) || 0;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Non-fatal
|
|
112
|
+
}
|
|
113
|
+
// Pull to sync local default branch
|
|
114
|
+
if (commitsBehind > 0) {
|
|
115
|
+
console.log(`[TeamManager] Local is ${commitsBehind} commits behind origin/${defaultBranch}, pulling...`);
|
|
116
|
+
try {
|
|
117
|
+
await execAsync(`git pull origin ${defaultBranch} --ff-only`, {
|
|
118
|
+
cwd: repoPath,
|
|
119
|
+
timeout: 30000,
|
|
120
|
+
});
|
|
121
|
+
console.log(`[TeamManager] Pulled ${commitsBehind} commits from origin/${defaultBranch}`);
|
|
122
|
+
}
|
|
123
|
+
catch (pullErr) {
|
|
124
|
+
console.warn(`[TeamManager] Fast-forward pull failed, trying merge:`, pullErr instanceof Error ? pullErr.message : String(pullErr));
|
|
125
|
+
// If ff-only fails, don't force — just warn
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
console.log(`[TeamManager] Repo is up to date with origin/${defaultBranch}`);
|
|
130
|
+
}
|
|
131
|
+
// Inject a log event into the team's session log
|
|
132
|
+
const syncEvent = {
|
|
133
|
+
type: 'fc',
|
|
134
|
+
subtype: 'origin_sync',
|
|
135
|
+
agentName: '__fc__',
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
message: {
|
|
138
|
+
content: [{
|
|
139
|
+
type: 'text',
|
|
140
|
+
text: commitsBehind > 0
|
|
141
|
+
? `Synced with origin/${defaultBranch}: pulled ${commitsBehind} commit(s)`
|
|
142
|
+
: `Up to date with origin/${defaultBranch}`,
|
|
143
|
+
}],
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const events = this.parsedEvents.get(teamId);
|
|
147
|
+
if (events)
|
|
148
|
+
events.push(syncEvent);
|
|
149
|
+
sseBroker.broadcast('team_output', { team_id: teamId, event: syncEvent }, teamId);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error(`[TeamManager] Failed to sync with origin:`, err instanceof Error ? err.message : String(err));
|
|
153
|
+
}
|
|
154
|
+
return commitsBehind;
|
|
155
|
+
}
|
|
156
|
+
// -------------------------------------------------------------------------
|
|
157
|
+
// launch — create worktree, copy hooks, spawn Claude Code
|
|
158
|
+
// -------------------------------------------------------------------------
|
|
159
|
+
async launch(projectId, issueNumber, issueTitle, prompt, headless, force) {
|
|
160
|
+
const db = getDatabase();
|
|
161
|
+
// Look up project to get repo_path, github_repo, name
|
|
162
|
+
const project = db.getProject(projectId);
|
|
163
|
+
if (!project) {
|
|
164
|
+
throw new Error(`Project ${projectId} not found`);
|
|
165
|
+
}
|
|
166
|
+
console.log(`[TeamManager] Launch started: project=${project.name} issue=#${issueNumber}`);
|
|
167
|
+
// Usage gate: if in red zone and not forced, queue instead of launching
|
|
168
|
+
if (!force && getUsageZone() === 'red') {
|
|
169
|
+
console.log(`[TeamManager] Usage zone is RED — queueing team for issue #${issueNumber}`);
|
|
170
|
+
return this.queueTeam(db, project, projectId, issueNumber, issueTitle, headless, prompt);
|
|
171
|
+
}
|
|
172
|
+
// Check active team limit before proceeding
|
|
173
|
+
const activeCount = db.getActiveTeamCountByProject(projectId);
|
|
174
|
+
if (activeCount >= project.maxActiveTeams) {
|
|
175
|
+
// Queue this team instead of launching
|
|
176
|
+
return this.queueTeam(db, project, projectId, issueNumber, issueTitle, headless, prompt);
|
|
177
|
+
}
|
|
178
|
+
// If no title provided, fetch from GitHub
|
|
179
|
+
if (!issueTitle && project.githubRepo) {
|
|
180
|
+
try {
|
|
181
|
+
const { stdout } = await execAsync(`gh issue view ${issueNumber} --repo ${project.githubRepo} --json title --jq .title`, { timeout: 10000 });
|
|
182
|
+
const result = stdout.trim();
|
|
183
|
+
if (result) {
|
|
184
|
+
issueTitle = result;
|
|
185
|
+
console.log(`[TeamManager] Fetched issue title from GitHub: "${issueTitle}"`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// GitHub fetch failed, use fallback
|
|
190
|
+
issueTitle = `Issue #${issueNumber}`;
|
|
191
|
+
console.log(`[TeamManager] GitHub title fetch failed, using fallback: "${issueTitle}"`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (!issueTitle) {
|
|
195
|
+
issueTitle = `Issue #${issueNumber}`;
|
|
196
|
+
}
|
|
197
|
+
// Derive worktree naming from project
|
|
198
|
+
const { worktreeName, branchName, worktreeRelPath, worktreeAbsPath } = this.deriveWorktreeNames(project, issueNumber);
|
|
199
|
+
// Check if a team already exists for this worktree name
|
|
200
|
+
const existing = db.getTeamByWorktree(worktreeName);
|
|
201
|
+
let relaunchTeamId = null;
|
|
202
|
+
if (existing) {
|
|
203
|
+
if (['running', 'launching', 'idle', 'stuck', 'queued'].includes(existing.status)) {
|
|
204
|
+
throw new Error(`Team already active for issue ${issueNumber} (status: ${existing.status})`);
|
|
205
|
+
}
|
|
206
|
+
if (existing.status === 'done') {
|
|
207
|
+
throw new Error(`Team already completed for issue ${issueNumber} — completed teams cannot be relaunched`);
|
|
208
|
+
}
|
|
209
|
+
// Terminal state (failed) — reuse the existing team record
|
|
210
|
+
relaunchTeamId = existing.id;
|
|
211
|
+
}
|
|
212
|
+
// ── Step 1: Insert or reuse team record in DB (status: queued) ──
|
|
213
|
+
// Team appears in FleetGrid immediately at this point.
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
let team;
|
|
216
|
+
if (relaunchTeamId !== null) {
|
|
217
|
+
// Relaunch: reset the existing terminal team record
|
|
218
|
+
console.log(`[TeamManager] Relaunching existing team record: id=${relaunchTeamId}, worktree=${worktreeName}`);
|
|
219
|
+
const prevTeam = db.getTeam(relaunchTeamId);
|
|
220
|
+
db.updateTeam(relaunchTeamId, {
|
|
221
|
+
status: 'queued',
|
|
222
|
+
phase: 'init',
|
|
223
|
+
pid: null,
|
|
224
|
+
sessionId: null,
|
|
225
|
+
issueTitle: issueTitle ?? null,
|
|
226
|
+
headless: headless !== false,
|
|
227
|
+
launchedAt: now,
|
|
228
|
+
stoppedAt: null,
|
|
229
|
+
lastEventAt: null,
|
|
230
|
+
});
|
|
231
|
+
db.insertTransition({
|
|
232
|
+
teamId: relaunchTeamId,
|
|
233
|
+
fromStatus: prevTeam?.status ?? 'failed',
|
|
234
|
+
toStatus: 'queued',
|
|
235
|
+
trigger: 'pm_action',
|
|
236
|
+
reason: 'Relaunched by PM',
|
|
237
|
+
});
|
|
238
|
+
team = db.getTeam(relaunchTeamId);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// Fresh launch: insert new team record
|
|
242
|
+
console.log(`[TeamManager] Inserting team record: worktree=${worktreeName}, branch=${branchName}`);
|
|
243
|
+
team = db.insertTeam({
|
|
244
|
+
projectId,
|
|
245
|
+
issueNumber,
|
|
246
|
+
issueTitle: issueTitle ?? null,
|
|
247
|
+
worktreeName,
|
|
248
|
+
branchName,
|
|
249
|
+
status: 'queued',
|
|
250
|
+
phase: 'init',
|
|
251
|
+
headless: headless !== false,
|
|
252
|
+
launchedAt: now,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
console.log(`[TeamManager] Team queued: id=${team.id}, status=queued, relaunch=${relaunchTeamId !== null}`);
|
|
256
|
+
// Broadcast immediately so the team appears in the grid right away
|
|
257
|
+
this.broadcastSnapshot();
|
|
258
|
+
// Sync with origin before creating worktree
|
|
259
|
+
await this.syncWithOrigin(project.repoPath, team.id);
|
|
260
|
+
// ── Step 2: Create git worktree in the PROJECT's repo ──
|
|
261
|
+
const worktreeOk = await this.createWorktree(project.repoPath, worktreeRelPath, worktreeAbsPath, branchName, team.id, 'queued');
|
|
262
|
+
if (!worktreeOk) {
|
|
263
|
+
throw new Error(`Failed to create worktree for team ${team.id}`);
|
|
264
|
+
}
|
|
265
|
+
console.log(`[TeamManager] Worktree created: ${worktreeName} at ${worktreeAbsPath}`);
|
|
266
|
+
// Update team status to launching now that worktree exists
|
|
267
|
+
db.insertTransition({
|
|
268
|
+
teamId: team.id,
|
|
269
|
+
fromStatus: 'queued',
|
|
270
|
+
toStatus: 'launching',
|
|
271
|
+
trigger: 'system',
|
|
272
|
+
reason: 'Worktree created, spawning Claude Code process',
|
|
273
|
+
});
|
|
274
|
+
db.updateTeam(team.id, { status: 'launching' });
|
|
275
|
+
this.broadcastSnapshot();
|
|
276
|
+
// ── Step 3: Copy hook scripts and settings into worktree ──
|
|
277
|
+
this.copyHooksAndSettings(worktreeAbsPath);
|
|
278
|
+
// ── Step 4: Spawn Claude Code process ──
|
|
279
|
+
const resolvedPrompt = prompt || this.resolvePromptFromFile(project, issueNumber);
|
|
280
|
+
const isHeadless = headless !== false;
|
|
281
|
+
if (!isHeadless && process.platform === 'win32') {
|
|
282
|
+
// ── Interactive mode (Windows): open Claude Code in a new terminal ──
|
|
283
|
+
await this.launchInteractive(team, project, worktreeAbsPath, resolvedPrompt);
|
|
284
|
+
return db.getTeam(team.id);
|
|
285
|
+
}
|
|
286
|
+
// ── Headless mode (default): spawn in background, capture output ──
|
|
287
|
+
const args = this.buildHeadlessClaudeArgs(worktreeName, {
|
|
288
|
+
model: project.model,
|
|
289
|
+
});
|
|
290
|
+
const spawnEnv = this.buildSpawnEnv(project, worktreeName, projectId);
|
|
291
|
+
const claudePath = resolveClaudePath();
|
|
292
|
+
console.log(`[TeamManager] Spawning: ${claudePath} ${args.join(' ')} (headless=${isHeadless})`);
|
|
293
|
+
const child = this.spawnAndValidate(claudePath, args, project.repoPath, spawnEnv, team.id);
|
|
294
|
+
if (!child) {
|
|
295
|
+
throw new Error('Failed to spawn Claude Code process — no PID returned');
|
|
296
|
+
}
|
|
297
|
+
this.setupStdinAndOutput(team.id, child, resolvedPrompt);
|
|
298
|
+
this.attachProcessHandlers(team.id, child);
|
|
299
|
+
sseBroker.broadcast('team_launched', { team_id: team.id, issue_number: issueNumber, project_id: projectId }, team.id);
|
|
300
|
+
return db.getTeam(team.id);
|
|
301
|
+
}
|
|
302
|
+
// -------------------------------------------------------------------------
|
|
303
|
+
// stop — kill process tree
|
|
304
|
+
// -------------------------------------------------------------------------
|
|
305
|
+
async stop(teamId) {
|
|
306
|
+
const db = getDatabase();
|
|
307
|
+
const team = db.getTeam(teamId);
|
|
308
|
+
if (!team) {
|
|
309
|
+
throw new Error(`Team ${teamId} not found`);
|
|
310
|
+
}
|
|
311
|
+
// Cancel any pending merge-shutdown timer for this team
|
|
312
|
+
const pendingTimer = this.shutdownTimers.get(teamId);
|
|
313
|
+
if (pendingTimer) {
|
|
314
|
+
clearTimeout(pendingTimer);
|
|
315
|
+
this.shutdownTimers.delete(teamId);
|
|
316
|
+
}
|
|
317
|
+
// Queued teams have no process — just cancel them directly
|
|
318
|
+
if (team.status === 'queued') {
|
|
319
|
+
db.insertTransition({
|
|
320
|
+
teamId,
|
|
321
|
+
fromStatus: 'queued',
|
|
322
|
+
toStatus: 'failed',
|
|
323
|
+
trigger: 'pm_action',
|
|
324
|
+
reason: 'PM stopped queued team',
|
|
325
|
+
});
|
|
326
|
+
db.updateTeam(teamId, { status: 'failed', stoppedAt: new Date().toISOString() });
|
|
327
|
+
this.broadcastSnapshot();
|
|
328
|
+
return db.getTeam(teamId);
|
|
329
|
+
}
|
|
330
|
+
// Try graceful shutdown via stdin.end() first — closing stdin signals
|
|
331
|
+
// Claude Code to finish its current work and exit cleanly.
|
|
332
|
+
const stdin = this.stdinPipes.get(teamId);
|
|
333
|
+
if (stdin && !stdin.destroyed) {
|
|
334
|
+
try {
|
|
335
|
+
stdin.end();
|
|
336
|
+
console.log(`[TeamManager] Sent stdin EOF to team ${teamId} for graceful shutdown`);
|
|
337
|
+
// Give the process 5 seconds to finish gracefully before force-killing
|
|
338
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// stdin.end() failed — fall through to force kill
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
this.stdinPipes.delete(teamId);
|
|
345
|
+
// Force kill if still running — re-read from DB to get fresh PID
|
|
346
|
+
// (the process may have exited during the 5s grace period)
|
|
347
|
+
const freshTeam = db.getTeam(teamId);
|
|
348
|
+
if (freshTeam?.pid) {
|
|
349
|
+
this.killProcess(freshTeam.pid);
|
|
350
|
+
}
|
|
351
|
+
// Clean up child process reference
|
|
352
|
+
this.clearThinking(teamId);
|
|
353
|
+
this.flushTokenCounters(teamId);
|
|
354
|
+
this.persistParsedEvents(teamId);
|
|
355
|
+
this.childProcesses.delete(teamId);
|
|
356
|
+
this.outputBuffers.delete(teamId);
|
|
357
|
+
this.parsedEvents.delete(teamId);
|
|
358
|
+
this.agentMaps.delete(teamId);
|
|
359
|
+
this.tokenCounters.delete(teamId);
|
|
360
|
+
// Re-read to get the latest status (process exit handler may have already updated it)
|
|
361
|
+
const stopTeam = db.getTeam(teamId);
|
|
362
|
+
if (stopTeam && !['done', 'failed'].includes(stopTeam.status)) {
|
|
363
|
+
db.insertTransition({
|
|
364
|
+
teamId,
|
|
365
|
+
fromStatus: stopTeam.status,
|
|
366
|
+
toStatus: 'failed',
|
|
367
|
+
trigger: 'pm_action',
|
|
368
|
+
reason: 'PM stopped team',
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const updated = db.updateTeam(teamId, {
|
|
372
|
+
status: 'failed',
|
|
373
|
+
pid: null,
|
|
374
|
+
stoppedAt: new Date().toISOString(),
|
|
375
|
+
});
|
|
376
|
+
sseBroker.broadcast('team_stopped', { team_id: teamId }, teamId);
|
|
377
|
+
this.broadcastSnapshot();
|
|
378
|
+
// Process queue when a slot frees up
|
|
379
|
+
if (team.projectId) {
|
|
380
|
+
this.processQueue(team.projectId).catch((err) => {
|
|
381
|
+
console.error(`[TeamManager] processQueue error after stop:`, err);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return updated;
|
|
385
|
+
}
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
// resume — re-spawn with --resume flag in existing worktree
|
|
388
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
async resume(teamId) {
|
|
390
|
+
const db = getDatabase();
|
|
391
|
+
const team = db.getTeam(teamId);
|
|
392
|
+
if (!team) {
|
|
393
|
+
throw new Error(`Team ${teamId} not found`);
|
|
394
|
+
}
|
|
395
|
+
if (team.status === 'done') {
|
|
396
|
+
throw new Error('Cannot resume a completed team');
|
|
397
|
+
}
|
|
398
|
+
// Resolve project for repo path and queue limit check
|
|
399
|
+
if (!team.projectId) {
|
|
400
|
+
throw new Error(`Team ${teamId} has no project`);
|
|
401
|
+
}
|
|
402
|
+
const project = db.getProject(team.projectId);
|
|
403
|
+
if (!project) {
|
|
404
|
+
throw new Error(`Project for team ${teamId} not found (projectId: ${team.projectId})`);
|
|
405
|
+
}
|
|
406
|
+
// Check queue limit — if too many teams are active, queue the resume instead
|
|
407
|
+
const activeCount = db.getActiveTeamCountByProject(team.projectId);
|
|
408
|
+
if (activeCount >= project.maxActiveTeams) {
|
|
409
|
+
db.insertTransition({
|
|
410
|
+
teamId,
|
|
411
|
+
fromStatus: team.status,
|
|
412
|
+
toStatus: 'queued',
|
|
413
|
+
trigger: 'pm_action',
|
|
414
|
+
reason: `Resume queued (${activeCount}/${project.maxActiveTeams} active)`,
|
|
415
|
+
});
|
|
416
|
+
db.updateTeam(teamId, { status: 'queued' });
|
|
417
|
+
console.log(`[TeamManager] Resume queued for team ${teamId} (${activeCount}/${project.maxActiveTeams} active)`);
|
|
418
|
+
this.broadcastSnapshot();
|
|
419
|
+
return db.getTeam(teamId);
|
|
420
|
+
}
|
|
421
|
+
// Verify worktree still exists
|
|
422
|
+
const worktreeAbsPath = path.join(project.repoPath, config.worktreeDir, team.worktreeName);
|
|
423
|
+
if (!fs.existsSync(worktreeAbsPath)) {
|
|
424
|
+
throw new Error(`Worktree ${team.worktreeName} no longer exists at ${worktreeAbsPath}`);
|
|
425
|
+
}
|
|
426
|
+
// Build args with --resume (headless mode — resume always uses stream-json)
|
|
427
|
+
const args = this.buildHeadlessClaudeArgs(team.worktreeName, {
|
|
428
|
+
resume: true,
|
|
429
|
+
model: project.model,
|
|
430
|
+
});
|
|
431
|
+
// Update status to launching
|
|
432
|
+
db.insertTransition({
|
|
433
|
+
teamId,
|
|
434
|
+
fromStatus: team.status,
|
|
435
|
+
toStatus: 'launching',
|
|
436
|
+
trigger: 'pm_action',
|
|
437
|
+
reason: 'PM resumed team',
|
|
438
|
+
});
|
|
439
|
+
db.updateTeam(teamId, {
|
|
440
|
+
status: 'launching',
|
|
441
|
+
launchedAt: new Date().toISOString(),
|
|
442
|
+
stoppedAt: null,
|
|
443
|
+
});
|
|
444
|
+
const spawnEnv = this.buildSpawnEnv(project, team.worktreeName, project.id);
|
|
445
|
+
const claudePath = resolveClaudePath();
|
|
446
|
+
console.log(`[TeamManager] Resume spawning: ${claudePath} ${args.join(' ')}`);
|
|
447
|
+
const child = this.spawnAndValidate(claudePath, args, project.repoPath, spawnEnv, teamId);
|
|
448
|
+
if (!child) {
|
|
449
|
+
throw new Error('Failed to spawn Claude Code process — no PID returned');
|
|
450
|
+
}
|
|
451
|
+
// Resume: no initial prompt — just set up stdin and output capture
|
|
452
|
+
this.setupStdinAndOutput(teamId, child);
|
|
453
|
+
this.attachProcessHandlers(teamId, child);
|
|
454
|
+
sseBroker.broadcast('team_launched', { team_id: teamId, issue_number: team.issueNumber }, teamId);
|
|
455
|
+
this.broadcastSnapshot();
|
|
456
|
+
return db.getTeam(teamId);
|
|
457
|
+
}
|
|
458
|
+
// -------------------------------------------------------------------------
|
|
459
|
+
// restart — stop then launch
|
|
460
|
+
// -------------------------------------------------------------------------
|
|
461
|
+
async restart(teamId, prompt) {
|
|
462
|
+
const db = getDatabase();
|
|
463
|
+
const team = db.getTeam(teamId);
|
|
464
|
+
if (!team) {
|
|
465
|
+
throw new Error(`Team ${teamId} not found`);
|
|
466
|
+
}
|
|
467
|
+
if (team.status === 'done') {
|
|
468
|
+
throw new Error('Cannot restart a completed team');
|
|
469
|
+
}
|
|
470
|
+
// Stop if running or queued
|
|
471
|
+
if (['launching', 'running', 'idle', 'stuck', 'queued'].includes(team.status)) {
|
|
472
|
+
await this.stop(teamId);
|
|
473
|
+
}
|
|
474
|
+
// Re-launch with the team's project
|
|
475
|
+
const projectId = team.projectId;
|
|
476
|
+
if (!projectId) {
|
|
477
|
+
throw new Error(`Team ${teamId} has no projectId — cannot restart`);
|
|
478
|
+
}
|
|
479
|
+
return this.launch(projectId, team.issueNumber, team.issueTitle ?? undefined, prompt);
|
|
480
|
+
}
|
|
481
|
+
// -------------------------------------------------------------------------
|
|
482
|
+
// stopAll — stop all running teams
|
|
483
|
+
// -------------------------------------------------------------------------
|
|
484
|
+
async stopAll() {
|
|
485
|
+
// Clear any pending merge-shutdown timers before force-stopping
|
|
486
|
+
this.clearShutdownTimers();
|
|
487
|
+
const db = getDatabase();
|
|
488
|
+
const activeTeams = db.getActiveTeams();
|
|
489
|
+
const results = [];
|
|
490
|
+
for (const team of activeTeams) {
|
|
491
|
+
try {
|
|
492
|
+
const stopped = await this.stop(team.id);
|
|
493
|
+
results.push(stopped);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// Log but continue stopping other teams
|
|
497
|
+
results.push(team);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return results;
|
|
501
|
+
}
|
|
502
|
+
// -------------------------------------------------------------------------
|
|
503
|
+
// queueTeam — insert a team with 'queued' status without spawning
|
|
504
|
+
// -------------------------------------------------------------------------
|
|
505
|
+
async queueTeam(db, project, projectId, issueNumber, issueTitle, headless, prompt) {
|
|
506
|
+
// Fetch title from GitHub if needed
|
|
507
|
+
if (!issueTitle && project.githubRepo) {
|
|
508
|
+
try {
|
|
509
|
+
const { stdout } = await execAsync(`gh issue view ${issueNumber} --repo ${project.githubRepo} --json title --jq .title`, { timeout: 10000 });
|
|
510
|
+
const result = stdout.trim();
|
|
511
|
+
if (result)
|
|
512
|
+
issueTitle = result;
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
issueTitle = `Issue #${issueNumber}`;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
else if (!issueTitle) {
|
|
519
|
+
issueTitle = `Issue #${issueNumber}`;
|
|
520
|
+
}
|
|
521
|
+
const { worktreeName, branchName } = this.deriveWorktreeNames(project, issueNumber);
|
|
522
|
+
// Check for existing team
|
|
523
|
+
const existing = db.getTeamByWorktree(worktreeName);
|
|
524
|
+
if (existing) {
|
|
525
|
+
if (['running', 'launching', 'idle', 'stuck', 'queued'].includes(existing.status)) {
|
|
526
|
+
throw new Error(`Team already active for issue ${issueNumber} (status: ${existing.status})`);
|
|
527
|
+
}
|
|
528
|
+
if (existing.status === 'done') {
|
|
529
|
+
throw new Error(`Team already completed for issue ${issueNumber} — completed teams cannot be relaunched`);
|
|
530
|
+
}
|
|
531
|
+
// Terminal state (failed) — reuse the existing team record as queued
|
|
532
|
+
const now = new Date().toISOString();
|
|
533
|
+
db.updateTeam(existing.id, {
|
|
534
|
+
status: 'queued',
|
|
535
|
+
phase: 'init',
|
|
536
|
+
pid: null,
|
|
537
|
+
sessionId: null,
|
|
538
|
+
issueTitle: issueTitle ?? null,
|
|
539
|
+
customPrompt: prompt ?? null,
|
|
540
|
+
headless: headless !== false,
|
|
541
|
+
launchedAt: now,
|
|
542
|
+
stoppedAt: null,
|
|
543
|
+
lastEventAt: null,
|
|
544
|
+
});
|
|
545
|
+
db.insertTransition({
|
|
546
|
+
teamId: existing.id,
|
|
547
|
+
fromStatus: existing.status,
|
|
548
|
+
toStatus: 'queued',
|
|
549
|
+
trigger: 'pm_action',
|
|
550
|
+
reason: 'Re-queued by PM (queue path)',
|
|
551
|
+
});
|
|
552
|
+
const team = db.getTeam(existing.id);
|
|
553
|
+
const activeCount = db.getActiveTeamCountByProject(projectId);
|
|
554
|
+
console.log(`[TeamManager] Team ${team.id} queued (${activeCount}/${project.maxActiveTeams} active, headless=${team.headless})`);
|
|
555
|
+
this.broadcastSnapshot();
|
|
556
|
+
return team;
|
|
557
|
+
}
|
|
558
|
+
// Fresh insert with queued status
|
|
559
|
+
const now = new Date().toISOString();
|
|
560
|
+
const team = db.insertTeam({
|
|
561
|
+
projectId,
|
|
562
|
+
issueNumber,
|
|
563
|
+
issueTitle: issueTitle ?? null,
|
|
564
|
+
worktreeName,
|
|
565
|
+
branchName,
|
|
566
|
+
status: 'queued',
|
|
567
|
+
phase: 'init',
|
|
568
|
+
customPrompt: prompt ?? null,
|
|
569
|
+
headless: headless !== false,
|
|
570
|
+
launchedAt: now,
|
|
571
|
+
});
|
|
572
|
+
db.insertTransition({
|
|
573
|
+
teamId: team.id,
|
|
574
|
+
fromStatus: 'queued',
|
|
575
|
+
toStatus: 'queued',
|
|
576
|
+
trigger: 'pm_action',
|
|
577
|
+
reason: 'Team created and queued',
|
|
578
|
+
});
|
|
579
|
+
const activeCount = db.getActiveTeamCountByProject(projectId);
|
|
580
|
+
console.log(`[TeamManager] Team ${team.id} queued (${activeCount}/${project.maxActiveTeams} active)`);
|
|
581
|
+
this.broadcastSnapshot();
|
|
582
|
+
return team;
|
|
583
|
+
}
|
|
584
|
+
// -------------------------------------------------------------------------
|
|
585
|
+
// forceLaunch — bypass usage gate and slot limits to launch a queued team
|
|
586
|
+
// -------------------------------------------------------------------------
|
|
587
|
+
async forceLaunch(teamId) {
|
|
588
|
+
const db = getDatabase();
|
|
589
|
+
const team = db.getTeam(teamId);
|
|
590
|
+
if (!team) {
|
|
591
|
+
throw new Error(`Team ${teamId} not found`);
|
|
592
|
+
}
|
|
593
|
+
if (team.status !== 'queued') {
|
|
594
|
+
throw new Error(`Team ${teamId} is not queued (current status: ${team.status})`);
|
|
595
|
+
}
|
|
596
|
+
const projectId = team.projectId;
|
|
597
|
+
if (!projectId) {
|
|
598
|
+
throw new Error(`Team ${teamId} has no project ID`);
|
|
599
|
+
}
|
|
600
|
+
const project = db.getProject(projectId);
|
|
601
|
+
if (!project) {
|
|
602
|
+
throw new Error(`Project ${projectId} not found`);
|
|
603
|
+
}
|
|
604
|
+
// Log a warning if this exceeds maxActiveTeams
|
|
605
|
+
const activeCount = db.getActiveTeamCountByProject(projectId);
|
|
606
|
+
if (activeCount >= project.maxActiveTeams) {
|
|
607
|
+
console.warn(`[TeamManager] Force-launching team ${teamId} exceeds maxActiveTeams (${activeCount}/${project.maxActiveTeams})`);
|
|
608
|
+
}
|
|
609
|
+
// Insert transition and update status BEFORE calling launchQueued,
|
|
610
|
+
// since launchQueued's guard at the top accepts both 'queued' and 'launching'.
|
|
611
|
+
db.insertTransition({
|
|
612
|
+
teamId,
|
|
613
|
+
fromStatus: 'queued',
|
|
614
|
+
toStatus: 'launching',
|
|
615
|
+
trigger: 'pm_action',
|
|
616
|
+
reason: 'PM force-launched team',
|
|
617
|
+
});
|
|
618
|
+
db.updateTeam(teamId, { status: 'launching' });
|
|
619
|
+
this.broadcastSnapshot();
|
|
620
|
+
// Delegate to the existing private launch method
|
|
621
|
+
await this.launchQueued(team);
|
|
622
|
+
return db.getTeam(teamId);
|
|
623
|
+
}
|
|
624
|
+
// -------------------------------------------------------------------------
|
|
625
|
+
// processQueue — dequeue and launch teams when slots free up
|
|
626
|
+
// -------------------------------------------------------------------------
|
|
627
|
+
async processQueue(projectId) {
|
|
628
|
+
// Guard against concurrent processQueue calls for the same project
|
|
629
|
+
if (this._processingQueue.has(projectId))
|
|
630
|
+
return;
|
|
631
|
+
this._processingQueue.add(projectId);
|
|
632
|
+
// Track whether any teams were actually launched — used by re-drain to
|
|
633
|
+
// avoid an infinite loop when all queued teams are blocked by dependencies.
|
|
634
|
+
let launchedCount = 0;
|
|
635
|
+
try {
|
|
636
|
+
const db = getDatabase();
|
|
637
|
+
const project = db.getProject(projectId);
|
|
638
|
+
if (!project)
|
|
639
|
+
return;
|
|
640
|
+
// Usage gate: do not dequeue if in red zone
|
|
641
|
+
if (getUsageZone() === 'red') {
|
|
642
|
+
console.log(`[TeamManager] processQueue blocked — usage zone is RED`);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const activeCount = db.getActiveTeamCountByProject(projectId);
|
|
646
|
+
const available = project.maxActiveTeams - activeCount;
|
|
647
|
+
if (available <= 0)
|
|
648
|
+
return;
|
|
649
|
+
const queued = db.getQueuedTeamsByProject(projectId);
|
|
650
|
+
// Filter queued teams by dependency status — only launch unblocked teams
|
|
651
|
+
const toDequeue = await this.filterUnblockedTeams(queued, available, projectId);
|
|
652
|
+
launchedCount = toDequeue.length;
|
|
653
|
+
for (const team of toDequeue) {
|
|
654
|
+
console.log(`[TeamManager] Dequeuing team ${team.id} (${team.worktreeName})`);
|
|
655
|
+
// Mark as launching BEFORE releasing the guard, so concurrent calls
|
|
656
|
+
// see this team as active (counted towards the active limit).
|
|
657
|
+
db.insertTransition({
|
|
658
|
+
teamId: team.id,
|
|
659
|
+
fromStatus: 'queued',
|
|
660
|
+
toStatus: 'launching',
|
|
661
|
+
trigger: 'system',
|
|
662
|
+
reason: 'Slot available, dequeuing team',
|
|
663
|
+
});
|
|
664
|
+
db.updateTeam(team.id, { status: 'launching' });
|
|
665
|
+
try {
|
|
666
|
+
await this.launchQueued(team);
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
670
|
+
console.error(`[TeamManager] Failed to dequeue team ${team.id}: ${msg}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
this._processingQueue.delete(projectId);
|
|
676
|
+
// Re-drain: if a concurrent processQueue call was dropped by the guard
|
|
677
|
+
// while we were awaiting launchQueued, there may still be queued teams
|
|
678
|
+
// with available slots. Schedule a re-check via setImmediate to break
|
|
679
|
+
// the call stack and let the new call acquire the guard cleanly.
|
|
680
|
+
// Only re-drain if we actually launched at least one team — otherwise
|
|
681
|
+
// all remaining queued teams are blocked by dependencies and re-draining
|
|
682
|
+
// would cause an infinite loop.
|
|
683
|
+
if (launchedCount > 0) {
|
|
684
|
+
const db = getDatabase();
|
|
685
|
+
const project = db.getProject(projectId);
|
|
686
|
+
if (project) {
|
|
687
|
+
const activeCount = db.getActiveTeamCountByProject(projectId);
|
|
688
|
+
const queued = db.getQueuedTeamsByProject(projectId);
|
|
689
|
+
if (queued.length > 0 && activeCount < project.maxActiveTeams) {
|
|
690
|
+
setImmediate(() => {
|
|
691
|
+
this.processQueue(projectId).catch((err) => {
|
|
692
|
+
console.error(`[TeamManager] processQueue re-drain error:`, err);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// -------------------------------------------------------------------------
|
|
701
|
+
// filterUnblockedTeams — check dependencies for queued teams
|
|
702
|
+
// -------------------------------------------------------------------------
|
|
703
|
+
/**
|
|
704
|
+
* Filter queued teams to only include those with no open dependencies.
|
|
705
|
+
* Teams with open dependencies are logged and tracked in the GitHubPoller
|
|
706
|
+
* for auto-launch when dependencies resolve.
|
|
707
|
+
*
|
|
708
|
+
* If a circular dependency is detected, the team is treated as unblocked
|
|
709
|
+
* to avoid deadlocking the queue.
|
|
710
|
+
*
|
|
711
|
+
* Uses the IssueFetcher's cached data when available, falling back to
|
|
712
|
+
* fresh API calls. If the dependency check fails entirely, the team is
|
|
713
|
+
* treated as unblocked (permissive fallback).
|
|
714
|
+
*/
|
|
715
|
+
async filterUnblockedTeams(queued, available, projectId) {
|
|
716
|
+
const unblocked = [];
|
|
717
|
+
// Dynamic imports to avoid circular dependencies
|
|
718
|
+
let getIssueFetcher = null;
|
|
719
|
+
let detectCircularDeps = null;
|
|
720
|
+
let githubPollerModule = null;
|
|
721
|
+
try {
|
|
722
|
+
const issueFetcherMod = await import('./issue-fetcher.js');
|
|
723
|
+
getIssueFetcher = issueFetcherMod.getIssueFetcher;
|
|
724
|
+
detectCircularDeps = issueFetcherMod.detectCircularDependencies;
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
console.error('[TeamManager] Failed to import issue-fetcher for dependency check:', err);
|
|
728
|
+
// Permissive fallback: if we can't check deps, allow all queued teams
|
|
729
|
+
return queued.slice(0, available);
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
githubPollerModule = await import('./github-poller.js');
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
console.error('[TeamManager] Failed to import github-poller for blocked tracking:', err);
|
|
736
|
+
}
|
|
737
|
+
const fetcher = getIssueFetcher();
|
|
738
|
+
for (const team of queued) {
|
|
739
|
+
if (unblocked.length >= available)
|
|
740
|
+
break;
|
|
741
|
+
try {
|
|
742
|
+
const deps = await fetcher.fetchDependenciesForIssue(projectId, team.issueNumber);
|
|
743
|
+
// Permissive fallback: if fetch returns null, treat as unblocked
|
|
744
|
+
if (!deps || deps.resolved) {
|
|
745
|
+
unblocked.push(team);
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
// Has open dependencies — check for circular dependencies
|
|
749
|
+
const openDeps = deps.blockedBy.filter((d) => d.state === 'open');
|
|
750
|
+
if (detectCircularDeps && openDeps.length > 0) {
|
|
751
|
+
// Build a local dependency graph for cycle detection
|
|
752
|
+
const depGraph = new Map();
|
|
753
|
+
depGraph.set(team.issueNumber, openDeps.map((d) => d.number));
|
|
754
|
+
// Add the open deps' own dependencies (if we can fetch them)
|
|
755
|
+
for (const dep of openDeps) {
|
|
756
|
+
try {
|
|
757
|
+
const subDeps = await fetcher.fetchDependenciesForIssue(projectId, dep.number);
|
|
758
|
+
if (subDeps && subDeps.blockedBy.length > 0) {
|
|
759
|
+
depGraph.set(dep.number, subDeps.blockedBy.filter((d) => d.state === 'open').map((d) => d.number));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
// Can't fetch sub-deps — skip (cycle detection will still work for direct cycles)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const cycle = detectCircularDeps(team.issueNumber, depGraph);
|
|
767
|
+
if (cycle) {
|
|
768
|
+
console.warn(`[TeamManager] Circular dependency detected for team ${team.id} (issue #${team.issueNumber}): ` +
|
|
769
|
+
`${cycle.map((n) => '#' + n).join(' -> ')} — treating as unblocked to avoid deadlock`);
|
|
770
|
+
unblocked.push(team);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
// Genuinely blocked — log and track for auto-launch on resolution
|
|
775
|
+
console.log(`[TeamManager] Skipping team ${team.id} (issue #${team.issueNumber}) — ` +
|
|
776
|
+
`blocked by open deps: ${openDeps.map((d) => '#' + d.number).join(', ')}`);
|
|
777
|
+
if (githubPollerModule) {
|
|
778
|
+
githubPollerModule.githubPoller.trackBlockedIssue(projectId, team.issueNumber, openDeps.map((d) => d.number));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
// Permissive fallback: if dependency check fails, allow the team to launch
|
|
783
|
+
console.error(`[TeamManager] Dependency check failed for team ${team.id} (issue #${team.issueNumber}), ` +
|
|
784
|
+
`allowing launch: ${err instanceof Error ? err.message : String(err)}`);
|
|
785
|
+
unblocked.push(team);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return unblocked;
|
|
789
|
+
}
|
|
790
|
+
// -------------------------------------------------------------------------
|
|
791
|
+
// launchQueued — spawn a team that was previously queued
|
|
792
|
+
// -------------------------------------------------------------------------
|
|
793
|
+
async launchQueued(team) {
|
|
794
|
+
const db = getDatabase();
|
|
795
|
+
// Re-check status to avoid racing with other dequeue calls
|
|
796
|
+
// processQueue pre-sets status to 'launching' before calling us,
|
|
797
|
+
// so we must accept both 'queued' and 'launching'.
|
|
798
|
+
const fresh = db.getTeam(team.id);
|
|
799
|
+
if (!fresh || (fresh.status !== 'queued' && fresh.status !== 'launching'))
|
|
800
|
+
return;
|
|
801
|
+
const projectId = team.projectId;
|
|
802
|
+
if (!projectId) {
|
|
803
|
+
console.error(`[TeamManager] Queued team ${team.id} has no projectId`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const project = db.getProject(projectId);
|
|
807
|
+
if (!project) {
|
|
808
|
+
console.error(`[TeamManager] Project ${projectId} not found for queued team ${team.id}`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const worktreeAbsPath = path.join(project.repoPath, config.worktreeDir, team.worktreeName);
|
|
812
|
+
const worktreeRelPath = path.posix.join(config.worktreeDir, team.worktreeName);
|
|
813
|
+
const branchName = team.branchName ?? `worktree-${team.worktreeName}`;
|
|
814
|
+
// Sync with origin before creating worktree
|
|
815
|
+
await this.syncWithOrigin(project.repoPath, team.id);
|
|
816
|
+
// ── Step 1: Create git worktree ──
|
|
817
|
+
const worktreeOk = await this.createWorktree(project.repoPath, worktreeRelPath, worktreeAbsPath, branchName, team.id, 'launching');
|
|
818
|
+
if (!worktreeOk)
|
|
819
|
+
return;
|
|
820
|
+
console.log(`[TeamManager] Worktree created for dequeued team: ${team.worktreeName}`);
|
|
821
|
+
db.updateTeam(team.id, { status: 'launching' });
|
|
822
|
+
this.broadcastSnapshot();
|
|
823
|
+
// ── Step 2: Copy hooks and settings ──
|
|
824
|
+
this.copyHooksAndSettings(worktreeAbsPath);
|
|
825
|
+
// ── Step 3: Spawn Claude Code ──
|
|
826
|
+
const resolvedPrompt = team.customPrompt || this.resolvePromptFromFile(project, team.issueNumber);
|
|
827
|
+
const isHeadless = team.headless;
|
|
828
|
+
if (!isHeadless && process.platform === 'win32') {
|
|
829
|
+
// ── Interactive mode (Windows): open Claude Code in a new terminal ──
|
|
830
|
+
await this.launchInteractive(team, project, worktreeAbsPath, resolvedPrompt);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
// ── Headless mode (default): spawn in background, capture output ──
|
|
834
|
+
const args = this.buildHeadlessClaudeArgs(team.worktreeName, {
|
|
835
|
+
model: project.model,
|
|
836
|
+
});
|
|
837
|
+
const spawnEnv = this.buildSpawnEnv(project, team.worktreeName, projectId);
|
|
838
|
+
const claudePath = resolveClaudePath();
|
|
839
|
+
console.log(`[TeamManager] Spawning dequeued team ${team.id}: ${claudePath} ${args.join(' ')} (headless=${isHeadless})`);
|
|
840
|
+
const child = this.spawnAndValidate(claudePath, args, project.repoPath, spawnEnv, team.id);
|
|
841
|
+
if (!child)
|
|
842
|
+
return;
|
|
843
|
+
this.setupStdinAndOutput(team.id, child, resolvedPrompt);
|
|
844
|
+
this.attachProcessHandlers(team.id, child);
|
|
845
|
+
sseBroker.broadcast('team_launched', { team_id: team.id, issue_number: team.issueNumber, project_id: projectId }, team.id);
|
|
846
|
+
}
|
|
847
|
+
// -------------------------------------------------------------------------
|
|
848
|
+
// getOutput — return rolling buffer content
|
|
849
|
+
// -------------------------------------------------------------------------
|
|
850
|
+
getOutput(teamId, lines) {
|
|
851
|
+
const buffer = this.outputBuffers.get(teamId);
|
|
852
|
+
if (!buffer) {
|
|
853
|
+
return [];
|
|
854
|
+
}
|
|
855
|
+
if (lines && lines > 0 && lines < buffer.lines.length) {
|
|
856
|
+
return buffer.lines.slice(-lines);
|
|
857
|
+
}
|
|
858
|
+
return [...buffer.lines];
|
|
859
|
+
}
|
|
860
|
+
// -------------------------------------------------------------------------
|
|
861
|
+
// getParsedEvents — return parsed NDJSON stream events
|
|
862
|
+
// -------------------------------------------------------------------------
|
|
863
|
+
getParsedEvents(teamId) {
|
|
864
|
+
// Check in-memory buffer first (for running teams)
|
|
865
|
+
const inMemory = this.parsedEvents.get(teamId);
|
|
866
|
+
if (inMemory && inMemory.length > 0) {
|
|
867
|
+
return inMemory;
|
|
868
|
+
}
|
|
869
|
+
// Fall back to persisted events in DB (for done/failed/restarted teams)
|
|
870
|
+
try {
|
|
871
|
+
const db = getDatabase();
|
|
872
|
+
const json = db.getStreamEvents(teamId);
|
|
873
|
+
if (json) {
|
|
874
|
+
return JSON.parse(json);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
catch (err) {
|
|
878
|
+
console.error(`[TeamManager] Failed to load persisted stream events for team ${teamId}:`, err);
|
|
879
|
+
}
|
|
880
|
+
return [];
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Persist the in-memory parsed events for a team to the database.
|
|
884
|
+
* Called before clearing the in-memory buffer on process exit/stop.
|
|
885
|
+
*/
|
|
886
|
+
persistParsedEvents(teamId) {
|
|
887
|
+
const events = this.parsedEvents.get(teamId);
|
|
888
|
+
if (!events || events.length === 0)
|
|
889
|
+
return;
|
|
890
|
+
try {
|
|
891
|
+
const db = getDatabase();
|
|
892
|
+
db.upsertStreamEvents(teamId, JSON.stringify(events));
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
console.error(`[TeamManager] Failed to persist stream events for team ${teamId}:`, err);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
// -------------------------------------------------------------------------
|
|
899
|
+
// sendMessage — deliver a PM message to a running team via stdin
|
|
900
|
+
// -------------------------------------------------------------------------
|
|
901
|
+
sendMessage(teamId, message, source = 'fc', subtype) {
|
|
902
|
+
const stdin = this.stdinPipes.get(teamId);
|
|
903
|
+
if (!stdin || stdin.destroyed)
|
|
904
|
+
return false;
|
|
905
|
+
try {
|
|
906
|
+
this.writeStdinMessage(stdin, message);
|
|
907
|
+
console.log(`[TeamManager] Message sent to team ${teamId}: ${message.substring(0, 100)}`);
|
|
908
|
+
// Inject a synthetic event into parsedEvents so it appears in the
|
|
909
|
+
// Session Log alongside assistant responses (issue #5).
|
|
910
|
+
// 'user' = manual PM message, 'fc' = automated Fleet Commander message.
|
|
911
|
+
// 'subtype' distinguishes FC message categories for visual differentiation.
|
|
912
|
+
const syntheticEvent = {
|
|
913
|
+
type: source,
|
|
914
|
+
agentName: source === 'user' ? '__pm__' : '__fc__',
|
|
915
|
+
timestamp: new Date().toISOString(),
|
|
916
|
+
message: { content: [{ type: 'text', text: message }] },
|
|
917
|
+
...(subtype ? { subtype } : {}),
|
|
918
|
+
};
|
|
919
|
+
const events = this.parsedEvents.get(teamId);
|
|
920
|
+
if (events) {
|
|
921
|
+
events.push(syntheticEvent);
|
|
922
|
+
while (events.length > MAX_PARSED_EVENTS) {
|
|
923
|
+
events.shift();
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
sseBroker.broadcast('team_output', { team_id: teamId, event: syntheticEvent }, teamId);
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
catch (err) {
|
|
930
|
+
console.error(`[TeamManager] Failed to send message to team ${teamId}:`, err);
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// -------------------------------------------------------------------------
|
|
935
|
+
// getStdinPipe — expose stdin pipe for external graceful shutdown
|
|
936
|
+
// -------------------------------------------------------------------------
|
|
937
|
+
getStdinPipe(teamId) {
|
|
938
|
+
return this.stdinPipes.get(teamId);
|
|
939
|
+
}
|
|
940
|
+
// -------------------------------------------------------------------------
|
|
941
|
+
// gracefulShutdown — notify TL of merge, wait grace period, then kill
|
|
942
|
+
// -------------------------------------------------------------------------
|
|
943
|
+
/**
|
|
944
|
+
* Gracefully shut down a team after its PR is merged.
|
|
945
|
+
* 1. Send pr_merged_shutdown message to TL via stdin
|
|
946
|
+
* 2. Wait graceMs for the process to exit on its own
|
|
947
|
+
* 3. If still alive: close stdin, wait 10s, then force kill
|
|
948
|
+
*
|
|
949
|
+
* Race-condition safe: the process exit handler already does cleanup,
|
|
950
|
+
* so all timer callbacks re-check childProcesses.has() before acting.
|
|
951
|
+
*/
|
|
952
|
+
gracefulShutdown(teamId, prNumber, graceMs) {
|
|
953
|
+
// Clear any existing shutdown timer for this team
|
|
954
|
+
const existing = this.shutdownTimers.get(teamId);
|
|
955
|
+
if (existing) {
|
|
956
|
+
clearTimeout(existing);
|
|
957
|
+
this.shutdownTimers.delete(teamId);
|
|
958
|
+
}
|
|
959
|
+
// Step 1: Send the shutdown message via stdin
|
|
960
|
+
const msg = resolveMessage('pr_merged_shutdown', {
|
|
961
|
+
PR_NUMBER: String(prNumber),
|
|
962
|
+
});
|
|
963
|
+
if (msg) {
|
|
964
|
+
this.sendMessage(teamId, msg, 'fc', 'pr_merged_shutdown');
|
|
965
|
+
}
|
|
966
|
+
console.log(`[TeamManager] Graceful shutdown initiated for team ${teamId} (PR #${prNumber}, grace=${graceMs}ms)`);
|
|
967
|
+
// Step 2: Set grace period timer
|
|
968
|
+
const graceTimer = setTimeout(() => {
|
|
969
|
+
this.shutdownTimers.delete(teamId);
|
|
970
|
+
// Re-check: process may have exited during grace period
|
|
971
|
+
if (!this.childProcesses.has(teamId)) {
|
|
972
|
+
console.log(`[TeamManager] Team ${teamId} already exited during grace period`);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
console.log(`[TeamManager] Grace period expired for team ${teamId} — closing stdin`);
|
|
976
|
+
// Step 3: Close stdin to signal CC to finish
|
|
977
|
+
const stdin = this.stdinPipes.get(teamId);
|
|
978
|
+
if (stdin && !stdin.destroyed) {
|
|
979
|
+
try {
|
|
980
|
+
stdin.end();
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
// stdin.end() failed — proceed to force kill
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
this.stdinPipes.delete(teamId);
|
|
987
|
+
// Step 4: Wait 10s then force kill if still alive
|
|
988
|
+
const killTimer = setTimeout(() => {
|
|
989
|
+
if (!this.childProcesses.has(teamId)) {
|
|
990
|
+
console.log(`[TeamManager] Team ${teamId} exited after stdin close`);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
console.log(`[TeamManager] Force-killing team ${teamId} after merge shutdown`);
|
|
994
|
+
const db = getDatabase();
|
|
995
|
+
const team = db.getTeam(teamId);
|
|
996
|
+
if (team?.pid) {
|
|
997
|
+
this.killProcess(team.pid);
|
|
998
|
+
}
|
|
999
|
+
// Clean up maps — the exit handler may also fire, but
|
|
1000
|
+
// childProcesses.delete is idempotent
|
|
1001
|
+
this.flushTokenCounters(teamId);
|
|
1002
|
+
this.persistParsedEvents(teamId);
|
|
1003
|
+
this.childProcesses.delete(teamId);
|
|
1004
|
+
this.outputBuffers.delete(teamId);
|
|
1005
|
+
this.parsedEvents.delete(teamId);
|
|
1006
|
+
this.tokenCounters.delete(teamId);
|
|
1007
|
+
// Set stoppedAt and broadcast
|
|
1008
|
+
if (team && !team.stoppedAt) {
|
|
1009
|
+
db.updateTeam(teamId, {
|
|
1010
|
+
pid: null,
|
|
1011
|
+
stoppedAt: new Date().toISOString(),
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
sseBroker.broadcast('team_stopped', { team_id: teamId }, teamId);
|
|
1015
|
+
}, 10_000);
|
|
1016
|
+
if (killTimer.unref)
|
|
1017
|
+
killTimer.unref();
|
|
1018
|
+
}, graceMs);
|
|
1019
|
+
if (graceTimer.unref)
|
|
1020
|
+
graceTimer.unref();
|
|
1021
|
+
this.shutdownTimers.set(teamId, graceTimer);
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Clear all active shutdown timers. Called during server shutdown
|
|
1025
|
+
* to prevent dangling timers.
|
|
1026
|
+
*/
|
|
1027
|
+
clearShutdownTimers() {
|
|
1028
|
+
for (const [teamId, timer] of this.shutdownTimers) {
|
|
1029
|
+
clearTimeout(timer);
|
|
1030
|
+
}
|
|
1031
|
+
this.shutdownTimers.clear();
|
|
1032
|
+
}
|
|
1033
|
+
// -------------------------------------------------------------------------
|
|
1034
|
+
// writeStdinMessage — low-level: write an SDKUserMessage JSON line to stdin
|
|
1035
|
+
// -------------------------------------------------------------------------
|
|
1036
|
+
writeStdinMessage(stdin, content) {
|
|
1037
|
+
const msg = {
|
|
1038
|
+
type: 'user',
|
|
1039
|
+
session_id: '',
|
|
1040
|
+
message: { role: 'user', content },
|
|
1041
|
+
parent_tool_use_id: null,
|
|
1042
|
+
};
|
|
1043
|
+
stdin.write(JSON.stringify(msg) + '\n');
|
|
1044
|
+
}
|
|
1045
|
+
// -------------------------------------------------------------------------
|
|
1046
|
+
// resolvePromptFromFile — read prompt from project's prompt file on disk
|
|
1047
|
+
// -------------------------------------------------------------------------
|
|
1048
|
+
/**
|
|
1049
|
+
* Read the project's prompt file and replace {{ISSUE_NUMBER}} placeholder.
|
|
1050
|
+
* Fallback chain: project prompt file > prompts/default-prompt.md > hardcoded default.
|
|
1051
|
+
*/
|
|
1052
|
+
resolvePromptFromFile(project, issueNumber) {
|
|
1053
|
+
if (project.promptFile) {
|
|
1054
|
+
const absPath = path.join(config.fleetCommanderRoot, project.promptFile);
|
|
1055
|
+
if (fs.existsSync(absPath)) {
|
|
1056
|
+
try {
|
|
1057
|
+
const template = fs.readFileSync(absPath, 'utf-8');
|
|
1058
|
+
const resolved = template.replace(/\{\{ISSUE_NUMBER\}\}/g, String(issueNumber));
|
|
1059
|
+
console.log(`[TeamManager] Resolved prompt from file: ${project.promptFile}`);
|
|
1060
|
+
return resolved;
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
console.warn(`[TeamManager] Failed to read prompt file ${absPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
console.warn(`[TeamManager] Prompt file not found: ${absPath}`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// Fall back to default-prompt.md
|
|
1071
|
+
const defaultPath = path.join(config.fleetCommanderRoot, 'prompts', 'default-prompt.md');
|
|
1072
|
+
if (fs.existsSync(defaultPath)) {
|
|
1073
|
+
try {
|
|
1074
|
+
const template = fs.readFileSync(defaultPath, 'utf-8');
|
|
1075
|
+
const resolved = template.replace(/\{\{ISSUE_NUMBER\}\}/g, String(issueNumber));
|
|
1076
|
+
console.log(`[TeamManager] Resolved prompt from default: prompts/default-prompt.md`);
|
|
1077
|
+
return resolved;
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
// Fall through to hardcoded fallback
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// Hardcoded fallback (should not normally be reached)
|
|
1084
|
+
const fallback = `Read the ENTIRE file .claude/prompts/fleet-workflow.md before taking any actions.\nYou are the TL. There is NO coordinator — you orchestrate the Diamond team directly.\nPhase 0: Spawn fleet-planner. Wait for plan. Phase 1: Spawn fleet-dev WITH the planner's plan. Wait for ready. Phase 2: Spawn fleet-reviewer. Dev and reviewer communicate p2p. Planner stays alive for p2p questions.\nIssue: #${issueNumber}`;
|
|
1085
|
+
console.log(`[TeamManager] Using hardcoded fallback prompt`);
|
|
1086
|
+
return fallback;
|
|
1087
|
+
}
|
|
1088
|
+
// -------------------------------------------------------------------------
|
|
1089
|
+
// launchBatch — launch multiple teams with optional stagger delay
|
|
1090
|
+
// -------------------------------------------------------------------------
|
|
1091
|
+
async launchBatch(projectId, issues, prompt, delayMs, headless) {
|
|
1092
|
+
const results = [];
|
|
1093
|
+
const delay = delayMs ?? 2000; // default 2-second stagger
|
|
1094
|
+
for (let i = 0; i < issues.length; i++) {
|
|
1095
|
+
const issue = issues[i];
|
|
1096
|
+
try {
|
|
1097
|
+
const team = await this.launch(projectId, issue.number, issue.title, prompt, headless);
|
|
1098
|
+
results.push(team);
|
|
1099
|
+
}
|
|
1100
|
+
catch (err) {
|
|
1101
|
+
// Log error and continue — don't stop the batch
|
|
1102
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1103
|
+
console.error(`[TeamManager] Batch launch failed at issue #${issue.number} (${i + 1}/${issues.length}): ${msg}`);
|
|
1104
|
+
}
|
|
1105
|
+
// Stagger delay between launches (skip after last)
|
|
1106
|
+
if (i < issues.length - 1 && delay > 0) {
|
|
1107
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return results;
|
|
1111
|
+
}
|
|
1112
|
+
// -------------------------------------------------------------------------
|
|
1113
|
+
// Shared spawn/lifecycle helpers (extracted from launch/resume/launchQueued)
|
|
1114
|
+
// -------------------------------------------------------------------------
|
|
1115
|
+
/**
|
|
1116
|
+
* Build the spawn environment for a Claude Code process.
|
|
1117
|
+
* Inherits the server's env and adds fleet/git-bash vars.
|
|
1118
|
+
* This is the SINGLE source of CC env vars for BOTH headless and interactive modes.
|
|
1119
|
+
*/
|
|
1120
|
+
buildSpawnEnv(project, worktreeName, projectId) {
|
|
1121
|
+
const env = {
|
|
1122
|
+
...process.env,
|
|
1123
|
+
FLEET_TEAM_ID: worktreeName,
|
|
1124
|
+
FLEET_PROJECT_ID: String(projectId),
|
|
1125
|
+
FLEET_GITHUB_REPO: project.githubRepo ?? '',
|
|
1126
|
+
};
|
|
1127
|
+
// Only set agent teams env var when enabled; explicitly clear it otherwise
|
|
1128
|
+
// so it's not inherited from process.env
|
|
1129
|
+
if (config.enableAgentTeams) {
|
|
1130
|
+
env['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] = '1';
|
|
1131
|
+
}
|
|
1132
|
+
else {
|
|
1133
|
+
env['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] = undefined;
|
|
1134
|
+
}
|
|
1135
|
+
const gitBash = findGitBash();
|
|
1136
|
+
if (gitBash) {
|
|
1137
|
+
env['CLAUDE_CODE_GIT_BASH_PATH'] = gitBash;
|
|
1138
|
+
console.log(`[TeamManager] CLAUDE_CODE_GIT_BASH_PATH=${gitBash}`);
|
|
1139
|
+
}
|
|
1140
|
+
console.log(`[TeamManager] Spawn env: FLEET_TEAM_ID=${worktreeName}, FLEET_PROJECT_ID=${projectId}, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=${config.enableAgentTeams ? '1' : 'disabled'}`);
|
|
1141
|
+
return env;
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Build the base Claude CLI args shared by BOTH headless and interactive modes.
|
|
1145
|
+
* @param worktreeName — worktree name for --worktree flag
|
|
1146
|
+
* @param options.resume — add --resume flag (for resume flow)
|
|
1147
|
+
* @param options.model — optional model override from project
|
|
1148
|
+
* @param options.prompt — positional prompt arg (interactive mode only)
|
|
1149
|
+
*/
|
|
1150
|
+
buildBaseClaudeArgs(worktreeName, options) {
|
|
1151
|
+
const args = [];
|
|
1152
|
+
if (options?.resume) {
|
|
1153
|
+
args.push('--resume');
|
|
1154
|
+
}
|
|
1155
|
+
args.push('--worktree', worktreeName);
|
|
1156
|
+
if (config.skipPermissions) {
|
|
1157
|
+
args.push('--dangerously-skip-permissions');
|
|
1158
|
+
}
|
|
1159
|
+
if (options?.model) {
|
|
1160
|
+
args.push('--model', options.model);
|
|
1161
|
+
}
|
|
1162
|
+
if (options?.prompt) {
|
|
1163
|
+
args.push(options.prompt);
|
|
1164
|
+
}
|
|
1165
|
+
return args;
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Build Claude CLI args for headless (stream-json) mode.
|
|
1169
|
+
* Calls buildBaseClaudeArgs() and appends stream-json + verbose flags.
|
|
1170
|
+
*/
|
|
1171
|
+
buildHeadlessClaudeArgs(worktreeName, options) {
|
|
1172
|
+
const args = this.buildBaseClaudeArgs(worktreeName, options);
|
|
1173
|
+
args.push('--input-format', 'stream-json');
|
|
1174
|
+
args.push('--output-format', 'stream-json');
|
|
1175
|
+
args.push('--verbose');
|
|
1176
|
+
args.push('--include-partial-messages');
|
|
1177
|
+
return args;
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Build the `set VAR=value` portion of the innerCommand for interactive
|
|
1181
|
+
* mode on Windows. Iterates over buildSpawnEnv() output to generate
|
|
1182
|
+
* set commands dynamically, ensuring all env vars are passed.
|
|
1183
|
+
*
|
|
1184
|
+
* Only includes fleet/CC-specific vars (prefixed FLEET_, CLAUDE_CODE_)
|
|
1185
|
+
* to keep the command string manageable and avoid leaking unrelated env.
|
|
1186
|
+
*/
|
|
1187
|
+
buildInteractiveEnvSetCommands(spawnEnv) {
|
|
1188
|
+
const prefixes = ['FLEET_', 'CLAUDE_CODE_'];
|
|
1189
|
+
const setCmds = [];
|
|
1190
|
+
for (const [key, value] of Object.entries(spawnEnv)) {
|
|
1191
|
+
if (value !== undefined && prefixes.some(p => key.startsWith(p))) {
|
|
1192
|
+
setCmds.push(`set ${key}=${value}`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return setCmds.join(' && ');
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Launch a Claude Code process in interactive mode (Windows terminal).
|
|
1199
|
+
* Extracted from the duplicated logic in launch() and launchQueued().
|
|
1200
|
+
*
|
|
1201
|
+
* Opens a new terminal window (wt.exe or cmd.exe) with the Claude Code
|
|
1202
|
+
* CLI and all required env vars from buildSpawnEnv().
|
|
1203
|
+
*/
|
|
1204
|
+
async launchInteractive(team, project, worktreeAbsPath, prompt) {
|
|
1205
|
+
const db = getDatabase();
|
|
1206
|
+
const args = this.buildBaseClaudeArgs(team.worktreeName, {
|
|
1207
|
+
model: project.model,
|
|
1208
|
+
prompt,
|
|
1209
|
+
});
|
|
1210
|
+
const spawnEnv = this.buildSpawnEnv(project, team.worktreeName, team.projectId);
|
|
1211
|
+
const claudePath = resolveClaudePath();
|
|
1212
|
+
const fullCmd = `${claudePath} ${args.join(' ')}`;
|
|
1213
|
+
const windowTitle = `Team ${team.worktreeName}`;
|
|
1214
|
+
// Build env set commands dynamically from buildSpawnEnv()
|
|
1215
|
+
const envSetCmds = this.buildInteractiveEnvSetCommands(spawnEnv);
|
|
1216
|
+
const innerCommand = `cd /d "${worktreeAbsPath}" && ${envSetCmds} && ${fullCmd}`;
|
|
1217
|
+
const termPref = config.terminalCmd;
|
|
1218
|
+
let useWindowsTerminal = false;
|
|
1219
|
+
if (termPref === 'wt') {
|
|
1220
|
+
useWindowsTerminal = true;
|
|
1221
|
+
}
|
|
1222
|
+
else if (termPref === 'auto') {
|
|
1223
|
+
try {
|
|
1224
|
+
await execAsync('where wt.exe', { timeout: 3000 });
|
|
1225
|
+
useWindowsTerminal = true;
|
|
1226
|
+
}
|
|
1227
|
+
catch {
|
|
1228
|
+
useWindowsTerminal = false;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
let startCommand;
|
|
1232
|
+
if (useWindowsTerminal) {
|
|
1233
|
+
startCommand = `wt.exe new-tab --title "${windowTitle}" cmd.exe /k "${innerCommand}"`;
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
startCommand = `start "${windowTitle}" cmd.exe /k "${innerCommand}"`;
|
|
1237
|
+
}
|
|
1238
|
+
console.log(`[TeamManager] Interactive spawn (terminal=${useWindowsTerminal ? 'wt' : 'cmd'}): ${startCommand}`);
|
|
1239
|
+
const interactiveChild = spawn(startCommand, [], {
|
|
1240
|
+
env: spawnEnv,
|
|
1241
|
+
shell: true,
|
|
1242
|
+
detached: true,
|
|
1243
|
+
stdio: 'ignore',
|
|
1244
|
+
});
|
|
1245
|
+
interactiveChild.unref();
|
|
1246
|
+
console.log(`[TeamManager] Interactive window opened for team ${team.id} (worktree: ${team.worktreeName})`);
|
|
1247
|
+
db.insertTransition({
|
|
1248
|
+
teamId: team.id,
|
|
1249
|
+
fromStatus: 'launching',
|
|
1250
|
+
toStatus: 'running',
|
|
1251
|
+
trigger: 'system',
|
|
1252
|
+
reason: 'Interactive terminal window opened',
|
|
1253
|
+
});
|
|
1254
|
+
db.updateTeam(team.id, { status: 'running' });
|
|
1255
|
+
this.broadcastSnapshot();
|
|
1256
|
+
sseBroker.broadcast('team_launched', { team_id: team.id, issue_number: team.issueNumber, project_id: team.projectId }, team.id);
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Spawn a Claude Code process and validate it got a PID.
|
|
1260
|
+
* On failure, transitions team to 'failed' and broadcasts snapshot.
|
|
1261
|
+
* Returns the child process, or null if spawn failed.
|
|
1262
|
+
*/
|
|
1263
|
+
spawnAndValidate(claudePath, args, cwd, env, teamId) {
|
|
1264
|
+
const db = getDatabase();
|
|
1265
|
+
const child = spawn(claudePath, args, {
|
|
1266
|
+
cwd,
|
|
1267
|
+
env,
|
|
1268
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1269
|
+
detached: false,
|
|
1270
|
+
});
|
|
1271
|
+
const pid = child.pid;
|
|
1272
|
+
if (pid === undefined) {
|
|
1273
|
+
console.error(`[TeamManager] ERROR: spawn failed for team ${teamId}: no PID returned`);
|
|
1274
|
+
db.insertTransition({
|
|
1275
|
+
teamId,
|
|
1276
|
+
fromStatus: 'launching',
|
|
1277
|
+
toStatus: 'failed',
|
|
1278
|
+
trigger: 'system',
|
|
1279
|
+
reason: 'Spawn failed: no PID returned',
|
|
1280
|
+
});
|
|
1281
|
+
db.updateTeam(teamId, { status: 'failed', stoppedAt: new Date().toISOString() });
|
|
1282
|
+
this.broadcastSnapshot();
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
console.log(`[TeamManager] Process spawned: PID ${pid}`);
|
|
1286
|
+
db.updateTeam(teamId, { pid });
|
|
1287
|
+
this.broadcastSnapshot();
|
|
1288
|
+
this.childProcesses.set(teamId, child);
|
|
1289
|
+
return child;
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Attach exit and error handlers to a child process.
|
|
1293
|
+
* These handlers clean up maps, transition the team to done/failed,
|
|
1294
|
+
* broadcast SSE events, and trigger queue processing.
|
|
1295
|
+
*/
|
|
1296
|
+
attachProcessHandlers(teamId, child) {
|
|
1297
|
+
const db = getDatabase();
|
|
1298
|
+
child.on('exit', (code, signal) => {
|
|
1299
|
+
console.log(`[TeamManager] Process exited for team ${teamId} (code=${code}, signal=${signal})`);
|
|
1300
|
+
this.clearThinking(teamId);
|
|
1301
|
+
this.flushTokenCounters(teamId);
|
|
1302
|
+
this.persistParsedEvents(teamId);
|
|
1303
|
+
this.childProcesses.delete(teamId);
|
|
1304
|
+
this.stdinPipes.delete(teamId);
|
|
1305
|
+
this.outputBuffers.delete(teamId);
|
|
1306
|
+
this.parsedEvents.delete(teamId);
|
|
1307
|
+
this.tokenCounters.delete(teamId);
|
|
1308
|
+
const currentTeam = db.getTeam(teamId);
|
|
1309
|
+
if (!currentTeam)
|
|
1310
|
+
return;
|
|
1311
|
+
if (['launching', 'running', 'idle', 'stuck'].includes(currentTeam.status)) {
|
|
1312
|
+
const exitStatus = (code === 0) ? 'done' : 'failed';
|
|
1313
|
+
db.insertTransition({
|
|
1314
|
+
teamId,
|
|
1315
|
+
fromStatus: currentTeam.status,
|
|
1316
|
+
toStatus: exitStatus,
|
|
1317
|
+
trigger: 'system',
|
|
1318
|
+
reason: code === 0
|
|
1319
|
+
? 'Process exited normally (code 0)'
|
|
1320
|
+
: `Process exited with code ${code}${signal ? `, signal ${signal}` : ''}`,
|
|
1321
|
+
});
|
|
1322
|
+
db.updateTeam(teamId, {
|
|
1323
|
+
status: exitStatus,
|
|
1324
|
+
pid: null,
|
|
1325
|
+
stoppedAt: new Date().toISOString(),
|
|
1326
|
+
});
|
|
1327
|
+
sseBroker.broadcast('team_stopped', { team_id: teamId }, teamId);
|
|
1328
|
+
this.broadcastSnapshot();
|
|
1329
|
+
}
|
|
1330
|
+
if (currentTeam.projectId) {
|
|
1331
|
+
this.processQueue(currentTeam.projectId).catch((err) => {
|
|
1332
|
+
console.error(`[TeamManager] processQueue error after team exit:`, err);
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
child.on('error', (err) => {
|
|
1337
|
+
console.error(`[TeamManager] ERROR: process error for team ${teamId}:`, err.message);
|
|
1338
|
+
this.clearThinking(teamId);
|
|
1339
|
+
this.flushTokenCounters(teamId);
|
|
1340
|
+
this.persistParsedEvents(teamId);
|
|
1341
|
+
this.childProcesses.delete(teamId);
|
|
1342
|
+
this.stdinPipes.delete(teamId);
|
|
1343
|
+
this.outputBuffers.delete(teamId);
|
|
1344
|
+
this.parsedEvents.delete(teamId);
|
|
1345
|
+
this.tokenCounters.delete(teamId);
|
|
1346
|
+
const currentTeam = db.getTeam(teamId);
|
|
1347
|
+
if (!currentTeam)
|
|
1348
|
+
return;
|
|
1349
|
+
if (['launching', 'running', 'idle', 'stuck'].includes(currentTeam.status)) {
|
|
1350
|
+
db.insertTransition({
|
|
1351
|
+
teamId,
|
|
1352
|
+
fromStatus: currentTeam.status,
|
|
1353
|
+
toStatus: 'failed',
|
|
1354
|
+
trigger: 'system',
|
|
1355
|
+
reason: `Process error: ${err.message.slice(0, 200)}`,
|
|
1356
|
+
});
|
|
1357
|
+
db.updateTeam(teamId, {
|
|
1358
|
+
status: 'failed',
|
|
1359
|
+
pid: null,
|
|
1360
|
+
stoppedAt: new Date().toISOString(),
|
|
1361
|
+
});
|
|
1362
|
+
sseBroker.broadcast('team_stopped', { team_id: teamId }, teamId);
|
|
1363
|
+
this.broadcastSnapshot();
|
|
1364
|
+
}
|
|
1365
|
+
if (currentTeam.projectId) {
|
|
1366
|
+
this.processQueue(currentTeam.projectId).catch((queueErr) => {
|
|
1367
|
+
console.error(`[TeamManager] processQueue error after team error:`, queueErr);
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Store the stdin pipe, send the initial prompt (if provided), set up
|
|
1374
|
+
* output capture, and broadcast the initial FC event in the session log.
|
|
1375
|
+
*/
|
|
1376
|
+
setupStdinAndOutput(teamId, child, prompt) {
|
|
1377
|
+
if (child.stdin) {
|
|
1378
|
+
this.stdinPipes.set(teamId, child.stdin);
|
|
1379
|
+
if (prompt) {
|
|
1380
|
+
this.writeStdinMessage(child.stdin, prompt);
|
|
1381
|
+
console.log(`[TeamManager] Initial prompt sent via stdin for team ${teamId}`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
this.initOutputBuffer(teamId);
|
|
1385
|
+
this.captureOutput(teamId, child);
|
|
1386
|
+
if (prompt && child.stdin) {
|
|
1387
|
+
const initEvent = {
|
|
1388
|
+
type: 'fc',
|
|
1389
|
+
subtype: 'initial_prompt',
|
|
1390
|
+
agentName: '__fc__',
|
|
1391
|
+
timestamp: new Date().toISOString(),
|
|
1392
|
+
message: { content: [{ type: 'text', text: prompt }] },
|
|
1393
|
+
};
|
|
1394
|
+
const evts = this.parsedEvents.get(teamId);
|
|
1395
|
+
if (evts)
|
|
1396
|
+
evts.push(initEvent);
|
|
1397
|
+
sseBroker.broadcast('team_output', { team_id: teamId, event: initEvent }, teamId);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Create a git worktree with -b fallback. On failure, transitions team
|
|
1402
|
+
* to 'failed' and broadcasts snapshot.
|
|
1403
|
+
* Returns true on success, false on failure.
|
|
1404
|
+
*/
|
|
1405
|
+
async createWorktree(repoPath, worktreeRelPath, worktreeAbsPath, branchName, teamId, fromStatus) {
|
|
1406
|
+
if (fs.existsSync(worktreeAbsPath))
|
|
1407
|
+
return true;
|
|
1408
|
+
try {
|
|
1409
|
+
await execAsync(`git -C "${repoPath}" worktree add "${worktreeRelPath}" -b "${branchName}"`);
|
|
1410
|
+
return true;
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
// Branch may already exist — try without -b
|
|
1414
|
+
try {
|
|
1415
|
+
await execAsync(`git -C "${repoPath}" worktree add "${worktreeRelPath}" "${branchName}"`);
|
|
1416
|
+
return true;
|
|
1417
|
+
}
|
|
1418
|
+
catch (err2) {
|
|
1419
|
+
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
1420
|
+
console.error(`[TeamManager] ERROR: Worktree creation failed for team ${teamId}: ${msg}`);
|
|
1421
|
+
const db = getDatabase();
|
|
1422
|
+
db.insertTransition({
|
|
1423
|
+
teamId,
|
|
1424
|
+
fromStatus,
|
|
1425
|
+
toStatus: 'failed',
|
|
1426
|
+
trigger: 'system',
|
|
1427
|
+
reason: `Worktree creation failed: ${msg.slice(0, 200)}`,
|
|
1428
|
+
});
|
|
1429
|
+
db.updateTeam(teamId, { status: 'failed', stoppedAt: new Date().toISOString() });
|
|
1430
|
+
this.broadcastSnapshot();
|
|
1431
|
+
return false;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Copy hook scripts and generate settings.json into a worktree directory.
|
|
1437
|
+
*/
|
|
1438
|
+
copyHooksAndSettings(worktreeAbsPath) {
|
|
1439
|
+
const hookSrcDir = config.fcHooksDir;
|
|
1440
|
+
const hookDestDir = path.join(worktreeAbsPath, config.hookDir);
|
|
1441
|
+
fs.mkdirSync(hookDestDir, { recursive: true });
|
|
1442
|
+
if (fs.existsSync(hookSrcDir)) {
|
|
1443
|
+
const hookFiles = fs.readdirSync(hookSrcDir).filter((f) => f.endsWith('.sh'));
|
|
1444
|
+
for (const file of hookFiles) {
|
|
1445
|
+
const src = path.join(hookSrcDir, file);
|
|
1446
|
+
const dest = path.join(hookDestDir, file);
|
|
1447
|
+
fs.copyFileSync(src, dest);
|
|
1448
|
+
if (process.platform !== 'win32') {
|
|
1449
|
+
fs.chmodSync(dest, 0o755);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
console.log(`[TeamManager] Hooks copied to worktree`);
|
|
1454
|
+
// Generate settings.json from example
|
|
1455
|
+
const settingsExamplePath = path.join(hookSrcDir, 'settings.json.example');
|
|
1456
|
+
const settingsDestDir = path.join(worktreeAbsPath, '.claude');
|
|
1457
|
+
const settingsDestPath = path.join(settingsDestDir, 'settings.json');
|
|
1458
|
+
fs.mkdirSync(settingsDestDir, { recursive: true });
|
|
1459
|
+
if (fs.existsSync(settingsExamplePath)) {
|
|
1460
|
+
fs.copyFileSync(settingsExamplePath, settingsDestPath);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Derive worktree naming from a project and issue number.
|
|
1465
|
+
*/
|
|
1466
|
+
deriveWorktreeNames(project, issueNumber) {
|
|
1467
|
+
const slug = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
1468
|
+
const worktreeName = `${slug}-${issueNumber}`;
|
|
1469
|
+
const branchName = `worktree-${slug}-${issueNumber}`;
|
|
1470
|
+
const worktreeRelPath = path.posix.join(config.worktreeDir, worktreeName);
|
|
1471
|
+
const worktreeAbsPath = path.join(project.repoPath, config.worktreeDir, worktreeName);
|
|
1472
|
+
return { slug, worktreeName, branchName, worktreeRelPath, worktreeAbsPath };
|
|
1473
|
+
}
|
|
1474
|
+
// -------------------------------------------------------------------------
|
|
1475
|
+
// Internal helpers
|
|
1476
|
+
// -------------------------------------------------------------------------
|
|
1477
|
+
/**
|
|
1478
|
+
* Broadcast a full team dashboard snapshot to all SSE clients.
|
|
1479
|
+
* Called after any team state change so the Fleet Grid refreshes.
|
|
1480
|
+
*/
|
|
1481
|
+
broadcastSnapshot() {
|
|
1482
|
+
try {
|
|
1483
|
+
const db = getDatabase();
|
|
1484
|
+
const dashboard = db.getTeamDashboard();
|
|
1485
|
+
sseBroker.broadcast('snapshot', { teams: dashboard });
|
|
1486
|
+
}
|
|
1487
|
+
catch (err) {
|
|
1488
|
+
console.error('[TeamManager] Failed to broadcast snapshot:', err);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
initOutputBuffer(teamId) {
|
|
1492
|
+
this.outputBuffers.set(teamId, { lines: [] });
|
|
1493
|
+
}
|
|
1494
|
+
captureOutput(teamId, child) {
|
|
1495
|
+
const buffer = this.outputBuffers.get(teamId);
|
|
1496
|
+
if (!buffer)
|
|
1497
|
+
return;
|
|
1498
|
+
// Resolve the worktree name for log prefixes
|
|
1499
|
+
const db = getDatabase();
|
|
1500
|
+
const team = db.getTeam(teamId);
|
|
1501
|
+
const logPrefix = team ? team.worktreeName : `team-${teamId}`;
|
|
1502
|
+
// Initialize parsed events buffer for this team
|
|
1503
|
+
if (!this.parsedEvents.has(teamId)) {
|
|
1504
|
+
this.parsedEvents.set(teamId, []);
|
|
1505
|
+
}
|
|
1506
|
+
const events = this.parsedEvents.get(teamId);
|
|
1507
|
+
// Initialize agent map for tracking tool_use_id -> agent name
|
|
1508
|
+
if (!this.agentMaps.has(teamId)) {
|
|
1509
|
+
this.agentMaps.set(teamId, new Map());
|
|
1510
|
+
}
|
|
1511
|
+
const agentMap = this.agentMaps.get(teamId);
|
|
1512
|
+
// Initialize token counter (seed from DB for restarts)
|
|
1513
|
+
if (!this.tokenCounters.has(teamId)) {
|
|
1514
|
+
const existingTeam = db.getTeam(teamId);
|
|
1515
|
+
this.tokenCounters.set(teamId, {
|
|
1516
|
+
inputTokens: existingTeam?.totalInputTokens ?? 0,
|
|
1517
|
+
outputTokens: existingTeam?.totalOutputTokens ?? 0,
|
|
1518
|
+
cacheCreationTokens: existingTeam?.totalCacheCreationTokens ?? 0,
|
|
1519
|
+
cacheReadTokens: existingTeam?.totalCacheReadTokens ?? 0,
|
|
1520
|
+
costUsd: existingTeam?.totalCostUsd ?? 0,
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
// Partial line buffer for handling chunks that split across data events
|
|
1524
|
+
let stdoutPartial = '';
|
|
1525
|
+
// stdout: NDJSON from --output-format stream-json
|
|
1526
|
+
if (child.stdout) {
|
|
1527
|
+
child.stdout.on('data', (data) => {
|
|
1528
|
+
const text = stdoutPartial + data.toString('utf-8');
|
|
1529
|
+
const lines = text.split('\n');
|
|
1530
|
+
// Last element may be incomplete — save it for next chunk
|
|
1531
|
+
stdoutPartial = lines.pop() ?? '';
|
|
1532
|
+
for (const line of lines) {
|
|
1533
|
+
const trimmed = line.trim();
|
|
1534
|
+
if (!trimmed)
|
|
1535
|
+
continue;
|
|
1536
|
+
// Store raw line in output buffer
|
|
1537
|
+
buffer.lines.push(line);
|
|
1538
|
+
while (buffer.lines.length > MAX_OUTPUT_LINES) {
|
|
1539
|
+
buffer.lines.shift();
|
|
1540
|
+
}
|
|
1541
|
+
// Try to parse as JSON (NDJSON from stream-json output)
|
|
1542
|
+
try {
|
|
1543
|
+
const event = JSON.parse(trimmed);
|
|
1544
|
+
console.log(`[CC:${logPrefix}] ${event.type}: ${summarizeEvent(event)}`);
|
|
1545
|
+
// Skip CC-echoed "user" events — sendMessage() and
|
|
1546
|
+
// setupStdinAndOutput() already inject properly-labeled
|
|
1547
|
+
// synthetic events (type 'user' or 'fc') into parsedEvents.
|
|
1548
|
+
// The CC echo is redundant and would misattribute automated
|
|
1549
|
+
// FC messages as PM ("You") messages in the Session Log.
|
|
1550
|
+
if (event.type === 'user')
|
|
1551
|
+
continue;
|
|
1552
|
+
// Detect thinking start/stop from content_block_start/stop events
|
|
1553
|
+
// (must run before the buffer-skip below so thinking state is tracked)
|
|
1554
|
+
this.detectThinking(teamId, event);
|
|
1555
|
+
// content_block_start/delta/stop events are high-frequency partial
|
|
1556
|
+
// message fragments emitted by --include-partial-messages. They are
|
|
1557
|
+
// only needed for thinking detection (above) and must NOT be stored
|
|
1558
|
+
// in the parsedEvents buffer — they would flood the event cap
|
|
1559
|
+
// and evict meaningful session log entries.
|
|
1560
|
+
if (event.type === 'content_block_start' || event.type === 'content_block_delta' || event.type === 'content_block_stop' || event.type === 'stream_event') {
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
// ----- Agent name resolution -----
|
|
1564
|
+
// 1. Learn agent names: when the TL's assistant event contains
|
|
1565
|
+
// a tool_use content block for "Agent" or "Task", record the
|
|
1566
|
+
// mapping from tool_use_id -> agent name.
|
|
1567
|
+
const ev = event;
|
|
1568
|
+
if (event.type === 'assistant') {
|
|
1569
|
+
const msg = ev.message;
|
|
1570
|
+
const content = msg?.content;
|
|
1571
|
+
if (Array.isArray(content)) {
|
|
1572
|
+
for (const block of content) {
|
|
1573
|
+
if (block &&
|
|
1574
|
+
typeof block === 'object' &&
|
|
1575
|
+
block.type === 'tool_use') {
|
|
1576
|
+
const toolBlock = block;
|
|
1577
|
+
const toolName = toolBlock.name;
|
|
1578
|
+
const toolId = toolBlock.id;
|
|
1579
|
+
if (toolId && (toolName === 'Agent' || toolName === 'Task' || toolName === 'dispatch_agent')) {
|
|
1580
|
+
const input = toolBlock.input;
|
|
1581
|
+
const agentName = (input?.agent_name ?? input?.name ?? 'subagent');
|
|
1582
|
+
agentMap.set(toolId, agentName.toLowerCase());
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
// 2. Resolve the agent name for this event
|
|
1589
|
+
const parentToolUseId = ev.parent_tool_use_id ?? null;
|
|
1590
|
+
let resolvedAgentName;
|
|
1591
|
+
if (event.type === 'user' || event.type === 'fc') {
|
|
1592
|
+
resolvedAgentName = 'team-lead';
|
|
1593
|
+
}
|
|
1594
|
+
else if (parentToolUseId) {
|
|
1595
|
+
resolvedAgentName = agentMap.get(parentToolUseId) ?? 'subagent';
|
|
1596
|
+
}
|
|
1597
|
+
else {
|
|
1598
|
+
resolvedAgentName = 'team-lead';
|
|
1599
|
+
}
|
|
1600
|
+
// 3. Extract description and lastToolName from system/task_progress
|
|
1601
|
+
let description;
|
|
1602
|
+
let lastToolName;
|
|
1603
|
+
if (event.type === 'system') {
|
|
1604
|
+
const subtype = ev.subtype;
|
|
1605
|
+
if (subtype === 'task_progress' || subtype === 'task_notification') {
|
|
1606
|
+
description = ev.description ?? undefined;
|
|
1607
|
+
lastToolName = ev.last_tool_name ??
|
|
1608
|
+
ev.tool_name ?? undefined;
|
|
1609
|
+
// system task events may carry a tool_use_id referencing the parent agent
|
|
1610
|
+
const sysToolUseId = ev.tool_use_id;
|
|
1611
|
+
if (sysToolUseId && agentMap.has(sysToolUseId)) {
|
|
1612
|
+
resolvedAgentName = agentMap.get(sysToolUseId);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
// Store parsed event with timestamp + agent attribution
|
|
1617
|
+
const timestampedEvent = {
|
|
1618
|
+
...event,
|
|
1619
|
+
timestamp: new Date().toISOString(),
|
|
1620
|
+
agentName: resolvedAgentName,
|
|
1621
|
+
...(description ? { description } : {}),
|
|
1622
|
+
...(lastToolName ? { lastToolName } : {}),
|
|
1623
|
+
};
|
|
1624
|
+
events.push(timestampedEvent);
|
|
1625
|
+
if (events.length > MAX_PARSED_EVENTS) {
|
|
1626
|
+
events.shift();
|
|
1627
|
+
}
|
|
1628
|
+
// Accumulate token counts from assistant events
|
|
1629
|
+
this.accumulateTokens(teamId, event);
|
|
1630
|
+
// Broadcast interesting events via SSE
|
|
1631
|
+
// Include 'system' for task_progress/task_notification visibility
|
|
1632
|
+
if (['assistant', 'tool_use', 'tool_result', 'result', 'system'].includes(event.type)) {
|
|
1633
|
+
sseBroker.broadcast('team_output', {
|
|
1634
|
+
team_id: teamId,
|
|
1635
|
+
event: timestampedEvent,
|
|
1636
|
+
}, teamId);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
catch {
|
|
1640
|
+
// Not valid JSON — raw text output (e.g. startup messages)
|
|
1641
|
+
console.log(`[CC:${logPrefix}:raw] ${trimmed.substring(0, 200)}`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
child.stdout.on('end', () => {
|
|
1646
|
+
if (stdoutPartial.trim()) {
|
|
1647
|
+
const trimmed = stdoutPartial.trim();
|
|
1648
|
+
buffer.lines.push(stdoutPartial);
|
|
1649
|
+
while (buffer.lines.length > MAX_OUTPUT_LINES) {
|
|
1650
|
+
buffer.lines.shift();
|
|
1651
|
+
}
|
|
1652
|
+
try {
|
|
1653
|
+
const event = JSON.parse(trimmed);
|
|
1654
|
+
console.log(`[CC:${logPrefix}] ${event.type}: ${summarizeEvent(event)}`);
|
|
1655
|
+
// Skip CC-echoed "user" events (same rationale as in 'data' handler)
|
|
1656
|
+
if (event.type !== 'user') {
|
|
1657
|
+
// Detect thinking state before filtering
|
|
1658
|
+
this.detectThinking(teamId, event);
|
|
1659
|
+
// Filter out content_block events (same rationale as in 'data' handler)
|
|
1660
|
+
if (event.type !== 'content_block_start' && event.type !== 'content_block_delta' && event.type !== 'content_block_stop' && event.type !== 'stream_event') {
|
|
1661
|
+
// Resolve agent name (same logic as 'data' handler)
|
|
1662
|
+
const endEv = event;
|
|
1663
|
+
const endParentId = endEv.parent_tool_use_id ?? null;
|
|
1664
|
+
const endAgentName = endParentId
|
|
1665
|
+
? (agentMap.get(endParentId) ?? 'subagent')
|
|
1666
|
+
: 'team-lead';
|
|
1667
|
+
const timestampedEvent = {
|
|
1668
|
+
...event,
|
|
1669
|
+
timestamp: new Date().toISOString(),
|
|
1670
|
+
agentName: endAgentName,
|
|
1671
|
+
};
|
|
1672
|
+
events.push(timestampedEvent);
|
|
1673
|
+
if (events.length > MAX_PARSED_EVENTS) {
|
|
1674
|
+
events.shift();
|
|
1675
|
+
}
|
|
1676
|
+
// Accumulate token counts from assistant events
|
|
1677
|
+
this.accumulateTokens(teamId, event);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
catch {
|
|
1682
|
+
console.log(`[CC:${logPrefix}:raw] ${trimmed.substring(0, 200)}`);
|
|
1683
|
+
}
|
|
1684
|
+
stdoutPartial = '';
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
// stderr: always raw text (errors, warnings)
|
|
1689
|
+
if (child.stderr) {
|
|
1690
|
+
child.stderr.on('data', (data) => {
|
|
1691
|
+
const text = data.toString('utf-8');
|
|
1692
|
+
for (const line of text.split('\n')) {
|
|
1693
|
+
if (line.trim()) {
|
|
1694
|
+
console.log(`[CC:${logPrefix}:stderr] ${line}`);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
const newLines = text.split('\n');
|
|
1698
|
+
for (let idx = 0; idx < newLines.length; idx++) {
|
|
1699
|
+
const line = newLines[idx];
|
|
1700
|
+
if (line === '' && idx === newLines.length - 1)
|
|
1701
|
+
continue;
|
|
1702
|
+
buffer.lines.push(line);
|
|
1703
|
+
while (buffer.lines.length > MAX_OUTPUT_LINES) {
|
|
1704
|
+
buffer.lines.shift();
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Extract and accumulate token counts from stream events.
|
|
1712
|
+
* - `assistant` events: increment token counters from usage.input_tokens, etc.
|
|
1713
|
+
* - `result` events: replace totalCostUsd (it's cumulative) and flush to DB.
|
|
1714
|
+
*/
|
|
1715
|
+
accumulateTokens(teamId, event) {
|
|
1716
|
+
const counter = this.tokenCounters.get(teamId);
|
|
1717
|
+
if (!counter)
|
|
1718
|
+
return;
|
|
1719
|
+
const ev = event;
|
|
1720
|
+
if (event.type === 'assistant') {
|
|
1721
|
+
// Extract usage from message.usage (Claude API response format)
|
|
1722
|
+
const message = ev.message;
|
|
1723
|
+
const usage = message?.usage;
|
|
1724
|
+
if (usage) {
|
|
1725
|
+
if (typeof usage.input_tokens === 'number') {
|
|
1726
|
+
counter.inputTokens += usage.input_tokens;
|
|
1727
|
+
}
|
|
1728
|
+
if (typeof usage.output_tokens === 'number') {
|
|
1729
|
+
counter.outputTokens += usage.output_tokens;
|
|
1730
|
+
}
|
|
1731
|
+
if (typeof usage.cache_creation_input_tokens === 'number') {
|
|
1732
|
+
counter.cacheCreationTokens += usage.cache_creation_input_tokens;
|
|
1733
|
+
}
|
|
1734
|
+
if (typeof usage.cache_read_input_tokens === 'number') {
|
|
1735
|
+
counter.cacheReadTokens += usage.cache_read_input_tokens;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
if (event.type === 'result') {
|
|
1740
|
+
// total_cost_usd on result is cumulative for the session — replace, don't add
|
|
1741
|
+
if (typeof ev.total_cost_usd === 'number') {
|
|
1742
|
+
counter.costUsd = ev.total_cost_usd;
|
|
1743
|
+
console.log(`[TeamManager] Team ${teamId} cost: $${counter.costUsd.toFixed(4)}`);
|
|
1744
|
+
}
|
|
1745
|
+
// Flush to DB on every result event
|
|
1746
|
+
this.flushTokenCounters(teamId);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Persist in-memory token counters to the database and broadcast SSE update.
|
|
1751
|
+
*/
|
|
1752
|
+
flushTokenCounters(teamId) {
|
|
1753
|
+
const counter = this.tokenCounters.get(teamId);
|
|
1754
|
+
if (!counter)
|
|
1755
|
+
return;
|
|
1756
|
+
try {
|
|
1757
|
+
const db = getDatabase();
|
|
1758
|
+
db.updateTeam(teamId, {
|
|
1759
|
+
totalInputTokens: counter.inputTokens,
|
|
1760
|
+
totalOutputTokens: counter.outputTokens,
|
|
1761
|
+
totalCacheCreationTokens: counter.cacheCreationTokens,
|
|
1762
|
+
totalCacheReadTokens: counter.cacheReadTokens,
|
|
1763
|
+
totalCostUsd: counter.costUsd,
|
|
1764
|
+
});
|
|
1765
|
+
const currentTeam = db.getTeam(teamId);
|
|
1766
|
+
const status = currentTeam?.status ?? 'running';
|
|
1767
|
+
sseBroker.broadcast('team_status_changed', {
|
|
1768
|
+
team_id: teamId,
|
|
1769
|
+
status,
|
|
1770
|
+
previous_status: status,
|
|
1771
|
+
tokens: {
|
|
1772
|
+
input: counter.inputTokens,
|
|
1773
|
+
output: counter.outputTokens,
|
|
1774
|
+
cacheCreation: counter.cacheCreationTokens,
|
|
1775
|
+
cacheRead: counter.cacheReadTokens,
|
|
1776
|
+
costUsd: counter.costUsd,
|
|
1777
|
+
},
|
|
1778
|
+
}, teamId);
|
|
1779
|
+
}
|
|
1780
|
+
catch (err) {
|
|
1781
|
+
console.error(`[TeamManager] Failed to flush token counters for team ${teamId}:`, err);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* Detect thinking start/stop from Claude Code stream-json events.
|
|
1786
|
+
*
|
|
1787
|
+
* content_block_start with content_block.type === "thinking" signals the
|
|
1788
|
+
* start of an extended thinking block. content_block_stop (matched by
|
|
1789
|
+
* index) signals the end.
|
|
1790
|
+
*/
|
|
1791
|
+
detectThinking(teamId, event) {
|
|
1792
|
+
let ev = event;
|
|
1793
|
+
let effectiveType = event.type;
|
|
1794
|
+
// Unwrap stream_event envelopes: thinking signals from
|
|
1795
|
+
// --include-partial-messages arrive wrapped as
|
|
1796
|
+
// { type: "stream_event", event: { type: "content_block_start", ... } }
|
|
1797
|
+
if (event.type === 'stream_event') {
|
|
1798
|
+
const inner = ev.event;
|
|
1799
|
+
if (inner && typeof inner.type === 'string') {
|
|
1800
|
+
ev = inner;
|
|
1801
|
+
effectiveType = inner.type;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
if (effectiveType === 'content_block_start') {
|
|
1805
|
+
const block = ev.content_block;
|
|
1806
|
+
if (block && block.type === 'thinking') {
|
|
1807
|
+
this.thinkingTeams.add(teamId);
|
|
1808
|
+
this.thinkingStartTimes.set(teamId, Date.now());
|
|
1809
|
+
const index = typeof ev.index === 'number' ? ev.index : -1;
|
|
1810
|
+
this.thinkingBlockIndex.set(teamId, index);
|
|
1811
|
+
sseBroker.broadcast('team_thinking_start', { team_id: teamId }, teamId);
|
|
1812
|
+
console.log(`[TeamManager] Team ${teamId} thinking started (block index ${index})`);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
else if (effectiveType === 'content_block_stop') {
|
|
1816
|
+
const index = typeof ev.index === 'number' ? ev.index : -1;
|
|
1817
|
+
const trackedIndex = this.thinkingBlockIndex.get(teamId);
|
|
1818
|
+
if (this.thinkingTeams.has(teamId) && (trackedIndex === undefined || trackedIndex === index)) {
|
|
1819
|
+
const startTime = this.thinkingStartTimes.get(teamId) ?? Date.now();
|
|
1820
|
+
const durationMs = Date.now() - startTime;
|
|
1821
|
+
this.thinkingTeams.delete(teamId);
|
|
1822
|
+
this.thinkingStartTimes.delete(teamId);
|
|
1823
|
+
this.thinkingBlockIndex.delete(teamId);
|
|
1824
|
+
sseBroker.broadcast('team_thinking_stop', { team_id: teamId, duration_ms: durationMs }, teamId);
|
|
1825
|
+
console.log(`[TeamManager] Team ${teamId} thinking stopped (${durationMs}ms)`);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Clear thinking state for a team (e.g. on stop/cleanup).
|
|
1831
|
+
*/
|
|
1832
|
+
clearThinking(teamId) {
|
|
1833
|
+
if (this.thinkingTeams.has(teamId)) {
|
|
1834
|
+
const startTime = this.thinkingStartTimes.get(teamId) ?? Date.now();
|
|
1835
|
+
const durationMs = Date.now() - startTime;
|
|
1836
|
+
this.thinkingTeams.delete(teamId);
|
|
1837
|
+
this.thinkingStartTimes.delete(teamId);
|
|
1838
|
+
this.thinkingBlockIndex.delete(teamId);
|
|
1839
|
+
sseBroker.broadcast('team_thinking_stop', { team_id: teamId, duration_ms: durationMs }, teamId);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
killProcess(pid) {
|
|
1843
|
+
try {
|
|
1844
|
+
if (process.platform === 'win32') {
|
|
1845
|
+
// On Windows, use taskkill to kill the entire process tree
|
|
1846
|
+
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });
|
|
1847
|
+
}
|
|
1848
|
+
else {
|
|
1849
|
+
// On Unix, send SIGTERM
|
|
1850
|
+
process.kill(pid, 'SIGTERM');
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
catch {
|
|
1854
|
+
// Process may have already exited — ignore
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
// ---------------------------------------------------------------------------
|
|
1859
|
+
// Singleton
|
|
1860
|
+
// ---------------------------------------------------------------------------
|
|
1861
|
+
let _instance = null;
|
|
1862
|
+
export function getTeamManager() {
|
|
1863
|
+
if (!_instance) {
|
|
1864
|
+
_instance = new TeamManager();
|
|
1865
|
+
}
|
|
1866
|
+
return _instance;
|
|
1867
|
+
}
|
|
1868
|
+
export default TeamManager;
|
|
1869
|
+
//# sourceMappingURL=team-manager.js.map
|