@agent-relay/factory 0.1.27 → 0.1.29

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.
@@ -45,6 +45,8 @@ const PUBLISHED_PR_CONFIRM_ATTEMPTS = 20;
45
45
  const PUBLISHED_PR_CONFIRM_DELAY_MS = 100;
46
46
  const SLACK_REPLY_EVENTS_LIMIT = 100;
47
47
  const SLACK_REPLY_POLL_INTERVAL_MS = 5_000;
48
+ const SLACK_IDENTITY_MESSAGE_SCAN_LIMIT = 250;
49
+ const SLACK_IDENTITY_READ_BATCH_SIZE = 25;
48
50
  const AGENT_QUESTION_DEDUPE_LIMIT = 500;
49
51
  const AGENT_NEEDS_INPUT_MARKER = '[factory-needs-input]';
50
52
  const LEGACY_AGENT_NEEDS_INPUT_MARKER = 'FACTORY_NEEDS_INPUT';
@@ -131,6 +133,8 @@ export class FactoryLoop {
131
133
  #githubIssueCommentQueues = new Map();
132
134
  #githubIssueAuthors = new Map();
133
135
  #githubIssueAuthorLookups = new Map();
136
+ #slackReporterUserIds = new Map();
137
+ #slackReporterUserIdLookups = new Map();
134
138
  #reconciledGithubInProgress = new Set();
135
139
  #resolvedSlackChannelDir;
136
140
  #slackChannelDirRefresh;
@@ -1137,6 +1141,15 @@ export class FactoryLoop {
1137
1141
  titleMarker: FACTORY_E2E_MARKER,
1138
1142
  });
1139
1143
  }
1144
+ async #openPrForIssue(issue) {
1145
+ if (this.#customProbePrResolver) {
1146
+ return this.#probePrResolver(issue);
1147
+ }
1148
+ return this.#resolveIssuePr(issue, {
1149
+ titleMarker: FACTORY_E2E_MARKER,
1150
+ openOnly: true,
1151
+ });
1152
+ }
1140
1153
  async #resolveIssuePr(issue, opts = {}) {
1141
1154
  const issueKey = issueStateKey(issueRef(issue));
1142
1155
  const key = opts.openOnly ? `${issueKey}:open` : issueKey;
