@agent-relay/factory 0.1.26 → 0.1.28

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';
@@ -129,6 +131,11 @@ export class FactoryLoop {
129
131
  #githubIssueCommentWatchers = new Map();
130
132
  #githubIssueCommentWatchStates = new Map();
131
133
  #githubIssueCommentQueues = new Map();
134
+ #githubIssueAuthors = new Map();
135
+ #githubIssueAuthorLookups = new Map();
136
+ #slackReporterUserIds = new Map();
137
+ #slackReporterUserIdLookups = new Map();
138
+ #reconciledGithubInProgress = new Set();
132
139
  #resolvedSlackChannelDir;
133
140
  #slackChannelDirRefresh;
134
141
  // Agents we've already logged an ambiguous-PID-lookup warning for, so the
@@ -947,12 +954,16 @@ export class FactoryLoop {
947
954
  return { dispatchRelayflow: true };
948
955
  }
949
956
  if (isGithubIssueFilePath(path)) {
950
- const sourceKey = `github:${path}`;
957
+ const parts = githubIssuePathParts(path);
958
+ const sourceKey = parts
959
+ ? `github:${githubIssueIdentity(parts.owner, parts.repo, parts.number)}`
960
+ : `github:${path}`;
951
961
  if (seenIssueKeys.has(sourceKey)) {
952
962
  this.#increment('liveDuplicateIssueEventsSuppressed');
953
- this.#logger.debug?.('[factory] suppressed duplicate live GitHub issue event in current drain', {
963
+ this.#logger.debug?.('[factory] suppressed duplicate live GitHub issue alias in current drain', {
954
964
  id: event.id,
955
965
  path,
966
+ issue: parts ? sourceKey.slice('github:'.length) : undefined,
956
967
  });
957
968
  return { dispatchRelayflow: false };
958
969
  }
@@ -1130,8 +1141,18 @@ export class FactoryLoop {
1130
1141
  titleMarker: FACTORY_E2E_MARKER,
1131
1142
  });
1132
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
+ }
1133
1153
  async #resolveIssuePr(issue, opts = {}) {
1134
- const key = issueStateKey(issueRef(issue));
1154
+ const issueKey = issueStateKey(issueRef(issue));
1155
+ const key = opts.openOnly ? `${issueKey}:open` : issueKey;
1135
1156
  const now = this.#clock.now();
1136
1157
  const cached = this.#probePrResolvedCache.get(key);
