@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.
Files changed (285) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +241 -0
  3. package/dist/activity-events.d.ts +42 -0
  4. package/dist/activity-events.d.ts.map +1 -0
  5. package/dist/activity-events.js +192 -0
  6. package/dist/activity-events.js.map +1 -0
  7. package/dist/activity-log.d.ts +71 -0
  8. package/dist/activity-log.d.ts.map +1 -0
  9. package/dist/activity-log.js +203 -0
  10. package/dist/activity-log.js.map +1 -0
  11. package/dist/activity-signal.d.ts +20 -0
  12. package/dist/activity-signal.d.ts.map +1 -0
  13. package/dist/activity-signal.js +91 -0
  14. package/dist/activity-signal.js.map +1 -0
  15. package/dist/agent-report.d.ts +148 -0
  16. package/dist/agent-report.d.ts.map +1 -0
  17. package/dist/agent-report.js +516 -0
  18. package/dist/agent-report.js.map +1 -0
  19. package/dist/agent-selection.d.ts +31 -0
  20. package/dist/agent-selection.d.ts.map +1 -0
  21. package/dist/agent-selection.js +69 -0
  22. package/dist/agent-selection.js.map +1 -0
  23. package/dist/agent-workspace-hooks.d.ts +74 -0
  24. package/dist/agent-workspace-hooks.d.ts.map +1 -0
  25. package/dist/agent-workspace-hooks.js +988 -0
  26. package/dist/agent-workspace-hooks.js.map +1 -0
  27. package/dist/atomic-write.d.ts +6 -0
  28. package/dist/atomic-write.d.ts.map +1 -0
  29. package/dist/atomic-write.js +49 -0
  30. package/dist/atomic-write.js.map +1 -0
  31. package/dist/cleanup-stack.d.ts +37 -0
  32. package/dist/cleanup-stack.d.ts.map +1 -0
  33. package/dist/cleanup-stack.js +45 -0
  34. package/dist/cleanup-stack.js.map +1 -0
  35. package/dist/code-review-manager.d.ts +118 -0
  36. package/dist/code-review-manager.d.ts.map +1 -0
  37. package/dist/code-review-manager.js +719 -0
  38. package/dist/code-review-manager.js.map +1 -0
  39. package/dist/code-review-store.d.ts +114 -0
  40. package/dist/code-review-store.d.ts.map +1 -0
  41. package/dist/code-review-store.js +346 -0
  42. package/dist/code-review-store.js.map +1 -0
  43. package/dist/config-generator.d.ts +84 -0
  44. package/dist/config-generator.d.ts.map +1 -0
  45. package/dist/config-generator.js +295 -0
  46. package/dist/config-generator.js.map +1 -0
  47. package/dist/config.d.ts +55 -0
  48. package/dist/config.d.ts.map +1 -0
  49. package/dist/config.js +852 -0
  50. package/dist/config.js.map +1 -0
  51. package/dist/daemon-children.d.ts +55 -0
  52. package/dist/daemon-children.d.ts.map +1 -0
  53. package/dist/daemon-children.js +435 -0
  54. package/dist/daemon-children.js.map +1 -0
  55. package/dist/dashboard-notifications.d.ts +42 -0
  56. package/dist/dashboard-notifications.d.ts.map +1 -0
  57. package/dist/dashboard-notifications.js +123 -0
  58. package/dist/dashboard-notifications.js.map +1 -0
  59. package/dist/events-db.d.ts +39 -0
  60. package/dist/events-db.d.ts.map +1 -0
  61. package/dist/events-db.js +185 -0
  62. package/dist/events-db.js.map +1 -0
  63. package/dist/feature-flags.d.ts +2 -0
  64. package/dist/feature-flags.d.ts.map +1 -0
  65. package/dist/feature-flags.js +9 -0
  66. package/dist/feature-flags.js.map +1 -0
  67. package/dist/feedback-tools.d.ts +97 -0
  68. package/dist/feedback-tools.d.ts.map +1 -0
  69. package/dist/feedback-tools.js +161 -0
  70. package/dist/feedback-tools.js.map +1 -0
  71. package/dist/file-lock.d.ts +5 -0
  72. package/dist/file-lock.d.ts.map +1 -0
  73. package/dist/file-lock.js +59 -0
  74. package/dist/file-lock.js.map +1 -0
  75. package/dist/format-automated-comments.d.ts +18 -0
  76. package/dist/format-automated-comments.d.ts.map +1 -0
  77. package/dist/gh-trace.d.ts +57 -0
  78. package/dist/gh-trace.d.ts.map +1 -0
  79. package/dist/gh-trace.js +320 -0
  80. package/dist/gh-trace.js.map +1 -0
  81. package/dist/git-activity.d.ts +10 -0
  82. package/dist/git-activity.d.ts.map +1 -0
  83. package/dist/git-activity.js +30 -0
  84. package/dist/git-activity.js.map +1 -0
  85. package/dist/global-config.d.ts +1085 -0
  86. package/dist/global-config.d.ts.map +1 -0
  87. package/dist/global-config.js +1067 -0
  88. package/dist/global-config.js.map +1 -0
  89. package/dist/index.d.ts +91 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +59 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/key-value.d.ts +7 -0
  94. package/dist/key-value.d.ts.map +1 -0
  95. package/dist/key-value.js +24 -0
  96. package/dist/key-value.js.map +1 -0
  97. package/dist/lifecycle-manager.d.ts +22 -0
  98. package/dist/lifecycle-manager.d.ts.map +1 -0
  99. package/dist/lifecycle-manager.js +2813 -0
  100. package/dist/lifecycle-manager.js.map +1 -0
  101. package/dist/lifecycle-state.d.ts +28 -0
  102. package/dist/lifecycle-state.d.ts.map +1 -0
  103. package/dist/lifecycle-state.js +446 -0
  104. package/dist/lifecycle-state.js.map +1 -0
  105. package/dist/lifecycle-status-decisions.d.ts +85 -0
  106. package/dist/lifecycle-status-decisions.d.ts.map +1 -0
  107. package/dist/lifecycle-status-decisions.js +262 -0
  108. package/dist/lifecycle-status-decisions.js.map +1 -0
  109. package/dist/lifecycle-transition.d.ts +81 -0
  110. package/dist/lifecycle-transition.d.ts.map +1 -0
  111. package/dist/lifecycle-transition.js +207 -0
  112. package/dist/lifecycle-transition.js.map +1 -0
  113. package/dist/metadata.d.ts +54 -0
  114. package/dist/metadata.d.ts.map +1 -0
  115. package/dist/metadata.js +484 -0
  116. package/dist/metadata.js.map +1 -0
  117. package/dist/migration/storage-v2.d.ts +76 -0
  118. package/dist/migration/storage-v2.d.ts.map +1 -0
  119. package/dist/migration/storage-v2.js +1614 -0
  120. package/dist/migration/storage-v2.js.map +1 -0
  121. package/dist/notification-data.d.ts +135 -0
  122. package/dist/notification-data.d.ts.map +1 -0
  123. package/dist/notification-data.js +204 -0
  124. package/dist/notification-data.js.map +1 -0
  125. package/dist/notification-observability.d.ts +21 -0
  126. package/dist/notification-observability.d.ts.map +1 -0
  127. package/dist/notification-observability.js +154 -0
  128. package/dist/notification-observability.js.map +1 -0
  129. package/dist/notifier-resolution.d.ts +14 -0
  130. package/dist/notifier-resolution.d.ts.map +1 -0
  131. package/dist/notifier-resolution.js +23 -0
  132. package/dist/notifier-resolution.js.map +1 -0
  133. package/dist/observability.d.ts +100 -0
  134. package/dist/observability.d.ts.map +1 -0
  135. package/dist/observability.js +535 -0
  136. package/dist/observability.js.map +1 -0
  137. package/dist/opencode-agents-md.d.ts +3 -0
  138. package/dist/opencode-agents-md.d.ts.map +1 -0
  139. package/dist/opencode-agents-md.js +40 -0
  140. package/dist/opencode-agents-md.js.map +1 -0
  141. package/dist/opencode-config.d.ts +2 -0
  142. package/dist/opencode-config.d.ts.map +1 -0
  143. package/dist/opencode-config.js +17 -0
  144. package/dist/opencode-config.js.map +1 -0
  145. package/dist/opencode-session-id.d.ts +2 -0
  146. package/dist/opencode-session-id.d.ts.map +1 -0
  147. package/dist/opencode-session-id.js +12 -0
  148. package/dist/opencode-session-id.js.map +1 -0
  149. package/dist/opencode-shared.d.ts +80 -0
  150. package/dist/opencode-shared.d.ts.map +1 -0
  151. package/dist/opencode-shared.js +202 -0
  152. package/dist/opencode-shared.js.map +1 -0
  153. package/dist/orchestrator-prompt.d.ts +19 -0
  154. package/dist/orchestrator-prompt.d.ts.map +1 -0
  155. package/dist/orchestrator-prompt.js +130 -0
  156. package/dist/orchestrator-prompt.js.map +1 -0
  157. package/dist/orchestrator-session-strategy.d.ts +5 -0
  158. package/dist/orchestrator-session-strategy.d.ts.map +1 -0
  159. package/dist/orchestrator-session-strategy.js +13 -0
  160. package/dist/orchestrator-session-strategy.js.map +1 -0
  161. package/dist/paths.d.ts +145 -0
  162. package/dist/paths.d.ts.map +1 -0
  163. package/dist/paths.js +288 -0
  164. package/dist/paths.js.map +1 -0
  165. package/dist/platform.d.ts +32 -0
  166. package/dist/platform.d.ts.map +1 -0
  167. package/dist/platform.js +211 -0
  168. package/dist/platform.js.map +1 -0
  169. package/dist/plugin-registry.d.ts +15 -0
  170. package/dist/plugin-registry.d.ts.map +1 -0
  171. package/dist/plugin-registry.js +499 -0
  172. package/dist/plugin-registry.js.map +1 -0
  173. package/dist/portfolio-projects.d.ts +7 -0
  174. package/dist/portfolio-projects.d.ts.map +1 -0
  175. package/dist/portfolio-projects.js +65 -0
  176. package/dist/portfolio-projects.js.map +1 -0
  177. package/dist/portfolio-registry.d.ts +42 -0
  178. package/dist/portfolio-registry.d.ts.map +1 -0
  179. package/dist/portfolio-registry.js +311 -0
  180. package/dist/portfolio-registry.js.map +1 -0
  181. package/dist/portfolio-routing.d.ts +5 -0
  182. package/dist/portfolio-routing.d.ts.map +1 -0
  183. package/dist/portfolio-routing.js +24 -0
  184. package/dist/portfolio-routing.js.map +1 -0
  185. package/dist/portfolio-session-service.d.ts +15 -0
  186. package/dist/portfolio-session-service.d.ts.map +1 -0
  187. package/dist/portfolio-session-service.js +206 -0
  188. package/dist/portfolio-session-service.js.map +1 -0
  189. package/dist/process-cache.d.ts +32 -0
  190. package/dist/process-cache.d.ts.map +1 -0
  191. package/dist/process-cache.js +44 -0
  192. package/dist/process-cache.js.map +1 -0
  193. package/dist/project-resolver.d.ts +5 -0
  194. package/dist/project-resolver.d.ts.map +1 -0
  195. package/dist/project-resolver.js +20 -0
  196. package/dist/project-resolver.js.map +1 -0
  197. package/dist/prompt-builder.d.ts +42 -0
  198. package/dist/prompt-builder.d.ts.map +1 -0
  199. package/dist/prompt-builder.js +182 -0
  200. package/dist/prompt-builder.js.map +1 -0
  201. package/dist/prompts/orchestrator.md.js +4 -0
  202. package/dist/prompts/orchestrator.md.js.map +1 -0
  203. package/dist/query-activity-events.d.ts +42 -0
  204. package/dist/query-activity-events.d.ts.map +1 -0
  205. package/dist/query-activity-events.js +170 -0
  206. package/dist/query-activity-events.js.map +1 -0
  207. package/dist/recovery/actions.d.ts +7 -0
  208. package/dist/recovery/actions.d.ts.map +1 -0
  209. package/dist/recovery/index.d.ts +8 -0
  210. package/dist/recovery/index.d.ts.map +1 -0
  211. package/dist/recovery/logger.d.ts +12 -0
  212. package/dist/recovery/logger.d.ts.map +1 -0
  213. package/dist/recovery/manager.d.ts +24 -0
  214. package/dist/recovery/manager.d.ts.map +1 -0
  215. package/dist/recovery/scanner.d.ts +11 -0
  216. package/dist/recovery/scanner.d.ts.map +1 -0
  217. package/dist/recovery/types.d.ts +170 -0
  218. package/dist/recovery/types.d.ts.map +1 -0
  219. package/dist/recovery/validator.d.ts +8 -0
  220. package/dist/recovery/validator.d.ts.map +1 -0
  221. package/dist/report-watcher.d.ts +93 -0
  222. package/dist/report-watcher.d.ts.map +1 -0
  223. package/dist/report-watcher.js +182 -0
  224. package/dist/report-watcher.js.map +1 -0
  225. package/dist/scm-webhook-utils.d.ts +6 -0
  226. package/dist/scm-webhook-utils.d.ts.map +1 -0
  227. package/dist/scm-webhook-utils.js +36 -0
  228. package/dist/scm-webhook-utils.js.map +1 -0
  229. package/dist/session-manager.d.ts +22 -0
  230. package/dist/session-manager.d.ts.map +1 -0
  231. package/dist/session-manager.js +3077 -0
  232. package/dist/session-manager.js.map +1 -0
  233. package/dist/spawn-target.d.ts +23 -0
  234. package/dist/spawn-target.d.ts.map +1 -0
  235. package/dist/spawn-target.js +39 -0
  236. package/dist/spawn-target.js.map +1 -0
  237. package/dist/storage-key.d.ts +9 -0
  238. package/dist/storage-key.d.ts.map +1 -0
  239. package/dist/storage-key.js +59 -0
  240. package/dist/storage-key.js.map +1 -0
  241. package/dist/tmux.d.ts +39 -0
  242. package/dist/tmux.d.ts.map +1 -0
  243. package/dist/tmux.js +141 -0
  244. package/dist/tmux.js.map +1 -0
  245. package/dist/types.d.ts +1496 -0
  246. package/dist/types.d.ts.map +1 -0
  247. package/dist/types.js +215 -0
  248. package/dist/types.js.map +1 -0
  249. package/dist/update-cache.d.ts +59 -0
  250. package/dist/update-cache.d.ts.map +1 -0
  251. package/dist/update-cache.js +77 -0
  252. package/dist/update-cache.js.map +1 -0
  253. package/dist/utils/metadata-flatten.d.ts +3 -0
  254. package/dist/utils/metadata-flatten.d.ts.map +1 -0
  255. package/dist/utils/metadata-flatten.js +18 -0
  256. package/dist/utils/metadata-flatten.js.map +1 -0
  257. package/dist/utils/pr.d.ts +7 -0
  258. package/dist/utils/pr.d.ts.map +1 -0
  259. package/dist/utils/pr.js +97 -0
  260. package/dist/utils/pr.js.map +1 -0
  261. package/dist/utils/session-from-metadata.d.ts +16 -0
  262. package/dist/utils/session-from-metadata.d.ts.map +1 -0
  263. package/dist/utils/session-from-metadata.js +87 -0
  264. package/dist/utils/session-from-metadata.js.map +1 -0
  265. package/dist/utils/session-id.d.ts +4 -0
  266. package/dist/utils/session-id.d.ts.map +1 -0
  267. package/dist/utils/session-id.js +9 -0
  268. package/dist/utils/session-id.js.map +1 -0
  269. package/dist/utils/validation.d.ts +9 -0
  270. package/dist/utils/validation.d.ts.map +1 -0
  271. package/dist/utils/validation.js +45 -0
  272. package/dist/utils/validation.js.map +1 -0
  273. package/dist/utils.d.ts +65 -0
  274. package/dist/utils.d.ts.map +1 -0
  275. package/dist/utils.js +189 -0
  276. package/dist/utils.js.map +1 -0
  277. package/dist/version-compare.d.ts +27 -0
  278. package/dist/version-compare.d.ts.map +1 -0
  279. package/dist/version-compare.js +121 -0
  280. package/dist/version-compare.js.map +1 -0
  281. package/dist/windows-pty-registry.d.ts +27 -0
  282. package/dist/windows-pty-registry.d.ts.map +1 -0
  283. package/dist/windows-pty-registry.js +109 -0
  284. package/dist/windows-pty-registry.js.map +1 -0
  285. 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