@agent-relay/factory 0.1.38 → 0.1.40

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 (93) hide show
  1. package/README.md +66 -5
  2. package/dist/cli/fleet.d.ts +6 -0
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +35 -0
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/schema.d.ts +52 -0
  7. package/dist/config/schema.d.ts.map +1 -1
  8. package/dist/config/schema.js +9 -0
  9. package/dist/config/schema.js.map +1 -1
  10. package/dist/dispatch/templates.d.ts +2 -0
  11. package/dist/dispatch/templates.d.ts.map +1 -1
  12. package/dist/dispatch/templates.js +7 -0
  13. package/dist/dispatch/templates.js.map +1 -1
  14. package/dist/dispatch/test-guidance.d.ts +19 -0
  15. package/dist/dispatch/test-guidance.d.ts.map +1 -0
  16. package/dist/dispatch/test-guidance.js +180 -0
  17. package/dist/dispatch/test-guidance.js.map +1 -0
  18. package/dist/featuremap/check.d.ts +19 -0
  19. package/dist/featuremap/check.d.ts.map +1 -0
  20. package/dist/featuremap/check.js +81 -0
  21. package/dist/featuremap/check.js.map +1 -0
  22. package/dist/featuremap/index.d.ts +5 -0
  23. package/dist/featuremap/index.d.ts.map +1 -0
  24. package/dist/featuremap/index.js +3 -0
  25. package/dist/featuremap/index.js.map +1 -0
  26. package/dist/featuremap/validate.d.ts +55 -0
  27. package/dist/featuremap/validate.d.ts.map +1 -0
  28. package/dist/featuremap/validate.js +205 -0
  29. package/dist/featuremap/validate.js.map +1 -0
  30. package/dist/github/standalone-babysitter.d.ts +1 -0
  31. package/dist/github/standalone-babysitter.d.ts.map +1 -1
  32. package/dist/github/standalone-babysitter.js +16 -1
  33. package/dist/github/standalone-babysitter.js.map +1 -1
  34. package/dist/index.d.ts +5 -4
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +3 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
  39. package/dist/mount/relayfile-github-connection-write.js +4 -2
  40. package/dist/mount/relayfile-github-connection-write.js.map +1 -1
  41. package/dist/orchestrator/batch-tracker.d.ts +24 -2
  42. package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
  43. package/dist/orchestrator/batch-tracker.js +53 -4
  44. package/dist/orchestrator/batch-tracker.js.map +1 -1
  45. package/dist/orchestrator/coalesced-task-queue.d.ts +19 -0
  46. package/dist/orchestrator/coalesced-task-queue.d.ts.map +1 -0
  47. package/dist/orchestrator/coalesced-task-queue.js +85 -0
  48. package/dist/orchestrator/coalesced-task-queue.js.map +1 -0
  49. package/dist/orchestrator/dependencies.d.ts +21 -0
  50. package/dist/orchestrator/dependencies.d.ts.map +1 -0
  51. package/dist/orchestrator/dependencies.js +70 -0
  52. package/dist/orchestrator/dependencies.js.map +1 -0
  53. package/dist/orchestrator/factory.d.ts.map +1 -1
  54. package/dist/orchestrator/factory.js +965 -148
  55. package/dist/orchestrator/factory.js.map +1 -1
  56. package/dist/orchestrator/index.d.ts +3 -0
  57. package/dist/orchestrator/index.d.ts.map +1 -1
  58. package/dist/orchestrator/index.js +1 -0
  59. package/dist/orchestrator/index.js.map +1 -1
  60. package/dist/ports/mount.d.ts +2 -0
  61. package/dist/ports/mount.d.ts.map +1 -1
  62. package/dist/ports/state.d.ts +67 -4
  63. package/dist/ports/state.d.ts.map +1 -1
  64. package/dist/ports/writeback.d.ts +3 -0
  65. package/dist/ports/writeback.d.ts.map +1 -1
  66. package/dist/state/file-state-store.d.ts +18 -3
  67. package/dist/state/file-state-store.d.ts.map +1 -1
  68. package/dist/state/file-state-store.js +209 -2
  69. package/dist/state/file-state-store.js.map +1 -1
  70. package/dist/state/in-memory-state-store.d.ts +15 -1
  71. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  72. package/dist/state/in-memory-state-store.js +111 -0
  73. package/dist/state/in-memory-state-store.js.map +1 -1
  74. package/dist/subscriptions/index.d.ts +2 -0
  75. package/dist/subscriptions/index.d.ts.map +1 -1
  76. package/dist/subscriptions/index.js +1 -0
  77. package/dist/subscriptions/index.js.map +1 -1
  78. package/dist/subscriptions/slack-filter.d.ts +32 -0
  79. package/dist/subscriptions/slack-filter.d.ts.map +1 -0
  80. package/dist/subscriptions/slack-filter.js +62 -0
  81. package/dist/subscriptions/slack-filter.js.map +1 -0
  82. package/dist/subscriptions/specs.d.ts +2 -0
  83. package/dist/subscriptions/specs.d.ts.map +1 -1
  84. package/dist/subscriptions/specs.js +11 -0
  85. package/dist/subscriptions/specs.js.map +1 -1
  86. package/dist/triage/schema.d.ts +2 -2
  87. package/dist/types.d.ts +11 -0
  88. package/dist/types.d.ts.map +1 -1
  89. package/dist/writeback/github.d.ts +4 -0
  90. package/dist/writeback/github.d.ts.map +1 -1
  91. package/dist/writeback/github.js +95 -0
  92. package/dist/writeback/github.js.map +1 -1
  93. package/package.json +8 -1
@@ -13,11 +13,15 @@ import { isInFactoryScope } from '../safety/factory-scope.js';
13
13
  import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
14
14
  import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
15
15
  import { parseGithubHumanInputRequest, renderAgentTask, } from '../dispatch/templates.js';
16
+ import { resolveTestGuidance } from '../dispatch/test-guidance.js';
16
17
  import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
17
18
  import { agentNameForRole, sanitizeAgentSlug } from '../triage/agent-names.js';
18
19
  import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
20
+ import { parseSlackThreadReply, slackThreadReplyGlob } from '../subscriptions/slack-filter.js';
19
21
  import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
20
- import { issueKey } from './batch-tracker.js';
22
+ import { issueKey, } from './batch-tracker.js';
23
+ import { dependencyIdentity, findDependencyCycle, parseBlockedBy, resolveDependency, } from './dependencies.js';
24
+ import { CoalescedTaskQueue } from './coalesced-task-queue.js';
21
25
  import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
22
26
  import { readFactoryInFlightRegistry, terminatePids } from './reaper.js';
23
27
  import { createFactoryCloudEventV1, factoryCloudReleaseReasonV1, } from '../observability/events.js';
@@ -87,6 +91,9 @@ const DISPATCH_LIFECYCLE_RENEW_MS = 60_000;
87
91
  const DISPATCH_LIFECYCLE_RETRY_MS = 1_000;
88
92
  const STARTUP_AGENT_EXIT_DRAIN_TIMEOUT_MS = 30_000;
89
93
  const SLACK_EVENT_WATERMARK_CACHE_MS = 60_000;
94
+ const SLACK_CONVERSATION_TURN_LEASE_MS = 60_000;
95
+ const SLACK_CONVERSATION_TURN_RETRY_MS = 1_000;
96
+ const SLACK_REPLY_ROUTE_RETRY_MS = 1_000;
90
97
  const MERGE_GATE_MAX_ATTEMPTS = 12;
91
98
  const MERGE_GATE_POLL_DELAY_MS = 10_000;
92
99
  const MAX_LABEL_IMPLEMENTERS = 4;
@@ -116,7 +123,9 @@ export class FactoryLoop {
116
123
  #fleet;
117
124
  #triage;
118
125
  #linear;
126
+ #github;
119
127
  #githubWriteback;
128
+ #githubWritebackProvided;
120
129
  #slack;
121
130
  #mergeGate;
122
131
  #probeCloser;
@@ -147,6 +156,8 @@ export class FactoryLoop {
147
156
  #dispatchInFlight = new Map();
148
157
  #slackWatchers = new Map();
149
158
  #slackWatcherStarts = new Map();
159
+ #slackConversationTurns;
160
+ #slackConversationOwner = `${process.pid}:${randomUUID()}`;
150
161
  #githubIssueCommentWatchers = new Map();
151
162
  #githubIssueCommentWatchStates = new Map();
152
163
  #githubIssueCommentQueues = new Map();
@@ -168,6 +179,13 @@ export class FactoryLoop {
168
179
  // issue (or the comment writeback's own change event) does not re-post the
169
180
  // same notice every cycle. Cleared once the issue dispatches successfully.
170
181
  #labelDispatchFailures = new Map();
182
+ // Provider snapshots loaded during discovery, keyed by collision-safe
183
+ // owner/repo#number identity. GitHub-native records outrank Linear mirrors.
184
+ #dependencyIssues = new Map();
185
+ #terminalDependencyIdentities = new Set();
186
+ #dependencyParkNotices = new Map();
187
+ #dependencyGithubPathsByIdentity;
188
+ #dependencyLinearTreeLoaded = false;
171
189
  #pendingSlackClarifications = new Map();
172
190
  #pendingGithubClarifications = new Map();
173
191
  #clarificationIntents = new Map();
@@ -270,9 +288,10 @@ export class FactoryLoop {
270
288
  this.#linear = ports.linear ?? MountLinearWriteback(ports.mount, {
271
289
  safety: config.safety,
272
290
  });
291
+ this.#githubWritebackProvided = Boolean(ports.githubWriteback);
273
292
  this.#githubWriteback = ports.githubWriteback ?? new GhCliGithubWriteback();
274
293
  this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
275
- void (ports.github ?? MountGithubRead(ports.mount));
294
+ this.#github = ports.github ?? MountGithubRead(ports.mount);
276
295
  this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
277
296
  this.#probeCloser = ports.probeCloser ?? closeProbePr;
278
297
  this.#customProbePrResolver = Boolean(ports.probePrResolver);
@@ -304,6 +323,19 @@ export class FactoryLoop {
304
323
  this.#batchView = batch;
305
324
  return batch;
306
325
  });
326
+ this.#slackConversationTurns = new CoalescedTaskQueue({
327
+ delayMs: config.slack?.conversationCoalesceMs ?? 750,
328
+ run: async (conversationId) => this.#resumeSlackConversationTurn(conversationId),
329
+ onError: (error, conversationId) => {
330
+ this.#logger.warn?.('[factory] Slack conversation turn failed', {
331
+ conversationId,
332
+ error: describeError(error).errorMessage,
333
+ });
334
+ if (!this.#stopping) {
335
+ this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
336
+ }
337
+ },
338
+ });
307
339
  this.#wireFleetEvents();
308
340
  }
309
341
  async #resolveIntegrationInstructions() {
