@agent-relay/factory 0.1.1 → 0.1.3

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 (132) hide show
  1. package/README.md +145 -205
  2. package/dist/cli/fleet.d.ts +5 -1
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +152 -27
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/schema.d.ts +4 -4
  7. package/dist/config/schema.d.ts.map +1 -1
  8. package/dist/config/schema.js +13 -2
  9. package/dist/config/schema.js.map +1 -1
  10. package/dist/constants/linear.d.ts +2 -0
  11. package/dist/constants/linear.d.ts.map +1 -1
  12. package/dist/constants/linear.js +6 -0
  13. package/dist/constants/linear.js.map +1 -1
  14. package/dist/dispatch/templates.d.ts +18 -2
  15. package/dist/dispatch/templates.d.ts.map +1 -1
  16. package/dist/dispatch/templates.js +18 -10
  17. package/dist/dispatch/templates.js.map +1 -1
  18. package/dist/fleet/create-fleet.d.ts +3 -2
  19. package/dist/fleet/create-fleet.d.ts.map +1 -1
  20. package/dist/fleet/create-fleet.js +3 -2
  21. package/dist/fleet/create-fleet.js.map +1 -1
  22. package/dist/fleet/ensure-relay-broker.d.ts +24 -0
  23. package/dist/fleet/ensure-relay-broker.d.ts.map +1 -0
  24. package/dist/fleet/ensure-relay-broker.js +56 -0
  25. package/dist/fleet/ensure-relay-broker.js.map +1 -0
  26. package/dist/fleet/internal-fleet-client.d.ts +8 -3
  27. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  28. package/dist/fleet/internal-fleet-client.js +32 -5
  29. package/dist/fleet/internal-fleet-client.js.map +1 -1
  30. package/dist/fleet/relay-fleet-client.d.ts +90 -13
  31. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  32. package/dist/fleet/relay-fleet-client.js +496 -20
  33. package/dist/fleet/relay-fleet-client.js.map +1 -1
  34. package/dist/github/index.d.ts +4 -4
  35. package/dist/github/index.js +2 -2
  36. package/dist/github/probe-closer.d.ts +1 -1
  37. package/dist/github/probe-closer.js +1 -1
  38. package/dist/index.d.ts +33 -29
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +17 -15
  41. package/dist/index.js.map +1 -1
  42. package/dist/linear/state-resolver.d.ts +3 -2
  43. package/dist/linear/state-resolver.d.ts.map +1 -1
  44. package/dist/linear/state-resolver.js +74 -10
  45. package/dist/linear/state-resolver.js.map +1 -1
  46. package/dist/mount/local-mount-preflight.d.ts +1 -0
  47. package/dist/mount/local-mount-preflight.d.ts.map +1 -1
  48. package/dist/mount/local-mount-preflight.js +64 -12
  49. package/dist/mount/local-mount-preflight.js.map +1 -1
  50. package/dist/mount/relayfile-binary.d.ts +2 -0
  51. package/dist/mount/relayfile-binary.d.ts.map +1 -1
  52. package/dist/mount/relayfile-binary.js +39 -5
  53. package/dist/mount/relayfile-binary.js.map +1 -1
  54. package/dist/mount/relayfile-cloud-mount-client.d.ts +2 -2
  55. package/dist/mount/relayfile-cloud-mount-client.js +1 -1
  56. package/dist/node/factory-node.d.ts +59 -0
  57. package/dist/node/factory-node.d.ts.map +1 -0
  58. package/dist/node/factory-node.js +351 -0
  59. package/dist/node/factory-node.js.map +1 -0
  60. package/dist/node/factory.node.d.ts +3 -0
  61. package/dist/node/factory.node.d.ts.map +1 -0
  62. package/dist/node/factory.node.js +5 -0
  63. package/dist/node/factory.node.js.map +1 -0
  64. package/dist/orchestrator/batch-tracker.d.ts +2 -2
  65. package/dist/orchestrator/factory.d.ts +5 -2
  66. package/dist/orchestrator/factory.d.ts.map +1 -1
  67. package/dist/orchestrator/factory.js +413 -59
  68. package/dist/orchestrator/factory.js.map +1 -1
  69. package/dist/orchestrator/index.d.ts +4 -4
  70. package/dist/orchestrator/index.d.ts.map +1 -1
  71. package/dist/orchestrator/index.js +3 -3
  72. package/dist/orchestrator/index.js.map +1 -1
  73. package/dist/orchestrator/process-identity.d.ts +1 -1
  74. package/dist/orchestrator/reaper.d.ts +3 -3
  75. package/dist/orchestrator/reaper.js +3 -3
  76. package/dist/ports/fleet.d.ts +8 -2
  77. package/dist/ports/fleet.d.ts.map +1 -1
  78. package/dist/ports/index.d.ts +4 -4
  79. package/dist/ports/state.d.ts +3 -3
  80. package/dist/ports/writeback.d.ts +1 -1
  81. package/dist/safety/factory-scope.d.ts +2 -2
  82. package/dist/state/in-memory-state-store.d.ts +1 -1
  83. package/dist/state/in-memory-state-store.js +1 -1
  84. package/dist/subscriptions/event-client.d.ts +1 -1
  85. package/dist/subscriptions/event-client.js +2 -2
  86. package/dist/subscriptions/globs.js +1 -1
  87. package/dist/subscriptions/index.d.ts +7 -7
  88. package/dist/subscriptions/index.js +4 -4
  89. package/dist/subscriptions/linear-filter.d.ts +1 -1
  90. package/dist/subscriptions/linear-filter.js +1 -1
  91. package/dist/subscriptions/specs.d.ts +1 -1
  92. package/dist/subscriptions/specs.js +2 -2
  93. package/dist/testing/fakes.d.ts +1 -1
  94. package/dist/testing/fakes.js +1 -1
  95. package/dist/testing/fakes.js.map +1 -1
  96. package/dist/testing/index.d.ts +1 -1
  97. package/dist/testing/index.js +1 -1
  98. package/dist/triage/heuristic.d.ts +8 -4
  99. package/dist/triage/heuristic.d.ts.map +1 -1
  100. package/dist/triage/heuristic.js +70 -6
  101. package/dist/triage/heuristic.js.map +1 -1
  102. package/dist/triage/index.d.ts +6 -6
  103. package/dist/triage/index.d.ts.map +1 -1
  104. package/dist/triage/index.js +4 -4
  105. package/dist/triage/index.js.map +1 -1
  106. package/dist/triage/llm.d.ts +1 -1
  107. package/dist/triage/llm.d.ts.map +1 -1
  108. package/dist/triage/llm.js +5 -3
  109. package/dist/triage/llm.js.map +1 -1
  110. package/dist/triage/schema.d.ts +133 -29
  111. package/dist/triage/schema.d.ts.map +1 -1
  112. package/dist/triage/schema.js +6 -3
  113. package/dist/triage/schema.js.map +1 -1
  114. package/dist/triage/tiered.d.ts +1 -1
  115. package/dist/triage/tiered.d.ts.map +1 -1
  116. package/dist/triage/tiered.js +9 -2
  117. package/dist/triage/tiered.js.map +1 -1
  118. package/dist/types.d.ts +10 -9
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/writeback/github.d.ts +2 -2
  121. package/dist/writeback/github.js +1 -1
  122. package/dist/writeback/index.d.ts +5 -5
  123. package/dist/writeback/index.js +3 -3
  124. package/dist/writeback/linear.d.ts +3 -3
  125. package/dist/writeback/linear.d.ts.map +1 -1
  126. package/dist/writeback/linear.js +57 -14
  127. package/dist/writeback/linear.js.map +1 -1
  128. package/dist/writeback/slack.d.ts +1 -1
  129. package/dist/writeback/slack.js +2 -2
  130. package/package.json +10 -2
  131. package/scripts/com.agentrelay.factory-canary.plist.example +53 -0
  132. package/scripts/factory-canary.sh +89 -0