@@ -1193,6 +1206,7 @@ export class FactoryLoop {
1193
1206
  const skipped = [];
1194
1207
  let lastReadyReadProgressAtMs = this.#clock.now();
1195
1208
  let readyIssueReads = 0;
1209
+ const issueEntries = [];
1196
1210
  for (const path of paths) {
1197
1211
  const issue = await this.#readIssue(path);
1198
1212
  readyIssueReads += 1;
@@ -1202,6 +1216,24 @@ export class FactoryLoop {
1202
1216
  if (issue && issueSource === 'linear') {
1203
1217
  await this.#recordCanonicalIssueState(issue);
1204
1218
  }
1219
+ issueEntries.push({ path, issue });
1220
+ }
1221
+ if (issueSource === 'github') {
1222
+ // New ready work must not sit behind a long sequence of stale
1223
+ // in-progress recoveries. Load the canonical snapshots first, then
1224
+ // prioritize genuinely ready issues over orphan-recovery candidates.
1225
+ // Within either bucket, resume the most recently changed provider work
1226
+ // first so a just-interrupted dispatch does not sit behind an old
1227
+ // numeric backlog of leaked in-progress labels.
1228
+ issueEntries.sort((left, right) => {
1229
+ const readiness = Number(Boolean(right.issue && this.#isIssueReady(right.issue))) -
1230
+ Number(Boolean(left.issue && this.#isIssueReady(left.issue)));
1231
+ if (readiness !== 0)
1232
+ return readiness;
1233
+ return githubIssueUpdatedAtMs(right.issue) - githubIssueUpdatedAtMs(left.issue);
1234
+ });
1235
+ }
1236
+ for (const { issue } of issueEntries) {
1205
1237
  if (!issue) {
1206
1238
  continue;
1207
1239
  }
@@ -1263,7 +1295,19 @@ export class FactoryLoop {
1263
1295
  }
1264
1296
  const decision = await this.triageIssue(issue);
1265
1297
  triaged.push(decision);
1266
- const result = await this.dispatch(decision, { dryRun });
1298
+ let result;
1299
+ try {
1300
+ result = await this.dispatch(decision, { dryRun });
1301
+ }
1302
+ catch (error) {
1303
+ if (!(error instanceof LiveDispatchStateChangedError))
1304
+ throw error;
1305
+ skipped.push({ issue: decision.issue, reason: 'live state changed during dispatch' });
1306
+ this.#logger.info?.('[factory] skipped issue whose live state changed during dispatch', {
1307
+ issue: decision.issue.key,
1308
+ });
1309
+ continue;
1310
+ }
1267
1311
  if (result.agents.length === 0 && !dryRun) {
1268
1312
  skipped.push({ issue: decision.issue, reason: 'queued or escalated' });
1269
1313
  }
@@ -1384,9 +1428,9 @@ export class FactoryLoop {
1384
1428
  this.#increment('githubOrphanRecoveriesBlockedProviderStatus');
1385
1429
  return false;
1386
1430
  }
1387
- let hasOpenPr;
1431
+ let openPr;
1388
1432
  try {
1389
- hasOpenPr = await this.#hasOpenCompletionPr(issue);
1433
+ openPr = await this.#openCompletionPr(issue);
1390
1434
  }
1391
1435
  catch (error) {
1392
1436
  this.#increment('githubOrphanRecoveryPrProbeFailures');
@@ -1396,10 +1440,27 @@ export class FactoryLoop {
1396
1440
  });
1397
1441
  return false;
1398
1442
  }
1399
- if (hasOpenPr) {
1443
+ if (openPr) {
1444
+ let adopted = false;
1445
+ try {
1446
+ adopted = await this.#adoptOrphanedGithubPullRequest(issue, openPr);
1447
+ }
1448
+ catch (error) {
1449
+ this.#increment('githubOrphanedPullRequestAdoptionFailures');
1450
+ this.#logger.warn?.('[factory] could not adopt orphaned in-progress GitHub issue at its open PR', {
1451
+ issue: issue.key,
1452
+ repo: openPr.repo,
1453
+ prNumber: openPr.prNumber,
1454
+ error: describeError(error).errorMessage,
1455
+ });
1456
+ }
1400
1457
  this.#increment('githubOrphanRecoveriesBlockedOpenPr');
1401
- this.#logger.info?.('[factory] preserved in-progress GitHub issue because a matching open PR exists', {
1458
+ this.#logger.info?.(adopted
1459
+ ? '[factory] adopted orphaned in-progress GitHub issue at its existing PR'
1460
+ : '[factory] preserved in-progress GitHub issue because a matching open PR exists', {
1402
1461
  issue: issue.key,
1462
+ repo: openPr.repo,
1463
+ prNumber: openPr.prNumber,
1403
1464
  });
1404
1465
  return false;
1405
1466
  }
@@ -1428,15 +1489,100 @@ export class FactoryLoop {
1428
1489
  return false;
1429
1490
  }
1430
1491
  }
1431
- async #hasOpenCompletionPr(issue) {
1492
+ async #openCompletionPr(issue) {
1432
1493
  if (this.#customProbePrResolver) {
1433
- return Boolean(await this.#probePrResolver(issue));
1494
+ return await this.#probePrResolver(issue);
1434
1495
  }
1435
- return Boolean(await this.#resolveIssuePr(issue, {
1496
+ return await this.#resolveIssuePr(issue, {
1436
1497
  titleMarker: FACTORY_E2E_MARKER,
1437
1498
  openOnly: true,
1438
1499
  failOnLookupError: true,
1439
- }));
1500
+ });
1501
+ }
1502
+ async #adoptOrphanedGithubPullRequest(issue, pr) {
1503
+ if (!this.#config.babysitter.enabled ||
1504
+ pr.draft === true ||
1505
+ !pr.headRef?.startsWith('factory/') ||
1506
+ !factoryBranchMatchesIssue(pr.headRef, issue.key))
1507
+ return false;
1508
+ const triaged = await this.triageIssue(issue);
1509
+ const routed = labelDerivedDispatchDecision(issue, triaged, this.#config);
1510
+ if (!routed.ok)
1511
+ return false;
1512
+ const route = routed.decision.routes.find((candidate) => normalizeGithubRepo(candidate.repo, this.#config.repos.org).toLowerCase() === pr.repo.toLowerCase());
1513
+ if (!route)
1514
+ return false;
1515
+ let decision = routed.decision;
1516
+ if (this.#worktrees && route.clonePath) {
1517
+ const worktreePath = factoryWorktreePath(route.clonePath, issue.key, route.repo, stableHash(`${pr.repo}#${pr.prNumber}:${pr.headRef}`));
1518
+ decision = {
1519
+ ...decision,
1520
+ implementers: decision.implementers.map((spec) => spec.repo === route.repo
1521
+ ? {
1522
+ ...spec,
1523
+ baseClonePath: route.clonePath,
1524
+ clonePath: worktreePath,
1525
+ branch: pr.headRef,
1526
+ }
1527
+ : spec),
1528
+ };
1529
+ }
1530
+ const durableRemoteAdoption = this.#fleet.placementLocality === 'remote';
1531
+ const publishedPr = {
1532
+ repo: pr.repo,
1533
+ number: pr.prNumber,
1534
+ url: pr.url ?? `https://github.com/${pr.repo}/pull/${pr.prNumber}`,
1535
+ headRef: pr.headRef,
1536
+ };
1537
+ if (durableRemoteAdoption) {
1538
+ const claim = await this.#claimDispatchLifecycle(decision, false, randomUUID(), {
1539
+ phase: 'published',
1540
+ pullRequest: publishedPr,
1541
+ });
1542
+ decision = structuredClone(claim.lifecycle.decision);
1543
+ if (claim.lifecycle.phase === 'queued') {
1544
+ this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(claim.lifecycle));
1545
+ this.#increment('queued');
1546
+ this.#increment('githubOrphanedPullRequestsAdopted');
1547
+ return true;
1548
+ }
1549
+ }
1550
+ const batch = await this.#batch();
1551
+ const record = batch.start(decision, false);
1552
+ if (!record) {
1553
+ if (durableRemoteAdoption) {
1554
+ const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(decision.issue));
1555
+ if (durable) {
1556
+ this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(durable));
1557
+ this.#increment('githubOrphanedPullRequestsAdopted');
1558
+ return true;
1559
+ }
1560
+ }
1561
+ return false;
1562
+ }
1563
+ await this.#ensureBabysitter(record, {
1564
+ repo: pr.repo,
1565
+ prNumber: pr.prNumber,
1566
+ url: pr.url ?? `https://github.com/${pr.repo}/pull/${pr.prNumber}`,
1567
+ path: pr.path,
1568
+ headRef: pr.headRef,
1569
+ });
1570
+ const babysitter = [...record.agents.values()].find((tracked) => tracked.spec.role === 'babysitter');
1571
+ if (!babysitter) {
1572
+ if (durableRemoteAdoption)
1573
+ this.#scheduleDispatchLifecycleRetry(record);
1574
+ else
1575
+ batch.abandon(record.issue);
1576
+ return false;
1577
+ }
1578
+ record.result = {
1579
+ issue: record.issue,
1580
+ agents: [{ name: babysitter.result?.name ?? babysitter.spec.name, role: 'babysitter' }],
1581
+ dryRun: false,
1582
+ };
1583
+ await this.#writeInFlightRegistry();
1584
+ this.#increment('githubOrphanedPullRequestsAdopted');
1585
+ return true;
1440
1586
  }
1441
1587
  async #listRelayfileTree(prefix, phase) {
1442
1588
  return this.#withRelayfileOperation('listTree', { phase, prefix }, () => this.#mount.listTree(prefix), {
