@made-by-moonlight/athene-core 0.9.1
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/LICENSE +22 -0
- package/README.md +241 -0
- package/dist/activity-events.d.ts +42 -0
- package/dist/activity-events.d.ts.map +1 -0
- package/dist/activity-events.js +192 -0
- package/dist/activity-events.js.map +1 -0
- package/dist/activity-log.d.ts +71 -0
- package/dist/activity-log.d.ts.map +1 -0
- package/dist/activity-log.js +203 -0
- package/dist/activity-log.js.map +1 -0
- package/dist/activity-signal.d.ts +20 -0
- package/dist/activity-signal.d.ts.map +1 -0
- package/dist/activity-signal.js +91 -0
- package/dist/activity-signal.js.map +1 -0
- package/dist/agent-report.d.ts +148 -0
- package/dist/agent-report.d.ts.map +1 -0
- package/dist/agent-report.js +516 -0
- package/dist/agent-report.js.map +1 -0
- package/dist/agent-selection.d.ts +31 -0
- package/dist/agent-selection.d.ts.map +1 -0
- package/dist/agent-selection.js +69 -0
- package/dist/agent-selection.js.map +1 -0
- package/dist/agent-workspace-hooks.d.ts +74 -0
- package/dist/agent-workspace-hooks.d.ts.map +1 -0
- package/dist/agent-workspace-hooks.js +988 -0
- package/dist/agent-workspace-hooks.js.map +1 -0
- package/dist/atomic-write.d.ts +6 -0
- package/dist/atomic-write.d.ts.map +1 -0
- package/dist/atomic-write.js +49 -0
- package/dist/atomic-write.js.map +1 -0
- package/dist/cleanup-stack.d.ts +37 -0
- package/dist/cleanup-stack.d.ts.map +1 -0
- package/dist/cleanup-stack.js +45 -0
- package/dist/cleanup-stack.js.map +1 -0
- package/dist/code-review-manager.d.ts +118 -0
- package/dist/code-review-manager.d.ts.map +1 -0
- package/dist/code-review-manager.js +719 -0
- package/dist/code-review-manager.js.map +1 -0
- package/dist/code-review-store.d.ts +114 -0
- package/dist/code-review-store.d.ts.map +1 -0
- package/dist/code-review-store.js +346 -0
- package/dist/code-review-store.js.map +1 -0
- package/dist/config-generator.d.ts +84 -0
- package/dist/config-generator.d.ts.map +1 -0
- package/dist/config-generator.js +295 -0
- package/dist/config-generator.js.map +1 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +852 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon-children.d.ts +55 -0
- package/dist/daemon-children.d.ts.map +1 -0
- package/dist/daemon-children.js +435 -0
- package/dist/daemon-children.js.map +1 -0
- package/dist/dashboard-notifications.d.ts +42 -0
- package/dist/dashboard-notifications.d.ts.map +1 -0
- package/dist/dashboard-notifications.js +123 -0
- package/dist/dashboard-notifications.js.map +1 -0
- package/dist/events-db.d.ts +39 -0
- package/dist/events-db.d.ts.map +1 -0
- package/dist/events-db.js +185 -0
- package/dist/events-db.js.map +1 -0
- package/dist/feature-flags.d.ts +2 -0
- package/dist/feature-flags.d.ts.map +1 -0
- package/dist/feature-flags.js +9 -0
- package/dist/feature-flags.js.map +1 -0
- package/dist/feedback-tools.d.ts +97 -0
- package/dist/feedback-tools.d.ts.map +1 -0
- package/dist/feedback-tools.js +161 -0
- package/dist/feedback-tools.js.map +1 -0
- package/dist/file-lock.d.ts +5 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +59 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/format-automated-comments.d.ts +18 -0
- package/dist/format-automated-comments.d.ts.map +1 -0
- package/dist/gh-trace.d.ts +57 -0
- package/dist/gh-trace.d.ts.map +1 -0
- package/dist/gh-trace.js +320 -0
- package/dist/gh-trace.js.map +1 -0
- package/dist/git-activity.d.ts +10 -0
- package/dist/git-activity.d.ts.map +1 -0
- package/dist/git-activity.js +30 -0
- package/dist/git-activity.js.map +1 -0
- package/dist/global-config.d.ts +1085 -0
- package/dist/global-config.d.ts.map +1 -0
- package/dist/global-config.js +1067 -0
- package/dist/global-config.js.map +1 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/key-value.d.ts +7 -0
- package/dist/key-value.d.ts.map +1 -0
- package/dist/key-value.js +24 -0
- package/dist/key-value.js.map +1 -0
- package/dist/lifecycle-manager.d.ts +22 -0
- package/dist/lifecycle-manager.d.ts.map +1 -0
- package/dist/lifecycle-manager.js +2813 -0
- package/dist/lifecycle-manager.js.map +1 -0
- package/dist/lifecycle-state.d.ts +28 -0
- package/dist/lifecycle-state.d.ts.map +1 -0
- package/dist/lifecycle-state.js +446 -0
- package/dist/lifecycle-state.js.map +1 -0
- package/dist/lifecycle-status-decisions.d.ts +85 -0
- package/dist/lifecycle-status-decisions.d.ts.map +1 -0
- package/dist/lifecycle-status-decisions.js +262 -0
- package/dist/lifecycle-status-decisions.js.map +1 -0
- package/dist/lifecycle-transition.d.ts +81 -0
- package/dist/lifecycle-transition.d.ts.map +1 -0
- package/dist/lifecycle-transition.js +207 -0
- package/dist/lifecycle-transition.js.map +1 -0
- package/dist/metadata.d.ts +54 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +484 -0
- package/dist/metadata.js.map +1 -0
- package/dist/migration/storage-v2.d.ts +76 -0
- package/dist/migration/storage-v2.d.ts.map +1 -0
- package/dist/migration/storage-v2.js +1614 -0
- package/dist/migration/storage-v2.js.map +1 -0
- package/dist/notification-data.d.ts +135 -0
- package/dist/notification-data.d.ts.map +1 -0
- package/dist/notification-data.js +204 -0
- package/dist/notification-data.js.map +1 -0
- package/dist/notification-observability.d.ts +21 -0
- package/dist/notification-observability.d.ts.map +1 -0
- package/dist/notification-observability.js +154 -0
- package/dist/notification-observability.js.map +1 -0
- package/dist/notifier-resolution.d.ts +14 -0
- package/dist/notifier-resolution.d.ts.map +1 -0
- package/dist/notifier-resolution.js +23 -0
- package/dist/notifier-resolution.js.map +1 -0
- package/dist/observability.d.ts +100 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +535 -0
- package/dist/observability.js.map +1 -0
- package/dist/opencode-agents-md.d.ts +3 -0
- package/dist/opencode-agents-md.d.ts.map +1 -0
- package/dist/opencode-agents-md.js +40 -0
- package/dist/opencode-agents-md.js.map +1 -0
- package/dist/opencode-config.d.ts +2 -0
- package/dist/opencode-config.d.ts.map +1 -0
- package/dist/opencode-config.js +17 -0
- package/dist/opencode-config.js.map +1 -0
- package/dist/opencode-session-id.d.ts +2 -0
- package/dist/opencode-session-id.d.ts.map +1 -0
- package/dist/opencode-session-id.js +12 -0
- package/dist/opencode-session-id.js.map +1 -0
- package/dist/opencode-shared.d.ts +80 -0
- package/dist/opencode-shared.d.ts.map +1 -0
- package/dist/opencode-shared.js +202 -0
- package/dist/opencode-shared.js.map +1 -0
- package/dist/orchestrator-prompt.d.ts +19 -0
- package/dist/orchestrator-prompt.d.ts.map +1 -0
- package/dist/orchestrator-prompt.js +130 -0
- package/dist/orchestrator-prompt.js.map +1 -0
- package/dist/orchestrator-session-strategy.d.ts +5 -0
- package/dist/orchestrator-session-strategy.d.ts.map +1 -0
- package/dist/orchestrator-session-strategy.js +13 -0
- package/dist/orchestrator-session-strategy.js.map +1 -0
- package/dist/paths.d.ts +145 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +288 -0
- package/dist/paths.js.map +1 -0
- package/dist/platform.d.ts +32 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +211 -0
- package/dist/platform.js.map +1 -0
- package/dist/plugin-registry.d.ts +15 -0
- package/dist/plugin-registry.d.ts.map +1 -0
- package/dist/plugin-registry.js +499 -0
- package/dist/plugin-registry.js.map +1 -0
- package/dist/portfolio-projects.d.ts +7 -0
- package/dist/portfolio-projects.d.ts.map +1 -0
- package/dist/portfolio-projects.js +65 -0
- package/dist/portfolio-projects.js.map +1 -0
- package/dist/portfolio-registry.d.ts +42 -0
- package/dist/portfolio-registry.d.ts.map +1 -0
- package/dist/portfolio-registry.js +311 -0
- package/dist/portfolio-registry.js.map +1 -0
- package/dist/portfolio-routing.d.ts +5 -0
- package/dist/portfolio-routing.d.ts.map +1 -0
- package/dist/portfolio-routing.js +24 -0
- package/dist/portfolio-routing.js.map +1 -0
- package/dist/portfolio-session-service.d.ts +15 -0
- package/dist/portfolio-session-service.d.ts.map +1 -0
- package/dist/portfolio-session-service.js +206 -0
- package/dist/portfolio-session-service.js.map +1 -0
- package/dist/process-cache.d.ts +32 -0
- package/dist/process-cache.d.ts.map +1 -0
- package/dist/process-cache.js +44 -0
- package/dist/process-cache.js.map +1 -0
- package/dist/project-resolver.d.ts +5 -0
- package/dist/project-resolver.d.ts.map +1 -0
- package/dist/project-resolver.js +20 -0
- package/dist/project-resolver.js.map +1 -0
- package/dist/prompt-builder.d.ts +42 -0
- package/dist/prompt-builder.d.ts.map +1 -0
- package/dist/prompt-builder.js +182 -0
- package/dist/prompt-builder.js.map +1 -0
- package/dist/prompts/orchestrator.md.js +4 -0
- package/dist/prompts/orchestrator.md.js.map +1 -0
- package/dist/query-activity-events.d.ts +42 -0
- package/dist/query-activity-events.d.ts.map +1 -0
- package/dist/query-activity-events.js +170 -0
- package/dist/query-activity-events.js.map +1 -0
- package/dist/recovery/actions.d.ts +7 -0
- package/dist/recovery/actions.d.ts.map +1 -0
- package/dist/recovery/index.d.ts +8 -0
- package/dist/recovery/index.d.ts.map +1 -0
- package/dist/recovery/logger.d.ts +12 -0
- package/dist/recovery/logger.d.ts.map +1 -0
- package/dist/recovery/manager.d.ts +24 -0
- package/dist/recovery/manager.d.ts.map +1 -0
- package/dist/recovery/scanner.d.ts +11 -0
- package/dist/recovery/scanner.d.ts.map +1 -0
- package/dist/recovery/types.d.ts +170 -0
- package/dist/recovery/types.d.ts.map +1 -0
- package/dist/recovery/validator.d.ts +8 -0
- package/dist/recovery/validator.d.ts.map +1 -0
- package/dist/report-watcher.d.ts +93 -0
- package/dist/report-watcher.d.ts.map +1 -0
- package/dist/report-watcher.js +182 -0
- package/dist/report-watcher.js.map +1 -0
- package/dist/scm-webhook-utils.d.ts +6 -0
- package/dist/scm-webhook-utils.d.ts.map +1 -0
- package/dist/scm-webhook-utils.js +36 -0
- package/dist/scm-webhook-utils.js.map +1 -0
- package/dist/session-manager.d.ts +22 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +3077 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/spawn-target.d.ts +23 -0
- package/dist/spawn-target.d.ts.map +1 -0
- package/dist/spawn-target.js +39 -0
- package/dist/spawn-target.js.map +1 -0
- package/dist/storage-key.d.ts +9 -0
- package/dist/storage-key.d.ts.map +1 -0
- package/dist/storage-key.js +59 -0
- package/dist/storage-key.js.map +1 -0
- package/dist/tmux.d.ts +39 -0
- package/dist/tmux.d.ts.map +1 -0
- package/dist/tmux.js +141 -0
- package/dist/tmux.js.map +1 -0
- package/dist/types.d.ts +1496 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +215 -0
- package/dist/types.js.map +1 -0
- package/dist/update-cache.d.ts +59 -0
- package/dist/update-cache.d.ts.map +1 -0
- package/dist/update-cache.js +77 -0
- package/dist/update-cache.js.map +1 -0
- package/dist/utils/metadata-flatten.d.ts +3 -0
- package/dist/utils/metadata-flatten.d.ts.map +1 -0
- package/dist/utils/metadata-flatten.js +18 -0
- package/dist/utils/metadata-flatten.js.map +1 -0
- package/dist/utils/pr.d.ts +7 -0
- package/dist/utils/pr.d.ts.map +1 -0
- package/dist/utils/pr.js +97 -0
- package/dist/utils/pr.js.map +1 -0
- package/dist/utils/session-from-metadata.d.ts +16 -0
- package/dist/utils/session-from-metadata.d.ts.map +1 -0
- package/dist/utils/session-from-metadata.js +87 -0
- package/dist/utils/session-from-metadata.js.map +1 -0
- package/dist/utils/session-id.d.ts +4 -0
- package/dist/utils/session-id.d.ts.map +1 -0
- package/dist/utils/session-id.js +9 -0
- package/dist/utils/session-id.js.map +1 -0
- package/dist/utils/validation.d.ts +9 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +45 -0
- package/dist/utils/validation.js.map +1 -0
- package/dist/utils.d.ts +65 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +189 -0
- package/dist/utils.js.map +1 -0
- package/dist/version-compare.d.ts +27 -0
- package/dist/version-compare.d.ts.map +1 -0
- package/dist/version-compare.js +121 -0
- package/dist/version-compare.js.map +1 -0
- package/dist/windows-pty-registry.d.ts +27 -0
- package/dist/windows-pty-registry.d.ts.map +1 -0
- package/dist/windows-pty-registry.js +109 -0
- package/dist/windows-pty-registry.js.map +1 -0
- package/package.json +110 -0
|
@@ -0,0 +1,2813 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve, dirname } from 'node:path';
|
|
4
|
+
import { recordActivityEvent } from './activity-events.js';
|
|
5
|
+
import { SESSION_STATUS, TERMINAL_STATUSES, ACTIVITY_STATE, isProcessProbeIndeterminate } from './types.js';
|
|
6
|
+
import { cloneLifecycle, buildLifecycleMetadataPatch, deriveLegacyStatus } from './lifecycle-state.js';
|
|
7
|
+
import { updateMetadata } from './metadata.js';
|
|
8
|
+
import { getProjectSessionsDir } from './paths.js';
|
|
9
|
+
import { applyDecisionToLifecycle } from './lifecycle-transition.js';
|
|
10
|
+
import { createActivitySignal, formatActivitySignalEvidence, classifyActivitySignal, hasPositiveIdleEvidence, isWeakActivityEvidence } from './activity-signal.js';
|
|
11
|
+
import { readAgentReport, isAgentReportFresh, mapAgentReportToLifecycle } from './agent-report.js';
|
|
12
|
+
import { auditAgentReports, REPORT_WATCHER_METADATA_KEYS, getReactionKeyForTrigger } from './report-watcher.js';
|
|
13
|
+
import { createProjectObserver, createCorrelationId } from './observability.js';
|
|
14
|
+
import { resolveNotifierTarget } from './notifier-resolution.js';
|
|
15
|
+
import { recordNotificationDelivery } from './notification-observability.js';
|
|
16
|
+
import { resolveSessionRole } from './agent-selection.js';
|
|
17
|
+
import { DETECTING_MAX_ATTEMPTS, isDetectingTimedOut, parseAttemptCount, createDetectingDecision, resolveProbeDecision, resolvePREnrichmentDecision, resolvePRLiveDecision } from './lifecycle-status-decisions.js';
|
|
18
|
+
import { dedupePrInfos } from './utils/pr.js';
|
|
19
|
+
import { buildSessionTransitionNotificationData, buildPRStateNotificationData, buildReactionEscalationNotificationData, buildReactionNotificationData, buildCIFailureNotificationData } from './notification-data.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lifecycle Manager — state machine + polling loop + reaction engine.
|
|
23
|
+
*
|
|
24
|
+
* Periodically polls all sessions and:
|
|
25
|
+
* 1. Detects state transitions (spawning → working → pr_open → etc.)
|
|
26
|
+
* 2. Emits events on transitions
|
|
27
|
+
* 3. Triggers reactions (auto-handle CI failures, review comments, etc.)
|
|
28
|
+
* 4. Escalates to human notification when auto-handling fails
|
|
29
|
+
*
|
|
30
|
+
* Reference: scripts/claude-session-status, scripts/claude-review-check
|
|
31
|
+
*/
|
|
32
|
+
/** Parse a duration string like "10m", "30s", "1h" to milliseconds. */
|
|
33
|
+
function parseDuration(str) {
|
|
34
|
+
const match = str.match(/^(\d+)(s|m|h)$/);
|
|
35
|
+
if (!match)
|
|
36
|
+
return 0;
|
|
37
|
+
const value = parseInt(match[1], 10);
|
|
38
|
+
switch (match[2]) {
|
|
39
|
+
case "s":
|
|
40
|
+
return value * 1000;
|
|
41
|
+
case "m":
|
|
42
|
+
return value * 60_000;
|
|
43
|
+
case "h":
|
|
44
|
+
return value * 3_600_000;
|
|
45
|
+
default:
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Reaction keys for conditions that can oscillate (e.g. CI failing→pending→failing).
|
|
50
|
+
* Their trackers survive status exit so the escalation budget accumulates
|
|
51
|
+
* across oscillations instead of resetting to zero each time.
|
|
52
|
+
* Note: "merge-conflicts" is NOT here — statusToEventType never emits
|
|
53
|
+
* "merge.conflicts", so the transition handler at line ~1892 can't reach it.
|
|
54
|
+
* Merge-conflict tracker lifecycle is managed in maybeDispatchMergeConflicts. */
|
|
55
|
+
const PERSISTENT_REACTION_KEYS = new Set(["ci-failed"]);
|
|
56
|
+
/** Number of consecutive CI-passing polls required before the ci-failed tracker
|
|
57
|
+
* (including its escalated flag) is cleared, allowing a fresh budget for the
|
|
58
|
+
* next real CI failure incident. */
|
|
59
|
+
const CI_PASSING_STABLE_THRESHOLD = 2;
|
|
60
|
+
const TRANSIENT_DETACHED_GIT_MARKERS = [
|
|
61
|
+
"rebase-merge",
|
|
62
|
+
"rebase-apply",
|
|
63
|
+
"CHERRY_PICK_HEAD",
|
|
64
|
+
"BISECT_LOG",
|
|
65
|
+
];
|
|
66
|
+
function isErrnoException(error) {
|
|
67
|
+
return typeof error === "object" && error !== null && "code" in error;
|
|
68
|
+
}
|
|
69
|
+
async function pathExists(path) {
|
|
70
|
+
try {
|
|
71
|
+
await stat(path);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function hasTransientDetachedGitState(gitDir) {
|
|
82
|
+
const checks = await Promise.all(TRANSIENT_DETACHED_GIT_MARKERS.map((marker) => pathExists(join(gitDir, marker))));
|
|
83
|
+
return checks.some(Boolean);
|
|
84
|
+
}
|
|
85
|
+
async function resolveGitDir(workspacePath) {
|
|
86
|
+
const dotGitPath = join(workspacePath, ".git");
|
|
87
|
+
const dotGitStats = await stat(dotGitPath);
|
|
88
|
+
if (dotGitStats.isDirectory())
|
|
89
|
+
return dotGitPath;
|
|
90
|
+
const dotGitContent = (await readFile(dotGitPath, "utf8")).trim();
|
|
91
|
+
const gitDirMatch = dotGitContent.match(/^gitdir:\s*(.+)$/i);
|
|
92
|
+
if (!gitDirMatch) {
|
|
93
|
+
throw new Error(`Invalid .git pointer in workspace: ${workspacePath}`);
|
|
94
|
+
}
|
|
95
|
+
return resolve(dirname(dotGitPath), gitDirMatch[1].trim());
|
|
96
|
+
}
|
|
97
|
+
async function readWorkspaceBranch(workspacePath) {
|
|
98
|
+
let gitDir;
|
|
99
|
+
try {
|
|
100
|
+
gitDir = await resolveGitDir(workspacePath);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { kind: "unavailable" };
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const head = (await readFile(join(gitDir, "HEAD"), "utf8")).trim();
|
|
107
|
+
const prefix = "ref: refs/heads/";
|
|
108
|
+
if (!head.startsWith(prefix)) {
|
|
109
|
+
return (await hasTransientDetachedGitState(gitDir))
|
|
110
|
+
? { kind: "unavailable" }
|
|
111
|
+
: { kind: "detached" };
|
|
112
|
+
}
|
|
113
|
+
const branch = head.slice(prefix.length).trim();
|
|
114
|
+
if (branch.length > 0) {
|
|
115
|
+
return { kind: "branch", branch };
|
|
116
|
+
}
|
|
117
|
+
return (await hasTransientDetachedGitState(gitDir))
|
|
118
|
+
? { kind: "unavailable" }
|
|
119
|
+
: { kind: "detached" };
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { kind: "unavailable" };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Infer a reasonable priority from event type. */
|
|
126
|
+
function inferPriority(type) {
|
|
127
|
+
if (type.includes("stuck") || type.includes("needs_input") || type.includes("errored")) {
|
|
128
|
+
return "urgent";
|
|
129
|
+
}
|
|
130
|
+
if (type.startsWith("summary.")) {
|
|
131
|
+
return "info";
|
|
132
|
+
}
|
|
133
|
+
if (type.includes("approved") ||
|
|
134
|
+
type.includes("ready") ||
|
|
135
|
+
type.includes("merged") ||
|
|
136
|
+
type.includes("completed")) {
|
|
137
|
+
return "action";
|
|
138
|
+
}
|
|
139
|
+
if (type.includes("fail") || type.includes("changes_requested") || type.includes("conflicts")) {
|
|
140
|
+
return "warning";
|
|
141
|
+
}
|
|
142
|
+
return "info";
|
|
143
|
+
}
|
|
144
|
+
/** Create an OrchestratorEvent with defaults filled in. */
|
|
145
|
+
function createEvent(type, opts) {
|
|
146
|
+
return {
|
|
147
|
+
id: randomUUID(),
|
|
148
|
+
type,
|
|
149
|
+
priority: opts.priority ?? inferPriority(type),
|
|
150
|
+
sessionId: opts.sessionId,
|
|
151
|
+
projectId: opts.projectId,
|
|
152
|
+
timestamp: new Date(),
|
|
153
|
+
message: opts.message,
|
|
154
|
+
data: opts.data ?? {},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Determine which event type corresponds to a status transition. */
|
|
158
|
+
function statusToEventType(_from, to) {
|
|
159
|
+
switch (to) {
|
|
160
|
+
case "working":
|
|
161
|
+
return "session.working";
|
|
162
|
+
case "pr_open":
|
|
163
|
+
return "pr.created";
|
|
164
|
+
case "ci_failed":
|
|
165
|
+
return "ci.failing";
|
|
166
|
+
case "review_pending":
|
|
167
|
+
return "review.pending";
|
|
168
|
+
case "changes_requested":
|
|
169
|
+
return "review.changes_requested";
|
|
170
|
+
case "approved":
|
|
171
|
+
return "review.approved";
|
|
172
|
+
case "mergeable":
|
|
173
|
+
return "merge.ready";
|
|
174
|
+
case "merged":
|
|
175
|
+
return "merge.completed";
|
|
176
|
+
case "needs_input":
|
|
177
|
+
return "session.needs_input";
|
|
178
|
+
case "stuck":
|
|
179
|
+
return "session.stuck";
|
|
180
|
+
case "errored":
|
|
181
|
+
return "session.errored";
|
|
182
|
+
case "killed":
|
|
183
|
+
return "session.killed";
|
|
184
|
+
default:
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function prStateToEventType(from, to) {
|
|
189
|
+
if (from === to)
|
|
190
|
+
return null;
|
|
191
|
+
switch (to) {
|
|
192
|
+
case "closed":
|
|
193
|
+
return "pr.closed";
|
|
194
|
+
default:
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Build event context with PR and issue information for webhook payloads.
|
|
200
|
+
* This enriches events with useful metadata so external consumers (Telegram, Discord, etc.)
|
|
201
|
+
* can display meaningful information without making additional API calls.
|
|
202
|
+
*/
|
|
203
|
+
function buildEventContext(session, prEnrichmentCache) {
|
|
204
|
+
const sessionPRs = dedupePrInfos("prs" in session && Array.isArray(session.prs) ? session.prs : session.pr ? [session.pr] : []);
|
|
205
|
+
const prs = sessionPRs.map((p) => {
|
|
206
|
+
const cached = prEnrichmentCache.get(`${p.owner}/${p.repo}#${p.number}`);
|
|
207
|
+
return {
|
|
208
|
+
url: p.url,
|
|
209
|
+
title: cached?.title ?? null,
|
|
210
|
+
number: p.number,
|
|
211
|
+
branch: p.branch,
|
|
212
|
+
baseBranch: p.baseBranch,
|
|
213
|
+
owner: p.owner,
|
|
214
|
+
repo: p.repo,
|
|
215
|
+
isDraft: p.isDraft,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
const pr = prs[0] ?? null;
|
|
219
|
+
return {
|
|
220
|
+
pr,
|
|
221
|
+
prs,
|
|
222
|
+
issueId: session.issueId,
|
|
223
|
+
issueTitle: session.metadata["issueTitle"] ?? null,
|
|
224
|
+
summary: session.agentInfo?.summary ?? null,
|
|
225
|
+
branch: session.branch,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Map event type to reaction config key. */
|
|
229
|
+
function eventToReactionKey(eventType) {
|
|
230
|
+
switch (eventType) {
|
|
231
|
+
case "pr.closed":
|
|
232
|
+
return "pr-closed";
|
|
233
|
+
case "ci.failing":
|
|
234
|
+
return "ci-failed";
|
|
235
|
+
case "review.changes_requested":
|
|
236
|
+
return "changes-requested";
|
|
237
|
+
case "automated_review.found":
|
|
238
|
+
return "bugbot-comments";
|
|
239
|
+
case "merge.conflicts":
|
|
240
|
+
return "merge-conflicts";
|
|
241
|
+
case "merge.ready":
|
|
242
|
+
return "approved-and-green";
|
|
243
|
+
case "session.stuck":
|
|
244
|
+
return "agent-stuck";
|
|
245
|
+
case "session.needs_input":
|
|
246
|
+
return "agent-needs-input";
|
|
247
|
+
case "session.killed":
|
|
248
|
+
return "agent-exited";
|
|
249
|
+
case "summary.all_complete":
|
|
250
|
+
return "all-complete";
|
|
251
|
+
default:
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function transitionLogLevel(status) {
|
|
256
|
+
const eventType = statusToEventType(undefined, status);
|
|
257
|
+
if (!eventType) {
|
|
258
|
+
return "info";
|
|
259
|
+
}
|
|
260
|
+
const priority = inferPriority(eventType);
|
|
261
|
+
if (priority === "urgent") {
|
|
262
|
+
return "error";
|
|
263
|
+
}
|
|
264
|
+
if (priority === "warning") {
|
|
265
|
+
return "warn";
|
|
266
|
+
}
|
|
267
|
+
return "info";
|
|
268
|
+
}
|
|
269
|
+
function processProbeResultToProbeResult(result) {
|
|
270
|
+
if (isProcessProbeIndeterminate(result)) {
|
|
271
|
+
return { state: "unknown", failed: false, indeterminate: true };
|
|
272
|
+
}
|
|
273
|
+
return { state: result ? "alive" : "dead", failed: false };
|
|
274
|
+
}
|
|
275
|
+
function splitEvidenceSignals(evidence) {
|
|
276
|
+
return evidence
|
|
277
|
+
.split(/\s+/)
|
|
278
|
+
.map((signal) => signal.trim())
|
|
279
|
+
.filter((signal) => signal.length > 0);
|
|
280
|
+
}
|
|
281
|
+
function primaryLifecycleReason(lifecycle) {
|
|
282
|
+
if (lifecycle.session.state === "detecting")
|
|
283
|
+
return lifecycle.session.reason;
|
|
284
|
+
if (lifecycle.pr.reason !== "not_created" && lifecycle.pr.reason !== "in_progress") {
|
|
285
|
+
return lifecycle.pr.reason;
|
|
286
|
+
}
|
|
287
|
+
if (lifecycle.runtime.reason !== "process_running") {
|
|
288
|
+
return lifecycle.runtime.reason;
|
|
289
|
+
}
|
|
290
|
+
return lifecycle.session.reason;
|
|
291
|
+
}
|
|
292
|
+
function buildTransitionObservabilityData(previous, next, oldStatus, newStatus, evidence, detectingAttempts, statusTransition, reaction) {
|
|
293
|
+
return {
|
|
294
|
+
oldStatus,
|
|
295
|
+
newStatus,
|
|
296
|
+
statusTransition,
|
|
297
|
+
previousSessionState: previous.session.state,
|
|
298
|
+
newSessionState: next.session.state,
|
|
299
|
+
previousSessionReason: previous.session.reason,
|
|
300
|
+
newSessionReason: next.session.reason,
|
|
301
|
+
previousPRState: previous.pr.state,
|
|
302
|
+
newPRState: next.pr.state,
|
|
303
|
+
previousPRReason: previous.pr.reason,
|
|
304
|
+
newPRReason: next.pr.reason,
|
|
305
|
+
previousRuntimeState: previous.runtime.state,
|
|
306
|
+
newRuntimeState: next.runtime.state,
|
|
307
|
+
previousRuntimeReason: previous.runtime.reason,
|
|
308
|
+
newRuntimeReason: next.runtime.reason,
|
|
309
|
+
primaryReason: primaryLifecycleReason(next),
|
|
310
|
+
evidence,
|
|
311
|
+
signalsConsulted: splitEvidenceSignals(evidence),
|
|
312
|
+
detectingAttempts,
|
|
313
|
+
recoveryAction: reaction?.result?.action ?? null,
|
|
314
|
+
reactionKey: reaction?.key ?? null,
|
|
315
|
+
reactionSuccess: reaction?.result?.success ?? null,
|
|
316
|
+
escalated: reaction?.result?.escalated ?? null,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Create a LifecycleManager instance. */
|
|
320
|
+
function createLifecycleManager(deps) {
|
|
321
|
+
const { config, registry, sessionManager, projectId: scopedProjectId } = deps;
|
|
322
|
+
const observer = createProjectObserver(config, "lifecycle-manager");
|
|
323
|
+
const states = new Map();
|
|
324
|
+
const activityStateCache = new Map(); // sessionId → last observed activity
|
|
325
|
+
const reactionTrackers = new Map(); // "sessionId:reactionKey"
|
|
326
|
+
let pollTimer = null;
|
|
327
|
+
let polling = false; // re-entrancy guard
|
|
328
|
+
let allCompleteEmitted = false; // guard against repeated all_complete
|
|
329
|
+
const branchAdoptionReservations = new Map();
|
|
330
|
+
/**
|
|
331
|
+
* Cache for PR enrichment data within a single poll cycle.
|
|
332
|
+
* Cleared at the start of each pollAll() call.
|
|
333
|
+
* Key format: "${owner}/${repo}#${number}"
|
|
334
|
+
*/
|
|
335
|
+
const prEnrichmentCache = new Map();
|
|
336
|
+
function normalizeSessionPRs(session) {
|
|
337
|
+
const candidatePRs = session.prs.length > 0 ? session.prs : session.pr ? [session.pr] : [];
|
|
338
|
+
const uniquePRs = dedupePrInfos(candidatePRs);
|
|
339
|
+
if (uniquePRs.length !== session.prs.length || session.pr !== (uniquePRs[0] ?? null)) {
|
|
340
|
+
session.prs = uniquePRs;
|
|
341
|
+
session.pr = uniquePRs[0] ?? null;
|
|
342
|
+
}
|
|
343
|
+
return uniquePRs;
|
|
344
|
+
}
|
|
345
|
+
function indexedPRMetadataCleanup(session, prCount) {
|
|
346
|
+
const updates = {};
|
|
347
|
+
for (const key of Object.keys(session.metadata)) {
|
|
348
|
+
const match = key.match(/^(prEnrichment|prReviewComments)_(\d+)$/);
|
|
349
|
+
if (!match)
|
|
350
|
+
continue;
|
|
351
|
+
const index = Number.parseInt(match[2], 10);
|
|
352
|
+
if (Number.isNaN(index) || index >= prCount) {
|
|
353
|
+
updates[key] = "";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return updates;
|
|
357
|
+
}
|
|
358
|
+
function getPREnrichmentForSession(session) {
|
|
359
|
+
if (!session.pr)
|
|
360
|
+
return undefined;
|
|
361
|
+
return prEnrichmentCache.get(`${session.pr.owner}/${session.pr.repo}#${session.pr.number}`);
|
|
362
|
+
}
|
|
363
|
+
/** Repos where Guard 1 returned 304 in the current poll — safe to skip detectPR. */
|
|
364
|
+
let prListUnchangedRepos = new Set();
|
|
365
|
+
/**
|
|
366
|
+
* Per-session timestamp of last review backlog API check.
|
|
367
|
+
* Used to throttle review thread checks to at most once per 2 minutes.
|
|
368
|
+
* In-memory only — resets on restart (acceptable since it's a rate-limit hint, not state).
|
|
369
|
+
*/
|
|
370
|
+
const lastReviewBacklogCheckAt = new Map();
|
|
371
|
+
/** Throttle interval for review backlog API calls (2 minutes). */
|
|
372
|
+
const REVIEW_BACKLOG_THROTTLE_MS = 2 * 60 * 1000;
|
|
373
|
+
/**
|
|
374
|
+
* Populate the PR enrichment cache using batch GraphQL queries.
|
|
375
|
+
* This is called once per poll cycle to fetch data for all PRs efficiently.
|
|
376
|
+
*/
|
|
377
|
+
async function populatePREnrichmentCache(sessions) {
|
|
378
|
+
// Clear previous cache
|
|
379
|
+
prEnrichmentCache.clear();
|
|
380
|
+
prListUnchangedRepos = new Set();
|
|
381
|
+
// Collect all unique PRs and repos keyed by their owning session's project/plugin.
|
|
382
|
+
// Repos are collected from ALL sessions (not just ones with PRs) so Guard 1 runs
|
|
383
|
+
// for every active repo — enabling detectPR gating even when no PRs exist yet.
|
|
384
|
+
const prsByPlugin = new Map();
|
|
385
|
+
const reposByPlugin = new Map();
|
|
386
|
+
const seenPRKeys = new Set();
|
|
387
|
+
for (const session of sessions) {
|
|
388
|
+
const project = config.projects[session.projectId];
|
|
389
|
+
if (!project?.scm?.plugin || !project.repo)
|
|
390
|
+
continue;
|
|
391
|
+
const pluginKey = project.scm.plugin;
|
|
392
|
+
if (!prsByPlugin.has(pluginKey)) {
|
|
393
|
+
prsByPlugin.set(pluginKey, []);
|
|
394
|
+
}
|
|
395
|
+
if (!reposByPlugin.has(pluginKey)) {
|
|
396
|
+
reposByPlugin.set(pluginKey, new Set());
|
|
397
|
+
}
|
|
398
|
+
reposByPlugin.get(pluginKey).add(project.repo);
|
|
399
|
+
const sessionPRs = normalizeSessionPRs(session);
|
|
400
|
+
if (sessionPRs.length === 0)
|
|
401
|
+
continue;
|
|
402
|
+
// Loop over all PRs in the session — supports multi-repo sessions
|
|
403
|
+
// where an agent opened PRs on multiple repos.
|
|
404
|
+
for (const pr of sessionPRs) {
|
|
405
|
+
const actualPRRepo = `${pr.owner}/${pr.repo}`;
|
|
406
|
+
if (actualPRRepo !== project.repo) {
|
|
407
|
+
reposByPlugin.get(pluginKey).add(actualPRRepo);
|
|
408
|
+
}
|
|
409
|
+
const prKey = `${pr.owner}/${pr.repo}#${pr.number}`;
|
|
410
|
+
if (seenPRKeys.has(prKey))
|
|
411
|
+
continue;
|
|
412
|
+
seenPRKeys.add(prKey);
|
|
413
|
+
const pluginPRs = prsByPlugin.get(pluginKey);
|
|
414
|
+
if (pluginPRs) {
|
|
415
|
+
pluginPRs.push(pr);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// Fetch enrichment data and run Guard 1 for all active repos
|
|
420
|
+
for (const [pluginKey, pluginPRs] of prsByPlugin) {
|
|
421
|
+
const scm = registry.get("scm", pluginKey);
|
|
422
|
+
if (!scm?.enrichSessionsPRBatch)
|
|
423
|
+
continue;
|
|
424
|
+
const pluginRepos = [...(reposByPlugin.get(pluginKey) ?? [])];
|
|
425
|
+
const batchStartTime = Date.now();
|
|
426
|
+
try {
|
|
427
|
+
const enrichmentData = await scm.enrichSessionsPRBatch(pluginPRs, {
|
|
428
|
+
recordSuccess(_data) {
|
|
429
|
+
const batchDuration = Date.now() - batchStartTime;
|
|
430
|
+
observer?.recordOperation({
|
|
431
|
+
metric: "graphql_batch",
|
|
432
|
+
operation: "batch_enrichment",
|
|
433
|
+
correlationId: createCorrelationId("graphql-batch"),
|
|
434
|
+
outcome: "success",
|
|
435
|
+
projectId: scopedProjectId,
|
|
436
|
+
durationMs: batchDuration,
|
|
437
|
+
data: {
|
|
438
|
+
plugin: pluginKey,
|
|
439
|
+
prCount: pluginPRs.length,
|
|
440
|
+
prKeys: pluginPRs.map((pr) => `${pr.owner}/${pr.repo}#${pr.number}`),
|
|
441
|
+
},
|
|
442
|
+
level: "info",
|
|
443
|
+
});
|
|
444
|
+
},
|
|
445
|
+
recordFailure(data) {
|
|
446
|
+
const batchDuration = Date.now() - batchStartTime;
|
|
447
|
+
observer?.recordOperation({
|
|
448
|
+
metric: "graphql_batch",
|
|
449
|
+
operation: "batch_enrichment",
|
|
450
|
+
correlationId: createCorrelationId("graphql-batch"),
|
|
451
|
+
outcome: "failure",
|
|
452
|
+
reason: data.error,
|
|
453
|
+
level: "warn",
|
|
454
|
+
data: {
|
|
455
|
+
plugin: pluginKey,
|
|
456
|
+
prCount: pluginPRs.length,
|
|
457
|
+
error: data.error,
|
|
458
|
+
durationMs: batchDuration,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
log(level, message) {
|
|
463
|
+
observer?.recordDiagnostic?.({
|
|
464
|
+
operation: "batch_enrichment.log",
|
|
465
|
+
correlationId: createCorrelationId("graphql-batch"),
|
|
466
|
+
projectId: scopedProjectId,
|
|
467
|
+
message,
|
|
468
|
+
level,
|
|
469
|
+
data: {
|
|
470
|
+
plugin: pluginKey,
|
|
471
|
+
source: "ao-graphql-batch",
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
},
|
|
475
|
+
reportPRListUnchangedRepos(repos) {
|
|
476
|
+
for (const repo of repos) {
|
|
477
|
+
prListUnchangedRepos.add(repo);
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
}, pluginRepos);
|
|
481
|
+
// Merge into cache
|
|
482
|
+
for (const [key, data] of enrichmentData) {
|
|
483
|
+
prEnrichmentCache.set(key, data);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
// Batch fetch failed - individual calls will still work
|
|
488
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
489
|
+
const batchCorrelationId = createCorrelationId("batch-enrichment");
|
|
490
|
+
observer?.recordOperation?.({
|
|
491
|
+
metric: "lifecycle_poll",
|
|
492
|
+
operation: "batch_enrichment",
|
|
493
|
+
correlationId: batchCorrelationId,
|
|
494
|
+
outcome: "failure",
|
|
495
|
+
reason: errorMsg,
|
|
496
|
+
level: "warn",
|
|
497
|
+
data: { plugin: pluginKey, prCount: pluginPRs.length },
|
|
498
|
+
});
|
|
499
|
+
recordActivityEvent({
|
|
500
|
+
// Tag with scopedProjectId when the lifecycle worker is project-scoped
|
|
501
|
+
// so `athene events list --project <id>` surfaces this failure. Unscoped
|
|
502
|
+
// (multi-project) supervisors leave projectId null because the batch
|
|
503
|
+
// crosses project boundaries — RCA there should query without --project.
|
|
504
|
+
projectId: scopedProjectId,
|
|
505
|
+
source: "scm",
|
|
506
|
+
kind: "scm.batch_enrich_failed",
|
|
507
|
+
level: "warn",
|
|
508
|
+
summary: `batch_enrich failed for ${pluginPRs.length} PR(s)`,
|
|
509
|
+
data: {
|
|
510
|
+
plugin: pluginKey,
|
|
511
|
+
prCount: pluginPRs.length,
|
|
512
|
+
errorMessage: errorMsg,
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// Discover PRs for sessions that don't have one yet.
|
|
518
|
+
// Only run detectPR when Guard 1 returned 200 (repo's PR list changed).
|
|
519
|
+
// When Guard 1 returned 304, the repo is in prListUnchangedRepos — no new PRs exist.
|
|
520
|
+
for (const session of sessions) {
|
|
521
|
+
if (!session.branch)
|
|
522
|
+
continue;
|
|
523
|
+
if (session.metadata["prAutoDetect"] === "off" ||
|
|
524
|
+
session.metadata["prAutoDetect"] === "false")
|
|
525
|
+
continue;
|
|
526
|
+
if (session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator"))
|
|
527
|
+
continue;
|
|
528
|
+
// Skip detectPR only if we already have a PR on the configured project repo.
|
|
529
|
+
// This allows detecting additional PRs on different repos (multi-repo support).
|
|
530
|
+
const sessionPRs = normalizeSessionPRs(session);
|
|
531
|
+
const trackedRepos = new Set(sessionPRs.map((p) => `${p.owner}/${p.repo}`));
|
|
532
|
+
const projectRepoForDetect = config.projects[session.projectId]?.repo;
|
|
533
|
+
// primaryPR.branch is always the session branch (metadata doesn't store per-PR branches),
|
|
534
|
+
// so use the lifecycle closed-state alone to allow re-detection after a PR is rejected.
|
|
535
|
+
const primaryPRIsClosed = session.lifecycle.pr.state === "closed";
|
|
536
|
+
if (sessionPRs.length > 0 &&
|
|
537
|
+
projectRepoForDetect &&
|
|
538
|
+
trackedRepos.has(projectRepoForDetect) &&
|
|
539
|
+
!primaryPRIsClosed) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const project = config.projects[session.projectId];
|
|
543
|
+
if (!project?.repo || !project.scm?.plugin)
|
|
544
|
+
continue;
|
|
545
|
+
// Skip if Guard 1 confirmed no PR list changes for this repo
|
|
546
|
+
if (prListUnchangedRepos.has(project.repo))
|
|
547
|
+
continue;
|
|
548
|
+
const scm = registry.get("scm", project.scm.plugin);
|
|
549
|
+
if (!scm?.detectPR)
|
|
550
|
+
continue;
|
|
551
|
+
try {
|
|
552
|
+
const detectedPR = await scm.detectPR(session, project);
|
|
553
|
+
if (detectedPR) {
|
|
554
|
+
// Track by owner/repo/number — allows multiple PRs on the same repo
|
|
555
|
+
// in the same session (e.g. agent opens PR #10 and PR #11 both on acme/main-app).
|
|
556
|
+
// Only skip if we already have this exact PR number on this exact repo.
|
|
557
|
+
// If the existing PR on the same repo is closed, replace it with the new one.
|
|
558
|
+
const alreadyTracked = sessionPRs.some((p) => p.owner === detectedPR.owner &&
|
|
559
|
+
p.repo === detectedPR.repo &&
|
|
560
|
+
p.number === detectedPR.number);
|
|
561
|
+
if (!alreadyTracked) {
|
|
562
|
+
// Remove any closed PRs on the same repo before adding the new one.
|
|
563
|
+
// Open PRs on the same repo are kept — multiple open PRs per repo are valid.
|
|
564
|
+
session.prs = session.prs
|
|
565
|
+
.filter((p) => !(p.owner === detectedPR.owner &&
|
|
566
|
+
p.repo === detectedPR.repo &&
|
|
567
|
+
p.number !== detectedPR.number &&
|
|
568
|
+
prEnrichmentCache.get(`${p.owner}/${p.repo}#${p.number}`)?.state === "closed"))
|
|
569
|
+
.concat(detectedPR);
|
|
570
|
+
}
|
|
571
|
+
session.prs = dedupePrInfos(session.prs);
|
|
572
|
+
// pr is always the primary (first) PR
|
|
573
|
+
session.pr = session.prs[0] ?? detectedPR;
|
|
574
|
+
const sessionsDir = getProjectSessionsDir(session.projectId);
|
|
575
|
+
const allPrUrls = [...new Set(session.prs.map((p) => p.url))].join(",");
|
|
576
|
+
updateMetadata(sessionsDir, session.id, {
|
|
577
|
+
pr: session.pr.url,
|
|
578
|
+
prs: allPrUrls,
|
|
579
|
+
});
|
|
580
|
+
recordActivityEvent({
|
|
581
|
+
projectId: session.projectId,
|
|
582
|
+
sessionId: session.id,
|
|
583
|
+
source: "scm",
|
|
584
|
+
kind: "scm.detect_pr_succeeded",
|
|
585
|
+
summary: `PR #${detectedPR.number} detected`,
|
|
586
|
+
data: {
|
|
587
|
+
plugin: project.scm.plugin,
|
|
588
|
+
prNumber: detectedPR.number,
|
|
589
|
+
prUrl: detectedPR.url,
|
|
590
|
+
prOwner: detectedPR.owner,
|
|
591
|
+
prRepo: detectedPR.repo,
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
598
|
+
observer?.recordOperation?.({
|
|
599
|
+
metric: "lifecycle_poll",
|
|
600
|
+
operation: "scm.detect_pr",
|
|
601
|
+
outcome: "failure",
|
|
602
|
+
correlationId: createCorrelationId("detect-pr"),
|
|
603
|
+
projectId: session.projectId,
|
|
604
|
+
sessionId: session.id,
|
|
605
|
+
reason: errorMsg,
|
|
606
|
+
level: "warn",
|
|
607
|
+
});
|
|
608
|
+
recordActivityEvent({
|
|
609
|
+
projectId: session.projectId,
|
|
610
|
+
sessionId: session.id,
|
|
611
|
+
source: "scm",
|
|
612
|
+
kind: "scm.detect_pr_failed",
|
|
613
|
+
level: "warn",
|
|
614
|
+
summary: `detect_pr failed for ${session.id}`,
|
|
615
|
+
data: {
|
|
616
|
+
plugin: project.scm.plugin,
|
|
617
|
+
errorMessage: errorMsg,
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Persist batch enrichment data to session metadata files.
|
|
625
|
+
* The web dashboard reads this instead of calling GitHub API.
|
|
626
|
+
*/
|
|
627
|
+
function persistPREnrichmentToMetadata(sessions) {
|
|
628
|
+
for (const session of sessions) {
|
|
629
|
+
const sessionPRs = normalizeSessionPRs(session);
|
|
630
|
+
if (!session.pr)
|
|
631
|
+
continue;
|
|
632
|
+
const project = config.projects[session.projectId];
|
|
633
|
+
if (!project)
|
|
634
|
+
continue;
|
|
635
|
+
const sessionsDir = getProjectSessionsDir(session.projectId);
|
|
636
|
+
const cleanupUpdates = indexedPRMetadataCleanup(session, sessionPRs.length);
|
|
637
|
+
if (Object.keys(cleanupUpdates).length > 0) {
|
|
638
|
+
updateMetadata(sessionsDir, session.id, cleanupUpdates);
|
|
639
|
+
session.metadata = Object.fromEntries(Object.entries(session.metadata).filter(([key]) => cleanupUpdates[key] === undefined));
|
|
640
|
+
}
|
|
641
|
+
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
|
642
|
+
const cached = prEnrichmentCache.get(prKey);
|
|
643
|
+
if (cached) {
|
|
644
|
+
const blob = JSON.stringify({
|
|
645
|
+
state: cached.state,
|
|
646
|
+
ciStatus: cached.ciStatus,
|
|
647
|
+
reviewDecision: cached.reviewDecision,
|
|
648
|
+
mergeable: cached.mergeable,
|
|
649
|
+
title: cached.title,
|
|
650
|
+
additions: cached.additions,
|
|
651
|
+
deletions: cached.deletions,
|
|
652
|
+
isDraft: cached.isDraft,
|
|
653
|
+
hasConflicts: cached.hasConflicts,
|
|
654
|
+
isBehind: cached.isBehind,
|
|
655
|
+
blockers: cached.blockers,
|
|
656
|
+
ciChecks: cached.ciChecks?.map((c) => ({
|
|
657
|
+
name: c.name,
|
|
658
|
+
status: c.status,
|
|
659
|
+
url: c.url,
|
|
660
|
+
})),
|
|
661
|
+
enrichedAt: new Date().toISOString(),
|
|
662
|
+
});
|
|
663
|
+
if (session.metadata["prEnrichment"] !== blob) {
|
|
664
|
+
updateMetadata(sessionsDir, session.id, { prEnrichment: blob });
|
|
665
|
+
session.metadata["prEnrichment"] = blob;
|
|
666
|
+
}
|
|
667
|
+
// Keep in-memory isDraft in sync with enrichment data
|
|
668
|
+
if (cached.isDraft !== undefined && session.pr) {
|
|
669
|
+
session.pr.isDraft = cached.isDraft;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
for (let i = 1; i < sessionPRs.length; i++) {
|
|
673
|
+
const secondaryPR = sessionPRs[i];
|
|
674
|
+
if (!secondaryPR)
|
|
675
|
+
continue;
|
|
676
|
+
const secondaryKey = `${secondaryPR.owner}/${secondaryPR.repo}#${secondaryPR.number}`;
|
|
677
|
+
const secondaryCached = prEnrichmentCache.get(secondaryKey);
|
|
678
|
+
if (!secondaryCached)
|
|
679
|
+
continue;
|
|
680
|
+
const secondaryBlob = JSON.stringify({
|
|
681
|
+
state: secondaryCached.state,
|
|
682
|
+
ciStatus: secondaryCached.ciStatus,
|
|
683
|
+
reviewDecision: secondaryCached.reviewDecision,
|
|
684
|
+
mergeable: secondaryCached.mergeable,
|
|
685
|
+
title: secondaryCached.title,
|
|
686
|
+
additions: secondaryCached.additions,
|
|
687
|
+
deletions: secondaryCached.deletions,
|
|
688
|
+
isDraft: secondaryCached.isDraft,
|
|
689
|
+
hasConflicts: secondaryCached.hasConflicts,
|
|
690
|
+
isBehind: secondaryCached.isBehind,
|
|
691
|
+
blockers: secondaryCached.blockers,
|
|
692
|
+
ciChecks: secondaryCached.ciChecks?.map((c) => ({
|
|
693
|
+
name: c.name,
|
|
694
|
+
status: c.status,
|
|
695
|
+
url: c.url,
|
|
696
|
+
})),
|
|
697
|
+
enrichedAt: new Date().toISOString(),
|
|
698
|
+
});
|
|
699
|
+
const metaKey = `prEnrichment_${i}`;
|
|
700
|
+
if (session.metadata[metaKey] !== secondaryBlob) {
|
|
701
|
+
updateMetadata(sessionsDir, session.id, { [metaKey]: secondaryBlob });
|
|
702
|
+
session.metadata[metaKey] = secondaryBlob;
|
|
703
|
+
}
|
|
704
|
+
// Keep in-memory isDraft in sync with enrichment data
|
|
705
|
+
if (secondaryCached.isDraft !== undefined) {
|
|
706
|
+
secondaryPR.isDraft = secondaryCached.isDraft;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/** Check if idle time exceeds the agent-stuck threshold. */
|
|
712
|
+
function isIdleBeyondThreshold(session, idleTimestamp) {
|
|
713
|
+
const stuckReaction = getReactionConfigForSession(session, "agent-stuck");
|
|
714
|
+
const thresholdStr = stuckReaction?.threshold;
|
|
715
|
+
if (typeof thresholdStr !== "string")
|
|
716
|
+
return false;
|
|
717
|
+
const stuckThresholdMs = parseDuration(thresholdStr);
|
|
718
|
+
if (stuckThresholdMs <= 0)
|
|
719
|
+
return false;
|
|
720
|
+
const idleMs = Date.now() - idleTimestamp.getTime();
|
|
721
|
+
return idleMs > stuckThresholdMs;
|
|
722
|
+
}
|
|
723
|
+
function isBranchOwnedByAnotherActiveWorker(session, branch, siblingSessions, allSessionPrefixes) {
|
|
724
|
+
return siblingSessions.some((other) => {
|
|
725
|
+
if (other.id === session.id)
|
|
726
|
+
return false;
|
|
727
|
+
if (other.projectId !== session.projectId)
|
|
728
|
+
return false;
|
|
729
|
+
if (TERMINAL_STATUSES.has(other.status))
|
|
730
|
+
return false;
|
|
731
|
+
const otherProject = config.projects[other.projectId];
|
|
732
|
+
if (!otherProject)
|
|
733
|
+
return false;
|
|
734
|
+
const otherRole = resolveSessionRole(other.id, other.metadata, otherProject.sessionPrefix, allSessionPrefixes);
|
|
735
|
+
return otherRole === "worker" && other.branch === branch;
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
function acquireBranchAdoptionReservation(session, branch) {
|
|
739
|
+
const reservationKey = `${session.projectId}:${branch}`;
|
|
740
|
+
const existingOwner = branchAdoptionReservations.get(reservationKey);
|
|
741
|
+
if (existingOwner && existingOwner !== session.id) {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
branchAdoptionReservations.set(reservationKey, session.id);
|
|
745
|
+
return reservationKey;
|
|
746
|
+
}
|
|
747
|
+
function releaseBranchAdoptionReservation(reservationKey, sessionId) {
|
|
748
|
+
if (branchAdoptionReservations.get(reservationKey) === sessionId) {
|
|
749
|
+
branchAdoptionReservations.delete(reservationKey);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async function refreshTrackedBranch(session, siblingSessions) {
|
|
753
|
+
const project = config.projects[session.projectId];
|
|
754
|
+
if (!project)
|
|
755
|
+
return;
|
|
756
|
+
const allSessionPrefixes = Object.values(config.projects).map((p) => p.sessionPrefix);
|
|
757
|
+
const sessionRole = resolveSessionRole(session.id, session.metadata, project.sessionPrefix, allSessionPrefixes);
|
|
758
|
+
const workspacePath = session.workspacePath;
|
|
759
|
+
const canRefreshTrackedBranch = sessionRole === "worker" &&
|
|
760
|
+
workspacePath !== null &&
|
|
761
|
+
(!session.pr || session.lifecycle.pr.state === "closed");
|
|
762
|
+
if (!canRefreshTrackedBranch)
|
|
763
|
+
return;
|
|
764
|
+
const branchProbe = await readWorkspaceBranch(workspacePath);
|
|
765
|
+
if (branchProbe.kind === "detached") {
|
|
766
|
+
if (session.branch !== null) {
|
|
767
|
+
session.branch = null;
|
|
768
|
+
updateSessionMetadata(session, { branch: "" });
|
|
769
|
+
}
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (branchProbe.kind !== "branch" || branchProbe.branch === session.branch) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const reservationKey = acquireBranchAdoptionReservation(session, branchProbe.branch);
|
|
776
|
+
if (!reservationKey)
|
|
777
|
+
return;
|
|
778
|
+
try {
|
|
779
|
+
const sessionsForConflictCheck = siblingSessions ?? (await sessionManager.list(session.projectId));
|
|
780
|
+
if (!isBranchOwnedByAnotherActiveWorker(session, branchProbe.branch, sessionsForConflictCheck, allSessionPrefixes)) {
|
|
781
|
+
session.branch = branchProbe.branch;
|
|
782
|
+
updateSessionMetadata(session, { branch: branchProbe.branch });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
finally {
|
|
786
|
+
releaseBranchAdoptionReservation(reservationKey, session.id);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
/** Determine current status for a session by polling plugins. */
|
|
790
|
+
async function determineStatus(session) {
|
|
791
|
+
const project = config.projects[session.projectId];
|
|
792
|
+
if (!project) {
|
|
793
|
+
return {
|
|
794
|
+
status: session.status,
|
|
795
|
+
evidence: "project_missing",
|
|
796
|
+
detectingAttempts: parseAttemptCount(session.metadata["detectingAttempts"]),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const lifecycle = cloneLifecycle(session.lifecycle);
|
|
800
|
+
const nowIso = new Date().toISOString();
|
|
801
|
+
const agentName = session.metadata["agent"];
|
|
802
|
+
const agent = agentName ? registry.get("agent", agentName) : null;
|
|
803
|
+
const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
|
|
804
|
+
let detectedIdleTimestamp = null;
|
|
805
|
+
let idleWasBlocked = false;
|
|
806
|
+
const canProbeRuntimeIdentity = session.status !== SESSION_STATUS.SPAWNING;
|
|
807
|
+
const currentDetectingAttempts = parseAttemptCount(session.metadata["detectingAttempts"]);
|
|
808
|
+
const currentDetectingStartedAt = session.metadata["detectingStartedAt"] || undefined;
|
|
809
|
+
const currentDetectingEvidenceHash = session.metadata["detectingEvidenceHash"] || undefined;
|
|
810
|
+
const commit = (decision = {
|
|
811
|
+
status: deriveLegacyStatus(lifecycle),
|
|
812
|
+
evidence: "lifecycle_commit",
|
|
813
|
+
detecting: { attempts: currentDetectingAttempts },
|
|
814
|
+
}) => {
|
|
815
|
+
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
|
816
|
+
session.lifecycle = lifecycle;
|
|
817
|
+
session.status = decision.status;
|
|
818
|
+
session.activitySignal = activitySignal;
|
|
819
|
+
return {
|
|
820
|
+
status: decision.status,
|
|
821
|
+
evidence: decision.evidence,
|
|
822
|
+
detectingAttempts: decision.detecting.attempts,
|
|
823
|
+
detectingStartedAt: decision.detecting.startedAt,
|
|
824
|
+
detectingEvidenceHash: decision.detecting.evidenceHash,
|
|
825
|
+
};
|
|
826
|
+
};
|
|
827
|
+
let runtimeProbe = { state: "unknown", failed: false };
|
|
828
|
+
if (session.runtimeHandle && canProbeRuntimeIdentity) {
|
|
829
|
+
const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime);
|
|
830
|
+
if (runtime) {
|
|
831
|
+
try {
|
|
832
|
+
const alive = await runtime.isAlive(session.runtimeHandle);
|
|
833
|
+
lifecycle.runtime.lastObservedAt = nowIso;
|
|
834
|
+
runtimeProbe = { state: alive ? "alive" : "dead", failed: false };
|
|
835
|
+
if (alive) {
|
|
836
|
+
lifecycle.runtime.state = "alive";
|
|
837
|
+
lifecycle.runtime.reason = "process_running";
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
lifecycle.runtime.state = "missing";
|
|
841
|
+
lifecycle.runtime.reason =
|
|
842
|
+
session.runtimeHandle.runtimeName === "tmux" ? "tmux_missing" : "process_missing";
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
lifecycle.runtime.state = "probe_failed";
|
|
847
|
+
lifecycle.runtime.reason = "probe_error";
|
|
848
|
+
lifecycle.runtime.lastObservedAt = nowIso;
|
|
849
|
+
runtimeProbe = { state: "unknown", failed: true };
|
|
850
|
+
recordActivityEvent({
|
|
851
|
+
projectId: session.projectId,
|
|
852
|
+
sessionId: session.id,
|
|
853
|
+
source: "runtime",
|
|
854
|
+
kind: "runtime.probe_failed",
|
|
855
|
+
level: "warn",
|
|
856
|
+
summary: `runtime.isAlive probe failed for ${session.id}`,
|
|
857
|
+
data: {
|
|
858
|
+
runtimeName: session.runtimeHandle.runtimeName,
|
|
859
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
let activitySignal = createActivitySignal("unavailable");
|
|
866
|
+
let processProbe = { state: "unknown", failed: false };
|
|
867
|
+
let activityEvidence = formatActivitySignalEvidence(activitySignal);
|
|
868
|
+
if (agent && (session.runtimeHandle || session.workspacePath)) {
|
|
869
|
+
try {
|
|
870
|
+
if (agent.recordActivity &&
|
|
871
|
+
session.workspacePath &&
|
|
872
|
+
session.runtimeHandle &&
|
|
873
|
+
canProbeRuntimeIdentity) {
|
|
874
|
+
try {
|
|
875
|
+
const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime);
|
|
876
|
+
const terminalOutput = runtime
|
|
877
|
+
? await runtime.getOutput(session.runtimeHandle, 10)
|
|
878
|
+
: "";
|
|
879
|
+
if (terminalOutput) {
|
|
880
|
+
await agent.recordActivity(session, terminalOutput);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
catch (error) {
|
|
884
|
+
observer?.recordOperation?.({
|
|
885
|
+
metric: "lifecycle_poll",
|
|
886
|
+
operation: "activity.record",
|
|
887
|
+
outcome: "failure",
|
|
888
|
+
correlationId: createCorrelationId("lifecycle-poll"),
|
|
889
|
+
projectId: session.projectId,
|
|
890
|
+
sessionId: session.id,
|
|
891
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
892
|
+
level: "warn",
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const detectedActivity = await agent.getActivityState(session, config.readyThresholdMs);
|
|
897
|
+
if (detectedActivity) {
|
|
898
|
+
activitySignal = classifyActivitySignal(detectedActivity, "native");
|
|
899
|
+
activityEvidence = formatActivitySignalEvidence(activitySignal);
|
|
900
|
+
lifecycle.runtime.lastObservedAt = nowIso;
|
|
901
|
+
const prevActivity = activityStateCache.get(session.id);
|
|
902
|
+
activityStateCache.set(session.id, detectedActivity.state);
|
|
903
|
+
if (prevActivity !== undefined && prevActivity !== detectedActivity.state) {
|
|
904
|
+
recordActivityEvent({
|
|
905
|
+
projectId: session.projectId,
|
|
906
|
+
sessionId: session.id,
|
|
907
|
+
source: "lifecycle",
|
|
908
|
+
kind: "activity.transition",
|
|
909
|
+
summary: `${prevActivity} → ${detectedActivity.state}`,
|
|
910
|
+
data: { from: prevActivity, to: detectedActivity.state },
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
if (lifecycle.runtime.state !== "missing" && lifecycle.runtime.state !== "probe_failed") {
|
|
914
|
+
lifecycle.runtime.state = "alive";
|
|
915
|
+
lifecycle.runtime.reason = "process_running";
|
|
916
|
+
}
|
|
917
|
+
if (detectedActivity.state === "waiting_input") {
|
|
918
|
+
return commit({
|
|
919
|
+
status: SESSION_STATUS.NEEDS_INPUT,
|
|
920
|
+
evidence: activityEvidence,
|
|
921
|
+
detecting: { attempts: 0 },
|
|
922
|
+
sessionState: "needs_input",
|
|
923
|
+
sessionReason: "awaiting_user_input",
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
if (detectedActivity.state === "exited" && canProbeRuntimeIdentity) {
|
|
927
|
+
processProbe = { state: "dead", failed: false };
|
|
928
|
+
lifecycle.runtime.state = "exited";
|
|
929
|
+
lifecycle.runtime.reason = "process_missing";
|
|
930
|
+
}
|
|
931
|
+
if (hasPositiveIdleEvidence(activitySignal)) {
|
|
932
|
+
detectedIdleTimestamp = activitySignal.timestamp;
|
|
933
|
+
idleWasBlocked = activitySignal.activity === "blocked";
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
else if (session.runtimeHandle && canProbeRuntimeIdentity) {
|
|
937
|
+
activitySignal = createActivitySignal("null", { source: "native" });
|
|
938
|
+
activityEvidence = formatActivitySignalEvidence(activitySignal);
|
|
939
|
+
const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime);
|
|
940
|
+
const terminalOutput = runtime ? await runtime.getOutput(session.runtimeHandle, 10) : "";
|
|
941
|
+
if (terminalOutput) {
|
|
942
|
+
const activity = agent.detectActivity(terminalOutput);
|
|
943
|
+
activitySignal = classifyActivitySignal({ state: activity }, "terminal");
|
|
944
|
+
activityEvidence = formatActivitySignalEvidence(activitySignal);
|
|
945
|
+
if (activity === "waiting_input") {
|
|
946
|
+
return commit({
|
|
947
|
+
status: SESSION_STATUS.NEEDS_INPUT,
|
|
948
|
+
evidence: activityEvidence,
|
|
949
|
+
detecting: { attempts: 0 },
|
|
950
|
+
sessionState: "needs_input",
|
|
951
|
+
sessionReason: "awaiting_user_input",
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
try {
|
|
955
|
+
const processAlive = await agent.isProcessRunning(session.runtimeHandle);
|
|
956
|
+
processProbe = processProbeResultToProbeResult(processAlive);
|
|
957
|
+
if (processAlive === false) {
|
|
958
|
+
lifecycle.runtime.state = "exited";
|
|
959
|
+
lifecycle.runtime.reason = "process_missing";
|
|
960
|
+
lifecycle.runtime.lastObservedAt = nowIso;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
catch (err) {
|
|
964
|
+
processProbe = { state: "unknown", failed: true };
|
|
965
|
+
recordActivityEvent({
|
|
966
|
+
projectId: session.projectId,
|
|
967
|
+
sessionId: session.id,
|
|
968
|
+
source: "agent",
|
|
969
|
+
kind: "agent.process_probe_failed",
|
|
970
|
+
level: "warn",
|
|
971
|
+
summary: `agent.isProcessRunning failed for ${session.id}`,
|
|
972
|
+
data: {
|
|
973
|
+
agentName,
|
|
974
|
+
where: "fallback",
|
|
975
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
activitySignal = createActivitySignal("null", { source: "native" });
|
|
983
|
+
activityEvidence = formatActivitySignalEvidence(activitySignal);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
activitySignal = createActivitySignal("probe_failure", { source: "native" });
|
|
988
|
+
activityEvidence = formatActivitySignalEvidence(activitySignal);
|
|
989
|
+
recordActivityEvent({
|
|
990
|
+
projectId: session.projectId,
|
|
991
|
+
sessionId: session.id,
|
|
992
|
+
source: "agent",
|
|
993
|
+
kind: "agent.activity_probe_failed",
|
|
994
|
+
level: "warn",
|
|
995
|
+
summary: `activity probing failed for ${session.id}`,
|
|
996
|
+
data: {
|
|
997
|
+
agentName,
|
|
998
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
if (lifecycle.session.state === "stuck" ||
|
|
1002
|
+
lifecycle.session.state === "needs_input" ||
|
|
1003
|
+
lifecycle.session.state === "detecting") {
|
|
1004
|
+
return commit({
|
|
1005
|
+
status: session.status,
|
|
1006
|
+
evidence: activityEvidence,
|
|
1007
|
+
detecting: { attempts: currentDetectingAttempts },
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
return commit(createDetectingDecision({
|
|
1011
|
+
currentAttempts: currentDetectingAttempts,
|
|
1012
|
+
idleWasBlocked,
|
|
1013
|
+
evidence: activityEvidence,
|
|
1014
|
+
detectingStartedAt: currentDetectingStartedAt,
|
|
1015
|
+
previousEvidenceHash: currentDetectingEvidenceHash,
|
|
1016
|
+
}));
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (processProbe.state === "unknown" &&
|
|
1020
|
+
!processProbe.indeterminate &&
|
|
1021
|
+
session.runtimeHandle &&
|
|
1022
|
+
canProbeRuntimeIdentity &&
|
|
1023
|
+
agent) {
|
|
1024
|
+
try {
|
|
1025
|
+
const processAlive = await agent.isProcessRunning(session.runtimeHandle);
|
|
1026
|
+
processProbe = processProbeResultToProbeResult(processAlive);
|
|
1027
|
+
if (processAlive === false) {
|
|
1028
|
+
lifecycle.runtime.state = "exited";
|
|
1029
|
+
lifecycle.runtime.reason = "process_missing";
|
|
1030
|
+
lifecycle.runtime.lastObservedAt = nowIso;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
catch (err) {
|
|
1034
|
+
processProbe = { state: "unknown", failed: true };
|
|
1035
|
+
recordActivityEvent({
|
|
1036
|
+
projectId: session.projectId,
|
|
1037
|
+
sessionId: session.id,
|
|
1038
|
+
source: "agent",
|
|
1039
|
+
kind: "agent.process_probe_failed",
|
|
1040
|
+
level: "warn",
|
|
1041
|
+
summary: `agent.isProcessRunning failed for ${session.id}`,
|
|
1042
|
+
data: {
|
|
1043
|
+
agentName,
|
|
1044
|
+
where: "standalone",
|
|
1045
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (processProbe.indeterminate) {
|
|
1051
|
+
recordActivityEvent({
|
|
1052
|
+
projectId: session.projectId,
|
|
1053
|
+
sessionId: session.id,
|
|
1054
|
+
source: "agent",
|
|
1055
|
+
kind: "agent.process_probe_failed",
|
|
1056
|
+
level: "warn",
|
|
1057
|
+
summary: `agent.isProcessRunning indeterminate for ${session.id}`,
|
|
1058
|
+
data: {
|
|
1059
|
+
agentName,
|
|
1060
|
+
reason: "probe_indeterminate",
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
return {
|
|
1064
|
+
status: session.status,
|
|
1065
|
+
evidence: session.metadata["lifecycleEvidence"] ?? "process_probe_indeterminate",
|
|
1066
|
+
detectingAttempts: currentDetectingAttempts,
|
|
1067
|
+
detectingStartedAt: currentDetectingStartedAt,
|
|
1068
|
+
detectingEvidenceHash: currentDetectingEvidenceHash,
|
|
1069
|
+
skipMetadataWrite: true,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
const probeDecision = resolveProbeDecision({
|
|
1073
|
+
currentAttempts: currentDetectingAttempts,
|
|
1074
|
+
runtimeProbe,
|
|
1075
|
+
processProbe,
|
|
1076
|
+
canProbeRuntimeIdentity,
|
|
1077
|
+
activitySignal,
|
|
1078
|
+
activityEvidence,
|
|
1079
|
+
idleWasBlocked,
|
|
1080
|
+
detectingStartedAt: currentDetectingStartedAt,
|
|
1081
|
+
previousEvidenceHash: currentDetectingEvidenceHash,
|
|
1082
|
+
});
|
|
1083
|
+
if (probeDecision) {
|
|
1084
|
+
return commit(probeDecision);
|
|
1085
|
+
}
|
|
1086
|
+
// detectPR is handled in populatePREnrichmentCache (gated by Guard 1 ETag).
|
|
1087
|
+
// By this point, session.pr is already set if a PR was discovered.
|
|
1088
|
+
if (session.pr && scm) {
|
|
1089
|
+
try {
|
|
1090
|
+
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
|
1091
|
+
const cachedData = prEnrichmentCache.get(prKey);
|
|
1092
|
+
if (lifecycle.pr.state === "none") {
|
|
1093
|
+
lifecycle.pr.state = "open";
|
|
1094
|
+
}
|
|
1095
|
+
if (lifecycle.pr.reason === "not_created") {
|
|
1096
|
+
lifecycle.pr.reason = "in_progress";
|
|
1097
|
+
}
|
|
1098
|
+
lifecycle.pr.number = session.pr.number;
|
|
1099
|
+
lifecycle.pr.url = session.pr.url;
|
|
1100
|
+
lifecycle.pr.lastObservedAt = nowIso;
|
|
1101
|
+
const shouldEscalateIdleToStuck = detectedIdleTimestamp !== null && hasPositiveIdleEvidence(activitySignal)
|
|
1102
|
+
? isIdleBeyondThreshold(session, detectedIdleTimestamp)
|
|
1103
|
+
: false;
|
|
1104
|
+
if (cachedData) {
|
|
1105
|
+
// When session has multiple PRs, aggregate enrichment across all of them.
|
|
1106
|
+
// ci_failed if ANY fails; approved/merged only when ALL pass.
|
|
1107
|
+
if (session.prs.length > 1) {
|
|
1108
|
+
const allEnrichments = session.prs
|
|
1109
|
+
.map((p) => prEnrichmentCache.get(`${p.owner}/${p.repo}#${p.number}`))
|
|
1110
|
+
.filter((e) => e !== undefined);
|
|
1111
|
+
if (allEnrichments.length === session.prs.length) {
|
|
1112
|
+
const aggregated = {
|
|
1113
|
+
ciStatus: allEnrichments.some((e) => e.ciStatus === "failing")
|
|
1114
|
+
? "failing"
|
|
1115
|
+
: allEnrichments.every((e) => e.ciStatus === "passing" || e.ciStatus === "none")
|
|
1116
|
+
? "passing"
|
|
1117
|
+
: "pending",
|
|
1118
|
+
reviewDecision: allEnrichments.some((e) => e.reviewDecision === "changes_requested")
|
|
1119
|
+
? "changes_requested"
|
|
1120
|
+
: allEnrichments.every((e) => e.reviewDecision === "approved")
|
|
1121
|
+
? "approved"
|
|
1122
|
+
: allEnrichments.every((e) => e.reviewDecision === "none")
|
|
1123
|
+
? "none"
|
|
1124
|
+
: "pending",
|
|
1125
|
+
state: allEnrichments.every((e) => e.state === "merged")
|
|
1126
|
+
? "merged"
|
|
1127
|
+
: allEnrichments.some((e) => e.state === "open")
|
|
1128
|
+
? "open"
|
|
1129
|
+
: "closed",
|
|
1130
|
+
mergeable: allEnrichments.every((e) => e.mergeable),
|
|
1131
|
+
blockers: [...new Set(allEnrichments.flatMap((e) => e.blockers ?? []))],
|
|
1132
|
+
title: cachedData.title,
|
|
1133
|
+
additions: cachedData.additions,
|
|
1134
|
+
deletions: cachedData.deletions,
|
|
1135
|
+
isDraft: allEnrichments.some((e) => e.isDraft),
|
|
1136
|
+
hasConflicts: allEnrichments.some((e) => e.hasConflicts),
|
|
1137
|
+
isBehind: allEnrichments.some((e) => e.isBehind),
|
|
1138
|
+
};
|
|
1139
|
+
return commit(resolvePREnrichmentDecision(aggregated, {
|
|
1140
|
+
shouldEscalateIdleToStuck,
|
|
1141
|
+
idleWasBlocked,
|
|
1142
|
+
activityEvidence,
|
|
1143
|
+
}));
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
// Partial cache miss for multi-PR session: never decide on primary PR
|
|
1147
|
+
// alone — fall through to the live-API check that verifies all PRs.
|
|
1148
|
+
if (session.prs.length <= 1) {
|
|
1149
|
+
return commit(resolvePREnrichmentDecision(cachedData, {
|
|
1150
|
+
shouldEscalateIdleToStuck,
|
|
1151
|
+
idleWasBlocked,
|
|
1152
|
+
activityEvidence,
|
|
1153
|
+
}));
|
|
1154
|
+
}
|
|
1155
|
+
// intentional fall-through to live-API block below
|
|
1156
|
+
}
|
|
1157
|
+
// Batch enrichment cache miss — fall back to getPRState for terminal
|
|
1158
|
+
// states (merged/closed) only. Detecting these promptly prevents
|
|
1159
|
+
// delayed cleanup. Non-terminal state updates wait for the next batch
|
|
1160
|
+
// cycle (30s) to avoid ~110 individual REST calls per 15-min window.
|
|
1161
|
+
try {
|
|
1162
|
+
if (session.prs.length > 1) {
|
|
1163
|
+
// Multi-PR: only terminate when ALL PRs are in a terminal state.
|
|
1164
|
+
const states = await Promise.all(session.prs.map((p) => scm.getPRState(p)));
|
|
1165
|
+
if (states.every((s) => s === "merged" || s === "closed")) {
|
|
1166
|
+
const prState = states.every((s) => s === "merged") ? "merged" : "closed";
|
|
1167
|
+
return commit(resolvePRLiveDecision({
|
|
1168
|
+
prState,
|
|
1169
|
+
ciStatus: "none",
|
|
1170
|
+
reviewDecision: "none",
|
|
1171
|
+
mergeable: false,
|
|
1172
|
+
shouldEscalateIdleToStuck,
|
|
1173
|
+
idleWasBlocked,
|
|
1174
|
+
activityEvidence,
|
|
1175
|
+
}));
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
const prState = await scm.getPRState(session.pr);
|
|
1180
|
+
if (prState === "merged" || prState === "closed") {
|
|
1181
|
+
return commit(resolvePRLiveDecision({
|
|
1182
|
+
prState,
|
|
1183
|
+
ciStatus: "none",
|
|
1184
|
+
reviewDecision: "none",
|
|
1185
|
+
mergeable: false,
|
|
1186
|
+
shouldEscalateIdleToStuck,
|
|
1187
|
+
idleWasBlocked,
|
|
1188
|
+
activityEvidence,
|
|
1189
|
+
}));
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
catch (err) {
|
|
1194
|
+
// Best-effort — batch will retry next cycle. Record AE evidence so
|
|
1195
|
+
// RCA can answer "why didn't AO transition to merged/closed in time?"
|
|
1196
|
+
recordActivityEvent({
|
|
1197
|
+
projectId: session.projectId,
|
|
1198
|
+
sessionId: session.id,
|
|
1199
|
+
source: "scm",
|
|
1200
|
+
kind: "scm.poll_pr_failed",
|
|
1201
|
+
level: "warn",
|
|
1202
|
+
summary: `getPRState failed for PR #${session.pr.number}`,
|
|
1203
|
+
data: {
|
|
1204
|
+
plugin: project.scm?.plugin,
|
|
1205
|
+
prNumber: session.pr.number,
|
|
1206
|
+
prUrl: session.pr.url,
|
|
1207
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
1208
|
+
},
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
catch (error) {
|
|
1213
|
+
observer?.recordOperation?.({
|
|
1214
|
+
metric: "lifecycle_poll",
|
|
1215
|
+
operation: "scm.poll_pr",
|
|
1216
|
+
outcome: "failure",
|
|
1217
|
+
correlationId: createCorrelationId("lifecycle-poll"),
|
|
1218
|
+
projectId: session.projectId,
|
|
1219
|
+
sessionId: session.id,
|
|
1220
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1221
|
+
level: "warn",
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
// Fresh agent reports outrank weak inference (idle-beyond-threshold /
|
|
1226
|
+
// default-to-working) but runtime death, activity waiting_input, and SCM
|
|
1227
|
+
// ground truth already short-circuited above. Orchestrator sessions and
|
|
1228
|
+
// terminal states are skipped intentionally — `lifecycle.session.kind` is
|
|
1229
|
+
// the authoritative source (string-matching role/id suffixes misses
|
|
1230
|
+
// numbered orchestrator IDs like `${prefix}-orchestrator-1`).
|
|
1231
|
+
const agentReport = readAgentReport(session.metadata);
|
|
1232
|
+
if (agentReport &&
|
|
1233
|
+
isAgentReportFresh(agentReport) &&
|
|
1234
|
+
lifecycle.session.kind !== "orchestrator" &&
|
|
1235
|
+
lifecycle.session.state !== "terminated" &&
|
|
1236
|
+
lifecycle.session.state !== "done") {
|
|
1237
|
+
const mapped = mapAgentReportToLifecycle(agentReport.state);
|
|
1238
|
+
return commit({
|
|
1239
|
+
status: deriveLegacyStatus({
|
|
1240
|
+
...lifecycle,
|
|
1241
|
+
session: {
|
|
1242
|
+
...lifecycle.session,
|
|
1243
|
+
state: mapped.sessionState,
|
|
1244
|
+
reason: mapped.sessionReason,
|
|
1245
|
+
},
|
|
1246
|
+
}),
|
|
1247
|
+
evidence: `agent_report:${agentReport.state}`,
|
|
1248
|
+
detecting: { attempts: 0 },
|
|
1249
|
+
sessionState: mapped.sessionState,
|
|
1250
|
+
sessionReason: mapped.sessionReason,
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
if (detectedIdleTimestamp &&
|
|
1254
|
+
hasPositiveIdleEvidence(activitySignal) &&
|
|
1255
|
+
isIdleBeyondThreshold(session, detectedIdleTimestamp)) {
|
|
1256
|
+
return commit({
|
|
1257
|
+
status: SESSION_STATUS.STUCK,
|
|
1258
|
+
evidence: `idle_beyond_threshold ${activityEvidence}`,
|
|
1259
|
+
detecting: { attempts: 0 },
|
|
1260
|
+
sessionState: "stuck",
|
|
1261
|
+
sessionReason: idleWasBlocked ? "error_in_process" : "probe_failure",
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
if (isWeakActivityEvidence(activitySignal) &&
|
|
1265
|
+
(session.status === SESSION_STATUS.DETECTING ||
|
|
1266
|
+
session.status === SESSION_STATUS.STUCK ||
|
|
1267
|
+
session.status === SESSION_STATUS.NEEDS_INPUT ||
|
|
1268
|
+
lifecycle.session.state === "detecting" ||
|
|
1269
|
+
lifecycle.session.state === "stuck" ||
|
|
1270
|
+
lifecycle.session.state === "needs_input")) {
|
|
1271
|
+
const preservingProbeFailureStuck = activitySignal.state === "unavailable" &&
|
|
1272
|
+
lifecycle.session.state === "stuck" &&
|
|
1273
|
+
lifecycle.session.reason === "probe_failure" &&
|
|
1274
|
+
runtimeProbe.state === "alive" &&
|
|
1275
|
+
!runtimeProbe.failed;
|
|
1276
|
+
if (preservingProbeFailureStuck) {
|
|
1277
|
+
return commit({
|
|
1278
|
+
status: SESSION_STATUS.DETECTING,
|
|
1279
|
+
evidence: activityEvidence,
|
|
1280
|
+
detecting: { attempts: 0 },
|
|
1281
|
+
sessionState: "detecting",
|
|
1282
|
+
sessionReason: "probe_failure",
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
return commit({
|
|
1286
|
+
status: deriveLegacyStatus(lifecycle),
|
|
1287
|
+
evidence: activityEvidence,
|
|
1288
|
+
detecting: { attempts: 0 },
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
if (session.status === SESSION_STATUS.SPAWNING ||
|
|
1292
|
+
session.status === SESSION_STATUS.DETECTING ||
|
|
1293
|
+
session.status === SESSION_STATUS.STUCK ||
|
|
1294
|
+
session.status === SESSION_STATUS.NEEDS_INPUT) {
|
|
1295
|
+
return commit({
|
|
1296
|
+
status: SESSION_STATUS.WORKING,
|
|
1297
|
+
evidence: activityEvidence,
|
|
1298
|
+
detecting: { attempts: 0 },
|
|
1299
|
+
sessionState: "working",
|
|
1300
|
+
sessionReason: "task_in_progress",
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
return commit({
|
|
1304
|
+
status: session.status,
|
|
1305
|
+
evidence: activityEvidence,
|
|
1306
|
+
detecting: { attempts: 0 },
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
/** Execute a reaction for a session. */
|
|
1310
|
+
async function executeReaction(session, reactionKey, reactionConfig) {
|
|
1311
|
+
const { id: sessionId, projectId } = session;
|
|
1312
|
+
const trackerKey = `${sessionId}:${reactionKey}`;
|
|
1313
|
+
let tracker = reactionTrackers.get(trackerKey);
|
|
1314
|
+
if (!tracker) {
|
|
1315
|
+
tracker = { attempts: 0, firstTriggered: new Date() };
|
|
1316
|
+
reactionTrackers.set(trackerKey, tracker);
|
|
1317
|
+
}
|
|
1318
|
+
// Already escalated — wait for the condition to resolve before resuming.
|
|
1319
|
+
if (tracker.escalated) {
|
|
1320
|
+
return { reactionType: reactionKey, success: true, action: "escalated", escalated: true };
|
|
1321
|
+
}
|
|
1322
|
+
// Increment attempts before checking escalation
|
|
1323
|
+
tracker.attempts++;
|
|
1324
|
+
// Check if we should escalate
|
|
1325
|
+
const maxRetries = reactionConfig.retries ?? Infinity;
|
|
1326
|
+
const escalateAfter = reactionConfig.escalateAfter;
|
|
1327
|
+
let shouldEscalate = false;
|
|
1328
|
+
if (tracker.attempts > maxRetries) {
|
|
1329
|
+
shouldEscalate = true;
|
|
1330
|
+
}
|
|
1331
|
+
if (typeof escalateAfter === "string") {
|
|
1332
|
+
const durationMs = parseDuration(escalateAfter);
|
|
1333
|
+
if (durationMs > 0 && Date.now() - tracker.firstTriggered.getTime() > durationMs) {
|
|
1334
|
+
shouldEscalate = true;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
if (typeof escalateAfter === "number" && tracker.attempts > escalateAfter) {
|
|
1338
|
+
shouldEscalate = true;
|
|
1339
|
+
}
|
|
1340
|
+
if (shouldEscalate) {
|
|
1341
|
+
// Mirror the trigger checks above so the cause matches the gate that
|
|
1342
|
+
// actually fired. Numeric escalateAfter is an attempt-count gate, not a
|
|
1343
|
+
// duration; without this distinction it gets misattributed to max_duration.
|
|
1344
|
+
const escalationCause = tracker.attempts > maxRetries
|
|
1345
|
+
? "max_retries"
|
|
1346
|
+
: typeof escalateAfter === "number" && tracker.attempts > escalateAfter
|
|
1347
|
+
? "max_attempts"
|
|
1348
|
+
: "max_duration";
|
|
1349
|
+
const durationMs = Date.now() - tracker.firstTriggered.getTime();
|
|
1350
|
+
recordActivityEvent({
|
|
1351
|
+
projectId,
|
|
1352
|
+
sessionId,
|
|
1353
|
+
source: "reaction",
|
|
1354
|
+
kind: "reaction.escalated",
|
|
1355
|
+
level: "warn",
|
|
1356
|
+
summary: `reaction ${reactionKey} escalated after ${tracker.attempts} attempts`,
|
|
1357
|
+
data: {
|
|
1358
|
+
reactionKey,
|
|
1359
|
+
attempts: tracker.attempts,
|
|
1360
|
+
durationSinceFirstMs: durationMs,
|
|
1361
|
+
escalationCause,
|
|
1362
|
+
},
|
|
1363
|
+
});
|
|
1364
|
+
// Escalate to human
|
|
1365
|
+
const context = buildEventContext(session, prEnrichmentCache);
|
|
1366
|
+
const event = createEvent("reaction.escalated", {
|
|
1367
|
+
sessionId,
|
|
1368
|
+
projectId,
|
|
1369
|
+
message: `Reaction '${reactionKey}' escalated after ${tracker.attempts} attempts`,
|
|
1370
|
+
data: buildReactionEscalationNotificationData({
|
|
1371
|
+
eventType: "reaction.escalated",
|
|
1372
|
+
sessionId,
|
|
1373
|
+
projectId,
|
|
1374
|
+
context,
|
|
1375
|
+
reactionKey,
|
|
1376
|
+
action: "escalated",
|
|
1377
|
+
attempts: tracker.attempts,
|
|
1378
|
+
cause: escalationCause,
|
|
1379
|
+
durationMs,
|
|
1380
|
+
enrichment: getPREnrichmentForSession(session),
|
|
1381
|
+
}),
|
|
1382
|
+
});
|
|
1383
|
+
await notifyHuman(event, reactionConfig.priority ?? "urgent");
|
|
1384
|
+
// Mark as escalated — silences further dispatches until the underlying
|
|
1385
|
+
// condition resolves and clearReactionTracker() is called explicitly.
|
|
1386
|
+
tracker.escalated = true;
|
|
1387
|
+
return {
|
|
1388
|
+
reactionType: reactionKey,
|
|
1389
|
+
success: true,
|
|
1390
|
+
action: "escalated",
|
|
1391
|
+
escalated: true,
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
// Execute the reaction action
|
|
1395
|
+
const action = reactionConfig.action ?? "notify";
|
|
1396
|
+
switch (action) {
|
|
1397
|
+
case "send-to-agent": {
|
|
1398
|
+
if (reactionConfig.message) {
|
|
1399
|
+
try {
|
|
1400
|
+
await sessionManager.send(sessionId, reactionConfig.message);
|
|
1401
|
+
recordActivityEvent({
|
|
1402
|
+
projectId,
|
|
1403
|
+
sessionId,
|
|
1404
|
+
source: "reaction",
|
|
1405
|
+
kind: "reaction.action_succeeded",
|
|
1406
|
+
summary: `send-to-agent ${reactionKey}`,
|
|
1407
|
+
data: { reactionKey, action: "send-to-agent", attempts: tracker.attempts },
|
|
1408
|
+
});
|
|
1409
|
+
return {
|
|
1410
|
+
reactionType: reactionKey,
|
|
1411
|
+
success: true,
|
|
1412
|
+
action: "send-to-agent",
|
|
1413
|
+
message: reactionConfig.message,
|
|
1414
|
+
escalated: false,
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
catch (err) {
|
|
1418
|
+
// Send failed — allow retry on next poll cycle (don't escalate immediately)
|
|
1419
|
+
recordActivityEvent({
|
|
1420
|
+
projectId,
|
|
1421
|
+
sessionId,
|
|
1422
|
+
source: "reaction",
|
|
1423
|
+
kind: "reaction.send_to_agent_failed",
|
|
1424
|
+
level: "warn",
|
|
1425
|
+
summary: `send-to-agent failed for ${sessionId}`,
|
|
1426
|
+
data: {
|
|
1427
|
+
reactionKey,
|
|
1428
|
+
attempts: tracker.attempts,
|
|
1429
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
1430
|
+
},
|
|
1431
|
+
});
|
|
1432
|
+
return {
|
|
1433
|
+
reactionType: reactionKey,
|
|
1434
|
+
success: false,
|
|
1435
|
+
action: "send-to-agent",
|
|
1436
|
+
escalated: false,
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
break;
|
|
1441
|
+
}
|
|
1442
|
+
case "notify": {
|
|
1443
|
+
const context = buildEventContext(session, prEnrichmentCache);
|
|
1444
|
+
const event = createEvent("reaction.triggered", {
|
|
1445
|
+
sessionId,
|
|
1446
|
+
projectId,
|
|
1447
|
+
message: reactionConfig.message ?? `Reaction '${reactionKey}' triggered notification`,
|
|
1448
|
+
data: buildReactionNotificationData({
|
|
1449
|
+
eventType: "reaction.triggered",
|
|
1450
|
+
sessionId,
|
|
1451
|
+
projectId,
|
|
1452
|
+
context,
|
|
1453
|
+
reactionKey,
|
|
1454
|
+
action: "notify",
|
|
1455
|
+
enrichment: getPREnrichmentForSession(session),
|
|
1456
|
+
}),
|
|
1457
|
+
});
|
|
1458
|
+
await notifyHuman(event, reactionConfig.priority ?? "info");
|
|
1459
|
+
recordActivityEvent({
|
|
1460
|
+
projectId,
|
|
1461
|
+
sessionId,
|
|
1462
|
+
source: "reaction",
|
|
1463
|
+
kind: "reaction.action_succeeded",
|
|
1464
|
+
summary: `notify ${reactionKey}`,
|
|
1465
|
+
data: { reactionKey, action: "notify", attempts: tracker.attempts },
|
|
1466
|
+
});
|
|
1467
|
+
return {
|
|
1468
|
+
reactionType: reactionKey,
|
|
1469
|
+
success: true,
|
|
1470
|
+
action: "notify",
|
|
1471
|
+
escalated: false,
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
case "auto-merge": {
|
|
1475
|
+
// Auto-merge is handled by the SCM plugin
|
|
1476
|
+
// For now, just notify
|
|
1477
|
+
const context = buildEventContext(session, prEnrichmentCache);
|
|
1478
|
+
const event = createEvent("reaction.triggered", {
|
|
1479
|
+
sessionId,
|
|
1480
|
+
projectId,
|
|
1481
|
+
message: reactionConfig.message ?? `Reaction '${reactionKey}' triggered auto-merge`,
|
|
1482
|
+
data: buildReactionNotificationData({
|
|
1483
|
+
eventType: "reaction.triggered",
|
|
1484
|
+
sessionId,
|
|
1485
|
+
projectId,
|
|
1486
|
+
context,
|
|
1487
|
+
reactionKey,
|
|
1488
|
+
action: "auto-merge",
|
|
1489
|
+
enrichment: getPREnrichmentForSession(session),
|
|
1490
|
+
}),
|
|
1491
|
+
});
|
|
1492
|
+
await notifyHuman(event, "action");
|
|
1493
|
+
recordActivityEvent({
|
|
1494
|
+
projectId,
|
|
1495
|
+
sessionId,
|
|
1496
|
+
source: "reaction",
|
|
1497
|
+
kind: "reaction.action_succeeded",
|
|
1498
|
+
summary: `auto-merge ${reactionKey}`,
|
|
1499
|
+
data: { reactionKey, action: "auto-merge", attempts: tracker.attempts },
|
|
1500
|
+
});
|
|
1501
|
+
return {
|
|
1502
|
+
reactionType: reactionKey,
|
|
1503
|
+
success: true,
|
|
1504
|
+
action: "auto-merge",
|
|
1505
|
+
escalated: false,
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
return {
|
|
1510
|
+
reactionType: reactionKey,
|
|
1511
|
+
success: false,
|
|
1512
|
+
action,
|
|
1513
|
+
escalated: false,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
function clearReactionTracker(sessionId, reactionKey) {
|
|
1517
|
+
reactionTrackers.delete(`${sessionId}:${reactionKey}`);
|
|
1518
|
+
}
|
|
1519
|
+
function getReactionConfigForSession(session, reactionKey) {
|
|
1520
|
+
const project = config.projects[session.projectId];
|
|
1521
|
+
const globalReaction = config.reactions[reactionKey];
|
|
1522
|
+
const projectReaction = project?.reactions?.[reactionKey];
|
|
1523
|
+
const reactionConfig = projectReaction
|
|
1524
|
+
? { ...globalReaction, ...projectReaction }
|
|
1525
|
+
: globalReaction;
|
|
1526
|
+
return reactionConfig ? reactionConfig : null;
|
|
1527
|
+
}
|
|
1528
|
+
function updateSessionMetadata(session, updates) {
|
|
1529
|
+
const project = config.projects[session.projectId];
|
|
1530
|
+
if (!project)
|
|
1531
|
+
return;
|
|
1532
|
+
const sessionsDir = getProjectSessionsDir(session.projectId);
|
|
1533
|
+
const lifecycleUpdates = buildLifecycleMetadataPatch(cloneLifecycle(session.lifecycle));
|
|
1534
|
+
const mergedUpdates = { ...updates, ...lifecycleUpdates };
|
|
1535
|
+
updateMetadata(sessionsDir, session.id, mergedUpdates);
|
|
1536
|
+
sessionManager.invalidateCache();
|
|
1537
|
+
const cleaned = Object.fromEntries(Object.entries(session.metadata).filter(([key]) => {
|
|
1538
|
+
const update = mergedUpdates[key];
|
|
1539
|
+
return update === undefined || update !== "";
|
|
1540
|
+
}));
|
|
1541
|
+
for (const [key, value] of Object.entries(mergedUpdates)) {
|
|
1542
|
+
if (value === undefined || value === "")
|
|
1543
|
+
continue;
|
|
1544
|
+
cleaned[key] = value;
|
|
1545
|
+
}
|
|
1546
|
+
session.metadata = cleaned;
|
|
1547
|
+
session.status = deriveLegacyStatus(session.lifecycle);
|
|
1548
|
+
}
|
|
1549
|
+
function makeFingerprint(ids) {
|
|
1550
|
+
return [...ids].sort().join(",");
|
|
1551
|
+
}
|
|
1552
|
+
async function maybeDispatchReviewBacklog(session, _oldStatus, newStatus, transitionReaction) {
|
|
1553
|
+
const project = config.projects[session.projectId];
|
|
1554
|
+
if (!project || !session.pr)
|
|
1555
|
+
return;
|
|
1556
|
+
const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
|
|
1557
|
+
if (!scm)
|
|
1558
|
+
return;
|
|
1559
|
+
const humanReactionKey = "changes-requested";
|
|
1560
|
+
const automatedReactionKey = "bugbot-comments";
|
|
1561
|
+
if (TERMINAL_STATUSES.has(newStatus) || session.lifecycle.pr.state !== "open") {
|
|
1562
|
+
clearReactionTracker(session.id, humanReactionKey);
|
|
1563
|
+
clearReactionTracker(session.id, automatedReactionKey);
|
|
1564
|
+
lastReviewBacklogCheckAt.delete(session.id);
|
|
1565
|
+
updateSessionMetadata(session, {
|
|
1566
|
+
lastPendingReviewFingerprint: "",
|
|
1567
|
+
lastPendingReviewDispatchHash: "",
|
|
1568
|
+
lastPendingReviewDispatchAt: "",
|
|
1569
|
+
lastAutomatedReviewFingerprint: "",
|
|
1570
|
+
lastAutomatedReviewDispatchHash: "",
|
|
1571
|
+
lastAutomatedReviewDispatchAt: "",
|
|
1572
|
+
});
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
// Throttle review backlog API calls to at most once per 2 minutes.
|
|
1576
|
+
// Comments don't change faster than this in practice, and the SCM calls
|
|
1577
|
+
// (getReviewThreads) consumes API quota on every poll.
|
|
1578
|
+
//
|
|
1579
|
+
// Exception: bypass throttle when a transition reaction just fired for a
|
|
1580
|
+
// review reaction key. The enriched dispatch needs the current fingerprint
|
|
1581
|
+
// from the API so it can fire and record the hash in the same cycle. If we
|
|
1582
|
+
// throttle here, the next unthrottled poll sees a "new" fingerprint, clears
|
|
1583
|
+
// the reaction tracker, and fires a duplicate dispatch.
|
|
1584
|
+
const hasRelevantTransition = transitionReaction?.key === humanReactionKey ||
|
|
1585
|
+
transitionReaction?.key === automatedReactionKey;
|
|
1586
|
+
if (!hasRelevantTransition) {
|
|
1587
|
+
const lastCheckAt = lastReviewBacklogCheckAt.get(session.id) ?? 0;
|
|
1588
|
+
if (Date.now() - lastCheckAt < REVIEW_BACKLOG_THROTTLE_MS) {
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
// Single GraphQL call for all review threads (human + bot) + review summaries.
|
|
1593
|
+
// Split locally by isBot for separate reaction pipelines.
|
|
1594
|
+
let allThreads;
|
|
1595
|
+
let reviewSummaries = [];
|
|
1596
|
+
try {
|
|
1597
|
+
if (scm.getReviewThreads) {
|
|
1598
|
+
const result = await scm.getReviewThreads(session.pr);
|
|
1599
|
+
allThreads = result.threads;
|
|
1600
|
+
reviewSummaries = result.reviews;
|
|
1601
|
+
}
|
|
1602
|
+
else {
|
|
1603
|
+
// Fallback for SCM plugins that don't implement getReviewThreads yet
|
|
1604
|
+
allThreads = await scm.getPendingComments(session.pr);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
catch (err) {
|
|
1608
|
+
// Failed to fetch — preserve existing metadata; record AE evidence so
|
|
1609
|
+
// RCA can answer "why aren't review comments being dispatched?"
|
|
1610
|
+
recordActivityEvent({
|
|
1611
|
+
projectId: session.projectId,
|
|
1612
|
+
sessionId: session.id,
|
|
1613
|
+
source: "scm",
|
|
1614
|
+
kind: "scm.review_fetch_failed",
|
|
1615
|
+
level: "warn",
|
|
1616
|
+
summary: `review fetch failed for PR #${session.pr.number}`,
|
|
1617
|
+
data: {
|
|
1618
|
+
plugin: project.scm?.plugin,
|
|
1619
|
+
prNumber: session.pr.number,
|
|
1620
|
+
prUrl: session.pr.url,
|
|
1621
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
1622
|
+
},
|
|
1623
|
+
});
|
|
1624
|
+
// Don't update the throttle timestamp so the next poll retries immediately
|
|
1625
|
+
// instead of being blocked for 2 minutes with the agent left on a bare notification.
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
// Only stamp the throttle after a successful SCM fetch. If the fetch failed,
|
|
1629
|
+
// we returned above so the next poll can retry without waiting 2 minutes.
|
|
1630
|
+
lastReviewBacklogCheckAt.set(session.id, Date.now());
|
|
1631
|
+
// Persist review comments + summaries to metadata for dashboard consumption
|
|
1632
|
+
{
|
|
1633
|
+
const unresolved = allThreads.filter((c) => !c.isBot);
|
|
1634
|
+
const reviewBlob = JSON.stringify({
|
|
1635
|
+
unresolvedThreads: unresolved.length,
|
|
1636
|
+
unresolvedComments: unresolved.map((c) => ({
|
|
1637
|
+
url: c.url,
|
|
1638
|
+
path: c.path ?? "",
|
|
1639
|
+
author: c.author,
|
|
1640
|
+
body: c.body,
|
|
1641
|
+
})),
|
|
1642
|
+
reviews: reviewSummaries.map((r) => ({
|
|
1643
|
+
author: r.author,
|
|
1644
|
+
state: r.state,
|
|
1645
|
+
body: r.body,
|
|
1646
|
+
})),
|
|
1647
|
+
commentsUpdatedAt: new Date().toISOString(),
|
|
1648
|
+
});
|
|
1649
|
+
if (session.metadata["prReviewComments"] !== reviewBlob) {
|
|
1650
|
+
updateSessionMetadata(session, { prReviewComments: reviewBlob });
|
|
1651
|
+
}
|
|
1652
|
+
// Persist per-PR review comment blobs for secondary PRs so the dashboard
|
|
1653
|
+
// can enrich them independently (prReviewComments_1, prReviewComments_2, …).
|
|
1654
|
+
const sessionPRs = normalizeSessionPRs(session);
|
|
1655
|
+
const cleanupUpdates = indexedPRMetadataCleanup(session, sessionPRs.length);
|
|
1656
|
+
if (Object.keys(cleanupUpdates).length > 0) {
|
|
1657
|
+
updateSessionMetadata(session, cleanupUpdates);
|
|
1658
|
+
}
|
|
1659
|
+
for (let i = 1; i < sessionPRs.length; i++) {
|
|
1660
|
+
const secondaryPR = sessionPRs[i];
|
|
1661
|
+
if (!secondaryPR)
|
|
1662
|
+
continue;
|
|
1663
|
+
let secondaryThreads;
|
|
1664
|
+
let secondaryReviews;
|
|
1665
|
+
try {
|
|
1666
|
+
if (scm.getReviewThreads) {
|
|
1667
|
+
const result = await scm.getReviewThreads(secondaryPR);
|
|
1668
|
+
secondaryThreads = result.threads;
|
|
1669
|
+
secondaryReviews = result.reviews;
|
|
1670
|
+
}
|
|
1671
|
+
else {
|
|
1672
|
+
secondaryThreads = await scm.getPendingComments(secondaryPR);
|
|
1673
|
+
secondaryReviews = [];
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
catch {
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
const secondaryUnresolved = secondaryThreads.filter((c) => !c.isBot);
|
|
1680
|
+
const secondaryBlob = JSON.stringify({
|
|
1681
|
+
unresolvedThreads: secondaryUnresolved.length,
|
|
1682
|
+
unresolvedComments: secondaryUnresolved.map((c) => ({
|
|
1683
|
+
url: c.url,
|
|
1684
|
+
path: c.path ?? "",
|
|
1685
|
+
author: c.author,
|
|
1686
|
+
body: c.body,
|
|
1687
|
+
})),
|
|
1688
|
+
reviews: secondaryReviews.map((r) => ({
|
|
1689
|
+
author: r.author,
|
|
1690
|
+
state: r.state,
|
|
1691
|
+
body: r.body,
|
|
1692
|
+
})),
|
|
1693
|
+
commentsUpdatedAt: new Date().toISOString(),
|
|
1694
|
+
});
|
|
1695
|
+
const reviewMetaKey = `prReviewComments_${i}`;
|
|
1696
|
+
if (session.metadata[reviewMetaKey] !== secondaryBlob) {
|
|
1697
|
+
updateSessionMetadata(session, { [reviewMetaKey]: secondaryBlob });
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
const pendingComments = allThreads.filter((c) => !c.isBot);
|
|
1702
|
+
const automatedComments = allThreads.filter((c) => c.isBot);
|
|
1703
|
+
// --- Pending (human) review comments ---
|
|
1704
|
+
{
|
|
1705
|
+
const pendingFingerprint = makeFingerprint(pendingComments.map((comment) => comment.id));
|
|
1706
|
+
const lastPendingFingerprint = session.metadata["lastPendingReviewFingerprint"] ?? "";
|
|
1707
|
+
const lastPendingDispatchHash = session.metadata["lastPendingReviewDispatchHash"] ?? "";
|
|
1708
|
+
if (pendingFingerprint !== lastPendingFingerprint &&
|
|
1709
|
+
transitionReaction?.key !== humanReactionKey) {
|
|
1710
|
+
clearReactionTracker(session.id, humanReactionKey);
|
|
1711
|
+
}
|
|
1712
|
+
if (pendingFingerprint !== lastPendingFingerprint) {
|
|
1713
|
+
updateSessionMetadata(session, {
|
|
1714
|
+
lastPendingReviewFingerprint: pendingFingerprint,
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
if (!pendingFingerprint) {
|
|
1718
|
+
clearReactionTracker(session.id, humanReactionKey);
|
|
1719
|
+
updateSessionMetadata(session, {
|
|
1720
|
+
lastPendingReviewFingerprint: "",
|
|
1721
|
+
lastPendingReviewDispatchHash: "",
|
|
1722
|
+
lastPendingReviewDispatchAt: "",
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
else if (pendingFingerprint !== lastPendingDispatchHash) {
|
|
1726
|
+
const reactionConfig = getReactionConfigForSession(session, humanReactionKey);
|
|
1727
|
+
if (reactionConfig &&
|
|
1728
|
+
reactionConfig.action &&
|
|
1729
|
+
(reactionConfig.auto !== false || reactionConfig.action === "notify")) {
|
|
1730
|
+
const enrichedMessage = formatReviewCommentsMessage(pendingComments, "reviewer", reviewSummaries);
|
|
1731
|
+
// When the transition handler already called executeReaction for this
|
|
1732
|
+
// key, send the enriched payload directly to avoid double-billing the
|
|
1733
|
+
// reaction attempt budget. A project with retries:1 would otherwise
|
|
1734
|
+
// escalate on the very first transition poll.
|
|
1735
|
+
// Only bypass for "send-to-agent" — "notify" actions must go through
|
|
1736
|
+
// executeReaction so they route to notifyHuman instead of the agent.
|
|
1737
|
+
let success = false;
|
|
1738
|
+
if (transitionReaction?.key === humanReactionKey &&
|
|
1739
|
+
reactionConfig.action === "send-to-agent") {
|
|
1740
|
+
try {
|
|
1741
|
+
await sessionManager.send(session.id, enrichedMessage);
|
|
1742
|
+
success = true;
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
// Send failed — will retry on next unthrottled poll
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
else {
|
|
1749
|
+
const enrichedConfig = { ...reactionConfig, message: enrichedMessage };
|
|
1750
|
+
const result = await executeReaction(session, humanReactionKey, enrichedConfig);
|
|
1751
|
+
success = result.success;
|
|
1752
|
+
}
|
|
1753
|
+
if (success) {
|
|
1754
|
+
updateSessionMetadata(session, {
|
|
1755
|
+
lastPendingReviewDispatchHash: pendingFingerprint,
|
|
1756
|
+
lastPendingReviewDispatchAt: new Date().toISOString(),
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
// --- Automated (bot) review comments ---
|
|
1763
|
+
{
|
|
1764
|
+
const automatedFingerprint = makeFingerprint(automatedComments.map((comment) => comment.id));
|
|
1765
|
+
const lastAutomatedFingerprint = session.metadata["lastAutomatedReviewFingerprint"] ?? "";
|
|
1766
|
+
const lastAutomatedDispatchHash = session.metadata["lastAutomatedReviewDispatchHash"] ?? "";
|
|
1767
|
+
if (automatedFingerprint !== lastAutomatedFingerprint) {
|
|
1768
|
+
clearReactionTracker(session.id, automatedReactionKey);
|
|
1769
|
+
updateSessionMetadata(session, {
|
|
1770
|
+
lastAutomatedReviewFingerprint: automatedFingerprint,
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
if (!automatedFingerprint) {
|
|
1774
|
+
clearReactionTracker(session.id, automatedReactionKey);
|
|
1775
|
+
updateSessionMetadata(session, {
|
|
1776
|
+
lastAutomatedReviewFingerprint: "",
|
|
1777
|
+
lastAutomatedReviewDispatchHash: "",
|
|
1778
|
+
lastAutomatedReviewDispatchAt: "",
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
else if (automatedFingerprint !== lastAutomatedDispatchHash) {
|
|
1782
|
+
const reactionConfig = getReactionConfigForSession(session, automatedReactionKey);
|
|
1783
|
+
if (reactionConfig &&
|
|
1784
|
+
reactionConfig.action &&
|
|
1785
|
+
(reactionConfig.auto !== false || reactionConfig.action === "notify")) {
|
|
1786
|
+
const enrichedMessage = formatReviewCommentsMessage(automatedComments, "bot");
|
|
1787
|
+
let success = false;
|
|
1788
|
+
if (transitionReaction?.key === automatedReactionKey &&
|
|
1789
|
+
reactionConfig.action === "send-to-agent") {
|
|
1790
|
+
try {
|
|
1791
|
+
await sessionManager.send(session.id, enrichedMessage);
|
|
1792
|
+
success = true;
|
|
1793
|
+
}
|
|
1794
|
+
catch {
|
|
1795
|
+
// Send failed — will retry on next unthrottled poll
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
else {
|
|
1799
|
+
const enrichedConfig = { ...reactionConfig, message: enrichedMessage };
|
|
1800
|
+
const result = await executeReaction(session, automatedReactionKey, enrichedConfig);
|
|
1801
|
+
success = result.success;
|
|
1802
|
+
}
|
|
1803
|
+
if (success) {
|
|
1804
|
+
updateSessionMetadata(session, {
|
|
1805
|
+
lastAutomatedReviewDispatchHash: automatedFingerprint,
|
|
1806
|
+
lastAutomatedReviewDispatchAt: new Date().toISOString(),
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Format review comments into a message with inline data for the agent.
|
|
1815
|
+
* Includes file, line, author, body, and URL so the agent doesn't need
|
|
1816
|
+
* to re-fetch via gh api.
|
|
1817
|
+
*/
|
|
1818
|
+
function formatReviewCommentsMessage(comments, source, reviews = []) {
|
|
1819
|
+
const lines = [];
|
|
1820
|
+
// Prepend review summaries (the body submitted with "Changes requested" / "Approve")
|
|
1821
|
+
const nonEmptyReviews = reviews.filter((r) => r.body && r.body.trim().length > 0);
|
|
1822
|
+
if (nonEmptyReviews.length > 0) {
|
|
1823
|
+
for (const r of nonEmptyReviews) {
|
|
1824
|
+
lines.push(`Review by @${r.author} (${r.state}):`);
|
|
1825
|
+
lines.push(`"${r.body.trim()}"`, "");
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
const header = source === "reviewer"
|
|
1829
|
+
? `The following ${comments.length} unresolved review comment(s) are on your PR (as of just now). You should not need to re-fetch this data unless you need additional context.`
|
|
1830
|
+
: `The following ${comments.length} automated review comment(s) are on your PR (as of just now). You should not need to re-fetch this data unless you need additional context.`;
|
|
1831
|
+
lines.push(header, "");
|
|
1832
|
+
for (let i = 0; i < comments.length; i++) {
|
|
1833
|
+
const c = comments[i];
|
|
1834
|
+
const location = c.path ? `${c.path}${c.line ? `:${c.line}` : ""}` : "(general)";
|
|
1835
|
+
lines.push(`${i + 1}. ${location} (@${c.author}): "${c.body}"`);
|
|
1836
|
+
if (c.url)
|
|
1837
|
+
lines.push(` ${c.url}`);
|
|
1838
|
+
if (c.threadId)
|
|
1839
|
+
lines.push(` Thread ID: ${c.threadId}`);
|
|
1840
|
+
}
|
|
1841
|
+
lines.push("", "Address each comment, push fixes. Use the thread ID to resolve each thread directly after pushing. You should not need to re-fetch review data unless you need additional context beyond what is provided here.");
|
|
1842
|
+
return lines.join("\n");
|
|
1843
|
+
}
|
|
1844
|
+
function isFailedCICheck(check) {
|
|
1845
|
+
return check.status === "failed" || check.conclusion?.toUpperCase() === "FAILURE";
|
|
1846
|
+
}
|
|
1847
|
+
function formatCIFailureSummaryMessage(summary) {
|
|
1848
|
+
const lines = ["CI is failing on your PR.", ""];
|
|
1849
|
+
for (const job of summary.failedJobs) {
|
|
1850
|
+
const failed = job.failedStep ? `${job.name} → ${job.failedStep}` : job.name;
|
|
1851
|
+
lines.push(`Failed: ${failed}`);
|
|
1852
|
+
lines.push(`Failure URL: ${job.runUrl}`);
|
|
1853
|
+
if (job.logTail) {
|
|
1854
|
+
const lineCount = job.logTail.split(/\r?\n/).length;
|
|
1855
|
+
const lineLabel = lineCount === 1 ? "line" : "lines";
|
|
1856
|
+
const escapedTail = escapeMarkdownCodeFenceClosers(job.logTail);
|
|
1857
|
+
lines.push("", `Log tail (last ${lineCount} ${lineLabel}):`, "```", escapedTail, "```");
|
|
1858
|
+
}
|
|
1859
|
+
lines.push("");
|
|
1860
|
+
}
|
|
1861
|
+
lines.push("Fix the issues and push again.");
|
|
1862
|
+
return lines.join("\n");
|
|
1863
|
+
}
|
|
1864
|
+
function escapeMarkdownCodeFenceClosers(logTail) {
|
|
1865
|
+
return logTail
|
|
1866
|
+
.split(/\r?\n/)
|
|
1867
|
+
.map((line) => (line.startsWith("```") ? `\u200B${line}` : line))
|
|
1868
|
+
.join("\n");
|
|
1869
|
+
}
|
|
1870
|
+
function formatCIFailureChecksFallback(failedChecks) {
|
|
1871
|
+
const lines = ["CI checks are failing on your PR. Here are the failed checks:", ""];
|
|
1872
|
+
for (const check of failedChecks) {
|
|
1873
|
+
const status = check.conclusion ?? check.status;
|
|
1874
|
+
const link = check.url ? ` — ${check.url}` : "";
|
|
1875
|
+
lines.push(`- **${check.name}**: ${status}${link}`);
|
|
1876
|
+
}
|
|
1877
|
+
lines.push("", "Investigate the failures, fix the issues, and push again.");
|
|
1878
|
+
return lines.join("\n");
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Format CI failures into a human-readable message for the agent.
|
|
1882
|
+
* Uses SCM-provided failed job/step/log details when available and falls
|
|
1883
|
+
* back to check names/statuses/links for SCM plugins that do not implement it.
|
|
1884
|
+
*/
|
|
1885
|
+
async function formatCIFailureMessage(scm, pr, failedChecks) {
|
|
1886
|
+
if (scm.getCIFailureSummary) {
|
|
1887
|
+
try {
|
|
1888
|
+
const summary = await scm.getCIFailureSummary(pr, failedChecks);
|
|
1889
|
+
if (summary?.failedJobs.length) {
|
|
1890
|
+
return formatCIFailureSummaryMessage(summary);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
catch {
|
|
1894
|
+
// Fall back to check names when summary enrichment fails.
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
return formatCIFailureChecksFallback(failedChecks);
|
|
1898
|
+
}
|
|
1899
|
+
async function getFailedCIChecks(scm, pr, options) {
|
|
1900
|
+
const prKey = `${pr.owner}/${pr.repo}#${pr.number}`;
|
|
1901
|
+
const cachedEnrichment = prEnrichmentCache.get(prKey);
|
|
1902
|
+
let checks = cachedEnrichment?.ciChecks;
|
|
1903
|
+
if (checks === undefined && options.allowFetch) {
|
|
1904
|
+
try {
|
|
1905
|
+
checks = await scm.getCIChecks(pr);
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
const failedChecks = checks?.filter(isFailedCICheck) ?? [];
|
|
1912
|
+
return failedChecks.length > 0 ? failedChecks : null;
|
|
1913
|
+
}
|
|
1914
|
+
function makeCIFailureFingerprint(failedChecks) {
|
|
1915
|
+
return makeFingerprint(failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`));
|
|
1916
|
+
}
|
|
1917
|
+
async function maybeDispatchCIFailureDetails(session, _oldStatus, newStatus, transitionReaction) {
|
|
1918
|
+
const project = config.projects[session.projectId];
|
|
1919
|
+
if (!project || !session.pr)
|
|
1920
|
+
return;
|
|
1921
|
+
const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
|
|
1922
|
+
if (!scm)
|
|
1923
|
+
return;
|
|
1924
|
+
const ciReactionKey = "ci-failed";
|
|
1925
|
+
// Clear tracking when PR is closed/merged
|
|
1926
|
+
if (newStatus === "merged" || newStatus === "killed") {
|
|
1927
|
+
clearReactionTracker(session.id, ciReactionKey);
|
|
1928
|
+
updateSessionMetadata(session, {
|
|
1929
|
+
lastCIFailureFingerprint: "",
|
|
1930
|
+
lastCIFailureDispatchHash: "",
|
|
1931
|
+
lastCIFailureDispatchAt: "",
|
|
1932
|
+
});
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
// Only dispatch CI details when in ci_failed state
|
|
1936
|
+
if (newStatus !== "ci_failed") {
|
|
1937
|
+
// CI is no longer failing — clear tracking so next failure is dispatched fresh
|
|
1938
|
+
const lastFingerprint = session.metadata["lastCIFailureFingerprint"] ?? "";
|
|
1939
|
+
if (lastFingerprint) {
|
|
1940
|
+
clearReactionTracker(session.id, ciReactionKey);
|
|
1941
|
+
updateSessionMetadata(session, {
|
|
1942
|
+
lastCIFailureFingerprint: "",
|
|
1943
|
+
lastCIFailureDispatchHash: "",
|
|
1944
|
+
lastCIFailureDispatchAt: "",
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const failedChecks = await getFailedCIChecks(scm, session.pr, { allowFetch: true });
|
|
1950
|
+
if (!failedChecks)
|
|
1951
|
+
return;
|
|
1952
|
+
const ciFingerprint = makeCIFailureFingerprint(failedChecks);
|
|
1953
|
+
const lastCIFingerprint = session.metadata["lastCIFailureFingerprint"] ?? "";
|
|
1954
|
+
const lastCIDispatchHash = session.metadata["lastCIFailureDispatchHash"] ?? "";
|
|
1955
|
+
// Reset reaction tracker when failure set changes
|
|
1956
|
+
if (ciFingerprint !== lastCIFingerprint && transitionReaction?.key !== ciReactionKey) {
|
|
1957
|
+
clearReactionTracker(session.id, ciReactionKey);
|
|
1958
|
+
}
|
|
1959
|
+
if (ciFingerprint !== lastCIFingerprint) {
|
|
1960
|
+
updateSessionMetadata(session, {
|
|
1961
|
+
lastCIFailureFingerprint: ciFingerprint,
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
// If the transition reaction already delivered an enriched agent message,
|
|
1965
|
+
// or handled a non-agent action, record the dispatch hash so subsequent
|
|
1966
|
+
// polls don't re-send the same failure details.
|
|
1967
|
+
if (transitionReaction?.key === ciReactionKey &&
|
|
1968
|
+
transitionReaction.result?.success &&
|
|
1969
|
+
(transitionReaction.messageEnriched === true ||
|
|
1970
|
+
transitionReaction.result.action !== "send-to-agent")) {
|
|
1971
|
+
updateSessionMetadata(session, {
|
|
1972
|
+
lastCIFailureDispatchHash: ciFingerprint,
|
|
1973
|
+
lastCIFailureDispatchAt: new Date().toISOString(),
|
|
1974
|
+
});
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
// Skip if we already dispatched this exact failure set
|
|
1978
|
+
if (ciFingerprint === lastCIDispatchHash)
|
|
1979
|
+
return;
|
|
1980
|
+
// Dispatch CI failure details directly via sessionManager.send() rather than
|
|
1981
|
+
// executeReaction() to avoid consuming the ci-failed reaction's retry budget.
|
|
1982
|
+
// The transition reaction owns escalation; this is a follow-up info delivery.
|
|
1983
|
+
const reactionConfig = getReactionConfigForSession(session, ciReactionKey);
|
|
1984
|
+
if (reactionConfig &&
|
|
1985
|
+
reactionConfig.action &&
|
|
1986
|
+
(reactionConfig.auto !== false || reactionConfig.action === "notify")) {
|
|
1987
|
+
const detailedMessage = await formatCIFailureMessage(scm, session.pr, failedChecks);
|
|
1988
|
+
try {
|
|
1989
|
+
if (reactionConfig.action === "send-to-agent") {
|
|
1990
|
+
await sessionManager.send(session.id, detailedMessage);
|
|
1991
|
+
}
|
|
1992
|
+
else {
|
|
1993
|
+
// For "notify" action, send to human notifiers instead
|
|
1994
|
+
const context = buildEventContext(session, prEnrichmentCache);
|
|
1995
|
+
const event = createEvent("ci.failing", {
|
|
1996
|
+
sessionId: session.id,
|
|
1997
|
+
projectId: session.projectId,
|
|
1998
|
+
message: detailedMessage,
|
|
1999
|
+
data: buildCIFailureNotificationData({
|
|
2000
|
+
sessionId: session.id,
|
|
2001
|
+
projectId: session.projectId,
|
|
2002
|
+
context,
|
|
2003
|
+
failedChecks,
|
|
2004
|
+
}),
|
|
2005
|
+
});
|
|
2006
|
+
await notifyHuman(event, reactionConfig.priority ?? "warning");
|
|
2007
|
+
}
|
|
2008
|
+
updateSessionMetadata(session, {
|
|
2009
|
+
lastCIFailureDispatchHash: ciFingerprint,
|
|
2010
|
+
lastCIFailureDispatchAt: new Date().toISOString(),
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
catch {
|
|
2014
|
+
// Send failed — will retry on next poll cycle
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Dispatch merge conflict notifications to the agent session.
|
|
2020
|
+
* Conflicts are detected from the PR enrichment cache or getMergeability()
|
|
2021
|
+
* and dispatched independently of the session status (conflicts can coexist
|
|
2022
|
+
* with ci_failed, changes_requested, etc.).
|
|
2023
|
+
*/
|
|
2024
|
+
async function maybeDispatchMergeConflicts(session, newStatus) {
|
|
2025
|
+
const project = config.projects[session.projectId];
|
|
2026
|
+
if (!project || !session.pr)
|
|
2027
|
+
return;
|
|
2028
|
+
const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
|
|
2029
|
+
if (!scm)
|
|
2030
|
+
return;
|
|
2031
|
+
const conflictReactionKey = "merge-conflicts";
|
|
2032
|
+
// Clear tracking when PR is no longer open.
|
|
2033
|
+
if (session.lifecycle.pr.state !== "open" || newStatus === "killed") {
|
|
2034
|
+
clearReactionTracker(session.id, conflictReactionKey);
|
|
2035
|
+
updateSessionMetadata(session, {
|
|
2036
|
+
lastMergeConflictDispatched: "",
|
|
2037
|
+
});
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
// Only check for conflicts on open PRs
|
|
2041
|
+
if (newStatus !== "pr_open" &&
|
|
2042
|
+
newStatus !== "ci_failed" &&
|
|
2043
|
+
newStatus !== "review_pending" &&
|
|
2044
|
+
newStatus !== "changes_requested" &&
|
|
2045
|
+
newStatus !== "approved" &&
|
|
2046
|
+
newStatus !== "mergeable") {
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
// Check for conflicts using cached enrichment data or fallback to individual call.
|
|
2050
|
+
// When batch enrichment ran (cachedData is present), use its hasConflicts value
|
|
2051
|
+
// to avoid 3 redundant REST calls from getMergeability() — the batch already
|
|
2052
|
+
// fetched the mergeable/mergeStateStatus fields via GraphQL.
|
|
2053
|
+
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
|
2054
|
+
const cachedData = prEnrichmentCache.get(prKey);
|
|
2055
|
+
if (!cachedData) {
|
|
2056
|
+
// No batch data — skip this cycle, batch will populate on next cycle (30s)
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
const hasConflicts = cachedData.hasConflicts ?? false;
|
|
2060
|
+
const lastDispatched = session.metadata["lastMergeConflictDispatched"] ?? "";
|
|
2061
|
+
if (hasConflicts) {
|
|
2062
|
+
// Already dispatched for current conflict state — skip
|
|
2063
|
+
if (lastDispatched === "true")
|
|
2064
|
+
return;
|
|
2065
|
+
const reactionConfig = getReactionConfigForSession(session, conflictReactionKey);
|
|
2066
|
+
if (reactionConfig &&
|
|
2067
|
+
reactionConfig.action &&
|
|
2068
|
+
(reactionConfig.auto !== false || reactionConfig.action === "notify")) {
|
|
2069
|
+
try {
|
|
2070
|
+
// Build enriched config with dynamic base branch message.
|
|
2071
|
+
// Preserve "warning" priority from old direct-dispatch code unless
|
|
2072
|
+
// the user explicitly set a different priority in their config.
|
|
2073
|
+
const enrichedConfig = {
|
|
2074
|
+
...reactionConfig,
|
|
2075
|
+
priority: reactionConfig.priority ?? "warning",
|
|
2076
|
+
};
|
|
2077
|
+
if (reactionConfig.action === "send-to-agent" && !reactionConfig.message) {
|
|
2078
|
+
const baseBranch = session.pr.baseBranch ?? "the default branch";
|
|
2079
|
+
const behindNote = cachedData.isBehind ? ` is behind ${baseBranch} and` : "";
|
|
2080
|
+
enrichedConfig.message = `Your PR branch${behindNote} has merge conflicts with ${baseBranch}. Rebase your branch on ${baseBranch}, resolve the conflicts, and push. You should not need to call gh for merge status unless you need additional context — this information is current.`;
|
|
2081
|
+
}
|
|
2082
|
+
const result = await executeReaction(session, conflictReactionKey, enrichedConfig);
|
|
2083
|
+
// Only set dedup flag for non-escalated success — escalation hands off
|
|
2084
|
+
// to the human, so we must NOT suppress future agent dispatches if the
|
|
2085
|
+
// condition recurs after the tracker resets.
|
|
2086
|
+
if (result.success && result.action !== "escalated") {
|
|
2087
|
+
updateSessionMetadata(session, {
|
|
2088
|
+
lastMergeConflictDispatched: "true",
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
catch {
|
|
2093
|
+
// Dispatch failed — will retry on next poll cycle
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
else if (lastDispatched === "true") {
|
|
2098
|
+
// Conflicts resolved — clear dedup flag and reaction tracker so future
|
|
2099
|
+
// conflicts start a fresh incident with a fresh escalation budget.
|
|
2100
|
+
updateSessionMetadata(session, {
|
|
2101
|
+
lastMergeConflictDispatched: "",
|
|
2102
|
+
});
|
|
2103
|
+
clearReactionTracker(session.id, conflictReactionKey);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
/** Send a notification to all configured notifiers. */
|
|
2107
|
+
async function notifyHuman(event, priority) {
|
|
2108
|
+
const eventWithPriority = { ...event, priority };
|
|
2109
|
+
const notifierNames = config.notificationRouting[priority] ?? config.defaults.notifiers;
|
|
2110
|
+
for (const name of notifierNames) {
|
|
2111
|
+
const target = resolveNotifierTarget(config, name);
|
|
2112
|
+
const notifier = registry.get("notifier", target.reference) ??
|
|
2113
|
+
registry.get("notifier", target.pluginName);
|
|
2114
|
+
if (!notifier) {
|
|
2115
|
+
recordNotificationDelivery({
|
|
2116
|
+
observer,
|
|
2117
|
+
event: eventWithPriority,
|
|
2118
|
+
target,
|
|
2119
|
+
outcome: "failure",
|
|
2120
|
+
method: "notify",
|
|
2121
|
+
reason: "notifier target not found",
|
|
2122
|
+
failureKind: "target_missing",
|
|
2123
|
+
recordActivityEvent: true,
|
|
2124
|
+
});
|
|
2125
|
+
continue;
|
|
2126
|
+
}
|
|
2127
|
+
try {
|
|
2128
|
+
await notifier.notify(eventWithPriority);
|
|
2129
|
+
recordNotificationDelivery({
|
|
2130
|
+
observer,
|
|
2131
|
+
event: eventWithPriority,
|
|
2132
|
+
target,
|
|
2133
|
+
outcome: "success",
|
|
2134
|
+
method: "notify",
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
catch (err) {
|
|
2138
|
+
recordNotificationDelivery({
|
|
2139
|
+
observer,
|
|
2140
|
+
event: eventWithPriority,
|
|
2141
|
+
target,
|
|
2142
|
+
outcome: "failure",
|
|
2143
|
+
method: "notify",
|
|
2144
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
2145
|
+
failureKind: "delivery_failed",
|
|
2146
|
+
recordActivityEvent: true,
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* When a session's PR is merged, tear down its tmux runtime, remove its
|
|
2153
|
+
* worktree, and archive its metadata. Guarded by an idleness check so we
|
|
2154
|
+
* don't kill an agent mid-task; deferred cases set `mergedPendingCleanupSince`
|
|
2155
|
+
* in metadata and retry on subsequent polls until the agent idles or the
|
|
2156
|
+
* grace window elapses.
|
|
2157
|
+
*/
|
|
2158
|
+
async function maybeAutoCleanupOnMerge(session) {
|
|
2159
|
+
if (session.status !== SESSION_STATUS.MERGED)
|
|
2160
|
+
return;
|
|
2161
|
+
// config.lifecycle is typed optional to support hand-constructed
|
|
2162
|
+
// configs in tests. When loaded from YAML via Zod, the schema's
|
|
2163
|
+
// .default({}) always populates it. The destructure below handles
|
|
2164
|
+
// both paths uniformly.
|
|
2165
|
+
const { autoCleanupOnMerge = true, mergeCleanupIdleGraceMs: graceMs = 300_000 } = config.lifecycle ?? {};
|
|
2166
|
+
if (!autoCleanupOnMerge)
|
|
2167
|
+
return;
|
|
2168
|
+
// Check for idleness: if the agent is still working, defer cleanup.
|
|
2169
|
+
const nowIso = new Date().toISOString();
|
|
2170
|
+
const pendingSince = session.metadata["mergedPendingCleanupSince"] || nowIso;
|
|
2171
|
+
const pendingSinceMs = Date.parse(pendingSince);
|
|
2172
|
+
const graceElapsed = Number.isFinite(pendingSinceMs)
|
|
2173
|
+
? Date.now() - pendingSinceMs >= graceMs
|
|
2174
|
+
: false;
|
|
2175
|
+
const activity = session.activity;
|
|
2176
|
+
const agentIsBusy = activity === ACTIVITY_STATE.ACTIVE ||
|
|
2177
|
+
activity === ACTIVITY_STATE.WAITING_INPUT ||
|
|
2178
|
+
activity === ACTIVITY_STATE.BLOCKED;
|
|
2179
|
+
if (agentIsBusy && !graceElapsed) {
|
|
2180
|
+
if (!session.metadata["mergedPendingCleanupSince"]) {
|
|
2181
|
+
updateSessionMetadata(session, { mergedPendingCleanupSince: nowIso });
|
|
2182
|
+
}
|
|
2183
|
+
observer.recordOperation({
|
|
2184
|
+
metric: "lifecycle_poll",
|
|
2185
|
+
operation: "lifecycle.merge_cleanup.deferred",
|
|
2186
|
+
outcome: "success",
|
|
2187
|
+
correlationId: createCorrelationId("lifecycle-merge-cleanup"),
|
|
2188
|
+
projectId: session.projectId,
|
|
2189
|
+
sessionId: session.id,
|
|
2190
|
+
reason: primaryLifecycleReason(session.lifecycle),
|
|
2191
|
+
data: { activity, pendingSince, graceMs },
|
|
2192
|
+
level: "info",
|
|
2193
|
+
});
|
|
2194
|
+
recordActivityEvent({
|
|
2195
|
+
projectId: session.projectId,
|
|
2196
|
+
sessionId: session.id,
|
|
2197
|
+
source: "lifecycle",
|
|
2198
|
+
kind: "session.auto_cleanup_deferred",
|
|
2199
|
+
summary: `auto-cleanup deferred for ${session.id}`,
|
|
2200
|
+
data: {
|
|
2201
|
+
activity,
|
|
2202
|
+
// Elapsed wall-time since cleanup was first deferred. NOT a Unix
|
|
2203
|
+
// timestamp — naming it `pendingSinceMs` was misleading (Greptile).
|
|
2204
|
+
pendingElapsedMs: Number.isFinite(pendingSinceMs) ? Date.now() - pendingSinceMs : null,
|
|
2205
|
+
graceMs,
|
|
2206
|
+
},
|
|
2207
|
+
});
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
const correlationId = createCorrelationId("lifecycle-merge-cleanup");
|
|
2211
|
+
try {
|
|
2212
|
+
const result = await sessionManager.kill(session.id, {
|
|
2213
|
+
purgeOpenCode: true,
|
|
2214
|
+
reason: "pr_merged",
|
|
2215
|
+
});
|
|
2216
|
+
observer.recordOperation({
|
|
2217
|
+
metric: "lifecycle_poll",
|
|
2218
|
+
operation: "lifecycle.merge_cleanup.completed",
|
|
2219
|
+
outcome: "success",
|
|
2220
|
+
correlationId,
|
|
2221
|
+
projectId: session.projectId,
|
|
2222
|
+
sessionId: session.id,
|
|
2223
|
+
reason: primaryLifecycleReason(session.lifecycle),
|
|
2224
|
+
data: {
|
|
2225
|
+
cleaned: result.cleaned,
|
|
2226
|
+
alreadyTerminated: result.alreadyTerminated,
|
|
2227
|
+
graceElapsed,
|
|
2228
|
+
activity,
|
|
2229
|
+
},
|
|
2230
|
+
level: "info",
|
|
2231
|
+
});
|
|
2232
|
+
recordActivityEvent({
|
|
2233
|
+
projectId: session.projectId,
|
|
2234
|
+
sessionId: session.id,
|
|
2235
|
+
source: "lifecycle",
|
|
2236
|
+
kind: "session.auto_cleanup_completed",
|
|
2237
|
+
summary: `auto-cleanup completed for ${session.id}`,
|
|
2238
|
+
data: {
|
|
2239
|
+
cleaned: result.cleaned,
|
|
2240
|
+
alreadyTerminated: result.alreadyTerminated,
|
|
2241
|
+
graceElapsed,
|
|
2242
|
+
activity,
|
|
2243
|
+
},
|
|
2244
|
+
});
|
|
2245
|
+
states.delete(session.id);
|
|
2246
|
+
}
|
|
2247
|
+
catch (err) {
|
|
2248
|
+
// Leave `merged` status in place so the next poll retries. Preserve the
|
|
2249
|
+
// deferral marker so idempotent retries don't restart the grace clock.
|
|
2250
|
+
if (!session.metadata["mergedPendingCleanupSince"]) {
|
|
2251
|
+
updateSessionMetadata(session, { mergedPendingCleanupSince: nowIso });
|
|
2252
|
+
}
|
|
2253
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
2254
|
+
observer.recordOperation({
|
|
2255
|
+
metric: "lifecycle_poll",
|
|
2256
|
+
operation: "lifecycle.merge_cleanup.failed",
|
|
2257
|
+
outcome: "failure",
|
|
2258
|
+
correlationId,
|
|
2259
|
+
projectId: session.projectId,
|
|
2260
|
+
sessionId: session.id,
|
|
2261
|
+
reason: errorMsg,
|
|
2262
|
+
level: "warn",
|
|
2263
|
+
});
|
|
2264
|
+
recordActivityEvent({
|
|
2265
|
+
projectId: session.projectId,
|
|
2266
|
+
sessionId: session.id,
|
|
2267
|
+
source: "lifecycle",
|
|
2268
|
+
kind: "session.auto_cleanup_failed",
|
|
2269
|
+
level: "error",
|
|
2270
|
+
summary: `auto-cleanup failed for ${session.id}`,
|
|
2271
|
+
data: { errorMessage: errorMsg },
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
/** Poll a single session and handle state transitions. */
|
|
2276
|
+
async function checkSession(session) {
|
|
2277
|
+
// Use tracked state if available; otherwise use the persisted metadata status
|
|
2278
|
+
// (not session.status, which list() may have already overwritten for dead runtimes).
|
|
2279
|
+
// This ensures transitions are detected after a lifecycle manager restart.
|
|
2280
|
+
const tracked = states.get(session.id);
|
|
2281
|
+
const oldStatus = tracked ?? (session.metadata?.["status"] || session.status);
|
|
2282
|
+
const previousLifecycle = cloneLifecycle(session.lifecycle);
|
|
2283
|
+
const previousPRState = session.lifecycle.pr.state;
|
|
2284
|
+
const assessment = await determineStatus(session);
|
|
2285
|
+
if (assessment.skipMetadataWrite) {
|
|
2286
|
+
states.set(session.id, oldStatus);
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
const newStatus = assessment.status;
|
|
2290
|
+
const lifecycleChanged = session.metadata["lifecycle"] !== JSON.stringify(session.lifecycle);
|
|
2291
|
+
let transitionReaction;
|
|
2292
|
+
const nextLifecycleEvidence = assessment.evidence;
|
|
2293
|
+
const nextDetectingAttempts = assessment.detectingAttempts > 0 ? String(assessment.detectingAttempts) : "";
|
|
2294
|
+
const nextDetectingStartedAt = assessment.detectingStartedAt ?? "";
|
|
2295
|
+
const nextDetectingEvidenceHash = assessment.detectingEvidenceHash ?? "";
|
|
2296
|
+
// Escalation can happen via attempt limit OR time limit
|
|
2297
|
+
const isDetectingEscalated = newStatus === SESSION_STATUS.STUCK &&
|
|
2298
|
+
(assessment.detectingAttempts > DETECTING_MAX_ATTEMPTS ||
|
|
2299
|
+
isDetectingTimedOut(nextDetectingStartedAt));
|
|
2300
|
+
const nextDetectingEscalatedAt = isDetectingEscalated
|
|
2301
|
+
? session.metadata["detectingEscalatedAt"] || new Date().toISOString()
|
|
2302
|
+
: "";
|
|
2303
|
+
// Emit ONCE per escalation — guarded by detectingEscalatedAt being empty.
|
|
2304
|
+
// Subsequent polls while session stays stuck have detectingEscalatedAt set
|
|
2305
|
+
// and won't re-fire (per invariant: don't repeat escalation events).
|
|
2306
|
+
if (isDetectingEscalated && !session.metadata["detectingEscalatedAt"]) {
|
|
2307
|
+
const cause = assessment.detectingAttempts > DETECTING_MAX_ATTEMPTS ? "max_attempts" : "max_duration";
|
|
2308
|
+
recordActivityEvent({
|
|
2309
|
+
projectId: session.projectId,
|
|
2310
|
+
sessionId: session.id,
|
|
2311
|
+
source: "lifecycle",
|
|
2312
|
+
kind: "detecting.escalated",
|
|
2313
|
+
level: "warn",
|
|
2314
|
+
summary: `detecting → stuck via ${cause}`,
|
|
2315
|
+
data: {
|
|
2316
|
+
attempts: assessment.detectingAttempts,
|
|
2317
|
+
cause,
|
|
2318
|
+
startedAt: nextDetectingStartedAt,
|
|
2319
|
+
},
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
const metadataUpdates = {};
|
|
2323
|
+
if (session.metadata["lifecycleEvidence"] !== nextLifecycleEvidence) {
|
|
2324
|
+
metadataUpdates["lifecycleEvidence"] = nextLifecycleEvidence;
|
|
2325
|
+
}
|
|
2326
|
+
if ((session.metadata["detectingAttempts"] || "") !== nextDetectingAttempts) {
|
|
2327
|
+
metadataUpdates["detectingAttempts"] = nextDetectingAttempts;
|
|
2328
|
+
}
|
|
2329
|
+
if ((session.metadata["detectingStartedAt"] || "") !== nextDetectingStartedAt) {
|
|
2330
|
+
metadataUpdates["detectingStartedAt"] = nextDetectingStartedAt;
|
|
2331
|
+
}
|
|
2332
|
+
if ((session.metadata["detectingEvidenceHash"] || "") !== nextDetectingEvidenceHash) {
|
|
2333
|
+
metadataUpdates["detectingEvidenceHash"] = nextDetectingEvidenceHash;
|
|
2334
|
+
}
|
|
2335
|
+
if ((session.metadata["detectingEscalatedAt"] || "") !== nextDetectingEscalatedAt) {
|
|
2336
|
+
metadataUpdates["detectingEscalatedAt"] = nextDetectingEscalatedAt;
|
|
2337
|
+
}
|
|
2338
|
+
if (Object.keys(metadataUpdates).length > 0) {
|
|
2339
|
+
updateSessionMetadata(session, metadataUpdates);
|
|
2340
|
+
}
|
|
2341
|
+
// CI resolution tracking — reset the ci-failed tracker (including its escalated
|
|
2342
|
+
// flag) once CI has been passing for CI_PASSING_STABLE_THRESHOLD consecutive polls.
|
|
2343
|
+
// This lets the next real CI failure start with a fresh budget.
|
|
2344
|
+
if (session.pr) {
|
|
2345
|
+
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
|
2346
|
+
const cachedData = prEnrichmentCache.get(prKey);
|
|
2347
|
+
if (cachedData) {
|
|
2348
|
+
if (cachedData.ciStatus === "passing") {
|
|
2349
|
+
const stableCount = Number(session.metadata["ciPassingStableCount"] ?? "0") + 1;
|
|
2350
|
+
if (stableCount >= CI_PASSING_STABLE_THRESHOLD) {
|
|
2351
|
+
clearReactionTracker(session.id, "ci-failed");
|
|
2352
|
+
updateSessionMetadata(session, { ciPassingStableCount: "" });
|
|
2353
|
+
}
|
|
2354
|
+
else {
|
|
2355
|
+
updateSessionMetadata(session, { ciPassingStableCount: String(stableCount) });
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
else if (session.metadata["ciPassingStableCount"]) {
|
|
2359
|
+
// pending or failing resets the stability window — only "passing" counts as resolution
|
|
2360
|
+
updateSessionMetadata(session, { ciPassingStableCount: "" });
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
if (newStatus !== oldStatus) {
|
|
2365
|
+
const correlationId = createCorrelationId("lifecycle-transition");
|
|
2366
|
+
// State transition detected
|
|
2367
|
+
states.set(session.id, newStatus);
|
|
2368
|
+
updateSessionMetadata(session, { status: newStatus });
|
|
2369
|
+
recordActivityEvent({
|
|
2370
|
+
projectId: session.projectId,
|
|
2371
|
+
sessionId: session.id,
|
|
2372
|
+
source: "lifecycle",
|
|
2373
|
+
kind: "lifecycle.transition",
|
|
2374
|
+
level: newStatus === "ci_failed" ? "warn" : "info",
|
|
2375
|
+
summary: `${oldStatus} → ${newStatus}`,
|
|
2376
|
+
data: { from: oldStatus, to: newStatus },
|
|
2377
|
+
});
|
|
2378
|
+
observer.recordOperation({
|
|
2379
|
+
metric: "lifecycle_poll",
|
|
2380
|
+
operation: "lifecycle.transition",
|
|
2381
|
+
outcome: "success",
|
|
2382
|
+
correlationId,
|
|
2383
|
+
projectId: session.projectId,
|
|
2384
|
+
sessionId: session.id,
|
|
2385
|
+
reason: primaryLifecycleReason(session.lifecycle),
|
|
2386
|
+
data: buildTransitionObservabilityData(previousLifecycle, session.lifecycle, oldStatus, newStatus, assessment.evidence, assessment.detectingAttempts, true),
|
|
2387
|
+
level: transitionLogLevel(newStatus),
|
|
2388
|
+
});
|
|
2389
|
+
// Reset allCompleteEmitted when any session becomes active again
|
|
2390
|
+
if (!TERMINAL_STATUSES.has(newStatus)) {
|
|
2391
|
+
allCompleteEmitted = false;
|
|
2392
|
+
}
|
|
2393
|
+
// Clear reaction trackers for the old status so retries reset on state changes.
|
|
2394
|
+
// Persistent keys (ci-failed) are excluded — their trackers survive oscillation
|
|
2395
|
+
// so the escalation budget accumulates across cycles. On escalation, the tracker
|
|
2396
|
+
// is cleared in executeReaction so future incidents get a fresh budget.
|
|
2397
|
+
const oldEventType = statusToEventType(undefined, oldStatus);
|
|
2398
|
+
if (oldEventType) {
|
|
2399
|
+
const oldReactionKey = eventToReactionKey(oldEventType);
|
|
2400
|
+
if (oldReactionKey && !PERSISTENT_REACTION_KEYS.has(oldReactionKey)) {
|
|
2401
|
+
clearReactionTracker(session.id, oldReactionKey);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
// Handle transition: notify humans and/or trigger reactions
|
|
2405
|
+
const eventType = statusToEventType(oldStatus, newStatus);
|
|
2406
|
+
if (eventType) {
|
|
2407
|
+
let reactionHandledNotify = false;
|
|
2408
|
+
const reactionKey = eventToReactionKey(eventType);
|
|
2409
|
+
if (reactionKey) {
|
|
2410
|
+
let reactionConfig = getReactionConfigForSession(session, reactionKey);
|
|
2411
|
+
let messageEnriched = false;
|
|
2412
|
+
// Enrich CI failure message with failed job/step/log details when
|
|
2413
|
+
// batch check data is already available. If it is not, the
|
|
2414
|
+
// post-transition CI dispatcher below fetches checks and sends the
|
|
2415
|
+
// composed message without altering lifecycle state transitions.
|
|
2416
|
+
if (reactionKey === "ci-failed" &&
|
|
2417
|
+
session.pr &&
|
|
2418
|
+
reactionConfig?.action === "send-to-agent") {
|
|
2419
|
+
const project = config.projects[session.projectId];
|
|
2420
|
+
const scm = project?.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
|
|
2421
|
+
if (scm) {
|
|
2422
|
+
const failedChecks = await getFailedCIChecks(scm, session.pr, { allowFetch: false });
|
|
2423
|
+
if (failedChecks) {
|
|
2424
|
+
reactionConfig = {
|
|
2425
|
+
...reactionConfig,
|
|
2426
|
+
message: await formatCIFailureMessage(scm, session.pr, failedChecks),
|
|
2427
|
+
};
|
|
2428
|
+
messageEnriched = true;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
if (reactionConfig && reactionConfig.action) {
|
|
2433
|
+
// auto: false skips automated agent actions but still allows notifications
|
|
2434
|
+
if (reactionConfig.auto !== false || reactionConfig.action === "notify") {
|
|
2435
|
+
const reactionResult = await executeReaction(session, reactionKey, reactionConfig);
|
|
2436
|
+
transitionReaction = { key: reactionKey, result: reactionResult, messageEnriched };
|
|
2437
|
+
observer.recordOperation({
|
|
2438
|
+
metric: "lifecycle_poll",
|
|
2439
|
+
operation: "lifecycle.transition.reaction",
|
|
2440
|
+
outcome: reactionResult.success ? "success" : "failure",
|
|
2441
|
+
correlationId,
|
|
2442
|
+
projectId: session.projectId,
|
|
2443
|
+
sessionId: session.id,
|
|
2444
|
+
reason: primaryLifecycleReason(session.lifecycle),
|
|
2445
|
+
data: buildTransitionObservabilityData(previousLifecycle, session.lifecycle, oldStatus, newStatus, assessment.evidence, assessment.detectingAttempts, true, transitionReaction),
|
|
2446
|
+
level: reactionResult.success ? "info" : "warn",
|
|
2447
|
+
});
|
|
2448
|
+
// Reaction is handling this event — suppress immediate human notification.
|
|
2449
|
+
// "send-to-agent" retries + escalates on its own; "notify"/"auto-merge"
|
|
2450
|
+
// already call notifyHuman internally. Notifying here would bypass the
|
|
2451
|
+
// delayed escalation behaviour configured via retries/escalateAfter.
|
|
2452
|
+
reactionHandledNotify = true;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
// For transitions not already notified by a reaction, notify humans.
|
|
2457
|
+
// All priorities (including "info") are routed through notificationRouting
|
|
2458
|
+
// so the config controls which notifiers receive each priority level.
|
|
2459
|
+
if (!reactionHandledNotify) {
|
|
2460
|
+
const priority = inferPriority(eventType);
|
|
2461
|
+
const context = buildEventContext(session, prEnrichmentCache);
|
|
2462
|
+
const event = createEvent(eventType, {
|
|
2463
|
+
sessionId: session.id,
|
|
2464
|
+
projectId: session.projectId,
|
|
2465
|
+
message: `${session.id}: ${oldStatus} → ${newStatus}`,
|
|
2466
|
+
data: buildSessionTransitionNotificationData({
|
|
2467
|
+
eventType,
|
|
2468
|
+
sessionId: session.id,
|
|
2469
|
+
projectId: session.projectId,
|
|
2470
|
+
context,
|
|
2471
|
+
oldStatus,
|
|
2472
|
+
newStatus,
|
|
2473
|
+
enrichment: getPREnrichmentForSession(session),
|
|
2474
|
+
}),
|
|
2475
|
+
});
|
|
2476
|
+
await notifyHuman(event, priority);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
else {
|
|
2481
|
+
// No transition but track current state
|
|
2482
|
+
states.set(session.id, newStatus);
|
|
2483
|
+
if (lifecycleChanged) {
|
|
2484
|
+
updateSessionMetadata(session, { status: newStatus });
|
|
2485
|
+
observer.recordOperation({
|
|
2486
|
+
metric: "lifecycle_poll",
|
|
2487
|
+
operation: "lifecycle.sync",
|
|
2488
|
+
outcome: "success",
|
|
2489
|
+
correlationId: createCorrelationId("lifecycle-sync"),
|
|
2490
|
+
projectId: session.projectId,
|
|
2491
|
+
sessionId: session.id,
|
|
2492
|
+
reason: primaryLifecycleReason(session.lifecycle),
|
|
2493
|
+
data: buildTransitionObservabilityData(previousLifecycle, session.lifecycle, oldStatus, newStatus, assessment.evidence, assessment.detectingAttempts, false),
|
|
2494
|
+
level: transitionLogLevel(newStatus),
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
const prEventType = prStateToEventType(previousPRState, session.lifecycle.pr.state);
|
|
2499
|
+
if (prEventType) {
|
|
2500
|
+
let reactionHandledNotify = false;
|
|
2501
|
+
const reactionKey = eventToReactionKey(prEventType);
|
|
2502
|
+
if (reactionKey) {
|
|
2503
|
+
const reactionConfig = getReactionConfigForSession(session, reactionKey);
|
|
2504
|
+
if (reactionConfig && reactionConfig.action) {
|
|
2505
|
+
if (reactionConfig.auto !== false || reactionConfig.action === "notify") {
|
|
2506
|
+
await executeReaction(session, reactionKey, reactionConfig);
|
|
2507
|
+
reactionHandledNotify = true;
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
if (!reactionHandledNotify) {
|
|
2512
|
+
const context = buildEventContext(session, prEnrichmentCache);
|
|
2513
|
+
const prEvent = createEvent(prEventType, {
|
|
2514
|
+
sessionId: session.id,
|
|
2515
|
+
projectId: session.projectId,
|
|
2516
|
+
message: `${session.id}: PR ${previousPRState} → ${session.lifecycle.pr.state}`,
|
|
2517
|
+
data: buildPRStateNotificationData({
|
|
2518
|
+
eventType: prEventType,
|
|
2519
|
+
sessionId: session.id,
|
|
2520
|
+
projectId: session.projectId,
|
|
2521
|
+
context,
|
|
2522
|
+
oldPRState: previousPRState,
|
|
2523
|
+
newPRState: session.lifecycle.pr.state,
|
|
2524
|
+
enrichment: getPREnrichmentForSession(session),
|
|
2525
|
+
}),
|
|
2526
|
+
});
|
|
2527
|
+
await notifyHuman(prEvent, inferPriority(prEventType));
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
// Pin first quality summary for title stability
|
|
2531
|
+
if (session.agentInfo?.summary &&
|
|
2532
|
+
!session.agentInfo.summaryIsFallback &&
|
|
2533
|
+
!session.metadata["pinnedSummary"]) {
|
|
2534
|
+
const trimmed = session.agentInfo.summary.replace(/[\n\r]/g, " ").trim();
|
|
2535
|
+
if (trimmed.length >= 5) {
|
|
2536
|
+
try {
|
|
2537
|
+
updateSessionMetadata(session, { pinnedSummary: trimmed });
|
|
2538
|
+
}
|
|
2539
|
+
catch {
|
|
2540
|
+
// Non-critical: title just won't be pinned this cycle
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
await Promise.allSettled([
|
|
2545
|
+
maybeDispatchReviewBacklog(session, oldStatus, newStatus, transitionReaction),
|
|
2546
|
+
maybeDispatchMergeConflicts(session, newStatus),
|
|
2547
|
+
maybeDispatchCIFailureDetails(session, oldStatus, newStatus, transitionReaction),
|
|
2548
|
+
]);
|
|
2549
|
+
// Report watcher: audit agent reports for issues (#140)
|
|
2550
|
+
await auditAndReactToReports(session);
|
|
2551
|
+
// PR-merge auto-cleanup: tear down runtime + worktree + archive metadata
|
|
2552
|
+
// once the agent is idle (or grace window elapses). Runs last so reactions
|
|
2553
|
+
// and notifications observe the live session before it is destroyed.
|
|
2554
|
+
await maybeAutoCleanupOnMerge(session);
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* Audit agent reports and trigger reactions when issues are detected.
|
|
2558
|
+
* Called at the end of each checkSession cycle.
|
|
2559
|
+
*/
|
|
2560
|
+
async function auditAndReactToReports(session) {
|
|
2561
|
+
const auditResult = auditAgentReports(session);
|
|
2562
|
+
const now = new Date().toISOString();
|
|
2563
|
+
// If no trigger, clear any active trigger metadata
|
|
2564
|
+
if (!auditResult || !auditResult.trigger) {
|
|
2565
|
+
const hadActiveTrigger = session.metadata[REPORT_WATCHER_METADATA_KEYS.ACTIVE_TRIGGER];
|
|
2566
|
+
if (hadActiveTrigger) {
|
|
2567
|
+
updateSessionMetadata(session, {
|
|
2568
|
+
[REPORT_WATCHER_METADATA_KEYS.LAST_AUDITED_AT]: now,
|
|
2569
|
+
[REPORT_WATCHER_METADATA_KEYS.ACTIVE_TRIGGER]: "",
|
|
2570
|
+
[REPORT_WATCHER_METADATA_KEYS.TRIGGER_ACTIVATED_AT]: "",
|
|
2571
|
+
[REPORT_WATCHER_METADATA_KEYS.TRIGGER_COUNT]: "",
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
const reactionKey = getReactionKeyForTrigger(auditResult.trigger);
|
|
2577
|
+
const reactionConfig = getReactionConfigForSession(session, reactionKey);
|
|
2578
|
+
// Update audit metadata
|
|
2579
|
+
const currentTriggerCount = parseInt(session.metadata[REPORT_WATCHER_METADATA_KEYS.TRIGGER_COUNT] ?? "0", 10);
|
|
2580
|
+
const isNewTrigger = session.metadata[REPORT_WATCHER_METADATA_KEYS.ACTIVE_TRIGGER] !== auditResult.trigger;
|
|
2581
|
+
updateSessionMetadata(session, {
|
|
2582
|
+
[REPORT_WATCHER_METADATA_KEYS.LAST_AUDITED_AT]: now,
|
|
2583
|
+
[REPORT_WATCHER_METADATA_KEYS.ACTIVE_TRIGGER]: auditResult.trigger,
|
|
2584
|
+
[REPORT_WATCHER_METADATA_KEYS.TRIGGER_ACTIVATED_AT]: isNewTrigger
|
|
2585
|
+
? now
|
|
2586
|
+
: (session.metadata[REPORT_WATCHER_METADATA_KEYS.TRIGGER_ACTIVATED_AT] ?? now),
|
|
2587
|
+
[REPORT_WATCHER_METADATA_KEYS.TRIGGER_COUNT]: String(isNewTrigger ? 1 : currentTriggerCount + 1),
|
|
2588
|
+
});
|
|
2589
|
+
// Log the audit finding
|
|
2590
|
+
observer.recordOperation({
|
|
2591
|
+
metric: "lifecycle_poll",
|
|
2592
|
+
operation: "report_watcher.audit",
|
|
2593
|
+
outcome: "success",
|
|
2594
|
+
correlationId: createCorrelationId("report-watcher"),
|
|
2595
|
+
projectId: session.projectId,
|
|
2596
|
+
sessionId: session.id,
|
|
2597
|
+
reason: auditResult.trigger,
|
|
2598
|
+
data: {
|
|
2599
|
+
trigger: auditResult.trigger,
|
|
2600
|
+
message: auditResult.message,
|
|
2601
|
+
timeSinceSpawnMs: auditResult.timeSinceSpawnMs,
|
|
2602
|
+
timeSinceReportMs: auditResult.timeSinceReportMs,
|
|
2603
|
+
reportState: auditResult.report?.state,
|
|
2604
|
+
},
|
|
2605
|
+
level: "warn",
|
|
2606
|
+
});
|
|
2607
|
+
// Emit ONCE per trigger activation (matches the detecting.escalated guard
|
|
2608
|
+
// pattern). Without this guard the audit would fire every poll cycle while
|
|
2609
|
+
// a trigger stays active, producing hundreds of identical events. The
|
|
2610
|
+
// observer.recordOperation above is unguarded by design (it's a metric);
|
|
2611
|
+
// the activity-event trail is for actionable evidence, not heartbeat.
|
|
2612
|
+
if (isNewTrigger) {
|
|
2613
|
+
recordActivityEvent({
|
|
2614
|
+
projectId: session.projectId,
|
|
2615
|
+
sessionId: session.id,
|
|
2616
|
+
source: "report-watcher",
|
|
2617
|
+
kind: "report_watcher.triggered",
|
|
2618
|
+
level: "warn",
|
|
2619
|
+
// Trigger is a bounded enum (no_acknowledge | stale_report |
|
|
2620
|
+
// agent_needs_input); auditResult.message includes free-form
|
|
2621
|
+
// report.note text from `athene report` and must not land in summary,
|
|
2622
|
+
// which is FTS-indexed and only truncated by sanitizeSummary.
|
|
2623
|
+
// Full message stays in `data.message` where sanitizeData redacts
|
|
2624
|
+
// credential URLs.
|
|
2625
|
+
summary: `${auditResult.trigger} triggered`,
|
|
2626
|
+
data: {
|
|
2627
|
+
trigger: auditResult.trigger,
|
|
2628
|
+
message: auditResult.message,
|
|
2629
|
+
timeSinceSpawnMs: auditResult.timeSinceSpawnMs,
|
|
2630
|
+
timeSinceReportMs: auditResult.timeSinceReportMs,
|
|
2631
|
+
reportState: auditResult.report?.state,
|
|
2632
|
+
},
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
// Execute reaction if configured
|
|
2636
|
+
if (isNewTrigger && reactionConfig && reactionConfig.auto !== false) {
|
|
2637
|
+
await executeReaction(session, reactionKey, reactionConfig);
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
/** Run one polling cycle across all sessions. */
|
|
2641
|
+
async function pollAll() {
|
|
2642
|
+
const correlationId = createCorrelationId("lifecycle-poll");
|
|
2643
|
+
const startedAt = Date.now();
|
|
2644
|
+
// Re-entrancy guard: skip if previous poll is still running
|
|
2645
|
+
if (polling)
|
|
2646
|
+
return;
|
|
2647
|
+
polling = true;
|
|
2648
|
+
try {
|
|
2649
|
+
const sessions = await sessionManager.list(scopedProjectId);
|
|
2650
|
+
// Include sessions that are active OR whose status changed from what we last saw
|
|
2651
|
+
// (e.g., list() detected a dead runtime and marked it "killed" — we need to
|
|
2652
|
+
// process that transition even though the new status is terminal)
|
|
2653
|
+
const sessionsToCheck = sessions.filter((s) => {
|
|
2654
|
+
if (!TERMINAL_STATUSES.has(s.status))
|
|
2655
|
+
return true;
|
|
2656
|
+
const tracked = states.get(s.id);
|
|
2657
|
+
return tracked !== undefined && tracked !== s.status;
|
|
2658
|
+
});
|
|
2659
|
+
await Promise.allSettled(sessionsToCheck.map((session) => refreshTrackedBranch(session, sessions)));
|
|
2660
|
+
// Prime the per-poll PR enrichment cache before session checks so
|
|
2661
|
+
// downstream status/reaction logic can reuse batch GraphQL data.
|
|
2662
|
+
await populatePREnrichmentCache(sessionsToCheck);
|
|
2663
|
+
// Poll all sessions concurrently
|
|
2664
|
+
await Promise.allSettled(sessionsToCheck.map((s) => checkSession(s)));
|
|
2665
|
+
// Persist batch enrichment data to session metadata files so the
|
|
2666
|
+
// web dashboard can read it without calling GitHub API.
|
|
2667
|
+
persistPREnrichmentToMetadata(sessionsToCheck);
|
|
2668
|
+
// Prune stale entries from states, reactionTrackers, and lastReviewBacklogCheckAt
|
|
2669
|
+
// for sessions that no longer appear in the session list (e.g., after kill/cleanup)
|
|
2670
|
+
const currentSessionIds = new Set(sessions.map((s) => s.id));
|
|
2671
|
+
for (const trackedId of states.keys()) {
|
|
2672
|
+
if (!currentSessionIds.has(trackedId)) {
|
|
2673
|
+
states.delete(trackedId);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
for (const trackedId of activityStateCache.keys()) {
|
|
2677
|
+
if (!currentSessionIds.has(trackedId)) {
|
|
2678
|
+
activityStateCache.delete(trackedId);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
for (const trackerKey of reactionTrackers.keys()) {
|
|
2682
|
+
const sessionId = trackerKey.split(":")[0];
|
|
2683
|
+
if (sessionId && !currentSessionIds.has(sessionId)) {
|
|
2684
|
+
reactionTrackers.delete(trackerKey);
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
for (const sessionId of lastReviewBacklogCheckAt.keys()) {
|
|
2688
|
+
if (!currentSessionIds.has(sessionId)) {
|
|
2689
|
+
lastReviewBacklogCheckAt.delete(sessionId);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
// Check if all sessions are complete (trigger reaction only once)
|
|
2693
|
+
const activeSessions = sessions.filter((s) => !TERMINAL_STATUSES.has(s.status));
|
|
2694
|
+
if (sessions.length > 0 && activeSessions.length === 0 && !allCompleteEmitted) {
|
|
2695
|
+
allCompleteEmitted = true;
|
|
2696
|
+
// Execute all-complete reaction if configured
|
|
2697
|
+
const reactionKey = eventToReactionKey("summary.all_complete");
|
|
2698
|
+
if (reactionKey) {
|
|
2699
|
+
const reactionConfig = config.reactions[reactionKey];
|
|
2700
|
+
if (reactionConfig && reactionConfig.action) {
|
|
2701
|
+
if (reactionConfig.auto !== false || reactionConfig.action === "notify") {
|
|
2702
|
+
// Create a minimal session context for system events (no PR/issue context)
|
|
2703
|
+
const systemSession = {
|
|
2704
|
+
id: "system",
|
|
2705
|
+
projectId: "all",
|
|
2706
|
+
pr: null,
|
|
2707
|
+
issueId: null,
|
|
2708
|
+
branch: null,
|
|
2709
|
+
metadata: {},
|
|
2710
|
+
agentInfo: null,
|
|
2711
|
+
};
|
|
2712
|
+
await executeReaction(systemSession, reactionKey, reactionConfig);
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
if (scopedProjectId) {
|
|
2718
|
+
observer.recordOperation({
|
|
2719
|
+
metric: "lifecycle_poll",
|
|
2720
|
+
operation: "lifecycle.poll",
|
|
2721
|
+
outcome: "success",
|
|
2722
|
+
correlationId,
|
|
2723
|
+
projectId: scopedProjectId,
|
|
2724
|
+
durationMs: Date.now() - startedAt,
|
|
2725
|
+
data: { sessionCount: sessions.length, activeSessionCount: activeSessions.length },
|
|
2726
|
+
level: "info",
|
|
2727
|
+
});
|
|
2728
|
+
observer.setHealth({
|
|
2729
|
+
surface: "lifecycle.worker",
|
|
2730
|
+
status: "ok",
|
|
2731
|
+
projectId: scopedProjectId,
|
|
2732
|
+
correlationId,
|
|
2733
|
+
details: {
|
|
2734
|
+
projectId: scopedProjectId,
|
|
2735
|
+
sessionCount: sessions.length,
|
|
2736
|
+
activeSessionCount: activeSessions.length,
|
|
2737
|
+
},
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
catch (err) {
|
|
2742
|
+
const errorReason = err instanceof Error ? err.message : String(err);
|
|
2743
|
+
observer.recordOperation({
|
|
2744
|
+
metric: "lifecycle_poll",
|
|
2745
|
+
operation: "lifecycle.poll",
|
|
2746
|
+
outcome: "failure",
|
|
2747
|
+
correlationId,
|
|
2748
|
+
projectId: scopedProjectId,
|
|
2749
|
+
durationMs: Date.now() - startedAt,
|
|
2750
|
+
reason: errorReason,
|
|
2751
|
+
level: "error",
|
|
2752
|
+
});
|
|
2753
|
+
recordActivityEvent({
|
|
2754
|
+
projectId: scopedProjectId,
|
|
2755
|
+
source: "lifecycle",
|
|
2756
|
+
kind: "lifecycle.poll_failed",
|
|
2757
|
+
level: "error",
|
|
2758
|
+
// Keep summary generic — sanitizeSummary only truncates, but the FTS
|
|
2759
|
+
// index covers it. Error text (which can contain credential URLs from
|
|
2760
|
+
// git/gh subprocess output) is routed through `data` where sanitizeData
|
|
2761
|
+
// redacts credentials.
|
|
2762
|
+
summary: "poll cycle failed",
|
|
2763
|
+
data: {
|
|
2764
|
+
errorMessage: errorReason,
|
|
2765
|
+
durationMs: Date.now() - startedAt,
|
|
2766
|
+
projectScope: scopedProjectId ?? "all",
|
|
2767
|
+
},
|
|
2768
|
+
});
|
|
2769
|
+
observer.setHealth({
|
|
2770
|
+
surface: "lifecycle.worker",
|
|
2771
|
+
status: "error",
|
|
2772
|
+
projectId: scopedProjectId,
|
|
2773
|
+
correlationId,
|
|
2774
|
+
reason: errorReason,
|
|
2775
|
+
details: scopedProjectId ? { projectId: scopedProjectId } : { projectScope: "all" },
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
finally {
|
|
2779
|
+
polling = false;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
return {
|
|
2783
|
+
start(intervalMs = 30_000) {
|
|
2784
|
+
if (pollTimer)
|
|
2785
|
+
return; // Already running
|
|
2786
|
+
pollTimer = setInterval(() => void pollAll(), intervalMs);
|
|
2787
|
+
// Run immediately on start
|
|
2788
|
+
void pollAll();
|
|
2789
|
+
},
|
|
2790
|
+
stop() {
|
|
2791
|
+
if (pollTimer) {
|
|
2792
|
+
clearInterval(pollTimer);
|
|
2793
|
+
pollTimer = null;
|
|
2794
|
+
}
|
|
2795
|
+
},
|
|
2796
|
+
getStates() {
|
|
2797
|
+
return new Map(states);
|
|
2798
|
+
},
|
|
2799
|
+
async check(sessionId) {
|
|
2800
|
+
const session = await sessionManager.get(sessionId);
|
|
2801
|
+
if (!session)
|
|
2802
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
2803
|
+
await refreshTrackedBranch(session);
|
|
2804
|
+
// Populate batch enrichment cache for this session's PR so
|
|
2805
|
+
// checkSession can read from cache (no individual REST fallback).
|
|
2806
|
+
await populatePREnrichmentCache([session]);
|
|
2807
|
+
await checkSession(session);
|
|
2808
|
+
},
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
export { createLifecycleManager };
|
|
2813
|
+
//# sourceMappingURL=lifecycle-manager.js.map
|