1137
1158
  if (cached && cached.expiresAtMs > now) {
@@ -1176,12 +1197,16 @@ export class FactoryLoop {
1176
1197
  await this.#ensureGithubIngestionReady();
1177
1198
  }
1178
1199
  const paths = await this.#readyIssuePaths();
1200
+ const orphanRecovery = issueSource === 'github'
1201
+ ? await this.#githubOrphanRecoveryContext()
1202
+ : undefined;
1179
1203
  const pulled = [];
1180
1204
  const triaged = [];
1181
1205
  const dispatched = [];
1182
1206
  const skipped = [];
1183
1207
  let lastReadyReadProgressAtMs = this.#clock.now();
1184
1208
  let readyIssueReads = 0;
1209
+ const issueEntries = [];
1185
1210
  for (const path of paths) {
1186
1211
  const issue = await this.#readIssue(path);
1187
1212
  readyIssueReads += 1;
@@ -1191,40 +1216,108 @@ export class FactoryLoop {
1191
1216
  if (issue && issueSource === 'linear') {
1192
1217
  await this.#recordCanonicalIssueState(issue);
1193
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) {
1194
1237
  if (!issue) {
1195
1238
  continue;
1196
1239
  }
1197
1240
  pulled.push(issueRef(issue));
1198
- const dispatchBlock = await this.#dispatchBlockReason(issue);
1199
- if (dispatchBlock) {
1200
- skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
1201
- continue;
1241
+ const wasReady = this.#isIssueReady(issue);
1242
+ const labels = isGithubIssue(issue)
1243
+ ? new Set(issue.labels.map((label) => label.trim().toLowerCase()))
1244
+ : undefined;
1245
+ const requiredLabel = this.#config.safety.requireLabel.trim().toLowerCase();
1246
+ const mayRecoverGithubOrphan = !wasReady &&
1247
+ !dryRun &&
1248
+ issueSource === 'github' &&
1249
+ Boolean(orphanRecovery) &&
1250
+ Boolean(requiredLabel) &&
1251
+ Boolean(labels?.has(requiredLabel)) &&
1252
+ Boolean(labels?.has('factory:in-progress')) &&
1253
+ !labels?.has('factory:human-review');
1254
+ if (!mayRecoverGithubOrphan) {
1255
+ const dispatchBlock = await this.#dispatchBlockReason(issue);
1256
+ if (dispatchBlock) {
1257
+ skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
1258
+ continue;
1259
+ }
1202
1260
  }
1203
1261
  const batch = await this.#batch();
1204
1262
  if (batch.isInFlight(issue) || batch.isQueued(issue)) {
1205
1263
  skipped.push({ issue: issueRef(issue), reason: 'already tracked' });
1206
1264
  continue;
1207
1265
  }
1208
- if (!this.#isIssueReady(issue)) {
1266
+ const recoveredOrphan = mayRecoverGithubOrphan &&
1267
+ await this.#reconcileOrphanedGithubInProgress(issue, orphanRecovery, dryRun);
1268
+ if (!wasReady && !recoveredOrphan) {
1269
+ if (mayRecoverGithubOrphan) {
1270
+ const dispatchBlock = await this.#dispatchBlockReason(issue);
1271
+ if (dispatchBlock) {
1272
+ skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
1273
+ continue;
1274
+ }
1275
+ }
1209
1276
  skipped.push({ issue: issueRef(issue), reason: 'live state is not ready-for-agent' });
1210
1277
  continue;
1211
1278
  }
1212
- if (!isInFactoryScope(issue, this.#config.safety)) {
1213
- skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
1214
- continue;
1215
- }
1216
- if (!isDispatchableIssue(issue)) {
1217
- skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
1218
- continue;
1219
- }
1220
- const decision = await this.triageIssue(issue);
1221
- triaged.push(decision);
1222
- const result = await this.dispatch(decision, { dryRun });
1223
- if (result.agents.length === 0 && !dryRun) {
1224
- skipped.push({ issue: decision.issue, reason: 'queued or escalated' });
1279
+ const recoveredIdentity = recoveredOrphan ? githubIssueRefIdentity(issueRef(issue)) : undefined;
1280
+ try {
1281
+ if (recoveredOrphan) {
1282
+ const dispatchBlock = await this.#dispatchBlockReason(issue);
1283
+ if (dispatchBlock) {
1284
+ skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
1285
+ continue;
1286
+ }
1287
+ }
1288
+ if (!isInFactoryScope(issue, this.#config.safety)) {
1289
+ skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
1290
+ continue;
1291
+ }
1292
+ if (!isDispatchableIssue(issue)) {
1293
+ skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
1294
+ continue;
1295
+ }
1296
+ const decision = await this.triageIssue(issue);
1297
+ triaged.push(decision);
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
+ }
1311
+ if (result.agents.length === 0 && !dryRun) {
1312
+ skipped.push({ issue: decision.issue, reason: 'queued or escalated' });
1313
+ }
1314
+ else {
1315
+ dispatched.push(result);
1316
+ }
1225
1317
  }
1226
- else {
1227
- dispatched.push(result);
1318
+ finally {
1319
+ if (recoveredIdentity)
1320
+ this.#reconciledGithubInProgress.delete(recoveredIdentity);
1228
1321
  }
1229
1322
  }
1230
1323
  report = { pulled, triaged, dispatched, skipped, dryRun, slackDegraded: this.#slackDegraded };
@@ -1255,6 +1348,242 @@ export class FactoryLoop {
1255
1348
  }
1256
1349
  }
1257
1350
  }
1351
+ async #githubOrphanRecoveryContext() {
1352
+ try {
1353
+ const [registry, roster, lifecycles, waitingClarifications] = await Promise.all([
1354
+ readFactoryInFlightRegistry(this.#config.loop.registryPath),
1355
+ this.#fleet.roster(),
1356
+ this.#state.listDispatchLifecycles(this.#workspaceId),
1357
+ this.#state.listWaitingClarifications(this.#workspaceId),
1358
+ ]);
1359
+ const onlineAgents = new Set(roster.agents.map((agent) => agent.name));
1360
+ const activeIssueIdentities = new Set();
1361
+ for (const [, lifecycle] of lifecycles) {
1362
+ if (isTerminalDispatchLifecycle(lifecycle))
1363
+ continue;
1364
+ const identity = githubIssueRefIdentity(lifecycle.issue);
1365
+ if (identity)
1366
+ activeIssueIdentities.add(identity);
1367
+ }
1368
+ for (const [, waiting] of waitingClarifications) {
1369
+ const identity = githubIssueRefIdentity(waiting.issue);
1370
+ if (identity)
1371
+ activeIssueIdentities.add(identity);
1372
+ }
1373
+ for (const agent of registry?.agents ?? []) {
1374
+ if (!onlineAgents.has(agent.name) || !agent.issue)
1375
+ continue;
1376
+ const identity = githubIssueRefIdentity(agent.issue);
1377
+ if (identity)
1378
+ activeIssueIdentities.add(identity);
1379
+ }
1380
+ return {
1381
+ activeIssueIdentities,
1382
+ onlineAgentNames: onlineAgents,
1383
+ };
1384
+ }
1385
+ catch (error) {
1386
+ this.#increment('githubOrphanRecoveryContextFailures');
1387
+ this.#logger.warn?.('[factory] could not establish orphan-recovery safety context; preserving in-progress issues', {
1388
+ error: describeError(error).errorMessage,
1389
+ });
1390
+ return undefined;
1391
+ }
1392
+ }
1393
+ async #reconcileOrphanedGithubInProgress(issue, context, dryRun) {
1394
+ if (dryRun || !context || !isGithubIssue(issue))
1395
+ return false;
1396
+ const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
1397
+ const required = this.#config.safety.requireLabel.trim().toLowerCase();
1398
+ if (!required ||
1399
+ !labels.has(required) ||
1400
+ !labels.has('factory:in-progress') ||
1401
+ labels.has('factory:human-review'))
1402
+ return false;
1403
+ const identity = githubIssueRefIdentity(issueRef(issue));
1404
+ if (!identity ||
1405
+ context.activeIssueIdentities.has(identity) ||
1406
+ [...context.onlineAgentNames].some((name) => githubAgentNameMatchesIssue(name, issue))) {
1407
+ this.#increment('githubOrphanRecoveriesBlockedActive');
1408
+ return false;
1409
+ }
1410
+ const getProviderStatus = this.#githubWriteback.getIssueStatus;
1411
+ if (!getProviderStatus) {
1412
+ this.#increment('githubOrphanRecoveryStatusLookupUnavailable');
1413
+ return false;
1414
+ }
1415
+ let providerStatus;
1416
+ try {
1417
+ providerStatus = await getProviderStatus.call(this.#githubWriteback, issue);
1418
+ }
1419
+ catch (error) {
1420
+ this.#increment('githubOrphanRecoveryStatusLookupFailures');
1421
+ this.#logger.warn?.('[factory] could not verify provider-authoritative GitHub issue status; preserving it', {
1422
+ issue: issue.key,
1423
+ error: describeError(error).errorMessage,
1424
+ });
1425
+ return false;
1426
+ }
1427
+ if (!providerStatus || providerStatus === 'human-review') {
1428
+ this.#increment('githubOrphanRecoveriesBlockedProviderStatus');
1429
+ return false;
1430
+ }
1431
+ let openPr;
1432
+ try {
1433
+ openPr = await this.#openCompletionPr(issue);
1434
+ }
1435
+ catch (error) {
1436
+ this.#increment('githubOrphanRecoveryPrProbeFailures');
1437
+ this.#logger.warn?.('[factory] could not prove an in-progress GitHub issue has no open PR; preserving it', {
1438
+ issue: issue.key,
1439
+ error: describeError(error).errorMessage,
1440
+ });
1441
+ return false;
1442
+ }
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
+ }
1457
+ this.#increment('githubOrphanRecoveriesBlockedOpenPr');
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', {
1461
+ issue: issue.key,
1462
+ repo: openPr.repo,
1463
+ prNumber: openPr.prNumber,
1464
+ });
1465
+ return false;
1466
+ }
1467
+ try {
1468
+ if (providerStatus === 'in-progress') {
1469
+ await this.#githubWriteback.setStatus(issue, 'ready');
1470
+ }
1471
+ // A crashed dispatch may leave its durable attempt marked in-flight even
1472
+ // after every agent and lifecycle disappeared. Only clear that stale bit
1473
+ // after all provider, agent, lifecycle, and open-PR safety checks pass.
1474
+ await this.#clearDispatchInFlight(issue);
1475
+ this.#reconciledGithubInProgress.add(identity);
1476
+ this.#increment('githubOrphanedInProgressRecovered');
1477
+ this.#logger.warn?.('[factory] recovered orphaned GitHub in-progress issue for redispatch', {
1478
+ issue: issue.key,
1479
+ path: issue.path,
1480
+ });
1481
+ return true;
1482
+ }
1483
+ catch (error) {
1484
+ this.#increment('githubOrphanRecoveryWritebackFailures');
1485
+ this.#logger.warn?.('[factory] failed to clear orphaned GitHub lifecycle status; preserving in-progress issue', {
1486
+ issue: issue.key,
1487
+ error: describeError(error).errorMessage,
1488
+ });
1489
+ return false;
1490
+ }
1491
+ }
1492
+ async #openCompletionPr(issue) {
1493
+ if (this.#customProbePrResolver) {
1494
+ return await this.#probePrResolver(issue);
1495
+ }
1496
+ return await this.#resolveIssuePr(issue, {
1497
+ titleMarker: FACTORY_E2E_MARKER,
1498
+ openOnly: true,
1499
+ failOnLookupError: true,
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;
1586
+ }
1258
1587
  async #listRelayfileTree(prefix, phase) {
