@agent-relay/factory 0.1.13 → 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 +21 -12
- package/dist/cli/fleet.d.ts +1 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +117 -12
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +26 -16
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +4 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/dispatch/relayflow-registry.d.ts +61 -0
- package/dist/dispatch/relayflow-registry.d.ts.map +1 -0
- package/dist/dispatch/relayflow-registry.js +133 -0
- package/dist/dispatch/relayflow-registry.js.map +1 -0
- 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 +297 -24
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/node/factory-node.d.ts +6 -5
- package/dist/node/factory-node.d.ts.map +1 -1
- package/dist/node/factory-node.js +58 -43
- package/dist/node/factory-node.js.map +1 -1
- package/dist/orchestrator/factory.d.ts +3 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +931 -87
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/index.d.ts +1 -1
- package/dist/orchestrator/index.d.ts.map +1 -1
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/ports/index.d.ts +1 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/state.d.ts +23 -0
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/ports/writeback.d.ts +6 -0
- package/dist/ports/writeback.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/subscriptions/event-client.d.ts +1 -0
- package/dist/subscriptions/event-client.d.ts.map +1 -1
- package/dist/subscriptions/event-client.js +1 -0
- package/dist/subscriptions/event-client.js.map +1 -1
- package/dist/triage/schema.d.ts +44 -44
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/github.d.ts +19 -1
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +130 -0
- package/dist/writeback/github.js.map +1 -1
- package/dist/writeback/index.d.ts +2 -1
- package/dist/writeback/index.d.ts.map +1 -1
- package/dist/writeback/index.js +1 -1
- package/dist/writeback/index.js.map +1 -1
- package/package.json +10 -4
|
@@ -7,10 +7,11 @@ import { GithubMergeGate, closeProbePr } from '../github/index.js';
|
|
|
7
7
|
import { InMemoryStateStore } from '../state/in-memory-state-store.js';
|
|
8
8
|
import { containsExplicitIssueReference, containsIssueKey } from '../issue-key-match.js';
|
|
9
9
|
import { isInFactoryScope } from '../safety/factory-scope.js';
|
|
10
|
+
import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
|
|
10
11
|
import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
|
|
11
12
|
import { renderAgentTask } from '../dispatch/templates.js';
|
|
12
13
|
import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
|
|
13
|
-
import { MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
|
|
14
|
+
import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
|
|
14
15
|
import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
|
|
15
16
|
import { issueKey } from './batch-tracker.js';
|
|
16
17
|
import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
|
|
@@ -19,6 +20,7 @@ const ISSUE_ROOT = '/linear/issues';
|
|
|
19
20
|
const GITHUB_ISSUE_ROOT = '/github/repos';
|
|
20
21
|
const READY_EVENTS_LIMIT = 100;
|
|
21
22
|
const LIVE_ISSUE_GLOB = `${ISSUE_ROOT}/**`;
|
|
23
|
+
const LIVE_RELAYFLOW_GLOB = '/**';
|
|
22
24
|
// Subscribe broadly under /github/repos and let isGithubIssueFilePath() /
|
|
23
25
|
// githubPullPathParts() re-validate the exact shape in the callback.
|
|
24
26
|
// globMatchesPath() treats a non-terminal `**` as a single-segment wildcard, so
|
|
@@ -39,6 +41,7 @@ const SLACK_REPLY_POLL_INTERVAL_MS = 5_000;
|
|
|
39
41
|
const AGENT_QUESTION_DEDUPE_LIMIT = 500;
|
|
40
42
|
const AGENT_NEEDS_INPUT_MARKER = '[factory-needs-input]';
|
|
41
43
|
const LEGACY_AGENT_NEEDS_INPUT_MARKER = 'FACTORY_NEEDS_INPUT';
|
|
44
|
+
const GITHUB_ESCALATION_MARKER_PREFIX = 'factory-escalation:';
|
|
42
45
|
// The babysitter DMs `factory` with this marker once it believes the PR is
|
|
43
46
|
// green; the orchestrator confirms readiness with one authoritative gh read
|
|
44
47
|
// before transitioning the issue to Human Review.
|
|
@@ -58,6 +61,7 @@ const DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
|
58
61
|
const REMOTE_OPERATION_PROGRESS_INTERVAL_MS = 15_000;
|
|
59
62
|
const REMOTE_OPERATION_SLOW_WARN_MS = 30_000;
|
|
60
63
|
const GITHUB_FACTORY_LABEL = 'factory';
|
|
64
|
+
const GITHUB_LIFECYCLE_LABELS = new Set(['factory:in-progress', 'factory:human-review']);
|
|
61
65
|
const GITHUB_MIRROR_TITLE_PREFIX = '[factory]';
|
|
62
66
|
const GITHUB_MIRROR_SOURCE_PREFIX = 'Source: ';
|
|
63
67
|
export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
|
|
@@ -76,6 +80,7 @@ export class FactoryLoop {
|
|
|
76
80
|
#fleet;
|
|
77
81
|
#triage;
|
|
78
82
|
#linear;
|
|
83
|
+
#githubWriteback;
|
|
79
84
|
#slack;
|
|
80
85
|
#mergeGate;
|
|
81
86
|
#probeCloser;
|
|
@@ -91,6 +96,7 @@ export class FactoryLoop {
|
|
|
91
96
|
#terminationGraceMs;
|
|
92
97
|
#state;
|
|
93
98
|
#workspaceId;
|
|
99
|
+
#relayflows;
|
|
94
100
|
#batchView;
|
|
95
101
|
#batchReady;
|
|
96
102
|
#listeners = new Map();
|
|
@@ -99,6 +105,9 @@ export class FactoryLoop {
|
|
|
99
105
|
#dispatchInFlight = new Map();
|
|
100
106
|
#slackWatchers = new Map();
|
|
101
107
|
#slackWatcherStarts = new Map();
|
|
108
|
+
#githubIssueCommentWatchers = new Map();
|
|
109
|
+
#githubIssueCommentWatchStates = new Map();
|
|
110
|
+
#githubIssueCommentQueues = new Map();
|
|
102
111
|
#resolvedSlackChannelDir;
|
|
103
112
|
#slackChannelDirRefresh;
|
|
104
113
|
// Agents we've already logged an ambiguous-PID-lookup warning for, so the
|
|
@@ -110,6 +119,7 @@ export class FactoryLoop {
|
|
|
110
119
|
// same notice every cycle. Cleared once the issue dispatches successfully.
|
|
111
120
|
#labelDispatchFailures = new Map();
|
|
112
121
|
#pendingSlackClarifications = new Map();
|
|
122
|
+
#pendingGithubClarifications = new Map();
|
|
113
123
|
#postMergeDoneAdvances = new Set();
|
|
114
124
|
#slackDegraded = false;
|
|
115
125
|
#slackDegradedReason;
|
|
@@ -158,6 +168,7 @@ export class FactoryLoop {
|
|
|
158
168
|
// undefined = readiness not yet probed; resolved lazily so the standalone
|
|
159
169
|
// runOnce() path (which skips #start) still ingests when the mount is present.
|
|
160
170
|
#githubIngestionEnabled;
|
|
171
|
+
#resolvedIssueSource;
|
|
161
172
|
#integrationInstructions;
|
|
162
173
|
#integrationInstructionsRefresh;
|
|
163
174
|
#starting;
|
|
@@ -177,6 +188,7 @@ export class FactoryLoop {
|
|
|
177
188
|
this.#linear = ports.linear ?? MountLinearWriteback(ports.mount, {
|
|
178
189
|
safety: config.safety,
|
|
179
190
|
});
|
|
191
|
+
this.#githubWriteback = ports.githubWriteback ?? new GhCliGithubWriteback();
|
|
180
192
|
this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
|
|
181
193
|
void (ports.github ?? MountGithubRead(ports.mount));
|
|
182
194
|
this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
|
|
@@ -195,6 +207,7 @@ export class FactoryLoop {
|
|
|
195
207
|
this.#readChildPids = ports.readChildPids;
|
|
196
208
|
this.#terminationGraceMs = ports.terminationGraceMs;
|
|
197
209
|
this.#workspaceId = config.workspaceId ?? 'default';
|
|
210
|
+
this.#relayflows = ports.relayflows;
|
|
198
211
|
this.#state = ports.stateStore ?? new InMemoryStateStore({
|
|
199
212
|
batchSize: config.batchSize,
|
|
200
213
|
agentQuestionDedupeLimit: AGENT_QUESTION_DEDUPE_LIMIT,
|
|
@@ -287,18 +300,26 @@ export class FactoryLoop {
|
|
|
287
300
|
}
|
|
288
301
|
async #start(opts) {
|
|
289
302
|
this.#stopping = false;
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
292
|
-
this.#
|
|
303
|
+
const issueSource = await this.#issueSource();
|
|
304
|
+
if (issueSource === 'linear') {
|
|
305
|
+
const ready = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
|
|
306
|
+
if (ready !== 'ready') {
|
|
307
|
+
this.#error(new Error(`${ISSUE_ROOT} sub-root is not mounted`));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const githubReady = await this.#ensureGithubIngestionReady();
|
|
312
|
+
if (issueSource === 'github' && !githubReady) {
|
|
313
|
+
this.#error(new Error(`${GITHUB_ISSUE_ROOT} sub-root is not mounted`));
|
|
293
314
|
return;
|
|
294
315
|
}
|
|
295
|
-
await this.#ensureGithubIngestionReady();
|
|
296
316
|
this.#wireFleetEvents();
|
|
297
317
|
if ((opts.mode ?? 'live') === 'live') {
|
|
298
318
|
this.#started = true;
|
|
299
319
|
try {
|
|
300
320
|
await this.#startLiveSubscription(opts.liveSubscription);
|
|
301
321
|
await this.#rearmSlackReplyWatchers();
|
|
322
|
+
await this.#rearmGithubIssueCommentWatchers();
|
|
302
323
|
this.#scheduleCompletionSweep(0);
|
|
303
324
|
return;
|
|
304
325
|
}
|
|
@@ -309,7 +330,8 @@ export class FactoryLoop {
|
|
|
309
330
|
}
|
|
310
331
|
}
|
|
311
332
|
await this.#backfillReadyIssues();
|
|
312
|
-
this.#subscription = this.#mount.subscribe([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB], (event) => {
|
|
333
|
+
this.#subscription = this.#mount.subscribe(this.#subscriptionGlobs([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB]), (event) => {
|
|
334
|
+
void this.#dispatchRelayflowEvent(event);
|
|
313
335
|
// The SDK types `resource` as always-present, but the polling fallback and
|
|
314
336
|
// degraded-sync paths can deliver events without it. Skip those rather
|
|
315
337
|
// than throwing (which would otherwise crash the subscription handler).
|
|
@@ -329,6 +351,7 @@ export class FactoryLoop {
|
|
|
329
351
|
});
|
|
330
352
|
this.#started = true;
|
|
331
353
|
await this.#rearmSlackReplyWatchers();
|
|
354
|
+
await this.#rearmGithubIssueCommentWatchers();
|
|
332
355
|
this.#scheduleCompletionSweep(0);
|
|
333
356
|
}
|
|
334
357
|
async stop() {
|
|
@@ -353,6 +376,10 @@ export class FactoryLoop {
|
|
|
353
376
|
await this.#boundedStopTeardown('factory subscription unsubscribe', () => subscription?.unsubscribe());
|
|
354
377
|
await Promise.all([...this.#slackWatchers.values()].map((watcher) => watcher.stop()));
|
|
355
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();
|
|
356
383
|
await this.#state.clearSlackThreads(this.#workspaceId);
|
|
357
384
|
this.#slackWatcherStarts.clear();
|
|
358
385
|
this.#offAgentExit?.();
|
|
@@ -415,7 +442,7 @@ export class FactoryLoop {
|
|
|
415
442
|
// LIVE_GITHUB_ISSUE_GLOB is a terminal `${GITHUB_ISSUE_ROOT}/**`, so it
|
|
416
443
|
// already covers the PR change events the babysitter consumes; pull-event
|
|
417
444
|
// *processing* is gated on babysitter.enabled in #prepareLiveEventForDrain.
|
|
418
|
-
this.#subscription = this.#mount.subscribe([LIVE_ISSUE_GLOB, LIVE_GITHUB_ISSUE_GLOB], (event) => {
|
|
445
|
+
this.#subscription = this.#mount.subscribe(this.#subscriptionGlobs([LIVE_ISSUE_GLOB, LIVE_GITHUB_ISSUE_GLOB]), (event) => {
|
|
419
446
|
this.#enqueueLiveEvent(event);
|
|
420
447
|
}, { from: 'now', coalesce: 'none' });
|
|
421
448
|
}
|
|
@@ -618,9 +645,12 @@ export class FactoryLoop {
|
|
|
618
645
|
const batch = events.splice(0, LIVE_EVENT_DRAIN_BATCH_SIZE);
|
|
619
646
|
const paths = [];
|
|
620
647
|
for (const event of batch) {
|
|
621
|
-
const
|
|
622
|
-
if (
|
|
623
|
-
|
|
648
|
+
const prepared = await this.#prepareLiveEventForDrain(event, seenIssueKeys);
|
|
649
|
+
if (prepared.dispatchRelayflow) {
|
|
650
|
+
void this.#dispatchRelayflowEvent(event);
|
|
651
|
+
}
|
|
652
|
+
if (prepared.path) {
|
|
653
|
+
paths.push(prepared.path);
|
|
624
654
|
}
|
|
625
655
|
}
|
|
626
656
|
await Promise.all(paths.map((path) => this.#handlePreparedLiveChange(path)));
|
|
@@ -652,14 +682,18 @@ export class FactoryLoop {
|
|
|
652
682
|
return;
|
|
653
683
|
await this.#refreshLiveHeartbeat();
|
|
654
684
|
}
|
|
685
|
+
#subscriptionGlobs(factoryGlobs) {
|
|
686
|
+
return this.#relayflows ? [LIVE_RELAYFLOW_GLOB] : factoryGlobs;
|
|
687
|
+
}
|
|
655
688
|
async #prepareLiveEventForDrain(event, seenIssueKeys) {
|
|
656
689
|
const path = changeEventPath(event);
|
|
657
690
|
if (!path) {
|
|
658
|
-
return
|
|
691
|
+
return { dispatchRelayflow: false };
|
|
659
692
|
}
|
|
660
693
|
const isPullPath = isGithubPullFilePath(path);
|
|
661
|
-
|
|
662
|
-
|
|
694
|
+
const isFactoryPath = isIssueFilePath(path) || isGithubIssueFilePath(path) || isPullPath;
|
|
695
|
+
if (!isFactoryPath && !this.#relayflows) {
|
|
696
|
+
return { dispatchRelayflow: false };
|
|
663
697
|
}
|
|
664
698
|
if (isBeforeLiveCutoff(event.occurredAt, this.#liveConnectStartedAtMs, this.#liveReplaySkewMarginMs)) {
|
|
665
699
|
this.#increment('liveReplayEventsSuppressed');
|
|
@@ -671,7 +705,7 @@ export class FactoryLoop {
|
|
|
671
705
|
connectStartedAt: new Date(this.#liveConnectStartedAtMs).toISOString(),
|
|
672
706
|
replaySkewMarginMs: this.#liveReplaySkewMarginMs,
|
|
673
707
|
});
|
|
674
|
-
return
|
|
708
|
+
return { dispatchRelayflow: false };
|
|
675
709
|
}
|
|
676
710
|
if (isAtOrBeforeHighWatermark(event.id, this.#liveEventHighWatermark)) {
|
|
677
711
|
this.#increment('liveReplayEventsSuppressed');
|
|
@@ -681,7 +715,7 @@ export class FactoryLoop {
|
|
|
681
715
|
highWatermark: this.#liveEventHighWatermark,
|
|
682
716
|
path,
|
|
683
717
|
});
|
|
684
|
-
return
|
|
718
|
+
return { dispatchRelayflow: false };
|
|
685
719
|
}
|
|
686
720
|
const dedupeKey = liveEventDedupeKey(event);
|
|
687
721
|
if (dedupeKey) {
|
|
@@ -691,7 +725,7 @@ export class FactoryLoop {
|
|
|
691
725
|
id: event.id,
|
|
692
726
|
path,
|
|
693
727
|
});
|
|
694
|
-
return
|
|
728
|
+
return { dispatchRelayflow: false };
|
|
695
729
|
}
|
|
696
730
|
rememberLiveEvent(this.#seenLiveEvents, dedupeKey);
|
|
697
731
|
}
|
|
@@ -699,6 +733,9 @@ export class FactoryLoop {
|
|
|
699
733
|
this.#increment('liveEventsMissingIdentity');
|
|
700
734
|
this.#logger.warn?.('[factory] live issue event missing stable identity', { path });
|
|
701
735
|
}
|
|
736
|
+
if (!isFactoryPath) {
|
|
737
|
+
return { dispatchRelayflow: true };
|
|
738
|
+
}
|
|
702
739
|
if (isGithubIssueFilePath(path)) {
|
|
703
740
|
const sourceKey = `github:${path}`;
|
|
704
741
|
if (seenIssueKeys.has(sourceKey)) {
|
|
@@ -707,11 +744,11 @@ export class FactoryLoop {
|
|
|
707
744
|
id: event.id,
|
|
708
745
|
path,
|
|
709
746
|
});
|
|
710
|
-
return
|
|
747
|
+
return { dispatchRelayflow: false };
|
|
711
748
|
}
|
|
712
749
|
seenIssueKeys.add(sourceKey);
|
|
713
750
|
this.#recordArrivalLatency(event);
|
|
714
|
-
return path;
|
|
751
|
+
return { path, dispatchRelayflow: true };
|
|
715
752
|
}
|
|
716
753
|
if (isPullPath) {
|
|
717
754
|
// Dedupe PR change events by path within a drain; the babysitter routing
|
|
@@ -719,11 +756,11 @@ export class FactoryLoop {
|
|
|
719
756
|
const sourceKey = `pull:${path}`;
|
|
720
757
|
if (seenIssueKeys.has(sourceKey)) {
|
|
721
758
|
this.#increment('liveDuplicatePrEventsSuppressed');
|
|
722
|
-
return
|
|
759
|
+
return { dispatchRelayflow: false };
|
|
723
760
|
}
|
|
724
761
|
seenIssueKeys.add(sourceKey);
|
|
725
762
|
this.#recordArrivalLatency(event);
|
|
726
|
-
return path;
|
|
763
|
+
return { path, dispatchRelayflow: true };
|
|
727
764
|
}
|
|
728
765
|
const issueKey = keyFromPath(path);
|
|
729
766
|
if (seenIssueKeys.has(issueKey)) {
|
|
@@ -733,7 +770,7 @@ export class FactoryLoop {
|
|
|
733
770
|
path,
|
|
734
771
|
issue: issueKey,
|
|
735
772
|
});
|
|
736
|
-
return
|
|
773
|
+
return { dispatchRelayflow: false };
|
|
737
774
|
}
|
|
738
775
|
const issueRef = { key: issueKey, uuid: uuidFromPath(path) ?? issueKey, path };
|
|
739
776
|
const batch = await this.#batch();
|
|
@@ -744,11 +781,40 @@ export class FactoryLoop {
|
|
|
744
781
|
path,
|
|
745
782
|
issue: issueKey,
|
|
746
783
|
});
|
|
747
|
-
return
|
|
784
|
+
return { dispatchRelayflow: false };
|
|
748
785
|
}
|
|
749
786
|
seenIssueKeys.add(issueKey);
|
|
750
787
|
this.#recordArrivalLatency(event);
|
|
751
|
-
return path;
|
|
788
|
+
return { path, dispatchRelayflow: true };
|
|
789
|
+
}
|
|
790
|
+
async #dispatchRelayflowEvent(event) {
|
|
791
|
+
const relayflows = this.#relayflows;
|
|
792
|
+
if (!relayflows)
|
|
793
|
+
return;
|
|
794
|
+
try {
|
|
795
|
+
const result = await dispatchRelayflowForChangeEvent(event, relayflows.registry, {
|
|
796
|
+
cwd: relayflows.cwd ?? process.cwd(),
|
|
797
|
+
mountRoot: relayflows.mountRoot ?? this.#integrationsMountRoot(),
|
|
798
|
+
workflowRunner: relayflows.workflowRunner,
|
|
799
|
+
});
|
|
800
|
+
if (!result)
|
|
801
|
+
return;
|
|
802
|
+
this.#increment('relayflowEventsDispatched');
|
|
803
|
+
this.#logger.info?.('[factory] relayflow dispatched for integration event', {
|
|
804
|
+
trigger: result.trigger,
|
|
805
|
+
templatePath: result.templatePath,
|
|
806
|
+
runId: result.runId,
|
|
807
|
+
status: result.status,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
this.#increment('relayflowDispatchErrors');
|
|
812
|
+
this.#logger.warn?.('[factory] relayflow dispatch failed for integration event', {
|
|
813
|
+
eventId: event.id,
|
|
814
|
+
path: changeEventPath(event),
|
|
815
|
+
error,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
752
818
|
}
|
|
753
819
|
async #handlePreparedLiveChange(path) {
|
|
754
820
|
if (isGithubPullFilePath(path)) {
|
|
@@ -888,7 +954,13 @@ export class FactoryLoop {
|
|
|
888
954
|
this.#logger.info?.('[factory] run-once started', { dryRun });
|
|
889
955
|
let report;
|
|
890
956
|
try {
|
|
891
|
-
await this.#
|
|
957
|
+
const issueSource = await this.#issueSource();
|
|
958
|
+
if (issueSource === 'linear') {
|
|
959
|
+
await this.#ingestGithubIssues({ dryRun });
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
await this.#ensureGithubIngestionReady();
|
|
963
|
+
}
|
|
892
964
|
const paths = await this.#readyIssuePaths();
|
|
893
965
|
const pulled = [];
|
|
894
966
|
const triaged = [];
|
|
@@ -900,7 +972,7 @@ export class FactoryLoop {
|
|
|
900
972
|
const issue = await this.#readIssue(path);
|
|
901
973
|
readyIssueReads += 1;
|
|
902
974
|
lastReadyReadProgressAtMs = this.#logTimedProgress('[factory] Linear ready issue read progress', startedAtMs, lastReadyReadProgressAtMs, { read: readyIssueReads, total: paths.length, path });
|
|
903
|
-
if (issue) {
|
|
975
|
+
if (issue && issueSource === 'linear') {
|
|
904
976
|
await this.#recordCanonicalIssueState(issue);
|
|
905
977
|
}
|
|
906
978
|
if (!issue) {
|
|
@@ -917,7 +989,7 @@ export class FactoryLoop {
|
|
|
917
989
|
skipped.push({ issue: issueRef(issue), reason: 'already tracked' });
|
|
918
990
|
continue;
|
|
919
991
|
}
|
|
920
|
-
if (!this.#
|
|
992
|
+
if (!this.#isIssueReady(issue)) {
|
|
921
993
|
skipped.push({ issue: issueRef(issue), reason: 'live state is not ready-for-agent' });
|
|
922
994
|
continue;
|
|
923
995
|
}
|
|
@@ -925,7 +997,7 @@ export class FactoryLoop {
|
|
|
925
997
|
skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
|
|
926
998
|
continue;
|
|
927
999
|
}
|
|
928
|
-
if (!
|
|
1000
|
+
if (!isDispatchableIssue(issue)) {
|
|
929
1001
|
skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
|
|
930
1002
|
continue;
|
|
931
1003
|
}
|
|
@@ -1153,14 +1225,14 @@ export class FactoryLoop {
|
|
|
1153
1225
|
this.#error(error, decision.issue);
|
|
1154
1226
|
throw error;
|
|
1155
1227
|
}
|
|
1156
|
-
if (!
|
|
1228
|
+
if (!isDispatchableIssue(liveIssue)) {
|
|
1157
1229
|
const error = new Error(`Refusing to dispatch ${decision.issue.key}: not reconciled real Linear issue`);
|
|
1158
1230
|
this.#error(error, decision.issue);
|
|
1159
1231
|
throw error;
|
|
1160
1232
|
}
|
|
1161
1233
|
const escalationReason = triageEscalationReason(decision);
|
|
1162
1234
|
if (escalationReason) {
|
|
1163
|
-
const replayedResult = await this.#
|
|
1235
|
+
const replayedResult = await this.#escalateTriage(decision, escalationReason, dryRun);
|
|
1164
1236
|
this.#recordTriageEscalation(decision, escalationReason);
|
|
1165
1237
|
return replayedResult ?? { issue: decision.issue, agents: [], dryRun };
|
|
1166
1238
|
}
|
|
@@ -1179,7 +1251,7 @@ export class FactoryLoop {
|
|
|
1179
1251
|
const signature = labelDispatchFailureSignature(labelDispatch);
|
|
1180
1252
|
if (this.#labelDispatchFailures.get(decision.issue.key) !== signature) {
|
|
1181
1253
|
try {
|
|
1182
|
-
await this.#
|
|
1254
|
+
await this.#postIssueComment(liveIssue, comment);
|
|
1183
1255
|
// Record only after a successful post so a failed writeback retries
|
|
1184
1256
|
// next cycle rather than being suppressed as already-notified.
|
|
1185
1257
|
this.#labelDispatchFailures.set(decision.issue.key, signature);
|
|
@@ -1228,17 +1300,22 @@ export class FactoryLoop {
|
|
|
1228
1300
|
let implementingStateId;
|
|
1229
1301
|
if (!dryRun) {
|
|
1230
1302
|
const issue = await this.#readIssue(dispatchDecision.issue.path);
|
|
1231
|
-
if (!issue || !this.#
|
|
1303
|
+
if (!issue || !this.#isIssueReady(issue)) {
|
|
1232
1304
|
throw new Error(`Live state changed before writeback for ${dispatchDecision.issue.key}`);
|
|
1233
1305
|
}
|
|
1234
|
-
implementingStateId = this.#states.idFor(issue.team, 'agentImplementing');
|
|
1235
1306
|
try {
|
|
1236
|
-
await this.#
|
|
1307
|
+
await this.#postIssueComment(issue, comment);
|
|
1237
1308
|
}
|
|
1238
1309
|
catch (error) {
|
|
1239
1310
|
this.#logger.warn?.('[factory] comment writeback skipped', error);
|
|
1240
1311
|
}
|
|
1241
|
-
|
|
1312
|
+
if (isGithubIssue(issue)) {
|
|
1313
|
+
await this.#githubWriteback.setStatus(issue, 'in-progress');
|
|
1314
|
+
}
|
|
1315
|
+
else {
|
|
1316
|
+
implementingStateId = this.#states.idFor(issue.team, 'agentImplementing');
|
|
1317
|
+
await this.#linear.setState(issue, implementingStateId);
|
|
1318
|
+
}
|
|
1242
1319
|
this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
|
|
1243
1320
|
}
|
|
1244
1321
|
const result = {
|
|
@@ -1256,6 +1333,7 @@ export class FactoryLoop {
|
|
|
1256
1333
|
await this.#sendImplementerTask(record);
|
|
1257
1334
|
await this.#sendCriticalReviewerMessage(record);
|
|
1258
1335
|
await this.#injectPendingSlackClarification(record);
|
|
1336
|
+
await this.#injectPendingGithubClarification(record);
|
|
1259
1337
|
}
|
|
1260
1338
|
return result;
|
|
1261
1339
|
}
|
|
@@ -1320,24 +1398,24 @@ export class FactoryLoop {
|
|
|
1320
1398
|
}
|
|
1321
1399
|
}
|
|
1322
1400
|
async #handleChange(path, opts = {}) {
|
|
1323
|
-
if (!isIssueFilePath(path)) {
|
|
1401
|
+
if (!isIssueFilePath(path) && !isGithubIssueFilePath(path)) {
|
|
1324
1402
|
return;
|
|
1325
1403
|
}
|
|
1326
1404
|
try {
|
|
1327
1405
|
const issue = await this.#readIssue(path);
|
|
1328
|
-
if (issue) {
|
|
1406
|
+
if (issue && !isGithubIssue(issue)) {
|
|
1329
1407
|
await this.#recordCanonicalIssueState(issue);
|
|
1330
1408
|
}
|
|
1331
|
-
if (!issue || !this.#
|
|
1409
|
+
if (!issue || !this.#isIssueReady(issue)) {
|
|
1332
1410
|
return;
|
|
1333
1411
|
}
|
|
1334
|
-
if (opts.requireRealIssue && !
|
|
1412
|
+
if (opts.requireRealIssue && !isDispatchableIssue(issue)) {
|
|
1335
1413
|
return;
|
|
1336
1414
|
}
|
|
1337
1415
|
if (!isInFactoryScope(issue, this.#config.safety)) {
|
|
1338
1416
|
return;
|
|
1339
1417
|
}
|
|
1340
|
-
if (!
|
|
1418
|
+
if (!isDispatchableIssue(issue)) {
|
|
1341
1419
|
return;
|
|
1342
1420
|
}
|
|
1343
1421
|
const batch = await this.#batch();
|
|
@@ -1350,7 +1428,7 @@ export class FactoryLoop {
|
|
|
1350
1428
|
const decision = await this.triageIssue(issue);
|
|
1351
1429
|
const escalationReason = triageEscalationReason(decision);
|
|
1352
1430
|
if (escalationReason) {
|
|
1353
|
-
await this.#
|
|
1431
|
+
await this.#escalateTriage(decision, escalationReason, this.#config.dryRun);
|
|
1354
1432
|
this.#recordTriageEscalation(decision, escalationReason);
|
|
1355
1433
|
return;
|
|
1356
1434
|
}
|
|
@@ -1367,6 +1445,42 @@ export class FactoryLoop {
|
|
|
1367
1445
|
this.#logger.error?.('[factory] failed to handle issue change', error);
|
|
1368
1446
|
}
|
|
1369
1447
|
}
|
|
1448
|
+
async #issueSource() {
|
|
1449
|
+
if (this.#resolvedIssueSource) {
|
|
1450
|
+
return this.#resolvedIssueSource;
|
|
1451
|
+
}
|
|
1452
|
+
if (this.#config.issueSource) {
|
|
1453
|
+
this.#resolvedIssueSource = this.#config.issueSource;
|
|
1454
|
+
return this.#resolvedIssueSource;
|
|
1455
|
+
}
|
|
1456
|
+
const linearReady = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
|
|
1457
|
+
this.#resolvedIssueSource = linearReady === 'ready' ? 'linear' : 'github';
|
|
1458
|
+
if (this.#resolvedIssueSource === 'github') {
|
|
1459
|
+
this.#logger.info?.('[factory] Linear issue source is not connected; using GitHub issues');
|
|
1460
|
+
}
|
|
1461
|
+
return this.#resolvedIssueSource;
|
|
1462
|
+
}
|
|
1463
|
+
#isIssueReady(issue) {
|
|
1464
|
+
if (!isGithubIssue(issue)) {
|
|
1465
|
+
return this.#states.isRole(issue.stateId, 'readyForAgent');
|
|
1466
|
+
}
|
|
1467
|
+
const githubState = (issue.state?.name ?? stringValue(wrappedPayload(issue.raw).state) ?? '').trim().toLowerCase();
|
|
1468
|
+
if (githubState === 'closed') {
|
|
1469
|
+
return false;
|
|
1470
|
+
}
|
|
1471
|
+
if (issue.labels.some((label) => GITHUB_LIFECYCLE_LABELS.has(label.trim().toLowerCase()))) {
|
|
1472
|
+
return false;
|
|
1473
|
+
}
|
|
1474
|
+
const required = this.#config.safety.requireLabel.trim().toLowerCase();
|
|
1475
|
+
return Boolean(required) && issue.labels.some((label) => label.trim().toLowerCase() === required);
|
|
1476
|
+
}
|
|
1477
|
+
async #postIssueComment(issue, body) {
|
|
1478
|
+
if (isGithubIssue(issue)) {
|
|
1479
|
+
await this.#githubWriteback.postComment(issue, body);
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
await this.#linear.postComment(issue, body);
|
|
1483
|
+
}
|
|
1370
1484
|
// Probes the GitHub issue sub-root at most once and caches the verdict so
|
|
1371
1485
|
// repeated iterations skip listTree calls when the mount is absent.
|
|
1372
1486
|
async #ensureGithubIngestionReady() {
|
|
@@ -1481,6 +1595,12 @@ export class FactoryLoop {
|
|
|
1481
1595
|
if (!ghIssue) {
|
|
1482
1596
|
return;
|
|
1483
1597
|
}
|
|
1598
|
+
if (await this.#issueSource() === 'github') {
|
|
1599
|
+
if (!githubIssueIsClosed(ghIssue) && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
|
|
1600
|
+
await this.#handleChange(ghIssue.path);
|
|
1601
|
+
}
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1484
1604
|
if (githubIssueIsClosed(ghIssue)) {
|
|
1485
1605
|
const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
|
|
1486
1606
|
if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
|
|
@@ -1497,7 +1617,7 @@ export class FactoryLoop {
|
|
|
1497
1617
|
}
|
|
1498
1618
|
return;
|
|
1499
1619
|
}
|
|
1500
|
-
if (!githubIssueHasFactoryLabel(ghIssue)) {
|
|
1620
|
+
if (!githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
|
|
1501
1621
|
return;
|
|
1502
1622
|
}
|
|
1503
1623
|
const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
|
|
@@ -1775,6 +1895,9 @@ export class FactoryLoop {
|
|
|
1775
1895
|
}
|
|
1776
1896
|
}
|
|
1777
1897
|
async #readyIssuePaths() {
|
|
1898
|
+
if (await this.#issueSource() === 'github') {
|
|
1899
|
+
return this.#githubIssuePaths();
|
|
1900
|
+
}
|
|
1778
1901
|
const pathsByKey = new Map();
|
|
1779
1902
|
const canonicalPathsByKey = new Map();
|
|
1780
1903
|
for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'Linear ready issue canonical discovery')) {
|
|
@@ -1800,6 +1923,10 @@ export class FactoryLoop {
|
|
|
1800
1923
|
}
|
|
1801
1924
|
async #readIssue(path) {
|
|
1802
1925
|
try {
|
|
1926
|
+
if (isGithubIssueFilePath(path)) {
|
|
1927
|
+
const githubIssue = await this.#readGithubIssue(path);
|
|
1928
|
+
return githubIssue ? githubIssueAsFactoryIssue(githubIssue) : undefined;
|
|
1929
|
+
}
|
|
1803
1930
|
// Newly-synced issues land as a change-event STUB at the primary
|
|
1804
1931
|
// /linear/issues/<key>__<uuid>.json path (no state/url/team); the full
|
|
1805
1932
|
// record lands at the by-id / by-uuid aliases. Read the canonical sibling
|
|
@@ -2291,15 +2418,11 @@ export class FactoryLoop {
|
|
|
2291
2418
|
issue: record.issue.key,
|
|
2292
2419
|
});
|
|
2293
2420
|
}
|
|
2294
|
-
await this.#
|
|
2421
|
+
await this.#postAgentQuestion(record, question);
|
|
2295
2422
|
}
|
|
2296
|
-
async #
|
|
2423
|
+
async #postAgentQuestion(record, question) {
|
|
2297
2424
|
if (!this.#slack || !this.#config.slack) {
|
|
2298
|
-
this.#
|
|
2299
|
-
this.#logger.warn?.('[factory] agent question has no Slack channel configured', {
|
|
2300
|
-
issue: record.issue,
|
|
2301
|
-
from: question.agentName,
|
|
2302
|
-
});
|
|
2425
|
+
await this.#postAgentQuestionToGithub(record, question);
|
|
2303
2426
|
return;
|
|
2304
2427
|
}
|
|
2305
2428
|
if (await this.#shouldSkipSlackWriteback('agent-question')) {
|
|
@@ -2332,6 +2455,401 @@ export class FactoryLoop {
|
|
|
2332
2455
|
this.#logger.warn?.(`[factory] failed to post agent question for ${record.issue.key}`, error);
|
|
2333
2456
|
}
|
|
2334
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
|
+
}
|
|
2335
2853
|
#recordArrivalLatency(event) {
|
|
2336
2854
|
const occurredAt = Date.parse(event.occurredAt);
|
|
2337
2855
|
if (!Number.isFinite(occurredAt))
|
|
@@ -2525,9 +3043,15 @@ export class FactoryLoop {
|
|
|
2525
3043
|
}
|
|
2526
3044
|
this.#postMergeDoneAdvances.add(advanceKey);
|
|
2527
3045
|
try {
|
|
2528
|
-
const
|
|
2529
|
-
|
|
2530
|
-
|
|
3046
|
+
const githubIssue = isGithubIssue(issue);
|
|
3047
|
+
if (githubIssue) {
|
|
3048
|
+
await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.`);
|
|
3049
|
+
}
|
|
3050
|
+
else {
|
|
3051
|
+
const doneStateId = this.#states.idFor(issue.team, 'done');
|
|
3052
|
+
await this.#linear.setState(issue, doneStateId);
|
|
3053
|
+
await this.#recordCanonicalIssueState({ key: issue.key, stateId: doneStateId });
|
|
3054
|
+
}
|
|
2531
3055
|
this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
|
|
2532
3056
|
this.#increment('mergedPrAdvancedDone');
|
|
2533
3057
|
this.#increment('done');
|
|
@@ -2540,11 +3064,12 @@ export class FactoryLoop {
|
|
|
2540
3064
|
try {
|
|
2541
3065
|
const channel = await this.#slackChannelDir();
|
|
2542
3066
|
if (channel) {
|
|
3067
|
+
const systemOfRecord = githubIssue ? 'GitHub issue closed' : 'Linear state set to Done';
|
|
2543
3068
|
const root = await this.#slack.postThread({
|
|
2544
3069
|
channel,
|
|
2545
|
-
text: `${issue.key}: PR merged;
|
|
3070
|
+
text: `${issue.key}: PR merged; ${systemOfRecord}.`,
|
|
2546
3071
|
});
|
|
2547
|
-
await this.#slack.reply(root.threadId, `${issue.key}:
|
|
3072
|
+
await this.#slack.reply(root.threadId, `${issue.key}: ${systemOfRecord}.`);
|
|
2548
3073
|
this.#recordSlackWritebackSuccess('merge-done-thread');
|
|
2549
3074
|
}
|
|
2550
3075
|
}
|
|
@@ -2571,15 +3096,19 @@ export class FactoryLoop {
|
|
|
2571
3096
|
// This no-record path runs after agents are released, so there is no
|
|
2572
3097
|
// tracked PR identity left. Keep the scan simple and prefer branch identity
|
|
2573
3098
|
// over title/body references to avoid "related to AR-N" body false positives.
|
|
2574
|
-
|
|
2575
|
-
|
|
3099
|
+
const githubSource = await this.#issueSource() === 'github';
|
|
3100
|
+
const paths = githubSource ? await this.#githubIssuePaths() : await this.#mount.listTree(ISSUE_ROOT);
|
|
3101
|
+
for (const path of paths) {
|
|
3102
|
+
if (githubSource ? !isGithubIssueFilePath(path) : !isIssueFilePath(path)) {
|
|
2576
3103
|
continue;
|
|
2577
3104
|
}
|
|
2578
3105
|
const issue = await this.#readIssue(path);
|
|
2579
|
-
if (!issue ||
|
|
3106
|
+
if (!issue || (githubSource
|
|
3107
|
+
? issue.state?.name?.trim().toLowerCase() === 'closed'
|
|
3108
|
+
: !isUpstreamState(issue.stateId))) {
|
|
2580
3109
|
continue;
|
|
2581
3110
|
}
|
|
2582
|
-
if (!
|
|
3111
|
+
if (!isDispatchableIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) {
|
|
2583
3112
|
continue;
|
|
2584
3113
|
}
|
|
2585
3114
|
const score = prSnapshotIssueMatchScore(snapshot, issue.key);
|
|
@@ -2587,7 +3116,7 @@ export class FactoryLoop {
|
|
|
2587
3116
|
best = { issue, score };
|
|
2588
3117
|
}
|
|
2589
3118
|
}
|
|
2590
|
-
this.#logger.debug?.(
|
|
3119
|
+
this.#logger.debug?.(`[factory] scanned ${githubSource ? 'GitHub' : 'Linear'} issues for merged PR advance`, {
|
|
2591
3120
|
prNumber: snapshot.number,
|
|
2592
3121
|
durationMs: this.#clock.now() - scanStartedAtMs,
|
|
2593
3122
|
matchedIssue: best?.issue.key,
|
|
@@ -2752,6 +3281,28 @@ export class FactoryLoop {
|
|
|
2752
3281
|
}
|
|
2753
3282
|
return found;
|
|
2754
3283
|
}
|
|
3284
|
+
async #githubPrObservedMerged(record, issue) {
|
|
3285
|
+
const babysatSnapshot = await this.#readBabysatPrSnapshot(record);
|
|
3286
|
+
if (babysatSnapshot && prMetaShowsMerged(babysatSnapshot)) {
|
|
3287
|
+
return true;
|
|
3288
|
+
}
|
|
3289
|
+
const pr = this.#babysitterPr.get(record.issue.key) ?? await this.#completionPrForIssue(issue);
|
|
3290
|
+
if (!pr) {
|
|
3291
|
+
return false;
|
|
3292
|
+
}
|
|
3293
|
+
for (const path of await this.#pullMetaPathsFor(pr.repo, pr.prNumber)) {
|
|
3294
|
+
try {
|
|
3295
|
+
const snapshot = parsePullSnapshot((await this.#mount.readFile(path)).content, pr.prNumber);
|
|
3296
|
+
if (snapshot && prMetaShowsMerged(snapshot)) {
|
|
3297
|
+
return true;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
catch {
|
|
3301
|
+
// A concurrent mount refresh may remove an alternate path; keep scanning.
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
return false;
|
|
3305
|
+
}
|
|
2755
3306
|
async #completeIssue(record, opts = {}) {
|
|
2756
3307
|
const completionKey = issueKey(record.issue);
|
|
2757
3308
|
if (this.#completionInFlight.has(completionKey)) {
|
|
@@ -2766,16 +3317,44 @@ export class FactoryLoop {
|
|
|
2766
3317
|
// (the legacy terminal) so the issue never gets stuck on an unconfigured
|
|
2767
3318
|
// state.
|
|
2768
3319
|
const issueTeam = issue?.team;
|
|
2769
|
-
const
|
|
3320
|
+
const githubIssue = issue ? isGithubIssue(issue) : false;
|
|
3321
|
+
const configuredHumanReview = opts.targetState !== 'done' &&
|
|
2770
3322
|
this.#config.terminalState === 'human-review' &&
|
|
2771
|
-
this.#states.hasHumanReview(issueTeam);
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
3323
|
+
(githubIssue || this.#states.hasHumanReview(issueTeam));
|
|
3324
|
+
let githubMerged = githubIssue && opts.completionReason === 'pr-merged';
|
|
3325
|
+
if (issue && githubIssue && !configuredHumanReview && !githubMerged) {
|
|
3326
|
+
githubMerged = await this.#githubPrObservedMerged(record, issue);
|
|
3327
|
+
if (!githubMerged && opts.runMergeGate !== false) {
|
|
3328
|
+
const mergeCommandAccepted = await this.#runCompletionMergeGate(issue);
|
|
3329
|
+
// A successful merge command can mean queued/auto-merge rather than
|
|
3330
|
+
// merged. Only mounted PR state or a merged webhook may prove merge.
|
|
3331
|
+
if (mergeCommandAccepted) {
|
|
3332
|
+
githubMerged = await this.#githubPrObservedMerged(record, issue);
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
// A GitHub issue only closes after its PR merges. If a configured done
|
|
3337
|
+
// path cannot merge (including mergePolicy: never), park it for a human
|
|
3338
|
+
// instead of closing the issue while its PR remains open.
|
|
3339
|
+
const humanReview = configuredHumanReview || (githubIssue && !githubMerged);
|
|
2775
3340
|
const statusLabel = humanReview ? 'In Human Review' : 'Done';
|
|
2776
3341
|
if (issue) {
|
|
2777
|
-
|
|
2778
|
-
|
|
3342
|
+
if (githubIssue) {
|
|
3343
|
+
if (humanReview) {
|
|
3344
|
+
await this.#githubWriteback.setStatus(issue, 'human-review');
|
|
3345
|
+
await this.#githubWriteback.postComment(issue, `Factory agents completed; this issue is awaiting human review. The pull request remains open.\n\nMerge policy: ${this.#config.mergePolicy}`);
|
|
3346
|
+
}
|
|
3347
|
+
else {
|
|
3348
|
+
await this.#githubWriteback.closeIssue(issue, 'Factory observed the linked pull request merge and completed this issue.');
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
else {
|
|
3352
|
+
const targetState = humanReview
|
|
3353
|
+
? this.#states.idFor(issueTeam, 'humanReview')
|
|
3354
|
+
: this.#states.idFor(issueTeam, 'done');
|
|
3355
|
+
await this.#linear.setState(issue, targetState);
|
|
3356
|
+
await this.#recordCanonicalIssueState({ key: issue.key, stateId: targetState });
|
|
3357
|
+
}
|
|
2779
3358
|
this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
|
|
2780
3359
|
}
|
|
2781
3360
|
if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('completion-thread')) {
|
|
@@ -2783,14 +3362,15 @@ export class FactoryLoop {
|
|
|
2783
3362
|
const channel = await this.#slackChannelDir();
|
|
2784
3363
|
if (channel) {
|
|
2785
3364
|
const merged = opts.completionReason === 'pr-merged';
|
|
3365
|
+
const systemOfRecord = githubIssue ? 'GitHub status' : 'Linear state';
|
|
2786
3366
|
const completionText = merged
|
|
2787
|
-
? `${record.issue.key}: PR merged;
|
|
3367
|
+
? `${record.issue.key}: PR merged; ${systemOfRecord} set to ${statusLabel}.`
|
|
2788
3368
|
: `${record.issue.key}: factory agents completed${humanReview ? '; awaiting human review' : ''}.\nStatus: ${statusLabel}\nMerge policy: ${this.#config.mergePolicy}`;
|
|
2789
3369
|
const stateText = merged
|
|
2790
|
-
? `${record.issue.key}:
|
|
3370
|
+
? `${record.issue.key}: ${systemOfRecord} set to ${statusLabel}.`
|
|
2791
3371
|
: humanReview
|
|
2792
|
-
? `${record.issue.key}: awaiting human review;
|
|
2793
|
-
: `${record.issue.key}:
|
|
3372
|
+
? `${record.issue.key}: awaiting human review; ${systemOfRecord} set to ${statusLabel}.`
|
|
3373
|
+
: `${record.issue.key}: ${systemOfRecord} set to ${statusLabel}.`;
|
|
2794
3374
|
const root = await this.#slack.postThread({
|
|
2795
3375
|
channel,
|
|
2796
3376
|
text: completionText,
|
|
@@ -2806,13 +3386,14 @@ export class FactoryLoop {
|
|
|
2806
3386
|
// Only auto-merge on the `done` terminal path. Human Review parks the PR
|
|
2807
3387
|
// for an operator — the merge gate (which requires an APPROVED review)
|
|
2808
3388
|
// would refuse anyway, and we must not merge before the human has looked.
|
|
2809
|
-
if (issue && !humanReview && opts.runMergeGate !== false) {
|
|
3389
|
+
if (issue && !githubIssue && !humanReview && opts.runMergeGate !== false) {
|
|
2810
3390
|
await this.#runCompletionMergeGate(issue);
|
|
2811
3391
|
}
|
|
2812
3392
|
await this.#releaseAndTerminateAgents([...record.agents], humanReview ? 'issue-human-review' : 'issue-done', 'completion');
|
|
2813
3393
|
this.#increment(humanReview ? 'humanReview' : 'done');
|
|
2814
3394
|
this.#emit('issue-done', { issue: record.issue });
|
|
2815
3395
|
await this.#stopSlackWatcher(record.issue);
|
|
3396
|
+
await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
|
|
2816
3397
|
await this.#recordDispatchTerminal(record.issue);
|
|
2817
3398
|
const next = (await this.#batch()).complete(record.issue);
|
|
2818
3399
|
if (next) {
|
|
@@ -2841,6 +3422,22 @@ export class FactoryLoop {
|
|
|
2841
3422
|
this.#logger.error?.('[factory] error', error);
|
|
2842
3423
|
this.#emit('error', { error, ...describeError(error), issue });
|
|
2843
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
|
+
}
|
|
2844
3441
|
#increment(name) {
|
|
2845
3442
|
this.#counters[name] = (this.#counters[name] ?? 0) + 1;
|
|
2846
3443
|
}
|
|
@@ -3064,16 +3661,12 @@ export class FactoryLoop {
|
|
|
3064
3661
|
await this.#watchSlackThread(record, root.threadId);
|
|
3065
3662
|
this.#recordSlackWritebackSuccess('dispatch-thread');
|
|
3066
3663
|
}
|
|
3067
|
-
async #
|
|
3664
|
+
async #escalateTriage(decision, reason, dryRun) {
|
|
3068
3665
|
if (dryRun) {
|
|
3069
3666
|
return;
|
|
3070
3667
|
}
|
|
3071
3668
|
if (!this.#slack || !this.#config.slack) {
|
|
3072
|
-
this.#
|
|
3073
|
-
issue: decision.issue,
|
|
3074
|
-
reason,
|
|
3075
|
-
});
|
|
3076
|
-
return;
|
|
3669
|
+
return await this.#escalateTriageToGithub(decision, reason);
|
|
3077
3670
|
}
|
|
3078
3671
|
if (await this.#shouldSkipSlackWriteback('triage-escalation')) {
|
|
3079
3672
|
return;
|
|
@@ -3111,6 +3704,42 @@ export class FactoryLoop {
|
|
|
3111
3704
|
this.#slackWatcherStarts.delete(key);
|
|
3112
3705
|
}
|
|
3113
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
|
+
}
|
|
3114
3743
|
async #postAndWatchSlackEscalationThread(decision, reason) {
|
|
3115
3744
|
if (!this.#slack || !this.#config.slack) {
|
|
3116
3745
|
return;
|
|
@@ -3373,11 +4002,11 @@ export class FactoryLoop {
|
|
|
3373
4002
|
}
|
|
3374
4003
|
async #handleTriageEscalationSlackAnswer(record, text) {
|
|
3375
4004
|
const issue = await this.#readIssue(record.issue.path);
|
|
3376
|
-
if (!issue || !isInFactoryScope(issue, this.#config.safety) || !
|
|
4005
|
+
if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
|
|
3377
4006
|
this.#increment('slackTriageAnswersIgnoredIssueUnavailable');
|
|
3378
4007
|
return;
|
|
3379
4008
|
}
|
|
3380
|
-
if (!this.#
|
|
4009
|
+
if (!this.#isIssueReady(issue)) {
|
|
3381
4010
|
this.#increment('slackTriageAnswersIgnoredIssueNotReady');
|
|
3382
4011
|
return;
|
|
3383
4012
|
}
|
|
@@ -3452,6 +4081,33 @@ export class FactoryLoop {
|
|
|
3452
4081
|
}
|
|
3453
4082
|
this.#pendingSlackClarifications.delete(key);
|
|
3454
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
|
+
}
|
|
3455
4111
|
// Inject the human's Slack reply into the agent framed as the
|
|
3456
4112
|
// <integration-event> the spawn prompt tells it to expect (not an ambiguous
|
|
3457
4113
|
// "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
|
|
@@ -3544,27 +4200,27 @@ export class FactoryLoop {
|
|
|
3544
4200
|
async #runCompletionMergeGate(issue) {
|
|
3545
4201
|
if (this.#isSyntheticProbeIssue(issue)) {
|
|
3546
4202
|
await this.#closeSyntheticProbeIfPresent(issue);
|
|
3547
|
-
return;
|
|
4203
|
+
return false;
|
|
3548
4204
|
}
|
|
3549
|
-
if (!
|
|
4205
|
+
if (!isDispatchableIssue(issue)) {
|
|
3550
4206
|
this.#logger.warn?.('[factory] merge gate skipped non-real Linear issue', { issue: issue.key });
|
|
3551
4207
|
this.#increment('mergeGateSkippedNonReal');
|
|
3552
|
-
return;
|
|
4208
|
+
return false;
|
|
3553
4209
|
}
|
|
3554
4210
|
if (this.#config.mergePolicy !== 'on-green-with-review') {
|
|
3555
|
-
return;
|
|
4211
|
+
return false;
|
|
3556
4212
|
}
|
|
3557
4213
|
const pr = await this.#probePrResolver(issue);
|
|
3558
4214
|
if (!pr) {
|
|
3559
4215
|
this.#logger.warn?.('[factory] merge gate found no PR for real issue', { issue: issue.key });
|
|
3560
4216
|
this.#increment('mergeGateMissingPr');
|
|
3561
|
-
return;
|
|
4217
|
+
return false;
|
|
3562
4218
|
}
|
|
3563
4219
|
const ready = await this.#waitForMergeReady(pr);
|
|
3564
4220
|
const headSha = ready?.live.headRefOid;
|
|
3565
4221
|
if (!ready || !headSha) {
|
|
3566
4222
|
this.#increment('mergeGateNotMerged');
|
|
3567
|
-
return;
|
|
4223
|
+
return false;
|
|
3568
4224
|
}
|
|
3569
4225
|
const result = await this.#mergeGate.merge({
|
|
3570
4226
|
repo: pr.repo,
|
|
@@ -3580,7 +4236,7 @@ export class FactoryLoop {
|
|
|
3580
4236
|
reason: result.reason,
|
|
3581
4237
|
});
|
|
3582
4238
|
this.#increment('mergeGateMergeAborted');
|
|
3583
|
-
return;
|
|
4239
|
+
return false;
|
|
3584
4240
|
}
|
|
3585
4241
|
this.#logger.info?.('[factory] merge gate merged PR', {
|
|
3586
4242
|
issue: issue.key,
|
|
@@ -3589,6 +4245,7 @@ export class FactoryLoop {
|
|
|
3589
4245
|
headSha,
|
|
3590
4246
|
});
|
|
3591
4247
|
this.#increment('mergeGateMerged');
|
|
4248
|
+
return true;
|
|
3592
4249
|
}
|
|
3593
4250
|
async #closeSyntheticProbeIfPresent(issue) {
|
|
3594
4251
|
const probe = this.#customProbePrResolver
|
|
@@ -3728,10 +4385,54 @@ export function parseGithubIssue(path, content) {
|
|
|
3728
4385
|
labels: Array.isArray(payload.labels)
|
|
3729
4386
|
? payload.labels.map(labelName).filter((label) => Boolean(label))
|
|
3730
4387
|
: [],
|
|
4388
|
+
author: githubAuthorLogin(payload),
|
|
3731
4389
|
path,
|
|
3732
4390
|
raw: asRecord(parsed) ?? payload,
|
|
3733
4391
|
};
|
|
3734
4392
|
}
|
|
4393
|
+
export function parseGithubFactoryIssue(path, content) {
|
|
4394
|
+
return githubIssueAsFactoryIssue(parseGithubIssue(path, content));
|
|
4395
|
+
}
|
|
4396
|
+
const githubIssueAsFactoryIssue = (issue) => {
|
|
4397
|
+
const wrapper = asRecord(issue.raw) ?? {};
|
|
4398
|
+
const payload = wrappedPayload(wrapper);
|
|
4399
|
+
const objectId = stringValue(wrapper.objectId);
|
|
4400
|
+
const stableId = `${issue.repo}#${objectId ?? issue.number}`;
|
|
4401
|
+
return {
|
|
4402
|
+
uuid: stableId,
|
|
4403
|
+
key: String(issue.number),
|
|
4404
|
+
title: issue.title,
|
|
4405
|
+
description: issue.body,
|
|
4406
|
+
stateId: '',
|
|
4407
|
+
state: issue.state ? { name: issue.state } : undefined,
|
|
4408
|
+
labels: issue.labels,
|
|
4409
|
+
path: issue.path,
|
|
4410
|
+
raw: {
|
|
4411
|
+
...wrapper,
|
|
4412
|
+
provider: 'github',
|
|
4413
|
+
objectType: 'issue',
|
|
4414
|
+
objectId: stableId,
|
|
4415
|
+
payload: {
|
|
4416
|
+
...payload,
|
|
4417
|
+
id: stableId,
|
|
4418
|
+
number: issue.number,
|
|
4419
|
+
title: issue.title,
|
|
4420
|
+
description: issue.body,
|
|
4421
|
+
url: issue.url,
|
|
4422
|
+
source: {
|
|
4423
|
+
provider: 'github',
|
|
4424
|
+
id: stableId,
|
|
4425
|
+
owner: issue.owner,
|
|
4426
|
+
repo: issue.repoName,
|
|
4427
|
+
number: issue.number,
|
|
4428
|
+
url: issue.url,
|
|
4429
|
+
path: issue.path,
|
|
4430
|
+
...(issue.author ? { author: issue.author, reporter: issue.author } : {}),
|
|
4431
|
+
},
|
|
4432
|
+
},
|
|
4433
|
+
},
|
|
4434
|
+
};
|
|
4435
|
+
};
|
|
3735
4436
|
export async function readFactoryLoopHeartbeat(path = DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH) {
|
|
3736
4437
|
try {
|
|
3737
4438
|
return parseJsonContent(await readFile(path, 'utf8'));
|
|
@@ -3764,6 +4465,49 @@ export function isRealLinearIssue(issue) {
|
|
|
3764
4465
|
typeof payload.url === 'string' &&
|
|
3765
4466
|
payload.url.length > 0;
|
|
3766
4467
|
}
|
|
4468
|
+
export function isDispatchableIssue(issue) {
|
|
4469
|
+
if (isRealLinearIssue(issue)) {
|
|
4470
|
+
return true;
|
|
4471
|
+
}
|
|
4472
|
+
if (!isGithubIssue(issue)) {
|
|
4473
|
+
return false;
|
|
4474
|
+
}
|
|
4475
|
+
const payload = wrappedPayload(issue.raw);
|
|
4476
|
+
const source = asRecord(payload.source);
|
|
4477
|
+
const sourceId = stringValue(source?.id);
|
|
4478
|
+
const number = numberValue(source?.number) ?? numberValue(payload.number);
|
|
4479
|
+
const url = stringValue(source?.url) ?? stringValue(payload.url);
|
|
4480
|
+
const hasStableIdentity = Boolean(sourceId?.trim()) || (Number.isInteger(number) && (number ?? 0) > 0);
|
|
4481
|
+
return Boolean(issue.uuid.trim() && issue.key.trim() && hasStableIdentity && url?.trim());
|
|
4482
|
+
}
|
|
4483
|
+
const isGithubIssue = (issue) => {
|
|
4484
|
+
const wrapper = asRecord(issue.raw);
|
|
4485
|
+
const source = asRecord(wrappedPayload(issue.raw).source);
|
|
4486
|
+
return stringValue(source?.provider)?.toLowerCase() === 'github' &&
|
|
4487
|
+
(stringValue(wrapper?.provider)?.toLowerCase() === 'github' || isGithubIssueFilePath(issue.path));
|
|
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
|
+
};
|
|
3767
4511
|
const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
|
|
3768
4512
|
const pidsFromSpawnResult = (result) => {
|
|
3769
4513
|
const pids = new Set();
|
|
@@ -3943,7 +4687,10 @@ function githubUrlCandidates(value) {
|
|
|
3943
4687
|
return [...candidates];
|
|
3944
4688
|
}
|
|
3945
4689
|
function labelRoutesForIssue(issue, config) {
|
|
3946
|
-
const
|
|
4690
|
+
const githubReadinessLabel = isGithubIssue(issue) ? config.safety.requireLabel.trim().toLowerCase() : undefined;
|
|
4691
|
+
const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label) &&
|
|
4692
|
+
label.toLowerCase() !== githubReadinessLabel &&
|
|
4693
|
+
(!isGithubIssue(issue) || !GITHUB_LIFECYCLE_LABELS.has(label.toLowerCase())));
|
|
3947
4694
|
const routes = [];
|
|
3948
4695
|
const offendingLabels = [];
|
|
3949
4696
|
const seenRepos = new Set();
|
|
@@ -4134,6 +4881,73 @@ export const githubIssuePathParts = (path) => {
|
|
|
4134
4881
|
slug: match[7],
|
|
4135
4882
|
};
|
|
4136
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
|
+
});
|
|
4137
4951
|
const githubIssueReadCandidatePaths = (path) => {
|
|
4138
4952
|
if (path.endsWith('/')) {
|
|
4139
4953
|
return [`${path}meta.json`, `${path}metadata.json`];
|
|
@@ -4171,10 +4985,11 @@ const githubIssueDirectoryPathParts = (path) => {
|
|
|
4171
4985
|
slug: match[6],
|
|
4172
4986
|
};
|
|
4173
4987
|
};
|
|
4174
|
-
const githubIssueHasFactoryLabel = (issue) => issue.labels.some((label) => label.toLowerCase() ===
|
|
4988
|
+
const githubIssueHasFactoryLabel = (issue, requiredLabel = GITHUB_FACTORY_LABEL) => issue.labels.some((label) => label.trim().toLowerCase() === requiredLabel.trim().toLowerCase());
|
|
4175
4989
|
const githubIssueIsClosed = (issue) => issue.state === 'closed';
|
|
4176
4990
|
const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => {
|
|
4177
4991
|
const teamId = config.linear.teamIds[config.safety.requireTeamKey];
|
|
4992
|
+
const reporter = issue.author;
|
|
4178
4993
|
return {
|
|
4179
4994
|
id: githubIssueMirrorId(issue),
|
|
4180
4995
|
title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(),
|
|
@@ -4190,6 +5005,7 @@ const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId
|
|
|
4190
5005
|
number: issue.number,
|
|
4191
5006
|
url: issue.url,
|
|
4192
5007
|
path: issue.path,
|
|
5008
|
+
...(reporter ? { author: reporter, reporter } : {}),
|
|
4193
5009
|
},
|
|
4194
5010
|
};
|
|
4195
5011
|
};
|
|
@@ -4464,6 +5280,15 @@ const uuidFromPath = (path) => path.split('__')[1]?.replace(/\.json$/, '');
|
|
|
4464
5280
|
const stringValue = (value) => typeof value === 'string' ? value : undefined;
|
|
4465
5281
|
const booleanValue = (value) => typeof value === 'boolean' ? value : undefined;
|
|
4466
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
|
+
};
|
|
4467
5292
|
const liveHeartbeatIntervalMs = (staleMs) => Math.min(DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS, Math.max(500, Math.floor(staleMs / 4)));
|
|
4468
5293
|
const installFactoryDraftPredicate = (mount, config) => {
|
|
4469
5294
|
mount.setDefaultAllowedDraftPredicate?.((path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, config));
|
|
@@ -4826,6 +5651,15 @@ const dispatchAfterSlackClarification = (decision, escalationReason) => ({
|
|
|
4826
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.`,
|
|
4827
5652
|
].filter(Boolean).join(' '),
|
|
4828
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
|
+
});
|
|
4829
5663
|
const issueWithSlackClarification = (issue, text) => ({
|
|
4830
5664
|
...issue,
|
|
4831
5665
|
description: [
|
|
@@ -4835,11 +5669,21 @@ const issueWithSlackClarification = (issue, text) => ({
|
|
|
4835
5669
|
text,
|
|
4836
5670
|
].join('\n'),
|
|
4837
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
|
+
});
|
|
4838
5681
|
const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
|
|
4839
5682
|
// The human's Slack-thread reply, framed as an <integration-event> the agent is
|
|
4840
5683
|
// told (at spawn) to expect — a recognizable injected event, not an ambiguous
|
|
4841
5684
|
// keystroke. Trailing CR submits it to the agent's PTY.
|
|
4842
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`;
|
|
4843
5687
|
const isFactoryQuestionTarget = (target) => {
|
|
4844
5688
|
const normalized = target.trim().replace(/^@/u, '').toLowerCase();
|
|
4845
5689
|
return normalized === 'broker' || normalized === 'factory';
|