@agent-relay/factory 0.1.26 → 0.1.27

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.
@@ -1 +1 @@
1
- {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../src/orchestrator/factory.ts"],"names":[],"mappings":"AAIA,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAI1E,OAAO,KAAK,EAKV,WAAW,EAKX,WAAW,EAKZ,MAAM,UAAU,CAAA;AA+BjB,OAAO,KAAK,EACV,cAAc,EACd,OAAO,EACP,mBAAmB,EACnB,YAAY,EACZ,aAAa,EACb,mBAAmB,EAEnB,qBAAqB,EACrB,oBAAoB,EACpB,mBAAmB,EAGnB,QAAQ,EACR,eAAe,EACf,WAAW,EAGX,cAAc,EAEf,MAAM,UAAU,CAAA;AAOjB,KAAK,YAAY,GAAG,cAAc,GAAG,YAAY,GAAG,YAAY,GAAG,oBAAoB,GAAG,OAAO,CAAA;AACjG,KAAK,QAAQ,GAAG,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAA;AAiEtD,KAAK,iBAAiB,GAAG;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC7B,CAAA;AA8ED,eAAO,MAAM,mCAAmC,iDAAiD,CAAA;AACjG,eAAO,MAAM,kCAAkC,gDAAgD,CAAA;AAM/F,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAEjF;AAED,qBAAa,WAAY,YAAW,OAAO;;gBAgI7B,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,YAAY;IA+GhD,uBAAuB,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAiEvD,KAAK,CAAC,IAAI,GAAE,mBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiGpD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAsHrB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAklBxB,OAAO,CAAC,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IA2NlE,OAAO,CAAC,IAAI,GAAE,qBAA0B,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAyDrE,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,cAAc,CAAC;IAOxD,QAAQ,CAAC,QAAQ,EAAE,cAAc,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,cAAc,CAAC;IA6OlG,MAAM,IAAI,aAAa;IAWvB,EAAE,CAAC,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,QAAQ,GAAG,MAAM,IAAI;CA2wMxD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,WAAW,CAiC5E;AAyBD,wBAAsB,oCAAoC,CACxD,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,UAAU,CAAC,EACpC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,WAAW,CAAC,CAetB;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,iBAAiB,CA6BlF;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,WAAW,CAEnF;AA2CD,wBAAsB,wBAAwB,CAC5C,IAAI,SAAsC,GACzC,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAM3C;AAED,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,oBAAoB,GAAG,SAAS,EAC3C,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9C,mBAAmB,CAgBrB;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAO7D;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAc/D;AAufD,eAAO,MAAM,oBAAoB,GAAI,MAAM,MAAM,KAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAwBpH,CAAA;AAiTD,eAAO,MAAM,2BAA2B,GAAI,QAAQ,aAAa,KAAG,MAAM,EAItE,CAAA;AA6gBJ,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,MAC4B,CAAA;AA0SvE,eAAO,MAAM,eAAe,GAAI,OAAO,WAAW,KAAG,MAAM,GAAG,SAG7D,CAAA"}
1
+ {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../src/orchestrator/factory.ts"],"names":[],"mappings":"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"}
@@ -129,6 +129,9 @@ export class FactoryLoop {
129
129
  #githubIssueCommentWatchers = new Map();
130
130
  #githubIssueCommentWatchStates = new Map();
131
131
  #githubIssueCommentQueues = new Map();
132
+ #githubIssueAuthors = new Map();
133
+ #githubIssueAuthorLookups = new Map();
134
+ #reconciledGithubInProgress = new Set();
132
135
  #resolvedSlackChannelDir;
133
136
  #slackChannelDirRefresh;
134
137
  // Agents we've already logged an ambiguous-PID-lookup warning for, so the
@@ -947,12 +950,16 @@ export class FactoryLoop {
947
950
  return { dispatchRelayflow: true };
948
951
  }
949
952
  if (isGithubIssueFilePath(path)) {
950
- const sourceKey = `github:${path}`;
953
+ const parts = githubIssuePathParts(path);
954
+ const sourceKey = parts
955
+ ? `github:${githubIssueIdentity(parts.owner, parts.repo, parts.number)}`
956
+ : `github:${path}`;
951
957
  if (seenIssueKeys.has(sourceKey)) {
952
958
  this.#increment('liveDuplicateIssueEventsSuppressed');
953
- this.#logger.debug?.('[factory] suppressed duplicate live GitHub issue event in current drain', {
959
+ this.#logger.debug?.('[factory] suppressed duplicate live GitHub issue alias in current drain', {
954
960
  id: event.id,
955
961
  path,
962
+ issue: parts ? sourceKey.slice('github:'.length) : undefined,
956
963
  });
957
964
  return { dispatchRelayflow: false };
958
965
  }
@@ -1131,7 +1138,8 @@ export class FactoryLoop {
1131
1138
  });
1132
1139
  }