@@ -626,6 +658,7 @@ export class FactoryLoop {
626
658
  this.#offAgentMessage?.();
627
659
  this.#offAgentLifecycleSignal?.();
628
660
  await Promise.allSettled([...this.#agentLifecycleSignalsInFlight.values()]);
661
+ await this.#slackConversationTurns.stop();
629
662
  this.#offAgentExit = undefined;
630
663
  this.#offDeliveryFailed = undefined;
631
664
  this.#offAgentMessage = undefined;
@@ -1270,6 +1303,13 @@ export class FactoryLoop {
1270
1303
  this.#logger.info?.('[factory] run-once started', { dryRun });
1271
1304
  let report;
1272
1305
  try {
1306
+ this.#dependencyIssues.clear();
1307
+ // Terminal observations are only a live-cycle cache. Rebuild them from
1308
+ // current provider snapshots (or merged PR metadata) so a reopened issue
1309
+ // cannot remain permanently resolved after an earlier close event.
1310
+ this.#terminalDependencyIdentities.clear();
1311
+ this.#dependencyGithubPathsByIdentity = undefined;
1312
+ this.#dependencyLinearTreeLoaded = false;
1273
1313
  const issueSource = await this.#issueSource();
1274
1314
  if (issueSource === 'linear') {
1275
1315
  await this.#ingestGithubIssues({ dryRun });
@@ -1392,7 +1432,12 @@ export class FactoryLoop {
1392
1432
  continue;
1393
1433
  }
1394
1434
  if (result.agents.length === 0 && !dryRun) {
1395
- skipped.push({ issue: decision.issue, reason: 'queued or escalated' });
1435
+ const reason = result.hold?.kind === 'dependency-cycle'
1436
+ ? `dependency cycle detected: ${result.hold.cycle?.join(' -> ') ?? 'unknown cycle'}`
1437
+ : result.hold?.kind === 'dependency'
1438
+ ? `parked on dependencies: ${result.hold.blockers?.join(', ') ?? 'unresolved dependency'}`
1439
+ : 'queued or escalated';
1440
+ skipped.push({ issue: decision.issue, reason });
1396
1441
  }
1397
1442
  else {
1398
1443
  dispatched.push(result);
@@ -2004,6 +2049,26 @@ export class FactoryLoop {
2004
2049
  this.#recordTriageEscalation(dispatchDecision, escalationReason);
2005
2050
  return replayedResult ?? { issue: dispatchDecision.issue, agents: [], dryRun };
2006
2051
  }
2052
+ const dependencyAdmission = await this.#dependencyAdmission(liveIssue, dispatchDecision);
2053
+ if (dependencyAdmission.blockers.length > 0 || dependencyAdmission.cycle) {
2054
+ batch.queue(dispatchDecision, dryRun, dependencyAdmission);
2055
+ const parked = batch.getParked(dispatchDecision.issue);
2056
+ if (!parked)
2057
+ throw new Error(`dependency admission failed to park ${dispatchDecision.issue.key}`);
2058
+ const comment = await this.#reportDependencyPark(liveIssue, parked, dryRun);
2059
+ return {
2060
+ issue: dispatchDecision.issue,
2061
+ agents: [],
2062
+ comments: [comment],
2063
+ dryRun,
2064
+ hold: {
2065
+ kind: parked.cycle ? 'dependency-cycle' : 'dependency',
2066
+ blockers: parked.blockers.map((blocker) => blocker.label),
2067
+ cycle: parked.cycle ? [...parked.cycle] : undefined,
2068
+ },
2069
+ };
2070
+ }
2071
+ this.#clearDependencyPark(batch, dispatchDecision.issue);
2007
2072
  // Full task rendering is part of the durable spawn specification. It must
2008
2073
  // happen before a remote lifecycle is first claimed so takeover cannot
2009
2074
  // recover a persisted minimal triage task after a crash in this gap.
@@ -2031,7 +2096,12 @@ export class FactoryLoop {
2031
2096
  this.#scheduleDispatchLifecycleRetry(queuedRecord);
2032
2097
  this.#increment('queued');
2033
2098
  this.#emit('issue-queued', { issue: dispatchDecision.issue });
2034
- return lifecycleClaim.lifecycle.result ?? { issue: dispatchDecision.issue, agents: [], dryRun };
2099
+ return lifecycleClaim.lifecycle.result ?? {
2100
+ issue: dispatchDecision.issue,
2101
+ agents: [],
2102
+ dryRun,
2103
+ hold: { kind: 'capacity' },
2104
+ };
2035
2105
  }
2036
2106
  if (!lifecycleClaim.created) {
2037
2107
  const restored = batch.restore(inFlightRecordFromLifecycle(lifecycleClaim.lifecycle));
@@ -2042,12 +2112,12 @@ export class FactoryLoop {
2042
2112
  if (!durableDispatch)
2043
2113
  this.#consumePendingDispatchClarifications(dispatchDecision.issue);
2044
2114
  await this.#recordDispatchAttempt(dispatchDecision.issue);
2045
- const record = batch.start(dispatchDecision, dryRun);
2115
+ const record = batch.start(dispatchDecision, dryRun, dependencyAdmission);
2046
2116
  if (!record) {
2047
2117
  await this.#clearDispatchInFlight(dispatchDecision.issue);
2048
2118
  this.#increment('queued');
2049
2119
  this.#emit('issue-queued', { issue: dispatchDecision.issue });
2050
- return { issue: dispatchDecision.issue, agents: [], dryRun };
2120
+ return { issue: dispatchDecision.issue, agents: [], dryRun, hold: { kind: 'capacity' } };
2051
2121
  }
2052
2122
  if (record.result) {
2053
2123
  return record.result;
@@ -2175,6 +2245,12 @@ export class FactoryLoop {
2175
2245
  return {
2176
2246
  inFlight: batch?.inFlight.map((record) => record.issue) ?? [],
2177
2247
  queued: batch?.queued.map((queued) => queued.issue) ?? [],
2248
+ parked: batch?.parked.map((parked) => ({
2249
+ issue: parked.issue,
2250
+ blockers: parked.blockers.map((blocker) => blocker.label),
2251
+ cycle: parked.cycle ? [...parked.cycle] : undefined,
2252
+ capacityBlocked: parked.capacityBlocked,
2253
+ })) ?? [],
2178
2254
  counters: { ...this.#counters },
2179
2255
  slackDegraded: this.#slackDegraded,
2180
2256
  slackDegradedReason: this.#slackDegradedReason,
@@ -2250,7 +2326,7 @@ export class FactoryLoop {
2250
2326
  const batch = await this.#batch();
2251
2327
  const agents = [];
2252
2328
  let hasNonterminalDurableLifecycle = false;
2253
- const durableLifecycles = await this.#state.listDispatchLifecycles(this.#workspaceId);
2329
+ const durableLifecycles = await this.#deduplicateQueuedGithubLifecycleAliases(await this.#state.listDispatchLifecycles(this.#workspaceId));
2254
2330
  this.#logger.info?.('[factory] durable startup adoption loaded', {
2255
2331
  lifecycles: durableLifecycles.length,
2256
2332
  });
@@ -2342,6 +2418,64 @@ export class FactoryLoop {
2342
2418
  this.#logger.warn?.('[factory] failed to re-adopt durable in-flight agents', { error });
2343
2419
  }
2344
2420
  }
2421
+ async #deduplicateQueuedGithubLifecycleAliases(lifecycles) {
2422
+ const groups = new Map();
2423
+ for (const entry of lifecycles) {
2424
+ if (isTerminalDispatchLifecycle(entry[1]))
2425
+ continue;
2426
+ const identity = githubIssueRefIdentity(entry[1].issue);
2427
+ if (!identity)
2428
+ continue;
2429
+ const group = groups.get(identity) ?? [];
2430
+ group.push(entry);
2431
+ groups.set(identity, group);
2432
+ }
2433
+ const clearedKeys = new Set();
2434
+ for (const [identity, group] of groups) {
2435
+ if (group.length < 2)
2436
+ continue;
2437
+ const active = group.filter(([, lifecycle]) => lifecycle.phase !== 'queued');
2438
+ // Two independently active aliases may each own useful work. Preserve
2439
+ // both for operator reconciliation instead of guessing which branch wins.
2440
+ if (active.length > 1) {
2441
+ this.#increment('dispatchLifecycleGithubAliasConflicts');
2442
+ this.#logger.warn?.('[factory] retained conflicting active GitHub lifecycle aliases', {
2443
+ identity,
2444
+ count: group.length,
2445
+ });
2446
+ continue;
2447
+ }
2448
+ const nowMs = this.#clock.now();
2449
+ const ownedElsewhere = group.some(([, lifecycle]) => lifecycle.lease &&
2450
+ lifecycle.lease.owner !== this.#dispatchLifecycleOwner &&
2451
+ lifecycle.lease.leaseUntilMs > nowMs);
2452
+ if (ownedElsewhere) {
2453
+ this.#increment('dispatchLifecycleGithubAliasDedupeDeferred');
2454
+ continue;
2455
+ }
2456
+ const winner = active[0] ?? [...group].sort(compareQueuedGithubLifecycleAliases)[0];
2457
+ for (const [key, lifecycle] of group) {
2458
+ if (key === winner[0] || lifecycle.phase !== 'queued')
2459
+ continue;
2460
+ const cleared = await this.#state.clearQueuedDispatchLifecycle(this.#workspaceId, key, lifecycle.lease);
2461
+ if (!cleared) {
2462
+ this.#increment('dispatchLifecycleGithubAliasDedupeDeferred');
2463
+ continue;
2464
+ }
2465
+ this.#dispatchLifecycleEpochs.delete(key);
2466
+ const timer = this.#dispatchLifecycleRetryTimers.get(key);
2467
+ if (timer)
2468
+ clearTimeout(timer);
2469
+ this.#dispatchLifecycleRetryTimers.delete(key);
2470
+ this.#dispatchLifecycleCapacityWaitLogged.delete(key);
2471
+ clearedKeys.add(key);
2472
+ this.#increment('dispatchLifecycleGithubAliasesCollapsed');
2473
+ }
2474
+ }
2475
+ return clearedKeys.size > 0
2476
+ ? lifecycles.filter(([key]) => !clearedKeys.has(key))
2477
+ : lifecycles;
2478
+ }
2345
2479
  async #drainAgentExitsInFlight(names, timeoutMs) {
2346
2480
  const drain = async () => {
2347
2481
  for (;;) {
@@ -2637,6 +2771,19 @@ export class FactoryLoop {
2637
2771
  acquiredNow = true;
2638
2772
  }
2639
2773
  if (lifecycle.phase === 'queued') {
2774
+ const liveIssue = await this.#readIssue(lifecycle.issue.path);
2775
+ if (liveIssue) {
2776
+ const dependencyAdmission = await this.#dependencyAdmission(liveIssue, lifecycle.decision);
2777
+ const batch = await this.#batch();
2778
+ if (dependencyAdmission.blockers.length > 0 || dependencyAdmission.cycle) {
2779
+ batch.queue(lifecycle.decision, lifecycle.dryRun, dependencyAdmission);
2780
+ const parked = batch.getParked(lifecycle.issue);
2781
+ if (parked)
2782
+ await this.#reportDependencyPark(liveIssue, parked, lifecycle.dryRun);
2783
+ return;
2784
+ }
2785
+ this.#clearDependencyPark(batch, lifecycle.issue);
2786
+ }
2640
2787
  const epoch = this.#dispatchLifecycleEpochs.get(key);
2641
2788
  if (epoch === undefined || !await this.#state.promoteDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now())) {
2642
2789
  throw new DispatchLifecycleCapacityError(`durable dispatch ${lifecycle.issue.key} is waiting for batch capacity`);
@@ -2954,6 +3101,9 @@ export class FactoryLoop {
2954
3101
  if (issue && !isGithubIssue(issue)) {
2955
3102
  await this.#recordCanonicalIssueState(issue);
2956
3103
  }
3104
+ if (issue && this.#dependencyIssueIsTerminal(issue)) {
3105
+ await this.#markDependencyTerminalAndReconcile(issue);
3106
+ }
2957
3107
  if (!issue || !this.#isIssueReady(issue)) {
2958
3108
  return;
2959
3109
  }
@@ -2982,14 +3132,7 @@ export class FactoryLoop {
2982
3132
  this.#recordTriageEscalation(escalationDecision, escalationReason);
2983
3133
  return;
2984
3134
  }
2985
- if (batch.canStart()) {
2986
- await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
2987
- }
2988
- else {
2989
- if (batch.queue(escalationDecision, this.#config.dryRun)) {
2990
- this.#emit('issue-queued', { issue: escalationDecision.issue });
2991
- }
2992
- }
3135
+ await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
2993
3136
  }
2994
3137
  catch (error) {
2995
3138
  if (error instanceof LiveDispatchStateChangedError) {
@@ -3225,13 +3368,17 @@ export class FactoryLoop {
3225
3368
  if (!ghIssue) {
3226
3369
  return;
3227
3370
  }
3371
+ const closed = githubIssueIsClosed(ghIssue);
3372
+ if (closed) {
3373
+ await this.#markDependencyTerminalAndReconcile(githubIssueAsFactoryIssue(ghIssue));
3374
+ }
3228
3375
  if (await this.#issueSource() === 'github') {
3229
- if (!githubIssueIsClosed(ghIssue) && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
3376
+ if (!closed && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
3230
3377
  await this.#handleChange(ghIssue.path);
3231
3378
  }
3232
3379
  return;
3233
3380
  }
3234
- if (githubIssueIsClosed(ghIssue)) {
3381
+ if (closed) {
3235
3382
  const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
3236
3383
  if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
3237
3384
  if (!opts.dryRun) {
@@ -3284,7 +3431,9 @@ export class FactoryLoop {
3284
3431
  for (const candidatePath of candidatePaths) {
3285
3432
  try {
3286
3433
  const { content } = await this.#readRelayfileFile(candidatePath, 'GitHub issue ingestion');
3287
- return parseGithubIssue(candidatePath, content);
3434
+ const githubIssue = parseGithubIssue(candidatePath, content);
3435
+ this.#indexDependencyIssue(githubIssueAsFactoryIssue(githubIssue));
3436
+ return githubIssue;
3288
3437
  }
3289
3438
  catch (error) {
3290
3439
  if (isMissingIssueFileError(error) && candidatePath !== candidatePaths.at(-1)) {
@@ -3373,6 +3522,248 @@ export class FactoryLoop {
3373
3522
  });
3374
3523
  return entry?.[0];
3375
3524
  }
3525
+ #indexDependencyIssue(issue) {
3526
+ if (!issue)
3527
+ return;
3528
+ const repo = dependencyRepoForIssue(issue, undefined, this.#config);
3529
+ const identity = dependencyIdentityForIssue(issue, repo);
3530
+ if (!identity)
3531
+ return;
3532
+ const rank = isGithubIssueFilePath(issue.path) ? 2 : 1;
3533
+ const existing = this.#dependencyIssues.get(identity);
3534
+ if (!existing || rank >= existing.rank) {
3535
+ this.#dependencyIssues.set(identity, { issue, rank });
3536
+ if (this.#dependencyIssueIsTerminal(issue)) {
3537
+ this.#terminalDependencyIdentities.add(identity);
3538
+ }
3539
+ else {
3540
+ this.#terminalDependencyIdentities.delete(identity);
3541
+ }
3542
+ }
3543
+ }
3544
+ async #dependencyAdmission(issue, decision) {
3545
+ const declared = parseBlockedBy(issue.description);
3546
+ if (declared.length === 0)
3547
+ return { blockers: [] };
3548
+ const currentRepo = dependencyRepoForIssue(issue, decision, this.#config);
3549
+ const resolved = declared
3550
+ .map((dependency) => resolveDependency(dependency, currentRepo))
3551
+ .filter((dependency) => Boolean(dependency));
3552
+ await this.#loadMissingDependencyIssues(resolved.map((dependency) => dependency.identity));
3553
+ const resolvedTerminal = new Set();
3554
+ for (const dependency of resolved) {
3555
+ if (await this.#dependencyIsTerminalOrMerged(dependency.identity)) {
3556
+ resolvedTerminal.add(dependency.identity);
3557
+ }
3558
+ }
3559
+ const blockers = resolved
3560
+ .filter((dependency) => !resolvedTerminal.has(dependency.identity))
3561
+ .map((dependency) => {
3562
+ const blocker = this.#dependencyIssues.get(dependency.identity)?.issue;
3563
+ return {
3564
+ identity: dependency.identity,
3565
+ key: blocker ? issueKey(issueRef(blocker)) : dependency.identity,
3566
+ label: dependency.label,
3567
+ };
3568
+ });
3569
+ // A bare #N without a resolvable single repository is itself unresolved.
3570
+ // Keep it visible and fail closed instead of guessing among routes.
3571
+ for (const dependency of declared) {
3572
+ if (dependency.repo || currentRepo)
3573
+ continue;
3574
+ blockers.push({
3575
+ identity: `unresolved:${issueKey(decision.issue)}:${dependency.raw}`,
3576
+ key: `unresolved:${dependency.raw}`,
3577
+ label: `${dependency.raw} (repository unresolved)`,
3578
+ });
3579
+ }
3580
+ const currentIdentity = dependencyIdentityForIssue(issue, currentRepo);
3581
+ const cycle = currentIdentity
3582
+ ? findDependencyCycle(currentIdentity, this.#dependencyGraph())
3583
+ : undefined;
3584
+ return { blockers, cycle };
3585
+ }
3586
+ async #loadMissingDependencyIssues(identities) {
3587
+ const pending = [...new Set(identities)];
3588
+ const expanded = new Set();
3589
+ while (pending.length > 0) {
3590
+ const identity = pending.shift();
3591
+ if (expanded.has(identity))
3592
+ continue;
3593
+ expanded.add(identity);
3594
+ if (!this.#dependencyIssues.has(identity)) {
3595
+ // Startup discovery may use the ready-only GitHub issue index. A
3596
+ // blocker is often closed, in progress, or deliberately unlabeled, so
3597
+ // probe its stable by-id aliases directly before consulting that
3598
+ // filtered discovery set.
3599
+ const separator = identity.lastIndexOf('#');
3600
+ const repoIdentity = identity.slice(0, separator);
3601
+ const number = Number(identity.slice(separator + 1));
3602
+ const configuredRepo = configuredGithubRepoParts(this.#config)
3603
+ .find(({ owner, repo }) => `${owner}/${repo}`.toLowerCase() === repoIdentity);
3604
+ if (configuredRepo && Number.isSafeInteger(number) && number > 0) {
3605
+ const { owner, repo } = configuredRepo;
3606
+ for (const path of [
3607
+ `${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`,
3608
+ `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/by-id/${number}.json`,
3609
+ ]) {
3610
+ await this.#readIssue(path);
3611
+ if (this.#dependencyIssues.has(identity))
3612
+ break;
3613
+ }
3614
+ }
3615
+ }
3616
+ if (!this.#dependencyIssues.has(identity)) {
3617
+ if (!this.#dependencyGithubPathsByIdentity) {
3618
+ this.#dependencyGithubPathsByIdentity = new Map();
3619
+ for (const path of await this.#githubIssuePaths()) {
3620
+ const parts = githubIssuePathParts(path);
3621
+ if (parts) {
3622
+ this.#dependencyGithubPathsByIdentity.set(githubIssueIdentity(parts.owner, parts.repo, parts.number), path);
3623
+ }
3624
+ }
3625
+ }
3626
+ const githubPath = this.#dependencyGithubPathsByIdentity.get(identity);
3627
+ if (githubPath)
3628
+ await this.#readIssue(githubPath);
3629
+ }
3630
+ if (!this.#dependencyIssues.has(identity) && !this.#dependencyLinearTreeLoaded) {
3631
+ // Linear paths do not encode the routed repository identity, so load
3632
+ // the tree once and let #indexDependencyIssue build the composite map.
3633
+ // The pass-scoped flag also lets every later blocked issue reuse the
3634
+ // resulting dependency index instead of rescanning the full tree.
3635
+ this.#dependencyLinearTreeLoaded = true;
3636
+ for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'dependency blocker discovery')) {
3637
+ if (isIssueFilePath(path))
3638
+ await this.#readIssue(path);
3639
+ }
3640
+ }
3641
+ const blocker = this.#dependencyIssues.get(identity)?.issue;
3642
+ if (!blocker)
3643
+ continue;
3644
+ const repo = identity.slice(0, identity.lastIndexOf('#'));
3645
+ for (const dependency of parseBlockedBy(blocker.description)) {
3646
+ const resolved = resolveDependency(dependency, repo);
3647
+ if (resolved && !expanded.has(resolved.identity))
3648
+ pending.push(resolved.identity);
3649
+ }
3650
+ }
3651
+ }
3652
+ #clearDependencyPark(batch, issue) {
3653
+ batch.clearPark(issue);
3654
+ this.#dependencyParkNotices.delete(issueKey(issue));
3655
+ }
3656
+ #dependencyGraph() {
3657
+ const graph = new Map();
3658
+ for (const [identity, { issue }] of this.#dependencyIssues) {
3659
+ if (this.#dependencyIdentityIsTerminal(identity)) {
3660
+ graph.set(identity, []);
3661
+ continue;
3662
+ }
3663
+ const repo = identity.slice(0, identity.lastIndexOf('#'));
3664
+ const dependencies = parseBlockedBy(issue.description)
3665
+ .map((dependency) => resolveDependency(dependency, repo))
3666
+ .filter((dependency) => Boolean(dependency))
3667
+ .filter((dependency) => !this.#dependencyIdentityIsTerminal(dependency.identity))
3668
+ .map((dependency) => dependency.identity);
3669
+ graph.set(identity, [...new Set(dependencies)]);
3670
+ }
3671
+ return graph;
3672
+ }
3673
+ #dependencyIdentityIsTerminal(identity) {
3674
+ if (this.#terminalDependencyIdentities.has(identity))
3675
+ return true;
3676
+ const issue = this.#dependencyIssues.get(identity)?.issue;
3677
+ return Boolean(issue && this.#dependencyIssueIsTerminal(issue));
3678
+ }
3679
+ async #dependencyIsTerminalOrMerged(identity) {
3680
+ if (this.#dependencyIdentityIsTerminal(identity))
3681
+ return true;
3682
+ const issue = this.#dependencyIssues.get(identity)?.issue;
3683
+ if (!issue)
3684
+ return false;
3685
+ const repo = dependencyRepoForIssue(issue, undefined, this.#config);
3686
+ if (!repo)
3687
+ return false;
3688
+ const pullRequest = await resolveIssuePrFromMount(this.#mount, this.#config, issue, {
3689
+ allowLegacyGithubBranch: true,
3690
+ repo,
3691
+ });
3692
+ if (normalizePrState(pullRequest?.state) !== 'MERGED')
3693
+ return false;
3694
+ this.#terminalDependencyIdentities.add(identity);
3695
+ return true;
3696
+ }
3697
+ #dependencyIssueIsTerminal(issue) {
3698
+ return isGithubIssue(issue)
3699
+ ? githubFactoryIssueIsClosed(issue)
3700
+ : this.#states.isRole(issue.stateId, 'done');
3701
+ }
3702
+ async #reportDependencyPark(issue, parked, dryRun) {
3703
+ const cycle = parked.cycle?.join(' -> ');
3704
+ const blockers = parked.blockers.map((blocker) => blocker.label);
3705
+ const signature = JSON.stringify({ blockers, cycle, capacityBlocked: parked.capacityBlocked });
3706
+ const marker = `<!-- factory-dependency-park:${stableHash(signature)} -->`;
3707
+ const comment = parked.cycle
3708
+ ? [
3709
+ marker,
3710
+ 'Factory refused dispatch because it detected a dependency cycle.',
3711
+ `Cycle: ${cycle}`,
3712
+ blockers.length > 0 ? `Unresolved blockers: ${blockers.join(', ')}` : undefined,
3713
+ ].filter((line) => Boolean(line)).join('\n')
3714
+ : [
3715
+ marker,
3716
+ 'Factory parked this issue because declared dependencies are unresolved.',
3717
+ `Blocked by: ${blockers.join(', ')}`,
3718
+ parked.capacityBlocked ? 'Capacity is also currently unavailable.' : undefined,
3719
+ ].filter((line) => Boolean(line)).join('\n');
3720
+ const key = issueKey(parked.issue);
3721
+ if (dryRun || this.#dependencyParkNotices.get(key) === signature)
3722
+ return comment;
3723
+ this.#increment(parked.cycle ? 'dependencyCycles' : 'dependencyParks');
3724
+ if (parked.cycle) {
3725
+ this.#error(new Error(`Dependency cycle detected: ${cycle}`), parked.issue);
3726
+ }
3727
+ try {
3728
+ await this.#postIssueComment(issue, comment);
3729
+ this.#dependencyParkNotices.set(key, signature);
3730
+ }
3731
+ catch (error) {
3732
+ this.#logger.warn?.('[factory] dependency park writeback skipped', {
3733
+ issue: parked.issue.key,
3734
+ error: describeError(error).errorMessage,
3735
+ });
3736
+ }
3737
+ return comment;
3738
+ }
3739
+ async #markDependencyTerminalAndReconcile(issue) {
3740
+ const repo = dependencyRepoForIssue(issue, undefined, this.#config);
3741
+ const identity = dependencyIdentityForIssue(issue, repo);
3742
+ if (!identity)
3743
+ return;
3744
+ this.#indexDependencyIssue(issue);
3745
+ // A merge event can prove terminality before the issue snapshot reflects
3746
+ // its new state, so apply the explicit observation after indexing it.
3747
+ this.#terminalDependencyIdentities.add(identity);
3748
+ const batch = await this.#batch();
3749
+ this.#clearDependencyPark(batch, issueRef(issue));
3750
+ const candidates = batch.parked.filter((parked) => parked.blockers.some((blocker) => blocker.identity === identity));
3751
+ for (const parked of candidates) {
3752
+ try {
3753
+ const liveIssue = await this.#readIssue(parked.issue.path);
3754
+ if (!liveIssue || !this.#isIssueReady(liveIssue))
3755
+ continue;
3756
+ await this.dispatch(parked.decision, { dryRun: parked.dryRun });
3757
+ }
3758
+ catch (error) {
3759
+ this.#logger.warn?.('[factory] dependency park reconciliation failed', {
3760
+ blocker: identity,
3761
+ issue: parked.issue.key,
3762
+ error: describeError(error).errorMessage,
3763
+ });
3764
+ }
3765
+ }
3766
+ }
3376
3767
  async #dispatchBlockReason(issue) {
3377
3768
  const key = issueStateKey(issue);
3378
3769
  const state = await this.#state.getDispatchAttempts(this.#workspaceId, key);
@@ -3657,12 +4048,14 @@ export class FactoryLoop {
3657
4048
  // (relayfile-adapters#205). The factory matches state by UUID, so backfill
3658
4049
  // the id from the name when the payload omitted it — otherwise every issue
3659
4050
  // reads as stateId='' and no role (incl. readyForAgent) ever matches.
4051
+ let resolvedIssue = issue;
3660
4052
  if (issue && !issue.stateId && issue.state?.name) {
3661
4053
  const backfilled = this.#states.idForName(issue.state.name, issue.team);
3662
4054
  if (backfilled)
3663
- return { ...issue, stateId: backfilled };
4055
+ resolvedIssue = { ...issue, stateId: backfilled };
3664
4056
  }
3665
- return issue;
4057
+ this.#indexDependencyIssue(resolvedIssue);
4058
+ return resolvedIssue;
3666
4059
  }
3667
4060
  catch (error) {
3668
4061
  if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
@@ -4104,7 +4497,7 @@ export class FactoryLoop {
4104
4497
  let publishedPr;
4105
4498
  if (exiting?.spec.role === 'implementer' &&
4106
4499
  !record.dryRun &&
4107
- (this.#mount.githubWrite || this.#mount.writebackTransport === 'relayfile-cloud')) {
4500
+ this.#shouldAttemptPullRequestPublication()) {
4108
4501
  try {
4109
4502
  await this.#saveDispatchLifecycle(record, 'publishing');
4110
4503
  publishedPr = await this.#publishImplementerPullRequest(record, exiting);
@@ -4339,11 +4732,14 @@ export class FactoryLoop {
4339
4732
  }
4340
4733
  // Publish a PR from the implementer's committed branch when it exited without
4341
4734
  // opening one. Best-effort and idempotent: returns undefined when there is no
4342
- // GitHub write path, no clone, or nothing publishable (no branch / no commits
4343
- // ahead of base — `#publishImplementerPullRequest` refuses head==base), so the
4344
- // caller falls back to its normal restart/conclude handling.
4735
+ // clone or nothing publishable (no branch / no commits ahead of base —
4736
+ // `#publishImplementerPullRequest` refuses head==base), so the caller falls
4737
+ // back to its normal restart/conclude handling. Explicit app identity errors
4738
+ // remain fail-closed and propagate to the lifecycle error path.
4345
4739
  async #tryPublishImplementerPr(record, implementer) {
4346
- if (record.dryRun || (!implementer.spec.clonePath && !implementer.spec.branch) || !this.#mount.githubWrite) {
4740
+ if (record.dryRun ||
4741
+ (!implementer.spec.clonePath && !implementer.spec.branch) ||
4742
+ !this.#shouldAttemptPullRequestPublication()) {
4347
4743
  return undefined;
4348
4744
  }
4349
4745
  try {
@@ -4363,6 +4759,8 @@ export class FactoryLoop {
4363
4759
  return published;
4364
4760
  }
4365
4761
  catch (error) {
4762
+ if (this.#config.github.identity === 'app' && !this.#mount.githubWrite)
4763
+ throw error;
4366
4764
  this.#increment('exitPrPublishSkipped');
4367
4765
  this.#logger.warn?.('[factory] could not publish implementer PR on exit; falling back', {
4368
4766
  issue: record.issue.key,
@@ -4378,10 +4776,7 @@ export class FactoryLoop {
4378
4776
  const cached = this.#publishedPullRequests.get(key);
4379
4777
  if (cached)
4380
4778
  return cached;
4381
- const githubWrite = this.#mount.githubWrite;
4382
- if (!githubWrite) {
4383
- throw new Error('GitHub write path not available on this mount — connect GitHub to your workspace');
4384
- }
4779
+ const { identity, publisher } = this.#githubPullRequestPublisher();
4385
4780
  const remoteBranch = implementer.result?.locality === 'remote' && implementer.spec.branch
4386
4781
  ? implementer.spec.branch
4387
4782
  : undefined;
@@ -4419,32 +4814,67 @@ export class FactoryLoop {
4419
4814
  }
4420
4815
  }
4421
4816
  const baseRef = await this.#githubDefaultBranch(repo);
4422
- const result = await githubWrite.publishPullRequest({
4817
+ const result = await publisher.publishPullRequest({
4423
4818
  repo,
4424
4819
  ...(remoteBranch ? { headRef: remoteBranch } : { clonePath: implementer.spec.clonePath }),
4425
4820
  baseRef,
4426
4821
  title: `${issue.key}: ${issue.title}`,
4427
4822
  body: githubPullRequestBody(issue),
4428
4823
  });
4429
- if (result.repo.toLowerCase() !== repo.toLowerCase() ||
4430
- result.headRef !== (remoteBranch ?? result.headRef) ||
4431
- !Number.isInteger(result.number) ||
4432
- result.number <= 0 ||
4433
- !result.url) {
4824
+ const published = result.author
4825
+ ? result
4826
+ : { ...result, author: identity };
4827
+ if (published.repo.toLowerCase() !== repo.toLowerCase() ||
4828
+ published.headRef !== (remoteBranch ?? published.headRef) ||
4829
+ !Number.isInteger(published.number) ||
4830
+ published.number <= 0 ||
4831
+ !published.url) {
4434
4832
  throw new Error(`GitHub PR publication returned an unexpected receipt for ${repo}/${remoteBranch ?? 'local HEAD'}`);
4435
4833
  }
4436
4834
  if (remoteBranch && this.#mount.writebackTransport === 'relayfile-cloud') {
4437
- await this.#confirmPublishedRemotePullRequest(repo, result, remoteBranch);
4835
+ await this.#confirmPublishedRemotePullRequest(repo, published, remoteBranch);
4438
4836
  }
4439
- this.#publishedPullRequests.set(key, result);
4837
+ this.#publishedPullRequests.set(key, published);
4440
4838
  this.#increment('githubPullRequestsPublished');
4441
- this.#logger.info?.('[factory] published PR through workspace GitHub connection', {
4839
+ this.#logger.info?.('[factory] published PR', {
4442
4840
  issue: issue.key,
4443
- repo: result.repo,
4444
- prNumber: result.number,
4445
- url: result.url,
4841
+ repo: published.repo,
4842
+ prNumber: published.number,
4843
+ url: published.url,
4844
+ identity,
4845
+ author: published.author,
4446
4846
  });
4447
- return result;
4847
+ return published;
4848
+ }
4849
+ #githubPullRequestPublisher() {
4850
+ const configured = this.#config.github.identity;
4851
+ if (configured !== 'user' && this.#mount.githubWrite) {
4852
+ return { identity: 'app', publisher: this.#mount.githubWrite };
4853
+ }
4854
+ if (configured === 'app') {
4855
+ throw new Error('GitHub PR identity "app" requires a connected workspace GitHub App write path; refusing to fall back to the local gh user');
4856
+ }
4857
+ const publishPullRequest = this.#githubWriteback.publishPullRequest;
4858
+ if (!publishPullRequest) {
4859
+ throw new Error(`GitHub PR identity "${configured}" requires local gh user publication, but the configured GitHub writeback does not support it`);
4860
+ }
4861
+ return {
4862
+ identity: 'user',
4863
+ publisher: { publishPullRequest: publishPullRequest.bind(this.#githubWriteback) },
4864
+ };
4865
+ }
4866
+ #shouldAttemptPullRequestPublication() {
4867
+ if (this.#config.github.identity !== 'auto')
4868
+ return true;
4869
+ if (this.#mount.githubWrite)
4870
+ return true;
4871
+ // Fake mounts deliberately avoid invoking the host's real gh binary unless
4872
+ // a publisher was injected. Every production mount (cloud or custom) still
4873
+ // gets the configured auto fallback.
4874
+ if (this.#mount.writebackTransport === 'test') {
4875
+ return Boolean(this.#githubWritebackProvided && this.#githubWriteback.publishPullRequest);
4876
+ }
4877
+ return true;
4448
4878
  }
4449
4879
  async #openPullRequestByHead(repo, expectedHeadRef) {
4450
4880
  if (this.#hasProbePrGhRunner) {
@@ -6385,27 +6815,38 @@ export class FactoryLoop {
6385
6815
  const implementerNames = decision.implementers.map((implementer) => implementer.name);
6386
6816
  const reviewerName = decision.reviewer.name;
6387
6817
  const integrationInstructions = await this.#resolveIntegrationInstructions();
6388
- const render = (spec) => ({
6389
- ...spec,
6390
- task: renderAgentTask({
6818
+ const render = async (spec) => {
6819
+ const route = routeForSpec(decision, spec);
6820
+ const testGuidance = await resolveTestGuidance({
6821
+ repoPath: route.clonePath,
6391
6822
  issue: templateIssue,
6392
- route: routeForSpec(decision, spec),
6393
- role: spec.role,
6394
- config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
6395
- reviewerName,
6396
- implementerNames,
6397
- integrationsMountRoot: this.#integrationsMountRoot(),
6398
- integrationInstructions,
6399
- branchName: spec.branch ?? decision.implementers.find((candidate) => candidate.repo === spec.repo)?.branch,
6400
- branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
6401
- agentName: spec.name,
6402
- ...(this.#fleet.lifecycleActionName ? { lifecycleActionName: this.#fleet.lifecycleActionName } : {}),
6403
- }),
6404
- });
6823
+ route,
6824
+ previewUrl: previewUrlFromSpec(spec),
6825
+ });
6826
+ return {
6827
+ ...spec,
6828
+ task: renderAgentTask({
6829
+ issue: templateIssue,
6830
+ route,
6831
+ role: spec.role,
6832
+ config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
6833
+ reviewerName,
6834
+ implementerNames,
6835
+ integrationsMountRoot: this.#integrationsMountRoot(),
6836
+ integrationInstructions,
6837
+ testGuidance,
6838
+ branchName: spec.branch ?? decision.implementers.find((candidate) => candidate.repo === spec.repo)?.branch,
6839
+ branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
6840
+ agentName: spec.name,
6841
+ ...(this.#fleet.lifecycleActionName ? { lifecycleActionName: this.#fleet.lifecycleActionName } : {}),
6842
+ }),
6843
+ };
6844
+ };
6845
+ const rendered = await Promise.all([...decision.implementers, decision.reviewer].map(render));
6405
6846
  return {
6406
6847
  ...decision,
6407
- implementers: decision.implementers.map(render),
6408
- reviewer: render(decision.reviewer),
6848
+ implementers: rendered.slice(0, decision.implementers.length),
6849
+ reviewer: rendered[decision.implementers.length],
6409
6850
  };
6410
6851
  }
6411
6852
  #consumePendingDispatchClarifications(issue) {
@@ -7214,6 +7655,7 @@ export class FactoryLoop {
7214
7655
  await this.#recordCanonicalIssueState({ ...issueRef(issue), stateId: doneStateId });
7215
7656
  }
7216
7657
  this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
7658
+ await this.#markDependencyTerminalAndReconcile(issue);
7217
7659
  this.#increment('mergedPrAdvancedDone');
7218
7660
  this.#increment('done');
7219
7661
  this.#logger.info?.('[factory] merged PR advanced issue to Done', {
@@ -7367,6 +7809,7 @@ export class FactoryLoop {
7367
7809
  });
7368
7810
  this.#babysitterSpawned.add(babysitterKey);
7369
7811
  await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
7812
+ await this.#retargetSlackConversationToBabysitter(record);
7370
7813
  return;
7371
7814
  }
7372
7815
  // Reserve up-front so concurrent PR events in a drain don't double-spawn.
@@ -7413,9 +7856,19 @@ export class FactoryLoop {
7413
7856
  .filter((agent) => agent.spec.role === 'implementer')
7414
7857
  .map((agent) => agent.result?.name ?? agent.spec.name);
7415
7858
  const integrationInstructions = await this.#resolveIntegrationInstructions();
7859
+ const templateIssue = templateIssueFromRecord(record, issue);
7860
+ const prSummary = await this.#github.getPr(prRef.repo, prRef.prNumber).catch(() => undefined);
7861
+ const taskRoute = { ...(route ?? { repo: prRef.repo }), clonePath: spec.clonePath ?? route?.clonePath };
7862
+ const testGuidance = await resolveTestGuidance({
7863
+ repoPath: taskRoute.clonePath,
7864
+ issue: templateIssue,
7865
+ route: taskRoute,
7866
+ changedFiles: prSummary?.filesChanged,
7867
+ previewUrl: previewUrlFromSpec(spec),
7868
+ });
7416
7869
  const task = renderAgentTask({
7417
- issue: templateIssueFromRecord(record, issue),
7418
- route: { ...(route ?? { repo: prRef.repo }), clonePath: spec.clonePath },
7870
+ issue: templateIssue,
7871
+ route: taskRoute,
7419
7872
  role: 'babysitter',
7420
7873
  config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
7421
7874
  reviewerName,
@@ -7424,6 +7877,7 @@ export class FactoryLoop {
7424
7877
  slackDispatchThread: await this.#slackDispatchThreadFor(record),
7425
7878
  integrationsMountRoot: this.#integrationsMountRoot(),
7426
7879
  integrationInstructions,
7880
+ testGuidance,
7427
7881
  branchName: spec.branch,
7428
7882
  branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
7429
7883
  agentName: spec.name,
@@ -7442,6 +7896,7 @@ export class FactoryLoop {
7442
7896
  agentName: tracked?.result?.name ?? spawned.name,
7443
7897
  });
7444
7898
  await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
7899
+ await this.#retargetSlackConversationToBabysitter(record);
7445
7900
  await this.#writeInFlightRegistry();
7446
7901
  if (!await this.#saveDispatchLifecycle(record, 'running'))
7447
7902
  return;
@@ -7781,6 +8236,8 @@ export class FactoryLoop {
7781
8236
  await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
7782
8237
  }
7783
8238
  this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
8239
+ if (!humanReview)
8240
+ await this.#markDependencyTerminalAndReconcile(issue);
7784
8241
  }
7785
8242
  if (!await this.#saveDispatchLifecycle(record, 'writeback-applied'))
7786
8243
  return;
@@ -7940,8 +8397,8 @@ export class FactoryLoop {
7940
8397
  });
7941
8398
  this.#emit('error', { error, ...describeError(error), issue });
7942
8399
  }
7943
- #increment(name) {
7944
- this.#counters[name] = (this.#counters[name] ?? 0) + 1;
8400
+ #increment(name, amount = 1) {
8401
+ this.#counters[name] = (this.#counters[name] ?? 0) + amount;
7945
8402
  }
7946
8403
  #recordTriageEscalation(decision, reason) {
7947
8404
  this.#increment('triageEscalations');
@@ -8177,7 +8634,11 @@ export class FactoryLoop {
8177
8634
  // the reply watcher instead of returning early, otherwise human replies in
8178
8635
  // the existing thread are watched by nobody and silently dropped.
8179
8636
  if (existingThread) {
8180
- await this.#rearmSlackWatcher(record, existingThread);
8637
+ const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(existingThread));
8638
+ await this.#ensureSlackConversationSession(record, existingThread);
8639
+ await this.#rearmSlackWatcher(record, existingThread, {
8640
+ replayConversationReplies: Boolean(durableConversation),
8641
+ });
8181
8642
  }
8182
8643
  return;
8183
8644
  }
@@ -8207,9 +8668,223 @@ export class FactoryLoop {
8207
8668
  ].join('\n'),
8208
8669
  });
8209
8670
  await this.#state.setSlackThread(this.#workspaceId, issueKey(record.issue), root.threadId);
8671
+ await this.#ensureSlackConversationSession(record, root.threadId);
8210
8672
  await this.#watchSlackThread(record, root.threadId);
8211
8673
  this.#recordSlackWritebackSuccess('dispatch-thread');
8212
8674
  }
8675
+ // Once a PR is open, the babysitter is the one driving further conversation
8676
+ // with the human — prefer it over the implementer that originally opened the
8677
+ // Slack thread, matching the pre-conversation-resume routing that sent human
8678
+ // replies to both roles once a PR existed.
8679
+ #slackConversationOwnerCandidate(record) {
8680
+ const candidates = [...record.agents.entries()].map(([name, tracked]) => ({ name, tracked }));
8681
+ return candidates.find(({ tracked }) => tracked.spec.role === 'babysitter' && Boolean(tracked.sessionRef))
8682
+ ?? candidates.find(({ tracked }) => tracked.spec.role === 'implementer' && Boolean(tracked.sessionRef));
8683
+ }
8684
+ async #ensureSlackConversationSession(record, threadId, options = {}) {
8685
+ const conversationId = slackConversationId(threadId);
8686
+ const owned = this.#slackConversationOwnerCandidate(record);
8687
+ const existing = await this.#state.getConversationSession(this.#workspaceId, conversationId);
8688
+ if (existing) {
8689
+ const sessionRef = owned?.tracked.sessionRef;
8690
+ const agentName = owned ? (owned.tracked.result?.name ?? owned.name) : undefined;
8691
+ if (owned && sessionRef &&
8692
+ (agentName !== existing.agent.name || (options.forceAgentRebind === true && sessionRef !== existing.agent.sessionRef))) {
8693
+ const rebound = await this.#state.rebindConversationSession(this.#workspaceId, conversationId, {
8694
+ name: agentName,
8695
+ sessionRef,
8696
+ node: owned.tracked.result?.node ?? owned.tracked.spec.node,
8697
+ capability: owned.tracked.spec.capability,
8698
+ repo: owned.tracked.spec.repo,
8699
+ clonePath: owned.tracked.spec.clonePath,
8700
+ });
8701
+ if (rebound)
8702
+ this.#increment('slackConversationSessionsRebound');
8703
+ }
8704
+ if (existing.pending.length > 0 || existing.delivery) {
8705
+ const waiting = await this.#state.getWaitingClarification(this.#workspaceId, issueKey(existing.issue));
8706
+ if (!waiting)
8707
+ this.#slackConversationTurns.schedule(conversationId);
8708
+ }
8709
+ return;
8710
+ }
8711
+ const sessionRef = owned?.tracked.sessionRef;
8712
+ if (!owned || !sessionRef) {
8713
+ this.#increment('slackConversationSessionsSkippedMissingSession');
8714
+ return;
8715
+ }
8716
+ const channelDir = await this.#slackChannelDir() ?? this.#config.slack?.channel;
8717
+ if (!channelDir)
8718
+ return;
8719
+ const agentName = owned.tracked.result?.name ?? owned.name;
8720
+ const reserved = await this.#state.reserveConversationSession(this.#workspaceId, conversationId, {
8721
+ provider: 'slack',
8722
+ issue: { ...record.issue },
8723
+ externalId: threadId,
8724
+ context: { channelDir },
8725
+ agent: {
8726
+ name: agentName,
8727
+ sessionRef,
8728
+ node: owned.tracked.result?.node ?? owned.tracked.spec.node,
8729
+ capability: owned.tracked.spec.capability,
8730
+ repo: owned.tracked.spec.repo,
8731
+ clonePath: owned.tracked.spec.clonePath,
8732
+ },
8733
+ history: [],
8734
+ processedMessageIds: [],
8735
+ pending: [],
8736
+ });
8737
+ if (reserved)
8738
+ this.#increment('slackConversationSessionsOwned');
8739
+ }
8740
+ // Called right after a babysitter is spawned/reattached for an issue's PR so
8741
+ // an already-owned Slack conversation session (reserved earlier by the
8742
+ // implementer) retargets onto the babysitter for the next turn, instead of
8743
+ // resuming a session the babysitter has taken over from.
8744
+ async #retargetSlackConversationToBabysitter(record) {
8745
+ if (!this.#slack || !this.#config.slack)
8746
+ return;
8747
+ const threadId = await this.#persistedSlackThread(issueKey(record.issue));
8748
+ if (!threadId)
8749
+ return;
8750
+ await this.#ensureSlackConversationSession(record, threadId, { forceAgentRebind: true });
8751
+ }
8752
+ async #scheduleSlackConversationIfPending(threadId) {
8753
+ const conversationId = slackConversationId(threadId);
8754
+ const session = await this.#state.getConversationSession(this.#workspaceId, conversationId);
8755
+ if (session && (session.pending.length > 0 || session.delivery)) {
8756
+ this.#slackConversationTurns.schedule(conversationId);
8757
+ }
8758
+ }
8759
+ async #resumeSlackConversationTurn(conversationId) {
8760
+ if (this.#stopping)
8761
+ return;
8762
+ const claimId = randomUUID();
8763
+ const claimed = await this.#state.claimConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now(), SLACK_CONVERSATION_TURN_LEASE_MS);
8764
+ if (!claimed?.delivery) {
8765
+ const current = await this.#state.getConversationSession(this.#workspaceId, conversationId);
8766
+ if (current && (current.pending.length > 0 || current.delivery)) {
8767
+ this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
8768
+ }
8769
+ return;
8770
+ }
8771
+ if (!await this.#ownsActiveSlackConversationIssue(claimed.issue)) {
8772
+ await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
8773
+ this.#increment('slackConversationTurnsSuppressedStaleOwner');
8774
+ return;
8775
+ }
8776
+ let leaseLost = false;
8777
+ let renewalInFlight = false;
8778
+ const heartbeat = setInterval(() => {
8779
+ if (renewalInFlight || leaseLost || this.#stopping)
8780
+ return;
8781
+ renewalInFlight = true;
8782
+ void this.#state.renewConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now()).then((renewed) => { leaseLost = !renewed; })
8783
+ .catch((error) => this.#logger.warn?.('[factory] Slack conversation lease renewal failed', {
8784
+ conversationId,
8785
+ error: describeError(error).errorMessage,
8786
+ }))
8787
+ .finally(() => { renewalInFlight = false; });
8788
+ }, Math.max(1_000, Math.floor(SLACK_CONVERSATION_TURN_LEASE_MS / 3)));
8789
+ heartbeat.unref?.();
8790
+ try {
8791
+ const result = await this.#fleet.resume({
8792
+ name: claimed.agent.name,
8793
+ sessionRef: claimed.agent.sessionRef,
8794
+ node: claimed.agent.node ?? 'self',
8795
+ capability: claimed.agent.capability,
8796
+ repo: claimed.agent.repo,
8797
+ clonePath: claimed.agent.clonePath,
8798
+ task: slackConversationResumeTask(claimed),
8799
+ });
8800
+ const completed = await this.#state.completeConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, { name: result.name, sessionRef: result.sessionRef });
8801
+ // The token-fenced state mutation is authoritative. A renewal already
8802
+ // queued behind this completion can observe the cleared delivery and set
8803
+ // leaseLost even though completion committed successfully.
8804
+ if (!completed) {
8805
+ this.#increment('slackConversationTurnOwnershipLost');
8806
+ return;
8807
+ }
8808
+ await this.#recordSlackConversationResume(claimed, result);
8809
+ this.#increment('slackConversationTurnsResumed');
8810
+ if (claimed.delivery.messages.length > 1) {
8811
+ this.#increment('slackConversationRepliesCoalesced', claimed.delivery.messages.length - 1);
8812
+ }
8813
+ }
8814
+ catch (error) {
8815
+ try {
8816
+ await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
8817
+ }
8818
+ catch (releaseError) {
8819
+ this.#logger.warn?.('[factory] Slack conversation claim release failed; waiting for lease recovery', {
8820
+ conversationId,
8821
+ error: describeError(releaseError).errorMessage,
8822
+ });
8823
+ }
8824
+ this.#increment('slackConversationTurnResumeFailures');
8825
+ this.#logger.warn?.('[factory] Slack conversation resume failed; retaining the turn for retry', {
8826
+ issue: claimed.issue.key,
8827
+ threadId: claimed.externalId,
8828
+ sessionRef: claimed.agent.sessionRef,
8829
+ error: describeError(error).errorMessage,
8830
+ });
8831
+ this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
8832
+ return;
8833
+ }
8834
+ finally {
8835
+ clearInterval(heartbeat);
8836
+ }
8837
+ const remaining = await this.#state.getConversationSession(this.#workspaceId, conversationId);
8838
+ if (remaining && (remaining.pending.length > 0 || remaining.delivery)) {
8839
+ this.#slackConversationTurns.schedule(conversationId);
8840
+ }
8841
+ }
8842
+ async #recordSlackConversationResume(session, result) {
8843
+ const record = (await this.#batch()).getIssue(session.issue);
8844
+ if (!record)
8845
+ return;
8846
+ const entry = [...record.agents.entries()].find(([name, tracked]) => name === session.agent.name || tracked.result?.name === session.agent.name);
8847
+ if (!entry)
8848
+ return;
8849
+ const [previousName, tracked] = entry;
8850
+ tracked.result = {
8851
+ ...tracked.result,
8852
+ ...result,
8853
+ node: result.node ?? tracked.result?.node,
8854
+ locality: result.locality ?? tracked.result?.locality,
8855
+ };
8856
+ tracked.sessionRef = result.sessionRef ?? tracked.sessionRef;
8857
+ if (result.name !== previousName) {
8858
+ record.agents.delete(previousName);
8859
+ record.agents.set(result.name, tracked);
8860
+ }
8861
+ try {
8862
+ await this.#writeInFlightRegistry();
8863
+ await this.#saveDispatchLifecycle(record, 'running');
8864
+ }
8865
+ catch (error) {
8866
+ this.#logger.warn?.('[factory] Slack conversation resume bookkeeping failed', {
8867
+ issue: session.issue.key,
8868
+ agent: result.name,
8869
+ error: describeError(error).errorMessage,
8870
+ });
8871
+ }
8872
+ }
8873
+ async #ownsActiveSlackConversationIssue(issue) {
8874
+ if (!(await this.#batch()).getIssue(issue))
8875
+ return false;
8876
+ const key = issueKey(issue);
8877
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
8878
+ if (!lifecycle)
8879
+ return true;
8880
+ if (lifecycle.phase === 'releasing' || isTerminalDispatchLifecycle(lifecycle))
8881
+ return false;
8882
+ if (!lifecycle.lease)
8883
+ return false;
8884
+ return lifecycle.lease.owner === this.#dispatchLifecycleOwner &&
8885
+ lifecycle.lease.epoch === this.#dispatchLifecycleEpochs.get(key) &&
8886
+ lifecycle.lease.leaseUntilMs > this.#clock.now();
8887
+ }
8213
8888
  async #escalateTriage(decision, reason, dryRun) {
8214
8889
  if (dryRun) {
8215
8890
  return;
@@ -8494,7 +9169,7 @@ export class FactoryLoop {
8494
9169
  this.#recordSlackWritebackSuccess('triage-escalation');
8495
9170
  return replayedResult;
8496
9171
  }
8497
- async #watchSlackThread(record, threadId) {
9172
+ async #watchSlackThread(record, threadId, options = {}) {
8498
9173
  if (!this.#config.slack) {
8499
9174
  return;
8500
9175
  }
@@ -8506,12 +9181,14 @@ export class FactoryLoop {
8506
9181
  const messagesPrefix = slackChannelMessagesPrefix(channelDir);
8507
9182
  const preExistingPaths = new Set();
8508
9183
  const preExistingPathOrder = [];
9184
+ const preExistingEvents = [];
8509
9185
  const seenReplies = new Set();
8510
9186
  const seenReplyMessages = new Set();
8511
9187
  let missingIdentityLogged = false;
8512
9188
  let cursor;
8513
9189
  let stopped = false;
8514
9190
  let pollTimer;
9191
+ const routeRetryTimers = new Map();
8515
9192
  const markPreExisting = async () => {
8516
9193
  try {
8517
9194
  const page = await this.#mount.getEvents({ limit: SLACK_REPLY_EVENTS_LIMIT });
@@ -8521,6 +9198,7 @@ export class FactoryLoop {
8521
9198
  if (path && path.startsWith(messagesPrefix)) {
8522
9199
  preExistingPaths.add(path);
8523
9200
  preExistingPathOrder.push(path);
9201
+ preExistingEvents.push(event);
8524
9202
  }
8525
9203
  }
8526
9204
  }
@@ -8528,7 +9206,8 @@ export class FactoryLoop {
8528
9206
  this.#logger.warn?.('[factory] unable to seed Slack reply watcher event cursor', error);
8529
9207
  }
8530
9208
  };
8531
- const handle = async (event) => {
9209
+ const handle = async (event, allowPreExisting = false) => {
9210
+ const routeRetryKey = eventIdentity(event) ?? changeEventPath(event) ?? randomUUID();
8532
9211
  try {
8533
9212
  // Polling-fallback / degraded-sync events can lack `resource.path`
8534
9213
  // despite the SDK type. Skip them quietly so one malformed event never
@@ -8544,7 +9223,7 @@ export class FactoryLoop {
8544
9223
  this.#logger.warn?.('[factory] Slack reply event missing stable identity; falling back to path/content dedupe');
8545
9224
  }
8546
9225
  }
8547
- if (preExistingPaths.has(path)) {
9226
+ if (!allowPreExisting && preExistingPaths.has(path)) {
8548
9227
  return;
8549
9228
  }
8550
9229
  const reply = await this.#readSlackReply(path);
@@ -8561,21 +9240,34 @@ export class FactoryLoop {
8561
9240
  this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path });
8562
9241
  return;
8563
9242
  }
8564
- seenReplies.add(replyKey);
8565
9243
  if (reply.isBot) {
9244
+ seenReplies.add(replyKey);
9245
+ seenReplyMessages.add(replyMessageKey);
8566
9246
  return;
8567
9247
  }
8568
- seenReplyMessages.add(replyMessageKey);
8569
9248
  await this.#routeSlackAnswerToImplementers(record, reply);
9249
+ // Acknowledge in memory only after the durable route succeeds. A read or
9250
+ // state-store failure must remain replayable by the poll/subscription.
9251
+ seenReplies.add(replyKey);
9252
+ seenReplyMessages.add(replyMessageKey);
8570
9253
  }
8571
9254
  catch (error) {
8572
9255
  this.#logger.error?.('[factory] failed to handle Slack reply event', error);
9256
+ if (!stopped && !routeRetryTimers.has(routeRetryKey)) {
9257
+ const retryTimer = setTimeout(() => {
9258
+ routeRetryTimers.delete(routeRetryKey);
9259
+ if (!stopped)
9260
+ void handle(event, true);
9261
+ }, SLACK_REPLY_ROUTE_RETRY_MS);
9262
+ retryTimer.unref?.();
9263
+ routeRetryTimers.set(routeRetryKey, retryTimer);
9264
+ }
8573
9265
  }
8574
9266
  };
8575
9267
  await markPreExisting();
8576
9268
  let subscription;
8577
9269
  try {
8578
- subscription = this.#mount.subscribe([`${messagesPrefix}**`], (event) => {
9270
+ subscription = this.#mount.subscribe([slackThreadReplyGlob(channelDir)], (event) => {
8579
9271
  // Receipt time is independent of the provider-authored sync timestamp.
8580
9272
  // A healthy webhook can therefore override a frozen advisory status
8581
9273
  // without trusting the same field that declared the provider stale.
@@ -8615,9 +9307,16 @@ export class FactoryLoop {
8615
9307
  clearTimeout(pollTimer);
8616
9308
  pollTimer = undefined;
8617
9309
  }
9310
+ for (const retryTimer of routeRetryTimers.values())
9311
+ clearTimeout(retryTimer);
9312
+ routeRetryTimers.clear();
8618
9313
  await this.#boundedStopTeardown('Slack reply subscription unsubscribe', () => subscription?.unsubscribe());
8619
9314
  },
8620
9315
  });
9316
+ if (options.replayConversationReplies) {
9317
+ for (const event of preExistingEvents)
9318
+ await handle(event, true);
9319
+ }
8621
9320
  return await this.#replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPathOrder);
8622
9321
  }
8623
9322
  async #replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPaths) {
@@ -8646,13 +9345,13 @@ export class FactoryLoop {
8646
9345
  // exists (persisted thread id) but has no in-process watcher. #watchSlackThread
8647
9346
  // is itself idempotent on #slackWatchers; the guard here keeps the counter (and
8648
9347
  // the seeding getEvents call) limited to genuine re-arms.
8649
- async #rearmSlackWatcher(record, threadId) {
9348
+ async #rearmSlackWatcher(record, threadId, options = {}) {
8650
9349
  const key = issueKey(record.issue);
8651
9350
  if (this.#slackWatchers.has(key) || this.#slackWatcherStarts.has(key)) {
8652
9351
  return;
8653
9352
  }
8654
9353
  try {
8655
- await this.#watchSlackThread(record, threadId);
9354
+ await this.#watchSlackThread(record, threadId, options);
8656
9355
  this.#increment('slackWatchersRearmed');
8657
9356
  }
8658
9357
  catch (error) {
@@ -8667,7 +9366,8 @@ export class FactoryLoop {
8667
9366
  if (!this.#slack || !this.#config.slack) {
8668
9367
  return;
8669
9368
  }
8670
- for (const record of (await this.#batch()).inFlight) {
9369
+ const batch = await this.#batch();
9370
+ for (const record of batch.inFlight) {
8671
9371
  if (record.dryRun) {
8672
9372
  continue;
8673
9373
  }
@@ -8686,7 +9386,27 @@ export class FactoryLoop {
8686
9386
  if (!threadId) {
8687
9387
  continue;
8688
9388
  }
8689
- await this.#rearmSlackWatcher(record, threadId);
9389
+ const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(threadId));
9390
+ await this.#ensureSlackConversationSession(record, threadId);
9391
+ await this.#rearmSlackWatcher(record, threadId, {
9392
+ replayConversationReplies: Boolean(durableConversation),
9393
+ });
9394
+ }
9395
+ for (const [conversationId, session] of await this.#state.listConversationSessions(this.#workspaceId)) {
9396
+ if (session.provider !== 'slack')
9397
+ continue;
9398
+ const threadId = session.externalId;
9399
+ const record = batch.getIssue(session.issue);
9400
+ const key = issueKey(session.issue);
9401
+ if (record && !record.dryRun && !this.#slackWatchers.has(key) && !this.#slackWatcherStarts.has(key)) {
9402
+ await this.#state.setSlackThread(this.#workspaceId, key, threadId);
9403
+ await this.#ensureSlackConversationSession(record, threadId);
9404
+ await this.#rearmSlackWatcher(record, threadId, { replayConversationReplies: true });
9405
+ }
9406
+ const waiting = await this.#state.getWaitingClarification(this.#workspaceId, key);
9407
+ if (record && !waiting && (session.pending.length > 0 || session.delivery)) {
9408
+ this.#slackConversationTurns.schedule(conversationId);
9409
+ }
8690
9410
  }
8691
9411
  await this.#sweepWaitingClarifications();
8692
9412
  for (const [, waiting] of await this.#state.listWaitingClarifications(this.#workspaceId)) {
@@ -8833,13 +9553,19 @@ export class FactoryLoop {
8833
9553
  const key = issueKey(issue);
8834
9554
  const watcher = this.#slackWatchers.get(key);
8835
9555
  this.#slackWatchers.delete(key);
8836
- await this.#state.clearSlackThread(this.#workspaceId, key);
9556
+ const threadId = await this.#state.getSlackThread(this.#workspaceId, key);
8837
9557
  await watcher?.stop();
9558
+ if (threadId) {
9559
+ const conversationId = slackConversationId(threadId);
9560
+ await this.#slackConversationTurns.cancel(conversationId);
9561
+ await this.#state.clearConversationSession(this.#workspaceId, conversationId);
9562
+ }
9563
+ await this.#state.clearSlackThread(this.#workspaceId, key);
8838
9564
  }
8839
9565
  async #readSlackReply(path) {
8840
9566
  try {
8841
9567
  const { content } = await this.#mount.readFile(path);
8842
- return parseSlackReply(path, content, this.#config.slack?.botUserId ?? 'U0B2596R7EZ');
9568
+ return parseSlackThreadReply(path, content, this.#config.slack?.botUserId ?? 'U0B2596R7EZ');
8843
9569
  }
8844
9570
  catch (error) {
8845
9571
  this.#logger.warn?.(`Unable to read Slack reply ${path}`, error);
@@ -8862,20 +9588,61 @@ export class FactoryLoop {
8862
9588
  return;
8863
9589
  }
8864
9590
  if (waiting?.threadId === reply.threadTs) {
9591
+ const replyId = `${reply.threadTs}:${reply.messageTs}`;
8865
9592
  const claimed = await this.#state.claimClarificationReply(this.#workspaceId, clarificationKey, {
8866
- id: `${reply.threadTs}:${reply.messageTs}`,
9593
+ id: replyId,
8867
9594
  text,
8868
- receivedAtMs: this.#clock.now(),
9595
+ receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
8869
9596
  source: 'slack',
9597
+ author: reply.author,
8870
9598
  });
8871
9599
  if (!claimed) {
8872
- this.#increment('clarificationDuplicateWakesSuppressed');
9600
+ const currentWaiting = await this.#state.getWaitingClarification(this.#workspaceId, clarificationKey);
9601
+ if (currentWaiting?.reply?.id === replyId) {
9602
+ this.#increment('clarificationDuplicateWakesSuppressed');
9603
+ return;
9604
+ }
9605
+ // The first reply wakes the parked clarification team. Additional
9606
+ // rapid-fire replies are ordinary conversation turns and must not be
9607
+ // discarded merely because the one-shot clarification slot is full.
9608
+ const conversationId = slackConversationId(reply.threadTs);
9609
+ const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
9610
+ id: replyId,
9611
+ text,
9612
+ receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
9613
+ providerSequence: reply.messageTs,
9614
+ author: reply.author,
9615
+ });
9616
+ if (queued) {
9617
+ this.#increment('slackConversationRepliesQueued');
9618
+ }
9619
+ else {
9620
+ this.#increment('slackConversationDuplicateRepliesSuppressed');
9621
+ }
8873
9622
  return;
8874
9623
  }
8875
9624
  this.#increment('clarificationRepliesClaimed');
8876
9625
  await this.#wakeWaitingClarification(clarificationKey, claimed);
8877
9626
  return;
8878
9627
  }
9628
+ const conversationId = slackConversationId(reply.threadTs);
9629
+ const conversation = await this.#state.getConversationSession(this.#workspaceId, conversationId);
9630
+ if (conversation && issueKey(conversation.issue) === clarificationKey) {
9631
+ const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
9632
+ id: `${reply.threadTs}:${reply.messageTs}`,
9633
+ text,
9634
+ receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
9635
+ providerSequence: reply.messageTs,
9636
+ author: reply.author,
9637
+ });
9638
+ if (!queued) {
9639
+ this.#increment('slackConversationDuplicateRepliesSuppressed');
9640
+ return;
9641
+ }
9642
+ this.#increment('slackConversationRepliesQueued');
9643
+ this.#slackConversationTurns.schedule(conversationId);
9644
+ return;
9645
+ }
8879
9646
  const liveRecord = (await this.#batch()).getIssue(record.issue);
8880
9647
  if (!liveRecord || liveRecord.dryRun) {
8881
9648
  if (isTriageEscalationWatchRecord(record)) {
@@ -8884,23 +9651,7 @@ export class FactoryLoop {
8884
9651
  this.#increment('slackAnswersIgnoredNoInFlight');
8885
9652
  return;
8886
9653
  }
8887
- if (!this.#fleet.sendInput) {
8888
- return;
8889
- }
8890
- // Route human replies to the implementers AND the babysitter — once a PR is
8891
- // open the babysitter is the one chatting with the human about it.
8892
- const recipients = [...liveRecord.agents.values()]
8893
- .filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
8894
- .map((agent) => agent.result?.name ?? agent.spec.name)
8895
- .filter((name) => Boolean(name));
8896
- if (recipients.length === 0) {
8897
- this.#increment('slackAnswersIgnoredNoImplementer');
8898
- return;
8899
- }
8900
- for (const recipient of new Set(recipients)) {
8901
- await this.#injectSlackReplyEvent(recipient, liveRecord.issue, text);
8902
- this.#increment('slackAnswersInjected');
8903
- }
9654
+ this.#increment('slackAnswersIgnoredNoConversationSession');
8904
9655
  }
8905
9656
  async #wakeWaitingClarification(key, waiting) {
8906
9657
  const existing = this.#clarificationWakeInFlight.get(key);
@@ -9056,10 +9807,15 @@ export class FactoryLoop {
9056
9807
  await renewLease();
9057
9808
  await this.#writeInFlightRegistry();
9058
9809
  await this.#saveDispatchLifecycle(record, 'running');
9810
+ if (waiting.threadId) {
9811
+ await this.#ensureSlackConversationSession(record, waiting.threadId, { forceAgentRebind: true });
9812
+ }
9059
9813
  await renewLease();
9060
9814
  const completed = await this.#state.completeClarificationWake(this.#workspaceId, key, this.#clarificationWakeOwner);
9061
9815
  if (!completed)
9062
9816
  throw new ClarificationWakeLeaseLostError('clarification wake completion lost ownership');
9817
+ if (waiting.threadId)
9818
+ await this.#scheduleSlackConversationIfPending(waiting.threadId);
9063
9819
  this.#increment('clarificationTeamsWoken');
9064
9820
  this.#logger.info?.('[factory] restarted team after human clarification', {
9065
9821
  issue: waiting.issue.key,
@@ -9299,14 +10055,6 @@ export class FactoryLoop {
9299
10055
  this.#emit('issue-queued', { issue: decision.issue });
9300
10056
  }
9301
10057
  }
9302
- // Route ordinary Slack conversation into a live agent framed as the
9303
- // <integration-event> the spawn prompt tells it to expect (not an ambiguous
9304
- // "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
9305
- // event. (A broker confirmed-delivery path via waitForInjected is a possible
9306
- // robustness follow-up.)
9307
- async #injectSlackReplyEvent(recipient, issue, text) {
9308
- await this.#fleet.sendInput?.(recipient, slackReplyEvent(issue, text));
9309
- }
9310
10058
  // Absolute path to the local .integrations mount the daemon manages. The mount
9311
10059
  // is created at the daemon's cwd (see ensureLocalMount), and spawned agents run
9312
10060
  // in their repo clonePath, so writeback paths handed to agents must be absolute
@@ -9706,6 +10454,57 @@ const githubIssueSourceRef = (issue) => {
9706
10454
  }
9707
10455
  return { owner, repo, number: number, url };
9708
10456
  };
10457
+ function dependencyRepoForIssue(issue, decision, config) {
10458
+ const source = githubIssueSourceRef(issue);
10459
+ if (source)
10460
+ return `${source.owner}/${source.repo}`;
10461
+ const mirroredRepo = githubMirrorRepoForIssue(issue);
10462
+ if (mirroredRepo)
10463
+ return mirroredRepo;
10464
+ const normalize = (repo) => {
10465
+ if (!repo)
10466
+ return undefined;
10467
+ try {
10468
+ return normalizeGithubRepo(repo, config.repos.org);
10469
+ }
10470
+ catch {
10471
+ return undefined;
10472
+ }
10473
+ };
10474
+ const decisionRepos = new Set((decision?.routes ?? [])
10475
+ .map((route) => normalize(route.repo))
10476
+ .filter((repo) => Boolean(repo)));
10477
+ if (decisionRepos.size === 1)
10478
+ return [...decisionRepos][0];
10479
+ if (decisionRepos.size > 1)
10480
+ return undefined;
10481
+ const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
10482
+ const labelRepos = new Set(Object.entries(config.repos.byLabel)
10483
+ .filter(([label]) => labels.has(label.trim().toLowerCase()))
10484
+ .map(([, repo]) => normalize(repo))
10485
+ .filter((repo) => Boolean(repo)));
10486
+ if (labelRepos.size === 1)
10487
+ return [...labelRepos][0];
10488
+ if (labelRepos.size > 1)
10489
+ return undefined;
10490
+ const projectRepo = issue.project
10491
+ ? Object.entries(config.repos.byProject)
10492
+ .find(([project]) => project.trim().toLowerCase() === issue.project.trim().toLowerCase())?.[1]
10493
+ : undefined;
10494
+ return normalize(projectRepo) ?? normalize(config.repos.default);
10495
+ }
10496
+ function dependencyIdentityForIssue(issue, repo) {
10497
+ const source = githubIssueSourceRef(issue);
10498
+ if (source)
10499
+ return dependencyIdentity(`${source.owner}/${source.repo}`, source.number);
10500
+ const pathParts = githubIssuePathParts(issue.path);
10501
+ if (pathParts)
10502
+ return dependencyIdentity(`${pathParts.owner}/${pathParts.repo}`, pathParts.number);
10503
+ const number = Number(issue.key.match(/(\d+)$/u)?.[1]);
10504
+ return repo && Number.isSafeInteger(number) && number > 0
10505
+ ? dependencyIdentity(repo, number)
10506
+ : undefined;
10507
+ }
9709
10508
  const githubIssueAuthor = (issue) => {
9710
10509
  const payload = wrappedPayload(issue.raw);
9711
10510
  const source = asRecord(payload.source);
@@ -10107,10 +10906,14 @@ const routeForSpec = (decision, spec) => {
10107
10906
  const route = decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? decision.routes.find((candidate) => candidate.repo === spec.repo);
10108
10907
  return {
10109
10908
  repo: spec.repo,
10110
- clonePath: spec.clonePath,
10909
+ clonePath: spec.clonePath ?? route?.clonePath,
10111
10910
  rationale: route?.rationale,
10112
10911
  };
10113
10912
  };
10913
+ const previewUrlFromSpec = (spec) => {
10914
+ const previewUrl = spec.previewUrl;
10915
+ return typeof previewUrl === 'string' && previewUrl.trim() ? previewUrl.trim() : undefined;
10916
+ };
10114
10917
  const repoMapFromConfig = (config) => {
10115
10918
  const repos = new Set([
10116
10919
  ...Object.values(config.repos.byLabel),
@@ -10237,24 +11040,42 @@ const normalizeGithubIssueCommentWatch = (watch) => ({
10237
11040
  processedCommentIds: [...new Set(watch.processedCommentIds ?? [])],
10238
11041
  });
10239
11042
  const githubIssueReadCandidatePaths = (path) => {
11043
+ const candidates = [];
10240
11044
  if (path.endsWith('/')) {
10241
- return [`${path}meta.json`, `${path}metadata.json`];
11045
+ candidates.push(`${path}meta.json`, `${path}metadata.json`);
10242
11046
  }
10243
- if (path.endsWith('/meta.json')) {
10244
- return [path, path.replace(/\/meta\.json$/u, '/metadata.json')];
11047
+ else if (path.endsWith('/meta.json')) {
11048
+ candidates.push(path, path.replace(/\/meta\.json$/u, '/metadata.json'));
10245
11049
  }
10246
- if (path.endsWith('/metadata.json')) {
10247
- return [path, path.replace(/\/metadata\.json$/u, '/meta.json')];
11050
+ else if (path.endsWith('/metadata.json')) {
11051
+ candidates.push(path, path.replace(/\/metadata\.json$/u, '/meta.json'));
10248
11052
  }
10249
- if (githubIssuePathParts(path)) {
10250
- return [path];
11053
+ else if (githubIssuePathParts(path)) {
11054
+ candidates.push(path);
10251
11055
  }
10252
- if (githubIssueDirectoryPathParts(path)) {
11056
+ else if (githubIssueDirectoryPathParts(path)) {
10253
11057
  // meta.json is the canonical relayfile GitHub issue basename. metadata.json
10254
11058
  // remains a legacy read fallback for older local mount-state snapshots.
10255
- return [`${path}/meta.json`, `${path}/metadata.json`];
11059
+ candidates.push(`${path}/meta.json`, `${path}/metadata.json`);
10256
11060
  }
10257
- return [path];
11061
+ else {
11062
+ candidates.push(path);
11063
+ }
11064
+ // Title-derived directories are mutable aliases. A renamed issue may leave
11065
+ // a durable lifecycle pointing at its old slug, while the adapter continues
11066
+ // to expose the stable public by-id aliases. Always include both supported
11067
+ // repository layouts so recovery never hot-loops on an obsolete title path.
11068
+ const parts = githubIssuePathParts(path) ?? githubIssueDirectoryPathParts(path);
11069
+ if (parts) {
11070
+ candidates.push(`${GITHUB_ISSUE_ROOT}/${parts.owner}__${parts.repo}/issues/by-id/${parts.number}.json`, `${GITHUB_ISSUE_ROOT}/${parts.owner}/${parts.repo}/issues/by-id/${parts.number}.json`);
11071
+ }
11072
+ return [...new Set(candidates)];
11073
+ };
11074
+ const compareQueuedGithubLifecycleAliases = (left, right) => {
11075
+ const stablePath = (lifecycle) => lifecycle.issue.path.includes('/issues/by-id/') ? 0 : 1;
11076
+ return stablePath(left[1]) - stablePath(right[1]) ||
11077
+ (right[1].updatedAtMs ?? 0) - (left[1].updatedAtMs ?? 0) ||
11078
+ left[0].localeCompare(right[0]);
10258
11079
  };
10259
11080
  const githubIssueDirectoryPathParts = (path) => {
10260
11081
  const match = path.match(/^\/github\/repos\/(?:([^/]+)\/([^/]+)|([A-Za-z0-9-]+)__([^/]+))\/issues\/(\d+)(?:__([^/]+))?$/u);
@@ -10324,7 +11145,7 @@ const linearIssueMirrorsGithubIssue = (issue, ghIssue) => {
10324
11145
  const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
10325
11146
  const candidates = [];
10326
11147
  const listErrors = [];
10327
- for (const repo of reposFromConfig(config)) {
11148
+ for (const repo of opts.repo ? [opts.repo] : reposFromConfig(config)) {
10328
11149
  const paths = new Set();
10329
11150
  for (const root of githubPullRoots(repo)) {
10330
11151
  try {
@@ -10549,7 +11370,7 @@ const readProbePrCandidate = async (mount, path) => {
10549
11370
  booleanValue(payload.crossRepository) ??
10550
11371
  (headRepo && baseRepo ? headRepo.toLowerCase() !== baseRepo.toLowerCase() : undefined),
10551
11372
  draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
10552
- state: stringValue(payload.state),
11373
+ state: booleanValue(payload.merged) === true ? 'MERGED' : stringValue(payload.state),
10553
11374
  url: stringValue(payload.url) ?? stringValue(payload.html_url),
10554
11375
  };
10555
11376
  }
@@ -11244,8 +12065,12 @@ const defaultRestartPolicy = (spec) =>
11244
12065
  spec.role === 'implementer' || spec.role === 'reviewer' || spec.role === 'babysitter'
11245
12066
  ? { maxRestarts: 0 }
11246
12067
  : spec.restartPolicy;
11247
- const slackPayloadTs = (threadId) => threadId.replace(/_/g, '.');
11248
12068
  const slackChannelMessagesPrefix = (channelDir) => `/slack/channels/${channelDir}/messages/`;
12069
+ const slackConversationId = (threadId) => `slack:${threadId}`;
12070
+ const slackMessageReceivedAtMs = (messageTs, fallback) => {
12071
+ const seconds = Number(messageTs);
12072
+ return Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds * 1_000) : fallback;
12073
+ };
11249
12074
  const eventIdentity = (event) => {
11250
12075
  const record = event;
11251
12076
  const rawId = record.id ?? record.event_id ?? record.seq;
@@ -11475,28 +12300,6 @@ const dispatchResultFromLifecycle = (lifecycle) => lifecycle.result ? structured
11475
12300
  agents: lifecycle.agents.map(({ name, tracked }) => ({ name, role: tracked.spec.role })),
11476
12301
  dryRun: lifecycle.dryRun,
11477
12302
  };
11478
- const parseSlackReply = (path, content, botUserId) => {
11479
- const raw = asRecord(parseJsonContent(content)) ?? {};
11480
- const payload = wrappedPayload(raw);
11481
- const channelDir = path.match(/^\/slack\/channels\/([^/]+)\//u)?.[1] ?? '';
11482
- const pathMatch = path.match(/^\/slack\/channels\/[^/]+\/messages\/([^/]+)(?:\/replies\/([^/]+))?/u);
11483
- const parentFromPath = pathMatch?.[2] ? slackPayloadTs(pathMatch[1]) : undefined;
11484
- const messageFromPath = slackPayloadTs(pathMatch?.[2] ?? pathMatch?.[1] ?? '');
11485
- const messageTs = stringValue(payload.ts) ?? messageFromPath;
11486
- const threadTs = stringValue(payload.thread_ts) ?? parentFromPath;
11487
- if (!channelDir || !threadTs || !messageTs) {
11488
- return undefined;
11489
- }
11490
- return {
11491
- channelDir,
11492
- threadTs,
11493
- messageTs,
11494
- text: stringValue(payload.text) ?? '',
11495
- isThreadReply: Boolean(parentFromPath) || threadTs !== messageTs,
11496
- isBot: isOwnSlackBotReply(payload, botUserId),
11497
- raw,
11498
- };
11499
- };
11500
12303
  const triageEscalationReason = (decision) => {
11501
12304
  const reasons = [];
11502
12305
  if (decision.confidence === 'low') {
@@ -11569,11 +12372,6 @@ const issueWithGithubClarification = (issue, text) => ({
11569
12372
  text,
11570
12373
  ].join('\n'),
11571
12374
  });
11572
- const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
11573
- // The human's Slack-thread reply, framed as an <integration-event> the agent is
11574
- // told (at spawn) to expect — a recognizable injected event, not an ambiguous
11575
- // keystroke. Trailing CR submits it to the agent's PTY.
11576
- const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
11577
12375
  const clarificationResumeTask = (baseTask, waiting) => {
11578
12376
  const reply = waiting.reply;
11579
12377
  return [
@@ -11585,6 +12383,27 @@ const clarificationResumeTask = (baseTask, waiting) => {
11585
12383
  'Re-hydrate from the issue, branch, worktree, and any open PR, then continue the task. Do not repeat completed work.',
11586
12384
  ].join('\n');
11587
12385
  };
12386
+ const slackConversationResumeTask = (session) => {
12387
+ const delivered = session.delivery?.messages ?? [];
12388
+ const renderMessages = (messages) => messages
12389
+ .map((message, index) => `${index + 1}. ${message.author ? `${message.author}: ` : ''}${message.text}`)
12390
+ .join('\n');
12391
+ return [
12392
+ `Continue the existing ${session.issue.key} session for one turn in its Factory-owned Slack thread.`,
12393
+ `Slack channel mount: /slack/channels/${session.context.channelDir ?? 'unknown'}`,
12394
+ `Slack thread timestamp: ${session.externalId}`,
12395
+ 'The session history, issue, branch, worktree, and open PR remain authoritative. Re-hydrate current repository state before changing code and do not repeat completed work.',
12396
+ ...(session.history.length > 0
12397
+ ? ['', 'Earlier human messages in this Slack conversation:', renderMessages(session.history.slice(-20))]
12398
+ : []),
12399
+ '',
12400
+ 'New human messages coalesced into this turn, in order:',
12401
+ renderMessages(delivered),
12402
+ '',
12403
+ 'Address every new message. Reply in the same Slack thread through the connected Slack mount/writeback surface, then continue or report the issue work as appropriate.',
12404
+ 'This input was supplied atomically as a fresh resume task. Do not request or depend on live PTY/session injection.',
12405
+ ].join('\n');
12406
+ };
11588
12407
  const isFactoryQuestionTarget = (target) => {
11589
12408
  const normalized = target.trim().replace(/^@/u, '').toLowerCase();
11590
12409
  return normalized === 'broker' || normalized === 'factory';
@@ -11680,8 +12499,6 @@ const clarificationStaleSlackText = (waiting, stakeholderUserIds = []) => [
11680
12499
  `Question from ${waiting.askerName}: ${waiting.question} Reply in this thread to wake the saved agent team, or move the issue out of Agent Implementing to cancel the wake.`,
11681
12500
  ].filter((line) => Boolean(line)).join('\n');
11682
12501
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
11683
- const isOwnSlackBotReply = (payload, botUserId) => payload.user_is_bot === true ||
11684
- stringValue(payload.user) === botUserId;
11685
12502
  const unrefDelay = (ms) => new Promise((resolve) => {
11686
12503
  const timer = setTimeout(resolve, ms);
11687
12504
  timer.unref?.();