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