@agent-relay/factory 0.1.14 → 0.1.16
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/README.md +13 -6
- package/dist/cli/fleet.d.ts +1 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +44 -4
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +1 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/dispatch/templates.js +3 -3
- package/dist/dispatch/templates.js.map +1 -1
- package/dist/fleet/create-fleet.d.ts +6 -0
- package/dist/fleet/create-fleet.d.ts.map +1 -1
- package/dist/fleet/create-fleet.js +21 -0
- package/dist/fleet/create-fleet.js.map +1 -1
- package/dist/fleet/internal-fleet-client.d.ts +1 -0
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +139 -8
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/github/merge-gate.d.ts.map +1 -1
- package/dist/github/merge-gate.js +2 -0
- package/dist/github/merge-gate.js.map +1 -1
- package/dist/github/probe-closer.d.ts +3 -1
- package/dist/github/probe-closer.d.ts.map +1 -1
- package/dist/github/probe-closer.js +8 -8
- package/dist/github/probe-closer.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +3 -2
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +6 -1
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/mount/relayfile-github-connection-write.d.ts +26 -0
- package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -0
- package/dist/mount/relayfile-github-connection-write.js +127 -0
- package/dist/mount/relayfile-github-connection-write.js.map +1 -0
- package/dist/orchestrator/factory.d.ts +1 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +724 -15
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/index.d.ts +1 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/mount.d.ts +27 -0
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +23 -0
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/state/file-state-store.d.ts +20 -0
- package/dist/state/file-state-store.d.ts.map +1 -0
- package/dist/state/file-state-store.js +132 -0
- package/dist/state/file-state-store.js.map +1 -0
- package/dist/state/in-memory-state-store.d.ts +4 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +30 -0
- package/dist/state/in-memory-state-store.js.map +1 -1
- package/dist/testing/fakes.d.ts +3 -2
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +3 -1
- package/dist/testing/fakes.js.map +1 -1
- package/dist/triage/schema.d.ts +44 -44
- package/dist/writeback/github.d.ts +2 -0
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +2 -0
- package/dist/writeback/github.js.map +1 -1
- package/package.json +3 -1
|
@@ -41,6 +41,7 @@ const SLACK_REPLY_POLL_INTERVAL_MS = 5_000;
|
|
|
41
41
|
const AGENT_QUESTION_DEDUPE_LIMIT = 500;
|
|
42
42
|
const AGENT_NEEDS_INPUT_MARKER = '[factory-needs-input]';
|
|
43
43
|
const LEGACY_AGENT_NEEDS_INPUT_MARKER = 'FACTORY_NEEDS_INPUT';
|
|
44
|
+
const GITHUB_ESCALATION_MARKER_PREFIX = 'factory-escalation:';
|
|
44
45
|
// The babysitter DMs `factory` with this marker once it believes the PR is
|
|
45
46
|
// green; the orchestrator confirms readiness with one authoritative gh read
|
|
46
47
|
// before transitioning the issue to Human Review.
|
|
@@ -104,6 +105,9 @@ export class FactoryLoop {
|
|
|
104
105
|
#dispatchInFlight = new Map();
|
|
105
106
|
#slackWatchers = new Map();
|
|
106
107
|
#slackWatcherStarts = new Map();
|
|
108
|
+
#githubIssueCommentWatchers = new Map();
|
|
109
|
+
#githubIssueCommentWatchStates = new Map();
|
|
110
|
+
#githubIssueCommentQueues = new Map();
|
|
107
111
|
#resolvedSlackChannelDir;
|
|
108
112
|
#slackChannelDirRefresh;
|
|
109
113
|
// Agents we've already logged an ambiguous-PID-lookup warning for, so the
|
|
@@ -115,6 +119,7 @@ export class FactoryLoop {
|
|
|
115
119
|
// same notice every cycle. Cleared once the issue dispatches successfully.
|
|
116
120
|
#labelDispatchFailures = new Map();
|
|
117
121
|
#pendingSlackClarifications = new Map();
|
|
122
|
+
#pendingGithubClarifications = new Map();
|
|
118
123
|
#postMergeDoneAdvances = new Set();
|
|
119
124
|
#slackDegraded = false;
|
|
120
125
|
#slackDegradedReason;
|
|
@@ -151,6 +156,7 @@ export class FactoryLoop {
|
|
|
151
156
|
// Issue key -> the open PR the babysitter is shepherding, including the
|
|
152
157
|
// webhook-fed mount path so readiness can re-read PR meta without a gh call.
|
|
153
158
|
#babysitterPr = new Map();
|
|
159
|
+
#publishedPullRequests = new Set();
|
|
154
160
|
#probePrGhBackoffUntilMs = new Map();
|
|
155
161
|
#probePrResolvedCache = new Map();
|
|
156
162
|
// GitHub issue mirror-id -> resolved Linear mirror path, so repeat ingestion
|
|
@@ -314,6 +320,7 @@ export class FactoryLoop {
|
|
|
314
320
|
try {
|
|
315
321
|
await this.#startLiveSubscription(opts.liveSubscription);
|
|
316
322
|
await this.#rearmSlackReplyWatchers();
|
|
323
|
+
await this.#rearmGithubIssueCommentWatchers();
|
|
317
324
|
this.#scheduleCompletionSweep(0);
|
|
318
325
|
return;
|
|
319
326
|
}
|
|
@@ -345,6 +352,7 @@ export class FactoryLoop {
|
|
|
345
352
|
});
|
|
346
353
|
this.#started = true;
|
|
347
354
|
await this.#rearmSlackReplyWatchers();
|
|
355
|
+
await this.#rearmGithubIssueCommentWatchers();
|
|
348
356
|
this.#scheduleCompletionSweep(0);
|
|
349
357
|
}
|
|
350
358
|
async stop() {
|
|
@@ -369,6 +377,10 @@ export class FactoryLoop {
|
|
|
369
377
|
await this.#boundedStopTeardown('factory subscription unsubscribe', () => subscription?.unsubscribe());
|
|
370
378
|
await Promise.all([...this.#slackWatchers.values()].map((watcher) => watcher.stop()));
|
|
371
379
|
this.#slackWatchers.clear();
|
|
380
|
+
await Promise.all([...this.#githubIssueCommentWatchers.values()].map((watcher) => watcher.stop()));
|
|
381
|
+
this.#githubIssueCommentWatchers.clear();
|
|
382
|
+
this.#githubIssueCommentWatchStates.clear();
|
|
383
|
+
this.#githubIssueCommentQueues.clear();
|
|
372
384
|
await this.#state.clearSlackThreads(this.#workspaceId);
|
|
373
385
|
this.#slackWatcherStarts.clear();
|
|
374
386
|
this.#offAgentExit?.();
|
|
@@ -1221,7 +1233,7 @@ export class FactoryLoop {
|
|
|
1221
1233
|
}
|
|
1222
1234
|
const escalationReason = triageEscalationReason(decision);
|
|
1223
1235
|
if (escalationReason) {
|
|
1224
|
-
const replayedResult = await this.#
|
|
1236
|
+
const replayedResult = await this.#escalateTriage(decision, escalationReason, dryRun);
|
|
1225
1237
|
this.#recordTriageEscalation(decision, escalationReason);
|
|
1226
1238
|
return replayedResult ?? { issue: decision.issue, agents: [], dryRun };
|
|
1227
1239
|
}
|
|
@@ -1322,6 +1334,7 @@ export class FactoryLoop {
|
|
|
1322
1334
|
await this.#sendImplementerTask(record);
|
|
1323
1335
|
await this.#sendCriticalReviewerMessage(record);
|
|
1324
1336
|
await this.#injectPendingSlackClarification(record);
|
|
1337
|
+
await this.#injectPendingGithubClarification(record);
|
|
1325
1338
|
}
|
|
1326
1339
|
return result;
|
|
1327
1340
|
}
|
|
@@ -1416,7 +1429,7 @@ export class FactoryLoop {
|
|
|
1416
1429
|
const decision = await this.triageIssue(issue);
|
|
1417
1430
|
const escalationReason = triageEscalationReason(decision);
|
|
1418
1431
|
if (escalationReason) {
|
|
1419
|
-
await this.#
|
|
1432
|
+
await this.#escalateTriage(decision, escalationReason, this.#config.dryRun);
|
|
1420
1433
|
this.#recordTriageEscalation(decision, escalationReason);
|
|
1421
1434
|
return;
|
|
1422
1435
|
}
|
|
@@ -2183,6 +2196,19 @@ export class FactoryLoop {
|
|
|
2183
2196
|
}
|
|
2184
2197
|
const exiting = record.agents.get(name);
|
|
2185
2198
|
if (isCompletionReason(reason)) {
|
|
2199
|
+
let publishedPr;
|
|
2200
|
+
if (exiting?.spec.role === 'implementer' &&
|
|
2201
|
+
!record.dryRun &&
|
|
2202
|
+
(this.#mount.githubWrite || this.#mount.writebackTransport === 'relayfile-cloud')) {
|
|
2203
|
+
try {
|
|
2204
|
+
publishedPr = await this.#publishImplementerPullRequest(record, exiting);
|
|
2205
|
+
}
|
|
2206
|
+
catch (error) {
|
|
2207
|
+
this.#increment('githubPullRequestPublishFailures');
|
|
2208
|
+
this.#error(error, record.issue);
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2186
2212
|
if (this.#config.babysitter.enabled && !record.dryRun) {
|
|
2187
2213
|
// Babysitter path: an implementer/reviewer finishing does NOT mark the
|
|
2188
2214
|
// issue done — it hands the open PR to the babysitter. The babysitter
|
|
@@ -2191,6 +2217,13 @@ export class FactoryLoop {
|
|
|
2191
2217
|
if (exiting?.spec.role === 'babysitter') {
|
|
2192
2218
|
await this.#maybeAdvanceToHumanReview(record);
|
|
2193
2219
|
}
|
|
2220
|
+
else if (publishedPr) {
|
|
2221
|
+
await this.#ensureBabysitter(record, {
|
|
2222
|
+
repo: publishedPr.repo,
|
|
2223
|
+
prNumber: publishedPr.number,
|
|
2224
|
+
url: publishedPr.url,
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2194
2227
|
else {
|
|
2195
2228
|
await this.#ensureBabysitterForIssue(record);
|
|
2196
2229
|
}
|
|
@@ -2279,6 +2312,69 @@ export class FactoryLoop {
|
|
|
2279
2312
|
this.#error(error, record.issue);
|
|
2280
2313
|
}
|
|
2281
2314
|
}
|
|
2315
|
+
async #publishImplementerPullRequest(record, implementer) {
|
|
2316
|
+
const key = `${issueKey(record.issue)}:${implementer.spec.repo}`;
|
|
2317
|
+
if (this.#publishedPullRequests.has(key))
|
|
2318
|
+
return undefined;
|
|
2319
|
+
const githubWrite = this.#mount.githubWrite;
|
|
2320
|
+
if (!githubWrite) {
|
|
2321
|
+
throw new Error('GitHub write path not available on this mount — connect GitHub to your workspace');
|
|
2322
|
+
}
|
|
2323
|
+
if (!implementer.spec.clonePath) {
|
|
2324
|
+
throw new Error(`GitHub PR publication requires a configured clone path for ${implementer.spec.repo}`);
|
|
2325
|
+
}
|
|
2326
|
+
const issue = await this.#readIssue(record.issue.path);
|
|
2327
|
+
if (!issue) {
|
|
2328
|
+
throw new Error(`Unable to publish GitHub PR: issue ${record.issue.key} is no longer readable`);
|
|
2329
|
+
}
|
|
2330
|
+
const sourceRepo = githubMirrorRepoForIssue(issue);
|
|
2331
|
+
const sourceRepoParts = sourceRepo ? githubRepoParts(sourceRepo) : undefined;
|
|
2332
|
+
const bareRepoName = implementer.spec.repo.includes('/') ? undefined : implementer.spec.repo;
|
|
2333
|
+
const sourceOwner = bareRepoName && sourceRepoParts?.repo === bareRepoName
|
|
2334
|
+
? sourceRepoParts.owner
|
|
2335
|
+
: undefined;
|
|
2336
|
+
const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
|
|
2337
|
+
const baseRef = await this.#githubDefaultBranch(repo);
|
|
2338
|
+
const result = await githubWrite.publishPullRequest({
|
|
2339
|
+
repo,
|
|
2340
|
+
clonePath: implementer.spec.clonePath,
|
|
2341
|
+
baseRef,
|
|
2342
|
+
title: `${issue.key}: ${issue.title}`,
|
|
2343
|
+
body: githubPullRequestBody(issue),
|
|
2344
|
+
});
|
|
2345
|
+
this.#publishedPullRequests.add(key);
|
|
2346
|
+
this.#increment('githubPullRequestsPublished');
|
|
2347
|
+
this.#logger.info?.('[factory] published PR through workspace GitHub connection', {
|
|
2348
|
+
issue: issue.key,
|
|
2349
|
+
repo: result.repo,
|
|
2350
|
+
prNumber: result.number,
|
|
2351
|
+
url: result.url,
|
|
2352
|
+
});
|
|
2353
|
+
return result;
|
|
2354
|
+
}
|
|
2355
|
+
async #githubDefaultBranch(repo) {
|
|
2356
|
+
const parts = githubRepoParts(repo);
|
|
2357
|
+
if (!parts) {
|
|
2358
|
+
throw new Error(`GitHub repo must be owner/repo before resolving its default branch: ${repo}`);
|
|
2359
|
+
}
|
|
2360
|
+
const path = `/github/repos/${encodeURIComponent(parts.owner)}/${encodeURIComponent(parts.repo)}/meta.json`;
|
|
2361
|
+
let payload;
|
|
2362
|
+
try {
|
|
2363
|
+
payload = wrappedPayload((await this.#mount.readFile(path)).content);
|
|
2364
|
+
}
|
|
2365
|
+
catch (error) {
|
|
2366
|
+
throw new Error(`Unable to resolve the default branch for ${repo} from ${path}: ${describeError(error).errorMessage}`);
|
|
2367
|
+
}
|
|
2368
|
+
const repository = asRecord(payload.repository);
|
|
2369
|
+
const defaultBranch = (stringValue(payload.defaultBranch) ??
|
|
2370
|
+
stringValue(payload.default_branch) ??
|
|
2371
|
+
stringValue(repository?.defaultBranch) ??
|
|
2372
|
+
stringValue(repository?.default_branch))?.trim();
|
|
2373
|
+
if (!defaultBranch) {
|
|
2374
|
+
throw new Error(`GitHub repository metadata for ${repo} does not include a default branch`);
|
|
2375
|
+
}
|
|
2376
|
+
return defaultBranch;
|
|
2377
|
+
}
|
|
2282
2378
|
// An implementer that exited, was resumed once, and STILL produced no PR is
|
|
2283
2379
|
// not making progress. Surface it (counter + a best-effort Slack note to the
|
|
2284
2380
|
// dispatch thread) so a human can step in, instead of the issue sitting
|
|
@@ -2406,15 +2502,11 @@ export class FactoryLoop {
|
|
|
2406
2502
|
issue: record.issue.key,
|
|
2407
2503
|
});
|
|
2408
2504
|
}
|
|
2409
|
-
await this.#
|
|
2505
|
+
await this.#postAgentQuestion(record, question);
|
|
2410
2506
|
}
|
|
2411
|
-
async #
|
|
2507
|
+
async #postAgentQuestion(record, question) {
|
|
2412
2508
|
if (!this.#slack || !this.#config.slack) {
|
|
2413
|
-
this.#
|
|
2414
|
-
this.#logger.warn?.('[factory] agent question has no Slack channel configured', {
|
|
2415
|
-
issue: record.issue,
|
|
2416
|
-
from: question.agentName,
|
|
2417
|
-
});
|
|
2509
|
+
await this.#postAgentQuestionToGithub(record, question);
|
|
2418
2510
|
return;
|
|
2419
2511
|
}
|
|
2420
2512
|
if (await this.#shouldSkipSlackWriteback('agent-question')) {
|
|
@@ -2447,6 +2539,401 @@ export class FactoryLoop {
|
|
|
2447
2539
|
this.#logger.warn?.(`[factory] failed to post agent question for ${record.issue.key}`, error);
|
|
2448
2540
|
}
|
|
2449
2541
|
}
|
|
2542
|
+
async #postAgentQuestionToGithub(record, question) {
|
|
2543
|
+
const correlationId = githubEscalationCorrelationId('agent-question', record.issue, question.question);
|
|
2544
|
+
const issue = await this.#readIssue(record.issue.path);
|
|
2545
|
+
const source = issue ? githubIssueSourceRef(issue) : undefined;
|
|
2546
|
+
const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
|
|
2547
|
+
if (!issue || !source || !authorizedAuthor) {
|
|
2548
|
+
this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
await this.#addGithubIssueCommentWatch(record.issue, source, {
|
|
2552
|
+
correlationId,
|
|
2553
|
+
kind: 'agent-question',
|
|
2554
|
+
authorizedAuthor,
|
|
2555
|
+
});
|
|
2556
|
+
try {
|
|
2557
|
+
await this.#githubWriteback.postComment(issue, [
|
|
2558
|
+
`${record.issue.key}: ${question.agentName} needs input.`,
|
|
2559
|
+
`Question: ${question.question}`,
|
|
2560
|
+
`Authorized responder: @${authorizedAuthor} (the issue reporter).`,
|
|
2561
|
+
`Reply with a comment that starts with \`${githubReplyPrefix(correlationId)}\`.`,
|
|
2562
|
+
'',
|
|
2563
|
+
githubEscalationMarker(correlationId),
|
|
2564
|
+
].join('\n'));
|
|
2565
|
+
this.#increment('agentQuestionsPostedToGithub');
|
|
2566
|
+
}
|
|
2567
|
+
catch (error) {
|
|
2568
|
+
await this.#removeGithubIssueCommentPending(source, correlationId);
|
|
2569
|
+
this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'GitHub issue comment writeback failed', error);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
async #addGithubIssueCommentWatch(issue, source, pending) {
|
|
2573
|
+
const key = githubIssueSourceKey(source);
|
|
2574
|
+
let watch = this.#githubIssueCommentWatchStates.get(key);
|
|
2575
|
+
if (!watch) {
|
|
2576
|
+
const persisted = await this.#state.listGithubIssueCommentWatches(this.#workspaceId);
|
|
2577
|
+
watch = persisted.find(([persistedKey]) => persistedKey === key)?.[1];
|
|
2578
|
+
}
|
|
2579
|
+
if (!watch) {
|
|
2580
|
+
const sinceCommentId = await this.#latestGithubIssueCommentId(source);
|
|
2581
|
+
watch = {
|
|
2582
|
+
issue,
|
|
2583
|
+
source,
|
|
2584
|
+
pending: [],
|
|
2585
|
+
sinceCommentId,
|
|
2586
|
+
lastSeenCommentId: sinceCommentId,
|
|
2587
|
+
processedCommentIds: [],
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
const added = !watch.pending.some((candidate) => candidate.correlationId === pending.correlationId);
|
|
2591
|
+
if (added) {
|
|
2592
|
+
watch.pending.push(pending);
|
|
2593
|
+
}
|
|
2594
|
+
await this.#watchGithubIssueComments(watch);
|
|
2595
|
+
return added;
|
|
2596
|
+
}
|
|
2597
|
+
async #watchGithubIssueComments(watch) {
|
|
2598
|
+
watch = normalizeGithubIssueCommentWatch(watch);
|
|
2599
|
+
const key = githubIssueSourceKey(watch.source);
|
|
2600
|
+
this.#githubIssueCommentWatchStates.set(key, watch);
|
|
2601
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
2602
|
+
if (this.#githubIssueCommentWatchers.has(key)) {
|
|
2603
|
+
await this.#replayGithubIssueComments(key);
|
|
2604
|
+
return false;
|
|
2605
|
+
}
|
|
2606
|
+
const source = watch.source;
|
|
2607
|
+
let stopped = false;
|
|
2608
|
+
const handle = async (event) => {
|
|
2609
|
+
const path = changeEventPath(event);
|
|
2610
|
+
const parts = path ? githubIssueCommentPathParts(path) : undefined;
|
|
2611
|
+
if (stopped ||
|
|
2612
|
+
!path ||
|
|
2613
|
+
!parts ||
|
|
2614
|
+
parts.owner.toLowerCase() !== source.owner.toLowerCase() ||
|
|
2615
|
+
parts.repo.toLowerCase() !== source.repo.toLowerCase() ||
|
|
2616
|
+
parts.number !== source.number) {
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
try {
|
|
2620
|
+
await this.#enqueueGithubIssueComment(key, path);
|
|
2621
|
+
}
|
|
2622
|
+
catch (error) {
|
|
2623
|
+
this.#logger.error?.('[factory] failed to handle GitHub issue comment reply', {
|
|
2624
|
+
issue: watch.issue,
|
|
2625
|
+
path,
|
|
2626
|
+
error,
|
|
2627
|
+
});
|
|
2628
|
+
this.#increment('githubIssueCommentReplyErrors');
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
let subscription;
|
|
2632
|
+
try {
|
|
2633
|
+
subscription = this.#mount.subscribe(githubIssueCommentGlobs(source), (event) => {
|
|
2634
|
+
void handle(event);
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
catch (error) {
|
|
2638
|
+
this.#githubIssueCommentWatchStates.delete(key);
|
|
2639
|
+
this.#surfaceEscalationDeliveryFailure('github-reply-watcher', watch.issue, githubEscalationCorrelationId('reply-watcher', watch.issue, key), 'GitHub issue comment reply subscription failed', error);
|
|
2640
|
+
return false;
|
|
2641
|
+
}
|
|
2642
|
+
this.#githubIssueCommentWatchers.set(key, {
|
|
2643
|
+
stop: async () => {
|
|
2644
|
+
stopped = true;
|
|
2645
|
+
await this.#boundedStopTeardown('GitHub issue comment subscription unsubscribe', () => subscription.unsubscribe());
|
|
2646
|
+
},
|
|
2647
|
+
});
|
|
2648
|
+
await this.#replayGithubIssueComments(key);
|
|
2649
|
+
return true;
|
|
2650
|
+
}
|
|
2651
|
+
async #stopGithubIssueCommentWatcher(source, clearPersisted = true) {
|
|
2652
|
+
const key = githubIssueSourceKey(source);
|
|
2653
|
+
const watcher = this.#githubIssueCommentWatchers.get(key);
|
|
2654
|
+
this.#githubIssueCommentWatchers.delete(key);
|
|
2655
|
+
this.#githubIssueCommentWatchStates.delete(key);
|
|
2656
|
+
if (clearPersisted) {
|
|
2657
|
+
await this.#state.clearGithubIssueCommentWatch(this.#workspaceId, key);
|
|
2658
|
+
}
|
|
2659
|
+
await watcher?.stop();
|
|
2660
|
+
}
|
|
2661
|
+
async #stopGithubIssueCommentWatcherForIssue(issue) {
|
|
2662
|
+
const keysToStop = [];
|
|
2663
|
+
for (const [key, watch] of this.#githubIssueCommentWatchStates) {
|
|
2664
|
+
if (watch.issue.uuid === issue.uuid || watch.issue.path === issue.path) {
|
|
2665
|
+
keysToStop.push(key);
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
for (const key of keysToStop) {
|
|
2669
|
+
const watcher = this.#githubIssueCommentWatchers.get(key);
|
|
2670
|
+
this.#githubIssueCommentWatchers.delete(key);
|
|
2671
|
+
this.#githubIssueCommentWatchStates.delete(key);
|
|
2672
|
+
await this.#state.clearGithubIssueCommentWatch(this.#workspaceId, key);
|
|
2673
|
+
await watcher?.stop();
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
async #removeGithubIssueCommentPending(source, correlationId) {
|
|
2677
|
+
const key = githubIssueSourceKey(source);
|
|
2678
|
+
const watch = this.#githubIssueCommentWatchStates.get(key);
|
|
2679
|
+
if (!watch)
|
|
2680
|
+
return;
|
|
2681
|
+
watch.pending = watch.pending.filter((pending) => pending.correlationId !== correlationId);
|
|
2682
|
+
if (watch.pending.length === 0) {
|
|
2683
|
+
await this.#stopGithubIssueCommentWatcher(source);
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
2687
|
+
}
|
|
2688
|
+
async #rearmGithubIssueCommentWatchers() {
|
|
2689
|
+
for (const [, watch] of await this.#state.listGithubIssueCommentWatches(this.#workspaceId)) {
|
|
2690
|
+
if (watch.pending.length === 0)
|
|
2691
|
+
continue;
|
|
2692
|
+
try {
|
|
2693
|
+
await this.#watchGithubIssueComments(watch);
|
|
2694
|
+
this.#increment('githubIssueCommentWatchersRearmed');
|
|
2695
|
+
}
|
|
2696
|
+
catch (error) {
|
|
2697
|
+
this.#logger.warn?.('[factory] failed to re-arm GitHub issue comment watcher', {
|
|
2698
|
+
issue: watch.issue,
|
|
2699
|
+
error,
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
async #replayGithubIssueComments(key) {
|
|
2705
|
+
const watch = this.#githubIssueCommentWatchStates.get(key);
|
|
2706
|
+
if (!watch)
|
|
2707
|
+
return;
|
|
2708
|
+
const comments = [];
|
|
2709
|
+
const sinceCommentId = githubCommentNumericId(watch.sinceCommentId ?? watch.lastSeenCommentId);
|
|
2710
|
+
const processedCommentIds = new Set(watch.processedCommentIds ?? []);
|
|
2711
|
+
for (const path of await this.#githubIssueCommentPaths(watch.source)) {
|
|
2712
|
+
const parts = githubIssueCommentPathParts(path);
|
|
2713
|
+
const id = parts ? githubCommentNumericId(parts.commentId) : undefined;
|
|
2714
|
+
if (id !== undefined && id > sinceCommentId && !processedCommentIds.has(String(id))) {
|
|
2715
|
+
comments.push({ path, id });
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
comments.sort((a, b) => a.id - b.id);
|
|
2719
|
+
for (const comment of comments) {
|
|
2720
|
+
await this.#enqueueGithubIssueComment(key, comment.path);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
async #enqueueGithubIssueComment(key, path) {
|
|
2724
|
+
const previous = this.#githubIssueCommentQueues.get(key) ?? Promise.resolve();
|
|
2725
|
+
const current = previous.catch(() => undefined).then(async () => {
|
|
2726
|
+
await this.#handleGithubIssueComment(key, path);
|
|
2727
|
+
});
|
|
2728
|
+
this.#githubIssueCommentQueues.set(key, current);
|
|
2729
|
+
try {
|
|
2730
|
+
await current;
|
|
2731
|
+
}
|
|
2732
|
+
finally {
|
|
2733
|
+
if (this.#githubIssueCommentQueues.get(key) === current) {
|
|
2734
|
+
this.#githubIssueCommentQueues.delete(key);
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
async #githubIssueCommentPaths(source) {
|
|
2739
|
+
const paths = new Set();
|
|
2740
|
+
const owner = encodeURIComponent(source.owner);
|
|
2741
|
+
const repo = encodeURIComponent(source.repo);
|
|
2742
|
+
for (const prefix of [
|
|
2743
|
+
`${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues`,
|
|
2744
|
+
`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues`,
|
|
2745
|
+
]) {
|
|
2746
|
+
try {
|
|
2747
|
+
for (const path of await this.#mount.listTree(prefix)) {
|
|
2748
|
+
const parts = githubIssueCommentPathParts(path);
|
|
2749
|
+
if (parts &&
|
|
2750
|
+
parts.owner.toLowerCase() === source.owner.toLowerCase() &&
|
|
2751
|
+
parts.repo.toLowerCase() === source.repo.toLowerCase() &&
|
|
2752
|
+
parts.number === source.number) {
|
|
2753
|
+
paths.add(path);
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
catch (error) {
|
|
2758
|
+
this.#logger.warn?.('[factory] unable to list GitHub issue comments for replay', { prefix, error });
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
return [...paths];
|
|
2762
|
+
}
|
|
2763
|
+
async #latestGithubIssueCommentId(source) {
|
|
2764
|
+
let latest = 0;
|
|
2765
|
+
for (const path of await this.#githubIssueCommentPaths(source)) {
|
|
2766
|
+
const id = githubCommentNumericId(githubIssueCommentPathParts(path)?.commentId);
|
|
2767
|
+
if (id > latest)
|
|
2768
|
+
latest = id;
|
|
2769
|
+
}
|
|
2770
|
+
return latest > 0 ? String(latest) : undefined;
|
|
2771
|
+
}
|
|
2772
|
+
async #readGithubIssueComment(path) {
|
|
2773
|
+
try {
|
|
2774
|
+
const { content } = await this.#mount.readFile(path);
|
|
2775
|
+
return parseGithubIssueComment(path, content);
|
|
2776
|
+
}
|
|
2777
|
+
catch (error) {
|
|
2778
|
+
this.#logger.warn?.('[factory] unable to read GitHub issue comment reply', { path, error });
|
|
2779
|
+
return undefined;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
async #handleGithubIssueComment(key, path) {
|
|
2783
|
+
const watch = this.#githubIssueCommentWatchStates.get(key);
|
|
2784
|
+
if (!watch)
|
|
2785
|
+
return;
|
|
2786
|
+
const comment = await this.#readGithubIssueComment(path);
|
|
2787
|
+
if (!comment)
|
|
2788
|
+
return;
|
|
2789
|
+
const commentId = githubCommentNumericId(comment.commentId);
|
|
2790
|
+
const normalizedCommentId = String(commentId);
|
|
2791
|
+
const processedCommentIds = new Set(watch.processedCommentIds ?? []);
|
|
2792
|
+
if (commentId === 0 ||
|
|
2793
|
+
commentId <= githubCommentNumericId(watch.sinceCommentId) ||
|
|
2794
|
+
processedCommentIds.has(normalizedCommentId)) {
|
|
2795
|
+
this.#increment('githubIssueCommentDuplicatesSuppressed');
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
const reply = githubCorrelatedReply(comment.body);
|
|
2799
|
+
const pending = reply
|
|
2800
|
+
? watch.pending.find((candidate) => candidate.correlationId === reply.correlationId)
|
|
2801
|
+
: undefined;
|
|
2802
|
+
let resolved = false;
|
|
2803
|
+
let discardClaimedPending = false;
|
|
2804
|
+
if (pending?.claimedByCommentId === normalizedCommentId) {
|
|
2805
|
+
this.#increment('githubAnswersClaimedReplaySuppressed');
|
|
2806
|
+
discardClaimedPending = true;
|
|
2807
|
+
}
|
|
2808
|
+
else if (pending && comment.isBot) {
|
|
2809
|
+
this.#increment('githubAnswersIgnoredBot');
|
|
2810
|
+
this.#logger.info?.('[factory] ignored GitHub issue answer from a bot', {
|
|
2811
|
+
issue: watch.issue,
|
|
2812
|
+
commentId: normalizedCommentId,
|
|
2813
|
+
correlationId: pending.correlationId,
|
|
2814
|
+
});
|
|
2815
|
+
}
|
|
2816
|
+
else if (pending && comment.author?.toLowerCase() === pending.authorizedAuthor.toLowerCase()) {
|
|
2817
|
+
pending.claimedByCommentId = normalizedCommentId;
|
|
2818
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
2819
|
+
resolved = await this.#routeGithubAnswerToImplementers(watch, pending, comment, reply.text);
|
|
2820
|
+
if (!resolved) {
|
|
2821
|
+
delete pending.claimedByCommentId;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
else if (pending) {
|
|
2825
|
+
this.#increment('githubAnswersIgnoredUnauthorizedAuthor');
|
|
2826
|
+
this.#logger.info?.('[factory] ignored GitHub issue answer from unauthorized author', {
|
|
2827
|
+
issue: watch.issue,
|
|
2828
|
+
commentId: normalizedCommentId,
|
|
2829
|
+
correlationId: pending.correlationId,
|
|
2830
|
+
author: comment.author,
|
|
2831
|
+
authorizedAuthor: pending.authorizedAuthor,
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2834
|
+
else if (reply) {
|
|
2835
|
+
this.#increment('githubAnswersIgnoredUnknownCorrelation');
|
|
2836
|
+
this.#logger.info?.('[factory] ignored GitHub issue answer with unknown correlation', {
|
|
2837
|
+
issue: watch.issue,
|
|
2838
|
+
commentId: normalizedCommentId,
|
|
2839
|
+
correlationId: reply.correlationId,
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
else if (!reply && comment.body.includes('[factory-reply:')) {
|
|
2843
|
+
this.#increment('githubAnswersIgnoredMissingCorrelationPrefix');
|
|
2844
|
+
}
|
|
2845
|
+
processedCommentIds.add(normalizedCommentId);
|
|
2846
|
+
watch.processedCommentIds = [...processedCommentIds];
|
|
2847
|
+
watch.lastSeenCommentId = String(Math.max(commentId, githubCommentNumericId(watch.lastSeenCommentId)));
|
|
2848
|
+
if ((resolved || discardClaimedPending) && pending) {
|
|
2849
|
+
watch.pending = watch.pending.filter((candidate) => candidate.correlationId !== pending.correlationId);
|
|
2850
|
+
}
|
|
2851
|
+
if (watch.pending.length === 0) {
|
|
2852
|
+
await this.#stopGithubIssueCommentWatcher(watch.source);
|
|
2853
|
+
}
|
|
2854
|
+
else {
|
|
2855
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
async #routeGithubAnswerToImplementers(watch, pending, comment, text) {
|
|
2859
|
+
if (pending.kind === 'triage' && pending.decision) {
|
|
2860
|
+
return await this.#handleTriageEscalationGithubAnswer(escalationWatchRecord(pending.decision), text);
|
|
2861
|
+
}
|
|
2862
|
+
const liveRecord = (await this.#batch()).getIssue(watch.issue);
|
|
2863
|
+
if (!liveRecord || liveRecord.dryRun) {
|
|
2864
|
+
this.#increment('githubAnswersIgnoredNoInFlight');
|
|
2865
|
+
return false;
|
|
2866
|
+
}
|
|
2867
|
+
if (!this.#fleet.sendInput)
|
|
2868
|
+
return false;
|
|
2869
|
+
const recipients = [...liveRecord.agents.values()]
|
|
2870
|
+
.filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
|
|
2871
|
+
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
2872
|
+
.filter((name) => Boolean(name));
|
|
2873
|
+
if (recipients.length === 0) {
|
|
2874
|
+
this.#increment('githubAnswersIgnoredNoImplementer');
|
|
2875
|
+
return false;
|
|
2876
|
+
}
|
|
2877
|
+
for (const recipient of new Set(recipients)) {
|
|
2878
|
+
await this.#fleet.sendInput(recipient, githubReplyEvent(liveRecord.issue, text, comment.author));
|
|
2879
|
+
this.#increment('githubAnswersInjected');
|
|
2880
|
+
}
|
|
2881
|
+
return true;
|
|
2882
|
+
}
|
|
2883
|
+
async #handleTriageEscalationGithubAnswer(record, text) {
|
|
2884
|
+
const issue = await this.#readIssue(record.issue.path);
|
|
2885
|
+
if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
|
|
2886
|
+
this.#increment('githubTriageAnswersIgnoredIssueUnavailable');
|
|
2887
|
+
return false;
|
|
2888
|
+
}
|
|
2889
|
+
if (!this.#isIssueReady(issue)) {
|
|
2890
|
+
this.#increment('githubTriageAnswersIgnoredIssueNotReady');
|
|
2891
|
+
return false;
|
|
2892
|
+
}
|
|
2893
|
+
const batch = await this.#batch();
|
|
2894
|
+
if (batch.isInFlight(record.issue) || batch.isQueued(record.issue)) {
|
|
2895
|
+
this.#increment('githubTriageAnswersIgnoredAlreadyActive');
|
|
2896
|
+
return false;
|
|
2897
|
+
}
|
|
2898
|
+
if (await this.#dispatchBlockReason(record.issue)) {
|
|
2899
|
+
this.#increment('githubTriageAnswersIgnoredBlocked');
|
|
2900
|
+
return false;
|
|
2901
|
+
}
|
|
2902
|
+
const clarifiedIssue = issueWithGithubClarification(issue, text);
|
|
2903
|
+
const decision = await this.#triage.triage(clarifiedIssue, {
|
|
2904
|
+
config: this.#config,
|
|
2905
|
+
repoMap: repoMapFromConfig(this.#config),
|
|
2906
|
+
});
|
|
2907
|
+
const escalationReason = triageEscalationReason(decision);
|
|
2908
|
+
if (escalationReason) {
|
|
2909
|
+
if (hasDispatchableRoute(decision)) {
|
|
2910
|
+
this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
|
|
2911
|
+
const result = await this.#startOrQueueGithubClarifiedDecision(dispatchAfterGithubClarification(decision, escalationReason));
|
|
2912
|
+
this.#increment('githubTriageAnswersDispatchedWithRemainingEscalation');
|
|
2913
|
+
return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
|
|
2914
|
+
}
|
|
2915
|
+
this.#increment('githubTriageAnswersStillEscalated');
|
|
2916
|
+
this.#logger.warn?.('[factory] GitHub triage answer still leaves issue escalated', {
|
|
2917
|
+
issue: record.issue,
|
|
2918
|
+
reason: escalationReason,
|
|
2919
|
+
});
|
|
2920
|
+
return false;
|
|
2921
|
+
}
|
|
2922
|
+
this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
|
|
2923
|
+
const result = await this.#startOrQueueGithubClarifiedDecision(decision);
|
|
2924
|
+
this.#increment('githubTriageAnswersDispatched');
|
|
2925
|
+
return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
|
|
2926
|
+
}
|
|
2927
|
+
async #startOrQueueGithubClarifiedDecision(decision) {
|
|
2928
|
+
const batch = await this.#batch();
|
|
2929
|
+
if (batch.canStart()) {
|
|
2930
|
+
return await this.dispatch(decision, { dryRun: this.#config.dryRun });
|
|
2931
|
+
}
|
|
2932
|
+
if (batch.queue(decision, this.#config.dryRun)) {
|
|
2933
|
+
this.#increment('githubTriageAnswersQueued');
|
|
2934
|
+
this.#emit('issue-queued', { issue: decision.issue });
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2450
2937
|
#recordArrivalLatency(event) {
|
|
2451
2938
|
const occurredAt = Date.parse(event.occurredAt);
|
|
2452
2939
|
if (!Number.isFinite(occurredAt))
|
|
@@ -2990,6 +3477,7 @@ export class FactoryLoop {
|
|
|
2990
3477
|
this.#increment(humanReview ? 'humanReview' : 'done');
|
|
2991
3478
|
this.#emit('issue-done', { issue: record.issue });
|
|
2992
3479
|
await this.#stopSlackWatcher(record.issue);
|
|
3480
|
+
await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
|
|
2993
3481
|
await this.#recordDispatchTerminal(record.issue);
|
|
2994
3482
|
const next = (await this.#batch()).complete(record.issue);
|
|
2995
3483
|
if (next) {
|
|
@@ -3006,6 +3494,10 @@ export class FactoryLoop {
|
|
|
3006
3494
|
this.#probePrResolvedCache.delete(completionKey);
|
|
3007
3495
|
this.#babysitterSpawned.delete(record.issue.key);
|
|
3008
3496
|
this.#babysitterPr.delete(record.issue.key);
|
|
3497
|
+
for (const publishedKey of this.#publishedPullRequests) {
|
|
3498
|
+
if (publishedKey.startsWith(`${record.issue.key}:`))
|
|
3499
|
+
this.#publishedPullRequests.delete(publishedKey);
|
|
3500
|
+
}
|
|
3009
3501
|
}
|
|
3010
3502
|
}
|
|
3011
3503
|
#emit(event, payload) {
|
|
@@ -3018,6 +3510,22 @@ export class FactoryLoop {
|
|
|
3018
3510
|
this.#logger.error?.('[factory] error', error);
|
|
3019
3511
|
this.#emit('error', { error, ...describeError(error), issue });
|
|
3020
3512
|
}
|
|
3513
|
+
#surfaceEscalationDeliveryFailure(kind, issue, correlationId, reason, cause) {
|
|
3514
|
+
const error = new Error(`${reason} (${correlationId})`);
|
|
3515
|
+
if (cause !== undefined) {
|
|
3516
|
+
Object.assign(error, { cause });
|
|
3517
|
+
}
|
|
3518
|
+
this.#increment('errors');
|
|
3519
|
+
this.#increment('escalationDeliveryFailures');
|
|
3520
|
+
this.#logger.error?.('[factory] escalation delivery failed', {
|
|
3521
|
+
kind,
|
|
3522
|
+
correlationId,
|
|
3523
|
+
issue,
|
|
3524
|
+
reason,
|
|
3525
|
+
cause,
|
|
3526
|
+
});
|
|
3527
|
+
this.#emit('error', { error, ...describeError(error), issue });
|
|
3528
|
+
}
|
|
3021
3529
|
#increment(name) {
|
|
3022
3530
|
this.#counters[name] = (this.#counters[name] ?? 0) + 1;
|
|
3023
3531
|
}
|
|
@@ -3241,16 +3749,12 @@ export class FactoryLoop {
|
|
|
3241
3749
|
await this.#watchSlackThread(record, root.threadId);
|
|
3242
3750
|
this.#recordSlackWritebackSuccess('dispatch-thread');
|
|
3243
3751
|
}
|
|
3244
|
-
async #
|
|
3752
|
+
async #escalateTriage(decision, reason, dryRun) {
|
|
3245
3753
|
if (dryRun) {
|
|
3246
3754
|
return;
|
|
3247
3755
|
}
|
|
3248
3756
|
if (!this.#slack || !this.#config.slack) {
|
|
3249
|
-
this.#
|
|
3250
|
-
issue: decision.issue,
|
|
3251
|
-
reason,
|
|
3252
|
-
});
|
|
3253
|
-
return;
|
|
3757
|
+
return await this.#escalateTriageToGithub(decision, reason);
|
|
3254
3758
|
}
|
|
3255
3759
|
if (await this.#shouldSkipSlackWriteback('triage-escalation')) {
|
|
3256
3760
|
return;
|
|
@@ -3288,6 +3792,42 @@ export class FactoryLoop {
|
|
|
3288
3792
|
this.#slackWatcherStarts.delete(key);
|
|
3289
3793
|
}
|
|
3290
3794
|
}
|
|
3795
|
+
async #escalateTriageToGithub(decision, reason) {
|
|
3796
|
+
const question = triageEscalationQuestion(decision);
|
|
3797
|
+
const correlationId = githubEscalationCorrelationId('triage', decision.issue, question);
|
|
3798
|
+
const issue = await this.#readIssue(decision.issue.path);
|
|
3799
|
+
const source = issue ? githubIssueSourceRef(issue) : undefined;
|
|
3800
|
+
const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
|
|
3801
|
+
if (!issue || !source || !authorizedAuthor) {
|
|
3802
|
+
this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
|
|
3803
|
+
return;
|
|
3804
|
+
}
|
|
3805
|
+
const pendingAdded = await this.#addGithubIssueCommentWatch(decision.issue, source, {
|
|
3806
|
+
correlationId,
|
|
3807
|
+
kind: 'triage',
|
|
3808
|
+
authorizedAuthor,
|
|
3809
|
+
decision,
|
|
3810
|
+
});
|
|
3811
|
+
if (!pendingAdded) {
|
|
3812
|
+
return;
|
|
3813
|
+
}
|
|
3814
|
+
try {
|
|
3815
|
+
await this.#githubWriteback.postComment(issue, [
|
|
3816
|
+
`Factory needs clarification before dispatching ${decision.issue.key}.`,
|
|
3817
|
+
`Reason: ${reason}`,
|
|
3818
|
+
`Question: ${question}`,
|
|
3819
|
+
`Authorized responder: @${authorizedAuthor} (the issue reporter).`,
|
|
3820
|
+
`Reply with a comment that starts with \`${githubReplyPrefix(correlationId)}\`.`,
|
|
3821
|
+
'',
|
|
3822
|
+
githubEscalationMarker(correlationId),
|
|
3823
|
+
].join('\n'));
|
|
3824
|
+
this.#increment('triageEscalationsPostedToGithub');
|
|
3825
|
+
}
|
|
3826
|
+
catch (error) {
|
|
3827
|
+
await this.#removeGithubIssueCommentPending(source, correlationId);
|
|
3828
|
+
this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'GitHub issue comment writeback failed', error);
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3291
3831
|
async #postAndWatchSlackEscalationThread(decision, reason) {
|
|
3292
3832
|
if (!this.#slack || !this.#config.slack) {
|
|
3293
3833
|
return;
|
|
@@ -3629,6 +4169,33 @@ export class FactoryLoop {
|
|
|
3629
4169
|
}
|
|
3630
4170
|
this.#pendingSlackClarifications.delete(key);
|
|
3631
4171
|
}
|
|
4172
|
+
async #injectPendingGithubClarification(record) {
|
|
4173
|
+
const key = issueKey(record.issue);
|
|
4174
|
+
const text = this.#pendingGithubClarifications.get(key);
|
|
4175
|
+
if (!text || !this.#fleet.sendInput) {
|
|
4176
|
+
return;
|
|
4177
|
+
}
|
|
4178
|
+
const recipients = [...record.agents.values()]
|
|
4179
|
+
.filter((agent) => agent.spec.role === 'implementer' ||
|
|
4180
|
+
agent.spec.role === 'workflow' ||
|
|
4181
|
+
agent.spec.role === 'babysitter')
|
|
4182
|
+
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
4183
|
+
.filter((name) => Boolean(name));
|
|
4184
|
+
for (const recipient of new Set(recipients)) {
|
|
4185
|
+
try {
|
|
4186
|
+
await this.#fleet.sendInput(recipient, githubReplyEvent(record.issue, text));
|
|
4187
|
+
this.#increment('githubTriageAnswersInjectedToAgents');
|
|
4188
|
+
}
|
|
4189
|
+
catch (error) {
|
|
4190
|
+
this.#logger.warn?.('[factory] failed to inject GitHub triage clarification into agent', {
|
|
4191
|
+
issue: record.issue.key,
|
|
4192
|
+
recipient,
|
|
4193
|
+
error,
|
|
4194
|
+
});
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
this.#pendingGithubClarifications.delete(key);
|
|
4198
|
+
}
|
|
3632
4199
|
// Inject the human's Slack reply into the agent framed as the
|
|
3633
4200
|
// <integration-event> the spawn prompt tells it to expect (not an ambiguous
|
|
3634
4201
|
// "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
|
|
@@ -3782,6 +4349,7 @@ export class FactoryLoop {
|
|
|
3782
4349
|
prNumber: probe.prNumber,
|
|
3783
4350
|
expectedIssueKey: issue.key,
|
|
3784
4351
|
requireTitleMarker: false,
|
|
4352
|
+
...(this.#mount.githubWrite ? { githubWrite: this.#mount.githubWrite } : {}),
|
|
3785
4353
|
});
|
|
3786
4354
|
this.#increment('mergeGateSyntheticClosed');
|
|
3787
4355
|
}
|
|
@@ -3906,6 +4474,7 @@ export function parseGithubIssue(path, content) {
|
|
|
3906
4474
|
labels: Array.isArray(payload.labels)
|
|
3907
4475
|
? payload.labels.map(labelName).filter((label) => Boolean(label))
|
|
3908
4476
|
: [],
|
|
4477
|
+
author: githubAuthorLogin(payload),
|
|
3909
4478
|
path,
|
|
3910
4479
|
raw: asRecord(parsed) ?? payload,
|
|
3911
4480
|
};
|
|
@@ -3947,6 +4516,7 @@ const githubIssueAsFactoryIssue = (issue) => {
|
|
|
3947
4516
|
number: issue.number,
|
|
3948
4517
|
url: issue.url,
|
|
3949
4518
|
path: issue.path,
|
|
4519
|
+
...(issue.author ? { author: issue.author, reporter: issue.author } : {}),
|
|
3950
4520
|
},
|
|
3951
4521
|
},
|
|
3952
4522
|
},
|
|
@@ -4005,6 +4575,28 @@ const isGithubIssue = (issue) => {
|
|
|
4005
4575
|
return stringValue(source?.provider)?.toLowerCase() === 'github' &&
|
|
4006
4576
|
(stringValue(wrapper?.provider)?.toLowerCase() === 'github' || isGithubIssueFilePath(issue.path));
|
|
4007
4577
|
};
|
|
4578
|
+
const githubIssueSourceRef = (issue) => {
|
|
4579
|
+
const source = asRecord(wrappedPayload(issue.raw).source);
|
|
4580
|
+
if (stringValue(source?.provider)?.toLowerCase() !== 'github') {
|
|
4581
|
+
return undefined;
|
|
4582
|
+
}
|
|
4583
|
+
const owner = stringValue(source?.owner)?.trim();
|
|
4584
|
+
const repo = stringValue(source?.repo)?.trim();
|
|
4585
|
+
const number = numberValue(source?.number);
|
|
4586
|
+
const url = stringValue(source?.url)?.trim();
|
|
4587
|
+
if (!owner || !repo || !Number.isInteger(number) || (number ?? 0) <= 0 || !url) {
|
|
4588
|
+
return undefined;
|
|
4589
|
+
}
|
|
4590
|
+
return { owner, repo, number: number, url };
|
|
4591
|
+
};
|
|
4592
|
+
const githubIssueAuthor = (issue) => {
|
|
4593
|
+
const payload = wrappedPayload(issue.raw);
|
|
4594
|
+
const source = asRecord(payload.source);
|
|
4595
|
+
if (stringValue(source?.provider)?.toLowerCase() === 'github') {
|
|
4596
|
+
return githubAuthorLogin(source ?? {})?.trim() || undefined;
|
|
4597
|
+
}
|
|
4598
|
+
return source ? undefined : githubAuthorLogin(payload)?.trim() || undefined;
|
|
4599
|
+
};
|
|
4008
4600
|
const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
|
|
4009
4601
|
const pidsFromSpawnResult = (result) => {
|
|
4010
4602
|
const pids = new Set();
|
|
@@ -4378,6 +4970,73 @@ export const githubIssuePathParts = (path) => {
|
|
|
4378
4970
|
slug: match[7],
|
|
4379
4971
|
};
|
|
4380
4972
|
};
|
|
4973
|
+
const githubIssueCommentPathParts = (path) => {
|
|
4974
|
+
const match = path.match(/^\/github\/repos\/(?:([^/]+)\/([^/]+)|([A-Za-z0-9-]+)__([^/]+))\/issues\/(\d+)(?:__[^/]*)?\/comments\/([^/]+?)(?:\.json|\/(?:meta|metadata)\.json)$/u);
|
|
4975
|
+
const owner = match?.[1] ?? match?.[3];
|
|
4976
|
+
const repo = match?.[2] ?? match?.[4];
|
|
4977
|
+
const number = Number(match?.[5]);
|
|
4978
|
+
const commentId = match?.[6];
|
|
4979
|
+
if (!owner || !repo || !Number.isInteger(number) || number <= 0 || !commentId) {
|
|
4980
|
+
return undefined;
|
|
4981
|
+
}
|
|
4982
|
+
return { owner, repo, number, commentId };
|
|
4983
|
+
};
|
|
4984
|
+
const parseGithubIssueComment = (path, content) => {
|
|
4985
|
+
const parts = githubIssueCommentPathParts(path);
|
|
4986
|
+
if (!parts) {
|
|
4987
|
+
return undefined;
|
|
4988
|
+
}
|
|
4989
|
+
const raw = asRecord(parseJsonContent(content)) ?? {};
|
|
4990
|
+
const payload = wrappedPayload(raw);
|
|
4991
|
+
const comment = asRecord(payload.comment) ?? payload;
|
|
4992
|
+
const author = asRecord(comment.author) ?? asRecord(comment.user);
|
|
4993
|
+
const authorLogin = stringValue(author?.login);
|
|
4994
|
+
const authorType = stringValue(author?.type)?.toLowerCase();
|
|
4995
|
+
return {
|
|
4996
|
+
owner: parts.owner,
|
|
4997
|
+
repo: parts.repo,
|
|
4998
|
+
issueNumber: parts.number,
|
|
4999
|
+
commentId: String(numberValue(comment.id) ?? parts.commentId),
|
|
5000
|
+
body: stringValue(comment.body) ?? '',
|
|
5001
|
+
author: authorLogin,
|
|
5002
|
+
isBot: authorType === 'bot' || Boolean(authorLogin?.toLowerCase().endsWith('[bot]')),
|
|
5003
|
+
raw,
|
|
5004
|
+
};
|
|
5005
|
+
};
|
|
5006
|
+
const githubIssueSourceKey = (source) => `${source.owner.toLowerCase()}/${source.repo.toLowerCase()}#${source.number}`;
|
|
5007
|
+
const githubIssueCommentGlobs = (source) => {
|
|
5008
|
+
const owner = encodeURIComponent(source.owner);
|
|
5009
|
+
const repo = encodeURIComponent(source.repo);
|
|
5010
|
+
const issueSegment = `${source.number}*`;
|
|
5011
|
+
return [
|
|
5012
|
+
`${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/${issueSegment}/comments/**`,
|
|
5013
|
+
`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/${issueSegment}/comments/**`,
|
|
5014
|
+
];
|
|
5015
|
+
};
|
|
5016
|
+
const githubEscalationCorrelationId = (kind, issue, text) => `factory-${kind}-${stableHash(`${issue.uuid}:${issue.path}:${text}`)}`;
|
|
5017
|
+
const githubEscalationMarker = (correlationId) => `<!-- ${GITHUB_ESCALATION_MARKER_PREFIX}${correlationId} -->`;
|
|
5018
|
+
const githubReplyPrefix = (correlationId) => `[factory-reply:${correlationId}]`;
|
|
5019
|
+
const githubCorrelatedReply = (body) => {
|
|
5020
|
+
if (!body.startsWith('[factory-reply:'))
|
|
5021
|
+
return undefined;
|
|
5022
|
+
const markerEnd = body.indexOf(']');
|
|
5023
|
+
if (markerEnd < 0)
|
|
5024
|
+
return undefined;
|
|
5025
|
+
const correlationId = body.slice('[factory-reply:'.length, markerEnd);
|
|
5026
|
+
if (!/^factory-(?:triage|agent-question)-[a-z0-9]+$/u.test(correlationId))
|
|
5027
|
+
return undefined;
|
|
5028
|
+
const text = body.slice(markerEnd + 1).replace(/^\s*:?\s*/u, '').trim();
|
|
5029
|
+
return text ? { correlationId, text } : undefined;
|
|
5030
|
+
};
|
|
5031
|
+
const githubCommentNumericId = (value) => {
|
|
5032
|
+
const parsed = Number(value);
|
|
5033
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
5034
|
+
};
|
|
5035
|
+
const normalizeGithubIssueCommentWatch = (watch) => ({
|
|
5036
|
+
...watch,
|
|
5037
|
+
sinceCommentId: watch.sinceCommentId ?? watch.lastSeenCommentId,
|
|
5038
|
+
processedCommentIds: [...new Set(watch.processedCommentIds ?? [])],
|
|
5039
|
+
});
|
|
4381
5040
|
const githubIssueReadCandidatePaths = (path) => {
|
|
4382
5041
|
if (path.endsWith('/')) {
|
|
4383
5042
|
return [`${path}meta.json`, `${path}metadata.json`];
|
|
@@ -4419,6 +5078,7 @@ const githubIssueHasFactoryLabel = (issue, requiredLabel = GITHUB_FACTORY_LABEL)
|
|
|
4419
5078
|
const githubIssueIsClosed = (issue) => issue.state === 'closed';
|
|
4420
5079
|
const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => {
|
|
4421
5080
|
const teamId = config.linear.teamIds[config.safety.requireTeamKey];
|
|
5081
|
+
const reporter = issue.author;
|
|
4422
5082
|
return {
|
|
4423
5083
|
id: githubIssueMirrorId(issue),
|
|
4424
5084
|
title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(),
|
|
@@ -4434,6 +5094,7 @@ const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId
|
|
|
4434
5094
|
number: issue.number,
|
|
4435
5095
|
url: issue.url,
|
|
4436
5096
|
path: issue.path,
|
|
5097
|
+
...(reporter ? { author: reporter, reporter } : {}),
|
|
4437
5098
|
},
|
|
4438
5099
|
};
|
|
4439
5100
|
};
|
|
@@ -4708,6 +5369,15 @@ const uuidFromPath = (path) => path.split('__')[1]?.replace(/\.json$/, '');
|
|
|
4708
5369
|
const stringValue = (value) => typeof value === 'string' ? value : undefined;
|
|
4709
5370
|
const booleanValue = (value) => typeof value === 'boolean' ? value : undefined;
|
|
4710
5371
|
const numberValue = (value) => typeof value === 'number' ? value : undefined;
|
|
5372
|
+
const githubAuthorLogin = (payload) => {
|
|
5373
|
+
for (const value of [payload.author, payload.user, payload.reporter]) {
|
|
5374
|
+
const login = (stringValue(value) ??
|
|
5375
|
+
stringValue(asRecord(value)?.login))?.trim();
|
|
5376
|
+
if (login)
|
|
5377
|
+
return login;
|
|
5378
|
+
}
|
|
5379
|
+
return undefined;
|
|
5380
|
+
};
|
|
4711
5381
|
const liveHeartbeatIntervalMs = (staleMs) => Math.min(DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS, Math.max(500, Math.floor(staleMs / 4)));
|
|
4712
5382
|
const installFactoryDraftPredicate = (mount, config) => {
|
|
4713
5383
|
mount.setDefaultAllowedDraftPredicate?.((path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, config));
|
|
@@ -4729,8 +5399,12 @@ const isAllowedFactoryDraft = async (path, content, opts, mount, config) => {
|
|
|
4729
5399
|
if (/^\/slack\/channels\/[^/]+\/messages\/.+/u.test(path)) {
|
|
4730
5400
|
return true;
|
|
4731
5401
|
}
|
|
5402
|
+
if (isFactoryGithubWritebackPath(path)) {
|
|
5403
|
+
return true;
|
|
5404
|
+
}
|
|
4732
5405
|
return false;
|
|
4733
5406
|
};
|
|
5407
|
+
const isFactoryGithubWritebackPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/refs%2Fheads%2F[^/]+\.json|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
|
|
4734
5408
|
const isIssuePathInFactoryScope = async (mount, path, config) => {
|
|
4735
5409
|
try {
|
|
4736
5410
|
return isInFactoryScope(parseLinearIssue(path, (await mount.readFile(path)).content), config.safety);
|
|
@@ -4888,6 +5562,22 @@ const labelName = (value) => {
|
|
|
4888
5562
|
return stringValue(record?.name);
|
|
4889
5563
|
};
|
|
4890
5564
|
const isCompletionReason = (reason) => reason === 'issue-done' || reason === 'done' || reason === 'completed';
|
|
5565
|
+
const normalizeGithubRepo = (repo, defaultOwner) => {
|
|
5566
|
+
if (repo.includes('/'))
|
|
5567
|
+
return repo;
|
|
5568
|
+
const owner = defaultOwner?.trim();
|
|
5569
|
+
if (!owner) {
|
|
5570
|
+
throw new Error(`GitHub repository owner is required for bare repo route: ${repo}; set repos.org or use owner/repo`);
|
|
5571
|
+
}
|
|
5572
|
+
return `${owner}/${repo}`;
|
|
5573
|
+
};
|
|
5574
|
+
const githubPullRequestBody = (issue) => [
|
|
5575
|
+
issue.description,
|
|
5576
|
+
'',
|
|
5577
|
+
isGithubIssue(issue) && /^\d+$/u.test(issue.key)
|
|
5578
|
+
? `Fixes #${issue.key}`
|
|
5579
|
+
: `Factory issue ${issue.key}`,
|
|
5580
|
+
].join('\n').trim();
|
|
4891
5581
|
// The broker rejects re-registering a name it never released on exit
|
|
4892
5582
|
// (relay#1116-family) with a 500 "agent '<name>' already exists". Detect it from
|
|
4893
5583
|
// the structured payload or the message so resume can treat it as terminal
|
|
@@ -5070,6 +5760,15 @@ const dispatchAfterSlackClarification = (decision, escalationReason) => ({
|
|
|
5070
5760
|
`Human answered the Slack triage escalation (${escalationReason}); dispatching to the matched agent so it can acknowledge the answer and ask follow-up questions if needed.`,
|
|
5071
5761
|
].filter(Boolean).join(' '),
|
|
5072
5762
|
});
|
|
5763
|
+
const dispatchAfterGithubClarification = (decision, escalationReason) => ({
|
|
5764
|
+
...decision,
|
|
5765
|
+
thin: false,
|
|
5766
|
+
confidence: 'high',
|
|
5767
|
+
rationale: [
|
|
5768
|
+
decision.rationale,
|
|
5769
|
+
`Human answered the GitHub triage escalation (${escalationReason}); dispatching to the matched agent so it can acknowledge the answer and ask follow-up questions if needed.`,
|
|
5770
|
+
].filter(Boolean).join(' '),
|
|
5771
|
+
});
|
|
5073
5772
|
const issueWithSlackClarification = (issue, text) => ({
|
|
5074
5773
|
...issue,
|
|
5075
5774
|
description: [
|
|
@@ -5079,11 +5778,21 @@ const issueWithSlackClarification = (issue, text) => ({
|
|
|
5079
5778
|
text,
|
|
5080
5779
|
].join('\n'),
|
|
5081
5780
|
});
|
|
5781
|
+
const issueWithGithubClarification = (issue, text) => ({
|
|
5782
|
+
...issue,
|
|
5783
|
+
description: [
|
|
5784
|
+
issue.description,
|
|
5785
|
+
'',
|
|
5786
|
+
'Human clarification from GitHub:',
|
|
5787
|
+
text,
|
|
5788
|
+
].join('\n'),
|
|
5789
|
+
});
|
|
5082
5790
|
const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
|
|
5083
5791
|
// The human's Slack-thread reply, framed as an <integration-event> the agent is
|
|
5084
5792
|
// told (at spawn) to expect — a recognizable injected event, not an ambiguous
|
|
5085
5793
|
// keystroke. Trailing CR submits it to the agent's PTY.
|
|
5086
5794
|
const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
|
|
5795
|
+
const githubReplyEvent = (issue, text, author) => `<integration-event source="github" issue="${issue.key}">\nHuman reply${author ? ` from @${author}` : ''} on the GitHub issue:\n${text}\n</integration-event>\r`;
|
|
5087
5796
|
const isFactoryQuestionTarget = (target) => {
|
|
5088
5797
|
const normalized = target.trim().replace(/^@/u, '').toLowerCase();
|
|
5089
5798
|
return normalized === 'broker' || normalized === 'factory';
|