@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.
@@ -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.#escalateTriageToSlack(decision, escalationReason, dryRun);
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.#escalateTriageToSlack(decision, escalationReason, this.#config.dryRun);
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.#postAgentQuestionToSlack(record, question);
2421
+ await this.#postAgentQuestion(record, question);
2410
2422
  }
2411
- async #postAgentQuestionToSlack(record, question) {
2423
+ async #postAgentQuestion(record, question) {
2412
2424
  if (!this.#slack || !this.#config.slack) {
2413
- this.#increment('agentQuestionsSkippedNoSlack');
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 #escalateTriageToSlack(decision, reason, dryRun) {
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.#logger.warn?.('[factory] triage escalation has no Slack channel configured', {
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';