@agent-relay/factory 0.1.38 → 0.1.39
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 +66 -5
- package/dist/cli/fleet.d.ts +6 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +35 -0
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +52 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +9 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/dispatch/templates.d.ts +2 -0
- package/dist/dispatch/templates.d.ts.map +1 -1
- package/dist/dispatch/templates.js +7 -0
- package/dist/dispatch/templates.js.map +1 -1
- package/dist/dispatch/test-guidance.d.ts +19 -0
- package/dist/dispatch/test-guidance.d.ts.map +1 -0
- package/dist/dispatch/test-guidance.js +180 -0
- package/dist/dispatch/test-guidance.js.map +1 -0
- package/dist/featuremap/check.d.ts +19 -0
- package/dist/featuremap/check.d.ts.map +1 -0
- package/dist/featuremap/check.js +81 -0
- package/dist/featuremap/check.js.map +1 -0
- package/dist/featuremap/index.d.ts +5 -0
- package/dist/featuremap/index.d.ts.map +1 -0
- package/dist/featuremap/index.js +3 -0
- package/dist/featuremap/index.js.map +1 -0
- package/dist/featuremap/validate.d.ts +55 -0
- package/dist/featuremap/validate.d.ts.map +1 -0
- package/dist/featuremap/validate.js +205 -0
- package/dist/featuremap/validate.js.map +1 -0
- package/dist/github/standalone-babysitter.d.ts +1 -0
- package/dist/github/standalone-babysitter.d.ts.map +1 -1
- package/dist/github/standalone-babysitter.js +16 -1
- package/dist/github/standalone-babysitter.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
- package/dist/mount/relayfile-github-connection-write.js +4 -2
- package/dist/mount/relayfile-github-connection-write.js.map +1 -1
- package/dist/orchestrator/batch-tracker.d.ts +24 -2
- package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
- package/dist/orchestrator/batch-tracker.js +53 -4
- package/dist/orchestrator/batch-tracker.js.map +1 -1
- package/dist/orchestrator/coalesced-task-queue.d.ts +19 -0
- package/dist/orchestrator/coalesced-task-queue.d.ts.map +1 -0
- package/dist/orchestrator/coalesced-task-queue.js +85 -0
- package/dist/orchestrator/coalesced-task-queue.js.map +1 -0
- package/dist/orchestrator/dependencies.d.ts +21 -0
- package/dist/orchestrator/dependencies.d.ts.map +1 -0
- package/dist/orchestrator/dependencies.js +70 -0
- package/dist/orchestrator/dependencies.js.map +1 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +878 -137
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -0
- package/dist/orchestrator/index.d.ts.map +1 -1
- package/dist/orchestrator/index.js +1 -0
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/ports/mount.d.ts +2 -0
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +66 -4
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/ports/writeback.d.ts +3 -0
- package/dist/ports/writeback.d.ts.map +1 -1
- package/dist/state/file-state-store.d.ts +17 -3
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +191 -2
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/in-memory-state-store.d.ts +14 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +99 -0
- package/dist/state/in-memory-state-store.js.map +1 -1
- package/dist/subscriptions/index.d.ts +2 -0
- package/dist/subscriptions/index.d.ts.map +1 -1
- package/dist/subscriptions/index.js +1 -0
- package/dist/subscriptions/index.js.map +1 -1
- package/dist/subscriptions/slack-filter.d.ts +32 -0
- package/dist/subscriptions/slack-filter.d.ts.map +1 -0
- package/dist/subscriptions/slack-filter.js +62 -0
- package/dist/subscriptions/slack-filter.js.map +1 -0
- package/dist/subscriptions/specs.d.ts +2 -0
- package/dist/subscriptions/specs.d.ts.map +1 -1
- package/dist/subscriptions/specs.js +11 -0
- package/dist/subscriptions/specs.js.map +1 -1
- package/dist/triage/schema.d.ts +2 -2
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/github.d.ts +4 -0
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +95 -0
- package/dist/writeback/github.js.map +1 -1
- package/package.json +8 -1
|
@@ -13,11 +13,15 @@ import { isInFactoryScope } from '../safety/factory-scope.js';
|
|
|
13
13
|
import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
|
|
14
14
|
import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
|
|
15
15
|
import { parseGithubHumanInputRequest, renderAgentTask, } from '../dispatch/templates.js';
|
|
16
|
+
import { resolveTestGuidance } from '../dispatch/test-guidance.js';
|
|
16
17
|
import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
|
|
17
18
|
import { agentNameForRole, sanitizeAgentSlug } from '../triage/agent-names.js';
|
|
18
19
|
import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
|
|
20
|
+
import { parseSlackThreadReply, slackThreadReplyGlob } from '../subscriptions/slack-filter.js';
|
|
19
21
|
import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
|
|
20
|
-
import { issueKey } from './batch-tracker.js';
|
|
22
|
+
import { issueKey, } from './batch-tracker.js';
|
|
23
|
+
import { dependencyIdentity, findDependencyCycle, parseBlockedBy, resolveDependency, } from './dependencies.js';
|
|
24
|
+
import { CoalescedTaskQueue } from './coalesced-task-queue.js';
|
|
21
25
|
import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
|
|
22
26
|
import { readFactoryInFlightRegistry, terminatePids } from './reaper.js';
|
|
23
27
|
import { createFactoryCloudEventV1, factoryCloudReleaseReasonV1, } from '../observability/events.js';
|
|
@@ -87,6 +91,9 @@ const DISPATCH_LIFECYCLE_RENEW_MS = 60_000;
|
|
|
87
91
|
const DISPATCH_LIFECYCLE_RETRY_MS = 1_000;
|
|
88
92
|
const STARTUP_AGENT_EXIT_DRAIN_TIMEOUT_MS = 30_000;
|
|
89
93
|
const SLACK_EVENT_WATERMARK_CACHE_MS = 60_000;
|
|
94
|
+
const SLACK_CONVERSATION_TURN_LEASE_MS = 60_000;
|
|
95
|
+
const SLACK_CONVERSATION_TURN_RETRY_MS = 1_000;
|
|
96
|
+
const SLACK_REPLY_ROUTE_RETRY_MS = 1_000;
|
|
90
97
|
const MERGE_GATE_MAX_ATTEMPTS = 12;
|
|
91
98
|
const MERGE_GATE_POLL_DELAY_MS = 10_000;
|
|
92
99
|
const MAX_LABEL_IMPLEMENTERS = 4;
|
|
@@ -116,7 +123,9 @@ export class FactoryLoop {
|
|
|
116
123
|
#fleet;
|
|
117
124
|
#triage;
|
|
118
125
|
#linear;
|
|
126
|
+
#github;
|
|
119
127
|
#githubWriteback;
|
|
128
|
+
#githubWritebackProvided;
|
|
120
129
|
#slack;
|
|
121
130
|
#mergeGate;
|
|
122
131
|
#probeCloser;
|
|
@@ -147,6 +156,8 @@ export class FactoryLoop {
|
|
|
147
156
|
#dispatchInFlight = new Map();
|
|
148
157
|
#slackWatchers = new Map();
|
|
149
158
|
#slackWatcherStarts = new Map();
|
|
159
|
+
#slackConversationTurns;
|
|
160
|
+
#slackConversationOwner = `${process.pid}:${randomUUID()}`;
|
|
150
161
|
#githubIssueCommentWatchers = new Map();
|
|
151
162
|
#githubIssueCommentWatchStates = new Map();
|
|
152
163
|
#githubIssueCommentQueues = new Map();
|
|
@@ -168,6 +179,13 @@ export class FactoryLoop {
|
|
|
168
179
|
// issue (or the comment writeback's own change event) does not re-post the
|
|
169
180
|
// same notice every cycle. Cleared once the issue dispatches successfully.
|
|
170
181
|
#labelDispatchFailures = new Map();
|
|
182
|
+
// Provider snapshots loaded during discovery, keyed by collision-safe
|
|
183
|
+
// owner/repo#number identity. GitHub-native records outrank Linear mirrors.
|
|
184
|
+
#dependencyIssues = new Map();
|
|
185
|
+
#terminalDependencyIdentities = new Set();
|
|
186
|
+
#dependencyParkNotices = new Map();
|
|
187
|
+
#dependencyGithubPathsByIdentity;
|
|
188
|
+
#dependencyLinearTreeLoaded = false;
|
|
171
189
|
#pendingSlackClarifications = new Map();
|
|
172
190
|
#pendingGithubClarifications = new Map();
|
|
173
191
|
#clarificationIntents = new Map();
|
|
@@ -270,9 +288,10 @@ export class FactoryLoop {
|
|
|
270
288
|
this.#linear = ports.linear ?? MountLinearWriteback(ports.mount, {
|
|
271
289
|
safety: config.safety,
|
|
272
290
|
});
|
|
291
|
+
this.#githubWritebackProvided = Boolean(ports.githubWriteback);
|
|
273
292
|
this.#githubWriteback = ports.githubWriteback ?? new GhCliGithubWriteback();
|
|
274
293
|
this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
|
|
275
|
-
|
|
294
|
+
this.#github = ports.github ?? MountGithubRead(ports.mount);
|
|
276
295
|
this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
|
|
277
296
|
this.#probeCloser = ports.probeCloser ?? closeProbePr;
|
|
278
297
|
this.#customProbePrResolver = Boolean(ports.probePrResolver);
|
|
@@ -304,6 +323,19 @@ export class FactoryLoop {
|
|
|
304
323
|
this.#batchView = batch;
|
|
305
324
|
return batch;
|
|
306
325
|
});
|
|
326
|
+
this.#slackConversationTurns = new CoalescedTaskQueue({
|
|
327
|
+
delayMs: config.slack?.conversationCoalesceMs ?? 750,
|
|
328
|
+
run: async (conversationId) => this.#resumeSlackConversationTurn(conversationId),
|
|
329
|
+
onError: (error, conversationId) => {
|
|
330
|
+
this.#logger.warn?.('[factory] Slack conversation turn failed', {
|
|
331
|
+
conversationId,
|
|
332
|
+
error: describeError(error).errorMessage,
|
|
333
|
+
});
|
|
334
|
+
if (!this.#stopping) {
|
|
335
|
+
this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
});
|
|
307
339
|
this.#wireFleetEvents();
|
|
308
340
|
}
|
|
309
341
|
async #resolveIntegrationInstructions() {
|
|
@@ -626,6 +658,7 @@ export class FactoryLoop {
|
|
|
626
658
|
this.#offAgentMessage?.();
|
|
627
659
|
this.#offAgentLifecycleSignal?.();
|
|
628
660
|
await Promise.allSettled([...this.#agentLifecycleSignalsInFlight.values()]);
|
|
661
|
+
await this.#slackConversationTurns.stop();
|
|
629
662
|
this.#offAgentExit = undefined;
|
|
630
663
|
this.#offDeliveryFailed = undefined;
|
|
631
664
|
this.#offAgentMessage = undefined;
|
|
@@ -1270,6 +1303,13 @@ export class FactoryLoop {
|
|
|
1270
1303
|
this.#logger.info?.('[factory] run-once started', { dryRun });
|
|
1271
1304
|
let report;
|
|
1272
1305
|
try {
|
|
1306
|
+
this.#dependencyIssues.clear();
|
|
1307
|
+
// Terminal observations are only a live-cycle cache. Rebuild them from
|
|
1308
|
+
// current provider snapshots (or merged PR metadata) so a reopened issue
|
|
1309
|
+
// cannot remain permanently resolved after an earlier close event.
|
|
1310
|
+
this.#terminalDependencyIdentities.clear();
|
|
1311
|
+
this.#dependencyGithubPathsByIdentity = undefined;
|
|
1312
|
+
this.#dependencyLinearTreeLoaded = false;
|
|
1273
1313
|
const issueSource = await this.#issueSource();
|
|
1274
1314
|
if (issueSource === 'linear') {
|
|
1275
1315
|
await this.#ingestGithubIssues({ dryRun });
|
|
@@ -1392,7 +1432,12 @@ export class FactoryLoop {
|
|
|
1392
1432
|
continue;
|
|
1393
1433
|
}
|
|
1394
1434
|
if (result.agents.length === 0 && !dryRun) {
|
|
1395
|
-
|
|
1435
|
+
const reason = result.hold?.kind === 'dependency-cycle'
|
|
1436
|
+
? `dependency cycle detected: ${result.hold.cycle?.join(' -> ') ?? 'unknown cycle'}`
|
|
1437
|
+
: result.hold?.kind === 'dependency'
|
|
1438
|
+
? `parked on dependencies: ${result.hold.blockers?.join(', ') ?? 'unresolved dependency'}`
|
|
1439
|
+
: 'queued or escalated';
|
|
1440
|
+
skipped.push({ issue: decision.issue, reason });
|
|
1396
1441
|
}
|
|
1397
1442
|
else {
|
|
1398
1443
|
dispatched.push(result);
|
|
@@ -2004,6 +2049,26 @@ export class FactoryLoop {
|
|
|
2004
2049
|
this.#recordTriageEscalation(dispatchDecision, escalationReason);
|
|
2005
2050
|
return replayedResult ?? { issue: dispatchDecision.issue, agents: [], dryRun };
|
|
2006
2051
|
}
|
|
2052
|
+
const dependencyAdmission = await this.#dependencyAdmission(liveIssue, dispatchDecision);
|
|
2053
|
+
if (dependencyAdmission.blockers.length > 0 || dependencyAdmission.cycle) {
|
|
2054
|
+
batch.queue(dispatchDecision, dryRun, dependencyAdmission);
|
|
2055
|
+
const parked = batch.getParked(dispatchDecision.issue);
|
|
2056
|
+
if (!parked)
|
|
2057
|
+
throw new Error(`dependency admission failed to park ${dispatchDecision.issue.key}`);
|
|
2058
|
+
const comment = await this.#reportDependencyPark(liveIssue, parked, dryRun);
|
|
2059
|
+
return {
|
|
2060
|
+
issue: dispatchDecision.issue,
|
|
2061
|
+
agents: [],
|
|
2062
|
+
comments: [comment],
|
|
2063
|
+
dryRun,
|
|
2064
|
+
hold: {
|
|
2065
|
+
kind: parked.cycle ? 'dependency-cycle' : 'dependency',
|
|
2066
|
+
blockers: parked.blockers.map((blocker) => blocker.label),
|
|
2067
|
+
cycle: parked.cycle ? [...parked.cycle] : undefined,
|
|
2068
|
+
},
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
this.#clearDependencyPark(batch, dispatchDecision.issue);
|
|
2007
2072
|
// Full task rendering is part of the durable spawn specification. It must
|
|
2008
2073
|
// happen before a remote lifecycle is first claimed so takeover cannot
|
|
2009
2074
|
// recover a persisted minimal triage task after a crash in this gap.
|
|
@@ -2031,7 +2096,12 @@ export class FactoryLoop {
|
|
|
2031
2096
|
this.#scheduleDispatchLifecycleRetry(queuedRecord);
|
|
2032
2097
|
this.#increment('queued');
|
|
2033
2098
|
this.#emit('issue-queued', { issue: dispatchDecision.issue });
|
|
2034
|
-
return lifecycleClaim.lifecycle.result ?? {
|
|
2099
|
+
return lifecycleClaim.lifecycle.result ?? {
|
|
2100
|
+
issue: dispatchDecision.issue,
|
|
2101
|
+
agents: [],
|
|
2102
|
+
dryRun,
|
|
2103
|
+
hold: { kind: 'capacity' },
|
|
2104
|
+
};
|
|
2035
2105
|
}
|
|
2036
2106
|
if (!lifecycleClaim.created) {
|
|
2037
2107
|
const restored = batch.restore(inFlightRecordFromLifecycle(lifecycleClaim.lifecycle));
|
|
@@ -2042,12 +2112,12 @@ export class FactoryLoop {
|
|
|
2042
2112
|
if (!durableDispatch)
|
|
2043
2113
|
this.#consumePendingDispatchClarifications(dispatchDecision.issue);
|
|
2044
2114
|
await this.#recordDispatchAttempt(dispatchDecision.issue);
|
|
2045
|
-
const record = batch.start(dispatchDecision, dryRun);
|
|
2115
|
+
const record = batch.start(dispatchDecision, dryRun, dependencyAdmission);
|
|
2046
2116
|
if (!record) {
|
|
2047
2117
|
await this.#clearDispatchInFlight(dispatchDecision.issue);
|
|
2048
2118
|
this.#increment('queued');
|
|
2049
2119
|
this.#emit('issue-queued', { issue: dispatchDecision.issue });
|
|
2050
|
-
return { issue: dispatchDecision.issue, agents: [], dryRun };
|
|
2120
|
+
return { issue: dispatchDecision.issue, agents: [], dryRun, hold: { kind: 'capacity' } };
|
|
2051
2121
|
}
|
|
2052
2122
|
if (record.result) {
|
|
2053
2123
|
return record.result;
|
|
@@ -2175,6 +2245,12 @@ export class FactoryLoop {
|
|
|
2175
2245
|
return {
|
|
2176
2246
|
inFlight: batch?.inFlight.map((record) => record.issue) ?? [],
|
|
2177
2247
|
queued: batch?.queued.map((queued) => queued.issue) ?? [],
|
|
2248
|
+
parked: batch?.parked.map((parked) => ({
|
|
2249
|
+
issue: parked.issue,
|
|
2250
|
+
blockers: parked.blockers.map((blocker) => blocker.label),
|
|
2251
|
+
cycle: parked.cycle ? [...parked.cycle] : undefined,
|
|
2252
|
+
capacityBlocked: parked.capacityBlocked,
|
|
2253
|
+
})) ?? [],
|
|
2178
2254
|
counters: { ...this.#counters },
|
|
2179
2255
|
slackDegraded: this.#slackDegraded,
|
|
2180
2256
|
slackDegradedReason: this.#slackDegradedReason,
|
|
@@ -2637,6 +2713,19 @@ export class FactoryLoop {
|
|
|
2637
2713
|
acquiredNow = true;
|
|
2638
2714
|
}
|
|
2639
2715
|
if (lifecycle.phase === 'queued') {
|
|
2716
|
+
const liveIssue = await this.#readIssue(lifecycle.issue.path);
|
|
2717
|
+
if (liveIssue) {
|
|
2718
|
+
const dependencyAdmission = await this.#dependencyAdmission(liveIssue, lifecycle.decision);
|
|
2719
|
+
const batch = await this.#batch();
|
|
2720
|
+
if (dependencyAdmission.blockers.length > 0 || dependencyAdmission.cycle) {
|
|
2721
|
+
batch.queue(lifecycle.decision, lifecycle.dryRun, dependencyAdmission);
|
|
2722
|
+
const parked = batch.getParked(lifecycle.issue);
|
|
2723
|
+
if (parked)
|
|
2724
|
+
await this.#reportDependencyPark(liveIssue, parked, lifecycle.dryRun);
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
this.#clearDependencyPark(batch, lifecycle.issue);
|
|
2728
|
+
}
|
|
2640
2729
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
2641
2730
|
if (epoch === undefined || !await this.#state.promoteDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now())) {
|
|
2642
2731
|
throw new DispatchLifecycleCapacityError(`durable dispatch ${lifecycle.issue.key} is waiting for batch capacity`);
|
|
@@ -2954,6 +3043,9 @@ export class FactoryLoop {
|
|
|
2954
3043
|
if (issue && !isGithubIssue(issue)) {
|
|
2955
3044
|
await this.#recordCanonicalIssueState(issue);
|
|
2956
3045
|
}
|
|
3046
|
+
if (issue && this.#dependencyIssueIsTerminal(issue)) {
|
|
3047
|
+
await this.#markDependencyTerminalAndReconcile(issue);
|
|
3048
|
+
}
|
|
2957
3049
|
if (!issue || !this.#isIssueReady(issue)) {
|
|
2958
3050
|
return;
|
|
2959
3051
|
}
|
|
@@ -2982,14 +3074,7 @@ export class FactoryLoop {
|
|
|
2982
3074
|
this.#recordTriageEscalation(escalationDecision, escalationReason);
|
|
2983
3075
|
return;
|
|
2984
3076
|
}
|
|
2985
|
-
|
|
2986
|
-
await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
|
|
2987
|
-
}
|
|
2988
|
-
else {
|
|
2989
|
-
if (batch.queue(escalationDecision, this.#config.dryRun)) {
|
|
2990
|
-
this.#emit('issue-queued', { issue: escalationDecision.issue });
|
|
2991
|
-
}
|
|
2992
|
-
}
|
|
3077
|
+
await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
|
|
2993
3078
|
}
|
|
2994
3079
|
catch (error) {
|
|
2995
3080
|
if (error instanceof LiveDispatchStateChangedError) {
|
|
@@ -3225,13 +3310,17 @@ export class FactoryLoop {
|
|
|
3225
3310
|
if (!ghIssue) {
|
|
3226
3311
|
return;
|
|
3227
3312
|
}
|
|
3313
|
+
const closed = githubIssueIsClosed(ghIssue);
|
|
3314
|
+
if (closed) {
|
|
3315
|
+
await this.#markDependencyTerminalAndReconcile(githubIssueAsFactoryIssue(ghIssue));
|
|
3316
|
+
}
|
|
3228
3317
|
if (await this.#issueSource() === 'github') {
|
|
3229
|
-
if (!
|
|
3318
|
+
if (!closed && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
|
|
3230
3319
|
await this.#handleChange(ghIssue.path);
|
|
3231
3320
|
}
|
|
3232
3321
|
return;
|
|
3233
3322
|
}
|
|
3234
|
-
if (
|
|
3323
|
+
if (closed) {
|
|
3235
3324
|
const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
|
|
3236
3325
|
if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
|
|
3237
3326
|
if (!opts.dryRun) {
|
|
@@ -3284,7 +3373,9 @@ export class FactoryLoop {
|
|
|
3284
3373
|
for (const candidatePath of candidatePaths) {
|
|
3285
3374
|
try {
|
|
3286
3375
|
const { content } = await this.#readRelayfileFile(candidatePath, 'GitHub issue ingestion');
|
|
3287
|
-
|
|
3376
|
+
const githubIssue = parseGithubIssue(candidatePath, content);
|
|
3377
|
+
this.#indexDependencyIssue(githubIssueAsFactoryIssue(githubIssue));
|
|
3378
|
+
return githubIssue;
|
|
3288
3379
|
}
|
|
3289
3380
|
catch (error) {
|
|
3290
3381
|
if (isMissingIssueFileError(error) && candidatePath !== candidatePaths.at(-1)) {
|
|
@@ -3373,6 +3464,248 @@ export class FactoryLoop {
|
|
|
3373
3464
|
});
|
|
3374
3465
|
return entry?.[0];
|
|
3375
3466
|
}
|
|
3467
|
+
#indexDependencyIssue(issue) {
|
|
3468
|
+
if (!issue)
|
|
3469
|
+
return;
|
|
3470
|
+
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
3471
|
+
const identity = dependencyIdentityForIssue(issue, repo);
|
|
3472
|
+
if (!identity)
|
|
3473
|
+
return;
|
|
3474
|
+
const rank = isGithubIssueFilePath(issue.path) ? 2 : 1;
|
|
3475
|
+
const existing = this.#dependencyIssues.get(identity);
|
|
3476
|
+
if (!existing || rank >= existing.rank) {
|
|
3477
|
+
this.#dependencyIssues.set(identity, { issue, rank });
|
|
3478
|
+
if (this.#dependencyIssueIsTerminal(issue)) {
|
|
3479
|
+
this.#terminalDependencyIdentities.add(identity);
|
|
3480
|
+
}
|
|
3481
|
+
else {
|
|
3482
|
+
this.#terminalDependencyIdentities.delete(identity);
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
async #dependencyAdmission(issue, decision) {
|
|
3487
|
+
const declared = parseBlockedBy(issue.description);
|
|
3488
|
+
if (declared.length === 0)
|
|
3489
|
+
return { blockers: [] };
|
|
3490
|
+
const currentRepo = dependencyRepoForIssue(issue, decision, this.#config);
|
|
3491
|
+
const resolved = declared
|
|
3492
|
+
.map((dependency) => resolveDependency(dependency, currentRepo))
|
|
3493
|
+
.filter((dependency) => Boolean(dependency));
|
|
3494
|
+
await this.#loadMissingDependencyIssues(resolved.map((dependency) => dependency.identity));
|
|
3495
|
+
const resolvedTerminal = new Set();
|
|
3496
|
+
for (const dependency of resolved) {
|
|
3497
|
+
if (await this.#dependencyIsTerminalOrMerged(dependency.identity)) {
|
|
3498
|
+
resolvedTerminal.add(dependency.identity);
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
const blockers = resolved
|
|
3502
|
+
.filter((dependency) => !resolvedTerminal.has(dependency.identity))
|
|
3503
|
+
.map((dependency) => {
|
|
3504
|
+
const blocker = this.#dependencyIssues.get(dependency.identity)?.issue;
|
|
3505
|
+
return {
|
|
3506
|
+
identity: dependency.identity,
|
|
3507
|
+
key: blocker ? issueKey(issueRef(blocker)) : dependency.identity,
|
|
3508
|
+
label: dependency.label,
|
|
3509
|
+
};
|
|
3510
|
+
});
|
|
3511
|
+
// A bare #N without a resolvable single repository is itself unresolved.
|
|
3512
|
+
// Keep it visible and fail closed instead of guessing among routes.
|
|
3513
|
+
for (const dependency of declared) {
|
|
3514
|
+
if (dependency.repo || currentRepo)
|
|
3515
|
+
continue;
|
|
3516
|
+
blockers.push({
|
|
3517
|
+
identity: `unresolved:${issueKey(decision.issue)}:${dependency.raw}`,
|
|
3518
|
+
key: `unresolved:${dependency.raw}`,
|
|
3519
|
+
label: `${dependency.raw} (repository unresolved)`,
|
|
3520
|
+
});
|
|
3521
|
+
}
|
|
3522
|
+
const currentIdentity = dependencyIdentityForIssue(issue, currentRepo);
|
|
3523
|
+
const cycle = currentIdentity
|
|
3524
|
+
? findDependencyCycle(currentIdentity, this.#dependencyGraph())
|
|
3525
|
+
: undefined;
|
|
3526
|
+
return { blockers, cycle };
|
|
3527
|
+
}
|
|
3528
|
+
async #loadMissingDependencyIssues(identities) {
|
|
3529
|
+
const pending = [...new Set(identities)];
|
|
3530
|
+
const expanded = new Set();
|
|
3531
|
+
while (pending.length > 0) {
|
|
3532
|
+
const identity = pending.shift();
|
|
3533
|
+
if (expanded.has(identity))
|
|
3534
|
+
continue;
|
|
3535
|
+
expanded.add(identity);
|
|
3536
|
+
if (!this.#dependencyIssues.has(identity)) {
|
|
3537
|
+
// Startup discovery may use the ready-only GitHub issue index. A
|
|
3538
|
+
// blocker is often closed, in progress, or deliberately unlabeled, so
|
|
3539
|
+
// probe its stable by-id aliases directly before consulting that
|
|
3540
|
+
// filtered discovery set.
|
|
3541
|
+
const separator = identity.lastIndexOf('#');
|
|
3542
|
+
const repoIdentity = identity.slice(0, separator);
|
|
3543
|
+
const number = Number(identity.slice(separator + 1));
|
|
3544
|
+
const configuredRepo = configuredGithubRepoParts(this.#config)
|
|
3545
|
+
.find(({ owner, repo }) => `${owner}/${repo}`.toLowerCase() === repoIdentity);
|
|
3546
|
+
if (configuredRepo && Number.isSafeInteger(number) && number > 0) {
|
|
3547
|
+
const { owner, repo } = configuredRepo;
|
|
3548
|
+
for (const path of [
|
|
3549
|
+
`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`,
|
|
3550
|
+
`${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/by-id/${number}.json`,
|
|
3551
|
+
]) {
|
|
3552
|
+
await this.#readIssue(path);
|
|
3553
|
+
if (this.#dependencyIssues.has(identity))
|
|
3554
|
+
break;
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
if (!this.#dependencyIssues.has(identity)) {
|
|
3559
|
+
if (!this.#dependencyGithubPathsByIdentity) {
|
|
3560
|
+
this.#dependencyGithubPathsByIdentity = new Map();
|
|
3561
|
+
for (const path of await this.#githubIssuePaths()) {
|
|
3562
|
+
const parts = githubIssuePathParts(path);
|
|
3563
|
+
if (parts) {
|
|
3564
|
+
this.#dependencyGithubPathsByIdentity.set(githubIssueIdentity(parts.owner, parts.repo, parts.number), path);
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
const githubPath = this.#dependencyGithubPathsByIdentity.get(identity);
|
|
3569
|
+
if (githubPath)
|
|
3570
|
+
await this.#readIssue(githubPath);
|
|
3571
|
+
}
|
|
3572
|
+
if (!this.#dependencyIssues.has(identity) && !this.#dependencyLinearTreeLoaded) {
|
|
3573
|
+
// Linear paths do not encode the routed repository identity, so load
|
|
3574
|
+
// the tree once and let #indexDependencyIssue build the composite map.
|
|
3575
|
+
// The pass-scoped flag also lets every later blocked issue reuse the
|
|
3576
|
+
// resulting dependency index instead of rescanning the full tree.
|
|
3577
|
+
this.#dependencyLinearTreeLoaded = true;
|
|
3578
|
+
for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'dependency blocker discovery')) {
|
|
3579
|
+
if (isIssueFilePath(path))
|
|
3580
|
+
await this.#readIssue(path);
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
const blocker = this.#dependencyIssues.get(identity)?.issue;
|
|
3584
|
+
if (!blocker)
|
|
3585
|
+
continue;
|
|
3586
|
+
const repo = identity.slice(0, identity.lastIndexOf('#'));
|
|
3587
|
+
for (const dependency of parseBlockedBy(blocker.description)) {
|
|
3588
|
+
const resolved = resolveDependency(dependency, repo);
|
|
3589
|
+
if (resolved && !expanded.has(resolved.identity))
|
|
3590
|
+
pending.push(resolved.identity);
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
#clearDependencyPark(batch, issue) {
|
|
3595
|
+
batch.clearPark(issue);
|
|
3596
|
+
this.#dependencyParkNotices.delete(issueKey(issue));
|
|
3597
|
+
}
|
|
3598
|
+
#dependencyGraph() {
|
|
3599
|
+
const graph = new Map();
|
|
3600
|
+
for (const [identity, { issue }] of this.#dependencyIssues) {
|
|
3601
|
+
if (this.#dependencyIdentityIsTerminal(identity)) {
|
|
3602
|
+
graph.set(identity, []);
|
|
3603
|
+
continue;
|
|
3604
|
+
}
|
|
3605
|
+
const repo = identity.slice(0, identity.lastIndexOf('#'));
|
|
3606
|
+
const dependencies = parseBlockedBy(issue.description)
|
|
3607
|
+
.map((dependency) => resolveDependency(dependency, repo))
|
|
3608
|
+
.filter((dependency) => Boolean(dependency))
|
|
3609
|
+
.filter((dependency) => !this.#dependencyIdentityIsTerminal(dependency.identity))
|
|
3610
|
+
.map((dependency) => dependency.identity);
|
|
3611
|
+
graph.set(identity, [...new Set(dependencies)]);
|
|
3612
|
+
}
|
|
3613
|
+
return graph;
|
|
3614
|
+
}
|
|
3615
|
+
#dependencyIdentityIsTerminal(identity) {
|
|
3616
|
+
if (this.#terminalDependencyIdentities.has(identity))
|
|
3617
|
+
return true;
|
|
3618
|
+
const issue = this.#dependencyIssues.get(identity)?.issue;
|
|
3619
|
+
return Boolean(issue && this.#dependencyIssueIsTerminal(issue));
|
|
3620
|
+
}
|
|
3621
|
+
async #dependencyIsTerminalOrMerged(identity) {
|
|
3622
|
+
if (this.#dependencyIdentityIsTerminal(identity))
|
|
3623
|
+
return true;
|
|
3624
|
+
const issue = this.#dependencyIssues.get(identity)?.issue;
|
|
3625
|
+
if (!issue)
|
|
3626
|
+
return false;
|
|
3627
|
+
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
3628
|
+
if (!repo)
|
|
3629
|
+
return false;
|
|
3630
|
+
const pullRequest = await resolveIssuePrFromMount(this.#mount, this.#config, issue, {
|
|
3631
|
+
allowLegacyGithubBranch: true,
|
|
3632
|
+
repo,
|
|
3633
|
+
});
|
|
3634
|
+
if (normalizePrState(pullRequest?.state) !== 'MERGED')
|
|
3635
|
+
return false;
|
|
3636
|
+
this.#terminalDependencyIdentities.add(identity);
|
|
3637
|
+
return true;
|
|
3638
|
+
}
|
|
3639
|
+
#dependencyIssueIsTerminal(issue) {
|
|
3640
|
+
return isGithubIssue(issue)
|
|
3641
|
+
? githubFactoryIssueIsClosed(issue)
|
|
3642
|
+
: this.#states.isRole(issue.stateId, 'done');
|
|
3643
|
+
}
|
|
3644
|
+
async #reportDependencyPark(issue, parked, dryRun) {
|
|
3645
|
+
const cycle = parked.cycle?.join(' -> ');
|
|
3646
|
+
const blockers = parked.blockers.map((blocker) => blocker.label);
|
|
3647
|
+
const signature = JSON.stringify({ blockers, cycle, capacityBlocked: parked.capacityBlocked });
|
|
3648
|
+
const marker = `<!-- factory-dependency-park:${stableHash(signature)} -->`;
|
|
3649
|
+
const comment = parked.cycle
|
|
3650
|
+
? [
|
|
3651
|
+
marker,
|
|
3652
|
+
'Factory refused dispatch because it detected a dependency cycle.',
|
|
3653
|
+
`Cycle: ${cycle}`,
|
|
3654
|
+
blockers.length > 0 ? `Unresolved blockers: ${blockers.join(', ')}` : undefined,
|
|
3655
|
+
].filter((line) => Boolean(line)).join('\n')
|
|
3656
|
+
: [
|
|
3657
|
+
marker,
|
|
3658
|
+
'Factory parked this issue because declared dependencies are unresolved.',
|
|
3659
|
+
`Blocked by: ${blockers.join(', ')}`,
|
|
3660
|
+
parked.capacityBlocked ? 'Capacity is also currently unavailable.' : undefined,
|
|
3661
|
+
].filter((line) => Boolean(line)).join('\n');
|
|
3662
|
+
const key = issueKey(parked.issue);
|
|
3663
|
+
if (dryRun || this.#dependencyParkNotices.get(key) === signature)
|
|
3664
|
+
return comment;
|
|
3665
|
+
this.#increment(parked.cycle ? 'dependencyCycles' : 'dependencyParks');
|
|
3666
|
+
if (parked.cycle) {
|
|
3667
|
+
this.#error(new Error(`Dependency cycle detected: ${cycle}`), parked.issue);
|
|
3668
|
+
}
|
|
3669
|
+
try {
|
|
3670
|
+
await this.#postIssueComment(issue, comment);
|
|
3671
|
+
this.#dependencyParkNotices.set(key, signature);
|
|
3672
|
+
}
|
|
3673
|
+
catch (error) {
|
|
3674
|
+
this.#logger.warn?.('[factory] dependency park writeback skipped', {
|
|
3675
|
+
issue: parked.issue.key,
|
|
3676
|
+
error: describeError(error).errorMessage,
|
|
3677
|
+
});
|
|
3678
|
+
}
|
|
3679
|
+
return comment;
|
|
3680
|
+
}
|
|
3681
|
+
async #markDependencyTerminalAndReconcile(issue) {
|
|
3682
|
+
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
3683
|
+
const identity = dependencyIdentityForIssue(issue, repo);
|
|
3684
|
+
if (!identity)
|
|
3685
|
+
return;
|
|
3686
|
+
this.#indexDependencyIssue(issue);
|
|
3687
|
+
// A merge event can prove terminality before the issue snapshot reflects
|
|
3688
|
+
// its new state, so apply the explicit observation after indexing it.
|
|
3689
|
+
this.#terminalDependencyIdentities.add(identity);
|
|
3690
|
+
const batch = await this.#batch();
|
|
3691
|
+
this.#clearDependencyPark(batch, issueRef(issue));
|
|
3692
|
+
const candidates = batch.parked.filter((parked) => parked.blockers.some((blocker) => blocker.identity === identity));
|
|
3693
|
+
for (const parked of candidates) {
|
|
3694
|
+
try {
|
|
3695
|
+
const liveIssue = await this.#readIssue(parked.issue.path);
|
|
3696
|
+
if (!liveIssue || !this.#isIssueReady(liveIssue))
|
|
3697
|
+
continue;
|
|
3698
|
+
await this.dispatch(parked.decision, { dryRun: parked.dryRun });
|
|
3699
|
+
}
|
|
3700
|
+
catch (error) {
|
|
3701
|
+
this.#logger.warn?.('[factory] dependency park reconciliation failed', {
|
|
3702
|
+
blocker: identity,
|
|
3703
|
+
issue: parked.issue.key,
|
|
3704
|
+
error: describeError(error).errorMessage,
|
|
3705
|
+
});
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3376
3709
|
async #dispatchBlockReason(issue) {
|
|
3377
3710
|
const key = issueStateKey(issue);
|
|
3378
3711
|
const state = await this.#state.getDispatchAttempts(this.#workspaceId, key);
|
|
@@ -3657,12 +3990,14 @@ export class FactoryLoop {
|
|
|
3657
3990
|
// (relayfile-adapters#205). The factory matches state by UUID, so backfill
|
|
3658
3991
|
// the id from the name when the payload omitted it — otherwise every issue
|
|
3659
3992
|
// reads as stateId='' and no role (incl. readyForAgent) ever matches.
|
|
3993
|
+
let resolvedIssue = issue;
|
|
3660
3994
|
if (issue && !issue.stateId && issue.state?.name) {
|
|
3661
3995
|
const backfilled = this.#states.idForName(issue.state.name, issue.team);
|
|
3662
3996
|
if (backfilled)
|
|
3663
|
-
|
|
3997
|
+
resolvedIssue = { ...issue, stateId: backfilled };
|
|
3664
3998
|
}
|
|
3665
|
-
|
|
3999
|
+
this.#indexDependencyIssue(resolvedIssue);
|
|
4000
|
+
return resolvedIssue;
|
|
3666
4001
|
}
|
|
3667
4002
|
catch (error) {
|
|
3668
4003
|
if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
|
|
@@ -4104,7 +4439,7 @@ export class FactoryLoop {
|
|
|
4104
4439
|
let publishedPr;
|
|
4105
4440
|
if (exiting?.spec.role === 'implementer' &&
|
|
4106
4441
|
!record.dryRun &&
|
|
4107
|
-
|
|
4442
|
+
this.#shouldAttemptPullRequestPublication()) {
|
|
4108
4443
|
try {
|
|
4109
4444
|
await this.#saveDispatchLifecycle(record, 'publishing');
|
|
4110
4445
|
publishedPr = await this.#publishImplementerPullRequest(record, exiting);
|
|
@@ -4339,11 +4674,14 @@ export class FactoryLoop {
|
|
|
4339
4674
|
}
|
|
4340
4675
|
// Publish a PR from the implementer's committed branch when it exited without
|
|
4341
4676
|
// opening one. Best-effort and idempotent: returns undefined when there is no
|
|
4342
|
-
//
|
|
4343
|
-
//
|
|
4344
|
-
//
|
|
4677
|
+
// clone or nothing publishable (no branch / no commits ahead of base —
|
|
4678
|
+
// `#publishImplementerPullRequest` refuses head==base), so the caller falls
|
|
4679
|
+
// back to its normal restart/conclude handling. Explicit app identity errors
|
|
4680
|
+
// remain fail-closed and propagate to the lifecycle error path.
|
|
4345
4681
|
async #tryPublishImplementerPr(record, implementer) {
|
|
4346
|
-
if (record.dryRun ||
|
|
4682
|
+
if (record.dryRun ||
|
|
4683
|
+
(!implementer.spec.clonePath && !implementer.spec.branch) ||
|
|
4684
|
+
!this.#shouldAttemptPullRequestPublication()) {
|
|
4347
4685
|
return undefined;
|
|
4348
4686
|
}
|
|
4349
4687
|
try {
|
|
@@ -4363,6 +4701,8 @@ export class FactoryLoop {
|
|
|
4363
4701
|
return published;
|
|
4364
4702
|
}
|
|
4365
4703
|
catch (error) {
|
|
4704
|
+
if (this.#config.github.identity === 'app' && !this.#mount.githubWrite)
|
|
4705
|
+
throw error;
|
|
4366
4706
|
this.#increment('exitPrPublishSkipped');
|
|
4367
4707
|
this.#logger.warn?.('[factory] could not publish implementer PR on exit; falling back', {
|
|
4368
4708
|
issue: record.issue.key,
|
|
@@ -4378,10 +4718,7 @@ export class FactoryLoop {
|
|
|
4378
4718
|
const cached = this.#publishedPullRequests.get(key);
|
|
4379
4719
|
if (cached)
|
|
4380
4720
|
return cached;
|
|
4381
|
-
const
|
|
4382
|
-
if (!githubWrite) {
|
|
4383
|
-
throw new Error('GitHub write path not available on this mount — connect GitHub to your workspace');
|
|
4384
|
-
}
|
|
4721
|
+
const { identity, publisher } = this.#githubPullRequestPublisher();
|
|
4385
4722
|
const remoteBranch = implementer.result?.locality === 'remote' && implementer.spec.branch
|
|
4386
4723
|
? implementer.spec.branch
|
|
4387
4724
|
: undefined;
|
|
@@ -4419,32 +4756,67 @@ export class FactoryLoop {
|
|
|
4419
4756
|
}
|
|
4420
4757
|
}
|
|
4421
4758
|
const baseRef = await this.#githubDefaultBranch(repo);
|
|
4422
|
-
const result = await
|
|
4759
|
+
const result = await publisher.publishPullRequest({
|
|
4423
4760
|
repo,
|
|
4424
4761
|
...(remoteBranch ? { headRef: remoteBranch } : { clonePath: implementer.spec.clonePath }),
|
|
4425
4762
|
baseRef,
|
|
4426
4763
|
title: `${issue.key}: ${issue.title}`,
|
|
4427
4764
|
body: githubPullRequestBody(issue),
|
|
4428
4765
|
});
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4766
|
+
const published = result.author
|
|
4767
|
+
? result
|
|
4768
|
+
: { ...result, author: identity };
|
|
4769
|
+
if (published.repo.toLowerCase() !== repo.toLowerCase() ||
|
|
4770
|
+
published.headRef !== (remoteBranch ?? published.headRef) ||
|
|
4771
|
+
!Number.isInteger(published.number) ||
|
|
4772
|
+
published.number <= 0 ||
|
|
4773
|
+
!published.url) {
|
|
4434
4774
|
throw new Error(`GitHub PR publication returned an unexpected receipt for ${repo}/${remoteBranch ?? 'local HEAD'}`);
|
|
4435
4775
|
}
|
|
4436
4776
|
if (remoteBranch && this.#mount.writebackTransport === 'relayfile-cloud') {
|
|
4437
|
-
await this.#confirmPublishedRemotePullRequest(repo,
|
|
4777
|
+
await this.#confirmPublishedRemotePullRequest(repo, published, remoteBranch);
|
|
4438
4778
|
}
|
|
4439
|
-
this.#publishedPullRequests.set(key,
|
|
4779
|
+
this.#publishedPullRequests.set(key, published);
|
|
4440
4780
|
this.#increment('githubPullRequestsPublished');
|
|
4441
|
-
this.#logger.info?.('[factory] published PR
|
|
4781
|
+
this.#logger.info?.('[factory] published PR', {
|
|
4442
4782
|
issue: issue.key,
|
|
4443
|
-
repo:
|
|
4444
|
-
prNumber:
|
|
4445
|
-
url:
|
|
4783
|
+
repo: published.repo,
|
|
4784
|
+
prNumber: published.number,
|
|
4785
|
+
url: published.url,
|
|
4786
|
+
identity,
|
|
4787
|
+
author: published.author,
|
|
4446
4788
|
});
|
|
4447
|
-
return
|
|
4789
|
+
return published;
|
|
4790
|
+
}
|
|
4791
|
+
#githubPullRequestPublisher() {
|
|
4792
|
+
const configured = this.#config.github.identity;
|
|
4793
|
+
if (configured !== 'user' && this.#mount.githubWrite) {
|
|
4794
|
+
return { identity: 'app', publisher: this.#mount.githubWrite };
|
|
4795
|
+
}
|
|
4796
|
+
if (configured === 'app') {
|
|
4797
|
+
throw new Error('GitHub PR identity "app" requires a connected workspace GitHub App write path; refusing to fall back to the local gh user');
|
|
4798
|
+
}
|
|
4799
|
+
const publishPullRequest = this.#githubWriteback.publishPullRequest;
|
|
4800
|
+
if (!publishPullRequest) {
|
|
4801
|
+
throw new Error(`GitHub PR identity "${configured}" requires local gh user publication, but the configured GitHub writeback does not support it`);
|
|
4802
|
+
}
|
|
4803
|
+
return {
|
|
4804
|
+
identity: 'user',
|
|
4805
|
+
publisher: { publishPullRequest: publishPullRequest.bind(this.#githubWriteback) },
|
|
4806
|
+
};
|
|
4807
|
+
}
|
|
4808
|
+
#shouldAttemptPullRequestPublication() {
|
|
4809
|
+
if (this.#config.github.identity !== 'auto')
|
|
4810
|
+
return true;
|
|
4811
|
+
if (this.#mount.githubWrite)
|
|
4812
|
+
return true;
|
|
4813
|
+
// Fake mounts deliberately avoid invoking the host's real gh binary unless
|
|
4814
|
+
// a publisher was injected. Every production mount (cloud or custom) still
|
|
4815
|
+
// gets the configured auto fallback.
|
|
4816
|
+
if (this.#mount.writebackTransport === 'test') {
|
|
4817
|
+
return Boolean(this.#githubWritebackProvided && this.#githubWriteback.publishPullRequest);
|
|
4818
|
+
}
|
|
4819
|
+
return true;
|
|
4448
4820
|
}
|
|
4449
4821
|
async #openPullRequestByHead(repo, expectedHeadRef) {
|
|
4450
4822
|
if (this.#hasProbePrGhRunner) {
|
|
@@ -6385,27 +6757,38 @@ export class FactoryLoop {
|
|
|
6385
6757
|
const implementerNames = decision.implementers.map((implementer) => implementer.name);
|
|
6386
6758
|
const reviewerName = decision.reviewer.name;
|
|
6387
6759
|
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
6388
|
-
const render = (spec) =>
|
|
6389
|
-
|
|
6390
|
-
|
|
6760
|
+
const render = async (spec) => {
|
|
6761
|
+
const route = routeForSpec(decision, spec);
|
|
6762
|
+
const testGuidance = await resolveTestGuidance({
|
|
6763
|
+
repoPath: route.clonePath,
|
|
6391
6764
|
issue: templateIssue,
|
|
6392
|
-
route
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
|
-
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
|
|
6765
|
+
route,
|
|
6766
|
+
previewUrl: previewUrlFromSpec(spec),
|
|
6767
|
+
});
|
|
6768
|
+
return {
|
|
6769
|
+
...spec,
|
|
6770
|
+
task: renderAgentTask({
|
|
6771
|
+
issue: templateIssue,
|
|
6772
|
+
route,
|
|
6773
|
+
role: spec.role,
|
|
6774
|
+
config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
|
|
6775
|
+
reviewerName,
|
|
6776
|
+
implementerNames,
|
|
6777
|
+
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
6778
|
+
integrationInstructions,
|
|
6779
|
+
testGuidance,
|
|
6780
|
+
branchName: spec.branch ?? decision.implementers.find((candidate) => candidate.repo === spec.repo)?.branch,
|
|
6781
|
+
branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
|
|
6782
|
+
agentName: spec.name,
|
|
6783
|
+
...(this.#fleet.lifecycleActionName ? { lifecycleActionName: this.#fleet.lifecycleActionName } : {}),
|
|
6784
|
+
}),
|
|
6785
|
+
};
|
|
6786
|
+
};
|
|
6787
|
+
const rendered = await Promise.all([...decision.implementers, decision.reviewer].map(render));
|
|
6405
6788
|
return {
|
|
6406
6789
|
...decision,
|
|
6407
|
-
implementers: decision.implementers.
|
|
6408
|
-
reviewer:
|
|
6790
|
+
implementers: rendered.slice(0, decision.implementers.length),
|
|
6791
|
+
reviewer: rendered[decision.implementers.length],
|
|
6409
6792
|
};
|
|
6410
6793
|
}
|
|
6411
6794
|
#consumePendingDispatchClarifications(issue) {
|
|
@@ -7214,6 +7597,7 @@ export class FactoryLoop {
|
|
|
7214
7597
|
await this.#recordCanonicalIssueState({ ...issueRef(issue), stateId: doneStateId });
|
|
7215
7598
|
}
|
|
7216
7599
|
this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
|
|
7600
|
+
await this.#markDependencyTerminalAndReconcile(issue);
|
|
7217
7601
|
this.#increment('mergedPrAdvancedDone');
|
|
7218
7602
|
this.#increment('done');
|
|
7219
7603
|
this.#logger.info?.('[factory] merged PR advanced issue to Done', {
|
|
@@ -7367,6 +7751,7 @@ export class FactoryLoop {
|
|
|
7367
7751
|
});
|
|
7368
7752
|
this.#babysitterSpawned.add(babysitterKey);
|
|
7369
7753
|
await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
|
|
7754
|
+
await this.#retargetSlackConversationToBabysitter(record);
|
|
7370
7755
|
return;
|
|
7371
7756
|
}
|
|
7372
7757
|
// Reserve up-front so concurrent PR events in a drain don't double-spawn.
|
|
@@ -7413,9 +7798,19 @@ export class FactoryLoop {
|
|
|
7413
7798
|
.filter((agent) => agent.spec.role === 'implementer')
|
|
7414
7799
|
.map((agent) => agent.result?.name ?? agent.spec.name);
|
|
7415
7800
|
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
7801
|
+
const templateIssue = templateIssueFromRecord(record, issue);
|
|
7802
|
+
const prSummary = await this.#github.getPr(prRef.repo, prRef.prNumber).catch(() => undefined);
|
|
7803
|
+
const taskRoute = { ...(route ?? { repo: prRef.repo }), clonePath: spec.clonePath ?? route?.clonePath };
|
|
7804
|
+
const testGuidance = await resolveTestGuidance({
|
|
7805
|
+
repoPath: taskRoute.clonePath,
|
|
7806
|
+
issue: templateIssue,
|
|
7807
|
+
route: taskRoute,
|
|
7808
|
+
changedFiles: prSummary?.filesChanged,
|
|
7809
|
+
previewUrl: previewUrlFromSpec(spec),
|
|
7810
|
+
});
|
|
7416
7811
|
const task = renderAgentTask({
|
|
7417
|
-
issue:
|
|
7418
|
-
route:
|
|
7812
|
+
issue: templateIssue,
|
|
7813
|
+
route: taskRoute,
|
|
7419
7814
|
role: 'babysitter',
|
|
7420
7815
|
config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
|
|
7421
7816
|
reviewerName,
|
|
@@ -7424,6 +7819,7 @@ export class FactoryLoop {
|
|
|
7424
7819
|
slackDispatchThread: await this.#slackDispatchThreadFor(record),
|
|
7425
7820
|
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
7426
7821
|
integrationInstructions,
|
|
7822
|
+
testGuidance,
|
|
7427
7823
|
branchName: spec.branch,
|
|
7428
7824
|
branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
|
|
7429
7825
|
agentName: spec.name,
|
|
@@ -7442,6 +7838,7 @@ export class FactoryLoop {
|
|
|
7442
7838
|
agentName: tracked?.result?.name ?? spawned.name,
|
|
7443
7839
|
});
|
|
7444
7840
|
await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
|
|
7841
|
+
await this.#retargetSlackConversationToBabysitter(record);
|
|
7445
7842
|
await this.#writeInFlightRegistry();
|
|
7446
7843
|
if (!await this.#saveDispatchLifecycle(record, 'running'))
|
|
7447
7844
|
return;
|
|
@@ -7781,6 +8178,8 @@ export class FactoryLoop {
|
|
|
7781
8178
|
await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
|
|
7782
8179
|
}
|
|
7783
8180
|
this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
|
|
8181
|
+
if (!humanReview)
|
|
8182
|
+
await this.#markDependencyTerminalAndReconcile(issue);
|
|
7784
8183
|
}
|
|
7785
8184
|
if (!await this.#saveDispatchLifecycle(record, 'writeback-applied'))
|
|
7786
8185
|
return;
|
|
@@ -7940,8 +8339,8 @@ export class FactoryLoop {
|
|
|
7940
8339
|
});
|
|
7941
8340
|
this.#emit('error', { error, ...describeError(error), issue });
|
|
7942
8341
|
}
|
|
7943
|
-
#increment(name) {
|
|
7944
|
-
this.#counters[name] = (this.#counters[name] ?? 0) +
|
|
8342
|
+
#increment(name, amount = 1) {
|
|
8343
|
+
this.#counters[name] = (this.#counters[name] ?? 0) + amount;
|
|
7945
8344
|
}
|
|
7946
8345
|
#recordTriageEscalation(decision, reason) {
|
|
7947
8346
|
this.#increment('triageEscalations');
|
|
@@ -8177,7 +8576,11 @@ export class FactoryLoop {
|
|
|
8177
8576
|
// the reply watcher instead of returning early, otherwise human replies in
|
|
8178
8577
|
// the existing thread are watched by nobody and silently dropped.
|
|
8179
8578
|
if (existingThread) {
|
|
8180
|
-
await this.#
|
|
8579
|
+
const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(existingThread));
|
|
8580
|
+
await this.#ensureSlackConversationSession(record, existingThread);
|
|
8581
|
+
await this.#rearmSlackWatcher(record, existingThread, {
|
|
8582
|
+
replayConversationReplies: Boolean(durableConversation),
|
|
8583
|
+
});
|
|
8181
8584
|
}
|
|
8182
8585
|
return;
|
|
8183
8586
|
}
|
|
@@ -8207,9 +8610,223 @@ export class FactoryLoop {
|
|
|
8207
8610
|
].join('\n'),
|
|
8208
8611
|
});
|
|
8209
8612
|
await this.#state.setSlackThread(this.#workspaceId, issueKey(record.issue), root.threadId);
|
|
8613
|
+
await this.#ensureSlackConversationSession(record, root.threadId);
|
|
8210
8614
|
await this.#watchSlackThread(record, root.threadId);
|
|
8211
8615
|
this.#recordSlackWritebackSuccess('dispatch-thread');
|
|
8212
8616
|
}
|
|
8617
|
+
// Once a PR is open, the babysitter is the one driving further conversation
|
|
8618
|
+
// with the human — prefer it over the implementer that originally opened the
|
|
8619
|
+
// Slack thread, matching the pre-conversation-resume routing that sent human
|
|
8620
|
+
// replies to both roles once a PR existed.
|
|
8621
|
+
#slackConversationOwnerCandidate(record) {
|
|
8622
|
+
const candidates = [...record.agents.entries()].map(([name, tracked]) => ({ name, tracked }));
|
|
8623
|
+
return candidates.find(({ tracked }) => tracked.spec.role === 'babysitter' && Boolean(tracked.sessionRef))
|
|
8624
|
+
?? candidates.find(({ tracked }) => tracked.spec.role === 'implementer' && Boolean(tracked.sessionRef));
|
|
8625
|
+
}
|
|
8626
|
+
async #ensureSlackConversationSession(record, threadId, options = {}) {
|
|
8627
|
+
const conversationId = slackConversationId(threadId);
|
|
8628
|
+
const owned = this.#slackConversationOwnerCandidate(record);
|
|
8629
|
+
const existing = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8630
|
+
if (existing) {
|
|
8631
|
+
const sessionRef = owned?.tracked.sessionRef;
|
|
8632
|
+
const agentName = owned ? (owned.tracked.result?.name ?? owned.name) : undefined;
|
|
8633
|
+
if (owned && sessionRef &&
|
|
8634
|
+
(agentName !== existing.agent.name || (options.forceAgentRebind === true && sessionRef !== existing.agent.sessionRef))) {
|
|
8635
|
+
const rebound = await this.#state.rebindConversationSession(this.#workspaceId, conversationId, {
|
|
8636
|
+
name: agentName,
|
|
8637
|
+
sessionRef,
|
|
8638
|
+
node: owned.tracked.result?.node ?? owned.tracked.spec.node,
|
|
8639
|
+
capability: owned.tracked.spec.capability,
|
|
8640
|
+
repo: owned.tracked.spec.repo,
|
|
8641
|
+
clonePath: owned.tracked.spec.clonePath,
|
|
8642
|
+
});
|
|
8643
|
+
if (rebound)
|
|
8644
|
+
this.#increment('slackConversationSessionsRebound');
|
|
8645
|
+
}
|
|
8646
|
+
if (existing.pending.length > 0 || existing.delivery) {
|
|
8647
|
+
const waiting = await this.#state.getWaitingClarification(this.#workspaceId, issueKey(existing.issue));
|
|
8648
|
+
if (!waiting)
|
|
8649
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
8650
|
+
}
|
|
8651
|
+
return;
|
|
8652
|
+
}
|
|
8653
|
+
const sessionRef = owned?.tracked.sessionRef;
|
|
8654
|
+
if (!owned || !sessionRef) {
|
|
8655
|
+
this.#increment('slackConversationSessionsSkippedMissingSession');
|
|
8656
|
+
return;
|
|
8657
|
+
}
|
|
8658
|
+
const channelDir = await this.#slackChannelDir() ?? this.#config.slack?.channel;
|
|
8659
|
+
if (!channelDir)
|
|
8660
|
+
return;
|
|
8661
|
+
const agentName = owned.tracked.result?.name ?? owned.name;
|
|
8662
|
+
const reserved = await this.#state.reserveConversationSession(this.#workspaceId, conversationId, {
|
|
8663
|
+
provider: 'slack',
|
|
8664
|
+
issue: { ...record.issue },
|
|
8665
|
+
externalId: threadId,
|
|
8666
|
+
context: { channelDir },
|
|
8667
|
+
agent: {
|
|
8668
|
+
name: agentName,
|
|
8669
|
+
sessionRef,
|
|
8670
|
+
node: owned.tracked.result?.node ?? owned.tracked.spec.node,
|
|
8671
|
+
capability: owned.tracked.spec.capability,
|
|
8672
|
+
repo: owned.tracked.spec.repo,
|
|
8673
|
+
clonePath: owned.tracked.spec.clonePath,
|
|
8674
|
+
},
|
|
8675
|
+
history: [],
|
|
8676
|
+
processedMessageIds: [],
|
|
8677
|
+
pending: [],
|
|
8678
|
+
});
|
|
8679
|
+
if (reserved)
|
|
8680
|
+
this.#increment('slackConversationSessionsOwned');
|
|
8681
|
+
}
|
|
8682
|
+
// Called right after a babysitter is spawned/reattached for an issue's PR so
|
|
8683
|
+
// an already-owned Slack conversation session (reserved earlier by the
|
|
8684
|
+
// implementer) retargets onto the babysitter for the next turn, instead of
|
|
8685
|
+
// resuming a session the babysitter has taken over from.
|
|
8686
|
+
async #retargetSlackConversationToBabysitter(record) {
|
|
8687
|
+
if (!this.#slack || !this.#config.slack)
|
|
8688
|
+
return;
|
|
8689
|
+
const threadId = await this.#persistedSlackThread(issueKey(record.issue));
|
|
8690
|
+
if (!threadId)
|
|
8691
|
+
return;
|
|
8692
|
+
await this.#ensureSlackConversationSession(record, threadId, { forceAgentRebind: true });
|
|
8693
|
+
}
|
|
8694
|
+
async #scheduleSlackConversationIfPending(threadId) {
|
|
8695
|
+
const conversationId = slackConversationId(threadId);
|
|
8696
|
+
const session = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8697
|
+
if (session && (session.pending.length > 0 || session.delivery)) {
|
|
8698
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
8699
|
+
}
|
|
8700
|
+
}
|
|
8701
|
+
async #resumeSlackConversationTurn(conversationId) {
|
|
8702
|
+
if (this.#stopping)
|
|
8703
|
+
return;
|
|
8704
|
+
const claimId = randomUUID();
|
|
8705
|
+
const claimed = await this.#state.claimConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now(), SLACK_CONVERSATION_TURN_LEASE_MS);
|
|
8706
|
+
if (!claimed?.delivery) {
|
|
8707
|
+
const current = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8708
|
+
if (current && (current.pending.length > 0 || current.delivery)) {
|
|
8709
|
+
this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
|
|
8710
|
+
}
|
|
8711
|
+
return;
|
|
8712
|
+
}
|
|
8713
|
+
if (!await this.#ownsActiveSlackConversationIssue(claimed.issue)) {
|
|
8714
|
+
await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
|
|
8715
|
+
this.#increment('slackConversationTurnsSuppressedStaleOwner');
|
|
8716
|
+
return;
|
|
8717
|
+
}
|
|
8718
|
+
let leaseLost = false;
|
|
8719
|
+
let renewalInFlight = false;
|
|
8720
|
+
const heartbeat = setInterval(() => {
|
|
8721
|
+
if (renewalInFlight || leaseLost || this.#stopping)
|
|
8722
|
+
return;
|
|
8723
|
+
renewalInFlight = true;
|
|
8724
|
+
void this.#state.renewConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now()).then((renewed) => { leaseLost = !renewed; })
|
|
8725
|
+
.catch((error) => this.#logger.warn?.('[factory] Slack conversation lease renewal failed', {
|
|
8726
|
+
conversationId,
|
|
8727
|
+
error: describeError(error).errorMessage,
|
|
8728
|
+
}))
|
|
8729
|
+
.finally(() => { renewalInFlight = false; });
|
|
8730
|
+
}, Math.max(1_000, Math.floor(SLACK_CONVERSATION_TURN_LEASE_MS / 3)));
|
|
8731
|
+
heartbeat.unref?.();
|
|
8732
|
+
try {
|
|
8733
|
+
const result = await this.#fleet.resume({
|
|
8734
|
+
name: claimed.agent.name,
|
|
8735
|
+
sessionRef: claimed.agent.sessionRef,
|
|
8736
|
+
node: claimed.agent.node ?? 'self',
|
|
8737
|
+
capability: claimed.agent.capability,
|
|
8738
|
+
repo: claimed.agent.repo,
|
|
8739
|
+
clonePath: claimed.agent.clonePath,
|
|
8740
|
+
task: slackConversationResumeTask(claimed),
|
|
8741
|
+
});
|
|
8742
|
+
const completed = await this.#state.completeConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, { name: result.name, sessionRef: result.sessionRef });
|
|
8743
|
+
// The token-fenced state mutation is authoritative. A renewal already
|
|
8744
|
+
// queued behind this completion can observe the cleared delivery and set
|
|
8745
|
+
// leaseLost even though completion committed successfully.
|
|
8746
|
+
if (!completed) {
|
|
8747
|
+
this.#increment('slackConversationTurnOwnershipLost');
|
|
8748
|
+
return;
|
|
8749
|
+
}
|
|
8750
|
+
await this.#recordSlackConversationResume(claimed, result);
|
|
8751
|
+
this.#increment('slackConversationTurnsResumed');
|
|
8752
|
+
if (claimed.delivery.messages.length > 1) {
|
|
8753
|
+
this.#increment('slackConversationRepliesCoalesced', claimed.delivery.messages.length - 1);
|
|
8754
|
+
}
|
|
8755
|
+
}
|
|
8756
|
+
catch (error) {
|
|
8757
|
+
try {
|
|
8758
|
+
await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
|
|
8759
|
+
}
|
|
8760
|
+
catch (releaseError) {
|
|
8761
|
+
this.#logger.warn?.('[factory] Slack conversation claim release failed; waiting for lease recovery', {
|
|
8762
|
+
conversationId,
|
|
8763
|
+
error: describeError(releaseError).errorMessage,
|
|
8764
|
+
});
|
|
8765
|
+
}
|
|
8766
|
+
this.#increment('slackConversationTurnResumeFailures');
|
|
8767
|
+
this.#logger.warn?.('[factory] Slack conversation resume failed; retaining the turn for retry', {
|
|
8768
|
+
issue: claimed.issue.key,
|
|
8769
|
+
threadId: claimed.externalId,
|
|
8770
|
+
sessionRef: claimed.agent.sessionRef,
|
|
8771
|
+
error: describeError(error).errorMessage,
|
|
8772
|
+
});
|
|
8773
|
+
this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
|
|
8774
|
+
return;
|
|
8775
|
+
}
|
|
8776
|
+
finally {
|
|
8777
|
+
clearInterval(heartbeat);
|
|
8778
|
+
}
|
|
8779
|
+
const remaining = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8780
|
+
if (remaining && (remaining.pending.length > 0 || remaining.delivery)) {
|
|
8781
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
8782
|
+
}
|
|
8783
|
+
}
|
|
8784
|
+
async #recordSlackConversationResume(session, result) {
|
|
8785
|
+
const record = (await this.#batch()).getIssue(session.issue);
|
|
8786
|
+
if (!record)
|
|
8787
|
+
return;
|
|
8788
|
+
const entry = [...record.agents.entries()].find(([name, tracked]) => name === session.agent.name || tracked.result?.name === session.agent.name);
|
|
8789
|
+
if (!entry)
|
|
8790
|
+
return;
|
|
8791
|
+
const [previousName, tracked] = entry;
|
|
8792
|
+
tracked.result = {
|
|
8793
|
+
...tracked.result,
|
|
8794
|
+
...result,
|
|
8795
|
+
node: result.node ?? tracked.result?.node,
|
|
8796
|
+
locality: result.locality ?? tracked.result?.locality,
|
|
8797
|
+
};
|
|
8798
|
+
tracked.sessionRef = result.sessionRef ?? tracked.sessionRef;
|
|
8799
|
+
if (result.name !== previousName) {
|
|
8800
|
+
record.agents.delete(previousName);
|
|
8801
|
+
record.agents.set(result.name, tracked);
|
|
8802
|
+
}
|
|
8803
|
+
try {
|
|
8804
|
+
await this.#writeInFlightRegistry();
|
|
8805
|
+
await this.#saveDispatchLifecycle(record, 'running');
|
|
8806
|
+
}
|
|
8807
|
+
catch (error) {
|
|
8808
|
+
this.#logger.warn?.('[factory] Slack conversation resume bookkeeping failed', {
|
|
8809
|
+
issue: session.issue.key,
|
|
8810
|
+
agent: result.name,
|
|
8811
|
+
error: describeError(error).errorMessage,
|
|
8812
|
+
});
|
|
8813
|
+
}
|
|
8814
|
+
}
|
|
8815
|
+
async #ownsActiveSlackConversationIssue(issue) {
|
|
8816
|
+
if (!(await this.#batch()).getIssue(issue))
|
|
8817
|
+
return false;
|
|
8818
|
+
const key = issueKey(issue);
|
|
8819
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
8820
|
+
if (!lifecycle)
|
|
8821
|
+
return true;
|
|
8822
|
+
if (lifecycle.phase === 'releasing' || isTerminalDispatchLifecycle(lifecycle))
|
|
8823
|
+
return false;
|
|
8824
|
+
if (!lifecycle.lease)
|
|
8825
|
+
return false;
|
|
8826
|
+
return lifecycle.lease.owner === this.#dispatchLifecycleOwner &&
|
|
8827
|
+
lifecycle.lease.epoch === this.#dispatchLifecycleEpochs.get(key) &&
|
|
8828
|
+
lifecycle.lease.leaseUntilMs > this.#clock.now();
|
|
8829
|
+
}
|
|
8213
8830
|
async #escalateTriage(decision, reason, dryRun) {
|
|
8214
8831
|
if (dryRun) {
|
|
8215
8832
|
return;
|
|
@@ -8494,7 +9111,7 @@ export class FactoryLoop {
|
|
|
8494
9111
|
this.#recordSlackWritebackSuccess('triage-escalation');
|
|
8495
9112
|
return replayedResult;
|
|
8496
9113
|
}
|
|
8497
|
-
async #watchSlackThread(record, threadId) {
|
|
9114
|
+
async #watchSlackThread(record, threadId, options = {}) {
|
|
8498
9115
|
if (!this.#config.slack) {
|
|
8499
9116
|
return;
|
|
8500
9117
|
}
|
|
@@ -8506,12 +9123,14 @@ export class FactoryLoop {
|
|
|
8506
9123
|
const messagesPrefix = slackChannelMessagesPrefix(channelDir);
|
|
8507
9124
|
const preExistingPaths = new Set();
|
|
8508
9125
|
const preExistingPathOrder = [];
|
|
9126
|
+
const preExistingEvents = [];
|
|
8509
9127
|
const seenReplies = new Set();
|
|
8510
9128
|
const seenReplyMessages = new Set();
|
|
8511
9129
|
let missingIdentityLogged = false;
|
|
8512
9130
|
let cursor;
|
|
8513
9131
|
let stopped = false;
|
|
8514
9132
|
let pollTimer;
|
|
9133
|
+
const routeRetryTimers = new Map();
|
|
8515
9134
|
const markPreExisting = async () => {
|
|
8516
9135
|
try {
|
|
8517
9136
|
const page = await this.#mount.getEvents({ limit: SLACK_REPLY_EVENTS_LIMIT });
|
|
@@ -8521,6 +9140,7 @@ export class FactoryLoop {
|
|
|
8521
9140
|
if (path && path.startsWith(messagesPrefix)) {
|
|
8522
9141
|
preExistingPaths.add(path);
|
|
8523
9142
|
preExistingPathOrder.push(path);
|
|
9143
|
+
preExistingEvents.push(event);
|
|
8524
9144
|
}
|
|
8525
9145
|
}
|
|
8526
9146
|
}
|
|
@@ -8528,7 +9148,8 @@ export class FactoryLoop {
|
|
|
8528
9148
|
this.#logger.warn?.('[factory] unable to seed Slack reply watcher event cursor', error);
|
|
8529
9149
|
}
|
|
8530
9150
|
};
|
|
8531
|
-
const handle = async (event) => {
|
|
9151
|
+
const handle = async (event, allowPreExisting = false) => {
|
|
9152
|
+
const routeRetryKey = eventIdentity(event) ?? changeEventPath(event) ?? randomUUID();
|
|
8532
9153
|
try {
|
|
8533
9154
|
// Polling-fallback / degraded-sync events can lack `resource.path`
|
|
8534
9155
|
// despite the SDK type. Skip them quietly so one malformed event never
|
|
@@ -8544,7 +9165,7 @@ export class FactoryLoop {
|
|
|
8544
9165
|
this.#logger.warn?.('[factory] Slack reply event missing stable identity; falling back to path/content dedupe');
|
|
8545
9166
|
}
|
|
8546
9167
|
}
|
|
8547
|
-
if (preExistingPaths.has(path)) {
|
|
9168
|
+
if (!allowPreExisting && preExistingPaths.has(path)) {
|
|
8548
9169
|
return;
|
|
8549
9170
|
}
|
|
8550
9171
|
const reply = await this.#readSlackReply(path);
|
|
@@ -8561,21 +9182,34 @@ export class FactoryLoop {
|
|
|
8561
9182
|
this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path });
|
|
8562
9183
|
return;
|
|
8563
9184
|
}
|
|
8564
|
-
seenReplies.add(replyKey);
|
|
8565
9185
|
if (reply.isBot) {
|
|
9186
|
+
seenReplies.add(replyKey);
|
|
9187
|
+
seenReplyMessages.add(replyMessageKey);
|
|
8566
9188
|
return;
|
|
8567
9189
|
}
|
|
8568
|
-
seenReplyMessages.add(replyMessageKey);
|
|
8569
9190
|
await this.#routeSlackAnswerToImplementers(record, reply);
|
|
9191
|
+
// Acknowledge in memory only after the durable route succeeds. A read or
|
|
9192
|
+
// state-store failure must remain replayable by the poll/subscription.
|
|
9193
|
+
seenReplies.add(replyKey);
|
|
9194
|
+
seenReplyMessages.add(replyMessageKey);
|
|
8570
9195
|
}
|
|
8571
9196
|
catch (error) {
|
|
8572
9197
|
this.#logger.error?.('[factory] failed to handle Slack reply event', error);
|
|
9198
|
+
if (!stopped && !routeRetryTimers.has(routeRetryKey)) {
|
|
9199
|
+
const retryTimer = setTimeout(() => {
|
|
9200
|
+
routeRetryTimers.delete(routeRetryKey);
|
|
9201
|
+
if (!stopped)
|
|
9202
|
+
void handle(event, true);
|
|
9203
|
+
}, SLACK_REPLY_ROUTE_RETRY_MS);
|
|
9204
|
+
retryTimer.unref?.();
|
|
9205
|
+
routeRetryTimers.set(routeRetryKey, retryTimer);
|
|
9206
|
+
}
|
|
8573
9207
|
}
|
|
8574
9208
|
};
|
|
8575
9209
|
await markPreExisting();
|
|
8576
9210
|
let subscription;
|
|
8577
9211
|
try {
|
|
8578
|
-
subscription = this.#mount.subscribe([
|
|
9212
|
+
subscription = this.#mount.subscribe([slackThreadReplyGlob(channelDir)], (event) => {
|
|
8579
9213
|
// Receipt time is independent of the provider-authored sync timestamp.
|
|
8580
9214
|
// A healthy webhook can therefore override a frozen advisory status
|
|
8581
9215
|
// without trusting the same field that declared the provider stale.
|
|
@@ -8615,9 +9249,16 @@ export class FactoryLoop {
|
|
|
8615
9249
|
clearTimeout(pollTimer);
|
|
8616
9250
|
pollTimer = undefined;
|
|
8617
9251
|
}
|
|
9252
|
+
for (const retryTimer of routeRetryTimers.values())
|
|
9253
|
+
clearTimeout(retryTimer);
|
|
9254
|
+
routeRetryTimers.clear();
|
|
8618
9255
|
await this.#boundedStopTeardown('Slack reply subscription unsubscribe', () => subscription?.unsubscribe());
|
|
8619
9256
|
},
|
|
8620
9257
|
});
|
|
9258
|
+
if (options.replayConversationReplies) {
|
|
9259
|
+
for (const event of preExistingEvents)
|
|
9260
|
+
await handle(event, true);
|
|
9261
|
+
}
|
|
8621
9262
|
return await this.#replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPathOrder);
|
|
8622
9263
|
}
|
|
8623
9264
|
async #replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPaths) {
|
|
@@ -8646,13 +9287,13 @@ export class FactoryLoop {
|
|
|
8646
9287
|
// exists (persisted thread id) but has no in-process watcher. #watchSlackThread
|
|
8647
9288
|
// is itself idempotent on #slackWatchers; the guard here keeps the counter (and
|
|
8648
9289
|
// the seeding getEvents call) limited to genuine re-arms.
|
|
8649
|
-
async #rearmSlackWatcher(record, threadId) {
|
|
9290
|
+
async #rearmSlackWatcher(record, threadId, options = {}) {
|
|
8650
9291
|
const key = issueKey(record.issue);
|
|
8651
9292
|
if (this.#slackWatchers.has(key) || this.#slackWatcherStarts.has(key)) {
|
|
8652
9293
|
return;
|
|
8653
9294
|
}
|
|
8654
9295
|
try {
|
|
8655
|
-
await this.#watchSlackThread(record, threadId);
|
|
9296
|
+
await this.#watchSlackThread(record, threadId, options);
|
|
8656
9297
|
this.#increment('slackWatchersRearmed');
|
|
8657
9298
|
}
|
|
8658
9299
|
catch (error) {
|
|
@@ -8667,7 +9308,8 @@ export class FactoryLoop {
|
|
|
8667
9308
|
if (!this.#slack || !this.#config.slack) {
|
|
8668
9309
|
return;
|
|
8669
9310
|
}
|
|
8670
|
-
|
|
9311
|
+
const batch = await this.#batch();
|
|
9312
|
+
for (const record of batch.inFlight) {
|
|
8671
9313
|
if (record.dryRun) {
|
|
8672
9314
|
continue;
|
|
8673
9315
|
}
|
|
@@ -8686,7 +9328,27 @@ export class FactoryLoop {
|
|
|
8686
9328
|
if (!threadId) {
|
|
8687
9329
|
continue;
|
|
8688
9330
|
}
|
|
8689
|
-
await this.#
|
|
9331
|
+
const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(threadId));
|
|
9332
|
+
await this.#ensureSlackConversationSession(record, threadId);
|
|
9333
|
+
await this.#rearmSlackWatcher(record, threadId, {
|
|
9334
|
+
replayConversationReplies: Boolean(durableConversation),
|
|
9335
|
+
});
|
|
9336
|
+
}
|
|
9337
|
+
for (const [conversationId, session] of await this.#state.listConversationSessions(this.#workspaceId)) {
|
|
9338
|
+
if (session.provider !== 'slack')
|
|
9339
|
+
continue;
|
|
9340
|
+
const threadId = session.externalId;
|
|
9341
|
+
const record = batch.getIssue(session.issue);
|
|
9342
|
+
const key = issueKey(session.issue);
|
|
9343
|
+
if (record && !record.dryRun && !this.#slackWatchers.has(key) && !this.#slackWatcherStarts.has(key)) {
|
|
9344
|
+
await this.#state.setSlackThread(this.#workspaceId, key, threadId);
|
|
9345
|
+
await this.#ensureSlackConversationSession(record, threadId);
|
|
9346
|
+
await this.#rearmSlackWatcher(record, threadId, { replayConversationReplies: true });
|
|
9347
|
+
}
|
|
9348
|
+
const waiting = await this.#state.getWaitingClarification(this.#workspaceId, key);
|
|
9349
|
+
if (record && !waiting && (session.pending.length > 0 || session.delivery)) {
|
|
9350
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
9351
|
+
}
|
|
8690
9352
|
}
|
|
8691
9353
|
await this.#sweepWaitingClarifications();
|
|
8692
9354
|
for (const [, waiting] of await this.#state.listWaitingClarifications(this.#workspaceId)) {
|
|
@@ -8833,13 +9495,19 @@ export class FactoryLoop {
|
|
|
8833
9495
|
const key = issueKey(issue);
|
|
8834
9496
|
const watcher = this.#slackWatchers.get(key);
|
|
8835
9497
|
this.#slackWatchers.delete(key);
|
|
8836
|
-
await this.#state.
|
|
9498
|
+
const threadId = await this.#state.getSlackThread(this.#workspaceId, key);
|
|
8837
9499
|
await watcher?.stop();
|
|
9500
|
+
if (threadId) {
|
|
9501
|
+
const conversationId = slackConversationId(threadId);
|
|
9502
|
+
await this.#slackConversationTurns.cancel(conversationId);
|
|
9503
|
+
await this.#state.clearConversationSession(this.#workspaceId, conversationId);
|
|
9504
|
+
}
|
|
9505
|
+
await this.#state.clearSlackThread(this.#workspaceId, key);
|
|
8838
9506
|
}
|
|
8839
9507
|
async #readSlackReply(path) {
|
|
8840
9508
|
try {
|
|
8841
9509
|
const { content } = await this.#mount.readFile(path);
|
|
8842
|
-
return
|
|
9510
|
+
return parseSlackThreadReply(path, content, this.#config.slack?.botUserId ?? 'U0B2596R7EZ');
|
|
8843
9511
|
}
|
|
8844
9512
|
catch (error) {
|
|
8845
9513
|
this.#logger.warn?.(`Unable to read Slack reply ${path}`, error);
|
|
@@ -8862,20 +9530,61 @@ export class FactoryLoop {
|
|
|
8862
9530
|
return;
|
|
8863
9531
|
}
|
|
8864
9532
|
if (waiting?.threadId === reply.threadTs) {
|
|
9533
|
+
const replyId = `${reply.threadTs}:${reply.messageTs}`;
|
|
8865
9534
|
const claimed = await this.#state.claimClarificationReply(this.#workspaceId, clarificationKey, {
|
|
8866
|
-
id:
|
|
9535
|
+
id: replyId,
|
|
8867
9536
|
text,
|
|
8868
|
-
receivedAtMs: this.#clock.now(),
|
|
9537
|
+
receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
|
|
8869
9538
|
source: 'slack',
|
|
9539
|
+
author: reply.author,
|
|
8870
9540
|
});
|
|
8871
9541
|
if (!claimed) {
|
|
8872
|
-
this.#
|
|
9542
|
+
const currentWaiting = await this.#state.getWaitingClarification(this.#workspaceId, clarificationKey);
|
|
9543
|
+
if (currentWaiting?.reply?.id === replyId) {
|
|
9544
|
+
this.#increment('clarificationDuplicateWakesSuppressed');
|
|
9545
|
+
return;
|
|
9546
|
+
}
|
|
9547
|
+
// The first reply wakes the parked clarification team. Additional
|
|
9548
|
+
// rapid-fire replies are ordinary conversation turns and must not be
|
|
9549
|
+
// discarded merely because the one-shot clarification slot is full.
|
|
9550
|
+
const conversationId = slackConversationId(reply.threadTs);
|
|
9551
|
+
const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
|
|
9552
|
+
id: replyId,
|
|
9553
|
+
text,
|
|
9554
|
+
receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
|
|
9555
|
+
providerSequence: reply.messageTs,
|
|
9556
|
+
author: reply.author,
|
|
9557
|
+
});
|
|
9558
|
+
if (queued) {
|
|
9559
|
+
this.#increment('slackConversationRepliesQueued');
|
|
9560
|
+
}
|
|
9561
|
+
else {
|
|
9562
|
+
this.#increment('slackConversationDuplicateRepliesSuppressed');
|
|
9563
|
+
}
|
|
8873
9564
|
return;
|
|
8874
9565
|
}
|
|
8875
9566
|
this.#increment('clarificationRepliesClaimed');
|
|
8876
9567
|
await this.#wakeWaitingClarification(clarificationKey, claimed);
|
|
8877
9568
|
return;
|
|
8878
9569
|
}
|
|
9570
|
+
const conversationId = slackConversationId(reply.threadTs);
|
|
9571
|
+
const conversation = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
9572
|
+
if (conversation && issueKey(conversation.issue) === clarificationKey) {
|
|
9573
|
+
const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
|
|
9574
|
+
id: `${reply.threadTs}:${reply.messageTs}`,
|
|
9575
|
+
text,
|
|
9576
|
+
receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
|
|
9577
|
+
providerSequence: reply.messageTs,
|
|
9578
|
+
author: reply.author,
|
|
9579
|
+
});
|
|
9580
|
+
if (!queued) {
|
|
9581
|
+
this.#increment('slackConversationDuplicateRepliesSuppressed');
|
|
9582
|
+
return;
|
|
9583
|
+
}
|
|
9584
|
+
this.#increment('slackConversationRepliesQueued');
|
|
9585
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
9586
|
+
return;
|
|
9587
|
+
}
|
|
8879
9588
|
const liveRecord = (await this.#batch()).getIssue(record.issue);
|
|
8880
9589
|
if (!liveRecord || liveRecord.dryRun) {
|
|
8881
9590
|
if (isTriageEscalationWatchRecord(record)) {
|
|
@@ -8884,23 +9593,7 @@ export class FactoryLoop {
|
|
|
8884
9593
|
this.#increment('slackAnswersIgnoredNoInFlight');
|
|
8885
9594
|
return;
|
|
8886
9595
|
}
|
|
8887
|
-
|
|
8888
|
-
return;
|
|
8889
|
-
}
|
|
8890
|
-
// Route human replies to the implementers AND the babysitter — once a PR is
|
|
8891
|
-
// open the babysitter is the one chatting with the human about it.
|
|
8892
|
-
const recipients = [...liveRecord.agents.values()]
|
|
8893
|
-
.filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
|
|
8894
|
-
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
8895
|
-
.filter((name) => Boolean(name));
|
|
8896
|
-
if (recipients.length === 0) {
|
|
8897
|
-
this.#increment('slackAnswersIgnoredNoImplementer');
|
|
8898
|
-
return;
|
|
8899
|
-
}
|
|
8900
|
-
for (const recipient of new Set(recipients)) {
|
|
8901
|
-
await this.#injectSlackReplyEvent(recipient, liveRecord.issue, text);
|
|
8902
|
-
this.#increment('slackAnswersInjected');
|
|
8903
|
-
}
|
|
9596
|
+
this.#increment('slackAnswersIgnoredNoConversationSession');
|
|
8904
9597
|
}
|
|
8905
9598
|
async #wakeWaitingClarification(key, waiting) {
|
|
8906
9599
|
const existing = this.#clarificationWakeInFlight.get(key);
|
|
@@ -9056,10 +9749,15 @@ export class FactoryLoop {
|
|
|
9056
9749
|
await renewLease();
|
|
9057
9750
|
await this.#writeInFlightRegistry();
|
|
9058
9751
|
await this.#saveDispatchLifecycle(record, 'running');
|
|
9752
|
+
if (waiting.threadId) {
|
|
9753
|
+
await this.#ensureSlackConversationSession(record, waiting.threadId, { forceAgentRebind: true });
|
|
9754
|
+
}
|
|
9059
9755
|
await renewLease();
|
|
9060
9756
|
const completed = await this.#state.completeClarificationWake(this.#workspaceId, key, this.#clarificationWakeOwner);
|
|
9061
9757
|
if (!completed)
|
|
9062
9758
|
throw new ClarificationWakeLeaseLostError('clarification wake completion lost ownership');
|
|
9759
|
+
if (waiting.threadId)
|
|
9760
|
+
await this.#scheduleSlackConversationIfPending(waiting.threadId);
|
|
9063
9761
|
this.#increment('clarificationTeamsWoken');
|
|
9064
9762
|
this.#logger.info?.('[factory] restarted team after human clarification', {
|
|
9065
9763
|
issue: waiting.issue.key,
|
|
@@ -9299,14 +9997,6 @@ export class FactoryLoop {
|
|
|
9299
9997
|
this.#emit('issue-queued', { issue: decision.issue });
|
|
9300
9998
|
}
|
|
9301
9999
|
}
|
|
9302
|
-
// Route ordinary Slack conversation into a live agent framed as the
|
|
9303
|
-
// <integration-event> the spawn prompt tells it to expect (not an ambiguous
|
|
9304
|
-
// "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
|
|
9305
|
-
// event. (A broker confirmed-delivery path via waitForInjected is a possible
|
|
9306
|
-
// robustness follow-up.)
|
|
9307
|
-
async #injectSlackReplyEvent(recipient, issue, text) {
|
|
9308
|
-
await this.#fleet.sendInput?.(recipient, slackReplyEvent(issue, text));
|
|
9309
|
-
}
|
|
9310
10000
|
// Absolute path to the local .integrations mount the daemon manages. The mount
|
|
9311
10001
|
// is created at the daemon's cwd (see ensureLocalMount), and spawned agents run
|
|
9312
10002
|
// in their repo clonePath, so writeback paths handed to agents must be absolute
|
|
@@ -9706,6 +10396,57 @@ const githubIssueSourceRef = (issue) => {
|
|
|
9706
10396
|
}
|
|
9707
10397
|
return { owner, repo, number: number, url };
|
|
9708
10398
|
};
|
|
10399
|
+
function dependencyRepoForIssue(issue, decision, config) {
|
|
10400
|
+
const source = githubIssueSourceRef(issue);
|
|
10401
|
+
if (source)
|
|
10402
|
+
return `${source.owner}/${source.repo}`;
|
|
10403
|
+
const mirroredRepo = githubMirrorRepoForIssue(issue);
|
|
10404
|
+
if (mirroredRepo)
|
|
10405
|
+
return mirroredRepo;
|
|
10406
|
+
const normalize = (repo) => {
|
|
10407
|
+
if (!repo)
|
|
10408
|
+
return undefined;
|
|
10409
|
+
try {
|
|
10410
|
+
return normalizeGithubRepo(repo, config.repos.org);
|
|
10411
|
+
}
|
|
10412
|
+
catch {
|
|
10413
|
+
return undefined;
|
|
10414
|
+
}
|
|
10415
|
+
};
|
|
10416
|
+
const decisionRepos = new Set((decision?.routes ?? [])
|
|
10417
|
+
.map((route) => normalize(route.repo))
|
|
10418
|
+
.filter((repo) => Boolean(repo)));
|
|
10419
|
+
if (decisionRepos.size === 1)
|
|
10420
|
+
return [...decisionRepos][0];
|
|
10421
|
+
if (decisionRepos.size > 1)
|
|
10422
|
+
return undefined;
|
|
10423
|
+
const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
|
|
10424
|
+
const labelRepos = new Set(Object.entries(config.repos.byLabel)
|
|
10425
|
+
.filter(([label]) => labels.has(label.trim().toLowerCase()))
|
|
10426
|
+
.map(([, repo]) => normalize(repo))
|
|
10427
|
+
.filter((repo) => Boolean(repo)));
|
|
10428
|
+
if (labelRepos.size === 1)
|
|
10429
|
+
return [...labelRepos][0];
|
|
10430
|
+
if (labelRepos.size > 1)
|
|
10431
|
+
return undefined;
|
|
10432
|
+
const projectRepo = issue.project
|
|
10433
|
+
? Object.entries(config.repos.byProject)
|
|
10434
|
+
.find(([project]) => project.trim().toLowerCase() === issue.project.trim().toLowerCase())?.[1]
|
|
10435
|
+
: undefined;
|
|
10436
|
+
return normalize(projectRepo) ?? normalize(config.repos.default);
|
|
10437
|
+
}
|
|
10438
|
+
function dependencyIdentityForIssue(issue, repo) {
|
|
10439
|
+
const source = githubIssueSourceRef(issue);
|
|
10440
|
+
if (source)
|
|
10441
|
+
return dependencyIdentity(`${source.owner}/${source.repo}`, source.number);
|
|
10442
|
+
const pathParts = githubIssuePathParts(issue.path);
|
|
10443
|
+
if (pathParts)
|
|
10444
|
+
return dependencyIdentity(`${pathParts.owner}/${pathParts.repo}`, pathParts.number);
|
|
10445
|
+
const number = Number(issue.key.match(/(\d+)$/u)?.[1]);
|
|
10446
|
+
return repo && Number.isSafeInteger(number) && number > 0
|
|
10447
|
+
? dependencyIdentity(repo, number)
|
|
10448
|
+
: undefined;
|
|
10449
|
+
}
|
|
9709
10450
|
const githubIssueAuthor = (issue) => {
|
|
9710
10451
|
const payload = wrappedPayload(issue.raw);
|
|
9711
10452
|
const source = asRecord(payload.source);
|
|
@@ -10107,10 +10848,14 @@ const routeForSpec = (decision, spec) => {
|
|
|
10107
10848
|
const route = decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? decision.routes.find((candidate) => candidate.repo === spec.repo);
|
|
10108
10849
|
return {
|
|
10109
10850
|
repo: spec.repo,
|
|
10110
|
-
clonePath: spec.clonePath,
|
|
10851
|
+
clonePath: spec.clonePath ?? route?.clonePath,
|
|
10111
10852
|
rationale: route?.rationale,
|
|
10112
10853
|
};
|
|
10113
10854
|
};
|
|
10855
|
+
const previewUrlFromSpec = (spec) => {
|
|
10856
|
+
const previewUrl = spec.previewUrl;
|
|
10857
|
+
return typeof previewUrl === 'string' && previewUrl.trim() ? previewUrl.trim() : undefined;
|
|
10858
|
+
};
|
|
10114
10859
|
const repoMapFromConfig = (config) => {
|
|
10115
10860
|
const repos = new Set([
|
|
10116
10861
|
...Object.values(config.repos.byLabel),
|
|
@@ -10324,7 +11069,7 @@ const linearIssueMirrorsGithubIssue = (issue, ghIssue) => {
|
|
|
10324
11069
|
const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
|
|
10325
11070
|
const candidates = [];
|
|
10326
11071
|
const listErrors = [];
|
|
10327
|
-
for (const repo of reposFromConfig(config)) {
|
|
11072
|
+
for (const repo of opts.repo ? [opts.repo] : reposFromConfig(config)) {
|
|
10328
11073
|
const paths = new Set();
|
|
10329
11074
|
for (const root of githubPullRoots(repo)) {
|
|
10330
11075
|
try {
|
|
@@ -10549,7 +11294,7 @@ const readProbePrCandidate = async (mount, path) => {
|
|
|
10549
11294
|
booleanValue(payload.crossRepository) ??
|
|
10550
11295
|
(headRepo && baseRepo ? headRepo.toLowerCase() !== baseRepo.toLowerCase() : undefined),
|
|
10551
11296
|
draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
|
|
10552
|
-
state: stringValue(payload.state),
|
|
11297
|
+
state: booleanValue(payload.merged) === true ? 'MERGED' : stringValue(payload.state),
|
|
10553
11298
|
url: stringValue(payload.url) ?? stringValue(payload.html_url),
|
|
10554
11299
|
};
|
|
10555
11300
|
}
|
|
@@ -11244,8 +11989,12 @@ const defaultRestartPolicy = (spec) =>
|
|
|
11244
11989
|
spec.role === 'implementer' || spec.role === 'reviewer' || spec.role === 'babysitter'
|
|
11245
11990
|
? { maxRestarts: 0 }
|
|
11246
11991
|
: spec.restartPolicy;
|
|
11247
|
-
const slackPayloadTs = (threadId) => threadId.replace(/_/g, '.');
|
|
11248
11992
|
const slackChannelMessagesPrefix = (channelDir) => `/slack/channels/${channelDir}/messages/`;
|
|
11993
|
+
const slackConversationId = (threadId) => `slack:${threadId}`;
|
|
11994
|
+
const slackMessageReceivedAtMs = (messageTs, fallback) => {
|
|
11995
|
+
const seconds = Number(messageTs);
|
|
11996
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds * 1_000) : fallback;
|
|
11997
|
+
};
|
|
11249
11998
|
const eventIdentity = (event) => {
|
|
11250
11999
|
const record = event;
|
|
11251
12000
|
const rawId = record.id ?? record.event_id ?? record.seq;
|
|
@@ -11475,28 +12224,6 @@ const dispatchResultFromLifecycle = (lifecycle) => lifecycle.result ? structured
|
|
|
11475
12224
|
agents: lifecycle.agents.map(({ name, tracked }) => ({ name, role: tracked.spec.role })),
|
|
11476
12225
|
dryRun: lifecycle.dryRun,
|
|
11477
12226
|
};
|
|
11478
|
-
const parseSlackReply = (path, content, botUserId) => {
|
|
11479
|
-
const raw = asRecord(parseJsonContent(content)) ?? {};
|
|
11480
|
-
const payload = wrappedPayload(raw);
|
|
11481
|
-
const channelDir = path.match(/^\/slack\/channels\/([^/]+)\//u)?.[1] ?? '';
|
|
11482
|
-
const pathMatch = path.match(/^\/slack\/channels\/[^/]+\/messages\/([^/]+)(?:\/replies\/([^/]+))?/u);
|
|
11483
|
-
const parentFromPath = pathMatch?.[2] ? slackPayloadTs(pathMatch[1]) : undefined;
|
|
11484
|
-
const messageFromPath = slackPayloadTs(pathMatch?.[2] ?? pathMatch?.[1] ?? '');
|
|
11485
|
-
const messageTs = stringValue(payload.ts) ?? messageFromPath;
|
|
11486
|
-
const threadTs = stringValue(payload.thread_ts) ?? parentFromPath;
|
|
11487
|
-
if (!channelDir || !threadTs || !messageTs) {
|
|
11488
|
-
return undefined;
|
|
11489
|
-
}
|
|
11490
|
-
return {
|
|
11491
|
-
channelDir,
|
|
11492
|
-
threadTs,
|
|
11493
|
-
messageTs,
|
|
11494
|
-
text: stringValue(payload.text) ?? '',
|
|
11495
|
-
isThreadReply: Boolean(parentFromPath) || threadTs !== messageTs,
|
|
11496
|
-
isBot: isOwnSlackBotReply(payload, botUserId),
|
|
11497
|
-
raw,
|
|
11498
|
-
};
|
|
11499
|
-
};
|
|
11500
12227
|
const triageEscalationReason = (decision) => {
|
|
11501
12228
|
const reasons = [];
|
|
11502
12229
|
if (decision.confidence === 'low') {
|
|
@@ -11569,11 +12296,6 @@ const issueWithGithubClarification = (issue, text) => ({
|
|
|
11569
12296
|
text,
|
|
11570
12297
|
].join('\n'),
|
|
11571
12298
|
});
|
|
11572
|
-
const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
|
|
11573
|
-
// The human's Slack-thread reply, framed as an <integration-event> the agent is
|
|
11574
|
-
// told (at spawn) to expect — a recognizable injected event, not an ambiguous
|
|
11575
|
-
// keystroke. Trailing CR submits it to the agent's PTY.
|
|
11576
|
-
const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
|
|
11577
12299
|
const clarificationResumeTask = (baseTask, waiting) => {
|
|
11578
12300
|
const reply = waiting.reply;
|
|
11579
12301
|
return [
|
|
@@ -11585,6 +12307,27 @@ const clarificationResumeTask = (baseTask, waiting) => {
|
|
|
11585
12307
|
'Re-hydrate from the issue, branch, worktree, and any open PR, then continue the task. Do not repeat completed work.',
|
|
11586
12308
|
].join('\n');
|
|
11587
12309
|
};
|
|
12310
|
+
const slackConversationResumeTask = (session) => {
|
|
12311
|
+
const delivered = session.delivery?.messages ?? [];
|
|
12312
|
+
const renderMessages = (messages) => messages
|
|
12313
|
+
.map((message, index) => `${index + 1}. ${message.author ? `${message.author}: ` : ''}${message.text}`)
|
|
12314
|
+
.join('\n');
|
|
12315
|
+
return [
|
|
12316
|
+
`Continue the existing ${session.issue.key} session for one turn in its Factory-owned Slack thread.`,
|
|
12317
|
+
`Slack channel mount: /slack/channels/${session.context.channelDir ?? 'unknown'}`,
|
|
12318
|
+
`Slack thread timestamp: ${session.externalId}`,
|
|
12319
|
+
'The session history, issue, branch, worktree, and open PR remain authoritative. Re-hydrate current repository state before changing code and do not repeat completed work.',
|
|
12320
|
+
...(session.history.length > 0
|
|
12321
|
+
? ['', 'Earlier human messages in this Slack conversation:', renderMessages(session.history.slice(-20))]
|
|
12322
|
+
: []),
|
|
12323
|
+
'',
|
|
12324
|
+
'New human messages coalesced into this turn, in order:',
|
|
12325
|
+
renderMessages(delivered),
|
|
12326
|
+
'',
|
|
12327
|
+
'Address every new message. Reply in the same Slack thread through the connected Slack mount/writeback surface, then continue or report the issue work as appropriate.',
|
|
12328
|
+
'This input was supplied atomically as a fresh resume task. Do not request or depend on live PTY/session injection.',
|
|
12329
|
+
].join('\n');
|
|
12330
|
+
};
|
|
11588
12331
|
const isFactoryQuestionTarget = (target) => {
|
|
11589
12332
|
const normalized = target.trim().replace(/^@/u, '').toLowerCase();
|
|
11590
12333
|
return normalized === 'broker' || normalized === 'factory';
|
|
@@ -11680,8 +12423,6 @@ const clarificationStaleSlackText = (waiting, stakeholderUserIds = []) => [
|
|
|
11680
12423
|
`Question from ${waiting.askerName}: ${waiting.question} Reply in this thread to wake the saved agent team, or move the issue out of Agent Implementing to cancel the wake.`,
|
|
11681
12424
|
].filter((line) => Boolean(line)).join('\n');
|
|
11682
12425
|
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
11683
|
-
const isOwnSlackBotReply = (payload, botUserId) => payload.user_is_bot === true ||
|
|
11684
|
-
stringValue(payload.user) === botUserId;
|
|
11685
12426
|
const unrefDelay = (ms) => new Promise((resolve) => {
|
|
11686
12427
|
const timer = setTimeout(resolve, ms);
|
|
11687
12428
|
timer.unref?.();
|