1133
1140
  async #resolveIssuePr(issue, opts = {}) {
1134
- const key = issueStateKey(issueRef(issue));
1141
+ const issueKey = issueStateKey(issueRef(issue));
1142
+ const key = opts.openOnly ? `${issueKey}:open` : issueKey;
1135
1143
  const now = this.#clock.now();
1136
1144
  const cached = this.#probePrResolvedCache.get(key);
1137
1145
  if (cached && cached.expiresAtMs > now) {
@@ -1176,6 +1184,9 @@ export class FactoryLoop {
1176
1184
  await this.#ensureGithubIngestionReady();
1177
1185
  }
1178
1186
  const paths = await this.#readyIssuePaths();
1187
+ const orphanRecovery = issueSource === 'github'
1188
+ ? await this.#githubOrphanRecoveryContext()
1189
+ : undefined;
1179
1190
  const pulled = [];
1180
1191
  const triaged = [];
1181
1192
  const dispatched = [];
@@ -1195,36 +1206,74 @@ export class FactoryLoop {
1195
1206
  continue;
1196
1207
  }
1197
1208
  pulled.push(issueRef(issue));
1198
- const dispatchBlock = await this.#dispatchBlockReason(issue);
1199
- if (dispatchBlock) {
1200
- skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
1201
- continue;
1209
+ const wasReady = this.#isIssueReady(issue);
1210
+ const labels = isGithubIssue(issue)
1211
+ ? new Set(issue.labels.map((label) => label.trim().toLowerCase()))
1212
+ : undefined;
1213
+ const requiredLabel = this.#config.safety.requireLabel.trim().toLowerCase();
1214
+ const mayRecoverGithubOrphan = !wasReady &&
1215
+ !dryRun &&
1216
+ issueSource === 'github' &&
1217
+ Boolean(orphanRecovery) &&
1218
+ Boolean(requiredLabel) &&
1219
+ Boolean(labels?.has(requiredLabel)) &&
1220
+ Boolean(labels?.has('factory:in-progress')) &&
1221
+ !labels?.has('factory:human-review');
1222
+ if (!mayRecoverGithubOrphan) {
1223
+ const dispatchBlock = await this.#dispatchBlockReason(issue);
1224
+ if (dispatchBlock) {
1225
+ skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
1226
+ continue;
1227
+ }
1202
1228
  }
1203
1229
  const batch = await this.#batch();
1204
1230
  if (batch.isInFlight(issue) || batch.isQueued(issue)) {
1205
1231
  skipped.push({ issue: issueRef(issue), reason: 'already tracked' });
1206
1232
  continue;
1207
1233
  }
1208
- if (!this.#isIssueReady(issue)) {
1234
+ const recoveredOrphan = mayRecoverGithubOrphan &&
1235
+ await this.#reconcileOrphanedGithubInProgress(issue, orphanRecovery, dryRun);
1236
+ if (!wasReady && !recoveredOrphan) {
1237
+ if (mayRecoverGithubOrphan) {
1238
+ const dispatchBlock = await this.#dispatchBlockReason(issue);
1239
+ if (dispatchBlock) {
1240
+ skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
1241
+ continue;
1242
+ }
1243
+ }
1209
1244
  skipped.push({ issue: issueRef(issue), reason: 'live state is not ready-for-agent' });
1210
1245
  continue;
1211
1246
  }
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' });
1247
+ const recoveredIdentity = recoveredOrphan ? githubIssueRefIdentity(issueRef(issue)) : undefined;
1248
+ try {
1249
+ if (recoveredOrphan) {
1250
+ const dispatchBlock = await this.#dispatchBlockReason(issue);
1251
+ if (dispatchBlock) {
1252
+ skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
1253
+ continue;
1254
+ }
1255
+ }
1256
+ if (!isInFactoryScope(issue, this.#config.safety)) {
1257
+ skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
1258
+ continue;
1259
+ }
1260
+ if (!isDispatchableIssue(issue)) {
1261
+ skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
1262
+ continue;
1263
+ }
1264
+ const decision = await this.triageIssue(issue);
1265
+ triaged.push(decision);
1266
+ const result = await this.dispatch(decision, { dryRun });
1267
+ if (result.agents.length === 0 && !dryRun) {
1268
+ skipped.push({ issue: decision.issue, reason: 'queued or escalated' });
1269
+ }
1270
+ else {
1271
+ dispatched.push(result);
1272
+ }
1225
1273
  }
1226
- else {
1227
- dispatched.push(result);
1274
+ finally {
1275
+ if (recoveredIdentity)
1276
+ this.#reconciledGithubInProgress.delete(recoveredIdentity);
1228
1277
  }
1229
1278
  }
1230
1279
  report = { pulled, triaged, dispatched, skipped, dryRun, slackDegraded: this.#slackDegraded };
@@ -1255,6 +1304,140 @@ export class FactoryLoop {
1255
1304
  }
1256
1305
  }
1257
1306
  }
1307
+ async #githubOrphanRecoveryContext() {
1308
+ try {
1309
+ const [registry, roster, lifecycles, waitingClarifications] = await Promise.all([
1310
+ readFactoryInFlightRegistry(this.#config.loop.registryPath),
1311
+ this.#fleet.roster(),
1312
+ this.#state.listDispatchLifecycles(this.#workspaceId),
1313
+ this.#state.listWaitingClarifications(this.#workspaceId),
1314
+ ]);
1315
+ const onlineAgents = new Set(roster.agents.map((agent) => agent.name));
1316
+ const activeIssueIdentities = new Set();
1317
+ for (const [, lifecycle] of lifecycles) {
1318
+ if (isTerminalDispatchLifecycle(lifecycle))
1319
+ continue;
1320
+ const identity = githubIssueRefIdentity(lifecycle.issue);
1321
+ if (identity)
1322
+ activeIssueIdentities.add(identity);
1323
+ }
1324
+ for (const [, waiting] of waitingClarifications) {
1325
+ const identity = githubIssueRefIdentity(waiting.issue);
1326
+ if (identity)
1327
+ activeIssueIdentities.add(identity);
1328
+ }
1329
+ for (const agent of registry?.agents ?? []) {
1330
+ if (!onlineAgents.has(agent.name) || !agent.issue)
1331
+ continue;
1332
+ const identity = githubIssueRefIdentity(agent.issue);
1333
+ if (identity)
1334
+ activeIssueIdentities.add(identity);
1335
+ }
1336
+ return {
1337
+ activeIssueIdentities,
1338
+ onlineAgentNames: onlineAgents,
1339
+ };
1340
+ }
1341
+ catch (error) {
1342
+ this.#increment('githubOrphanRecoveryContextFailures');
1343
+ this.#logger.warn?.('[factory] could not establish orphan-recovery safety context; preserving in-progress issues', {
1344
+ error: describeError(error).errorMessage,
1345
+ });
1346
+ return undefined;
1347
+ }
1348
+ }
1349
+ async #reconcileOrphanedGithubInProgress(issue, context, dryRun) {
1350
+ if (dryRun || !context || !isGithubIssue(issue))
1351
+ return false;
1352
+ const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
1353
+ const required = this.#config.safety.requireLabel.trim().toLowerCase();
1354
+ if (!required ||
1355
+ !labels.has(required) ||
1356
+ !labels.has('factory:in-progress') ||
1357
+ labels.has('factory:human-review'))
1358
+ return false;
1359
+ const identity = githubIssueRefIdentity(issueRef(issue));
1360
+ if (!identity ||
1361
+ context.activeIssueIdentities.has(identity) ||
1362
+ [...context.onlineAgentNames].some((name) => githubAgentNameMatchesIssue(name, issue))) {
1363
+ this.#increment('githubOrphanRecoveriesBlockedActive');
1364
+ return false;
1365
+ }
1366
+ const getProviderStatus = this.#githubWriteback.getIssueStatus;
1367
+ if (!getProviderStatus) {
1368
+ this.#increment('githubOrphanRecoveryStatusLookupUnavailable');
1369
+ return false;
1370
+ }
1371
+ let providerStatus;
1372
+ try {
1373
+ providerStatus = await getProviderStatus.call(this.#githubWriteback, issue);
1374
+ }
1375
+ catch (error) {
1376
+ this.#increment('githubOrphanRecoveryStatusLookupFailures');
1377
+ this.#logger.warn?.('[factory] could not verify provider-authoritative GitHub issue status; preserving it', {
1378
+ issue: issue.key,
1379
+ error: describeError(error).errorMessage,
1380
+ });
1381
+ return false;
1382
+ }
1383
+ if (!providerStatus || providerStatus === 'human-review') {
1384
+ this.#increment('githubOrphanRecoveriesBlockedProviderStatus');
1385
+ return false;
1386
+ }
1387
+ let hasOpenPr;
1388
+ try {
1389
+ hasOpenPr = await this.#hasOpenCompletionPr(issue);
1390
+ }
1391
+ catch (error) {
1392
+ this.#increment('githubOrphanRecoveryPrProbeFailures');
1393
+ this.#logger.warn?.('[factory] could not prove an in-progress GitHub issue has no open PR; preserving it', {
1394
+ issue: issue.key,
1395
+ error: describeError(error).errorMessage,
1396
+ });
1397
+ return false;
1398
+ }
1399
+ if (hasOpenPr) {
1400
+ this.#increment('githubOrphanRecoveriesBlockedOpenPr');
1401
+ this.#logger.info?.('[factory] preserved in-progress GitHub issue because a matching open PR exists', {
1402
+ issue: issue.key,
1403
+ });
1404
+ return false;
1405
+ }
1406
+ try {
1407
+ if (providerStatus === 'in-progress') {
1408
+ await this.#githubWriteback.setStatus(issue, 'ready');
1409
+ }
1410
+ // A crashed dispatch may leave its durable attempt marked in-flight even
1411
+ // after every agent and lifecycle disappeared. Only clear that stale bit
1412
+ // after all provider, agent, lifecycle, and open-PR safety checks pass.
1413
+ await this.#clearDispatchInFlight(issue);
1414
+ this.#reconciledGithubInProgress.add(identity);
1415
+ this.#increment('githubOrphanedInProgressRecovered');
1416
+ this.#logger.warn?.('[factory] recovered orphaned GitHub in-progress issue for redispatch', {
1417
+ issue: issue.key,
1418
+ path: issue.path,
1419
+ });
1420
+ return true;
1421
+ }
1422
+ catch (error) {
1423
+ this.#increment('githubOrphanRecoveryWritebackFailures');
1424
+ this.#logger.warn?.('[factory] failed to clear orphaned GitHub lifecycle status; preserving in-progress issue', {
1425
+ issue: issue.key,
1426
+ error: describeError(error).errorMessage,
1427
+ });
1428
+ return false;
1429
+ }
1430
+ }
1431
+ async #hasOpenCompletionPr(issue) {
1432
+ if (this.#customProbePrResolver) {
1433
+ return Boolean(await this.#probePrResolver(issue));
1434
+ }
1435
+ return Boolean(await this.#resolveIssuePr(issue, {
1436
+ titleMarker: FACTORY_E2E_MARKER,
1437
+ openOnly: true,
1438
+ failOnLookupError: true,
1439
+ }));
1440
+ }
1258
1441
  async #listRelayfileTree(prefix, phase) {
