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.
Files changed (133) hide show
  1. package/dist/cli/commands/agents.d.ts.map +1 -1
  2. package/dist/cli/commands/agents.js +4 -11
  3. package/dist/cli/commands/agents.js.map +1 -1
  4. package/dist/cli/commands/approach.d.ts.map +1 -1
  5. package/dist/cli/commands/approach.js +2 -6
  6. package/dist/cli/commands/approach.js.map +1 -1
  7. package/dist/cli/commands/init.d.ts.map +1 -1
  8. package/dist/cli/commands/init.js +9 -0
  9. package/dist/cli/commands/init.js.map +1 -1
  10. package/dist/cli/commands/init.test.js +3 -0
  11. package/dist/cli/commands/init.test.js.map +1 -1
  12. package/dist/cli/commands/manager/index.d.ts +2 -27
  13. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  14. package/dist/cli/commands/manager/index.js +23 -1519
  15. package/dist/cli/commands/manager/index.js.map +1 -1
  16. package/dist/cli/commands/manager/manager-utils.d.ts +9 -0
  17. package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -0
  18. package/dist/cli/commands/manager/manager-utils.js +49 -0
  19. package/dist/cli/commands/manager/manager-utils.js.map +1 -0
  20. package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts +7 -0
  21. package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts.map +1 -0
  22. package/dist/cli/commands/manager/pr-sync-orchestrator.js +537 -0
  23. package/dist/cli/commands/manager/pr-sync-orchestrator.js.map +1 -0
  24. package/dist/cli/commands/manager/qa-review-handler.d.ts +15 -0
  25. package/dist/cli/commands/manager/qa-review-handler.d.ts.map +1 -0
  26. package/dist/cli/commands/manager/qa-review-handler.js +290 -0
  27. package/dist/cli/commands/manager/qa-review-handler.js.map +1 -0
  28. package/dist/cli/commands/manager/stuck-story-helpers.d.ts +32 -0
  29. package/dist/cli/commands/manager/stuck-story-helpers.d.ts.map +1 -0
  30. package/dist/cli/commands/manager/stuck-story-helpers.js +163 -0
  31. package/dist/cli/commands/manager/stuck-story-helpers.js.map +1 -0
  32. package/dist/cli/commands/manager/stuck-story-processor.d.ts +8 -0
  33. package/dist/cli/commands/manager/stuck-story-processor.d.ts.map +1 -0
  34. package/dist/cli/commands/manager/stuck-story-processor.js +392 -0
  35. package/dist/cli/commands/manager/stuck-story-processor.js.map +1 -0
  36. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts +3 -0
  37. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -0
  38. package/dist/cli/commands/manager/tech-lead-lifecycle.js +141 -0
  39. package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -0
  40. package/dist/cli/commands/my-stories.d.ts.map +1 -1
  41. package/dist/cli/commands/my-stories.js +5 -20
  42. package/dist/cli/commands/my-stories.js.map +1 -1
  43. package/dist/cli/commands/pr.js +7 -22
  44. package/dist/cli/commands/pr.js.map +1 -1
  45. package/dist/cli/commands/progress.d.ts.map +1 -1
  46. package/dist/cli/commands/progress.js +2 -5
  47. package/dist/cli/commands/progress.js.map +1 -1
  48. package/dist/cli/commands/resume.d.ts.map +1 -1
  49. package/dist/cli/commands/resume.js +3 -6
  50. package/dist/cli/commands/resume.js.map +1 -1
  51. package/dist/cli/commands/status.d.ts.map +1 -1
  52. package/dist/cli/commands/status.js +2 -5
  53. package/dist/cli/commands/status.js.map +1 -1
  54. package/dist/cli/commands/stories.d.ts.map +1 -1
  55. package/dist/cli/commands/stories.js +2 -5
  56. package/dist/cli/commands/stories.js.map +1 -1
  57. package/dist/cluster/adapters.d.ts +3 -2
  58. package/dist/cluster/adapters.d.ts.map +1 -1
  59. package/dist/cluster/adapters.js +2 -11
  60. package/dist/cluster/adapters.js.map +1 -1
  61. package/dist/cluster/cluster-http-server.d.ts +20 -0
  62. package/dist/cluster/cluster-http-server.d.ts.map +1 -0
  63. package/dist/cluster/cluster-http-server.js +140 -0
  64. package/dist/cluster/cluster-http-server.js.map +1 -0
  65. package/dist/cluster/heartbeat-manager.d.ts +24 -0
  66. package/dist/cluster/heartbeat-manager.d.ts.map +1 -0
  67. package/dist/cluster/heartbeat-manager.js +74 -0
  68. package/dist/cluster/heartbeat-manager.js.map +1 -0
  69. package/dist/cluster/raft-state-machine.d.ts +48 -0
  70. package/dist/cluster/raft-state-machine.d.ts.map +1 -0
  71. package/dist/cluster/raft-state-machine.js +207 -0
  72. package/dist/cluster/raft-state-machine.js.map +1 -0
  73. package/dist/cluster/runtime.d.ts +5 -29
  74. package/dist/cluster/runtime.d.ts.map +1 -1
  75. package/dist/cluster/runtime.js +58 -406
  76. package/dist/cluster/runtime.js.map +1 -1
  77. package/dist/integrations/jira/sync.d.ts +2 -5
  78. package/dist/integrations/jira/sync.d.ts.map +1 -1
  79. package/dist/integrations/jira/sync.js +116 -178
  80. package/dist/integrations/jira/sync.js.map +1 -1
  81. package/dist/utils/cli-helpers.d.ts +19 -0
  82. package/dist/utils/cli-helpers.d.ts.map +1 -0
  83. package/dist/utils/cli-helpers.js +51 -0
  84. package/dist/utils/cli-helpers.js.map +1 -0
  85. package/dist/utils/cli-helpers.test.d.ts +2 -0
  86. package/dist/utils/cli-helpers.test.d.ts.map +1 -0
  87. package/dist/utils/cli-helpers.test.js +100 -0
  88. package/dist/utils/cli-helpers.test.js.map +1 -0
  89. package/dist/utils/github-cli.d.ts +3 -0
  90. package/dist/utils/github-cli.d.ts.map +1 -0
  91. package/dist/utils/github-cli.js +4 -0
  92. package/dist/utils/github-cli.js.map +1 -0
  93. package/dist/utils/pr-sync.d.ts.map +1 -1
  94. package/dist/utils/pr-sync.js +1 -2
  95. package/dist/utils/pr-sync.js.map +1 -1
  96. package/dist/utils/story-status.d.ts +19 -0
  97. package/dist/utils/story-status.d.ts.map +1 -0
  98. package/dist/utils/story-status.js +58 -0
  99. package/dist/utils/story-status.js.map +1 -0
  100. package/dist/utils/story-status.test.d.ts +2 -0
  101. package/dist/utils/story-status.test.d.ts.map +1 -0
  102. package/dist/utils/story-status.test.js +65 -0
  103. package/dist/utils/story-status.test.js.map +1 -0
  104. package/package.json +1 -1
  105. package/src/cli/commands/agents.ts +3 -11
  106. package/src/cli/commands/approach.ts +2 -7
  107. package/src/cli/commands/init.test.ts +4 -0
  108. package/src/cli/commands/init.ts +9 -0
  109. package/src/cli/commands/manager/index.ts +166 -2236
  110. package/src/cli/commands/manager/manager-utils.ts +85 -0
  111. package/src/cli/commands/manager/pr-sync-orchestrator.ts +659 -0
  112. package/src/cli/commands/manager/qa-review-handler.ts +399 -0
  113. package/src/cli/commands/manager/stuck-story-helpers.ts +255 -0
  114. package/src/cli/commands/manager/stuck-story-processor.ts +604 -0
  115. package/src/cli/commands/manager/tech-lead-lifecycle.ts +210 -0
  116. package/src/cli/commands/my-stories.ts +5 -30
  117. package/src/cli/commands/pr.ts +6 -22
  118. package/src/cli/commands/progress.ts +2 -7
  119. package/src/cli/commands/resume.ts +3 -6
  120. package/src/cli/commands/status.ts +2 -5
  121. package/src/cli/commands/stories.ts +2 -5
  122. package/src/cluster/adapters.ts +3 -12
  123. package/src/cluster/cluster-http-server.ts +187 -0
  124. package/src/cluster/heartbeat-manager.ts +112 -0
  125. package/src/cluster/raft-state-machine.ts +267 -0
  126. package/src/cluster/runtime.ts +71 -515
  127. package/src/integrations/jira/sync.ts +157 -215
  128. package/src/utils/cli-helpers.test.ts +138 -0
  129. package/src/utils/cli-helpers.ts +61 -0
  130. package/src/utils/github-cli.ts +4 -0
  131. package/src/utils/pr-sync.ts +1 -3
  132. package/src/utils/story-status.test.ts +74 -0
  133. package/src/utils/story-status.ts +62 -0
