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