1259
1442
  return this.#withRelayfileOperation('listTree', { phase, prefix }, () => this.#mount.listTree(prefix), {
1260
1443
  count: (paths) => paths.length,
@@ -2185,11 +2368,14 @@ export class FactoryLoop {
2185
2368
  if (githubState === 'closed') {
2186
2369
  return false;
2187
2370
  }
2188
- if (issue.labels.some((label) => GITHUB_LIFECYCLE_LABELS.has(label.trim().toLowerCase()))) {
2371
+ const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
2372
+ if (labels.has('factory:human-review'))
2373
+ return false;
2374
+ const identity = githubIssueRefIdentity(issueRef(issue));
2375
+ if (labels.has('factory:in-progress') && (!identity || !this.#reconciledGithubInProgress.has(identity)))
2189
2376
  return false;
2190
- }
2191
2377
  const required = this.#config.safety.requireLabel.trim().toLowerCase();
2192
- return Boolean(required) && issue.labels.some((label) => label.trim().toLowerCase() === required);
2378
+ return Boolean(required) && labels.has(required);
2193
2379
  }
2194
2380
  async #postIssueComment(issue, body) {
2195
2381
  if (isGithubIssue(issue)) {
@@ -2274,12 +2460,22 @@ export class FactoryLoop {
2274
2460
  }
2275
2461
  async #githubIssuePaths() {
2276
2462
  try {
2277
- const issuePaths = new Set();
2463
+ const issuePaths = new Map();
2278
2464
  for (const root of githubIssueScanRoots(this.#config)) {
2279
2465
  const paths = await this.#listRelayfileTree(root, 'GitHub issue ingestion');
2280
2466
  for (const path of paths) {
2281
- if (githubIssuePathParts(path) !== undefined) {
2282
- issuePaths.add(path);
2467
+ const parts = githubIssuePathParts(path);
2468
+ if (parts) {
2469
+ const identity = githubIssueIdentity(parts.owner, parts.repo, parts.number);
2470
+ const existing = issuePaths.get(identity);
2471
+ if (!existing || githubIssuePathPreference(path) < githubIssuePathPreference(existing)) {
2472
+ if (existing)
2473
+ this.#increment('githubIssueAliasPathsSuppressed');
2474
+ issuePaths.set(identity, path);
2475
+ }
2476
+ else {
2477
+ this.#increment('githubIssueAliasPathsSuppressed');
2478
+ }
2283
2479
  }
2284
2480
  else if (githubIssueDirectoryPathParts(path) !== undefined) {
2285
2481
  // listTree returns the issue directory entry alongside its
@@ -2295,7 +2491,7 @@ export class FactoryLoop {
2295
2491
  }
2296
2492
  }
2297
2493
  }
2298
- return [...issuePaths].sort();
2494
+ return [...issuePaths.values()].sort();
2299
2495
  }
2300
2496
  catch (error) {
2301
2497
  this.#increment('githubIssueListFailures');
@@ -4274,7 +4470,7 @@ export class FactoryLoop {
4274
4470
  const correlationId = githubEscalationCorrelationId('agent-question', record.issue, question.question);
4275
4471
  const issue = await this.#readIssue(record.issue.path);
4276
4472
  const source = issue ? githubIssueSourceRef(issue) : undefined;
4277
- const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
4473
+ const authorizedAuthor = issue ? await this.#resolveGithubIssueAuthor(issue) : undefined;
4278
4474
  if (!issue || !source || !authorizedAuthor) {
4279
4475
  this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, fallbackReason
4280
4476
  ? `${fallbackReason}; no GitHub issue write path with an identifiable issue reporter is available`
@@ -4770,7 +4966,7 @@ export class FactoryLoop {
4770
4966
  };
4771
4967
  const correlationId = githubEscalationCorrelationId('agent-question', record.issue, `${comment.commentId}:${question.question}`);
4772
4968
  const issue = await this.#readIssue(record.issue.path);
4773
- const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
4969
+ const authorizedAuthor = issue ? await this.#resolveGithubIssueAuthor(issue) : undefined;
4774
4970
  if (!authorizedAuthor) {
4775
4971
  this.#increment('githubAgentQuestionsIgnoredMissingAuthorizedAuthor');
4776
4972
  this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'source GitHub issue has no identifiable reporter authorized to answer the durable question');
@@ -6407,7 +6603,7 @@ export class FactoryLoop {
6407
6603
  const correlationId = githubEscalationCorrelationId('triage', decision.issue, question);
6408
6604
  const issue = await this.#readIssue(decision.issue.path);
6409
6605
  const source = issue ? githubIssueSourceRef(issue) : undefined;
6410
- const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
6606
+ const authorizedAuthor = issue ? await this.#resolveGithubIssueAuthor(issue) : undefined;
6411
6607
  if (!issue || !source || !authorizedAuthor) {
6412
6608
  this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
6413
6609
  return;
@@ -6482,7 +6678,7 @@ export class FactoryLoop {
6482
6678
  return;
6483
6679
  const source = githubIssueSourceRef(issue);
6484
6680
  const stakeholderMentions = slackMentions(this.#config.slack.stakeholderUserIds);
6485
- const reporter = githubIssueAuthor(issue);
6681
+ const reporter = await this.#resolveGithubIssueAuthor(issue);
6486
6682
  const audience = [stakeholderMentions, reporter ? `GitHub reporter: @${reporter}.` : undefined]
6487
6683
  .filter((part) => Boolean(part))
6488
6684
  .join(' ');
@@ -6501,6 +6697,46 @@ export class FactoryLoop {
6501
6697
  this.#increment('triageEscalationsMirroredToSlack');
6502
6698
  this.#recordSlackWritebackSuccess('triage-escalation-mirror');
6503
6699
  }
6700
+ async #resolveGithubIssueAuthor(issue) {
6701
+ const embeddedAuthor = githubIssueAuthor(issue);
6702
+ if (embeddedAuthor)
6703
+ return embeddedAuthor;
6704
+ const source = githubIssueSourceRef(issue);
6705
+ const lookup = this.#githubWriteback.getIssueAuthor;
6706
+ if (!source || !lookup)
6707
+ return undefined;
6708
+ const key = githubIssueSourceKey(source);
6709
+ if (this.#githubIssueAuthors.has(key)) {
6710
+ return this.#githubIssueAuthors.get(key);
6711
+ }
6712
+ const existing = this.#githubIssueAuthorLookups.get(key);
6713
+ if (existing)
6714
+ return existing;
6715
+ const pending = lookup.call(this.#githubWriteback, issue)
6716
+ .then((author) => {
6717
+ const normalized = author?.trim() || undefined;
6718
+ this.#githubIssueAuthors.set(key, normalized);
6719
+ if (normalized) {
6720
+ this.#increment('githubIssueAuthorsResolvedFromProvider');
6721
+ }
6722
+ return normalized;
6723
+ })
6724
+ .catch((error) => {
6725
+ this.#increment('githubIssueAuthorLookupFailures');
6726
+ this.#logger.warn?.('[factory] provider-authoritative GitHub issue author lookup failed', {
6727
+ owner: source.owner,
6728
+ repo: source.repo,
6729
+ issue: source.number,
6730
+ error: describeError(error).errorMessage,
6731
+ });
6732
+ return undefined;
6733
+ })
6734
+ .finally(() => {
6735
+ this.#githubIssueAuthorLookups.delete(key);
6736
+ });
6737
+ this.#githubIssueAuthorLookups.set(key, pending);
6738
+ return pending;
6739
+ }
6504
6740
  async #postAndWatchSlackEscalationThread(decision, reason) {
6505
6741
  if (!this.#slack || !this.#config.slack) {
6506
6742
  return;
@@ -8136,6 +8372,26 @@ export const githubIssuePathParts = (path) => {
8136
8372
  slug: match[7],
8137
8373
  };
8138
8374
  };
8375
+ const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
8376
+ const githubIssueRefIdentity = (issue) => {
8377
+ const parts = githubIssuePathParts(issue.path);
8378
+ return parts ? githubIssueIdentity(parts.owner, parts.repo, parts.number) : undefined;
8379
+ };
8380
+ const githubIssuePathPreference = (path) => {
8381
+ if (path.includes('/issues/by-id/'))
8382
+ return 0;
8383
+ if (path.endsWith('/meta.json'))
8384
+ return 1;
8385
+ if (path.endsWith('.json'))
8386
+ return 2;
8387
+ return 3;
8388
+ };
8389
+ const githubAgentNameMatchesIssue = (name, issue) => {
8390
+ const parts = githubIssuePathParts(issue.path);
8391
+ if (!parts)
8392
+ return false;
8393
+ return name.startsWith(`ar-${parts.number}-`) && name.endsWith(`-${sanitizeAgentSlug(parts.repo)}`);
8394
+ };
8139
8395
  const githubIssueCommentPathParts = (path) => {
8140
8396
  const match = path.match(/^\/github\/repos\/(?:([^/]+)\/([^/]+)|([A-Za-z0-9-]+)__([^/]+))\/issues\/(\d+)(?:__[^/]*)?\/comments\/([^/]+?)(?:\.json|\/(?:meta|metadata)\.json)$/u);
8141
8397
  const owner = match?.[1] ?? match?.[3];
@@ -8295,6 +8551,8 @@ const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
8295
8551
  if (!path.endsWith('.json'))
8296
8552
  continue;
8297
8553
  const pr = await readProbePrCandidate(mount, path);
8554
+ if (opts.openOnly && normalizePrState(pr?.state) !== 'OPEN')
8555
+ continue;
8298
8556
  const score = pr
8299
8557
  ? issuePrMatchScore(pr, issue, opts.titleMarker ?? config.safety.requireTitlePrefix, opts)
8300
8558
  : 0;
@@ -8307,6 +8565,7 @@ const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
8307
8565
  };
8308
8566
  const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
8309
8567
  const candidates = [];
8568
+ let lookupFailures = 0;
8310
8569
  for (const repo of reposFromConfig(config)) {
8311
8570
  let payload;
8312
8571
  try {
@@ -8323,26 +8582,33 @@ const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
8323
8582
  String(PROBE_PR_GH_CANDIDATE_LIMIT),
8324
8583
  ]);
8325
8584
  if (!result.stdout.trim()) {
8585
+ lookupFailures += 1;
8326
8586
  logger?.warn?.('[factory] gh PR resolver returned empty output', { issue: issue.key, repo });
8327
8587
  continue;
8328
8588
  }
8329
8589
  payload = parseJsonContent(result.stdout);
8330
8590
  }
8331
8591
  catch (error) {
8592
+ lookupFailures += 1;
8332
8593
  logger?.warn?.('[factory] gh PR resolver failed', { issue: issue.key, repo, error });
8333
8594
  continue;
8334
8595
  }
8335
8596
  if (!Array.isArray(payload)) {
8597
+ lookupFailures += 1;
8336
8598
  logger?.warn?.('[factory] gh PR resolver returned non-array payload', { issue: issue.key, repo });
8337
8599
  continue;
8338
8600
  }
8339
8601
  if (payload.length >= PROBE_PR_GH_CANDIDATE_LIMIT) {
8340
8602
  logger?.warn?.('[factory] gh PR resolver hit candidate limit', { issue: issue.key, repo, limit: PROBE_PR_GH_CANDIDATE_LIMIT });
8603
+ if (opts.failOnLookupError)
8604
+ lookupFailures += 1;
8341
8605
  }
8342
8606
  for (const entry of payload) {
8343
8607
  const pr = ghProbePrCandidate(entry);
8344
8608
  if (!pr || !containsIssueKey(pr.headRef, issue.key))
8345
8609
  continue;
8610
+ if (opts.openOnly && normalizePrState(pr.state) !== 'OPEN')
8611
+ continue;
8346
8612
  const score = issuePrMatchScore(pr, issue, opts.titleMarker ?? config.safety.requireTitlePrefix, opts);
8347
8613
  if (score <= 0)
8348
8614
  continue;
@@ -8355,9 +8621,13 @@ const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
8355
8621
  });
8356
8622
  }
8357
8623
  }
8358
- return candidates.sort((a, b) => b.score - a.score ||
8624
+ const resolved = candidates.sort((a, b) => b.score - a.score ||
8359
8625
  Number(b.open) - Number(a.open) ||
8360
8626
  b.prNumber - a.prNumber)[0];
8627
+ if (!resolved && opts.failOnLookupError && lookupFailures > 0) {
8628
+ throw new Error(`Unable to confirm open pull request state for ${issue.key} in ${lookupFailures} configured repository lookup(s)`);
8629
+ }
8630
+ return resolved;
8361
8631
  };
8362
8632
  const reposFromConfig = (config) => {
8363
8633
  const repos = new Set([
@@ -8452,6 +8722,7 @@ const readProbePrCandidate = async (mount, path) => {
8452
8722
  body: stringValue(payload.body) ?? '',
8453
8723
  headRef: refName(payload.headRef) ?? refName(payload.head) ?? stringValue(payload.head_ref) ?? '',
8454
8724
  draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
8725
+ state: stringValue(payload.state),
8455
8726
  };
8456
8727
  }
8457
8728
  catch {