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
@@ -2,33 +2,24 @@
2
2
  import chalk from 'chalk';
3
3
  import { Command } from 'commander';
4
4
  import { createHash } from 'crypto';
5
- import { execa } from 'execa';
6
5
  import { join } from 'path';
7
- import { getCliRuntimeBuilder, resolveRuntimeModelForCli } from '../../../cli-runtimes/index.js';
8
6
  import { ClusterRuntime, fetchLocalClusterStatus } from '../../../cluster/runtime.js';
9
7
  import { loadConfig } from '../../../config/loader.js';
10
- import { syncFromProvider, syncStatusForStory, } from '../../../connectors/project-management/operations.js';
8
+ import { syncFromProvider } from '../../../connectors/project-management/operations.js';
11
9
  import { queryAll, queryOne, withTransaction } from '../../../db/client.js';
12
10
  import { acquireLock } from '../../../db/lock.js';
13
- import { getAgentById, getAgentsByType, getAllAgents, updateAgent, } from '../../../db/queries/agents.js';
14
- import { createEscalation, getActiveEscalationsForAgent, getPendingEscalations, updateEscalation, } from '../../../db/queries/escalations.js';
11
+ import { getAgentById, getAllAgents } from '../../../db/queries/agents.js';
12
+ import { getPendingEscalations, updateEscalation } from '../../../db/queries/escalations.js';
15
13
  import { createLog } from '../../../db/queries/logs.js';
16
14
  import { getAllPendingMessages, markMessagesRead, } from '../../../db/queries/messages.js';
17
- import { backfillGithubPrNumbers, createPullRequest, getMergeQueue, getOpenPullRequestsByStory, getPullRequestsByStatus, updatePullRequest, } from '../../../db/queries/pull-requests.js';
18
- import { getRequirementsByStatus } from '../../../db/queries/requirements.js';
19
- import { getStoriesByStatus, getStoryById, updateStory } from '../../../db/queries/stories.js';
20
- import { getAllTeams } from '../../../db/queries/teams.js';
21
- import { getPullRequestComments, getPullRequestReviews } from '../../../git/github.js';
15
+ import { backfillGithubPrNumbers } from '../../../db/queries/pull-requests.js';
16
+ import { getStoryById } from '../../../db/queries/stories.js';
22
17
  import { Scheduler } from '../../../orchestrator/scheduler.js';
23
18
  import { AgentState } from '../../../state-detectors/types.js';
24
- import { captureTmuxPane, getHiveSessions, isManagerRunning, isTmuxSessionRunning, killTmuxSession, sendToTmuxSession, spawnTmuxSession, stopManager as stopManagerSession, } from '../../../tmux/manager.js';
19
+ import { captureTmuxPane, getHiveSessions, isManagerRunning, isTmuxSessionRunning, killTmuxSession, stopManager as stopManagerSession, } from '../../../tmux/manager.js';
25
20
  import { autoMergeApprovedPRs } from '../../../utils/auto-merge.js';
26
- import { findHiveRoot as findHiveRootFromDir, getHivePaths } from '../../../utils/paths.js';
27
- import { fetchOpenGitHubPRs, getExistingPRIdentifiers, ghRepoSlug, } from '../../../utils/pr-sync.js';
28
- import { extractStoryIdFromBranch } from '../../../utils/story-id.js';
29
21
  import { withHiveContext, withHiveRoot } from '../../../utils/with-hive-context.js';
30
- import { generateTechLeadPrompt } from '../req.js';
31
- import { agentStates, createManagerNudgeEnvelope, detectAgentState, enforceBypassMode, forwardMessages, getAgentSafetyMode, handlePermissionPrompt, handlePlanApproval, nudgeAgent, submitManagerNudgeWithVerification, updateAgentStateTracking, } from './agent-monitoring.js';
22
+ import { agentStates, detectAgentState, enforceBypassMode, forwardMessages, getAgentSafetyMode, handlePermissionPrompt, handlePlanApproval, nudgeAgent, updateAgentStateTracking, } from './agent-monitoring.js';
32
23
  import { spawnAuditorIfNeeded } from './auditor-lifecycle.js';
33
24
  import { autoAssignPlannedStories } from './auto-assignment.js';
34
25
  import { assessCompletionFromOutput } from './done-intelligence.js';
@@ -36,21 +27,21 @@ import { handleEscalationAndNudge } from './escalation-handler.js';
36
27
  import { checkFeatureSignOff } from './feature-sign-off.js';
37
28
  import { checkFeatureTestResult } from './feature-test-result.js';
38
29
  import { handleStalledPlanningHandoff } from './handoff-recovery.js';
39
- import { cleanupAgentsReferencingMergedStory } from './merged-story-cleanup.js';
30
+ import { formatDuration, getMaxStuckNudgesPerStory, getScreenStaticInactivityThresholdMs, sendManagerNudge, verboseLog, verboseLogCtx, } from './manager-utils.js';
40
31
  import { shouldAutoResolveOrphanedManagerEscalation } from './orphaned-escalations.js';
41
- import { isTechLeadRestartOnCooldown } from './restart-cooldown.js';
42
- import { findSessionForAgent } from './session-resolution.js';
32
+ import { closeStalePRs, reconcileAgentsOnMergedStories, recoverStaleReviewingPRs, syncMergedPRs, syncOpenPRs, } from './pr-sync-orchestrator.js';
33
+ import { autoRejectCommentOnlyReviews, handleRejectedPRs, notifyQAOfQueuedPRs, } from './qa-review-handler.js';
43
34
  import { spinDownIdleAgents, spinDownMergedAgents } from './spin-down.js';
44
35
  import { findStaleSessionEscalations } from './stale-escalations.js';
36
+ import { applyHumanInterventionStateOverride, clearHumanIntervention, isClassifierTimeoutReason, markClassifierTimeoutForHumanIntervention, markDoneFalseForHumanIntervention, screenStaticBySession, } from './stuck-story-helpers.js';
37
+ import { autoProgressDoneStory, nudgeQAFailedStories, nudgeStuckStories, recoverUnassignedQAFailedStories, } from './stuck-story-processor.js';
38
+ import { restartStaleTechLead } from './tech-lead-lifecycle.js';
45
39
  import { MANAGER_NUDGE_END_MARKER, MANAGER_NUDGE_START_MARKER, TMUX_CAPTURE_LINES, TMUX_CAPTURE_LINES_SHORT, } from './types.js';
40
+ // Re-export functions that moved to submodules (preserves public API for tests/consumers)
41
+ export { autoRejectCommentOnlyReviews } from './qa-review-handler.js';
42
+ export { shouldDeferStuckReminderUntilStaticWindow, shouldTreatUnknownAsStuckWaiting, } from './stuck-story-helpers.js';
46
43
  const DONE_INFERENCE_CONFIDENCE_THRESHOLD = 0.82;
47
44
  const SCREEN_STATIC_AI_RECHECK_MS = 5 * 60 * 1000;
