@agent-relay/factory 0.0.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +182 -23
  2. package/bin/factory.mjs +7 -0
  3. package/dist/cli/fleet.d.ts +81 -0
  4. package/dist/cli/fleet.d.ts.map +1 -0
  5. package/dist/cli/fleet.js +571 -0
  6. package/dist/cli/fleet.js.map +1 -0
  7. package/dist/config/schema.d.ts +1313 -0
  8. package/dist/config/schema.d.ts.map +1 -0
  9. package/dist/config/schema.js +320 -0
  10. package/dist/config/schema.js.map +1 -0
  11. package/dist/constants/linear.d.ts +6 -0
  12. package/dist/constants/linear.d.ts.map +1 -0
  13. package/dist/constants/linear.js +21 -0
  14. package/dist/constants/linear.js.map +1 -0
  15. package/dist/constants/slack.d.ts +3 -0
  16. package/dist/constants/slack.d.ts.map +1 -0
  17. package/dist/constants/slack.js +3 -0
  18. package/dist/constants/slack.js.map +1 -0
  19. package/dist/dispatch/templates.d.ts +52 -0
  20. package/dist/dispatch/templates.d.ts.map +1 -0
  21. package/dist/dispatch/templates.js +118 -0
  22. package/dist/dispatch/templates.js.map +1 -0
  23. package/dist/fleet/create-fleet.d.ts +14 -0
  24. package/dist/fleet/create-fleet.d.ts.map +1 -0
  25. package/dist/fleet/create-fleet.js +15 -0
  26. package/dist/fleet/create-fleet.js.map +1 -0
  27. package/dist/fleet/ensure-relay-broker.d.ts +24 -0
  28. package/dist/fleet/ensure-relay-broker.d.ts.map +1 -0
  29. package/dist/fleet/ensure-relay-broker.js +56 -0
  30. package/dist/fleet/ensure-relay-broker.js.map +1 -0
  31. package/dist/fleet/internal-fleet-client.d.ts +90 -0
  32. package/dist/fleet/internal-fleet-client.d.ts.map +1 -0
  33. package/dist/fleet/internal-fleet-client.js +602 -0
  34. package/dist/fleet/internal-fleet-client.js.map +1 -0
  35. package/dist/fleet/relay-fleet-client.d.ts +105 -0
  36. package/dist/fleet/relay-fleet-client.d.ts.map +1 -0
  37. package/dist/fleet/relay-fleet-client.js +509 -0
  38. package/dist/fleet/relay-fleet-client.js.map +1 -0
  39. package/dist/github/index.d.ts +5 -0
  40. package/dist/github/index.d.ts.map +1 -0
  41. package/dist/github/index.js +3 -0
  42. package/dist/github/index.js.map +1 -0
  43. package/dist/github/merge-gate.d.ts +47 -0
  44. package/dist/github/merge-gate.d.ts.map +1 -0
  45. package/dist/github/merge-gate.js +147 -0
  46. package/dist/github/merge-gate.js.map +1 -0
  47. package/dist/github/probe-closer.d.ts +15 -0
  48. package/dist/github/probe-closer.d.ts.map +1 -0
  49. package/dist/github/probe-closer.js +67 -0
  50. package/dist/github/probe-closer.js.map +1 -0
  51. package/dist/index.d.ts +36 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +20 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/issue-key-match.d.ts +3 -0
  56. package/dist/issue-key-match.d.ts.map +1 -0
  57. package/dist/issue-key-match.js +22 -0
  58. package/dist/issue-key-match.js.map +1 -0
  59. package/dist/linear/state-resolver.d.ts +26 -0
  60. package/dist/linear/state-resolver.d.ts.map +1 -0
  61. package/dist/linear/state-resolver.js +285 -0
  62. package/dist/linear/state-resolver.js.map +1 -0
  63. package/dist/mount/local-mount-preflight.d.ts +9 -0
  64. package/dist/mount/local-mount-preflight.d.ts.map +1 -0
  65. package/dist/mount/local-mount-preflight.js +149 -0
  66. package/dist/mount/local-mount-preflight.js.map +1 -0
  67. package/dist/mount/relayfile-binary.d.ts +9 -0
  68. package/dist/mount/relayfile-binary.d.ts.map +1 -0
  69. package/dist/mount/relayfile-binary.js +177 -0
  70. package/dist/mount/relayfile-binary.js.map +1 -0
  71. package/dist/mount/relayfile-cloud-mount-client.d.ts +123 -0
  72. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -0
  73. package/dist/mount/relayfile-cloud-mount-client.js +427 -0
  74. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -0
  75. package/dist/node/factory-node.d.ts +59 -0
  76. package/dist/node/factory-node.d.ts.map +1 -0
  77. package/dist/node/factory-node.js +351 -0
  78. package/dist/node/factory-node.js.map +1 -0
  79. package/dist/node/factory.node.d.ts +3 -0
  80. package/dist/node/factory.node.d.ts.map +1 -0
  81. package/dist/node/factory.node.js +5 -0
  82. package/dist/node/factory.node.js.map +1 -0
  83. package/dist/orchestrator/batch-tracker.d.ts +42 -0
  84. package/dist/orchestrator/batch-tracker.d.ts.map +1 -0
  85. package/dist/orchestrator/batch-tracker.js +125 -0
  86. package/dist/orchestrator/batch-tracker.js.map +1 -0
  87. package/dist/orchestrator/factory.d.ts +58 -0
  88. package/dist/orchestrator/factory.d.ts.map +1 -0
  89. package/dist/orchestrator/factory.js +4372 -0
  90. package/dist/orchestrator/factory.js.map +1 -0
  91. package/dist/orchestrator/index.d.ts +5 -0
  92. package/dist/orchestrator/index.d.ts.map +1 -0
  93. package/dist/orchestrator/index.js +4 -0
  94. package/dist/orchestrator/index.js.map +1 -0
  95. package/dist/orchestrator/process-identity.d.ts +23 -0
  96. package/dist/orchestrator/process-identity.d.ts.map +1 -0
  97. package/dist/orchestrator/process-identity.js +122 -0
  98. package/dist/orchestrator/process-identity.js.map +1 -0
  99. package/dist/orchestrator/reaper.d.ts +60 -0
  100. package/dist/orchestrator/reaper.d.ts.map +1 -0
  101. package/dist/orchestrator/reaper.js +293 -0
  102. package/dist/orchestrator/reaper.js.map +1 -0
  103. package/dist/ports/fleet.d.ts +101 -0
  104. package/dist/ports/fleet.d.ts.map +1 -0
  105. package/dist/ports/fleet.js +2 -0
  106. package/dist/ports/fleet.js.map +1 -0
  107. package/dist/ports/index.d.ts +5 -0
  108. package/dist/ports/index.d.ts.map +1 -0
  109. package/dist/ports/index.js +2 -0
  110. package/dist/ports/index.js.map +1 -0
  111. package/dist/ports/mount.d.ts +56 -0
  112. package/dist/ports/mount.d.ts.map +1 -0
  113. package/dist/ports/mount.js +2 -0
  114. package/dist/ports/mount.js.map +1 -0
  115. package/dist/ports/state.d.ts +61 -0
  116. package/dist/ports/state.d.ts.map +1 -0
  117. package/dist/ports/state.js +2 -0
  118. package/dist/ports/state.js.map +1 -0
  119. package/dist/ports/system.d.ts +15 -0
  120. package/dist/ports/system.d.ts.map +1 -0
  121. package/dist/ports/system.js +2 -0
  122. package/dist/ports/system.js.map +1 -0
  123. package/dist/ports/writeback.d.ts +25 -0
  124. package/dist/ports/writeback.d.ts.map +1 -0
  125. package/dist/ports/writeback.js +2 -0
  126. package/dist/ports/writeback.js.map +1 -0
  127. package/dist/safety/factory-scope.d.ts +16 -0
  128. package/dist/safety/factory-scope.d.ts.map +1 -0
  129. package/dist/safety/factory-scope.js +107 -0
  130. package/dist/safety/factory-scope.js.map +1 -0
  131. package/dist/state/in-memory-state-store.d.ts +31 -0
  132. package/dist/state/in-memory-state-store.d.ts.map +1 -0
  133. package/dist/state/in-memory-state-store.js +115 -0
  134. package/dist/state/in-memory-state-store.js.map +1 -0
  135. package/dist/state/index.d.ts +3 -0
  136. package/dist/state/index.d.ts.map +1 -0
  137. package/dist/state/index.js +2 -0
  138. package/dist/state/index.js.map +1 -0
  139. package/dist/state/store.d.ts +29 -0
  140. package/dist/state/store.d.ts.map +1 -0
  141. package/dist/state/store.js +26 -0
  142. package/dist/state/store.js.map +1 -0
  143. package/dist/subscriptions/common.d.ts +7 -0
  144. package/dist/subscriptions/common.d.ts.map +1 -0
  145. package/dist/subscriptions/common.js +59 -0
  146. package/dist/subscriptions/common.js.map +1 -0
  147. package/dist/subscriptions/event-client.d.ts +40 -0
  148. package/dist/subscriptions/event-client.d.ts.map +1 -0
  149. package/dist/subscriptions/event-client.js +234 -0
  150. package/dist/subscriptions/event-client.js.map +1 -0
  151. package/dist/subscriptions/globs.d.ts +5 -0
  152. package/dist/subscriptions/globs.d.ts.map +1 -0
  153. package/dist/subscriptions/globs.js +50 -0
  154. package/dist/subscriptions/globs.js.map +1 -0
  155. package/dist/subscriptions/index.d.ts +8 -0
  156. package/dist/subscriptions/index.d.ts.map +1 -0
  157. package/dist/subscriptions/index.js +5 -0
  158. package/dist/subscriptions/index.js.map +1 -0
  159. package/dist/subscriptions/linear-filter.d.ts +26 -0
  160. package/dist/subscriptions/linear-filter.d.ts.map +1 -0
  161. package/dist/subscriptions/linear-filter.js +143 -0
  162. package/dist/subscriptions/linear-filter.js.map +1 -0
  163. package/dist/subscriptions/specs.d.ts +44 -0
  164. package/dist/subscriptions/specs.d.ts.map +1 -0
  165. package/dist/subscriptions/specs.js +195 -0
  166. package/dist/subscriptions/specs.js.map +1 -0
  167. package/dist/testing/fakes.d.ts +114 -0
  168. package/dist/testing/fakes.d.ts.map +1 -0
  169. package/dist/testing/fakes.js +219 -0
  170. package/dist/testing/fakes.js.map +1 -0
  171. package/dist/testing/index.d.ts +2 -0
  172. package/dist/testing/index.d.ts.map +1 -0
  173. package/dist/testing/index.js +2 -0
  174. package/dist/testing/index.js.map +1 -0
  175. package/dist/triage/heuristic.d.ts +42 -0
  176. package/dist/triage/heuristic.d.ts.map +1 -0
  177. package/dist/triage/heuristic.js +326 -0
  178. package/dist/triage/heuristic.js.map +1 -0
  179. package/dist/triage/index.d.ts +7 -0
  180. package/dist/triage/index.d.ts.map +1 -0
  181. package/dist/triage/index.js +5 -0
  182. package/dist/triage/index.js.map +1 -0
  183. package/dist/triage/llm.d.ts +11 -0
  184. package/dist/triage/llm.d.ts.map +1 -0
  185. package/dist/triage/llm.js +48 -0
  186. package/dist/triage/llm.js.map +1 -0
  187. package/dist/triage/schema.d.ts +344 -0
  188. package/dist/triage/schema.d.ts.map +1 -0
  189. package/dist/triage/schema.js +37 -0
  190. package/dist/triage/schema.js.map +1 -0
  191. package/dist/triage/tiered.d.ts +8 -0
  192. package/dist/triage/tiered.d.ts.map +1 -0
  193. package/dist/triage/tiered.js +47 -0
  194. package/dist/triage/tiered.js.map +1 -0
  195. package/dist/types.d.ts +212 -0
  196. package/dist/types.d.ts.map +1 -0
  197. package/dist/types.js +2 -0
  198. package/dist/types.js.map +1 -0
  199. package/dist/webhook/handler.d.ts +22 -0
  200. package/dist/webhook/handler.d.ts.map +1 -0
  201. package/dist/webhook/handler.js +57 -0
  202. package/dist/webhook/handler.js.map +1 -0
  203. package/dist/webhook/index.d.ts +5 -0
  204. package/dist/webhook/index.d.ts.map +1 -0
  205. package/dist/webhook/index.js +3 -0
  206. package/dist/webhook/index.js.map +1 -0
  207. package/dist/webhook/registrar.d.ts +16 -0
  208. package/dist/webhook/registrar.d.ts.map +1 -0
  209. package/dist/webhook/registrar.js +50 -0
  210. package/dist/webhook/registrar.js.map +1 -0
  211. package/dist/writeback/github.d.ts +6 -0
  212. package/dist/writeback/github.d.ts.map +1 -0
  213. package/dist/writeback/github.js +48 -0
  214. package/dist/writeback/github.js.map +1 -0
  215. package/dist/writeback/index.d.ts +6 -0
  216. package/dist/writeback/index.d.ts.map +1 -0
  217. package/dist/writeback/index.js +4 -0
  218. package/dist/writeback/index.js.map +1 -0
  219. package/dist/writeback/linear.d.ts +41 -0
  220. package/dist/writeback/linear.d.ts.map +1 -0
  221. package/dist/writeback/linear.js +286 -0
  222. package/dist/writeback/linear.js.map +1 -0
  223. package/dist/writeback/shared.d.ts +7 -0
  224. package/dist/writeback/shared.d.ts.map +1 -0
  225. package/dist/writeback/shared.js +29 -0
  226. package/dist/writeback/shared.js.map +1 -0
  227. package/dist/writeback/slack.d.ts +16 -0
  228. package/dist/writeback/slack.d.ts.map +1 -0
  229. package/dist/writeback/slack.js +104 -0
  230. package/dist/writeback/slack.js.map +1 -0
  231. package/package.json +54 -9
  232. package/scripts/com.agentrelay.factory-canary.plist.example +53 -0
  233. package/scripts/factory-canary.sh +89 -0
  234. package/index.d.ts +0 -2
  235. package/index.js +0 -7
