@agent-relay/factory 0.1.37 → 0.1.39

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 +110 -11
  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 +74 -4
  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/fleet/internal-fleet-client.d.ts.map +1 -1
  31. package/dist/fleet/internal-fleet-client.js +13 -0
  32. package/dist/fleet/internal-fleet-client.js.map +1 -1
  33. package/dist/fleet/relay-fleet-client.d.ts +1 -0
  34. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  35. package/dist/fleet/relay-fleet-client.js +4 -0
  36. package/dist/fleet/relay-fleet-client.js.map +1 -1
  37. package/dist/github/repo-identity.d.ts +8 -0
  38. package/dist/github/repo-identity.d.ts.map +1 -0
  39. package/dist/github/repo-identity.js +28 -0
  40. package/dist/github/repo-identity.js.map +1 -0
  41. package/dist/github/standalone-babysitter.d.ts +1 -0
  42. package/dist/github/standalone-babysitter.d.ts.map +1 -1
  43. package/dist/github/standalone-babysitter.js +16 -1
  44. package/dist/github/standalone-babysitter.js.map +1 -1
  45. package/dist/hosted/index.d.ts +5 -0
  46. package/dist/hosted/index.d.ts.map +1 -0
  47. package/dist/hosted/index.js +3 -0
  48. package/dist/hosted/index.js.map +1 -0
  49. package/dist/hosted/orchestrator.d.ts +12 -0
  50. package/dist/hosted/orchestrator.d.ts.map +1 -0
  51. package/dist/hosted/orchestrator.js +513 -0
  52. package/dist/hosted/orchestrator.js.map +1 -0
  53. package/dist/hosted/state-store.d.ts +48 -0
  54. package/dist/hosted/state-store.d.ts.map +1 -0
  55. package/dist/hosted/state-store.js +210 -0
  56. package/dist/hosted/state-store.js.map +1 -0
  57. package/dist/hosted/types.d.ts +173 -0
  58. package/dist/hosted/types.d.ts.map +1 -0
  59. package/dist/hosted/types.js +2 -0
  60. package/dist/hosted/types.js.map +1 -0
  61. package/dist/index.d.ts +8 -7
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +4 -3
  64. package/dist/index.js.map +1 -1
  65. package/dist/mount/relayfile-cloud-mount-client.d.ts +20 -2
  66. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  67. package/dist/mount/relayfile-cloud-mount-client.js +209 -22
  68. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  69. package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
  70. package/dist/mount/relayfile-github-connection-write.js +4 -2
  71. package/dist/mount/relayfile-github-connection-write.js.map +1 -1
  72. package/dist/orchestrator/batch-tracker.d.ts +24 -2
  73. package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
  74. package/dist/orchestrator/batch-tracker.js +70 -5
  75. package/dist/orchestrator/batch-tracker.js.map +1 -1
  76. package/dist/orchestrator/coalesced-task-queue.d.ts +19 -0
  77. package/dist/orchestrator/coalesced-task-queue.d.ts.map +1 -0
  78. package/dist/orchestrator/coalesced-task-queue.js +85 -0
  79. package/dist/orchestrator/coalesced-task-queue.js.map +1 -0
  80. package/dist/orchestrator/dependencies.d.ts +21 -0
  81. package/dist/orchestrator/dependencies.d.ts.map +1 -0
  82. package/dist/orchestrator/dependencies.js +70 -0
  83. package/dist/orchestrator/dependencies.js.map +1 -0
  84. package/dist/orchestrator/factory.d.ts.map +1 -1
  85. package/dist/orchestrator/factory.js +1466 -224
  86. package/dist/orchestrator/factory.js.map +1 -1
  87. package/dist/orchestrator/index.d.ts +3 -0
  88. package/dist/orchestrator/index.d.ts.map +1 -1
  89. package/dist/orchestrator/index.js +1 -0
  90. package/dist/orchestrator/index.js.map +1 -1
  91. package/dist/ports/mount.d.ts +2 -0
  92. package/dist/ports/mount.d.ts.map +1 -1
  93. package/dist/ports/state.d.ts +66 -4
  94. package/dist/ports/state.d.ts.map +1 -1
  95. package/dist/ports/writeback.d.ts +3 -0
  96. package/dist/ports/writeback.d.ts.map +1 -1
  97. package/dist/state/file-state-store.d.ts +17 -3
  98. package/dist/state/file-state-store.d.ts.map +1 -1
  99. package/dist/state/file-state-store.js +204 -3
  100. package/dist/state/file-state-store.js.map +1 -1
  101. package/dist/state/in-memory-state-store.d.ts +14 -1
  102. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  103. package/dist/state/in-memory-state-store.js +112 -1
  104. package/dist/state/in-memory-state-store.js.map +1 -1
  105. package/dist/subscriptions/index.d.ts +2 -0
  106. package/dist/subscriptions/index.d.ts.map +1 -1
  107. package/dist/subscriptions/index.js +1 -0
  108. package/dist/subscriptions/index.js.map +1 -1
  109. package/dist/subscriptions/slack-filter.d.ts +32 -0
  110. package/dist/subscriptions/slack-filter.d.ts.map +1 -0
  111. package/dist/subscriptions/slack-filter.js +62 -0
  112. package/dist/subscriptions/slack-filter.js.map +1 -0
  113. package/dist/subscriptions/specs.d.ts +2 -0
  114. package/dist/subscriptions/specs.d.ts.map +1 -1
  115. package/dist/subscriptions/specs.js +11 -0
  116. package/dist/subscriptions/specs.js.map +1 -1
  117. package/dist/triage/schema.d.ts +16 -16
  118. package/dist/types.d.ts +17 -0
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/writeback/github.d.ts +4 -0
  121. package/dist/writeback/github.d.ts.map +1 -1
  122. package/dist/writeback/github.js +95 -0
  123. package/dist/writeback/github.js.map +1 -1
  124. package/dist/writeback/index.d.ts +2 -0
  125. package/dist/writeback/index.d.ts.map +1 -1
  126. package/dist/writeback/index.js +1 -0
  127. package/dist/writeback/index.js.map +1 -1
  128. package/dist/writeback/mount-health.d.ts +15 -0
  129. package/dist/writeback/mount-health.d.ts.map +1 -0
  130. package/dist/writeback/mount-health.js +15 -0
  131. package/dist/writeback/mount-health.js.map +1 -0
  132. package/package.json +13 -2
@@ -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';
@@ -85,7 +89,11 @@ const STOP_TEARDOWN_TIMEOUT_MS = 2_500;
85
89
  const DISPATCH_LIFECYCLE_LEASE_MS = 5 * 60_000;
86
90
  const DISPATCH_LIFECYCLE_RENEW_MS = 60_000;
87
91
  const DISPATCH_LIFECYCLE_RETRY_MS = 1_000;
92
+ const STARTUP_AGENT_EXIT_DRAIN_TIMEOUT_MS = 30_000;
88
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;
89
97
  const MERGE_GATE_MAX_ATTEMPTS = 12;
90
98
  const MERGE_GATE_POLL_DELAY_MS = 10_000;
91
99
  const MAX_LABEL_IMPLEMENTERS = 4;
@@ -99,6 +107,8 @@ const GITHUB_MIRROR_TITLE_PREFIX = '[factory]';
99
107
  const GITHUB_MIRROR_SOURCE_PREFIX = 'Source: ';
100
108
  export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
101
109
  export const DEFAULT_FACTORY_LOOP_REGISTRY_PATH = '/tmp/factory-run/factory-loop-registry.json';