48
- const DEFAULT_SCREEN_STATIC_INACTIVITY_THRESHOLD_MS = 10 * 60 * 1000;
49
- const DEFAULT_MAX_STUCK_NUDGES_PER_STORY = 1;
50
- const REVIEWING_PR_VALIDATION_MIN_AGE_MS = 5 * 60 * 1000;
51
- const GH_PR_VIEW_TIMEOUT_MS = 30_000;
52
- const CLASSIFIER_TIMEOUT_REASON_PREFIX = 'Classifier timeout';
53
- const AI_DONE_FALSE_REASON_PREFIX = 'AI done=false escalation';
54
45
  export function classifyNoActionSummary(snapshot) {
55
46
  if (snapshot.pendingEscalations > 0) {
56
47
  return {
@@ -73,31 +64,6 @@ export function classifyNoActionSummary(snapshot) {
73
64
  }
74
65
  return { color: 'green', message: 'All agents productive' };
75
66
  }
76
- export function shouldTreatUnknownAsStuckWaiting(snapshot) {
77
- const thresholdMs = Math.max(1, snapshot.staticInactivityThresholdMs);
78
- return (snapshot.state === AgentState.UNKNOWN &&
79
- !snapshot.isWaiting &&
80
- snapshot.sessionUnchangedForMs >= thresholdMs);
81
- }
82
- export function shouldDeferStuckReminderUntilStaticWindow(snapshot) {
83
- const thresholdMs = Math.max(1, snapshot.staticInactivityThresholdMs);
84
- if (snapshot.state === AgentState.WORK_COMPLETE) {
85
- return false;
86
- }
87
- return snapshot.sessionUnchangedForMs < thresholdMs;
88
- }
89
- const screenStaticBySession = new Map();
90
- const classifierTimeoutInterventionsBySession = new Map();
91
- const aiDoneFalseInterventionsBySession = new Map();
92
- const techLeadLastRestartByAgentId = new Map();
93
- function verboseLog(verbose, message) {
94
- if (!verbose)
95
- return;
96
- console.log(chalk.gray(` [verbose] ${message}`));
97
- }
98
- function verboseLogCtx(ctx, message) {
99
- verboseLog(ctx.verbose, message);
100
- }
101
67
  function summarizeOutputForVerbose(output) {
102
68
  const compact = output
103
69
  .split('\n')
@@ -109,19 +75,6 @@ function summarizeOutputForVerbose(output) {
109
75
  return compact;
110
76
  return `${compact.slice(0, 177)}...`;
111
77
  }
112
- function shouldIncludeProgressUpdates(config) {
113
- return config.integrations?.project_management?.provider !== 'none';
114
- }
115
- function formatDuration(ms) {
116
- if (ms <= 0)
117
- return 'now';
118
- const totalSeconds = Math.ceil(ms / 1000);
119
- const minutes = Math.floor(totalSeconds / 60);
120
- const seconds = totalSeconds % 60;
121
- if (minutes <= 0)
122
- return `${seconds}s`;
123
- return `${minutes}m ${seconds}s`;
124
- }
125
78
  function buildOutputFingerprint(output) {
126
79
  return createHash('sha256').update(output).digest('hex');
127
80
  }
@@ -146,160 +99,6 @@ function stripManagerNudgeBlocks(output) {
146
99
  }
147
100
  return filtered.join('\n');
148
101
  }
149
- async function submitManagerNudge(ctx, sessionName, nudgeId) {
150
- console.log(chalk.gray(` Nudge ${nudgeId}: double-checking Enter delivery after nudge (verification loop enabled)`));
151
- const result = await submitManagerNudgeWithVerification(sessionName, nudgeId);
152
- ctx.counters.nudgeEnterPresses = (ctx.counters.nudgeEnterPresses ?? 0) + result.enterPresses;
153
- ctx.counters.nudgeEnterRetries = (ctx.counters.nudgeEnterRetries ?? 0) + result.retryEnters;
154
- if (!result.confirmed) {
155
- ctx.counters.nudgeSubmitUnconfirmed = (ctx.counters.nudgeSubmitUnconfirmed ?? 0) + 1;
156
- console.log(chalk.yellow(` Nudge ${nudgeId}: unable to confirm Enter delivery after ${result.checks} check(s), ${result.enterPresses} Enter keypress(es)`));
157
- return;
158
- }
159
- console.log(chalk.gray(` Nudge ${nudgeId}: Enter delivery confirmed after ${result.checks} check(s), ${result.enterPresses} Enter keypress(es)`));
160
- }
161
- async function sendManagerNudge(ctx, sessionName, message) {
162
- const envelope = createManagerNudgeEnvelope(message);
163
- await sendToTmuxSession(sessionName, envelope.text);
164
- await submitManagerNudge(ctx, sessionName, envelope.nudgeId);
165
- }
166
- function getScreenStaticInactivityThresholdMs(config) {
167
- return Math.max(1, config?.manager.screen_static_inactivity_threshold_ms ??
168
- DEFAULT_SCREEN_STATIC_INACTIVITY_THRESHOLD_MS);
169
- }
170
- function getMaxStuckNudgesPerStory(config) {
171
- return Math.max(0, config?.manager.max_stuck_nudges_per_story ?? DEFAULT_MAX_STUCK_NUDGES_PER_STORY);
172
- }
173
- function isClassifierTimeoutReason(reason) {
174
- return /local classifier unavailable:.*timed out|command timed out/i.test(reason);
175
- }
176
- function formatClassifierTimeoutEscalationReason(storyId, reason) {
177
- const singleLine = reason.replace(/\s+/g, ' ').trim();
178
- const shortReason = singleLine.length > 240 ? `${singleLine.slice(0, 237)}...` : singleLine;
179
- return `${CLASSIFIER_TIMEOUT_REASON_PREFIX}: manager completion classifier timed out for ${storyId}. Manual human intervention required. Detail: ${shortReason}`;
180
- }
181
- async function applyHumanInterventionStateOverride(ctx, sessionName, storyId, stateResult, agentId = null) {
182
- const timeoutIntervention = classifierTimeoutInterventionsBySession.get(sessionName);
183
- if (timeoutIntervention && (!storyId || storyId !== timeoutIntervention.storyId)) {
184
- classifierTimeoutInterventionsBySession.delete(sessionName);
185
- }
186
- const doneFalseIntervention = aiDoneFalseInterventionsBySession.get(sessionName);
187
- if (doneFalseIntervention && (!storyId || storyId !== doneFalseIntervention.storyId)) {
188
- aiDoneFalseInterventionsBySession.delete(sessionName);
189
- }
190
- const transientIntervention = [timeoutIntervention, doneFalseIntervention]
191
- .filter((candidate) => Boolean(candidate && storyId && candidate.storyId === storyId))
192
- .sort((a, b) => b.createdAtMs - a.createdAtMs)[0] ?? null;
193
- const persistedIntervention = storyId === null
194
- ? null
195
- : await ctx.withDb(async (db) => {
196
- return ((agentId ? getActiveEscalationsForAgent(db.db, agentId) : []).find(escalation => escalation.story_id === storyId &&
197
- (escalation.reason.startsWith(CLASSIFIER_TIMEOUT_REASON_PREFIX) ||
198
- escalation.reason.startsWith(AI_DONE_FALSE_REASON_PREFIX))) ??
199
- getPendingEscalations(db.db).find(escalation => escalation.story_id === storyId &&
200
- (escalation.reason.startsWith(CLASSIFIER_TIMEOUT_REASON_PREFIX) ||
201
- escalation.reason.startsWith(AI_DONE_FALSE_REASON_PREFIX))) ??
202
- null);
203
- });
204
- const interventionReason = transientIntervention?.reason || persistedIntervention?.reason || null;
205
- if (!interventionReason) {
206
- return stateResult;
207
- }
208
- return {
209
- ...stateResult,
210
- state: AgentState.ASKING_QUESTION,
211
- needsHuman: true,
212
- isWaiting: true,
213
- reason: `Manual intervention required: ${interventionReason}`,
214
- };
215
- }
216
- function clearHumanIntervention(sessionName) {
217
- classifierTimeoutInterventionsBySession.delete(sessionName);
218
- aiDoneFalseInterventionsBySession.delete(sessionName);
219
- }
220
- async function markClassifierTimeoutForHumanIntervention(ctx, sessionName, storyId, reason, agentId = null) {
221
- const escalationReason = formatClassifierTimeoutEscalationReason(storyId, reason);
222
- classifierTimeoutInterventionsBySession.set(sessionName, {
223
- storyId,
224
- reason: escalationReason,
225
- createdAtMs: Date.now(),
226
- });
227
- await ctx.withDb(async (db) => {
228
- const activeTimeoutEscalation = (agentId ? getActiveEscalationsForAgent(db.db, agentId) : []).some(escalation => escalation.reason.startsWith(CLASSIFIER_TIMEOUT_REASON_PREFIX));
229
- if (!activeTimeoutEscalation) {
230
- const escalation = createEscalation(db.db, {
231
- storyId,
232
- fromAgentId: agentId,
233
- toAgentId: null,
234
- reason: escalationReason,
235
- });
236
- createLog(db.db, {
237
- agentId: 'manager',
238
- storyId,
239
- eventType: 'ESCALATION_CREATED',
240
- status: 'error',
241
- message: `${sessionName} requires human intervention: completion classifier timed out`,
242
- metadata: {
243
- escalation_id: escalation.id,
244
- session_name: sessionName,
245
- escalation_type: 'classifier_timeout',
246
- },
247
- });
248
- db.save();
249
- ctx.counters.escalationsCreated++;
250
- ctx.escalatedSessions.add(sessionName);
251
- }
252
- });
253
- const tracked = agentStates.get(sessionName);
254
- if (tracked) {
255
- tracked.lastState = AgentState.ASKING_QUESTION;
256
- tracked.lastStateChangeTime = Date.now();
257
- }
258
- }
259
- function formatDoneFalseEscalationReason(storyId, reason) {
260
- const singleLine = reason.replace(/\s+/g, ' ').trim();
261
- const shortReason = singleLine.length > 240 ? `${singleLine.slice(0, 237)}...` : singleLine;
262
- return `${AI_DONE_FALSE_REASON_PREFIX}: manager AI assessment returned done=false for ${storyId} after nudge limit reached. Manual human intervention required. Detail: ${shortReason}`;
263
- }
264
- async function markDoneFalseForHumanIntervention(ctx, sessionName, storyId, reason, agentId = null) {
265
- const escalationReason = formatDoneFalseEscalationReason(storyId, reason);
266
- aiDoneFalseInterventionsBySession.set(sessionName, {
267
- storyId,
268
- reason: escalationReason,
269
- createdAtMs: Date.now(),
270
- });
271
- await ctx.withDb(async (db) => {
272
- const hasActiveEscalation = (agentId ? getActiveEscalationsForAgent(db.db, agentId) : []).some(escalation => escalation.reason.startsWith(AI_DONE_FALSE_REASON_PREFIX));
273
- if (!hasActiveEscalation) {
274
- const escalation = createEscalation(db.db, {
275
- storyId,
276
- fromAgentId: agentId,
277
- toAgentId: null,
278
- reason: escalationReason,
279
- });
280
- createLog(db.db, {
281
- agentId: 'manager',
282
- storyId,
283
- eventType: 'ESCALATION_CREATED',
284
- status: 'error',
285
- message: `${sessionName} requires human intervention: AI assessment reports blocked/incomplete after nudge limit`,
286
- metadata: {
287
- escalation_id: escalation.id,
288
- session_name: sessionName,
289
- escalation_type: 'ai_done_false',
290
- },
291
- });
292
- db.save();
293
- ctx.counters.escalationsCreated++;
294
- ctx.escalatedSessions.add(sessionName);
295
- }
296
- });
297
- const tracked = agentStates.get(sessionName);
298
- if (tracked) {
299
- tracked.lastState = AgentState.ASKING_QUESTION;
300
- tracked.lastStateChangeTime = Date.now();
301
- }
302
- }
303
102
  function updateScreenStaticTracking(sessionName, output, nowMs, staticInactivityThresholdMs) {
304
103
  const fingerprint = buildOutputFingerprint(stripManagerNudgeBlocks(output));
305
104
  const existing = screenStaticBySession.get(sessionName);
@@ -339,12 +138,6 @@ function markFullAiDetectionRun(sessionName, nowMs) {
339
138
  return;
340
139
  tracking.lastAiAssessmentMs = nowMs;
341
140
  }
342
- function getSessionStaticUnchangedForMs(sessionName, nowMs) {
343
- const tracking = screenStaticBySession.get(sessionName);
344
- if (!tracking)
345
- return 0;
346
- return Math.max(0, nowMs - tracking.unchangedSinceMs);
347
- }
348
141
  export const managerCommand = new Command('manager').description('Micromanager daemon that keeps agents productive');
349
142
  // Start the manager daemon
350
143
  managerCommand
@@ -780,529 +573,8 @@ async function runAutoMerge(ctx) {
780
573
  console.log(chalk.green(` Auto-merged ${autoMerged} approved PR(s)`));
781
574
  }
782
575
  }
783
- async function syncMergedPRs(ctx) {
784
- // Phase 1: Read teams (brief lock)
785
- const teamInfos = await ctx.withDb(async (db) => {
786
- const { getAllTeams } = await import('../../../db/queries/teams.js');
787
- return getAllTeams(db.db)
788
- .filter(t => t.repo_path)
789
- .map(t => ({
790
- repoDir: `${ctx.root}/${t.repo_path}`,
791
- slug: ghRepoSlug(t.repo_url),
792
- }));
793
- });
794
- if (teamInfos.length === 0)
795
- return;
796
- // Phase 2: GitHub CLI calls (no lock)
797
- const GITHUB_PR_LIST_LIMIT = 20;
798
- const GH_CLI_TIMEOUT_MS = 30000;
799
- const ghResults = [];
800
- for (const team of teamInfos) {
801
- try {
802
- const args = [
803
- 'pr',
804
- 'list',
805
- '--json',
806
- 'number,headRefName,mergedAt',
807
- '--state',
808
- 'merged',
809
- '--limit',
810
- String(GITHUB_PR_LIST_LIMIT),
811
- ];
812
- if (team.slug)
813
- args.push('-R', team.slug);
814
- const result = await execa('gh', args, { cwd: team.repoDir, timeout: GH_CLI_TIMEOUT_MS });
815
- ghResults.push({ mergedPRs: JSON.parse(result.stdout) });
816
- }
817
- catch {
818
- ghResults.push({ mergedPRs: [] });
819
- }
820
- }
821
- // Phase 3: DB reads + writes (brief lock)
822
- const mergedSynced = await ctx.withDb(async (db) => {
823
- let storiesUpdated = 0;
824
- for (const ghResult of ghResults) {
825
- const candidateStoryIds = Array.from(new Set(ghResult.mergedPRs
826
- .map(pr => extractStoryIdFromBranch(pr.headRefName))
827
- .filter((id) => Boolean(id))));
828
- if (candidateStoryIds.length === 0)
829
- continue;
830
- const placeholders = candidateStoryIds.map(() => '?').join(',');
831
- const updatableStories = queryAll(db.db, `SELECT id FROM stories WHERE status != 'merged' AND id IN (${placeholders})`, candidateStoryIds);
832
- const updatableStoryIds = new Set(updatableStories.map(s => s.id));
833
- const toUpdate = [];
834
- for (const pr of ghResult.mergedPRs) {
835
- const storyId = extractStoryIdFromBranch(pr.headRefName);
836
- if (!storyId || !updatableStoryIds.has(storyId))
837
- continue;
838
- updatableStoryIds.delete(storyId);
839
- toUpdate.push({ storyId, prNumber: pr.number });
840
- }
841
- if (toUpdate.length > 0) {
842
- await withTransaction(db.db, () => {
843
- for (const update of toUpdate) {
844
- updateStory(db.db, update.storyId, { status: 'merged', assignedAgentId: null });
845
- const cleanup = cleanupAgentsReferencingMergedStory(db.db, update.storyId);
846
- createLog(db.db, {
847
- agentId: 'manager',
848
- storyId: update.storyId,
849
- eventType: 'STORY_MERGED',
850
- message: `Story synced to merged from GitHub PR #${update.prNumber}`,
851
- metadata: {
852
- merged_agent_cleanup_cleared: cleanup.cleared,
853
- merged_agent_cleanup_reassigned: cleanup.reassigned,
854
- },
855
- });
856
- }
857
- });
858
- for (const update of toUpdate) {
859
- syncStatusForStory(ctx.root, db.db, update.storyId, 'merged');
860
- }
861
- storiesUpdated += toUpdate.length;
862
- }
863
- }
864
- if (storiesUpdated > 0)
865
- db.save();
866
- return storiesUpdated;
867
- });
868
- verboseLogCtx(ctx, `syncMergedPRs: synced=${mergedSynced}`);
869
- if (mergedSynced > 0) {
870
- console.log(chalk.green(` Synced ${mergedSynced} merged story(ies) from GitHub`));
871
- }
872
- }
873
- async function reconcileAgentsOnMergedStories(ctx) {
874
- const result = await ctx.withDb(async (db) => {
875
- const mergedStoryIds = queryAll(db.db, `
876
- SELECT DISTINCT s.id
877
- FROM stories s
878
- JOIN agents a ON a.current_story_id = s.id
879
- WHERE s.status = 'merged'
880
- AND a.status != 'terminated'
881
- `).map(row => row.id);
882
- if (mergedStoryIds.length === 0) {
883
- return { storyCount: 0, cleared: 0, reassigned: 0 };
884
- }
885
- let cleared = 0;
886
- let reassigned = 0;
887
- for (const storyId of mergedStoryIds) {
888
- const cleanup = cleanupAgentsReferencingMergedStory(db.db, storyId);
889
- if (cleanup.cleared > 0) {
890
- createLog(db.db, {
891
- agentId: 'manager',
892
- storyId,
893
- eventType: 'STORY_PROGRESS_UPDATE',
894
- message: `Reconciled stale merged-story agent assignments`,
895
- metadata: {
896
- story_id: storyId,
897
- cleared_agents: cleanup.cleared,
898
- reassigned_agents: cleanup.reassigned,
899
- recovery: 'merged_story_agent_reconcile',
900
- },
901
- });
902
- }
903
- cleared += cleanup.cleared;
904
- reassigned += cleanup.reassigned;
905
- }
906
- if (cleared > 0) {
907
- db.save();
908
- }
909
- return { storyCount: mergedStoryIds.length, cleared, reassigned };
910
- });
911
- verboseLogCtx(ctx, `reconcileAgentsOnMergedStories: stories=${result.storyCount}, cleared=${result.cleared}, reassigned=${result.reassigned}`);
912
- if (result.cleared > 0) {
913
- console.log(chalk.yellow(` Reconciled ${result.cleared} stale merged-story agent assignment(s) (${result.reassigned} reassigned, ${result.cleared - result.reassigned} idled)`));
914
- }
915
- }
916
- async function syncOpenPRs(ctx) {
917
- const maxAgeHours = ctx.config.merge_queue?.max_age_hours;
918
- // Phase 1: Read teams + existing identifiers (brief lock)
919
- const setupData = await ctx.withDb(async (db) => {
920
- const { getAllTeams } = await import('../../../db/queries/teams.js');
921
- const teams = getAllTeams(db.db);
922
- const { existingBranches, existingPrNumbers } = getExistingPRIdentifiers(db.db, true);
923
- return {
924
- teams: teams
925
- .filter(t => t.repo_path)
926
- .map(t => ({
927
- id: t.id,
928
- repoDir: `${ctx.root}/${t.repo_path}`,
929
- slug: ghRepoSlug(t.repo_url),
930
- })),
931
- existingBranches,
932
- existingPrNumbers,
933
- };
934
- });
935
- if (setupData.teams.length === 0)
936
- return;
937
- // Phase 2: GitHub CLI calls (no lock)
938
- const teamPRs = new Map();
939
- for (const team of setupData.teams) {
940
- try {
941
- const prs = await fetchOpenGitHubPRs(team.repoDir, team.slug);
942
- teamPRs.set(team.id, prs);
943
- }
944
- catch {
945
- // gh CLI might not be authenticated
946
- }
947
- }
948
- // Phase 3: Import into DB (brief lock)
949
- const syncedPRs = await ctx.withDb(async (db, scheduler) => {
950
- // Re-read identifiers in case another process synced in the meantime
951
- const { existingBranches, existingPrNumbers } = getExistingPRIdentifiers(db.db, true);
952
- let totalSynced = 0;
953
- for (const team of setupData.teams) {
954
- const prs = teamPRs.get(team.id);
955
- if (!prs)
956
- continue;
957
- for (const ghPR of prs) {
958
- if (existingBranches.has(ghPR.headRefName) || existingPrNumbers.has(ghPR.number))
959
- continue;
960
- // Age filtering
961
- if (maxAgeHours !== undefined) {
962
- const ageHours = (Date.now() - new Date(ghPR.createdAt).getTime()) / (1000 * 60 * 60);
963
- if (ageHours > maxAgeHours) {
964
- createLog(db.db, {
965
- agentId: 'manager',
966
- eventType: 'PR_SYNC_SKIPPED',
967
- status: 'info',
968
- message: `Skipped syncing old PR #${ghPR.number} (${ghPR.headRefName}): created ${ageHours.toFixed(1)}h ago (max: ${maxAgeHours}h)`,
969
- metadata: {
970
- pr_number: ghPR.number,
971
- branch: ghPR.headRefName,
972
- age_hours: ageHours,
973
- max_age_hours: maxAgeHours,
974
- reason: 'too_old',
975
- },
976
- });
977
- continue;
978
- }
979
- }
980
- const storyId = extractStoryIdFromBranch(ghPR.headRefName);
981
- if (storyId) {
982
- const storyRows = queryAll(db.db, `SELECT id, status FROM stories WHERE id = ? AND status != 'merged'`, [storyId]);
983
- if (storyRows.length === 0) {
984
- createLog(db.db, {
985
- agentId: 'manager',
986
- eventType: 'PR_SYNC_SKIPPED',
987
- status: 'info',
988
- message: `Skipped syncing PR #${ghPR.number} (${ghPR.headRefName}): story ${storyId} not found or already merged`,
989
- metadata: {
990
- pr_number: ghPR.number,
991
- branch: ghPR.headRefName,
992
- story_id: storyId,
993
- reason: 'inactive_story',
994
- },
995
- });
996
- continue;
997
- }
998
- }
999
- createPullRequest(db.db, {
1000
- storyId,
1001
- teamId: team.id,
1002
- branchName: ghPR.headRefName,
1003
- githubPrNumber: ghPR.number,
1004
- githubPrUrl: ghPR.url,
1005
- submittedBy: null,
1006
- });
1007
- existingBranches.add(ghPR.headRefName);
1008
- existingPrNumbers.add(ghPR.number);
1009
- totalSynced++;
1010
- }
1011
- }
1012
- if (totalSynced > 0) {
1013
- db.save();
1014
- await scheduler.checkMergeQueue();
1015
- db.save();
1016
- }
1017
- return totalSynced;
1018
- });
1019
- verboseLogCtx(ctx, `syncOpenPRs: synced=${syncedPRs}`);
1020
- if (syncedPRs > 0) {
1021
- console.log(chalk.yellow(` Synced ${syncedPRs} GitHub PR(s) into merge queue`));
1022
- }
1023
- }
1024
- async function closeStalePRs(ctx) {
1025
- // Phase 1: Read teams + PR data (brief lock)
1026
- const { teamInfos, prsByStory } = await ctx.withDb(async (db) => {
1027
- const { getAllTeams } = await import('../../../db/queries/teams.js');
1028
- const teams = getAllTeams(db.db).filter(t => t.repo_path);
1029
- // Pre-fetch all non-closed PR data grouped by story
1030
- const allPRs = queryAll(db.db, `SELECT story_id, id, github_pr_number FROM pull_requests WHERE status NOT IN ('closed') ORDER BY created_at DESC`);
1031
- const prsByStory = new Map();
1032
- for (const pr of allPRs) {
1033
- if (!pr.story_id)
1034
- continue;
1035
- const existing = prsByStory.get(pr.story_id) || [];
1036
- existing.push({ id: pr.id, github_pr_number: pr.github_pr_number });
1037
- prsByStory.set(pr.story_id, existing);
1038
- }
1039
- return {
1040
- teamInfos: teams.map(t => ({
1041
- repoDir: `${ctx.root}/${t.repo_path}`,
1042
- })),
1043
- prsByStory,
1044
- };
1045
- });
1046
- if (teamInfos.length === 0)
1047
- return;
1048
- // Phase 2: GitHub CLI calls (no lock)
1049
- const GH_CLI_TIMEOUT_MS = 30000;
1050
- const baseBranch = ctx.config.github?.base_branch ?? 'main';
1051
- const closed = [];
1052
- for (const team of teamInfos) {
1053
- try {
1054
- const openGHPRs = await fetchOpenGitHubPRs(team.repoDir);
1055
- for (const ghPR of openGHPRs) {
1056
- // Skip PRs that don't target the configured base branch
1057
- if (ghPR.baseRefName !== baseBranch)
1058
- continue;
1059
- const storyId = extractStoryIdFromBranch(ghPR.headRefName);
1060
- if (!storyId)
1061
- continue;
1062
- const prsForStory = prsByStory.get(storyId);
1063
- if (!prsForStory || prsForStory.length === 0)
1064
- continue;
1065
- const hasUnsyncedEntry = prsForStory.some(pr => pr.github_pr_number == null);
1066
- if (hasUnsyncedEntry)
1067
- continue;
1068
- const isInQueue = prsForStory.some(pr => pr.github_pr_number === ghPR.number);
1069
- if (!isInQueue) {
1070
- const supersededByPrNumber = prsForStory.find(pr => pr.github_pr_number !== null)?.github_pr_number ?? null;
1071
- try {
1072
- await execa('gh', ['pr', 'close', String(ghPR.number)], {
1073
- cwd: team.repoDir,
1074
- timeout: GH_CLI_TIMEOUT_MS,
1075
- });
1076
- closed.push({
1077
- storyId,
1078
- closedPrNumber: ghPR.number,
1079
- branch: ghPR.headRefName,
1080
- supersededByPrNumber,
1081
- });
1082
- }
1083
- catch {
1084
- // Non-fatal
1085
- }
1086
- }
1087
- }
1088
- }
1089
- catch {
1090
- continue;
1091
- }
1092
- }
1093
- // Phase 3: Write logs (brief lock)
1094
- if (closed.length > 0) {
1095
- await ctx.withDb(async (db) => {
1096
- for (const info of closed) {
1097
- const supersededDesc = info.supersededByPrNumber !== null ? ` by PR #${info.supersededByPrNumber}` : '';
1098
- createLog(db.db, {
1099
- agentId: 'manager',
1100
- storyId: info.storyId,
1101
- eventType: 'PR_CLOSED',
1102
- message: `Auto-closed stale GitHub PR #${info.closedPrNumber} (${info.branch}) - superseded${supersededDesc}`,
1103
- metadata: {
1104
- github_pr_number: info.closedPrNumber,
1105
- branch: info.branch,
1106
- reason: 'stale',
1107
- superseded_by_pr_number: info.supersededByPrNumber,
1108
- },
1109
- });
1110
- }
1111
- db.save();
1112
- });
1113
- console.log(chalk.yellow(` Closed ${closed.length} stale GitHub PR(s):`));
1114
- for (const info of closed) {
1115
- const supersededDesc = info.supersededByPrNumber !== null
1116
- ? ` (superseded by PR #${info.supersededByPrNumber})`
1117
- : '';
1118
- console.log(chalk.gray(` PR #${info.closedPrNumber} [${info.storyId}] ${info.branch}${supersededDesc}`));
1119
- }
1120
- }
1121
- verboseLogCtx(ctx, `closeStalePRs: closed=${closed.length}`);
1122
- }
1123
- async function recoverStaleReviewingPRs(ctx) {
1124
- const now = Date.now();
1125
- // Phase 1: Read stale reviewing PRs and resolve repo metadata (brief lock)
1126
- const candidates = await ctx.withDb(async (db) => {
1127
- const reviewingPRs = getPullRequestsByStatus(db.db, 'reviewing').filter(pr => {
1128
- if (!pr.github_pr_number || !pr.team_id)
1129
- return false;
1130
- const updatedAtMs = Date.parse(pr.updated_at);
1131
- if (Number.isNaN(updatedAtMs))
1132
- return true;
1133
- return now - updatedAtMs >= REVIEWING_PR_VALIDATION_MIN_AGE_MS;
1134
- });
1135
- verboseLogCtx(ctx, `recoverStaleReviewingPRs: staleCandidates=${reviewingPRs.length}`);
1136
- if (reviewingPRs.length === 0) {
1137
- return [];
1138
- }
1139
- const { getAllTeams } = await import('../../../db/queries/teams.js');
1140
- const teams = getAllTeams(db.db);
1141
- const teamsById = new Map(teams.map(team => [team.id, team]));
1142
- const result = [];
1143
- for (const pr of reviewingPRs) {
1144
- const team = teamsById.get(pr.team_id);
1145
- if (!team?.repo_path)
1146
- continue;
1147
- result.push({
1148
- id: pr.id,
1149
- storyId: pr.story_id,
1150
- teamId: pr.team_id,
1151
- branchName: pr.branch_name,
1152
- githubPrNumber: pr.github_pr_number,
1153
- reviewedBy: pr.reviewed_by,
1154
- repoDir: `${ctx.root}/${team.repo_path}`,
1155
- repoSlug: ghRepoSlug(team.repo_url),
1156
- });
1157
- }
1158
- return result;
1159
- });
1160
- if (candidates.length === 0)
1161
- return;
1162
- // Phase 2: Check GitHub state for each stale reviewing PR (no lock)
1163
- const mergedResults = [];
1164
- const rejectedResults = [];
1165
- for (const candidate of candidates) {
1166
- try {
1167
- const args = ['pr', 'view', String(candidate.githubPrNumber), '--json', 'state,url'];
1168
- if (candidate.repoSlug)
1169
- args.push('-R', candidate.repoSlug);
1170
- const result = await execa('gh', args, {
1171
- cwd: candidate.repoDir,
1172
- timeout: GH_PR_VIEW_TIMEOUT_MS,
1173
- });
1174
- const parsed = JSON.parse(result.stdout);
1175
- const state = parsed.state?.toUpperCase();
1176
- const url = parsed.url || null;
1177
- if (state === 'OPEN') {
1178
- // PR is still open on GitHub but stale in 'reviewing' — the QA agent
1179
- // may have missed the original nudge. Re-nudge if QA agent is idle.
1180
- if (candidate.reviewedBy) {
1181
- const qaAgent = ctx.agentsBySessionName.get(candidate.reviewedBy);
1182
- if (qaAgent && qaAgent.status === 'idle') {
1183
- const githubLine = candidate.repoSlug
1184
- ? `\n# GitHub: https://github.com/${candidate.repoSlug}/pull/${candidate.githubPrNumber}`
1185
- : '';
1186
- await sendManagerNudge(ctx, candidate.reviewedBy, `# [REMINDER] You are assigned PR review ${candidate.id} (${candidate.storyId || 'no-story'}).${githubLine}
1187
- # This PR has been waiting for review. Execute now:
1188
- # hive pr show ${candidate.id}
1189
- # hive pr approve ${candidate.id}
1190
- # or reject:
1191
- # hive pr reject ${candidate.id} -r "reason"`);
1192
- verboseLogCtx(ctx, `recoverStaleReviewingPRs: re-nudged idle QA ${candidate.reviewedBy} for stale pr=${candidate.id}`);
1193
- }
1194
- }
1195
- continue;
1196
- }
1197
- if (state === 'MERGED') {
1198
- mergedResults.push({
1199
- candidate,
1200
- githubState: 'MERGED',
1201
- githubUrl: url,
1202
- });
1203
- continue;
1204
- }
1205
- if (state) {
1206
- rejectedResults.push({
1207
- candidate,
1208
- githubState: state,
1209
- githubUrl: url,
1210
- });
1211
- }
1212
- }
1213
- catch (err) {
1214
- verboseLogCtx(ctx, `recoverStaleReviewingPRs: skip pr=${candidate.id} github_check_failed=${err instanceof Error ? err.message : String(err)}`);
1215
- }
1216
- }
1217
- if (mergedResults.length === 0 && rejectedResults.length === 0)
1218
- return;
1219
- const mergedStoryIds = [];
1220
- // Phase 3: Apply DB updates (brief lock)
1221
- await ctx.withDb(async (db) => {
1222
- for (const result of mergedResults) {
1223
- await withTransaction(db.db, () => {
1224
- const currentPR = queryOne(db.db, `SELECT status FROM pull_requests WHERE id = ?`, [result.candidate.id]);
1225
- if (!currentPR || currentPR.status !== 'reviewing')
1226
- return;
1227
- updatePullRequest(db.db, result.candidate.id, {
1228
- status: 'merged',
1229
- reviewedBy: result.candidate.reviewedBy || 'manager',
1230
- });
1231
- createLog(db.db, {
1232
- agentId: 'manager',
1233
- storyId: result.candidate.storyId || undefined,
1234
- eventType: 'PR_MERGED',
1235
- message: `Auto-closed reviewing PR ${result.candidate.id}: GitHub PR #${result.candidate.githubPrNumber} is already merged`,
1236
- metadata: {
1237
- pr_id: result.candidate.id,
1238
- github_pr_number: result.candidate.githubPrNumber,
1239
- github_state: result.githubState,
1240
- github_url: result.githubUrl,
1241
- },
1242
- });
1243
- if (!result.candidate.storyId)
1244
- return;
1245
- updateStory(db.db, result.candidate.storyId, { status: 'merged', assignedAgentId: null });
1246
- const cleanup = cleanupAgentsReferencingMergedStory(db.db, result.candidate.storyId);
1247
- createLog(db.db, {
1248
- agentId: 'manager',
1249
- storyId: result.candidate.storyId,
1250
- eventType: 'STORY_MERGED',
1251
- message: `Story auto-synced to merged (GitHub PR #${result.candidate.githubPrNumber} already merged)`,
1252
- metadata: {
1253
- pr_id: result.candidate.id,
1254
- github_pr_number: result.candidate.githubPrNumber,
1255
- github_url: result.githubUrl,
1256
- merged_agent_cleanup_cleared: cleanup.cleared,
1257
- merged_agent_cleanup_reassigned: cleanup.reassigned,
1258
- },
1259
- });
1260
- mergedStoryIds.push(result.candidate.storyId);
1261
- }, () => db.save());
1262
- }
1263
- for (const result of rejectedResults) {
1264
- await withTransaction(db.db, () => {
1265
- const currentPR = queryOne(db.db, `SELECT status FROM pull_requests WHERE id = ?`, [result.candidate.id]);
1266
- if (!currentPR || currentPR.status !== 'reviewing')
1267
- return;
1268
- const reason = `GitHub PR #${result.candidate.githubPrNumber} is ${result.githubState.toLowerCase()} on GitHub${result.githubUrl ? ` (${result.githubUrl})` : ''}. Reopen/create a new PR and resubmit.`;
1269
- updatePullRequest(db.db, result.candidate.id, {
1270
- status: 'rejected',
1271
- reviewedBy: result.candidate.reviewedBy || 'manager',
1272
- reviewNotes: reason,
1273
- });
1274
- createLog(db.db, {
1275
- agentId: 'manager',
1276
- storyId: result.candidate.storyId || undefined,
1277
- eventType: 'PR_REJECTED',
1278
- status: 'warn',
1279
- message: `Auto-rejected stale review ${result.candidate.id}: ${reason}`,
1280
- metadata: {
1281
- pr_id: result.candidate.id,
1282
- github_pr_number: result.candidate.githubPrNumber,
1283
- github_state: result.githubState,
1284
- github_url: result.githubUrl,
1285
- branch: result.candidate.branchName,
1286
- team_id: result.candidate.teamId,
1287
- },
1288
- });
1289
- }, () => db.save());
1290
- }
1291
- });
1292
- // Sync merged stories to PM provider outside lock
1293
- const uniqueMergedStoryIds = Array.from(new Set(mergedStoryIds));
1294
- for (const storyId of uniqueMergedStoryIds) {
1295
- await ctx.withDb(async (db) => {
1296
- await syncStatusForStory(ctx.root, db.db, storyId, 'merged');
1297
- });
1298
- }
1299
- if (mergedResults.length > 0) {
1300
- console.log(chalk.green(` Auto-synced ${mergedResults.length} reviewing PR(s) that were already merged on GitHub`));
1301
- }
1302
- if (rejectedResults.length > 0) {
1303
- console.log(chalk.yellow(` Auto-rejected ${rejectedResults.length} stale reviewing PR(s) with non-open GitHub PR state`));
1304
- }
1305
- }
576
+ // syncMergedPRs, reconcileAgentsOnMergedStories, syncOpenPRs, closeStalePRs,
577
+ // recoverStaleReviewingPRs moved to ./pr-sync-orchestrator.ts
1306
578
  async function syncJiraStatuses(ctx) {
1307
579
  await ctx.withDb(async (db) => {
1308
580
  const syncedStories = await syncFromProvider(ctx.root, db.db);
@@ -1609,656 +881,10 @@ async function batchMarkMessagesRead(ctx) {
1609
881
  });
1610
882
  }
1611
883
  }
1612
- async function notifyQAOfQueuedPRs(ctx) {
1613
- // Phase 1: Read PR queue and assign reviews (brief lock)
1614
- const { queuedPRs, dispatched } = await ctx.withDb(async (db) => {
1615
- const openPRs = getMergeQueue(db.db);
1616
- verboseLogCtx(ctx, `notifyQAOfQueuedPRs: open=${openPRs.length}`);
1617
- const queued = openPRs.filter(pr => pr.status === 'queued');
1618
- const reviewing = openPRs.filter(pr => pr.status === 'reviewing');
1619
- ctx.counters.queuedPRCount = queued.length;
1620
- ctx.counters.reviewingPRCount = reviewing.length;
1621
- verboseLogCtx(ctx, `notifyQAOfQueuedPRs: queued=${queued.length}`);
1622
- if (queued.length === 0) {
1623
- return {
1624
- queuedPRs: [],
1625
- dispatched: [],
1626
- };
1627
- }
1628
- const reviewingSessions = new Set(openPRs
1629
- .filter(pr => pr.status === 'reviewing' && pr.reviewed_by)
1630
- .map(pr => pr.reviewed_by));
1631
- const idleQASessions = ctx.hiveSessions.filter(session => {
1632
- if (!session.name.includes('-qa-'))
1633
- return false;
1634
- if (reviewingSessions.has(session.name))
1635
- return false;
1636
- const agent = ctx.agentsBySessionName.get(session.name);
1637
- return Boolean(agent && agent.status === 'idle');
1638
- });
1639
- verboseLogCtx(ctx, `notifyQAOfQueuedPRs: idleQA=${idleQASessions.length}`);
1640
- const dispatchedList = [];
1641
- let dispatchCount = 0;
1642
- for (const qa of idleQASessions) {
1643
- const nextPR = queued[dispatchCount];
1644
- if (!nextPR)
1645
- break;
1646
- await withTransaction(db.db, () => {
1647
- updatePullRequest(db.db, nextPR.id, {
1648
- status: 'reviewing',
1649
- reviewedBy: qa.name,
1650
- });
1651
- createLog(db.db, {
1652
- agentId: qa.name,
1653
- storyId: nextPR.story_id || undefined,
1654
- eventType: 'PR_REVIEW_STARTED',
1655
- message: `Manager assigned PR review: ${nextPR.id}`,
1656
- metadata: { pr_id: nextPR.id, branch: nextPR.branch_name },
1657
- });
1658
- }, () => db.save());
1659
- dispatchedList.push({
1660
- prId: nextPR.id,
1661
- qaName: qa.name,
1662
- storyId: nextPR.story_id,
1663
- githubPrUrl: nextPR.github_pr_url,
1664
- });
1665
- dispatchCount++;
1666
- verboseLogCtx(ctx, `notifyQAOfQueuedPRs: assigned pr=${nextPR.id} -> ${qa.name}`);
1667
- }
1668
- return { queuedPRs: queued, dispatched: dispatchedList };
1669
- });
1670
- if (queuedPRs.length === 0)
1671
- return;
1672
- // Phase 2: Send tmux nudges (no lock needed)
1673
- for (const d of dispatched) {
1674
- const githubLine = d.githubPrUrl ? `\n# GitHub: ${d.githubPrUrl}` : '';
1675
- await sendManagerNudge(ctx, d.qaName, `# You are assigned PR review ${d.prId} (${d.storyId || 'no-story'}).${githubLine}
1676
- # Execute now:
1677
- # hive pr show ${d.prId}
1678
- # hive pr approve ${d.prId}
1679
- # (If manual merge is required in this repo, use --no-merge.)
1680
- # or reject:
1681
- # hive pr reject ${d.prId} -r "reason"`);
1682
- }
1683
- // Fallback nudge if PRs are still queued but all QA sessions are busy/unavailable.
1684
- if (dispatched.length === 0) {
1685
- verboseLogCtx(ctx, 'notifyQAOfQueuedPRs: no idle QA, sent queue nudge fallback');
1686
- const qaSessions = ctx.hiveSessions.filter(s => s.name.includes('-qa-'));
1687
- for (const qa of qaSessions) {
1688
- await sendManagerNudge(ctx, qa.name, `# ${queuedPRs.length} PR(s) waiting in queue. Run: hive pr queue`);
1689
- }
1690
- }
1691
- }
1692
- /**
1693
- * Auto-reject PRs where the QA agent posted review comments/feedback on GitHub
1694
- * but never formally approved or rejected via `hive pr approve/reject`.
1695
- *
1696
- * Detection: PR is in 'reviewing' status, the assigned QA agent is idle,
1697
- * and there are GitHub comments or CHANGES_REQUESTED reviews on the PR.
1698
- *
1699
- * Action: Auto-reject the PR with the QA's feedback as the rejection reason,
1700
- * which triggers the standard qa_failed flow back to the developer agent.
1701
- */
1702
- export async function autoRejectCommentOnlyReviews(ctx) {
1703
- // Phase 1: Identify reviewing PRs with idle QA agents (brief lock)
1704
- const candidates = await ctx.withDb(async (db) => {
1705
- const reviewingPRs = getPullRequestsByStatus(db.db, 'reviewing').filter(pr => pr.github_pr_number && pr.team_id && pr.reviewed_by);
1706
- verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: reviewingWithQA=${reviewingPRs.length}`);
1707
- if (reviewingPRs.length === 0)
1708
- return [];
1709
- // Only consider PRs whose QA agent is idle (finished reviewing but didn't approve/reject)
1710
- const idlePRs = reviewingPRs.filter(pr => {
1711
- const qaAgent = ctx.agentsBySessionName.get(pr.reviewed_by);
1712
- if (!qaAgent)
1713
- return false;
1714
- // Check if the QA agent is idle or if their session shows idle state
1715
- const qaState = agentStates.get(pr.reviewed_by);
1716
- return qaAgent.status === 'idle' || qaState?.lastState === AgentState.IDLE_AT_PROMPT;
1717
- });
1718
- verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: idleQACandidates=${idlePRs.length}`);
1719
- if (idlePRs.length === 0)
1720
- return [];
1721
- const { getAllTeams } = await import('../../../db/queries/teams.js');
1722
- const teams = getAllTeams(db.db);
1723
- const teamsById = new Map(teams.map(team => [team.id, team]));
1724
- return idlePRs
1725
- .map(pr => {
1726
- const team = teamsById.get(pr.team_id);
1727
- if (!team?.repo_path)
1728
- return null;
1729
- return {
1730
- id: pr.id,
1731
- storyId: pr.story_id,
1732
- teamId: pr.team_id,
1733
- branchName: pr.branch_name,
1734
- githubPrNumber: pr.github_pr_number,
1735
- reviewedBy: pr.reviewed_by,
1736
- submittedBy: pr.submitted_by,
1737
- repoDir: `${ctx.root}/${team.repo_path}`,
1738
- };
1739
- })
1740
- .filter(Boolean);
1741
- });
1742
- if (candidates.length === 0)
1743
- return;
1744
- // Phase 2: Check GitHub for comments/reviews on each candidate (no lock)
1745
- const toReject = [];
1746
- for (const candidate of candidates) {
1747
- try {
1748
- // Fetch both reviews and comments from GitHub
1749
- const [reviews, comments] = await Promise.all([
1750
- getPullRequestReviews(candidate.repoDir, candidate.githubPrNumber).catch(() => []),
1751
- getPullRequestComments(candidate.repoDir, candidate.githubPrNumber).catch(() => []),
1752
- ]);
1753
- // If there's a formal APPROVED review, skip (QA approved via GitHub directly)
1754
- const hasApproval = reviews.some(r => r.state === 'APPROVED');
1755
- if (hasApproval) {
1756
- verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: pr=${candidate.id} has GitHub approval, skipping`);
1757
- continue;
1758
- }
1759
- // Check for CHANGES_REQUESTED reviews
1760
- const changesRequested = reviews.filter(r => r.state === 'CHANGES_REQUESTED');
1761
- // Check for substantive issue comments (filter out bot noise and very short comments)
1762
- const substantiveComments = comments.filter(c => {
1763
- if (c.body.length < 20)
1764
- return false;
1765
- // Skip known bot comments (Ellipsis, etc.)
1766
- if (c.body.includes('Looks good to me') && c.body.length < 100)
1767
- return false;
1768
- return true;
1769
- });
1770
- // If there are review feedback items, auto-reject
1771
- if (changesRequested.length > 0 || substantiveComments.length > 0) {
1772
- // Build rejection reason from the feedback
1773
- const feedbackParts = [];
1774
- for (const review of changesRequested) {
1775
- if (review.body)
1776
- feedbackParts.push(review.body);
1777
- }
1778
- for (const comment of substantiveComments) {
1779
- feedbackParts.push(comment.body);
1780
- }
1781
- const reason = feedbackParts.length > 0
1782
- ? feedbackParts.join('\n---\n').slice(0, 2000)
1783
- : 'QA posted review feedback on GitHub without formal approval. See PR comments.';
1784
- toReject.push({ candidate, reason });
1785
- verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: pr=${candidate.id} has ${changesRequested.length} changes_requested + ${substantiveComments.length} comments, will auto-reject`);
1786
- }
1787
- }
1788
- catch (err) {
1789
- verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: skip pr=${candidate.id} github_check_failed=${err instanceof Error ? err.message : String(err)}`);
1790
- }
1791
- }
1792
- if (toReject.length === 0)
1793
- return;
1794
- // Phase 3: Reject PRs in DB (brief lock)
1795
- await ctx.withDb(async (db) => {
1796
- for (const { candidate, reason } of toReject) {
1797
- await withTransaction(db.db, () => {
1798
- updatePullRequest(db.db, candidate.id, {
1799
- status: 'rejected',
1800
- reviewNotes: reason,
1801
- });
1802
- if (candidate.storyId) {
1803
- updateStory(db.db, candidate.storyId, { status: 'qa_failed' });
1804
- }
1805
- createLog(db.db, {
1806
- agentId: 'manager',
1807
- eventType: 'PR_REJECTED',
1808
- message: `Auto-rejected PR ${candidate.id}: QA posted review comments without formal approve/reject`,
1809
- storyId: candidate.storyId || undefined,
1810
- metadata: { pr_id: candidate.id, auto_rejected: true },
1811
- });
1812
- }, () => db.save());
1813
- console.log(chalk.yellow(` Auto-rejected PR ${candidate.id} (story: ${candidate.storyId || '-'}): QA left review comments without approving`));
1814
- }
1815
- });
1816
- // Phase 4: Notify developer agents via tmux (no lock)
1817
- for (const { candidate, reason } of toReject) {
1818
- if (candidate.submittedBy) {
1819
- const devSession = ctx.hiveSessions.find(s => s.name === candidate.submittedBy);
1820
- if (devSession) {
1821
- await sendManagerNudge(ctx, devSession.name, `# ⚠️ PR AUTO-REJECTED - QA REVIEW FEEDBACK ⚠️
1822
- # Story: ${candidate.storyId || 'Unknown'}
1823
- # QA agent (${candidate.reviewedBy}) posted review feedback without formally approving.
1824
- # Feedback:
1825
- # ${reason.split('\n').slice(0, 10).join('\n# ')}
1826
- #
1827
- # Fix the issues and resubmit: hive pr submit -b ${candidate.branchName} -s ${candidate.storyId || 'STORY-ID'} --from ${devSession.name}`);
1828
- }
1829
- }
1830
- }
1831
- }
1832
- async function handleRejectedPRs(ctx) {
1833
- // Phase 1: Read rejected PRs and update DB (brief lock)
1834
- const rejectedPRData = await ctx.withDb(async (db) => {
1835
- const rejectedPRs = getPullRequestsByStatus(db.db, 'rejected');
1836
- verboseLogCtx(ctx, `handleRejectedPRs: rejected=${rejectedPRs.length}`);
1837
- if (rejectedPRs.length === 0)
1838
- return [];
1839
- const prData = [];
1840
- for (const pr of rejectedPRs) {
1841
- if (pr.story_id) {
1842
- const storyId = pr.story_id;
1843
- await withTransaction(db.db, () => {
1844
- updateStory(db.db, storyId, { status: 'qa_failed' });
1845
- createLog(db.db, {
1846
- agentId: 'manager',
1847
- eventType: 'STORY_QA_FAILED',
1848
- message: `Story ${storyId} QA failed: ${pr.review_notes || 'See review comments'}`,
1849
- storyId: storyId,
1850
- });
1851
- }, () => db.save());
1852
- // Sync status change to Jira
1853
- await syncStatusForStory(ctx.root, db.db, storyId, 'qa_failed');
1854
- }
1855
- // Mark as closed to prevent re-notification spam
1856
- await withTransaction(db.db, () => {
1857
- updatePullRequest(db.db, pr.id, { status: 'closed' });
1858
- }, () => db.save());
1859
- prData.push({
1860
- id: pr.id,
1861
- storyId: pr.story_id,
1862
- branchName: pr.branch_name,
1863
- reviewNotes: pr.review_notes,
1864
- submittedBy: pr.submitted_by,
1865
- });
1866
- }
1867
- return prData;
1868
- });
1869
- if (rejectedPRData.length === 0)
1870
- return;
1871
- // Phase 2: Send tmux notifications (no lock needed)
1872
- let rejectionNotified = 0;
1873
- for (const pr of rejectedPRData) {
1874
- if (pr.submittedBy) {
1875
- const devSession = ctx.hiveSessions.find(s => s.name === pr.submittedBy);
1876
- if (devSession) {
1877
- verboseLogCtx(ctx, `handleRejectedPRs: notifying ${devSession.name} for pr=${pr.id}, story=${pr.storyId || '-'}`);
1878
- await sendManagerNudge(ctx, devSession.name, `# ⚠️ PR REJECTED - ACTION REQUIRED ⚠️
1879
- # Story: ${pr.storyId || 'Unknown'}
1880
- # Reason: ${pr.reviewNotes || 'See review comments'}
1881
- #
1882
- # You MUST fix this issue before doing anything else.
1883
- # Fix the issues and resubmit: hive pr submit -b ${pr.branchName} -s ${pr.storyId || 'STORY-ID'} --from ${devSession.name}`);
1884
- rejectionNotified++;
1885
- }
1886
- }
1887
- }
1888
- console.log(chalk.yellow(` Notified ${rejectionNotified} developer(s) of PR rejection(s)`));
1889
- }
1890
- async function nudgeQAFailedStories(ctx) {
1891
- // Phase 1: Read QA-failed stories and agents (brief lock)
1892
- const candidates = await ctx.withDb(async (db) => {
1893
- const qaFailedStories = getStoriesByStatus(db.db, 'qa_failed').filter(story => !['merged', 'completed'].includes(story.status));
1894
- verboseLogCtx(ctx, `nudgeQAFailedStories: candidates=${qaFailedStories.length}`);
1895
- const result = [];
1896
- for (const story of qaFailedStories) {
1897
- if (!story.assigned_agent_id) {
1898
- verboseLogCtx(ctx, `nudgeQAFailedStories: story=${story.id} skip=no_assigned_agent`);
1899
- continue;
1900
- }
1901
- const agent = getAgentById(db.db, story.assigned_agent_id);
1902
- if (!agent || agent.status !== 'working') {
1903
- verboseLogCtx(ctx, `nudgeQAFailedStories: story=${story.id} skip=agent_not_working status=${agent?.status || 'missing'}`);
1904
- continue;
1905
- }
1906
- const agentSession = findSessionForAgent(ctx.hiveSessions, agent);
1907
- if (!agentSession) {
1908
- verboseLogCtx(ctx, `nudgeQAFailedStories: story=${story.id} skip=no_session`);
1909
- continue;
1910
- }
1911
- result.push({
1912
- storyId: story.id,
1913
- sessionName: agentSession.name,
1914
- cliTool: (agent.cli_tool || 'claude'),
1915
- });
1916
- }
1917
- return result;
1918
- });
1919
- // Phase 2: Tmux captures and nudges (no lock needed)
1920
- for (const candidate of candidates) {
1921
- const output = await captureTmuxPane(candidate.sessionName, TMUX_CAPTURE_LINES_SHORT);
1922
- const stateResult = detectAgentState(output, candidate.cliTool);
1923
- if (stateResult.isWaiting &&
1924
- !stateResult.needsHuman &&
1925
- stateResult.state !== AgentState.THINKING) {
1926
- verboseLogCtx(ctx, `nudgeQAFailedStories: story=${candidate.storyId} nudge session=${candidate.sessionName} state=${stateResult.state}`);
1927
- await sendManagerNudge(ctx, candidate.sessionName, `# REMINDER: Story ${candidate.storyId} failed QA review!
1928
- # You must fix the issues and resubmit the PR.
1929
- # Check the QA feedback and address all concerns.
1930
- hive pr queue`);
1931
- }
1932
- else {
1933
- verboseLogCtx(ctx, `nudgeQAFailedStories: story=${candidate.storyId} skip=not_ready waiting=${stateResult.isWaiting} needsHuman=${stateResult.needsHuman} state=${stateResult.state}`);
1934
- }
1935
- }
1936
- }
1937
- async function recoverUnassignedQAFailedStories(ctx) {
1938
- const result = await ctx.withDb(async (db, scheduler) => {
1939
- const recoverableStories = queryAll(db.db, `
1940
- SELECT * FROM stories
1941
- WHERE status = 'qa_failed'
1942
- AND assigned_agent_id IS NULL
1943
- `);
1944
- if (recoverableStories.length === 0)
1945
- return null;
1946
- verboseLogCtx(ctx, `recoverUnassignedQAFailedStories: recovered=${recoverableStories.length}`);
1947
- await withTransaction(db.db, () => {
1948
- for (const story of recoverableStories) {
1949
- updateStory(db.db, story.id, { status: 'planned', assignedAgentId: null });
1950
- createLog(db.db, {
1951
- agentId: 'manager',
1952
- storyId: story.id,
1953
- eventType: 'ORPHANED_STORY_RECOVERED',
1954
- message: `Recovered QA-failed story ${story.id} (unassigned) back to planned`,
1955
- metadata: { from_status: 'qa_failed', to_status: 'planned' },
1956
- });
1957
- }
1958
- }, () => db.save());
1959
- for (const story of recoverableStories) {
1960
- await syncStatusForStory(ctx.root, db.db, story.id, 'planned');
1961
- }
1962
- // Proactively re-assign recovered work so it does not stall until manual `hive assign`.
1963
- const assignmentResult = await scheduler.assignStories();
1964
- verboseLogCtx(ctx, `recoverUnassignedQAFailedStories.assignStories: assigned=${assignmentResult.assigned}, errors=${assignmentResult.errors.length}`);
1965
- db.save();
1966
- if (assignmentResult.assigned > 0) {
1967
- await scheduler.flushJiraQueue();
1968
- db.save();
1969
- }
1970
- return { recoverableCount: recoverableStories.length, assignmentResult };
1971
- });
1972
- if (result) {
1973
- console.log(chalk.yellow(` Recovered ${result.recoverableCount} QA-failed unassigned story(ies), assigned ${result.assignmentResult.assigned}`));
1974
- if (result.assignmentResult.errors.length > 0) {
1975
- console.log(chalk.yellow(` Assignment errors during QA-failed recovery: ${result.assignmentResult.errors.length}`));
1976
- }
1977
- }
1978
- }
1979
- async function nudgeStuckStories(ctx) {
1980
- const stuckThresholdMs = Math.max(1, ctx.config.manager.stuck_threshold_ms);
1981
- const staticInactivityThresholdMs = getScreenStaticInactivityThresholdMs(ctx.config);
1982
- const maxStuckNudgesPerStory = getMaxStuckNudgesPerStory(ctx.config);
1983
- const waitingNudgeCooldownMs = Math.max(ctx.config.manager.nudge_cooldown_ms, staticInactivityThresholdMs);
1984
- const staleUpdatedAt = new Date(Date.now() - stuckThresholdMs).toISOString();
1985
- // Phase 1: Read stuck stories and agents (brief lock)
1986
- const candidates = await ctx.withDb(async (db) => {
1987
- const stuckStories = queryAll(db.db, `SELECT * FROM stories
1988
- WHERE status = 'in_progress'
1989
- AND updated_at < ?`, [staleUpdatedAt]).filter(story => !['merged', 'completed'].includes(story.status));
1990
- verboseLogCtx(ctx, `nudgeStuckStories: candidates=${stuckStories.length}, staleBefore=${staleUpdatedAt}, thresholdMs=${stuckThresholdMs}`);
1991
- const result = [];
1992
- for (const story of stuckStories) {
1993
- verboseLogCtx(ctx, `nudgeStuckStories: evaluating story=${story.id}`);
1994
- if (!story.assigned_agent_id) {
1995
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=no_assigned_agent`);
1996
- continue;
1997
- }
1998
- const agent = getAgentById(db.db, story.assigned_agent_id);
1999
- if (!agent) {
2000
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=missing_agent`);
2001
- continue;
2002
- }
2003
- const agentSession = findSessionForAgent(ctx.hiveSessions, agent);
2004
- if (!agentSession) {
2005
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=no_agent_session`);
2006
- continue;
2007
- }
2008
- result.push({
2009
- story,
2010
- agent,
2011
- sessionName: agentSession.name,
2012
- cliTool: (agent.cli_tool || 'claude'),
2013
- });
2014
- }
2015
- return result;
2016
- });
2017
- // Phase 2: Tmux captures, AI classifier, nudges (no lock held)
2018
- for (const candidate of candidates) {
2019
- const { story, agent, sessionName, cliTool } = candidate;
2020
- const now = Date.now();
2021
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} session=${sessionName} cli=${cliTool}`);
2022
- const trackedState = agentStates.get(sessionName);
2023
- if (trackedState &&
2024
- [
2025
- AgentState.ASKING_QUESTION,
2026
- AgentState.AWAITING_SELECTION,
2027
- AgentState.PLAN_APPROVAL,
2028
- AgentState.PERMISSION_REQUIRED,
2029
- AgentState.USER_DECLINED,
2030
- ].includes(trackedState.lastState)) {
2031
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=waiting_for_human state=${trackedState.lastState}`);
2032
- continue;
2033
- }
2034
- if (trackedState && now - trackedState.lastNudgeTime < waitingNudgeCooldownMs) {
2035
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=nudge_to_ai_window remainingMs=${waitingNudgeCooldownMs - (now - trackedState.lastNudgeTime)}`);
2036
- continue;
2037
- }
2038
- const output = await captureTmuxPane(sessionName, TMUX_CAPTURE_LINES_SHORT);
2039
- const stateResult = detectAgentState(output, cliTool);
2040
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} detected state=${stateResult.state}, waiting=${stateResult.isWaiting}, needsHuman=${stateResult.needsHuman}`);
2041
- if (stateResult.needsHuman) {
2042
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=needs_human`);
2043
- continue;
2044
- }
2045
- const sessionUnchangedForMs = getSessionStaticUnchangedForMs(sessionName, now);
2046
- const unknownLooksStuck = shouldTreatUnknownAsStuckWaiting({
2047
- state: stateResult.state,
2048
- isWaiting: stateResult.isWaiting,
2049
- sessionUnchangedForMs,
2050
- staticInactivityThresholdMs,
2051
- });
2052
- if (stateResult.state === AgentState.THINKING) {
2053
- if (trackedState && (trackedState.storyStuckNudgeCount || 0) > 0) {
2054
- trackedState.storyStuckNudgeCount = 0;
2055
- }
2056
- clearHumanIntervention(sessionName);
2057
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=thinking state=${stateResult.state}`);
2058
- continue;
2059
- }
2060
- if (!stateResult.isWaiting && !unknownLooksStuck) {
2061
- if (trackedState && (trackedState.storyStuckNudgeCount || 0) > 0) {
2062
- trackedState.storyStuckNudgeCount = 0;
2063
- }
2064
- clearHumanIntervention(sessionName);
2065
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=not_waiting state=${stateResult.state}`);
2066
- continue;
2067
- }
2068
- if (unknownLooksStuck) {
2069
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} action=unknown_state_stuck_heuristic unchangedMs=${sessionUnchangedForMs}`);
2070
- }
2071
- if (shouldDeferStuckReminderUntilStaticWindow({
2072
- state: stateResult.state,
2073
- sessionUnchangedForMs,
2074
- staticInactivityThresholdMs,
2075
- })) {
2076
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=done_inference_static_window remainingMs=${staticInactivityThresholdMs - sessionUnchangedForMs}`);
2077
- continue;
2078
- }
2079
- else {
2080
- const completionAssessment = await assessCompletionFromOutput(ctx.config, sessionName, story.id, output);
2081
- const aiSaysDone = completionAssessment.done &&
2082
- completionAssessment.confidence >= DONE_INFERENCE_CONFIDENCE_THRESHOLD;
2083
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} doneInference done=${completionAssessment.done}, confidence=${completionAssessment.confidence.toFixed(2)}, aiSaysDone=${aiSaysDone}, reason=${completionAssessment.reason}`);
2084
- if (isClassifierTimeoutReason(completionAssessment.reason)) {
2085
- await markClassifierTimeoutForHumanIntervention(ctx, sessionName, story.id, completionAssessment.reason, agent.id);
2086
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} action=classifier_timeout_escalation session=${sessionName}`);
2087
- continue;
2088
- }
2089
- clearHumanIntervention(sessionName);
2090
- if (aiSaysDone) {
2091
- const progressed = await autoProgressDoneStory(ctx, story, agent, sessionName, completionAssessment.reason, completionAssessment.confidence);
2092
- if (progressed) {
2093
- ctx.counters.autoProgressed++;
2094
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} action=auto_progressed`);
2095
- continue;
2096
- }
2097
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} auto_progress_failed`);
2098
- }
2099
- else {
2100
- const stuckNudgesSent = trackedState?.storyStuckNudgeCount || 0;
2101
- if (stuckNudgesSent >= maxStuckNudgesPerStory) {
2102
- await markDoneFalseForHumanIntervention(ctx, sessionName, story.id, completionAssessment.reason, agent.id);
2103
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} action=done_false_escalation session=${sessionName}`);
2104
- continue;
2105
- }
2106
- }
2107
- }
2108
- const stuckNudgesSent = trackedState?.storyStuckNudgeCount || 0;
2109
- if (stuckNudgesSent >= maxStuckNudgesPerStory) {
2110
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=stuck_nudge_limit reached=${stuckNudgesSent}/${maxStuckNudgesPerStory}`);
2111
- continue;
2112
- }
2113
- if (stateResult.state === AgentState.WORK_COMPLETE) {
2114
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} action=mandatory_completion_signal session=${sessionName}`);
2115
- const completionSignalLines = [
2116
- `# MANDATORY COMPLETION SIGNAL: execute now for ${story.id}`,
2117
- `hive pr submit -b $(git rev-parse --abbrev-ref HEAD) -s ${story.id} --from ${sessionName}`,
2118
- `hive my-stories complete ${story.id}`,
2119
- ];
2120
- if (shouldIncludeProgressUpdates(ctx.config)) {
2121
- completionSignalLines.push(`hive progress ${story.id} -m "PR submitted to merge queue" --from ${sessionName} --done`);
2122
- }
2123
- else {
2124
- completionSignalLines.push('# project_management.provider is none; skip hive progress in this workspace.');
2125
- }
2126
- completionSignalLines.push('# Do not stop at a summary. Completion requires the commands above.');
2127
- await sendManagerNudge(ctx, sessionName, completionSignalLines.join('\n'));
2128
- ctx.counters.nudged++;
2129
- if (trackedState) {
2130
- trackedState.lastNudgeTime = now;
2131
- trackedState.storyStuckNudgeCount = (trackedState.storyStuckNudgeCount || 0) + 1;
2132
- }
2133
- else {
2134
- agentStates.set(sessionName, {
2135
- lastState: stateResult.state,
2136
- lastStateChangeTime: now,
2137
- lastNudgeTime: now,
2138
- storyStuckNudgeCount: 1,
2139
- });
2140
- }
2141
- continue;
2142
- }
2143
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} action=stuck_reminder session=${sessionName}`);
2144
- await sendManagerNudge(ctx, sessionName, `# REMINDER: Story ${story.id} has been in progress for a while.
2145
- # If stuck, escalate to your Senior or Tech Lead.
2146
- # If done, submit your PR: hive pr submit -b $(git rev-parse --abbrev-ref HEAD) -s ${story.id} --from ${sessionName}
2147
- # Then mark complete: hive my-stories complete ${story.id}`);
2148
- ctx.counters.nudged++;
2149
- if (trackedState) {
2150
- trackedState.lastNudgeTime = now;
2151
- trackedState.storyStuckNudgeCount = (trackedState.storyStuckNudgeCount || 0) + 1;
2152
- }
2153
- else {
2154
- agentStates.set(sessionName, {
2155
- lastState: stateResult.state,
2156
- lastStateChangeTime: now,
2157
- lastNudgeTime: now,
2158
- storyStuckNudgeCount: 1,
2159
- });
2160
- }
2161
- }
2162
- }
2163
- async function autoProgressDoneStory(ctx, story, agent, sessionName, reason, confidence) {
2164
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id}, session=${sessionName}, confidence=${confidence.toFixed(2)}`);
2165
- // Resolve branch name outside lock (involves git operations)
2166
- const branch = await resolveStoryBranchName(ctx.root, story, agent, msg => verboseLogCtx(ctx, `resolveStoryBranchName: story=${story.id} ${msg}`));
2167
- // DB operations under brief lock
2168
- const action = await ctx.withDb(async (db, scheduler) => {
2169
- const openPRs = getOpenPullRequestsByStory(db.db, story.id);
2170
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id}, openPRs=${openPRs.length}`);
2171
- if (openPRs.length > 0) {
2172
- if (story.status !== 'pr_submitted') {
2173
- updateStory(db.db, story.id, { status: 'pr_submitted' });
2174
- createLog(db.db, {
2175
- agentId: 'manager',
2176
- storyId: story.id,
2177
- eventType: 'STORY_PROGRESS_UPDATE',
2178
- message: `Auto-progressed ${story.id} to pr_submitted (existing PR detected)`,
2179
- metadata: {
2180
- session_name: sessionName,
2181
- recovery: 'done_inference_existing_pr',
2182
- reason,
2183
- confidence,
2184
- open_pr_count: openPRs.length,
2185
- },
2186
- });
2187
- db.save();
2188
- await syncStatusForStory(ctx.root, db.db, story.id, 'pr_submitted');
2189
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} status moved to pr_submitted`);
2190
- }
2191
- return 'existing_pr';
2192
- }
2193
- if (!branch) {
2194
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} action=failed_no_branch`);
2195
- return 'no_branch';
2196
- }
2197
- await withTransaction(db.db, () => {
2198
- updateStory(db.db, story.id, { status: 'pr_submitted', branchName: branch });
2199
- createPullRequest(db.db, {
2200
- storyId: story.id,
2201
- teamId: story.team_id || null,
2202
- branchName: branch,
2203
- submittedBy: sessionName,
2204
- });
2205
- createLog(db.db, {
2206
- agentId: 'manager',
2207
- storyId: story.id,
2208
- eventType: 'PR_SUBMITTED',
2209
- message: `Auto-submitted PR for ${story.id} after AI completion inference`,
2210
- metadata: {
2211
- session_name: sessionName,
2212
- recovery: 'done_inference_auto_submit',
2213
- reason,
2214
- confidence,
2215
- branch,
2216
- },
2217
- });
2218
- }, () => db.save());
2219
- await syncStatusForStory(ctx.root, db.db, story.id, 'pr_submitted');
2220
- await scheduler.checkMergeQueue();
2221
- db.save();
2222
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} action=auto_submitted branch=${branch}`);
2223
- return 'auto_submitted';
2224
- });
2225
- // Tmux notifications (no lock needed)
2226
- if (action === 'existing_pr') {
2227
- await sendManagerNudge(ctx, sessionName, `# AUTO-PROGRESS: Manager inferred ${story.id} is complete (confidence ${confidence.toFixed(2)}), detected existing PR, and moved story to PR-submitted state.`);
2228
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} action=existing_pr_progressed`);
2229
- return true;
2230
- }
2231
- if (action === 'no_branch') {
2232
- return false;
2233
- }
2234
- await sendManagerNudge(ctx, sessionName, `# AUTO-PROGRESS: Manager inferred ${story.id} is complete (confidence ${confidence.toFixed(2)}), auto-submitted branch ${branch} to merge queue.`);
2235
- return true;
2236
- }
2237
- async function resolveStoryBranchName(root, story, agent, log) {
2238
- if (story.branch_name && story.branch_name.trim().length > 0) {
2239
- log?.(`source=story.branch_name value=${story.branch_name.trim()}`);
2240
- return story.branch_name.trim();
2241
- }
2242
- if (!agent.worktree_path) {
2243
- log?.('source=worktree skip=no_worktree_path');
2244
- return null;
2245
- }
2246
- const worktreeDir = join(root, agent.worktree_path);
2247
- try {
2248
- const result = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: worktreeDir });
2249
- const branch = result.stdout.trim();
2250
- if (!branch || branch === 'HEAD') {
2251
- log?.(`source=git_rev_parse invalid_branch=${branch || '(empty)'}`);
2252
- return null;
2253
- }
2254
- log?.(`source=git_rev_parse value=${branch}`);
2255
- return branch;
2256
- }
2257
- catch {
2258
- log?.(`source=git_rev_parse failed cwd=${worktreeDir}`);
2259
- return null;
2260
- }
2261
- }
884
+ // notifyQAOfQueuedPRs, autoRejectCommentOnlyReviews, handleRejectedPRs
885
+ // moved to ./qa-review-handler.ts
886
+ // nudgeQAFailedStories, recoverUnassignedQAFailedStories, nudgeStuckStories,
887
+ // autoProgressDoneStory, resolveStoryBranchName moved to ./stuck-story-processor.ts
2262
888
  async function notifyUnassignedStories(ctx) {
2263
889
  // Phase 1: Read planned unassigned stories (brief lock)
2264
890
  const plannedCount = await ctx.withDb(async (db) => {
@@ -2287,129 +913,7 @@ async function notifyUnassignedStories(ctx) {
2287
913
  }
2288
914
  }
2289
915
  }
2290
- async function restartStaleTechLead(ctx) {
2291
- const maxAgeHours = ctx.config.manager.tech_lead_max_age_hours;
2292
- const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
2293
- const now = Date.now();
2294
- // Phase 1: Read tech lead agents (brief lock)
2295
- const techLeads = await ctx.withDb(async (db) => {
2296
- const leads = getAgentsByType(db.db, 'tech_lead');
2297
- verboseLogCtx(ctx, `restartStaleTechLead: found ${leads.length} tech lead agent(s)`);
2298
- return leads.map(tl => ({
2299
- id: tl.id,
2300
- tmuxSession: tl.tmux_session,
2301
- cliTool: (tl.cli_tool || 'claude'),
2302
- createdAt: tl.created_at,
2303
- }));
2304
- });
2305
- // Phase 2: Check sessions and restart (tmux I/O outside lock, DB writes under brief lock)
2306
- for (const techLead of techLeads) {
2307
- if (!techLead.tmuxSession) {
2308
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} skip=no_tmux_session`);
2309
- continue;
2310
- }
2311
- const sessionRunning = await isTmuxSessionRunning(techLead.tmuxSession);
2312
- if (!sessionRunning) {
2313
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} skip=session_not_running session=${techLead.tmuxSession}`);
2314
- continue;
2315
- }
2316
- const createdAt = new Date(techLead.createdAt).getTime();
2317
- const ageMs = now - createdAt;
2318
- const ageHours = ageMs / (60 * 60 * 1000);
2319
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} age=${ageHours.toFixed(2)}h threshold=${maxAgeHours}h`);
2320
- if (ageMs < maxAgeMs) {
2321
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} skip=not_stale remainingMs=${maxAgeMs - ageMs}`);
2322
- continue;
2323
- }
2324
- const cooldown = isTechLeadRestartOnCooldown(techLeadLastRestartByAgentId.get(techLead.id), now, maxAgeHours);
2325
- if (cooldown.onCooldown) {
2326
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} skip=cooldown cooldownHours=${cooldown.cooldownHours} remainingMs=${cooldown.remainingMs}`);
2327
- continue;
2328
- }
2329
- const output = await captureTmuxPane(techLead.tmuxSession, TMUX_CAPTURE_LINES_SHORT);
2330
- const stateResult = detectAgentState(output, techLead.cliTool);
2331
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} state=${stateResult.state} waiting=${stateResult.isWaiting} needsHuman=${stateResult.needsHuman}`);
2332
- if (!stateResult.isWaiting ||
2333
- stateResult.needsHuman ||
2334
- stateResult.state === AgentState.THINKING) {
2335
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} skip=not_safe_state state=${stateResult.state}`);
2336
- continue;
2337
- }
2338
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} action=restarting session=${techLead.tmuxSession}`);
2339
- // Kill the existing session (tmux I/O, no lock)
2340
- await killTmuxSession(techLead.tmuxSession);
2341
- // Spawn a new session with the same configuration (tmux I/O, no lock)
2342
- const hiveRoot = findHiveRootFromDir(ctx.root);
2343
- if (!hiveRoot) {
2344
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} error=hive_root_not_found`);
2345
- continue;
2346
- }
2347
- const paths = getHivePaths(hiveRoot);
2348
- const config = loadConfig(paths.hiveDir);
2349
- const agentConfig = config.models.tech_lead;
2350
- const cliTool = agentConfig.cli_tool;
2351
- const safetyMode = agentConfig.safety_mode;
2352
- const model = resolveRuntimeModelForCli(agentConfig.model, cliTool);
2353
- const runtimeBuilder = getCliRuntimeBuilder(cliTool);
2354
- const commandArgs = runtimeBuilder.buildSpawnCommand(model, safetyMode);
2355
- // Look up active requirement and teams to provide context to the restarted tech lead
2356
- const initialPrompt = await ctx.withDb(async (db) => {
2357
- const planningReqs = getRequirementsByStatus(db.db, 'planning');
2358
- const inProgressReqs = getRequirementsByStatus(db.db, 'in_progress');
2359
- const activeReq = planningReqs[0] ?? inProgressReqs[0] ?? null;
2360
- const teams = getAllTeams(db.db);
2361
- if (activeReq) {
2362
- return generateTechLeadPrompt(activeReq.id, activeReq.title, activeReq.description, teams, activeReq.godmode === 1, activeReq.target_branch || 'main');
2363
- }
2364
- return `You are the Tech Lead of Hive, an AI development team orchestrator.
2365
-
2366
- You have been restarted to refresh your context. No active requirement is currently being planned.
2367
-
2368
- ## Next Steps
2369
-
2370
- 1. Check the current status of the Hive workspace:
2371
- \`\`\`bash
2372
- hive status
2373
- \`\`\`
2374
-
2375
- 2. Check your inbox for messages from developers:
2376
- \`\`\`bash
2377
- hive msg inbox hive-tech-lead
2378
- \`\`\`
2379
-
2380
- 3. If there are pending requirements, begin planning them. If all work is complete, monitor for new requirements.`;
2381
- });
2382
- await spawnTmuxSession({
2383
- sessionName: techLead.tmuxSession,
2384
- workDir: ctx.root,
2385
- commandArgs,
2386
- initialPrompt,
2387
- });
2388
- // DB writes under brief lock
2389
- await ctx.withDb(async (db) => {
2390
- createLog(db.db, {
2391
- agentId: 'manager',
2392
- eventType: 'AGENT_SPAWNED',
2393
- status: 'info',
2394
- message: `Tech lead ${techLead.id} restarted for context freshness (age: ${ageHours.toFixed(1)}h)`,
2395
- metadata: {
2396
- agent_id: techLead.id,
2397
- tmux_session: techLead.tmuxSession,
2398
- age_hours: ageHours,
2399
- threshold_hours: maxAgeHours,
2400
- restart_reason: 'context_freshness',
2401
- },
2402
- });
2403
- updateAgent(db.db, techLead.id, {
2404
- status: 'working',
2405
- createdAt: new Date().toISOString(),
2406
- });
2407
- db.save();
2408
- });
2409
- techLeadLastRestartByAgentId.set(techLead.id, now);
2410
- console.log(chalk.green(` Tech lead ${techLead.id} restarted for context freshness (age: ${ageHours.toFixed(1)}h)`));
2411
- }
2412
- }
916
+ // restartStaleTechLead moved to ./tech-lead-lifecycle.ts
2413
917
  async function printSummary(ctx) {
2414
918
  const { escalationsCreated, escalationsResolved, nudged, nudgeEnterPresses, nudgeEnterRetries, nudgeSubmitUnconfirmed, autoProgressed, messagesForwarded, queuedPRCount, reviewingPRCount, handoffPromoted, handoffAutoAssigned, plannedAutoAssigned, jiraSynced, featureTestsSpawned, auditorsSpawned, } = ctx.counters;
2415
919
  const summary = [];