@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.
- package/dist/cli/fleet.js +1 -1
- package/dist/cli/fleet.js.map +1 -1
- package/dist/issue-key-match.d.ts.map +1 -1
- package/dist/issue-key-match.js +11 -1
- package/dist/issue-key-match.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +1 -0
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +14 -1
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/mount/relayfile-github-connection-write.d.ts +1 -1
- package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
- package/dist/mount/relayfile-github-connection-write.js +66 -12
- package/dist/mount/relayfile-github-connection-write.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +836 -92
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/mount.d.ts +2 -0
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/writeback.d.ts +5 -1
- package/dist/ports/writeback.d.ts.map +1 -1
- package/dist/triage/heuristic.d.ts.map +1 -1
- package/dist/triage/heuristic.js +2 -1
- package/dist/triage/heuristic.js.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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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
|
|
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
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) &&
|
|
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
|
|
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
|
-
|
|
2282
|
-
|
|
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
|
|
3570
|
-
for (const [agentName] of
|
|
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
|
-
|
|
3574
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 (
|
|
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 ?
|
|
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 ?
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 =
|
|
6486
|
-
const
|
|
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
|
|
7912
|
-
const
|
|
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
|
-
(!
|
|
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
|
|
8571
|
+
for (const label of candidateLabels) {
|
|
7919
8572
|
const entry = findLabelRoute(config.repos.byLabel, label);
|
|
7920
8573
|
if (!entry) {
|
|
7921
|
-
|
|
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({
|
|
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 || !
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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.`,
|