@@ -1,19 +1,20 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { dirname } from 'node:path';
3
- import { FactoryConfigSchema } from '../config/schema';
4
- import { linearByStatePath } from '../constants/linear';
5
- import { stateResolutionFromIds } from '../linear/state-resolver';
6
- import { GithubMergeGate, closeProbePr } from '../github';
7
- import { InMemoryStateStore } from '../state/in-memory-state-store';
8
- import { containsExplicitIssueReference, containsIssueKey } from '../issue-key-match';
9
- import { isInFactoryScope } from '../safety/factory-scope';
10
- import { renderAgentTask } from '../dispatch/templates';
11
- import { HeuristicTriage, TieredTriage, babysitterSpec } from '../triage';
12
- import { MountGithubRead, MountLinearWriteback, MountSlackWriteback } from '../writeback';
13
- import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared';
14
- import { issueKey } from './batch-tracker';
15
- import { findAgentProcessByName, readProcessIdentity } from './process-identity';
16
- import { terminatePids } from './reaper';
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';
17
18
  const ISSUE_ROOT = '/linear/issues';
18
19
  const GITHUB_ISSUE_ROOT = '/github/repos';
19
20
  const READY_EVENTS_LIMIT = 100;
@@ -95,6 +96,10 @@ export class FactoryLoop {
95
96
  #resumeInFlight = new Map();
96
97
  #slackWatchers = new Map();
97
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();
98
103
  // Last invalid-label failure signature we posted per issue, so a stuck Ready
99
104
  // issue (or the comment writeback's own change event) does not re-post the
100
105
  // same notice every cycle. Cleared once the issue dispatches successfully.
@@ -147,6 +152,8 @@ export class FactoryLoop {
147
152
  // undefined = readiness not yet probed; resolved lazily so the standalone
148
153
  // runOnce() path (which skips #start) still ingests when the mount is present.
149
154
  #githubIngestionEnabled;
155
+ #integrationInstructions;
156
+ #integrationInstructionsRefresh;
150
157
  #starting;
151
158
  #started = false;
152
159
  #stopping = false;
@@ -154,8 +161,10 @@ export class FactoryLoop {
154
161
  this.#config = config;
155
162
  this.#mount = ports.mount;
156
163
  // Resolved role<->state mapping. The CLI injects a name-resolved, per-team
157
- // resolution via ports; fall back to one built from explicit stateIds.
158
- this.#states = ports.stateResolution ?? stateResolutionFromIds(config.stateIds);
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);
159
168
  installFactoryDraftPredicate(this.#mount, config);
160
169
  this.#fleet = ports.fleet;
161
170
  this.#triage = ports.triage ?? new TieredTriage(new HeuristicTriage());
@@ -190,6 +199,63 @@ export class FactoryLoop {
190
199
  });
191
200
  this.#wireFleetEvents();
192
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
+ }
193
259
  async #batch() {
194
260
  if (this.#batchView) {
195
261
  return this.#batchView;
@@ -226,6 +292,7 @@ export class FactoryLoop {
226
292
  this.#started = true;
227
293
  try {
228
294
  await this.#startLiveSubscription(opts.liveSubscription);
295
+ await this.#rearmSlackReplyWatchers();
229
296
  this.#scheduleCompletionSweep(0);
230
297
  return;
231
298
  }
@@ -237,17 +304,25 @@ export class FactoryLoop {
237
304
  }
238
305
  await this.#backfillReadyIssues();
239
306
  this.#subscription = this.#mount.subscribe([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB], (event) => {
240
- if (isGithubPullFilePath(event.resource.path)) {
241
- void this.#handlePrChange(event.resource.path);
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) {
242
312
  return;
243
313
  }
244
- if (isGithubIssueFilePath(event.resource.path)) {
245
- void this.#handleGithubIssueChange(event.resource.path, { dryRun: this.#config.dryRun });
314
+ if (isGithubPullFilePath(path)) {
315
+ void this.#handlePrChange(path);
246
316
  return;
247
317
  }
248
- void this.#handleChange(event.resource.path);
318
+ if (isGithubIssueFilePath(path)) {
319
+ void this.#handleGithubIssueChange(path, { dryRun: this.#config.dryRun });
320
+ return;
321
+ }
322
+ void this.#handleChange(path);
249
323
  });
250
324
  this.#started = true;
325
+ await this.#rearmSlackReplyWatchers();
251
326
  this.#scheduleCompletionSweep(0);
252
327
  }
253
328
  async stop() {
@@ -572,7 +647,10 @@ export class FactoryLoop {
572
647
  await this.#refreshLiveHeartbeat();
573
648
  }
574
649
  async #prepareLiveEventForDrain(event, seenIssueKeys) {
575
- const path = event.resource.path;
650
+ const path = changeEventPath(event);
651
+ if (!path) {
652
+ return undefined;
653
+ }
576
654
  const isPullPath = isGithubPullFilePath(path);
577
655
  if (!isIssueFilePath(path) && !isGithubIssueFilePath(path) && !isPullPath) {
578
656
  return undefined;
@@ -855,6 +933,11 @@ export class FactoryLoop {
855
933
  let consecutiveFailures = 0;
856
934
  let completed = false;
857
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();
858
941
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
859
942
  await this.#writeLoopHeartbeat(heartbeatPath, registryPath, 'running', iteration, maxIterations);
860
943
  try {
@@ -973,8 +1056,9 @@ export class FactoryLoop {
973
1056
  }
974
1057
  const spawnedForReaperHandoff = [];
975
1058
  try {
1059
+ const specs = dispatchSpecs(dispatchDecision);
976
1060
  const agents = [];
977
- for (const spec of [...dispatchDecision.implementers, dispatchDecision.reviewer]) {
1061
+ for (const spec of specs) {
978
1062
  const spawned = await this.#spawnAgent(record, spec, dryRun);
979
1063
  const tracked = record.agents.get(spawned.name);
980
1064
  if (tracked) {
@@ -1070,8 +1154,9 @@ export class FactoryLoop {
1070
1154
  }
1071
1155
  async #backfillReadyIssues() {
1072
1156
  const page = await this.#mount.getEvents({ limit: READY_EVENTS_LIMIT });
1073
- const eventPaths = page.events.map((event) => event.resource.path).filter(isIssueFilePath);
1074
- const githubEventPaths = page.events.map((event) => event.resource.path).filter(isGithubIssueFilePath);
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);
1075
1160
  for (const path of new Set(githubEventPaths)) {
1076
1161
  await this.#handleGithubIssueChange(path, { dryRun: this.#config.dryRun });
1077
1162
  }
@@ -1537,8 +1622,21 @@ export class FactoryLoop {
1537
1622
  }
1538
1623
  async #readIssue(path) {
1539
1624
  try {
1540
- const { content } = await this.#mount.readFile(path);
1541
- return parseLinearIssue(path, content);
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;
1542
1640
  }
1543
1641
  catch (error) {
1544
1642
  if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
@@ -1619,7 +1717,10 @@ export class FactoryLoop {
1619
1717
  return { pids: [scan.identity.pid], status: 'found' };
1620
1718
  }
1621
1719
  if (scan.status === 'ambiguous') {
1622
- this.#logger.warn?.(`[factory] ambiguous process lookup for ${agentName}`);
1720
+ if (!this.#ambiguousLookupWarned.has(agentName)) {
1721
+ this.#ambiguousLookupWarned.add(agentName);
1722
+ this.#logger.warn?.(`[factory] ambiguous process lookup for ${agentName} (suppressing repeats)`);
1723
+ }
1623
1724
  return { pids: [], status: 'unresolved' };
1624
1725
  }
1625
1726
  if (pids.length > 0) {
@@ -1760,6 +1861,8 @@ export class FactoryLoop {
1760
1861
  capability: spec.capability,
1761
1862
  node: spec.node ?? 'self',
1762
1863
  task: spec.task,
1864
+ workflow: spec.workflow,
1865
+ inputs: spec.inputs,
1763
1866
  model: spec.model,
1764
1867
  cwd: spec.clonePath,
1765
1868
  sessionRef: spec.sessionRef,
@@ -1817,6 +1920,12 @@ export class FactoryLoop {
1817
1920
  if (tracked.sessionRef) {
1818
1921
  const resumeKey = `${issueKey(record.issue)}:${name}:${tracked.sessionRef}`;
1819
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
+ }
1820
1929
  return;
1821
1930
  }
1822
1931
  const existing = this.#resumeInFlight.get(resumeKey);
@@ -1875,6 +1984,27 @@ export class FactoryLoop {
1875
1984
  this.#error(error, record.issue);
1876
1985
  }
1877
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
+ }
1878
2008
  async #issueHasCompletionPr(record) {
1879
2009
  try {
1880
2010
  const issue = await this.#readIssue(record.issue.path);
@@ -2032,7 +2162,7 @@ export class FactoryLoop {
2032
2162
  this.#counters.liveArrivalLatencyMsMax = Math.max(this.#counters.liveArrivalLatencyMsMax ?? 0, latencyMs);
2033
2163
  this.#logger.debug?.('[factory] live issue event latency recorded', {
2034
2164
  eventId: event.id,
2035
- path: event.resource.path,
2165
+ path: changeEventPath(event),
2036
2166
  latencyMs,
2037
2167
  });
2038
2168
  }
@@ -2065,6 +2195,7 @@ export class FactoryLoop {
2065
2195
  const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer');
2066
2196
  const reviewerName = reviewer?.result?.name ?? reviewer?.spec.name ?? 'reviewer';
2067
2197
  const implementerNames = implementers.map((agent) => agent.result?.name ?? agent.spec.name);
2198
+ const integrationInstructions = await this.#resolveIntegrationInstructions();
2068
2199
  for (const implementer of implementers) {
2069
2200
  const input = {
2070
2201
  to: implementer.result?.name ?? implementer.spec.name,
@@ -2076,6 +2207,8 @@ export class FactoryLoop {
2076
2207
  reviewerName,
2077
2208
  implementerNames,
2078
2209
  slackDispatchThread: await this.#slackDispatchThreadFor(record),
2210
+ integrationsMountRoot: this.#integrationsMountRoot(),
2211
+ integrationInstructions,
2079
2212
  }),
2080
2213
  from: 'factory',
2081
2214
  data: { issue: record.issue },
@@ -2309,6 +2442,7 @@ export class FactoryLoop {
2309
2442
  const implementerNames = [...record.agents.values()]
2310
2443
  .filter((agent) => agent.spec.role === 'implementer')
2311
2444
  .map((agent) => agent.result?.name ?? agent.spec.name);
2445
+ const integrationInstructions = await this.#resolveIntegrationInstructions();
2312
2446
  const task = renderAgentTask({
2313
2447
  issue: templateIssueFromRecord(record, issue),
2314
2448
  route: route ?? { repo: prRef.repo },
@@ -2318,6 +2452,8 @@ export class FactoryLoop {
2318
2452
  implementerNames,
2319
2453
  pr: { number: prRef.prNumber, url: prRef.url },
2320
2454
  slackDispatchThread: await this.#slackDispatchThreadFor(record),
2455
+ integrationsMountRoot: this.#integrationsMountRoot(),
2456
+ integrationInstructions,
2321
2457
  });
2322
2458
  const spawned = await this.#spawnAgent(record, { ...spec, task }, false);
2323
2459
  await this.#writeInFlightRegistry();
@@ -2674,13 +2810,21 @@ export class FactoryLoop {
2674
2810
  return;
2675
2811
  }
2676
2812
  const key = issueKey(record.issue);
2677
- if (await this.#state.getSlackThread(this.#workspaceId, key) || this.#slackWatcherStarts.has(key)) {
2813
+ const existingThread = await this.#state.getSlackThread(this.#workspaceId, key);
2814
+ if (existingThread || this.#slackWatcherStarts.has(key)) {
2678
2815
  try {
2679
2816
  await this.#slackWatcherStarts.get(key);
2680
2817
  }
2681
2818
  catch {
2682
2819
  // The initiator logs Slack watcher startup failures.
2683
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
+ }
2684
2828
  return;
2685
2829
  }
2686
2830
  const start = this.#postAndWatchSlackDispatchThread(record, result);
@@ -2727,13 +2871,20 @@ export class FactoryLoop {
2727
2871
  return;
2728
2872
  }
2729
2873
  const key = issueKey(decision.issue);
2730
- if (await this.#state.getSlackThread(this.#workspaceId, key) || this.#slackWatcherStarts.has(key)) {
2874
+ const existingThread = await this.#state.getSlackThread(this.#workspaceId, key);
2875
+ if (existingThread || this.#slackWatcherStarts.has(key)) {
2731
2876
  try {
2732
2877
  await this.#slackWatcherStarts.get(key);
2733
2878
  }
2734
2879
  catch {
2735
2880
  // The initiator logs Slack watcher startup failures.
2736
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
+ }
2737
2888
  return;
2738
2889
  }
2739
2890
  const start = this.#postAndWatchSlackEscalationThread(decision, reason);
@@ -2763,13 +2914,7 @@ export class FactoryLoop {
2763
2914
  ].join('\n'),
2764
2915
  });
2765
2916
  await this.#state.setSlackThread(this.#workspaceId, issueKey(decision.issue), root.threadId);
2766
- await this.#watchSlackThread({
2767
- issue: decision.issue,
2768
- decision,
2769
- agents: new Map(),
2770
- invocationIds: new Set(),
2771
- dryRun: false,
2772
- }, root.threadId);
2917
+ await this.#watchSlackThread(escalationWatchRecord(decision), root.threadId);
2773
2918
  this.#recordSlackWritebackSuccess('triage-escalation');
2774
2919
  }
2775
2920
  async #watchSlackThread(record, threadId) {
@@ -2794,8 +2939,9 @@ export class FactoryLoop {
2794
2939
  const page = await this.#mount.getEvents({ limit: SLACK_REPLY_EVENTS_LIMIT });
2795
2940
  cursor = page.nextCursor ?? undefined;
2796
2941
  for (const event of page.events) {
2797
- if (event.resource.path.startsWith(messagesPrefix)) {
2798
- preExistingPaths.add(event.resource.path);
2942
+ const path = changeEventPath(event);
2943
+ if (path && path.startsWith(messagesPrefix)) {
2944
+ preExistingPaths.add(path);
2799
2945
  }
2800
2946
  }
2801
2947
  }
@@ -2805,7 +2951,11 @@ export class FactoryLoop {
2805
2951
  };
2806
2952
  const handle = async (event) => {
2807
2953
  try {
2808
- if (stopped || !event.resource.path.startsWith(messagesPrefix)) {
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)) {
2809
2959
  return;
2810
2960
  }
2811
2961
  const eventKey = eventIdentity(event);
@@ -2815,21 +2965,21 @@ export class FactoryLoop {
2815
2965
  this.#logger.warn?.('[factory] Slack reply event missing stable identity; falling back to path/content dedupe');
2816
2966
  }
2817
2967
  }
2818
- if (preExistingPaths.has(event.resource.path)) {
2968
+ if (preExistingPaths.has(path)) {
2819
2969
  return;
2820
2970
  }
2821
- const reply = await this.#readSlackReply(event.resource.path);
2971
+ const reply = await this.#readSlackReply(path);
2822
2972
  if (!reply || !reply.isThreadReply || reply.threadTs !== threadId || reply.channelDir !== channelDir) {
2823
2973
  return;
2824
2974
  }
2825
2975
  const replyMessageKey = `${reply.threadTs}:${reply.messageTs}`;
2826
2976
  if (seenReplyMessages.has(replyMessageKey)) {
2827
- this.#logger.debug?.('[factory] suppressed duplicate Slack reply message', { issue: record.issue.key, path: event.resource.path });
2977
+ this.#logger.debug?.('[factory] suppressed duplicate Slack reply message', { issue: record.issue.key, path });
2828
2978
  return;
2829
2979
  }
2830
- const replyKey = `${eventKey ?? event.resource.path}:${stableHash(JSON.stringify(reply.raw))}`;
2980
+ const replyKey = `${eventKey ?? path}:${stableHash(JSON.stringify(reply.raw))}`;
2831
2981
  if (seenReplies.has(replyKey)) {
2832
- this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path: event.resource.path });
2982
+ this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path });
2833
2983
  return;
2834
2984
  }
2835
2985
  seenReplies.add(replyKey);
@@ -2886,6 +3036,53 @@ export class FactoryLoop {
2886
3036
  },
2887
3037
  });
2888
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
+ }
2889
3086
  async #stopSlackWatcher(issue) {
2890
3087
  const key = issueKey(issue);
2891
3088
  const watcher = this.#slackWatchers.get(key);
@@ -2927,18 +3124,34 @@ export class FactoryLoop {
2927
3124
  this.#increment('slackAnswersIgnoredNoImplementer');
2928
3125
  return;
2929
3126
  }
2930
- const input = slackAnswerInput(liveRecord.issue, text);
2931
3127
  for (const recipient of new Set(recipients)) {
2932
- await this.#fleet.sendInput(recipient, input);
3128
+ await this.#injectSlackReplyEvent(recipient, liveRecord.issue, text);
2933
3129
  this.#increment('slackAnswersInjected');
2934
3130
  }
2935
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
+ }
2936
3147
  async #slackDispatchThreadFor(record) {
2937
3148
  if (!this.#config.slack) {
2938
3149
  return undefined;
2939
3150
  }
2940
3151
  const threadId = await this.#state.getSlackThread(this.#workspaceId, issueKey(record.issue));
2941
- return threadId ? { channel: this.#config.slack.channel, threadId } : undefined;
3152
+ return threadId
3153
+ ? { channel: this.#config.slack.channel, threadId, mountRoot: this.#integrationsMountRoot() }
3154
+ : undefined;
2942
3155
  }
2943
3156
  async #runCompletionMergeGate(issue) {
2944
3157
  if (this.#isSyntheticProbeIssue(issue)) {
@@ -3063,6 +3276,46 @@ export function parseLinearIssue(path, content) {
3063
3276
  raw: asRecord(parsed) ?? payload,
3064
3277
  };
3065
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
+ }
3066
3319
  export function parseGithubIssue(path, content) {
3067
3320
  const parsed = parseJsonContent(content);
3068
3321
  const payload = wrappedPayload(parsed);
@@ -3138,11 +3391,42 @@ const pidsFromSpawnResult = (result) => {
3138
3391
  const dispatchComment = (decision, agents) => [
3139
3392
  `Factory dispatch for ${decision.issue.key}`,
3140
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,
3141
3395
  `Reviewer: ${agents.find((agent) => agent.role === 'reviewer')?.name ?? 'none'}`,
3142
- ].join('\n');
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
+ }
3143
3403
  function labelDerivedDispatchDecision(liveIssue, decision, config) {
3144
3404
  const routesByLabel = labelRoutesForIssue(liveIssue, config);
3145
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
+ }
3146
3430
  return {
3147
3431
  ok: false,
3148
3432
  reason: 'no-labels',
@@ -3156,8 +3440,15 @@ function labelDerivedDispatchDecision(liveIssue, decision, config) {
3156
3440
  offendingLabels: routesByLabel.offendingLabels.length > 0 ? routesByLabel.offendingLabels : routesByLabel.labels,
3157
3441
  };
3158
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');
3159
3450
  const maxImplementers = Math.min(config.triage.maxImplementers, MAX_LABEL_IMPLEMENTERS);
3160
- if (routesByLabel.routes.length > maxImplementers) {
3451
+ if (scope === 'team' && routesByLabel.routes.length > maxImplementers) {
3161
3452
  return {
3162
3453
  ok: false,
3163
3454
  reason: 'too-many-labels',
@@ -3166,19 +3457,30 @@ function labelDerivedDispatchDecision(liveIssue, decision, config) {
3166
3457
  };
3167
3458
  }
3168
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;
3169
3470
  return {
3170
3471
  ok: true,
3171
3472
  decision: {
3172
3473
  ...decision,
3173
- routes: routesByLabel.routes.map(({ route }) => route),
3174
- scope: implementers.length >= 2 ? 'team' : 'single',
3175
- implementers,
3176
- reviewer: routeReviewerSpec(liveIssue, config, routesByLabel.routes[0].route, decision.reviewer),
3474
+ routes,
3475
+ scope,
3476
+ implementers: selectedImplementers,
3477
+ workflow,
3478
+ reviewer: routeReviewerSpec(liveIssue, config, selectedRoutes[0].route, decision.reviewer),
3177
3479
  },
3178
3480
  };
3179
3481
  }
3180
3482
  function labelRoutesForIssue(issue, config) {
3181
- const labels = uniqueNormalizedLabels(issue.labels);
3483
+ const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label));
3182
3484
  const routes = [];
3183
3485
  const offendingLabels = [];
3184
3486
  const seenRepos = new Set();
@@ -3229,6 +3531,29 @@ function routeReviewerSpec(issue, config, route, reviewer) {
3229
3531
  node: reviewer.node ?? 'self',
3230
3532
  };
3231
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
+ }
3232
3557
  function labelDispatchFailureSignature(resolution) {
3233
3558
  return `${resolution.reason}:${[...resolution.offendingLabels].sort().join(',')}`;
3234
3559
  }
@@ -3276,7 +3601,13 @@ function uniqueNormalizedLabels(labels) {
3276
3601
  return unique;
3277
3602
  }
3278
3603
  function taskForDispatch(issue, route, role) {
3279
- const verb = role === 'implementer' ? 'Implement' : role === 'babysitter' ? 'Babysit the PR for' : 'Review';
3604
+ const verb = role === 'implementer'
3605
+ ? 'Implement'
3606
+ : role === 'babysitter'
3607
+ ? 'Babysit the PR for'
3608
+ : role === 'workflow'
3609
+ ? 'Run workflow for'
3610
+ : 'Review';
3280
3611
  return [
3281
3612
  `${verb} ${issue.key}: ${issue.title}`,
3282
3613
  `Repo: ${route.repo}`,
@@ -3690,7 +4021,7 @@ const liveEventDedupeKey = (event) => {
3690
4021
  return [
3691
4022
  event.id,
3692
4023
  event.type,
3693
- event.resource.path,
4024
+ stringValue(resource.path) ?? '',
3694
4025
  stringValue(resource.revision) ?? '',
3695
4026
  event.digest ?? '',
3696
4027
  ].join('\u001f');
@@ -3840,6 +4171,15 @@ const eventIdentity = (event) => {
3840
4171
  const id = typeof rawId === 'string' || typeof rawId === 'number' ? String(rawId) : undefined;
3841
4172
  return id ? `event:${id}` : undefined;
3842
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
+ };
3843
4183
  const describeError = (error) => {
3844
4184
  if (error instanceof Error) {
3845
4185
  return {
@@ -3907,6 +4247,16 @@ const contextualError = (context, error) => {
3907
4247
  return wrapped;
3908
4248
  };
3909
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
+ });
3910
4260
  const cloneTrackedAgent = (tracked) => ({
3911
4261
  spec: { ...tracked.spec },
3912
4262
  result: tracked.result ? { ...tracked.result } : undefined,
@@ -3948,6 +4298,10 @@ const triageEscalationReason = (decision) => {
3948
4298
  return `${reasons.join(' and ')}${decision.rationale ? `: ${decision.rationale}` : ''}`;
3949
4299
  };
3950
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`;
3951
4305
  const isFactoryQuestionTarget = (target) => {
3952
4306
  const normalized = target.trim().replace(/^@/u, '').toLowerCase();
3953
4307
  return normalized === 'broker' || normalized === 'factory';