hungry-ghost-hive 0.43.0 → 0.43.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/agents.d.ts.map +1 -1
- package/dist/cli/commands/agents.js +4 -11
- package/dist/cli/commands/agents.js.map +1 -1
- package/dist/cli/commands/approach.d.ts.map +1 -1
- package/dist/cli/commands/approach.js +2 -6
- package/dist/cli/commands/approach.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +9 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +3 -0
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/cli/commands/manager/index.d.ts +2 -27
- package/dist/cli/commands/manager/index.d.ts.map +1 -1
- package/dist/cli/commands/manager/index.js +23 -1519
- package/dist/cli/commands/manager/index.js.map +1 -1
- package/dist/cli/commands/manager/manager-utils.d.ts +9 -0
- package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -0
- package/dist/cli/commands/manager/manager-utils.js +49 -0
- package/dist/cli/commands/manager/manager-utils.js.map +1 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts +7 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts.map +1 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.js +537 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.js.map +1 -0
- package/dist/cli/commands/manager/qa-review-handler.d.ts +15 -0
- package/dist/cli/commands/manager/qa-review-handler.d.ts.map +1 -0
- package/dist/cli/commands/manager/qa-review-handler.js +290 -0
- package/dist/cli/commands/manager/qa-review-handler.js.map +1 -0
- package/dist/cli/commands/manager/stuck-story-helpers.d.ts +32 -0
- package/dist/cli/commands/manager/stuck-story-helpers.d.ts.map +1 -0
- package/dist/cli/commands/manager/stuck-story-helpers.js +163 -0
- package/dist/cli/commands/manager/stuck-story-helpers.js.map +1 -0
- package/dist/cli/commands/manager/stuck-story-processor.d.ts +8 -0
- package/dist/cli/commands/manager/stuck-story-processor.d.ts.map +1 -0
- package/dist/cli/commands/manager/stuck-story-processor.js +392 -0
- package/dist/cli/commands/manager/stuck-story-processor.js.map +1 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts +3 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.js +141 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -0
- package/dist/cli/commands/my-stories.d.ts.map +1 -1
- package/dist/cli/commands/my-stories.js +5 -20
- package/dist/cli/commands/my-stories.js.map +1 -1
- package/dist/cli/commands/pr.js +7 -22
- package/dist/cli/commands/pr.js.map +1 -1
- package/dist/cli/commands/progress.d.ts.map +1 -1
- package/dist/cli/commands/progress.js +2 -5
- package/dist/cli/commands/progress.js.map +1 -1
- package/dist/cli/commands/resume.d.ts.map +1 -1
- package/dist/cli/commands/resume.js +3 -6
- package/dist/cli/commands/resume.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +2 -5
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/stories.d.ts.map +1 -1
- package/dist/cli/commands/stories.js +2 -5
- package/dist/cli/commands/stories.js.map +1 -1
- package/dist/cluster/adapters.d.ts +3 -2
- package/dist/cluster/adapters.d.ts.map +1 -1
- package/dist/cluster/adapters.js +2 -11
- package/dist/cluster/adapters.js.map +1 -1
- package/dist/cluster/cluster-http-server.d.ts +20 -0
- package/dist/cluster/cluster-http-server.d.ts.map +1 -0
- package/dist/cluster/cluster-http-server.js +140 -0
- package/dist/cluster/cluster-http-server.js.map +1 -0
- package/dist/cluster/heartbeat-manager.d.ts +24 -0
- package/dist/cluster/heartbeat-manager.d.ts.map +1 -0
- package/dist/cluster/heartbeat-manager.js +74 -0
- package/dist/cluster/heartbeat-manager.js.map +1 -0
- package/dist/cluster/raft-state-machine.d.ts +48 -0
- package/dist/cluster/raft-state-machine.d.ts.map +1 -0
- package/dist/cluster/raft-state-machine.js +207 -0
- package/dist/cluster/raft-state-machine.js.map +1 -0
- package/dist/cluster/runtime.d.ts +5 -29
- package/dist/cluster/runtime.d.ts.map +1 -1
- package/dist/cluster/runtime.js +58 -406
- package/dist/cluster/runtime.js.map +1 -1
- package/dist/integrations/jira/sync.d.ts +2 -5
- package/dist/integrations/jira/sync.d.ts.map +1 -1
- package/dist/integrations/jira/sync.js +116 -178
- package/dist/integrations/jira/sync.js.map +1 -1
- package/dist/utils/cli-helpers.d.ts +19 -0
- package/dist/utils/cli-helpers.d.ts.map +1 -0
- package/dist/utils/cli-helpers.js +51 -0
- package/dist/utils/cli-helpers.js.map +1 -0
- package/dist/utils/cli-helpers.test.d.ts +2 -0
- package/dist/utils/cli-helpers.test.d.ts.map +1 -0
- package/dist/utils/cli-helpers.test.js +100 -0
- package/dist/utils/cli-helpers.test.js.map +1 -0
- package/dist/utils/github-cli.d.ts +3 -0
- package/dist/utils/github-cli.d.ts.map +1 -0
- package/dist/utils/github-cli.js +4 -0
- package/dist/utils/github-cli.js.map +1 -0
- package/dist/utils/pr-sync.d.ts.map +1 -1
- package/dist/utils/pr-sync.js +1 -2
- package/dist/utils/pr-sync.js.map +1 -1
- package/dist/utils/story-status.d.ts +19 -0
- package/dist/utils/story-status.d.ts.map +1 -0
- package/dist/utils/story-status.js +58 -0
- package/dist/utils/story-status.js.map +1 -0
- package/dist/utils/story-status.test.d.ts +2 -0
- package/dist/utils/story-status.test.d.ts.map +1 -0
- package/dist/utils/story-status.test.js +65 -0
- package/dist/utils/story-status.test.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/commands/agents.ts +3 -11
- package/src/cli/commands/approach.ts +2 -7
- package/src/cli/commands/init.test.ts +4 -0
- package/src/cli/commands/init.ts +9 -0
- package/src/cli/commands/manager/index.ts +166 -2236
- package/src/cli/commands/manager/manager-utils.ts +85 -0
- package/src/cli/commands/manager/pr-sync-orchestrator.ts +659 -0
- package/src/cli/commands/manager/qa-review-handler.ts +399 -0
- package/src/cli/commands/manager/stuck-story-helpers.ts +255 -0
- package/src/cli/commands/manager/stuck-story-processor.ts +604 -0
- package/src/cli/commands/manager/tech-lead-lifecycle.ts +210 -0
- package/src/cli/commands/my-stories.ts +5 -30
- package/src/cli/commands/pr.ts +6 -22
- package/src/cli/commands/progress.ts +2 -7
- package/src/cli/commands/resume.ts +3 -6
- package/src/cli/commands/status.ts +2 -5
- package/src/cli/commands/stories.ts +2 -5
- package/src/cluster/adapters.ts +3 -12
- package/src/cluster/cluster-http-server.ts +187 -0
- package/src/cluster/heartbeat-manager.ts +112 -0
- package/src/cluster/raft-state-machine.ts +267 -0
- package/src/cluster/runtime.ts +71 -515
- package/src/integrations/jira/sync.ts +157 -215
- package/src/utils/cli-helpers.test.ts +138 -0
- package/src/utils/cli-helpers.ts +61 -0
- package/src/utils/github-cli.ts +4 -0
- package/src/utils/pr-sync.ts +1 -3
- package/src/utils/story-status.test.ts +74 -0
- package/src/utils/story-status.ts +62 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Licensed under the Hungry Ghost Hive License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getCliRuntimeBuilder, resolveRuntimeModelForCli } from '../../../cli-runtimes/index.js';
|
|
5
|
+
import { loadConfig } from '../../../config/loader.js';
|
|
6
|
+
import { getAgentsByType, updateAgent } from '../../../db/queries/agents.js';
|
|
7
|
+
import { createLog } from '../../../db/queries/logs.js';
|
|
8
|
+
import { getRequirementsByStatus } from '../../../db/queries/requirements.js';
|
|
9
|
+
import { getAllTeams } from '../../../db/queries/teams.js';
|
|
10
|
+
import { AgentState } from '../../../state-detectors/types.js';
|
|
11
|
+
import {
|
|
12
|
+
captureTmuxPane,
|
|
13
|
+
isTmuxSessionRunning,
|
|
14
|
+
killTmuxSession,
|
|
15
|
+
spawnTmuxSession,
|
|
16
|
+
} from '../../../tmux/manager.js';
|
|
17
|
+
import type { CLITool } from '../../../utils/cli-commands.js';
|
|
18
|
+
import { findHiveRoot as findHiveRootFromDir, getHivePaths } from '../../../utils/paths.js';
|
|
19
|
+
import { generateTechLeadPrompt } from '../req.js';
|
|
20
|
+
import { detectAgentState } from './agent-monitoring.js';
|
|
21
|
+
import { verboseLogCtx } from './manager-utils.js';
|
|
22
|
+
import { isTechLeadRestartOnCooldown } from './restart-cooldown.js';
|
|
23
|
+
import type { ManagerCheckContext } from './types.js';
|
|
24
|
+
import { TMUX_CAPTURE_LINES_SHORT } from './types.js';
|
|
25
|
+
|
|
26
|
+
const techLeadLastRestartByAgentId = new Map<string, number>();
|
|
27
|
+
|
|
28
|
+
export async function restartStaleTechLead(ctx: ManagerCheckContext): Promise<void> {
|
|
29
|
+
const maxAgeHours = ctx.config.manager.tech_lead_max_age_hours;
|
|
30
|
+
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
|
|
33
|
+
// Phase 1: Read tech lead agents (brief lock)
|
|
34
|
+
const techLeads = await ctx.withDb(async db => {
|
|
35
|
+
const leads = getAgentsByType(db.db, 'tech_lead');
|
|
36
|
+
verboseLogCtx(ctx, `restartStaleTechLead: found ${leads.length} tech lead agent(s)`);
|
|
37
|
+
return leads.map(tl => ({
|
|
38
|
+
id: tl.id,
|
|
39
|
+
tmuxSession: tl.tmux_session,
|
|
40
|
+
cliTool: (tl.cli_tool || 'claude') as CLITool,
|
|
41
|
+
createdAt: tl.created_at,
|
|
42
|
+
}));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Phase 2: Check sessions and restart (tmux I/O outside lock, DB writes under brief lock)
|
|
46
|
+
for (const techLead of techLeads) {
|
|
47
|
+
if (!techLead.tmuxSession) {
|
|
48
|
+
verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} skip=no_tmux_session`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sessionRunning = await isTmuxSessionRunning(techLead.tmuxSession);
|
|
53
|
+
if (!sessionRunning) {
|
|
54
|
+
verboseLogCtx(
|
|
55
|
+
ctx,
|
|
56
|
+
`restartStaleTechLead: techLead=${techLead.id} skip=session_not_running session=${techLead.tmuxSession}`
|
|
57
|
+
);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const createdAt = new Date(techLead.createdAt).getTime();
|
|
62
|
+
const ageMs = now - createdAt;
|
|
63
|
+
const ageHours = ageMs / (60 * 60 * 1000);
|
|
64
|
+
|
|
65
|
+
verboseLogCtx(
|
|
66
|
+
ctx,
|
|
67
|
+
`restartStaleTechLead: techLead=${techLead.id} age=${ageHours.toFixed(2)}h threshold=${maxAgeHours}h`
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (ageMs < maxAgeMs) {
|
|
71
|
+
verboseLogCtx(
|
|
72
|
+
ctx,
|
|
73
|
+
`restartStaleTechLead: techLead=${techLead.id} skip=not_stale remainingMs=${maxAgeMs - ageMs}`
|
|
74
|
+
);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cooldown = isTechLeadRestartOnCooldown(
|
|
79
|
+
techLeadLastRestartByAgentId.get(techLead.id),
|
|
80
|
+
now,
|
|
81
|
+
maxAgeHours
|
|
82
|
+
);
|
|
83
|
+
if (cooldown.onCooldown) {
|
|
84
|
+
verboseLogCtx(
|
|
85
|
+
ctx,
|
|
86
|
+
`restartStaleTechLead: techLead=${techLead.id} skip=cooldown cooldownHours=${cooldown.cooldownHours} remainingMs=${cooldown.remainingMs}`
|
|
87
|
+
);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const output = await captureTmuxPane(techLead.tmuxSession, TMUX_CAPTURE_LINES_SHORT);
|
|
92
|
+
const stateResult = detectAgentState(output, techLead.cliTool);
|
|
93
|
+
|
|
94
|
+
verboseLogCtx(
|
|
95
|
+
ctx,
|
|
96
|
+
`restartStaleTechLead: techLead=${techLead.id} state=${stateResult.state} waiting=${stateResult.isWaiting} needsHuman=${stateResult.needsHuman}`
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
!stateResult.isWaiting ||
|
|
101
|
+
stateResult.needsHuman ||
|
|
102
|
+
stateResult.state === AgentState.THINKING
|
|
103
|
+
) {
|
|
104
|
+
verboseLogCtx(
|
|
105
|
+
ctx,
|
|
106
|
+
`restartStaleTechLead: techLead=${techLead.id} skip=not_safe_state state=${stateResult.state}`
|
|
107
|
+
);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
verboseLogCtx(
|
|
112
|
+
ctx,
|
|
113
|
+
`restartStaleTechLead: techLead=${techLead.id} action=restarting session=${techLead.tmuxSession}`
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Kill the existing session (tmux I/O, no lock)
|
|
117
|
+
await killTmuxSession(techLead.tmuxSession);
|
|
118
|
+
|
|
119
|
+
// Spawn a new session with the same configuration (tmux I/O, no lock)
|
|
120
|
+
const hiveRoot = findHiveRootFromDir(ctx.root);
|
|
121
|
+
if (!hiveRoot) {
|
|
122
|
+
verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} error=hive_root_not_found`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const paths = getHivePaths(hiveRoot);
|
|
127
|
+
const config = loadConfig(paths.hiveDir);
|
|
128
|
+
const agentConfig = config.models.tech_lead;
|
|
129
|
+
const cliTool = agentConfig.cli_tool;
|
|
130
|
+
const safetyMode = agentConfig.safety_mode;
|
|
131
|
+
const model = resolveRuntimeModelForCli(agentConfig.model, cliTool);
|
|
132
|
+
|
|
133
|
+
const runtimeBuilder = getCliRuntimeBuilder(cliTool);
|
|
134
|
+
const commandArgs = runtimeBuilder.buildSpawnCommand(model, safetyMode);
|
|
135
|
+
|
|
136
|
+
// Look up active requirement and teams to provide context to the restarted tech lead
|
|
137
|
+
const initialPrompt = await ctx.withDb(async db => {
|
|
138
|
+
const planningReqs = getRequirementsByStatus(db.db, 'planning');
|
|
139
|
+
const inProgressReqs = getRequirementsByStatus(db.db, 'in_progress');
|
|
140
|
+
const activeReq = planningReqs[0] ?? inProgressReqs[0] ?? null;
|
|
141
|
+
const teams = getAllTeams(db.db);
|
|
142
|
+
|
|
143
|
+
if (activeReq) {
|
|
144
|
+
return generateTechLeadPrompt(
|
|
145
|
+
activeReq.id,
|
|
146
|
+
activeReq.title,
|
|
147
|
+
activeReq.description,
|
|
148
|
+
teams,
|
|
149
|
+
activeReq.godmode === 1,
|
|
150
|
+
activeReq.target_branch || 'main'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return `You are the Tech Lead of Hive, an AI development team orchestrator.
|
|
155
|
+
|
|
156
|
+
You have been restarted to refresh your context. No active requirement is currently being planned.
|
|
157
|
+
|
|
158
|
+
## Next Steps
|
|
159
|
+
|
|
160
|
+
1. Check the current status of the Hive workspace:
|
|
161
|
+
\`\`\`bash
|
|
162
|
+
hive status
|
|
163
|
+
\`\`\`
|
|
164
|
+
|
|
165
|
+
2. Check your inbox for messages from developers:
|
|
166
|
+
\`\`\`bash
|
|
167
|
+
hive msg inbox hive-tech-lead
|
|
168
|
+
\`\`\`
|
|
169
|
+
|
|
170
|
+
3. If there are pending requirements, begin planning them. If all work is complete, monitor for new requirements.`;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await spawnTmuxSession({
|
|
174
|
+
sessionName: techLead.tmuxSession,
|
|
175
|
+
workDir: ctx.root,
|
|
176
|
+
commandArgs,
|
|
177
|
+
initialPrompt,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// DB writes under brief lock
|
|
181
|
+
await ctx.withDb(async db => {
|
|
182
|
+
createLog(db.db, {
|
|
183
|
+
agentId: 'manager',
|
|
184
|
+
eventType: 'AGENT_SPAWNED',
|
|
185
|
+
status: 'info',
|
|
186
|
+
message: `Tech lead ${techLead.id} restarted for context freshness (age: ${ageHours.toFixed(1)}h)`,
|
|
187
|
+
metadata: {
|
|
188
|
+
agent_id: techLead.id,
|
|
189
|
+
tmux_session: techLead.tmuxSession,
|
|
190
|
+
age_hours: ageHours,
|
|
191
|
+
threshold_hours: maxAgeHours,
|
|
192
|
+
restart_reason: 'context_freshness',
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
updateAgent(db.db, techLead.id, {
|
|
196
|
+
status: 'working',
|
|
197
|
+
createdAt: new Date().toISOString(),
|
|
198
|
+
});
|
|
199
|
+
db.save();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
techLeadLastRestartByAgentId.set(techLead.id, now);
|
|
203
|
+
|
|
204
|
+
console.log(
|
|
205
|
+
chalk.green(
|
|
206
|
+
` Tech lead ${techLead.id} restarted for context freshness (age: ${ageHours.toFixed(1)}h)`
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -6,6 +6,7 @@ import { syncStatusForStory } from '../../connectors/project-management/operatio
|
|
|
6
6
|
import { queryAll, queryOne, run, type StoryRow } from '../../db/client.js';
|
|
7
7
|
import { createLog } from '../../db/queries/logs.js';
|
|
8
8
|
import { createStory, getStoryDependencies, updateStory } from '../../db/queries/stories.js';
|
|
9
|
+
import { requireAgentBySession, requireStory } from '../../utils/cli-helpers.js';
|
|
9
10
|
import { withHiveContext, withReadOnlyHiveContext } from '../../utils/with-hive-context.js';
|
|
10
11
|
|
|
11
12
|
export const myStoriesCommand = new Command('my-stories')
|
|
@@ -123,23 +124,10 @@ myStoriesCommand
|
|
|
123
124
|
.action(async (storyId: string, options: { session: string }) => {
|
|
124
125
|
await withHiveContext(async ({ root, db }) => {
|
|
125
126
|
// Find agent by session
|
|
126
|
-
const agent =
|
|
127
|
-
db.db,
|
|
128
|
-
"SELECT id FROM agents WHERE tmux_session = ? AND status != 'terminated'",
|
|
129
|
-
[options.session]
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
if (!agent) {
|
|
133
|
-
console.error(chalk.red(`No agent found with session: ${options.session}`));
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
127
|
+
const agent = requireAgentBySession(db.db, options.session);
|
|
136
128
|
|
|
137
129
|
// Check story exists and is available
|
|
138
|
-
const story =
|
|
139
|
-
if (!story) {
|
|
140
|
-
console.error(chalk.red(`Story not found: ${storyId}`));
|
|
141
|
-
process.exit(1);
|
|
142
|
-
}
|
|
130
|
+
const story = requireStory(db.db, storyId);
|
|
143
131
|
|
|
144
132
|
if (story.assigned_agent_id && story.assigned_agent_id !== agent.id) {
|
|
145
133
|
console.error(chalk.red(`Story already assigned to another agent.`));
|
|
@@ -185,11 +173,7 @@ myStoriesCommand
|
|
|
185
173
|
.description('Mark a story as complete (ready for review)')
|
|
186
174
|
.action(async (storyId: string) => {
|
|
187
175
|
await withHiveContext(async ({ root, db }) => {
|
|
188
|
-
|
|
189
|
-
if (!story) {
|
|
190
|
-
console.error(chalk.red(`Story not found: ${storyId}`));
|
|
191
|
-
process.exit(1);
|
|
192
|
-
}
|
|
176
|
+
requireStory(db.db, storyId);
|
|
193
177
|
|
|
194
178
|
run(
|
|
195
179
|
db.db,
|
|
@@ -245,16 +229,7 @@ myStoriesCommand
|
|
|
245
229
|
}
|
|
246
230
|
|
|
247
231
|
await withHiveContext(async ({ db }) => {
|
|
248
|
-
const agent =
|
|
249
|
-
db.db,
|
|
250
|
-
"SELECT id, team_id FROM agents WHERE tmux_session = ? AND status != 'terminated'",
|
|
251
|
-
[options.session]
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
if (!agent) {
|
|
255
|
-
console.error(chalk.red(`No agent found with session: ${options.session}`));
|
|
256
|
-
process.exit(1);
|
|
257
|
-
}
|
|
232
|
+
const agent = requireAgentBySession(db.db, options.session);
|
|
258
233
|
|
|
259
234
|
if (!agent.team_id) {
|
|
260
235
|
console.error(
|
package/src/cli/commands/pr.ts
CHANGED
|
@@ -16,15 +16,15 @@ import {
|
|
|
16
16
|
getMergeQueue,
|
|
17
17
|
getNextInQueue,
|
|
18
18
|
getOpenPullRequestsByStory,
|
|
19
|
-
getPullRequestById,
|
|
20
19
|
getQueuePosition,
|
|
21
20
|
updatePullRequest,
|
|
22
21
|
} from '../../db/queries/pull-requests.js';
|
|
23
|
-
import {
|
|
22
|
+
import { updateStory } from '../../db/queries/stories.js';
|
|
24
23
|
import { getTeamById } from '../../db/queries/teams.js';
|
|
25
24
|
import { Scheduler } from '../../orchestrator/scheduler.js';
|
|
26
25
|
import { isTmuxSessionRunning, sendToTmuxSession } from '../../tmux/manager.js';
|
|
27
26
|
import { autoMergeApprovedPRs } from '../../utils/auto-merge.js';
|
|
27
|
+
import { requirePullRequest, requireStory } from '../../utils/cli-helpers.js';
|
|
28
28
|
import { markManualMergeRequired } from '../../utils/manual-merge.js';
|
|
29
29
|
import { getExistingPRIdentifiers, syncOpenGitHubPRs } from '../../utils/pr-sync.js';
|
|
30
30
|
import { extractStoryIdFromBranch, normalizeStoryId } from '../../utils/story-id.js';
|
|
@@ -57,11 +57,7 @@ prCommand
|
|
|
57
57
|
|
|
58
58
|
// Get team from story
|
|
59
59
|
let teamId = options.team || null;
|
|
60
|
-
const story =
|
|
61
|
-
if (!story) {
|
|
62
|
-
console.error(chalk.red(`Story not found: ${storyId}`));
|
|
63
|
-
process.exit(1);
|
|
64
|
-
}
|
|
60
|
+
const story = requireStory(db.db, storyId);
|
|
65
61
|
|
|
66
62
|
teamId = story.team_id;
|
|
67
63
|
|
|
@@ -277,11 +273,7 @@ prCommand
|
|
|
277
273
|
.description('View details of a PR')
|
|
278
274
|
.action(async (prId: string) => {
|
|
279
275
|
await withReadOnlyHiveContext(async ({ db }) => {
|
|
280
|
-
const pr =
|
|
281
|
-
if (!pr) {
|
|
282
|
-
console.error(chalk.red(`PR not found: ${prId}`));
|
|
283
|
-
process.exit(1);
|
|
284
|
-
}
|
|
276
|
+
const pr = requirePullRequest(db.db, prId);
|
|
285
277
|
|
|
286
278
|
console.log(chalk.bold(`\nPull Request: ${pr.id}\n`));
|
|
287
279
|
console.log(chalk.gray(`Branch: ${pr.branch_name}`));
|
|
@@ -317,11 +309,7 @@ prCommand
|
|
|
317
309
|
.option('--no-merge', 'Approve without merging (manual merge needed)')
|
|
318
310
|
.action(async (prId: string, options: { notes?: string; from?: string; merge?: boolean }) => {
|
|
319
311
|
await withHiveContext(async ({ root, db }) => {
|
|
320
|
-
const pr =
|
|
321
|
-
if (!pr) {
|
|
322
|
-
console.error(chalk.red(`PR not found: ${prId}`));
|
|
323
|
-
process.exit(1);
|
|
324
|
-
}
|
|
312
|
+
const pr = requirePullRequest(db.db, prId);
|
|
325
313
|
|
|
326
314
|
if (pr.status === 'merged') {
|
|
327
315
|
console.log(chalk.yellow('PR already merged.'));
|
|
@@ -443,11 +431,7 @@ prCommand
|
|
|
443
431
|
.option('--from <session>', 'QA agent session')
|
|
444
432
|
.action(async (prId: string, options: { reason: string; from?: string }) => {
|
|
445
433
|
await withHiveContext(async ({ root, db }) => {
|
|
446
|
-
const pr =
|
|
447
|
-
if (!pr) {
|
|
448
|
-
console.error(chalk.red(`PR not found: ${prId}`));
|
|
449
|
-
process.exit(1);
|
|
450
|
-
}
|
|
434
|
+
const pr = requirePullRequest(db.db, prId);
|
|
451
435
|
|
|
452
436
|
updatePullRequest(db.db, prId, {
|
|
453
437
|
status: 'rejected',
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from '../../connectors/project-management/operations.js';
|
|
10
10
|
import { queryOne } from '../../db/client.js';
|
|
11
11
|
import { createLog } from '../../db/queries/logs.js';
|
|
12
|
-
import
|
|
12
|
+
import { requireStory } from '../../utils/cli-helpers.js';
|
|
13
13
|
import { withHiveContext } from '../../utils/with-hive-context.js';
|
|
14
14
|
|
|
15
15
|
export const progressCommand = new Command('progress')
|
|
@@ -43,12 +43,7 @@ export const progressCommand = new Command('progress')
|
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
const story =
|
|
47
|
-
|
|
48
|
-
if (!story) {
|
|
49
|
-
console.error(chalk.red(`Story not found: ${storyId}`));
|
|
50
|
-
process.exit(1);
|
|
51
|
-
}
|
|
46
|
+
const story = requireStory(db.db, storyId);
|
|
52
47
|
|
|
53
48
|
if (!story.external_subtask_key) {
|
|
54
49
|
console.error(
|
|
@@ -10,10 +10,11 @@ import {
|
|
|
10
10
|
} from '../../cli-runtimes/index.js';
|
|
11
11
|
import { loadConfig } from '../../config/index.js';
|
|
12
12
|
import { withTransaction } from '../../db/client.js';
|
|
13
|
-
import {
|
|
13
|
+
import { getAllAgents, updateAgent, type AgentRow } from '../../db/queries/agents.js';
|
|
14
14
|
import { createLog } from '../../db/queries/logs.js';
|
|
15
15
|
import { getTeamById } from '../../db/queries/teams.js';
|
|
16
16
|
import { isTmuxAvailable, isTmuxSessionRunning, spawnTmuxSession } from '../../tmux/manager.js';
|
|
17
|
+
import { requireAgent } from '../../utils/cli-helpers.js';
|
|
17
18
|
import { withHiveContext } from '../../utils/with-hive-context.js';
|
|
18
19
|
|
|
19
20
|
export const resumeCommand = new Command('resume')
|
|
@@ -34,11 +35,7 @@ export const resumeCommand = new Command('resume')
|
|
|
34
35
|
let agentsToResume: AgentRow[];
|
|
35
36
|
|
|
36
37
|
if (options.agent) {
|
|
37
|
-
const agent =
|
|
38
|
-
if (!agent) {
|
|
39
|
-
console.error(chalk.red(`Agent not found: ${options.agent}`));
|
|
40
|
-
process.exit(1);
|
|
41
|
-
}
|
|
38
|
+
const agent = requireAgent(db.db, options.agent);
|
|
42
39
|
if (agent.status === 'terminated') {
|
|
43
40
|
console.error(chalk.red('Cannot resume a terminated agent'));
|
|
44
41
|
process.exit(1);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getStoryDependencies,
|
|
14
14
|
} from '../../db/queries/stories.js';
|
|
15
15
|
import { getAllTeams, getTeamByName } from '../../db/queries/teams.js';
|
|
16
|
+
import { requireStory } from '../../utils/cli-helpers.js';
|
|
16
17
|
import { statusColor } from '../../utils/logger.js';
|
|
17
18
|
import { withReadOnlyHiveContext } from '../../utils/with-hive-context.js';
|
|
18
19
|
|
|
@@ -237,11 +238,7 @@ function showTeamStatus(db: import('sql.js').Database, teamName: string, json?:
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
function showStoryStatus(db: import('sql.js').Database, storyId: string, json?: boolean): void {
|
|
240
|
-
const story =
|
|
241
|
-
if (!story) {
|
|
242
|
-
console.error(chalk.red(`Story not found: ${storyId}`));
|
|
243
|
-
process.exit(1);
|
|
244
|
-
}
|
|
241
|
+
const story = requireStory(db, storyId);
|
|
245
242
|
|
|
246
243
|
const dependencies = getStoryDependencies(db, story.id);
|
|
247
244
|
const logs = getLogsByStory(db, story.id).slice(0, 10);
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
updateStory,
|
|
13
13
|
type StoryStatus,
|
|
14
14
|
} from '../../db/queries/stories.js';
|
|
15
|
+
import { requireStory } from '../../utils/cli-helpers.js';
|
|
15
16
|
import { statusColor } from '../../utils/logger.js';
|
|
16
17
|
import { withHiveContext, withReadOnlyHiveContext } from '../../utils/with-hive-context.js';
|
|
17
18
|
|
|
@@ -144,11 +145,7 @@ storiesCommand
|
|
|
144
145
|
.description('Show story details')
|
|
145
146
|
.action(async (storyId: string) => {
|
|
146
147
|
await withReadOnlyHiveContext(async ({ db }) => {
|
|
147
|
-
const story =
|
|
148
|
-
if (!story) {
|
|
149
|
-
console.error(chalk.red(`Story not found: ${storyId}`));
|
|
150
|
-
process.exit(1);
|
|
151
|
-
}
|
|
148
|
+
const story = requireStory(db.db, storyId);
|
|
152
149
|
|
|
153
150
|
const dependencies = getStoryDependencies(db.db, story.id);
|
|
154
151
|
|
package/src/cluster/adapters.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Licensed under the Hungry Ghost Hive License. See LICENSE.
|
|
2
2
|
|
|
3
3
|
import { run } from '../db/client.js';
|
|
4
|
-
import
|
|
4
|
+
import { STORY_STATUS_ORDER } from '../utils/story-status.js';
|
|
5
|
+
import type { ReplicatedTable, TableAdapter } from './types.js';
|
|
5
6
|
import {
|
|
6
7
|
asNullableNumber,
|
|
7
8
|
asNullableString,
|
|
@@ -12,17 +13,7 @@ import {
|
|
|
12
13
|
toAgentLogPayload,
|
|
13
14
|
} from './utils.js';
|
|
14
15
|
|
|
15
|
-
export
|
|
16
|
-
'draft',
|
|
17
|
-
'estimated',
|
|
18
|
-
'planned',
|
|
19
|
-
'in_progress',
|
|
20
|
-
'review',
|
|
21
|
-
'qa',
|
|
22
|
-
'qa_failed',
|
|
23
|
-
'pr_submitted',
|
|
24
|
-
'merged',
|
|
25
|
-
];
|
|
16
|
+
export { STORY_STATUS_ORDER };
|
|
26
17
|
|
|
27
18
|
export const REPLICATED_TABLES: TableAdapter[] = [
|
|
28
19
|
{
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Licensed under the Hungry Ghost Hive License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
|
4
|
+
import type { ClusterConfig } from '../config/schema.js';
|
|
5
|
+
import type { ClusterEvent, VersionVector } from './replication.js';
|
|
6
|
+
|
|
7
|
+
interface DeltaRequest {
|
|
8
|
+
version_vector: VersionVector;
|
|
9
|
+
limit?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface DeltaResponse {
|
|
13
|
+
events: ClusterEvent[];
|
|
14
|
+
version_vector: VersionVector;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_CLUSTER_REQUEST_BODY_BYTES = 1024 * 1024; // 1 MiB
|
|
18
|
+
|
|
19
|
+
export interface ClusterHttpHandlers {
|
|
20
|
+
getStatus: () => unknown;
|
|
21
|
+
handleVoteRequest: (body: unknown) => unknown;
|
|
22
|
+
handleHeartbeat: (body: unknown) => unknown;
|
|
23
|
+
getDeltaFromCache: (vector: VersionVector, limit: number) => ClusterEvent[];
|
|
24
|
+
getVersionVectorCache: () => VersionVector;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ClusterHttpServer {
|
|
28
|
+
private server: Server | null = null;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly config: ClusterConfig,
|
|
32
|
+
private readonly handlers: ClusterHttpHandlers
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
async startServer(): Promise<void> {
|
|
36
|
+
this.server = createServer((req, res) => {
|
|
37
|
+
void this.handleHttpRequest(req, res);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await new Promise<void>((resolve, reject) => {
|
|
41
|
+
if (!this.server) return reject(new Error('Cluster HTTP server not initialized'));
|
|
42
|
+
|
|
43
|
+
this.server.once('error', reject);
|
|
44
|
+
this.server.listen(this.config.listen_port, this.config.listen_host, () => {
|
|
45
|
+
this.server?.removeListener('error', reject);
|
|
46
|
+
resolve();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async stopServer(): Promise<void> {
|
|
52
|
+
if (this.server) {
|
|
53
|
+
await new Promise<void>(resolve => {
|
|
54
|
+
this.server?.close(() => resolve());
|
|
55
|
+
});
|
|
56
|
+
this.server = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
if (!this.authorize(req)) {
|
|
63
|
+
sendJson(res, 401, { error: 'Unauthorized' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const method = req.method || 'GET';
|
|
68
|
+
const path = req.url?.split('?')[0] || '/';
|
|
69
|
+
|
|
70
|
+
if (method === 'GET' && path === '/cluster/v1/status') {
|
|
71
|
+
sendJson(res, 200, this.handlers.getStatus());
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (method === 'POST' && path === '/cluster/v1/election/request-vote') {
|
|
76
|
+
const body = await readJsonBody(req);
|
|
77
|
+
const response = this.handlers.handleVoteRequest(body);
|
|
78
|
+
sendJson(res, 200, response);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (method === 'POST' && path === '/cluster/v1/election/heartbeat') {
|
|
83
|
+
const body = await readJsonBody(req);
|
|
84
|
+
const response = this.handlers.handleHeartbeat(body);
|
|
85
|
+
sendJson(res, 200, response);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (method === 'POST' && path === '/cluster/v1/events/delta') {
|
|
90
|
+
const body = (await readJsonBody(req)) as Partial<DeltaRequest>;
|
|
91
|
+
const vector = toVersionVector(body.version_vector);
|
|
92
|
+
const limit =
|
|
93
|
+
typeof body.limit === 'number' && Number.isFinite(body.limit) && body.limit > 0
|
|
94
|
+
? Math.floor(body.limit)
|
|
95
|
+
: 2000;
|
|
96
|
+
|
|
97
|
+
const events = this.handlers.getDeltaFromCache(vector, limit);
|
|
98
|
+
sendJson(res, 200, {
|
|
99
|
+
events,
|
|
100
|
+
version_vector: this.handlers.getVersionVectorCache(),
|
|
101
|
+
} satisfies DeltaResponse);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof HttpRequestError) {
|
|
108
|
+
sendJson(res, error.statusCode, { error: error.message });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
113
|
+
sendJson(res, 500, { error: message });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private authorize(req: IncomingMessage): boolean {
|
|
118
|
+
if (!this.config.auth_token) return true;
|
|
119
|
+
|
|
120
|
+
const authHeader = req.headers.authorization;
|
|
121
|
+
if (!authHeader) return false;
|
|
122
|
+
|
|
123
|
+
const expected = `Bearer ${this.config.auth_token}`;
|
|
124
|
+
return authHeader === expected;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
class HttpRequestError extends Error {
|
|
129
|
+
constructor(
|
|
130
|
+
public readonly statusCode: number,
|
|
131
|
+
message: string
|
|
132
|
+
) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.name = 'HttpRequestError';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function readJsonBody(
|
|
139
|
+
req: IncomingMessage,
|
|
140
|
+
maxBytes: number = MAX_CLUSTER_REQUEST_BODY_BYTES
|
|
141
|
+
): Promise<unknown> {
|
|
142
|
+
const chunks: Buffer[] = [];
|
|
143
|
+
let totalBytes = 0;
|
|
144
|
+
|
|
145
|
+
for await (const chunk of req) {
|
|
146
|
+
const normalizedChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
147
|
+
totalBytes += normalizedChunk.length;
|
|
148
|
+
|
|
149
|
+
if (totalBytes > maxBytes) {
|
|
150
|
+
throw new HttpRequestError(413, `Payload too large (max ${maxBytes} bytes)`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
chunks.push(normalizedChunk);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (chunks.length === 0) return {};
|
|
157
|
+
|
|
158
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
159
|
+
if (!raw.trim()) return {};
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(raw) as unknown;
|
|
163
|
+
} catch {
|
|
164
|
+
throw new HttpRequestError(400, 'Invalid JSON payload');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sendJson(res: ServerResponse, statusCode: number, body: unknown): void {
|
|
169
|
+
res.statusCode = statusCode;
|
|
170
|
+
res.setHeader('Content-Type', 'application/json');
|
|
171
|
+
res.end(JSON.stringify(body));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toVersionVector(input: unknown): VersionVector {
|
|
175
|
+
if (!input || typeof input !== 'object') return {};
|
|
176
|
+
|
|
177
|
+
const vector: VersionVector = {};
|
|
178
|
+
|
|
179
|
+
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
|
180
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
181
|
+
if (Number.isFinite(num) && num >= 0) {
|
|
182
|
+
vector[key] = Math.floor(num);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return vector;
|
|
187
|
+
}
|