hungry-ghost-hive 0.43.0 → 0.43.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/agents.d.ts.map +1 -1
- package/dist/cli/commands/agents.js +4 -11
- package/dist/cli/commands/agents.js.map +1 -1
- package/dist/cli/commands/approach.d.ts.map +1 -1
- package/dist/cli/commands/approach.js +2 -6
- package/dist/cli/commands/approach.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +9 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +3 -0
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/cli/commands/manager/index.d.ts +2 -27
- package/dist/cli/commands/manager/index.d.ts.map +1 -1
- package/dist/cli/commands/manager/index.js +23 -1519
- package/dist/cli/commands/manager/index.js.map +1 -1
- package/dist/cli/commands/manager/manager-utils.d.ts +9 -0
- package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -0
- package/dist/cli/commands/manager/manager-utils.js +49 -0
- package/dist/cli/commands/manager/manager-utils.js.map +1 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts +7 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts.map +1 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.js +537 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.js.map +1 -0
- package/dist/cli/commands/manager/qa-review-handler.d.ts +15 -0
- package/dist/cli/commands/manager/qa-review-handler.d.ts.map +1 -0
- package/dist/cli/commands/manager/qa-review-handler.js +290 -0
- package/dist/cli/commands/manager/qa-review-handler.js.map +1 -0
- package/dist/cli/commands/manager/stuck-story-helpers.d.ts +32 -0
- package/dist/cli/commands/manager/stuck-story-helpers.d.ts.map +1 -0
- package/dist/cli/commands/manager/stuck-story-helpers.js +163 -0
- package/dist/cli/commands/manager/stuck-story-helpers.js.map +1 -0
- package/dist/cli/commands/manager/stuck-story-processor.d.ts +8 -0
- package/dist/cli/commands/manager/stuck-story-processor.d.ts.map +1 -0
- package/dist/cli/commands/manager/stuck-story-processor.js +392 -0
- package/dist/cli/commands/manager/stuck-story-processor.js.map +1 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts +3 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.js +141 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -0
- package/dist/cli/commands/my-stories.d.ts.map +1 -1
- package/dist/cli/commands/my-stories.js +5 -20
- package/dist/cli/commands/my-stories.js.map +1 -1
- package/dist/cli/commands/pr.js +7 -22
- package/dist/cli/commands/pr.js.map +1 -1
- package/dist/cli/commands/progress.d.ts.map +1 -1
- package/dist/cli/commands/progress.js +2 -5
- package/dist/cli/commands/progress.js.map +1 -1
- package/dist/cli/commands/resume.d.ts.map +1 -1
- package/dist/cli/commands/resume.js +3 -6
- package/dist/cli/commands/resume.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +2 -5
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/stories.d.ts.map +1 -1
- package/dist/cli/commands/stories.js +2 -5
- package/dist/cli/commands/stories.js.map +1 -1
- package/dist/cluster/adapters.d.ts +3 -2
- package/dist/cluster/adapters.d.ts.map +1 -1
- package/dist/cluster/adapters.js +2 -11
- package/dist/cluster/adapters.js.map +1 -1
- package/dist/cluster/cluster-http-server.d.ts +20 -0
- package/dist/cluster/cluster-http-server.d.ts.map +1 -0
- package/dist/cluster/cluster-http-server.js +140 -0
- package/dist/cluster/cluster-http-server.js.map +1 -0
- package/dist/cluster/heartbeat-manager.d.ts +24 -0
- package/dist/cluster/heartbeat-manager.d.ts.map +1 -0
- package/dist/cluster/heartbeat-manager.js +74 -0
- package/dist/cluster/heartbeat-manager.js.map +1 -0
- package/dist/cluster/raft-state-machine.d.ts +48 -0
- package/dist/cluster/raft-state-machine.d.ts.map +1 -0
- package/dist/cluster/raft-state-machine.js +207 -0
- package/dist/cluster/raft-state-machine.js.map +1 -0
- package/dist/cluster/runtime.d.ts +5 -29
- package/dist/cluster/runtime.d.ts.map +1 -1
- package/dist/cluster/runtime.js +58 -406
- package/dist/cluster/runtime.js.map +1 -1
- package/dist/integrations/jira/sync.d.ts +2 -5
- package/dist/integrations/jira/sync.d.ts.map +1 -1
- package/dist/integrations/jira/sync.js +116 -178
- package/dist/integrations/jira/sync.js.map +1 -1
- package/dist/utils/cli-helpers.d.ts +19 -0
- package/dist/utils/cli-helpers.d.ts.map +1 -0
- package/dist/utils/cli-helpers.js +51 -0
- package/dist/utils/cli-helpers.js.map +1 -0
- package/dist/utils/cli-helpers.test.d.ts +2 -0
- package/dist/utils/cli-helpers.test.d.ts.map +1 -0
- package/dist/utils/cli-helpers.test.js +100 -0
- package/dist/utils/cli-helpers.test.js.map +1 -0
- package/dist/utils/github-cli.d.ts +3 -0
- package/dist/utils/github-cli.d.ts.map +1 -0
- package/dist/utils/github-cli.js +4 -0
- package/dist/utils/github-cli.js.map +1 -0
- package/dist/utils/pr-sync.d.ts.map +1 -1
- package/dist/utils/pr-sync.js +1 -2
- package/dist/utils/pr-sync.js.map +1 -1
- package/dist/utils/story-status.d.ts +19 -0
- package/dist/utils/story-status.d.ts.map +1 -0
- package/dist/utils/story-status.js +58 -0
- package/dist/utils/story-status.js.map +1 -0
- package/dist/utils/story-status.test.d.ts +2 -0
- package/dist/utils/story-status.test.d.ts.map +1 -0
- package/dist/utils/story-status.test.js +65 -0
- package/dist/utils/story-status.test.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/commands/agents.ts +3 -11
- package/src/cli/commands/approach.ts +2 -7
- package/src/cli/commands/init.test.ts +4 -0
- package/src/cli/commands/init.ts +9 -0
- package/src/cli/commands/manager/index.ts +166 -2236
- package/src/cli/commands/manager/manager-utils.ts +85 -0
- package/src/cli/commands/manager/pr-sync-orchestrator.ts +659 -0
- package/src/cli/commands/manager/qa-review-handler.ts +399 -0
- package/src/cli/commands/manager/stuck-story-helpers.ts +255 -0
- package/src/cli/commands/manager/stuck-story-processor.ts +604 -0
- package/src/cli/commands/manager/tech-lead-lifecycle.ts +210 -0
- package/src/cli/commands/my-stories.ts +5 -30
- package/src/cli/commands/pr.ts +6 -22
- package/src/cli/commands/progress.ts +2 -7
- package/src/cli/commands/resume.ts +3 -6
- package/src/cli/commands/status.ts +2 -5
- package/src/cli/commands/stories.ts +2 -5
- package/src/cluster/adapters.ts +3 -12
- package/src/cluster/cluster-http-server.ts +187 -0
- package/src/cluster/heartbeat-manager.ts +112 -0
- package/src/cluster/raft-state-machine.ts +267 -0
- package/src/cluster/runtime.ts +71 -515
- package/src/integrations/jira/sync.ts +157 -215
- package/src/utils/cli-helpers.test.ts +138 -0
- package/src/utils/cli-helpers.ts +61 -0
- package/src/utils/github-cli.ts +4 -0
- package/src/utils/pr-sync.ts +1 -3
- package/src/utils/story-status.test.ts +74 -0
- package/src/utils/story-status.ts +62 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
96
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1198
|
-
|
|
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
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
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
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
2197
|
-
|
|
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
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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 (
|
|
1240
|
+
if (plannedCount === 0) return;
|
|
1241
|
+
verboseLogCtx(ctx, `notifyUnassignedStories: plannedUnassigned=${plannedCount}`);
|
|
2251
1242
|
|
|
2252
|
-
// Phase 2:
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
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
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
1252
|
+
if (
|
|
1253
|
+
stateResult.isWaiting &&
|
|
1254
|
+
!stateResult.needsHuman &&
|
|
1255
|
+
stateResult.state !== AgentState.THINKING
|
|
1256
|
+
) {
|
|
1257
|
+
verboseLogCtx(
|
|
2274
1258
|
ctx,
|
|
2275
|
-
|
|
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
|
-
|
|
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 {
|