@@ -1759,7 +1905,7 @@ export class FactoryLoop {
1759
1905
  if (!dryRun) {
1760
1906
  const issue = await this.#readIssue(dispatchDecision.issue.path);
1761
1907
  if (!issue || !this.#isIssueReady(issue)) {
1762
- throw new Error(`Live state changed before writeback for ${dispatchDecision.issue.key}`);
1908
+ throw new LiveDispatchStateChangedError(dispatchDecision.issue.key);
1763
1909
  }
1764
1910
  try {
1765
1911
  await this.#postIssueComment(issue, comment);
@@ -1798,14 +1944,33 @@ export class FactoryLoop {
1798
1944
  // acknowledged spawns, so cleanup never races a name-only survivor.
1799
1945
  const failureHandoffs = this.#dispatchFailureHandoffs(record, spawnedForReaperHandoff);
1800
1946
  await this.#persistDispatchFailureReaperHandoff(record, failureHandoffs);
1801
- const worktreesTornDown = await this.#teardownFailedDispatchWorktrees(failureHandoffs);
1802
- await this.#recordDispatchFailure(decision.issue);
1803
- const failedState = await this.#state.getDispatchAttempts(this.#workspaceId, decision.issue.key);
1804
- await this.#saveDispatchLifecycle(record, failedState?.terminal ? 'abandoned' : 'retryable');
1947
+ let worktreesTornDown = await this.#teardownFailedDispatchWorktrees(failureHandoffs);
1948
+ const liveStateChanged = error instanceof LiveDispatchStateChangedError;
1949
+ if (liveStateChanged && !failureHandoffs.some((handoff) => handoff.worktree)) {
1950
+ const failed = await this.#releaseAndTerminateAgents(failureHandoffs.map((handoff) => [handoff.name, handoff.tracked]), 'live dispatch state changed', 'completion');
1951
+ if (failed.length === 0) {
1952
+ for (const handoff of failureHandoffs) {
1953
+ await this.#state.clearFailureHandoff(this.#workspaceId, registryHandoffKey(handoff.issue, handoff.name));
1954
+ }
1955
+ worktreesTornDown = failureHandoffs.length > 0;
1956
+ }
1957
+ }
1958
+ let failedState;
1959
+ if (liveStateChanged) {
1960
+ await this.#clearDispatchInFlight(decision.issue);
1961
+ await this.#saveDispatchLifecycle(record, 'abandoned');
1962
+ this.#increment('dispatchLiveStateRaces');
1963
+ }
1964
+ else {
1965
+ await this.#recordDispatchFailure(decision.issue);
1966
+ failedState = await this.#state.getDispatchAttempts(this.#workspaceId, decision.issue.key);
1967
+ await this.#saveDispatchLifecycle(record, failedState?.terminal ? 'abandoned' : 'retryable');
1968
+ }
1805
1969
  batch.abandon(decision.issue);
1806
- if (!failedState?.terminal)
1970
+ if (!liveStateChanged && !failedState?.terminal)
1807
1971
  this.#scheduleDispatchLifecycleRetry(record);
1808
- this.#error(error, decision.issue);
1972
+ if (!liveStateChanged)
1973
+ this.#error(error, decision.issue);
1809
1974
  // The teardown runs while the record still exists so it can safely
1810
1975
  // derive every shared checkout. Rewrite the registry only after abandon
1811
1976
  // removes those agents from the ordinary in-flight view.
@@ -1950,18 +2115,20 @@ export class FactoryLoop {
1950
2115
  this.#dispatchLifecycleRenewTimer = undefined;
1951
2116
  }
1952
2117
  }
1953
- async #claimDispatchLifecycle(decision, dryRun, preparedRunId) {
2118
+ async #claimDispatchLifecycle(decision, dryRun, preparedRunId, initial = {}) {
1954
2119
  const key = issueKey(decision.issue);
1955
2120
  const seed = {
1956
2121
  runId: preparedRunId ?? randomUUID(),
1957
2122
  issue: { ...decision.issue },
1958
2123
  decision: structuredClone(decision),
1959
2124
  dryRun,
1960
- phase: 'dispatching',
2125
+ phase: initial.phase ?? 'dispatching',
1961
2126
  agents: [],
1962
2127
  invocationIds: [],
1963
2128
  updatedAtMs: this.#clock.now(),
1964
2129
  };
2130
+ if (initial.pullRequest)
2131
+ seed.pullRequest = initial.pullRequest;
1965
2132
  if (!preparedRunId)
1966
2133
  seed.decision = decisionWithLifecycleBranches(seed.decision, seed.runId);
1967
2134
  const claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, seed, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
@@ -2087,7 +2254,19 @@ export class FactoryLoop {
2087
2254
  if (!promoted || promoted.phase !== 'dispatching') {
2088
2255
  throw new Error(`durable dispatch ${lifecycle.issue.key} lost its promoted lifecycle`);
2089
2256
  }
2090
- lifecycle = promoted;
2257
+ if (promoted.pullRequest) {
2258
+ const promotedRecord = inFlightRecordFromLifecycle(promoted);
2259
+ if (!await this.#saveDispatchLifecycle(promotedRecord, 'published', promoted.pullRequest))
2260
+ return;
2261
+ const published = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
2262
+ if (!published || published.phase !== 'published') {
2263
+ throw new Error(`durable dispatch ${lifecycle.issue.key} lost its published PR during promotion`);
2264
+ }
2265
+ lifecycle = published;
2266
+ }
2267
+ else {
2268
+ lifecycle = promoted;
2269
+ }
2091
2270
  }
2092
2271
  const batch = await this.#batch();
2093
2272
  const durableRecord = inFlightRecordFromLifecycle(lifecycle);
@@ -2123,7 +2302,7 @@ export class FactoryLoop {
2123
2302
  const implementer = [...record.agents.values()].find((agent) => agent.spec.role === 'implementer');
2124
2303
  if (!implementer)
2125
2304
  throw new Error(`durable dispatch ${record.issue.key} has no implementer to publish`);
2126
- const published = await this.#publishImplementerPullRequest(record, implementer);
2305
+ const published = await this.#publishImplementerPullRequest(record, implementer, { reconcileExisting: true });
2127
2306
  if (!published)
2128
2307
  throw new Error(`durable dispatch ${record.issue.key} did not produce a pull request`);
2129
2308
  if (!await this.#saveDispatchLifecycle(record, 'published', published))
@@ -2342,6 +2521,12 @@ export class FactoryLoop {
2342
2521
  }
2343
2522
  }
2344
2523
  catch (error) {
2524
+ if (error instanceof LiveDispatchStateChangedError) {
2525
+ this.#logger.info?.('[factory] ignored issue event whose live state changed during dispatch', {
2526
+ issue: error.issueKey,
2527
+ });
2528
+ return;
2529
+ }
2345
2530
  this.#logger.error?.('[factory] failed to handle issue change', error);
2346
2531
  }
2347
2532
  }
@@ -2487,7 +2672,6 @@ export class FactoryLoop {
2487
2672
  }
2488
2673
  else if (isGithubIssueTreePath(path)) {
2489
2674
  this.#increment('githubIssuesIgnoredByPathRegex');
2490
- this.#logger.debug?.('[factory] ignored GitHub issue path with unsupported relayfile shape', { path });
2491
2675
  }
2492
2676
  }
2493
2677
  }
@@ -3280,7 +3464,9 @@ export class FactoryLoop {
3280
3464
  }
3281
3465
  }