1259
1588
  return this.#withRelayfileOperation('listTree', { phase, prefix }, () => this.#mount.listTree(prefix), {
1260
1589
  count: (paths) => paths.length,
@@ -1576,7 +1905,7 @@ export class FactoryLoop {
1576
1905
  if (!dryRun) {
1577
1906
  const issue = await this.#readIssue(dispatchDecision.issue.path);
1578
1907
  if (!issue || !this.#isIssueReady(issue)) {
1579
- throw new Error(`Live state changed before writeback for ${dispatchDecision.issue.key}`);
1908
+ throw new LiveDispatchStateChangedError(dispatchDecision.issue.key);
1580
1909
  }
1581
1910
  try {
1582
1911
  await this.#postIssueComment(issue, comment);
@@ -1615,14 +1944,33 @@ export class FactoryLoop {
1615
1944
  // acknowledged spawns, so cleanup never races a name-only survivor.
1616
1945
  const failureHandoffs = this.#dispatchFailureHandoffs(record, spawnedForReaperHandoff);
1617
1946
  await this.#persistDispatchFailureReaperHandoff(record, failureHandoffs);
1618
- const worktreesTornDown = await this.#teardownFailedDispatchWorktrees(failureHandoffs);
1619
- await this.#recordDispatchFailure(decision.issue);
1620
- const failedState = await this.#state.getDispatchAttempts(this.#workspaceId, decision.issue.key);
1621
- 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
+ }
1622
1969
  batch.abandon(decision.issue);
1623
- if (!failedState?.terminal)
1970
+ if (!liveStateChanged && !failedState?.terminal)
1624
1971
  this.#scheduleDispatchLifecycleRetry(record);
1625
- this.#error(error, decision.issue);
1972
+ if (!liveStateChanged)
1973
+ this.#error(error, decision.issue);
1626
1974
  // The teardown runs while the record still exists so it can safely
1627
1975
  // derive every shared checkout. Rewrite the registry only after abandon
1628
1976
  // removes those agents from the ordinary in-flight view.
@@ -1767,18 +2115,20 @@ export class FactoryLoop {
1767
2115
  this.#dispatchLifecycleRenewTimer = undefined;
1768
2116
  }
1769
2117
  }
1770
- async #claimDispatchLifecycle(decision, dryRun, preparedRunId) {
2118
+ async #claimDispatchLifecycle(decision, dryRun, preparedRunId, initial = {}) {
1771
2119
  const key = issueKey(decision.issue);
1772
2120
  const seed = {
1773
2121
  runId: preparedRunId ?? randomUUID(),
1774
2122
  issue: { ...decision.issue },
1775
2123
  decision: structuredClone(decision),
1776
2124
  dryRun,
1777
- phase: 'dispatching',
2125
+ phase: initial.phase ?? 'dispatching',
1778
2126
  agents: [],
1779
2127
  invocationIds: [],
1780
2128
  updatedAtMs: this.#clock.now(),
1781
2129
  };
2130
+ if (initial.pullRequest)
2131
+ seed.pullRequest = initial.pullRequest;
1782
2132
  if (!preparedRunId)
1783
2133
  seed.decision = decisionWithLifecycleBranches(seed.decision, seed.runId);
1784
2134
  const claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, seed, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
@@ -1904,7 +2254,19 @@ export class FactoryLoop {
1904
2254
  if (!promoted || promoted.phase !== 'dispatching') {
1905
2255
  throw new Error(`durable dispatch ${lifecycle.issue.key} lost its promoted lifecycle`);
1906
2256
  }
1907
- 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
+ }
1908
2270
  }
1909
2271
  const batch = await this.#batch();
1910
2272
  const durableRecord = inFlightRecordFromLifecycle(lifecycle);
@@ -1940,7 +2302,7 @@ export class FactoryLoop {
1940
2302
  const implementer = [...record.agents.values()].find((agent) => agent.spec.role === 'implementer');
1941
2303
  if (!implementer)
1942
2304
  throw new Error(`durable dispatch ${record.issue.key} has no implementer to publish`);
1943
- const published = await this.#publishImplementerPullRequest(record, implementer);
2305
+ const published = await this.#publishImplementerPullRequest(record, implementer, { reconcileExisting: true });
1944
2306
  if (!published)
1945
2307
  throw new Error(`durable dispatch ${record.issue.key} did not produce a pull request`);
1946
2308
  if (!await this.#saveDispatchLifecycle(record, 'published', published))
@@ -2159,6 +2521,12 @@ export class FactoryLoop {
2159
2521
  }
2160
2522
  }
2161
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
+ }
2162
2530
  this.#logger.error?.('[factory] failed to handle issue change', error);
2163
2531
  }
