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
@@ -3,49 +3,24 @@
3
3
  import chalk from 'chalk';
4
4
  import { Command } from 'commander';
5
5
  import { createHash } from 'crypto';
6
- import { execa } from 'execa';
7
6
  import { join } from 'path';
8
- import { getCliRuntimeBuilder, resolveRuntimeModelForCli } from '../../../cli-runtimes/index.js';
9
7
  import { ClusterRuntime, fetchLocalClusterStatus } from '../../../cluster/runtime.js';
10
8
  import { loadConfig } from '../../../config/loader.js';
11
9
  import type { HiveConfig } from '../../../config/schema.js';
12
- import {
13
- syncFromProvider,
14
- syncStatusForStory,
15
- } from '../../../connectors/project-management/operations.js';
10
+ import { syncFromProvider } from '../../../connectors/project-management/operations.js';
16
11
  import type { StoryRow } from '../../../db/client.js';
17
12
  import { queryAll, queryOne, withTransaction } from '../../../db/client.js';
18
13
  import { acquireLock } from '../../../db/lock.js';
19
- import {
20
- getAgentById,
21
- getAgentsByType,
22
- getAllAgents,
23
- updateAgent,
24
- } from '../../../db/queries/agents.js';
25
- import {
26
- createEscalation,
27
- getActiveEscalationsForAgent,
28
- getPendingEscalations,
29
- updateEscalation,
30
- } from '../../../db/queries/escalations.js';
14
+ import { getAgentById, getAllAgents } from '../../../db/queries/agents.js';
15
+ import { getPendingEscalations, updateEscalation } from '../../../db/queries/escalations.js';
31
16
  import { createLog } from '../../../db/queries/logs.js';
32
17
  import {
33
18
  getAllPendingMessages,
34
19
  markMessagesRead,
35
20
  type MessageRow,
36
21
  } from '../../../db/queries/messages.js';
37
- import {
38
- backfillGithubPrNumbers,
39
- createPullRequest,
40
- getMergeQueue,
41
- getOpenPullRequestsByStory,
42
- getPullRequestsByStatus,
43
- updatePullRequest,
44
- } from '../../../db/queries/pull-requests.js';
45
- import { getRequirementsByStatus } from '../../../db/queries/requirements.js';
46
- import { getStoriesByStatus, getStoryById, updateStory } from '../../../db/queries/stories.js';
47
- import { getAllTeams } from '../../../db/queries/teams.js';
48
- import { getPullRequestComments, getPullRequestReviews } from '../../../git/github.js';
22
+ import { backfillGithubPrNumbers } from '../../../db/queries/pull-requests.js';
23
+ import { getStoryById } from '../../../db/queries/stories.js';
49
24
  import { Scheduler } from '../../../orchestrator/scheduler.js';
50
25
  import { AgentState } from '../../../state-detectors/types.js';