@@ -0,0 +1,4372 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, resolve } from 'node:path';
3
+ import { FactoryConfigSchema } from '../config/schema.js';
4
+ import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
5
+ import { stateResolutionFromIds } from '../linear/state-resolver.js';
6
+ import { GithubMergeGate, closeProbePr } from '../github/index.js';
7
+ import { InMemoryStateStore } from '../state/in-memory-state-store.js';
8
+ import { containsExplicitIssueReference, containsIssueKey } from '../issue-key-match.js';
9
+ import { isInFactoryScope } from '../safety/factory-scope.js';
10
+ import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
11
+ import { renderAgentTask } from '../dispatch/templates.js';
12
+ import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
13
+ import { MountGithubRead, MountLinearWriteback, MountSlackWriteback } from '../writeback/index.js';
14
+ import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
15
+ import { issueKey } from './batch-tracker.js';
16
+ import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
17
+ import { terminatePids } from './reaper.js';
18
+ const ISSUE_ROOT = '/linear/issues';
19
+ const GITHUB_ISSUE_ROOT = '/github/repos';
20
+ const READY_EVENTS_LIMIT = 100;
21
+ const LIVE_ISSUE_GLOB = `${ISSUE_ROOT}/**`;
22
+ // Subscribe broadly under /github/repos and let isGithubIssueFilePath() /
23
+ // githubPullPathParts() re-validate the exact shape in the callback.
24
+ // globMatchesPath() treats a non-terminal `**` as a single-segment wildcard, so
25
+ // a more specific glob like `.../issues/**/*.json` would miss the two-segment
26
+ // <owner>/<repo> prefix and the nested <number>__<slug>/meta.json (and
27
+ // directory) event shapes this factory accepts. A terminal `**` prefix-matches
28
+ // every descendant, so all supported issue AND pull-request path variants reach
29
+ // the handler — including the PR change events the babysitter reacts to.
30
+ export const LIVE_GITHUB_ISSUE_GLOB = `${GITHUB_ISSUE_ROOT}/**`;
31
+ const LIVE_DEDUPE_LIMIT = 5_000;
32
+ const LIVE_EVENT_DRAIN_BATCH_SIZE = 5;
33
+ const COMPLETION_SWEEP_INTERVAL_MS = 15_000;
34
+ const COMPLETION_SWEEP_BATCH_SIZE = 2;
35
+ const PROBE_PR_GH_BACKOFF_MS = 60_000;
36
+ const PROBE_PR_GH_CANDIDATE_LIMIT = 200;
37
+ const SLACK_REPLY_EVENTS_LIMIT = 100;
38
+ const SLACK_REPLY_POLL_INTERVAL_MS = 5_000;
39
+ const AGENT_QUESTION_DEDUPE_LIMIT = 500;
40
+ const AGENT_NEEDS_INPUT_MARKER = '[factory-needs-input]';
41
+ const LEGACY_AGENT_NEEDS_INPUT_MARKER = 'FACTORY_NEEDS_INPUT';
42
+ // The babysitter DMs `factory` with this marker once it believes the PR is
43
+ // green; the orchestrator confirms readiness with one authoritative gh read
44
+ // before transitioning the issue to Human Review.
45
+ const AGENT_PR_READY_MARKER = '[factory-pr-ready]';
46
+ const FACTORY_E2E_MARKER = '[factory-e2e]';
47
+ const INJECTION_CONFIRMATION_TIMEOUT_MS = 90_000;
48
+ const INJECTION_RETRY_DELAY_MS = 1_000;
49
+ const INJECTION_RETRY_ATTEMPT_TIMEOUT_MS = 15_000;
50
+ const INJECTION_MAX_ATTEMPTS = 6;
51
+ const STOP_TEARDOWN_TIMEOUT_MS = 2_500;
52
+ const SLACK_EVENT_WATERMARK_CACHE_MS = 60_000;
53
+ const MERGE_GATE_MAX_ATTEMPTS = 12;
54
+ const MERGE_GATE_POLL_DELAY_MS = 10_000;
55
+ const MAX_LABEL_IMPLEMENTERS = 4;
56
+ const DISPATCH_FAILURE_HANDOFF_UNRESOLVED_TTL_MS = 5 * 60_000;
57
+ const DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS = 15_000;
58
+ const GITHUB_FACTORY_LABEL = 'factory';
59
+ const GITHUB_MIRROR_TITLE_PREFIX = '[factory]';
60
+ const GITHUB_MIRROR_SOURCE_PREFIX = 'Source: ';
61
+ export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
62
+ export const DEFAULT_FACTORY_LOOP_REGISTRY_PATH = '/tmp/factory-run/factory-loop-registry.json';
63
+ const realClock = {
64
+ now: () => Date.now(),
65
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
66
+ };
67
+ export function createFactory(config, ports) {
68
+ return new FactoryLoop(FactoryConfigSchema.parse(config), ports);
69
+ }
70
+ export class FactoryLoop {
71
+ #config;
72
+ #mount;
73
+ #states;
74
+ #fleet;
75
+ #triage;
76
+ #linear;
77
+ #slack;
78
+ #mergeGate;
79
+ #probeCloser;
80
+ #probePrResolver;
81
+ #customProbePrResolver;
82
+ #probePrGhRunner;
83
+ #logger;
84
+ #clock;
85
+ #processIdentityReader;
86
+ #processFinder;
87
+ #kill;
88
+ #readChildPids;
89
+ #terminationGraceMs;
90
+ #state;
91
+ #workspaceId;
92
+ #batchView;
93
+ #batchReady;
94
+ #listeners = new Map();
95
+ #counters = {};
96
+ #resumeInFlight = new Map();
97
+ #slackWatchers = new Map();
98
+ #slackWatcherStarts = new Map();
99
+ // Agents we've already logged an ambiguous-PID-lookup warning for, so the
100
+ // reaper doesn't spam the same benign "ambiguous process lookup" line on every
101
+ // poll (a joined/cloud agent has no local PID to resolve — expected).
102
+ #ambiguousLookupWarned = new Set();
103
+ // Last invalid-label failure signature we posted per issue, so a stuck Ready
104
+ // issue (or the comment writeback's own change event) does not re-post the
105
+ // same notice every cycle. Cleared once the issue dispatches successfully.
106
+ #labelDispatchFailures = new Map();
107
+ #postMergeDoneAdvances = new Set();
108
+ #slackDegraded = false;
109
+ #slackDegradedReason;
110
+ #slackWritebackFailureDegraded = false;
111
+ #slackWritebackFailureBackoffUntilMs = 0;
112
+ #slackEventWatermarkCache;
113
+ #slackEventWatermarkRefresh;
114
+ #subscription;
115
+ #livePollTimer;
116
+ #livePollInFlight = false;
117
+ #liveEventCursor;
118
+ #liveEventHighWatermark;
119
+ #liveConnectStartedAtMs = 0;
120
+ #liveReplaySkewMarginMs = 0;
121
+ #liveHeartbeatTimer;
122
+ #liveHeartbeatActive = false;
123
+ #liveHeartbeatInFlight = false;
124
+ #liveHeartbeatRefresh;
125
+ #liveHeartbeatLastWriteMs = 0;
126
+ #stoppingHeartbeatRefreshActive = false;
127
+ #liveEventQueue = [];
128
+ #liveEventDrainScheduled = false;
129
+ #liveEventDrainActive = false;
130
+ // Holds back the live-event drain while the startup full pull runs, so events
131
+ // that arrive during the pull buffer and drain afterward (batch dedupe then
132
+ // suppresses any overlap with what the pull already dispatched).
133
+ #deferLiveEventDrain = false;
134
+ #completionSweepTimer;
135
+ #completionSweepActive = false;
136
+ #completionInFlight = new Set();
137
+ // Issue keys for which a babysitter has already been spawned, so repeated PR
138
+ // webhooks / agent-exit safety nets don't respawn it.
139
+ #babysitterSpawned = new Set();
140
+ // Issue key -> the open PR the babysitter is shepherding, including the
141
+ // webhook-fed mount path so readiness can re-read PR meta without a gh call.
142
+ #babysitterPr = new Map();
143
+ #probePrGhBackoffUntilMs = new Map();
144
+ #probePrResolvedCache = new Map();
145
+ // GitHub issue mirror-id -> resolved Linear mirror path, so repeat ingestion
146
+ // cycles read the mirror directly instead of re-scanning all Linear issues.
147
+ #githubMirrorPathCache = new Map();
148
+ #seenLiveEvents = new Set();
149
+ #offAgentExit;
150
+ #offDeliveryFailed;
151
+ #offAgentMessage;
152
+ // undefined = readiness not yet probed; resolved lazily so the standalone
153
+ // runOnce() path (which skips #start) still ingests when the mount is present.
154
+ #githubIngestionEnabled;
155
+ #integrationInstructions;
156
+ #integrationInstructionsRefresh;
157
+ #starting;
158
+ #started = false;
159
+ #stopping = false;
160
+ constructor(config, ports) {
161
+ this.#config = config;
162
+ this.#mount = ports.mount;
163
+ // Resolved role<->state mapping. The CLI injects a name-resolved, per-team
164
+ // resolution via ports; fall back to one built from explicit stateIds plus
165
+ // the configured role NAMES so name->UUID backfill still works for sparse
166
+ // synced records (state.name but no state.id) without the states catalog.
167
+ this.#states = ports.stateResolution ?? stateResolutionFromIds(config.stateIds, config.linear.states);
168
+ installFactoryDraftPredicate(this.#mount, config);
169
+ this.#fleet = ports.fleet;
170
+ this.#triage = ports.triage ?? new TieredTriage(new HeuristicTriage());
171
+ this.#linear = ports.linear ?? MountLinearWriteback(ports.mount, {
172
+ safety: config.safety,
173
+ });
174
+ this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
175
+ void (ports.github ?? MountGithubRead(ports.mount));
176
+ this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
177
+ this.#probeCloser = ports.probeCloser ?? closeProbePr;
178
+ this.#customProbePrResolver = Boolean(ports.probePrResolver);
179
+ this.#probePrGhRunner = ports.probePrGhRunner ?? failClosedGhRunner;
180
+ this.#probePrResolver = ports.probePrResolver ?? ((issue) => this.#resolveIssuePr(issue));
181
+ this.#logger = ports.logger ?? console;
182
+ this.#clock = ports.clock ?? realClock;
183
+ this.#processIdentityReader = ports.processIdentityReader ?? readProcessIdentity;
184
+ this.#processFinder = ports.processFinder ?? ((agentName, opts) => findAgentProcessByName(agentName, {
185
+ readProcessIdentity: this.#processIdentityReader,
186
+ protectedPids: opts?.protectedPids,
187
+ }));
188
+ this.#kill = ports.kill ?? process.kill;
189
+ this.#readChildPids = ports.readChildPids;
190
+ this.#terminationGraceMs = ports.terminationGraceMs;
191
+ this.#workspaceId = config.workspaceId ?? 'default';
192
+ this.#state = ports.stateStore ?? new InMemoryStateStore({
193
+ batchSize: config.batchSize,
194
+ agentQuestionDedupeLimit: AGENT_QUESTION_DEDUPE_LIMIT,
195
+ });
196
+ this.#batchReady = this.#state.getBatch(this.#workspaceId).then((batch) => {
197
+ this.#batchView = batch;
198
+ return batch;
199
+ });
200
+ this.#wireFleetEvents();
201
+ }
202
+ async #resolveIntegrationInstructions() {
203
+ if (this.#integrationInstructionsRefresh) {
204
+ return this.#integrationInstructionsRefresh;
205
+ }
206
+ this.#integrationInstructionsRefresh = this.#doResolveIntegrationInstructions();
207
+ try {
208
+ return await this.#integrationInstructionsRefresh;
209
+ }
210
+ finally {
211
+ this.#integrationInstructionsRefresh = undefined;
212
+ }
213
+ }
214
+ async #doResolveIntegrationInstructions() {
215
+ if (this.#integrationInstructions !== undefined) {
216
+ return this.#integrationInstructions;
217
+ }
218
+ try {
219
+ const reader = {
220
+ readFile: async (path) => {
221
+ try {
222
+ const { content } = await this.#mount.readFile(path);
223
+ return typeof content === 'string' ? content : undefined;
224
+ }
225
+ catch {
226
+ return undefined;
227
+ }
228
+ },
229
+ listPaths: async (prefix) => {
230
+ return this.#mount.listTree(prefix);
231
+ },
232
+ };
233
+ const descriptors = await deriveDescriptorsFromMount(reader);
234
+ // The package emits paths relative to the daemon's .integrations mount,
235
+ // but the agent runs in its repo clonePath — a relative `.integrations/...`
236
+ // path is unreachable from there. Absolutize every writeback path to the
237
+ // daemon's mount root so the prescriptive instructions are actionable.
238
+ const root = this.#integrationsMountRoot();
239
+ const abs = (p) => (isAbsolute(p) ? p : resolve(root, '..', p));
240
+ const absoluteDescriptors = descriptors.map((descriptor) => ({
241
+ ...descriptor,
242
+ mountRoot: abs(descriptor.mountRoot),
243
+ ...(descriptor.discoveryRoot ? { discoveryRoot: abs(descriptor.discoveryRoot) } : {}),
244
+ writableResources: descriptor.writableResources.map((res) => ({
245
+ ...res,
246
+ path: abs(res.path),
247
+ ...(res.createExamplePath ? { createExamplePath: abs(res.createExamplePath) } : {}),
248
+ ...(res.schemaPath ? { schemaPath: abs(res.schemaPath) } : {}),
249
+ })),
250
+ }));
251
+ this.#integrationInstructions = prescriptiveInstructions(absoluteDescriptors);
252
+ return this.#integrationInstructions;
253
+ }
254
+ catch {
255
+ this.#logger.warn?.('[factory] failed to resolve integration instructions from mount');
256
+ return undefined;
257
+ }
258
+ }
259
+ async #batch() {
260
+ if (this.#batchView) {
261
+ return this.#batchView;
262
+ }
263
+ const batch = await this.#batchReady;
264
+ this.#batchView = batch;
265
+ return batch;
266
+ }
267
+ async start(opts = {}) {
268
+ if (this.#started) {
269
+ return;
270
+ }
271
+ if (this.#starting) {
272
+ return this.#starting;
273
+ }
274
+ this.#starting = this.#start(opts);
275
+ try {
276
+ await this.#starting;
277
+ }
278
+ finally {
279
+ this.#starting = undefined;
280
+ }
281
+ }
282
+ async #start(opts) {
283
+ this.#stopping = false;
284
+ const ready = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
285
+ if (ready !== 'ready') {
286
+ this.#error(new Error(`${ISSUE_ROOT} sub-root is not mounted`));
287
+ return;
288
+ }
289
+ await this.#ensureGithubIngestionReady();
290
+ this.#wireFleetEvents();
291
+ if (opts.mode === 'live') {
292
+ this.#started = true;
293
+ try {
294
+ await this.#startLiveSubscription(opts.liveSubscription);
295
+ await this.#rearmSlackReplyWatchers();
296
+ this.#scheduleCompletionSweep(0);
297
+ return;
298
+ }
299
+ catch (error) {
300
+ this.#started = false;
301
+ await this.#stopLiveHeartbeat('stopping');
302
+ throw error;
303
+ }
304
+ }
305
+ await this.#backfillReadyIssues();
306
+ this.#subscription = this.#mount.subscribe([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB], (event) => {
307
+ // The SDK types `resource` as always-present, but the polling fallback and
308
+ // degraded-sync paths can deliver events without it. Skip those rather
309
+ // than throwing (which would otherwise crash the subscription handler).
310
+ const path = changeEventPath(event);
311
+ if (!path) {
312
+ return;
313
+ }
314
+ if (isGithubPullFilePath(path)) {
315
+ void this.#handlePrChange(path);
316
+ return;
317
+ }
318
+ if (isGithubIssueFilePath(path)) {
319
+ void this.#handleGithubIssueChange(path, { dryRun: this.#config.dryRun });
320
+ return;
321
+ }
322
+ void this.#handleChange(path);
323
+ });
324
+ this.#started = true;
325
+ await this.#rearmSlackReplyWatchers();
326
+ this.#scheduleCompletionSweep(0);
327
+ }
328
+ async stop() {
329
+ this.#started = false;
330
+ this.#stopping = true;
331
+ if (this.#completionSweepTimer)
332
+ clearTimeout(this.#completionSweepTimer);
333
+ this.#completionSweepTimer = undefined;
334
+ this.#stoppingHeartbeatRefreshActive = await this.#stopLiveHeartbeat('stopping');
335
+ try {
336
+ await this.#releaseInFlightAgents('factory-stopped');
337
+ if (this.#livePollTimer)
338
+ clearTimeout(this.#livePollTimer);
339
+ this.#livePollTimer = undefined;
340
+ this.#livePollInFlight = false;
341
+ this.#liveEventQueue.length = 0;
342
+ this.#completionInFlight.clear();
343
+ this.#babysitterSpawned.clear();
344
+ this.#babysitterPr.clear();
345
+ const subscription = this.#subscription;
346
+ this.#subscription = undefined;
347
+ await this.#boundedStopTeardown('factory subscription unsubscribe', () => subscription?.unsubscribe());
348
+ await Promise.all([...this.#slackWatchers.values()].map((watcher) => watcher.stop()));
349
+ this.#slackWatchers.clear();
350
+ await this.#state.clearSlackThreads(this.#workspaceId);
351
+ this.#slackWatcherStarts.clear();
352
+ this.#offAgentExit?.();
353
+ this.#offDeliveryFailed?.();
354
+ this.#offAgentMessage?.();
355
+ this.#offAgentExit = undefined;
356
+ this.#offDeliveryFailed = undefined;
357
+ this.#offAgentMessage = undefined;
358
+ await this.#fleet.dispose();
359
+ }
360
+ finally {
361
+ this.#stoppingHeartbeatRefreshActive = false;
362
+ }
363
+ }
364
+ async #boundedStopTeardown(label, teardown) {
365
+ let timer;
366
+ const action = Promise.resolve()
367
+ .then(teardown)
368
+ .then(() => ({ status: 'done' }), (error) => ({ status: 'error', error }));
369
+ const timeout = new Promise((resolve) => {
370
+ timer = setTimeout(() => resolve({ status: 'timeout' }), STOP_TEARDOWN_TIMEOUT_MS);
371
+ timer.unref?.();
372
+ });
373
+ const result = await Promise.race([action, timeout]);
374
+ if (timer) {
375
+ clearTimeout(timer);
376
+ }
377
+ if (result.status === 'timeout') {
378
+ this.#logger.warn?.(`[factory] ${label} timed out after ${STOP_TEARDOWN_TIMEOUT_MS}ms; continuing shutdown and allowing the server-side subscription to expire`, {
379
+ timeoutMs: STOP_TEARDOWN_TIMEOUT_MS,
380
+ });
381
+ }
382
+ else if (result.status === 'error') {
383
+ this.#logger.warn?.(`[factory] failed while stopping ${label}; continuing shutdown`, result.error);
384
+ }
385
+ }
386
+ async dispose() {
387
+ await this.stop();
388
+ }
389
+ async #startLiveSubscription(overrides = {}) {
390
+ const options = this.#liveOptions(overrides);
391
+ await this.#startLiveHeartbeat();
392
+ this.#liveConnectStartedAtMs = this.#clock.now();
393
+ this.#liveReplaySkewMarginMs = options.replaySkewMarginMs;
394
+ const highWatermark = await this.#currentEventHighWatermark();
395
+ this.#liveEventHighWatermark = highWatermark.highWatermark;
396
+ this.#seenLiveEvents.clear();
397
+ this.#logger.info?.('[factory] live subscription starting', {
398
+ transport: options.transport,
399
+ highWatermark: this.#liveEventHighWatermark,
400
+ replaySkewMarginMs: this.#liveReplaySkewMarginMs,
401
+ highWatermarkRouteUnavailable: highWatermark.routeUnavailable,
402
+ });
403
+ // Register the live subscription BEFORE the startup full pull so an issue
404
+ // that becomes Ready *during* the pull is captured, not lost in the window
405
+ // between listTree and subscribe. Events buffer (deferred drain) until the
406
+ // pull finishes; batch dedupe then suppresses any overlap with what the
407
+ // pull already dispatched.
408
+ if (options.transport !== 'poll') {
409
+ // LIVE_GITHUB_ISSUE_GLOB is a terminal `${GITHUB_ISSUE_ROOT}/**`, so it
410
+ // already covers the PR change events the babysitter consumes; pull-event
411
+ // *processing* is gated on babysitter.enabled in #prepareLiveEventForDrain.
412
+ this.#subscription = this.#mount.subscribe([LIVE_ISSUE_GLOB, LIVE_GITHUB_ISSUE_GLOB], (event) => {
413
+ this.#enqueueLiveEvent(event);
414
+ }, { from: 'now', coalesce: 'none' });
415
+ }
416
+ if (highWatermark.routeUnavailable) {
417
+ this.#increment('liveHighWatermarkFullPullFallbacks');
418
+ this.#logger.info?.('[factory] live subscription high-watermark route unavailable; running startup full pull before draining buffered events');
419
+ this.#deferLiveEventDrain = true;
420
+ try {
421
+ await this.runOnce();
422
+ }
423
+ catch (error) {
424
+ // A startup pull failure must not abort the daemon: log it and fall back
425
+ // to the live event stream (plus any buffered events) instead of leaving
426
+ // the factory down.
427
+ this.#increment('liveHighWatermarkFullPullErrors');
428
+ this.#error(error);
429
+ }
430
+ finally {
431
+ this.#deferLiveEventDrain = false;
432
+ this.#scheduleLiveEventDrain();
433
+ }
434
+ await this.#refreshLiveHeartbeatIfDue();
435
+ }
436
+ if (options.transport === 'poll') {
437
+ this.#liveEventCursor = await this.#currentEventCursor(options.eventLimit);
438
+ this.#scheduleLivePoll(0, options);
439
+ }
440
+ }
441
+ async #startLiveHeartbeat() {
442
+ this.#liveHeartbeatActive = true;
443
+ await this.#writeLiveHeartbeat('running');
444
+ this.#scheduleLiveHeartbeatRefresh();
445
+ }
446
+ async #stopLiveHeartbeat(status) {
447
+ if (!this.#liveHeartbeatActive && !this.#liveHeartbeatTimer) {
448
+ return false;
449
+ }
450
+ this.#liveHeartbeatActive = false;
451
+ if (this.#liveHeartbeatTimer) {
452
+ clearTimeout(this.#liveHeartbeatTimer);
453
+ this.#liveHeartbeatTimer = undefined;
454
+ }
455
+ await this.#liveHeartbeatRefresh;
456
+ await this.#writeLiveHeartbeat(status);
457
+ return status === 'stopping';
458
+ }
459
+ async #refreshStoppingHeartbeat() {
460
+ if (!this.#stoppingHeartbeatRefreshActive) {
461
+ return;
462
+ }
463
+ try {
464
+ await this.#writeLiveHeartbeat('stopping');
465
+ }
466
+ catch (error) {
467
+ this.#logger.warn?.('[factory] failed to refresh stopping heartbeat', error);
468
+ }
469
+ }
470
+ #scheduleLiveHeartbeatRefresh() {
471
+ if (!this.#liveHeartbeatActive || this.#liveHeartbeatTimer) {
472
+ return;
473
+ }
474
+ // This heartbeat proves daemon process liveness for the external crash reaper.
475
+ // MountClient subscriptions do not expose connected/keepalive state here, so
476
+ // subscription-wedge detection remains a separate watchdog concern.
477
+ this.#liveHeartbeatTimer = setTimeout(() => {
478
+ this.#liveHeartbeatTimer = undefined;
479
+ this.#liveHeartbeatRefresh = this.#refreshLiveHeartbeat()
480
+ .finally(() => {
481
+ this.#liveHeartbeatRefresh = undefined;
482
+ });
483
+ }, liveHeartbeatIntervalMs(this.#config.loop.heartbeatStaleMs));
484
+ this.#liveHeartbeatTimer.unref?.();
485
+ }
486
+ async #refreshLiveHeartbeat() {
487
+ if (!this.#liveHeartbeatActive || this.#liveHeartbeatInFlight) {
488
+ return;
489
+ }
490
+ this.#liveHeartbeatInFlight = true;
491
+ try {
492
+ await this.#writeLiveHeartbeat('running');
493
+ }
494
+ catch (error) {
495
+ this.#logger.warn?.('[factory] failed to refresh live daemon heartbeat', error);
496
+ }
497
+ finally {
498
+ this.#liveHeartbeatInFlight = false;
499
+ this.#scheduleLiveHeartbeatRefresh();
500
+ }
501
+ }
502
+ async #writeLiveHeartbeat(status) {
503
+ await this.#writeLoopHeartbeat(this.#config.loop.heartbeatPath, this.#config.loop.registryPath, status, 0, 0);
504
+ this.#liveHeartbeatLastWriteMs = this.#clock.now();
505
+ }
506
+ #liveOptions(overrides) {
507
+ return {
508
+ transport: overrides.transport ?? this.#config.liveSubscription.transport,
509
+ pollIntervalMs: overrides.pollIntervalMs ?? this.#config.liveSubscription.pollIntervalMs,
510
+ eventLimit: overrides.eventLimit ?? this.#config.liveSubscription.eventLimit,
511
+ replaySkewMarginMs: overrides.replaySkewMarginMs ?? this.#config.liveSubscription.replaySkewMarginMs,
512
+ };
513
+ }
514
+ async #currentEventCursor(limit) {
515
+ let cursor;
516
+ for (;;) {
517
+ const page = await this.#mount.getEvents({ cursor, limit });
518
+ cursor = eventCursorAfterPage(cursor, page.events, page.nextCursor);
519
+ if (!page.nextCursor)
520
+ return cursor;
521
+ }
522
+ }
523
+ async #currentEventHighWatermark() {
524
+ try {
525
+ return {
526
+ highWatermark: await this.#mount.getEventHighWatermark?.(),
527
+ routeUnavailable: false,
528
+ };
529
+ }
530
+ catch (error) {
531
+ this.#increment('liveHighWatermarkUnavailable');
532
+ this.#logger.warn?.('[factory] live subscription high-watermark unavailable', error);
533
+ return {
534
+ routeUnavailable: isHighWatermarkRouteUnavailable(error),
535
+ };
536
+ }
537
+ }
538
+ #scheduleLivePoll(delayMs, options) {
539
+ if (this.#livePollTimer || !this.#started)
540
+ return;
541
+ this.#livePollTimer = setTimeout(() => {
542
+ this.#livePollTimer = undefined;
543
+ void this.#pollLiveEvents(options).finally(() => {
544
+ if (this.#started)
545
+ this.#scheduleLivePoll(options.pollIntervalMs, options);
546
+ });
547
+ }, delayMs);
548
+ }
549
+ async #pollLiveEvents(options) {
550
+ if (this.#livePollInFlight)
551
+ return;
552
+ this.#livePollInFlight = true;
553
+ try {
554
+ let cursor = this.#liveEventCursor;
555
+ for (;;) {
556
+ const page = await this.#mount.getEvents({ cursor, limit: options.eventLimit });
557
+ await this.#handleLiveEventsWithYield(page.events);
558
+ const nextCursor = eventCursorAfterPage(cursor, page.events, page.nextCursor);
559
+ this.#liveEventCursor = nextCursor;
560
+ if (!page.nextCursor || page.nextCursor === cursor)
561
+ break;
562
+ cursor = page.nextCursor;
563
+ }
564
+ }
565
+ catch (error) {
566
+ this.#logger.warn?.('[factory] live subscription poll failed', error);
567
+ }
568
+ finally {
569
+ this.#livePollInFlight = false;
570
+ }
571
+ }
572
+ #enqueueLiveEvent(event) {
573
+ if (!this.#started)
574
+ return;
575
+ this.#liveEventQueue.push(event);
576
+ this.#scheduleLiveEventDrain();
577
+ }
578
+ #scheduleLiveEventDrain() {
579
+ if (this.#liveEventDrainScheduled || this.#liveEventDrainActive || this.#deferLiveEventDrain || !this.#started) {
580
+ return;
581
+ }
582
+ this.#liveEventDrainScheduled = true;
583
+ void liveEventYield()
584
+ .then(() => {
585
+ this.#liveEventDrainScheduled = false;
586
+ return this.#drainLiveEventQueue();
587
+ })
588
+ .catch((error) => {
589
+ this.#liveEventDrainScheduled = false;
590
+ this.#logger.warn?.('[factory] live issue event drain failed', error);
591
+ });
592
+ }
593
+ async #drainLiveEventQueue() {
594
+ if (this.#liveEventDrainActive) {
595
+ return;
596
+ }
597
+ this.#liveEventDrainActive = true;
598
+ try {
599
+ await this.#handleLiveEventsWithYield(this.#liveEventQueue);
600
+ }
601
+ finally {
602
+ this.#liveEventDrainActive = false;
603
+ if (this.#liveEventQueue.length > 0 && this.#started) {
604
+ this.#scheduleLiveEventDrain();
605
+ }
606
+ }
607
+ }
608
+ async #handleLiveEventsWithYield(events) {
609
+ let handled = 0;
610
+ const seenIssueKeys = new Set();
611
+ while (events.length > 0 && this.#started) {
612
+ const batch = events.splice(0, LIVE_EVENT_DRAIN_BATCH_SIZE);
613
+ const paths = [];
614
+ for (const event of batch) {
615
+ const path = await this.#prepareLiveEventForDrain(event, seenIssueKeys);
616
+ if (path) {
617
+ paths.push(path);
618
+ }
619
+ }
620
+ await Promise.all(paths.map((path) => this.#handlePreparedLiveChange(path)));
621
+ handled += batch.length;
622
+ await this.#refreshLiveHeartbeatIfDue();
623
+ if (events.length > 0) {
624
+ this.#increment('liveEventDrainYields');
625
+ await liveEventYield();
626
+ }
627
+ }
628
+ if (handled === 0) {
629
+ await this.#refreshLiveHeartbeatIfDue();
630
+ }
631
+ }
632
+ async #refreshLiveHeartbeatIfDue() {
633
+ if (!this.#liveHeartbeatActive)
634
+ return;
635
+ const intervalMs = liveHeartbeatIntervalMs(this.#config.loop.heartbeatStaleMs);
636
+ if (this.#clock.now() - this.#liveHeartbeatLastWriteMs < intervalMs)
637
+ return;
638
+ // A scheduled (real-timer) refresh may already be in flight carrying a
639
+ // timestamp captured before this drain batch advanced the clock. The
640
+ // #liveHeartbeatInFlight guard would otherwise make this due refresh a
641
+ // no-op, leaving the heartbeat stamped stale until the next interval. Wait
642
+ // for that write to settle, then re-check and force a current-timestamped
643
+ // write if we are still due so the heartbeat never falls behind the drain.
644
+ await this.#liveHeartbeatRefresh;
645
+ if (this.#clock.now() - this.#liveHeartbeatLastWriteMs < intervalMs)
646
+ return;
647
+ await this.#refreshLiveHeartbeat();
648
+ }
649
+ async #prepareLiveEventForDrain(event, seenIssueKeys) {
650
+ const path = changeEventPath(event);
651
+ if (!path) {
652
+ return undefined;
653
+ }
654
+ const isPullPath = isGithubPullFilePath(path);
655
+ if (!isIssueFilePath(path) && !isGithubIssueFilePath(path) && !isPullPath) {
656
+ return undefined;
657
+ }
658
+ if (isBeforeLiveCutoff(event.occurredAt, this.#liveConnectStartedAtMs, this.#liveReplaySkewMarginMs)) {
659
+ this.#increment('liveReplayEventsSuppressed');
660
+ this.#increment('liveReplayEventsSuppressedByTime');
661
+ this.#logger.debug?.('[factory] suppressed stale live issue event', {
662
+ id: event.id,
663
+ path,
664
+ occurredAt: event.occurredAt,
665
+ connectStartedAt: new Date(this.#liveConnectStartedAtMs).toISOString(),
666
+ replaySkewMarginMs: this.#liveReplaySkewMarginMs,
667
+ });
668
+ return undefined;
669
+ }
670
+ if (isAtOrBeforeHighWatermark(event.id, this.#liveEventHighWatermark)) {
671
+ this.#increment('liveReplayEventsSuppressed');
672
+ this.#increment('liveReplayEventsSuppressedByWatermark');
673
+ this.#logger.debug?.('[factory] suppressed replayed live issue event', {
674
+ id: event.id,
675
+ highWatermark: this.#liveEventHighWatermark,
676
+ path,
677
+ });
678
+ return undefined;
679
+ }
680
+ const dedupeKey = liveEventDedupeKey(event);
681
+ if (dedupeKey) {
682
+ if (this.#seenLiveEvents.has(dedupeKey)) {
683
+ this.#increment('liveDuplicateEventsSuppressed');
684
+ this.#logger.debug?.('[factory] suppressed duplicate live issue event', {
685
+ id: event.id,
686
+ path,
687
+ });
688
+ return undefined;
689
+ }
690
+ rememberLiveEvent(this.#seenLiveEvents, dedupeKey);
691
+ }
692
+ else {
693
+ this.#increment('liveEventsMissingIdentity');
694
+ this.#logger.warn?.('[factory] live issue event missing stable identity', { path });
695
+ }
696
+ if (isGithubIssueFilePath(path)) {
697
+ const sourceKey = `github:${path}`;
698
+ if (seenIssueKeys.has(sourceKey)) {
699
+ this.#increment('liveDuplicateIssueEventsSuppressed');
700
+ this.#logger.debug?.('[factory] suppressed duplicate live GitHub issue event in current drain', {
701
+ id: event.id,
702
+ path,
703
+ });
704
+ return undefined;
705
+ }
706
+ seenIssueKeys.add(sourceKey);
707
+ this.#recordArrivalLatency(event);
708
+ return path;
709
+ }
710
+ if (isPullPath) {
711
+ // Dedupe PR change events by path within a drain; the babysitter routing
712
+ // re-derives the issue from the PR head ref downstream.
713
+ const sourceKey = `pull:${path}`;
714
+ if (seenIssueKeys.has(sourceKey)) {
715
+ this.#increment('liveDuplicatePrEventsSuppressed');
716
+ return undefined;
717
+ }
718
+ seenIssueKeys.add(sourceKey);
719
+ this.#recordArrivalLatency(event);
720
+ return path;
721
+ }
722
+ const issueKey = keyFromPath(path);
723
+ if (seenIssueKeys.has(issueKey)) {
724
+ this.#increment('liveDuplicateIssueEventsSuppressed');
725
+ this.#logger.debug?.('[factory] suppressed duplicate live issue event for issue in current drain', {
726
+ id: event.id,
727
+ path,
728
+ issue: issueKey,
729
+ });
730
+ return undefined;
731
+ }
732
+ const issueRef = { key: issueKey, uuid: uuidFromPath(path) ?? issueKey, path };
733
+ const batch = await this.#batch();
734
+ if (batch.isInFlight(issueRef) || batch.isQueued(issueRef)) {
735
+ this.#increment('liveDuplicateIssueEventsSuppressed');
736
+ this.#logger.debug?.('[factory] suppressed duplicate live issue event for tracked issue', {
737
+ id: event.id,
738
+ path,
739
+ issue: issueKey,
740
+ });
741
+ return undefined;
742
+ }
743
+ seenIssueKeys.add(issueKey);
744
+ this.#recordArrivalLatency(event);
745
+ return path;
746
+ }
747
+ async #handlePreparedLiveChange(path) {
748
+ if (isGithubPullFilePath(path)) {
749
+ await this.#handlePrChange(path);
750
+ return;
751
+ }
752
+ if (isGithubIssueFilePath(path)) {
753
+ await this.#handleGithubIssueChange(path, { dryRun: this.#config.dryRun });
754
+ return;
755
+ }
756
+ await this.#handleChange(path, { requireRealIssue: true });
757
+ }
758
+ #scheduleCompletionSweep(delayMs = COMPLETION_SWEEP_INTERVAL_MS) {
759
+ if (!this.#started || this.#completionSweepTimer || this.#completionSweepActive) {
760
+ return;
761
+ }
762
+ this.#completionSweepTimer = setTimeout(() => {
763
+ this.#completionSweepTimer = undefined;
764
+ void this.#sweepPrStateCompletions('live-timer')
765
+ .catch((error) => {
766
+ this.#increment('completionSweepErrors');
767
+ this.#logger.warn?.('[factory] PR completion sweep failed', error);
768
+ })
769
+ .finally(() => {
770
+ if (this.#started)
771
+ this.#scheduleCompletionSweep();
772
+ });
773
+ }, delayMs);
774
+ this.#completionSweepTimer.unref?.();
775
+ }
776
+ async #sweepPrStateCompletions(reason) {
777
+ // When the babysitter owns PR-open, completion is driven by PR webhooks +
778
+ // the babysitter's readiness signal (see #handlePrChange / #handleAgentExit),
779
+ // not this polling sweep. Disabling it here is what makes the babysitter path
780
+ // webhook-driven rather than polled.
781
+ if (this.#config.babysitter.enabled) {
782
+ return;
783
+ }
784
+ if (this.#completionSweepActive) {
785
+ return;
786
+ }
787
+ this.#completionSweepActive = true;
788
+ try {
789
+ const batch = await this.#batch();
790
+ const records = batch.inFlight
791
+ .filter((record) => !record.dryRun && !this.#completionInFlight.has(issueKey(record.issue)));
792
+ if (records.length === 0) {
793
+ return;
794
+ }
795
+ this.#increment('completionSweepRuns');
796
+ for (let index = 0; index < records.length; index += COMPLETION_SWEEP_BATCH_SIZE) {
797
+ const candidates = await Promise.all(records.slice(index, index + COMPLETION_SWEEP_BATCH_SIZE).map(async (record) => {
798
+ const issue = await this.#readIssue(record.issue.path);
799
+ if (!issue || !isInFactoryScope(issue, this.#config.safety)) {
800
+ return undefined;
801
+ }
802
+ const pr = await this.#completionPrForIssue(issue);
803
+ if (!pr) {
804
+ this.#increment('completionSweepMissingPr');
805
+ return undefined;
806
+ }
807
+ if (pr.draft) {
808
+ this.#increment('completionSweepDraftPr');
809
+ this.#probePrGhBackoffUntilMs.set(issue.key, this.#clock.now() + PROBE_PR_GH_BACKOFF_MS);
810
+ return undefined;
811
+ }
812
+ return { record, pr };
813
+ }));
814
+ for (const candidate of candidates) {
815
+ if (!candidate || batch.getIssue(candidate.record.issue) !== candidate.record) {
816
+ continue;
817
+ }
818
+ this.#increment('completionSweepCompleted');
819
+ this.#logger.info?.('[factory] PR completion sweep completing issue', {
820
+ issue: candidate.record.issue.key,
821
+ repo: candidate.pr.repo,
822
+ prNumber: candidate.pr.prNumber,
823
+ reason,
824
+ });
825
+ // workaround for relay#1116: agents often exit as worker_exited after opening a PR,
826
+ // so PR state is the primary completion signal that frees the batch slot.
827
+ await this.#completeIssue(candidate.record);
828
+ }
829
+ await this.#refreshLiveHeartbeatIfDue();
830
+ if (index + COMPLETION_SWEEP_BATCH_SIZE < records.length) {
831
+ await liveEventYield();
832
+ }
833
+ }
834
+ }
835
+ finally {
836
+ this.#completionSweepActive = false;
837
+ }
838
+ }
839
+ async #completionPrForIssue(issue) {
840
+ if (this.#customProbePrResolver) {
841
+ return this.#probePrResolver(issue);
842
+ }
843
+ return this.#resolveIssuePr(issue, {
844
+ titleMarker: FACTORY_E2E_MARKER,
845
+ });
846
+ }
847
+ async #resolveIssuePr(issue, opts = {}) {
848
+ const key = issue.key;
849
+ const now = this.#clock.now();
850
+ const cached = this.#probePrResolvedCache.get(key);
851
+ if (cached && cached.expiresAtMs > now) {
852
+ return cached.pr;
853
+ }
854
+ const mountPr = await resolveIssuePrFromMount(this.#mount, this.#config, issue, opts);
855
+ if (mountPr) {
856
+ return mountPr;
857
+ }
858
+ const backoffUntil = this.#probePrGhBackoffUntilMs.get(key) ?? 0;
859
+ if (backoffUntil > now) {
860
+ this.#increment('probePrGhBackoffSkips');
861
+ return undefined;
862
+ }
863
+ const ghPr = await resolveIssuePrFromGh(this.#probePrGhRunner, this.#config, issue, opts, this.#logger);
864
+ this.#increment('probePrGhResolveAttempts');
865
+ if (ghPr) {
866
+ this.#probePrGhBackoffUntilMs.delete(key);
867
+ if (!ghPr.draft) {
868
+ this.#probePrResolvedCache.set(key, { pr: ghPr, expiresAtMs: now + PROBE_PR_GH_BACKOFF_MS });
869
+ }
870
+ this.#increment('probePrGhResolveHits');
871
+ return ghPr;
872
+ }
873
+ this.#probePrGhBackoffUntilMs.set(key, now + PROBE_PR_GH_BACKOFF_MS);
874
+ return undefined;
875
+ }
876
+ async runOnce(opts = {}) {
877
+ const dryRun = opts.dryRun ?? this.#config.dryRun;
878
+ await this.#ingestGithubIssues({ dryRun });
879
+ const paths = await this.#readyIssuePaths();
880
+ const pulled = [];
881
+ const triaged = [];
882
+ const dispatched = [];
883
+ const skipped = [];
884
+ for (const path of paths) {
885
+ const issue = await this.#readIssue(path);
886
+ if (issue) {
887
+ await this.#recordCanonicalIssueState(issue);
888
+ }
889
+ if (!issue) {
890
+ continue;
891
+ }
892
+ pulled.push(issueRef(issue));
893
+ const dispatchBlock = await this.#dispatchBlockReason(issue);
894
+ if (dispatchBlock) {
895
+ skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
896
+ continue;
897
+ }
898
+ const batch = await this.#batch();
899
+ if (batch.isInFlight(issue) || batch.isQueued(issue)) {
900
+ skipped.push({ issue: issueRef(issue), reason: 'already tracked' });
901
+ continue;
902
+ }
903
+ if (!this.#states.isRole(issue.stateId, 'readyForAgent')) {
904
+ skipped.push({ issue: issueRef(issue), reason: 'live state is not ready-for-agent' });
905
+ continue;
906
+ }
907
+ if (!isInFactoryScope(issue, this.#config.safety)) {
908
+ skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
909
+ continue;
910
+ }
911
+ if (!isRealLinearIssue(issue)) {
912
+ skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
913
+ continue;
914
+ }
915
+ const decision = await this.triageIssue(issue);
916
+ triaged.push(decision);
917
+ const result = await this.dispatch(decision, { dryRun });
918
+ if (result.agents.length === 0 && !dryRun) {
919
+ skipped.push({ issue: decision.issue, reason: 'queued or escalated' });
920
+ }
921
+ else {
922
+ dispatched.push(result);
923
+ }
924
+ }
925
+ return { pulled, triaged, dispatched, skipped, dryRun, slackDegraded: this.#slackDegraded };
926
+ }
927
+ async runLoop(opts = {}) {
928
+ const maxIterations = Math.min(5, Math.max(1, Math.trunc(opts.maxIterations ?? this.#config.loop.maxIterations)));
929
+ const maxConsecutiveFailures = Math.min(5, Math.max(1, Math.trunc(opts.maxConsecutiveFailures ?? this.#config.loop.maxConsecutiveFailures)));
930
+ const heartbeatPath = opts.heartbeatPath ?? this.#config.loop.heartbeatPath;
931
+ const registryPath = opts.registryPath ?? this.#config.loop.registryPath;
932
+ const reports = [];
933
+ let consecutiveFailures = 0;
934
+ let completed = false;
935
+ try {
936
+ // Re-arm Slack reply watchers for any issue that is already in-flight with
937
+ // a persisted dispatch thread before the first iteration runs. The loop
938
+ // path never calls #start(), so without this a watcher only lives for the
939
+ // process that originally dispatched — replies after a restart are dropped.
940
+ await this.#rearmSlackReplyWatchers();
941
+ for (let iteration = 0; iteration < maxIterations; iteration += 1) {
942
+ await this.#writeLoopHeartbeat(heartbeatPath, registryPath, 'running', iteration, maxIterations);
943
+ try {
944
+ await this.#sweepPrStateCompletions('run-loop');
945
+ reports.push(await this.runOnce({ dryRun: opts.dryRun }));
946
+ consecutiveFailures = 0;
947
+ }
948
+ catch (error) {
949
+ consecutiveFailures += 1;
950
+ this.#increment('loopIterationFailures');
951
+ this.#error(error);
952
+ reports.push(failedIterationReport(error, opts.dryRun ?? this.#config.dryRun));
953
+ await this.#reapDispatchFailureHandoffsNow(heartbeatPath, registryPath);
954
+ await this.#writeLoopHeartbeat(heartbeatPath, registryPath, 'running', iteration + 1, maxIterations);
955
+ if (consecutiveFailures >= maxConsecutiveFailures) {
956
+ this.#increment('loopCircuitBreaks');
957
+ this.#logger.error?.('[factory] stopping loop after consecutive iteration failures', {
958
+ consecutiveFailures,
959
+ maxConsecutiveFailures,
960
+ });
961
+ break;
962
+ }
963
+ continue;
964
+ }
965
+ await this.#writeLoopHeartbeat(heartbeatPath, registryPath, 'running', iteration + 1, maxIterations);
966
+ }
967
+ this.#increment('loopIdle');
968
+ await this.#writeLoopHeartbeat(heartbeatPath, registryPath, 'idle', reports.length, maxIterations);
969
+ completed = true;
970
+ return reports;
971
+ }
972
+ finally {
973
+ if (!completed) {
974
+ await this.#writeLoopHeartbeat(heartbeatPath, registryPath, 'stopping', reports.length, maxIterations);
975
+ }
976
+ await this.stop();
977
+ }
978
+ }
979
+ async triageIssue(issue) {
980
+ return this.#triage.triage(issue, {
981
+ config: this.#config,
982
+ repoMap: repoMapFromConfig(this.#config),
983
+ });
984
+ }
985
+ async dispatch(decision, opts = {}) {
986
+ const dryRun = opts.dryRun ?? this.#config.dryRun;
987
+ const batch = await this.#batch();
988
+ const existingRecord = batch.getIssue(decision.issue);
989
+ if (existingRecord?.result) {
990
+ return existingRecord.result;
991
+ }
992
+ const blockReason = await this.#dispatchBlockReason(decision.issue);
993
+ if (blockReason) {
994
+ const error = new Error(`Refusing to dispatch ${decision.issue.key}: ${blockReason}`);
995
+ this.#error(error, decision.issue);
996
+ throw error;
997
+ }
998
+ const liveIssue = await this.#readIssue(decision.issue.path);
999
+ if (!liveIssue || !isInFactoryScope(liveIssue, this.#config.safety)) {
1000
+ const error = new Error(`Refusing to dispatch ${decision.issue.key}: not factory-e2e scope`);
1001
+ this.#error(error, decision.issue);
1002
+ throw error;
1003
+ }
1004
+ if (!isRealLinearIssue(liveIssue)) {
1005
+ const error = new Error(`Refusing to dispatch ${decision.issue.key}: not reconciled real Linear issue`);
1006
+ this.#error(error, decision.issue);
1007
+ throw error;
1008
+ }
1009
+ const escalationReason = triageEscalationReason(decision);
1010
+ if (escalationReason) {
1011
+ await this.#escalateTriageToSlack(decision, escalationReason, dryRun);
1012
+ this.#error(new Error(`${escalationReason}; escalation required`), decision.issue);
1013
+ return { issue: decision.issue, agents: [], dryRun };
1014
+ }
1015
+ // TODO(AR-274 follow-up): short-circuit LLM triage once label-derived
1016
+ // routes are authoritative for dispatch identity.
1017
+ const labelDispatch = labelDerivedDispatchDecision(liveIssue, decision, this.#config);
1018
+ if (!labelDispatch.ok) {
1019
+ const comment = labelDispatchFailureComment(decision.issue, labelDispatch);
1020
+ this.#logger.warn?.('[factory] skipped dispatch due to invalid repo labels', {
1021
+ issue: decision.issue.key,
1022
+ labels: liveIssue.labels,
1023
+ offendingLabels: labelDispatch.offendingLabels,
1024
+ reason: labelDispatch.reason,
1025
+ });
1026
+ if (!dryRun) {
1027
+ const signature = labelDispatchFailureSignature(labelDispatch);
1028
+ if (this.#labelDispatchFailures.get(decision.issue.key) !== signature) {
1029
+ try {
1030
+ await this.#linear.postComment(liveIssue, comment);
1031
+ // Record only after a successful post so a failed writeback retries
1032
+ // next cycle rather than being suppressed as already-notified.
1033
+ this.#labelDispatchFailures.set(decision.issue.key, signature);
1034
+ }
1035
+ catch (error) {
1036
+ this.#logger.warn?.('[factory] label dispatch block comment writeback skipped', error);
1037
+ }
1038
+ }
1039
+ }
1040
+ return { issue: decision.issue, agents: [], comments: [comment], dryRun };
1041
+ }
1042
+ const dispatchDecision = labelDispatch.decision;
1043
+ // A valid label resolution clears any prior failure notice so a later
1044
+ // regression posts a fresh, actionable comment instead of being deduped.
1045
+ this.#labelDispatchFailures.delete(dispatchDecision.issue.key);
1046
+ await this.#recordDispatchAttempt(dispatchDecision.issue);
1047
+ const record = batch.start(dispatchDecision, dryRun);
1048
+ if (!record) {
1049
+ await this.#clearDispatchInFlight(dispatchDecision.issue);
1050
+ this.#increment('queued');
1051
+ this.#emit('issue-queued', { issue: dispatchDecision.issue });
1052
+ return { issue: dispatchDecision.issue, agents: [], dryRun };
1053
+ }
1054
+ if (record.result) {
1055
+ return record.result;
1056
+ }
1057
+ const spawnedForReaperHandoff = [];
1058
+ try {
1059
+ const specs = dispatchSpecs(dispatchDecision);
1060
+ const agents = [];
1061
+ for (const spec of specs) {
1062
+ const spawned = await this.#spawnAgent(record, spec, dryRun);
1063
+ const tracked = record.agents.get(spawned.name);
1064
+ if (tracked) {
1065
+ spawnedForReaperHandoff.push({
1066
+ issue: record.issue,
1067
+ name: spawned.name,
1068
+ tracked: cloneTrackedAgent(tracked),
1069
+ persistedAtMs: this.#clock.now(),
1070
+ });
1071
+ }
1072
+ agents.push({ name: spawned.name, role: spec.role });
1073
+ }
1074
+ await this.#writeInFlightRegistry();
1075
+ const comment = dispatchComment(dispatchDecision, agents);
1076
+ let implementingStateId;
1077
+ if (!dryRun) {
1078
+ const issue = await this.#readIssue(dispatchDecision.issue.path);
1079
+ if (!issue || !this.#states.isRole(issue.stateId, 'readyForAgent')) {
1080
+ throw new Error(`Live state changed before writeback for ${dispatchDecision.issue.key}`);
1081
+ }
1082
+ implementingStateId = this.#states.idFor(issue.team, 'agentImplementing');
1083
+ try {
1084
+ await this.#linear.postComment(issue, comment);
1085
+ }
1086
+ catch (error) {
1087
+ this.#logger.warn?.('[factory] comment writeback skipped', error);
1088
+ }
1089
+ await this.#linear.setState(issue, implementingStateId);
1090
+ this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
1091
+ }
1092
+ const result = {
1093
+ issue: dispatchDecision.issue,
1094
+ agents,
1095
+ comments: [comment],
1096
+ stateId: implementingStateId,
1097
+ dryRun,
1098
+ };
1099
+ record.result = result;
1100
+ this.#increment('dispatched');
1101
+ this.#emit('dispatched', { issue: dispatchDecision.issue, result });
1102
+ if (!dryRun) {
1103
+ await this.#ensureSlackDispatchThread(record, result);
1104
+ await this.#sendImplementerTask(record);
1105
+ await this.#sendCriticalReviewerMessage(record);
1106
+ }
1107
+ return result;
1108
+ }
1109
+ catch (error) {
1110
+ await this.#persistDispatchFailureReaperHandoff(record, spawnedForReaperHandoff);
1111
+ await this.#recordDispatchFailure(decision.issue);
1112
+ batch.abandon(decision.issue);
1113
+ this.#error(error, decision.issue);
1114
+ throw error;
1115
+ }
1116
+ }
1117
+ status() {
1118
+ const batch = this.#batchView;
1119
+ return {
1120
+ inFlight: batch?.inFlight.map((record) => record.issue) ?? [],
1121
+ queued: batch?.queued.map((queued) => queued.issue) ?? [],
1122
+ counters: { ...this.#counters },
1123
+ slackDegraded: this.#slackDegraded,
1124
+ slackDegradedReason: this.#slackDegradedReason,
1125
+ };
1126
+ }
1127
+ on(event, listener) {
1128
+ let listeners = this.#listeners.get(event);
1129
+ if (!listeners) {
1130
+ listeners = new Set();
1131
+ this.#listeners.set(event, listeners);
1132
+ }
1133
+ listeners.add(listener);
1134
+ return () => {
1135
+ listeners?.delete(listener);
1136
+ };
1137
+ }
1138
+ #wireFleetEvents() {
1139
+ if (!this.#offAgentExit) {
1140
+ this.#offAgentExit = this.#fleet.onAgentExit((name, reason) => {
1141
+ void this.#handleAgentExit(name, reason);
1142
+ });
1143
+ }
1144
+ if (!this.#offDeliveryFailed) {
1145
+ this.#offDeliveryFailed = this.#fleet.onDeliveryFailed?.((info) => {
1146
+ void this.#handleDeliveryFailed(info);
1147
+ });
1148
+ }
1149
+ if (!this.#offAgentMessage) {
1150
+ this.#offAgentMessage = this.#fleet.onAgentMessage?.((message) => {
1151
+ void this.#handleAgentMessage(message);
1152
+ });
1153
+ }
1154
+ }
1155
+ async #backfillReadyIssues() {
1156
+ const page = await this.#mount.getEvents({ limit: READY_EVENTS_LIMIT });
1157
+ const allPaths = page.events.map((event) => changeEventPath(event)).filter((p) => Boolean(p));
1158
+ const eventPaths = allPaths.filter(isIssueFilePath);
1159
+ const githubEventPaths = allPaths.filter(isGithubIssueFilePath);
1160
+ for (const path of new Set(githubEventPaths)) {
1161
+ await this.#handleGithubIssueChange(path, { dryRun: this.#config.dryRun });
1162
+ }
1163
+ await this.#ingestGithubIssues({ dryRun: this.#config.dryRun });
1164
+ const treePaths = await this.#readyIssuePaths();
1165
+ for (const path of new Set([...eventPaths, ...treePaths])) {
1166
+ await this.#handleChange(path);
1167
+ }
1168
+ }
1169
+ async #handleChange(path, opts = {}) {
1170
+ if (!isIssueFilePath(path)) {
1171
+ return;
1172
+ }
1173
+ try {
1174
+ const issue = await this.#readIssue(path);
1175
+ if (issue) {
1176
+ await this.#recordCanonicalIssueState(issue);
1177
+ }
1178
+ if (!issue || !this.#states.isRole(issue.stateId, 'readyForAgent')) {
1179
+ return;
1180
+ }
1181
+ if (opts.requireRealIssue && !isRealLinearIssue(issue)) {
1182
+ return;
1183
+ }
1184
+ if (!isInFactoryScope(issue, this.#config.safety)) {
1185
+ return;
1186
+ }
1187
+ if (!isRealLinearIssue(issue)) {
1188
+ return;
1189
+ }
1190
+ const batch = await this.#batch();
1191
+ if (batch.isInFlight(issue) || batch.isQueued(issue)) {
1192
+ return;
1193
+ }
1194
+ if (await this.#dispatchBlockReason(issue)) {
1195
+ return;
1196
+ }
1197
+ const decision = await this.triageIssue(issue);
1198
+ const escalationReason = triageEscalationReason(decision);
1199
+ if (escalationReason) {
1200
+ await this.#escalateTriageToSlack(decision, escalationReason, this.#config.dryRun);
1201
+ this.#error(new Error(`${escalationReason}; escalation required`), decision.issue);
1202
+ return;
1203
+ }
1204
+ if (batch.canStart()) {
1205
+ await this.dispatch(decision, { dryRun: this.#config.dryRun });
1206
+ }
1207
+ else {
1208
+ if (batch.queue(decision, this.#config.dryRun)) {
1209
+ this.#emit('issue-queued', { issue: decision.issue });
1210
+ }
1211
+ }
1212
+ }
1213
+ catch (error) {
1214
+ this.#logger.error?.('[factory] failed to handle issue change', error);
1215
+ }
1216
+ }
1217
+ // Probes the GitHub issue sub-root at most once and caches the verdict so
1218
+ // repeated iterations skip listTree calls when the mount is absent.
1219
+ async #ensureGithubIngestionReady() {
1220
+ if (this.#githubIngestionEnabled !== undefined) {
1221
+ return this.#githubIngestionEnabled;
1222
+ }
1223
+ const githubReady = await this.#mount.ensureSubRoot(GITHUB_ISSUE_ROOT, { timeoutMs: 90_000 });
1224
+ this.#githubIngestionEnabled = githubReady === 'ready';
1225
+ if (!this.#githubIngestionEnabled) {
1226
+ this.#logger.warn?.(`[factory] ${GITHUB_ISSUE_ROOT} sub-root is not mounted; GitHub issue ingestion disabled`);
1227
+ }
1228
+ return this.#githubIngestionEnabled;
1229
+ }
1230
+ async #ingestGithubIssues(opts = {}) {
1231
+ if (!await this.#ensureGithubIngestionReady()) {
1232
+ return;
1233
+ }
1234
+ // Load the existing Linear mirror candidates once for the whole pass so
1235
+ // dedupe stays O(N + M) reads instead of re-scanning ISSUE_ROOT for every
1236
+ // GitHub issue (gemini perf finding on #findGithubIssueMirror).
1237
+ const candidates = this.#newMirrorCandidateCache();
1238
+ for (const path of await this.#githubIssuePaths()) {
1239
+ await this.#handleGithubIssueChange(path, { ...opts, candidates });
1240
+ }
1241
+ }
1242
+ // Lazily lists+reads ISSUE_ROOT mirror candidates at most once, then memoizes
1243
+ // them for reuse across every GitHub issue handled in the same pass.
1244
+ #newMirrorCandidateCache() {
1245
+ let loaded;
1246
+ return {
1247
+ load: () => {
1248
+ if (!loaded) {
1249
+ loaded = this.#loadLinearMirrorCandidates();
1250
+ }
1251
+ return loaded;
1252
+ },
1253
+ };
1254
+ }
1255
+ async #loadLinearMirrorCandidates() {
1256
+ const candidates = [];
1257
+ for (const path of await this.#mount.listTree(ISSUE_ROOT)) {
1258
+ if (!isLinearIssueMirrorCandidatePath(path)) {
1259
+ continue;
1260
+ }
1261
+ const issue = await this.#readIssue(path);
1262
+ if (issue) {
1263
+ candidates.push(issue);
1264
+ }
1265
+ }
1266
+ return candidates;
1267
+ }
1268
+ async #githubIssuePaths() {
1269
+ try {
1270
+ const paths = await this.#mount.listTree(GITHUB_ISSUE_ROOT);
1271
+ const issuePaths = [];
1272
+ for (const path of paths) {
1273
+ if (githubIssuePathParts(path) !== undefined) {
1274
+ issuePaths.push(path);
1275
+ }
1276
+ else if (githubIssueDirectoryPathParts(path) !== undefined) {
1277
+ // listTree returns the issue directory entry alongside its
1278
+ // meta.json file; githubIssuePathParts() already collected the
1279
+ // file, so skip the directory to avoid reading the same issue
1280
+ // twice in one backfill pass. Directory paths are only meaningful
1281
+ // for live change events, not the tree scan.
1282
+ continue;
1283
+ }
1284
+ else if (isGithubIssueTreePath(path)) {
1285
+ this.#increment('githubIssuesIgnoredByPathRegex');
1286
+ this.#logger.debug?.('[factory] ignored GitHub issue path with unsupported relayfile shape', { path });
1287
+ }
1288
+ }
1289
+ return issuePaths.sort();
1290
+ }
1291
+ catch (error) {
1292
+ this.#increment('githubIssueListFailures');
1293
+ this.#logger.warn?.('[factory] failed to list GitHub issue source tree', error);
1294
+ return [];
1295
+ }
1296
+ }
1297
+ async #handleGithubIssueChange(path, opts = {}) {
1298
+ if (this.#githubIngestionEnabled === false || !isGithubIssueFilePath(path)) {
1299
+ return;
1300
+ }
1301
+ try {
1302
+ const ghIssue = await this.#readGithubIssue(path);
1303
+ if (!ghIssue) {
1304
+ return;
1305
+ }
1306
+ if (githubIssueIsClosed(ghIssue)) {
1307
+ const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
1308
+ if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
1309
+ if (!opts.dryRun) {
1310
+ const record = (await this.#batch()).getIssue(mirror);
1311
+ if (record) {
1312
+ await this.#completeIssue(record);
1313
+ }
1314
+ else {
1315
+ await this.#linear.setState(mirror, this.#states.idFor(mirror.team, 'done'));
1316
+ }
1317
+ }
1318
+ this.#increment('githubIssueMirrorsClosed');
1319
+ }
1320
+ return;
1321
+ }
1322
+ if (!githubIssueHasFactoryLabel(ghIssue)) {
1323
+ return;
1324
+ }
1325
+ const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
1326
+ if (mirror) {
1327
+ this.#increment('githubIssueMirrorsDeduped');
1328
+ return;
1329
+ }
1330
+ const repoLabel = this.#repoLabelForGithubIssue(ghIssue);
1331
+ if (!repoLabel) {
1332
+ this.#increment('githubIssueMirrorsSkippedUnroutable');
1333
+ this.#logger.warn?.('[factory] skipped factory-labeled GitHub issue without repos.byLabel route', {
1334
+ path,
1335
+ repo: ghIssue.repo,
1336
+ url: ghIssue.url,
1337
+ });
1338
+ return;
1339
+ }
1340
+ if (!opts.dryRun) {
1341
+ await this.#linear.createIssue(githubIssueMirrorPayload(ghIssue, repoLabel, this.#config, this.#states.idFor(this.#config.safety.requireTeamKey, 'readyForAgent')));
1342
+ }
1343
+ this.#increment('githubIssueMirrorsCreated');
1344
+ }
1345
+ catch (error) {
1346
+ this.#logger.error?.('[factory] failed to ingest GitHub issue', error);
1347
+ }
1348
+ }
1349
+ async #readGithubIssue(path) {
1350
+ const candidatePaths = githubIssueReadCandidatePaths(path);
1351
+ try {
1352
+ for (const candidatePath of candidatePaths) {
1353
+ try {
1354
+ const { content } = await this.#mount.readFile(candidatePath);
1355
+ return parseGithubIssue(candidatePath, content);
1356
+ }
1357
+ catch (error) {
1358
+ if (isMissingIssueFileError(error) && candidatePath !== candidatePaths.at(-1)) {
1359
+ continue;
1360
+ }
1361
+ throw error;
1362
+ }
1363
+ }
1364
+ }
1365
+ catch (error) {
1366
+ if (isMissingIssueFileError(error)) {
1367
+ this.#increment('githubIssuePhantomSkipped');
1368
+ this.#logger.debug?.('[factory] skipped missing GitHub issue file discovered from issue tree', { path });
1369
+ return undefined;
1370
+ }
1371
+ throw error;
1372
+ }
1373
+ }
1374
+ async #findGithubIssueMirror(ghIssue, candidates) {
1375
+ const draftPath = githubIssueMirrorDraftPath(ghIssue);
1376
+ try {
1377
+ return parseLinearIssue(draftPath, (await this.#mount.readFile(draftPath)).content);
1378
+ }
1379
+ catch {
1380
+ // The draft path only exists before the Linear provider reconciles the
1381
+ // create writeback into a canonical AR-* issue. Fall through to persisted
1382
+ // source metadata on existing Linear issues for restart/replay dedupe.
1383
+ }
1384
+ // Cached resolved mirror path: skip the full ISSUE_ROOT scan for mirrors we
1385
+ // already located on an earlier cycle. Re-validate the cached path and fall
1386
+ // back to a scan if the mirror was deleted or renamed.
1387
+ const cacheKey = githubIssueMirrorId(ghIssue);
1388
+ const cachedPath = this.#githubMirrorPathCache.get(cacheKey);
1389
+ if (cachedPath) {
1390
+ const cached = await this.#readIssue(cachedPath);
1391
+ if (cached && linearIssueMirrorsGithubIssue(cached, ghIssue)) {
1392
+ return cached;
1393
+ }
1394
+ this.#githubMirrorPathCache.delete(cacheKey);
1395
+ }
1396
+ // During a full ingestion pass the candidate list is loaded once and shared
1397
+ // across every GitHub issue; the live single-event path passes none and
1398
+ // loads on demand.
1399
+ const mirrorCandidates = candidates
1400
+ ? await candidates.load()
1401
+ : await this.#loadLinearMirrorCandidates();
1402
+ const mirror = mirrorCandidates.find((issue) => linearIssueMirrorsGithubIssue(issue, ghIssue));
1403
+ if (mirror) {
1404
+ this.#githubMirrorPathCache.set(cacheKey, mirror.path);
1405
+ }
1406
+ return mirror;
1407
+ }
1408
+ #repoLabelForGithubIssue(ghIssue) {
1409
+ const fullName = ghIssue.repo.toLowerCase();
1410
+ const bareName = ghIssue.repoName.toLowerCase();
1411
+ const entry = Object.entries(this.#config.repos.byLabel)
1412
+ .find(([, repo]) => {
1413
+ const configured = repo.toLowerCase();
1414
+ // ghIssue.repo is always owner/name, so byLabel entries configured with
1415
+ // just the bare repo name still match via repoName.
1416
+ return configured === fullName || configured === bareName;
1417
+ });
1418
+ return entry?.[0];
1419
+ }
1420
+ async #dispatchBlockReason(issue) {
1421
+ const key = issue.key;
1422
+ const state = await this.#state.getDispatchAttempts(this.#workspaceId, key);
1423
+ if (!state)
1424
+ return undefined;
1425
+ if (state.terminal)
1426
+ return 'dispatch already terminal';
1427
+ if (state.inFlight)
1428
+ return 'dispatch already in-flight';
1429
+ const now = this.#clock.now();
1430
+ if (state.backoffUntilMs > now) {
1431
+ return 'dispatch backoff active';
1432
+ }
1433
+ if (state.attempts >= this.#config.dispatch.maxAttempts) {
1434
+ state.terminal = true;
1435
+ await this.#state.recordDispatchAttempt(this.#workspaceId, key, state);
1436
+ return 'dispatch retry limit reached';
1437
+ }
1438
+ return undefined;
1439
+ }
1440
+ async #recordDispatchAttempt(issue) {
1441
+ const key = issue.key;
1442
+ const state = await this.#state.getDispatchAttempts(this.#workspaceId, key) ?? {
1443
+ attempts: 0,
1444
+ inFlight: false,
1445
+ terminal: false,
1446
+ backoffUntilMs: 0,
1447
+ };
1448
+ state.attempts += 1;
1449
+ state.inFlight = true;
1450
+ state.backoffUntilMs = 0;
1451
+ await this.#state.recordDispatchAttempt(this.#workspaceId, key, state);
1452
+ }
1453
+ async #clearDispatchInFlight(issue) {
1454
+ await this.#state.releaseInFlight(this.#workspaceId, issue.key);
1455
+ }
1456
+ async #recordDispatchFailure(issue) {
1457
+ const state = await this.#state.getDispatchAttempts(this.#workspaceId, issue.key);
1458
+ if (!state)
1459
+ return;
1460
+ state.inFlight = false;
1461
+ if (state.attempts >= this.#config.dispatch.maxAttempts) {
1462
+ state.terminal = true;
1463
+ state.backoffUntilMs = 0;
1464
+ this.#increment('dispatchTerminalFailures');
1465
+ await this.#state.recordDispatchAttempt(this.#workspaceId, issue.key, state);
1466
+ return;
1467
+ }
1468
+ state.backoffUntilMs = this.#clock.now() + this.#config.dispatch.errorCooldownMs;
1469
+ await this.#state.recordDispatchAttempt(this.#workspaceId, issue.key, state);
1470
+ this.#increment('dispatchBackoffs');
1471
+ }
1472
+ async #recordDispatchTerminal(issue) {
1473
+ const state = await this.#state.getDispatchAttempts(this.#workspaceId, issue.key) ?? {
1474
+ attempts: 0,
1475
+ inFlight: false,
1476
+ terminal: false,
1477
+ backoffUntilMs: 0,
1478
+ };
1479
+ state.inFlight = false;
1480
+ state.terminal = true;
1481
+ state.backoffUntilMs = 0;
1482
+ await this.#state.recordDispatchAttempt(this.#workspaceId, issue.key, state);
1483
+ }
1484
+ async #recordCanonicalIssueState(issue) {
1485
+ const previousStateId = await this.#state.getCanonicalState(this.#workspaceId, issue.key);
1486
+ const previousRole = this.#states.roleOf(previousStateId);
1487
+ const reopenedFromTerminal = previousRole === 'done' || previousRole === 'humanReview';
1488
+ if (reopenedFromTerminal && this.#states.isRole(issue.stateId, 'readyForAgent')) {
1489
+ const dispatchState = await this.#state.getDispatchAttempts(this.#workspaceId, issue.key);
1490
+ if (dispatchState?.terminal) {
1491
+ dispatchState.attempts = 0;
1492
+ dispatchState.inFlight = false;
1493
+ dispatchState.terminal = false;
1494
+ dispatchState.backoffUntilMs = 0;
1495
+ await this.#state.recordDispatchAttempt(this.#workspaceId, issue.key, dispatchState);
1496
+ this.#increment('dispatchTerminalReopened');
1497
+ }
1498
+ }
1499
+ await this.#state.recordCanonicalState(this.#workspaceId, issue.key, issue.stateId);
1500
+ }
1501
+ async #writeLoopHeartbeat(path, registryPath, status, iteration, maxIterations) {
1502
+ const updatedAtMs = this.#clock.now();
1503
+ const heartbeat = {
1504
+ pid: process.pid,
1505
+ status,
1506
+ iteration,
1507
+ maxIterations,
1508
+ updatedAt: new Date(updatedAtMs).toISOString(),
1509
+ updatedAtMs,
1510
+ registryPath,
1511
+ };
1512
+ await mkdir(dirname(path), { recursive: true });
1513
+ await writeFile(path, `${JSON.stringify(heartbeat, null, 2)}\n`, 'utf8');
1514
+ await this.#writeInFlightRegistry(registryPath, path);
1515
+ }
1516
+ async #reapDispatchFailureHandoffsNow(heartbeatPath, registryPath) {
1517
+ const handoffs = await this.#state.listFailureHandoffs(this.#workspaceId);
1518
+ if (handoffs.length === 0) {
1519
+ return;
1520
+ }
1521
+ try {
1522
+ const protectedPids = await this.#protectedPids();
1523
+ let registryChanged = false;
1524
+ for (const [key, handoff] of handoffs) {
1525
+ const roots = await this.#terminationRoots(handoff.name, handoff.tracked, protectedPids);
1526
+ if (roots.pids.length === 0 && roots.status === 'unresolved') {
1527
+ const unresolvedAgeMs = this.#clock.now() - handoff.persistedAtMs;
1528
+ this.#increment('agentTerminateMissingPid');
1529
+ this.#logger.error?.('[factory] no pid available to reap dispatch-failed handoff during loop catch', {
1530
+ agentName: handoff.name,
1531
+ issue: handoff.issue,
1532
+ sessionRef: handoff.tracked.sessionRef,
1533
+ unresolvedAgeMs,
1534
+ });
1535
+ if (unresolvedAgeMs >= DISPATCH_FAILURE_HANDOFF_UNRESOLVED_TTL_MS) {
1536
+ await this.#state.clearFailureHandoff(this.#workspaceId, key);
1537
+ registryChanged = true;
1538
+ this.#increment('dispatchFailureReaperHandoffsDroppedStaleUnresolved');
1539
+ this.#logger.warn?.('[factory] dropped stale unresolved dispatch-failed handoff', {
1540
+ agentName: handoff.name,
1541
+ issue: handoff.issue,
1542
+ unresolvedAgeMs,
1543
+ ttlMs: DISPATCH_FAILURE_HANDOFF_UNRESOLVED_TTL_MS,
1544
+ });
1545
+ try {
1546
+ await this.#fleet.release(handoff.name, 'dispatch failed');
1547
+ }
1548
+ catch (error) {
1549
+ this.#logger.warn?.('[factory] failed to release unresolved dispatch-failure handoff after pruning', {
1550
+ agentName: handoff.name,
1551
+ error,
1552
+ });
1553
+ }
1554
+ }
1555
+ continue;
1556
+ }
1557
+ let blockingSkip = false;
1558
+ if (roots.pids.length > 0) {
1559
+ const report = await terminatePids(roots.pids, {
1560
+ kill: this.#kill,
1561
+ readChildPids: this.#readChildPids,
1562
+ sleep: this.#clock.sleep,
1563
+ termGraceMs: this.#terminationGraceMs,
1564
+ protectedPids,
1565
+ });
1566
+ if (report.terminated.length > 0) {
1567
+ this.#increment('loopDispatchFailureHandoffsReaped');
1568
+ }
1569
+ for (const skipped of report.skipped) {
1570
+ if (skipped.reason !== 'pid not running') {
1571
+ blockingSkip = true;
1572
+ this.#logger.warn?.('[factory] dispatch-failure handoff reap skipped during loop catch', {
1573
+ ...skipped,
1574
+ agentName: handoff.name,
1575
+ });
1576
+ }
1577
+ }
1578
+ }
1579
+ if (!blockingSkip) {
1580
+ await this.#state.clearFailureHandoff(this.#workspaceId, key);
1581
+ registryChanged = true;
1582
+ try {
1583
+ await this.#fleet.release(handoff.name, 'dispatch failed');
1584
+ }
1585
+ catch (error) {
1586
+ this.#logger.warn?.(`[factory] failed to release ${handoff.name} after dispatch-failure reap`, error);
1587
+ }
1588
+ }
1589
+ }
1590
+ if (registryChanged) {
1591
+ await this.#writeInFlightRegistry(registryPath, heartbeatPath);
1592
+ }
1593
+ }
1594
+ catch (error) {
1595
+ this.#increment('loopDispatchFailureHandoffReapFailures');
1596
+ this.#error(error);
1597
+ }
1598
+ }
1599
+ async #readyIssuePaths() {
1600
+ const pathsByKey = new Map();
1601
+ const canonicalPathsByKey = new Map();
1602
+ for (const path of await this.#mount.listTree(ISSUE_ROOT)) {
1603
+ if (isIssueFilePath(path)) {
1604
+ const key = keyFromPath(path);
1605
+ canonicalPathsByKey.set(key, path);
1606
+ pathsByKey.set(key, path);
1607
+ }
1608
+ }
1609
+ for (const path of await this.#mount.listTree(linearByStatePath('ready-for-agent'))) {
1610
+ if (isIssueAliasFilePath(path)) {
1611
+ const canonicalPath = canonicalPathsByKey.get(keyFromPath(path));
1612
+ if (canonicalPath) {
1613
+ pathsByKey.set(keyFromPath(path), canonicalPath);
1614
+ }
1615
+ else {
1616
+ this.#increment('readyAliasesWithoutCanonical');
1617
+ this.#logger.debug?.('[factory] skipped ready alias without canonical issue', { path });
1618
+ }
1619
+ }
1620
+ }
1621
+ return [...pathsByKey.values()].sort();
1622
+ }
1623
+ async #readIssue(path) {
1624
+ try {
1625
+ // Newly-synced issues land as a change-event STUB at the primary
1626
+ // /linear/issues/<key>__<uuid>.json path (no state/url/team); the full
1627
+ // record lands at the by-id / by-uuid aliases. Read the canonical sibling
1628
+ // when the primary parses empty so triage sees real state.
1629
+ const issue = await readLinearIssueWithCanonicalFallback(this.#mount, path);
1630
+ // Synced Linear records may carry only the state NAME, not the state UUID
1631
+ // (relayfile-adapters#205). The factory matches state by UUID, so backfill
1632
+ // the id from the name when the payload omitted it — otherwise every issue
1633
+ // reads as stateId='' and no role (incl. readyForAgent) ever matches.
1634
+ if (issue && !issue.stateId && issue.state?.name) {
1635
+ const backfilled = this.#states.idForName(issue.state.name, issue.team);
1636
+ if (backfilled)
1637
+ return { ...issue, stateId: backfilled };
1638
+ }
1639
+ return issue;
1640
+ }
1641
+ catch (error) {
1642
+ if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
1643
+ this.#increment('phantomSkipped');
1644
+ this.#logger.debug?.('[factory] skipped missing issue file discovered from issue tree', { path });
1645
+ return undefined;
1646
+ }
1647
+ this.#logger.warn?.(`Unable to read issue ${path}`, error);
1648
+ return undefined;
1649
+ }
1650
+ }
1651
+ async #releaseInFlightAgents(reason) {
1652
+ const agents = new Map();
1653
+ for (const record of (await this.#batch()).inFlight) {
1654
+ if (record.dryRun) {
1655
+ continue;
1656
+ }
1657
+ for (const [agentName, tracked] of record.agents) {
1658
+ agents.set(agentName, tracked);
1659
+ }
1660
+ }
1661
+ await this.#releaseAndTerminateAgents([...agents], reason, 'stop');
1662
+ await this.#writeInFlightRegistry(undefined, undefined, true);
1663
+ }
1664
+ async #releaseAndTerminateAgents(agents, reason, context) {
1665
+ const protectedPids = await this.#protectedPids();
1666
+ for (const [agentName, tracked] of agents) {
1667
+ if (context === 'stop') {
1668
+ await this.#refreshStoppingHeartbeat();
1669
+ }
1670
+ const roots = await this.#terminationRoots(agentName, tracked, protectedPids);
1671
+ if (roots.pids.length === 0 && roots.status === 'unresolved') {
1672
+ this.#increment('agentTerminateMissingPid');
1673
+ this.#logger.error?.(`[factory] no pid available to terminate ${agentName} during ${context}`, {
1674
+ agentName,
1675
+ reason,
1676
+ sessionRef: tracked.sessionRef,
1677
+ });
1678
+ }
1679
+ if (roots.pids.length > 0) {
1680
+ const report = await terminatePids(roots.pids, {
1681
+ kill: this.#kill,
1682
+ readChildPids: this.#readChildPids,
1683
+ sleep: this.#clock.sleep,
1684
+ termGraceMs: this.#terminationGraceMs,
1685
+ protectedPids,
1686
+ });
1687
+ for (const skipped of report.skipped) {
1688
+ if (skipped.reason !== 'pid not running') {
1689
+ this.#logger.warn?.(`[factory] failed to terminate pid ${skipped.pid} for ${agentName} during ${context}`, skipped.reason);
1690
+ }
1691
+ }
1692
+ }
1693
+ if (context === 'stop') {
1694
+ await this.#refreshStoppingHeartbeat();
1695
+ }
1696
+ try {
1697
+ await this.#fleet.release(agentName, reason);
1698
+ }
1699
+ catch (error) {
1700
+ this.#logger.warn?.(`[factory] failed to release ${agentName} during ${context}`, error);
1701
+ }
1702
+ if (context === 'stop') {
1703
+ await this.#refreshStoppingHeartbeat();
1704
+ }
1705
+ }
1706
+ }
1707
+ async #terminationRoots(agentName, tracked, protectedPids = []) {
1708
+ const pids = pidsFromSpawnResult(tracked.result);
1709
+ if (!this.#fleet.resolveAgentPid) {
1710
+ return pids.length > 0 ? { pids, status: 'found' } : { pids: [], status: 'unresolved' };
1711
+ }
1712
+ const scan = await this.#processFinder(agentName, { protectedPids });
1713
+ if (scan.status === 'found' &&
1714
+ Number.isInteger(scan.identity.pid) &&
1715
+ scan.identity.pid > 0 &&
1716
+ scan.identity.cmdline.includes(agentName)) {
1717
+ return { pids: [scan.identity.pid], status: 'found' };
1718
+ }
1719
+ if (scan.status === 'ambiguous') {
1720
+ if (!this.#ambiguousLookupWarned.has(agentName)) {
1721
+ this.#ambiguousLookupWarned.add(agentName);
1722
+ this.#logger.warn?.(`[factory] ambiguous process lookup for ${agentName} (suppressing repeats)`);
1723
+ }
1724
+ return { pids: [], status: 'unresolved' };
1725
+ }
1726
+ if (pids.length > 0) {
1727
+ return { pids, status: 'found' };
1728
+ }
1729
+ try {
1730
+ const resolution = await this.#fleet.resolveAgentPid?.(agentName);
1731
+ if (!resolution) {
1732
+ return { pids: [], status: 'unresolved' };
1733
+ }
1734
+ if (resolution.status === 'found' && Number.isInteger(resolution.pid) && resolution.pid > 0) {
1735
+ return { pids: [resolution.pid], status: 'found' };
1736
+ }
1737
+ if (resolution.status === 'unresolved' && scan.status === 'missing') {
1738
+ return { pids: [], status: 'missing' };
1739
+ }
1740
+ return { pids: [], status: resolution.status };
1741
+ }
1742
+ catch (error) {
1743
+ this.#logger.warn?.(`[factory] failed to resolve pid for ${agentName}`, error);
1744
+ return { pids: [], status: 'unresolved' };
1745
+ }
1746
+ }
1747
+ async #protectedPids() {
1748
+ try {
1749
+ return await this.#fleet.protectedPids?.() ?? [];
1750
+ }
1751
+ catch (error) {
1752
+ this.#logger.warn?.('[factory] failed to resolve protected fleet pids', error);
1753
+ return [];
1754
+ }
1755
+ }
1756
+ async #persistDispatchFailureReaperHandoff(record, handoffAgents) {
1757
+ if (record.dryRun || handoffAgents.length === 0) {
1758
+ return;
1759
+ }
1760
+ try {
1761
+ for (const agent of handoffAgents) {
1762
+ await this.#state.recordFailureHandoff(this.#workspaceId, registryHandoffKey(agent.issue, agent.name), agent);
1763
+ }
1764
+ await this.#writeInFlightRegistry();
1765
+ this.#increment('dispatchFailureReaperHandoffs');
1766
+ this.#logger.warn?.('[factory] persisted dispatch-failed agents for orphan reaper', {
1767
+ issue: record.issue,
1768
+ agents: handoffAgents.map((agent) => agent.name).sort(),
1769
+ });
1770
+ }
1771
+ catch (error) {
1772
+ this.#increment('dispatchFailureReaperHandoffFailures');
1773
+ for (const agent of handoffAgents) {
1774
+ await this.#state.clearFailureHandoff(this.#workspaceId, registryHandoffKey(agent.issue, agent.name));
1775
+ }
1776
+ this.#logger.error?.('[factory] failed to persist dispatch-failed agents for orphan reaper', {
1777
+ issue: record.issue,
1778
+ error,
1779
+ });
1780
+ this.#error(error, record.issue);
1781
+ }
1782
+ }
1783
+ async #writeInFlightRegistry(path = this.#config.loop.registryPath, heartbeatPath = this.#config.loop.heartbeatPath, empty = false) {
1784
+ const updatedAtMs = this.#clock.now();
1785
+ const agents = [];
1786
+ const seenAgents = new Set();
1787
+ const appendAgent = async (issue, agentName, tracked) => {
1788
+ const key = registryHandoffKey(issue, agentName);
1789
+ if (seenAgents.has(key)) {
1790
+ return;
1791
+ }
1792
+ seenAgents.add(key);
1793
+ const { pids } = await this.#terminationRoots(agentName, tracked);
1794
+ const processes = [];
1795
+ for (const pid of pids) {
1796
+ const identity = await this.#processIdentityReader(pid);
1797
+ if (identity && identity.cmdline.includes(agentName)) {
1798
+ processes.push({ ...identity, agentName });
1799
+ }
1800
+ }
1801
+ agents.push({
1802
+ name: agentName,
1803
+ role: tracked.spec.role,
1804
+ issue,
1805
+ sessionRef: tracked.sessionRef,
1806
+ pids,
1807
+ processes,
1808
+ });
1809
+ };
1810
+ if (!empty) {
1811
+ for (const record of (await this.#batch()).inFlight) {
1812
+ if (record.dryRun)
1813
+ continue;
1814
+ for (const [agentName, tracked] of record.agents) {
1815
+ await appendAgent(record.issue, agentName, tracked);
1816
+ }
1817
+ }
1818
+ }
1819
+ for (const [, agent] of await this.#state.listFailureHandoffs(this.#workspaceId)) {
1820
+ await appendAgent(agent.issue, agent.name, agent.tracked);
1821
+ }
1822
+ const registry = {
1823
+ pid: process.pid,
1824
+ heartbeatPath,
1825
+ updatedAt: new Date(updatedAtMs).toISOString(),
1826
+ updatedAtMs,
1827
+ agents,
1828
+ };
1829
+ await mkdir(dirname(path), { recursive: true });
1830
+ await writeFile(path, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
1831
+ }
1832
+ async #spawnAgent(record, spec, dryRun) {
1833
+ const batch = await this.#batch();
1834
+ const invocationId = batch.invocationIdFor(record.issue, spec);
1835
+ const existing = record.agents.get(spec.name);
1836
+ if (existing) {
1837
+ return { name: existing.result?.name ?? spec.name };
1838
+ }
1839
+ if (!batch.shouldSpawn(record, invocationId)) {
1840
+ return { name: spec.name };
1841
+ }
1842
+ if (dryRun) {
1843
+ batch.recordDryRun(record, spec, invocationId);
1844
+ return { name: spec.name };
1845
+ }
1846
+ let roster;
1847
+ try {
1848
+ roster = await retryOnTimeout(() => this.#fleet.roster(), { attempts: 3, delayMs: 2000 });
1849
+ }
1850
+ catch (error) {
1851
+ throw contextualError(`Dispatch roster lookup failed for ${record.issue.key}`, error);
1852
+ }
1853
+ if (roster.agents.some((agent) => agent.name === spec.name)) {
1854
+ batch.recordSpawn(record, spec, invocationId, { name: spec.name, sessionRef: spec.sessionRef });
1855
+ return { name: spec.name };
1856
+ }
1857
+ let result;
1858
+ try {
1859
+ result = await this.#fleet.spawn({
1860
+ name: spec.name,
1861
+ capability: spec.capability,
1862
+ node: spec.node ?? 'self',
1863
+ task: spec.task,
1864
+ workflow: spec.workflow,
1865
+ inputs: spec.inputs,
1866
+ model: spec.model,
1867
+ cwd: spec.clonePath,
1868
+ sessionRef: spec.sessionRef,
1869
+ invocationId,
1870
+ restartPolicy: spec.restartPolicy ?? defaultRestartPolicy(spec),
1871
+ channel: spec.channel,
1872
+ });
1873
+ }
1874
+ catch (error) {
1875
+ throw contextualError(`Dispatch spawn failed for ${record.issue.key}/${spec.name} (${spec.capability}) cwd=${spec.clonePath ?? 'default'}`, error);
1876
+ }
1877
+ batch.recordSpawn(record, spec, invocationId, result);
1878
+ return { name: result.name };
1879
+ }
1880
+ async #handleAgentExit(name, reason) {
1881
+ if (this.#stopping) {
1882
+ return;
1883
+ }
1884
+ const batch = await this.#batch();
1885
+ const record = batch.getIssueByAgent(name);
1886
+ if (!record) {
1887
+ return;
1888
+ }
1889
+ const exiting = record.agents.get(name);
1890
+ if (isCompletionReason(reason)) {
1891
+ if (this.#config.babysitter.enabled && !record.dryRun) {
1892
+ // Babysitter path: an implementer/reviewer finishing does NOT mark the
1893
+ // issue done — it hands the open PR to the babysitter. The babysitter
1894
+ // itself finishing means it believes the PR is ready, so re-check and
1895
+ // advance to Human Review.
1896
+ if (exiting?.spec.role === 'babysitter') {
1897
+ await this.#maybeAdvanceToHumanReview(record);
1898
+ }
1899
+ else {
1900
+ await this.#ensureBabysitterForIssue(record);
1901
+ }
1902
+ return;
1903
+ }
1904
+ await this.#completeIssue(record);
1905
+ return;
1906
+ }
1907
+ const tracked = exiting;
1908
+ if (!tracked || record.dryRun) {
1909
+ return;
1910
+ }
1911
+ try {
1912
+ if (tracked.spec.role === 'implementer' && await this.#issueHasCompletionPr(record)) {
1913
+ if (this.#config.babysitter.enabled) {
1914
+ await this.#ensureBabysitterForIssue(record);
1915
+ return;
1916
+ }
1917
+ await this.#completeIssue(record);
1918
+ return;
1919
+ }
1920
+ if (tracked.sessionRef) {
1921
+ const resumeKey = `${issueKey(record.issue)}:${name}:${tracked.sessionRef}`;
1922
+ if (await this.#state.isResumed(this.#workspaceId, resumeKey)) {
1923
+ // Already resumed once and STILL exiting with no completion PR — the
1924
+ // agent isn't making progress. Escalate so a human notices, instead of
1925
+ // leaving the issue silently in-flight forever.
1926
+ if (tracked.spec.role === 'implementer') {
1927
+ await this.#escalateStalledIssue(record, name);
1928
+ }
1929
+ return;
1930
+ }
1931
+ const existing = this.#resumeInFlight.get(resumeKey);
1932
+ if (existing) {
1933
+ await existing;
1934
+ return;
1935
+ }
1936
+ const resume = this.#resumeTrackedAgent(record, name, tracked);
1937
+ this.#resumeInFlight.set(resumeKey, resume);
1938
+ try {
1939
+ await resume;
1940
+ await this.#state.markResumed(this.#workspaceId, resumeKey);
1941
+ }
1942
+ catch (error) {
1943
+ if (isAgentAlreadyExistsError(error)) {
1944
+ // The broker never released this agent's name on exit
1945
+ // (relay#1116-family), so re-registering collides with the stuck
1946
+ // name. The error is marked retryable but isn't — retrying just
1947
+ // re-collides forever. Treat it as terminal for this name: record
1948
+ // the resume key so subsequent exit events short-circuit, count it,
1949
+ // and warn once instead of spamming a 500 stack trace. The external
1950
+ // reaper / a broker restart reclaims the leaked name.
1951
+ await this.#state.markResumed(this.#workspaceId, resumeKey);
1952
+ this.#increment('resumeNameCollisions');
1953
+ this.#logger.warn?.('[factory] resume skipped: broker still holds agent name (relay#1116); not retrying', {
1954
+ issue: record.issue.key,
1955
+ name,
1956
+ });
1957
+ }
1958
+ else {
1959
+ throw error;
1960
+ }
1961
+ }
1962
+ finally {
1963
+ this.#resumeInFlight.delete(resumeKey);
1964
+ }
1965
+ }
1966
+ else {
1967
+ const invocationId = `${batch.invocationIdFor(record.issue, tracked.spec)}:restart:${this.#clock.now()}`;
1968
+ const result = await this.#fleet.spawn({
1969
+ name: tracked.spec.name,
1970
+ capability: tracked.spec.capability,
1971
+ node: tracked.spec.node ?? 'self',
1972
+ task: tracked.spec.task,
1973
+ model: tracked.spec.model,
1974
+ cwd: tracked.spec.clonePath,
1975
+ sessionRef: tracked.spec.sessionRef,
1976
+ invocationId,
1977
+ restartPolicy: defaultRestartPolicy(tracked.spec),
1978
+ channel: tracked.spec.channel,
1979
+ });
1980
+ batch.recordSpawn(record, tracked.spec, invocationId, result);
1981
+ }
1982
+ }
1983
+ catch (error) {
1984
+ this.#error(error, record.issue);
1985
+ }
1986
+ }
1987
+ // An implementer that exited, was resumed once, and STILL produced no PR is
1988
+ // not making progress. Surface it (counter + a best-effort Slack note to the
1989
+ // dispatch thread) so a human can step in, instead of the issue sitting
1990
+ // silently "in flight" with nothing happening. We do NOT fake a Linear state
1991
+ // change here (the mount may be wedged); the human owns the next step.
1992
+ async #escalateStalledIssue(record, name) {
1993
+ this.#increment('issuesStalledNoPr');
1994
+ this.#logger.warn?.('[factory] implementer exited without a PR after a resume; escalating for human attention', {
1995
+ issue: record.issue.key,
1996
+ agent: name,
1997
+ });
1998
+ try {
1999
+ const thread = await this.#slackDispatchThreadFor(record);
2000
+ if (thread && this.#slack) {
2001
+ await this.#slack.reply(thread.threadId, `:warning: ${record.issue.key}: the implementer exited without opening a PR (after a retry). It needs a human look.`);
2002
+ }
2003
+ }
2004
+ catch (error) {
2005
+ this.#logger.warn?.('[factory] failed to post stalled-issue escalation to Slack', error);
2006
+ }
2007
+ }
2008
+ async #issueHasCompletionPr(record) {
2009
+ try {
2010
+ const issue = await this.#readIssue(record.issue.path);
2011
+ if (!issue) {
2012
+ return false;
2013
+ }
2014
+ // Only a NON-DRAFT (ready) PR counts as completion. A draft PR means the
2015
+ // work isn't review-ready, so an implementer exiting with only a draft PR
2016
+ // must NOT mark the issue done / release agents — mirror the
2017
+ // #sweepPrStateCompletions draft guard, which keeps draft-PR issues in flight.
2018
+ const pr = await this.#completionPrForIssue(issue);
2019
+ return Boolean(pr && !pr.draft);
2020
+ }
2021
+ catch (error) {
2022
+ this.#logger.warn?.('[factory] PR probe failed after implementer exit; preserving restart behavior', {
2023
+ issue: record.issue.key,
2024
+ error: describeError(error).errorMessage,
2025
+ });
2026
+ this.#increment('exitPrProbeFailures');
2027
+ return false;
2028
+ }
2029
+ }
2030
+ async #resumeTrackedAgent(record, name, tracked) {
2031
+ if (!tracked.sessionRef) {
2032
+ return;
2033
+ }
2034
+ const result = await this.#fleet.resume({
2035
+ name,
2036
+ sessionRef: tracked.sessionRef,
2037
+ node: tracked.spec.node ?? 'self',
2038
+ capability: tracked.spec.capability,
2039
+ });
2040
+ tracked.result = result;
2041
+ tracked.sessionRef = result.sessionRef ?? tracked.sessionRef;
2042
+ record.agents.delete(name);
2043
+ record.agents.set(result.name, tracked);
2044
+ }
2045
+ async #handleDeliveryFailed(info) {
2046
+ const critical = await this.#state.consumeCritical(this.#workspaceId, info.msgId ?? '');
2047
+ const record = (await this.#batch()).getIssueByAgent(info.to);
2048
+ const issue = critical?.issue ?? record?.issue;
2049
+ const error = new Error(`Critical delivery failed to ${info.to}${info.reason ? `: ${info.reason}` : ''}`);
2050
+ this.#error(error, issue);
2051
+ if (critical && this.#fleet.waitForInjected) {
2052
+ try {
2053
+ const ack = await this.#waitForInjectedAndSubmit(critical.input);
2054
+ await this.#state.recordCritical(this.#workspaceId, ack.eventId, critical);
2055
+ }
2056
+ catch (retryError) {
2057
+ this.#error(retryError, critical.issue);
2058
+ }
2059
+ }
2060
+ }
2061
+ async #handleAgentMessage(message) {
2062
+ // The babysitter signals "PR is green" by DMing factory. Confirm with an
2063
+ // authoritative readiness read before advancing to Human Review.
2064
+ if (this.#config.babysitter.enabled && isFactoryQuestionTarget(message.target)) {
2065
+ const ready = parsePrReadySignal(message);
2066
+ if (ready) {
2067
+ const record = (await this.#batch()).getIssueByAgent(ready.agentName);
2068
+ if (!record || record.dryRun) {
2069
+ this.#increment('prReadySignalsIgnoredNoInFlight');
2070
+ return;
2071
+ }
2072
+ if (ready.issueKey && ready.issueKey !== record.issue.key) {
2073
+ this.#increment('prReadySignalsIgnoredIssueMismatch');
2074
+ return;
2075
+ }
2076
+ await this.#maybeAdvanceToHumanReview(record);
2077
+ return;
2078
+ }
2079
+ }
2080
+ const question = parseAgentQuestion(message);
2081
+ if (!question || !isFactoryQuestionTarget(message.target)) {
2082
+ return;
2083
+ }
2084
+ const record = (await this.#batch()).getIssueByAgent(question.agentName);
2085
+ if (!record || record.dryRun) {
2086
+ this.#increment('agentQuestionsIgnoredNoInFlight');
2087
+ return;
2088
+ }
2089
+ if (question.issueKey && question.issueKey !== record.issue.key) {
2090
+ this.#increment('agentQuestionsIgnoredIssueMismatch');
2091
+ this.#logger.warn?.('[factory] ignored agent question for mismatched issue', {
2092
+ from: question.agentName,
2093
+ requestedIssue: question.issueKey,
2094
+ activeIssue: record.issue.key,
2095
+ });
2096
+ return;
2097
+ }
2098
+ const dedupeKey = agentQuestionDedupeKey(record.issue, question);
2099
+ if (!await this.#state.claimAgentQuestion(this.#workspaceId, dedupeKey)) {
2100
+ this.#increment('agentQuestionDuplicatesSuppressed');
2101
+ this.#logger.debug?.('[factory] suppressed duplicate agent question', {
2102
+ from: question.agentName,
2103
+ issue: record.issue.key,
2104
+ });
2105
+ return;
2106
+ }
2107
+ if (!question.eventId) {
2108
+ this.#increment('agentQuestionsMissingIdentity');
2109
+ this.#logger.warn?.('[factory] agent question event missing stable identity; falling back to sender/content dedupe', {
2110
+ from: question.agentName,
2111
+ issue: record.issue.key,
2112
+ });
2113
+ }
2114
+ await this.#postAgentQuestionToSlack(record, question);
2115
+ }
2116
+ async #postAgentQuestionToSlack(record, question) {
2117
+ if (!this.#slack || !this.#config.slack) {
2118
+ this.#increment('agentQuestionsSkippedNoSlack');
2119
+ this.#logger.warn?.('[factory] agent question has no Slack channel configured', {
2120
+ issue: record.issue,
2121
+ from: question.agentName,
2122
+ });
2123
+ return;
2124
+ }
2125
+ if (await this.#shouldSkipSlackWriteback('agent-question')) {
2126
+ this.#increment('agentQuestionsSkippedSlackDegraded');
2127
+ return;
2128
+ }
2129
+ const key = issueKey(record.issue);
2130
+ try {
2131
+ await this.#slackWatcherStarts.get(key);
2132
+ }
2133
+ catch {
2134
+ // The initiator logs Slack watcher startup failures.
2135
+ }
2136
+ const threadId = await this.#state.getSlackThread(this.#workspaceId, key);
2137
+ if (!threadId) {
2138
+ this.#increment('agentQuestionsSkippedMissingThread');
2139
+ this.#logger.warn?.('[factory] agent question has no Slack dispatch thread', {
2140
+ issue: record.issue,
2141
+ from: question.agentName,
2142
+ });
2143
+ return;
2144
+ }
2145
+ try {
2146
+ await this.#slack.reply(threadId, agentQuestionSlackText(record.issue, question));
2147
+ this.#increment('agentQuestionsPostedToSlack');
2148
+ this.#recordSlackWritebackSuccess('agent-question');
2149
+ }
2150
+ catch (error) {
2151
+ this.#markSlackWritebackFailure('agent-question', error);
2152
+ this.#logger.warn?.(`[factory] failed to post agent question for ${record.issue.key}`, error);
2153
+ }
2154
+ }
2155
+ #recordArrivalLatency(event) {
2156
+ const occurredAt = Date.parse(event.occurredAt);
2157
+ if (!Number.isFinite(occurredAt))
2158
+ return;
2159
+ const latencyMs = Math.max(0, this.#clock.now() - occurredAt);
2160
+ this.#counters.liveEvents = (this.#counters.liveEvents ?? 0) + 1;
2161
+ this.#counters.liveArrivalLatencyMsLast = latencyMs;
2162
+ this.#counters.liveArrivalLatencyMsMax = Math.max(this.#counters.liveArrivalLatencyMsMax ?? 0, latencyMs);
2163
+ this.#logger.debug?.('[factory] live issue event latency recorded', {
2164
+ eventId: event.id,
2165
+ path: changeEventPath(event),
2166
+ latencyMs,
2167
+ });
2168
+ }
2169
+ async #sendCriticalReviewerMessage(record) {
2170
+ if (!this.#fleet.waitForInjected) {
2171
+ return;
2172
+ }
2173
+ const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer');
2174
+ if (!reviewer) {
2175
+ return;
2176
+ }
2177
+ const input = {
2178
+ to: reviewer.result?.name ?? reviewer.spec.name,
2179
+ text: `Review is queued for ${record.issue.key}. Watch implementer PR handoff and report readiness.`,
2180
+ from: 'factory',
2181
+ data: { issue: record.issue },
2182
+ };
2183
+ const ack = await this.#waitForInjectedAndSubmit(input);
2184
+ await this.#state.recordCritical(this.#workspaceId, ack.eventId, { issue: record.issue, input });
2185
+ }
2186
+ async #sendImplementerTask(record) {
2187
+ if (!this.#fleet.waitForInjected) {
2188
+ return;
2189
+ }
2190
+ const implementers = [...record.agents.values()].filter((agent) => agent.spec.role === 'implementer');
2191
+ if (implementers.length === 0) {
2192
+ return;
2193
+ }
2194
+ const issue = await this.#readIssue(record.issue.path);
2195
+ const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer');
2196
+ const reviewerName = reviewer?.result?.name ?? reviewer?.spec.name ?? 'reviewer';
2197
+ const implementerNames = implementers.map((agent) => agent.result?.name ?? agent.spec.name);
2198
+ const integrationInstructions = await this.#resolveIntegrationInstructions();
2199
+ for (const implementer of implementers) {
2200
+ const input = {
2201
+ to: implementer.result?.name ?? implementer.spec.name,
2202
+ text: renderAgentTask({
2203
+ issue: templateIssueFromRecord(record, issue),
2204
+ route: routeForImplementer(record, implementer.spec),
2205
+ role: 'implementer',
2206
+ config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
2207
+ reviewerName,
2208
+ implementerNames,
2209
+ slackDispatchThread: await this.#slackDispatchThreadFor(record),
2210
+ integrationsMountRoot: this.#integrationsMountRoot(),
2211
+ integrationInstructions,
2212
+ }),
2213
+ from: 'factory',
2214
+ data: { issue: record.issue },
2215
+ };
2216
+ const ack = await this.#waitForInjectedAndSubmit(input);
2217
+ await this.#state.recordCritical(this.#workspaceId, ack.eventId, { issue: record.issue, input });
2218
+ }
2219
+ }
2220
+ async #waitForInjectedAndSubmit(input) {
2221
+ if (!this.#fleet.waitForInjected) {
2222
+ throw new Error('Fleet client does not support confirmed task injection');
2223
+ }
2224
+ const ack = await this.#waitForInjectedWithRetry(input);
2225
+ await this.#submitInjectedTask(input, ack);
2226
+ return ack;
2227
+ }
2228
+ async #waitForInjectedWithRetry(input) {
2229
+ if (!this.#fleet.waitForInjected) {
2230
+ throw new Error('Fleet client does not support confirmed task injection');
2231
+ }
2232
+ const startedAt = this.#clock.now();
2233
+ let attempt = 0;
2234
+ let lastError;
2235
+ while (attempt < INJECTION_MAX_ATTEMPTS && this.#clock.now() - startedAt < INJECTION_CONFIRMATION_TIMEOUT_MS) {
2236
+ attempt += 1;
2237
+ const elapsed = Math.max(0, this.#clock.now() - startedAt);
2238
+ const remaining = Math.max(1, INJECTION_CONFIRMATION_TIMEOUT_MS - elapsed);
2239
+ try {
2240
+ return await this.#fleet.waitForInjected(input, {
2241
+ timeoutMs: Math.min(INJECTION_RETRY_ATTEMPT_TIMEOUT_MS, remaining),
2242
+ });
2243
+ }
2244
+ catch (error) {
2245
+ lastError = error;
2246
+ if (!isRegistrationLagInjectionError(error) ||
2247
+ remaining <= INJECTION_RETRY_DELAY_MS ||
2248
+ attempt >= INJECTION_MAX_ATTEMPTS) {
2249
+ throw error;
2250
+ }
2251
+ this.#increment('injectionRegistrationLagRetries');
2252
+ this.#logger.warn?.('[factory] task injection target not registered yet; retrying', {
2253
+ to: input.to,
2254
+ attempt,
2255
+ error: describeError(error).errorMessage,
2256
+ });
2257
+ await this.#clock.sleep(Math.min(INJECTION_RETRY_DELAY_MS, remaining));
2258
+ }
2259
+ }
2260
+ throw lastError instanceof Error ? lastError : new Error(`Timed out waiting to inject task to ${input.to}`);
2261
+ }
2262
+ async #submitInjectedTask(input, ack) {
2263
+ if (!this.#fleet.sendInput) {
2264
+ return;
2265
+ }
2266
+ const targets = ack.targets && ack.targets.length > 0 ? ack.targets : [input.to];
2267
+ for (const target of new Set(targets)) {
2268
+ await this.#fleet.sendInput(target, '\r');
2269
+ }
2270
+ }
2271
+ // ── PR babysitter ──────────────────────────────────────────────────────────
2272
+ // Webhook-driven: a change event on the PR's webhook-fed mount file
2273
+ // (/github/repos/<owner>/<repo>/pulls/<n>/meta.json) — PR opened, new commits,
2274
+ // draft toggle, closed/merged — routes here. The PR readiness *verdict* (CI
2275
+ // green, conflicts resolved, comments addressed) is owned by the babysitter
2276
+ // agent, which sees the same per-event webhook data in its sandbox and signals
2277
+ // `[factory-pr-ready]`; the orchestrator never runs `gh`. PR meta events here
2278
+ // only (a) spawn the babysitter on open and (b) carry the latest open/draft/
2279
+ // merged state used to guard the final transition.
2280
+ async #handlePrChange(path) {
2281
+ const parts = githubPullPathParts(path);
2282
+ if (!parts) {
2283
+ return;
2284
+ }
2285
+ let snapshot;
2286
+ try {
2287
+ snapshot = parsePullSnapshot((await this.#mount.readFile(path)).content, parts.number);
2288
+ }
2289
+ catch (error) {
2290
+ this.#logger.debug?.('[factory] babysitter could not read PR snapshot', { path, error: describeError(error).errorMessage });
2291
+ return;
2292
+ }
2293
+ if (!snapshot) {
2294
+ return;
2295
+ }
2296
+ // Map the PR to an in-flight issue by its head ref (reuses the existing
2297
+ // key matcher; no branch-naming assumption).
2298
+ const headRef = snapshot.headRef ?? '';
2299
+ const record = (await this.#batch()).inFlight.find((candidate) => !candidate.dryRun && headRef && containsIssueKey(headRef, candidate.issue.key));
2300
+ if (prMetaShowsMerged(snapshot)) {
2301
+ await this.#advanceMergedPrToDone(snapshot, record);
2302
+ return;
2303
+ }
2304
+ if (!this.#config.babysitter.enabled) {
2305
+ return;
2306
+ }
2307
+ if (!record) {
2308
+ return;
2309
+ }
2310
+ if (snapshot.state && snapshot.state.trim().toUpperCase() !== 'OPEN') {
2311
+ return;
2312
+ }
2313
+ if (snapshot.draft) {
2314
+ this.#increment('babysitterDraftPrSkipped');
2315
+ return;
2316
+ }
2317
+ await this.#ensureBabysitter(record, { repo: `${parts.owner}/${parts.repo}`, prNumber: snapshot.number, url: snapshot.url, path });
2318
+ }
2319
+ async #advanceMergedPrToDone(snapshot, record) {
2320
+ if (record) {
2321
+ await this.#completeIssue(record, { targetState: 'done', runMergeGate: false, completionReason: 'pr-merged' });
2322
+ return;
2323
+ }
2324
+ const issue = await this.#findMergeAdvanceIssueForPr(snapshot);
2325
+ if (!issue) {
2326
+ this.#increment('mergedPrAdvanceNoIssue');
2327
+ return;
2328
+ }
2329
+ const advanceKey = `${issue.key}:${snapshot.number}`;
2330
+ if (this.#postMergeDoneAdvances.has(advanceKey)) {
2331
+ this.#increment('mergedPrAdvanceDuplicatesSuppressed');
2332
+ return;
2333
+ }
2334
+ this.#postMergeDoneAdvances.add(advanceKey);
2335
+ try {
2336
+ const doneStateId = this.#states.idFor(issue.team, 'done');
2337
+ await this.#linear.setState(issue, doneStateId);
2338
+ await this.#recordCanonicalIssueState({ key: issue.key, stateId: doneStateId });
2339
+ this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
2340
+ this.#increment('mergedPrAdvancedDone');
2341
+ this.#increment('done');
2342
+ this.#logger.info?.('[factory] merged PR advanced issue to Done', {
2343
+ issue: issue.key,
2344
+ prNumber: snapshot.number,
2345
+ url: snapshot.url,
2346
+ });
2347
+ if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('merge-done-thread')) {
2348
+ try {
2349
+ const root = await this.#slack.postThread({
2350
+ channel: this.#config.slack.channel,
2351
+ text: `${issue.key}: PR merged; Linear state set to Done.`,
2352
+ });
2353
+ await this.#slack.reply(root.threadId, `${issue.key}: Linear state set to Done.`);
2354
+ this.#recordSlackWritebackSuccess('merge-done-thread');
2355
+ }
2356
+ catch (error) {
2357
+ this.#markSlackWritebackFailure('merge-done-thread', error);
2358
+ }
2359
+ }
2360
+ }
2361
+ catch (error) {
2362
+ this.#postMergeDoneAdvances.delete(advanceKey);
2363
+ this.#error(error, issueRef(issue));
2364
+ }
2365
+ }
2366
+ async #findMergeAdvanceIssueForPr(snapshot) {
2367
+ // An issue is "upstream" of a merge if it sits in the agent-implementing or
2368
+ // human-review role for its team. UUIDs are globally unique, so the reverse
2369
+ // role lookup covers every team without per-team scoping here.
2370
+ const isUpstreamState = (stateId) => {
2371
+ const role = this.#states.roleOf(stateId);
2372
+ return role === 'agentImplementing' || role === 'humanReview';
2373
+ };
2374
+ let best;
2375
+ const scanStartedAtMs = this.#clock.now();
2376
+ // This no-record path runs after agents are released, so there is no
2377
+ // tracked PR identity left. Keep the scan simple and prefer branch identity
2378
+ // over title/body references to avoid "related to AR-N" body false positives.
2379
+ for (const path of await this.#mount.listTree(ISSUE_ROOT)) {
2380
+ if (!isIssueFilePath(path)) {
2381
+ continue;
2382
+ }
2383
+ const issue = await this.#readIssue(path);
2384
+ if (!issue || !isUpstreamState(issue.stateId)) {
2385
+ continue;
2386
+ }
2387
+ if (!isRealLinearIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) {
2388
+ continue;
2389
+ }
2390
+ const score = prSnapshotIssueMatchScore(snapshot, issue.key);
2391
+ if (score > 0 && (!best || score > best.score)) {
2392
+ best = { issue, score };
2393
+ }
2394
+ }
2395
+ this.#logger.debug?.('[factory] scanned Linear issues for merged PR advance', {
2396
+ prNumber: snapshot.number,
2397
+ durationMs: this.#clock.now() - scanStartedAtMs,
2398
+ matchedIssue: best?.issue.key,
2399
+ matchScore: best?.score,
2400
+ });
2401
+ return best?.issue;
2402
+ }
2403
+ // Safety net for a missed PR-open mount event: resolve the PR via the existing
2404
+ // probe resolver and spawn the babysitter. Triggered by an implementer exiting
2405
+ // after opening its PR (an event, not a poll).
2406
+ async #ensureBabysitterForIssue(record) {
2407
+ if (this.#babysitterSpawned.has(record.issue.key)) {
2408
+ return;
2409
+ }
2410
+ const issue = await this.#readIssue(record.issue.path);
2411
+ if (!issue) {
2412
+ return;
2413
+ }
2414
+ const pr = await this.#completionPrForIssue(issue);
2415
+ if (!pr || pr.draft) {
2416
+ return;
2417
+ }
2418
+ await this.#ensureBabysitter(record, { repo: pr.repo, prNumber: pr.prNumber });
2419
+ }
2420
+ async #ensureBabysitter(record, prRef) {
2421
+ this.#babysitterPr.set(record.issue.key, { repo: prRef.repo, prNumber: prRef.prNumber, path: prRef.path });
2422
+ if (this.#babysitterSpawned.has(record.issue.key)) {
2423
+ return;
2424
+ }
2425
+ if ([...record.agents.values()].some((agent) => agent.spec.role === 'babysitter')) {
2426
+ this.#babysitterSpawned.add(record.issue.key);
2427
+ return;
2428
+ }
2429
+ // Reserve up-front so concurrent PR events in a drain don't double-spawn.
2430
+ this.#babysitterSpawned.add(record.issue.key);
2431
+ try {
2432
+ const issue = await this.#readIssue(record.issue.path);
2433
+ if (!issue) {
2434
+ this.#babysitterSpawned.delete(record.issue.key);
2435
+ return;
2436
+ }
2437
+ const route = record.decision.routes.find((candidate) => candidate.repo === prRef.repo)
2438
+ ?? record.decision.routes[0];
2439
+ const spec = babysitterSpec(issue, this.#config, route);
2440
+ const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer');
2441
+ const reviewerName = reviewer?.result?.name ?? reviewer?.spec.name ?? `${spec.name.replace(/-babysit$/, '')}-review`;
2442
+ const implementerNames = [...record.agents.values()]
2443
+ .filter((agent) => agent.spec.role === 'implementer')
2444
+ .map((agent) => agent.result?.name ?? agent.spec.name);
2445
+ const integrationInstructions = await this.#resolveIntegrationInstructions();
2446
+ const task = renderAgentTask({
2447
+ issue: templateIssueFromRecord(record, issue),
2448
+ route: route ?? { repo: prRef.repo },
2449
+ role: 'babysitter',
2450
+ config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
2451
+ reviewerName,
2452
+ implementerNames,
2453
+ pr: { number: prRef.prNumber, url: prRef.url },
2454
+ slackDispatchThread: await this.#slackDispatchThreadFor(record),
2455
+ integrationsMountRoot: this.#integrationsMountRoot(),
2456
+ integrationInstructions,
2457
+ });
2458
+ const spawned = await this.#spawnAgent(record, { ...spec, task }, false);
2459
+ await this.#writeInFlightRegistry();
2460
+ this.#increment('babysittersSpawned');
2461
+ this.#logger.info?.('[factory] babysitter spawned for open PR', {
2462
+ issue: record.issue.key,
2463
+ repo: prRef.repo,
2464
+ prNumber: prRef.prNumber,
2465
+ babysitter: spawned.name,
2466
+ });
2467
+ if (this.#fleet.waitForInjected) {
2468
+ const tracked = record.agents.get(spawned.name);
2469
+ const input = {
2470
+ to: tracked?.result?.name ?? spawned.name,
2471
+ text: task,
2472
+ from: 'factory',
2473
+ data: { issue: record.issue },
2474
+ };
2475
+ const ack = await this.#waitForInjectedAndSubmit(input);
2476
+ await this.#state.recordCritical(this.#workspaceId, ack.eventId, { issue: record.issue, input });
2477
+ }
2478
+ }
2479
+ catch (error) {
2480
+ // Allow a later event to retry the spawn.
2481
+ this.#babysitterSpawned.delete(record.issue.key);
2482
+ this.#increment('babysitterSpawnFailures');
2483
+ this.#error(error, record.issue);
2484
+ }
2485
+ }
2486
+ // The babysitter owns the readiness verdict (CI green + conflicts resolved +
2487
+ // review comments addressed) — it sees the per-event PR webhook data in its
2488
+ // sandbox, exactly like AgentWorkforce/agents review. It signals readiness by
2489
+ // DMing `[factory-pr-ready]`. The orchestrator trusts that signal and only
2490
+ // guards on the PR's OWN webhook-fed meta (still open, not a draft, not already
2491
+ // merged) before flipping the issue to Human Review. No `gh` call.
2492
+ async #maybeAdvanceToHumanReview(record) {
2493
+ if (this.#completionInFlight.has(issueKey(record.issue))) {
2494
+ return;
2495
+ }
2496
+ const snapshot = await this.#readBabysatPrSnapshot(record);
2497
+ if (snapshot) {
2498
+ const guard = prMetaAllowsHumanReview(snapshot);
2499
+ if (!guard.ok) {
2500
+ this.#increment('babysitterReadinessGuardBlocked');
2501
+ this.#logger.info?.('[factory] babysitter ready signal ignored; PR meta not eligible', {
2502
+ issue: record.issue.key,
2503
+ reason: guard.reason,
2504
+ });
2505
+ return;
2506
+ }
2507
+ }
2508
+ this.#increment('babysitterReadinessReady');
2509
+ this.#logger.info?.('[factory] babysitter signalled PR ready; advancing to human review', {
2510
+ issue: record.issue.key,
2511
+ prMetaChecked: Boolean(snapshot),
2512
+ });
2513
+ await this.#completeIssue(record);
2514
+ }
2515
+ // Re-read the babysat PR's webhook-fed meta from the mount (no gh). Prefers the
2516
+ // exact path captured when the babysitter was spawned; otherwise scans the
2517
+ // repo's pulls subtree for the PR number across known layout shapes.
2518
+ async #readBabysatPrSnapshot(record) {
2519
+ const ref = this.#babysitterPr.get(record.issue.key);
2520
+ if (!ref) {
2521
+ return undefined;
2522
+ }
2523
+ const candidatePaths = ref.path ? [ref.path] : await this.#pullMetaPathsFor(ref.repo, ref.prNumber);
2524
+ for (const path of candidatePaths) {
2525
+ try {
2526
+ const snapshot = parsePullSnapshot((await this.#mount.readFile(path)).content, ref.prNumber);
2527
+ if (snapshot) {
2528
+ return snapshot;
2529
+ }
2530
+ }
2531
+ catch {
2532
+ // try the next candidate path
2533
+ }
2534
+ }
2535
+ return undefined;
2536
+ }
2537
+ async #pullMetaPathsFor(repo, prNumber) {
2538
+ const [owner, name] = repo.split('/');
2539
+ if (!owner || !name) {
2540
+ return [];
2541
+ }
2542
+ // Scan both the nested <owner>/<repo> and the flat <owner>__<repo> pulls
2543
+ // roots so the readiness guard finds the PR meta regardless of mount layout.
2544
+ const roots = [`/github/repos/${owner}/${name}/pulls/`, `/github/repos/${owner}__${name}/pulls/`];
2545
+ // Match .../pulls/<n>(__slug)?(/...)? for this PR number, in either the
2546
+ // nested directory or flat <n>.json / by-id/<n>.json forms.
2547
+ const numberSegment = new RegExp(`/pulls/(?:by-id/)?${prNumber}(?:__[^/]*)?(?:/|\\.json$)`, 'u');
2548
+ const found = [];
2549
+ for (const root of roots) {
2550
+ try {
2551
+ const tree = await this.#mount.listTree(root);
2552
+ found.push(...tree.filter((path) => path.endsWith('.json') && numberSegment.test(path)));
2553
+ }
2554
+ catch {
2555
+ // try the next root
2556
+ }
2557
+ }
2558
+ return found;
2559
+ }
2560
+ async #completeIssue(record, opts = {}) {
2561
+ const completionKey = issueKey(record.issue);
2562
+ if (this.#completionInFlight.has(completionKey)) {
2563
+ return;
2564
+ }
2565
+ this.#completionInFlight.add(completionKey);
2566
+ try {
2567
+ const issue = await this.#readIssue(record.issue.path);
2568
+ // Resolve the terminal state for the issue's own team. Land in
2569
+ // `human-review` only when the operator opted into that terminal state AND
2570
+ // the team actually has a human-review state; otherwise fall back to `done`
2571
+ // (the legacy terminal) so the issue never gets stuck on an unconfigured
2572
+ // state.
2573
+ const issueTeam = issue?.team;
2574
+ const humanReview = opts.targetState !== 'done' &&
2575
+ this.#config.terminalState === 'human-review' &&
2576
+ this.#states.hasHumanReview(issueTeam);
2577
+ const targetState = humanReview
2578
+ ? this.#states.idFor(issueTeam, 'humanReview')
2579
+ : this.#states.idFor(issueTeam, 'done');
2580
+ const statusLabel = humanReview ? 'In Human Review' : 'Done';
2581
+ if (issue) {
2582
+ await this.#linear.setState(issue, targetState);
2583
+ await this.#recordCanonicalIssueState({ key: issue.key, stateId: targetState });
2584
+ this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
2585
+ }
2586
+ if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('completion-thread')) {
2587
+ try {
2588
+ const merged = opts.completionReason === 'pr-merged';
2589
+ const completionText = merged
2590
+ ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
2591
+ : `${record.issue.key}: factory agents completed${humanReview ? '; awaiting human review' : ''}.\nStatus: ${statusLabel}\nMerge policy: ${this.#config.mergePolicy}`;
2592
+ const stateText = merged
2593
+ ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
2594
+ : humanReview
2595
+ ? `${record.issue.key}: awaiting human review; Linear state set to ${statusLabel}.`
2596
+ : `${record.issue.key}: Linear state set to ${statusLabel}.`;
2597
+ const root = await this.#slack.postThread({
2598
+ channel: this.#config.slack.channel,
2599
+ text: completionText,
2600
+ });
2601
+ await this.#slack.reply(root.threadId, stateText);
2602
+ this.#recordSlackWritebackSuccess('completion-thread');
2603
+ }
2604
+ catch (error) {
2605
+ this.#markSlackWritebackFailure('completion-thread', error);
2606
+ }
2607
+ }
2608
+ // Only auto-merge on the `done` terminal path. Human Review parks the PR
2609
+ // for an operator — the merge gate (which requires an APPROVED review)
2610
+ // would refuse anyway, and we must not merge before the human has looked.
2611
+ if (issue && !humanReview && opts.runMergeGate !== false) {
2612
+ await this.#runCompletionMergeGate(issue);
2613
+ }
2614
+ await this.#releaseAndTerminateAgents([...record.agents], humanReview ? 'issue-human-review' : 'issue-done', 'completion');
2615
+ this.#increment(humanReview ? 'humanReview' : 'done');
2616
+ this.#emit('issue-done', { issue: record.issue });
2617
+ await this.#stopSlackWatcher(record.issue);
2618
+ await this.#recordDispatchTerminal(record.issue);
2619
+ const next = (await this.#batch()).complete(record.issue);
2620
+ if (next) {
2621
+ await this.dispatch(next.decision, { dryRun: next.dryRun });
2622
+ }
2623
+ await this.#writeInFlightRegistry();
2624
+ }
2625
+ catch (error) {
2626
+ this.#error(error, record.issue);
2627
+ }
2628
+ finally {
2629
+ this.#completionInFlight.delete(completionKey);
2630
+ this.#probePrGhBackoffUntilMs.delete(completionKey);
2631
+ this.#probePrResolvedCache.delete(completionKey);
2632
+ this.#babysitterSpawned.delete(record.issue.key);
2633
+ this.#babysitterPr.delete(record.issue.key);
2634
+ }
2635
+ }
2636
+ #emit(event, payload) {
2637
+ for (const listener of this.#listeners.get(event) ?? []) {
2638
+ listener(payload);
2639
+ }
2640
+ }
2641
+ #error(error, issue) {
2642
+ this.#increment('errors');
2643
+ this.#logger.error?.('[factory] error', error);
2644
+ this.#emit('error', { error, ...describeError(error), issue });
2645
+ }
2646
+ #increment(name) {
2647
+ this.#counters[name] = (this.#counters[name] ?? 0) + 1;
2648
+ }
2649
+ async #shouldSkipSlackWriteback(context) {
2650
+ if (!this.#config.slack)
2651
+ return false;
2652
+ const freshness = await this.#slackFreshness();
2653
+ if (this.#slackWritebackFailureDegraded) {
2654
+ if (this.#slackWritebackFailureBackoffUntilMs > this.#clock.now()) {
2655
+ this.#increment('slackWritebacksSkipped');
2656
+ return true;
2657
+ }
2658
+ return false;
2659
+ }
2660
+ if (!freshness.degraded && freshness.known) {
2661
+ if (this.#slackDegraded) {
2662
+ this.#logger.info?.('[factory] Slack sync recovered; resuming Slack writeback', { context });
2663
+ this.#increment('slackRecoveredEpisodes');
2664
+ }
2665
+ this.#slackDegraded = false;
2666
+ this.#slackDegradedReason = undefined;
2667
+ return false;
2668
+ }
2669
+ if (!freshness.degraded && this.#slackDegraded) {
2670
+ this.#increment('slackWritebacksSkipped');
2671
+ return true;
2672
+ }
2673
+ if (!freshness.degraded) {
2674
+ return false;
2675
+ }
2676
+ this.#slackDegradedReason = freshness.reason;
2677
+ this.#increment('slackWritebacksSkipped');
2678
+ if (!this.#slackDegraded) {
2679
+ this.#slackDegraded = true;
2680
+ this.#increment('slackDegradedEpisodes');
2681
+ this.#logger.warn?.('[factory] Slack sync degraded; skipping Slack writeback', {
2682
+ context,
2683
+ reason: freshness.reason,
2684
+ status: freshness.status,
2685
+ });
2686
+ }
2687
+ return true;
2688
+ }
2689
+ #markSlackWritebackFailure(context, error) {
2690
+ this.#slackWritebackFailureDegraded = true;
2691
+ this.#slackWritebackFailureBackoffUntilMs = this.#clock.now() + (this.#config.slack?.staleAfterMs ?? 10 * 60_000);
2692
+ this.#slackDegradedReason = `slack writeback failed: ${describeError(error).errorMessage}`;
2693
+ if (!this.#slackDegraded) {
2694
+ this.#slackDegraded = true;
2695
+ this.#increment('slackDegradedEpisodes');
2696
+ this.#logger.warn?.('[factory] Slack writeback failed; marking Slack degraded', {
2697
+ context,
2698
+ reason: this.#slackDegradedReason,
2699
+ });
2700
+ }
2701
+ }
2702
+ #recordSlackWritebackSuccess(context) {
2703
+ if (this.#slackWritebackFailureDegraded) {
2704
+ this.#logger.info?.('[factory] Slack writeback recovered; clearing write-failure degradation', { context });
2705
+ this.#increment('slackRecoveredEpisodes');
2706
+ }
2707
+ this.#slackWritebackFailureDegraded = false;
2708
+ this.#slackWritebackFailureBackoffUntilMs = 0;
2709
+ if (this.#slackDegraded) {
2710
+ this.#slackDegraded = false;
2711
+ this.#slackDegradedReason = undefined;
2712
+ }
2713
+ }
2714
+ async #slackFreshness() {
2715
+ const staleAfterMs = this.#config.slack?.staleAfterMs ?? 10 * 60_000;
2716
+ let sawSlackStatus = false;
2717
+ let softStatusResult;
2718
+ let softStatus;
2719
+ try {
2720
+ const status = await this.#mount.getSyncStatus?.('slack');
2721
+ sawSlackStatus = status?.provider === 'slack';
2722
+ const statusResult = slackSyncStatusResult(status, this.#clock.now(), staleAfterMs);
2723
+ if (statusResult.known) {
2724
+ if (!statusResult.degraded || statusResult.severity === 'hard') {
2725
+ return { known: true, degraded: statusResult.degraded, reason: statusResult.reason, status };
2726
+ }
2727
+ softStatusResult = statusResult;
2728
+ softStatus = status;
2729
+ }
2730
+ }
2731
+ catch (error) {
2732
+ this.#logger.warn?.('[factory] Slack sync freshness check failed; proceeding without degradation', error);
2733
+ }
2734
+ try {
2735
+ const watermark = await this.#slackEventWatermark();
2736
+ if (watermark.lastEventAtMs === undefined) {
2737
+ return sawSlackStatus
2738
+ ? {
2739
+ known: true,
2740
+ degraded: true,
2741
+ reason: softStatusResult?.reason ?? 'slack sync has no recent event watermark',
2742
+ status: softStatus,
2743
+ }
2744
+ : { known: false, degraded: false };
2745
+ }
2746
+ const ageMs = this.#clock.now() - watermark.lastEventAtMs;
2747
+ if (ageMs <= staleAfterMs) {
2748
+ if (softStatusResult?.degraded) {
2749
+ this.#increment('slackGateBypassedByEventWatermark');
2750
+ this.#logger.info?.('[factory] Slack sync soft-degraded but event watermark is fresh; continuing Slack writeback', {
2751
+ reason: softStatusResult.reason,
2752
+ status: softStatus,
2753
+ lastSlackEventAtMs: watermark.lastEventAtMs,
2754
+ eventWatermarkAgeMs: ageMs,
2755
+ });
2756
+ }
2757
+ return { known: true, degraded: false };
2758
+ }
2759
+ return {
2760
+ known: true,
2761
+ degraded: true,
2762
+ reason: softStatusResult?.reason ?? `slack event watermark stale by ${ageMs}ms`,
2763
+ status: softStatus,
2764
+ };
2765
+ }
2766
+ catch (error) {
2767
+ this.#logger.warn?.(softStatusResult?.degraded
2768
+ ? '[factory] Slack event freshness fallback failed; honoring soft sync degradation'
2769
+ : '[factory] Slack event freshness fallback failed; proceeding without degradation', error);
2770
+ return softStatusResult?.degraded
2771
+ ? { known: true, degraded: true, reason: softStatusResult.reason, status: softStatus }
2772
+ : { known: false, degraded: false };
2773
+ }
2774
+ }
2775
+ async #slackEventWatermark() {
2776
+ const nowMs = this.#clock.now();
2777
+ if (this.#slackEventWatermarkCache && nowMs - this.#slackEventWatermarkCache.checkedAtMs <= SLACK_EVENT_WATERMARK_CACHE_MS) {
2778
+ return this.#slackEventWatermarkCache.result;
2779
+ }
2780
+ if (this.#slackEventWatermarkRefresh) {
2781
+ return this.#slackEventWatermarkRefresh;
2782
+ }
2783
+ this.#slackEventWatermarkRefresh = this.#refreshSlackEventWatermark();
2784
+ try {
2785
+ return await this.#slackEventWatermarkRefresh;
2786
+ }
2787
+ finally {
2788
+ this.#slackEventWatermarkRefresh = undefined;
2789
+ }
2790
+ }
2791
+ async #refreshSlackEventWatermark() {
2792
+ this.#increment('slackEventWatermarkRefreshes');
2793
+ const page = await this.#mount.getEvents({ provider: 'slack', last: 100, limit: 100 });
2794
+ const lastEventAtMs = page.events
2795
+ .filter((event) => eventProvider(event) === 'slack')
2796
+ .map((event) => eventOccurredAtMs(event))
2797
+ .filter((time) => time !== undefined && Number.isFinite(time))
2798
+ .sort((a, b) => b - a)[0];
2799
+ const result = lastEventAtMs === undefined
2800
+ ? { known: true }
2801
+ : { known: true, lastEventAtMs };
2802
+ this.#slackEventWatermarkCache = { checkedAtMs: this.#clock.now(), result };
2803
+ return result;
2804
+ }
2805
+ async #ensureSlackDispatchThread(record, result) {
2806
+ if (!this.#slack || !this.#config.slack || result.dryRun) {
2807
+ return;
2808
+ }
2809
+ if (await this.#shouldSkipSlackWriteback('dispatch-thread')) {
2810
+ return;
2811
+ }
2812
+ const key = issueKey(record.issue);
2813
+ const existingThread = await this.#state.getSlackThread(this.#workspaceId, key);
2814
+ if (existingThread || this.#slackWatcherStarts.has(key)) {
2815
+ try {
2816
+ await this.#slackWatcherStarts.get(key);
2817
+ }
2818
+ catch {
2819
+ // The initiator logs Slack watcher startup failures.
2820
+ }
2821
+ // A dispatch thread already exists (often persisted from a previous
2822
+ // process) but the in-process watcher map starts empty on restart. Re-arm
2823
+ // the reply watcher instead of returning early, otherwise human replies in
2824
+ // the existing thread are watched by nobody and silently dropped.
2825
+ if (existingThread) {
2826
+ await this.#rearmSlackWatcher(record, existingThread);
2827
+ }
2828
+ return;
2829
+ }
2830
+ const start = this.#postAndWatchSlackDispatchThread(record, result);
2831
+ this.#slackWatcherStarts.set(key, start);
2832
+ try {
2833
+ await start;
2834
+ }
2835
+ catch (error) {
2836
+ this.#markSlackWritebackFailure('dispatch-thread', error);
2837
+ this.#logger.warn?.(`[factory] failed to establish Slack dispatch thread for ${record.issue.key}`, error);
2838
+ }
2839
+ finally {
2840
+ this.#slackWatcherStarts.delete(key);
2841
+ }
2842
+ }
2843
+ async #postAndWatchSlackDispatchThread(record, result) {
2844
+ if (!this.#slack || !this.#config.slack) {
2845
+ return;
2846
+ }
2847
+ const root = await this.#slack.postThread({
2848
+ channel: this.#config.slack.channel,
2849
+ text: [
2850
+ `${record.issue.key}: factory agents dispatched.`,
2851
+ `State: ${result.stateId ?? 'dispatching'}`,
2852
+ `Agents: ${result.agents.map((agent) => agent.name).join(', ') || 'none'}`,
2853
+ ].join('\n'),
2854
+ });
2855
+ await this.#state.setSlackThread(this.#workspaceId, issueKey(record.issue), root.threadId);
2856
+ await this.#watchSlackThread(record, root.threadId);
2857
+ this.#recordSlackWritebackSuccess('dispatch-thread');
2858
+ }
2859
+ async #escalateTriageToSlack(decision, reason, dryRun) {
2860
+ if (dryRun) {
2861
+ return;
2862
+ }
2863
+ if (!this.#slack || !this.#config.slack) {
2864
+ this.#logger.warn?.('[factory] triage escalation has no Slack channel configured', {
2865
+ issue: decision.issue,
2866
+ reason,
2867
+ });
2868
+ return;
2869
+ }
2870
+ if (await this.#shouldSkipSlackWriteback('triage-escalation')) {
2871
+ return;
2872
+ }
2873
+ const key = issueKey(decision.issue);
2874
+ const existingThread = await this.#state.getSlackThread(this.#workspaceId, key);
2875
+ if (existingThread || this.#slackWatcherStarts.has(key)) {
2876
+ try {
2877
+ await this.#slackWatcherStarts.get(key);
2878
+ }
2879
+ catch {
2880
+ // The initiator logs Slack watcher startup failures.
2881
+ }
2882
+ // Re-arm the reply watcher for an escalation thread that already exists but
2883
+ // has no live in-process watcher (e.g. after a restart), matching the
2884
+ // dispatch-thread path.
2885
+ if (existingThread) {
2886
+ await this.#rearmSlackWatcher(escalationWatchRecord(decision), existingThread);
2887
+ }
2888
+ return;
2889
+ }
2890
+ const start = this.#postAndWatchSlackEscalationThread(decision, reason);
2891
+ this.#slackWatcherStarts.set(key, start);
2892
+ try {
2893
+ await start;
2894
+ }
2895
+ catch (error) {
2896
+ this.#markSlackWritebackFailure('triage-escalation', error);
2897
+ this.#logger.warn?.(`[factory] failed to establish Slack escalation thread for ${decision.issue.key}`, error);
2898
+ }
2899
+ finally {
2900
+ this.#slackWatcherStarts.delete(key);
2901
+ }
2902
+ }
2903
+ async #postAndWatchSlackEscalationThread(decision, reason) {
2904
+ if (!this.#slack || !this.#config.slack) {
2905
+ return;
2906
+ }
2907
+ const issue = await this.#readIssue(decision.issue.path);
2908
+ const root = await this.#slack.postThread({
2909
+ channel: this.#config.slack.channel,
2910
+ text: [
2911
+ `${decision.issue.key}: factory triage escalation for ${issue?.title ?? decision.issue.key}`,
2912
+ `Reason: ${reason}`,
2913
+ `Question: Please clarify the intended repo/approach or add enough acceptance detail for the factory agent to proceed.`,
2914
+ ].join('\n'),
2915
+ });
2916
+ await this.#state.setSlackThread(this.#workspaceId, issueKey(decision.issue), root.threadId);
2917
+ await this.#watchSlackThread(escalationWatchRecord(decision), root.threadId);
2918
+ this.#recordSlackWritebackSuccess('triage-escalation');
2919
+ }
2920
+ async #watchSlackThread(record, threadId) {
2921
+ if (!this.#config.slack) {
2922
+ return;
2923
+ }
2924
+ const key = issueKey(record.issue);
2925
+ if (this.#slackWatchers.has(key)) {
2926
+ return;
2927
+ }
2928
+ const channelDir = this.#config.slack.channel;
2929
+ const messagesPrefix = slackChannelMessagesPrefix(channelDir);
2930
+ const preExistingPaths = new Set();
2931
+ const seenReplies = new Set();
2932
+ const seenReplyMessages = new Set();
2933
+ let missingIdentityLogged = false;
2934
+ let cursor;
2935
+ let stopped = false;
2936
+ let pollTimer;
2937
+ const markPreExisting = async () => {
2938
+ try {
2939
+ const page = await this.#mount.getEvents({ limit: SLACK_REPLY_EVENTS_LIMIT });
2940
+ cursor = page.nextCursor ?? undefined;
2941
+ for (const event of page.events) {
2942
+ const path = changeEventPath(event);
2943
+ if (path && path.startsWith(messagesPrefix)) {
2944
+ preExistingPaths.add(path);
2945
+ }
2946
+ }
2947
+ }
2948
+ catch (error) {
2949
+ this.#logger.warn?.('[factory] unable to seed Slack reply watcher event cursor', error);
2950
+ }
2951
+ };
2952
+ const handle = async (event) => {
2953
+ try {
2954
+ // Polling-fallback / degraded-sync events can lack `resource.path`
2955
+ // despite the SDK type. Skip them quietly so one malformed event never
2956
+ // wedges Slack reply processing (replies that DO carry a path still flow).
2957
+ const path = changeEventPath(event);
2958
+ if (stopped || !path || !path.startsWith(messagesPrefix)) {
2959
+ return;
2960
+ }
2961
+ const eventKey = eventIdentity(event);
2962
+ if (!eventKey) {
2963
+ if (!missingIdentityLogged) {
2964
+ missingIdentityLogged = true;
2965
+ this.#logger.warn?.('[factory] Slack reply event missing stable identity; falling back to path/content dedupe');
2966
+ }
2967
+ }
2968
+ if (preExistingPaths.has(path)) {
2969
+ return;
2970
+ }
2971
+ const reply = await this.#readSlackReply(path);
2972
+ if (!reply || !reply.isThreadReply || reply.threadTs !== threadId || reply.channelDir !== channelDir) {
2973
+ return;
2974
+ }
2975
+ const replyMessageKey = `${reply.threadTs}:${reply.messageTs}`;
2976
+ if (seenReplyMessages.has(replyMessageKey)) {
2977
+ this.#logger.debug?.('[factory] suppressed duplicate Slack reply message', { issue: record.issue.key, path });
2978
+ return;
2979
+ }
2980
+ const replyKey = `${eventKey ?? path}:${stableHash(JSON.stringify(reply.raw))}`;
2981
+ if (seenReplies.has(replyKey)) {
2982
+ this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path });
2983
+ return;
2984
+ }
2985
+ seenReplies.add(replyKey);
2986
+ if (reply.isBot) {
2987
+ return;
2988
+ }
2989
+ seenReplyMessages.add(replyMessageKey);
2990
+ await this.#routeSlackAnswerToImplementers(record, reply);
2991
+ }
2992
+ catch (error) {
2993
+ this.#logger.error?.('[factory] failed to handle Slack reply event', error);
2994
+ }
2995
+ };
2996
+ await markPreExisting();
2997
+ let subscription;
2998
+ try {
2999
+ subscription = this.#mount.subscribe([`${messagesPrefix}**`], (event) => {
3000
+ void handle(event);
3001
+ });
3002
+ }
3003
+ catch (error) {
3004
+ this.#logger.warn?.('[factory] Slack reply subscribe failed; relying on event polling', error);
3005
+ }
3006
+ const poll = async () => {
3007
+ if (stopped) {
3008
+ return;
3009
+ }
3010
+ try {
3011
+ const page = await this.#mount.getEvents({ cursor, limit: SLACK_REPLY_EVENTS_LIMIT });
3012
+ cursor = page.nextCursor ?? cursor;
3013
+ for (const event of page.events) {
3014
+ await handle(event);
3015
+ }
3016
+ }
3017
+ catch (error) {
3018
+ this.#logger.warn?.('[factory] Slack reply polling failed', error);
3019
+ }
3020
+ if (!stopped) {
3021
+ pollTimer = setTimeout(() => {
3022
+ void poll();
3023
+ }, SLACK_REPLY_POLL_INTERVAL_MS);
3024
+ pollTimer.unref?.();
3025
+ }
3026
+ };
3027
+ void poll();
3028
+ this.#slackWatchers.set(key, {
3029
+ stop: async () => {
3030
+ stopped = true;
3031
+ if (pollTimer) {
3032
+ clearTimeout(pollTimer);
3033
+ pollTimer = undefined;
3034
+ }
3035
+ await this.#boundedStopTeardown('Slack reply subscription unsubscribe', () => subscription?.unsubscribe());
3036
+ },
3037
+ });
3038
+ }
3039
+ // Re-attach a live reply watcher to a dispatch/escalation thread that already
3040
+ // exists (persisted thread id) but has no in-process watcher. #watchSlackThread
3041
+ // is itself idempotent on #slackWatchers; the guard here keeps the counter (and
3042
+ // the seeding getEvents call) limited to genuine re-arms.
3043
+ async #rearmSlackWatcher(record, threadId) {
3044
+ const key = issueKey(record.issue);
3045
+ if (this.#slackWatchers.has(key) || this.#slackWatcherStarts.has(key)) {
3046
+ return;
3047
+ }
3048
+ try {
3049
+ await this.#watchSlackThread(record, threadId);
3050
+ this.#increment('slackWatchersRearmed');
3051
+ }
3052
+ catch (error) {
3053
+ this.#logger.warn?.('[factory] failed to re-arm Slack reply watcher', { issue: record.issue.key, error });
3054
+ }
3055
+ }
3056
+ // On start()/runLoop() init, re-arm Slack reply watchers for every in-flight
3057
+ // issue that has a persisted dispatch thread. A watcher otherwise only lives as
3058
+ // long as the process that dispatched it, so replies after a restart (or a
3059
+ // run-once exit followed by a loop) would be watched by nobody.
3060
+ async #rearmSlackReplyWatchers() {
3061
+ if (!this.#slack || !this.#config.slack) {
3062
+ return;
3063
+ }
3064
+ for (const record of (await this.#batch()).inFlight) {
3065
+ if (record.dryRun) {
3066
+ continue;
3067
+ }
3068
+ const key = issueKey(record.issue);
3069
+ if (this.#slackWatchers.has(key) || this.#slackWatcherStarts.has(key)) {
3070
+ continue;
3071
+ }
3072
+ let threadId;
3073
+ try {
3074
+ threadId = await this.#state.getSlackThread(this.#workspaceId, key);
3075
+ }
3076
+ catch (error) {
3077
+ this.#logger.warn?.('[factory] unable to read persisted Slack thread during watcher rehydration', { issue: record.issue.key, error });
3078
+ continue;
3079
+ }
3080
+ if (!threadId) {
3081
+ continue;
3082
+ }
3083
+ await this.#rearmSlackWatcher(record, threadId);
3084
+ }
3085
+ }
3086
+ async #stopSlackWatcher(issue) {
3087
+ const key = issueKey(issue);
3088
+ const watcher = this.#slackWatchers.get(key);
3089
+ this.#slackWatchers.delete(key);
3090
+ await this.#state.clearSlackThread(this.#workspaceId, key);
3091
+ await watcher?.stop();
3092
+ }
3093
+ async #readSlackReply(path) {
3094
+ try {
3095
+ const { content } = await this.#mount.readFile(path);
3096
+ return parseSlackReply(path, content, this.#config.slack?.botUserId ?? 'U0B2596R7EZ');
3097
+ }
3098
+ catch (error) {
3099
+ this.#logger.warn?.(`Unable to read Slack reply ${path}`, error);
3100
+ return undefined;
3101
+ }
3102
+ }
3103
+ async #routeSlackAnswerToImplementers(record, reply) {
3104
+ if (!this.#config.slack || !this.#fleet.sendInput) {
3105
+ return;
3106
+ }
3107
+ const liveRecord = (await this.#batch()).getIssue(record.issue);
3108
+ if (!liveRecord || liveRecord.dryRun) {
3109
+ this.#increment('slackAnswersIgnoredNoInFlight');
3110
+ return;
3111
+ }
3112
+ const text = reply.text.trim();
3113
+ if (!text) {
3114
+ this.#increment('slackAnswersIgnoredEmpty');
3115
+ return;
3116
+ }
3117
+ // Route human replies to the implementers AND the babysitter — once a PR is
3118
+ // open the babysitter is the one chatting with the human about it.
3119
+ const recipients = [...liveRecord.agents.values()]
3120
+ .filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
3121
+ .map((agent) => agent.result?.name ?? agent.spec.name)
3122
+ .filter((name) => Boolean(name));
3123
+ if (recipients.length === 0) {
3124
+ this.#increment('slackAnswersIgnoredNoImplementer');
3125
+ return;
3126
+ }
3127
+ for (const recipient of new Set(recipients)) {
3128
+ await this.#injectSlackReplyEvent(recipient, liveRecord.issue, text);
3129
+ this.#increment('slackAnswersInjected');
3130
+ }
3131
+ }
3132
+ // Inject the human's Slack reply into the agent framed as the
3133
+ // <integration-event> the spawn prompt tells it to expect (not an ambiguous
3134
+ // "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
3135
+ // event. (A broker confirmed-delivery path via waitForInjected is a possible
3136
+ // robustness follow-up.)
3137
+ async #injectSlackReplyEvent(recipient, issue, text) {
3138
+ await this.#fleet.sendInput?.(recipient, slackReplyEvent(issue, text));
3139
+ }
3140
+ // Absolute path to the local .integrations mount the daemon manages. The mount
3141
+ // is created at the daemon's cwd (see ensureLocalMount), and spawned agents run
3142
+ // in their repo clonePath, so writeback paths handed to agents must be absolute
3143
+ // against this root rather than a bare relative `.integrations/...`.
3144
+ #integrationsMountRoot() {
3145
+ return resolve(process.cwd(), '.integrations');
3146
+ }
3147
+ async #slackDispatchThreadFor(record) {
3148
+ if (!this.#config.slack) {
3149
+ return undefined;
3150
+ }
3151
+ const threadId = await this.#state.getSlackThread(this.#workspaceId, issueKey(record.issue));
3152
+ return threadId
3153
+ ? { channel: this.#config.slack.channel, threadId, mountRoot: this.#integrationsMountRoot() }
3154
+ : undefined;
3155
+ }
3156
+ async #runCompletionMergeGate(issue) {
3157
+ if (this.#isSyntheticProbeIssue(issue)) {
3158
+ await this.#closeSyntheticProbeIfPresent(issue);
3159
+ return;
3160
+ }
3161
+ if (!isRealLinearIssue(issue)) {
3162
+ this.#logger.warn?.('[factory] merge gate skipped non-real Linear issue', { issue: issue.key });
3163
+ this.#increment('mergeGateSkippedNonReal');
3164
+ return;
3165
+ }
3166
+ if (this.#config.mergePolicy !== 'on-green-with-review') {
3167
+ return;
3168
+ }
3169
+ const pr = await this.#probePrResolver(issue);
3170
+ if (!pr) {
3171
+ this.#logger.warn?.('[factory] merge gate found no PR for real issue', { issue: issue.key });
3172
+ this.#increment('mergeGateMissingPr');
3173
+ return;
3174
+ }
3175
+ const ready = await this.#waitForMergeReady(pr);
3176
+ const headSha = ready?.live.headRefOid;
3177
+ if (!ready || !headSha) {
3178
+ this.#increment('mergeGateNotMerged');
3179
+ return;
3180
+ }
3181
+ const result = await this.#mergeGate.merge({
3182
+ repo: pr.repo,
3183
+ number: pr.prNumber,
3184
+ expectedHeadSha: headSha,
3185
+ });
3186
+ if (!result.merged) {
3187
+ this.#logger.warn?.('[factory] merge gate aborted guarded merge', {
3188
+ issue: issue.key,
3189
+ repo: pr.repo,
3190
+ prNumber: pr.prNumber,
3191
+ headSha,
3192
+ reason: result.reason,
3193
+ });
3194
+ this.#increment('mergeGateMergeAborted');
3195
+ return;
3196
+ }
3197
+ this.#logger.info?.('[factory] merge gate merged PR', {
3198
+ issue: issue.key,
3199
+ repo: pr.repo,
3200
+ prNumber: pr.prNumber,
3201
+ headSha,
3202
+ });
3203
+ this.#increment('mergeGateMerged');
3204
+ }
3205
+ async #closeSyntheticProbeIfPresent(issue) {
3206
+ const probe = this.#customProbePrResolver
3207
+ ? await this.#probePrResolver(issue)
3208
+ : await this.#resolveIssuePr(issue, {
3209
+ titleMarker: FACTORY_E2E_MARKER,
3210
+ });
3211
+ if (!probe) {
3212
+ return;
3213
+ }
3214
+ await this.#probeCloser({
3215
+ repo: probe.repo,
3216
+ prNumber: probe.prNumber,
3217
+ expectedIssueKey: issue.key,
3218
+ requireTitleMarker: false,
3219
+ });
3220
+ this.#increment('mergeGateSyntheticClosed');
3221
+ }
3222
+ async #waitForMergeReady(pr) {
3223
+ let lastReason = 'not checked';
3224
+ for (let attempt = 1; attempt <= MERGE_GATE_MAX_ATTEMPTS; attempt += 1) {
3225
+ const verdict = await this.#mergeGate.check({ repo: pr.repo, number: pr.prNumber });
3226
+ lastReason = verdict.reason;
3227
+ if (verdict.ready && verdict.live.headRefOid) {
3228
+ return verdict;
3229
+ }
3230
+ if (attempt < MERGE_GATE_MAX_ATTEMPTS) {
3231
+ await this.#clock.sleep(MERGE_GATE_POLL_DELAY_MS);
3232
+ }
3233
+ }
3234
+ this.#logger.warn?.('[factory] merge gate left PR open; readiness timeout', {
3235
+ repo: pr.repo,
3236
+ prNumber: pr.prNumber,
3237
+ attempts: MERGE_GATE_MAX_ATTEMPTS,
3238
+ reason: lastReason,
3239
+ });
3240
+ return undefined;
3241
+ }
3242
+ #isSyntheticProbeIssue(issue) {
3243
+ return hasTitlePrefix(issue.title, FACTORY_E2E_MARKER);
3244
+ }
3245
+ }
3246
+ export function parseLinearIssue(path, content) {
3247
+ const parsed = parseJsonContent(content);
3248
+ const payload = wrappedPayload(parsed);
3249
+ const wrapper = asRecord(parsed) ?? {};
3250
+ const state = asRecord(payload.state);
3251
+ const labels = Array.isArray(payload.labels)
3252
+ ? payload.labels.map(labelName).filter((label) => Boolean(label))
3253
+ : [];
3254
+ const project = recordName(payload.project);
3255
+ const team = recordName(payload.team);
3256
+ const assignee = recordName(payload.assignee);
3257
+ const key = stringValue(payload.identifier) ?? keyFromPath(path);
3258
+ const uuid = stringValue(payload.id) ?? stringValue(wrapper.objectId) ?? uuidFromPath(path) ?? key;
3259
+ const stateName = stringValue(state?.name) ?? stringValue(payload.state_name);
3260
+ // Resolve the issue's state UUID from the payload only. The factory maps that
3261
+ // UUID to a role via the runtime state resolution (no hardcoded name->id), so
3262
+ // an unknown state simply matches no role rather than being mis-routed.
3263
+ const stateId = stringValue(payload.stateId) ?? stringValue(state?.id) ?? '';
3264
+ return {
3265
+ uuid,
3266
+ key,
3267
+ title: stringValue(payload.title) ?? '',
3268
+ description: stringValue(payload.description) ?? '',
3269
+ stateId,
3270
+ state: state || stateName ? { name: stateName ?? '' } : undefined,
3271
+ labels,
3272
+ project,
3273
+ team,
3274
+ assignee,
3275
+ path,
3276
+ raw: asRecord(parsed) ?? payload,
3277
+ };
3278
+ }
3279
+ // A primary issue file that parsed without any usable state is a change-event
3280
+ // STUB ({created,path,externalId,ts,id}) — the active-issues sync wrote the full
3281
+ // body to the by-id/by-uuid aliases instead. Distinguishes a stub from a real
3282
+ // record (which always carries at least a state name from sync).
3283
+ const isUsableIssueRecord = (issue) => Boolean(issue.stateId || issue.state?.name);
3284
+ // The canonical sibling records for a primary /linear/issues/<key>__<uuid>.json
3285
+ // path: by-id keyed on the human key, by-uuid keyed on the Linear UUID.
3286
+ const canonicalIssueRecordPaths = (path) => {
3287
+ const key = keyFromPath(path);
3288
+ const uuid = uuidFromPath(path);
3289
+ return [
3290
+ ...(key ? [linearByIdPath(key)] : []),
3291
+ ...(uuid ? [linearByUuidPath(uuid)] : []),
3292
+ ].filter((candidate) => candidate !== path);
3293
+ };
3294
+ // Read an issue, falling back to the canonical by-id/by-uuid alias when the
3295
+ // primary path holds only a stub. The canonical content is re-parsed against
3296
+ // the ORIGINAL primary path so issue.path/key/uuid stay primary-anchored (the
3297
+ // rest of the factory dedupes and dispatches by the primary path). A missing
3298
+ // alias is tolerated; the original (stub) record is returned if no alias helps.
3299
+ export async function readLinearIssueWithCanonicalFallback(mount, path) {
3300
+ const { content } = await mount.readFile(path);
3301
+ const issue = parseLinearIssue(path, content);
3302
+ if (isUsableIssueRecord(issue))
3303
+ return issue;
3304
+ for (const candidate of canonicalIssueRecordPaths(path)) {
3305
+ try {
3306
+ const canonical = await mount.readFile(candidate);
3307
+ const parsed = parseLinearIssue(path, canonical.content);
3308
+ if (isUsableIssueRecord(parsed))
3309
+ return parsed;
3310
+ }
3311
+ catch (error) {
3312
+ if (isMissingIssueFileError(error))
3313
+ continue;
3314
+ throw error;
3315
+ }
3316
+ }
3317
+ return issue;
3318
+ }
3319
+ export function parseGithubIssue(path, content) {
3320
+ const parsed = parseJsonContent(content);
3321
+ const payload = wrappedPayload(parsed);
3322
+ const pathParts = githubIssuePathParts(path);
3323
+ const repository = asRecord(payload.repository);
3324
+ const owner = stringValue(asRecord(repository?.owner)?.login) ?? stringValue(asRecord(repository?.owner)?.name) ?? pathParts?.owner;
3325
+ const repoName = stringValue(repository?.name) ?? pathParts?.repo;
3326
+ const number = numberValue(payload.number) ?? pathParts?.number;
3327
+ const url = stringValue(payload.html_url) ?? stringValue(payload.url) ?? stringValue(payload.issue_url);
3328
+ if (!owner || !repoName || typeof number !== 'number' || !url) {
3329
+ throw new Error(`GitHub issue ${path} is missing owner, repo, number, or url`);
3330
+ }
3331
+ return {
3332
+ owner,
3333
+ repoName,
3334
+ repo: `${owner}/${repoName}`,
3335
+ number,
3336
+ title: stringValue(payload.title) ?? '',
3337
+ body: stringValue(payload.body) ?? '',
3338
+ url,
3339
+ state: (stringValue(payload.state) ?? '').toLowerCase(),
3340
+ labels: Array.isArray(payload.labels)
3341
+ ? payload.labels.map(labelName).filter((label) => Boolean(label))
3342
+ : [],
3343
+ path,
3344
+ raw: asRecord(parsed) ?? payload,
3345
+ };
3346
+ }
3347
+ export async function readFactoryLoopHeartbeat(path = DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH) {
3348
+ try {
3349
+ return parseJsonContent(await readFile(path, 'utf8'));
3350
+ }
3351
+ catch {
3352
+ return undefined;
3353
+ }
3354
+ }
3355
+ export function checkFactoryLoopLiveness(heartbeat, opts = {}) {
3356
+ if (!heartbeat) {
3357
+ return { ok: false, stale: true, reason: 'heartbeat missing' };
3358
+ }
3359
+ const nowMs = opts.nowMs ?? Date.now();
3360
+ const staleMs = opts.staleMs ?? 60_000;
3361
+ const ageMs = Math.max(0, nowMs - heartbeat.updatedAtMs);
3362
+ const stale = ageMs > staleMs;
3363
+ if (stale) {
3364
+ return { ok: false, stale: true, ageMs, heartbeat, reason: 'heartbeat stale' };
3365
+ }
3366
+ if (heartbeat.status === 'stopping') {
3367
+ return { ok: false, stale: false, ageMs, heartbeat, reason: 'loop stopping' };
3368
+ }
3369
+ return { ok: true, stale: false, ageMs, heartbeat };
3370
+ }
3371
+ export function isRealLinearIssue(issue) {
3372
+ const payload = wrappedPayload(issue.raw);
3373
+ const identifier = stringValue(payload.identifier) ?? issue.key;
3374
+ return identifier === issue.key &&
3375
+ /^[A-Z]+-\d+$/u.test(identifier) &&
3376
+ typeof payload.url === 'string' &&
3377
+ payload.url.length > 0;
3378
+ }
3379
+ const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
3380
+ const pidsFromSpawnResult = (result) => {
3381
+ const pids = new Set();
3382
+ for (const pid of result?.pids ?? []) {
3383
+ if (Number.isInteger(pid) && pid > 0)
3384
+ pids.add(pid);
3385
+ }
3386
+ if (Number.isInteger(result?.pid) && result.pid > 0) {
3387
+ pids.add(result.pid);
3388
+ }
3389
+ return [...pids].sort((a, b) => a - b);
3390
+ };
3391
+ const dispatchComment = (decision, agents) => [
3392
+ `Factory dispatch for ${decision.issue.key}`,
3393
+ `Implementers: ${agents.filter((agent) => agent.role === 'implementer').map((agent) => agent.name).join(', ') || 'none'}`,
3394
+ decision.scope === 'workflow' ? `Workflow: ${agents.find((agent) => agent.role === 'workflow')?.name ?? 'none'}` : undefined,
3395
+ `Reviewer: ${agents.find((agent) => agent.role === 'reviewer')?.name ?? 'none'}`,
3396
+ ].filter((line) => line !== undefined).join('\n');
3397
+ function dispatchSpecs(decision) {
3398
+ if (decision.scope === 'workflow') {
3399
+ return decision.workflow ? [decision.workflow] : [];
3400
+ }
3401
+ return [...decision.implementers, decision.reviewer];
3402
+ }
3403
+ function labelDerivedDispatchDecision(liveIssue, decision, config) {
3404
+ const routesByLabel = labelRoutesForIssue(liveIssue, config);
3405
+ if (routesByLabel.labels.length === 0) {
3406
+ // No repo labels — which is also what a label-less sync produces
3407
+ // (relayfile-adapters#205, labels dropped from the synced record). Fall back
3408
+ // to the configured default repo (consistent with triage, which already
3409
+ // routes unlabeled issues to repos.default) rather than refusing to dispatch.
3410
+ const defaultRepo = config.repos.default;
3411
+ if (defaultRepo) {
3412
+ const route = {
3413
+ repo: defaultRepo,
3414
+ clonePath: config.repos.clonePaths[defaultRepo],
3415
+ rationale: 'No repo label present; routed to repos.default.',
3416
+ };
3417
+ const implementer = routeImplementerSpec(liveIssue, config, 'default', route);
3418
+ return {
3419
+ ok: true,
3420
+ decision: {
3421
+ ...decision,
3422
+ routes: [route],
3423
+ scope: 'single',
3424
+ implementers: [implementer],
3425
+ workflow: undefined,
3426
+ reviewer: routeReviewerSpec(liveIssue, config, route, decision.reviewer),
3427
+ },
3428
+ };
3429
+ }
3430
+ return {
3431
+ ok: false,
3432
+ reason: 'no-labels',
3433
+ offendingLabels: [],
3434
+ };
3435
+ }
3436
+ if (routesByLabel.routes.length === 0) {
3437
+ return {
3438
+ ok: false,
3439
+ reason: 'unmapped-labels',
3440
+ offendingLabels: routesByLabel.offendingLabels.length > 0 ? routesByLabel.offendingLabels : routesByLabel.labels,
3441
+ };
3442
+ }
3443
+ const explicitScope = scopeFromLabels(liveIssue.labels);
3444
+ const scope = explicitScope
3445
+ ?? (routesByLabel.routes.length >= 2 || decision.scope === 'team'
3446
+ ? 'team'
3447
+ : decision.scope === 'workflow'
3448
+ ? 'workflow'
3449
+ : 'single');
3450
+ const maxImplementers = Math.min(config.triage.maxImplementers, MAX_LABEL_IMPLEMENTERS);
3451
+ if (scope === 'team' && routesByLabel.routes.length > maxImplementers) {
3452
+ return {
3453
+ ok: false,
3454
+ reason: 'too-many-labels',
3455
+ offendingLabels: routesByLabel.routes.map((assignment) => assignment.slug),
3456
+ maxImplementers,
3457
+ };
3458
+ }
3459
+ const implementers = routesByLabel.routes.map(({ slug, route }) => routeImplementerSpec(liveIssue, config, slug, route));
3460
+ const selectedRoutes = scope === 'single' ? routesByLabel.routes.slice(0, 1) : routesByLabel.routes;
3461
+ const selectedImplementers = scope === 'team'
3462
+ ? implementers
3463
+ : scope === 'single'
3464
+ ? implementers.slice(0, 1)
3465
+ : [];
3466
+ const routes = selectedRoutes.map(({ route }) => route);
3467
+ const workflow = scope === 'workflow'
3468
+ ? routeWorkflowSpec(liveIssue, config, selectedRoutes, decision.workflow)
3469
+ : undefined;
3470
+ return {
3471
+ ok: true,
3472
+ decision: {
3473
+ ...decision,
3474
+ routes,
3475
+ scope,
3476
+ implementers: selectedImplementers,
3477
+ workflow,
3478
+ reviewer: routeReviewerSpec(liveIssue, config, selectedRoutes[0].route, decision.reviewer),
3479
+ },
3480
+ };
3481
+ }
3482
+ function labelRoutesForIssue(issue, config) {
3483
+ const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label));
3484
+ const routes = [];
3485
+ const offendingLabels = [];
3486
+ const seenRepos = new Set();
3487
+ for (const label of labels) {
3488
+ const entry = findLabelRoute(config.repos.byLabel, label);
3489
+ if (!entry) {
3490
+ offendingLabels.push(label);
3491
+ continue;
3492
+ }
3493
+ const repo = entry.repo;
3494
+ if (seenRepos.has(repo)) {
3495
+ continue;
3496
+ }
3497
+ seenRepos.add(repo);
3498
+ routes.push({
3499
+ slug: entry.label,
3500
+ route: {
3501
+ repo,
3502
+ clonePath: config.repos.clonePaths[repo],
3503
+ rationale: `Label "${entry.label}" routes to ${repo}.`,
3504
+ },
3505
+ });
3506
+ }
3507
+ return { labels, offendingLabels, routes };
3508
+ }
3509
+ function routeImplementerSpec(issue, config, slug, route) {
3510
+ return {
3511
+ name: `${agentBaseName(issue)}-impl-${sanitizeAgentSlug(slug)}`,
3512
+ role: 'implementer',
3513
+ capability: 'spawn:codex',
3514
+ model: config.models.implementer,
3515
+ task: taskForDispatch(issue, route, 'implementer'),
3516
+ repo: route.repo,
3517
+ clonePath: route.clonePath,
3518
+ node: 'self',
3519
+ };
3520
+ }
3521
+ function routeReviewerSpec(issue, config, route, reviewer) {
3522
+ return {
3523
+ ...reviewer,
3524
+ name: `${agentBaseName(issue)}-review`,
3525
+ role: 'reviewer',
3526
+ capability: reviewer.capability ?? 'spawn:claude',
3527
+ model: reviewer.model ?? config.models.reviewer,
3528
+ task: taskForDispatch(issue, route, 'reviewer'),
3529
+ repo: route.repo,
3530
+ clonePath: route.clonePath,
3531
+ node: reviewer.node ?? 'self',
3532
+ };
3533
+ }
3534
+ function routeWorkflowSpec(issue, _config, routesByLabel, workflow) {
3535
+ const route = routesByLabel[0].route;
3536
+ return {
3537
+ ...workflow,
3538
+ name: workflow?.name ?? `${agentBaseName(issue)}-workflow`,
3539
+ role: 'workflow',
3540
+ capability: 'workflow:run',
3541
+ task: workflow?.task ?? taskForDispatch(issue, route, 'workflow'),
3542
+ workflow: workflow?.workflow ?? 'workflows/factory/linear-issue.ts',
3543
+ inputs: {
3544
+ ...workflow?.inputs,
3545
+ issue: { uuid: issue.uuid, key: issue.key, path: issue.path },
3546
+ title: issue.title,
3547
+ description: issue.description,
3548
+ labels: issue.labels,
3549
+ repoLabels: routesByLabel.map(({ slug }) => slug),
3550
+ routes: routesByLabel.map(({ route }) => route),
3551
+ },
3552
+ repo: route.repo,
3553
+ clonePath: route.clonePath,
3554
+ node: workflow?.node ?? 'self',
3555
+ };
3556
+ }
3557
+ function labelDispatchFailureSignature(resolution) {
3558
+ return `${resolution.reason}:${[...resolution.offendingLabels].sort().join(',')}`;
3559
+ }
3560
+ function labelDispatchFailureComment(issue, resolution) {
3561
+ const lines = [`Factory dispatch for ${issue.key} skipped`];
3562
+ if (resolution.reason === 'no-labels') {
3563
+ lines.push('No Linear labels were present.');
3564
+ lines.push('Add one repo label from factory.config.json repos.byLabel, then move the issue back to Ready for Agent.');
3565
+ }
3566
+ else if (resolution.reason === 'too-many-labels') {
3567
+ lines.push(`Too many repo labels matched dispatch: ${resolution.offendingLabels.join(', ')}.`);
3568
+ lines.push(`Dispatch is capped at ${resolution.maxImplementers} implementer(s); scope the issue or raise triage.maxImplementers within the dispatch cap.`);
3569
+ }
3570
+ else if (resolution.offendingLabels.length > 0) {
3571
+ lines.push(`No repo labels matched factory.config.json repos.byLabel. Unmapped label(s): ${resolution.offendingLabels.join(', ')}.`);
3572
+ lines.push('Update the labels or factory.config.json repos.byLabel, then move the issue back to Ready for Agent.');
3573
+ }
3574
+ else {
3575
+ lines.push('No repo labels matched factory.config.json repos.byLabel.');
3576
+ lines.push('Update the labels or factory.config.json repos.byLabel, then move the issue back to Ready for Agent.');
3577
+ }
3578
+ return lines.join('\n');
3579
+ }
3580
+ function findLabelRoute(map, label) {
3581
+ const exact = map[label];
3582
+ if (exact) {
3583
+ return { label, repo: exact };
3584
+ }
3585
+ const normalized = label.toLowerCase();
3586
+ const entry = Object.entries(map).find(([candidate]) => candidate.toLowerCase() === normalized);
3587
+ return entry ? { label: entry[0], repo: entry[1] } : undefined;
3588
+ }
3589
+ function uniqueNormalizedLabels(labels) {
3590
+ const seen = new Set();
3591
+ const unique = [];
3592
+ for (const label of labels) {
3593
+ const trimmed = label.trim();
3594
+ const normalized = trimmed.toLowerCase();
3595
+ if (!trimmed || seen.has(normalized)) {
3596
+ continue;
3597
+ }
3598
+ seen.add(normalized);
3599
+ unique.push(trimmed);
3600
+ }
3601
+ return unique;
3602
+ }
3603
+ function taskForDispatch(issue, route, role) {
3604
+ const verb = role === 'implementer'
3605
+ ? 'Implement'
3606
+ : role === 'babysitter'
3607
+ ? 'Babysit the PR for'
3608
+ : role === 'workflow'
3609
+ ? 'Run workflow for'
3610
+ : 'Review';
3611
+ return [
3612
+ `${verb} ${issue.key}: ${issue.title}`,
3613
+ `Repo: ${route.repo}`,
3614
+ `Route rationale: ${route.rationale}`,
3615
+ issue.description,
3616
+ ].join('\n\n');
3617
+ }
3618
+ function agentBaseName(issue) {
3619
+ const number = issue.key.match(/\d+/)?.[0] ?? sanitizeAgentSlug(issue.key);
3620
+ return `ar-${number}`;
3621
+ }
3622
+ function sanitizeAgentSlug(slug) {
3623
+ return slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'scope';
3624
+ }
3625
+ const templateIssueFromRecord = (record, issue) => ({
3626
+ key: issue?.key ?? record.issue.key,
3627
+ title: issue?.title ?? record.issue.key,
3628
+ description: issue?.description ?? '',
3629
+ });
3630
+ const routeForImplementer = (record, spec) => {
3631
+ const route = record.decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? record.decision.routes.find((candidate) => candidate.repo === spec.repo);
3632
+ return {
3633
+ repo: spec.repo,
3634
+ clonePath: spec.clonePath,
3635
+ rationale: route?.rationale,
3636
+ };
3637
+ };
3638
+ const repoMapFromConfig = (config) => {
3639
+ const repos = new Set([
3640
+ ...Object.values(config.repos.byLabel),
3641
+ ...Object.values(config.repos.byProject),
3642
+ ...config.repos.keywordRules.map((rule) => rule.repo),
3643
+ config.repos.default,
3644
+ ].filter((repo) => Boolean(repo)));
3645
+ return [...repos].map((repo) => ({
3646
+ repo,
3647
+ clonePath: config.repos.clonePaths[repo],
3648
+ source: 'default',
3649
+ }));
3650
+ };
3651
+ export const githubIssuePathParts = (path) => {
3652
+ // Canonical GitHub relayfile issue entries are nested as
3653
+ // /github/repos/<owner>/<repo>/issues/<number>__<slug>/meta.json. Keep the
3654
+ // legacy flat and by-id forms for older mount state, and accept metadata.json
3655
+ // as a read-only compatibility alias for historical local mount snapshots.
3656
+ const match = path.match(/^\/github\/repos\/([^/]+)\/([^/]+)\/issues\/(?:(?:by-id\/)?(\d+)\.json|(\d+)(?:__([^/]+))?\/(?:meta|metadata)\.json)$/u);
3657
+ if (!match) {
3658
+ return undefined;
3659
+ }
3660
+ const number = Number(match[3] ?? match[4]);
3661
+ return {
3662
+ owner: match[1],
3663
+ repo: match[2],
3664
+ number,
3665
+ slug: match[5],
3666
+ };
3667
+ };
3668
+ const githubIssueReadCandidatePaths = (path) => {
3669
+ if (path.endsWith('/')) {
3670
+ return [`${path}meta.json`, `${path}metadata.json`];
3671
+ }
3672
+ if (path.endsWith('/meta.json')) {
3673
+ return [path, path.replace(/\/meta\.json$/u, '/metadata.json')];
3674
+ }
3675
+ if (path.endsWith('/metadata.json')) {
3676
+ return [path, path.replace(/\/metadata\.json$/u, '/meta.json')];
3677
+ }
3678
+ if (githubIssuePathParts(path)) {
3679
+ return [path];
3680
+ }
3681
+ if (githubIssueDirectoryPathParts(path)) {
3682
+ // meta.json is the canonical relayfile GitHub issue basename. metadata.json
3683
+ // remains a legacy read fallback for older local mount-state snapshots.
3684
+ return [`${path}/meta.json`, `${path}/metadata.json`];
3685
+ }
3686
+ return [path];
3687
+ };
3688
+ const githubIssueDirectoryPathParts = (path) => {
3689
+ const match = path.match(/^\/github\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:__([^/]+))?$/u);
3690
+ if (!match) {
3691
+ return undefined;
3692
+ }
3693
+ return {
3694
+ owner: match[1],
3695
+ repo: match[2],
3696
+ number: Number(match[3]),
3697
+ slug: match[4],
3698
+ };
3699
+ };
3700
+ const githubIssueHasFactoryLabel = (issue) => issue.labels.some((label) => label.toLowerCase() === GITHUB_FACTORY_LABEL);
3701
+ const githubIssueIsClosed = (issue) => issue.state === 'closed';
3702
+ const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => ({
3703
+ id: githubIssueMirrorId(issue),
3704
+ title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(),
3705
+ description: githubIssueMirrorDescription(issue),
3706
+ stateId: readyForAgentStateId,
3707
+ labels: [{ name: repoLabel }],
3708
+ team: { key: config.safety.requireTeamKey },
3709
+ source: {
3710
+ provider: 'github',
3711
+ owner: issue.owner,
3712
+ repo: issue.repoName,
3713
+ number: issue.number,
3714
+ url: issue.url,
3715
+ path: issue.path,
3716
+ },
3717
+ });
3718
+ const githubIssueMirrorDescription = (issue) => {
3719
+ const body = issue.body.trim();
3720
+ const source = `${GITHUB_MIRROR_SOURCE_PREFIX}${issue.url}`;
3721
+ return body ? `${body}\n\n${source}` : source;
3722
+ };
3723
+ const githubIssueMirrorId = (issue) => `github-${stableHash(`${issue.repo.toLowerCase()}#${issue.number}:${issue.url}`)}`;
3724
+ const githubIssueMirrorDraftPath = (issue) => `/linear/issues/factory-create-${githubIssueMirrorId(issue)}.json`;
3725
+ const linearIssueMirrorsGithubIssue = (issue, ghIssue) => {
3726
+ const payload = wrappedPayload(issue.raw);
3727
+ const source = asRecord(payload.source);
3728
+ if (stringValue(source?.provider)?.toLowerCase() === 'github' &&
3729
+ stringValue(source?.url) === ghIssue.url) {
3730
+ return true;
3731
+ }
3732
+ if (stringValue(source?.provider)?.toLowerCase() === 'github' &&
3733
+ stringValue(source?.owner)?.toLowerCase() === ghIssue.owner.toLowerCase() &&
3734
+ stringValue(source?.repo)?.toLowerCase() === ghIssue.repoName.toLowerCase() &&
3735
+ numberValue(source?.number) === ghIssue.number) {
3736
+ return true;
3737
+ }
3738
+ return issue.description
3739
+ .split(/\r?\n/u)
3740
+ .some((line) => line.trim() === `${GITHUB_MIRROR_SOURCE_PREFIX}${ghIssue.url}`);
3741
+ };
3742
+ const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
3743
+ const candidates = [];
3744
+ for (const repo of reposFromConfig(config)) {
3745
+ for (const path of await mount.listTree(githubPullRoot(repo))) {
3746
+ if (!path.endsWith('.json'))
3747
+ continue;
3748
+ const pr = await readProbePrCandidate(mount, path);
3749
+ const score = pr
3750
+ ? issuePrMatchScore(pr, issue, opts.titleMarker ?? config.safety.requireTitlePrefix, opts)
3751
+ : 0;
3752
+ if (!pr || score <= 0)
3753
+ continue;
3754
+ candidates.push({ repo, prNumber: pr.number, draft: pr.draft, score });
3755
+ }
3756
+ }
3757
+ return candidates.sort((a, b) => b.score - a.score || b.prNumber - a.prNumber)[0];
3758
+ };
3759
+ const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
3760
+ const candidates = [];
3761
+ for (const repo of reposFromConfig(config)) {
3762
+ let payload;
3763
+ try {
3764
+ const result = await run([
3765
+ 'pr',
3766
+ 'list',
3767
+ '--repo',
3768
+ repo,
3769
+ '--state',
3770
+ 'all',
3771
+ '--json',
3772
+ 'number,title,body,headRefName,isDraft,state',
3773
+ '--limit',
3774
+ String(PROBE_PR_GH_CANDIDATE_LIMIT),
3775
+ ]);
3776
+ if (!result.stdout.trim()) {
3777
+ logger?.warn?.('[factory] gh PR resolver returned empty output', { issue: issue.key, repo });
3778
+ continue;
3779
+ }
3780
+ payload = parseJsonContent(result.stdout);
3781
+ }
3782
+ catch (error) {
3783
+ logger?.warn?.('[factory] gh PR resolver failed', { issue: issue.key, repo, error });
3784
+ continue;
3785
+ }
3786
+ if (!Array.isArray(payload)) {
3787
+ logger?.warn?.('[factory] gh PR resolver returned non-array payload', { issue: issue.key, repo });
3788
+ continue;
3789
+ }
3790
+ if (payload.length >= PROBE_PR_GH_CANDIDATE_LIMIT) {
3791
+ logger?.warn?.('[factory] gh PR resolver hit candidate limit', { issue: issue.key, repo, limit: PROBE_PR_GH_CANDIDATE_LIMIT });
3792
+ }
3793
+ for (const entry of payload) {
3794
+ const pr = ghProbePrCandidate(entry);
3795
+ if (!pr || !containsIssueKey(pr.headRef, issue.key))
3796
+ continue;
3797
+ const score = issuePrMatchScore(pr, issue, opts.titleMarker ?? config.safety.requireTitlePrefix, opts);
3798
+ if (score <= 0)
3799
+ continue;
3800
+ candidates.push({
3801
+ repo,
3802
+ prNumber: pr.number,
3803
+ draft: pr.draft,
3804
+ score,
3805
+ open: normalizePrState(pr.state) === 'OPEN',
3806
+ });
3807
+ }
3808
+ }
3809
+ return candidates.sort((a, b) => b.score - a.score ||
3810
+ Number(b.open) - Number(a.open) ||
3811
+ b.prNumber - a.prNumber)[0];
3812
+ };
3813
+ const reposFromConfig = (config) => {
3814
+ const repos = new Set([
3815
+ ...Object.values(config.repos.byLabel),
3816
+ ...Object.values(config.repos.byProject),
3817
+ ...config.repos.keywordRules.map((rule) => rule.repo),
3818
+ config.repos.default,
3819
+ ].filter((repo) => Boolean(repo)));
3820
+ return [...repos];
3821
+ };
3822
+ const githubPullRoot = (repo) => {
3823
+ const [owner, name] = repo.split('/');
3824
+ return owner && name ? `/github/repos/${owner}__${name}/pulls/by-id/` : `/github/repos/${repo}/pulls/by-id/`;
3825
+ };
3826
+ const readProbePrCandidate = async (mount, path) => {
3827
+ try {
3828
+ const payload = wrappedPayload((await mount.readFile(path)).content);
3829
+ const number = typeof payload.number === 'number'
3830
+ ? payload.number
3831
+ : Number(path.split('/').at(-1)?.replace(/\.json$/, ''));
3832
+ if (!Number.isInteger(number) || number <= 0)
3833
+ return undefined;
3834
+ return {
3835
+ number,
3836
+ title: stringValue(payload.title) ?? '',
3837
+ body: stringValue(payload.body) ?? '',
3838
+ headRef: refName(payload.headRef) ?? refName(payload.head) ?? stringValue(payload.head_ref) ?? '',
3839
+ draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
3840
+ };
3841
+ }
3842
+ catch {
3843
+ return undefined;
3844
+ }
3845
+ };
3846
+ const ghProbePrCandidate = (value) => {
3847
+ const payload = asRecord(value);
3848
+ if (!payload)
3849
+ return undefined;
3850
+ const number = numberValue(payload.number);
3851
+ if (typeof number !== 'number' || !Number.isInteger(number) || number <= 0)
3852
+ return undefined;
3853
+ return {
3854
+ number,
3855
+ title: stringValue(payload.title) ?? '',
3856
+ body: stringValue(payload.body) ?? '',
3857
+ headRef: stringValue(payload.headRefName) ?? '',
3858
+ draft: booleanValue(payload.isDraft),
3859
+ state: stringValue(payload.state),
3860
+ };
3861
+ };
3862
+ const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
3863
+ if (opts.requireTitleMarker && !hasTitlePrefix(pr.title, marker))
3864
+ return 0;
3865
+ if (containsIssueKey(pr.headRef, issue.key))
3866
+ return 30;
3867
+ if (containsIssueKey(pr.title, issue.key))
3868
+ return 20;
3869
+ if (containsExplicitIssueReference(pr.body, issue.key))
3870
+ return 10;
3871
+ return 0;
3872
+ };
3873
+ const hasTitlePrefix = (title, marker) => title === marker || title.startsWith(`${marker} `);
3874
+ const normalizePrState = (state) => state?.toUpperCase();
3875
+ const failClosedGhRunner = async () => ({ stdout: '[]' });
3876
+ const ISSUE_KEY_PATTERN = /^[A-Z]+-\d+$/u;
3877
+ const isIssuePathUnderRoot = (path) => path.startsWith(`${ISSUE_ROOT}/`) && path.endsWith('.json');
3878
+ const isIssueFilePath = (path) => isIssuePathUnderRoot(path) &&
3879
+ !path.includes('/comments/') &&
3880
+ !path.includes('/by-state/') &&
3881
+ !path.includes('/by-id/') &&
3882
+ isCanonicalIssueFileBasename(path.split('/').at(-1) ?? '');
3883
+ const isLinearIssueMirrorCandidatePath = (path) => isIssueFilePath(path) || /^\/linear\/issues\/factory-create-[^/]+\.json$/u.test(path);
3884
+ const isGithubIssueFilePath = (path) => githubIssuePathParts(path) !== undefined || githubIssueDirectoryPathParts(path) !== undefined;
3885
+ const isGithubIssueTreePath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/issues\/.+/u.test(path);
3886
+ const githubPullPathParts = (path) => {
3887
+ // Tolerate every webhook-fed PR mount layout we've seen, across both the
3888
+ // nested <owner>/<repo> directory shape and the flat <owner>__<repo> shape
3889
+ // (the latter is what githubPullRoot / resolveIssuePrFromMount still use):
3890
+ // .../<owner>/<repo>/pulls/<n>__<slug>/meta.json (current adapters shape)
3891
+ // .../<owner>__<repo>/pulls/by-id/<n>.json (flat mount shape)
3892
+ // .../pulls/<n>/meta.json | metadata.json | .../pulls/<n>.json (variants)
3893
+ // Deliberately ignores siblings like pulls/_index.json and pulls/<n>/comments/*.
3894
+ const match = path.match(/^\/github\/repos\/(?:([^/]+)\/([^/]+)|([^/]+)__([^/]+))\/pulls\/(?:by-id\/)?(\d+)(?:__[^/]*)?(?:\/(?:meta|metadata)\.json|\.json)$/u);
3895
+ if (!match)
3896
+ return undefined;
3897
+ const owner = match[1] ?? match[3];
3898
+ const repo = match[2] ?? match[4];
3899
+ if (!owner || !repo)
3900
+ return undefined;
3901
+ return { owner, repo, number: Number(match[5]) };
3902
+ };
3903
+ const isGithubPullFilePath = (path) => githubPullPathParts(path) !== undefined;
3904
+ const parsePullSnapshot = (content, fallbackNumber) => {
3905
+ const payload = wrappedPayload(content);
3906
+ const number = typeof payload.number === 'number' ? payload.number : fallbackNumber;
3907
+ if (!Number.isInteger(number) || number <= 0)
3908
+ return undefined;
3909
+ return {
3910
+ number,
3911
+ state: stringValue(payload.state),
3912
+ headRef: refName(payload.headRef) ?? refName(payload.head) ?? stringValue(payload.head_ref) ?? stringValue(payload.headRefName),
3913
+ draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
3914
+ url: stringValue(payload.url) ?? stringValue(payload.html_url),
3915
+ title: stringValue(payload.title),
3916
+ body: stringValue(payload.body),
3917
+ merged: booleanValue(payload.merged),
3918
+ };
3919
+ };
3920
+ const prSnapshotIssueMatchScore = (snapshot, issueKey) => {
3921
+ if (containsIssueKey(snapshot.headRef ?? '', issueKey))
3922
+ return 30;
3923
+ if (containsIssueKey(snapshot.title ?? '', issueKey))
3924
+ return 20;
3925
+ if (containsExplicitIssueReference(snapshot.body ?? '', issueKey))
3926
+ return 10;
3927
+ return 0;
3928
+ };
3929
+ const prMetaShowsMerged = (snapshot) => snapshot.merged === true || snapshot.state?.trim().toUpperCase() === 'MERGED';
3930
+ // Guard applied to the babysitter's ready signal: the PR's own webhook-fed meta
3931
+ // must still be eligible for human review. A missing `state` is treated as open
3932
+ // (older mount layouts omit it). The CI/conflict/review verdict is NOT re-derived
3933
+ // here — the babysitter already owns it.
3934
+ const prMetaAllowsHumanReview = (snapshot) => {
3935
+ const state = snapshot.state?.trim().toUpperCase();
3936
+ if (state && state !== 'OPEN') {
3937
+ return { ok: false, reason: `pr state is ${state}` };
3938
+ }
3939
+ if (snapshot.merged === true) {
3940
+ return { ok: false, reason: 'pr already merged' };
3941
+ }
3942
+ if (snapshot.draft === true) {
3943
+ return { ok: false, reason: 'pr is a draft' };
3944
+ }
3945
+ return { ok: true, reason: 'pr open and not a draft' };
3946
+ };
3947
+ const isIssueAliasFilePath = (path) => path.startsWith(linearByStatePath('ready-for-agent')) &&
3948
+ path.endsWith('.json') &&
3949
+ !path.includes('/comments/') &&
3950
+ ISSUE_KEY_PATTERN.test(keyFromPath(path));
3951
+ const isCanonicalIssueFileBasename = (basename) => {
3952
+ const stem = basename.replace(/\.json$/u, '');
3953
+ const parts = stem.split('__');
3954
+ return parts.length === 2 && ISSUE_KEY_PATTERN.test(parts[0]) && parts[1].length > 0;
3955
+ };
3956
+ const isMissingIssueFileError = (error) => {
3957
+ const record = asRecord(error);
3958
+ const status = record?.status ?? record?.statusCode;
3959
+ if (status === 404)
3960
+ return true;
3961
+ return error instanceof Error && /(?:404|not found|file not found)/iu.test(error.message);
3962
+ };
3963
+ export const keyFromPath = (path) => path.split('/').at(-1)?.replace(/\.json$/, '').split('__')[0] ?? path;
3964
+ const uuidFromPath = (path) => path.split('__')[1]?.replace(/\.json$/, '');
3965
+ const stringValue = (value) => typeof value === 'string' ? value : undefined;
3966
+ const booleanValue = (value) => typeof value === 'boolean' ? value : undefined;
3967
+ const numberValue = (value) => typeof value === 'number' ? value : undefined;
3968
+ const liveHeartbeatIntervalMs = (staleMs) => Math.min(DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS, Math.max(500, Math.floor(staleMs / 4)));
3969
+ const installFactoryDraftPredicate = (mount, config) => {
3970
+ mount.setDefaultAllowedDraftPredicate?.((path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, config));
3971
+ };
3972
+ const isAllowedFactoryDraft = async (path, content, opts, mount, config) => {
3973
+ if (!opts?.guarded)
3974
+ return false;
3975
+ // Comment writeback nested under its issue: /linear/issues/<ref>/comments/<draft>.json.
3976
+ // Scope-check the owning issue (the draft content is a comment, not an issue).
3977
+ const nestedComment = /^\/linear\/issues\/([^/]+)\/comments\/[^/]+$/u.exec(path);
3978
+ if (nestedComment) {
3979
+ return isIssuePathInFactoryScope(mount, `/linear/issues/${nestedComment[1]}.json`, config);
3980
+ }
3981
+ if (path.startsWith('/linear/issues/')) {
3982
+ if (isInFactoryScope(scopeIssueFromDraftContent(content), config.safety))
3983
+ return true;
3984
+ return isIssuePathInFactoryScope(mount, path, config);
3985
+ }
3986
+ if (/^\/slack\/channels\/[^/]+\/messages\/.+/u.test(path)) {
3987
+ return true;
3988
+ }
3989
+ return false;
3990
+ };
3991
+ const isIssuePathInFactoryScope = async (mount, path, config) => {
3992
+ try {
3993
+ return isInFactoryScope(parseLinearIssue(path, (await mount.readFile(path)).content), config.safety);
3994
+ }
3995
+ catch {
3996
+ return false;
3997
+ }
3998
+ };
3999
+ const scopeIssueFromDraftContent = (content) => ({
4000
+ title: typeof asRecord(content)?.title === 'string' ? asRecord(content)?.title : '',
4001
+ team: typeof asRecord(asRecord(content)?.team)?.key === 'string'
4002
+ ? asRecord(asRecord(content)?.team)?.key
4003
+ : undefined,
4004
+ raw: asRecord(content) ?? {},
4005
+ });
4006
+ const eventCursorAfterPage = (cursor, events, nextCursor) => {
4007
+ if (nextCursor)
4008
+ return nextCursor;
4009
+ if (events.length === 0)
4010
+ return cursor;
4011
+ const numericCursor = cursor === undefined ? 0 : Number(cursor);
4012
+ if (Number.isInteger(numericCursor) && numericCursor >= 0) {
4013
+ return String(numericCursor + events.length);
4014
+ }
4015
+ return events.at(-1)?.id ?? cursor;
4016
+ };
4017
+ const liveEventDedupeKey = (event) => {
4018
+ if (!event.id)
4019
+ return undefined;
4020
+ const resource = asRecord(event.resource) ?? {};
4021
+ return [
4022
+ event.id,
4023
+ event.type,
4024
+ stringValue(resource.path) ?? '',
4025
+ stringValue(resource.revision) ?? '',
4026
+ event.digest ?? '',
4027
+ ].join('\u001f');
4028
+ };
4029
+ const isBeforeLiveCutoff = (occurredAt, connectStartedAtMs, skewMarginMs) => {
4030
+ const occurredAtMs = Date.parse(occurredAt);
4031
+ if (!Number.isFinite(occurredAtMs))
4032
+ return false;
4033
+ return occurredAtMs < connectStartedAtMs - skewMarginMs;
4034
+ };
4035
+ const isAtOrBeforeHighWatermark = (eventId, highWatermark) => {
4036
+ if (!eventId || !highWatermark)
4037
+ return false;
4038
+ if (eventId === highWatermark)
4039
+ return true;
4040
+ const eventSequence = eventSequenceNumber(eventId);
4041
+ const watermarkSequence = eventSequenceNumber(highWatermark);
4042
+ if (eventSequence !== undefined && watermarkSequence !== undefined) {
4043
+ return eventSequence <= watermarkSequence;
4044
+ }
4045
+ return false;
4046
+ };
4047
+ const isHighWatermarkRouteUnavailable = (error) => {
4048
+ const details = asRecord(error) ?? {};
4049
+ const response = asRecord(details.response) ?? {};
4050
+ const status = details.status ?? details.statusCode ?? response.status ?? response.statusCode;
4051
+ if (status === 404 || status === '404') {
4052
+ return true;
4053
+ }
4054
+ const code = stringValue(details.code)?.toLowerCase();
4055
+ if (code === 'route_not_found') {
4056
+ return true;
4057
+ }
4058
+ return error instanceof Error && /route not found/i.test(error.message);
4059
+ };
4060
+ /**
4061
+ * Slack writeback liveness follows the provider signal hierarchy:
4062
+ * getEvents() provider watermarks are the authoritative ingest signal, sync
4063
+ * status is advisory, and only explicit error/failed states fail closed. Soft
4064
+ * sync states can be stale while Slack mount writes are still live, so
4065
+ * #slackFreshness lets fresh event watermarks override them instead of blocking
4066
+ * operator question writebacks.
4067
+ */
4068
+ const slackSyncStatusResult = (status, nowMs, staleAfterMs) => {
4069
+ if (!status)
4070
+ return { known: false, degraded: false };
4071
+ const normalized = status.status?.toLowerCase();
4072
+ if (normalized && ['error', 'failed'].includes(normalized)) {
4073
+ return { known: true, degraded: true, reason: `slack sync status is ${status.status}`, severity: 'hard' };
4074
+ }
4075
+ if (normalized && ['lagging', 'stale', 'degraded'].includes(normalized)) {
4076
+ return { known: true, degraded: true, reason: `slack sync status is ${status.status}`, severity: 'soft' };
4077
+ }
4078
+ const lastEventAtMs = status.lastEventAtMs ??
4079
+ (status.lastEventAt ? Date.parse(status.lastEventAt) : undefined) ??
4080
+ (status.watermarkTs ? Date.parse(status.watermarkTs) : undefined);
4081
+ if (lastEventAtMs !== undefined && Number.isFinite(lastEventAtMs)) {
4082
+ const ageMs = nowMs - lastEventAtMs;
4083
+ return ageMs > staleAfterMs
4084
+ ? { known: true, degraded: true, reason: `slack sync watermark stale by ${ageMs}ms`, severity: 'soft' }
4085
+ : { known: true, degraded: false };
4086
+ }
4087
+ if (status.lagSeconds !== undefined && Number.isFinite(status.lagSeconds)) {
4088
+ const lagMs = status.lagSeconds * 1000;
4089
+ return lagMs > staleAfterMs
4090
+ ? { known: true, degraded: true, reason: `slack sync lag is ${lagMs}ms`, severity: 'soft' }
4091
+ : { known: true, degraded: false };
4092
+ }
4093
+ if (normalized && ['ok', 'healthy', 'fresh', 'synced', 'ready'].includes(normalized)) {
4094
+ return { known: true, degraded: false };
4095
+ }
4096
+ return { known: false, degraded: false };
4097
+ };
4098
+ const eventProvider = (event) => {
4099
+ const flat = asRecord(event) ?? {};
4100
+ return stringValue(asRecord(flat.resource)?.provider) ?? stringValue(flat.provider);
4101
+ };
4102
+ const eventOccurredAtMs = (event) => {
4103
+ const flat = asRecord(event) ?? {};
4104
+ const timestamp = stringValue(flat.occurredAt) ?? stringValue(flat.timestamp);
4105
+ const parsed = timestamp ? Date.parse(timestamp) : Number.NaN;
4106
+ return Number.isFinite(parsed) ? parsed : undefined;
4107
+ };
4108
+ const eventSequenceNumber = (eventId) => {
4109
+ const whole = Number(eventId);
4110
+ if (Number.isFinite(whole))
4111
+ return whole;
4112
+ const trailing = eventId.match(/(\d+)$/u)?.[1];
4113
+ if (!trailing)
4114
+ return undefined;
4115
+ const parsed = Number(trailing);
4116
+ return Number.isFinite(parsed) ? parsed : undefined;
4117
+ };
4118
+ const rememberLiveEvent = (seen, key) => {
4119
+ seen.add(key);
4120
+ if (seen.size <= LIVE_DEDUPE_LIMIT)
4121
+ return;
4122
+ const oldest = seen.values().next().value;
4123
+ if (oldest)
4124
+ seen.delete(oldest);
4125
+ };
4126
+ const recordName = (value) => {
4127
+ if (typeof value === 'string') {
4128
+ return value;
4129
+ }
4130
+ const record = asRecord(value);
4131
+ return stringValue(record?.name) ?? stringValue(record?.key) ?? stringValue(record?.id);
4132
+ };
4133
+ const refName = (value) => {
4134
+ if (typeof value === 'string') {
4135
+ return value;
4136
+ }
4137
+ const record = asRecord(value);
4138
+ return stringValue(record?.name) ?? stringValue(record?.ref);
4139
+ };
4140
+ const labelName = (value) => {
4141
+ if (typeof value === 'string') {
4142
+ return value;
4143
+ }
4144
+ const record = asRecord(value);
4145
+ return stringValue(record?.name);
4146
+ };
4147
+ const isCompletionReason = (reason) => reason === 'issue-done' || reason === 'done' || reason === 'completed';
4148
+ // The broker rejects re-registering a name it never released on exit
4149
+ // (relay#1116-family) with a 500 "agent '<name>' already exists". Detect it from
4150
+ // the structured payload or the message so resume can treat it as terminal
4151
+ // rather than retrying the (falsely) "retryable" error forever.
4152
+ const isAgentAlreadyExistsError = (error) => {
4153
+ const record = asRecord(error);
4154
+ const data = asRecord(record?.data);
4155
+ const message = stringValue(data?.error) ?? (error instanceof Error ? error.message : '');
4156
+ return /already exists/iu.test(message);
4157
+ };
4158
+ const defaultRestartPolicy = (spec) =>
4159
+ // Implementers and babysitters are both long-running and resumable — the
4160
+ // babysitter shepherds an open PR over many CI/review cycles, so an abnormal
4161
+ // exit should resume its session rather than drop the PR. The reviewer is
4162
+ // short-lived and keeps the fleet default.
4163
+ spec.role === 'implementer' || spec.role === 'babysitter'
4164
+ ? { maxRestarts: 3, strategy: 'resume' }
4165
+ : spec.restartPolicy;
4166
+ const slackPayloadTs = (threadId) => threadId.replace(/_/g, '.');
4167
+ const slackChannelMessagesPrefix = (channelDir) => `/slack/channels/${channelDir}/messages/`;
4168
+ const eventIdentity = (event) => {
4169
+ const record = event;
4170
+ const rawId = record.id ?? record.event_id ?? record.seq;
4171
+ const id = typeof rawId === 'string' || typeof rawId === 'number' ? String(rawId) : undefined;
4172
+ return id ? `event:${id}` : undefined;
4173
+ };
4174
+ // Safe accessor for an event's resource path. The SDK types `resource` as
4175
+ // always-present, but the HTTP polling fallback and degraded-sync paths can
4176
+ // deliver events without it — reading `.path` directly then throws and (in a
4177
+ // subscription/poll handler) crash-loops. Returns undefined for such events so
4178
+ // callers skip them and keep processing the well-formed ones.
4179
+ export const changeEventPath = (event) => {
4180
+ const path = event?.resource?.path;
4181
+ return typeof path === 'string' && path ? path : undefined;
4182
+ };
4183
+ const describeError = (error) => {
4184
+ if (error instanceof Error) {
4185
+ return {
4186
+ errorMessage: error.message || error.name || 'Error',
4187
+ errorStack: error.stack,
4188
+ };
4189
+ }
4190
+ if (typeof error === 'string') {
4191
+ return { errorMessage: error };
4192
+ }
4193
+ try {
4194
+ const serialized = JSON.stringify(error);
4195
+ if (serialized && serialized !== '{}') {
4196
+ return { errorMessage: serialized };
4197
+ }
4198
+ }
4199
+ catch {
4200
+ // Fall through to String(error).
4201
+ }
4202
+ return { errorMessage: String(error) };
4203
+ };
4204
+ const failedIterationReport = (error, dryRun) => {
4205
+ const details = describeError(error);
4206
+ return {
4207
+ pulled: [],
4208
+ triaged: [],
4209
+ dispatched: [],
4210
+ skipped: [],
4211
+ dryRun,
4212
+ error: {
4213
+ message: details.errorMessage,
4214
+ ...(details.errorStack ? { stack: details.errorStack } : {}),
4215
+ },
4216
+ };
4217
+ };
4218
+ const isRegistrationLagInjectionError = (error) => {
4219
+ const { errorMessage } = describeError(error);
4220
+ return /recipient unavailable|not registered|unknown recipient|no such (agent|recipient)|timed out waiting for delivery_injected/i
4221
+ .test(errorMessage);
4222
+ };
4223
+ const isTimeoutError = (error) => error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
4224
+ const retryOnTimeout = async (fn, opts) => {
4225
+ let lastError;
4226
+ for (let attempt = 0; attempt < opts.attempts; attempt += 1) {
4227
+ try {
4228
+ return await fn();
4229
+ }
4230
+ catch (error) {
4231
+ lastError = error;
4232
+ if (!isTimeoutError(error) || attempt === opts.attempts - 1)
4233
+ throw error;
4234
+ await new Promise((resolve) => setTimeout(resolve, opts.delayMs));
4235
+ }
4236
+ }
4237
+ throw lastError;
4238
+ };
4239
+ const contextualError = (context, error) => {
4240
+ const details = describeError(error);
4241
+ const wrapped = new Error(`${context}: ${details.errorMessage}`);
4242
+ if (details.errorStack) {
4243
+ wrapped.stack = `${wrapped.stack ?? wrapped.message}\nCaused by: ${details.errorStack}`;
4244
+ }
4245
+ const withCause = wrapped;
4246
+ withCause.cause = error;
4247
+ return wrapped;
4248
+ };
4249
+ const registryHandoffKey = (issue, agentName) => `${issueKey(issue)}:${agentName}`;
4250
+ // Synthetic in-flight record used to watch an escalation thread. Escalations have
4251
+ // no spawned agents; reply routing re-reads the live batch record by issue, so
4252
+ // the empty agents/invocations here are only placeholders for the watcher key.
4253
+ const escalationWatchRecord = (decision) => ({
4254
+ issue: decision.issue,
4255
+ decision,
4256
+ agents: new Map(),
4257
+ invocationIds: new Set(),
4258
+ dryRun: false,
4259
+ });
4260
+ const cloneTrackedAgent = (tracked) => ({
4261
+ spec: { ...tracked.spec },
4262
+ result: tracked.result ? { ...tracked.result } : undefined,
4263
+ sessionRef: tracked.sessionRef,
4264
+ });
4265
+ const parseSlackReply = (path, content, botUserId) => {
4266
+ const raw = asRecord(parseJsonContent(content)) ?? {};
4267
+ const payload = wrappedPayload(raw);
4268
+ const channelDir = path.match(/^\/slack\/channels\/([^/]+)\//u)?.[1] ?? '';
4269
+ const pathMatch = path.match(/^\/slack\/channels\/[^/]+\/messages\/([^/]+)(?:\/replies\/([^/]+))?/u);
4270
+ const parentFromPath = pathMatch?.[2] ? slackPayloadTs(pathMatch[1]) : undefined;
4271
+ const messageFromPath = slackPayloadTs(pathMatch?.[2] ?? pathMatch?.[1] ?? '');
4272
+ const messageTs = stringValue(payload.ts) ?? messageFromPath;
4273
+ const threadTs = stringValue(payload.thread_ts) ?? parentFromPath;
4274
+ if (!channelDir || !threadTs || !messageTs) {
4275
+ return undefined;
4276
+ }
4277
+ return {
4278
+ channelDir,
4279
+ threadTs,
4280
+ messageTs,
4281
+ text: stringValue(payload.text) ?? '',
4282
+ isThreadReply: Boolean(parentFromPath) || threadTs !== messageTs,
4283
+ isBot: isOwnSlackBotReply(payload, botUserId),
4284
+ raw,
4285
+ };
4286
+ };
4287
+ const triageEscalationReason = (decision) => {
4288
+ const reasons = [];
4289
+ if (decision.confidence === 'low') {
4290
+ reasons.push('low-confidence triage');
4291
+ }
4292
+ if (decision.thin) {
4293
+ reasons.push('thin issue context');
4294
+ }
4295
+ if (reasons.length === 0) {
4296
+ return undefined;
4297
+ }
4298
+ return `${reasons.join(' and ')}${decision.rationale ? `: ${decision.rationale}` : ''}`;
4299
+ };
4300
+ const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
4301
+ // The human's Slack-thread reply, framed as an <integration-event> the agent is
4302
+ // told (at spawn) to expect — a recognizable injected event, not an ambiguous
4303
+ // keystroke. Trailing CR submits it to the agent's PTY.
4304
+ const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
4305
+ const isFactoryQuestionTarget = (target) => {
4306
+ const normalized = target.trim().replace(/^@/u, '').toLowerCase();
4307
+ return normalized === 'broker' || normalized === 'factory';
4308
+ };
4309
+ const parseAgentQuestion = (message) => {
4310
+ const markerPattern = new RegExp(`(^|\\n)\\s*(?:${escapeRegExp(AGENT_NEEDS_INPUT_MARKER)}|${escapeRegExp(LEGACY_AGENT_NEEDS_INPUT_MARKER)})\\s*(?::\\s*)?`, 'iu');
4311
+ const match = markerPattern.exec(message.body);
4312
+ if (!match || !message.from.trim()) {
4313
+ return undefined;
4314
+ }
4315
+ const markerEnd = match.index + match[0].length;
4316
+ const bodyAfterMarker = message.body.slice(markerEnd).trim();
4317
+ const issueKey = message.body.match(/(?:^|\n)\s*Issue:\s*([A-Z]+-\d+)\s*(?:\n|$)/iu)?.[1]?.toUpperCase();
4318
+ const question = extractQuestionText(bodyAfterMarker);
4319
+ if (!question) {
4320
+ return undefined;
4321
+ }
4322
+ return {
4323
+ agentName: message.from.trim(),
4324
+ issueKey,
4325
+ question,
4326
+ eventId: message.eventId,
4327
+ };
4328
+ };
4329
+ const parsePrReadySignal = (message) => {
4330
+ if (!message.from.trim()) {
4331
+ return undefined;
4332
+ }
4333
+ const markerPattern = new RegExp(`(^|\\n|\\s)${escapeRegExp(AGENT_PR_READY_MARKER)}\\s*(?::\\s*)?([A-Z]+-\\d+)?`, 'iu');
4334
+ const match = markerPattern.exec(message.body);
4335
+ if (!match) {
4336
+ return undefined;
4337
+ }
4338
+ return { agentName: message.from.trim(), issueKey: match[2]?.toUpperCase() };
4339
+ };
4340
+ const extractQuestionText = (bodyAfterMarker) => {
4341
+ const questionMatch = bodyAfterMarker.match(/(?:^|\n)\s*Question:\s*([\s\S]+)$/iu);
4342
+ const raw = questionMatch?.[1] ?? bodyAfterMarker;
4343
+ return raw
4344
+ .split(/\r?\n/u)
4345
+ .filter((line) => !/^\s*Issue:\s*[A-Z]+-\d+\s*$/iu.test(line))
4346
+ .join('\n')
4347
+ .trim();
4348
+ };
4349
+ const agentQuestionDedupeKey = (issue, question) => `${question.eventId ?? 'missing'}:${stableHash(JSON.stringify({
4350
+ issue: issue.key,
4351
+ from: question.agentName,
4352
+ question: question.question,
4353
+ }))}`;
4354
+ const agentQuestionSlackText = (issue, question) => [
4355
+ `${issue.key}: ${question.agentName} needs input.`,
4356
+ `Question: ${question.question}`,
4357
+ ].join('\n');
4358
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
4359
+ const isOwnSlackBotReply = (payload, botUserId) => payload.user_is_bot === true ||
4360
+ stringValue(payload.user) === botUserId;
4361
+ const unrefDelay = (ms) => new Promise((resolve) => {
4362
+ const timer = setTimeout(resolve, ms);
4363
+ timer.unref?.();
4364
+ });
4365
+ const liveEventYield = () => new Promise((resolve) => {
4366
+ if (typeof setImmediate === 'function') {
4367
+ setImmediate(resolve);
4368
+ return;
4369
+ }
4370
+ setTimeout(resolve, 0);
4371
+ });
4372
+ //# sourceMappingURL=factory.js.map