@agent-relay/factory 0.1.14 → 0.1.15
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 +4 -2
- package/dist/cli/fleet.d.ts +1 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +31 -4
- package/dist/cli/fleet.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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/factory.d.ts +1 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +615 -15
- package/dist/orchestrator/factory.js.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/triage/schema.d.ts +44 -44
- 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;
|
|
@@ -314,6 +319,7 @@ export class FactoryLoop {
|
|
|
314
319
|
try {
|
|
315
320
|
await this.#startLiveSubscription(opts.liveSubscription);
|
|
316
321
|
await this.#rearmSlackReplyWatchers();
|
|
322
|
+
await this.#rearmGithubIssueCommentWatchers();
|
|
317
323
|
this.#scheduleCompletionSweep(0);
|
|
318
324
|
return;
|
|
319
325
|
}
|
|
@@ -345,6 +351,7 @@ export class FactoryLoop {
|
|
|
345
351
|
});
|
|
346
352
|
this.#started = true;
|
|
347
353
|
await this.#rearmSlackReplyWatchers();
|
|
354
|
+
await this.#rearmGithubIssueCommentWatchers();
|
|
348
355
|
this.#scheduleCompletionSweep(0);
|
|
349
356
|
}
|
|
350
357
|
async stop() {
|
|
@@ -369,6 +376,10 @@ export class FactoryLoop {
|
|
|
369
376
|
await this.#boundedStopTeardown('factory subscription unsubscribe', () => subscription?.unsubscribe());
|
|
370
377
|
await Promise.all([...this.#slackWatchers.values()].map((watcher) => watcher.stop()));
|
|
371
378
|
this.#slackWatchers.clear();
|
|
379
|
+
await Promise.all([...this.#githubIssueCommentWatchers.values()].map((watcher) => watcher.stop()));
|
|
380
|
+
this.#githubIssueCommentWatchers.clear();
|
|
381
|
+
this.#githubIssueCommentWatchStates.clear();
|
|
382
|
+
this.#githubIssueCommentQueues.clear();
|
|
372
383
|
await this.#state.clearSlackThreads(this.#workspaceId);
|
|
373
384
|
this.#slackWatcherStarts.clear();
|
|
374
385
|
this.#offAgentExit?.();
|
|
@@ -1221,7 +1232,7 @@ export class FactoryLoop {
|
|
|
1221
1232
|
}
|
|
1222
1233
|
const escalationReason = triageEscalationReason(decision);
|
|
1223
1234
|
if (escalationReason) {
|
|
1224
|
-
const replayedResult = await this.#
|
|
1235
|
+
const replayedResult = await this.#escalateTriage(decision, escalationReason, dryRun);
|
|
1225
1236
|
this.#recordTriageEscalation(decision, escalationReason);
|
|
1226
1237
|
return replayedResult ?? { issue: decision.issue, agents: [], dryRun };
|
|
1227
1238
|
}
|
|
@@ -1322,6 +1333,7 @@ export class FactoryLoop {
|
|
|
1322
1333
|
await this.#sendImplementerTask(record);
|
|
1323
1334
|
await this.#sendCriticalReviewerMessage(record);
|
|
1324
1335
|
await this.#injectPendingSlackClarification(record);
|
|
1336
|
+
await this.#injectPendingGithubClarification(record);
|
|
1325
1337
|
}
|
|
1326
1338
|
return result;
|
|
1327
1339
|
}
|
|
@@ -1416,7 +1428,7 @@ export class FactoryLoop {
|
|
|
1416
1428
|
const decision = await this.triageIssue(issue);
|
|
1417
1429
|
const escalationReason = triageEscalationReason(decision);
|
|
1418
1430
|
if (escalationReason) {
|
|
1419
|
-
await this.#
|
|
1431
|
+
await this.#escalateTriage(decision, escalationReason, this.#config.dryRun);
|
|
1420
1432
|
this.#recordTriageEscalation(decision, escalationReason);
|
|
1421
1433
|
return;
|
|
1422
1434
|
}
|
|
@@ -2406,15 +2418,11 @@ export class FactoryLoop {
|
|
|
2406
2418
|
issue: record.issue.key,
|
|
2407
2419
|
});
|
|
2408
2420
|
}
|
|
2409
|
-
await this.#
|
|
2421
|
+
await this.#postAgentQuestion(record, question);
|
|
2410
2422
|
}
|
|
2411
|
-
async #
|
|
2423
|
+
async #postAgentQuestion(record, question) {
|
|
2412
2424
|
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
|
-
});
|
|
2425
|
+
await this.#postAgentQuestionToGithub(record, question);
|
|
2418
2426
|
return;
|
|
2419
2427
|
}
|
|
2420
2428
|
if (await this.#shouldSkipSlackWriteback('agent-question')) {
|
|
@@ -2447,6 +2455,401 @@ export class FactoryLoop {
|
|
|
2447
2455
|
this.#logger.warn?.(`[factory] failed to post agent question for ${record.issue.key}`, error);
|
|
2448
2456
|
}
|
|
2449
2457
|
}
|
|
2458
|
+
async #postAgentQuestionToGithub(record, question) {
|
|
2459
|
+
const correlationId = githubEscalationCorrelationId('agent-question', record.issue, question.question);
|
|
2460
|
+
const issue = await this.#readIssue(record.issue.path);
|
|
2461
|
+
const source = issue ? githubIssueSourceRef(issue) : undefined;
|
|
2462
|
+
const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
|
|
2463
|
+
if (!issue || !source || !authorizedAuthor) {
|
|
2464
|
+
this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
await this.#addGithubIssueCommentWatch(record.issue, source, {
|
|
2468
|
+
correlationId,
|
|
2469
|
+
kind: 'agent-question',
|
|
2470
|
+
authorizedAuthor,
|
|
2471
|
+
});
|
|
2472
|
+
try {
|
|
2473
|
+
await this.#githubWriteback.postComment(issue, [
|
|
2474
|
+
`${record.issue.key}: ${question.agentName} needs input.`,
|
|
2475
|
+
`Question: ${question.question}`,
|
|
2476
|
+
`Authorized responder: @${authorizedAuthor} (the issue reporter).`,
|
|
2477
|
+
`Reply with a comment that starts with \`${githubReplyPrefix(correlationId)}\`.`,
|
|
2478
|
+
'',
|
|
2479
|
+
githubEscalationMarker(correlationId),
|
|
2480
|
+
].join('\n'));
|
|
2481
|
+
this.#increment('agentQuestionsPostedToGithub');
|
|
2482
|
+
}
|
|
2483
|
+
catch (error) {
|
|
2484
|
+
await this.#removeGithubIssueCommentPending(source, correlationId);
|
|
2485
|
+
this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'GitHub issue comment writeback failed', error);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
async #addGithubIssueCommentWatch(issue, source, pending) {
|
|
2489
|
+
const key = githubIssueSourceKey(source);
|
|
2490
|
+
let watch = this.#githubIssueCommentWatchStates.get(key);
|
|
2491
|
+
if (!watch) {
|
|
2492
|
+
const persisted = await this.#state.listGithubIssueCommentWatches(this.#workspaceId);
|
|
2493
|
+
watch = persisted.find(([persistedKey]) => persistedKey === key)?.[1];
|
|
2494
|
+
}
|
|
2495
|
+
if (!watch) {
|
|
2496
|
+
const sinceCommentId = await this.#latestGithubIssueCommentId(source);
|
|
2497
|
+
watch = {
|
|
2498
|
+
issue,
|
|
2499
|
+
source,
|
|
2500
|
+
pending: [],
|
|
2501
|
+
sinceCommentId,
|
|
2502
|
+
lastSeenCommentId: sinceCommentId,
|
|
2503
|
+
processedCommentIds: [],
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
const added = !watch.pending.some((candidate) => candidate.correlationId === pending.correlationId);
|
|
2507
|
+
if (added) {
|
|
2508
|
+
watch.pending.push(pending);
|
|
2509
|
+
}
|
|
2510
|
+
await this.#watchGithubIssueComments(watch);
|
|
2511
|
+
return added;
|
|
2512
|
+
}
|
|
2513
|
+
async #watchGithubIssueComments(watch) {
|
|
2514
|
+
watch = normalizeGithubIssueCommentWatch(watch);
|
|
2515
|
+
const key = githubIssueSourceKey(watch.source);
|
|
2516
|
+
this.#githubIssueCommentWatchStates.set(key, watch);
|
|
2517
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
2518
|
+
if (this.#githubIssueCommentWatchers.has(key)) {
|
|
2519
|
+
await this.#replayGithubIssueComments(key);
|
|
2520
|
+
return false;
|
|
2521
|
+
}
|
|
2522
|
+
const source = watch.source;
|
|
2523
|
+
let stopped = false;
|
|
2524
|
+
const handle = async (event) => {
|
|
2525
|
+
const path = changeEventPath(event);
|
|
2526
|
+
const parts = path ? githubIssueCommentPathParts(path) : undefined;
|
|
2527
|
+
if (stopped ||
|
|
2528
|
+
!path ||
|
|
2529
|
+
!parts ||
|
|
2530
|
+
parts.owner.toLowerCase() !== source.owner.toLowerCase() ||
|
|
2531
|
+
parts.repo.toLowerCase() !== source.repo.toLowerCase() ||
|
|
2532
|
+
parts.number !== source.number) {
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
try {
|
|
2536
|
+
await this.#enqueueGithubIssueComment(key, path);
|
|
2537
|
+
}
|
|
2538
|
+
catch (error) {
|
|
2539
|
+
this.#logger.error?.('[factory] failed to handle GitHub issue comment reply', {
|
|
2540
|
+
issue: watch.issue,
|
|
2541
|
+
path,
|
|
2542
|
+
error,
|
|
2543
|
+
});
|
|
2544
|
+
this.#increment('githubIssueCommentReplyErrors');
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
let subscription;
|
|
2548
|
+
try {
|
|
2549
|
+
subscription = this.#mount.subscribe(githubIssueCommentGlobs(source), (event) => {
|
|
2550
|
+
void handle(event);
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
catch (error) {
|
|
2554
|
+
this.#githubIssueCommentWatchStates.delete(key);
|
|
2555
|
+
this.#surfaceEscalationDeliveryFailure('github-reply-watcher', watch.issue, githubEscalationCorrelationId('reply-watcher', watch.issue, key), 'GitHub issue comment reply subscription failed', error);
|
|
2556
|
+
return false;
|
|
2557
|
+
}
|
|
2558
|
+
this.#githubIssueCommentWatchers.set(key, {
|
|
2559
|
+
stop: async () => {
|
|
2560
|
+
stopped = true;
|
|
2561
|
+
await this.#boundedStopTeardown('GitHub issue comment subscription unsubscribe', () => subscription.unsubscribe());
|
|
2562
|
+
},
|
|
2563
|
+
});
|
|
2564
|
+
await this.#replayGithubIssueComments(key);
|
|
2565
|
+
return true;
|
|
2566
|
+
}
|
|
2567
|
+
async #stopGithubIssueCommentWatcher(source, clearPersisted = true) {
|
|
2568
|
+
const key = githubIssueSourceKey(source);
|
|
2569
|
+
const watcher = this.#githubIssueCommentWatchers.get(key);
|
|
2570
|
+
this.#githubIssueCommentWatchers.delete(key);
|
|
2571
|
+
this.#githubIssueCommentWatchStates.delete(key);
|
|
2572
|
+
if (clearPersisted) {
|
|
2573
|
+
await this.#state.clearGithubIssueCommentWatch(this.#workspaceId, key);
|
|
2574
|
+
}
|
|
2575
|
+
await watcher?.stop();
|
|
2576
|
+
}
|
|
2577
|
+
async #stopGithubIssueCommentWatcherForIssue(issue) {
|
|
2578
|
+
const keysToStop = [];
|
|
2579
|
+
for (const [key, watch] of this.#githubIssueCommentWatchStates) {
|
|
2580
|
+
if (watch.issue.uuid === issue.uuid || watch.issue.path === issue.path) {
|
|
2581
|
+
keysToStop.push(key);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
for (const key of keysToStop) {
|
|
2585
|
+
const watcher = this.#githubIssueCommentWatchers.get(key);
|
|
2586
|
+
this.#githubIssueCommentWatchers.delete(key);
|
|
2587
|
+
this.#githubIssueCommentWatchStates.delete(key);
|
|
2588
|
+
await this.#state.clearGithubIssueCommentWatch(this.#workspaceId, key);
|
|
2589
|
+
await watcher?.stop();
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
async #removeGithubIssueCommentPending(source, correlationId) {
|
|
2593
|
+
const key = githubIssueSourceKey(source);
|
|
2594
|
+
const watch = this.#githubIssueCommentWatchStates.get(key);
|
|
2595
|
+
if (!watch)
|
|
2596
|
+
return;
|
|
2597
|
+
watch.pending = watch.pending.filter((pending) => pending.correlationId !== correlationId);
|
|
2598
|
+
if (watch.pending.length === 0) {
|
|
2599
|
+
await this.#stopGithubIssueCommentWatcher(source);
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
2603
|
+
}
|
|
2604
|
+
async #rearmGithubIssueCommentWatchers() {
|
|
2605
|
+
for (const [, watch] of await this.#state.listGithubIssueCommentWatches(this.#workspaceId)) {
|
|
2606
|
+
if (watch.pending.length === 0)
|
|
2607
|
+
continue;
|
|
2608
|
+
try {
|
|
2609
|
+
await this.#watchGithubIssueComments(watch);
|
|
2610
|
+
this.#increment('githubIssueCommentWatchersRearmed');
|
|
2611
|
+
}
|
|
2612
|
+
catch (error) {
|
|
2613
|
+
this.#logger.warn?.('[factory] failed to re-arm GitHub issue comment watcher', {
|
|
2614
|
+
issue: watch.issue,
|
|
2615
|
+
error,
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
async #replayGithubIssueComments(key) {
|
|
2621
|
+
const watch = this.#githubIssueCommentWatchStates.get(key);
|
|
2622
|
+
if (!watch)
|
|
2623
|
+
return;
|
|
2624
|
+
const comments = [];
|
|
2625
|
+
const sinceCommentId = githubCommentNumericId(watch.sinceCommentId ?? watch.lastSeenCommentId);
|
|
2626
|
+
const processedCommentIds = new Set(watch.processedCommentIds ?? []);
|
|
2627
|
+
for (const path of await this.#githubIssueCommentPaths(watch.source)) {
|
|
2628
|
+
const parts = githubIssueCommentPathParts(path);
|
|
2629
|
+
const id = parts ? githubCommentNumericId(parts.commentId) : undefined;
|
|
2630
|
+
if (id !== undefined && id > sinceCommentId && !processedCommentIds.has(String(id))) {
|
|
2631
|
+
comments.push({ path, id });
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
comments.sort((a, b) => a.id - b.id);
|
|
2635
|
+
for (const comment of comments) {
|
|
2636
|
+
await this.#enqueueGithubIssueComment(key, comment.path);
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
async #enqueueGithubIssueComment(key, path) {
|
|
2640
|
+
const previous = this.#githubIssueCommentQueues.get(key) ?? Promise.resolve();
|
|
2641
|
+
const current = previous.catch(() => undefined).then(async () => {
|
|
2642
|
+
await this.#handleGithubIssueComment(key, path);
|
|
2643
|
+
});
|
|
2644
|
+
this.#githubIssueCommentQueues.set(key, current);
|
|
2645
|
+
try {
|
|
2646
|
+
await current;
|
|
2647
|
+
}
|
|
2648
|
+
finally {
|
|
2649
|
+
if (this.#githubIssueCommentQueues.get(key) === current) {
|
|
2650
|
+
this.#githubIssueCommentQueues.delete(key);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
async #githubIssueCommentPaths(source) {
|
|
2655
|
+
const paths = new Set();
|
|
2656
|
+
const owner = encodeURIComponent(source.owner);
|
|
2657
|
+
const repo = encodeURIComponent(source.repo);
|
|
2658
|
+
for (const prefix of [
|
|
2659
|
+
`${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues`,
|
|
2660
|
+
`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues`,
|
|
2661
|
+
]) {
|
|
2662
|
+
try {
|
|
2663
|
+
for (const path of await this.#mount.listTree(prefix)) {
|
|
2664
|
+
const parts = githubIssueCommentPathParts(path);
|
|
2665
|
+
if (parts &&
|
|
2666
|
+
parts.owner.toLowerCase() === source.owner.toLowerCase() &&
|
|
2667
|
+
parts.repo.toLowerCase() === source.repo.toLowerCase() &&
|
|
2668
|
+
parts.number === source.number) {
|
|
2669
|
+
paths.add(path);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
catch (error) {
|
|
2674
|
+
this.#logger.warn?.('[factory] unable to list GitHub issue comments for replay', { prefix, error });
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return [...paths];
|
|
2678
|
+
}
|
|
2679
|
+
async #latestGithubIssueCommentId(source) {
|
|
2680
|
+
let latest = 0;
|
|
2681
|
+
for (const path of await this.#githubIssueCommentPaths(source)) {
|
|
2682
|
+
const id = githubCommentNumericId(githubIssueCommentPathParts(path)?.commentId);
|
|
2683
|
+
if (id > latest)
|
|
2684
|
+
latest = id;
|
|
2685
|
+
}
|
|
2686
|
+
return latest > 0 ? String(latest) : undefined;
|
|
2687
|
+
}
|
|
2688
|
+
async #readGithubIssueComment(path) {
|
|
2689
|
+
try {
|
|
2690
|
+
const { content } = await this.#mount.readFile(path);
|
|
2691
|
+
return parseGithubIssueComment(path, content);
|
|
2692
|
+
}
|
|
2693
|
+
catch (error) {
|
|
2694
|
+
this.#logger.warn?.('[factory] unable to read GitHub issue comment reply', { path, error });
|
|
2695
|
+
return undefined;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
async #handleGithubIssueComment(key, path) {
|
|
2699
|
+
const watch = this.#githubIssueCommentWatchStates.get(key);
|
|
2700
|
+
if (!watch)
|
|
2701
|
+
return;
|
|
2702
|
+
const comment = await this.#readGithubIssueComment(path);
|
|
2703
|
+
if (!comment)
|
|
2704
|
+
return;
|
|
2705
|
+
const commentId = githubCommentNumericId(comment.commentId);
|
|
2706
|
+
const normalizedCommentId = String(commentId);
|
|
2707
|
+
const processedCommentIds = new Set(watch.processedCommentIds ?? []);
|
|
2708
|
+
if (commentId === 0 ||
|
|
2709
|
+
commentId <= githubCommentNumericId(watch.sinceCommentId) ||
|
|
2710
|
+
processedCommentIds.has(normalizedCommentId)) {
|
|
2711
|
+
this.#increment('githubIssueCommentDuplicatesSuppressed');
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
const reply = githubCorrelatedReply(comment.body);
|
|
2715
|
+
const pending = reply
|
|
2716
|
+
? watch.pending.find((candidate) => candidate.correlationId === reply.correlationId)
|
|
2717
|
+
: undefined;
|
|
2718
|
+
let resolved = false;
|
|
2719
|
+
let discardClaimedPending = false;
|
|
2720
|
+
if (pending?.claimedByCommentId === normalizedCommentId) {
|
|
2721
|
+
this.#increment('githubAnswersClaimedReplaySuppressed');
|
|
2722
|
+
discardClaimedPending = true;
|
|
2723
|
+
}
|
|
2724
|
+
else if (pending && comment.isBot) {
|
|
2725
|
+
this.#increment('githubAnswersIgnoredBot');
|
|
2726
|
+
this.#logger.info?.('[factory] ignored GitHub issue answer from a bot', {
|
|
2727
|
+
issue: watch.issue,
|
|
2728
|
+
commentId: normalizedCommentId,
|
|
2729
|
+
correlationId: pending.correlationId,
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
else if (pending && comment.author?.toLowerCase() === pending.authorizedAuthor.toLowerCase()) {
|
|
2733
|
+
pending.claimedByCommentId = normalizedCommentId;
|
|
2734
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
2735
|
+
resolved = await this.#routeGithubAnswerToImplementers(watch, pending, comment, reply.text);
|
|
2736
|
+
if (!resolved) {
|
|
2737
|
+
delete pending.claimedByCommentId;
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
else if (pending) {
|
|
2741
|
+
this.#increment('githubAnswersIgnoredUnauthorizedAuthor');
|
|
2742
|
+
this.#logger.info?.('[factory] ignored GitHub issue answer from unauthorized author', {
|
|
2743
|
+
issue: watch.issue,
|
|
2744
|
+
commentId: normalizedCommentId,
|
|
2745
|
+
correlationId: pending.correlationId,
|
|
2746
|
+
author: comment.author,
|
|
2747
|
+
authorizedAuthor: pending.authorizedAuthor,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
else if (reply) {
|
|
2751
|
+
this.#increment('githubAnswersIgnoredUnknownCorrelation');
|
|
2752
|
+
this.#logger.info?.('[factory] ignored GitHub issue answer with unknown correlation', {
|
|
2753
|
+
issue: watch.issue,
|
|
2754
|
+
commentId: normalizedCommentId,
|
|
2755
|
+
correlationId: reply.correlationId,
|
|
2756
|
+
});
|
|
2757
|
+
}
|
|
2758
|
+
else if (!reply && comment.body.includes('[factory-reply:')) {
|
|
2759
|
+
this.#increment('githubAnswersIgnoredMissingCorrelationPrefix');
|
|
2760
|
+
}
|
|
2761
|
+
processedCommentIds.add(normalizedCommentId);
|
|
2762
|
+
watch.processedCommentIds = [...processedCommentIds];
|
|
2763
|
+
watch.lastSeenCommentId = String(Math.max(commentId, githubCommentNumericId(watch.lastSeenCommentId)));
|
|
2764
|
+
if ((resolved || discardClaimedPending) && pending) {
|
|
2765
|
+
watch.pending = watch.pending.filter((candidate) => candidate.correlationId !== pending.correlationId);
|
|
2766
|
+
}
|
|
2767
|
+
if (watch.pending.length === 0) {
|
|
2768
|
+
await this.#stopGithubIssueCommentWatcher(watch.source);
|
|
2769
|
+
}
|
|
2770
|
+
else {
|
|
2771
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
async #routeGithubAnswerToImplementers(watch, pending, comment, text) {
|
|
2775
|
+
if (pending.kind === 'triage' && pending.decision) {
|
|
2776
|
+
return await this.#handleTriageEscalationGithubAnswer(escalationWatchRecord(pending.decision), text);
|
|
2777
|
+
}
|
|
2778
|
+
const liveRecord = (await this.#batch()).getIssue(watch.issue);
|
|
2779
|
+
if (!liveRecord || liveRecord.dryRun) {
|
|
2780
|
+
this.#increment('githubAnswersIgnoredNoInFlight');
|
|
2781
|
+
return false;
|
|
2782
|
+
}
|
|
2783
|
+
if (!this.#fleet.sendInput)
|
|
2784
|
+
return false;
|
|
2785
|
+
const recipients = [...liveRecord.agents.values()]
|
|
2786
|
+
.filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
|
|
2787
|
+
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
2788
|
+
.filter((name) => Boolean(name));
|
|
2789
|
+
if (recipients.length === 0) {
|
|
2790
|
+
this.#increment('githubAnswersIgnoredNoImplementer');
|
|
2791
|
+
return false;
|
|
2792
|
+
}
|
|
2793
|
+
for (const recipient of new Set(recipients)) {
|
|
2794
|
+
await this.#fleet.sendInput(recipient, githubReplyEvent(liveRecord.issue, text, comment.author));
|
|
2795
|
+
this.#increment('githubAnswersInjected');
|
|
2796
|
+
}
|
|
2797
|
+
return true;
|
|
2798
|
+
}
|
|
2799
|
+
async #handleTriageEscalationGithubAnswer(record, text) {
|
|
2800
|
+
const issue = await this.#readIssue(record.issue.path);
|
|
2801
|
+
if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
|
|
2802
|
+
this.#increment('githubTriageAnswersIgnoredIssueUnavailable');
|
|
2803
|
+
return false;
|
|
2804
|
+
}
|
|
2805
|
+
if (!this.#isIssueReady(issue)) {
|
|
2806
|
+
this.#increment('githubTriageAnswersIgnoredIssueNotReady');
|
|
2807
|
+
return false;
|
|
2808
|
+
}
|
|
2809
|
+
const batch = await this.#batch();
|
|
2810
|
+
if (batch.isInFlight(record.issue) || batch.isQueued(record.issue)) {
|
|
2811
|
+
this.#increment('githubTriageAnswersIgnoredAlreadyActive');
|
|
2812
|
+
return false;
|
|
2813
|
+
}
|
|
2814
|
+
if (await this.#dispatchBlockReason(record.issue)) {
|
|
2815
|
+
this.#increment('githubTriageAnswersIgnoredBlocked');
|
|
2816
|
+
return false;
|
|
2817
|
+
}
|
|
2818
|
+
const clarifiedIssue = issueWithGithubClarification(issue, text);
|
|
2819
|
+
const decision = await this.#triage.triage(clarifiedIssue, {
|
|
2820
|
+
config: this.#config,
|
|
2821
|
+
repoMap: repoMapFromConfig(this.#config),
|
|
2822
|
+
});
|
|
2823
|
+
const escalationReason = triageEscalationReason(decision);
|
|
2824
|
+
if (escalationReason) {
|
|
2825
|
+
if (hasDispatchableRoute(decision)) {
|
|
2826
|
+
this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
|
|
2827
|
+
const result = await this.#startOrQueueGithubClarifiedDecision(dispatchAfterGithubClarification(decision, escalationReason));
|
|
2828
|
+
this.#increment('githubTriageAnswersDispatchedWithRemainingEscalation');
|
|
2829
|
+
return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
|
|
2830
|
+
}
|
|
2831
|
+
this.#increment('githubTriageAnswersStillEscalated');
|
|
2832
|
+
this.#logger.warn?.('[factory] GitHub triage answer still leaves issue escalated', {
|
|
2833
|
+
issue: record.issue,
|
|
2834
|
+
reason: escalationReason,
|
|
2835
|
+
});
|
|
2836
|
+
return false;
|
|
2837
|
+
}
|
|
2838
|
+
this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
|
|
2839
|
+
const result = await this.#startOrQueueGithubClarifiedDecision(decision);
|
|
2840
|
+
this.#increment('githubTriageAnswersDispatched');
|
|
2841
|
+
return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
|
|
2842
|
+
}
|
|
2843
|
+
async #startOrQueueGithubClarifiedDecision(decision) {
|
|
2844
|
+
const batch = await this.#batch();
|
|
2845
|
+
if (batch.canStart()) {
|
|
2846
|
+
return await this.dispatch(decision, { dryRun: this.#config.dryRun });
|
|
2847
|
+
}
|
|
2848
|
+
if (batch.queue(decision, this.#config.dryRun)) {
|
|
2849
|
+
this.#increment('githubTriageAnswersQueued');
|
|
2850
|
+
this.#emit('issue-queued', { issue: decision.issue });
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2450
2853
|
#recordArrivalLatency(event) {
|
|
2451
2854
|
const occurredAt = Date.parse(event.occurredAt);
|
|
2452
2855
|
if (!Number.isFinite(occurredAt))
|
|
@@ -2990,6 +3393,7 @@ export class FactoryLoop {
|
|
|
2990
3393
|
this.#increment(humanReview ? 'humanReview' : 'done');
|
|
2991
3394
|
this.#emit('issue-done', { issue: record.issue });
|
|
2992
3395
|
await this.#stopSlackWatcher(record.issue);
|
|
3396
|
+
await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
|
|
2993
3397
|
await this.#recordDispatchTerminal(record.issue);
|
|
2994
3398
|
const next = (await this.#batch()).complete(record.issue);
|
|
2995
3399
|
if (next) {
|
|
@@ -3018,6 +3422,22 @@ export class FactoryLoop {
|
|
|
3018
3422
|
this.#logger.error?.('[factory] error', error);
|
|
3019
3423
|
this.#emit('error', { error, ...describeError(error), issue });
|
|
3020
3424
|
}
|
|
3425
|
+
#surfaceEscalationDeliveryFailure(kind, issue, correlationId, reason, cause) {
|
|
3426
|
+
const error = new Error(`${reason} (${correlationId})`);
|
|
3427
|
+
if (cause !== undefined) {
|
|
3428
|
+
Object.assign(error, { cause });
|
|
3429
|
+
}
|
|
3430
|
+
this.#increment('errors');
|
|
3431
|
+
this.#increment('escalationDeliveryFailures');
|
|
3432
|
+
this.#logger.error?.('[factory] escalation delivery failed', {
|
|
3433
|
+
kind,
|
|
3434
|
+
correlationId,
|
|
3435
|
+
issue,
|
|
3436
|
+
reason,
|
|
3437
|
+
cause,
|
|
3438
|
+
});
|
|
3439
|
+
this.#emit('error', { error, ...describeError(error), issue });
|
|
3440
|
+
}
|
|
3021
3441
|
#increment(name) {
|
|
3022
3442
|
this.#counters[name] = (this.#counters[name] ?? 0) + 1;
|
|
3023
3443
|
}
|
|
@@ -3241,16 +3661,12 @@ export class FactoryLoop {
|
|
|
3241
3661
|
await this.#watchSlackThread(record, root.threadId);
|
|
3242
3662
|
this.#recordSlackWritebackSuccess('dispatch-thread');
|
|
3243
3663
|
}
|
|
3244
|
-
async #
|
|
3664
|
+
async #escalateTriage(decision, reason, dryRun) {
|
|
3245
3665
|
if (dryRun) {
|
|
3246
3666
|
return;
|
|
3247
3667
|
}
|
|
3248
3668
|
if (!this.#slack || !this.#config.slack) {
|
|
3249
|
-
this.#
|
|
3250
|
-
issue: decision.issue,
|
|
3251
|
-
reason,
|
|
3252
|
-
});
|
|
3253
|
-
return;
|
|
3669
|
+
return await this.#escalateTriageToGithub(decision, reason);
|
|
3254
3670
|
}
|
|
3255
3671
|
if (await this.#shouldSkipSlackWriteback('triage-escalation')) {
|
|
3256
3672
|
return;
|
|
@@ -3288,6 +3704,42 @@ export class FactoryLoop {
|
|
|
3288
3704
|
this.#slackWatcherStarts.delete(key);
|
|
3289
3705
|
}
|
|
3290
3706
|
}
|
|
3707
|
+
async #escalateTriageToGithub(decision, reason) {
|
|
3708
|
+
const question = triageEscalationQuestion(decision);
|
|
3709
|
+
const correlationId = githubEscalationCorrelationId('triage', decision.issue, question);
|
|
3710
|
+
const issue = await this.#readIssue(decision.issue.path);
|
|
3711
|
+
const source = issue ? githubIssueSourceRef(issue) : undefined;
|
|
3712
|
+
const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
|
|
3713
|
+
if (!issue || !source || !authorizedAuthor) {
|
|
3714
|
+
this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
const pendingAdded = await this.#addGithubIssueCommentWatch(decision.issue, source, {
|
|
3718
|
+
correlationId,
|
|
3719
|
+
kind: 'triage',
|
|
3720
|
+
authorizedAuthor,
|
|
3721
|
+
decision,
|
|
3722
|
+
});
|
|
3723
|
+
if (!pendingAdded) {
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
try {
|
|
3727
|
+
await this.#githubWriteback.postComment(issue, [
|
|
3728
|
+
`Factory needs clarification before dispatching ${decision.issue.key}.`,
|
|
3729
|
+
`Reason: ${reason}`,
|
|
3730
|
+
`Question: ${question}`,
|
|
3731
|
+
`Authorized responder: @${authorizedAuthor} (the issue reporter).`,
|
|
3732
|
+
`Reply with a comment that starts with \`${githubReplyPrefix(correlationId)}\`.`,
|
|
3733
|
+
'',
|
|
3734
|
+
githubEscalationMarker(correlationId),
|
|
3735
|
+
].join('\n'));
|
|
3736
|
+
this.#increment('triageEscalationsPostedToGithub');
|
|
3737
|
+
}
|
|
3738
|
+
catch (error) {
|
|
3739
|
+
await this.#removeGithubIssueCommentPending(source, correlationId);
|
|
3740
|
+
this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'GitHub issue comment writeback failed', error);
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3291
3743
|
async #postAndWatchSlackEscalationThread(decision, reason) {
|
|
3292
3744
|
if (!this.#slack || !this.#config.slack) {
|
|
3293
3745
|
return;
|
|
@@ -3629,6 +4081,33 @@ export class FactoryLoop {
|
|
|
3629
4081
|
}
|
|
3630
4082
|
this.#pendingSlackClarifications.delete(key);
|
|
3631
4083
|
}
|
|
4084
|
+
async #injectPendingGithubClarification(record) {
|
|
4085
|
+
const key = issueKey(record.issue);
|
|
4086
|
+
const text = this.#pendingGithubClarifications.get(key);
|
|
4087
|
+
if (!text || !this.#fleet.sendInput) {
|
|
4088
|
+
return;
|
|
4089
|
+
}
|
|
4090
|
+
const recipients = [...record.agents.values()]
|
|
4091
|
+
.filter((agent) => agent.spec.role === 'implementer' ||
|
|
4092
|
+
agent.spec.role === 'workflow' ||
|
|
4093
|
+
agent.spec.role === 'babysitter')
|
|
4094
|
+
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
4095
|
+
.filter((name) => Boolean(name));
|
|
4096
|
+
for (const recipient of new Set(recipients)) {
|
|
4097
|
+
try {
|
|
4098
|
+
await this.#fleet.sendInput(recipient, githubReplyEvent(record.issue, text));
|
|
4099
|
+
this.#increment('githubTriageAnswersInjectedToAgents');
|
|
4100
|
+
}
|
|
4101
|
+
catch (error) {
|
|
4102
|
+
this.#logger.warn?.('[factory] failed to inject GitHub triage clarification into agent', {
|
|
4103
|
+
issue: record.issue.key,
|
|
4104
|
+
recipient,
|
|
4105
|
+
error,
|
|
4106
|
+
});
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
this.#pendingGithubClarifications.delete(key);
|
|
4110
|
+
}
|
|
3632
4111
|
// Inject the human's Slack reply into the agent framed as the
|
|
3633
4112
|
// <integration-event> the spawn prompt tells it to expect (not an ambiguous
|
|
3634
4113
|
// "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
|
|
@@ -3906,6 +4385,7 @@ export function parseGithubIssue(path, content) {
|
|
|
3906
4385
|
labels: Array.isArray(payload.labels)
|
|
3907
4386
|
? payload.labels.map(labelName).filter((label) => Boolean(label))
|
|
3908
4387
|
: [],
|
|
4388
|
+
author: githubAuthorLogin(payload),
|
|
3909
4389
|
path,
|
|
3910
4390
|
raw: asRecord(parsed) ?? payload,
|
|
3911
4391
|
};
|
|
@@ -3947,6 +4427,7 @@ const githubIssueAsFactoryIssue = (issue) => {
|
|
|
3947
4427
|
number: issue.number,
|
|
3948
4428
|
url: issue.url,
|
|
3949
4429
|
path: issue.path,
|
|
4430
|
+
...(issue.author ? { author: issue.author, reporter: issue.author } : {}),
|
|
3950
4431
|
},
|
|
3951
4432
|
},
|
|
3952
4433
|
},
|
|
@@ -4005,6 +4486,28 @@ const isGithubIssue = (issue) => {
|
|
|
4005
4486
|
return stringValue(source?.provider)?.toLowerCase() === 'github' &&
|
|
4006
4487
|
(stringValue(wrapper?.provider)?.toLowerCase() === 'github' || isGithubIssueFilePath(issue.path));
|
|
4007
4488
|
};
|
|
4489
|
+
const githubIssueSourceRef = (issue) => {
|
|
4490
|
+
const source = asRecord(wrappedPayload(issue.raw).source);
|
|
4491
|
+
if (stringValue(source?.provider)?.toLowerCase() !== 'github') {
|
|
4492
|
+
return undefined;
|
|
4493
|
+
}
|
|
4494
|
+
const owner = stringValue(source?.owner)?.trim();
|
|
4495
|
+
const repo = stringValue(source?.repo)?.trim();
|
|
4496
|
+
const number = numberValue(source?.number);
|
|
4497
|
+
const url = stringValue(source?.url)?.trim();
|
|
4498
|
+
if (!owner || !repo || !Number.isInteger(number) || (number ?? 0) <= 0 || !url) {
|
|
4499
|
+
return undefined;
|
|
4500
|
+
}
|
|
4501
|
+
return { owner, repo, number: number, url };
|
|
4502
|
+
};
|
|
4503
|
+
const githubIssueAuthor = (issue) => {
|
|
4504
|
+
const payload = wrappedPayload(issue.raw);
|
|
4505
|
+
const source = asRecord(payload.source);
|
|
4506
|
+
if (stringValue(source?.provider)?.toLowerCase() === 'github') {
|
|
4507
|
+
return githubAuthorLogin(source ?? {})?.trim() || undefined;
|
|
4508
|
+
}
|
|
4509
|
+
return source ? undefined : githubAuthorLogin(payload)?.trim() || undefined;
|
|
4510
|
+
};
|
|
4008
4511
|
const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
|
|
4009
4512
|
const pidsFromSpawnResult = (result) => {
|
|
4010
4513
|
const pids = new Set();
|
|
@@ -4378,6 +4881,73 @@ export const githubIssuePathParts = (path) => {
|
|
|
4378
4881
|
slug: match[7],
|
|
4379
4882
|
};
|
|
4380
4883
|
};
|
|
4884
|
+
const githubIssueCommentPathParts = (path) => {
|
|
4885
|
+
const match = path.match(/^\/github\/repos\/(?:([^/]+)\/([^/]+)|([A-Za-z0-9-]+)__([^/]+))\/issues\/(\d+)(?:__[^/]*)?\/comments\/([^/]+?)(?:\.json|\/(?:meta|metadata)\.json)$/u);
|
|
4886
|
+
const owner = match?.[1] ?? match?.[3];
|
|
4887
|
+
const repo = match?.[2] ?? match?.[4];
|
|
4888
|
+
const number = Number(match?.[5]);
|
|
4889
|
+
const commentId = match?.[6];
|
|
4890
|
+
if (!owner || !repo || !Number.isInteger(number) || number <= 0 || !commentId) {
|
|
4891
|
+
return undefined;
|
|
4892
|
+
}
|
|
4893
|
+
return { owner, repo, number, commentId };
|
|
4894
|
+
};
|
|
4895
|
+
const parseGithubIssueComment = (path, content) => {
|
|
4896
|
+
const parts = githubIssueCommentPathParts(path);
|
|
4897
|
+
if (!parts) {
|
|
4898
|
+
return undefined;
|
|
4899
|
+
}
|
|
4900
|
+
const raw = asRecord(parseJsonContent(content)) ?? {};
|
|
4901
|
+
const payload = wrappedPayload(raw);
|
|
4902
|
+
const comment = asRecord(payload.comment) ?? payload;
|
|
4903
|
+
const author = asRecord(comment.author) ?? asRecord(comment.user);
|
|
4904
|
+
const authorLogin = stringValue(author?.login);
|
|
4905
|
+
const authorType = stringValue(author?.type)?.toLowerCase();
|
|
4906
|
+
return {
|
|
4907
|
+
owner: parts.owner,
|
|
4908
|
+
repo: parts.repo,
|
|
4909
|
+
issueNumber: parts.number,
|
|
4910
|
+
commentId: String(numberValue(comment.id) ?? parts.commentId),
|
|
4911
|
+
body: stringValue(comment.body) ?? '',
|
|
4912
|
+
author: authorLogin,
|
|
4913
|
+
isBot: authorType === 'bot' || Boolean(authorLogin?.toLowerCase().endsWith('[bot]')),
|
|
4914
|
+
raw,
|
|
4915
|
+
};
|
|
4916
|
+
};
|
|
4917
|
+
const githubIssueSourceKey = (source) => `${source.owner.toLowerCase()}/${source.repo.toLowerCase()}#${source.number}`;
|
|
4918
|
+
const githubIssueCommentGlobs = (source) => {
|
|
4919
|
+
const owner = encodeURIComponent(source.owner);
|
|
4920
|
+
const repo = encodeURIComponent(source.repo);
|
|
4921
|
+
const issueSegment = `${source.number}*`;
|
|
4922
|
+
return [
|
|
4923
|
+
`${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/${issueSegment}/comments/**`,
|
|
4924
|
+
`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/${issueSegment}/comments/**`,
|
|
4925
|
+
];
|
|
4926
|
+
};
|
|
4927
|
+
const githubEscalationCorrelationId = (kind, issue, text) => `factory-${kind}-${stableHash(`${issue.uuid}:${issue.path}:${text}`)}`;
|
|
4928
|
+
const githubEscalationMarker = (correlationId) => `<!-- ${GITHUB_ESCALATION_MARKER_PREFIX}${correlationId} -->`;
|
|
4929
|
+
const githubReplyPrefix = (correlationId) => `[factory-reply:${correlationId}]`;
|
|
4930
|
+
const githubCorrelatedReply = (body) => {
|
|
4931
|
+
if (!body.startsWith('[factory-reply:'))
|
|
4932
|
+
return undefined;
|
|
4933
|
+
const markerEnd = body.indexOf(']');
|
|
4934
|
+
if (markerEnd < 0)
|
|
4935
|
+
return undefined;
|
|
4936
|
+
const correlationId = body.slice('[factory-reply:'.length, markerEnd);
|
|
4937
|
+
if (!/^factory-(?:triage|agent-question)-[a-z0-9]+$/u.test(correlationId))
|
|
4938
|
+
return undefined;
|
|
4939
|
+
const text = body.slice(markerEnd + 1).replace(/^\s*:?\s*/u, '').trim();
|
|
4940
|
+
return text ? { correlationId, text } : undefined;
|
|
4941
|
+
};
|
|
4942
|
+
const githubCommentNumericId = (value) => {
|
|
4943
|
+
const parsed = Number(value);
|
|
4944
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
4945
|
+
};
|
|
4946
|
+
const normalizeGithubIssueCommentWatch = (watch) => ({
|
|
4947
|
+
...watch,
|
|
4948
|
+
sinceCommentId: watch.sinceCommentId ?? watch.lastSeenCommentId,
|
|
4949
|
+
processedCommentIds: [...new Set(watch.processedCommentIds ?? [])],
|
|
4950
|
+
});
|
|
4381
4951
|
const githubIssueReadCandidatePaths = (path) => {
|
|
4382
4952
|
if (path.endsWith('/')) {
|
|
4383
4953
|
return [`${path}meta.json`, `${path}metadata.json`];
|
|
@@ -4419,6 +4989,7 @@ const githubIssueHasFactoryLabel = (issue, requiredLabel = GITHUB_FACTORY_LABEL)
|
|
|
4419
4989
|
const githubIssueIsClosed = (issue) => issue.state === 'closed';
|
|
4420
4990
|
const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => {
|
|
4421
4991
|
const teamId = config.linear.teamIds[config.safety.requireTeamKey];
|
|
4992
|
+
const reporter = issue.author;
|
|
4422
4993
|
return {
|
|
4423
4994
|
id: githubIssueMirrorId(issue),
|
|
4424
4995
|
title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(),
|
|
@@ -4434,6 +5005,7 @@ const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId
|
|
|
4434
5005
|
number: issue.number,
|
|
4435
5006
|
url: issue.url,
|
|
4436
5007
|
path: issue.path,
|
|
5008
|
+
...(reporter ? { author: reporter, reporter } : {}),
|
|
4437
5009
|
},
|
|
4438
5010
|
};
|
|
4439
5011
|
};
|
|
@@ -4708,6 +5280,15 @@ const uuidFromPath = (path) => path.split('__')[1]?.replace(/\.json$/, '');
|
|
|
4708
5280
|
const stringValue = (value) => typeof value === 'string' ? value : undefined;
|
|
4709
5281
|
const booleanValue = (value) => typeof value === 'boolean' ? value : undefined;
|
|
4710
5282
|
const numberValue = (value) => typeof value === 'number' ? value : undefined;
|
|
5283
|
+
const githubAuthorLogin = (payload) => {
|
|
5284
|
+
for (const value of [payload.author, payload.user, payload.reporter]) {
|
|
5285
|
+
const login = (stringValue(value) ??
|
|
5286
|
+
stringValue(asRecord(value)?.login))?.trim();
|
|
5287
|
+
if (login)
|
|
5288
|
+
return login;
|
|
5289
|
+
}
|
|
5290
|
+
return undefined;
|
|
5291
|
+
};
|
|
4711
5292
|
const liveHeartbeatIntervalMs = (staleMs) => Math.min(DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS, Math.max(500, Math.floor(staleMs / 4)));
|
|
4712
5293
|
const installFactoryDraftPredicate = (mount, config) => {
|
|
4713
5294
|
mount.setDefaultAllowedDraftPredicate?.((path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, config));
|
|
@@ -5070,6 +5651,15 @@ const dispatchAfterSlackClarification = (decision, escalationReason) => ({
|
|
|
5070
5651
|
`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
5652
|
].filter(Boolean).join(' '),
|
|
5072
5653
|
});
|
|
5654
|
+
const dispatchAfterGithubClarification = (decision, escalationReason) => ({
|
|
5655
|
+
...decision,
|
|
5656
|
+
thin: false,
|
|
5657
|
+
confidence: 'high',
|
|
5658
|
+
rationale: [
|
|
5659
|
+
decision.rationale,
|
|
5660
|
+
`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.`,
|
|
5661
|
+
].filter(Boolean).join(' '),
|
|
5662
|
+
});
|
|
5073
5663
|
const issueWithSlackClarification = (issue, text) => ({
|
|
5074
5664
|
...issue,
|
|
5075
5665
|
description: [
|
|
@@ -5079,11 +5669,21 @@ const issueWithSlackClarification = (issue, text) => ({
|
|
|
5079
5669
|
text,
|
|
5080
5670
|
].join('\n'),
|
|
5081
5671
|
});
|
|
5672
|
+
const issueWithGithubClarification = (issue, text) => ({
|
|
5673
|
+
...issue,
|
|
5674
|
+
description: [
|
|
5675
|
+
issue.description,
|
|
5676
|
+
'',
|
|
5677
|
+
'Human clarification from GitHub:',
|
|
5678
|
+
text,
|
|
5679
|
+
].join('\n'),
|
|
5680
|
+
});
|
|
5082
5681
|
const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
|
|
5083
5682
|
// The human's Slack-thread reply, framed as an <integration-event> the agent is
|
|
5084
5683
|
// told (at spawn) to expect — a recognizable injected event, not an ambiguous
|
|
5085
5684
|
// keystroke. Trailing CR submits it to the agent's PTY.
|
|
5086
5685
|
const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
|
|
5686
|
+
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
5687
|
const isFactoryQuestionTarget = (target) => {
|
|
5088
5688
|
const normalized = target.trim().replace(/^@/u, '').toLowerCase();
|
|
5089
5689
|
return normalized === 'broker' || normalized === 'factory';
|