51
26
  import {
@@ -54,25 +29,14 @@ import {
54
29
  isManagerRunning,
55
30
  isTmuxSessionRunning,
56
31
  killTmuxSession,
57
- sendToTmuxSession,
58
- spawnTmuxSession,
59
32
  stopManager as stopManagerSession,
60
33
  } from '../../../tmux/manager.js';
61
34
  import type { WithLockFn } from '../../../utils/auto-merge.js';
62
35
  import { autoMergeApprovedPRs } from '../../../utils/auto-merge.js';
63
36
  import type { CLITool } from '../../../utils/cli-commands.js';
64
- import { findHiveRoot as findHiveRootFromDir, getHivePaths } from '../../../utils/paths.js';
65
- import {
66
- fetchOpenGitHubPRs,
67
- getExistingPRIdentifiers,
68
- ghRepoSlug,
69
- } from '../../../utils/pr-sync.js';
70
- import { extractStoryIdFromBranch } from '../../../utils/story-id.js';
71
37
  import { withHiveContext, withHiveRoot } from '../../../utils/with-hive-context.js';
72
- import { generateTechLeadPrompt } from '../req.js';
73
38
  import {
74
39
  agentStates,
75
- createManagerNudgeEnvelope,
76
40
  detectAgentState,
77
41
  enforceBypassMode,
78
42
  forwardMessages,
@@ -80,7 +44,6 @@ import {
80
44
  handlePermissionPrompt,
81
45
  handlePlanApproval,
82
46
  nudgeAgent,
83
- submitManagerNudgeWithVerification,
84
47
  updateAgentStateTracking,
85
48
  } from './agent-monitoring.js';
86
49
  import { spawnAuditorIfNeeded } from './auditor-lifecycle.js';
@@ -90,12 +53,45 @@ import { handleEscalationAndNudge } from './escalation-handler.js';
90
53
  import { checkFeatureSignOff } from './feature-sign-off.js';
91
54
  import { checkFeatureTestResult } from './feature-test-result.js';
92
55
  import { handleStalledPlanningHandoff } from './handoff-recovery.js';
93
- import { cleanupAgentsReferencingMergedStory } from './merged-story-cleanup.js';
56
+ import {
57
+ formatDuration,
58
+ getMaxStuckNudgesPerStory,
59
+ getScreenStaticInactivityThresholdMs,
60
+ sendManagerNudge,
61
+ verboseLog,
62
+ verboseLogCtx,
63
+ } from './manager-utils.js';
94
64
  import { shouldAutoResolveOrphanedManagerEscalation } from './orphaned-escalations.js';
95
- import { isTechLeadRestartOnCooldown } from './restart-cooldown.js';
96
- import { findSessionForAgent } from './session-resolution.js';
65
+ import {
66
+ closeStalePRs,
67
+ reconcileAgentsOnMergedStories,
68
+ recoverStaleReviewingPRs,
69
+ syncMergedPRs,
70
+ syncOpenPRs,
71
+ } from './pr-sync-orchestrator.js';
72
+ import {
73
+ autoRejectCommentOnlyReviews,
74
+ handleRejectedPRs,
75
+ notifyQAOfQueuedPRs,
76
+ } from './qa-review-handler.js';
97
77
  import { spinDownIdleAgents, spinDownMergedAgents } from './spin-down.js';
98
78
  import { findStaleSessionEscalations } from './stale-escalations.js';
79
+ import {
80
+ applyHumanInterventionStateOverride,
81
+ clearHumanIntervention,
82
+ isClassifierTimeoutReason,
83
+ markClassifierTimeoutForHumanIntervention,
84
+ markDoneFalseForHumanIntervention,
85
+ screenStaticBySession,
86
+ type ScreenStaticTracking,
87
+ } from './stuck-story-helpers.js';
88
+ import {
89
+ autoProgressDoneStory,
90
+ nudgeQAFailedStories,
91
+ nudgeStuckStories,
92
+ recoverUnassignedQAFailedStories,
93
+ } from './stuck-story-processor.js';
94
+ import { restartStaleTechLead } from './tech-lead-lifecycle.js';
99
95
  import type { ManagerCheckContext } from './types.js';
100
96
  import {
101
97
  MANAGER_NUDGE_END_MARKER,
@@ -104,27 +100,15 @@ import {
104
100
  TMUX_CAPTURE_LINES_SHORT,
105
101
  } from './types.js';
106
102
 
103
+ // Re-export functions that moved to submodules (preserves public API for tests/consumers)
104
+ export { autoRejectCommentOnlyReviews } from './qa-review-handler.js';
105
+ export {
106
+ shouldDeferStuckReminderUntilStaticWindow,
107
+ shouldTreatUnknownAsStuckWaiting,
108
+ } from './stuck-story-helpers.js';
109
+
107
110
  const DONE_INFERENCE_CONFIDENCE_THRESHOLD = 0.82;
108
111
  const SCREEN_STATIC_AI_RECHECK_MS = 5 * 60 * 1000;
109
- const DEFAULT_SCREEN_STATIC_INACTIVITY_THRESHOLD_MS = 10 * 60 * 1000;
110
- const DEFAULT_MAX_STUCK_NUDGES_PER_STORY = 1;
111
- const REVIEWING_PR_VALIDATION_MIN_AGE_MS = 5 * 60 * 1000;
112
- const GH_PR_VIEW_TIMEOUT_MS = 30_000;
113
- const CLASSIFIER_TIMEOUT_REASON_PREFIX = 'Classifier timeout';
114
- const AI_DONE_FALSE_REASON_PREFIX = 'AI done=false escalation';
115
-
116
- interface ClassifierTimeoutIntervention {
117
- storyId: string;
118
- reason: string;
119
- createdAtMs: number;
120
- }
121
-
122
- interface ScreenStaticTracking {
123
- fingerprint: string;
124
- unchangedSinceMs: number;
125
- lastAiAssessmentMs: number;
126
- }
127
-
128
112
  interface ScreenStaticStatus {
129
113
  changed: boolean;
130
114
  unchangedForMs: number;
@@ -146,19 +130,6 @@ interface SummaryLine {
146
130
  message: string;
147
131
  }
148
132
 
149
- interface UnknownStateStuckHeuristicSnapshot {
150
- state: AgentState;
151
- isWaiting: boolean;
152
- sessionUnchangedForMs: number;
153
- staticInactivityThresholdMs: number;
154
- }
155
-
156
- interface StuckReminderDeferralSnapshot {
157
- state: AgentState;
158
- sessionUnchangedForMs: number;
159
- staticInactivityThresholdMs: number;
160
- }
161
-
162
133
  export function classifyNoActionSummary(snapshot: NoActionSummarySnapshot): SummaryLine {
163
134
  if (snapshot.pendingEscalations > 0) {
164
135
  return {
@@ -187,41 +158,6 @@ export function classifyNoActionSummary(snapshot: NoActionSummarySnapshot): Summ
187
158
  return { color: 'green', message: 'All agents productive' };
188
159
  }
189
160
 
190
- export function shouldTreatUnknownAsStuckWaiting(
191
- snapshot: UnknownStateStuckHeuristicSnapshot
192
- ): boolean {
193
- const thresholdMs = Math.max(1, snapshot.staticInactivityThresholdMs);
194
- return (
195
- snapshot.state === AgentState.UNKNOWN &&
196
- !snapshot.isWaiting &&
197
- snapshot.sessionUnchangedForMs >= thresholdMs
198
- );
199
- }
200
-
201
- export function shouldDeferStuckReminderUntilStaticWindow(
202
- snapshot: StuckReminderDeferralSnapshot
203
- ): boolean {
204
- const thresholdMs = Math.max(1, snapshot.staticInactivityThresholdMs);
205
- if (snapshot.state === AgentState.WORK_COMPLETE) {
206
- return false;
207
- }
208
- return snapshot.sessionUnchangedForMs < thresholdMs;
209
- }
210
-
211
- const screenStaticBySession = new Map<string, ScreenStaticTracking>();
212
- const classifierTimeoutInterventionsBySession = new Map<string, ClassifierTimeoutIntervention>();
213
- const aiDoneFalseInterventionsBySession = new Map<string, ClassifierTimeoutIntervention>();
214
- const techLeadLastRestartByAgentId = new Map<string, number>();
215
-
216
- function verboseLog(verbose: boolean, message: string): void {
217
- if (!verbose) return;
218
- console.log(chalk.gray(` [verbose] ${message}`));
219
- }
220
-
221
- function verboseLogCtx(ctx: Pick<ManagerCheckContext, 'verbose'>, message: string): void {
222
- verboseLog(ctx.verbose, message);
223
- }
224
-
225
161
  function summarizeOutputForVerbose(output: string): string {
226
162
  const compact = output
227
163
  .split('\n')
@@ -233,19 +169,6 @@ function summarizeOutputForVerbose(output: string): string {
233
169
  return `${compact.slice(0, 177)}...`;
234
170
  }
235
171
 
236
- function shouldIncludeProgressUpdates(config: HiveConfig): boolean {
237
- return config.integrations?.project_management?.provider !== 'none';
238
- }
239
-
240
- function formatDuration(ms: number): string {
241
- if (ms <= 0) return 'now';
242
- const totalSeconds = Math.ceil(ms / 1000);
243
- const minutes = Math.floor(totalSeconds / 60);
244
- const seconds = totalSeconds % 60;
245
- if (minutes <= 0) return `${seconds}s`;
246
- return `${minutes}m ${seconds}s`;
247
- }
248
-
249
172
  function buildOutputFingerprint(output: string): string {
250
173
  return createHash('sha256').update(output).digest('hex');
251
174
  }
@@ -275,240 +198,6 @@ function stripManagerNudgeBlocks(output: string): string {
275
198
  return filtered.join('\n');
276
199
  }
277
200
 
278
- async function submitManagerNudge(
279
- ctx: ManagerCheckContext,
280
- sessionName: string,
281
- nudgeId: string
282
- ): Promise<void> {
283
- console.log(
284
- chalk.gray(
285
- ` Nudge ${nudgeId}: double-checking Enter delivery after nudge (verification loop enabled)`
286
- )
287
- );
288
- const result = await submitManagerNudgeWithVerification(sessionName, nudgeId);
289
- ctx.counters.nudgeEnterPresses = (ctx.counters.nudgeEnterPresses ?? 0) + result.enterPresses;
290
- ctx.counters.nudgeEnterRetries = (ctx.counters.nudgeEnterRetries ?? 0) + result.retryEnters;
291
- if (!result.confirmed) {
292
- ctx.counters.nudgeSubmitUnconfirmed = (ctx.counters.nudgeSubmitUnconfirmed ?? 0) + 1;
293
- console.log(
294
- chalk.yellow(
295
- ` Nudge ${nudgeId}: unable to confirm Enter delivery after ${result.checks} check(s), ${result.enterPresses} Enter keypress(es)`
296
- )
297
- );
298
- return;
299
- }
300
- console.log(
301
- chalk.gray(
302
- ` Nudge ${nudgeId}: Enter delivery confirmed after ${result.checks} check(s), ${result.enterPresses} Enter keypress(es)`
303
- )
304
- );
305
- }
306
-
307
- async function sendManagerNudge(
308
- ctx: ManagerCheckContext,
309
- sessionName: string,
310
- message: string
311
- ): Promise<void> {
312
- const envelope = createManagerNudgeEnvelope(message);
313
- await sendToTmuxSession(sessionName, envelope.text);
314
- await submitManagerNudge(ctx, sessionName, envelope.nudgeId);
315
- }
316
-
317
- function getScreenStaticInactivityThresholdMs(config?: HiveConfig): number {
318
- return Math.max(
319
- 1,
320
- config?.manager.screen_static_inactivity_threshold_ms ??
321
- DEFAULT_SCREEN_STATIC_INACTIVITY_THRESHOLD_MS
322
- );
323
- }
324
-
325
- function getMaxStuckNudgesPerStory(config?: HiveConfig): number {
326
- return Math.max(
327
- 0,
328
- config?.manager.max_stuck_nudges_per_story ?? DEFAULT_MAX_STUCK_NUDGES_PER_STORY
329
- );
330
- }
331
-
332
- function isClassifierTimeoutReason(reason: string): boolean {
333
- return /local classifier unavailable:.*timed out|command timed out/i.test(reason);
334
- }
335
-
336
- function formatClassifierTimeoutEscalationReason(storyId: string, reason: string): string {
337
- const singleLine = reason.replace(/\s+/g, ' ').trim();
338
- const shortReason = singleLine.length > 240 ? `${singleLine.slice(0, 237)}...` : singleLine;
339
- return `${CLASSIFIER_TIMEOUT_REASON_PREFIX}: manager completion classifier timed out for ${storyId}. Manual human intervention required. Detail: ${shortReason}`;
340
- }
341
-
342
- async function applyHumanInterventionStateOverride(
343
- ctx: ManagerCheckContext,
344
- sessionName: string,
345
- storyId: string | null,
346
- stateResult: ReturnType<typeof detectAgentState>,
347
- agentId: string | null = null
348
- ): Promise<ReturnType<typeof detectAgentState>> {
349
- const timeoutIntervention = classifierTimeoutInterventionsBySession.get(sessionName);
350
- if (timeoutIntervention && (!storyId || storyId !== timeoutIntervention.storyId)) {
351
- classifierTimeoutInterventionsBySession.delete(sessionName);
352
- }
353
- const doneFalseIntervention = aiDoneFalseInterventionsBySession.get(sessionName);
354
- if (doneFalseIntervention && (!storyId || storyId !== doneFalseIntervention.storyId)) {
355
- aiDoneFalseInterventionsBySession.delete(sessionName);
356
- }
357
-
358
- const transientIntervention =
359
- [timeoutIntervention, doneFalseIntervention]
360
- .filter((candidate): candidate is ClassifierTimeoutIntervention =>
361
- Boolean(candidate && storyId && candidate.storyId === storyId)
362
- )
363
- .sort((a, b) => b.createdAtMs - a.createdAtMs)[0] ?? null;
364
-
365
- const persistedIntervention =
366
- storyId === null
367
- ? null
368
- : await ctx.withDb(async db => {
369
- return (
370
- (agentId ? getActiveEscalationsForAgent(db.db, agentId) : []).find(
371
- escalation =>
372
- escalation.story_id === storyId &&
373
- (escalation.reason.startsWith(CLASSIFIER_TIMEOUT_REASON_PREFIX) ||
374
- escalation.reason.startsWith(AI_DONE_FALSE_REASON_PREFIX))
375
- ) ??
376
- getPendingEscalations(db.db).find(
377
- escalation =>
378
- escalation.story_id === storyId &&
379
- (escalation.reason.startsWith(CLASSIFIER_TIMEOUT_REASON_PREFIX) ||
380
- escalation.reason.startsWith(AI_DONE_FALSE_REASON_PREFIX))
381
- ) ??
382
- null
383
- );
384
- });
385
-
386
- const interventionReason = transientIntervention?.reason || persistedIntervention?.reason || null;
387
-
388
- if (!interventionReason) {
389
- return stateResult;
390
- }
391
-
392
- return {
393
- ...stateResult,
394
- state: AgentState.ASKING_QUESTION,
395
- needsHuman: true,
396
- isWaiting: true,
397
- reason: `Manual intervention required: ${interventionReason}`,
398
- };
399
- }
400
-
401
- function clearHumanIntervention(sessionName: string): void {
402
- classifierTimeoutInterventionsBySession.delete(sessionName);
403
- aiDoneFalseInterventionsBySession.delete(sessionName);
404
- }
405
-
406
- async function markClassifierTimeoutForHumanIntervention(
407
- ctx: ManagerCheckContext,
408
- sessionName: string,
409
- storyId: string,
410
- reason: string,
411
- agentId: string | null = null
412
- ): Promise<void> {
413
- const escalationReason = formatClassifierTimeoutEscalationReason(storyId, reason);
414
- classifierTimeoutInterventionsBySession.set(sessionName, {
415
- storyId,
416
- reason: escalationReason,
417
- createdAtMs: Date.now(),
418
- });
419
-
420
- await ctx.withDb(async db => {
421
- const activeTimeoutEscalation = (
422
- agentId ? getActiveEscalationsForAgent(db.db, agentId) : []
423
- ).some(escalation => escalation.reason.startsWith(CLASSIFIER_TIMEOUT_REASON_PREFIX));
424
- if (!activeTimeoutEscalation) {
425
- const escalation = createEscalation(db.db, {
426
- storyId,
427
- fromAgentId: agentId,
428
- toAgentId: null,
429
- reason: escalationReason,
430
- });
431
- createLog(db.db, {
432
- agentId: 'manager',
433
- storyId,
434
- eventType: 'ESCALATION_CREATED',
435
- status: 'error',
436
- message: `${sessionName} requires human intervention: completion classifier timed out`,
437
- metadata: {
438
- escalation_id: escalation.id,
439
- session_name: sessionName,
440
- escalation_type: 'classifier_timeout',
441
- },
442
- });
443
- db.save();
444
- ctx.counters.escalationsCreated++;
445
- ctx.escalatedSessions.add(sessionName);
446
- }
447
- });
448
-
449
- const tracked = agentStates.get(sessionName);
450
- if (tracked) {
451
- tracked.lastState = AgentState.ASKING_QUESTION;
452
- tracked.lastStateChangeTime = Date.now();
453
- }
454
- }
455
-
456
- function formatDoneFalseEscalationReason(storyId: string, reason: string): string {
457
- const singleLine = reason.replace(/\s+/g, ' ').trim();
458
- const shortReason = singleLine.length > 240 ? `${singleLine.slice(0, 237)}...` : singleLine;
459
- return `${AI_DONE_FALSE_REASON_PREFIX}: manager AI assessment returned done=false for ${storyId} after nudge limit reached. Manual human intervention required. Detail: ${shortReason}`;
460
- }
461
-
462
- async function markDoneFalseForHumanIntervention(
463
- ctx: ManagerCheckContext,
464
- sessionName: string,
465
- storyId: string,
466
- reason: string,
467
- agentId: string | null = null
468
- ): Promise<void> {
469
- const escalationReason = formatDoneFalseEscalationReason(storyId, reason);
470
- aiDoneFalseInterventionsBySession.set(sessionName, {
471
- storyId,
472
- reason: escalationReason,
473
- createdAtMs: Date.now(),
474
- });
475
-
476
- await ctx.withDb(async db => {
477
- const hasActiveEscalation = (agentId ? getActiveEscalationsForAgent(db.db, agentId) : []).some(
478
- escalation => escalation.reason.startsWith(AI_DONE_FALSE_REASON_PREFIX)
479
- );
480
- if (!hasActiveEscalation) {
481
- const escalation = createEscalation(db.db, {
482
- storyId,
483
- fromAgentId: agentId,
484
- toAgentId: null,
485
- reason: escalationReason,
486
- });
487
- createLog(db.db, {
488
- agentId: 'manager',
489
- storyId,
490
- eventType: 'ESCALATION_CREATED',
491
- status: 'error',
492
- message: `${sessionName} requires human intervention: AI assessment reports blocked/incomplete after nudge limit`,
493
- metadata: {
494
- escalation_id: escalation.id,
495
- session_name: sessionName,
496
- escalation_type: 'ai_done_false',
497
- },
498
- });
499
- db.save();
500
- ctx.counters.escalationsCreated++;
501
- ctx.escalatedSessions.add(sessionName);
502
- }
503
- });
504
-
505
- const tracked = agentStates.get(sessionName);
506
- if (tracked) {
507
- tracked.lastState = AgentState.ASKING_QUESTION;
508
- tracked.lastStateChangeTime = Date.now();
509
- }
510
- }
511
-
512
201
  function updateScreenStaticTracking(
513
202
  sessionName: string,
514
203
  output: string,
@@ -558,12 +247,6 @@ function markFullAiDetectionRun(sessionName: string, nowMs: number): void {
558
247
  tracking.lastAiAssessmentMs = nowMs;
559
248
  }
560
249
 
561
- function getSessionStaticUnchangedForMs(sessionName: string, nowMs: number): number {
562
- const tracking = screenStaticBySession.get(sessionName);
563
- if (!tracking) return 0;
564
- return Math.max(0, nowMs - tracking.unchangedSinceMs);
565
- }
566
-
567
250
  export const managerCommand = new Command('manager').description(
568
251
  'Micromanager daemon that keeps agents productive'
569
252
  );
@@ -1103,746 +786,114 @@ async function runAutoMerge(ctx: ManagerCheckContext): Promise<void> {
1103
786
  }
1104
787
  }
1105
788
 
1106
- async function syncMergedPRs(ctx: ManagerCheckContext): Promise<void> {
1107
- // Phase 1: Read teams (brief lock)
1108
- const teamInfos = await ctx.withDb(async db => {
1109
- const { getAllTeams } = await import('../../../db/queries/teams.js');
1110
- return getAllTeams(db.db)
1111
- .filter(t => t.repo_path)
1112
- .map(t => ({
1113
- repoDir: `${ctx.root}/${t.repo_path}`,
1114
- slug: ghRepoSlug(t.repo_url),
1115
- }));
1116
- });
1117
- if (teamInfos.length === 0) return;
1118
-
1119
- // Phase 2: GitHub CLI calls (no lock)
1120
- const GITHUB_PR_LIST_LIMIT = 20;
1121
- const GH_CLI_TIMEOUT_MS = 30000;
1122
- const ghResults: Array<{
1123
- mergedPRs: Array<{ number: number; headRefName: string; mergedAt: string }>;
1124
- }> = [];
1125
- for (const team of teamInfos) {
1126
- try {
1127
- const args = [
1128
- 'pr',
1129
- 'list',
1130
- '--json',
1131
- 'number,headRefName,mergedAt',
1132
- '--state',
1133
- 'merged',
1134
- '--limit',
1135
- String(GITHUB_PR_LIST_LIMIT),
1136
- ];
1137
- if (team.slug) args.push('-R', team.slug);
1138
- const result = await execa('gh', args, { cwd: team.repoDir, timeout: GH_CLI_TIMEOUT_MS });
1139
- ghResults.push({ mergedPRs: JSON.parse(result.stdout) });
1140
- } catch {
1141
- ghResults.push({ mergedPRs: [] });
1142
- }
1143
- }
1144
-
1145
- // Phase 3: DB reads + writes (brief lock)
1146
- const mergedSynced = await ctx.withDb(async db => {
1147
- let storiesUpdated = 0;
1148
- for (const ghResult of ghResults) {
1149
- const candidateStoryIds = Array.from(
1150
- new Set(
1151
- ghResult.mergedPRs
1152
- .map(pr => extractStoryIdFromBranch(pr.headRefName))
1153
- .filter((id): id is string => Boolean(id))
1154
- )
1155
- );
1156
- if (candidateStoryIds.length === 0) continue;
1157
-
1158
- const placeholders = candidateStoryIds.map(() => '?').join(',');
1159
- const updatableStories = queryAll<{ id: string }>(
1160
- db.db,
1161
- `SELECT id FROM stories WHERE status != 'merged' AND id IN (${placeholders})`,
1162
- candidateStoryIds
1163
- );
1164
- const updatableStoryIds = new Set(updatableStories.map(s => s.id));
1165
- const toUpdate: Array<{ storyId: string; prNumber: number }> = [];
1166
-
1167
- for (const pr of ghResult.mergedPRs) {
1168
- const storyId = extractStoryIdFromBranch(pr.headRefName);
1169
- if (!storyId || !updatableStoryIds.has(storyId)) continue;
1170
- updatableStoryIds.delete(storyId);
1171
- toUpdate.push({ storyId, prNumber: pr.number });
1172
- }
789
+ // syncMergedPRs, reconcileAgentsOnMergedStories, syncOpenPRs, closeStalePRs,
790
+ // recoverStaleReviewingPRs moved to ./pr-sync-orchestrator.ts
1173
791
 
1174
- if (toUpdate.length > 0) {
1175
- await withTransaction(db.db, () => {
1176
- for (const update of toUpdate) {
1177
- updateStory(db.db, update.storyId, { status: 'merged', assignedAgentId: null });
1178
- const cleanup = cleanupAgentsReferencingMergedStory(db.db, update.storyId);
1179
- createLog(db.db, {
1180
- agentId: 'manager',
1181
- storyId: update.storyId,
1182
- eventType: 'STORY_MERGED',
1183
- message: `Story synced to merged from GitHub PR #${update.prNumber}`,
1184
- metadata: {
1185
- merged_agent_cleanup_cleared: cleanup.cleared,
1186
- merged_agent_cleanup_reassigned: cleanup.reassigned,
1187
- },
1188
- });
1189
- }
1190
- });
1191
- for (const update of toUpdate) {
1192
- syncStatusForStory(ctx.root, db.db, update.storyId, 'merged');
1193
- }
1194
- storiesUpdated += toUpdate.length;
1195
- }
792
+ async function syncJiraStatuses(ctx: ManagerCheckContext): Promise<void> {
793
+ await ctx.withDb(async db => {
794
+ const syncedStories = await syncFromProvider(ctx.root, db.db);
795
+ verboseLogCtx(ctx, `syncJiraStatuses: synced=${syncedStories}`);
796
+ if (syncedStories > 0) {
797
+ ctx.counters.jiraSynced = syncedStories;
798
+ console.log(chalk.cyan(` Synced ${syncedStories} story status(es) from Jira`));
1196
799
  }
1197
- if (storiesUpdated > 0) db.save();
1198
- return storiesUpdated;
800
+ // Always save after Jira sync — syncFromJira now also pushes unsynced stories TO Jira
801
+ db.save();
1199
802
  });
1200
-
1201
- verboseLogCtx(ctx, `syncMergedPRs: synced=${mergedSynced}`);
1202
- if (mergedSynced > 0) {
1203
- console.log(chalk.green(` Synced ${mergedSynced} merged story(ies) from GitHub`));
1204
- }
1205
803
  }
1206
804
 
1207
- async function reconcileAgentsOnMergedStories(ctx: ManagerCheckContext): Promise<void> {
1208
- const result = await ctx.withDb(async db => {
1209
- const mergedStoryIds = queryAll<{ id: string }>(
1210
- db.db,
1211
- `
1212
- SELECT DISTINCT s.id
1213
- FROM stories s
1214
- JOIN agents a ON a.current_story_id = s.id
1215
- WHERE s.status = 'merged'
1216
- AND a.status != 'terminated'
1217
- `
1218
- ).map(row => row.id);
1219
-
1220
- if (mergedStoryIds.length === 0) {
1221
- return { storyCount: 0, cleared: 0, reassigned: 0 };
1222
- }
805
+ async function prepareSessionData(ctx: ManagerCheckContext): Promise<void> {
806
+ await ctx.withDb(async db => {
807
+ // Pre-populate escalation dedup set
808
+ const existingEscalations = getPendingEscalations(db.db);
809
+ ctx.escalatedSessions = new Set(
810
+ existingEscalations.filter(e => e.from_agent_id).map(e => e.from_agent_id)
811
+ );
1223
812
 
1224
- let cleared = 0;
1225
- let reassigned = 0;
1226
- for (const storyId of mergedStoryIds) {
1227
- const cleanup = cleanupAgentsReferencingMergedStory(db.db, storyId);
1228
- if (cleanup.cleared > 0) {
1229
- createLog(db.db, {
1230
- agentId: 'manager',
1231
- storyId,
1232
- eventType: 'STORY_PROGRESS_UPDATE',
1233
- message: `Reconciled stale merged-story agent assignments`,
1234
- metadata: {
1235
- story_id: storyId,
1236
- cleared_agents: cleanup.cleared,
1237
- reassigned_agents: cleanup.reassigned,
1238
- recovery: 'merged_story_agent_reconcile',
1239
- },
1240
- });
813
+ // Batch fetch all agents and index by session name
814
+ const allAgents = getAllAgents(db.db);
815
+ const bySessionName = new Map<string, (typeof allAgents)[number]>();
816
+ for (const agent of allAgents) {
817
+ bySessionName.set(`hive-${agent.id}`, agent);
818
+ if (agent.tmux_session) {
819
+ bySessionName.set(agent.tmux_session, agent);
1241
820
  }
1242
- cleared += cleanup.cleared;
1243
- reassigned += cleanup.reassigned;
1244
- }
1245
-
1246
- if (cleared > 0) {
1247
- db.save();
1248
821
  }
1249
-
1250
- return { storyCount: mergedStoryIds.length, cleared, reassigned };
1251
- });
1252
-
1253
- verboseLogCtx(
1254
- ctx,
1255
- `reconcileAgentsOnMergedStories: stories=${result.storyCount}, cleared=${result.cleared}, reassigned=${result.reassigned}`
1256
- );
1257
- if (result.cleared > 0) {
1258
- console.log(
1259
- chalk.yellow(
1260
- ` Reconciled ${result.cleared} stale merged-story agent assignment(s) (${result.reassigned} reassigned, ${result.cleared - result.reassigned} idled)`
1261
- )
822
+ ctx.agentsBySessionName = bySessionName;
823
+ verboseLogCtx(
824
+ ctx,
825
+ `prepareSessionData: escalations=${existingEscalations.length}, agentsIndexed=${bySessionName.size}`
1262
826
  );
1263
- }
827
+ });
1264
828
  }
1265
829
 
1266
- async function syncOpenPRs(ctx: ManagerCheckContext): Promise<void> {
1267
- const maxAgeHours = ctx.config.merge_queue?.max_age_hours;
1268
-
1269
- // Phase 1: Read teams + existing identifiers (brief lock)
1270
- const setupData = await ctx.withDb(async db => {
1271
- const { getAllTeams } = await import('../../../db/queries/teams.js');
1272
- const teams = getAllTeams(db.db);
1273
- const { existingBranches, existingPrNumbers } = getExistingPRIdentifiers(db.db, true);
1274
- return {
1275
- teams: teams
1276
- .filter(t => t.repo_path)
1277
- .map(t => ({
1278
- id: t.id,
1279
- repoDir: `${ctx.root}/${t.repo_path}`,
1280
- slug: ghRepoSlug(t.repo_url),
1281
- })),
1282
- existingBranches,
1283
- existingPrNumbers,
1284
- };
1285
- });
1286
- if (setupData.teams.length === 0) return;
830
+ async function resolveStaleEscalations(ctx: ManagerCheckContext): Promise<void> {
831
+ await ctx.withDb(async db => {
832
+ const staleAfterMs = Math.max(
833
+ 1,
834
+ ctx.config.manager.nudge_cooldown_ms,
835
+ ctx.config.manager.stuck_threshold_ms
836
+ );
837
+ const pendingEscalations = getPendingEscalations(db.db);
838
+ verboseLogCtx(
839
+ ctx,
840
+ `resolveStaleEscalations: pending=${pendingEscalations.length}, staleAfterMs=${staleAfterMs}`
841
+ );
842
+ if (pendingEscalations.length === 0) return;
1287
843
 
1288
- // Phase 2: GitHub CLI calls (no lock)
1289
- const teamPRs = new Map<string, import('../../../utils/pr-sync.js').GitHubPR[]>();
1290
- for (const team of setupData.teams) {
1291
- try {
1292
- const prs = await fetchOpenGitHubPRs(team.repoDir, team.slug);
1293
- teamPRs.set(team.id, prs);
1294
- } catch {
1295
- // gh CLI might not be authenticated
844
+ const uniqueAgents = new Map<string, ReturnType<typeof getAllAgents>[number]>();
845
+ for (const agent of ctx.agentsBySessionName.values()) {
846
+ uniqueAgents.set(agent.id, agent);
1296
847
  }
1297
- }
1298
848
 
1299
- // Phase 3: Import into DB (brief lock)
1300
- const syncedPRs = await ctx.withDb(async (db, scheduler) => {
1301
- // Re-read identifiers in case another process synced in the meantime
1302
- const { existingBranches, existingPrNumbers } = getExistingPRIdentifiers(db.db, true);
1303
- let totalSynced = 0;
1304
-
1305
- for (const team of setupData.teams) {
1306
- const prs = teamPRs.get(team.id);
1307
- if (!prs) continue;
1308
-
1309
- for (const ghPR of prs) {
1310
- if (existingBranches.has(ghPR.headRefName) || existingPrNumbers.has(ghPR.number)) continue;
1311
-
1312
- // Age filtering
1313
- if (maxAgeHours !== undefined) {
1314
- const ageHours = (Date.now() - new Date(ghPR.createdAt).getTime()) / (1000 * 60 * 60);
1315
- if (ageHours > maxAgeHours) {
1316
- createLog(db.db, {
1317
- agentId: 'manager',
1318
- eventType: 'PR_SYNC_SKIPPED',
1319
- status: 'info',
1320
- message: `Skipped syncing old PR #${ghPR.number} (${ghPR.headRefName}): created ${ageHours.toFixed(1)}h ago (max: ${maxAgeHours}h)`,
1321
- metadata: {
1322
- pr_number: ghPR.number,
1323
- branch: ghPR.headRefName,
1324
- age_hours: ageHours,
1325
- max_age_hours: maxAgeHours,
1326
- reason: 'too_old',
1327
- },
1328
- });
1329
- continue;
1330
- }
1331
- }
849
+ const staleEscalations = findStaleSessionEscalations({
850
+ pendingEscalations,
851
+ agents: [...uniqueAgents.values()],
852
+ liveSessionNames: new Set(ctx.hiveSessions.map(session => session.name)),
853
+ nowMs: Date.now(),
854
+ staleAfterMs,
855
+ });
1332
856
 
1333
- const storyId = extractStoryIdFromBranch(ghPR.headRefName);
1334
- if (storyId) {
1335
- const storyRows = queryAll<{ id: string; status: string }>(
1336
- db.db,
1337
- `SELECT id, status FROM stories WHERE id = ? AND status != 'merged'`,
1338
- [storyId]
1339
- );
1340
- if (storyRows.length === 0) {
1341
- createLog(db.db, {
1342
- agentId: 'manager',
1343
- eventType: 'PR_SYNC_SKIPPED',
1344
- status: 'info',
1345
- message: `Skipped syncing PR #${ghPR.number} (${ghPR.headRefName}): story ${storyId} not found or already merged`,
1346
- metadata: {
1347
- pr_number: ghPR.number,
1348
- branch: ghPR.headRefName,
1349
- story_id: storyId,
1350
- reason: 'inactive_story',
1351
- },
1352
- });
1353
- continue;
857
+ if (staleEscalations.length === 0) return;
858
+ verboseLogCtx(ctx, `resolveStaleEscalations: stale=${staleEscalations.length}`);
859
+
860
+ withTransaction(
861
+ db.db,
862
+ () => {
863
+ for (const stale of staleEscalations) {
864
+ updateEscalation(db.db, stale.escalation.id, {
865
+ status: 'resolved',
866
+ resolution: `Manager auto-resolved stale escalation: ${stale.reason}`,
867
+ });
868
+ if (stale.escalation.from_agent_id) {
869
+ ctx.escalatedSessions.delete(stale.escalation.from_agent_id);
1354
870
  }
871
+ ctx.counters.escalationsResolved++;
872
+ createLog(db.db, {
873
+ agentId: 'manager',
874
+ storyId: stale.escalation.story_id || undefined,
875
+ eventType: 'ESCALATION_RESOLVED',
876
+ message: `Auto-resolved stale escalation ${stale.escalation.id}`,
877
+ metadata: {
878
+ escalation_id: stale.escalation.id,
879
+ from_agent_id: stale.escalation.from_agent_id,
880
+ reason: stale.reason,
881
+ },
882
+ });
1355
883
  }
1356
-
1357
- createPullRequest(db.db, {
1358
- storyId,
1359
- teamId: team.id,
1360
- branchName: ghPR.headRefName,
1361
- githubPrNumber: ghPR.number,
1362
- githubPrUrl: ghPR.url,
1363
- submittedBy: null,
1364
- });
1365
- existingBranches.add(ghPR.headRefName);
1366
- existingPrNumbers.add(ghPR.number);
1367
- totalSynced++;
1368
- }
1369
- }
1370
-
1371
- if (totalSynced > 0) {
1372
- db.save();
1373
- await scheduler.checkMergeQueue();
1374
- db.save();
1375
- }
1376
- return totalSynced;
884
+ },
885
+ () => db.save()
886
+ );
887
+ console.log(chalk.yellow(` Auto-cleared ${staleEscalations.length} stale escalation(s)`));
1377
888
  });
1378
-
1379
- verboseLogCtx(ctx, `syncOpenPRs: synced=${syncedPRs}`);
1380
- if (syncedPRs > 0) {
1381
- console.log(chalk.yellow(` Synced ${syncedPRs} GitHub PR(s) into merge queue`));
1382
- }
1383
889
  }
1384
890
 
1385
- async function closeStalePRs(ctx: ManagerCheckContext): Promise<void> {
1386
- // Phase 1: Read teams + PR data (brief lock)
1387
- const { teamInfos, prsByStory } = await ctx.withDb(async db => {
1388
- const { getAllTeams } = await import('../../../db/queries/teams.js');
1389
- const teams = getAllTeams(db.db).filter(t => t.repo_path);
1390
- // Pre-fetch all non-closed PR data grouped by story
1391
- const allPRs = queryAll<{
1392
- story_id: string | null;
1393
- id: string;
1394
- github_pr_number: number | null;
1395
- }>(
1396
- db.db,
1397
- `SELECT story_id, id, github_pr_number FROM pull_requests WHERE status NOT IN ('closed') ORDER BY created_at DESC`
1398
- );
1399
- const prsByStory = new Map<string, Array<{ id: string; github_pr_number: number | null }>>();
1400
- for (const pr of allPRs) {
1401
- if (!pr.story_id) continue;
1402
- const existing = prsByStory.get(pr.story_id) || [];
1403
- existing.push({ id: pr.id, github_pr_number: pr.github_pr_number });
1404
- prsByStory.set(pr.story_id, existing);
1405
- }
1406
- return {
1407
- teamInfos: teams.map(t => ({
1408
- repoDir: `${ctx.root}/${t.repo_path}`,
1409
- })),
1410
- prsByStory,
1411
- };
1412
- });
1413
-
1414
- if (teamInfos.length === 0) return;
1415
-
1416
- // Phase 2: GitHub CLI calls (no lock)
1417
- const GH_CLI_TIMEOUT_MS = 30000;
1418
- const baseBranch = ctx.config.github?.base_branch ?? 'main';
1419
- const closed: import('../../../utils/pr-sync.js').ClosedPRInfo[] = [];
1420
-
1421
- for (const team of teamInfos) {
1422
- try {
1423
- const openGHPRs = await fetchOpenGitHubPRs(team.repoDir);
1424
- for (const ghPR of openGHPRs) {
1425
- // Skip PRs that don't target the configured base branch
1426
- if (ghPR.baseRefName !== baseBranch) continue;
1427
-
1428
- const storyId = extractStoryIdFromBranch(ghPR.headRefName);
1429
- if (!storyId) continue;
1430
- const prsForStory = prsByStory.get(storyId);
1431
- if (!prsForStory || prsForStory.length === 0) continue;
1432
- const hasUnsyncedEntry = prsForStory.some(pr => pr.github_pr_number == null);
1433
- if (hasUnsyncedEntry) continue;
1434
- const isInQueue = prsForStory.some(pr => pr.github_pr_number === ghPR.number);
1435
- if (!isInQueue) {
1436
- const supersededByPrNumber =
1437
- prsForStory.find(pr => pr.github_pr_number !== null)?.github_pr_number ?? null;
1438
- try {
1439
- await execa('gh', ['pr', 'close', String(ghPR.number)], {
1440
- cwd: team.repoDir,
1441
- timeout: GH_CLI_TIMEOUT_MS,
1442
- });
1443
- closed.push({
1444
- storyId,
1445
- closedPrNumber: ghPR.number,
1446
- branch: ghPR.headRefName,
1447
- supersededByPrNumber,
1448
- });
1449
- } catch {
1450
- // Non-fatal
1451
- }
1452
- }
1453
- }
1454
- } catch {
1455
- continue;
1456
- }
1457
- }
1458
-
1459
- // Phase 3: Write logs (brief lock)
1460
- if (closed.length > 0) {
1461
- await ctx.withDb(async db => {
1462
- for (const info of closed) {
1463
- const supersededDesc =
1464
- info.supersededByPrNumber !== null ? ` by PR #${info.supersededByPrNumber}` : '';
1465
- createLog(db.db, {
1466
- agentId: 'manager',
1467
- storyId: info.storyId,
1468
- eventType: 'PR_CLOSED',
1469
- message: `Auto-closed stale GitHub PR #${info.closedPrNumber} (${info.branch}) - superseded${supersededDesc}`,
1470
- metadata: {
1471
- github_pr_number: info.closedPrNumber,
1472
- branch: info.branch,
1473
- reason: 'stale',
1474
- superseded_by_pr_number: info.supersededByPrNumber,
1475
- },
1476
- });
1477
- }
1478
- db.save();
1479
- });
1480
- console.log(chalk.yellow(` Closed ${closed.length} stale GitHub PR(s):`));
1481
- for (const info of closed) {
1482
- const supersededDesc =
1483
- info.supersededByPrNumber !== null
1484
- ? ` (superseded by PR #${info.supersededByPrNumber})`
1485
- : '';
1486
- console.log(
1487
- chalk.gray(
1488
- ` PR #${info.closedPrNumber} [${info.storyId}] ${info.branch}${supersededDesc}`
1489
- )
1490
- );
1491
- }
1492
- }
1493
- verboseLogCtx(ctx, `closeStalePRs: closed=${closed.length}`);
1494
- }
1495
-
1496
- interface ReviewingPRValidationCandidate {
1497
- id: string;
1498
- storyId: string | null;
1499
- teamId: string;
1500
- branchName: string;
1501
- githubPrNumber: number;
1502
- reviewedBy: string | null;
1503
- repoDir: string;
1504
- repoSlug: string | null;
1505
- }
1506
-
1507
- interface ReviewingPRValidationResult {
1508
- candidate: ReviewingPRValidationCandidate;
1509
- githubState: string;
1510
- githubUrl: string | null;
1511
- }
1512
-
1513
- async function recoverStaleReviewingPRs(ctx: ManagerCheckContext): Promise<void> {
1514
- const now = Date.now();
1515
-
1516
- // Phase 1: Read stale reviewing PRs and resolve repo metadata (brief lock)
1517
- const candidates = await ctx.withDb(async db => {
1518
- const reviewingPRs = getPullRequestsByStatus(db.db, 'reviewing').filter(pr => {
1519
- if (!pr.github_pr_number || !pr.team_id) return false;
1520
- const updatedAtMs = Date.parse(pr.updated_at);
1521
- if (Number.isNaN(updatedAtMs)) return true;
1522
- return now - updatedAtMs >= REVIEWING_PR_VALIDATION_MIN_AGE_MS;
1523
- });
1524
-
1525
- verboseLogCtx(ctx, `recoverStaleReviewingPRs: staleCandidates=${reviewingPRs.length}`);
1526
- if (reviewingPRs.length === 0) {
1527
- return [] as ReviewingPRValidationCandidate[];
1528
- }
1529
-
1530
- const { getAllTeams } = await import('../../../db/queries/teams.js');
1531
- const teams = getAllTeams(db.db);
1532
- const teamsById = new Map(teams.map(team => [team.id, team]));
1533
-
1534
- const result: ReviewingPRValidationCandidate[] = [];
1535
- for (const pr of reviewingPRs) {
1536
- const team = teamsById.get(pr.team_id!);
1537
- if (!team?.repo_path) continue;
1538
-
1539
- result.push({
1540
- id: pr.id,
1541
- storyId: pr.story_id,
1542
- teamId: pr.team_id!,
1543
- branchName: pr.branch_name,
1544
- githubPrNumber: pr.github_pr_number!,
1545
- reviewedBy: pr.reviewed_by,
1546
- repoDir: `${ctx.root}/${team.repo_path}`,
1547
- repoSlug: ghRepoSlug(team.repo_url),
1548
- });
1549
- }
1550
-
1551
- return result;
1552
- });
1553
-
1554
- if (candidates.length === 0) return;
1555
-
1556
- // Phase 2: Check GitHub state for each stale reviewing PR (no lock)
1557
- const mergedResults: ReviewingPRValidationResult[] = [];
1558
- const rejectedResults: ReviewingPRValidationResult[] = [];
1559
-
1560
- for (const candidate of candidates) {
1561
- try {
1562
- const args = ['pr', 'view', String(candidate.githubPrNumber), '--json', 'state,url'];
1563
- if (candidate.repoSlug) args.push('-R', candidate.repoSlug);
1564
- const result = await execa('gh', args, {
1565
- cwd: candidate.repoDir,
1566
- timeout: GH_PR_VIEW_TIMEOUT_MS,
1567
- });
1568
- const parsed = JSON.parse(result.stdout) as { state?: string; url?: string };
1569
- const state = parsed.state?.toUpperCase();
1570
- const url = parsed.url || null;
1571
-
1572
- if (state === 'OPEN') {
1573
- // PR is still open on GitHub but stale in 'reviewing' — the QA agent
1574
- // may have missed the original nudge. Re-nudge if QA agent is idle.
1575
- if (candidate.reviewedBy) {
1576
- const qaAgent = ctx.agentsBySessionName.get(candidate.reviewedBy);
1577
- if (qaAgent && qaAgent.status === 'idle') {
1578
- const githubLine = candidate.repoSlug
1579
- ? `\n# GitHub: https://github.com/${candidate.repoSlug}/pull/${candidate.githubPrNumber}`
1580
- : '';
1581
- await sendManagerNudge(
1582
- ctx,
1583
- candidate.reviewedBy,
1584
- `# [REMINDER] You are assigned PR review ${candidate.id} (${candidate.storyId || 'no-story'}).${githubLine}
1585
- # This PR has been waiting for review. Execute now:
1586
- # hive pr show ${candidate.id}
1587
- # hive pr approve ${candidate.id}
1588
- # or reject:
1589
- # hive pr reject ${candidate.id} -r "reason"`
1590
- );
1591
- verboseLogCtx(
1592
- ctx,
1593
- `recoverStaleReviewingPRs: re-nudged idle QA ${candidate.reviewedBy} for stale pr=${candidate.id}`
1594
- );
1595
- }
1596
- }
1597
- continue;
1598
- }
1599
- if (state === 'MERGED') {
1600
- mergedResults.push({
1601
- candidate,
1602
- githubState: 'MERGED',
1603
- githubUrl: url,
1604
- });
1605
- continue;
1606
- }
1607
-
1608
- if (state) {
1609
- rejectedResults.push({
1610
- candidate,
1611
- githubState: state,
1612
- githubUrl: url,
1613
- });
1614
- }
1615
- } catch (err) {
1616
- verboseLogCtx(
1617
- ctx,
1618
- `recoverStaleReviewingPRs: skip pr=${candidate.id} github_check_failed=${err instanceof Error ? err.message : String(err)}`
1619
- );
1620
- }
1621
- }
1622
-
1623
- if (mergedResults.length === 0 && rejectedResults.length === 0) return;
1624
-
1625
- const mergedStoryIds: string[] = [];
1626
-
1627
- // Phase 3: Apply DB updates (brief lock)
1628
- await ctx.withDb(async db => {
1629
- for (const result of mergedResults) {
1630
- await withTransaction(
1631
- db.db,
1632
- () => {
1633
- const currentPR = queryOne<{ status: string }>(
1634
- db.db,
1635
- `SELECT status FROM pull_requests WHERE id = ?`,
1636
- [result.candidate.id]
1637
- );
1638
- if (!currentPR || currentPR.status !== 'reviewing') return;
1639
-
1640
- updatePullRequest(db.db, result.candidate.id, {
1641
- status: 'merged',
1642
- reviewedBy: result.candidate.reviewedBy || 'manager',
1643
- });
1644
- createLog(db.db, {
1645
- agentId: 'manager',
1646
- storyId: result.candidate.storyId || undefined,
1647
- eventType: 'PR_MERGED',
1648
- message: `Auto-closed reviewing PR ${result.candidate.id}: GitHub PR #${result.candidate.githubPrNumber} is already merged`,
1649
- metadata: {
1650
- pr_id: result.candidate.id,
1651
- github_pr_number: result.candidate.githubPrNumber,
1652
- github_state: result.githubState,
1653
- github_url: result.githubUrl,
1654
- },
1655
- });
1656
-
1657
- if (!result.candidate.storyId) return;
1658
- updateStory(db.db, result.candidate.storyId, { status: 'merged', assignedAgentId: null });
1659
- const cleanup = cleanupAgentsReferencingMergedStory(db.db, result.candidate.storyId);
1660
- createLog(db.db, {
1661
- agentId: 'manager',
1662
- storyId: result.candidate.storyId,
1663
- eventType: 'STORY_MERGED',
1664
- message: `Story auto-synced to merged (GitHub PR #${result.candidate.githubPrNumber} already merged)`,
1665
- metadata: {
1666
- pr_id: result.candidate.id,
1667
- github_pr_number: result.candidate.githubPrNumber,
1668
- github_url: result.githubUrl,
1669
- merged_agent_cleanup_cleared: cleanup.cleared,
1670
- merged_agent_cleanup_reassigned: cleanup.reassigned,
1671
- },
1672
- });
1673
- mergedStoryIds.push(result.candidate.storyId);
1674
- },
1675
- () => db.save()
1676
- );
1677
- }
1678
-
1679
- for (const result of rejectedResults) {
1680
- await withTransaction(
1681
- db.db,
1682
- () => {
1683
- const currentPR = queryOne<{ status: string }>(
1684
- db.db,
1685
- `SELECT status FROM pull_requests WHERE id = ?`,
1686
- [result.candidate.id]
1687
- );
1688
- if (!currentPR || currentPR.status !== 'reviewing') return;
1689
-
1690
- const reason = `GitHub PR #${result.candidate.githubPrNumber} is ${result.githubState.toLowerCase()} on GitHub${result.githubUrl ? ` (${result.githubUrl})` : ''}. Reopen/create a new PR and resubmit.`;
1691
- updatePullRequest(db.db, result.candidate.id, {
1692
- status: 'rejected',
1693
- reviewedBy: result.candidate.reviewedBy || 'manager',
1694
- reviewNotes: reason,
1695
- });
1696
- createLog(db.db, {
1697
- agentId: 'manager',
1698
- storyId: result.candidate.storyId || undefined,
1699
- eventType: 'PR_REJECTED',
1700
- status: 'warn',
1701
- message: `Auto-rejected stale review ${result.candidate.id}: ${reason}`,
1702
- metadata: {
1703
- pr_id: result.candidate.id,
1704
- github_pr_number: result.candidate.githubPrNumber,
1705
- github_state: result.githubState,
1706
- github_url: result.githubUrl,
1707
- branch: result.candidate.branchName,
1708
- team_id: result.candidate.teamId,
1709
- },
1710
- });
1711
- },
1712
- () => db.save()
1713
- );
1714
- }
1715
- });
1716
-
1717
- // Sync merged stories to PM provider outside lock
1718
- const uniqueMergedStoryIds = Array.from(new Set(mergedStoryIds));
1719
- for (const storyId of uniqueMergedStoryIds) {
1720
- await ctx.withDb(async db => {
1721
- await syncStatusForStory(ctx.root, db.db, storyId, 'merged');
1722
- });
1723
- }
1724
-
1725
- if (mergedResults.length > 0) {
1726
- console.log(
1727
- chalk.green(
1728
- ` Auto-synced ${mergedResults.length} reviewing PR(s) that were already merged on GitHub`
1729
- )
1730
- );
1731
- }
1732
- if (rejectedResults.length > 0) {
1733
- console.log(
1734
- chalk.yellow(
1735
- ` Auto-rejected ${rejectedResults.length} stale reviewing PR(s) with non-open GitHub PR state`
1736
- )
1737
- );
1738
- }
1739
- }
1740
-
1741
- async function syncJiraStatuses(ctx: ManagerCheckContext): Promise<void> {
1742
- await ctx.withDb(async db => {
1743
- const syncedStories = await syncFromProvider(ctx.root, db.db);
1744
- verboseLogCtx(ctx, `syncJiraStatuses: synced=${syncedStories}`);
1745
- if (syncedStories > 0) {
1746
- ctx.counters.jiraSynced = syncedStories;
1747
- console.log(chalk.cyan(` Synced ${syncedStories} story status(es) from Jira`));
1748
- }
1749
- // Always save after Jira sync — syncFromJira now also pushes unsynced stories TO Jira
1750
- db.save();
1751
- });
1752
- }
1753
-
1754
- async function prepareSessionData(ctx: ManagerCheckContext): Promise<void> {
1755
- await ctx.withDb(async db => {
1756
- // Pre-populate escalation dedup set
1757
- const existingEscalations = getPendingEscalations(db.db);
1758
- ctx.escalatedSessions = new Set(
1759
- existingEscalations.filter(e => e.from_agent_id).map(e => e.from_agent_id)
1760
- );
1761
-
1762
- // Batch fetch all agents and index by session name
1763
- const allAgents = getAllAgents(db.db);
1764
- const bySessionName = new Map<string, (typeof allAgents)[number]>();
1765
- for (const agent of allAgents) {
1766
- bySessionName.set(`hive-${agent.id}`, agent);
1767
- if (agent.tmux_session) {
1768
- bySessionName.set(agent.tmux_session, agent);
1769
- }
1770
- }
1771
- ctx.agentsBySessionName = bySessionName;
1772
- verboseLogCtx(
1773
- ctx,
1774
- `prepareSessionData: escalations=${existingEscalations.length}, agentsIndexed=${bySessionName.size}`
1775
- );
1776
- });
1777
- }
1778
-
1779
- async function resolveStaleEscalations(ctx: ManagerCheckContext): Promise<void> {
1780
- await ctx.withDb(async db => {
1781
- const staleAfterMs = Math.max(
1782
- 1,
1783
- ctx.config.manager.nudge_cooldown_ms,
1784
- ctx.config.manager.stuck_threshold_ms
1785
- );
1786
- const pendingEscalations = getPendingEscalations(db.db);
1787
- verboseLogCtx(
1788
- ctx,
1789
- `resolveStaleEscalations: pending=${pendingEscalations.length}, staleAfterMs=${staleAfterMs}`
1790
- );
1791
- if (pendingEscalations.length === 0) return;
1792
-
1793
- const uniqueAgents = new Map<string, ReturnType<typeof getAllAgents>[number]>();
1794
- for (const agent of ctx.agentsBySessionName.values()) {
1795
- uniqueAgents.set(agent.id, agent);
1796
- }
1797
-
1798
- const staleEscalations = findStaleSessionEscalations({
1799
- pendingEscalations,
1800
- agents: [...uniqueAgents.values()],
1801
- liveSessionNames: new Set(ctx.hiveSessions.map(session => session.name)),
1802
- nowMs: Date.now(),
1803
- staleAfterMs,
1804
- });
1805
-
1806
- if (staleEscalations.length === 0) return;
1807
- verboseLogCtx(ctx, `resolveStaleEscalations: stale=${staleEscalations.length}`);
1808
-
1809
- withTransaction(
1810
- db.db,
1811
- () => {
1812
- for (const stale of staleEscalations) {
1813
- updateEscalation(db.db, stale.escalation.id, {
1814
- status: 'resolved',
1815
- resolution: `Manager auto-resolved stale escalation: ${stale.reason}`,
1816
- });
1817
- if (stale.escalation.from_agent_id) {
1818
- ctx.escalatedSessions.delete(stale.escalation.from_agent_id);
1819
- }
1820
- ctx.counters.escalationsResolved++;
1821
- createLog(db.db, {
1822
- agentId: 'manager',
1823
- storyId: stale.escalation.story_id || undefined,
1824
- eventType: 'ESCALATION_RESOLVED',
1825
- message: `Auto-resolved stale escalation ${stale.escalation.id}`,
1826
- metadata: {
1827
- escalation_id: stale.escalation.id,
1828
- from_agent_id: stale.escalation.from_agent_id,
1829
- reason: stale.reason,
1830
- },
1831
- });
1832
- }
1833
- },
1834
- () => db.save()
1835
- );
1836
- console.log(chalk.yellow(` Auto-cleared ${staleEscalations.length} stale escalation(s)`));
1837
- });
1838
- }
1839
-
1840
- async function resolveOrphanedSessionEscalations(ctx: ManagerCheckContext): Promise<void> {
1841
- const resolvedCount = await ctx.withDb(async db => {
1842
- const pendingEscalations = getPendingEscalations(db.db);
1843
- verboseLogCtx(ctx, `resolveOrphanedSessionEscalations: pending=${pendingEscalations.length}`);
1844
- if (pendingEscalations.length === 0) {
1845
- return 0;
891
+ async function resolveOrphanedSessionEscalations(ctx: ManagerCheckContext): Promise<void> {
892
+ const resolvedCount = await ctx.withDb(async db => {
893
+ const pendingEscalations = getPendingEscalations(db.db);
894
+ verboseLogCtx(ctx, `resolveOrphanedSessionEscalations: pending=${pendingEscalations.length}`);
895
+ if (pendingEscalations.length === 0) {
896
+ return 0;
1846
897
  }
1847
898
 
1848
899
  const activeSessionNames = new Set(ctx.hiveSessions.map(session => session.name));
@@ -2170,981 +1221,42 @@ async function batchMarkMessagesRead(ctx: ManagerCheckContext): Promise<void> {
2170
1221
  }
2171
1222
  }
2172
1223
 
2173
- async function notifyQAOfQueuedPRs(ctx: ManagerCheckContext): Promise<void> {
2174
- // Phase 1: Read PR queue and assign reviews (brief lock)
2175
- const { queuedPRs, dispatched } = await ctx.withDb(async db => {
2176
- const openPRs = getMergeQueue(db.db);
2177
- verboseLogCtx(ctx, `notifyQAOfQueuedPRs: open=${openPRs.length}`);
2178
-
2179
- const queued = openPRs.filter(pr => pr.status === 'queued');
2180
- const reviewing = openPRs.filter(pr => pr.status === 'reviewing');
2181
- ctx.counters.queuedPRCount = queued.length;
2182
- ctx.counters.reviewingPRCount = reviewing.length;
2183
- verboseLogCtx(ctx, `notifyQAOfQueuedPRs: queued=${queued.length}`);
2184
- if (queued.length === 0) {
2185
- return {
2186
- queuedPRs: [] as typeof queued,
2187
- dispatched: [] as Array<{
2188
- prId: string;
2189
- qaName: string;
2190
- storyId: string | null;
2191
- githubPrUrl: string | null;
2192
- }>,
2193
- };
2194
- }
1224
+ // notifyQAOfQueuedPRs, autoRejectCommentOnlyReviews, handleRejectedPRs
1225
+ // moved to ./qa-review-handler.ts
2195
1226
 
2196
- const reviewingSessions = new Set(
2197
- openPRs
2198
- .filter(pr => pr.status === 'reviewing' && pr.reviewed_by)
2199
- .map(pr => pr.reviewed_by as string)
2200
- );
1227
+ // nudgeQAFailedStories, recoverUnassignedQAFailedStories, nudgeStuckStories,
1228
+ // autoProgressDoneStory, resolveStoryBranchName moved to ./stuck-story-processor.ts
2201
1229
 
2202
- const idleQASessions = ctx.hiveSessions.filter(session => {
2203
- if (!session.name.includes('-qa-')) return false;
2204
- if (reviewingSessions.has(session.name)) return false;
2205
- const agent = ctx.agentsBySessionName.get(session.name);
2206
- return Boolean(agent && agent.status === 'idle');
2207
- });
2208
- verboseLogCtx(ctx, `notifyQAOfQueuedPRs: idleQA=${idleQASessions.length}`);
2209
-
2210
- const dispatchedList: Array<{
2211
- prId: string;
2212
- qaName: string;
2213
- storyId: string | null;
2214
- githubPrUrl: string | null;
2215
- }> = [];
2216
- let dispatchCount = 0;
2217
- for (const qa of idleQASessions) {
2218
- const nextPR = queued[dispatchCount];
2219
- if (!nextPR) break;
2220
-
2221
- await withTransaction(
2222
- db.db,
2223
- () => {
2224
- updatePullRequest(db.db, nextPR.id, {
2225
- status: 'reviewing',
2226
- reviewedBy: qa.name,
2227
- });
2228
- createLog(db.db, {
2229
- agentId: qa.name,
2230
- storyId: nextPR.story_id || undefined,
2231
- eventType: 'PR_REVIEW_STARTED',
2232
- message: `Manager assigned PR review: ${nextPR.id}`,
2233
- metadata: { pr_id: nextPR.id, branch: nextPR.branch_name },
2234
- });
2235
- },
2236
- () => db.save()
2237
- );
2238
- dispatchedList.push({
2239
- prId: nextPR.id,
2240
- qaName: qa.name,
2241
- storyId: nextPR.story_id,
2242
- githubPrUrl: nextPR.github_pr_url,
2243
- });
2244
- dispatchCount++;
2245
- verboseLogCtx(ctx, `notifyQAOfQueuedPRs: assigned pr=${nextPR.id} -> ${qa.name}`);
2246
- }
2247
- return { queuedPRs: queued, dispatched: dispatchedList };
1230
+ async function notifyUnassignedStories(ctx: ManagerCheckContext): Promise<void> {
1231
+ // Phase 1: Read planned unassigned stories (brief lock)
1232
+ const plannedCount = await ctx.withDb(async db => {
1233
+ const plannedStories = queryAll<StoryRow>(
1234
+ db.db,
1235
+ "SELECT * FROM stories WHERE status = 'planned' AND assigned_agent_id IS NULL"
1236
+ );
1237
+ return plannedStories.length;
2248
1238
  });
2249
1239
 
2250
- if (queuedPRs.length === 0) return;
1240
+ if (plannedCount === 0) return;
1241
+ verboseLogCtx(ctx, `notifyUnassignedStories: plannedUnassigned=${plannedCount}`);
2251
1242
 
2252
- // Phase 2: Send tmux nudges (no lock needed)
2253
- for (const d of dispatched) {
2254
- const githubLine = d.githubPrUrl ? `\n# GitHub: ${d.githubPrUrl}` : '';
2255
- await sendManagerNudge(
2256
- ctx,
2257
- d.qaName,
2258
- `# You are assigned PR review ${d.prId} (${d.storyId || 'no-story'}).${githubLine}
2259
- # Execute now:
2260
- # hive pr show ${d.prId}
2261
- # hive pr approve ${d.prId}
2262
- # (If manual merge is required in this repo, use --no-merge.)
2263
- # or reject:
2264
- # hive pr reject ${d.prId} -r "reason"`
2265
- );
2266
- }
1243
+ // Phase 2: Tmux captures and nudges (no lock needed)
1244
+ const seniorSessions = ctx.hiveSessions.filter(s => s.name.includes('-senior-'));
1245
+ verboseLogCtx(ctx, `notifyUnassignedStories: seniorSessions=${seniorSessions.length}`);
1246
+ for (const senior of seniorSessions) {
1247
+ const seniorAgent = ctx.agentsBySessionName.get(senior.name);
1248
+ const seniorCliTool = (seniorAgent?.cli_tool || 'claude') as CLITool;
1249
+ const output = await captureTmuxPane(senior.name, TMUX_CAPTURE_LINES_SHORT);
1250
+ const stateResult = detectAgentState(output, seniorCliTool);
2267
1251
 
2268
- // Fallback nudge if PRs are still queued but all QA sessions are busy/unavailable.
2269
- if (dispatched.length === 0) {
2270
- verboseLogCtx(ctx, 'notifyQAOfQueuedPRs: no idle QA, sent queue nudge fallback');
2271
- const qaSessions = ctx.hiveSessions.filter(s => s.name.includes('-qa-'));
2272
- for (const qa of qaSessions) {
2273
- await sendManagerNudge(
1252
+ if (
1253
+ stateResult.isWaiting &&
1254
+ !stateResult.needsHuman &&
1255
+ stateResult.state !== AgentState.THINKING
1256
+ ) {
1257
+ verboseLogCtx(
2274
1258
  ctx,
2275
- qa.name,
2276
- `# ${queuedPRs.length} PR(s) waiting in queue. Run: hive pr queue`
2277
- );
2278
- }
2279
- }
2280
- }
2281
-
2282
- /**
2283
- * Auto-reject PRs where the QA agent posted review comments/feedback on GitHub
2284
- * but never formally approved or rejected via `hive pr approve/reject`.
2285
- *
2286
- * Detection: PR is in 'reviewing' status, the assigned QA agent is idle,
2287
- * and there are GitHub comments or CHANGES_REQUESTED reviews on the PR.
2288
- *
2289
- * Action: Auto-reject the PR with the QA's feedback as the rejection reason,
2290
- * which triggers the standard qa_failed flow back to the developer agent.
2291
- */
2292
- export async function autoRejectCommentOnlyReviews(ctx: ManagerCheckContext): Promise<void> {
2293
- // Phase 1: Identify reviewing PRs with idle QA agents (brief lock)
2294
- const candidates = await ctx.withDb(async db => {
2295
- const reviewingPRs = getPullRequestsByStatus(db.db, 'reviewing').filter(
2296
- pr => pr.github_pr_number && pr.team_id && pr.reviewed_by
2297
- );
2298
-
2299
- verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: reviewingWithQA=${reviewingPRs.length}`);
2300
- if (reviewingPRs.length === 0) return [];
2301
-
2302
- // Only consider PRs whose QA agent is idle (finished reviewing but didn't approve/reject)
2303
- const idlePRs = reviewingPRs.filter(pr => {
2304
- const qaAgent = ctx.agentsBySessionName.get(pr.reviewed_by!);
2305
- if (!qaAgent) return false;
2306
- // Check if the QA agent is idle or if their session shows idle state
2307
- const qaState = agentStates.get(pr.reviewed_by!);
2308
- return qaAgent.status === 'idle' || qaState?.lastState === AgentState.IDLE_AT_PROMPT;
2309
- });
2310
-
2311
- verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: idleQACandidates=${idlePRs.length}`);
2312
- if (idlePRs.length === 0) return [];
2313
-
2314
- const { getAllTeams } = await import('../../../db/queries/teams.js');
2315
- const teams = getAllTeams(db.db);
2316
- const teamsById = new Map(teams.map(team => [team.id, team]));
2317
-
2318
- return idlePRs
2319
- .map(pr => {
2320
- const team = teamsById.get(pr.team_id!);
2321
- if (!team?.repo_path) return null;
2322
- return {
2323
- id: pr.id,
2324
- storyId: pr.story_id,
2325
- teamId: pr.team_id!,
2326
- branchName: pr.branch_name,
2327
- githubPrNumber: pr.github_pr_number!,
2328
- reviewedBy: pr.reviewed_by!,
2329
- submittedBy: pr.submitted_by,
2330
- repoDir: `${ctx.root}/${team.repo_path}`,
2331
- };
2332
- })
2333
- .filter(Boolean) as Array<{
2334
- id: string;
2335
- storyId: string | null;
2336
- branchName: string;
2337
- githubPrNumber: number;
2338
- reviewedBy: string;
2339
- submittedBy: string | null;
2340
- teamId: string;
2341
- repoDir: string;
2342
- }>;
2343
- });
2344
-
2345
- if (candidates.length === 0) return;
2346
-
2347
- // Phase 2: Check GitHub for comments/reviews on each candidate (no lock)
2348
- const toReject: Array<{
2349
- candidate: (typeof candidates)[number];
2350
- reason: string;
2351
- }> = [];
2352
-
2353
- for (const candidate of candidates) {
2354
- try {
2355
- // Fetch both reviews and comments from GitHub
2356
- const [reviews, comments] = await Promise.all([
2357
- getPullRequestReviews(candidate.repoDir, candidate.githubPrNumber).catch(
2358
- (): Array<{ author: string; state: string; body: string }> => []
2359
- ),
2360
- getPullRequestComments(candidate.repoDir, candidate.githubPrNumber).catch(
2361
- (): Array<{ author: string; body: string; createdAt: string }> => []
2362
- ),
2363
- ]);
2364
-
2365
- // If there's a formal APPROVED review, skip (QA approved via GitHub directly)
2366
- const hasApproval = reviews.some(r => r.state === 'APPROVED');
2367
- if (hasApproval) {
2368
- verboseLogCtx(
2369
- ctx,
2370
- `autoRejectCommentOnlyReviews: pr=${candidate.id} has GitHub approval, skipping`
2371
- );
2372
- continue;
2373
- }
2374
-
2375
- // Check for CHANGES_REQUESTED reviews
2376
- const changesRequested = reviews.filter(r => r.state === 'CHANGES_REQUESTED');
2377
-
2378
- // Check for substantive issue comments (filter out bot noise and very short comments)
2379
- const substantiveComments = comments.filter(c => {
2380
- if (c.body.length < 20) return false;
2381
- // Skip known bot comments (Ellipsis, etc.)
2382
- if (c.body.includes('Looks good to me') && c.body.length < 100) return false;
2383
- return true;
2384
- });
2385
-
2386
- // If there are review feedback items, auto-reject
2387
- if (changesRequested.length > 0 || substantiveComments.length > 0) {
2388
- // Build rejection reason from the feedback
2389
- const feedbackParts: string[] = [];
2390
- for (const review of changesRequested) {
2391
- if (review.body) feedbackParts.push(review.body);
2392
- }
2393
- for (const comment of substantiveComments) {
2394
- feedbackParts.push(comment.body);
2395
- }
2396
- const reason =
2397
- feedbackParts.length > 0
2398
- ? feedbackParts.join('\n---\n').slice(0, 2000)
2399
- : 'QA posted review feedback on GitHub without formal approval. See PR comments.';
2400
-
2401
- toReject.push({ candidate, reason });
2402
- verboseLogCtx(
2403
- ctx,
2404
- `autoRejectCommentOnlyReviews: pr=${candidate.id} has ${changesRequested.length} changes_requested + ${substantiveComments.length} comments, will auto-reject`
2405
- );
2406
- }
2407
- } catch (err) {
2408
- verboseLogCtx(
2409
- ctx,
2410
- `autoRejectCommentOnlyReviews: skip pr=${candidate.id} github_check_failed=${err instanceof Error ? err.message : String(err)}`
2411
- );
2412
- }
2413
- }
2414
-
2415
- if (toReject.length === 0) return;
2416
-
2417
- // Phase 3: Reject PRs in DB (brief lock)
2418
- await ctx.withDb(async db => {
2419
- for (const { candidate, reason } of toReject) {
2420
- await withTransaction(
2421
- db.db,
2422
- () => {
2423
- updatePullRequest(db.db, candidate.id, {
2424
- status: 'rejected',
2425
- reviewNotes: reason,
2426
- });
2427
- if (candidate.storyId) {
2428
- updateStory(db.db, candidate.storyId, { status: 'qa_failed' });
2429
- }
2430
- createLog(db.db, {
2431
- agentId: 'manager',
2432
- eventType: 'PR_REJECTED',
2433
- message: `Auto-rejected PR ${candidate.id}: QA posted review comments without formal approve/reject`,
2434
- storyId: candidate.storyId || undefined,
2435
- metadata: { pr_id: candidate.id, auto_rejected: true },
2436
- });
2437
- },
2438
- () => db.save()
2439
- );
2440
- console.log(
2441
- chalk.yellow(
2442
- ` Auto-rejected PR ${candidate.id} (story: ${candidate.storyId || '-'}): QA left review comments without approving`
2443
- )
2444
- );
2445
- }
2446
- });
2447
-
2448
- // Phase 4: Notify developer agents via tmux (no lock)
2449
- for (const { candidate, reason } of toReject) {
2450
- if (candidate.submittedBy) {
2451
- const devSession = ctx.hiveSessions.find(s => s.name === candidate.submittedBy);
2452
- if (devSession) {
2453
- await sendManagerNudge(
2454
- ctx,
2455
- devSession.name,
2456
- `# ⚠️ PR AUTO-REJECTED - QA REVIEW FEEDBACK ⚠️
2457
- # Story: ${candidate.storyId || 'Unknown'}
2458
- # QA agent (${candidate.reviewedBy}) posted review feedback without formally approving.
2459
- # Feedback:
2460
- # ${reason.split('\n').slice(0, 10).join('\n# ')}
2461
- #
2462
- # Fix the issues and resubmit: hive pr submit -b ${candidate.branchName} -s ${candidate.storyId || 'STORY-ID'} --from ${devSession.name}`
2463
- );
2464
- }
2465
- }
2466
- }
2467
- }
2468
-
2469
- async function handleRejectedPRs(ctx: ManagerCheckContext): Promise<void> {
2470
- // Phase 1: Read rejected PRs and update DB (brief lock)
2471
- const rejectedPRData = await ctx.withDb(async db => {
2472
- const rejectedPRs = getPullRequestsByStatus(db.db, 'rejected');
2473
- verboseLogCtx(ctx, `handleRejectedPRs: rejected=${rejectedPRs.length}`);
2474
- if (rejectedPRs.length === 0) return [];
2475
-
2476
- const prData: Array<{
2477
- id: string;
2478
- storyId: string | null;
2479
- branchName: string;
2480
- reviewNotes: string | null;
2481
- submittedBy: string | null;
2482
- }> = [];
2483
-
2484
- for (const pr of rejectedPRs) {
2485
- if (pr.story_id) {
2486
- const storyId = pr.story_id;
2487
- await withTransaction(
2488
- db.db,
2489
- () => {
2490
- updateStory(db.db, storyId, { status: 'qa_failed' });
2491
- createLog(db.db, {
2492
- agentId: 'manager',
2493
- eventType: 'STORY_QA_FAILED',
2494
- message: `Story ${storyId} QA failed: ${pr.review_notes || 'See review comments'}`,
2495
- storyId: storyId,
2496
- });
2497
- },
2498
- () => db.save()
2499
- );
2500
-
2501
- // Sync status change to Jira
2502
- await syncStatusForStory(ctx.root, db.db, storyId, 'qa_failed');
2503
- }
2504
-
2505
- // Mark as closed to prevent re-notification spam
2506
- await withTransaction(
2507
- db.db,
2508
- () => {
2509
- updatePullRequest(db.db, pr.id, { status: 'closed' });
2510
- },
2511
- () => db.save()
2512
- );
2513
-
2514
- prData.push({
2515
- id: pr.id,
2516
- storyId: pr.story_id,
2517
- branchName: pr.branch_name,
2518
- reviewNotes: pr.review_notes,
2519
- submittedBy: pr.submitted_by,
2520
- });
2521
- }
2522
- return prData;
2523
- });
2524
-
2525
- if (rejectedPRData.length === 0) return;
2526
-
2527
- // Phase 2: Send tmux notifications (no lock needed)
2528
- let rejectionNotified = 0;
2529
- for (const pr of rejectedPRData) {
2530
- if (pr.submittedBy) {
2531
- const devSession = ctx.hiveSessions.find(s => s.name === pr.submittedBy);
2532
- if (devSession) {
2533
- verboseLogCtx(
2534
- ctx,
2535
- `handleRejectedPRs: notifying ${devSession.name} for pr=${pr.id}, story=${pr.storyId || '-'}`
2536
- );
2537
- await sendManagerNudge(
2538
- ctx,
2539
- devSession.name,
2540
- `# ⚠️ PR REJECTED - ACTION REQUIRED ⚠️
2541
- # Story: ${pr.storyId || 'Unknown'}
2542
- # Reason: ${pr.reviewNotes || 'See review comments'}
2543
- #
2544
- # You MUST fix this issue before doing anything else.
2545
- # Fix the issues and resubmit: hive pr submit -b ${pr.branchName} -s ${pr.storyId || 'STORY-ID'} --from ${devSession.name}`
2546
- );
2547
- rejectionNotified++;
2548
- }
2549
- }
2550
- }
2551
-
2552
- console.log(chalk.yellow(` Notified ${rejectionNotified} developer(s) of PR rejection(s)`));
2553
- }
2554
-
2555
- async function nudgeQAFailedStories(ctx: ManagerCheckContext): Promise<void> {
2556
- // Phase 1: Read QA-failed stories and agents (brief lock)
2557
- const candidates = await ctx.withDb(async db => {
2558
- const qaFailedStories = getStoriesByStatus(db.db, 'qa_failed').filter(
2559
- story => !['merged', 'completed'].includes(story.status)
2560
- );
2561
- verboseLogCtx(ctx, `nudgeQAFailedStories: candidates=${qaFailedStories.length}`);
2562
-
2563
- const result: Array<{ storyId: string; sessionName: string; cliTool: CLITool }> = [];
2564
- for (const story of qaFailedStories) {
2565
- if (!story.assigned_agent_id) {
2566
- verboseLogCtx(ctx, `nudgeQAFailedStories: story=${story.id} skip=no_assigned_agent`);
2567
- continue;
2568
- }
2569
- const agent = getAgentById(db.db, story.assigned_agent_id);
2570
- if (!agent || agent.status !== 'working') {
2571
- verboseLogCtx(
2572
- ctx,
2573
- `nudgeQAFailedStories: story=${story.id} skip=agent_not_working status=${agent?.status || 'missing'}`
2574
- );
2575
- continue;
2576
- }
2577
- const agentSession = findSessionForAgent(ctx.hiveSessions, agent);
2578
- if (!agentSession) {
2579
- verboseLogCtx(ctx, `nudgeQAFailedStories: story=${story.id} skip=no_session`);
2580
- continue;
2581
- }
2582
- result.push({
2583
- storyId: story.id,
2584
- sessionName: agentSession.name,
2585
- cliTool: (agent.cli_tool || 'claude') as CLITool,
2586
- });
2587
- }
2588
- return result;
2589
- });
2590
-
2591
- // Phase 2: Tmux captures and nudges (no lock needed)
2592
- for (const candidate of candidates) {
2593
- const output = await captureTmuxPane(candidate.sessionName, TMUX_CAPTURE_LINES_SHORT);
2594
- const stateResult = detectAgentState(output, candidate.cliTool);
2595
-
2596
- if (
2597
- stateResult.isWaiting &&
2598
- !stateResult.needsHuman &&
2599
- stateResult.state !== AgentState.THINKING
2600
- ) {
2601
- verboseLogCtx(
2602
- ctx,
2603
- `nudgeQAFailedStories: story=${candidate.storyId} nudge session=${candidate.sessionName} state=${stateResult.state}`
2604
- );
2605
- await sendManagerNudge(
2606
- ctx,
2607
- candidate.sessionName,
2608
- `# REMINDER: Story ${candidate.storyId} failed QA review!
2609
- # You must fix the issues and resubmit the PR.
2610
- # Check the QA feedback and address all concerns.
2611
- hive pr queue`
2612
- );
2613
- } else {
2614
- verboseLogCtx(
2615
- ctx,
2616
- `nudgeQAFailedStories: story=${candidate.storyId} skip=not_ready waiting=${stateResult.isWaiting} needsHuman=${stateResult.needsHuman} state=${stateResult.state}`
2617
- );
2618
- }
2619
- }
2620
- }
2621
-
2622
- async function recoverUnassignedQAFailedStories(ctx: ManagerCheckContext): Promise<void> {
2623
- const result = await ctx.withDb(async (db, scheduler) => {
2624
- const recoverableStories = queryAll<StoryRow>(
2625
- db.db,
2626
- `
2627
- SELECT * FROM stories
2628
- WHERE status = 'qa_failed'
2629
- AND assigned_agent_id IS NULL
2630
- `
2631
- );
2632
-
2633
- if (recoverableStories.length === 0) return null;
2634
- verboseLogCtx(ctx, `recoverUnassignedQAFailedStories: recovered=${recoverableStories.length}`);
2635
-
2636
- await withTransaction(
2637
- db.db,
2638
- () => {
2639
- for (const story of recoverableStories) {
2640
- updateStory(db.db, story.id, { status: 'planned', assignedAgentId: null });
2641
- createLog(db.db, {
2642
- agentId: 'manager',
2643
- storyId: story.id,
2644
- eventType: 'ORPHANED_STORY_RECOVERED',
2645
- message: `Recovered QA-failed story ${story.id} (unassigned) back to planned`,
2646
- metadata: { from_status: 'qa_failed', to_status: 'planned' },
2647
- });
2648
- }
2649
- },
2650
- () => db.save()
2651
- );
2652
-
2653
- for (const story of recoverableStories) {
2654
- await syncStatusForStory(ctx.root, db.db, story.id, 'planned');
2655
- }
2656
-
2657
- // Proactively re-assign recovered work so it does not stall until manual `hive assign`.
2658
- const assignmentResult = await scheduler.assignStories();
2659
- verboseLogCtx(
2660
- ctx,
2661
- `recoverUnassignedQAFailedStories.assignStories: assigned=${assignmentResult.assigned}, errors=${assignmentResult.errors.length}`
2662
- );
2663
- db.save();
2664
-
2665
- if (assignmentResult.assigned > 0) {
2666
- await scheduler.flushJiraQueue();
2667
- db.save();
2668
- }
2669
-
2670
- return { recoverableCount: recoverableStories.length, assignmentResult };
2671
- });
2672
-
2673
- if (result) {
2674
- console.log(
2675
- chalk.yellow(
2676
- ` Recovered ${result.recoverableCount} QA-failed unassigned story(ies), assigned ${result.assignmentResult.assigned}`
2677
- )
2678
- );
2679
- if (result.assignmentResult.errors.length > 0) {
2680
- console.log(
2681
- chalk.yellow(
2682
- ` Assignment errors during QA-failed recovery: ${result.assignmentResult.errors.length}`
2683
- )
2684
- );
2685
- }
2686
- }
2687
- }
2688
-
2689
- async function nudgeStuckStories(ctx: ManagerCheckContext): Promise<void> {
2690
- const stuckThresholdMs = Math.max(1, ctx.config.manager.stuck_threshold_ms);
2691
- const staticInactivityThresholdMs = getScreenStaticInactivityThresholdMs(ctx.config);
2692
- const maxStuckNudgesPerStory = getMaxStuckNudgesPerStory(ctx.config);
2693
- const waitingNudgeCooldownMs = Math.max(
2694
- ctx.config.manager.nudge_cooldown_ms,
2695
- staticInactivityThresholdMs
2696
- );
2697
- const staleUpdatedAt = new Date(Date.now() - stuckThresholdMs).toISOString();
2698
-
2699
- // Phase 1: Read stuck stories and agents (brief lock)
2700
- const candidates = await ctx.withDb(async db => {
2701
- const stuckStories = queryAll<StoryRow>(
2702
- db.db,
2703
- `SELECT * FROM stories
2704
- WHERE status = 'in_progress'
2705
- AND updated_at < ?`,
2706
- [staleUpdatedAt]
2707
- ).filter(story => !['merged', 'completed'].includes(story.status));
2708
- verboseLogCtx(
2709
- ctx,
2710
- `nudgeStuckStories: candidates=${stuckStories.length}, staleBefore=${staleUpdatedAt}, thresholdMs=${stuckThresholdMs}`
2711
- );
2712
-
2713
- const result: Array<{
2714
- story: StoryRow;
2715
- agent: ReturnType<typeof getAllAgents>[number];
2716
- sessionName: string;
2717
- cliTool: CLITool;
2718
- }> = [];
2719
-
2720
- for (const story of stuckStories) {
2721
- verboseLogCtx(ctx, `nudgeStuckStories: evaluating story=${story.id}`);
2722
- if (!story.assigned_agent_id) {
2723
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=no_assigned_agent`);
2724
- continue;
2725
- }
2726
- const agent = getAgentById(db.db, story.assigned_agent_id);
2727
- if (!agent) {
2728
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=missing_agent`);
2729
- continue;
2730
- }
2731
- const agentSession = findSessionForAgent(ctx.hiveSessions, agent);
2732
- if (!agentSession) {
2733
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=no_agent_session`);
2734
- continue;
2735
- }
2736
- result.push({
2737
- story,
2738
- agent,
2739
- sessionName: agentSession.name,
2740
- cliTool: (agent.cli_tool || 'claude') as CLITool,
2741
- });
2742
- }
2743
- return result;
2744
- });
2745
-
2746
- // Phase 2: Tmux captures, AI classifier, nudges (no lock held)
2747
- for (const candidate of candidates) {
2748
- const { story, agent, sessionName, cliTool } = candidate;
2749
- const now = Date.now();
2750
- verboseLogCtx(
2751
- ctx,
2752
- `nudgeStuckStories: story=${story.id} session=${sessionName} cli=${cliTool}`
2753
- );
2754
-
2755
- const trackedState = agentStates.get(sessionName);
2756
- if (
2757
- trackedState &&
2758
- [
2759
- AgentState.ASKING_QUESTION,
2760
- AgentState.AWAITING_SELECTION,
2761
- AgentState.PLAN_APPROVAL,
2762
- AgentState.PERMISSION_REQUIRED,
2763
- AgentState.USER_DECLINED,
2764
- ].includes(trackedState.lastState)
2765
- ) {
2766
- verboseLogCtx(
2767
- ctx,
2768
- `nudgeStuckStories: story=${story.id} skip=waiting_for_human state=${trackedState.lastState}`
2769
- );
2770
- continue;
2771
- }
2772
- if (trackedState && now - trackedState.lastNudgeTime < waitingNudgeCooldownMs) {
2773
- verboseLogCtx(
2774
- ctx,
2775
- `nudgeStuckStories: story=${story.id} skip=nudge_to_ai_window remainingMs=${waitingNudgeCooldownMs - (now - trackedState.lastNudgeTime)}`
2776
- );
2777
- continue;
2778
- }
2779
-
2780
- const output = await captureTmuxPane(sessionName, TMUX_CAPTURE_LINES_SHORT);
2781
- const stateResult = detectAgentState(output, cliTool);
2782
- verboseLogCtx(
2783
- ctx,
2784
- `nudgeStuckStories: story=${story.id} detected state=${stateResult.state}, waiting=${stateResult.isWaiting}, needsHuman=${stateResult.needsHuman}`
2785
- );
2786
- if (stateResult.needsHuman) {
2787
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} skip=needs_human`);
2788
- continue;
2789
- }
2790
- const sessionUnchangedForMs = getSessionStaticUnchangedForMs(sessionName, now);
2791
- const unknownLooksStuck = shouldTreatUnknownAsStuckWaiting({
2792
- state: stateResult.state,
2793
- isWaiting: stateResult.isWaiting,
2794
- sessionUnchangedForMs,
2795
- staticInactivityThresholdMs,
2796
- });
2797
- if (stateResult.state === AgentState.THINKING) {
2798
- if (trackedState && (trackedState.storyStuckNudgeCount || 0) > 0) {
2799
- trackedState.storyStuckNudgeCount = 0;
2800
- }
2801
- clearHumanIntervention(sessionName);
2802
- verboseLogCtx(
2803
- ctx,
2804
- `nudgeStuckStories: story=${story.id} skip=thinking state=${stateResult.state}`
2805
- );
2806
- continue;
2807
- }
2808
- if (!stateResult.isWaiting && !unknownLooksStuck) {
2809
- if (trackedState && (trackedState.storyStuckNudgeCount || 0) > 0) {
2810
- trackedState.storyStuckNudgeCount = 0;
2811
- }
2812
- clearHumanIntervention(sessionName);
2813
- verboseLogCtx(
2814
- ctx,
2815
- `nudgeStuckStories: story=${story.id} skip=not_waiting state=${stateResult.state}`
2816
- );
2817
- continue;
2818
- }
2819
- if (unknownLooksStuck) {
2820
- verboseLogCtx(
2821
- ctx,
2822
- `nudgeStuckStories: story=${story.id} action=unknown_state_stuck_heuristic unchangedMs=${sessionUnchangedForMs}`
2823
- );
2824
- }
2825
-
2826
- if (
2827
- shouldDeferStuckReminderUntilStaticWindow({
2828
- state: stateResult.state,
2829
- sessionUnchangedForMs,
2830
- staticInactivityThresholdMs,
2831
- })
2832
- ) {
2833
- verboseLogCtx(
2834
- ctx,
2835
- `nudgeStuckStories: story=${story.id} skip=done_inference_static_window remainingMs=${staticInactivityThresholdMs - sessionUnchangedForMs}`
2836
- );
2837
- continue;
2838
- } else {
2839
- const completionAssessment = await assessCompletionFromOutput(
2840
- ctx.config,
2841
- sessionName,
2842
- story.id,
2843
- output
2844
- );
2845
- const aiSaysDone =
2846
- completionAssessment.done &&
2847
- completionAssessment.confidence >= DONE_INFERENCE_CONFIDENCE_THRESHOLD;
2848
- verboseLogCtx(
2849
- ctx,
2850
- `nudgeStuckStories: story=${story.id} doneInference done=${completionAssessment.done}, confidence=${completionAssessment.confidence.toFixed(2)}, aiSaysDone=${aiSaysDone}, reason=${completionAssessment.reason}`
2851
- );
2852
- if (isClassifierTimeoutReason(completionAssessment.reason)) {
2853
- await markClassifierTimeoutForHumanIntervention(
2854
- ctx,
2855
- sessionName,
2856
- story.id,
2857
- completionAssessment.reason,
2858
- agent.id
2859
- );
2860
- verboseLogCtx(
2861
- ctx,
2862
- `nudgeStuckStories: story=${story.id} action=classifier_timeout_escalation session=${sessionName}`
2863
- );
2864
- continue;
2865
- }
2866
- clearHumanIntervention(sessionName);
2867
-
2868
- if (aiSaysDone) {
2869
- const progressed = await autoProgressDoneStory(
2870
- ctx,
2871
- story,
2872
- agent,
2873
- sessionName,
2874
- completionAssessment.reason,
2875
- completionAssessment.confidence
2876
- );
2877
- if (progressed) {
2878
- ctx.counters.autoProgressed++;
2879
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} action=auto_progressed`);
2880
- continue;
2881
- }
2882
- verboseLogCtx(ctx, `nudgeStuckStories: story=${story.id} auto_progress_failed`);
2883
- } else {
2884
- const stuckNudgesSent = trackedState?.storyStuckNudgeCount || 0;
2885
- if (stuckNudgesSent >= maxStuckNudgesPerStory) {
2886
- await markDoneFalseForHumanIntervention(
2887
- ctx,
2888
- sessionName,
2889
- story.id,
2890
- completionAssessment.reason,
2891
- agent.id
2892
- );
2893
- verboseLogCtx(
2894
- ctx,
2895
- `nudgeStuckStories: story=${story.id} action=done_false_escalation session=${sessionName}`
2896
- );
2897
- continue;
2898
- }
2899
- }
2900
- }
2901
-
2902
- const stuckNudgesSent = trackedState?.storyStuckNudgeCount || 0;
2903
- if (stuckNudgesSent >= maxStuckNudgesPerStory) {
2904
- verboseLogCtx(
2905
- ctx,
2906
- `nudgeStuckStories: story=${story.id} skip=stuck_nudge_limit reached=${stuckNudgesSent}/${maxStuckNudgesPerStory}`
2907
- );
2908
- continue;
2909
- }
2910
-
2911
- if (stateResult.state === AgentState.WORK_COMPLETE) {
2912
- verboseLogCtx(
2913
- ctx,
2914
- `nudgeStuckStories: story=${story.id} action=mandatory_completion_signal session=${sessionName}`
2915
- );
2916
- const completionSignalLines = [
2917
- `# MANDATORY COMPLETION SIGNAL: execute now for ${story.id}`,
2918
- `hive pr submit -b $(git rev-parse --abbrev-ref HEAD) -s ${story.id} --from ${sessionName}`,
2919
- `hive my-stories complete ${story.id}`,
2920
- ];
2921
- if (shouldIncludeProgressUpdates(ctx.config)) {
2922
- completionSignalLines.push(
2923
- `hive progress ${story.id} -m "PR submitted to merge queue" --from ${sessionName} --done`
2924
- );
2925
- } else {
2926
- completionSignalLines.push(
2927
- '# project_management.provider is none; skip hive progress in this workspace.'
2928
- );
2929
- }
2930
- completionSignalLines.push(
2931
- '# Do not stop at a summary. Completion requires the commands above.'
2932
- );
2933
-
2934
- await sendManagerNudge(ctx, sessionName, completionSignalLines.join('\n'));
2935
- ctx.counters.nudged++;
2936
- if (trackedState) {
2937
- trackedState.lastNudgeTime = now;
2938
- trackedState.storyStuckNudgeCount = (trackedState.storyStuckNudgeCount || 0) + 1;
2939
- } else {
2940
- agentStates.set(sessionName, {
2941
- lastState: stateResult.state,
2942
- lastStateChangeTime: now,
2943
- lastNudgeTime: now,
2944
- storyStuckNudgeCount: 1,
2945
- });
2946
- }
2947
- continue;
2948
- }
2949
-
2950
- verboseLogCtx(
2951
- ctx,
2952
- `nudgeStuckStories: story=${story.id} action=stuck_reminder session=${sessionName}`
2953
- );
2954
- await sendManagerNudge(
2955
- ctx,
2956
- sessionName,
2957
- `# REMINDER: Story ${story.id} has been in progress for a while.
2958
- # If stuck, escalate to your Senior or Tech Lead.
2959
- # If done, submit your PR: hive pr submit -b $(git rev-parse --abbrev-ref HEAD) -s ${story.id} --from ${sessionName}
2960
- # Then mark complete: hive my-stories complete ${story.id}`
2961
- );
2962
- ctx.counters.nudged++;
2963
- if (trackedState) {
2964
- trackedState.lastNudgeTime = now;
2965
- trackedState.storyStuckNudgeCount = (trackedState.storyStuckNudgeCount || 0) + 1;
2966
- } else {
2967
- agentStates.set(sessionName, {
2968
- lastState: stateResult.state,
2969
- lastStateChangeTime: now,
2970
- lastNudgeTime: now,
2971
- storyStuckNudgeCount: 1,
2972
- });
2973
- }
2974
- }
2975
- }
2976
-
2977
- async function autoProgressDoneStory(
2978
- ctx: ManagerCheckContext,
2979
- story: StoryRow,
2980
- agent: ReturnType<typeof getAllAgents>[number],
2981
- sessionName: string,
2982
- reason: string,
2983
- confidence: number
2984
- ): Promise<boolean> {
2985
- verboseLogCtx(
2986
- ctx,
2987
- `autoProgressDoneStory: story=${story.id}, session=${sessionName}, confidence=${confidence.toFixed(2)}`
2988
- );
2989
-
2990
- // Resolve branch name outside lock (involves git operations)
2991
- const branch = await resolveStoryBranchName(ctx.root, story, agent, msg =>
2992
- verboseLogCtx(ctx, `resolveStoryBranchName: story=${story.id} ${msg}`)
2993
- );
2994
-
2995
- // DB operations under brief lock
2996
- const action = await ctx.withDb(async (db, scheduler) => {
2997
- const openPRs = getOpenPullRequestsByStory(db.db, story.id);
2998
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id}, openPRs=${openPRs.length}`);
2999
- if (openPRs.length > 0) {
3000
- if (story.status !== 'pr_submitted') {
3001
- updateStory(db.db, story.id, { status: 'pr_submitted' });
3002
- createLog(db.db, {
3003
- agentId: 'manager',
3004
- storyId: story.id,
3005
- eventType: 'STORY_PROGRESS_UPDATE',
3006
- message: `Auto-progressed ${story.id} to pr_submitted (existing PR detected)`,
3007
- metadata: {
3008
- session_name: sessionName,
3009
- recovery: 'done_inference_existing_pr',
3010
- reason,
3011
- confidence,
3012
- open_pr_count: openPRs.length,
3013
- },
3014
- });
3015
- db.save();
3016
- await syncStatusForStory(ctx.root, db.db, story.id, 'pr_submitted');
3017
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} status moved to pr_submitted`);
3018
- }
3019
- return 'existing_pr' as const;
3020
- }
3021
-
3022
- if (!branch) {
3023
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} action=failed_no_branch`);
3024
- return 'no_branch' as const;
3025
- }
3026
-
3027
- await withTransaction(
3028
- db.db,
3029
- () => {
3030
- updateStory(db.db, story.id, { status: 'pr_submitted', branchName: branch });
3031
- createPullRequest(db.db, {
3032
- storyId: story.id,
3033
- teamId: story.team_id || null,
3034
- branchName: branch,
3035
- submittedBy: sessionName,
3036
- });
3037
- createLog(db.db, {
3038
- agentId: 'manager',
3039
- storyId: story.id,
3040
- eventType: 'PR_SUBMITTED',
3041
- message: `Auto-submitted PR for ${story.id} after AI completion inference`,
3042
- metadata: {
3043
- session_name: sessionName,
3044
- recovery: 'done_inference_auto_submit',
3045
- reason,
3046
- confidence,
3047
- branch,
3048
- },
3049
- });
3050
- },
3051
- () => db.save()
3052
- );
3053
- await syncStatusForStory(ctx.root, db.db, story.id, 'pr_submitted');
3054
- await scheduler.checkMergeQueue();
3055
- db.save();
3056
- verboseLogCtx(
3057
- ctx,
3058
- `autoProgressDoneStory: story=${story.id} action=auto_submitted branch=${branch}`
3059
- );
3060
- return 'auto_submitted' as const;
3061
- });
3062
-
3063
- // Tmux notifications (no lock needed)
3064
- if (action === 'existing_pr') {
3065
- await sendManagerNudge(
3066
- ctx,
3067
- sessionName,
3068
- `# AUTO-PROGRESS: Manager inferred ${story.id} is complete (confidence ${confidence.toFixed(2)}), detected existing PR, and moved story to PR-submitted state.`
3069
- );
3070
- verboseLogCtx(ctx, `autoProgressDoneStory: story=${story.id} action=existing_pr_progressed`);
3071
- return true;
3072
- }
3073
-
3074
- if (action === 'no_branch') {
3075
- return false;
3076
- }
3077
-
3078
- await sendManagerNudge(
3079
- ctx,
3080
- sessionName,
3081
- `# AUTO-PROGRESS: Manager inferred ${story.id} is complete (confidence ${confidence.toFixed(2)}), auto-submitted branch ${branch} to merge queue.`
3082
- );
3083
- return true;
3084
- }
3085
-
3086
- async function resolveStoryBranchName(
3087
- root: string,
3088
- story: StoryRow,
3089
- agent: ReturnType<typeof getAllAgents>[number],
3090
- log?: (message: string) => void
3091
- ): Promise<string | null> {
3092
- if (story.branch_name && story.branch_name.trim().length > 0) {
3093
- log?.(`source=story.branch_name value=${story.branch_name.trim()}`);
3094
- return story.branch_name.trim();
3095
- }
3096
-
3097
- if (!agent.worktree_path) {
3098
- log?.('source=worktree skip=no_worktree_path');
3099
- return null;
3100
- }
3101
-
3102
- const worktreeDir = join(root, agent.worktree_path);
3103
- try {
3104
- const result = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: worktreeDir });
3105
- const branch = result.stdout.trim();
3106
- if (!branch || branch === 'HEAD') {
3107
- log?.(`source=git_rev_parse invalid_branch=${branch || '(empty)'}`);
3108
- return null;
3109
- }
3110
- log?.(`source=git_rev_parse value=${branch}`);
3111
- return branch;
3112
- } catch {
3113
- log?.(`source=git_rev_parse failed cwd=${worktreeDir}`);
3114
- return null;
3115
- }
3116
- }
3117
-
3118
- async function notifyUnassignedStories(ctx: ManagerCheckContext): Promise<void> {
3119
- // Phase 1: Read planned unassigned stories (brief lock)
3120
- const plannedCount = await ctx.withDb(async db => {
3121
- const plannedStories = queryAll<StoryRow>(
3122
- db.db,
3123
- "SELECT * FROM stories WHERE status = 'planned' AND assigned_agent_id IS NULL"
3124
- );
3125
- return plannedStories.length;
3126
- });
3127
-
3128
- if (plannedCount === 0) return;
3129
- verboseLogCtx(ctx, `notifyUnassignedStories: plannedUnassigned=${plannedCount}`);
3130
-
3131
- // Phase 2: Tmux captures and nudges (no lock needed)
3132
- const seniorSessions = ctx.hiveSessions.filter(s => s.name.includes('-senior-'));
3133
- verboseLogCtx(ctx, `notifyUnassignedStories: seniorSessions=${seniorSessions.length}`);
3134
- for (const senior of seniorSessions) {
3135
- const seniorAgent = ctx.agentsBySessionName.get(senior.name);
3136
- const seniorCliTool = (seniorAgent?.cli_tool || 'claude') as CLITool;
3137
- const output = await captureTmuxPane(senior.name, TMUX_CAPTURE_LINES_SHORT);
3138
- const stateResult = detectAgentState(output, seniorCliTool);
3139
-
3140
- if (
3141
- stateResult.isWaiting &&
3142
- !stateResult.needsHuman &&
3143
- stateResult.state !== AgentState.THINKING
3144
- ) {
3145
- verboseLogCtx(
3146
- ctx,
3147
- `notifyUnassignedStories: nudge ${senior.name} waiting=${stateResult.isWaiting} state=${stateResult.state}`
1259
+ `notifyUnassignedStories: nudge ${senior.name} waiting=${stateResult.isWaiting} state=${stateResult.state}`
3148
1260
  );
3149
1261
  await sendManagerNudge(
3150
1262
  ctx,
@@ -3160,189 +1272,7 @@ async function notifyUnassignedStories(ctx: ManagerCheckContext): Promise<void>
3160
1272
  }
3161
1273
  }
3162
1274
 
3163
- async function restartStaleTechLead(ctx: ManagerCheckContext): Promise<void> {
3164
- const maxAgeHours = ctx.config.manager.tech_lead_max_age_hours;
3165
- const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
3166
- const now = Date.now();
3167
-
3168
- // Phase 1: Read tech lead agents (brief lock)
3169
- const techLeads = await ctx.withDb(async db => {
3170
- const leads = getAgentsByType(db.db, 'tech_lead');
3171
- verboseLogCtx(ctx, `restartStaleTechLead: found ${leads.length} tech lead agent(s)`);
3172
- return leads.map(tl => ({
3173
- id: tl.id,
3174
- tmuxSession: tl.tmux_session,
3175
- cliTool: (tl.cli_tool || 'claude') as CLITool,
3176
- createdAt: tl.created_at,
3177
- }));
3178
- });
3179
-
3180
- // Phase 2: Check sessions and restart (tmux I/O outside lock, DB writes under brief lock)
3181
- for (const techLead of techLeads) {
3182
- if (!techLead.tmuxSession) {
3183
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} skip=no_tmux_session`);
3184
- continue;
3185
- }
3186
-
3187
- const sessionRunning = await isTmuxSessionRunning(techLead.tmuxSession);
3188
- if (!sessionRunning) {
3189
- verboseLogCtx(
3190
- ctx,
3191
- `restartStaleTechLead: techLead=${techLead.id} skip=session_not_running session=${techLead.tmuxSession}`
3192
- );
3193
- continue;
3194
- }
3195
-
3196
- const createdAt = new Date(techLead.createdAt).getTime();
3197
- const ageMs = now - createdAt;
3198
- const ageHours = ageMs / (60 * 60 * 1000);
3199
-
3200
- verboseLogCtx(
3201
- ctx,
3202
- `restartStaleTechLead: techLead=${techLead.id} age=${ageHours.toFixed(2)}h threshold=${maxAgeHours}h`
3203
- );
3204
-
3205
- if (ageMs < maxAgeMs) {
3206
- verboseLogCtx(
3207
- ctx,
3208
- `restartStaleTechLead: techLead=${techLead.id} skip=not_stale remainingMs=${maxAgeMs - ageMs}`
3209
- );
3210
- continue;
3211
- }
3212
-
3213
- const cooldown = isTechLeadRestartOnCooldown(
3214
- techLeadLastRestartByAgentId.get(techLead.id),
3215
- now,
3216
- maxAgeHours
3217
- );
3218
- if (cooldown.onCooldown) {
3219
- verboseLogCtx(
3220
- ctx,
3221
- `restartStaleTechLead: techLead=${techLead.id} skip=cooldown cooldownHours=${cooldown.cooldownHours} remainingMs=${cooldown.remainingMs}`
3222
- );
3223
- continue;
3224
- }
3225
-
3226
- const output = await captureTmuxPane(techLead.tmuxSession, TMUX_CAPTURE_LINES_SHORT);
3227
- const stateResult = detectAgentState(output, techLead.cliTool);
3228
-
3229
- verboseLogCtx(
3230
- ctx,
3231
- `restartStaleTechLead: techLead=${techLead.id} state=${stateResult.state} waiting=${stateResult.isWaiting} needsHuman=${stateResult.needsHuman}`
3232
- );
3233
-
3234
- if (
3235
- !stateResult.isWaiting ||
3236
- stateResult.needsHuman ||
3237
- stateResult.state === AgentState.THINKING
3238
- ) {
3239
- verboseLogCtx(
3240
- ctx,
3241
- `restartStaleTechLead: techLead=${techLead.id} skip=not_safe_state state=${stateResult.state}`
3242
- );
3243
- continue;
3244
- }
3245
-
3246
- verboseLogCtx(
3247
- ctx,
3248
- `restartStaleTechLead: techLead=${techLead.id} action=restarting session=${techLead.tmuxSession}`
3249
- );
3250
-
3251
- // Kill the existing session (tmux I/O, no lock)
3252
- await killTmuxSession(techLead.tmuxSession);
3253
-
3254
- // Spawn a new session with the same configuration (tmux I/O, no lock)
3255
- const hiveRoot = findHiveRootFromDir(ctx.root);
3256
- if (!hiveRoot) {
3257
- verboseLogCtx(ctx, `restartStaleTechLead: techLead=${techLead.id} error=hive_root_not_found`);
3258
- continue;
3259
- }
3260
-
3261
- const paths = getHivePaths(hiveRoot);
3262
- const config = loadConfig(paths.hiveDir);
3263
- const agentConfig = config.models.tech_lead;
3264
- const cliTool = agentConfig.cli_tool;
3265
- const safetyMode = agentConfig.safety_mode;
3266
- const model = resolveRuntimeModelForCli(agentConfig.model, cliTool);
3267
-
3268
- const runtimeBuilder = getCliRuntimeBuilder(cliTool);
3269
- const commandArgs = runtimeBuilder.buildSpawnCommand(model, safetyMode);
3270
-
3271
- // Look up active requirement and teams to provide context to the restarted tech lead
3272
- const initialPrompt = await ctx.withDb(async db => {
3273
- const planningReqs = getRequirementsByStatus(db.db, 'planning');
3274
- const inProgressReqs = getRequirementsByStatus(db.db, 'in_progress');
3275
- const activeReq = planningReqs[0] ?? inProgressReqs[0] ?? null;
3276
- const teams = getAllTeams(db.db);
3277
-
3278
- if (activeReq) {
3279
- return generateTechLeadPrompt(
3280
- activeReq.id,
3281
- activeReq.title,
3282
- activeReq.description,
3283
- teams,
3284
- activeReq.godmode === 1,
3285
- activeReq.target_branch || 'main'
3286
- );
3287
- }
3288
-
3289
- return `You are the Tech Lead of Hive, an AI development team orchestrator.
3290
-
3291
- You have been restarted to refresh your context. No active requirement is currently being planned.
3292
-
3293
- ## Next Steps
3294
-
3295
- 1. Check the current status of the Hive workspace:
3296
- \`\`\`bash
3297
- hive status
3298
- \`\`\`
3299
-
3300
- 2. Check your inbox for messages from developers:
3301
- \`\`\`bash
3302
- hive msg inbox hive-tech-lead
3303
- \`\`\`
3304
-
3305
- 3. If there are pending requirements, begin planning them. If all work is complete, monitor for new requirements.`;
3306
- });
3307
-
3308
- await spawnTmuxSession({
3309
- sessionName: techLead.tmuxSession,
3310
- workDir: ctx.root,
3311
- commandArgs,
3312
- initialPrompt,
3313
- });
3314
-
3315
- // DB writes under brief lock
3316
- await ctx.withDb(async db => {
3317
- createLog(db.db, {
3318
- agentId: 'manager',
3319
- eventType: 'AGENT_SPAWNED',
3320
- status: 'info',
3321
- message: `Tech lead ${techLead.id} restarted for context freshness (age: ${ageHours.toFixed(1)}h)`,
3322
- metadata: {
3323
- agent_id: techLead.id,
3324
- tmux_session: techLead.tmuxSession,
3325
- age_hours: ageHours,
3326
- threshold_hours: maxAgeHours,
3327
- restart_reason: 'context_freshness',
3328
- },
3329
- });
3330
- updateAgent(db.db, techLead.id, {
3331
- status: 'working',
3332
- createdAt: new Date().toISOString(),
3333
- });
3334
- db.save();
3335
- });
3336
-
3337
- techLeadLastRestartByAgentId.set(techLead.id, now);
3338
-
3339
- console.log(
3340
- chalk.green(
3341
- ` Tech lead ${techLead.id} restarted for context freshness (age: ${ageHours.toFixed(1)}h)`
3342
- )
3343
- );
3344
- }
3345
- }
1275
+ // restartStaleTechLead moved to ./tech-lead-lifecycle.ts
3346
1276
 
3347
1277
  async function printSummary(ctx: ManagerCheckContext): Promise<void> {
3348
1278
  const {