3282
3466
  if (isCompletionReason(reason)) {
3283
- if (exiting?.spec.role === 'implementer' && await this.#issueHasCompletionPr(record)) {
3467
+ if (exiting?.spec.role === 'implementer' && await this.#issueHasCompletionPr(record, {
3468
+ openOnly: this.#config.babysitter.enabled,
3469
+ })) {
3284
3470
  if (this.#config.babysitter.enabled)
3285
3471
  await this.#ensureBabysitterForIssue(record);
3286
3472
  else
@@ -3332,7 +3518,9 @@ export class FactoryLoop {
3332
3518
  return;
3333
3519
  }
3334
3520
  try {
3335
- if (tracked.spec.role === 'implementer' && await this.#issueHasCompletionPr(record)) {
3521
+ if (tracked.spec.role === 'implementer' && await this.#issueHasCompletionPr(record, {
3522
+ openOnly: this.#config.babysitter.enabled,
3523
+ })) {
3336
3524
  if (this.#config.babysitter.enabled) {
3337
3525
  await this.#ensureBabysitterForIssue(record);
3338
3526
  return;
@@ -3501,7 +3689,7 @@ export class FactoryLoop {
3501
3689
  return undefined;
3502
3690
  }
3503
3691
  }
3504
- async #publishImplementerPullRequest(record, implementer) {
3692
+ async #publishImplementerPullRequest(record, implementer, opts = {}) {
3505
3693
  const key = `${issueKey(record.issue)}:${implementer.spec.repo}`;
3506
3694
  const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
3507
3695
  if (durable?.pullRequest)
@@ -3530,6 +3718,21 @@ export class FactoryLoop {
3530
3718
  ? sourceRepoParts.owner
3531
3719
  : undefined;
3532
3720
  const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
3721
+ const expectedHeadRef = implementer.spec.branch ?? remoteBranch;
3722
+ if (opts.reconcileExisting && expectedHeadRef) {
3723
+ const existing = await this.#openPullRequestByHead(repo, expectedHeadRef);
3724
+ if (existing) {
3725
+ this.#publishedPullRequests.set(key, existing);
3726
+ this.#increment('githubPullRequestsReconciled');
3727
+ this.#logger.info?.('[factory] reconciled existing PR from implementer branch', {
3728
+ issue: issue.key,
3729
+ repo: existing.repo,
3730
+ prNumber: existing.number,
3731
+ url: existing.url,
3732
+ });
3733
+ return existing;
3734
+ }
3735
+ }
3533
3736
  const baseRef = await this.#githubDefaultBranch(repo);
3534
3737
  const result = await githubWrite.publishPullRequest({
3535
3738
  repo,
@@ -3558,6 +3761,52 @@ export class FactoryLoop {
3558
3761
  });
3559
3762
  return result;
3560
3763
  }
3764
+ async #openPullRequestByHead(repo, expectedHeadRef) {
3765
+ const parts = githubRepoParts(repo);
3766
+ if (!parts)
3767
+ return undefined;
3768
+ const roots = [
3769
+ `/github/repos/${encodeURIComponent(parts.owner)}/${encodeURIComponent(parts.repo)}/pulls/`,
3770
+ `/github/repos/${encodeURIComponent(parts.owner)}__${encodeURIComponent(parts.repo)}/pulls/`,
3771
+ ];
3772
+ const candidates = [];
3773
+ for (const root of roots) {
3774
+ let paths;
3775
+ try {
3776
+ paths = await this.#mount.listTree(root);
3777
+ }
3778
+ catch {
3779
+ continue;
3780
+ }
3781
+ for (const path of paths) {
3782
+ const pathParts = githubPullPathParts(path);
3783
+ if (!pathParts ||
3784
+ pathParts.owner.toLowerCase() !== parts.owner.toLowerCase() ||
3785
+ pathParts.repo.toLowerCase() !== parts.repo.toLowerCase())
3786
+ continue;
3787
+ try {
3788
+ const snapshot = parsePullSnapshot((await this.#mount.readFile(path)).content, pathParts.number);
3789
+ const state = snapshot?.state?.trim().toUpperCase();
3790
+ if (!snapshot ||
3791
+ snapshot.headRef !== expectedHeadRef ||
3792
+ state !== 'OPEN' ||
3793
+ snapshot.draft !== false ||
3794
+ snapshot.merged === true)
3795
+ continue;
3796
+ candidates.push({
3797
+ repo,
3798
+ number: snapshot.number,
3799
+ url: snapshot.url ?? `https://github.com/${repo}/pull/${snapshot.number}`,
3800
+ headRef: expectedHeadRef,
3801
+ });
3802
+ }
3803
+ catch {
3804
+ // A partially materialized PR record cannot prove exact ownership.
3805
+ }
3806
+ }
3807
+ }
3808
+ return candidates.sort((a, b) => b.number - a.number)[0];
3809
+ }
3561
3810
  async #prepareAgentWorktree(record, spec) {
3562
3811
  const worktree = this.#agentWorktree(record, spec);
3563
3812
  if (!worktree || !this.#worktrees)
@@ -3762,12 +4011,37 @@ export class FactoryLoop {
3762
4011
  // the release-driven exit event so it cannot re-trigger a resume before the
3763
4012
  // record leaves the batch.
3764
4013
  async #abandonStuckDispatch(record, reason) {
3765
- const remaining = [...record.agents].filter(([, tracked]) => tracked.spec.role !== 'implementer');
3766
- for (const [agentName] of remaining) {
4014
+ const agents = [...record.agents];
4015
+ for (const [agentName, tracked] of agents) {
4016
+ if (tracked.spec.role === 'implementer')
4017
+ continue;
3767
4018
  this.#fleet.markAgentTerminal?.(agentName, `implementer-terminal:${reason}`);
3768
4019
  }
3769
- if (remaining.length > 0) {
3770
- await this.#releaseAndTerminateAgents(remaining, 'issue-abandoned', 'completion');
4020
+ const worktreeHandoffs = this.#dispatchFailureHandoffs(record, []);
4021
+ let cleanupComplete = true;
4022
+ if (worktreeHandoffs.length > 0) {
4023
+ // A terminal no-PR dispatch no longer owns useful work. Fence and release
4024
+ // every agent sharing the checkout before removing it. If release or
4025
+ // cleanup fails, the durable handoff reaper retains responsibility rather
4026
+ // than leaving an invisible orphan under .factory-worktrees.
4027
+ await this.#persistDispatchFailureReaperHandoff(record, worktreeHandoffs);
4028
+ const worktreeAgentNames = new Set(worktreeHandoffs.map((handoff) => handoff.name));
4029
+ const nonWorktreeAgents = agents.filter(([name]) => !worktreeAgentNames.has(name));
4030
+ if (nonWorktreeAgents.length > 0) {
4031
+ const failed = await this.#releaseAndTerminateAgents(nonWorktreeAgents, 'issue-abandoned', 'completion');
4032
+ cleanupComplete = failed.length === 0;
4033
+ }
4034
+ cleanupComplete = await this.#teardownFailedDispatchWorktrees(worktreeHandoffs) && cleanupComplete;
4035
+ }
4036
+ else if (agents.length > 0) {
4037
+ const failed = await this.#releaseAndTerminateAgents(agents, 'issue-abandoned', 'completion');
4038
+ cleanupComplete = failed.length === 0;
4039
+ }
4040
+ if (!cleanupComplete) {
4041
+ this.#increment('abandonedDispatchReleaseRetries');
4042
+ this.#scheduleAbandonedDispatchRetry(record, reason);
4043
+ await this.#writeInFlightRegistry();
4044
+ return;
3771
4045
  }
3772
4046
  await this.#recordDispatchTerminal(record.issue);
3773
4047
  const next = (await this.#batch()).complete(record.issue);
@@ -3779,7 +4053,24 @@ export class FactoryLoop {
3779
4053
  await this.dispatch(next.decision, { dryRun: next.dryRun });
3780
4054
  }
3781
4055
  }
3782
- async #issueHasCompletionPr(record) {
4056
+ #scheduleAbandonedDispatchRetry(record, reason) {
4057
+ const key = issueKey(record.issue);
4058
+ if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
4059
+ return;
4060
+ const timer = setTimeout(() => {
4061
+ this.#dispatchLifecycleRetryTimers.delete(key);
4062
+ void this.#abandonStuckDispatch(record, reason).catch((error) => {
4063
+ this.#logger.warn?.('[factory] abandoned dispatch cleanup retry failed', {
4064
+ issue: record.issue.key,
4065
+ error: describeError(error).errorMessage,
4066
+ });
4067
+ this.#scheduleAbandonedDispatchRetry(record, reason);
4068
+ });
4069
+ }, DISPATCH_LIFECYCLE_RETRY_MS);
4070
+ timer.unref?.();
4071
+ this.#dispatchLifecycleRetryTimers.set(key, timer);
4072
+ }
4073
+ async #issueHasCompletionPr(record, opts = {}) {
3783
4074
  try {
3784
4075
  const issue = await this.#readIssue(record.issue.path);
3785
4076
  if (!issue) {
@@ -3789,7 +4080,9 @@ export class FactoryLoop {
3789
4080
  // work isn't review-ready, so an implementer exiting with only a draft PR
3790
4081
  // must NOT mark the issue done / release agents — mirror the
3791
4082
  // #sweepPrStateCompletions draft guard, which keeps draft-PR issues in flight.
3792
- const pr = await this.#completionPrForIssue(issue);
4083
+ const pr = opts.openOnly
4084
+ ? await this.#openPrForIssue(issue)
4085
+ : await this.#completionPrForIssue(issue);
3793
4086
  return Boolean(pr && !pr.draft);
3794
4087
  }
3795
4088
  catch (error) {
@@ -3852,11 +4145,19 @@ export class FactoryLoop {
3852
4145
  }
3853
4146
  async #handleDeliveryFailed(info) {
3854
4147
  const critical = await this.#state.consumeCritical(this.#workspaceId, info.msgId ?? '');
4148
+ if (!critical) {
4149
+ this.#increment('nonCriticalDeliveryFailuresIgnored');
4150
+ return;
4151
+ }
3855
4152
  const record = (await this.#batch()).getIssueByAgent(info.to);
3856
- const issue = critical?.issue ?? record?.issue;
4153
+ const issue = critical.issue ?? record?.issue;
3857
4154
  const error = new Error(`Critical delivery failed to ${info.to}${info.reason ? `: ${info.reason}` : ''}`);
3858
4155
  this.#error(error, issue);
3859
- if (critical && this.#fleet.waitForInjected) {
4156
+ if (isTerminalDeliveryFailure(info.reason)) {
4157
+ this.#increment('criticalDeliveryTerminalFailures');
4158
+ return;
4159
+ }
4160
+ if (this.#fleet.waitForInjected) {
3860
4161
  try {
3861
4162
  const ack = await this.#waitForInjectedAndSubmit(critical.input);
3862
4163
  await this.#state.recordCritical(this.#workspaceId, ack.eventId, critical);
@@ -4635,6 +4936,14 @@ export class FactoryLoop {
4635
4936
  else {
4636
4937
  watch.issue = { ...record.issue };
4637
4938
  watch.detectAgentQuestions = true;
4939
+ const humanReplyDispatch = record.decision.rationale.includes('Human answered the GitHub triage escalation');
4940
+ if (!triageEscalationReason(record.decision) && !humanReplyDispatch) {
4941
+ const pendingCount = watch.pending.length;
4942
+ watch.pending = watch.pending.filter((pending) => pending.kind !== 'triage');
4943
+ if (watch.pending.length < pendingCount) {
4944
+ this.#increment('triageEscalationsSupersededByActionableIssue');
4945
+ }
4946
+ }
4638
4947
  }
4639
4948
  if (this.#githubIssueCommentWatchers.has(key)) {
4640
4949
  const normalizedWatch = normalizeGithubIssueCommentWatch(watch);
@@ -5076,7 +5385,7 @@ export class FactoryLoop {
5076
5385
  return false;
5077
5386
  }
5078
5387
  this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
5079
- const result = await this.#startOrQueueGithubClarifiedDecision(decision);
5388
+ const result = await this.#startOrQueueGithubClarifiedDecision(dispatchAfterGithubClarification(decision, 'human clarification resolved triage'));
5080
5389
  this.#increment('githubTriageAnswersDispatched');
5081
5390
  return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
5082
5391
  }
@@ -5211,6 +5520,23 @@ export class FactoryLoop {
5211
5520
  this.#increment('babysitterOwnershipRestoreSkippedNonOwner');
5212
5521
  continue;
5213
5522
  }
5523
+ const snapshot = await this.#readPrSnapshot(session);
5524
+ const guard = snapshot ? prMetaAllowsHumanReview(snapshot) : undefined;
5525
+ if (!snapshot || !guard?.ok || prSnapshotIssueMatchScore(snapshot, session.issue.key) < 30) {
5526
+ await this.#state.clearBabysitterSession(this.#workspaceId, persistedKey);
5527
+ this.#increment('babysitterOwnershipRestoreStale');
5528
+ this.#logger.warn?.('[factory] discarded stale babysitter ownership during restore', {
5529
+ issue: session.issue.key,
5530
+ repo: session.repo,
5531
+ prNumber: session.prNumber,
5532
+ reason: !snapshot
5533
+ ? 'authoritative PR meta is unavailable'
5534
+ : !guard?.ok
5535
+ ? guard?.reason
5536
+ : 'PR branch does not identify the issue',
5537
+ });
5538
+ continue;
5539
+ }
5214
5540
  const record = batch.getIssue(session.issue);
5215
5541
  const tracked = record?.agents.get(session.agentName)
5216
5542
  ?? [...(record?.agents.values() ?? [])].find((agent) => agent.spec.role === 'babysitter')
@@ -5844,7 +6170,7 @@ export class FactoryLoop {
5844
6170
  if (!issue) {
5845
6171
  return;
5846
6172
  }
5847
- const pr = await this.#completionPrForIssue(issue);
6173
+ const pr = await this.#openPrForIssue(issue);
5848
6174
  if (!pr || pr.draft) {
5849
6175
  return;
5850
6176
  }
@@ -5916,8 +6242,10 @@ export class FactoryLoop {
5916
6242
  const initialSpec = babysitterSpec(issue, this.#config, route);
5917
6243
  const sharedCheckout = [...record.agents.values()]
5918
6244
  .map((agent) => agent.spec)
5919
- .find((candidate) => candidate.repo === initialSpec.repo && candidate.baseClonePath && candidate.clonePath);
5920
- const implementerBranch = record.decision.implementers
6245
+ .find((candidate) => candidate.repo === initialSpec.repo && candidate.baseClonePath && candidate.clonePath)
6246
+ ?? record.decision.implementers
6247
+ .find((candidate) => candidate.repo === initialSpec.repo && candidate.baseClonePath && candidate.clonePath);
6248
+ const implementerBranch = prRef.headRef ?? record.decision.implementers
5921
6249
  .find((candidate) => candidate.repo === initialSpec.repo && candidate.branch)?.branch;
5922
6250
  const spec = sharedCheckout
5923
6251
  ? {
@@ -6050,7 +6378,11 @@ export class FactoryLoop {
6050
6378
  if (!ref) {
6051
6379
  return undefined;
6052
6380
  }
6053
- const candidatePaths = ref.path ? [ref.path] : await this.#pullMetaPathsFor(ref.repo, ref.prNumber);
6381
+ return await this.#readPrSnapshot(ref);
6382
+ }
6383
+ async #readPrSnapshot(ref) {
6384
+ const discoveredPaths = await this.#pullMetaPathsFor(ref.repo, ref.prNumber);
6385
+ const candidatePaths = [...new Set([ref.path, ...discoveredPaths].filter((path) => Boolean(path)))];
6054
6386
  for (const path of candidatePaths) {
6055
6387
  try {
6056
6388
  const snapshot = parsePullSnapshot((await this.#mount.readFile(path)).content, ref.prNumber);
@@ -6599,9 +6931,9 @@ export class FactoryLoop {
6599
6931
  }
6600
6932
  }
6601
6933
  async #escalateTriageToGithub(decision, reason) {
6602
- const question = triageEscalationQuestion(decision);
6603
- const correlationId = githubEscalationCorrelationId('triage', decision.issue, question);
6604
6934
  const issue = await this.#readIssue(decision.issue.path);
6935
+ const question = triageEscalationQuestion(decision, issue);
6936
+ const correlationId = githubEscalationCorrelationId('triage', decision.issue, question);
6605
6937
  const source = issue ? githubIssueSourceRef(issue) : undefined;
6606
6938
  const authorizedAuthor = issue ? await this.#resolveGithubIssueAuthor(issue) : undefined;
6607
6939
  if (!issue || !source || !authorizedAuthor) {
@@ -6679,7 +7011,11 @@ export class FactoryLoop {
6679
7011
  const source = githubIssueSourceRef(issue);
6680
7012
  const stakeholderMentions = slackMentions(this.#config.slack.stakeholderUserIds);
6681
7013
  const reporter = await this.#resolveGithubIssueAuthor(issue);
6682
- const audience = [stakeholderMentions, reporter ? `GitHub reporter: @${reporter}.` : undefined]
7014
+ const reporterSlackUserId = reporter ? await this.#resolveSlackUserIdForGithubReporter(reporter) : undefined;
7015
+ const reporterAudience = reporter
7016
+ ? `GitHub reporter: ${reporterSlackUserId ? `<@${reporterSlackUserId}>` : `${reporter} (GitHub)`}.`
7017
+ : undefined;
7018
+ const audience = [stakeholderMentions, reporterAudience]
6683
7019
  .filter((part) => Boolean(part))
6684
7020
  .join(' ');
6685
7021
  const replyInstruction = source?.url
@@ -6690,7 +7026,7 @@ export class FactoryLoop {
6690
7026
  text: [
6691
7027
  `${audience ? `${audience} ` : ''}${decision.issue.key}: factory triage escalation for ${issue.title}`,
6692
7028
  `Reason: ${reason}`,
6693
- `Question: ${triageEscalationQuestion(decision)} ${replyInstruction}`,
7029
+ `Question: ${triageEscalationQuestion(decision, issue)} ${replyInstruction}`,
6694
7030
  ].join('\n'),
6695
7031
  });
6696
7032
  await this.#state.setSlackThread(this.#workspaceId, issueKey(decision.issue), root.threadId);
@@ -6737,6 +7073,75 @@ export class FactoryLoop {
6737
7073
  this.#githubIssueAuthorLookups.set(key, pending);
6738
7074
  return pending;
6739
7075
  }
7076
+ async #resolveSlackUserIdForGithubReporter(reporter) {
7077
+ const identity = normalizedCrossProviderIdentity(reporter);
7078
+ if (!identity)
7079
+ return undefined;
7080
+ if (this.#slackReporterUserIds.has(identity)) {
7081
+ return this.#slackReporterUserIds.get(identity);
7082
+ }
7083
+ const existing = this.#slackReporterUserIdLookups.get(identity);
7084
+ if (existing)
7085
+ return existing;
7086
+ const pending = this.#findSlackUserIdByIdentity(identity)
7087
+ .then((userId) => {
7088
+ this.#slackReporterUserIds.set(identity, userId);
7089
+ if (userId)
7090
+ this.#increment('slackReporterIdentitiesResolved');
7091
+ return userId;
7092
+ })
7093
+ .catch(() => {
7094
+ this.#slackReporterUserIds.set(identity, undefined);
7095
+ this.#increment('slackReporterIdentityLookupFailures');
7096
+ return undefined;
7097
+ })
7098
+ .finally(() => {
7099
+ this.#slackReporterUserIdLookups.delete(identity);
7100
+ });
7101
+ this.#slackReporterUserIdLookups.set(identity, pending);
7102
+ return pending;
7103
+ }
7104
+ async #findSlackUserIdByIdentity(identity) {
7105
+ const list = async (prefix) => {
7106
+ try {
7107
+ return await this.#mount.listTree(prefix);
7108
+ }
7109
+ catch {
7110
+ return [];
7111
+ }
7112
+ };
7113
+ const [userPaths, channelPaths] = await Promise.all([
7114
+ list('/slack/users'),
7115
+ list('/slack/channels'),
7116
+ ]);
7117
+ const candidates = [
7118
+ ...userPaths.filter(isSlackIdentityRecordPath),
7119
+ ...channelPaths
7120
+ .filter(isSlackIdentityRecordPath)
7121
+ .sort((left, right) => slackIdentityPathTimestamp(right) - slackIdentityPathTimestamp(left))
7122
+ .slice(0, SLACK_IDENTITY_MESSAGE_SCAN_LIMIT),
7123
+ ];
7124
+ const matches = new Set();
7125
+ for (let offset = 0; offset < candidates.length; offset += SLACK_IDENTITY_READ_BATCH_SIZE) {
7126
+ const batch = candidates.slice(offset, offset + SLACK_IDENTITY_READ_BATCH_SIZE);
7127
+ const records = await Promise.all(batch.map(async (path) => {
7128
+ try {
7129
+ return wrappedPayload((await this.#mount.readFile(path)).content);
7130
+ }
7131
+ catch {
7132
+ return undefined;
7133
+ }
7134
+ }));
7135
+ for (const payload of records) {
7136
+ const userId = payload ? slackUserIdMatchingIdentity(payload, identity) : undefined;
7137
+ if (userId)
7138
+ matches.add(userId);
7139
+ if (matches.size > 1)
7140
+ return undefined;
7141
+ }
7142
+ }
7143
+ return matches.size === 1 ? [...matches][0] : undefined;
7144
+ }
6740
7145
  async #postAndWatchSlackEscalationThread(decision, reason) {
6741
7146
  if (!this.#slack || !this.#config.slack) {
6742
7147
  return;
@@ -6748,7 +7153,7 @@ export class FactoryLoop {
6748
7153
  text: [
6749
7154
  `${stakeholderMentions ? `${stakeholderMentions} ` : ''}${decision.issue.key}: factory triage escalation for ${issue?.title ?? decision.issue.key}`,
6750
7155
  `Reason: ${reason}`,
6751
- `Question: ${triageEscalationQuestion(decision)}`,
7156
+ `Question: ${triageEscalationQuestion(decision, issue)}`,
6752
7157
  ].join('\n'),
6753
7158
  });
6754
7159
  await this.#state.setSlackThread(this.#workspaceId, issueKey(decision.issue), root.threadId);
@@ -7887,6 +8292,16 @@ const githubIssueAsFactoryIssue = (issue) => {
7887
8292
  },
7888
8293
  };
7889
8294
  };
8295
+ const githubIssueUpdatedAtMs = (issue) => {
8296
+ if (!issue || !isGithubIssue(issue))
8297
+ return 0;
8298
+ const payload = wrappedPayload(issue.raw);
8299
+ const updatedAt = stringValue(payload.updated_at) ?? stringValue(payload.updatedAt);
8300
+ if (!updatedAt)
8301
+ return 0;
8302
+ const parsed = Date.parse(updatedAt);
8303
+ return Number.isFinite(parsed) ? parsed : 0;
8304
+ };
7890
8305
  export async function readFactoryLoopHeartbeat(path = DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH) {
7891
8306
  try {
7892
8307
  return parseJsonContent(await readFile(path, 'utf8'));
@@ -8144,19 +8559,29 @@ function githubUrlCandidates(value) {
8144
8559
  return [...candidates];
8145
8560
  }
8146
8561
  function labelRoutesForIssue(issue, config) {
8147
- const githubReadinessLabel = isGithubIssue(issue) ? config.safety.requireLabel.trim().toLowerCase() : undefined;
8148
- const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label) &&
8562
+ const githubIssue = isGithubIssue(issue);
8563
+ const githubReadinessLabel = githubIssue ? config.safety.requireLabel.trim().toLowerCase() : undefined;
8564
+ const candidateLabels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label) &&
8149
8565
  label.toLowerCase() !== githubReadinessLabel &&
8150
- (!isGithubIssue(issue) || !GITHUB_LIFECYCLE_LABELS.has(label.toLowerCase())));
8566
+ (!githubIssue || !GITHUB_LIFECYCLE_LABELS.has(label.toLowerCase())));
8567
+ const labels = [];
8151
8568
  const routes = [];
8152
8569
  const offendingLabels = [];
8153
8570
  const seenRepos = new Set();
8154
- for (const label of labels) {
8571
+ for (const label of candidateLabels) {
8155
8572
  const entry = findLabelRoute(config.repos.byLabel, label);
8156
8573
  if (!entry) {
8157
- offendingLabels.push(label);
8574
+ // GitHub repositories commonly carry metadata labels such as `bug` and
8575
+ // `enhancement`; only explicitly configured repo labels participate in
8576
+ // routing. Linear labels remain authoritative and therefore fail closed
8577
+ // when unmapped.
8578
+ if (!githubIssue) {
8579
+ labels.push(label);
8580
+ offendingLabels.push(label);
8581
+ }
8158
8582
  continue;
8159
8583
  }
8584
+ labels.push(label);
8160
8585
  const repo = entry.repo;
8161
8586
  if (seenRepos.has(repo)) {
8162
8587
  continue;
@@ -8558,7 +8983,15 @@ const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
8558
8983
  : 0;
8559
8984
  if (!pr || score <= 0)
8560
8985
  continue;
8561
- candidates.push({ repo, prNumber: pr.number, draft: pr.draft, score });
8986
+ candidates.push({
8987
+ repo,
8988
+ prNumber: pr.number,
8989
+ draft: pr.draft,
8990
+ headRef: pr.headRef,
8991
+ url: pr.url,
8992
+ path,
8993
+ score,
8994
+ });
8562
8995
  }
8563
8996
  }
8564
8997
  return candidates.sort((a, b) => b.score - a.score || b.prNumber - a.prNumber)[0];
@@ -8577,7 +9010,7 @@ const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
8577
9010
  '--state',
8578
9011
  'all',
8579
9012
  '--json',
8580
- 'number,title,body,headRefName,isDraft,state',
9013
+ 'number,title,body,headRefName,isDraft,state,url',
8581
9014
  '--limit',
8582
9015
  String(PROBE_PR_GH_CANDIDATE_LIMIT),
8583
9016
  ]);
@@ -8605,7 +9038,7 @@ const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
8605
9038
  }
8606
9039
  for (const entry of payload) {
8607
9040
  const pr = ghProbePrCandidate(entry);
8608
- if (!pr || !containsIssueKey(pr.headRef, issue.key))
9041
+ if (!pr || !factoryBranchMatchesIssue(pr.headRef, issue.key))
8609
9042
  continue;
8610
9043
  if (opts.openOnly && normalizePrState(pr.state) !== 'OPEN')
8611
9044
  continue;
@@ -8616,6 +9049,8 @@ const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
8616
9049
  repo,
8617
9050
  prNumber: pr.number,
8618
9051
  draft: pr.draft,
9052
+ headRef: pr.headRef,
9053
+ url: pr.url,
8619
9054
  score,
8620
9055
  open: normalizePrState(pr.state) === 'OPEN',
8621
9056
  });
@@ -8723,6 +9158,7 @@ const readProbePrCandidate = async (mount, path) => {
8723
9158
  headRef: refName(payload.headRef) ?? refName(payload.head) ?? stringValue(payload.head_ref) ?? '',
8724
9159
  draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
8725
9160
  state: stringValue(payload.state),
9161
+ url: stringValue(payload.url) ?? stringValue(payload.html_url),
8726
9162
  };
8727
9163
  }
8728
9164
  catch {
@@ -8743,12 +9179,13 @@ const ghProbePrCandidate = (value) => {
8743
9179
  headRef: stringValue(payload.headRefName) ?? '',
8744
9180
  draft: booleanValue(payload.isDraft),
8745
9181
  state: stringValue(payload.state),
9182
+ url: stringValue(payload.url),
8746
9183
  };
8747
9184
  };
8748
9185
  const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
8749
9186
  if (opts.requireTitleMarker && !hasTitlePrefix(pr.title, marker))
8750
9187
  return 0;
8751
- if (containsIssueKey(pr.headRef, issue.key))
9188
+ if (factoryBranchMatchesIssue(pr.headRef, issue.key))
8752
9189
  return 30;
8753
9190
  if (containsIssueKey(pr.title, issue.key))
8754
9191
  return 20;
@@ -8757,6 +9194,10 @@ const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
8757
9194
  return 0;
8758
9195
  };
8759
9196
  const hasTitlePrefix = (title, marker) => title === marker || title.startsWith(`${marker} `);
9197
+ const factoryBranchMatchesIssue = (headRef, issueKey) => /^\d+$/u.test(issueKey)
9198
+ ? headRef.toLowerCase() === `factory/${issueKey.toLowerCase()}` ||
9199
+ headRef.toLowerCase().startsWith(`factory/${issueKey.toLowerCase()}-`)
9200
+ : containsIssueKey(headRef, issueKey);
8760
9201
  const normalizePrState = (state) => state?.toUpperCase();
8761
9202
  const failClosedGhRunner = async () => ({ stdout: '[]' });
8762
9203
  const ISSUE_KEY_PATTERN = /^[A-Z]+-\d+$/u;
@@ -9071,7 +9512,7 @@ const babysitterCriticalIssueMatches = (signalKey, issue) => {
9071
9512
  Number(match[3]) === parts.number;
9072
9513
  };
9073
9514
  const prSnapshotIssueMatchScore = (snapshot, issueKey) => {
9074
- if (containsIssueKey(snapshot.headRef ?? '', issueKey))
9515
+ if (factoryBranchMatchesIssue(snapshot.headRef ?? '', issueKey))
9075
9516
  return 30;
9076
9517
  if (containsIssueKey(snapshot.title ?? '', issueKey))
9077
9518
  return 20;
@@ -9153,7 +9594,7 @@ const isAllowedFactoryDraft = async (path, content, opts, mount, config) => {
9153
9594
  }
9154
9595
  return false;
9155
9596
  };
9156
- const isFactoryGithubWritebackPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/refs%2Fheads%2F[^/]+\.json|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
9597
+ const isFactoryGithubWritebackPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/(?:factory\.json|refs%2Fheads%2Ffactory%2F[^/]+\.json)|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
9157
9598
  const isIssuePathInFactoryScope = async (mount, path, config) => {
9158
9599
  try {
9159
9600
  return isInFactoryScope(parseLinearIssue(path, (await mount.readFile(path)).content), config.safety);
@@ -9540,25 +9981,27 @@ const triageEscalationReason = (decision) => {
9540
9981
  }
9541
9982
  return `${reasons.join(' and ')}${decision.rationale ? `: ${decision.rationale}` : ''}`;
9542
9983
  };
9543
- const triageEscalationQuestion = (decision) => {
9984
+ class LiveDispatchStateChangedError extends Error {
9985
+ issueKey;
9986
+ constructor(issueKey) {
9987
+ super(`Live state changed before writeback for ${issueKey}`);
9988
+ this.name = 'LiveDispatchStateChangedError';
9989
+ this.issueKey = issueKey;
9990
+ }
9991
+ }
9992
+ const triageEscalationQuestion = (decision, issue) => {
9544
9993
  const routedRepos = decision.routes.map((route) => route.repo).filter(Boolean);
9994
+ const subject = issue?.title?.trim() || decision.issue.key;
9995
+ const details = `For "${subject}", please reply with: (1) the exact user flow—where it starts, required inputs/actions, and the successful result; (2) permissions, validation, failure behavior, important edge cases, and anything out of scope; and (3) observable acceptance checks or tests. Say "use reasonable product defaults" for anything Factory may decide. After an authorized GitHub reply, Factory will dispatch agents; successful work will be opened as a pull request.`;
9545
9996
  if (routedRepos.length === 0) {
9546
- return [
9547
- 'Which repository or repositories should handle this issue?',
9548
- 'Please include the intended approach and the acceptance criteria/tests the agent should satisfy.',
9549
- ].join(' ');
9997
+ return `Which repository or repositories should handle this issue? ${details}`;
9550
9998
  }
9551
9999
  if (decision.thin) {
9552
- return [
9553
- `Factory matched ${routedRepos.join(', ')}.`,
9554
- 'Please clarify the concrete expected behavior, constraints, and acceptance criteria/tests before dispatch.',
9555
- ].join(' ');
10000
+ return `Factory matched ${routedRepos.join(', ')}. ${details}`;
9556
10001
  }
9557
- return [
9558
- `Factory matched ${routedRepos.join(', ')}, but triage confidence is low.`,
9559
- 'Please confirm the intended repo/approach or correct the route before dispatch.',
9560
- ].join(' ');
10002
+ return `Factory matched ${routedRepos.join(', ')}, but triage confidence is low. Please confirm that repository and intended approach, or provide the correct route. ${details}`;
9561
10003
  };
10004
+ const isTerminalDeliveryFailure = (reason) => /worker[_ -]?(?:exited|disappeared)|max delivery retries exceeded/iu.test(reason ?? '');
9562
10005
  const isTriageEscalationWatchRecord = (record) => record.agents.size === 0 && record.invocationIds.size === 0 && triageEscalationReason(record.decision) !== undefined;
9563
10006
  const hasDispatchableRoute = (decision) => decision.routes.length > 0 && dispatchSpecs(decision).length > 0;
9564
10007
  const dispatchAfterSlackClarification = (decision, escalationReason) => ({
@@ -9667,6 +10110,36 @@ const slackMentions = (userIds) => {
9667
10110
  .map((id) => `<@${id}>`);
9668
10111
  return mentions.length > 0 ? mentions.join(' ') : undefined;
9669
10112
  };
10113
+ const normalizedCrossProviderIdentity = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, '');
10114
+ const isSlackIdentityRecordPath = (path) => path.endsWith('.json') && (path.startsWith('/slack/users/') ||
10115
+ path.startsWith('/slack/channels/'));
10116
+ const slackIdentityPathTimestamp = (path) => {
10117
+ const timestamps = [...path.matchAll(/(?:^|\/)(\d{10})[_\.][0-9]+/gu)];
10118
+ return Number(timestamps.at(-1)?.[1] ?? 0);
10119
+ };
10120
+ const slackUserIdMatchingIdentity = (payload, identity) => {
10121
+ if (payload.is_bot === true ||
10122
+ payload.user_is_bot === true ||
10123
+ payload.is_deleted === true ||
10124
+ payload.deleted === true)
10125
+ return undefined;
10126
+ const userId = stringValue(payload.id) ?? stringValue(payload.user);
10127
+ if (!userId || !/^[UW][A-Z0-9]+$/u.test(userId))
10128
+ return undefined;
10129
+ const email = stringValue(payload.email) ?? stringValue(payload.user_email);
10130
+ const aliases = [
10131
+ stringValue(payload.name),
10132
+ stringValue(payload.real_name),
10133
+ stringValue(payload.display_name),
10134
+ stringValue(payload.user_name),
10135
+ stringValue(payload.user_real_name),
10136
+ stringValue(payload.user_display_name),
10137
+ email?.split('@')[0],
10138
+ ];
10139
+ return aliases.some((alias) => alias && normalizedCrossProviderIdentity(alias) === identity)
10140
+ ? userId
10141
+ : undefined;
10142
+ };
9670
10143
  const agentQuestionSlackText = (issue, question, stakeholderUserIds = []) => [
9671
10144
  slackMentions(stakeholderUserIds),
9672
10145
  `${issue.key}: ${question.agentName} needs input.`,