2164
2532
  }
@@ -2185,11 +2553,14 @@ export class FactoryLoop {
2185
2553
  if (githubState === 'closed') {
2186
2554
  return false;
2187
2555
  }
2188
- if (issue.labels.some((label) => GITHUB_LIFECYCLE_LABELS.has(label.trim().toLowerCase()))) {
2556
+ const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
2557
+ if (labels.has('factory:human-review'))
2558
+ return false;
2559
+ const identity = githubIssueRefIdentity(issueRef(issue));
2560
+ if (labels.has('factory:in-progress') && (!identity || !this.#reconciledGithubInProgress.has(identity)))
2189
2561
  return false;
2190
- }
2191
2562
  const required = this.#config.safety.requireLabel.trim().toLowerCase();
2192
- return Boolean(required) && issue.labels.some((label) => label.trim().toLowerCase() === required);
2563
+ return Boolean(required) && labels.has(required);
2193
2564
  }
2194
2565
  async #postIssueComment(issue, body) {
2195
2566
  if (isGithubIssue(issue)) {
@@ -2274,12 +2645,22 @@ export class FactoryLoop {
2274
2645
  }
2275
2646
  async #githubIssuePaths() {
2276
2647
  try {
2277
- const issuePaths = new Set();
2648
+ const issuePaths = new Map();
2278
2649
  for (const root of githubIssueScanRoots(this.#config)) {
2279
2650
  const paths = await this.#listRelayfileTree(root, 'GitHub issue ingestion');
2280
2651
  for (const path of paths) {
2281
- if (githubIssuePathParts(path) !== undefined) {
2282
- issuePaths.add(path);
2652
+ const parts = githubIssuePathParts(path);
2653
+ if (parts) {
2654
+ const identity = githubIssueIdentity(parts.owner, parts.repo, parts.number);
2655
+ const existing = issuePaths.get(identity);
2656
+ if (!existing || githubIssuePathPreference(path) < githubIssuePathPreference(existing)) {
2657
+ if (existing)
2658
+ this.#increment('githubIssueAliasPathsSuppressed');
2659
+ issuePaths.set(identity, path);
2660
+ }
2661
+ else {
2662
+ this.#increment('githubIssueAliasPathsSuppressed');
2663
+ }
2283
2664
  }
2284
2665
  else if (githubIssueDirectoryPathParts(path) !== undefined) {
2285
2666
  // listTree returns the issue directory entry alongside its
@@ -2291,11 +2672,10 @@ export class FactoryLoop {
2291
2672
  }
2292
2673
  else if (isGithubIssueTreePath(path)) {
2293
2674
  this.#increment('githubIssuesIgnoredByPathRegex');
2294
- this.#logger.debug?.('[factory] ignored GitHub issue path with unsupported relayfile shape', { path });
2295
2675
  }
2296
2676
  }
2297
2677
  }
2298
- return [...issuePaths].sort();
2678
+ return [...issuePaths.values()].sort();
2299
2679
  }
2300
2680
  catch (error) {
2301
2681
  this.#increment('githubIssueListFailures');
@@ -3084,7 +3464,9 @@ export class FactoryLoop {
3084
3464
  }
3085
3465
  }
3086
3466
  if (isCompletionReason(reason)) {
3087
- 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
+ })) {
3088
3470
  if (this.#config.babysitter.enabled)
3089
3471
  await this.#ensureBabysitterForIssue(record);
3090
3472
  else
@@ -3136,7 +3518,9 @@ export class FactoryLoop {
3136
3518
  return;
3137
3519
  }
3138
3520
  try {
3139
- 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
+ })) {
3140
3524
  if (this.#config.babysitter.enabled) {
3141
3525
  await this.#ensureBabysitterForIssue(record);
3142
3526
  return;
@@ -3305,7 +3689,7 @@ export class FactoryLoop {
3305
3689
  return undefined;
3306
3690
  }
3307
3691
  }
3308
- async #publishImplementerPullRequest(record, implementer) {
3692
+ async #publishImplementerPullRequest(record, implementer, opts = {}) {
3309
3693
  const key = `${issueKey(record.issue)}:${implementer.spec.repo}`;
3310
3694
  const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
3311
3695
  if (durable?.pullRequest)
@@ -3334,6 +3718,21 @@ export class FactoryLoop {
3334
3718
  ? sourceRepoParts.owner
3335
3719
  : undefined;
3336
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
+ }
3337
3736
  const baseRef = await this.#githubDefaultBranch(repo);
3338
3737
  const result = await githubWrite.publishPullRequest({
3339
3738
  repo,
@@ -3362,6 +3761,52 @@ export class FactoryLoop {
3362
3761
  });
3363
3762
  return result;
3364
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
+ }
3365
3810
  async #prepareAgentWorktree(record, spec) {
3366
3811
  const worktree = this.#agentWorktree(record, spec);
3367
3812
  if (!worktree || !this.#worktrees)
@@ -3566,12 +4011,37 @@ export class FactoryLoop {
3566
4011
  // the release-driven exit event so it cannot re-trigger a resume before the
3567
4012
  // record leaves the batch.
3568
4013
  async #abandonStuckDispatch(record, reason) {
3569
- const remaining = [...record.agents].filter(([, tracked]) => tracked.spec.role !== 'implementer');
3570
- 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;
3571
4018
  this.#fleet.markAgentTerminal?.(agentName, `implementer-terminal:${reason}`);
3572
4019
  }
3573
- if (remaining.length > 0) {
3574
- 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;
3575
4045
  }
3576
4046
  await this.#recordDispatchTerminal(record.issue);
3577
4047
  const next = (await this.#batch()).complete(record.issue);
@@ -3583,7 +4053,24 @@ export class FactoryLoop {
3583
4053
  await this.dispatch(next.decision, { dryRun: next.dryRun });
3584
4054
  }
3585
4055
  }
3586
- 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 = {}) {
3587
4074
  try {
3588
4075
  const issue = await this.#readIssue(record.issue.path);
3589
4076
  if (!issue) {
@@ -3593,7 +4080,9 @@ export class FactoryLoop {
3593
4080
  // work isn't review-ready, so an implementer exiting with only a draft PR
3594
4081
  // must NOT mark the issue done / release agents — mirror the
3595
4082
  // #sweepPrStateCompletions draft guard, which keeps draft-PR issues in flight.
3596
- const pr = await this.#completionPrForIssue(issue);
4083
+ const pr = opts.openOnly
4084
+ ? await this.#openPrForIssue(issue)
4085
+ : await this.#completionPrForIssue(issue);
3597
4086
  return Boolean(pr && !pr.draft);
3598
4087
  }
3599
4088
  catch (error) {
@@ -3656,11 +4145,19 @@ export class FactoryLoop {
3656
4145
  }
3657
4146
  async #handleDeliveryFailed(info) {
3658
4147
  const critical = await this.#state.consumeCritical(this.#workspaceId, info.msgId ?? '');
4148
+ if (!critical) {
4149
+ this.#increment('nonCriticalDeliveryFailuresIgnored');
4150
+ return;
4151
+ }
3659
4152
  const record = (await this.#batch()).getIssueByAgent(info.to);
3660
- const issue = critical?.issue ?? record?.issue;
4153
+ const issue = critical.issue ?? record?.issue;
3661
4154
  const error = new Error(`Critical delivery failed to ${info.to}${info.reason ? `: ${info.reason}` : ''}`);
3662
4155
  this.#error(error, issue);
3663
- if (critical && this.#fleet.waitForInjected) {
4156
+ if (isTerminalDeliveryFailure(info.reason)) {
4157
+ this.#increment('criticalDeliveryTerminalFailures');
4158
+ return;
4159
+ }
4160
+ if (this.#fleet.waitForInjected) {
3664
4161
  try {
3665
4162
  const ack = await this.#waitForInjectedAndSubmit(critical.input);
3666
4163
  await this.#state.recordCritical(this.#workspaceId, ack.eventId, critical);
@@ -4274,7 +4771,7 @@ export class FactoryLoop {
4274
4771
  const correlationId = githubEscalationCorrelationId('agent-question', record.issue, question.question);
4275
4772
  const issue = await this.#readIssue(record.issue.path);
4276
4773
  const source = issue ? githubIssueSourceRef(issue) : undefined;
4277
- const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
4774
+ const authorizedAuthor = issue ? await this.#resolveGithubIssueAuthor(issue) : undefined;
4278
4775
  if (!issue || !source || !authorizedAuthor) {
4279
4776
  this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, fallbackReason
4280
4777
  ? `${fallbackReason}; no GitHub issue write path with an identifiable issue reporter is available`
@@ -4439,6 +4936,14 @@ export class FactoryLoop {
4439
4936
  else {
4440
4937
  watch.issue = { ...record.issue };
4441
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
+ }
4442
4947
  }
4443
4948
  if (this.#githubIssueCommentWatchers.has(key)) {
4444
4949
  const normalizedWatch = normalizeGithubIssueCommentWatch(watch);
@@ -4770,7 +5275,7 @@ export class FactoryLoop {
4770
5275
  };
4771
5276
  const correlationId = githubEscalationCorrelationId('agent-question', record.issue, `${comment.commentId}:${question.question}`);
4772
5277
  const issue = await this.#readIssue(record.issue.path);
4773
- const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
5278
+ const authorizedAuthor = issue ? await this.#resolveGithubIssueAuthor(issue) : undefined;
4774
5279
  if (!authorizedAuthor) {
4775
5280
  this.#increment('githubAgentQuestionsIgnoredMissingAuthorizedAuthor');
4776
5281
  this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'source GitHub issue has no identifiable reporter authorized to answer the durable question');
@@ -4880,7 +5385,7 @@ export class FactoryLoop {
4880
5385
  return false;
4881
5386
  }
4882
5387
  this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
4883
- const result = await this.#startOrQueueGithubClarifiedDecision(decision);
5388
+ const result = await this.#startOrQueueGithubClarifiedDecision(dispatchAfterGithubClarification(decision, 'human clarification resolved triage'));
4884
5389
  this.#increment('githubTriageAnswersDispatched');
4885
5390
  return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
4886
5391
  }
@@ -5015,6 +5520,23 @@ export class FactoryLoop {
5015
5520
  this.#increment('babysitterOwnershipRestoreSkippedNonOwner');
5016
5521
  continue;
5017
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
+ }
5018
5540
  const record = batch.getIssue(session.issue);
5019
5541
  const tracked = record?.agents.get(session.agentName)
5020
5542
  ?? [...(record?.agents.values() ?? [])].find((agent) => agent.spec.role === 'babysitter')
@@ -5648,7 +6170,7 @@ export class FactoryLoop {
5648
6170
  if (!issue) {
5649
6171
  return;
5650
6172
  }
5651
- const pr = await this.#completionPrForIssue(issue);
6173
+ const pr = await this.#openPrForIssue(issue);
5652
6174
  if (!pr || pr.draft) {
5653
6175
  return;
5654
6176
  }
@@ -5720,8 +6242,10 @@ export class FactoryLoop {
5720
6242
  const initialSpec = babysitterSpec(issue, this.#config, route);
5721
6243
  const sharedCheckout = [...record.agents.values()]
5722
6244
  .map((agent) => agent.spec)
5723
- .find((candidate) => candidate.repo === initialSpec.repo && candidate.baseClonePath && candidate.clonePath);
5724
- 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
5725
6249
  .find((candidate) => candidate.repo === initialSpec.repo && candidate.branch)?.branch;
5726
6250
  const spec = sharedCheckout
5727
6251
  ? {
@@ -5854,7 +6378,11 @@ export class FactoryLoop {
5854
6378
  if (!ref) {
5855
6379
  return undefined;
5856
6380
  }
5857
- 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)))];
5858
6386
  for (const path of candidatePaths) {
5859
6387
  try {
5860
6388
  const snapshot = parsePullSnapshot((await this.#mount.readFile(path)).content, ref.prNumber);
@@ -6403,11 +6931,11 @@ export class FactoryLoop {
6403
6931
  }
6404
6932
  }
6405
6933
  async #escalateTriageToGithub(decision, reason) {
6406
- const question = triageEscalationQuestion(decision);
6407
- const correlationId = githubEscalationCorrelationId('triage', decision.issue, question);
6408
6934
  const issue = await this.#readIssue(decision.issue.path);
6935
+ const question = triageEscalationQuestion(decision, issue);
6936
+ const correlationId = githubEscalationCorrelationId('triage', decision.issue, question);
6409
6937
  const source = issue ? githubIssueSourceRef(issue) : undefined;
6410
- const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
6938
+ const authorizedAuthor = issue ? await this.#resolveGithubIssueAuthor(issue) : undefined;
6411
6939
  if (!issue || !source || !authorizedAuthor) {
6412
6940
  this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
6413
6941
  return;
@@ -6482,8 +7010,12 @@ export class FactoryLoop {
6482
7010
  return;
6483
7011
  const source = githubIssueSourceRef(issue);
6484
7012
  const stakeholderMentions = slackMentions(this.#config.slack.stakeholderUserIds);
6485
- const reporter = githubIssueAuthor(issue);
6486
- const audience = [stakeholderMentions, reporter ? `GitHub reporter: @${reporter}.` : undefined]
7013
+ const reporter = await this.#resolveGithubIssueAuthor(issue);
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]
6487
7019
  .filter((part) => Boolean(part))
6488
7020
  .join(' ');
6489
7021
  const replyInstruction = source?.url
@@ -6494,13 +7026,122 @@ export class FactoryLoop {
6494
7026
  text: [
6495
7027
  `${audience ? `${audience} ` : ''}${decision.issue.key}: factory triage escalation for ${issue.title}`,
6496
7028
  `Reason: ${reason}`,
6497
- `Question: ${triageEscalationQuestion(decision)} ${replyInstruction}`,
7029
+ `Question: ${triageEscalationQuestion(decision, issue)} ${replyInstruction}`,
6498
7030
  ].join('\n'),
6499
7031
  });
6500
7032
  await this.#state.setSlackThread(this.#workspaceId, issueKey(decision.issue), root.threadId);
6501
7033
  this.#increment('triageEscalationsMirroredToSlack');
6502
7034
  this.#recordSlackWritebackSuccess('triage-escalation-mirror');
6503
7035
  }
7036
+ async #resolveGithubIssueAuthor(issue) {
7037
+ const embeddedAuthor = githubIssueAuthor(issue);
7038
+ if (embeddedAuthor)
7039
+ return embeddedAuthor;
7040
+ const source = githubIssueSourceRef(issue);
7041
+ const lookup = this.#githubWriteback.getIssueAuthor;
7042
+ if (!source || !lookup)
7043
+ return undefined;
7044
+ const key = githubIssueSourceKey(source);
7045
+ if (this.#githubIssueAuthors.has(key)) {
7046
+ return this.#githubIssueAuthors.get(key);
7047
+ }
7048
+ const existing = this.#githubIssueAuthorLookups.get(key);
7049
+ if (existing)
7050
+ return existing;
7051
+ const pending = lookup.call(this.#githubWriteback, issue)
7052
+ .then((author) => {
7053
+ const normalized = author?.trim() || undefined;
7054
+ this.#githubIssueAuthors.set(key, normalized);
7055
+ if (normalized) {
7056
+ this.#increment('githubIssueAuthorsResolvedFromProvider');
7057
+ }
7058
+ return normalized;
7059
+ })
7060
+ .catch((error) => {
7061
+ this.#increment('githubIssueAuthorLookupFailures');
7062
+ this.#logger.warn?.('[factory] provider-authoritative GitHub issue author lookup failed', {
7063
+ owner: source.owner,
7064
+ repo: source.repo,
7065
+ issue: source.number,
7066
+ error: describeError(error).errorMessage,
7067
+ });
7068
+ return undefined;
7069
+ })
7070
+ .finally(() => {
7071
+ this.#githubIssueAuthorLookups.delete(key);
7072
+ });
7073
+ this.#githubIssueAuthorLookups.set(key, pending);
7074
+ return pending;
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
+ }
6504
7145
  async #postAndWatchSlackEscalationThread(decision, reason) {
6505
7146
  if (!this.#slack || !this.#config.slack) {
6506
7147
  return;
@@ -6512,7 +7153,7 @@ export class FactoryLoop {
6512
7153
  text: [
6513
7154
  `${stakeholderMentions ? `${stakeholderMentions} ` : ''}${decision.issue.key}: factory triage escalation for ${issue?.title ?? decision.issue.key}`,
6514
7155
  `Reason: ${reason}`,
6515
- `Question: ${triageEscalationQuestion(decision)}`,
7156
+ `Question: ${triageEscalationQuestion(decision, issue)}`,
6516
7157
  ].join('\n'),
6517
7158
  });
6518
7159
  await this.#state.setSlackThread(this.#workspaceId, issueKey(decision.issue), root.threadId);
@@ -7651,6 +8292,16 @@ const githubIssueAsFactoryIssue = (issue) => {
7651
8292
  },
7652
8293
  };
7653
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
+ };
7654
8305
  export async function readFactoryLoopHeartbeat(path = DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH) {
7655
8306
  try {
7656
8307
  return parseJsonContent(await readFile(path, 'utf8'));
@@ -7908,19 +8559,29 @@ function githubUrlCandidates(value) {
7908
8559
  return [...candidates];
7909
8560
  }
7910
8561
  function labelRoutesForIssue(issue, config) {
7911
- const githubReadinessLabel = isGithubIssue(issue) ? config.safety.requireLabel.trim().toLowerCase() : undefined;
7912
- 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) &&
7913
8565
  label.toLowerCase() !== githubReadinessLabel &&
7914
- (!isGithubIssue(issue) || !GITHUB_LIFECYCLE_LABELS.has(label.toLowerCase())));
8566
+ (!githubIssue || !GITHUB_LIFECYCLE_LABELS.has(label.toLowerCase())));
8567
+ const labels = [];
7915
8568
  const routes = [];
7916
8569
  const offendingLabels = [];
7917
8570
  const seenRepos = new Set();
7918
- for (const label of labels) {
8571
+ for (const label of candidateLabels) {
7919
8572
  const entry = findLabelRoute(config.repos.byLabel, label);
7920
8573
  if (!entry) {
7921
- 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
+ }
7922
8582
  continue;
7923
8583
  }
8584
+ labels.push(label);
7924
8585
  const repo = entry.repo;
7925
8586
  if (seenRepos.has(repo)) {
7926
8587
  continue;
@@ -8136,6 +8797,26 @@ export const githubIssuePathParts = (path) => {
8136
8797
  slug: match[7],
8137
8798
  };
8138
8799
  };
8800
+ const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
8801
+ const githubIssueRefIdentity = (issue) => {
8802
+ const parts = githubIssuePathParts(issue.path);
8803
+ return parts ? githubIssueIdentity(parts.owner, parts.repo, parts.number) : undefined;
8804
+ };
8805
+ const githubIssuePathPreference = (path) => {
8806
+ if (path.includes('/issues/by-id/'))
8807
+ return 0;
8808
+ if (path.endsWith('/meta.json'))
8809
+ return 1;
8810
+ if (path.endsWith('.json'))
8811
+ return 2;
8812
+ return 3;
8813
+ };
8814
+ const githubAgentNameMatchesIssue = (name, issue) => {
8815
+ const parts = githubIssuePathParts(issue.path);
8816
+ if (!parts)
8817
+ return false;
8818
+ return name.startsWith(`ar-${parts.number}-`) && name.endsWith(`-${sanitizeAgentSlug(parts.repo)}`);
8819
+ };
8139
8820
  const githubIssueCommentPathParts = (path) => {
8140
8821
  const match = path.match(/^\/github\/repos\/(?:([^/]+)\/([^/]+)|([A-Za-z0-9-]+)__([^/]+))\/issues\/(\d+)(?:__[^/]*)?\/comments\/([^/]+?)(?:\.json|\/(?:meta|metadata)\.json)$/u);
8141
8822
  const owner = match?.[1] ?? match?.[3];
@@ -8295,18 +8976,29 @@ const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
8295
8976
  if (!path.endsWith('.json'))
8296
8977
  continue;
8297
8978
  const pr = await readProbePrCandidate(mount, path);
8979
+ if (opts.openOnly && normalizePrState(pr?.state) !== 'OPEN')
8980
+ continue;
8298
8981
  const score = pr
8299
8982
  ? issuePrMatchScore(pr, issue, opts.titleMarker ?? config.safety.requireTitlePrefix, opts)
8300
8983
  : 0;
8301
8984
  if (!pr || score <= 0)
8302
8985
  continue;
8303
- 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
+ });
8304
8995
  }
8305
8996
  }
8306
8997
  return candidates.sort((a, b) => b.score - a.score || b.prNumber - a.prNumber)[0];
8307
8998
  };
8308
8999
  const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
8309
9000
  const candidates = [];
9001
+ let lookupFailures = 0;
8310
9002
  for (const repo of reposFromConfig(config)) {
8311
9003
  let payload;
8312
9004
  try {
@@ -8318,30 +9010,37 @@ const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
8318
9010
  '--state',
8319
9011
  'all',
8320
9012
  '--json',
8321
- 'number,title,body,headRefName,isDraft,state',
9013
+ 'number,title,body,headRefName,isDraft,state,url',
8322
9014
  '--limit',
8323
9015
  String(PROBE_PR_GH_CANDIDATE_LIMIT),
8324
9016
  ]);
8325
9017
  if (!result.stdout.trim()) {
9018
+ lookupFailures += 1;
8326
9019
  logger?.warn?.('[factory] gh PR resolver returned empty output', { issue: issue.key, repo });
8327
9020
  continue;
8328
9021
  }
8329
9022
  payload = parseJsonContent(result.stdout);
8330
9023
  }
8331
9024
  catch (error) {
9025
+ lookupFailures += 1;
8332
9026
  logger?.warn?.('[factory] gh PR resolver failed', { issue: issue.key, repo, error });
8333
9027
  continue;
8334
9028
  }
8335
9029
  if (!Array.isArray(payload)) {
9030
+ lookupFailures += 1;
8336
9031
  logger?.warn?.('[factory] gh PR resolver returned non-array payload', { issue: issue.key, repo });
8337
9032
  continue;
8338
9033
  }
8339
9034
  if (payload.length >= PROBE_PR_GH_CANDIDATE_LIMIT) {
8340
9035
  logger?.warn?.('[factory] gh PR resolver hit candidate limit', { issue: issue.key, repo, limit: PROBE_PR_GH_CANDIDATE_LIMIT });
9036
+ if (opts.failOnLookupError)
9037
+ lookupFailures += 1;
8341
9038
  }
8342
9039
  for (const entry of payload) {
8343
9040
  const pr = ghProbePrCandidate(entry);
8344
- if (!pr || !containsIssueKey(pr.headRef, issue.key))
9041
+ if (!pr || !factoryBranchMatchesIssue(pr.headRef, issue.key))
9042
+ continue;
9043
+ if (opts.openOnly && normalizePrState(pr.state) !== 'OPEN')
8345
9044
  continue;
8346
9045
  const score = issuePrMatchScore(pr, issue, opts.titleMarker ?? config.safety.requireTitlePrefix, opts);
8347
9046
  if (score <= 0)
@@ -8350,14 +9049,20 @@ const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
8350
9049
  repo,
8351
9050
  prNumber: pr.number,
8352
9051
  draft: pr.draft,
9052
+ headRef: pr.headRef,
9053
+ url: pr.url,
8353
9054
  score,
8354
9055
  open: normalizePrState(pr.state) === 'OPEN',
8355
9056
  });
8356
9057
  }
8357
9058
  }
8358
- return candidates.sort((a, b) => b.score - a.score ||
9059
+ const resolved = candidates.sort((a, b) => b.score - a.score ||
8359
9060
  Number(b.open) - Number(a.open) ||
8360
9061
  b.prNumber - a.prNumber)[0];
9062
+ if (!resolved && opts.failOnLookupError && lookupFailures > 0) {
9063
+ throw new Error(`Unable to confirm open pull request state for ${issue.key} in ${lookupFailures} configured repository lookup(s)`);
9064
+ }
9065
+ return resolved;
8361
9066
  };
8362
9067
  const reposFromConfig = (config) => {
8363
9068
  const repos = new Set([
@@ -8452,6 +9157,8 @@ const readProbePrCandidate = async (mount, path) => {
8452
9157
  body: stringValue(payload.body) ?? '',
8453
9158
  headRef: refName(payload.headRef) ?? refName(payload.head) ?? stringValue(payload.head_ref) ?? '',
8454
9159
  draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
9160
+ state: stringValue(payload.state),
9161
+ url: stringValue(payload.url) ?? stringValue(payload.html_url),
8455
9162
  };
8456
9163
  }
8457
9164
  catch {
@@ -8472,12 +9179,13 @@ const ghProbePrCandidate = (value) => {
8472
9179
  headRef: stringValue(payload.headRefName) ?? '',
8473
9180
  draft: booleanValue(payload.isDraft),
8474
9181
  state: stringValue(payload.state),
9182
+ url: stringValue(payload.url),
8475
9183
  };
8476
9184
  };
8477
9185
  const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
8478
9186
  if (opts.requireTitleMarker && !hasTitlePrefix(pr.title, marker))
8479
9187
  return 0;
8480
- if (containsIssueKey(pr.headRef, issue.key))
9188
+ if (factoryBranchMatchesIssue(pr.headRef, issue.key))
8481
9189
  return 30;
8482
9190
  if (containsIssueKey(pr.title, issue.key))
8483
9191
  return 20;
@@ -8486,6 +9194,10 @@ const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
8486
9194
  return 0;
8487
9195
  };
8488
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);
8489
9201
  const normalizePrState = (state) => state?.toUpperCase();
8490
9202
  const failClosedGhRunner = async () => ({ stdout: '[]' });
8491
9203
  const ISSUE_KEY_PATTERN = /^[A-Z]+-\d+$/u;
@@ -8800,7 +9512,7 @@ const babysitterCriticalIssueMatches = (signalKey, issue) => {
8800
9512
  Number(match[3]) === parts.number;
8801
9513
  };
8802
9514
  const prSnapshotIssueMatchScore = (snapshot, issueKey) => {
8803
- if (containsIssueKey(snapshot.headRef ?? '', issueKey))
9515
+ if (factoryBranchMatchesIssue(snapshot.headRef ?? '', issueKey))
8804
9516
  return 30;
8805
9517
  if (containsIssueKey(snapshot.title ?? '', issueKey))
8806
9518
  return 20;
@@ -8882,7 +9594,7 @@ const isAllowedFactoryDraft = async (path, content, opts, mount, config) => {
8882
9594
  }
8883
9595
  return false;
8884
9596
  };
8885
- 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);
8886
9598
  const isIssuePathInFactoryScope = async (mount, path, config) => {
8887
9599
  try {
8888
9600
  return isInFactoryScope(parseLinearIssue(path, (await mount.readFile(path)).content), config.safety);
@@ -9269,25 +9981,27 @@ const triageEscalationReason = (decision) => {
9269
9981
  }
9270
9982
  return `${reasons.join(' and ')}${decision.rationale ? `: ${decision.rationale}` : ''}`;
9271
9983
  };
9272
- 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) => {
9273
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.`;
9274
9996
  if (routedRepos.length === 0) {
9275
- return [
9276
- 'Which repository or repositories should handle this issue?',
9277
- 'Please include the intended approach and the acceptance criteria/tests the agent should satisfy.',
9278
- ].join(' ');
9997
+ return `Which repository or repositories should handle this issue? ${details}`;
9279
9998
  }
9280
9999
  if (decision.thin) {
9281
- return [
9282
- `Factory matched ${routedRepos.join(', ')}.`,
9283
- 'Please clarify the concrete expected behavior, constraints, and acceptance criteria/tests before dispatch.',
9284
- ].join(' ');
10000
+ return `Factory matched ${routedRepos.join(', ')}. ${details}`;
9285
10001
  }
9286
- return [
9287
- `Factory matched ${routedRepos.join(', ')}, but triage confidence is low.`,
9288
- 'Please confirm the intended repo/approach or correct the route before dispatch.',
9289
- ].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}`;
9290
10003
  };
10004
+ const isTerminalDeliveryFailure = (reason) => /worker[_ -]?(?:exited|disappeared)|max delivery retries exceeded/iu.test(reason ?? '');
9291
10005
  const isTriageEscalationWatchRecord = (record) => record.agents.size === 0 && record.invocationIds.size === 0 && triageEscalationReason(record.decision) !== undefined;
9292
10006
  const hasDispatchableRoute = (decision) => decision.routes.length > 0 && dispatchSpecs(decision).length > 0;
9293
10007
  const dispatchAfterSlackClarification = (decision, escalationReason) => ({
@@ -9396,6 +10110,36 @@ const slackMentions = (userIds) => {
9396
10110
  .map((id) => `<@${id}>`);
9397
10111
  return mentions.length > 0 ? mentions.join(' ') : undefined;
9398
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
+ };
9399
10143
  const agentQuestionSlackText = (issue, question, stakeholderUserIds = []) => [
9400
10144
  slackMentions(stakeholderUserIds),
9401
10145
  `${issue.key}: ${question.agentName} needs input.`,