@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.
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +306 -35
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/writeback.d.ts +5 -1
- package/dist/ports/writeback.d.ts.map +1 -1
- package/dist/writeback/github.d.ts +2 -0
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +36 -0
- package/dist/writeback/github.js.map +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
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) &&
|
|
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
|
|
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
|
-
|
|
2282
|
-
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
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 {
|