@@ -0,0 +1,604 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import chalk from 'chalk';
4
+ import { execa } from 'execa';
5
+ import { join } from 'path';
6
+ import { syncStatusForStory } from '../../../connectors/project-management/operations.js';
7
+ import type { StoryRow } from '../../../db/client.js';
8
+ import { queryAll, withTransaction } from '../../../db/client.js';
9
+ import { getAgentById, type getAllAgents } from '../../../db/queries/agents.js';
10
+ import { createLog } from '../../../db/queries/logs.js';
11
+ import {
12
+ createPullRequest,
13
+ getOpenPullRequestsByStory,
14
+ } from '../../../db/queries/pull-requests.js';
15
+ import { getStoriesByStatus, updateStory } from '../../../db/queries/stories.js';
16
+ import { AgentState } from '../../../state-detectors/types.js';
17
+ import { captureTmuxPane } from '../../../tmux/manager.js';
18
+ import type { CLITool } from '../../../utils/cli-commands.js';
19
+ import { agentStates, detectAgentState } from './agent-monitoring.js';
20
+ import { assessCompletionFromOutput } from './done-intelligence.js';
21
+ import {
22
+ getMaxStuckNudgesPerStory,
23
+ getScreenStaticInactivityThresholdMs,
24
+ sendManagerNudge,
25
+ verboseLogCtx,
26
+ } from './manager-utils.js';
27
+ import { findSessionForAgent } from './session-resolution.js';
28
+ import {
29
+ clearHumanIntervention,
30
+ getSessionStaticUnchangedForMs,
31
+ isClassifierTimeoutReason,
32
+ markClassifierTimeoutForHumanIntervention,
33
+ markDoneFalseForHumanIntervention,
34
+ shouldDeferStuckReminderUntilStaticWindow,
35
+ shouldIncludeProgressUpdates,
36
+ shouldTreatUnknownAsStuckWaiting,
37
+ } from './stuck-story-helpers.js';
38
+ import type { ManagerCheckContext } from './types.js';
39
+ import { TMUX_CAPTURE_LINES_SHORT } from './types.js';
40
+
41
+ const DONE_INFERENCE_CONFIDENCE_THRESHOLD = 0.82;
42
+
43
+ export async function nudgeStuckStories(ctx: ManagerCheckContext): Promise<void> {
44
+ const stuckThresholdMs = Math.max(1, ctx.config.manager.stuck_threshold_ms);
45
+ const staticInactivityThresholdMs = getScreenStaticInactivityThresholdMs(ctx.config);
46
+ const maxStuckNudgesPerStory = getMaxStuckNudgesPerStory(ctx.config);
47
+ const waitingNudgeCooldownMs = Math.max(
48
+ ctx.config.manager.nudge_cooldown_ms,
49
+ staticInactivityThresholdMs
50
+ );
51
+ const staleUpdatedAt = new Date(Date.now() - stuckThresholdMs).toISOString();
52
+
53
+ // Phase 1: Read stuck stories and agents (brief lock)
54
+ const candidates = await ctx.withDb(async db => {
55
+ const stuckStories = queryAll<StoryRow>(
56
+ db.db,
57
+ `SELECT * FROM stories
58
+ WHERE status = 'in_progress'
59
+ AND updated_at < ?`,
60
+ [staleUpdatedAt]
61
+ ).filter(story => !['merged', 'completed'].includes(story.status));
62
+ verboseLogCtx(
63
+ ctx,
64
+ `nudgeStuckStories: candidates=${stuckStories.length}, staleBefore=${staleUpdatedAt}, thresholdMs=${stuckThresholdMs}`
65
+ );
66
+
67
+ const result: Array<{
68
+ story: StoryRow;
69
+ agent: ReturnType<typeof getAllAgents>[number];
70
+ sessionName: string;
71
+ cliTool: CLITool;
72
+ }> = [];
73
+
74
+ for (const story of stuckStories) {
75
+ verboseLogCtx(ctx, `nudgeStuckStories: evaluating story=${story.id}`);
76
+ if (!story.assigned_agent_id) {
77
+ verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=no_assigned_agent`);
78
+ continue;
79
+ }
80
+ const agent = getAgentById(db.db, story.assigned_agent_id);
81
+ if (!agent) {
82
+ verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=missing_agent`);
83
+ continue;
84
+ }
85
+ const agentSession = findSessionForAgent(ctx.hiveSessions, agent);
86
+ if (!agentSession) {
87
+ verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=no_agent_session`);
88
+ continue;
89
+ }
90
+ result.push({
91
+ story,
92
+ agent,
93
+ sessionName: agentSession.name,
94
+ cliTool: (agent.cli_tool || 'claude') as CLITool,
95
+ });
96
+ }
97
+ return result;
98
+ });
99
+
100
+ // Phase 2: Tmux captures, AI classifier, nudges (no lock held)
101
+ for (const candidate of candidates) {
102
+ const { story, agent, sessionName, cliTool } = candidate;
103
+ const now = Date.now();
104
+ verboseLogCtx(
105
+ ctx,
106
+ `nudgeStuckStories: story=${story.id} session=${sessionName} cli=${cliTool}`
107
+ );
108
+
109
+ const trackedState = agentStates.get(sessionName);
110
+ if (
111
+ trackedState &&
112
+ [
113
+ AgentState.ASKING_QUESTION,
114
+ AgentState.AWAITING_SELECTION,
115
+ AgentState.PLAN_APPROVAL,
116
+ AgentState.PERMISSION_REQUIRED,
117
+ AgentState.USER_DECLINED,
118
+ ].includes(trackedState.lastState)
119
+ ) {
120
+ verboseLogCtx(
121
+ ctx,
122
+ `nudgeStuckStories: story=${story.id} skip=waiting_for_human state=${trackedState.lastState}`
123
+ );
124
+ continue;
125
+ }
126
+ if (trackedState && now - trackedState.lastNudgeTime < waitingNudgeCooldownMs) {
127
+ verboseLogCtx(
128
+ ctx,
129
+ `nudgeStuckStories: story=${story.id} skip=nudge_to_ai_window remainingMs=${waitingNudgeCooldownMs - (now - trackedState.lastNudgeTime)}`
130
+ );
131
+ continue;
132
+ }
133
+
134
+ const output = await captureTmuxPane(sessionName, TMUX_CAPTURE_LINES_SHORT);
135
+ const stateResult = detectAgentState(output, cliTool);
136
+ verboseLogCtx(
137
+ ctx,
138
+ `nudgeStuckStories: story=${story.id} detected state=${stateResult.state}, waiting=${stateResult.isWaiting}, needsHuman=${stateResult.needsHuman}`
139
+ );
140
+ if (stateResult.needsHuman) {
141
+ verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=needs_human`);
142
+ continue;
143
+ }
144
+ const sessionUnchangedForMs = getSessionStaticUnchangedForMs(sessionName, now);
145
+ const unknownLooksStuck = shouldTreatUnknownAsStuckWaiting({
146
+ state: stateResult.state,
147
+ isWaiting: stateResult.isWaiting,
148
+ sessionUnchangedForMs,
149
+ staticInactivityThresholdMs,
150
+ });
151
+ if (stateResult.state === AgentState.THINKING) {
152
+ if (trackedState && (trackedState.storyStuckNudgeCount || 0) > 0) {
153
+ trackedState.storyStuckNudgeCount = 0;
154
+ }
155
+ clearHumanIntervention(sessionName);
156
+ verboseLogCtx(
157
+ ctx,
158
+ `nudgeStuckStories: story=${story.id} skip=thinking state=${stateResult.state}`
159
+ );
160
+ continue;
161
+ }
162
+ if (!stateResult.isWaiting && !unknownLooksStuck) {
163
+ if (trackedState && (trackedState.storyStuckNudgeCount || 0) > 0) {
164
+ trackedState.storyStuckNudgeCount = 0;
165
+ }
166
+ clearHumanIntervention(sessionName);
167
+ verboseLogCtx(
168
+ ctx,
169
+ `nudgeStuckStories: story=${story.id} skip=not_waiting state=${stateResult.state}`
170
+ );
171
+ continue;
172
+ }
173
+ if (unknownLooksStuck) {
174
+ verboseLogCtx(
175
+ ctx,
176
+ `nudgeStuckStories: story=${story.id} action=unknown_state_stuck_heuristic unchangedMs=${sessionUnchangedForMs}`
177
+ );
178
+ }
179
+
180
+ if (
181
+ shouldDeferStuckReminderUntilStaticWindow({
182
+ state: stateResult.state,
183
+ sessionUnchangedForMs,
184
+ staticInactivityThresholdMs,
185
+ })
186
+ ) {
187
+ verboseLogCtx(
188
+ ctx,
189
+ `nudgeStuckStories: story=${story.id} skip=done_inference_static_window remainingMs=${staticInactivityThresholdMs - sessionUnchangedForMs}`
190
+ );
191
+ continue;
192
+ } else {
193
+ const completionAssessment = await assessCompletionFromOutput(
194
+ ctx.config,
195
+ sessionName,
196
+ story.id,
197
+ output
198
+ );
199
+ const aiSaysDone =
200
+ completionAssessment.done &&
201
+ completionAssessment.confidence >= DONE_INFERENCE_CONFIDENCE_THRESHOLD;
202
+ verboseLogCtx(
203
+ ctx,
204
+ `nudgeStuckStories: story=${story.id} doneInference done=${completionAssessment.done}, confidence=${completionAssessment.confidence.toFixed(2)}, aiSaysDone=${aiSaysDone}, reason=${completionAssessment.reason}`
205
+ );
206
+ if (isClassifierTimeoutReason(completionAssessment.reason)) {
207
+ await markClassifierTimeoutForHumanIntervention(
208
+ ctx,
209
+ sessionName,
210
+ story.id,
211
+ completionAssessment.reason,
212
+ agent.id
213
+ );
214
+ verboseLogCtx(
215
+ ctx,
216
+ `nudgeStuckStories: story=${story.id} action=classifier_timeout_escalation session=${sessionName}`
217
+ );
218
+ continue;
219
+ }
220
+ clearHumanIntervention(sessionName);
221
+
222
+ if (aiSaysDone) {
223
+ const progressed = await autoProgressDoneStory(
224
+ ctx,
225
+ story,
226
+ agent,
227
+ sessionName,
228
+ completionAssessment.reason,
229
+ completionAssessment.confidence
230
+ );
231
+ if (progressed) {
232
+ ctx.counters.autoProgressed++;
233
+ verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} action=auto_progressed`);
234
+ continue;
235
+ }
236
+ verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} auto_progress_failed`);
237
+ } else {
238
+ const stuckNudgesSent = trackedState?.storyStuckNudgeCount || 0;
239
+ if (stuckNudgesSent >= maxStuckNudgesPerStory) {
240
+ await markDoneFalseForHumanIntervention(
241
+ ctx,
242
+ sessionName,
243
+ story.id,
244
+ completionAssessment.reason,
245
+ agent.id
246
+ );
247
+ verboseLogCtx(
248
+ ctx,
249
+ `nudgeStuckStories: story=${story.id} action=done_false_escalation session=${sessionName}`
250
+ );
251
+ continue;
252
+ }
253
+ }
254
+ }
255
+
256
+ const stuckNudgesSent = trackedState?.storyStuckNudgeCount || 0;
257
+ if (stuckNudgesSent >= maxStuckNudgesPerStory) {
258
+ verboseLogCtx(
259
+ ctx,
260
+ `nudgeStuckStories: story=${story.id} skip=stuck_nudge_limit reached=${stuckNudgesSent}/${maxStuckNudgesPerStory}`
261
+ );
262
+ continue;
263
+ }
264
+
265
+ if (stateResult.state === AgentState.WORK_COMPLETE) {
266
+ verboseLogCtx(
267
+ ctx,
268
+ `nudgeStuckStories: story=${story.id} action=mandatory_completion_signal session=${sessionName}`
269
+ );
270
+ const completionSignalLines = [
271
+ `# MANDATORY COMPLETION SIGNAL: execute now for ${story.id}`,
272
+ `hive pr submit -b $(git rev-parse --abbrev-ref HEAD) -s ${story.id} --from ${sessionName}`,
273
+ `hive my-stories complete ${story.id}`,
274
+ ];
275
+ if (shouldIncludeProgressUpdates(ctx.config)) {
276
+ completionSignalLines.push(
277
+ `hive progress ${story.id} -m "PR submitted to merge queue" --from ${sessionName} --done`
278
+ );
279
+ } else {
280
+ completionSignalLines.push(
281
+ '# project_management.provider is none; skip hive progress in this workspace.'
282
+ );
283
+ }
284
+ completionSignalLines.push(
285
+ '# Do not stop at a summary. Completion requires the commands above.'
286
+ );
287
+
288
+ await sendManagerNudge(ctx, sessionName, completionSignalLines.join('\n'));
289
+ ctx.counters.nudged++;
290
+ if (trackedState) {
291
+ trackedState.lastNudgeTime = now;
292
+ trackedState.storyStuckNudgeCount = (trackedState.storyStuckNudgeCount || 0) + 1;
293
+ } else {
294
+ agentStates.set(sessionName, {
295
+ lastState: stateResult.state,
296
+ lastStateChangeTime: now,
297
+ lastNudgeTime: now,
298
+ storyStuckNudgeCount: 1,
299
+ });
300
+ }
301
+ continue;
302
+ }
303
+
304
+ verboseLogCtx(
305
+ ctx,
306
+ `nudgeStuckStories: story=${story.id} action=stuck_reminder session=${sessionName}`
307
+ );
308
+ await sendManagerNudge(
309
+ ctx,
310
+ sessionName,
311
+ `# REMINDER: Story ${story.id} has been in progress for a while.
312
+ # If stuck, escalate to your Senior or Tech Lead.
313
+ # If done, submit your PR: hive pr submit -b $(git rev-parse --abbrev-ref HEAD) -s ${story.id} --from ${sessionName}
314
+ # Then mark complete: hive my-stories complete ${story.id}`
315
+ );
316
+ ctx.counters.nudged++;
317
+ if (trackedState) {
318
+ trackedState.lastNudgeTime = now;
319
+ trackedState.storyStuckNudgeCount = (trackedState.storyStuckNudgeCount || 0) + 1;
320
+ } else {
321
+ agentStates.set(sessionName, {
322
+ lastState: stateResult.state,
323
+ lastStateChangeTime: now,
324
+ lastNudgeTime: now,
325
+ storyStuckNudgeCount: 1,
326
+ });
327
+ }
328
+ }
329
+ }
330
+
331
+ export async function autoProgressDoneStory(
332
+ ctx: ManagerCheckContext,
333
+ story: StoryRow,
334
+ agent: ReturnType<typeof getAllAgents>[number],
335
+ sessionName: string,
336
+ reason: string,
337
+ confidence: number
338
+ ): Promise<boolean> {
339
+ verboseLogCtx(
340
+ ctx,
341
+ `autoProgressDoneStory: story=${story.id}, session=${sessionName}, confidence=${confidence.toFixed(2)}`
342
+ );
343
+
344
+ // Resolve branch name outside lock (involves git operations)
345
+ const branch = await resolveStoryBranchName(ctx.root, story, agent, msg =>
346
+ verboseLogCtx(ctx, `resolveStoryBranchName: story=${story.id} ${msg}`)
347
+ );
348
+
349
+ // DB operations under brief lock
350
+ const action = await ctx.withDb(async (db, scheduler) => {
351
+ const openPRs = getOpenPullRequestsByStory(db.db, story.id);
352
+ verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id}, openPRs=${openPRs.length}`);
353
+ if (openPRs.length > 0) {
354
+ if (story.status !== 'pr_submitted') {
355
+ updateStory(db.db, story.id, { status: 'pr_submitted' });
356
+ createLog(db.db, {
357
+ agentId: 'manager',
358
+ storyId: story.id,
359
+ eventType: 'STORY_PROGRESS_UPDATE',
360
+ message: `Auto-progressed ${story.id} to pr_submitted (existing PR detected)`,
361
+ metadata: {
362
+ session_name: sessionName,
363
+ recovery: 'done_inference_existing_pr',
364
+ reason,
365
+ confidence,
366
+ open_pr_count: openPRs.length,
367
+ },
368
+ });
369
+ db.save();
370
+ await syncStatusForStory(ctx.root, db.db, story.id, 'pr_submitted');
371
+ verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} status moved to pr_submitted`);
372
+ }
373
+ return 'existing_pr' as const;
374
+ }
375
+
376
+ if (!branch) {
377
+ verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} action=failed_no_branch`);
378
+ return 'no_branch' as const;
379
+ }
380
+
381
+ await withTransaction(
382
+ db.db,
383
+ () => {
384
+ updateStory(db.db, story.id, { status: 'pr_submitted', branchName: branch });
385
+ createPullRequest(db.db, {
386
+ storyId: story.id,
387
+ teamId: story.team_id || null,
388
+ branchName: branch,
389
+ submittedBy: sessionName,
390
+ });
391
+ createLog(db.db, {
392
+ agentId: 'manager',
393
+ storyId: story.id,
394
+ eventType: 'PR_SUBMITTED',
395
+ message: `Auto-submitted PR for ${story.id} after AI completion inference`,
396
+ metadata: {
397
+ session_name: sessionName,
398
+ recovery: 'done_inference_auto_submit',
399
+ reason,
400
+ confidence,
401
+ branch,
402
+ },
403
+ });
404
+ },
405
+ () => db.save()
406
+ );
407
+ await syncStatusForStory(ctx.root, db.db, story.id, 'pr_submitted');
408
+ await scheduler.checkMergeQueue();
409
+ db.save();
410
+ verboseLogCtx(
411
+ ctx,
412
+ `autoProgressDoneStory: story=${story.id} action=auto_submitted branch=${branch}`
413
+ );
414
+ return 'auto_submitted' as const;
415
+ });
416
+
417
+ // Tmux notifications (no lock needed)
418
+ if (action === 'existing_pr') {
419
+ await sendManagerNudge(
420
+ ctx,
421
+ sessionName,
422
+ `# AUTO-PROGRESS: Manager inferred ${story.id} is complete (confidence ${confidence.toFixed(2)}), detected existing PR, and moved story to PR-submitted state.`
423
+ );
424
+ verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} action=existing_pr_progressed`);
425
+ return true;
426
+ }
427
+
428
+ if (action === 'no_branch') {
429
+ return false;
430
+ }
431
+
432
+ await sendManagerNudge(
433
+ ctx,
434
+ sessionName,
435
+ `# AUTO-PROGRESS: Manager inferred ${story.id} is complete (confidence ${confidence.toFixed(2)}), auto-submitted branch ${branch} to merge queue.`
436
+ );
437
+ return true;
438
+ }
439
+
440
+ async function resolveStoryBranchName(
441
+ root: string,
442
+ story: StoryRow,
443
+ agent: ReturnType<typeof getAllAgents>[number],
444
+ log?: (message: string) => void
445
+ ): Promise<string | null> {
446
+ if (story.branch_name && story.branch_name.trim().length > 0) {
447
+ log?.(`source=story.branch_name value=${story.branch_name.trim()}`);
448
+ return story.branch_name.trim();
449
+ }
450
+
451
+ if (!agent.worktree_path) {
452
+ log?.('source=worktree skip=no_worktree_path');
453
+ return null;
454
+ }
455
+
456
+ const worktreeDir = join(root, agent.worktree_path);
457
+ try {
458
+ const result = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: worktreeDir });
459
+ const branch = result.stdout.trim();
460
+ if (!branch || branch === 'HEAD') {
461
+ log?.(`source=git_rev_parse invalid_branch=${branch || '(empty)'}`);
462
+ return null;
463
+ }
464
+ log?.(`source=git_rev_parse value=${branch}`);
465
+ return branch;
466
+ } catch {
467
+ log?.(`source=git_rev_parse failed cwd=${worktreeDir}`);
468
+ return null;
469
+ }
470
+ }
471
+
472
+ export async function nudgeQAFailedStories(ctx: ManagerCheckContext): Promise<void> {
473
+ // Phase 1: Read QA-failed stories and agents (brief lock)
474
+ const candidates = await ctx.withDb(async db => {
475
+ const qaFailedStories = getStoriesByStatus(db.db, 'qa_failed').filter(
476
+ story => !['merged', 'completed'].includes(story.status)
477
+ );
478
+ verboseLogCtx(ctx, `nudgeQAFailedStories: candidates=${qaFailedStories.length}`);
479
+
480
+ const result: Array<{ storyId: string; sessionName: string; cliTool: CLITool }> = [];
481
+ for (const story of qaFailedStories) {
482
+ if (!story.assigned_agent_id) {
483
+ verboseLogCtx(ctx, `nudgeQAFailedStories: story=${story.id} skip=no_assigned_agent`);
484
+ continue;
485
+ }
486
+ const agent = getAgentById(db.db, story.assigned_agent_id);
487
+ if (!agent || agent.status !== 'working') {
488
+ verboseLogCtx(
489
+ ctx,
490
+ `nudgeQAFailedStories: story=${story.id} skip=agent_not_working status=${agent?.status || 'missing'}`
491
+ );
492
+ continue;
493
+ }
494
+ const agentSession = findSessionForAgent(ctx.hiveSessions, agent);
495
+ if (!agentSession) {
496
+ verboseLogCtx(ctx, `nudgeQAFailedStories: story=${story.id} skip=no_session`);
497
+ continue;
498
+ }
499
+ result.push({
500
+ storyId: story.id,
501
+ sessionName: agentSession.name,
502
+ cliTool: (agent.cli_tool || 'claude') as CLITool,
503
+ });
504
+ }
505
+ return result;
506
+ });
507
+
508
+ // Phase 2: Tmux captures and nudges (no lock needed)
509
+ for (const candidate of candidates) {
510
+ const output = await captureTmuxPane(candidate.sessionName, TMUX_CAPTURE_LINES_SHORT);
511
+ const stateResult = detectAgentState(output, candidate.cliTool);
512
+
513
+ if (
514
+ stateResult.isWaiting &&
515
+ !stateResult.needsHuman &&
516
+ stateResult.state !== AgentState.THINKING
517
+ ) {
518
+ verboseLogCtx(
519
+ ctx,
520
+ `nudgeQAFailedStories: story=${candidate.storyId} nudge session=${candidate.sessionName} state=${stateResult.state}`
521
+ );
522
+ await sendManagerNudge(
523
+ ctx,
524
+ candidate.sessionName,
525
+ `# REMINDER: Story ${candidate.storyId} failed QA review!
526
+ # You must fix the issues and resubmit the PR.
527
+ # Check the QA feedback and address all concerns.
528
+ hive pr queue`
529
+ );
530
+ } else {
531
+ verboseLogCtx(
532
+ ctx,
533
+ `nudgeQAFailedStories: story=${candidate.storyId} skip=not_ready waiting=${stateResult.isWaiting} needsHuman=${stateResult.needsHuman} state=${stateResult.state}`
534
+ );
535
+ }
536
+ }
537
+ }
538
+
539
+ export async function recoverUnassignedQAFailedStories(ctx: ManagerCheckContext): Promise<void> {
540
+ const result = await ctx.withDb(async (db, scheduler) => {
541
+ const recoverableStories = queryAll<StoryRow>(
542
+ db.db,
543
+ `
544
+ SELECT * FROM stories
545
+ WHERE status = 'qa_failed'
546
+ AND assigned_agent_id IS NULL
547
+ `
548
+ );
549
+
550
+ if (recoverableStories.length === 0) return null;
551
+ verboseLogCtx(ctx, `recoverUnassignedQAFailedStories: recovered=${recoverableStories.length}`);
552
+
553
+ await withTransaction(
554
+ db.db,
555
+ () => {
556
+ for (const story of recoverableStories) {
557
+ updateStory(db.db, story.id, { status: 'planned', assignedAgentId: null });
558
+ createLog(db.db, {
559
+ agentId: 'manager',
560
+ storyId: story.id,
561
+ eventType: 'ORPHANED_STORY_RECOVERED',
562
+ message: `Recovered QA-failed story ${story.id} (unassigned) back to planned`,
563
+ metadata: { from_status: 'qa_failed', to_status: 'planned' },
564
+ });
565
+ }
566
+ },
567
+ () => db.save()
568
+ );
569
+
570
+ for (const story of recoverableStories) {
571
+ await syncStatusForStory(ctx.root, db.db, story.id, 'planned');
572
+ }
573
+
574
+ // Proactively re-assign recovered work so it does not stall until manual `hive assign`.
575
+ const assignmentResult = await scheduler.assignStories();
576
+ verboseLogCtx(
577
+ ctx,
578
+ `recoverUnassignedQAFailedStories.assignStories: assigned=${assignmentResult.assigned}, errors=${assignmentResult.errors.length}`
579
+ );
580
+ db.save();
581
+
582
+ if (assignmentResult.assigned > 0) {
583
+ await scheduler.flushJiraQueue();
584
+ db.save();
585
+ }
586
+
587
+ return { recoverableCount: recoverableStories.length, assignmentResult };
588
+ });
589
+
590
+ if (result) {
591
+ console.log(
592
+ chalk.yellow(
593
+ ` Recovered ${result.recoverableCount} QA-failed unassigned story(ies), assigned ${result.assignmentResult.assigned}`
594
+ )
595
+ );
596
+ if (result.assignmentResult.errors.length > 0) {
597
+ console.log(
598
+ chalk.yellow(
599
+ ` Assignment errors during QA-failed recovery: ${result.assignmentResult.errors.length}`
600
+ )
601
+ );
602
+ }
603
+ }
604
+ }