@agent-relay/factory 0.1.36 → 0.1.37

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.
@@ -119,6 +119,7 @@ export class FactoryLoop {
119
119
  #probeCloser;
120
120
  #probePrResolver;
121
121
  #customProbePrResolver;
122
+ #hasProbePrGhRunner;
122
123
  #probePrGhRunner;
123
124
  #logger;
124
125
  #clock;
@@ -145,6 +146,7 @@ export class FactoryLoop {
145
146
  #githubIssueCommentWatchers = new Map();
146
147
  #githubIssueCommentWatchStates = new Map();
147
148
  #githubIssueCommentQueues = new Map();
149
+ #githubIssueCommentReplays = new Map();
148
150
  #githubIssueAuthors = new Map();
149
151
  #githubIssueAuthorLookups = new Map();
150
152
  #githubIssuePreferredPaths = new Map();
@@ -210,15 +212,19 @@ export class FactoryLoop {
210
212
  #completionSweepTimer;
211
213
  #completionSweepActive = false;
212
214
  #completionInFlight = new Set();
215
+ #agentExitsInFlight = new Map();
213
216
  #agentLifecycleSignalsInFlight = new Map();
214
- // Composite issue identities for which a babysitter has already been spawned, so repeated PR
215
- // webhooks / agent-exit safety nets don't respawn it.
217
+ #startupAgentAdoptionActive = false;
218
+ // Composite issue + PR identities for which a babysitter has already been spawned, so repeated
219
+ // webhooks / agent-exit safety nets don't respawn it while multi-repository issues retain one
220
+ // owner per PR.
216
221
  #babysitterSpawned = new Set();
217
222
  #babysitterSpawnInFlight = new Map();
218
- // Composite issue identity -> the open PR the babysitter is shepherding, including the
223
+ // Composite issue + PR identity -> the open PR the babysitter is shepherding, including the
219
224
  // webhook-fed mount path so readiness can re-read PR meta without a gh call.
220
225
  #babysitterPr = new Map();
221
226
  #babysitterIssueRefs = new Map();
227
+ #babysitterReady = new Set();
222
228
  #babysitterWakeStates = new Map();
223
229
  // A babysitter announces this fence before invoking destructive git tooling
224
230
  // and clears it afterward. Event text can be broker-delivered while a prompt
@@ -265,6 +271,7 @@ export class FactoryLoop {
265
271
  this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
266
272
  this.#probeCloser = ports.probeCloser ?? closeProbePr;
267
273
  this.#customProbePrResolver = Boolean(ports.probePrResolver);
274
+ this.#hasProbePrGhRunner = Boolean(ports.probePrGhRunner);
268
275
  this.#probePrGhRunner = ports.probePrGhRunner ?? failClosedGhRunner;
269
276
  this.#probePrResolver = ports.probePrResolver ?? ((issue) => this.#resolveIssuePr(issue));
270
277
  this.#logger = normalizeLogger(ports.logger ?? console);
@@ -459,9 +466,28 @@ export class FactoryLoop {
459
466
  this.#error(new Error(`${GITHUB_ISSUE_ROOT} sub-root is not mounted`));
460
467
  return;
461
468
  }
462
- this.#wireFleetEvents();
463
- await this.#adoptInFlightAgents();
464
- await this.#restoreBabysitterOwnership();
469
+ const live = (opts.mode ?? 'live') === 'live';
470
+ // Capture the legacy registry before the first live heartbeat rewrites it.
471
+ // Durable lifecycle rows are authoritative, but this fallback is still
472
+ // required to adopt workers started by pre-lifecycle Factory versions.
473
+ const legacyRegistry = live
474
+ ? await readFactoryInFlightRegistry(this.#config.loop.registryPath)
475
+ : undefined;
476
+ if (live)
477
+ await this.#startLiveHeartbeat();
478
+ this.#startupAgentAdoptionActive = true;
479
+ try {
480
+ this.#wireFleetEvents();
481
+ await this.#adoptInFlightAgents(legacyRegistry);
482
+ this.#startupAgentAdoptionActive = false;
483
+ await this.#restoreBabysitterOwnership();
484
+ }
485
+ catch (error) {
486
+ this.#startupAgentAdoptionActive = false;
487
+ if (live)
488
+ await this.#stopLiveHeartbeat('stopping');
489
+ throw error;
490
+ }
465
491
  if (opts.mode === 'dispatch-owner') {
466
492
  this.#started = true;
467
493
  this.#scheduleDispatchLifecycleRenewal();
@@ -473,7 +499,7 @@ export class FactoryLoop {
473
499
  await this.#rearmGithubIssueCommentWatchers();
474
500
  return;
475
501
  }
476
- if ((opts.mode ?? 'live') === 'live') {
502
+ if (live) {
477
503
  this.#started = true;
478
504
  try {
479
505
  await this.#startLiveSubscription(issueSource, opts.liveSubscription);
@@ -549,6 +575,7 @@ export class FactoryLoop {
549
575
  await this.#drainClarificationWakesForStop();
550
576
  this.#clarificationIntents.clear();
551
577
  await this.#drainBabysitterWakesForStop();
578
+ await this.#drainAgentExitsInFlight();
552
579
  // Durable relay placements must survive an owner restart so a successor
553
580
  // can adopt them. The one-shot/daemon stop path releases only
554
581
  // non-durable (local/internal) records; terminal completion performs the
@@ -567,6 +594,7 @@ export class FactoryLoop {
567
594
  this.#babysitterSpawned.clear();
568
595
  this.#babysitterPr.clear();
569
596
  this.#babysitterIssueRefs.clear();
597
+ this.#babysitterReady.clear();
570
598
  this.#babysitterCriticalAgents.clear();
571
599
  const subscription = this.#subscription;
572
600
  this.#subscription = undefined;
@@ -638,7 +666,6 @@ export class FactoryLoop {
638
666
  }
639
667
  async #startLiveSubscription(issueSource, overrides = {}) {
640
668
  const options = this.#liveOptions(overrides);
641
- await this.#startLiveHeartbeat();
642
669
  this.#liveConnectStartedAtMs = this.#clock.now();
643
670
  this.#liveReplaySkewMarginMs = options.replaySkewMarginMs;
644
671
  const highWatermark = await this.#currentEventHighWatermark();
@@ -1095,6 +1122,10 @@ export class FactoryLoop {
1095
1122
  this.#completionSweepTimer.unref?.();
1096
1123
  }
1097
1124
  async #sweepPrStateCompletions(reason) {
1125
+ // This timer is also the durable safety net for fleet exit events missed
1126
+ // while the event loop was busy (for example during a large startup pull).
1127
+ // Keep reconciliation active even when babysitters own PR completion.
1128
+ await this.#fleet.reconcileTrackedAgents?.();
1098
1129
  // When the babysitter owns PR-open, completion is driven by PR webhooks +
1099
1130
  // the babysitter's readiness signal (see #handlePrChange / #handleAgentExit),
1100
1131
  // not this polling sweep. Disabling it here is what makes the babysitter path
@@ -1130,6 +1161,10 @@ export class FactoryLoop {
1130
1161
  this.#probePrGhBackoffUntilMs.set(issueStateKey(issueRef(issue)), this.#clock.now() + PROBE_PR_GH_BACKOFF_MS);
1131
1162
  return undefined;
1132
1163
  }
1164
+ if (record.decision.implementers.length > 1 && !await this.#allImplementersHaveCompletionPr(record)) {
1165
+ this.#increment('completionSweepMissingPr');
1166
+ return undefined;
1167
+ }
1133
1168
  return { record, pr };
1134
1169
  }));
1135
1170
  for (const candidate of candidates) {
@@ -1241,6 +1276,7 @@ export class FactoryLoop {
1241
1276
  await this.#recordCanonicalIssueState(issue);
1242
1277
  }
1243
1278
  issueEntries.push({ path, issue });
1279
+ await this.#refreshLiveHeartbeatIfDue();
1244
1280
  }
1245
1281
  if (issueSource === 'github') {
1246
1282
  // New ready work must not sit behind a long sequence of stale
@@ -1258,6 +1294,7 @@ export class FactoryLoop {
1258
1294
  });
1259
1295
  }
1260
1296
  for (const { issue } of issueEntries) {
1297
+ await this.#refreshLiveHeartbeatIfDue();
1261
1298
  if (!issue) {
1262
1299
  continue;
1263
1300
  }
@@ -1906,15 +1943,9 @@ export class FactoryLoop {
1906
1943
  this.#error(error, decision.issue);
1907
1944
  throw error;
1908
1945
  }
1909
- const escalationReason = triageEscalationReason(decision);
1910
- if (escalationReason) {
1911
- const replayedResult = await this.#escalateTriage(decision, escalationReason, dryRun);
1912
- this.#recordTriageEscalation(decision, escalationReason);
1913
- return replayedResult ?? { issue: decision.issue, agents: [], dryRun };
1914
- }
1915
- // TODO(AR-274 follow-up): short-circuit LLM triage once label-derived
1916
- // routes are authoritative for dispatch identity.
1917
- const labelDispatch = labelDerivedDispatchDecision(liveIssue, decision, this.#config);
1946
+ const labelDispatch = opts.labelsValidated
1947
+ ? { ok: true, decision }
1948
+ : labelDerivedDispatchDecision(liveIssue, decision, this.#config);
1918
1949
  if (!labelDispatch.ok) {
1919
1950
  const comment = labelDispatchFailureComment(decision.issue, labelDispatch);
1920
1951
  this.#logger.warn?.('[factory] skipped dispatch due to invalid repo labels', {
@@ -1940,10 +1971,16 @@ export class FactoryLoop {
1940
1971
  }
1941
1972
  return { issue: decision.issue, agents: [], comments: [comment], dryRun };
1942
1973
  }
1943
- let dispatchDecision = labelDispatch.decision;
1974
+ let dispatchDecision = authoritativeRoutedDecision(decision, labelDispatch.decision);
1944
1975
  // A valid label resolution clears any prior failure notice so a later
1945
1976
  // regression posts a fresh, actionable comment instead of being deduped.
1946
1977
  this.#labelDispatchFailures.delete(issueStateKey(dispatchDecision.issue));
1978
+ const escalationReason = triageEscalationReason(dispatchDecision);
1979
+ if (escalationReason) {
1980
+ const replayedResult = await this.#escalateTriage(dispatchDecision, escalationReason, dryRun);
1981
+ this.#recordTriageEscalation(dispatchDecision, escalationReason);
1982
+ return replayedResult ?? { issue: dispatchDecision.issue, agents: [], dryRun };
1983
+ }
1947
1984
  // Full task rendering is part of the durable spawn specification. It must
1948
1985
  // happen before a remote lifecycle is first claimed so takeover cannot
1949
1986
  // recover a persisted minimal triage task after a crash in this gap.
@@ -2134,7 +2171,25 @@ export class FactoryLoop {
2134
2171
  #wireFleetEvents() {
2135
2172
  if (!this.#offAgentExit) {
2136
2173
  this.#offAgentExit = this.#fleet.onAgentExit((name, reason) => {
2137
- void this.#handleAgentExit(name, reason);
2174
+ // Internal broker subscriptions replay historical exits immediately.
2175
+ // Ignore that pre-hydration history; the roster reconcile below runs
2176
+ // after durable records are restored and is the authoritative signal.
2177
+ if (this.#startupAgentAdoptionActive)
2178
+ return;
2179
+ // Broker replay can deliver an old exit immediately when the listener
2180
+ // is installed, before durable agents are restored. Queue a later
2181
+ // roster-reconciled exit behind it instead of dropping the newer event.
2182
+ const previous = this.#agentExitsInFlight.get(name) ?? Promise.resolve();
2183
+ const handling = previous
2184
+ .catch(() => undefined)
2185
+ .then(async () => await this.#handleAgentExit(name, reason))
2186
+ .catch((error) => this.#error(error))
2187
+ .finally(() => {
2188
+ if (this.#agentExitsInFlight.get(name) === handling) {
2189
+ this.#agentExitsInFlight.delete(name);
2190
+ }
2191
+ });
2192
+ this.#agentExitsInFlight.set(name, handling);
2138
2193
  });
2139
2194
  }
2140
2195
  if (!this.#offDeliveryFailed) {
@@ -2167,12 +2222,16 @@ export class FactoryLoop {
2167
2222
  // in the durable lifecycle store, restore their full batch/spec association,
2168
2223
  // then reconcile once so exits that happened while this process was down are
2169
2224
  // handled instead of being dropped as unknown agents.
2170
- async #adoptInFlightAgents() {
2225
+ async #adoptInFlightAgents(legacyRegistry) {
2171
2226
  try {
2172
2227
  const batch = await this.#batch();
2173
2228
  const agents = [];
2174
2229
  let hasNonterminalDurableLifecycle = false;
2175
- for (const [key, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
2230
+ const durableLifecycles = await this.#state.listDispatchLifecycles(this.#workspaceId);
2231
+ this.#logger.info?.('[factory] durable startup adoption loaded', {
2232
+ lifecycles: durableLifecycles.length,
2233
+ });
2234
+ for (const [key, lifecycle] of durableLifecycles) {
2176
2235
  if (isTerminalDispatchLifecycle(lifecycle))
2177
2236
  continue;
2178
2237
  hasNonterminalDurableLifecycle = true;
@@ -2212,7 +2271,7 @@ export class FactoryLoop {
2212
2271
  // records existed. It preserves observation, but only new lifecycle rows
2213
2272
  // carry enough decision/spec state to process the reconciled exit.
2214
2273
  if (agents.length === 0 && !hasNonterminalDurableLifecycle) {
2215
- const registry = await readFactoryInFlightRegistry(this.#config.loop.registryPath);
2274
+ const registry = legacyRegistry ?? await readFactoryInFlightRegistry(this.#config.loop.registryPath);
2216
2275
  agents.push(...(registry?.agents ?? [])
2217
2276
  .filter((agent) => agent.invocationId || agent.node)
2218
2277
  .map((agent) => ({ name: agent.name, invocationId: agent.invocationId, node: agent.node })));
@@ -2221,13 +2280,37 @@ export class FactoryLoop {
2221
2280
  this.#fleet.hydrateTracked(agents);
2222
2281
  }
2223
2282
  this.#scheduleDispatchLifecycleRenewal();
2224
- if (this.#fleet.hydrateTracked)
2283
+ if (this.#fleet.hydrateTracked) {
2284
+ this.#startupAgentAdoptionActive = false;
2285
+ this.#logger.info?.('[factory] durable startup roster reconciliation started', {
2286
+ agents: agents.map((agent) => agent.name),
2287
+ });
2225
2288
  await this.#fleet.reconcileTrackedAgents?.();
2289
+ this.#logger.info?.('[factory] durable startup roster reconciliation completed', {
2290
+ pendingExits: [...this.#agentExitsInFlight.keys()].filter((name) => agents.some((agent) => agent.name === name)),
2291
+ });
2292
+ // Fleet callbacks are intentionally synchronous at the port boundary,
2293
+ // but recovery work is asynchronous (issue reads, worktree restore,
2294
+ // PR publication). Finish exits discovered by the startup reconcile
2295
+ // 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');
2298
+ }
2226
2299
  }
2227
2300
  catch (error) {
2228
2301
  this.#logger.warn?.('[factory] failed to re-adopt durable in-flight agents', { error });
2229
2302
  }
2230
2303
  }
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);
2312
+ }
2313
+ }
2231
2314
  #scheduleDispatchLifecycleRenewal() {
2232
2315
  if (this.#dispatchLifecycleRenewTimer || this.#dispatchLifecycleEpochs.size === 0)
2233
2316
  return;
@@ -2373,7 +2456,9 @@ export class FactoryLoop {
2373
2456
  return false;
2374
2457
  }
2375
2458
  const previous = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
2376
- const lifecycle = lifecycleFromInFlightRecord(record, previous?.runId ?? randomUUID(), phase, this.#clock.now(), pullRequest ?? previous?.pullRequest, releaseReason ?? previous?.releaseReason);
2459
+ const pullRequests = mergePublishedPullRequests(previous, pullRequest);
2460
+ const primaryPullRequest = previous?.pullRequest ?? pullRequest ?? pullRequests[0];
2461
+ const lifecycle = lifecycleFromInFlightRecord(record, previous?.runId ?? randomUUID(), phase, this.#clock.now(), primaryPullRequest, pullRequests, releaseReason ?? previous?.releaseReason);
2377
2462
  for (const agent of lifecycle.agents) {
2378
2463
  const previouslyReleasedAtMs = previous?.agents.find((candidate) => candidate.name === agent.name)?.releasedAtMs;
2379
2464
  if (previouslyReleasedAtMs !== undefined)
@@ -2531,31 +2616,46 @@ export class FactoryLoop {
2531
2616
  return;
2532
2617
  }
2533
2618
  if (lifecycle.phase === 'publishing') {
2534
- const implementer = [...record.agents.values()].find((agent) => agent.spec.role === 'implementer');
2535
- if (!implementer)
2619
+ const implementers = [...record.agents.values()].filter((agent) => agent.spec.role === 'implementer');
2620
+ if (implementers.length === 0)
2536
2621
  throw new Error(`durable dispatch ${record.issue.key} has no implementer to publish`);
2537
- const published = await this.#publishImplementerPullRequest(record, implementer, { reconcileExisting: true });
2538
- if (!published)
2539
- throw new Error(`durable dispatch ${record.issue.key} did not produce a pull request`);
2540
- if (!await this.#saveDispatchLifecycle(record, 'published', published))
2622
+ const publishedReceipts = [];
2623
+ for (const implementer of implementers) {
2624
+ const published = await this.#publishImplementerPullRequest(record, implementer, { reconcileExisting: true });
2625
+ if (!published)
2626
+ throw new Error(`durable dispatch ${record.issue.key} did not produce a pull request for ${implementer.spec.repo}`);
2627
+ publishedReceipts.push(published);
2628
+ if (!await this.#saveDispatchLifecycle(record, 'publishing', published))
2629
+ return;
2630
+ }
2631
+ if (!await this.#saveDispatchLifecycle(record, 'published'))
2541
2632
  return;
2542
2633
  if (this.#config.babysitter.enabled) {
2543
- await this.#ensureBabysitter(record, {
2544
- repo: published.repo,
2545
- prNumber: published.number,
2546
- url: published.url,
2547
- });
2634
+ for (const receipt of publishedReceipts) {
2635
+ await this.#ensureBabysitter(record, {
2636
+ repo: receipt.repo,
2637
+ prNumber: receipt.number,
2638
+ url: receipt.url,
2639
+ headRef: receipt.headRef,
2640
+ });
2641
+ }
2548
2642
  return;
2549
2643
  }
2550
2644
  await this.#completeIssue(record);
2551
2645
  return;
2552
2646
  }
2553
2647
  if (lifecycle.phase === 'published' && this.#config.babysitter.enabled && lifecycle.pullRequest) {
2554
- await this.#ensureBabysitter(record, {
2555
- repo: lifecycle.pullRequest.repo,
2556
- prNumber: lifecycle.pullRequest.number,
2557
- url: lifecycle.pullRequest.url,
2558
- });
2648
+ for (const receipt of lifecycle.pullRequests ?? [lifecycle.pullRequest]) {
2649
+ await this.#ensureBabysitter(record, {
2650
+ repo: receipt.repo,
2651
+ prNumber: receipt.number,
2652
+ url: receipt.url,
2653
+ headRef: receipt.headRef,
2654
+ });
2655
+ }
2656
+ return;
2657
+ }
2658
+ if (lifecycle.phase === 'published' && !await this.#allImplementersHaveCompletionPr(record)) {
2559
2659
  return;
2560
2660
  }
2561
2661
  if (lifecycle.phase === 'published' || lifecycle.phase === 'writeback-applied') {
@@ -2726,7 +2826,7 @@ export class FactoryLoop {
2726
2826
  // the babysitter's durable ownership/wake/critical state while that epoch
2727
2827
  // is still valid so a later reopened issue cannot inherit a stale PR owner.
2728
2828
  if (this.#usesDurableDispatchLifecycle() && this.#config.babysitter.enabled) {
2729
- await this.#cancelBabysitterWake(issueKey(record.issue));
2829
+ await this.#cancelBabysittersForIssue(record.issue);
2730
2830
  }
2731
2831
  if (!await this.#saveDispatchLifecycle(record, 'complete'))
2732
2832
  return false;
@@ -2796,18 +2896,20 @@ export class FactoryLoop {
2796
2896
  return;
2797
2897
  }
2798
2898
  const decision = await this.triageIssue(issue);
2799
- const escalationReason = triageEscalationReason(decision);
2899
+ const routed = labelDerivedDispatchDecision(issue, decision, this.#config);
2900
+ const escalationDecision = routed.ok ? authoritativeRoutedDecision(decision, routed.decision) : decision;
2901
+ const escalationReason = triageEscalationReason(escalationDecision);
2800
2902
  if (escalationReason) {
2801
- await this.#escalateTriage(decision, escalationReason, this.#config.dryRun);
2802
- this.#recordTriageEscalation(decision, escalationReason);
2903
+ await this.#escalateTriage(escalationDecision, escalationReason, this.#config.dryRun);
2904
+ this.#recordTriageEscalation(escalationDecision, escalationReason);
2803
2905
  return;
2804
2906
  }
2805
2907
  if (batch.canStart()) {
2806
- await this.dispatch(decision, { dryRun: this.#config.dryRun });
2908
+ await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
2807
2909
  }
2808
2910
  else {
2809
- if (batch.queue(decision, this.#config.dryRun)) {
2810
- this.#emit('issue-queued', { issue: decision.issue });
2911
+ if (batch.queue(escalationDecision, this.#config.dryRun)) {
2912
+ this.#emit('issue-queued', { issue: escalationDecision.issue });
2811
2913
  }
2812
2914
  }
2813
2915
  }
@@ -2889,6 +2991,7 @@ export class FactoryLoop {
2889
2991
  await this.#handleGithubIssueChange(path, { ...opts, candidates });
2890
2992
  processed += 1;
2891
2993
  lastProgressAtMs = this.#logTimedProgress('[factory] GitHub issue ingestion progress', startedAtMs, lastProgressAtMs, { processed, total: paths.length, path });
2994
+ await this.#refreshLiveHeartbeatIfDue();
2892
2995
  }
2893
2996
  this.#logger.info?.('[factory] GitHub issue ingestion completed', {
2894
2997
  dryRun: opts.dryRun ?? false,
@@ -2916,6 +3019,7 @@ export class FactoryLoop {
2916
3019
  let scanned = 0;
2917
3020
  let lastProgressAtMs = startedAtMs;
2918
3021
  for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'GitHub mirror candidate loading')) {
3022
+ await this.#refreshLiveHeartbeatIfDue();
2919
3023
  if (!isLinearIssueMirrorCandidatePath(path)) {
2920
3024
  continue;
2921
3025
  }
@@ -2938,7 +3042,8 @@ export class FactoryLoop {
2938
3042
  const issuePaths = new Map();
2939
3043
  for (const root of githubIssueScanRoots(this.#config)) {
2940
3044
  const paths = await this.#listRelayfileTree(root, 'GitHub issue ingestion');
2941
- for (const path of paths) {
3045
+ for (let index = 0; index < paths.length; index += 1) {
3046
+ const path = paths[index];
2942
3047
  const parts = githubIssuePathParts(path);
2943
3048
  if (parts) {
2944
3049
  const identity = githubIssueIdentity(parts.owner, parts.repo, parts.number);
@@ -2963,7 +3068,12 @@ export class FactoryLoop {
2963
3068
  else if (isGithubIssueTreePath(path)) {
2964
3069
  this.#increment('githubIssuesIgnoredByPathRegex');
2965
3070
  }
3071
+ if ((index + 1) % LIVE_EVENT_DRAIN_BATCH_SIZE === 0) {
3072
+ await this.#refreshLiveHeartbeatIfDue();
3073
+ await liveEventYield();
3074
+ }
2966
3075
  }
3076
+ await this.#refreshLiveHeartbeatIfDue();
2967
3077
  }
2968
3078
  for (const [identity, path] of issuePaths) {
2969
3079
  this.#githubIssuePreferredPaths.set(identity, path);
@@ -3815,6 +3925,13 @@ export class FactoryLoop {
3815
3925
  }
3816
3926
  return;
3817
3927
  }
3928
+ const tracingReconciledExit = reason === 'reconciled-missing';
3929
+ if (tracingReconciledExit) {
3930
+ this.#logger.info?.('[factory] reconciled agent exit recovery started', {
3931
+ issue: record.issue.key,
3932
+ name,
3933
+ });
3934
+ }
3818
3935
  if (!await this.#assertDispatchLifecycleOwner(record)) {
3819
3936
  this.#logger.warn?.('[factory] ignored agent exit after durable lifecycle ownership was lost', {
3820
3937
  issue: record.issue.key,
@@ -3822,6 +3939,8 @@ export class FactoryLoop {
3822
3939
  });
3823
3940
  return;
3824
3941
  }
3942
+ if (tracingReconciledExit)
3943
+ this.#logger.info?.('[factory] reconciled agent exit ownership confirmed', { issue: record.issue.key, name });
3825
3944
  // The issue-comment subscription and the fleet exit callback are separate
3826
3945
  // event streams. Reconcile comments that are already durable in the mount
3827
3946
  // before interpreting a clean exit as task completion, so an agent that
@@ -3830,9 +3949,13 @@ export class FactoryLoop {
3830
3949
  this.#increment('githubQuestionExitsSuppressed');
3831
3950
  return;
3832
3951
  }
3952
+ if (tracingReconciledExit)
3953
+ this.#logger.info?.('[factory] reconciled agent exit question replay completed', { issue: record.issue.key, name });
3833
3954
  const exiting = record.agents.get(name);
3834
3955
  if (exiting)
3835
3956
  await this.#reportAgent(record, exiting, 'agent.exited', { releaseReason: reason });
3957
+ if (tracingReconciledExit)
3958
+ this.#logger.info?.('[factory] reconciled agent exit telemetry completed', { issue: record.issue.key, name });
3836
3959
  if (this.#usesDurableDispatchLifecycle()) {
3837
3960
  const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
3838
3961
  if (lifecycle?.phase === 'parking') {
@@ -3843,10 +3966,10 @@ export class FactoryLoop {
3843
3966
  if (isCompletionReason(reason)) {
3844
3967
  if (exiting?.spec.role === 'implementer' && await this.#issueHasCompletionPr(record, {
3845
3968
  openOnly: this.#config.babysitter.enabled,
3846
- })) {
3969
+ }, exiting)) {
3847
3970
  if (this.#config.babysitter.enabled)
3848
3971
  await this.#ensureBabysitterForIssue(record);
3849
- else
3972
+ else if (await this.#allImplementersHaveCompletionPr(record))
3850
3973
  await this.#completeIssue(record);
3851
3974
  return;
3852
3975
  }
@@ -3873,7 +3996,7 @@ export class FactoryLoop {
3873
3996
  // itself finishing means it believes the PR is ready, so re-check and
3874
3997
  // advance to Human Review.
3875
3998
  if (exiting?.spec.role === 'babysitter') {
3876
- await this.#maybeAdvanceToHumanReview(record);
3999
+ await this.#maybeAdvanceToHumanReview(record, name);
3877
4000
  }
3878
4001
  else if (publishedPr) {
3879
4002
  await this.#ensureBabysitter(record, {
@@ -3887,7 +4010,8 @@ export class FactoryLoop {
3887
4010
  }
3888
4011
  return;
3889
4012
  }
3890
- await this.#completeIssue(record);
4013
+ if (await this.#allImplementersHaveCompletionPr(record))
4014
+ await this.#completeIssue(record);
3891
4015
  return;
3892
4016
  }
3893
4017
  const tracked = exiting;
@@ -3895,14 +4019,25 @@ export class FactoryLoop {
3895
4019
  return;
3896
4020
  }
3897
4021
  try {
3898
- if (tracked.spec.role === 'implementer' && await this.#issueHasCompletionPr(record, {
3899
- openOnly: this.#config.babysitter.enabled,
3900
- })) {
4022
+ const hasCompletionPr = tracked.spec.role === 'implementer'
4023
+ ? await this.#issueHasCompletionPr(record, {
4024
+ openOnly: this.#config.babysitter.enabled,
4025
+ }, tracked)
4026
+ : false;
4027
+ if (tracingReconciledExit) {
4028
+ this.#logger.info?.('[factory] reconciled agent exit completion PR lookup completed', {
4029
+ issue: record.issue.key,
4030
+ name,
4031
+ hasCompletionPr,
4032
+ });
4033
+ }
4034
+ if (hasCompletionPr) {
3901
4035
  if (this.#config.babysitter.enabled) {
3902
4036
  await this.#ensureBabysitterForIssue(record);
3903
4037
  return;
3904
4038
  }
3905
- await this.#completeIssue(record);
4039
+ if (await this.#allImplementersHaveCompletionPr(record))
4040
+ await this.#completeIssue(record);
3906
4041
  return;
3907
4042
  }
3908
4043
  // The implementer's turn ended without a PR of record. Agents reliably
@@ -3915,6 +4050,8 @@ export class FactoryLoop {
3915
4050
  // ahead of base, clone gone) it returns undefined and we fall through.
3916
4051
  if (tracked.spec.role === 'implementer') {
3917
4052
  await this.#saveDispatchLifecycle(record, 'publishing');
4053
+ if (tracingReconciledExit)
4054
+ this.#logger.info?.('[factory] reconciled agent exit PR publication started', { issue: record.issue.key, name });
3918
4055
  const publishedPr = await this.#tryPublishImplementerPr(record, tracked);
3919
4056
  if (publishedPr) {
3920
4057
  await this.#saveDispatchLifecycle(record, 'published', publishedPr);
@@ -3926,7 +4063,8 @@ export class FactoryLoop {
3926
4063
  });
3927
4064
  }
3928
4065
  else {
3929
- await this.#completeIssue(record);
4066
+ if (await this.#allImplementersHaveCompletionPr(record))
4067
+ await this.#completeIssue(record);
3930
4068
  }
3931
4069
  return;
3932
4070
  }
@@ -4045,6 +4183,10 @@ export class FactoryLoop {
4045
4183
  return undefined;
4046
4184
  }
4047
4185
  try {
4186
+ // A missed exit can be reconciled after the worker checkout was pruned.
4187
+ // Re-create its deterministic worktree from the retained local branch so
4188
+ // publication can still push/read the completed commit.
4189
+ await this.#prepareAgentWorktree(record, implementer.spec);
4048
4190
  const published = await this.#publishImplementerPullRequest(record, implementer);
4049
4191
  if (published) {
4050
4192
  this.#increment('implementerPrsPublishedOnExit');
@@ -4069,8 +4211,6 @@ export class FactoryLoop {
4069
4211
  async #publishImplementerPullRequest(record, implementer, opts = {}) {
4070
4212
  const key = `${issueKey(record.issue)}:${implementer.spec.repo}`;
4071
4213
  const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
4072
- if (durable?.pullRequest)
4073
- return durable.pullRequest;
4074
4214
  const cached = this.#publishedPullRequests.get(key);
4075
4215
  if (cached)
4076
4216
  return cached;
@@ -4095,6 +4235,9 @@ export class FactoryLoop {
4095
4235
  ? sourceRepoParts.owner
4096
4236
  : undefined;
4097
4237
  const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
4238
+ const durableReceipt = publishedPullRequests(durable).find((receipt) => receipt.repo.toLowerCase() === repo.toLowerCase());
4239
+ if (durableReceipt)
4240
+ return durableReceipt;
4098
4241
  const expectedHeadRef = implementer.spec.branch ?? remoteBranch;
4099
4242
  if (opts.reconcileExisting && expectedHeadRef) {
4100
4243
  const existing = await this.#openPullRequestByHead(repo, expectedHeadRef);
@@ -4139,6 +4282,44 @@ export class FactoryLoop {
4139
4282
  return result;
4140
4283
  }
4141
4284
  async #openPullRequestByHead(repo, expectedHeadRef) {
4285
+ if (this.#hasProbePrGhRunner) {
4286
+ try {
4287
+ const result = await this.#probePrGhRunner([
4288
+ 'pr',
4289
+ 'list',
4290
+ '--repo',
4291
+ repo,
4292
+ '--head',
4293
+ expectedHeadRef,
4294
+ '--state',
4295
+ 'open',
4296
+ '--json',
4297
+ 'number,url,headRefName,isDraft',
4298
+ '--limit',
4299
+ '10',
4300
+ ]);
4301
+ const payload = parseJsonContent(result.stdout);
4302
+ if (Array.isArray(payload)) {
4303
+ const candidates = payload.flatMap((entry) => {
4304
+ const candidate = asRecord(entry);
4305
+ const number = numberValue(candidate?.number);
4306
+ const url = stringValue(candidate?.url);
4307
+ const headRef = stringValue(candidate?.headRefName);
4308
+ if (!number || !url || headRef !== expectedHeadRef || candidate?.isDraft !== false)
4309
+ return [];
4310
+ return [{ repo, number, url, headRef }];
4311
+ });
4312
+ return candidates.sort((a, b) => b.number - a.number)[0];
4313
+ }
4314
+ }
4315
+ catch (error) {
4316
+ this.#logger.warn?.('[factory] exact-head gh PR lookup failed; falling back to mounted metadata', {
4317
+ repo,
4318
+ headRef: expectedHeadRef,
4319
+ error: describeError(error).errorMessage,
4320
+ });
4321
+ }
4322
+ }
4142
4323
  const parts = githubRepoParts(repo);
4143
4324
  if (!parts)
4144
4325
  return undefined;
@@ -4450,12 +4631,22 @@ export class FactoryLoop {
4450
4631
  timer.unref?.();
4451
4632
  this.#dispatchLifecycleRetryTimers.set(key, timer);
4452
4633
  }
4453
- async #issueHasCompletionPr(record, opts = {}) {
4634
+ async #issueHasCompletionPr(record, opts = {}, implementer) {
4454
4635
  try {
4455
4636
  const issue = await this.#readIssue(record.issue.path);
4456
4637
  if (!issue) {
4457
4638
  return false;
4458
4639
  }
4640
+ if (implementer?.spec.branch && record.decision.implementers.length > 1) {
4641
+ const sourceOwner = record.issue.path
4642
+ ? githubIssuePathParts(record.issue.path)?.owner
4643
+ : undefined;
4644
+ const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
4645
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
4646
+ if (publishedPullRequests(lifecycle).some((receipt) => receipt.repo.toLowerCase() === repo.toLowerCase()))
4647
+ return true;
4648
+ return Boolean(await this.#openPullRequestByHead(repo, implementer.spec.branch));
4649
+ }
4459
4650
  // Only a NON-DRAFT (ready) PR counts as completion. A draft PR means the
4460
4651
  // work isn't review-ready, so an implementer exiting with only a draft PR
4461
4652
  // must NOT mark the issue done / release agents — mirror the
@@ -4474,6 +4665,15 @@ export class FactoryLoop {
4474
4665
  return false;
4475
4666
  }
4476
4667
  }
4668
+ async #allImplementersHaveCompletionPr(record, opts = {}) {
4669
+ const implementers = [...record.agents.values()].filter((agent) => agent.spec.role === 'implementer');
4670
+ if (implementers.length === 0)
4671
+ return false;
4672
+ if (implementers.length === 1)
4673
+ return true;
4674
+ const completed = await Promise.all(implementers.map(async (implementer) => this.#issueHasCompletionPr(record, opts, implementer)));
4675
+ return completed.every(Boolean);
4676
+ }
4477
4677
  async #resumeTrackedAgent(record, name, tracked) {
4478
4678
  if (!tracked.sessionRef) {
4479
4679
  return;
@@ -4497,11 +4697,12 @@ export class FactoryLoop {
4497
4697
  record.agents.set(result.name, tracked);
4498
4698
  if (tracked.spec.role === 'babysitter') {
4499
4699
  this.#babysitterCriticalAgents.delete(name);
4500
- const ref = this.#babysitterPr.get(issueKey(record.issue));
4700
+ const ownership = [...this.#babysitterPr.entries()].find(([, candidate]) => candidate.agentName === name);
4701
+ const [ownershipKey, ref] = ownership ?? [];
4501
4702
  if (ref) {
4502
4703
  ref.agentName = result.name;
4503
4704
  for (const [wakeKey, state] of this.#babysitterWakeStates) {
4504
- if (issueKey(state.issue) !== issueKey(record.issue))
4705
+ if (state.agentName !== name)
4505
4706
  continue;
4506
4707
  if (state.timer)
4507
4708
  clearTimeout(state.timer);
@@ -4519,7 +4720,7 @@ export class FactoryLoop {
4519
4720
  if (state.kinds.size > 0)
4520
4721
  this.#scheduleBabysitterWake(state, BABYSITTER_EVENT_COALESCE_MS);
4521
4722
  }
4522
- await this.#persistBabysitterSession(record.issue, ref, tracked);
4723
+ await this.#persistBabysitterSession(record.issue, ref, tracked, ownershipKey);
4523
4724
  }
4524
4725
  }
4525
4726
  await this.#reportAgent(record, tracked, 'agent.resumed');
@@ -4586,7 +4787,7 @@ export class FactoryLoop {
4586
4787
  return;
4587
4788
  }
4588
4789
  this.#increment('agentLifecycleReadySignals');
4589
- await this.#maybeAdvanceToHumanReview(record);
4790
+ await this.#maybeAdvanceToHumanReview(record, signal.name);
4590
4791
  return;
4591
4792
  }
4592
4793
  if (tracked.spec.role === 'babysitter') {
@@ -4686,7 +4887,7 @@ export class FactoryLoop {
4686
4887
  this.#increment('prReadySignalsIgnoredIssueMismatch');
4687
4888
  return;
4688
4889
  }
4689
- await this.#maybeAdvanceToHumanReview(record);
4890
+ await this.#maybeAdvanceToHumanReview(record, ready.agentName);
4690
4891
  return;
4691
4892
  }
4692
4893
  }
@@ -5504,13 +5705,25 @@ export class FactoryLoop {
5504
5705
  }
5505
5706
  }
5506
5707
  async #replayGithubIssueComments(key) {
5708
+ const active = this.#githubIssueCommentReplays.get(key);
5709
+ if (active)
5710
+ return await active;
5711
+ const replay = this.#runGithubIssueCommentReplay(key).finally(() => {
5712
+ if (this.#githubIssueCommentReplays.get(key) === replay) {
5713
+ this.#githubIssueCommentReplays.delete(key);
5714
+ }
5715
+ });
5716
+ this.#githubIssueCommentReplays.set(key, replay);
5717
+ return await replay;
5718
+ }
5719
+ async #runGithubIssueCommentReplay(key) {
5507
5720
  const watch = this.#githubIssueCommentWatchStates.get(key);
5508
5721
  if (!watch)
5509
5722
  return;
5510
5723
  const comments = [];
5511
5724
  const sinceCommentId = githubCommentNumericId(watch.sinceCommentId ?? watch.lastSeenCommentId);
5512
5725
  const processedCommentIds = new Set(watch.processedCommentIds ?? []);
5513
- for (const path of await this.#githubIssueCommentPaths(watch.source)) {
5726
+ for (const path of await this.#githubIssueCommentPaths(watch.source, watch.issue.path)) {
5514
5727
  const parts = githubIssueCommentPathParts(path);
5515
5728
  const id = parts ? githubCommentNumericId(parts.commentId) : undefined;
5516
5729
  if (id !== undefined && id > sinceCommentId && !processedCommentIds.has(String(id))) {
@@ -5537,14 +5750,25 @@ export class FactoryLoop {
5537
5750
  }
5538
5751
  }
5539
5752
  }
5540
- async #githubIssueCommentPaths(source) {
5753
+ async #githubIssueCommentPaths(source, issuePath) {
5541
5754
  const paths = new Set();
5542
5755
  const owner = encodeURIComponent(source.owner);
5543
5756
  const repo = encodeURIComponent(source.repo);
5544
- for (const prefix of [
5545
- `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues`,
5546
- `${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues`,
5547
- ]) {
5757
+ const issueParts = issuePath ? githubIssuePathParts(issuePath) : undefined;
5758
+ const canonicalIssueRoot = issuePath
5759
+ && issueParts?.owner.toLowerCase() === source.owner.toLowerCase()
5760
+ && issueParts.repo.toLowerCase() === source.repo.toLowerCase()
5761
+ && issueParts.number === source.number
5762
+ && /\/(?:meta|metadata)\.json$/u.test(issuePath)
5763
+ ? dirname(issuePath)
5764
+ : undefined;
5765
+ const prefixes = canonicalIssueRoot
5766
+ ? [canonicalIssueRoot]
5767
+ : [
5768
+ `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues`,
5769
+ `${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues`,
5770
+ ];
5771
+ for (const prefix of prefixes) {
5548
5772
  try {
5549
5773
  for (const path of await this.#mount.listTree(prefix)) {
5550
5774
  const parts = githubIssueCommentPathParts(path);
@@ -5941,7 +6165,8 @@ export class FactoryLoop {
5941
6165
  async #restoreBabysitterOwnership() {
5942
6166
  const batch = await this.#batch();
5943
6167
  for (const [persistedKey, session] of await this.#state.listBabysitterSessions(this.#workspaceId)) {
5944
- if (persistedKey !== issueKey(session.issue) ||
6168
+ const ownershipKey = babysitterOwnershipKey(session.issue, session);
6169
+ if ((persistedKey !== issueKey(session.issue) && persistedKey !== ownershipKey) ||
5945
6170
  !validGithubRepo(session.repo) ||
5946
6171
  !validPrNumber(session.prNumber) ||
5947
6172
  !session.agentName) {
@@ -5971,7 +6196,9 @@ export class FactoryLoop {
5971
6196
  }
5972
6197
  const record = batch.getIssue(session.issue);
5973
6198
  const tracked = record?.agents.get(session.agentName)
5974
- ?? [...(record?.agents.values() ?? [])].find((agent) => agent.spec.role === 'babysitter')
6199
+ ?? [...(record?.agents.values() ?? [])].find((agent) => agent.spec.role === 'babysitter' &&
6200
+ githubPrIdentity(agent.spec.ownedPullRequest?.repo ?? '', agent.spec.ownedPullRequest?.number ?? 0) ===
6201
+ githubPrIdentity(session.repo, session.prNumber))
5975
6202
  ?? durableBabysitterTrackedAgent(session, this.#config.agentCapabilities.babysitter);
5976
6203
  const ref = {
5977
6204
  repo: session.repo,
@@ -5979,11 +6206,15 @@ export class FactoryLoop {
5979
6206
  path: session.path,
5980
6207
  agentName: session.agentName,
5981
6208
  };
5982
- this.#babysitterPr.set(persistedKey, ref);
5983
- this.#babysitterIssueRefs.set(persistedKey, { ...session.issue });
5984
- this.#babysitterSpawned.add(persistedKey);
6209
+ this.#babysitterPr.set(ownershipKey, ref);
6210
+ this.#babysitterIssueRefs.set(ownershipKey, { ...session.issue });
6211
+ this.#babysitterSpawned.add(ownershipKey);
5985
6212
  if (session.critical)
5986
6213
  this.#babysitterCriticalAgents.add(session.agentName);
6214
+ if (persistedKey !== ownershipKey) {
6215
+ await this.#state.setBabysitterSession(this.#workspaceId, ownershipKey, session);
6216
+ await this.#state.clearBabysitterSession(this.#workspaceId, persistedKey);
6217
+ }
5987
6218
  this.#increment('babysitterOwnershipRestored');
5988
6219
  const pendingKinds = session.pendingKinds.filter(isBabysitterWakeKind);
5989
6220
  if (pendingKinds.length > 0) {
@@ -6006,12 +6237,13 @@ export class FactoryLoop {
6006
6237
  }
6007
6238
  this.#babysitterWakeStates.clear();
6008
6239
  }
6009
- async #cancelBabysitterWake(issueIdentity) {
6010
- const issue = this.#babysitterIssueRefs.get(issueIdentity);
6240
+ async #cancelBabysitterWake(ownershipKey) {
6241
+ const issue = this.#babysitterIssueRefs.get(ownershipKey);
6242
+ const ref = this.#babysitterPr.get(ownershipKey);
6011
6243
  const mayClearDurable = !this.#usesDurableDispatchLifecycle()
6012
6244
  || Boolean(issue && await this.#assertIssueDispatchLifecycleOwner(issue));
6013
6245
  for (const [key, state] of this.#babysitterWakeStates) {
6014
- if (issueKey(state.issue) !== issueIdentity)
6246
+ if (!ref || babysitterOwnershipKey(state.issue, state) !== ownershipKey)
6015
6247
  continue;
6016
6248
  state.cancelled = true;
6017
6249
  delete state.tracked.spec.pendingPullRequestWake;
@@ -6020,11 +6252,19 @@ export class FactoryLoop {
6020
6252
  this.#babysitterWakeStates.delete(key);
6021
6253
  this.#babysitterCriticalAgents.delete(state.agentName);
6022
6254
  }
6023
- this.#babysitterPr.delete(issueIdentity);
6024
- this.#babysitterIssueRefs.delete(issueIdentity);
6025
- this.#babysitterSpawned.delete(issueIdentity);
6255
+ this.#babysitterPr.delete(ownershipKey);
6256
+ this.#babysitterIssueRefs.delete(ownershipKey);
6257
+ this.#babysitterSpawned.delete(ownershipKey);
6258
+ this.#babysitterReady.delete(ownershipKey);
6026
6259
  if (mayClearDurable)
6027
- await this.#state.clearBabysitterSession(this.#workspaceId, issueIdentity);
6260
+ await this.#state.clearBabysitterSession(this.#workspaceId, ownershipKey);
6261
+ }
6262
+ async #cancelBabysittersForIssue(issue) {
6263
+ const wanted = issueKey(issue);
6264
+ const keys = [...this.#babysitterIssueRefs.entries()]
6265
+ .filter(([, candidate]) => issueKey(candidate) === wanted)
6266
+ .map(([key]) => key);
6267
+ await Promise.all(keys.map(async (key) => this.#cancelBabysitterWake(key)));
6028
6268
  }
6029
6269
  async #routeBabysitterEvent(path, extraKinds = []) {
6030
6270
  const event = githubBabysitterEventPathParts(path);
@@ -6086,7 +6326,7 @@ export class FactoryLoop {
6086
6326
  const tracked = record?.agents.get(ref.agentName)
6087
6327
  ?? [...(record?.agents.values() ?? [])].find((agent) => agent.spec.role === 'babysitter')
6088
6328
  ?? durableBabysitterTrackedAgent({ issue, repo: ref.repo, prNumber: ref.prNumber, path: ref.path, agentName: ref.agentName, critical: false, pendingKinds: [] }, this.#config.agentCapabilities.babysitter);
6089
- return { issue, record, ref, tracked };
6329
+ return { key, issue, record, ref, tracked };
6090
6330
  }
6091
6331
  }
6092
6332
  return undefined;
@@ -6125,13 +6365,16 @@ export class FactoryLoop {
6125
6365
  // Owner lookup and queueing straddle async mount/state reads. Revalidate
6126
6366
  // the exact composite owner so a concurrent close/merge cancellation can
6127
6367
  // never recreate durable state from a stale child event.
6128
- const current = this.#babysitterPr.get(issueKey(issue));
6368
+ const ownershipKey = babysitterOwnershipKey(issue, ref);
6369
+ const current = this.#babysitterPr.get(ownershipKey);
6129
6370
  if (!current ||
6130
6371
  current.agentName !== ref.agentName ||
6131
6372
  githubPrIdentity(current.repo, current.prNumber) !== githubPrIdentity(ref.repo, ref.prNumber)) {
6132
6373
  this.#increment('babysitterEventsIgnoredStaleOwner');
6133
6374
  return;
6134
6375
  }
6376
+ // Any new event invalidates a prior readiness assertion for this exact PR.
6377
+ this.#babysitterReady.delete(ownershipKey);
6135
6378
  const key = babysitterWakeKey(issue, ref);
6136
6379
  let state = this.#babysitterWakeStates.get(key);
6137
6380
  if (!state) {
@@ -6177,18 +6420,18 @@ export class FactoryLoop {
6177
6420
  kinds: [...kinds].sort(compareBabysitterWakeKinds),
6178
6421
  };
6179
6422
  }
6180
- await this.#persistBabysitterSession(state.issue, this.#babysitterPr.get(issueKey(state.issue)) ?? {
6423
+ await this.#persistBabysitterSession(state.issue, this.#babysitterPr.get(babysitterOwnershipKey(state.issue, state)) ?? {
6181
6424
  repo: state.repo,
6182
6425
  prNumber: state.prNumber,
6183
6426
  agentName: state.agentName,
6184
6427
  }, state.tracked);
6185
6428
  }
6186
- async #persistBabysitterSession(issue, ref, tracked) {
6429
+ async #persistBabysitterSession(issue, ref, tracked, ownershipKey = babysitterOwnershipKey(issue, ref)) {
6187
6430
  if (!await this.#assertIssueDispatchLifecycleOwner(issue)) {
6188
6431
  throw new Error(`Babysitter lifecycle ownership lost for ${issue.key}`);
6189
6432
  }
6190
6433
  const pending = tracked?.spec.pendingPullRequestWake;
6191
- await this.#state.setBabysitterSession(this.#workspaceId, issueKey(issue), {
6434
+ await this.#state.setBabysitterSession(this.#workspaceId, ownershipKey, {
6192
6435
  issue: { ...issue },
6193
6436
  repo: ref.repo,
6194
6437
  prNumber: ref.prNumber,
@@ -6356,6 +6599,8 @@ export class FactoryLoop {
6356
6599
  state.nextDelayMs = this.#babysitterWakeUnreachableRetryMs;
6357
6600
  if (!state.unreachableEscalated) {
6358
6601
  state.unreachableEscalated = true;
6602
+ await this.#fleet.reconcileTrackedAgents?.();
6603
+ this.#increment('babysitterEventWakeUnreachableReconciliations');
6359
6604
  this.#increment('babysitterEventWakeUnreachableEscalations');
6360
6605
  this.#logger.warn?.('[factory] babysitter unreachable past grace window; slowing wake retries and flagging for human attention', {
6361
6606
  issue: state.issue.key,
@@ -6454,18 +6699,17 @@ export class FactoryLoop {
6454
6699
  // only and can never redirect a live babysitter.
6455
6700
  const owned = await this.#babysitterOwnerFor(repo, snapshot.number);
6456
6701
  if (owned) {
6457
- const ownedKey = issueKey(owned.issue);
6458
6702
  if (prMetaShowsMerged(snapshot)) {
6459
6703
  if (owned.record)
6460
6704
  await this.#advanceMergedPrToDone(snapshot, owned.record);
6461
6705
  else
6462
- await this.#cancelBabysitterWake(ownedKey);
6706
+ await this.#cancelBabysitterWake(owned.key);
6463
6707
  return;
6464
6708
  }
6465
6709
  if (!this.#config.babysitter.enabled)
6466
6710
  return;
6467
6711
  if (snapshot.state && snapshot.state.trim().toUpperCase() !== 'OPEN') {
6468
- await this.#cancelBabysitterWake(ownedKey);
6712
+ await this.#cancelBabysitterWake(owned.key);
6469
6713
  return;
6470
6714
  }
6471
6715
  if (snapshot.draft)
@@ -6474,8 +6718,23 @@ export class FactoryLoop {
6474
6718
  return;
6475
6719
  }
6476
6720
  const record = this.#inFlightIssueForPrSnapshot(snapshot, await this.#batch(), repo);
6477
- const babysitterKey = record ? issueKey(record.issue) : undefined;
6721
+ const babysitterKey = record ? babysitterOwnershipKey(record.issue, { repo, prNumber: snapshot.number }) : undefined;
6478
6722
  const existing = babysitterKey ? this.#babysitterPr.get(babysitterKey) : undefined;
6723
+ const sameRepoOwner = record
6724
+ ? [...this.#babysitterPr.entries()].find(([key, candidate]) => issueKey(this.#babysitterIssueRefs.get(key) ?? record.issue) === issueKey(record.issue) &&
6725
+ candidate.repo.toLowerCase() === repo.toLowerCase())?.[1]
6726
+ : undefined;
6727
+ if (sameRepoOwner && githubPrIdentity(sameRepoOwner.repo, sameRepoOwner.prNumber) !== githubPrIdentity(repo, snapshot.number)) {
6728
+ this.#increment('babysitterEventsIgnoredOwnershipMismatch');
6729
+ this.#logger.warn?.('[factory] ignored PR event that conflicts with established babysitter ownership', {
6730
+ issue: record?.issue.key,
6731
+ ownedRepo: sameRepoOwner.repo,
6732
+ ownedPrNumber: sameRepoOwner.prNumber,
6733
+ eventRepo: repo,
6734
+ eventPrNumber: snapshot.number,
6735
+ });
6736
+ return;
6737
+ }
6479
6738
  if (existing && githubPrIdentity(existing.repo, existing.prNumber) !== githubPrIdentity(repo, snapshot.number)) {
6480
6739
  this.#increment('babysitterEventsIgnoredOwnershipMismatch');
6481
6740
  this.#logger.warn?.('[factory] ignored PR event that conflicts with established babysitter ownership', {
@@ -6641,8 +6900,20 @@ export class FactoryLoop {
6641
6900
  // probe resolver and spawn the babysitter. Triggered by an implementer exiting
6642
6901
  // after opening its PR (an event, not a poll).
6643
6902
  async #ensureBabysitterForIssue(record) {
6644
- if (this.#babysitterSpawned.has(issueKey(record.issue))) {
6645
- return;
6903
+ if (this.#usesDurableDispatchLifecycle()) {
6904
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
6905
+ const receipts = lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : []);
6906
+ if (receipts.length > 0) {
6907
+ for (const receipt of receipts) {
6908
+ await this.#ensureBabysitter(record, {
6909
+ repo: receipt.repo,
6910
+ prNumber: receipt.number,
6911
+ url: receipt.url,
6912
+ headRef: receipt.headRef,
6913
+ });
6914
+ }
6915
+ return;
6916
+ }
6646
6917
  }
6647
6918
  const issue = await this.#readIssue(record.issue.path);
6648
6919
  if (!issue) {
@@ -6655,7 +6926,7 @@ export class FactoryLoop {
6655
6926
  await this.#ensureBabysitter(record, { repo: pr.repo, prNumber: pr.prNumber });
6656
6927
  }
6657
6928
  async #ensureBabysitter(record, prRef) {
6658
- const babysitterKey = issueKey(record.issue);
6929
+ const babysitterKey = babysitterOwnershipKey(record.issue, prRef);
6659
6930
  if (!await this.#assertIssueDispatchLifecycleOwner(record.issue)) {
6660
6931
  this.#increment('babysitterLifecycleOwnershipRejected');
6661
6932
  return;
@@ -6684,7 +6955,9 @@ export class FactoryLoop {
6684
6955
  settled.path = prRef.path;
6685
6956
  return;
6686
6957
  }
6687
- const trackedBabysitter = [...record.agents.entries()].find(([, agent]) => agent.spec.role === 'babysitter');
6958
+ const wantedPr = githubPrIdentity(prRef.repo, prRef.prNumber);
6959
+ const trackedBabysitter = [...record.agents.entries()].find(([, agent]) => agent.spec.role === 'babysitter' &&
6960
+ githubPrIdentity(agent.spec.ownedPullRequest?.repo ?? '', agent.spec.ownedPullRequest?.number ?? 0) === wantedPr);
6688
6961
  if (trackedBabysitter) {
6689
6962
  const [trackedName, tracked] = trackedBabysitter;
6690
6963
  const owned = tracked.spec.ownedPullRequest;
@@ -6718,6 +6991,12 @@ export class FactoryLoop {
6718
6991
  const route = record.decision.routes.find((candidate) => candidate.repo === prRef.repo)
6719
6992
  ?? record.decision.routes[0];
6720
6993
  const initialSpec = babysitterSpec(issue, this.#config, route);
6994
+ if ([...this.#babysitterIssueRefs.entries()].some(([key, candidate]) => key !== babysitterKey && issueKey(candidate) === issueKey(record.issue))) {
6995
+ initialSpec.name = agentNameForRole(issue, 'babysit', {
6996
+ repo: prRef.repo,
6997
+ discriminator: `${sanitizeAgentSlug(prRef.repo)}-${prRef.prNumber}`,
6998
+ });
6999
+ }
6721
7000
  const sharedCheckout = [...record.agents.values()]
6722
7001
  .map((agent) => agent.spec)
6723
7002
  .find((candidate) => candidate.repo === initialSpec.repo && candidate.baseClonePath && candidate.clonePath)
@@ -6820,18 +7099,21 @@ export class FactoryLoop {
6820
7099
  // invoking the lifecycle action with `kind: ready`. The orchestrator trusts that signal and only
6821
7100
  // guards on the PR's OWN webhook-fed meta (still open, not a draft, not already
6822
7101
  // merged) before flipping the issue to Human Review. No `gh` call.
6823
- async #maybeAdvanceToHumanReview(record) {
7102
+ async #maybeAdvanceToHumanReview(record, agentName) {
6824
7103
  if (this.#completionInFlight.has(issueKey(record.issue))) {
6825
7104
  return;
6826
7105
  }
6827
- if (!this.#babysitterPr.has(issueKey(record.issue))) {
7106
+ const ownership = [...this.#babysitterPr.entries()].find(([key, ref]) => ref.agentName === agentName && issueKey(this.#babysitterIssueRefs.get(key) ?? record.issue) === issueKey(record.issue));
7107
+ if (!ownership) {
6828
7108
  this.#increment('babysitterReadinessGuardBlocked');
6829
7109
  this.#logger.info?.('[factory] babysitter ready signal ignored; PR ownership is no longer active', {
6830
7110
  issue: record.issue.key,
7111
+ babysitter: agentName,
6831
7112
  });
6832
7113
  return;
6833
7114
  }
6834
- const snapshot = await this.#readBabysatPrSnapshot(record);
7115
+ const [ownershipKey, ref] = ownership;
7116
+ const snapshot = await this.#readPrSnapshot(ref);
6835
7117
  if (!snapshot) {
6836
7118
  this.#increment('babysitterReadinessGuardBlocked');
6837
7119
  this.#logger.info?.('[factory] babysitter ready signal ignored; authoritative PR meta is unavailable', {
@@ -6848,6 +7130,25 @@ export class FactoryLoop {
6848
7130
  });
6849
7131
  return;
6850
7132
  }
7133
+ this.#babysitterReady.add(ownershipKey);
7134
+ await this.#ensureBabysitterForIssue(record);
7135
+ const owners = [...this.#babysitterIssueRefs.entries()]
7136
+ .filter(([, issue]) => issueKey(issue) === issueKey(record.issue))
7137
+ .map(([key]) => key);
7138
+ const expectedPrOwners = new Set(record.decision.implementers.map((implementer) => implementer.repo)).size;
7139
+ if (owners.length < expectedPrOwners) {
7140
+ this.#increment('babysitterReadinessWaitingForPeers');
7141
+ this.#logger.info?.('[factory] babysitter ready; waiting for remaining repository PRs', {
7142
+ issue: record.issue.key,
7143
+ repo: ref.repo,
7144
+ prNumber: ref.prNumber,
7145
+ });
7146
+ return;
7147
+ }
7148
+ if (owners.length === 0 || owners.some((key) => !this.#babysitterReady.has(key))) {
7149
+ this.#increment('babysitterReadinessWaitingForPeers');
7150
+ return;
7151
+ }
6851
7152
  this.#increment('babysitterReadinessReady');
6852
7153
  this.#logger.info?.('[factory] babysitter signalled PR ready; advancing to human review', {
6853
7154
  issue: record.issue.key,
@@ -6859,7 +7160,8 @@ export class FactoryLoop {
6859
7160
  // exact path captured when the babysitter was spawned; otherwise scans the
6860
7161
  // repo's pulls subtree for the PR number across known layout shapes.
6861
7162
  async #readBabysatPrSnapshot(record) {
6862
- const ref = this.#babysitterPr.get(issueKey(record.issue));
7163
+ const ref = [...this.#babysitterPr.entries()]
7164
+ .find(([key]) => issueKey(this.#babysitterIssueRefs.get(key) ?? record.issue) === issueKey(record.issue))?.[1];
6863
7165
  if (!ref) {
6864
7166
  return undefined;
6865
7167
  }
@@ -6909,7 +7211,9 @@ export class FactoryLoop {
6909
7211
  if (babysatSnapshot && prMetaShowsMerged(babysatSnapshot)) {
6910
7212
  return true;
6911
7213
  }
6912
- const pr = this.#babysitterPr.get(issueKey(record.issue)) ?? await this.#completionPrForIssue(issue);
7214
+ const pr = [...this.#babysitterPr.entries()]
7215
+ .find(([key]) => issueKey(this.#babysitterIssueRefs.get(key) ?? record.issue) === issueKey(record.issue))?.[1]
7216
+ ?? await this.#completionPrForIssue(issue);
6913
7217
  if (!pr) {
6914
7218
  return false;
6915
7219
  }
@@ -7045,9 +7349,7 @@ export class FactoryLoop {
7045
7349
  const stateKey = issueStateKey(record.issue);
7046
7350
  this.#probePrGhBackoffUntilMs.delete(stateKey);
7047
7351
  this.#probePrResolvedCache.delete(stateKey);
7048
- this.#babysitterSpawned.delete(completionKey);
7049
- this.#babysitterPr.delete(completionKey);
7050
- await this.#cancelBabysitterWake(completionKey);
7352
+ await this.#cancelBabysittersForIssue(record.issue);
7051
7353
  const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue)).catch(() => undefined);
7052
7354
  if (!this.#usesDurableDispatchLifecycle() || (durable && isTerminalDispatchLifecycle(durable))) {
7053
7355
  for (const publishedKey of this.#publishedPullRequests.keys()) {
@@ -8944,6 +9246,19 @@ function dispatchSpecs(decision) {
8944
9246
  }
8945
9247
  return [...decision.implementers, decision.reviewer];
8946
9248
  }
9249
+ function authoritativeRoutedDecision(triaged, routed) {
9250
+ if (triaged.confidence !== 'low' || triaged.routes.length > 0 || routed.routes.length === 0) {
9251
+ return routed;
9252
+ }
9253
+ return {
9254
+ ...routed,
9255
+ confidence: 'high',
9256
+ rationale: [
9257
+ routed.routes.map((route) => route.rationale).filter(Boolean).join(' '),
9258
+ 'Repository identity was resolved authoritatively from the live issue labels or GitHub source repository.',
9259
+ ].filter(Boolean).join(' '),
9260
+ };
9261
+ }
8947
9262
  function labelDerivedDispatchDecision(liveIssue, decision, config) {
8948
9263
  const routesByLabel = labelRoutesForIssue(liveIssue, config);
8949
9264
  if (routesByLabel.labels.length === 0) {
@@ -10072,6 +10387,7 @@ const decodeGithubPathSegment = (value) => {
10072
10387
  const validGithubRepo = (repo) => /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{1,100}$/u.test(repo);
10073
10388
  const validPrNumber = (value) => Number.isInteger(value) && value > 0;
10074
10389
  const githubPrIdentity = (repo, prNumber) => validGithubRepo(repo) && validPrNumber(prNumber) ? `${repo.toLowerCase()}#${prNumber}` : undefined;
10390
+ const babysitterOwnershipKey = (issue, ref) => `${issueKey(issue)}:${githubPrIdentity(ref.repo, ref.prNumber) ?? 'invalid'}`;
10075
10391
  const recordMatchesGithubRepo = (record, eventRepo, defaultOwner) => {
10076
10392
  if (!validGithubRepo(eventRepo))
10077
10393
  return false;
@@ -10088,7 +10404,7 @@ const recordMatchesGithubRepo = (record, eventRepo, defaultOwner) => {
10088
10404
  }
10089
10405
  });
10090
10406
  };
10091
- const babysitterWakeKey = (issue, ref) => `${issueKey(issue)}:${githubPrIdentity(ref.repo, ref.prNumber) ?? 'invalid'}:${ref.agentName}`;
10407
+ const babysitterWakeKey = (issue, ref) => `${babysitterOwnershipKey(issue, ref)}:${ref.agentName}`;
10092
10408
  const BABYSITTER_WAKE_KIND_ORDER = [
10093
10409
  'changes-requested',
10094
10410
  'review-comment',
@@ -10612,7 +10928,26 @@ const durableBabysitterTrackedAgent = (session, capability = 'spawn:claude') =>
10612
10928
  result: { name: session.agentName },
10613
10929
  });
10614
10930
  const isTerminalDispatchLifecycle = (lifecycle) => lifecycle.phase === 'complete' || lifecycle.phase === 'abandoned';
10615
- const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequest, releaseReason) => ({
10931
+ const publishedPullRequests = (lifecycle) => {
10932
+ const receipts = [
10933
+ ...(lifecycle?.pullRequests ?? []).filter(Boolean),
10934
+ ...(lifecycle?.pullRequest ? [lifecycle.pullRequest] : []),
10935
+ ];
10936
+ return [...new Map(receipts.map((receipt) => [
10937
+ `${receipt.repo.toLowerCase()}#${receipt.number}`,
10938
+ { ...receipt },
10939
+ ])).values()];
10940
+ };
10941
+ const mergePublishedPullRequests = (lifecycle, receipt) => {
10942
+ const receipts = publishedPullRequests(lifecycle);
10943
+ if (receipt)
10944
+ receipts.push(receipt);
10945
+ return [...new Map(receipts.map((candidate) => [
10946
+ candidate.repo.toLowerCase(),
10947
+ { ...candidate },
10948
+ ])).values()];
10949
+ };
10950
+ const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequest, pullRequests = [], releaseReason) => ({
10616
10951
  runId,
10617
10952
  issue: { ...record.issue },
10618
10953
  decision: structuredClone(record.decision),
@@ -10621,6 +10956,7 @@ const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequ
10621
10956
  agents: [...record.agents].map(([name, tracked]) => ({ name, tracked: cloneTrackedAgent(tracked) })),
10622
10957
  invocationIds: [...record.invocationIds],
10623
10958
  result: record.result ? structuredClone(record.result) : undefined,
10959
+ ...(pullRequests.length > 0 ? { pullRequests: pullRequests.map((receipt) => ({ ...receipt })) } : {}),
10624
10960
  ...(pullRequest ? { pullRequest: { ...pullRequest } } : {}),
10625
10961
  ...(releaseReason ? { releaseReason } : {}),
10626
10962
  updatedAtMs,