110
+ class DispatchLifecycleCapacityError extends Error {
111
+ }
102
112
  const realClock = {
103
113
  now: () => Date.now(),
104
114
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -113,7 +123,9 @@ export class FactoryLoop {
113
123
  #fleet;
114
124
  #triage;
115
125
  #linear;
126
+ #github;
116
127
  #githubWriteback;
128
+ #githubWritebackProvided;
117
129
  #slack;
118
130
  #mergeGate;
119
131
  #probeCloser;
@@ -130,6 +142,7 @@ export class FactoryLoop {
130
142
  #terminationGraceMs;
131
143
  #babysitterWakeUnreachableEscalateMs;
132
144
  #babysitterWakeUnreachableRetryMs;
145
+ #startupAgentExitDrainTimeoutMs;
133
146
  #state;
134
147
  #workspaceId;
135
148
  #relayflows;
@@ -143,6 +156,8 @@ export class FactoryLoop {
143
156
  #dispatchInFlight = new Map();
144
157
  #slackWatchers = new Map();
145
158
  #slackWatcherStarts = new Map();
159
+ #slackConversationTurns;
160
+ #slackConversationOwner = `${process.pid}:${randomUUID()}`;
146
161
  #githubIssueCommentWatchers = new Map();
147
162
  #githubIssueCommentWatchStates = new Map();
148
163
  #githubIssueCommentQueues = new Map();
@@ -164,6 +179,13 @@ export class FactoryLoop {
164
179
  // issue (or the comment writeback's own change event) does not re-post the
165
180
  // same notice every cycle. Cleared once the issue dispatches successfully.
166
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;
167
189
  #pendingSlackClarifications = new Map();
168
190
  #pendingGithubClarifications = new Map();
169
191
  #clarificationIntents = new Map();
@@ -176,6 +198,7 @@ export class FactoryLoop {
176
198
  #dispatchTerminalWaiters = new Map();
177
199
  #dispatchLifecycleRetryTimers = new Map();
178
200
  #dispatchLifecycleDrives = new Set();
201
+ #dispatchLifecycleCapacityWaitLogged = new Set();
179
202
  #localReleaseCheckpoints = new Map();
180
203
  #dispatchLifecycleRenewTimer;
181
204
  #clarificationSweepTimer;
@@ -265,9 +288,10 @@ export class FactoryLoop {
265
288
  this.#linear = ports.linear ?? MountLinearWriteback(ports.mount, {
266
289
  safety: config.safety,
267
290
  });
291
+ this.#githubWritebackProvided = Boolean(ports.githubWriteback);
268
292
  this.#githubWriteback = ports.githubWriteback ?? new GhCliGithubWriteback();
269
293
  this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
270
- void (ports.github ?? MountGithubRead(ports.mount));
294
+ this.#github = ports.github ?? MountGithubRead(ports.mount);
271
295
  this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
272
296
  this.#probeCloser = ports.probeCloser ?? closeProbePr;
273
297
  this.#customProbePrResolver = Boolean(ports.probePrResolver);
@@ -286,6 +310,7 @@ export class FactoryLoop {
286
310
  this.#terminationGraceMs = ports.terminationGraceMs;
287
311
  this.#babysitterWakeUnreachableEscalateMs = ports.babysitterWakeUnreachableEscalateMs ?? BABYSITTER_WAKE_UNREACHABLE_ESCALATE_MS;
288
312
  this.#babysitterWakeUnreachableRetryMs = ports.babysitterWakeUnreachableRetryMs ?? BABYSITTER_WAKE_UNREACHABLE_RETRY_MS;
313
+ this.#startupAgentExitDrainTimeoutMs = ports.startupAgentExitDrainTimeoutMs ?? STARTUP_AGENT_EXIT_DRAIN_TIMEOUT_MS;
289
314
  this.#workspaceId = config.workspaceId ?? 'default';
290
315
  this.#relayflows = ports.relayflows;
291
316
  this.#worktrees = ports.worktrees;
@@ -298,6 +323,19 @@ export class FactoryLoop {
298
323
  this.#batchView = batch;
299
324
  return batch;
300
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
+ });
301
339
  this.#wireFleetEvents();
302
340
  }
303
341
  async #resolveIntegrationInstructions() {
@@ -480,7 +518,12 @@ export class FactoryLoop {
480
518
  this.#wireFleetEvents();
481
519
  await this.#adoptInFlightAgents(legacyRegistry);
482
520
  this.#startupAgentAdoptionActive = false;
483
- await this.#restoreBabysitterOwnership();
521
+ if (this.#config.babysitter.enabled) {
522
+ // Re-run the idempotent receipt fold after adoption returns. This
523
+ // catches records restored by lifecycle work that completed while the
524
+ // startup roster drain was in progress.
525
+ await this.#reconcileRestoredBabysitterReceipts();
526
+ }
484
527
  }
485
528
  catch (error) {
486
529
  this.#startupAgentAdoptionActive = false;
@@ -559,6 +602,12 @@ export class FactoryLoop {
559
602
  this.#completionSweepTimer = undefined;
560
603
  this.#stoppingHeartbeatRefreshActive = await this.#stopLiveHeartbeat('stopping');
561
604
  try {
605
+ // Relinquish durable ownership before waiting on mount-backed lifecycle
606
+ // drives. A slow Relayfile scan must not consume the shutdown deadline
607
+ // while every issue remains fenced to a publisher that is already
608
+ // stopping. The owner/epoch fence makes any late completion from those
609
+ // drives harmless; a second sweep below catches claims racing this one.
610
+ await this.#releaseOwnedDispatchLifecycleLeases();
562
611
  await Promise.allSettled([...this.#dispatchLifecycleDrives]);
563
612
  // Fence every source of new clarification work before touching the fleet.
564
613
  // A wake already past the fence is allowed to unwind, and is awaited
@@ -581,10 +630,7 @@ export class FactoryLoop {
581
630
  // non-durable (local/internal) records; terminal completion performs the
582
631
  // normal remote release before clearing the lifecycle.
583
632
  await this.#releaseInFlightAgents('factory-stopped', { preserveDurable: true });
584
- for (const [key, epoch] of [...this.#dispatchLifecycleEpochs]) {
585
- await this.#state.releaseDispatchLifecycleLease(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch);
586
- }
587
- this.#dispatchLifecycleEpochs.clear();
633
+ await this.#releaseOwnedDispatchLifecycleLeases();
588
634
  if (this.#livePollTimer)
589
635
  clearTimeout(this.#livePollTimer);
590
636
  this.#livePollTimer = undefined;
@@ -612,6 +658,7 @@ export class FactoryLoop {
612
658
  this.#offAgentMessage?.();
613
659
  this.#offAgentLifecycleSignal?.();
614
660
  await Promise.allSettled([...this.#agentLifecycleSignalsInFlight.values()]);
661
+ await this.#slackConversationTurns.stop();
615
662
  this.#offAgentExit = undefined;
616
663
  this.#offDeliveryFailed = undefined;
617
664
  this.#offAgentMessage = undefined;
@@ -622,6 +669,14 @@ export class FactoryLoop {
622
669
  this.#stoppingHeartbeatRefreshActive = false;
623
670
  }
624
671
  }
672
+ async #releaseOwnedDispatchLifecycleLeases() {
673
+ for (const [key, epoch] of [...this.#dispatchLifecycleEpochs]) {
674
+ await this.#state.releaseDispatchLifecycleLease(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch);
675
+ if (this.#dispatchLifecycleEpochs.get(key) === epoch) {
676
+ this.#dispatchLifecycleEpochs.delete(key);
677
+ }
678
+ }
679
+ }
625
680
  async #drainClarificationWakesForStop() {
626
681
  // A wake may add its promise just as the sweep that discovered it settles.
627
682
  // Re-snapshot until the map is empty rather than assuming one await is a
@@ -1248,6 +1303,13 @@ export class FactoryLoop {
1248
1303
  this.#logger.info?.('[factory] run-once started', { dryRun });
1249
1304
  let report;
1250
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;
1251
1313
  const issueSource = await this.#issueSource();
1252
1314
  if (issueSource === 'linear') {
1253
1315
  await this.#ingestGithubIssues({ dryRun });
@@ -1370,7 +1432,12 @@ export class FactoryLoop {
1370
1432
  continue;
1371
1433
  }
1372
1434
  if (result.agents.length === 0 && !dryRun) {
1373
- 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 });
1374
1441
  }
1375
1442
  else {
1376
1443
  dispatched.push(result);
@@ -1709,6 +1776,7 @@ export class FactoryLoop {
1709
1776
  url: pr.url ?? `https://github.com/${pr.repo}/pull/${pr.prNumber}`,
1710
1777
  path: pr.path,
1711
1778
  headRef,
1779
+ authoritative: true,
1712
1780
  });
1713
1781
  const babysitter = [...record.agents.values()].find((tracked) => tracked.spec.role === 'babysitter');
1714
1782
  if (!babysitter) {
@@ -1981,6 +2049,26 @@ export class FactoryLoop {
1981
2049
  this.#recordTriageEscalation(dispatchDecision, escalationReason);
1982
2050
  return replayedResult ?? { issue: dispatchDecision.issue, agents: [], dryRun };
1983
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);
1984
2072
  // Full task rendering is part of the durable spawn specification. It must
1985
2073
  // happen before a remote lifecycle is first claimed so takeover cannot
1986
2074
  // recover a persisted minimal triage task after a crash in this gap.
@@ -2008,7 +2096,12 @@ export class FactoryLoop {
2008
2096
  this.#scheduleDispatchLifecycleRetry(queuedRecord);
2009
2097
  this.#increment('queued');
2010
2098
  this.#emit('issue-queued', { issue: dispatchDecision.issue });
2011
- 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
+ };
2012
2105
  }
2013
2106
  if (!lifecycleClaim.created) {
2014
2107
  const restored = batch.restore(inFlightRecordFromLifecycle(lifecycleClaim.lifecycle));
@@ -2019,12 +2112,12 @@ export class FactoryLoop {
2019
2112
  if (!durableDispatch)
2020
2113
  this.#consumePendingDispatchClarifications(dispatchDecision.issue);
2021
2114
  await this.#recordDispatchAttempt(dispatchDecision.issue);
2022
- const record = batch.start(dispatchDecision, dryRun);
2115
+ const record = batch.start(dispatchDecision, dryRun, dependencyAdmission);
2023
2116
  if (!record) {
2024
2117
  await this.#clearDispatchInFlight(dispatchDecision.issue);
2025
2118
  this.#increment('queued');
2026
2119
  this.#emit('issue-queued', { issue: dispatchDecision.issue });
2027
- return { issue: dispatchDecision.issue, agents: [], dryRun };
2120
+ return { issue: dispatchDecision.issue, agents: [], dryRun, hold: { kind: 'capacity' } };
2028
2121
  }
2029
2122
  if (record.result) {
2030
2123
  return record.result;
@@ -2152,6 +2245,12 @@ export class FactoryLoop {
2152
2245
  return {
2153
2246
  inFlight: batch?.inFlight.map((record) => record.issue) ?? [],
2154
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
+ })) ?? [],
2155
2254
  counters: { ...this.#counters },
2156
2255
  slackDegraded: this.#slackDegraded,
2157
2256
  slackDegradedReason: this.#slackDegradedReason,
@@ -2279,6 +2378,14 @@ export class FactoryLoop {
2279
2378
  if (agents.length > 0 && this.#fleet.hydrateTracked) {
2280
2379
  this.#fleet.hydrateTracked(agents);
2281
2380
  }
2381
+ if (this.#config.babysitter.enabled) {
2382
+ // Restore and reconcile exact PR ownership before asking the fleet to
2383
+ // report missing agents. Otherwise a stale weak-match babysitter from
2384
+ // the lifecycle can be resumed before its independently durable,
2385
+ // metadata-validated replacement session becomes authoritative.
2386
+ await this.#restoreBabysitterOwnership();
2387
+ await this.#reconcileRestoredBabysitterReceipts();
2388
+ }
2282
2389
  this.#scheduleDispatchLifecycleRenewal();
2283
2390
  if (this.#fleet.hydrateTracked) {
2284
2391
  this.#startupAgentAdoptionActive = false;
@@ -2293,23 +2400,50 @@ export class FactoryLoop {
2293
2400
  // but recovery work is asynchronous (issue reads, worktree restore,
2294
2401
  // PR publication). Finish exits discovered by the startup reconcile
2295
2402
  // before the full ready-issue backfill can monopolize mount I/O.
2296
- await this.#drainAgentExitsInFlight(new Set(agents.map((agent) => agent.name)));
2297
- this.#logger.info?.('[factory] durable startup reconciled exits drained');
2403
+ const exitNames = new Set(agents.map((agent) => agent.name));
2404
+ const drained = await this.#drainAgentExitsInFlight(exitNames, this.#startMode === 'live' ? this.#startupAgentExitDrainTimeoutMs : undefined);
2405
+ if (drained) {
2406
+ this.#logger.info?.('[factory] durable startup reconciled exits drained');
2407
+ }
2408
+ else {
2409
+ this.#increment('startupAgentExitDrainTimeouts');
2410
+ this.#logger.warn?.('[factory] startup agent exit reconciliation is still running; continuing ready-issue discovery', {
2411
+ timeoutMs: this.#startupAgentExitDrainTimeoutMs,
2412
+ pendingExits: [...this.#agentExitsInFlight.keys()].filter((name) => exitNames.has(name)),
2413
+ });
2414
+ }
2298
2415
  }
2299
2416
  }
2300
2417
  catch (error) {
2301
2418
  this.#logger.warn?.('[factory] failed to re-adopt durable in-flight agents', { error });
2302
2419
  }
2303
2420
  }
2304
- async #drainAgentExitsInFlight(names) {
2305
- for (;;) {
2306
- const pending = [...this.#agentExitsInFlight]
2307
- .filter(([name]) => !names || names.has(name))
2308
- .map(([, handling]) => handling);
2309
- if (pending.length === 0)
2310
- return;
2311
- await Promise.allSettled(pending);
2421
+ async #drainAgentExitsInFlight(names, timeoutMs) {
2422
+ const drain = async () => {
2423
+ for (;;) {
2424
+ const pending = [...this.#agentExitsInFlight]
2425
+ .filter(([name]) => !names || names.has(name))
2426
+ .map(([, handling]) => handling);
2427
+ if (pending.length === 0)
2428
+ return;
2429
+ await Promise.allSettled(pending);
2430
+ }
2431
+ };
2432
+ if (timeoutMs === undefined) {
2433
+ await drain();
2434
+ return true;
2312
2435
  }
2436
+ let timer;
2437
+ const completed = await Promise.race([
2438
+ drain().then(() => true),
2439
+ new Promise((resolve) => {
2440
+ timer = setTimeout(() => resolve(false), Math.max(0, timeoutMs));
2441
+ timer.unref?.();
2442
+ }),
2443
+ ]);
2444
+ if (timer)
2445
+ clearTimeout(timer);
2446
+ return completed;
2313
2447
  }
2314
2448
  #scheduleDispatchLifecycleRenewal() {
2315
2449
  if (this.#dispatchLifecycleRenewTimer || this.#dispatchLifecycleEpochs.size === 0)
@@ -2457,7 +2591,7 @@ export class FactoryLoop {
2457
2591
  }
2458
2592
  const previous = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
2459
2593
  const pullRequests = mergePublishedPullRequests(previous, pullRequest);
2460
- const primaryPullRequest = previous?.pullRequest ?? pullRequest ?? pullRequests[0];
2594
+ const primaryPullRequest = primaryPublishedPullRequest(previous, pullRequest, pullRequests);
2461
2595
  const lifecycle = lifecycleFromInFlightRecord(record, previous?.runId ?? randomUUID(), phase, this.#clock.now(), primaryPullRequest, pullRequests, releaseReason ?? previous?.releaseReason);
2462
2596
  for (const agent of lifecycle.agents) {
2463
2597
  const previouslyReleasedAtMs = previous?.agents.find((candidate) => candidate.name === agent.name)?.releasedAtMs;
@@ -2502,11 +2636,27 @@ export class FactoryLoop {
2502
2636
  const timer = setTimeout(() => {
2503
2637
  this.#dispatchLifecycleRetryTimers.delete(key);
2504
2638
  const drive = this.#driveDispatchLifecycle(key)
2639
+ .then(() => {
2640
+ this.#dispatchLifecycleCapacityWaitLogged.delete(key);
2641
+ })
2505
2642
  .catch((error) => {
2506
- this.#logger.warn?.('[factory] durable dispatch lifecycle retry failed', {
2507
- issue: record.issue.key,
2508
- error: describeError(error).errorMessage,
2509
- });
2643
+ if (error instanceof DispatchLifecycleCapacityError) {
2644
+ if (!this.#dispatchLifecycleCapacityWaitLogged.has(key)) {
2645
+ this.#dispatchLifecycleCapacityWaitLogged.add(key);
2646
+ this.#increment('dispatchLifecycleCapacityWaits');
2647
+ this.#logger.warn?.('[factory] durable dispatch is queued for batch capacity; retries remain active', {
2648
+ issue: record.issue.key,
2649
+ retryMs: DISPATCH_LIFECYCLE_RETRY_MS,
2650
+ });
2651
+ }
2652
+ }
2653
+ else {
2654
+ this.#dispatchLifecycleCapacityWaitLogged.delete(key);
2655
+ this.#logger.warn?.('[factory] durable dispatch lifecycle retry failed', {
2656
+ issue: record.issue.key,
2657
+ error: describeError(error).errorMessage,
2658
+ });
2659
+ }
2510
2660
  this.#scheduleDispatchLifecycleRetry(record);
2511
2661
  })
2512
2662
  .finally(() => this.#dispatchLifecycleDrives.delete(drive));
@@ -2563,9 +2713,22 @@ export class FactoryLoop {
2563
2713
  acquiredNow = true;
2564
2714
  }
2565
2715
  if (lifecycle.phase === 'queued') {
2716
+ const liveIssue = await this.#readIssue(lifecycle.issue.path);
2717
+ if (liveIssue) {
2718
+ const dependencyAdmission = await this.#dependencyAdmission(liveIssue, lifecycle.decision);
2719
+ const batch = await this.#batch();
2720
+ if (dependencyAdmission.blockers.length > 0 || dependencyAdmission.cycle) {
2721
+ batch.queue(lifecycle.decision, lifecycle.dryRun, dependencyAdmission);
2722
+ const parked = batch.getParked(lifecycle.issue);
2723
+ if (parked)
2724
+ await this.#reportDependencyPark(liveIssue, parked, lifecycle.dryRun);
2725
+ return;
2726
+ }
2727
+ this.#clearDependencyPark(batch, lifecycle.issue);
2728
+ }
2566
2729
  const epoch = this.#dispatchLifecycleEpochs.get(key);
2567
2730
  if (epoch === undefined || !await this.#state.promoteDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now())) {
2568
- throw new Error(`durable dispatch ${lifecycle.issue.key} is waiting for batch capacity`);
2731
+ throw new DispatchLifecycleCapacityError(`durable dispatch ${lifecycle.issue.key} is waiting for batch capacity`);
2569
2732
  }
2570
2733
  const promoted = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
2571
2734
  if (!promoted || promoted.phase !== 'dispatching') {
@@ -2601,6 +2764,8 @@ export class FactoryLoop {
2601
2764
  })));
2602
2765
  await this.#fleet.reconcileTrackedAgents?.();
2603
2766
  }
2767
+ if (this.#config.babysitter.enabled)
2768
+ await this.#reconcileRestoredBabysitterReceipts(record);
2604
2769
  return;
2605
2770
  }
2606
2771
  if (lifecycle.phase === 'parking') {
@@ -2637,6 +2802,7 @@ export class FactoryLoop {
2637
2802
  prNumber: receipt.number,
2638
2803
  url: receipt.url,
2639
2804
  headRef: receipt.headRef,
2805
+ authoritative: true,
2640
2806
  });
2641
2807
  }
2642
2808
  return;
@@ -2651,6 +2817,7 @@ export class FactoryLoop {
2651
2817
  prNumber: receipt.number,
2652
2818
  url: receipt.url,
2653
2819
  headRef: receipt.headRef,
2820
+ authoritative: true,
2654
2821
  });
2655
2822
  }
2656
2823
  return;
@@ -2876,6 +3043,9 @@ export class FactoryLoop {
2876
3043
  if (issue && !isGithubIssue(issue)) {
2877
3044
  await this.#recordCanonicalIssueState(issue);
2878
3045
  }
3046
+ if (issue && this.#dependencyIssueIsTerminal(issue)) {
3047
+ await this.#markDependencyTerminalAndReconcile(issue);
3048
+ }
2879
3049
  if (!issue || !this.#isIssueReady(issue)) {
2880
3050
  return;
2881
3051
  }
@@ -2904,14 +3074,7 @@ export class FactoryLoop {
2904
3074
  this.#recordTriageEscalation(escalationDecision, escalationReason);
2905
3075
  return;
2906
3076
  }
2907
- if (batch.canStart()) {
2908
- await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
2909
- }
2910
- else {
2911
- if (batch.queue(escalationDecision, this.#config.dryRun)) {
2912
- this.#emit('issue-queued', { issue: escalationDecision.issue });
2913
- }
2914
- }
3077
+ await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
2915
3078
  }
2916
3079
  catch (error) {
2917
3080
  if (error instanceof LiveDispatchStateChangedError) {
@@ -3040,40 +3203,55 @@ export class FactoryLoop {
3040
3203
  async #githubIssuePaths() {
3041
3204
  try {
3042
3205
  const issuePaths = new Map();
3043
- for (const root of githubIssueScanRoots(this.#config)) {
3044
- const paths = await this.#listRelayfileTree(root, 'GitHub issue ingestion');
3045
- for (let index = 0; index < paths.length; index += 1) {
3046
- const path = paths[index];
3047
- const parts = githubIssuePathParts(path);
3048
- if (parts) {
3049
- const identity = githubIssueIdentity(parts.owner, parts.repo, parts.number);
3050
- const existing = issuePaths.get(identity);
3051
- if (!existing || githubIssuePathPreference(path) < githubIssuePathPreference(existing)) {
3052
- if (existing)
3206
+ for (const { owner, repo } of configuredGithubRepoParts(this.#config)) {
3207
+ const indexedPaths = await this.#githubIssuePathsFromIndex(owner, repo);
3208
+ const roots = githubIssueRepoRoots(owner, repo);
3209
+ // Keep the fallback roots as separate batches. Flattening a very large
3210
+ // provider result is synchronous work and can starve the durable loop
3211
+ // heartbeat before the bounded scan below gets a chance to yield.
3212
+ const pathBatches = indexedPaths
3213
+ ? [indexedPaths]
3214
+ : await Promise.all(roots.map(async (root) => await this.#listRelayfileTree(root, 'GitHub issue ingestion')));
3215
+ if (indexedPaths) {
3216
+ this.#increment('githubIssueIndexReposUsed');
3217
+ }
3218
+ else {
3219
+ this.#increment('githubIssueIndexFallbacks');
3220
+ }
3221
+ for (const paths of pathBatches) {
3222
+ for (let index = 0; index < paths.length; index += 1) {
3223
+ const path = paths[index];
3224
+ const parts = githubIssuePathParts(path);
3225
+ if (parts) {
3226
+ const identity = githubIssueIdentity(parts.owner, parts.repo, parts.number);
3227
+ const existing = issuePaths.get(identity);
3228
+ if (!existing || githubIssuePathPreference(path) < githubIssuePathPreference(existing)) {
3229
+ if (existing)
3230
+ this.#increment('githubIssueAliasPathsSuppressed');
3231
+ issuePaths.set(identity, path);
3232
+ }
3233
+ else {
3053
3234
  this.#increment('githubIssueAliasPathsSuppressed');
3054
- issuePaths.set(identity, path);
3235
+ }
3055
3236
  }
3056
- else {
3057
- this.#increment('githubIssueAliasPathsSuppressed');
3237
+ else if (githubIssueDirectoryPathParts(path) !== undefined) {
3238
+ // listTree returns the issue directory entry alongside its
3239
+ // meta.json file; githubIssuePathParts() already collected the
3240
+ // file, so skip the directory to avoid reading the same issue
3241
+ // twice in one backfill pass. Directory paths are only meaningful
3242
+ // for live change events, not the tree scan.
3243
+ continue;
3244
+ }
3245
+ else if (isGithubIssueTreePath(path)) {
3246
+ this.#increment('githubIssuesIgnoredByPathRegex');
3247
+ }
3248
+ if ((index + 1) % LIVE_EVENT_DRAIN_BATCH_SIZE === 0) {
3249
+ await this.#refreshLiveHeartbeatIfDue();
3250
+ await liveEventYield();
3058
3251
  }
3059
3252
  }
3060
- else if (githubIssueDirectoryPathParts(path) !== undefined) {
3061
- // listTree returns the issue directory entry alongside its
3062
- // meta.json file; githubIssuePathParts() already collected the
3063
- // file, so skip the directory to avoid reading the same issue
3064
- // twice in one backfill pass. Directory paths are only meaningful
3065
- // for live change events, not the tree scan.
3066
- continue;
3067
- }
3068
- else if (isGithubIssueTreePath(path)) {
3069
- this.#increment('githubIssuesIgnoredByPathRegex');
3070
- }
3071
- if ((index + 1) % LIVE_EVENT_DRAIN_BATCH_SIZE === 0) {
3072
- await this.#refreshLiveHeartbeatIfDue();
3073
- await liveEventYield();
3074
- }
3253
+ await this.#refreshLiveHeartbeatIfDue();
3075
3254
  }
3076
- await this.#refreshLiveHeartbeatIfDue();
3077
3255
  }
3078
3256
  for (const [identity, path] of issuePaths) {
3079
3257
  this.#githubIssuePreferredPaths.set(identity, path);
@@ -3088,6 +3266,41 @@ export class FactoryLoop {
3088
3266
  return [];
3089
3267
  }
3090
3268
  }
3269
+ async #githubIssuePathsFromIndex(owner, repo) {
3270
+ const indexPath = `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/_index.json`;
3271
+ let parsed;
3272
+ try {
3273
+ const { content } = await this.#readRelayfileFile(indexPath, 'GitHub issue index discovery');
3274
+ parsed = parseJsonContent(content);
3275
+ }
3276
+ catch {
3277
+ return undefined;
3278
+ }
3279
+ if (!Array.isArray(parsed))
3280
+ return undefined;
3281
+ const requiredLabel = this.#config.safety.requireLabel.trim().toLowerCase();
3282
+ if (!requiredLabel)
3283
+ return undefined;
3284
+ const paths = [];
3285
+ for (const entry of parsed) {
3286
+ const row = asRecord(entry);
3287
+ const number = row?.number;
3288
+ const state = typeof row?.state === 'string' ? row.state.trim().toLowerCase() : undefined;
3289
+ const labels = row?.labels;
3290
+ // Labels were added to the public GitHub issue index contract after the
3291
+ // first index version. Fall back for the entire repository if any row is
3292
+ // legacy or malformed so an eligible issue can never be filtered out.
3293
+ if (!Number.isSafeInteger(number) || Number(number) <= 0 || !state || !Array.isArray(labels) ||
3294
+ !labels.every((label) => typeof label === 'string')) {
3295
+ return undefined;
3296
+ }
3297
+ if (state !== 'open' || !labels.some((label) => label.trim().toLowerCase() === requiredLabel)) {
3298
+ continue;
3299
+ }
3300
+ paths.push(`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`);
3301
+ }
3302
+ return paths;
3303
+ }
3091
3304
  async #handleGithubIssueChange(path, opts = {}) {
3092
3305
  if (this.#githubIngestionEnabled === false || !isGithubIssueFilePath(path)) {
3093
3306
  return;
@@ -3097,13 +3310,17 @@ export class FactoryLoop {
3097
3310
  if (!ghIssue) {
3098
3311
  return;
3099
3312
  }
3313
+ const closed = githubIssueIsClosed(ghIssue);
3314
+ if (closed) {
3315
+ await this.#markDependencyTerminalAndReconcile(githubIssueAsFactoryIssue(ghIssue));
3316
+ }
3100
3317
  if (await this.#issueSource() === 'github') {
3101
- if (!githubIssueIsClosed(ghIssue) && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
3318
+ if (!closed && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
3102
3319
  await this.#handleChange(ghIssue.path);
3103
3320
  }
3104
3321
  return;
3105
3322
  }
3106
- if (githubIssueIsClosed(ghIssue)) {
3323
+ if (closed) {
3107
3324
  const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
3108
3325
  if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
3109
3326
  if (!opts.dryRun) {
@@ -3156,7 +3373,9 @@ export class FactoryLoop {
3156
3373
  for (const candidatePath of candidatePaths) {
3157
3374
  try {
3158
3375
  const { content } = await this.#readRelayfileFile(candidatePath, 'GitHub issue ingestion');
3159
- return parseGithubIssue(candidatePath, content);
3376
+ const githubIssue = parseGithubIssue(candidatePath, content);
3377
+ this.#indexDependencyIssue(githubIssueAsFactoryIssue(githubIssue));
3378
+ return githubIssue;
3160
3379
  }
3161
3380
  catch (error) {
3162
3381
  if (isMissingIssueFileError(error) && candidatePath !== candidatePaths.at(-1)) {
@@ -3245,6 +3464,248 @@ export class FactoryLoop {
3245
3464
  });
3246
3465
  return entry?.[0];
3247
3466
  }
3467
+ #indexDependencyIssue(issue) {
3468
+ if (!issue)
3469
+ return;
3470
+ const repo = dependencyRepoForIssue(issue, undefined, this.#config);
3471
+ const identity = dependencyIdentityForIssue(issue, repo);
3472
+ if (!identity)
3473
+ return;
3474
+ const rank = isGithubIssueFilePath(issue.path) ? 2 : 1;
3475
+ const existing = this.#dependencyIssues.get(identity);
3476
+ if (!existing || rank >= existing.rank) {
3477
+ this.#dependencyIssues.set(identity, { issue, rank });
3478
+ if (this.#dependencyIssueIsTerminal(issue)) {
3479
+ this.#terminalDependencyIdentities.add(identity);
3480
+ }
3481
+ else {
3482
+ this.#terminalDependencyIdentities.delete(identity);
3483
+ }
3484
+ }
3485
+ }
3486
+ async #dependencyAdmission(issue, decision) {
3487
+ const declared = parseBlockedBy(issue.description);
3488
+ if (declared.length === 0)
3489
+ return { blockers: [] };
3490
+ const currentRepo = dependencyRepoForIssue(issue, decision, this.#config);
3491
+ const resolved = declared
3492
+ .map((dependency) => resolveDependency(dependency, currentRepo))
3493
+ .filter((dependency) => Boolean(dependency));
3494
+ await this.#loadMissingDependencyIssues(resolved.map((dependency) => dependency.identity));
3495
+ const resolvedTerminal = new Set();
3496
+ for (const dependency of resolved) {
3497
+ if (await this.#dependencyIsTerminalOrMerged(dependency.identity)) {
3498
+ resolvedTerminal.add(dependency.identity);
3499
+ }
3500
+ }
3501
+ const blockers = resolved
3502
+ .filter((dependency) => !resolvedTerminal.has(dependency.identity))
3503
+ .map((dependency) => {
3504
+ const blocker = this.#dependencyIssues.get(dependency.identity)?.issue;
3505
+ return {
3506
+ identity: dependency.identity,
3507
+ key: blocker ? issueKey(issueRef(blocker)) : dependency.identity,
3508
+ label: dependency.label,
3509
+ };
3510
+ });
3511
+ // A bare #N without a resolvable single repository is itself unresolved.
3512
+ // Keep it visible and fail closed instead of guessing among routes.
3513
+ for (const dependency of declared) {
3514
+ if (dependency.repo || currentRepo)
3515
+ continue;
3516
+ blockers.push({
3517
+ identity: `unresolved:${issueKey(decision.issue)}:${dependency.raw}`,
3518
+ key: `unresolved:${dependency.raw}`,
3519
+ label: `${dependency.raw} (repository unresolved)`,
3520
+ });
3521
+ }
3522
+ const currentIdentity = dependencyIdentityForIssue(issue, currentRepo);
3523
+ const cycle = currentIdentity
3524
+ ? findDependencyCycle(currentIdentity, this.#dependencyGraph())
3525
+ : undefined;
3526
+ return { blockers, cycle };
3527
+ }
3528
+ async #loadMissingDependencyIssues(identities) {
3529
+ const pending = [...new Set(identities)];
3530
+ const expanded = new Set();
3531
+ while (pending.length > 0) {
3532
+ const identity = pending.shift();
3533
+ if (expanded.has(identity))
3534
+ continue;
3535
+ expanded.add(identity);
3536
+ if (!this.#dependencyIssues.has(identity)) {
3537
+ // Startup discovery may use the ready-only GitHub issue index. A
3538
+ // blocker is often closed, in progress, or deliberately unlabeled, so
3539
+ // probe its stable by-id aliases directly before consulting that
3540
+ // filtered discovery set.
3541
+ const separator = identity.lastIndexOf('#');
3542
+ const repoIdentity = identity.slice(0, separator);
3543
+ const number = Number(identity.slice(separator + 1));
3544
+ const configuredRepo = configuredGithubRepoParts(this.#config)
3545
+ .find(({ owner, repo }) => `${owner}/${repo}`.toLowerCase() === repoIdentity);
3546
+ if (configuredRepo && Number.isSafeInteger(number) && number > 0) {
3547
+ const { owner, repo } = configuredRepo;
3548
+ for (const path of [
3549
+ `${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`,
3550
+ `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/by-id/${number}.json`,
3551
+ ]) {
3552
+ await this.#readIssue(path);
3553
+ if (this.#dependencyIssues.has(identity))
3554
+ break;
3555
+ }
3556
+ }
3557
+ }
3558
+ if (!this.#dependencyIssues.has(identity)) {
3559
+ if (!this.#dependencyGithubPathsByIdentity) {
3560
+ this.#dependencyGithubPathsByIdentity = new Map();
3561
+ for (const path of await this.#githubIssuePaths()) {
3562
+ const parts = githubIssuePathParts(path);
3563
+ if (parts) {
3564
+ this.#dependencyGithubPathsByIdentity.set(githubIssueIdentity(parts.owner, parts.repo, parts.number), path);
3565
+ }
3566
+ }
3567
+ }
3568
+ const githubPath = this.#dependencyGithubPathsByIdentity.get(identity);
3569
+ if (githubPath)
3570
+ await this.#readIssue(githubPath);
3571
+ }
3572
+ if (!this.#dependencyIssues.has(identity) && !this.#dependencyLinearTreeLoaded) {
3573
+ // Linear paths do not encode the routed repository identity, so load
3574
+ // the tree once and let #indexDependencyIssue build the composite map.
3575
+ // The pass-scoped flag also lets every later blocked issue reuse the
3576
+ // resulting dependency index instead of rescanning the full tree.
3577
+ this.#dependencyLinearTreeLoaded = true;
3578
+ for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'dependency blocker discovery')) {
3579
+ if (isIssueFilePath(path))
3580
+ await this.#readIssue(path);
3581
+ }
3582
+ }
3583
+ const blocker = this.#dependencyIssues.get(identity)?.issue;
3584
+ if (!blocker)
3585
+ continue;
3586
+ const repo = identity.slice(0, identity.lastIndexOf('#'));
3587
+ for (const dependency of parseBlockedBy(blocker.description)) {
3588
+ const resolved = resolveDependency(dependency, repo);
3589
+ if (resolved && !expanded.has(resolved.identity))
3590
+ pending.push(resolved.identity);
3591
+ }
3592
+ }
3593
+ }
3594
+ #clearDependencyPark(batch, issue) {
3595
+ batch.clearPark(issue);
3596
+ this.#dependencyParkNotices.delete(issueKey(issue));
3597
+ }
3598
+ #dependencyGraph() {
3599
+ const graph = new Map();
3600
+ for (const [identity, { issue }] of this.#dependencyIssues) {
3601
+ if (this.#dependencyIdentityIsTerminal(identity)) {
3602
+ graph.set(identity, []);
3603
+ continue;
3604
+ }
3605
+ const repo = identity.slice(0, identity.lastIndexOf('#'));
3606
+ const dependencies = parseBlockedBy(issue.description)
3607
+ .map((dependency) => resolveDependency(dependency, repo))
3608
+ .filter((dependency) => Boolean(dependency))
3609
+ .filter((dependency) => !this.#dependencyIdentityIsTerminal(dependency.identity))
3610
+ .map((dependency) => dependency.identity);
3611
+ graph.set(identity, [...new Set(dependencies)]);
3612
+ }
3613
+ return graph;
3614
+ }
3615
+ #dependencyIdentityIsTerminal(identity) {
3616
+ if (this.#terminalDependencyIdentities.has(identity))
3617
+ return true;
3618
+ const issue = this.#dependencyIssues.get(identity)?.issue;
3619
+ return Boolean(issue && this.#dependencyIssueIsTerminal(issue));
3620
+ }
3621
+ async #dependencyIsTerminalOrMerged(identity) {
3622
+ if (this.#dependencyIdentityIsTerminal(identity))
3623
+ return true;
3624
+ const issue = this.#dependencyIssues.get(identity)?.issue;
3625
+ if (!issue)
3626
+ return false;
3627
+ const repo = dependencyRepoForIssue(issue, undefined, this.#config);
3628
+ if (!repo)
3629
+ return false;
3630
+ const pullRequest = await resolveIssuePrFromMount(this.#mount, this.#config, issue, {
3631
+ allowLegacyGithubBranch: true,
3632
+ repo,
3633
+ });
3634
+ if (normalizePrState(pullRequest?.state) !== 'MERGED')
3635
+ return false;
3636
+ this.#terminalDependencyIdentities.add(identity);
3637
+ return true;
3638
+ }
3639
+ #dependencyIssueIsTerminal(issue) {
3640
+ return isGithubIssue(issue)
3641
+ ? githubFactoryIssueIsClosed(issue)
3642
+ : this.#states.isRole(issue.stateId, 'done');
3643
+ }
3644
+ async #reportDependencyPark(issue, parked, dryRun) {
3645
+ const cycle = parked.cycle?.join(' -> ');
3646
+ const blockers = parked.blockers.map((blocker) => blocker.label);
3647
+ const signature = JSON.stringify({ blockers, cycle, capacityBlocked: parked.capacityBlocked });
3648
+ const marker = `<!-- factory-dependency-park:${stableHash(signature)} -->`;
3649
+ const comment = parked.cycle
3650
+ ? [
3651
+ marker,
3652
+ 'Factory refused dispatch because it detected a dependency cycle.',
3653
+ `Cycle: ${cycle}`,
3654
+ blockers.length > 0 ? `Unresolved blockers: ${blockers.join(', ')}` : undefined,
3655
+ ].filter((line) => Boolean(line)).join('\n')
3656
+ : [
3657
+ marker,
3658
+ 'Factory parked this issue because declared dependencies are unresolved.',
3659
+ `Blocked by: ${blockers.join(', ')}`,
3660
+ parked.capacityBlocked ? 'Capacity is also currently unavailable.' : undefined,
3661
+ ].filter((line) => Boolean(line)).join('\n');
3662
+ const key = issueKey(parked.issue);
3663
+ if (dryRun || this.#dependencyParkNotices.get(key) === signature)
3664
+ return comment;
3665
+ this.#increment(parked.cycle ? 'dependencyCycles' : 'dependencyParks');
3666
+ if (parked.cycle) {
3667
+ this.#error(new Error(`Dependency cycle detected: ${cycle}`), parked.issue);
3668
+ }
3669
+ try {
3670
+ await this.#postIssueComment(issue, comment);
3671
+ this.#dependencyParkNotices.set(key, signature);
3672
+ }
3673
+ catch (error) {
3674
+ this.#logger.warn?.('[factory] dependency park writeback skipped', {
3675
+ issue: parked.issue.key,
3676
+ error: describeError(error).errorMessage,
3677
+ });
3678
+ }
3679
+ return comment;
3680
+ }
3681
+ async #markDependencyTerminalAndReconcile(issue) {
3682
+ const repo = dependencyRepoForIssue(issue, undefined, this.#config);
3683
+ const identity = dependencyIdentityForIssue(issue, repo);
3684
+ if (!identity)
3685
+ return;
3686
+ this.#indexDependencyIssue(issue);
3687
+ // A merge event can prove terminality before the issue snapshot reflects
3688
+ // its new state, so apply the explicit observation after indexing it.
3689
+ this.#terminalDependencyIdentities.add(identity);
3690
+ const batch = await this.#batch();
3691
+ this.#clearDependencyPark(batch, issueRef(issue));
3692
+ const candidates = batch.parked.filter((parked) => parked.blockers.some((blocker) => blocker.identity === identity));
3693
+ for (const parked of candidates) {
3694
+ try {
3695
+ const liveIssue = await this.#readIssue(parked.issue.path);
3696
+ if (!liveIssue || !this.#isIssueReady(liveIssue))
3697
+ continue;
3698
+ await this.dispatch(parked.decision, { dryRun: parked.dryRun });
3699
+ }
3700
+ catch (error) {
3701
+ this.#logger.warn?.('[factory] dependency park reconciliation failed', {
3702
+ blocker: identity,
3703
+ issue: parked.issue.key,
3704
+ error: describeError(error).errorMessage,
3705
+ });
3706
+ }
3707
+ }
3708
+ }
3248
3709
  async #dispatchBlockReason(issue) {
3249
3710
  const key = issueStateKey(issue);
3250
3711
  const state = await this.#state.getDispatchAttempts(this.#workspaceId, key);
@@ -3529,12 +3990,14 @@ export class FactoryLoop {
3529
3990
  // (relayfile-adapters#205). The factory matches state by UUID, so backfill
3530
3991
  // the id from the name when the payload omitted it — otherwise every issue
3531
3992
  // reads as stateId='' and no role (incl. readyForAgent) ever matches.
3993
+ let resolvedIssue = issue;
3532
3994
  if (issue && !issue.stateId && issue.state?.name) {
3533
3995
  const backfilled = this.#states.idForName(issue.state.name, issue.team);
3534
3996
  if (backfilled)
3535
- return { ...issue, stateId: backfilled };
3997
+ resolvedIssue = { ...issue, stateId: backfilled };
3536
3998
  }
3537
- return issue;
3999
+ this.#indexDependencyIssue(resolvedIssue);
4000
+ return resolvedIssue;
3538
4001
  }
3539
4002
  catch (error) {
3540
4003
  if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
@@ -3976,7 +4439,7 @@ export class FactoryLoop {
3976
4439
  let publishedPr;
3977
4440
  if (exiting?.spec.role === 'implementer' &&
3978
4441
  !record.dryRun &&
3979
- (this.#mount.githubWrite || this.#mount.writebackTransport === 'relayfile-cloud')) {
4442
+ this.#shouldAttemptPullRequestPublication()) {
3980
4443
  try {
3981
4444
  await this.#saveDispatchLifecycle(record, 'publishing');
3982
4445
  publishedPr = await this.#publishImplementerPullRequest(record, exiting);
@@ -4003,6 +4466,7 @@ export class FactoryLoop {
4003
4466
  repo: publishedPr.repo,
4004
4467
  prNumber: publishedPr.number,
4005
4468
  url: publishedPr.url,
4469
+ authoritative: true,
4006
4470
  });
4007
4471
  }
4008
4472
  else {
@@ -4032,8 +4496,42 @@ export class FactoryLoop {
4032
4496
  });
4033
4497
  }
4034
4498
  if (hasCompletionPr) {
4499
+ let reconciledPr;
4500
+ if (tracingReconciledExit && tracked.spec.role === 'implementer') {
4501
+ // A restart can discover that the implementer is gone after its PR
4502
+ // reached GitHub but before the durable receipt was saved. Persist the
4503
+ // exact existing-branch receipt before handing off to a babysitter;
4504
+ // otherwise the lifecycle remains `running` forever and consumes a
4505
+ // batch slot even though useful implementation work has finished.
4506
+ if (!await this.#saveDispatchLifecycle(record, 'publishing'))
4507
+ return;
4508
+ try {
4509
+ reconciledPr = await this.#publishImplementerPullRequest(record, tracked, {
4510
+ reconcileExisting: true,
4511
+ });
4512
+ if (reconciledPr && !await this.#saveDispatchLifecycle(record, 'published', reconciledPr))
4513
+ return;
4514
+ }
4515
+ catch (error) {
4516
+ this.#increment('githubPullRequestPublishFailures');
4517
+ this.#error(error, record.issue);
4518
+ this.#scheduleDispatchLifecycleRetry(record);
4519
+ return;
4520
+ }
4521
+ }
4035
4522
  if (this.#config.babysitter.enabled) {
4036
- await this.#ensureBabysitterForIssue(record);
4523
+ if (reconciledPr) {
4524
+ await this.#ensureBabysitter(record, {
4525
+ repo: reconciledPr.repo,
4526
+ prNumber: reconciledPr.number,
4527
+ url: reconciledPr.url,
4528
+ headRef: reconciledPr.headRef,
4529
+ authoritative: true,
4530
+ });
4531
+ }
4532
+ else {
4533
+ await this.#ensureBabysitterForIssue(record);
4534
+ }
4037
4535
  return;
4038
4536
  }
4039
4537
  if (await this.#allImplementersHaveCompletionPr(record))
@@ -4060,6 +4558,7 @@ export class FactoryLoop {
4060
4558
  repo: publishedPr.repo,
4061
4559
  prNumber: publishedPr.number,
4062
4560
  url: publishedPr.url,
4561
+ authoritative: true,
4063
4562
  });
4064
4563
  }
4065
4564
  else {
@@ -4175,11 +4674,14 @@ export class FactoryLoop {
4175
4674
  }
4176
4675
  // Publish a PR from the implementer's committed branch when it exited without
4177
4676
  // opening one. Best-effort and idempotent: returns undefined when there is no
4178
- // GitHub write path, no clone, or nothing publishable (no branch / no commits
4179
- // ahead of base — `#publishImplementerPullRequest` refuses head==base), so the
4180
- // caller falls back to its normal restart/conclude handling.
4677
+ // clone or nothing publishable (no branch / no commits ahead of base —
4678
+ // `#publishImplementerPullRequest` refuses head==base), so the caller falls
4679
+ // back to its normal restart/conclude handling. Explicit app identity errors
4680
+ // remain fail-closed and propagate to the lifecycle error path.
4181
4681
  async #tryPublishImplementerPr(record, implementer) {
4182
- if (record.dryRun || (!implementer.spec.clonePath && !implementer.spec.branch) || !this.#mount.githubWrite) {
4682
+ if (record.dryRun ||
4683
+ (!implementer.spec.clonePath && !implementer.spec.branch) ||
4684
+ !this.#shouldAttemptPullRequestPublication()) {
4183
4685
  return undefined;
4184
4686
  }
4185
4687
  try {
@@ -4199,6 +4701,8 @@ export class FactoryLoop {
4199
4701
  return published;
4200
4702
  }
4201
4703
  catch (error) {
4704
+ if (this.#config.github.identity === 'app' && !this.#mount.githubWrite)
4705
+ throw error;
4202
4706
  this.#increment('exitPrPublishSkipped');
4203
4707
  this.#logger.warn?.('[factory] could not publish implementer PR on exit; falling back', {
4204
4708
  issue: record.issue.key,
@@ -4214,10 +4718,7 @@ export class FactoryLoop {
4214
4718
  const cached = this.#publishedPullRequests.get(key);
4215
4719
  if (cached)
4216
4720
  return cached;
4217
- const githubWrite = this.#mount.githubWrite;
4218
- if (!githubWrite) {
4219
- throw new Error('GitHub write path not available on this mount — connect GitHub to your workspace');
4220
- }
4721
+ const { identity, publisher } = this.#githubPullRequestPublisher();
4221
4722
  const remoteBranch = implementer.result?.locality === 'remote' && implementer.spec.branch
4222
4723
  ? implementer.spec.branch
4223
4724
  : undefined;
@@ -4236,9 +4737,10 @@ export class FactoryLoop {
4236
4737
  : undefined;
4237
4738
  const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
4238
4739
  const durableReceipt = publishedPullRequests(durable).find((receipt) => receipt.repo.toLowerCase() === repo.toLowerCase());
4239
- if (durableReceipt)
4240
- return durableReceipt;
4241
4740
  const expectedHeadRef = implementer.spec.branch ?? remoteBranch;
4741
+ if (durableReceipt &&
4742
+ (!opts.reconcileExisting || !expectedHeadRef || durableReceipt.headRef === expectedHeadRef))
4743
+ return durableReceipt;
4242
4744
  if (opts.reconcileExisting && expectedHeadRef) {
4243
4745
  const existing = await this.#openPullRequestByHead(repo, expectedHeadRef);
4244
4746
  if (existing) {
@@ -4254,32 +4756,67 @@ export class FactoryLoop {
4254
4756
  }
4255
4757
  }
4256
4758
  const baseRef = await this.#githubDefaultBranch(repo);
4257
- const result = await githubWrite.publishPullRequest({
4759
+ const result = await publisher.publishPullRequest({
4258
4760
  repo,
4259
4761
  ...(remoteBranch ? { headRef: remoteBranch } : { clonePath: implementer.spec.clonePath }),
4260
4762
  baseRef,
4261
4763
  title: `${issue.key}: ${issue.title}`,
4262
4764
  body: githubPullRequestBody(issue),
4263
4765
  });
4264
- if (result.repo.toLowerCase() !== repo.toLowerCase() ||
4265
- result.headRef !== (remoteBranch ?? result.headRef) ||
4266
- !Number.isInteger(result.number) ||
4267
- result.number <= 0 ||
4268
- !result.url) {
4766
+ const published = result.author
4767
+ ? result
4768
+ : { ...result, author: identity };
4769
+ if (published.repo.toLowerCase() !== repo.toLowerCase() ||
4770
+ published.headRef !== (remoteBranch ?? published.headRef) ||
4771
+ !Number.isInteger(published.number) ||
4772
+ published.number <= 0 ||
4773
+ !published.url) {
4269
4774
  throw new Error(`GitHub PR publication returned an unexpected receipt for ${repo}/${remoteBranch ?? 'local HEAD'}`);
4270
4775
  }
4271
4776
  if (remoteBranch && this.#mount.writebackTransport === 'relayfile-cloud') {
4272
- await this.#confirmPublishedRemotePullRequest(repo, result, remoteBranch);
4777
+ await this.#confirmPublishedRemotePullRequest(repo, published, remoteBranch);
4273
4778
  }
4274
- this.#publishedPullRequests.set(key, result);
4779
+ this.#publishedPullRequests.set(key, published);
4275
4780
  this.#increment('githubPullRequestsPublished');
4276
- this.#logger.info?.('[factory] published PR through workspace GitHub connection', {
4781
+ this.#logger.info?.('[factory] published PR', {
4277
4782
  issue: issue.key,
4278
- repo: result.repo,
4279
- prNumber: result.number,
4280
- url: result.url,
4783
+ repo: published.repo,
4784
+ prNumber: published.number,
4785
+ url: published.url,
4786
+ identity,
4787
+ author: published.author,
4281
4788
  });
4282
- return result;
4789
+ return published;
4790
+ }
4791
+ #githubPullRequestPublisher() {
4792
+ const configured = this.#config.github.identity;
4793
+ if (configured !== 'user' && this.#mount.githubWrite) {
4794
+ return { identity: 'app', publisher: this.#mount.githubWrite };
4795
+ }
4796
+ if (configured === 'app') {
4797
+ throw new Error('GitHub PR identity "app" requires a connected workspace GitHub App write path; refusing to fall back to the local gh user');
4798
+ }
4799
+ const publishPullRequest = this.#githubWriteback.publishPullRequest;
4800
+ if (!publishPullRequest) {
4801
+ throw new Error(`GitHub PR identity "${configured}" requires local gh user publication, but the configured GitHub writeback does not support it`);
4802
+ }
4803
+ return {
4804
+ identity: 'user',
4805
+ publisher: { publishPullRequest: publishPullRequest.bind(this.#githubWriteback) },
4806
+ };
4807
+ }
4808
+ #shouldAttemptPullRequestPublication() {
4809
+ if (this.#config.github.identity !== 'auto')
4810
+ return true;
4811
+ if (this.#mount.githubWrite)
4812
+ return true;
4813
+ // Fake mounts deliberately avoid invoking the host's real gh binary unless
4814
+ // a publisher was injected. Every production mount (cloud or custom) still
4815
+ // gets the configured auto fallback.
4816
+ if (this.#mount.writebackTransport === 'test') {
4817
+ return Boolean(this.#githubWritebackProvided && this.#githubWriteback.publishPullRequest);
4818
+ }
4819
+ return true;
4283
4820
  }
4284
4821
  async #openPullRequestByHead(repo, expectedHeadRef) {
4285
4822
  if (this.#hasProbePrGhRunner) {
@@ -4678,7 +5215,17 @@ export class FactoryLoop {
4678
5215
  if (!tracked.sessionRef) {
4679
5216
  return;
4680
5217
  }
5218
+ this.#logger.debug?.('[factory] tracked agent resume preparation started', {
5219
+ issue: record.issue.key,
5220
+ name,
5221
+ role: tracked.spec.role,
5222
+ });
4681
5223
  await this.#prepareAgentWorktree(record, tracked.spec);
5224
+ this.#logger.debug?.('[factory] tracked agent resume spawn started', {
5225
+ issue: record.issue.key,
5226
+ name,
5227
+ role: tracked.spec.role,
5228
+ });
4682
5229
  const result = await this.#fleet.resume({
4683
5230
  name,
4684
5231
  sessionRef: tracked.sessionRef,
@@ -4687,6 +5234,12 @@ export class FactoryLoop {
4687
5234
  repo: tracked.spec.repo,
4688
5235
  clonePath: tracked.spec.clonePath,
4689
5236
  });
5237
+ this.#logger.debug?.('[factory] tracked agent resume spawn completed', {
5238
+ issue: record.issue.key,
5239
+ name,
5240
+ resumedName: result.name,
5241
+ role: tracked.spec.role,
5242
+ });
4690
5243
  tracked.result = {
4691
5244
  ...result,
4692
5245
  node: result.node ?? tracked.result?.node,
@@ -4695,35 +5248,156 @@ export class FactoryLoop {
4695
5248
  tracked.sessionRef = result.sessionRef ?? tracked.sessionRef;
4696
5249
  record.agents.delete(name);
4697
5250
  record.agents.set(result.name, tracked);
4698
- if (tracked.spec.role === 'babysitter') {
4699
- this.#babysitterCriticalAgents.delete(name);
4700
- const ownership = [...this.#babysitterPr.entries()].find(([, candidate]) => candidate.agentName === name);
4701
- const [ownershipKey, ref] = ownership ?? [];
4702
- if (ref) {
4703
- ref.agentName = result.name;
4704
- for (const [wakeKey, state] of this.#babysitterWakeStates) {
4705
- if (state.agentName !== name)
4706
- continue;
4707
- if (state.timer)
4708
- clearTimeout(state.timer);
4709
- this.#babysitterWakeStates.delete(wakeKey);
4710
- state.timer = undefined;
4711
- state.agentName = result.name;
4712
- state.tracked = tracked;
4713
- if (state.deferredSubmitTargets) {
4714
- state.deferredSubmitTargets = undefined;
4715
- state.deliveringKinds = undefined;
4716
- state.kinds.add('pull-request-state');
4717
- await this.#recordPendingBabysitterWake(state);
5251
+ await this.#retargetBabysitterAgent(record, name, tracked);
5252
+ this.#logger.debug?.('[factory] tracked agent resume ownership retargeted', {
5253
+ issue: record.issue.key,
5254
+ name,
5255
+ resumedName: result.name,
5256
+ role: tracked.spec.role,
5257
+ });
5258
+ await this.#reportAgent(record, tracked, 'agent.resumed');
5259
+ }
5260
+ async #retargetBabysitterAgent(record, previousName, tracked) {
5261
+ if (tracked.spec.role !== 'babysitter')
5262
+ return;
5263
+ const currentName = tracked.result?.name ?? tracked.spec.name;
5264
+ this.#babysitterCriticalAgents.delete(previousName);
5265
+ const ownership = [...this.#babysitterPr.entries()]
5266
+ .find(([, candidate]) => candidate.agentName === previousName);
5267
+ const [ownershipKey, ref] = ownership ?? [];
5268
+ if (!ref)
5269
+ return;
5270
+ ref.agentName = currentName;
5271
+ // Resuming a session commonly preserves its Relaycast name. Iterate a
5272
+ // snapshot because deleting and re-inserting that same key while walking
5273
+ // the live Map would append it to the iterator again indefinitely.
5274
+ for (const [wakeKey, state] of [...this.#babysitterWakeStates]) {
5275
+ if (state.agentName !== previousName)
5276
+ continue;
5277
+ if (state.timer)
5278
+ clearTimeout(state.timer);
5279
+ this.#babysitterWakeStates.delete(wakeKey);
5280
+ state.timer = undefined;
5281
+ state.agentName = currentName;
5282
+ state.tracked = tracked;
5283
+ if (state.deferredSubmitTargets) {
5284
+ state.deferredSubmitTargets = undefined;
5285
+ state.deliveringKinds = undefined;
5286
+ state.kinds.add('pull-request-state');
5287
+ await this.#recordPendingBabysitterWake(state);
5288
+ }
5289
+ this.#babysitterWakeStates.set(babysitterWakeKey(record.issue, ref), state);
5290
+ if (state.kinds.size > 0)
5291
+ this.#scheduleBabysitterWake(state, BABYSITTER_EVENT_COALESCE_MS);
5292
+ }
5293
+ await this.#persistBabysitterSession(record.issue, ref, tracked, ownershipKey);
5294
+ }
5295
+ async #recoverUnreachableBabysitter(state) {
5296
+ const batch = await this.#batch();
5297
+ const record = batch.getIssueByAgent(state.agentName);
5298
+ const tracked = record?.agents.get(state.agentName);
5299
+ if (!record || !tracked || tracked.spec.role !== 'babysitter')
5300
+ return false;
5301
+ if (!await this.#assertIssueDispatchLifecycleOwner(record.issue))
5302
+ return false;
5303
+ const previousName = state.agentName;
5304
+ this.#fleet.markAgentTerminal?.(previousName, 'babysitter-unreachable');
5305
+ try {
5306
+ await this.#fleet.release(previousName, 'babysitter-unreachable');
5307
+ this.#logger.debug?.('[factory] unreachable babysitter release completed', {
5308
+ issue: record.issue.key,
5309
+ babysitter: previousName,
5310
+ });
5311
+ }
5312
+ catch (error) {
5313
+ // An unresolvable Relaycast identity is frequently already absent from
5314
+ // placement too. Release is best-effort; the fresh spawn below is the
5315
+ // recovery operation that matters.
5316
+ this.#increment('babysitterUnreachableReleaseFailures');
5317
+ this.#logger.warn?.('[factory] unreachable babysitter release failed; attempting session recovery', {
5318
+ issue: record.issue.key,
5319
+ babysitter: previousName,
5320
+ error: describeError(error).errorMessage,
5321
+ });
5322
+ }
5323
+ try {
5324
+ const configuredCapability = this.#config.agentCapabilities.babysitter;
5325
+ const capabilityChanged = tracked.spec.capability !== configuredCapability;
5326
+ if (tracked.sessionRef && !capabilityChanged) {
5327
+ await this.#resumeTrackedAgent(record, previousName, tracked);
5328
+ }
5329
+ else {
5330
+ const invocationId = `${batch.invocationIdFor(record.issue, tracked.spec)}:unreachable:${this.#clock.now()}`;
5331
+ const { sessionRef: _staleSessionRef, ...persistedSpec } = tracked.spec;
5332
+ const replacementSpec = capabilityChanged
5333
+ ? {
5334
+ ...persistedSpec,
5335
+ capability: configuredCapability,
5336
+ model: this.#config.models.babysitter,
4718
5337
  }
4719
- this.#babysitterWakeStates.set(babysitterWakeKey(record.issue, ref), state);
4720
- if (state.kinds.size > 0)
4721
- this.#scheduleBabysitterWake(state, BABYSITTER_EVENT_COALESCE_MS);
5338
+ : persistedSpec;
5339
+ await this.#prepareAgentWorktree(record, replacementSpec);
5340
+ const result = await this.#fleet.spawn({
5341
+ name: replacementSpec.name,
5342
+ capability: replacementSpec.capability,
5343
+ node: tracked.result?.node ?? replacementSpec.node ?? 'self',
5344
+ repo: replacementSpec.repo,
5345
+ task: replacementSpec.task,
5346
+ model: replacementSpec.model,
5347
+ cwd: replacementSpec.clonePath,
5348
+ invocationId,
5349
+ restartPolicy: defaultRestartPolicy(replacementSpec),
5350
+ channel: replacementSpec.channel,
5351
+ });
5352
+ batch.recordSpawn(record, replacementSpec, invocationId, result);
5353
+ const restarted = record.agents.get(result.name);
5354
+ if (!restarted)
5355
+ throw new Error(`Recovered babysitter ${result.name} was not tracked`);
5356
+ await this.#retargetBabysitterAgent(record, previousName, restarted);
5357
+ await this.#reportAgent(record, restarted, 'agent.resumed');
5358
+ if (capabilityChanged) {
5359
+ this.#increment('babysitterCapabilityMigrations');
5360
+ this.#logger.info?.('[factory] cold-started unreachable babysitter on configured capability', {
5361
+ issue: record.issue.key,
5362
+ babysitter: result.name,
5363
+ previousCapability: tracked.spec.capability,
5364
+ capability: configuredCapability,
5365
+ });
4722
5366
  }
4723
- await this.#persistBabysitterSession(record.issue, ref, tracked, ownershipKey);
4724
5367
  }
5368
+ this.#logger.debug?.('[factory] unreachable babysitter replacement started', {
5369
+ issue: record.issue.key,
5370
+ previousBabysitter: previousName,
5371
+ babysitter: state.agentName,
5372
+ });
5373
+ await this.#writeInFlightRegistry();
5374
+ this.#logger.debug?.('[factory] unreachable babysitter registry refreshed', {
5375
+ issue: record.issue.key,
5376
+ babysitter: state.agentName,
5377
+ });
5378
+ if (!await this.#saveDispatchLifecycle(record, 'running'))
5379
+ return false;
5380
+ this.#increment('babysitterEventWakeUnreachableRecoveries');
5381
+ this.#logger.info?.('[factory] restarted unreachable babysitter session', {
5382
+ issue: record.issue.key,
5383
+ repo: state.repo,
5384
+ prNumber: state.prNumber,
5385
+ previousBabysitter: previousName,
5386
+ babysitter: state.agentName,
5387
+ });
5388
+ return true;
5389
+ }
5390
+ catch (error) {
5391
+ this.#increment('babysitterEventWakeUnreachableRecoveryFailures');
5392
+ this.#logger.warn?.('[factory] failed to restart unreachable babysitter session', {
5393
+ issue: record.issue.key,
5394
+ repo: state.repo,
5395
+ prNumber: state.prNumber,
5396
+ babysitter: previousName,
5397
+ error: describeError(error).errorMessage,
5398
+ });
5399
+ return false;
4725
5400
  }
4726
- await this.#reportAgent(record, tracked, 'agent.resumed');
4727
5401
  }
4728
5402
  async #handleDeliveryFailed(info) {
4729
5403
  const critical = await this.#state.consumeCritical(this.#workspaceId, info.msgId ?? '');
@@ -6083,27 +6757,38 @@ export class FactoryLoop {
6083
6757
  const implementerNames = decision.implementers.map((implementer) => implementer.name);
6084
6758
  const reviewerName = decision.reviewer.name;
6085
6759
  const integrationInstructions = await this.#resolveIntegrationInstructions();
6086
- const render = (spec) => ({
6087
- ...spec,
6088
- task: renderAgentTask({
6760
+ const render = async (spec) => {
6761
+ const route = routeForSpec(decision, spec);
6762
+ const testGuidance = await resolveTestGuidance({
6763
+ repoPath: route.clonePath,
6089
6764
  issue: templateIssue,
6090
- route: routeForSpec(decision, spec),
6091
- role: spec.role,
6092
- config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
6093
- reviewerName,
6094
- implementerNames,
6095
- integrationsMountRoot: this.#integrationsMountRoot(),
6096
- integrationInstructions,
6097
- branchName: spec.branch ?? decision.implementers.find((candidate) => candidate.repo === spec.repo)?.branch,
6098
- branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
6099
- agentName: spec.name,
6100
- ...(this.#fleet.lifecycleActionName ? { lifecycleActionName: this.#fleet.lifecycleActionName } : {}),
6101
- }),
6102
- });
6765
+ route,
6766
+ previewUrl: previewUrlFromSpec(spec),
6767
+ });
6768
+ return {
6769
+ ...spec,
6770
+ task: renderAgentTask({
6771
+ issue: templateIssue,
6772
+ route,
6773
+ role: spec.role,
6774
+ config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
6775
+ reviewerName,
6776
+ implementerNames,
6777
+ integrationsMountRoot: this.#integrationsMountRoot(),
6778
+ integrationInstructions,
6779
+ testGuidance,
6780
+ branchName: spec.branch ?? decision.implementers.find((candidate) => candidate.repo === spec.repo)?.branch,
6781
+ branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
6782
+ agentName: spec.name,
6783
+ ...(this.#fleet.lifecycleActionName ? { lifecycleActionName: this.#fleet.lifecycleActionName } : {}),
6784
+ }),
6785
+ };
6786
+ };
6787
+ const rendered = await Promise.all([...decision.implementers, decision.reviewer].map(render));
6103
6788
  return {
6104
6789
  ...decision,
6105
- implementers: decision.implementers.map(render),
6106
- reviewer: render(decision.reviewer),
6790
+ implementers: rendered.slice(0, decision.implementers.length),
6791
+ reviewer: rendered[decision.implementers.length],
6107
6792
  };
6108
6793
  }
6109
6794
  #consumePendingDispatchClarifications(issue) {
@@ -6195,11 +6880,15 @@ export class FactoryLoop {
6195
6880
  continue;
6196
6881
  }
6197
6882
  const record = batch.getIssue(session.issue);
6198
- const tracked = record?.agents.get(session.agentName)
6199
- ?? [...(record?.agents.values() ?? [])].find((agent) => agent.spec.role === 'babysitter' &&
6883
+ const trackedEntry = record?.agents.has(session.agentName)
6884
+ ? [session.agentName, record.agents.get(session.agentName)]
6885
+ : [...(record?.agents.entries() ?? [])].find(([, agent]) => agent.spec.role === 'babysitter' &&
6200
6886
  githubPrIdentity(agent.spec.ownedPullRequest?.repo ?? '', agent.spec.ownedPullRequest?.number ?? 0) ===
6201
- githubPrIdentity(session.repo, session.prNumber))
6887
+ githubPrIdentity(session.repo, session.prNumber));
6888
+ const tracked = trackedEntry?.[1]
6202
6889
  ?? durableBabysitterTrackedAgent(session, this.#config.agentCapabilities.babysitter);
6890
+ if (record && !trackedEntry)
6891
+ record.agents.set(session.agentName, tracked);
6203
6892
  const ref = {
6204
6893
  repo: session.repo,
6205
6894
  prNumber: session.prNumber,
@@ -6223,6 +6912,74 @@ export class FactoryLoop {
6223
6912
  }
6224
6913
  }
6225
6914
  }
6915
+ async #reconcileRestoredBabysitterReceipts(onlyRecord) {
6916
+ const records = onlyRecord ? [onlyRecord] : (await this.#batch()).inFlight;
6917
+ for (const record of records) {
6918
+ if (!await this.#assertIssueDispatchLifecycleOwner(record.issue))
6919
+ continue;
6920
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
6921
+ // Babysitter sessions are independently durable and restore only after
6922
+ // their mounted PR metadata passes the open/draft/issue-identity guard.
6923
+ // Fold those validated receipts back into the lifecycle on takeover.
6924
+ // This closes the crash gap where the babysitter session persisted but
6925
+ // the lifecycle PR receipt did not, and lets exact ownership retire any
6926
+ // earlier weak-match babysitter for the same repository. The lifecycle's
6927
+ // own exact receipts are authoritative independently of the session
6928
+ // index: session restoration can legitimately be delayed or skipped
6929
+ // while mounted PR metadata converges, but that must never preserve a
6930
+ // superseded weak-match babysitter already disproved by publication.
6931
+ const authoritative = new Map();
6932
+ for (const receipt of lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : [])) {
6933
+ if (!receipt.repo || !validPrNumber(receipt.number) || !receipt.url || !receipt.headRef)
6934
+ continue;
6935
+ const identity = githubPrIdentity(receipt.repo, receipt.number);
6936
+ if (identity)
6937
+ authoritative.set(identity, { ...receipt });
6938
+ }
6939
+ const restored = [...this.#babysitterPr.entries()]
6940
+ .filter(([ownershipKey, ref]) => ref.agentName && issueKey(this.#babysitterIssueRefs.get(ownershipKey) ?? record.issue) === issueKey(record.issue))
6941
+ .map(([, ref]) => ref);
6942
+ for (const receipt of restored) {
6943
+ if (!receipt.repo || !validPrNumber(receipt.prNumber))
6944
+ continue;
6945
+ const snapshot = await this.#readPrSnapshot(receipt);
6946
+ const headRef = snapshot?.headRef ?? record.decision.implementers
6947
+ .find((implementer) => implementer.repo.toLowerCase() === receipt.repo.toLowerCase())?.branch;
6948
+ if (!headRef)
6949
+ continue;
6950
+ const identity = githubPrIdentity(receipt.repo, receipt.prNumber);
6951
+ if (!identity)
6952
+ continue;
6953
+ authoritative.set(identity, {
6954
+ repo: receipt.repo,
6955
+ number: receipt.prNumber,
6956
+ url: snapshot?.url ?? `https://github.com/${receipt.repo}/pull/${receipt.prNumber}`,
6957
+ headRef,
6958
+ ...(receipt.path ? { path: receipt.path } : {}),
6959
+ });
6960
+ }
6961
+ if (authoritative.size > 0) {
6962
+ this.#logger.debug?.('[factory] reconciling authoritative babysitter receipts', {
6963
+ issue: record.issue.key,
6964
+ lifecycleReceipts: lifecycle?.pullRequests?.map((receipt) => receipt.number) ?? [],
6965
+ restoredReceipts: restored.map((receipt) => receipt.prNumber),
6966
+ authoritativeReceipts: [...authoritative.values()].map((receipt) => receipt.number),
6967
+ });
6968
+ await this.#retireBabysittersOutsideCurrentRoutes(record);
6969
+ }
6970
+ for (const published of authoritative.values()) {
6971
+ if (!await this.#saveDispatchLifecycle(record, 'running', published))
6972
+ return;
6973
+ await this.#ensureBabysitter(record, {
6974
+ repo: published.repo,
6975
+ prNumber: published.number,
6976
+ url: published.url,
6977
+ path: published.path,
6978
+ authoritative: true,
6979
+ });
6980
+ }
6981
+ }
6982
+ }
6226
6983
  async #drainBabysitterWakesForStop() {
6227
6984
  for (const state of this.#babysitterWakeStates.values()) {
6228
6985
  state.cancelled = true;
@@ -6516,6 +7273,7 @@ export class FactoryLoop {
6516
7273
  await this.#fleet.sendMessage(input);
6517
7274
  state.unreachableSinceMs = undefined;
6518
7275
  state.unreachableEscalated = false;
7276
+ state.unreachableRecoveryAfterMs = undefined;
6519
7277
  if (this.#stopping || state.cancelled) {
6520
7278
  state.deliveringKinds = undefined;
6521
7279
  return;
@@ -6531,6 +7289,7 @@ export class FactoryLoop {
6531
7289
  // registration-lag backoff state accumulated by prior failures.
6532
7290
  state.unreachableSinceMs = undefined;
6533
7291
  state.unreachableEscalated = false;
7292
+ state.unreachableRecoveryAfterMs = undefined;
6534
7293
  targets = ack.targets.length > 0 ? [...new Set(ack.targets)] : [input.to];
6535
7294
  }
6536
7295
  if (this.#stopping || state.cancelled)
@@ -6586,23 +7345,23 @@ export class FactoryLoop {
6586
7345
  // unreachable window so a later genuine registration lag starts fresh.
6587
7346
  state.unreachableSinceMs = undefined;
6588
7347
  state.unreachableEscalated = false;
7348
+ state.unreachableRecoveryAfterMs = undefined;
6589
7349
  }
6590
7350
  const unreachableMs = state.unreachableSinceMs !== undefined
6591
7351
  ? this.#clock.now() - state.unreachableSinceMs
6592
7352
  : 0;
6593
7353
  if (registrationLag && unreachableMs >= this.#babysitterWakeUnreachableEscalateMs) {
6594
7354
  // The agent is up but its relay identity never became resolvable. Stop
6595
- // the tight 1s loop: back off to a slow cadence (still eventually
6596
- // recovering if the agent finally enrolls) and flag it once so an
6597
- // operator can intervene (e.g. re-spawn / restart) instead of the
6598
- // failure spinning silently forever.
7355
+ // the tight 1s loop, reconcile once, and restart the session. A recovery
7356
+ // cooldown prevents a still-converging Relaycast registration from
7357
+ // turning that restart into another tight loop.
6599
7358
  state.nextDelayMs = this.#babysitterWakeUnreachableRetryMs;
6600
7359
  if (!state.unreachableEscalated) {
6601
7360
  state.unreachableEscalated = true;
6602
7361
  await this.#fleet.reconcileTrackedAgents?.();
6603
7362
  this.#increment('babysitterEventWakeUnreachableReconciliations');
6604
7363
  this.#increment('babysitterEventWakeUnreachableEscalations');
6605
- this.#logger.warn?.('[factory] babysitter unreachable past grace window; slowing wake retries and flagging for human attention', {
7364
+ this.#logger.warn?.('[factory] babysitter unreachable past grace window; reconciling and restarting its session', {
6606
7365
  issue: state.issue.key,
6607
7366
  repo: state.repo,
6608
7367
  prNumber: state.prNumber,
@@ -6612,6 +7371,19 @@ export class FactoryLoop {
6612
7371
  error: describeError(error).errorMessage,
6613
7372
  });
6614
7373
  }
7374
+ if (state.unreachableRecoveryAfterMs === undefined ||
7375
+ this.#clock.now() >= state.unreachableRecoveryAfterMs) {
7376
+ state.unreachableRecoveryAfterMs = this.#clock.now() + this.#babysitterWakeUnreachableRetryMs;
7377
+ if (await this.#recoverUnreachableBabysitter(state)) {
7378
+ // Probe the newly registered identity promptly. If Relaycast still
7379
+ // cannot resolve it, the recovery cooldown above prevents another
7380
+ // teardown loop and the next attempt uses the slow cadence.
7381
+ state.nextDelayMs = BABYSITTER_EVENT_RETRY_MS;
7382
+ }
7383
+ }
7384
+ else {
7385
+ state.nextDelayMs = Math.max(BABYSITTER_EVENT_RETRY_MS, state.unreachableRecoveryAfterMs - this.#clock.now());
7386
+ }
6615
7387
  }
6616
7388
  else {
6617
7389
  state.nextDelayMs = BABYSITTER_EVENT_RETRY_MS;
@@ -6825,6 +7597,7 @@ export class FactoryLoop {
6825
7597
  await this.#recordCanonicalIssueState({ ...issueRef(issue), stateId: doneStateId });
6826
7598
  }
6827
7599
  this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
7600
+ await this.#markDependencyTerminalAndReconcile(issue);
6828
7601
  this.#increment('mergedPrAdvancedDone');
6829
7602
  this.#increment('done');
6830
7603
  this.#logger.info?.('[factory] merged PR advanced issue to Done', {
@@ -6910,6 +7683,7 @@ export class FactoryLoop {
6910
7683
  prNumber: receipt.number,
6911
7684
  url: receipt.url,
6912
7685
  headRef: receipt.headRef,
7686
+ authoritative: true,
6913
7687
  });
6914
7688
  }
6915
7689
  return;
@@ -6931,6 +7705,9 @@ export class FactoryLoop {
6931
7705
  this.#increment('babysitterLifecycleOwnershipRejected');
6932
7706
  return;
6933
7707
  }
7708
+ const replacedSuperseded = prRef.authoritative
7709
+ ? await this.#retireSupersededBabysitters(record, prRef)
7710
+ : false;
6934
7711
  this.#babysitterIssueRefs.set(babysitterKey, { ...record.issue });
6935
7712
  const existing = this.#babysitterPr.get(babysitterKey);
6936
7713
  if (existing && githubPrIdentity(existing.repo, existing.prNumber) !== githubPrIdentity(prRef.repo, prRef.prNumber)) {
@@ -6974,6 +7751,7 @@ export class FactoryLoop {
6974
7751
  });
6975
7752
  this.#babysitterSpawned.add(babysitterKey);
6976
7753
  await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
7754
+ await this.#retargetSlackConversationToBabysitter(record);
6977
7755
  return;
6978
7756
  }
6979
7757
  // Reserve up-front so concurrent PR events in a drain don't double-spawn.
@@ -6991,7 +7769,7 @@ export class FactoryLoop {
6991
7769
  const route = record.decision.routes.find((candidate) => candidate.repo === prRef.repo)
6992
7770
  ?? record.decision.routes[0];
6993
7771
  const initialSpec = babysitterSpec(issue, this.#config, route);
6994
- if ([...this.#babysitterIssueRefs.entries()].some(([key, candidate]) => key !== babysitterKey && issueKey(candidate) === issueKey(record.issue))) {
7772
+ if (replacedSuperseded || [...this.#babysitterIssueRefs.entries()].some(([key, candidate]) => key !== babysitterKey && issueKey(candidate) === issueKey(record.issue))) {
6995
7773
  initialSpec.name = agentNameForRole(issue, 'babysit', {
6996
7774
  repo: prRef.repo,
6997
7775
  discriminator: `${sanitizeAgentSlug(prRef.repo)}-${prRef.prNumber}`,
@@ -7020,9 +7798,19 @@ export class FactoryLoop {
7020
7798
  .filter((agent) => agent.spec.role === 'implementer')
7021
7799
  .map((agent) => agent.result?.name ?? agent.spec.name);
7022
7800
  const integrationInstructions = await this.#resolveIntegrationInstructions();
7801
+ const templateIssue = templateIssueFromRecord(record, issue);
7802
+ const prSummary = await this.#github.getPr(prRef.repo, prRef.prNumber).catch(() => undefined);
7803
+ const taskRoute = { ...(route ?? { repo: prRef.repo }), clonePath: spec.clonePath ?? route?.clonePath };
7804
+ const testGuidance = await resolveTestGuidance({
7805
+ repoPath: taskRoute.clonePath,
7806
+ issue: templateIssue,
7807
+ route: taskRoute,
7808
+ changedFiles: prSummary?.filesChanged,
7809
+ previewUrl: previewUrlFromSpec(spec),
7810
+ });
7023
7811
  const task = renderAgentTask({
7024
- issue: templateIssueFromRecord(record, issue),
7025
- route: { ...(route ?? { repo: prRef.repo }), clonePath: spec.clonePath },
7812
+ issue: templateIssue,
7813
+ route: taskRoute,
7026
7814
  role: 'babysitter',
7027
7815
  config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
7028
7816
  reviewerName,
@@ -7031,6 +7819,7 @@ export class FactoryLoop {
7031
7819
  slackDispatchThread: await this.#slackDispatchThreadFor(record),
7032
7820
  integrationsMountRoot: this.#integrationsMountRoot(),
7033
7821
  integrationInstructions,
7822
+ testGuidance,
7034
7823
  branchName: spec.branch,
7035
7824
  branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
7036
7825
  agentName: spec.name,
@@ -7049,6 +7838,7 @@ export class FactoryLoop {
7049
7838
  agentName: tracked?.result?.name ?? spawned.name,
7050
7839
  });
7051
7840
  await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
7841
+ await this.#retargetSlackConversationToBabysitter(record);
7052
7842
  await this.#writeInFlightRegistry();
7053
7843
  if (!await this.#saveDispatchLifecycle(record, 'running'))
7054
7844
  return;
@@ -7093,6 +7883,108 @@ export class FactoryLoop {
7093
7883
  }
7094
7884
  }
7095
7885
  }
7886
+ async #retireSupersededBabysitters(record, prRef) {
7887
+ const wanted = githubPrIdentity(prRef.repo, prRef.prNumber);
7888
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
7889
+ const trackedAgents = new Map([
7890
+ ...(lifecycle?.agents ?? [])
7891
+ .filter((agent) => agent.releasedAtMs === undefined)
7892
+ .map((agent) => [agent.name, cloneTrackedAgent(agent.tracked)]),
7893
+ ...record.agents,
7894
+ ]);
7895
+ const superseded = [...trackedAgents.entries()].filter(([, tracked]) => {
7896
+ const owned = tracked.spec.ownedPullRequest;
7897
+ return tracked.spec.role === 'babysitter' &&
7898
+ owned?.repo.toLowerCase() === prRef.repo.toLowerCase() &&
7899
+ githubPrIdentity(owned.repo, owned.number) !== wanted;
7900
+ });
7901
+ for (const [agentName, tracked] of superseded) {
7902
+ const owned = tracked.spec.ownedPullRequest;
7903
+ const failed = await this.#releaseAndTerminateAgents([[agentName, tracked]], 'superseded-pr-receipt', 'completion');
7904
+ if (failed.length > 0) {
7905
+ this.#increment('supersededBabysitterReleaseFailures');
7906
+ throw new Error(`Failed to release superseded babysitter ${agentName}`);
7907
+ }
7908
+ if (owned) {
7909
+ const staleKey = babysitterOwnershipKey(record.issue, {
7910
+ repo: owned.repo,
7911
+ prNumber: owned.number,
7912
+ });
7913
+ await this.#cancelBabysitterWake(staleKey);
7914
+ if (await this.#assertIssueDispatchLifecycleOwner(record.issue)) {
7915
+ await this.#state.clearBabysitterSession(this.#workspaceId, staleKey);
7916
+ }
7917
+ }
7918
+ record.agents.delete(agentName);
7919
+ this.#babysitterCriticalAgents.delete(agentName);
7920
+ this.#increment('supersededBabysittersReleased');
7921
+ this.#logger.info?.('[factory] released babysitter superseded by exact PR receipt', {
7922
+ issue: record.issue.key,
7923
+ babysitter: agentName,
7924
+ previousRepo: owned?.repo,
7925
+ previousPrNumber: owned?.number,
7926
+ repo: prRef.repo,
7927
+ prNumber: prRef.prNumber,
7928
+ });
7929
+ }
7930
+ if (superseded.length > 0) {
7931
+ const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
7932
+ if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
7933
+ throw new Error(`Failed to persist superseded babysitter cleanup for ${record.issue.key}`);
7934
+ }
7935
+ }
7936
+ return superseded.length > 0;
7937
+ }
7938
+ async #retireBabysittersOutsideCurrentRoutes(record) {
7939
+ const wantedRepos = new Set(record.decision.implementers.map((implementer) => implementer.repo.toLowerCase()));
7940
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
7941
+ const trackedAgents = new Map([
7942
+ ...(lifecycle?.agents ?? [])
7943
+ .filter((agent) => agent.releasedAtMs === undefined)
7944
+ .map((agent) => [agent.name, cloneTrackedAgent(agent.tracked)]),
7945
+ ...record.agents,
7946
+ ]);
7947
+ const unrouted = [...trackedAgents.entries()].filter(([, tracked]) => {
7948
+ const owned = tracked.spec.ownedPullRequest;
7949
+ return tracked.spec.role === 'babysitter' &&
7950
+ Boolean(owned) &&
7951
+ !wantedRepos.has(owned.repo.toLowerCase());
7952
+ });
7953
+ for (const [agentName, tracked] of unrouted) {
7954
+ const owned = tracked.spec.ownedPullRequest;
7955
+ const failed = await this.#releaseAndTerminateAgents([[agentName, tracked]], 'superseded-pr-route', 'completion');
7956
+ if (failed.length > 0) {
7957
+ this.#increment('supersededBabysitterReleaseFailures');
7958
+ throw new Error(`Failed to release unrouted babysitter ${agentName}`);
7959
+ }
7960
+ if (owned) {
7961
+ const staleKey = babysitterOwnershipKey(record.issue, {
7962
+ repo: owned.repo,
7963
+ prNumber: owned.number,
7964
+ });
7965
+ await this.#cancelBabysitterWake(staleKey);
7966
+ if (await this.#assertIssueDispatchLifecycleOwner(record.issue)) {
7967
+ await this.#state.clearBabysitterSession(this.#workspaceId, staleKey);
7968
+ }
7969
+ }
7970
+ record.agents.delete(agentName);
7971
+ this.#babysitterCriticalAgents.delete(agentName);
7972
+ this.#increment('supersededBabysittersReleased');
7973
+ this.#logger.info?.('[factory] released babysitter outside the current dispatch routes', {
7974
+ issue: record.issue.key,
7975
+ babysitter: agentName,
7976
+ previousRepo: owned?.repo,
7977
+ previousPrNumber: owned?.number,
7978
+ currentRepos: [...wantedRepos],
7979
+ });
7980
+ }
7981
+ if (unrouted.length > 0) {
7982
+ const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
7983
+ if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
7984
+ throw new Error(`Failed to persist unrouted babysitter cleanup for ${record.issue.key}`);
7985
+ }
7986
+ }
7987
+ }
7096
7988
  // The babysitter owns the readiness verdict (CI green + conflicts resolved +
7097
7989
  // review comments addressed) — it sees the per-event PR webhook data in its
7098
7990
  // sandbox, exactly like AgentWorkforce/agents review. It signals readiness by
@@ -7286,6 +8178,8 @@ export class FactoryLoop {
7286
8178
  await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
7287
8179
  }
7288
8180
  this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
8181
+ if (!humanReview)
8182
+ await this.#markDependencyTerminalAndReconcile(issue);
7289
8183
  }
7290
8184
  if (!await this.#saveDispatchLifecycle(record, 'writeback-applied'))
7291
8185
  return;
@@ -7445,8 +8339,8 @@ export class FactoryLoop {
7445
8339
  });
7446
8340
  this.#emit('error', { error, ...describeError(error), issue });
7447
8341
  }
7448
- #increment(name) {
7449
- this.#counters[name] = (this.#counters[name] ?? 0) + 1;
8342
+ #increment(name, amount = 1) {
8343
+ this.#counters[name] = (this.#counters[name] ?? 0) + amount;
7450
8344
  }
7451
8345
  #recordTriageEscalation(decision, reason) {
7452
8346
  this.#increment('triageEscalations');
@@ -7682,7 +8576,11 @@ export class FactoryLoop {
7682
8576
  // the reply watcher instead of returning early, otherwise human replies in
7683
8577
  // the existing thread are watched by nobody and silently dropped.
7684
8578
  if (existingThread) {
7685
- await this.#rearmSlackWatcher(record, existingThread);
8579
+ const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(existingThread));
8580
+ await this.#ensureSlackConversationSession(record, existingThread);
8581
+ await this.#rearmSlackWatcher(record, existingThread, {
8582
+ replayConversationReplies: Boolean(durableConversation),
8583
+ });
7686
8584
  }
7687
8585
  return;
7688
8586
  }
@@ -7712,9 +8610,223 @@ export class FactoryLoop {
7712
8610
  ].join('\n'),
7713
8611
  });
7714
8612
  await this.#state.setSlackThread(this.#workspaceId, issueKey(record.issue), root.threadId);
8613
+ await this.#ensureSlackConversationSession(record, root.threadId);
7715
8614
  await this.#watchSlackThread(record, root.threadId);
7716
8615
  this.#recordSlackWritebackSuccess('dispatch-thread');
7717
8616
  }
8617
+ // Once a PR is open, the babysitter is the one driving further conversation
8618
+ // with the human — prefer it over the implementer that originally opened the
8619
+ // Slack thread, matching the pre-conversation-resume routing that sent human
8620
+ // replies to both roles once a PR existed.
8621
+ #slackConversationOwnerCandidate(record) {
8622
+ const candidates = [...record.agents.entries()].map(([name, tracked]) => ({ name, tracked }));
8623
+ return candidates.find(({ tracked }) => tracked.spec.role === 'babysitter' && Boolean(tracked.sessionRef))
8624
+ ?? candidates.find(({ tracked }) => tracked.spec.role === 'implementer' && Boolean(tracked.sessionRef));
8625
+ }
8626
+ async #ensureSlackConversationSession(record, threadId, options = {}) {
8627
+ const conversationId = slackConversationId(threadId);
8628
+ const owned = this.#slackConversationOwnerCandidate(record);
8629
+ const existing = await this.#state.getConversationSession(this.#workspaceId, conversationId);
8630
+ if (existing) {
8631
+ const sessionRef = owned?.tracked.sessionRef;
8632
+ const agentName = owned ? (owned.tracked.result?.name ?? owned.name) : undefined;
8633
+ if (owned && sessionRef &&
8634
+ (agentName !== existing.agent.name || (options.forceAgentRebind === true && sessionRef !== existing.agent.sessionRef))) {
8635
+ const rebound = await this.#state.rebindConversationSession(this.#workspaceId, conversationId, {
8636
+ name: agentName,
8637
+ sessionRef,
8638
+ node: owned.tracked.result?.node ?? owned.tracked.spec.node,
8639
+ capability: owned.tracked.spec.capability,
8640
+ repo: owned.tracked.spec.repo,
8641
+ clonePath: owned.tracked.spec.clonePath,
8642
+ });
8643
+ if (rebound)
8644
+ this.#increment('slackConversationSessionsRebound');
8645
+ }
8646
+ if (existing.pending.length > 0 || existing.delivery) {
8647
+ const waiting = await this.#state.getWaitingClarification(this.#workspaceId, issueKey(existing.issue));
8648
+ if (!waiting)
8649
+ this.#slackConversationTurns.schedule(conversationId);
8650
+ }
8651
+ return;
8652
+ }
8653
+ const sessionRef = owned?.tracked.sessionRef;
8654
+ if (!owned || !sessionRef) {
8655
+ this.#increment('slackConversationSessionsSkippedMissingSession');
8656
+ return;
8657
+ }
8658
+ const channelDir = await this.#slackChannelDir() ?? this.#config.slack?.channel;
8659
+ if (!channelDir)
8660
+ return;
8661
+ const agentName = owned.tracked.result?.name ?? owned.name;
8662
+ const reserved = await this.#state.reserveConversationSession(this.#workspaceId, conversationId, {
8663
+ provider: 'slack',
8664
+ issue: { ...record.issue },
8665
+ externalId: threadId,
8666
+ context: { channelDir },
8667
+ agent: {
8668
+ name: agentName,
8669
+ sessionRef,
8670
+ node: owned.tracked.result?.node ?? owned.tracked.spec.node,
8671
+ capability: owned.tracked.spec.capability,
8672
+ repo: owned.tracked.spec.repo,
8673
+ clonePath: owned.tracked.spec.clonePath,
8674
+ },
8675
+ history: [],
8676
+ processedMessageIds: [],
8677
+ pending: [],
8678
+ });
8679
+ if (reserved)
8680
+ this.#increment('slackConversationSessionsOwned');
8681
+ }
8682
+ // Called right after a babysitter is spawned/reattached for an issue's PR so
8683
+ // an already-owned Slack conversation session (reserved earlier by the
8684
+ // implementer) retargets onto the babysitter for the next turn, instead of
8685
+ // resuming a session the babysitter has taken over from.
8686
+ async #retargetSlackConversationToBabysitter(record) {
8687
+ if (!this.#slack || !this.#config.slack)
8688
+ return;
8689
+ const threadId = await this.#persistedSlackThread(issueKey(record.issue));
8690
+ if (!threadId)
8691
+ return;
8692
+ await this.#ensureSlackConversationSession(record, threadId, { forceAgentRebind: true });
8693
+ }
8694
+ async #scheduleSlackConversationIfPending(threadId) {
8695
+ const conversationId = slackConversationId(threadId);
8696
+ const session = await this.#state.getConversationSession(this.#workspaceId, conversationId);
8697
+ if (session && (session.pending.length > 0 || session.delivery)) {
8698
+ this.#slackConversationTurns.schedule(conversationId);
8699
+ }
8700
+ }
8701
+ async #resumeSlackConversationTurn(conversationId) {
8702
+ if (this.#stopping)
8703
+ return;
8704
+ const claimId = randomUUID();
8705
+ const claimed = await this.#state.claimConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now(), SLACK_CONVERSATION_TURN_LEASE_MS);
8706
+ if (!claimed?.delivery) {
8707
+ const current = await this.#state.getConversationSession(this.#workspaceId, conversationId);
8708
+ if (current && (current.pending.length > 0 || current.delivery)) {
8709
+ this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
8710
+ }
8711
+ return;
8712
+ }
8713
+ if (!await this.#ownsActiveSlackConversationIssue(claimed.issue)) {
8714
+ await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
8715
+ this.#increment('slackConversationTurnsSuppressedStaleOwner');
8716
+ return;
8717
+ }
8718
+ let leaseLost = false;
8719
+ let renewalInFlight = false;
8720
+ const heartbeat = setInterval(() => {
8721
+ if (renewalInFlight || leaseLost || this.#stopping)
8722
+ return;
8723
+ renewalInFlight = true;
8724
+ void this.#state.renewConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now()).then((renewed) => { leaseLost = !renewed; })
8725
+ .catch((error) => this.#logger.warn?.('[factory] Slack conversation lease renewal failed', {
8726
+ conversationId,
8727
+ error: describeError(error).errorMessage,
8728
+ }))
8729
+ .finally(() => { renewalInFlight = false; });
8730
+ }, Math.max(1_000, Math.floor(SLACK_CONVERSATION_TURN_LEASE_MS / 3)));
8731
+ heartbeat.unref?.();
8732
+ try {
8733
+ const result = await this.#fleet.resume({
8734
+ name: claimed.agent.name,
8735
+ sessionRef: claimed.agent.sessionRef,
8736
+ node: claimed.agent.node ?? 'self',
8737
+ capability: claimed.agent.capability,
8738
+ repo: claimed.agent.repo,
8739
+ clonePath: claimed.agent.clonePath,
8740
+ task: slackConversationResumeTask(claimed),
8741
+ });
8742
+ const completed = await this.#state.completeConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, { name: result.name, sessionRef: result.sessionRef });
8743
+ // The token-fenced state mutation is authoritative. A renewal already
8744
+ // queued behind this completion can observe the cleared delivery and set
8745
+ // leaseLost even though completion committed successfully.
8746
+ if (!completed) {
8747
+ this.#increment('slackConversationTurnOwnershipLost');
8748
+ return;
8749
+ }
8750
+ await this.#recordSlackConversationResume(claimed, result);
8751
+ this.#increment('slackConversationTurnsResumed');
8752
+ if (claimed.delivery.messages.length > 1) {
8753
+ this.#increment('slackConversationRepliesCoalesced', claimed.delivery.messages.length - 1);
8754
+ }
8755
+ }
8756
+ catch (error) {
8757
+ try {
8758
+ await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
8759
+ }
8760
+ catch (releaseError) {
8761
+ this.#logger.warn?.('[factory] Slack conversation claim release failed; waiting for lease recovery', {
8762
+ conversationId,
8763
+ error: describeError(releaseError).errorMessage,
8764
+ });
8765
+ }
8766
+ this.#increment('slackConversationTurnResumeFailures');
8767
+ this.#logger.warn?.('[factory] Slack conversation resume failed; retaining the turn for retry', {
8768
+ issue: claimed.issue.key,
8769
+ threadId: claimed.externalId,
8770
+ sessionRef: claimed.agent.sessionRef,
8771
+ error: describeError(error).errorMessage,
8772
+ });
8773
+ this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
8774
+ return;
8775
+ }
8776
+ finally {
8777
+ clearInterval(heartbeat);
8778
+ }
8779
+ const remaining = await this.#state.getConversationSession(this.#workspaceId, conversationId);
8780
+ if (remaining && (remaining.pending.length > 0 || remaining.delivery)) {
8781
+ this.#slackConversationTurns.schedule(conversationId);
8782
+ }
8783
+ }
8784
+ async #recordSlackConversationResume(session, result) {
8785
+ const record = (await this.#batch()).getIssue(session.issue);
8786
+ if (!record)
8787
+ return;
8788
+ const entry = [...record.agents.entries()].find(([name, tracked]) => name === session.agent.name || tracked.result?.name === session.agent.name);
8789
+ if (!entry)
8790
+ return;
8791
+ const [previousName, tracked] = entry;
8792
+ tracked.result = {
8793
+ ...tracked.result,
8794
+ ...result,
8795
+ node: result.node ?? tracked.result?.node,
8796
+ locality: result.locality ?? tracked.result?.locality,
8797
+ };
8798
+ tracked.sessionRef = result.sessionRef ?? tracked.sessionRef;
8799
+ if (result.name !== previousName) {
8800
+ record.agents.delete(previousName);
8801
+ record.agents.set(result.name, tracked);
8802
+ }
8803
+ try {
8804
+ await this.#writeInFlightRegistry();
8805
+ await this.#saveDispatchLifecycle(record, 'running');
8806
+ }
8807
+ catch (error) {
8808
+ this.#logger.warn?.('[factory] Slack conversation resume bookkeeping failed', {
8809
+ issue: session.issue.key,
8810
+ agent: result.name,
8811
+ error: describeError(error).errorMessage,
8812
+ });
8813
+ }
8814
+ }
8815
+ async #ownsActiveSlackConversationIssue(issue) {
8816
+ if (!(await this.#batch()).getIssue(issue))
8817
+ return false;
8818
+ const key = issueKey(issue);
8819
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
8820
+ if (!lifecycle)
8821
+ return true;
8822
+ if (lifecycle.phase === 'releasing' || isTerminalDispatchLifecycle(lifecycle))
8823
+ return false;
8824
+ if (!lifecycle.lease)
8825
+ return false;
8826
+ return lifecycle.lease.owner === this.#dispatchLifecycleOwner &&
8827
+ lifecycle.lease.epoch === this.#dispatchLifecycleEpochs.get(key) &&
8828
+ lifecycle.lease.leaseUntilMs > this.#clock.now();
8829
+ }
7718
8830
  async #escalateTriage(decision, reason, dryRun) {
7719
8831
  if (dryRun) {
7720
8832
  return;
@@ -7999,7 +9111,7 @@ export class FactoryLoop {
7999
9111
  this.#recordSlackWritebackSuccess('triage-escalation');
8000
9112
  return replayedResult;
8001
9113
  }
8002
- async #watchSlackThread(record, threadId) {
9114
+ async #watchSlackThread(record, threadId, options = {}) {
8003
9115
  if (!this.#config.slack) {
8004
9116
  return;
8005
9117
  }
@@ -8011,12 +9123,14 @@ export class FactoryLoop {
8011
9123
  const messagesPrefix = slackChannelMessagesPrefix(channelDir);
8012
9124
  const preExistingPaths = new Set();
8013
9125
  const preExistingPathOrder = [];
9126
+ const preExistingEvents = [];
8014
9127
  const seenReplies = new Set();
8015
9128
  const seenReplyMessages = new Set();
8016
9129
  let missingIdentityLogged = false;
8017
9130
  let cursor;
8018
9131
  let stopped = false;
8019
9132
  let pollTimer;
9133
+ const routeRetryTimers = new Map();
8020
9134
  const markPreExisting = async () => {
8021
9135
  try {
8022
9136
  const page = await this.#mount.getEvents({ limit: SLACK_REPLY_EVENTS_LIMIT });
@@ -8026,6 +9140,7 @@ export class FactoryLoop {
8026
9140
  if (path && path.startsWith(messagesPrefix)) {
8027
9141
  preExistingPaths.add(path);
8028
9142
  preExistingPathOrder.push(path);
9143
+ preExistingEvents.push(event);
8029
9144
  }
8030
9145
  }
8031
9146
  }
@@ -8033,7 +9148,8 @@ export class FactoryLoop {
8033
9148
  this.#logger.warn?.('[factory] unable to seed Slack reply watcher event cursor', error);
8034
9149
  }
8035
9150
  };
8036
- const handle = async (event) => {
9151
+ const handle = async (event, allowPreExisting = false) => {
9152
+ const routeRetryKey = eventIdentity(event) ?? changeEventPath(event) ?? randomUUID();
8037
9153
  try {
8038
9154
  // Polling-fallback / degraded-sync events can lack `resource.path`
8039
9155
  // despite the SDK type. Skip them quietly so one malformed event never
@@ -8049,7 +9165,7 @@ export class FactoryLoop {
8049
9165
  this.#logger.warn?.('[factory] Slack reply event missing stable identity; falling back to path/content dedupe');
8050
9166
  }
8051
9167
  }
8052
- if (preExistingPaths.has(path)) {
9168
+ if (!allowPreExisting && preExistingPaths.has(path)) {
8053
9169
  return;
8054
9170
  }
8055
9171
  const reply = await this.#readSlackReply(path);
@@ -8066,21 +9182,34 @@ export class FactoryLoop {
8066
9182
  this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path });
8067
9183
  return;
8068
9184
  }
8069
- seenReplies.add(replyKey);
8070
9185
  if (reply.isBot) {
9186
+ seenReplies.add(replyKey);
9187
+ seenReplyMessages.add(replyMessageKey);
8071
9188
  return;
8072
9189
  }
8073
- seenReplyMessages.add(replyMessageKey);
8074
9190
  await this.#routeSlackAnswerToImplementers(record, reply);
9191
+ // Acknowledge in memory only after the durable route succeeds. A read or
9192
+ // state-store failure must remain replayable by the poll/subscription.
9193
+ seenReplies.add(replyKey);
9194
+ seenReplyMessages.add(replyMessageKey);
8075
9195
  }
8076
9196
  catch (error) {
8077
9197
  this.#logger.error?.('[factory] failed to handle Slack reply event', error);
9198
+ if (!stopped && !routeRetryTimers.has(routeRetryKey)) {
9199
+ const retryTimer = setTimeout(() => {
9200
+ routeRetryTimers.delete(routeRetryKey);
9201
+ if (!stopped)
9202
+ void handle(event, true);
9203
+ }, SLACK_REPLY_ROUTE_RETRY_MS);
9204
+ retryTimer.unref?.();
9205
+ routeRetryTimers.set(routeRetryKey, retryTimer);
9206
+ }
8078
9207
  }
8079
9208
  };
8080
9209
  await markPreExisting();
8081
9210
  let subscription;
8082
9211
  try {
8083
- subscription = this.#mount.subscribe([`${messagesPrefix}**`], (event) => {
9212
+ subscription = this.#mount.subscribe([slackThreadReplyGlob(channelDir)], (event) => {
8084
9213
  // Receipt time is independent of the provider-authored sync timestamp.
8085
9214
  // A healthy webhook can therefore override a frozen advisory status
8086
9215
  // without trusting the same field that declared the provider stale.
@@ -8120,9 +9249,16 @@ export class FactoryLoop {
8120
9249
  clearTimeout(pollTimer);
8121
9250
  pollTimer = undefined;
8122
9251
  }
9252
+ for (const retryTimer of routeRetryTimers.values())
9253
+ clearTimeout(retryTimer);
9254
+ routeRetryTimers.clear();
8123
9255
  await this.#boundedStopTeardown('Slack reply subscription unsubscribe', () => subscription?.unsubscribe());
8124
9256
  },
8125
9257
  });
9258
+ if (options.replayConversationReplies) {
9259
+ for (const event of preExistingEvents)
9260
+ await handle(event, true);
9261
+ }
8126
9262
  return await this.#replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPathOrder);
8127
9263
  }
8128
9264
  async #replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPaths) {
@@ -8151,13 +9287,13 @@ export class FactoryLoop {
8151
9287
  // exists (persisted thread id) but has no in-process watcher. #watchSlackThread
8152
9288
  // is itself idempotent on #slackWatchers; the guard here keeps the counter (and
8153
9289
  // the seeding getEvents call) limited to genuine re-arms.
8154
- async #rearmSlackWatcher(record, threadId) {
9290
+ async #rearmSlackWatcher(record, threadId, options = {}) {
8155
9291
  const key = issueKey(record.issue);
8156
9292
  if (this.#slackWatchers.has(key) || this.#slackWatcherStarts.has(key)) {
8157
9293
  return;
8158
9294
  }
8159
9295
  try {
8160
- await this.#watchSlackThread(record, threadId);
9296
+ await this.#watchSlackThread(record, threadId, options);
8161
9297
  this.#increment('slackWatchersRearmed');
8162
9298
  }
8163
9299
  catch (error) {
@@ -8172,7 +9308,8 @@ export class FactoryLoop {
8172
9308
  if (!this.#slack || !this.#config.slack) {
8173
9309
  return;
8174
9310
  }
8175
- for (const record of (await this.#batch()).inFlight) {
9311
+ const batch = await this.#batch();
9312
+ for (const record of batch.inFlight) {
8176
9313
  if (record.dryRun) {
8177
9314
  continue;
8178
9315
  }
@@ -8191,7 +9328,27 @@ export class FactoryLoop {
8191
9328
  if (!threadId) {
8192
9329
  continue;
8193
9330
  }
8194
- await this.#rearmSlackWatcher(record, threadId);
9331
+ const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(threadId));
9332
+ await this.#ensureSlackConversationSession(record, threadId);
9333
+ await this.#rearmSlackWatcher(record, threadId, {
9334
+ replayConversationReplies: Boolean(durableConversation),
9335
+ });
9336
+ }
9337
+ for (const [conversationId, session] of await this.#state.listConversationSessions(this.#workspaceId)) {
9338
+ if (session.provider !== 'slack')
9339
+ continue;
9340
+ const threadId = session.externalId;
9341
+ const record = batch.getIssue(session.issue);
9342
+ const key = issueKey(session.issue);
9343
+ if (record && !record.dryRun && !this.#slackWatchers.has(key) && !this.#slackWatcherStarts.has(key)) {
9344
+ await this.#state.setSlackThread(this.#workspaceId, key, threadId);
9345
+ await this.#ensureSlackConversationSession(record, threadId);
9346
+ await this.#rearmSlackWatcher(record, threadId, { replayConversationReplies: true });
9347
+ }
9348
+ const waiting = await this.#state.getWaitingClarification(this.#workspaceId, key);
9349
+ if (record && !waiting && (session.pending.length > 0 || session.delivery)) {
9350
+ this.#slackConversationTurns.schedule(conversationId);
9351
+ }
8195
9352
  }
8196
9353
  await this.#sweepWaitingClarifications();
8197
9354
  for (const [, waiting] of await this.#state.listWaitingClarifications(this.#workspaceId)) {
@@ -8338,13 +9495,19 @@ export class FactoryLoop {
8338
9495
  const key = issueKey(issue);
8339
9496
  const watcher = this.#slackWatchers.get(key);
8340
9497
  this.#slackWatchers.delete(key);
8341
- await this.#state.clearSlackThread(this.#workspaceId, key);
9498
+ const threadId = await this.#state.getSlackThread(this.#workspaceId, key);
8342
9499
  await watcher?.stop();
9500
+ if (threadId) {
9501
+ const conversationId = slackConversationId(threadId);
9502
+ await this.#slackConversationTurns.cancel(conversationId);
9503
+ await this.#state.clearConversationSession(this.#workspaceId, conversationId);
9504
+ }
9505
+ await this.#state.clearSlackThread(this.#workspaceId, key);
8343
9506
  }
8344
9507
  async #readSlackReply(path) {
8345
9508
  try {
8346
9509
  const { content } = await this.#mount.readFile(path);
8347
- return parseSlackReply(path, content, this.#config.slack?.botUserId ?? 'U0B2596R7EZ');
9510
+ return parseSlackThreadReply(path, content, this.#config.slack?.botUserId ?? 'U0B2596R7EZ');
8348
9511
  }
8349
9512
  catch (error) {
8350
9513
  this.#logger.warn?.(`Unable to read Slack reply ${path}`, error);
@@ -8367,20 +9530,61 @@ export class FactoryLoop {
8367
9530
  return;
8368
9531
  }
8369
9532
  if (waiting?.threadId === reply.threadTs) {
9533
+ const replyId = `${reply.threadTs}:${reply.messageTs}`;
8370
9534
  const claimed = await this.#state.claimClarificationReply(this.#workspaceId, clarificationKey, {
8371
- id: `${reply.threadTs}:${reply.messageTs}`,
9535
+ id: replyId,
8372
9536
  text,
8373
- receivedAtMs: this.#clock.now(),
9537
+ receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
8374
9538
  source: 'slack',
9539
+ author: reply.author,
8375
9540
  });
8376
9541
  if (!claimed) {
8377
- this.#increment('clarificationDuplicateWakesSuppressed');
9542
+ const currentWaiting = await this.#state.getWaitingClarification(this.#workspaceId, clarificationKey);
9543
+ if (currentWaiting?.reply?.id === replyId) {
9544
+ this.#increment('clarificationDuplicateWakesSuppressed');
9545
+ return;
9546
+ }
9547
+ // The first reply wakes the parked clarification team. Additional
9548
+ // rapid-fire replies are ordinary conversation turns and must not be
9549
+ // discarded merely because the one-shot clarification slot is full.
9550
+ const conversationId = slackConversationId(reply.threadTs);
9551
+ const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
9552
+ id: replyId,
9553
+ text,
9554
+ receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
9555
+ providerSequence: reply.messageTs,
9556
+ author: reply.author,
9557
+ });
9558
+ if (queued) {
9559
+ this.#increment('slackConversationRepliesQueued');
9560
+ }
9561
+ else {
9562
+ this.#increment('slackConversationDuplicateRepliesSuppressed');
9563
+ }
8378
9564
  return;
8379
9565
  }
8380
9566
  this.#increment('clarificationRepliesClaimed');
8381
9567
  await this.#wakeWaitingClarification(clarificationKey, claimed);
8382
9568
  return;
8383
9569
  }
9570
+ const conversationId = slackConversationId(reply.threadTs);
9571
+ const conversation = await this.#state.getConversationSession(this.#workspaceId, conversationId);
9572
+ if (conversation && issueKey(conversation.issue) === clarificationKey) {
9573
+ const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
9574
+ id: `${reply.threadTs}:${reply.messageTs}`,
9575
+ text,
9576
+ receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
9577
+ providerSequence: reply.messageTs,
9578
+ author: reply.author,
9579
+ });
9580
+ if (!queued) {
9581
+ this.#increment('slackConversationDuplicateRepliesSuppressed');
9582
+ return;
9583
+ }
9584
+ this.#increment('slackConversationRepliesQueued');
9585
+ this.#slackConversationTurns.schedule(conversationId);
9586
+ return;
9587
+ }
8384
9588
  const liveRecord = (await this.#batch()).getIssue(record.issue);
8385
9589
  if (!liveRecord || liveRecord.dryRun) {
8386
9590
  if (isTriageEscalationWatchRecord(record)) {
@@ -8389,23 +9593,7 @@ export class FactoryLoop {
8389
9593
  this.#increment('slackAnswersIgnoredNoInFlight');
8390
9594
  return;
8391
9595
  }
8392
- if (!this.#fleet.sendInput) {
8393
- return;
8394
- }
8395
- // Route human replies to the implementers AND the babysitter — once a PR is
8396
- // open the babysitter is the one chatting with the human about it.
8397
- const recipients = [...liveRecord.agents.values()]
8398
- .filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
8399
- .map((agent) => agent.result?.name ?? agent.spec.name)
8400
- .filter((name) => Boolean(name));
8401
- if (recipients.length === 0) {
8402
- this.#increment('slackAnswersIgnoredNoImplementer');
8403
- return;
8404
- }
8405
- for (const recipient of new Set(recipients)) {
8406
- await this.#injectSlackReplyEvent(recipient, liveRecord.issue, text);
8407
- this.#increment('slackAnswersInjected');
8408
- }
9596
+ this.#increment('slackAnswersIgnoredNoConversationSession');
8409
9597
  }
8410
9598
  async #wakeWaitingClarification(key, waiting) {
8411
9599
  const existing = this.#clarificationWakeInFlight.get(key);
@@ -8561,10 +9749,15 @@ export class FactoryLoop {
8561
9749
  await renewLease();
8562
9750
  await this.#writeInFlightRegistry();
8563
9751
  await this.#saveDispatchLifecycle(record, 'running');
9752
+ if (waiting.threadId) {
9753
+ await this.#ensureSlackConversationSession(record, waiting.threadId, { forceAgentRebind: true });
9754
+ }
8564
9755
  await renewLease();
8565
9756
  const completed = await this.#state.completeClarificationWake(this.#workspaceId, key, this.#clarificationWakeOwner);
8566
9757
  if (!completed)
8567
9758
  throw new ClarificationWakeLeaseLostError('clarification wake completion lost ownership');
9759
+ if (waiting.threadId)
9760
+ await this.#scheduleSlackConversationIfPending(waiting.threadId);
8568
9761
  this.#increment('clarificationTeamsWoken');
8569
9762
  this.#logger.info?.('[factory] restarted team after human clarification', {
8570
9763
  issue: waiting.issue.key,
@@ -8804,14 +9997,6 @@ export class FactoryLoop {
8804
9997
  this.#emit('issue-queued', { issue: decision.issue });
8805
9998
  }
8806
9999
  }
8807
- // Route ordinary Slack conversation into a live agent framed as the
8808
- // <integration-event> the spawn prompt tells it to expect (not an ambiguous
8809
- // "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
8810
- // event. (A broker confirmed-delivery path via waitForInjected is a possible
8811
- // robustness follow-up.)
8812
- async #injectSlackReplyEvent(recipient, issue, text) {
8813
- await this.#fleet.sendInput?.(recipient, slackReplyEvent(issue, text));
8814
- }
8815
10000
  // Absolute path to the local .integrations mount the daemon manages. The mount
8816
10001
  // is created at the daemon's cwd (see ensureLocalMount), and spawned agents run
8817
10002
  // in their repo clonePath, so writeback paths handed to agents must be absolute
@@ -9211,6 +10396,57 @@ const githubIssueSourceRef = (issue) => {
9211
10396
  }
9212
10397
  return { owner, repo, number: number, url };
9213
10398
  };
10399
+ function dependencyRepoForIssue(issue, decision, config) {
10400
+ const source = githubIssueSourceRef(issue);
10401
+ if (source)
10402
+ return `${source.owner}/${source.repo}`;
10403
+ const mirroredRepo = githubMirrorRepoForIssue(issue);
10404
+ if (mirroredRepo)
10405
+ return mirroredRepo;
10406
+ const normalize = (repo) => {
10407
+ if (!repo)
10408
+ return undefined;
10409
+ try {
10410
+ return normalizeGithubRepo(repo, config.repos.org);
10411
+ }
10412
+ catch {
10413
+ return undefined;
10414
+ }
10415
+ };
10416
+ const decisionRepos = new Set((decision?.routes ?? [])
10417
+ .map((route) => normalize(route.repo))
10418
+ .filter((repo) => Boolean(repo)));
10419
+ if (decisionRepos.size === 1)
10420
+ return [...decisionRepos][0];
10421
+ if (decisionRepos.size > 1)
10422
+ return undefined;
10423
+ const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
10424
+ const labelRepos = new Set(Object.entries(config.repos.byLabel)
10425
+ .filter(([label]) => labels.has(label.trim().toLowerCase()))
10426
+ .map(([, repo]) => normalize(repo))
10427
+ .filter((repo) => Boolean(repo)));
10428
+ if (labelRepos.size === 1)
10429
+ return [...labelRepos][0];
10430
+ if (labelRepos.size > 1)
10431
+ return undefined;
10432
+ const projectRepo = issue.project
10433
+ ? Object.entries(config.repos.byProject)
10434
+ .find(([project]) => project.trim().toLowerCase() === issue.project.trim().toLowerCase())?.[1]
10435
+ : undefined;
10436
+ return normalize(projectRepo) ?? normalize(config.repos.default);
10437
+ }
10438
+ function dependencyIdentityForIssue(issue, repo) {
10439
+ const source = githubIssueSourceRef(issue);
10440
+ if (source)
10441
+ return dependencyIdentity(`${source.owner}/${source.repo}`, source.number);
10442
+ const pathParts = githubIssuePathParts(issue.path);
10443
+ if (pathParts)
10444
+ return dependencyIdentity(`${pathParts.owner}/${pathParts.repo}`, pathParts.number);
10445
+ const number = Number(issue.key.match(/(\d+)$/u)?.[1]);
10446
+ return repo && Number.isSafeInteger(number) && number > 0
10447
+ ? dependencyIdentity(repo, number)
10448
+ : undefined;
10449
+ }
9214
10450
  const githubIssueAuthor = (issue) => {
9215
10451
  const payload = wrappedPayload(issue.raw);
9216
10452
  const source = asRecord(payload.source);
@@ -9612,10 +10848,14 @@ const routeForSpec = (decision, spec) => {
9612
10848
  const route = decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? decision.routes.find((candidate) => candidate.repo === spec.repo);
9613
10849
  return {
9614
10850
  repo: spec.repo,
9615
- clonePath: spec.clonePath,
10851
+ clonePath: spec.clonePath ?? route?.clonePath,
9616
10852
  rationale: route?.rationale,
9617
10853
  };
9618
10854
  };
10855
+ const previewUrlFromSpec = (spec) => {
10856
+ const previewUrl = spec.previewUrl;
10857
+ return typeof previewUrl === 'string' && previewUrl.trim() ? previewUrl.trim() : undefined;
10858
+ };
9619
10859
  const repoMapFromConfig = (config) => {
9620
10860
  const repos = new Set([
9621
10861
  ...Object.values(config.repos.byLabel),
@@ -9829,7 +11069,7 @@ const linearIssueMirrorsGithubIssue = (issue, ghIssue) => {
9829
11069
  const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
9830
11070
  const candidates = [];
9831
11071
  const listErrors = [];
9832
- for (const repo of reposFromConfig(config)) {
11072
+ for (const repo of opts.repo ? [opts.repo] : reposFromConfig(config)) {
9833
11073
  const paths = new Set();
9834
11074
  for (const root of githubPullRoots(repo)) {
9835
11075
  try {
@@ -10054,7 +11294,7 @@ const readProbePrCandidate = async (mount, path) => {
10054
11294
  booleanValue(payload.crossRepository) ??
10055
11295
  (headRepo && baseRepo ? headRepo.toLowerCase() !== baseRepo.toLowerCase() : undefined),
10056
11296
  draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
10057
- state: stringValue(payload.state),
11297
+ state: booleanValue(payload.merged) === true ? 'MERGED' : stringValue(payload.state),
10058
11298
  url: stringValue(payload.url) ?? stringValue(payload.html_url),
10059
11299
  };
10060
11300
  }
@@ -10749,8 +11989,12 @@ const defaultRestartPolicy = (spec) =>
10749
11989
  spec.role === 'implementer' || spec.role === 'reviewer' || spec.role === 'babysitter'
10750
11990
  ? { maxRestarts: 0 }
10751
11991
  : spec.restartPolicy;
10752
- const slackPayloadTs = (threadId) => threadId.replace(/_/g, '.');
10753
11992
  const slackChannelMessagesPrefix = (channelDir) => `/slack/channels/${channelDir}/messages/`;
11993
+ const slackConversationId = (threadId) => `slack:${threadId}`;
11994
+ const slackMessageReceivedAtMs = (messageTs, fallback) => {
11995
+ const seconds = Number(messageTs);
11996
+ return Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds * 1_000) : fallback;
11997
+ };
10754
11998
  const eventIdentity = (event) => {
10755
11999
  const record = event;
10756
12000
  const rawId = record.id ?? record.event_id ?? record.seq;
@@ -10947,6 +12191,12 @@ const mergePublishedPullRequests = (lifecycle, receipt) => {
10947
12191
  { ...candidate },
10948
12192
  ])).values()];
10949
12193
  };
12194
+ const primaryPublishedPullRequest = (previous, receipt, receipts) => {
12195
+ if (receipt &&
12196
+ (!previous?.pullRequest || previous.pullRequest.repo.toLowerCase() === receipt.repo.toLowerCase()))
12197
+ return receipt;
12198
+ return previous?.pullRequest ?? receipt ?? receipts[0];
12199
+ };
10950
12200
  const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequest, pullRequests = [], releaseReason) => ({
10951
12201
  runId,
10952
12202
  issue: { ...record.issue },
@@ -10974,28 +12224,6 @@ const dispatchResultFromLifecycle = (lifecycle) => lifecycle.result ? structured
10974
12224
  agents: lifecycle.agents.map(({ name, tracked }) => ({ name, role: tracked.spec.role })),
10975
12225
  dryRun: lifecycle.dryRun,
10976
12226
  };
10977
- const parseSlackReply = (path, content, botUserId) => {
10978
- const raw = asRecord(parseJsonContent(content)) ?? {};
10979
- const payload = wrappedPayload(raw);
10980
- const channelDir = path.match(/^\/slack\/channels\/([^/]+)\//u)?.[1] ?? '';
10981
- const pathMatch = path.match(/^\/slack\/channels\/[^/]+\/messages\/([^/]+)(?:\/replies\/([^/]+))?/u);
10982
- const parentFromPath = pathMatch?.[2] ? slackPayloadTs(pathMatch[1]) : undefined;
10983
- const messageFromPath = slackPayloadTs(pathMatch?.[2] ?? pathMatch?.[1] ?? '');
10984
- const messageTs = stringValue(payload.ts) ?? messageFromPath;
10985
- const threadTs = stringValue(payload.thread_ts) ?? parentFromPath;
10986
- if (!channelDir || !threadTs || !messageTs) {
10987
- return undefined;
10988
- }
10989
- return {
10990
- channelDir,
10991
- threadTs,
10992
- messageTs,
10993
- text: stringValue(payload.text) ?? '',
10994
- isThreadReply: Boolean(parentFromPath) || threadTs !== messageTs,
10995
- isBot: isOwnSlackBotReply(payload, botUserId),
10996
- raw,
10997
- };
10998
- };
10999
12227
  const triageEscalationReason = (decision) => {
11000
12228
  const reasons = [];
11001
12229
  if (decision.confidence === 'low') {
@@ -11068,11 +12296,6 @@ const issueWithGithubClarification = (issue, text) => ({
11068
12296
  text,
11069
12297
  ].join('\n'),
11070
12298
  });
11071
- const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
11072
- // The human's Slack-thread reply, framed as an <integration-event> the agent is
11073
- // told (at spawn) to expect — a recognizable injected event, not an ambiguous
11074
- // keystroke. Trailing CR submits it to the agent's PTY.
11075
- const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
11076
12299
  const clarificationResumeTask = (baseTask, waiting) => {
11077
12300
  const reply = waiting.reply;
11078
12301
  return [
@@ -11084,6 +12307,27 @@ const clarificationResumeTask = (baseTask, waiting) => {
11084
12307
  'Re-hydrate from the issue, branch, worktree, and any open PR, then continue the task. Do not repeat completed work.',
11085
12308
  ].join('\n');
11086
12309
  };
12310
+ const slackConversationResumeTask = (session) => {
12311
+ const delivered = session.delivery?.messages ?? [];
12312
+ const renderMessages = (messages) => messages
12313
+ .map((message, index) => `${index + 1}. ${message.author ? `${message.author}: ` : ''}${message.text}`)
12314
+ .join('\n');
12315
+ return [
12316
+ `Continue the existing ${session.issue.key} session for one turn in its Factory-owned Slack thread.`,
12317
+ `Slack channel mount: /slack/channels/${session.context.channelDir ?? 'unknown'}`,
12318
+ `Slack thread timestamp: ${session.externalId}`,
12319
+ '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.',
12320
+ ...(session.history.length > 0
12321
+ ? ['', 'Earlier human messages in this Slack conversation:', renderMessages(session.history.slice(-20))]
12322
+ : []),
12323
+ '',
12324
+ 'New human messages coalesced into this turn, in order:',
12325
+ renderMessages(delivered),
12326
+ '',
12327
+ '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.',
12328
+ 'This input was supplied atomically as a fresh resume task. Do not request or depend on live PTY/session injection.',
12329
+ ].join('\n');
12330
+ };
11087
12331
  const isFactoryQuestionTarget = (target) => {
11088
12332
  const normalized = target.trim().replace(/^@/u, '').toLowerCase();
11089
12333
  return normalized === 'broker' || normalized === 'factory';
@@ -11179,8 +12423,6 @@ const clarificationStaleSlackText = (waiting, stakeholderUserIds = []) => [
11179
12423
  `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.`,
11180
12424
  ].filter((line) => Boolean(line)).join('\n');
11181
12425
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
11182
- const isOwnSlackBotReply = (payload, botUserId) => payload.user_is_bot === true ||
11183
- stringValue(payload.user) === botUserId;
11184
12426
  const unrefDelay = (ms) => new Promise((resolve) => {
11185
12427
  const timer = setTimeout(resolve, ms);
11186
12428
  timer.unref?.();