@agent-relay/factory 0.1.38 → 0.1.40
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 +965 -148
- 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 +67 -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 +18 -3
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +209 -2
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/in-memory-state-store.d.ts +15 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +111 -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,
|
|
@@ -2250,7 +2326,7 @@ export class FactoryLoop {
|
|
|
2250
2326
|
const batch = await this.#batch();
|
|
2251
2327
|
const agents = [];
|
|
2252
2328
|
let hasNonterminalDurableLifecycle = false;
|
|
2253
|
-
const durableLifecycles = await this.#state.listDispatchLifecycles(this.#workspaceId);
|
|
2329
|
+
const durableLifecycles = await this.#deduplicateQueuedGithubLifecycleAliases(await this.#state.listDispatchLifecycles(this.#workspaceId));
|
|
2254
2330
|
this.#logger.info?.('[factory] durable startup adoption loaded', {
|
|
2255
2331
|
lifecycles: durableLifecycles.length,
|
|
2256
2332
|
});
|
|
@@ -2342,6 +2418,64 @@ export class FactoryLoop {
|
|
|
2342
2418
|
this.#logger.warn?.('[factory] failed to re-adopt durable in-flight agents', { error });
|
|
2343
2419
|
}
|
|
2344
2420
|
}
|
|
2421
|
+
async #deduplicateQueuedGithubLifecycleAliases(lifecycles) {
|
|
2422
|
+
const groups = new Map();
|
|
2423
|
+
for (const entry of lifecycles) {
|
|
2424
|
+
if (isTerminalDispatchLifecycle(entry[1]))
|
|
2425
|
+
continue;
|
|
2426
|
+
const identity = githubIssueRefIdentity(entry[1].issue);
|
|
2427
|
+
if (!identity)
|
|
2428
|
+
continue;
|
|
2429
|
+
const group = groups.get(identity) ?? [];
|
|
2430
|
+
group.push(entry);
|
|
2431
|
+
groups.set(identity, group);
|
|
2432
|
+
}
|
|
2433
|
+
const clearedKeys = new Set();
|
|
2434
|
+
for (const [identity, group] of groups) {
|
|
2435
|
+
if (group.length < 2)
|
|
2436
|
+
continue;
|
|
2437
|
+
const active = group.filter(([, lifecycle]) => lifecycle.phase !== 'queued');
|
|
2438
|
+
// Two independently active aliases may each own useful work. Preserve
|
|
2439
|
+
// both for operator reconciliation instead of guessing which branch wins.
|
|
2440
|
+
if (active.length > 1) {
|
|
2441
|
+
this.#increment('dispatchLifecycleGithubAliasConflicts');
|
|
2442
|
+
this.#logger.warn?.('[factory] retained conflicting active GitHub lifecycle aliases', {
|
|
2443
|
+
identity,
|
|
2444
|
+
count: group.length,
|
|
2445
|
+
});
|
|
2446
|
+
continue;
|
|
2447
|
+
}
|
|
2448
|
+
const nowMs = this.#clock.now();
|
|
2449
|
+
const ownedElsewhere = group.some(([, lifecycle]) => lifecycle.lease &&
|
|
2450
|
+
lifecycle.lease.owner !== this.#dispatchLifecycleOwner &&
|
|
2451
|
+
lifecycle.lease.leaseUntilMs > nowMs);
|
|
2452
|
+
if (ownedElsewhere) {
|
|
2453
|
+
this.#increment('dispatchLifecycleGithubAliasDedupeDeferred');
|
|
2454
|
+
continue;
|
|
2455
|
+
}
|
|
2456
|
+
const winner = active[0] ?? [...group].sort(compareQueuedGithubLifecycleAliases)[0];
|
|
2457
|
+
for (const [key, lifecycle] of group) {
|
|
2458
|
+
if (key === winner[0] || lifecycle.phase !== 'queued')
|
|
2459
|
+
continue;
|
|
2460
|
+
const cleared = await this.#state.clearQueuedDispatchLifecycle(this.#workspaceId, key, lifecycle.lease);
|
|
2461
|
+
if (!cleared) {
|
|
2462
|
+
this.#increment('dispatchLifecycleGithubAliasDedupeDeferred');
|
|
2463
|
+
continue;
|
|
2464
|
+
}
|
|
2465
|
+
this.#dispatchLifecycleEpochs.delete(key);
|
|
2466
|
+
const timer = this.#dispatchLifecycleRetryTimers.get(key);
|
|
2467
|
+
if (timer)
|
|
2468
|
+
clearTimeout(timer);
|
|
2469
|
+
this.#dispatchLifecycleRetryTimers.delete(key);
|
|
2470
|
+
this.#dispatchLifecycleCapacityWaitLogged.delete(key);
|
|
2471
|
+
clearedKeys.add(key);
|
|
2472
|
+
this.#increment('dispatchLifecycleGithubAliasesCollapsed');
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
return clearedKeys.size > 0
|
|
2476
|
+
? lifecycles.filter(([key]) => !clearedKeys.has(key))
|
|
2477
|
+
: lifecycles;
|
|
2478
|
+
}
|
|
2345
2479
|
async #drainAgentExitsInFlight(names, timeoutMs) {
|
|
2346
2480
|
const drain = async () => {
|
|
2347
2481
|
for (;;) {
|
|
@@ -2637,6 +2771,19 @@ export class FactoryLoop {
|
|
|
2637
2771
|
acquiredNow = true;
|
|
2638
2772
|
}
|
|
2639
2773
|
if (lifecycle.phase === 'queued') {
|
|
2774
|
+
const liveIssue = await this.#readIssue(lifecycle.issue.path);
|
|
2775
|
+
if (liveIssue) {
|
|
2776
|
+
const dependencyAdmission = await this.#dependencyAdmission(liveIssue, lifecycle.decision);
|
|
2777
|
+
const batch = await this.#batch();
|
|
2778
|
+
if (dependencyAdmission.blockers.length > 0 || dependencyAdmission.cycle) {
|
|
2779
|
+
batch.queue(lifecycle.decision, lifecycle.dryRun, dependencyAdmission);
|
|
2780
|
+
const parked = batch.getParked(lifecycle.issue);
|
|
2781
|
+
if (parked)
|
|
2782
|
+
await this.#reportDependencyPark(liveIssue, parked, lifecycle.dryRun);
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
this.#clearDependencyPark(batch, lifecycle.issue);
|
|
2786
|
+
}
|
|
2640
2787
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
2641
2788
|
if (epoch === undefined || !await this.#state.promoteDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now())) {
|
|
2642
2789
|
throw new DispatchLifecycleCapacityError(`durable dispatch ${lifecycle.issue.key} is waiting for batch capacity`);
|
|
@@ -2954,6 +3101,9 @@ export class FactoryLoop {
|
|
|
2954
3101
|
if (issue && !isGithubIssue(issue)) {
|
|
2955
3102
|
await this.#recordCanonicalIssueState(issue);
|
|
2956
3103
|
}
|
|
3104
|
+
if (issue && this.#dependencyIssueIsTerminal(issue)) {
|
|
3105
|
+
await this.#markDependencyTerminalAndReconcile(issue);
|
|
3106
|
+
}
|
|
2957
3107
|
if (!issue || !this.#isIssueReady(issue)) {
|
|
2958
3108
|
return;
|
|
2959
3109
|
}
|
|
@@ -2982,14 +3132,7 @@ export class FactoryLoop {
|
|
|
2982
3132
|
this.#recordTriageEscalation(escalationDecision, escalationReason);
|
|
2983
3133
|
return;
|
|
2984
3134
|
}
|
|
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
|
-
}
|
|
3135
|
+
await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
|
|
2993
3136
|
}
|
|
2994
3137
|
catch (error) {
|
|
2995
3138
|
if (error instanceof LiveDispatchStateChangedError) {
|
|
@@ -3225,13 +3368,17 @@ export class FactoryLoop {
|
|
|
3225
3368
|
if (!ghIssue) {
|
|
3226
3369
|
return;
|
|
3227
3370
|
}
|
|
3371
|
+
const closed = githubIssueIsClosed(ghIssue);
|
|
3372
|
+
if (closed) {
|
|
3373
|
+
await this.#markDependencyTerminalAndReconcile(githubIssueAsFactoryIssue(ghIssue));
|
|
3374
|
+
}
|
|
3228
3375
|
if (await this.#issueSource() === 'github') {
|
|
3229
|
-
if (!
|
|
3376
|
+
if (!closed && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
|
|
3230
3377
|
await this.#handleChange(ghIssue.path);
|
|
3231
3378
|
}
|
|
3232
3379
|
return;
|
|
3233
3380
|
}
|
|
3234
|
-
if (
|
|
3381
|
+
if (closed) {
|
|
3235
3382
|
const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
|
|
3236
3383
|
if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
|
|
3237
3384
|
if (!opts.dryRun) {
|
|
@@ -3284,7 +3431,9 @@ export class FactoryLoop {
|
|
|
3284
3431
|
for (const candidatePath of candidatePaths) {
|
|
3285
3432
|
try {
|
|
3286
3433
|
const { content } = await this.#readRelayfileFile(candidatePath, 'GitHub issue ingestion');
|
|
3287
|
-
|
|
3434
|
+
const githubIssue = parseGithubIssue(candidatePath, content);
|
|
3435
|
+
this.#indexDependencyIssue(githubIssueAsFactoryIssue(githubIssue));
|
|
3436
|
+
return githubIssue;
|
|
3288
3437
|
}
|
|
3289
3438
|
catch (error) {
|
|
3290
3439
|
if (isMissingIssueFileError(error) && candidatePath !== candidatePaths.at(-1)) {
|
|
@@ -3373,6 +3522,248 @@ export class FactoryLoop {
|
|
|
3373
3522
|
});
|
|
3374
3523
|
return entry?.[0];
|
|
3375
3524
|
}
|
|
3525
|
+
#indexDependencyIssue(issue) {
|
|
3526
|
+
if (!issue)
|
|
3527
|
+
return;
|
|
3528
|
+
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
3529
|
+
const identity = dependencyIdentityForIssue(issue, repo);
|
|
3530
|
+
if (!identity)
|
|
3531
|
+
return;
|
|
3532
|
+
const rank = isGithubIssueFilePath(issue.path) ? 2 : 1;
|
|
3533
|
+
const existing = this.#dependencyIssues.get(identity);
|
|
3534
|
+
if (!existing || rank >= existing.rank) {
|
|
3535
|
+
this.#dependencyIssues.set(identity, { issue, rank });
|
|
3536
|
+
if (this.#dependencyIssueIsTerminal(issue)) {
|
|
3537
|
+
this.#terminalDependencyIdentities.add(identity);
|
|
3538
|
+
}
|
|
3539
|
+
else {
|
|
3540
|
+
this.#terminalDependencyIdentities.delete(identity);
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
async #dependencyAdmission(issue, decision) {
|
|
3545
|
+
const declared = parseBlockedBy(issue.description);
|
|
3546
|
+
if (declared.length === 0)
|
|
3547
|
+
return { blockers: [] };
|
|
3548
|
+
const currentRepo = dependencyRepoForIssue(issue, decision, this.#config);
|
|
3549
|
+
const resolved = declared
|
|
3550
|
+
.map((dependency) => resolveDependency(dependency, currentRepo))
|
|
3551
|
+
.filter((dependency) => Boolean(dependency));
|
|
3552
|
+
await this.#loadMissingDependencyIssues(resolved.map((dependency) => dependency.identity));
|
|
3553
|
+
const resolvedTerminal = new Set();
|
|
3554
|
+
for (const dependency of resolved) {
|
|
3555
|
+
if (await this.#dependencyIsTerminalOrMerged(dependency.identity)) {
|
|
3556
|
+
resolvedTerminal.add(dependency.identity);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
const blockers = resolved
|
|
3560
|
+
.filter((dependency) => !resolvedTerminal.has(dependency.identity))
|
|
3561
|
+
.map((dependency) => {
|
|
3562
|
+
const blocker = this.#dependencyIssues.get(dependency.identity)?.issue;
|
|
3563
|
+
return {
|
|
3564
|
+
identity: dependency.identity,
|
|
3565
|
+
key: blocker ? issueKey(issueRef(blocker)) : dependency.identity,
|
|
3566
|
+
label: dependency.label,
|
|
3567
|
+
};
|
|
3568
|
+
});
|
|
3569
|
+
// A bare #N without a resolvable single repository is itself unresolved.
|
|
3570
|
+
// Keep it visible and fail closed instead of guessing among routes.
|
|
3571
|
+
for (const dependency of declared) {
|
|
3572
|
+
if (dependency.repo || currentRepo)
|
|
3573
|
+
continue;
|
|
3574
|
+
blockers.push({
|
|
3575
|
+
identity: `unresolved:${issueKey(decision.issue)}:${dependency.raw}`,
|
|
3576
|
+
key: `unresolved:${dependency.raw}`,
|
|
3577
|
+
label: `${dependency.raw} (repository unresolved)`,
|
|
3578
|
+
});
|
|
3579
|
+
}
|
|
3580
|
+
const currentIdentity = dependencyIdentityForIssue(issue, currentRepo);
|
|
3581
|
+
const cycle = currentIdentity
|
|
3582
|
+
? findDependencyCycle(currentIdentity, this.#dependencyGraph())
|
|
3583
|
+
: undefined;
|
|
3584
|
+
return { blockers, cycle };
|
|
3585
|
+
}
|
|
3586
|
+
async #loadMissingDependencyIssues(identities) {
|
|
3587
|
+
const pending = [...new Set(identities)];
|
|
3588
|
+
const expanded = new Set();
|
|
3589
|
+
while (pending.length > 0) {
|
|
3590
|
+
const identity = pending.shift();
|
|
3591
|
+
if (expanded.has(identity))
|
|
3592
|
+
continue;
|
|
3593
|
+
expanded.add(identity);
|
|
3594
|
+
if (!this.#dependencyIssues.has(identity)) {
|
|
3595
|
+
// Startup discovery may use the ready-only GitHub issue index. A
|
|
3596
|
+
// blocker is often closed, in progress, or deliberately unlabeled, so
|
|
3597
|
+
// probe its stable by-id aliases directly before consulting that
|
|
3598
|
+
// filtered discovery set.
|
|
3599
|
+
const separator = identity.lastIndexOf('#');
|
|
3600
|
+
const repoIdentity = identity.slice(0, separator);
|
|
3601
|
+
const number = Number(identity.slice(separator + 1));
|
|
3602
|
+
const configuredRepo = configuredGithubRepoParts(this.#config)
|
|
3603
|
+
.find(({ owner, repo }) => `${owner}/${repo}`.toLowerCase() === repoIdentity);
|
|
3604
|
+
if (configuredRepo && Number.isSafeInteger(number) && number > 0) {
|
|
3605
|
+
const { owner, repo } = configuredRepo;
|
|
3606
|
+
for (const path of [
|
|
3607
|
+
`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`,
|
|
3608
|
+
`${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/by-id/${number}.json`,
|
|
3609
|
+
]) {
|
|
3610
|
+
await this.#readIssue(path);
|
|
3611
|
+
if (this.#dependencyIssues.has(identity))
|
|
3612
|
+
break;
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
if (!this.#dependencyIssues.has(identity)) {
|
|
3617
|
+
if (!this.#dependencyGithubPathsByIdentity) {
|
|
3618
|
+
this.#dependencyGithubPathsByIdentity = new Map();
|
|
3619
|
+
for (const path of await this.#githubIssuePaths()) {
|
|
3620
|
+
const parts = githubIssuePathParts(path);
|
|
3621
|
+
if (parts) {
|
|
3622
|
+
this.#dependencyGithubPathsByIdentity.set(githubIssueIdentity(parts.owner, parts.repo, parts.number), path);
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
const githubPath = this.#dependencyGithubPathsByIdentity.get(identity);
|
|
3627
|
+
if (githubPath)
|
|
3628
|
+
await this.#readIssue(githubPath);
|
|
3629
|
+
}
|
|
3630
|
+
if (!this.#dependencyIssues.has(identity) && !this.#dependencyLinearTreeLoaded) {
|
|
3631
|
+
// Linear paths do not encode the routed repository identity, so load
|
|
3632
|
+
// the tree once and let #indexDependencyIssue build the composite map.
|
|
3633
|
+
// The pass-scoped flag also lets every later blocked issue reuse the
|
|
3634
|
+
// resulting dependency index instead of rescanning the full tree.
|
|
3635
|
+
this.#dependencyLinearTreeLoaded = true;
|
|
3636
|
+
for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'dependency blocker discovery')) {
|
|
3637
|
+
if (isIssueFilePath(path))
|
|
3638
|
+
await this.#readIssue(path);
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
const blocker = this.#dependencyIssues.get(identity)?.issue;
|
|
3642
|
+
if (!blocker)
|
|
3643
|
+
continue;
|
|
3644
|
+
const repo = identity.slice(0, identity.lastIndexOf('#'));
|
|
3645
|
+
for (const dependency of parseBlockedBy(blocker.description)) {
|
|
3646
|
+
const resolved = resolveDependency(dependency, repo);
|
|
3647
|
+
if (resolved && !expanded.has(resolved.identity))
|
|
3648
|
+
pending.push(resolved.identity);
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
#clearDependencyPark(batch, issue) {
|
|
3653
|
+
batch.clearPark(issue);
|
|
3654
|
+
this.#dependencyParkNotices.delete(issueKey(issue));
|
|
3655
|
+
}
|
|
3656
|
+
#dependencyGraph() {
|
|
3657
|
+
const graph = new Map();
|
|
3658
|
+
for (const [identity, { issue }] of this.#dependencyIssues) {
|
|
3659
|
+
if (this.#dependencyIdentityIsTerminal(identity)) {
|
|
3660
|
+
graph.set(identity, []);
|
|
3661
|
+
continue;
|
|
3662
|
+
}
|
|
3663
|
+
const repo = identity.slice(0, identity.lastIndexOf('#'));
|
|
3664
|
+
const dependencies = parseBlockedBy(issue.description)
|
|
3665
|
+
.map((dependency) => resolveDependency(dependency, repo))
|
|
3666
|
+
.filter((dependency) => Boolean(dependency))
|
|
3667
|
+
.filter((dependency) => !this.#dependencyIdentityIsTerminal(dependency.identity))
|
|
3668
|
+
.map((dependency) => dependency.identity);
|
|
3669
|
+
graph.set(identity, [...new Set(dependencies)]);
|
|
3670
|
+
}
|
|
3671
|
+
return graph;
|
|
3672
|
+
}
|
|
3673
|
+
#dependencyIdentityIsTerminal(identity) {
|
|
3674
|
+
if (this.#terminalDependencyIdentities.has(identity))
|
|
3675
|
+
return true;
|
|
3676
|
+
const issue = this.#dependencyIssues.get(identity)?.issue;
|
|
3677
|
+
return Boolean(issue && this.#dependencyIssueIsTerminal(issue));
|
|
3678
|
+
}
|
|
3679
|
+
async #dependencyIsTerminalOrMerged(identity) {
|
|
3680
|
+
if (this.#dependencyIdentityIsTerminal(identity))
|
|
3681
|
+
return true;
|
|
3682
|
+
const issue = this.#dependencyIssues.get(identity)?.issue;
|
|
3683
|
+
if (!issue)
|
|
3684
|
+
return false;
|
|
3685
|
+
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
3686
|
+
if (!repo)
|
|
3687
|
+
return false;
|
|
3688
|
+
const pullRequest = await resolveIssuePrFromMount(this.#mount, this.#config, issue, {
|
|
3689
|
+
allowLegacyGithubBranch: true,
|
|
3690
|
+
repo,
|
|
3691
|
+
});
|
|
3692
|
+
if (normalizePrState(pullRequest?.state) !== 'MERGED')
|
|
3693
|
+
return false;
|
|
3694
|
+
this.#terminalDependencyIdentities.add(identity);
|
|
3695
|
+
return true;
|
|
3696
|
+
}
|
|
3697
|
+
#dependencyIssueIsTerminal(issue) {
|
|
3698
|
+
return isGithubIssue(issue)
|
|
3699
|
+
? githubFactoryIssueIsClosed(issue)
|
|
3700
|
+
: this.#states.isRole(issue.stateId, 'done');
|
|
3701
|
+
}
|
|
3702
|
+
async #reportDependencyPark(issue, parked, dryRun) {
|
|
3703
|
+
const cycle = parked.cycle?.join(' -> ');
|
|
3704
|
+
const blockers = parked.blockers.map((blocker) => blocker.label);
|
|
3705
|
+
const signature = JSON.stringify({ blockers, cycle, capacityBlocked: parked.capacityBlocked });
|
|
3706
|
+
const marker = `<!-- factory-dependency-park:${stableHash(signature)} -->`;
|
|
3707
|
+
const comment = parked.cycle
|
|
3708
|
+
? [
|
|
3709
|
+
marker,
|
|
3710
|
+
'Factory refused dispatch because it detected a dependency cycle.',
|
|
3711
|
+
`Cycle: ${cycle}`,
|
|
3712
|
+
blockers.length > 0 ? `Unresolved blockers: ${blockers.join(', ')}` : undefined,
|
|
3713
|
+
].filter((line) => Boolean(line)).join('\n')
|
|
3714
|
+
: [
|
|
3715
|
+
marker,
|
|
3716
|
+
'Factory parked this issue because declared dependencies are unresolved.',
|
|
3717
|
+
`Blocked by: ${blockers.join(', ')}`,
|
|
3718
|
+
parked.capacityBlocked ? 'Capacity is also currently unavailable.' : undefined,
|
|
3719
|
+
].filter((line) => Boolean(line)).join('\n');
|
|
3720
|
+
const key = issueKey(parked.issue);
|
|
3721
|
+
if (dryRun || this.#dependencyParkNotices.get(key) === signature)
|
|
3722
|
+
return comment;
|
|
3723
|
+
this.#increment(parked.cycle ? 'dependencyCycles' : 'dependencyParks');
|
|
3724
|
+
if (parked.cycle) {
|
|
3725
|
+
this.#error(new Error(`Dependency cycle detected: ${cycle}`), parked.issue);
|
|
3726
|
+
}
|
|
3727
|
+
try {
|
|
3728
|
+
await this.#postIssueComment(issue, comment);
|
|
3729
|
+
this.#dependencyParkNotices.set(key, signature);
|
|
3730
|
+
}
|
|
3731
|
+
catch (error) {
|
|
3732
|
+
this.#logger.warn?.('[factory] dependency park writeback skipped', {
|
|
3733
|
+
issue: parked.issue.key,
|
|
3734
|
+
error: describeError(error).errorMessage,
|
|
3735
|
+
});
|
|
3736
|
+
}
|
|
3737
|
+
return comment;
|
|
3738
|
+
}
|
|
3739
|
+
async #markDependencyTerminalAndReconcile(issue) {
|
|
3740
|
+
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
3741
|
+
const identity = dependencyIdentityForIssue(issue, repo);
|
|
3742
|
+
if (!identity)
|
|
3743
|
+
return;
|
|
3744
|
+
this.#indexDependencyIssue(issue);
|
|
3745
|
+
// A merge event can prove terminality before the issue snapshot reflects
|
|
3746
|
+
// its new state, so apply the explicit observation after indexing it.
|
|
3747
|
+
this.#terminalDependencyIdentities.add(identity);
|
|
3748
|
+
const batch = await this.#batch();
|
|
3749
|
+
this.#clearDependencyPark(batch, issueRef(issue));
|
|
3750
|
+
const candidates = batch.parked.filter((parked) => parked.blockers.some((blocker) => blocker.identity === identity));
|
|
3751
|
+
for (const parked of candidates) {
|
|
3752
|
+
try {
|
|
3753
|
+
const liveIssue = await this.#readIssue(parked.issue.path);
|
|
3754
|
+
if (!liveIssue || !this.#isIssueReady(liveIssue))
|
|
3755
|
+
continue;
|
|
3756
|
+
await this.dispatch(parked.decision, { dryRun: parked.dryRun });
|
|
3757
|
+
}
|
|
3758
|
+
catch (error) {
|
|
3759
|
+
this.#logger.warn?.('[factory] dependency park reconciliation failed', {
|
|
3760
|
+
blocker: identity,
|
|
3761
|
+
issue: parked.issue.key,
|
|
3762
|
+
error: describeError(error).errorMessage,
|
|
3763
|
+
});
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3376
3767
|
async #dispatchBlockReason(issue) {
|
|
3377
3768
|
const key = issueStateKey(issue);
|
|
3378
3769
|
const state = await this.#state.getDispatchAttempts(this.#workspaceId, key);
|
|
@@ -3657,12 +4048,14 @@ export class FactoryLoop {
|
|
|
3657
4048
|
// (relayfile-adapters#205). The factory matches state by UUID, so backfill
|
|
3658
4049
|
// the id from the name when the payload omitted it — otherwise every issue
|
|
3659
4050
|
// reads as stateId='' and no role (incl. readyForAgent) ever matches.
|
|
4051
|
+
let resolvedIssue = issue;
|
|
3660
4052
|
if (issue && !issue.stateId && issue.state?.name) {
|
|
3661
4053
|
const backfilled = this.#states.idForName(issue.state.name, issue.team);
|
|
3662
4054
|
if (backfilled)
|
|
3663
|
-
|
|
4055
|
+
resolvedIssue = { ...issue, stateId: backfilled };
|
|
3664
4056
|
}
|
|
3665
|
-
|
|
4057
|
+
this.#indexDependencyIssue(resolvedIssue);
|
|
4058
|
+
return resolvedIssue;
|
|
3666
4059
|
}
|
|
3667
4060
|
catch (error) {
|
|
3668
4061
|
if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
|
|
@@ -4104,7 +4497,7 @@ export class FactoryLoop {
|
|
|
4104
4497
|
let publishedPr;
|
|
4105
4498
|
if (exiting?.spec.role === 'implementer' &&
|
|
4106
4499
|
!record.dryRun &&
|
|
4107
|
-
|
|
4500
|
+
this.#shouldAttemptPullRequestPublication()) {
|
|
4108
4501
|
try {
|
|
4109
4502
|
await this.#saveDispatchLifecycle(record, 'publishing');
|
|
4110
4503
|
publishedPr = await this.#publishImplementerPullRequest(record, exiting);
|
|
@@ -4339,11 +4732,14 @@ export class FactoryLoop {
|
|
|
4339
4732
|
}
|
|
4340
4733
|
// Publish a PR from the implementer's committed branch when it exited without
|
|
4341
4734
|
// opening one. Best-effort and idempotent: returns undefined when there is no
|
|
4342
|
-
//
|
|
4343
|
-
//
|
|
4344
|
-
//
|
|
4735
|
+
// clone or nothing publishable (no branch / no commits ahead of base —
|
|
4736
|
+
// `#publishImplementerPullRequest` refuses head==base), so the caller falls
|
|
4737
|
+
// back to its normal restart/conclude handling. Explicit app identity errors
|
|
4738
|
+
// remain fail-closed and propagate to the lifecycle error path.
|
|
4345
4739
|
async #tryPublishImplementerPr(record, implementer) {
|
|
4346
|
-
if (record.dryRun ||
|
|
4740
|
+
if (record.dryRun ||
|
|
4741
|
+
(!implementer.spec.clonePath && !implementer.spec.branch) ||
|
|
4742
|
+
!this.#shouldAttemptPullRequestPublication()) {
|
|
4347
4743
|
return undefined;
|
|
4348
4744
|
}
|
|
4349
4745
|
try {
|
|
@@ -4363,6 +4759,8 @@ export class FactoryLoop {
|
|
|
4363
4759
|
return published;
|
|
4364
4760
|
}
|
|
4365
4761
|
catch (error) {
|
|
4762
|
+
if (this.#config.github.identity === 'app' && !this.#mount.githubWrite)
|
|
4763
|
+
throw error;
|
|
4366
4764
|
this.#increment('exitPrPublishSkipped');
|
|
4367
4765
|
this.#logger.warn?.('[factory] could not publish implementer PR on exit; falling back', {
|
|
4368
4766
|
issue: record.issue.key,
|
|
@@ -4378,10 +4776,7 @@ export class FactoryLoop {
|
|
|
4378
4776
|
const cached = this.#publishedPullRequests.get(key);
|
|
4379
4777
|
if (cached)
|
|
4380
4778
|
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
|
-
}
|
|
4779
|
+
const { identity, publisher } = this.#githubPullRequestPublisher();
|
|
4385
4780
|
const remoteBranch = implementer.result?.locality === 'remote' && implementer.spec.branch
|
|
4386
4781
|
? implementer.spec.branch
|
|
4387
4782
|
: undefined;
|
|
@@ -4419,32 +4814,67 @@ export class FactoryLoop {
|
|
|
4419
4814
|
}
|
|
4420
4815
|
}
|
|
4421
4816
|
const baseRef = await this.#githubDefaultBranch(repo);
|
|
4422
|
-
const result = await
|
|
4817
|
+
const result = await publisher.publishPullRequest({
|
|
4423
4818
|
repo,
|
|
4424
4819
|
...(remoteBranch ? { headRef: remoteBranch } : { clonePath: implementer.spec.clonePath }),
|
|
4425
4820
|
baseRef,
|
|
4426
4821
|
title: `${issue.key}: ${issue.title}`,
|
|
4427
4822
|
body: githubPullRequestBody(issue),
|
|
4428
4823
|
});
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4824
|
+
const published = result.author
|
|
4825
|
+
? result
|
|
4826
|
+
: { ...result, author: identity };
|
|
4827
|
+
if (published.repo.toLowerCase() !== repo.toLowerCase() ||
|
|
4828
|
+
published.headRef !== (remoteBranch ?? published.headRef) ||
|
|
4829
|
+
!Number.isInteger(published.number) ||
|
|
4830
|
+
published.number <= 0 ||
|
|
4831
|
+
!published.url) {
|
|
4434
4832
|
throw new Error(`GitHub PR publication returned an unexpected receipt for ${repo}/${remoteBranch ?? 'local HEAD'}`);
|
|
4435
4833
|
}
|
|
4436
4834
|
if (remoteBranch && this.#mount.writebackTransport === 'relayfile-cloud') {
|
|
4437
|
-
await this.#confirmPublishedRemotePullRequest(repo,
|
|
4835
|
+
await this.#confirmPublishedRemotePullRequest(repo, published, remoteBranch);
|
|
4438
4836
|
}
|
|
4439
|
-
this.#publishedPullRequests.set(key,
|
|
4837
|
+
this.#publishedPullRequests.set(key, published);
|
|
4440
4838
|
this.#increment('githubPullRequestsPublished');
|
|
4441
|
-
this.#logger.info?.('[factory] published PR
|
|
4839
|
+
this.#logger.info?.('[factory] published PR', {
|
|
4442
4840
|
issue: issue.key,
|
|
4443
|
-
repo:
|
|
4444
|
-
prNumber:
|
|
4445
|
-
url:
|
|
4841
|
+
repo: published.repo,
|
|
4842
|
+
prNumber: published.number,
|
|
4843
|
+
url: published.url,
|
|
4844
|
+
identity,
|
|
4845
|
+
author: published.author,
|
|
4446
4846
|
});
|
|
4447
|
-
return
|
|
4847
|
+
return published;
|
|
4848
|
+
}
|
|
4849
|
+
#githubPullRequestPublisher() {
|
|
4850
|
+
const configured = this.#config.github.identity;
|
|
4851
|
+
if (configured !== 'user' && this.#mount.githubWrite) {
|
|
4852
|
+
return { identity: 'app', publisher: this.#mount.githubWrite };
|
|
4853
|
+
}
|
|
4854
|
+
if (configured === 'app') {
|
|
4855
|
+
throw new Error('GitHub PR identity "app" requires a connected workspace GitHub App write path; refusing to fall back to the local gh user');
|
|
4856
|
+
}
|
|
4857
|
+
const publishPullRequest = this.#githubWriteback.publishPullRequest;
|
|
4858
|
+
if (!publishPullRequest) {
|
|
4859
|
+
throw new Error(`GitHub PR identity "${configured}" requires local gh user publication, but the configured GitHub writeback does not support it`);
|
|
4860
|
+
}
|
|
4861
|
+
return {
|
|
4862
|
+
identity: 'user',
|
|
4863
|
+
publisher: { publishPullRequest: publishPullRequest.bind(this.#githubWriteback) },
|
|
4864
|
+
};
|
|
4865
|
+
}
|
|
4866
|
+
#shouldAttemptPullRequestPublication() {
|
|
4867
|
+
if (this.#config.github.identity !== 'auto')
|
|
4868
|
+
return true;
|
|
4869
|
+
if (this.#mount.githubWrite)
|
|
4870
|
+
return true;
|
|
4871
|
+
// Fake mounts deliberately avoid invoking the host's real gh binary unless
|
|
4872
|
+
// a publisher was injected. Every production mount (cloud or custom) still
|
|
4873
|
+
// gets the configured auto fallback.
|
|
4874
|
+
if (this.#mount.writebackTransport === 'test') {
|
|
4875
|
+
return Boolean(this.#githubWritebackProvided && this.#githubWriteback.publishPullRequest);
|
|
4876
|
+
}
|
|
4877
|
+
return true;
|
|
4448
4878
|
}
|
|
4449
4879
|
async #openPullRequestByHead(repo, expectedHeadRef) {
|
|
4450
4880
|
if (this.#hasProbePrGhRunner) {
|
|
@@ -6385,27 +6815,38 @@ export class FactoryLoop {
|
|
|
6385
6815
|
const implementerNames = decision.implementers.map((implementer) => implementer.name);
|
|
6386
6816
|
const reviewerName = decision.reviewer.name;
|
|
6387
6817
|
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
6388
|
-
const render = (spec) =>
|
|
6389
|
-
|
|
6390
|
-
|
|
6818
|
+
const render = async (spec) => {
|
|
6819
|
+
const route = routeForSpec(decision, spec);
|
|
6820
|
+
const testGuidance = await resolveTestGuidance({
|
|
6821
|
+
repoPath: route.clonePath,
|
|
6391
6822
|
issue: templateIssue,
|
|
6392
|
-
route
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
|
-
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
|
|
6823
|
+
route,
|
|
6824
|
+
previewUrl: previewUrlFromSpec(spec),
|
|
6825
|
+
});
|
|
6826
|
+
return {
|
|
6827
|
+
...spec,
|
|
6828
|
+
task: renderAgentTask({
|
|
6829
|
+
issue: templateIssue,
|
|
6830
|
+
route,
|
|
6831
|
+
role: spec.role,
|
|
6832
|
+
config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
|
|
6833
|
+
reviewerName,
|
|
6834
|
+
implementerNames,
|
|
6835
|
+
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
6836
|
+
integrationInstructions,
|
|
6837
|
+
testGuidance,
|
|
6838
|
+
branchName: spec.branch ?? decision.implementers.find((candidate) => candidate.repo === spec.repo)?.branch,
|
|
6839
|
+
branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
|
|
6840
|
+
agentName: spec.name,
|
|
6841
|
+
...(this.#fleet.lifecycleActionName ? { lifecycleActionName: this.#fleet.lifecycleActionName } : {}),
|
|
6842
|
+
}),
|
|
6843
|
+
};
|
|
6844
|
+
};
|
|
6845
|
+
const rendered = await Promise.all([...decision.implementers, decision.reviewer].map(render));
|
|
6405
6846
|
return {
|
|
6406
6847
|
...decision,
|
|
6407
|
-
implementers: decision.implementers.
|
|
6408
|
-
reviewer:
|
|
6848
|
+
implementers: rendered.slice(0, decision.implementers.length),
|
|
6849
|
+
reviewer: rendered[decision.implementers.length],
|
|
6409
6850
|
};
|
|
6410
6851
|
}
|
|
6411
6852
|
#consumePendingDispatchClarifications(issue) {
|
|
@@ -7214,6 +7655,7 @@ export class FactoryLoop {
|
|
|
7214
7655
|
await this.#recordCanonicalIssueState({ ...issueRef(issue), stateId: doneStateId });
|
|
7215
7656
|
}
|
|
7216
7657
|
this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
|
|
7658
|
+
await this.#markDependencyTerminalAndReconcile(issue);
|
|
7217
7659
|
this.#increment('mergedPrAdvancedDone');
|
|
7218
7660
|
this.#increment('done');
|
|
7219
7661
|
this.#logger.info?.('[factory] merged PR advanced issue to Done', {
|
|
@@ -7367,6 +7809,7 @@ export class FactoryLoop {
|
|
|
7367
7809
|
});
|
|
7368
7810
|
this.#babysitterSpawned.add(babysitterKey);
|
|
7369
7811
|
await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
|
|
7812
|
+
await this.#retargetSlackConversationToBabysitter(record);
|
|
7370
7813
|
return;
|
|
7371
7814
|
}
|
|
7372
7815
|
// Reserve up-front so concurrent PR events in a drain don't double-spawn.
|
|
@@ -7413,9 +7856,19 @@ export class FactoryLoop {
|
|
|
7413
7856
|
.filter((agent) => agent.spec.role === 'implementer')
|
|
7414
7857
|
.map((agent) => agent.result?.name ?? agent.spec.name);
|
|
7415
7858
|
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
7859
|
+
const templateIssue = templateIssueFromRecord(record, issue);
|
|
7860
|
+
const prSummary = await this.#github.getPr(prRef.repo, prRef.prNumber).catch(() => undefined);
|
|
7861
|
+
const taskRoute = { ...(route ?? { repo: prRef.repo }), clonePath: spec.clonePath ?? route?.clonePath };
|
|
7862
|
+
const testGuidance = await resolveTestGuidance({
|
|
7863
|
+
repoPath: taskRoute.clonePath,
|
|
7864
|
+
issue: templateIssue,
|
|
7865
|
+
route: taskRoute,
|
|
7866
|
+
changedFiles: prSummary?.filesChanged,
|
|
7867
|
+
previewUrl: previewUrlFromSpec(spec),
|
|
7868
|
+
});
|
|
7416
7869
|
const task = renderAgentTask({
|
|
7417
|
-
issue:
|
|
7418
|
-
route:
|
|
7870
|
+
issue: templateIssue,
|
|
7871
|
+
route: taskRoute,
|
|
7419
7872
|
role: 'babysitter',
|
|
7420
7873
|
config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
|
|
7421
7874
|
reviewerName,
|
|
@@ -7424,6 +7877,7 @@ export class FactoryLoop {
|
|
|
7424
7877
|
slackDispatchThread: await this.#slackDispatchThreadFor(record),
|
|
7425
7878
|
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
7426
7879
|
integrationInstructions,
|
|
7880
|
+
testGuidance,
|
|
7427
7881
|
branchName: spec.branch,
|
|
7428
7882
|
branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
|
|
7429
7883
|
agentName: spec.name,
|
|
@@ -7442,6 +7896,7 @@ export class FactoryLoop {
|
|
|
7442
7896
|
agentName: tracked?.result?.name ?? spawned.name,
|
|
7443
7897
|
});
|
|
7444
7898
|
await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
|
|
7899
|
+
await this.#retargetSlackConversationToBabysitter(record);
|
|
7445
7900
|
await this.#writeInFlightRegistry();
|
|
7446
7901
|
if (!await this.#saveDispatchLifecycle(record, 'running'))
|
|
7447
7902
|
return;
|
|
@@ -7781,6 +8236,8 @@ export class FactoryLoop {
|
|
|
7781
8236
|
await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
|
|
7782
8237
|
}
|
|
7783
8238
|
this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
|
|
8239
|
+
if (!humanReview)
|
|
8240
|
+
await this.#markDependencyTerminalAndReconcile(issue);
|
|
7784
8241
|
}
|
|
7785
8242
|
if (!await this.#saveDispatchLifecycle(record, 'writeback-applied'))
|
|
7786
8243
|
return;
|
|
@@ -7940,8 +8397,8 @@ export class FactoryLoop {
|
|
|
7940
8397
|
});
|
|
7941
8398
|
this.#emit('error', { error, ...describeError(error), issue });
|
|
7942
8399
|
}
|
|
7943
|
-
#increment(name) {
|
|
7944
|
-
this.#counters[name] = (this.#counters[name] ?? 0) +
|
|
8400
|
+
#increment(name, amount = 1) {
|
|
8401
|
+
this.#counters[name] = (this.#counters[name] ?? 0) + amount;
|
|
7945
8402
|
}
|
|
7946
8403
|
#recordTriageEscalation(decision, reason) {
|
|
7947
8404
|
this.#increment('triageEscalations');
|
|
@@ -8177,7 +8634,11 @@ export class FactoryLoop {
|
|
|
8177
8634
|
// the reply watcher instead of returning early, otherwise human replies in
|
|
8178
8635
|
// the existing thread are watched by nobody and silently dropped.
|
|
8179
8636
|
if (existingThread) {
|
|
8180
|
-
await this.#
|
|
8637
|
+
const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(existingThread));
|
|
8638
|
+
await this.#ensureSlackConversationSession(record, existingThread);
|
|
8639
|
+
await this.#rearmSlackWatcher(record, existingThread, {
|
|
8640
|
+
replayConversationReplies: Boolean(durableConversation),
|
|
8641
|
+
});
|
|
8181
8642
|
}
|
|
8182
8643
|
return;
|
|
8183
8644
|
}
|
|
@@ -8207,9 +8668,223 @@ export class FactoryLoop {
|
|
|
8207
8668
|
].join('\n'),
|
|
8208
8669
|
});
|
|
8209
8670
|
await this.#state.setSlackThread(this.#workspaceId, issueKey(record.issue), root.threadId);
|
|
8671
|
+
await this.#ensureSlackConversationSession(record, root.threadId);
|
|
8210
8672
|
await this.#watchSlackThread(record, root.threadId);
|
|
8211
8673
|
this.#recordSlackWritebackSuccess('dispatch-thread');
|
|
8212
8674
|
}
|
|
8675
|
+
// Once a PR is open, the babysitter is the one driving further conversation
|
|
8676
|
+
// with the human — prefer it over the implementer that originally opened the
|
|
8677
|
+
// Slack thread, matching the pre-conversation-resume routing that sent human
|
|
8678
|
+
// replies to both roles once a PR existed.
|
|
8679
|
+
#slackConversationOwnerCandidate(record) {
|
|
8680
|
+
const candidates = [...record.agents.entries()].map(([name, tracked]) => ({ name, tracked }));
|
|
8681
|
+
return candidates.find(({ tracked }) => tracked.spec.role === 'babysitter' && Boolean(tracked.sessionRef))
|
|
8682
|
+
?? candidates.find(({ tracked }) => tracked.spec.role === 'implementer' && Boolean(tracked.sessionRef));
|
|
8683
|
+
}
|
|
8684
|
+
async #ensureSlackConversationSession(record, threadId, options = {}) {
|
|
8685
|
+
const conversationId = slackConversationId(threadId);
|
|
8686
|
+
const owned = this.#slackConversationOwnerCandidate(record);
|
|
8687
|
+
const existing = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8688
|
+
if (existing) {
|
|
8689
|
+
const sessionRef = owned?.tracked.sessionRef;
|
|
8690
|
+
const agentName = owned ? (owned.tracked.result?.name ?? owned.name) : undefined;
|
|
8691
|
+
if (owned && sessionRef &&
|
|
8692
|
+
(agentName !== existing.agent.name || (options.forceAgentRebind === true && sessionRef !== existing.agent.sessionRef))) {
|
|
8693
|
+
const rebound = await this.#state.rebindConversationSession(this.#workspaceId, conversationId, {
|
|
8694
|
+
name: agentName,
|
|
8695
|
+
sessionRef,
|
|
8696
|
+
node: owned.tracked.result?.node ?? owned.tracked.spec.node,
|
|
8697
|
+
capability: owned.tracked.spec.capability,
|
|
8698
|
+
repo: owned.tracked.spec.repo,
|
|
8699
|
+
clonePath: owned.tracked.spec.clonePath,
|
|
8700
|
+
});
|
|
8701
|
+
if (rebound)
|
|
8702
|
+
this.#increment('slackConversationSessionsRebound');
|
|
8703
|
+
}
|
|
8704
|
+
if (existing.pending.length > 0 || existing.delivery) {
|
|
8705
|
+
const waiting = await this.#state.getWaitingClarification(this.#workspaceId, issueKey(existing.issue));
|
|
8706
|
+
if (!waiting)
|
|
8707
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
8708
|
+
}
|
|
8709
|
+
return;
|
|
8710
|
+
}
|
|
8711
|
+
const sessionRef = owned?.tracked.sessionRef;
|
|
8712
|
+
if (!owned || !sessionRef) {
|
|
8713
|
+
this.#increment('slackConversationSessionsSkippedMissingSession');
|
|
8714
|
+
return;
|
|
8715
|
+
}
|
|
8716
|
+
const channelDir = await this.#slackChannelDir() ?? this.#config.slack?.channel;
|
|
8717
|
+
if (!channelDir)
|
|
8718
|
+
return;
|
|
8719
|
+
const agentName = owned.tracked.result?.name ?? owned.name;
|
|
8720
|
+
const reserved = await this.#state.reserveConversationSession(this.#workspaceId, conversationId, {
|
|
8721
|
+
provider: 'slack',
|
|
8722
|
+
issue: { ...record.issue },
|
|
8723
|
+
externalId: threadId,
|
|
8724
|
+
context: { channelDir },
|
|
8725
|
+
agent: {
|
|
8726
|
+
name: agentName,
|
|
8727
|
+
sessionRef,
|
|
8728
|
+
node: owned.tracked.result?.node ?? owned.tracked.spec.node,
|
|
8729
|
+
capability: owned.tracked.spec.capability,
|
|
8730
|
+
repo: owned.tracked.spec.repo,
|
|
8731
|
+
clonePath: owned.tracked.spec.clonePath,
|
|
8732
|
+
},
|
|
8733
|
+
history: [],
|
|
8734
|
+
processedMessageIds: [],
|
|
8735
|
+
pending: [],
|
|
8736
|
+
});
|
|
8737
|
+
if (reserved)
|
|
8738
|
+
this.#increment('slackConversationSessionsOwned');
|
|
8739
|
+
}
|
|
8740
|
+
// Called right after a babysitter is spawned/reattached for an issue's PR so
|
|
8741
|
+
// an already-owned Slack conversation session (reserved earlier by the
|
|
8742
|
+
// implementer) retargets onto the babysitter for the next turn, instead of
|
|
8743
|
+
// resuming a session the babysitter has taken over from.
|
|
8744
|
+
async #retargetSlackConversationToBabysitter(record) {
|
|
8745
|
+
if (!this.#slack || !this.#config.slack)
|
|
8746
|
+
return;
|
|
8747
|
+
const threadId = await this.#persistedSlackThread(issueKey(record.issue));
|
|
8748
|
+
if (!threadId)
|
|
8749
|
+
return;
|
|
8750
|
+
await this.#ensureSlackConversationSession(record, threadId, { forceAgentRebind: true });
|
|
8751
|
+
}
|
|
8752
|
+
async #scheduleSlackConversationIfPending(threadId) {
|
|
8753
|
+
const conversationId = slackConversationId(threadId);
|
|
8754
|
+
const session = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8755
|
+
if (session && (session.pending.length > 0 || session.delivery)) {
|
|
8756
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
8757
|
+
}
|
|
8758
|
+
}
|
|
8759
|
+
async #resumeSlackConversationTurn(conversationId) {
|
|
8760
|
+
if (this.#stopping)
|
|
8761
|
+
return;
|
|
8762
|
+
const claimId = randomUUID();
|
|
8763
|
+
const claimed = await this.#state.claimConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now(), SLACK_CONVERSATION_TURN_LEASE_MS);
|
|
8764
|
+
if (!claimed?.delivery) {
|
|
8765
|
+
const current = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8766
|
+
if (current && (current.pending.length > 0 || current.delivery)) {
|
|
8767
|
+
this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
|
|
8768
|
+
}
|
|
8769
|
+
return;
|
|
8770
|
+
}
|
|
8771
|
+
if (!await this.#ownsActiveSlackConversationIssue(claimed.issue)) {
|
|
8772
|
+
await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
|
|
8773
|
+
this.#increment('slackConversationTurnsSuppressedStaleOwner');
|
|
8774
|
+
return;
|
|
8775
|
+
}
|
|
8776
|
+
let leaseLost = false;
|
|
8777
|
+
let renewalInFlight = false;
|
|
8778
|
+
const heartbeat = setInterval(() => {
|
|
8779
|
+
if (renewalInFlight || leaseLost || this.#stopping)
|
|
8780
|
+
return;
|
|
8781
|
+
renewalInFlight = true;
|
|
8782
|
+
void this.#state.renewConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now()).then((renewed) => { leaseLost = !renewed; })
|
|
8783
|
+
.catch((error) => this.#logger.warn?.('[factory] Slack conversation lease renewal failed', {
|
|
8784
|
+
conversationId,
|
|
8785
|
+
error: describeError(error).errorMessage,
|
|
8786
|
+
}))
|
|
8787
|
+
.finally(() => { renewalInFlight = false; });
|
|
8788
|
+
}, Math.max(1_000, Math.floor(SLACK_CONVERSATION_TURN_LEASE_MS / 3)));
|
|
8789
|
+
heartbeat.unref?.();
|
|
8790
|
+
try {
|
|
8791
|
+
const result = await this.#fleet.resume({
|
|
8792
|
+
name: claimed.agent.name,
|
|
8793
|
+
sessionRef: claimed.agent.sessionRef,
|
|
8794
|
+
node: claimed.agent.node ?? 'self',
|
|
8795
|
+
capability: claimed.agent.capability,
|
|
8796
|
+
repo: claimed.agent.repo,
|
|
8797
|
+
clonePath: claimed.agent.clonePath,
|
|
8798
|
+
task: slackConversationResumeTask(claimed),
|
|
8799
|
+
});
|
|
8800
|
+
const completed = await this.#state.completeConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, { name: result.name, sessionRef: result.sessionRef });
|
|
8801
|
+
// The token-fenced state mutation is authoritative. A renewal already
|
|
8802
|
+
// queued behind this completion can observe the cleared delivery and set
|
|
8803
|
+
// leaseLost even though completion committed successfully.
|
|
8804
|
+
if (!completed) {
|
|
8805
|
+
this.#increment('slackConversationTurnOwnershipLost');
|
|
8806
|
+
return;
|
|
8807
|
+
}
|
|
8808
|
+
await this.#recordSlackConversationResume(claimed, result);
|
|
8809
|
+
this.#increment('slackConversationTurnsResumed');
|
|
8810
|
+
if (claimed.delivery.messages.length > 1) {
|
|
8811
|
+
this.#increment('slackConversationRepliesCoalesced', claimed.delivery.messages.length - 1);
|
|
8812
|
+
}
|
|
8813
|
+
}
|
|
8814
|
+
catch (error) {
|
|
8815
|
+
try {
|
|
8816
|
+
await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
|
|
8817
|
+
}
|
|
8818
|
+
catch (releaseError) {
|
|
8819
|
+
this.#logger.warn?.('[factory] Slack conversation claim release failed; waiting for lease recovery', {
|
|
8820
|
+
conversationId,
|
|
8821
|
+
error: describeError(releaseError).errorMessage,
|
|
8822
|
+
});
|
|
8823
|
+
}
|
|
8824
|
+
this.#increment('slackConversationTurnResumeFailures');
|
|
8825
|
+
this.#logger.warn?.('[factory] Slack conversation resume failed; retaining the turn for retry', {
|
|
8826
|
+
issue: claimed.issue.key,
|
|
8827
|
+
threadId: claimed.externalId,
|
|
8828
|
+
sessionRef: claimed.agent.sessionRef,
|
|
8829
|
+
error: describeError(error).errorMessage,
|
|
8830
|
+
});
|
|
8831
|
+
this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
|
|
8832
|
+
return;
|
|
8833
|
+
}
|
|
8834
|
+
finally {
|
|
8835
|
+
clearInterval(heartbeat);
|
|
8836
|
+
}
|
|
8837
|
+
const remaining = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8838
|
+
if (remaining && (remaining.pending.length > 0 || remaining.delivery)) {
|
|
8839
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
8840
|
+
}
|
|
8841
|
+
}
|
|
8842
|
+
async #recordSlackConversationResume(session, result) {
|
|
8843
|
+
const record = (await this.#batch()).getIssue(session.issue);
|
|
8844
|
+
if (!record)
|
|
8845
|
+
return;
|
|
8846
|
+
const entry = [...record.agents.entries()].find(([name, tracked]) => name === session.agent.name || tracked.result?.name === session.agent.name);
|
|
8847
|
+
if (!entry)
|
|
8848
|
+
return;
|
|
8849
|
+
const [previousName, tracked] = entry;
|
|
8850
|
+
tracked.result = {
|
|
8851
|
+
...tracked.result,
|
|
8852
|
+
...result,
|
|
8853
|
+
node: result.node ?? tracked.result?.node,
|
|
8854
|
+
locality: result.locality ?? tracked.result?.locality,
|
|
8855
|
+
};
|
|
8856
|
+
tracked.sessionRef = result.sessionRef ?? tracked.sessionRef;
|
|
8857
|
+
if (result.name !== previousName) {
|
|
8858
|
+
record.agents.delete(previousName);
|
|
8859
|
+
record.agents.set(result.name, tracked);
|
|
8860
|
+
}
|
|
8861
|
+
try {
|
|
8862
|
+
await this.#writeInFlightRegistry();
|
|
8863
|
+
await this.#saveDispatchLifecycle(record, 'running');
|
|
8864
|
+
}
|
|
8865
|
+
catch (error) {
|
|
8866
|
+
this.#logger.warn?.('[factory] Slack conversation resume bookkeeping failed', {
|
|
8867
|
+
issue: session.issue.key,
|
|
8868
|
+
agent: result.name,
|
|
8869
|
+
error: describeError(error).errorMessage,
|
|
8870
|
+
});
|
|
8871
|
+
}
|
|
8872
|
+
}
|
|
8873
|
+
async #ownsActiveSlackConversationIssue(issue) {
|
|
8874
|
+
if (!(await this.#batch()).getIssue(issue))
|
|
8875
|
+
return false;
|
|
8876
|
+
const key = issueKey(issue);
|
|
8877
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
8878
|
+
if (!lifecycle)
|
|
8879
|
+
return true;
|
|
8880
|
+
if (lifecycle.phase === 'releasing' || isTerminalDispatchLifecycle(lifecycle))
|
|
8881
|
+
return false;
|
|
8882
|
+
if (!lifecycle.lease)
|
|
8883
|
+
return false;
|
|
8884
|
+
return lifecycle.lease.owner === this.#dispatchLifecycleOwner &&
|
|
8885
|
+
lifecycle.lease.epoch === this.#dispatchLifecycleEpochs.get(key) &&
|
|
8886
|
+
lifecycle.lease.leaseUntilMs > this.#clock.now();
|
|
8887
|
+
}
|
|
8213
8888
|
async #escalateTriage(decision, reason, dryRun) {
|
|
8214
8889
|
if (dryRun) {
|
|
8215
8890
|
return;
|
|
@@ -8494,7 +9169,7 @@ export class FactoryLoop {
|
|
|
8494
9169
|
this.#recordSlackWritebackSuccess('triage-escalation');
|
|
8495
9170
|
return replayedResult;
|
|
8496
9171
|
}
|
|
8497
|
-
async #watchSlackThread(record, threadId) {
|
|
9172
|
+
async #watchSlackThread(record, threadId, options = {}) {
|
|
8498
9173
|
if (!this.#config.slack) {
|
|
8499
9174
|
return;
|
|
8500
9175
|
}
|
|
@@ -8506,12 +9181,14 @@ export class FactoryLoop {
|
|
|
8506
9181
|
const messagesPrefix = slackChannelMessagesPrefix(channelDir);
|
|
8507
9182
|
const preExistingPaths = new Set();
|
|
8508
9183
|
const preExistingPathOrder = [];
|
|
9184
|
+
const preExistingEvents = [];
|
|
8509
9185
|
const seenReplies = new Set();
|
|
8510
9186
|
const seenReplyMessages = new Set();
|
|
8511
9187
|
let missingIdentityLogged = false;
|
|
8512
9188
|
let cursor;
|
|
8513
9189
|
let stopped = false;
|
|
8514
9190
|
let pollTimer;
|
|
9191
|
+
const routeRetryTimers = new Map();
|
|
8515
9192
|
const markPreExisting = async () => {
|
|
8516
9193
|
try {
|
|
8517
9194
|
const page = await this.#mount.getEvents({ limit: SLACK_REPLY_EVENTS_LIMIT });
|
|
@@ -8521,6 +9198,7 @@ export class FactoryLoop {
|
|
|
8521
9198
|
if (path && path.startsWith(messagesPrefix)) {
|
|
8522
9199
|
preExistingPaths.add(path);
|
|
8523
9200
|
preExistingPathOrder.push(path);
|
|
9201
|
+
preExistingEvents.push(event);
|
|
8524
9202
|
}
|
|
8525
9203
|
}
|
|
8526
9204
|
}
|
|
@@ -8528,7 +9206,8 @@ export class FactoryLoop {
|
|
|
8528
9206
|
this.#logger.warn?.('[factory] unable to seed Slack reply watcher event cursor', error);
|
|
8529
9207
|
}
|
|
8530
9208
|
};
|
|
8531
|
-
const handle = async (event) => {
|
|
9209
|
+
const handle = async (event, allowPreExisting = false) => {
|
|
9210
|
+
const routeRetryKey = eventIdentity(event) ?? changeEventPath(event) ?? randomUUID();
|
|
8532
9211
|
try {
|
|
8533
9212
|
// Polling-fallback / degraded-sync events can lack `resource.path`
|
|
8534
9213
|
// despite the SDK type. Skip them quietly so one malformed event never
|
|
@@ -8544,7 +9223,7 @@ export class FactoryLoop {
|
|
|
8544
9223
|
this.#logger.warn?.('[factory] Slack reply event missing stable identity; falling back to path/content dedupe');
|
|
8545
9224
|
}
|
|
8546
9225
|
}
|
|
8547
|
-
if (preExistingPaths.has(path)) {
|
|
9226
|
+
if (!allowPreExisting && preExistingPaths.has(path)) {
|
|
8548
9227
|
return;
|
|
8549
9228
|
}
|
|
8550
9229
|
const reply = await this.#readSlackReply(path);
|
|
@@ -8561,21 +9240,34 @@ export class FactoryLoop {
|
|
|
8561
9240
|
this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path });
|
|
8562
9241
|
return;
|
|
8563
9242
|
}
|
|
8564
|
-
seenReplies.add(replyKey);
|
|
8565
9243
|
if (reply.isBot) {
|
|
9244
|
+
seenReplies.add(replyKey);
|
|
9245
|
+
seenReplyMessages.add(replyMessageKey);
|
|
8566
9246
|
return;
|
|
8567
9247
|
}
|
|
8568
|
-
seenReplyMessages.add(replyMessageKey);
|
|
8569
9248
|
await this.#routeSlackAnswerToImplementers(record, reply);
|
|
9249
|
+
// Acknowledge in memory only after the durable route succeeds. A read or
|
|
9250
|
+
// state-store failure must remain replayable by the poll/subscription.
|
|
9251
|
+
seenReplies.add(replyKey);
|
|
9252
|
+
seenReplyMessages.add(replyMessageKey);
|
|
8570
9253
|
}
|
|
8571
9254
|
catch (error) {
|
|
8572
9255
|
this.#logger.error?.('[factory] failed to handle Slack reply event', error);
|
|
9256
|
+
if (!stopped && !routeRetryTimers.has(routeRetryKey)) {
|
|
9257
|
+
const retryTimer = setTimeout(() => {
|
|
9258
|
+
routeRetryTimers.delete(routeRetryKey);
|
|
9259
|
+
if (!stopped)
|
|
9260
|
+
void handle(event, true);
|
|
9261
|
+
}, SLACK_REPLY_ROUTE_RETRY_MS);
|
|
9262
|
+
retryTimer.unref?.();
|
|
9263
|
+
routeRetryTimers.set(routeRetryKey, retryTimer);
|
|
9264
|
+
}
|
|
8573
9265
|
}
|
|
8574
9266
|
};
|
|
8575
9267
|
await markPreExisting();
|
|
8576
9268
|
let subscription;
|
|
8577
9269
|
try {
|
|
8578
|
-
subscription = this.#mount.subscribe([
|
|
9270
|
+
subscription = this.#mount.subscribe([slackThreadReplyGlob(channelDir)], (event) => {
|
|
8579
9271
|
// Receipt time is independent of the provider-authored sync timestamp.
|
|
8580
9272
|
// A healthy webhook can therefore override a frozen advisory status
|
|
8581
9273
|
// without trusting the same field that declared the provider stale.
|
|
@@ -8615,9 +9307,16 @@ export class FactoryLoop {
|
|
|
8615
9307
|
clearTimeout(pollTimer);
|
|
8616
9308
|
pollTimer = undefined;
|
|
8617
9309
|
}
|
|
9310
|
+
for (const retryTimer of routeRetryTimers.values())
|
|
9311
|
+
clearTimeout(retryTimer);
|
|
9312
|
+
routeRetryTimers.clear();
|
|
8618
9313
|
await this.#boundedStopTeardown('Slack reply subscription unsubscribe', () => subscription?.unsubscribe());
|
|
8619
9314
|
},
|
|
8620
9315
|
});
|
|
9316
|
+
if (options.replayConversationReplies) {
|
|
9317
|
+
for (const event of preExistingEvents)
|
|
9318
|
+
await handle(event, true);
|
|
9319
|
+
}
|
|
8621
9320
|
return await this.#replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPathOrder);
|
|
8622
9321
|
}
|
|
8623
9322
|
async #replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPaths) {
|
|
@@ -8646,13 +9345,13 @@ export class FactoryLoop {
|
|
|
8646
9345
|
// exists (persisted thread id) but has no in-process watcher. #watchSlackThread
|
|
8647
9346
|
// is itself idempotent on #slackWatchers; the guard here keeps the counter (and
|
|
8648
9347
|
// the seeding getEvents call) limited to genuine re-arms.
|
|
8649
|
-
async #rearmSlackWatcher(record, threadId) {
|
|
9348
|
+
async #rearmSlackWatcher(record, threadId, options = {}) {
|
|
8650
9349
|
const key = issueKey(record.issue);
|
|
8651
9350
|
if (this.#slackWatchers.has(key) || this.#slackWatcherStarts.has(key)) {
|
|
8652
9351
|
return;
|
|
8653
9352
|
}
|
|
8654
9353
|
try {
|
|
8655
|
-
await this.#watchSlackThread(record, threadId);
|
|
9354
|
+
await this.#watchSlackThread(record, threadId, options);
|
|
8656
9355
|
this.#increment('slackWatchersRearmed');
|
|
8657
9356
|
}
|
|
8658
9357
|
catch (error) {
|
|
@@ -8667,7 +9366,8 @@ export class FactoryLoop {
|
|
|
8667
9366
|
if (!this.#slack || !this.#config.slack) {
|
|
8668
9367
|
return;
|
|
8669
9368
|
}
|
|
8670
|
-
|
|
9369
|
+
const batch = await this.#batch();
|
|
9370
|
+
for (const record of batch.inFlight) {
|
|
8671
9371
|
if (record.dryRun) {
|
|
8672
9372
|
continue;
|
|
8673
9373
|
}
|
|
@@ -8686,7 +9386,27 @@ export class FactoryLoop {
|
|
|
8686
9386
|
if (!threadId) {
|
|
8687
9387
|
continue;
|
|
8688
9388
|
}
|
|
8689
|
-
await this.#
|
|
9389
|
+
const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(threadId));
|
|
9390
|
+
await this.#ensureSlackConversationSession(record, threadId);
|
|
9391
|
+
await this.#rearmSlackWatcher(record, threadId, {
|
|
9392
|
+
replayConversationReplies: Boolean(durableConversation),
|
|
9393
|
+
});
|
|
9394
|
+
}
|
|
9395
|
+
for (const [conversationId, session] of await this.#state.listConversationSessions(this.#workspaceId)) {
|
|
9396
|
+
if (session.provider !== 'slack')
|
|
9397
|
+
continue;
|
|
9398
|
+
const threadId = session.externalId;
|
|
9399
|
+
const record = batch.getIssue(session.issue);
|
|
9400
|
+
const key = issueKey(session.issue);
|
|
9401
|
+
if (record && !record.dryRun && !this.#slackWatchers.has(key) && !this.#slackWatcherStarts.has(key)) {
|
|
9402
|
+
await this.#state.setSlackThread(this.#workspaceId, key, threadId);
|
|
9403
|
+
await this.#ensureSlackConversationSession(record, threadId);
|
|
9404
|
+
await this.#rearmSlackWatcher(record, threadId, { replayConversationReplies: true });
|
|
9405
|
+
}
|
|
9406
|
+
const waiting = await this.#state.getWaitingClarification(this.#workspaceId, key);
|
|
9407
|
+
if (record && !waiting && (session.pending.length > 0 || session.delivery)) {
|
|
9408
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
9409
|
+
}
|
|
8690
9410
|
}
|
|
8691
9411
|
await this.#sweepWaitingClarifications();
|
|
8692
9412
|
for (const [, waiting] of await this.#state.listWaitingClarifications(this.#workspaceId)) {
|
|
@@ -8833,13 +9553,19 @@ export class FactoryLoop {
|
|
|
8833
9553
|
const key = issueKey(issue);
|
|
8834
9554
|
const watcher = this.#slackWatchers.get(key);
|
|
8835
9555
|
this.#slackWatchers.delete(key);
|
|
8836
|
-
await this.#state.
|
|
9556
|
+
const threadId = await this.#state.getSlackThread(this.#workspaceId, key);
|
|
8837
9557
|
await watcher?.stop();
|
|
9558
|
+
if (threadId) {
|
|
9559
|
+
const conversationId = slackConversationId(threadId);
|
|
9560
|
+
await this.#slackConversationTurns.cancel(conversationId);
|
|
9561
|
+
await this.#state.clearConversationSession(this.#workspaceId, conversationId);
|
|
9562
|
+
}
|
|
9563
|
+
await this.#state.clearSlackThread(this.#workspaceId, key);
|
|
8838
9564
|
}
|
|
8839
9565
|
async #readSlackReply(path) {
|
|
8840
9566
|
try {
|
|
8841
9567
|
const { content } = await this.#mount.readFile(path);
|
|
8842
|
-
return
|
|
9568
|
+
return parseSlackThreadReply(path, content, this.#config.slack?.botUserId ?? 'U0B2596R7EZ');
|
|
8843
9569
|
}
|
|
8844
9570
|
catch (error) {
|
|
8845
9571
|
this.#logger.warn?.(`Unable to read Slack reply ${path}`, error);
|
|
@@ -8862,20 +9588,61 @@ export class FactoryLoop {
|
|
|
8862
9588
|
return;
|
|
8863
9589
|
}
|
|
8864
9590
|
if (waiting?.threadId === reply.threadTs) {
|
|
9591
|
+
const replyId = `${reply.threadTs}:${reply.messageTs}`;
|
|
8865
9592
|
const claimed = await this.#state.claimClarificationReply(this.#workspaceId, clarificationKey, {
|
|
8866
|
-
id:
|
|
9593
|
+
id: replyId,
|
|
8867
9594
|
text,
|
|
8868
|
-
receivedAtMs: this.#clock.now(),
|
|
9595
|
+
receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
|
|
8869
9596
|
source: 'slack',
|
|
9597
|
+
author: reply.author,
|
|
8870
9598
|
});
|
|
8871
9599
|
if (!claimed) {
|
|
8872
|
-
this.#
|
|
9600
|
+
const currentWaiting = await this.#state.getWaitingClarification(this.#workspaceId, clarificationKey);
|
|
9601
|
+
if (currentWaiting?.reply?.id === replyId) {
|
|
9602
|
+
this.#increment('clarificationDuplicateWakesSuppressed');
|
|
9603
|
+
return;
|
|
9604
|
+
}
|
|
9605
|
+
// The first reply wakes the parked clarification team. Additional
|
|
9606
|
+
// rapid-fire replies are ordinary conversation turns and must not be
|
|
9607
|
+
// discarded merely because the one-shot clarification slot is full.
|
|
9608
|
+
const conversationId = slackConversationId(reply.threadTs);
|
|
9609
|
+
const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
|
|
9610
|
+
id: replyId,
|
|
9611
|
+
text,
|
|
9612
|
+
receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
|
|
9613
|
+
providerSequence: reply.messageTs,
|
|
9614
|
+
author: reply.author,
|
|
9615
|
+
});
|
|
9616
|
+
if (queued) {
|
|
9617
|
+
this.#increment('slackConversationRepliesQueued');
|
|
9618
|
+
}
|
|
9619
|
+
else {
|
|
9620
|
+
this.#increment('slackConversationDuplicateRepliesSuppressed');
|
|
9621
|
+
}
|
|
8873
9622
|
return;
|
|
8874
9623
|
}
|
|
8875
9624
|
this.#increment('clarificationRepliesClaimed');
|
|
8876
9625
|
await this.#wakeWaitingClarification(clarificationKey, claimed);
|
|
8877
9626
|
return;
|
|
8878
9627
|
}
|
|
9628
|
+
const conversationId = slackConversationId(reply.threadTs);
|
|
9629
|
+
const conversation = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
9630
|
+
if (conversation && issueKey(conversation.issue) === clarificationKey) {
|
|
9631
|
+
const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
|
|
9632
|
+
id: `${reply.threadTs}:${reply.messageTs}`,
|
|
9633
|
+
text,
|
|
9634
|
+
receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
|
|
9635
|
+
providerSequence: reply.messageTs,
|
|
9636
|
+
author: reply.author,
|
|
9637
|
+
});
|
|
9638
|
+
if (!queued) {
|
|
9639
|
+
this.#increment('slackConversationDuplicateRepliesSuppressed');
|
|
9640
|
+
return;
|
|
9641
|
+
}
|
|
9642
|
+
this.#increment('slackConversationRepliesQueued');
|
|
9643
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
9644
|
+
return;
|
|
9645
|
+
}
|
|
8879
9646
|
const liveRecord = (await this.#batch()).getIssue(record.issue);
|
|
8880
9647
|
if (!liveRecord || liveRecord.dryRun) {
|
|
8881
9648
|
if (isTriageEscalationWatchRecord(record)) {
|
|
@@ -8884,23 +9651,7 @@ export class FactoryLoop {
|
|
|
8884
9651
|
this.#increment('slackAnswersIgnoredNoInFlight');
|
|
8885
9652
|
return;
|
|
8886
9653
|
}
|
|
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
|
-
}
|
|
9654
|
+
this.#increment('slackAnswersIgnoredNoConversationSession');
|
|
8904
9655
|
}
|
|
8905
9656
|
async #wakeWaitingClarification(key, waiting) {
|
|
8906
9657
|
const existing = this.#clarificationWakeInFlight.get(key);
|
|
@@ -9056,10 +9807,15 @@ export class FactoryLoop {
|
|
|
9056
9807
|
await renewLease();
|
|
9057
9808
|
await this.#writeInFlightRegistry();
|
|
9058
9809
|
await this.#saveDispatchLifecycle(record, 'running');
|
|
9810
|
+
if (waiting.threadId) {
|
|
9811
|
+
await this.#ensureSlackConversationSession(record, waiting.threadId, { forceAgentRebind: true });
|
|
9812
|
+
}
|
|
9059
9813
|
await renewLease();
|
|
9060
9814
|
const completed = await this.#state.completeClarificationWake(this.#workspaceId, key, this.#clarificationWakeOwner);
|
|
9061
9815
|
if (!completed)
|
|
9062
9816
|
throw new ClarificationWakeLeaseLostError('clarification wake completion lost ownership');
|
|
9817
|
+
if (waiting.threadId)
|
|
9818
|
+
await this.#scheduleSlackConversationIfPending(waiting.threadId);
|
|
9063
9819
|
this.#increment('clarificationTeamsWoken');
|
|
9064
9820
|
this.#logger.info?.('[factory] restarted team after human clarification', {
|
|
9065
9821
|
issue: waiting.issue.key,
|
|
@@ -9299,14 +10055,6 @@ export class FactoryLoop {
|
|
|
9299
10055
|
this.#emit('issue-queued', { issue: decision.issue });
|
|
9300
10056
|
}
|
|
9301
10057
|
}
|
|
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
10058
|
// Absolute path to the local .integrations mount the daemon manages. The mount
|
|
9311
10059
|
// is created at the daemon's cwd (see ensureLocalMount), and spawned agents run
|
|
9312
10060
|
// in their repo clonePath, so writeback paths handed to agents must be absolute
|
|
@@ -9706,6 +10454,57 @@ const githubIssueSourceRef = (issue) => {
|
|
|
9706
10454
|
}
|
|
9707
10455
|
return { owner, repo, number: number, url };
|
|
9708
10456
|
};
|
|
10457
|
+
function dependencyRepoForIssue(issue, decision, config) {
|
|
10458
|
+
const source = githubIssueSourceRef(issue);
|
|
10459
|
+
if (source)
|
|
10460
|
+
return `${source.owner}/${source.repo}`;
|
|
10461
|
+
const mirroredRepo = githubMirrorRepoForIssue(issue);
|
|
10462
|
+
if (mirroredRepo)
|
|
10463
|
+
return mirroredRepo;
|
|
10464
|
+
const normalize = (repo) => {
|
|
10465
|
+
if (!repo)
|
|
10466
|
+
return undefined;
|
|
10467
|
+
try {
|
|
10468
|
+
return normalizeGithubRepo(repo, config.repos.org);
|
|
10469
|
+
}
|
|
10470
|
+
catch {
|
|
10471
|
+
return undefined;
|
|
10472
|
+
}
|
|
10473
|
+
};
|
|
10474
|
+
const decisionRepos = new Set((decision?.routes ?? [])
|
|
10475
|
+
.map((route) => normalize(route.repo))
|
|
10476
|
+
.filter((repo) => Boolean(repo)));
|
|
10477
|
+
if (decisionRepos.size === 1)
|
|
10478
|
+
return [...decisionRepos][0];
|
|
10479
|
+
if (decisionRepos.size > 1)
|
|
10480
|
+
return undefined;
|
|
10481
|
+
const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
|
|
10482
|
+
const labelRepos = new Set(Object.entries(config.repos.byLabel)
|
|
10483
|
+
.filter(([label]) => labels.has(label.trim().toLowerCase()))
|
|
10484
|
+
.map(([, repo]) => normalize(repo))
|
|
10485
|
+
.filter((repo) => Boolean(repo)));
|
|
10486
|
+
if (labelRepos.size === 1)
|
|
10487
|
+
return [...labelRepos][0];
|
|
10488
|
+
if (labelRepos.size > 1)
|
|
10489
|
+
return undefined;
|
|
10490
|
+
const projectRepo = issue.project
|
|
10491
|
+
? Object.entries(config.repos.byProject)
|
|
10492
|
+
.find(([project]) => project.trim().toLowerCase() === issue.project.trim().toLowerCase())?.[1]
|
|
10493
|
+
: undefined;
|
|
10494
|
+
return normalize(projectRepo) ?? normalize(config.repos.default);
|
|
10495
|
+
}
|
|
10496
|
+
function dependencyIdentityForIssue(issue, repo) {
|
|
10497
|
+
const source = githubIssueSourceRef(issue);
|
|
10498
|
+
if (source)
|
|
10499
|
+
return dependencyIdentity(`${source.owner}/${source.repo}`, source.number);
|
|
10500
|
+
const pathParts = githubIssuePathParts(issue.path);
|
|
10501
|
+
if (pathParts)
|
|
10502
|
+
return dependencyIdentity(`${pathParts.owner}/${pathParts.repo}`, pathParts.number);
|
|
10503
|
+
const number = Number(issue.key.match(/(\d+)$/u)?.[1]);
|
|
10504
|
+
return repo && Number.isSafeInteger(number) && number > 0
|
|
10505
|
+
? dependencyIdentity(repo, number)
|
|
10506
|
+
: undefined;
|
|
10507
|
+
}
|
|
9709
10508
|
const githubIssueAuthor = (issue) => {
|
|
9710
10509
|
const payload = wrappedPayload(issue.raw);
|
|
9711
10510
|
const source = asRecord(payload.source);
|
|
@@ -10107,10 +10906,14 @@ const routeForSpec = (decision, spec) => {
|
|
|
10107
10906
|
const route = decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? decision.routes.find((candidate) => candidate.repo === spec.repo);
|
|
10108
10907
|
return {
|
|
10109
10908
|
repo: spec.repo,
|
|
10110
|
-
clonePath: spec.clonePath,
|
|
10909
|
+
clonePath: spec.clonePath ?? route?.clonePath,
|
|
10111
10910
|
rationale: route?.rationale,
|
|
10112
10911
|
};
|
|
10113
10912
|
};
|
|
10913
|
+
const previewUrlFromSpec = (spec) => {
|
|
10914
|
+
const previewUrl = spec.previewUrl;
|
|
10915
|
+
return typeof previewUrl === 'string' && previewUrl.trim() ? previewUrl.trim() : undefined;
|
|
10916
|
+
};
|
|
10114
10917
|
const repoMapFromConfig = (config) => {
|
|
10115
10918
|
const repos = new Set([
|
|
10116
10919
|
...Object.values(config.repos.byLabel),
|
|
@@ -10237,24 +11040,42 @@ const normalizeGithubIssueCommentWatch = (watch) => ({
|
|
|
10237
11040
|
processedCommentIds: [...new Set(watch.processedCommentIds ?? [])],
|
|
10238
11041
|
});
|
|
10239
11042
|
const githubIssueReadCandidatePaths = (path) => {
|
|
11043
|
+
const candidates = [];
|
|
10240
11044
|
if (path.endsWith('/')) {
|
|
10241
|
-
|
|
11045
|
+
candidates.push(`${path}meta.json`, `${path}metadata.json`);
|
|
10242
11046
|
}
|
|
10243
|
-
if (path.endsWith('/meta.json')) {
|
|
10244
|
-
|
|
11047
|
+
else if (path.endsWith('/meta.json')) {
|
|
11048
|
+
candidates.push(path, path.replace(/\/meta\.json$/u, '/metadata.json'));
|
|
10245
11049
|
}
|
|
10246
|
-
if (path.endsWith('/metadata.json')) {
|
|
10247
|
-
|
|
11050
|
+
else if (path.endsWith('/metadata.json')) {
|
|
11051
|
+
candidates.push(path, path.replace(/\/metadata\.json$/u, '/meta.json'));
|
|
10248
11052
|
}
|
|
10249
|
-
if (githubIssuePathParts(path)) {
|
|
10250
|
-
|
|
11053
|
+
else if (githubIssuePathParts(path)) {
|
|
11054
|
+
candidates.push(path);
|
|
10251
11055
|
}
|
|
10252
|
-
if (githubIssueDirectoryPathParts(path)) {
|
|
11056
|
+
else if (githubIssueDirectoryPathParts(path)) {
|
|
10253
11057
|
// meta.json is the canonical relayfile GitHub issue basename. metadata.json
|
|
10254
11058
|
// remains a legacy read fallback for older local mount-state snapshots.
|
|
10255
|
-
|
|
11059
|
+
candidates.push(`${path}/meta.json`, `${path}/metadata.json`);
|
|
10256
11060
|
}
|
|
10257
|
-
|
|
11061
|
+
else {
|
|
11062
|
+
candidates.push(path);
|
|
11063
|
+
}
|
|
11064
|
+
// Title-derived directories are mutable aliases. A renamed issue may leave
|
|
11065
|
+
// a durable lifecycle pointing at its old slug, while the adapter continues
|
|
11066
|
+
// to expose the stable public by-id aliases. Always include both supported
|
|
11067
|
+
// repository layouts so recovery never hot-loops on an obsolete title path.
|
|
11068
|
+
const parts = githubIssuePathParts(path) ?? githubIssueDirectoryPathParts(path);
|
|
11069
|
+
if (parts) {
|
|
11070
|
+
candidates.push(`${GITHUB_ISSUE_ROOT}/${parts.owner}__${parts.repo}/issues/by-id/${parts.number}.json`, `${GITHUB_ISSUE_ROOT}/${parts.owner}/${parts.repo}/issues/by-id/${parts.number}.json`);
|
|
11071
|
+
}
|
|
11072
|
+
return [...new Set(candidates)];
|
|
11073
|
+
};
|
|
11074
|
+
const compareQueuedGithubLifecycleAliases = (left, right) => {
|
|
11075
|
+
const stablePath = (lifecycle) => lifecycle.issue.path.includes('/issues/by-id/') ? 0 : 1;
|
|
11076
|
+
return stablePath(left[1]) - stablePath(right[1]) ||
|
|
11077
|
+
(right[1].updatedAtMs ?? 0) - (left[1].updatedAtMs ?? 0) ||
|
|
11078
|
+
left[0].localeCompare(right[0]);
|
|
10258
11079
|
};
|
|
10259
11080
|
const githubIssueDirectoryPathParts = (path) => {
|
|
10260
11081
|
const match = path.match(/^\/github\/repos\/(?:([^/]+)\/([^/]+)|([A-Za-z0-9-]+)__([^/]+))\/issues\/(\d+)(?:__([^/]+))?$/u);
|
|
@@ -10324,7 +11145,7 @@ const linearIssueMirrorsGithubIssue = (issue, ghIssue) => {
|
|
|
10324
11145
|
const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
|
|
10325
11146
|
const candidates = [];
|
|
10326
11147
|
const listErrors = [];
|
|
10327
|
-
for (const repo of reposFromConfig(config)) {
|
|
11148
|
+
for (const repo of opts.repo ? [opts.repo] : reposFromConfig(config)) {
|
|
10328
11149
|
const paths = new Set();
|
|
10329
11150
|
for (const root of githubPullRoots(repo)) {
|
|
10330
11151
|
try {
|
|
@@ -10549,7 +11370,7 @@ const readProbePrCandidate = async (mount, path) => {
|
|
|
10549
11370
|
booleanValue(payload.crossRepository) ??
|
|
10550
11371
|
(headRepo && baseRepo ? headRepo.toLowerCase() !== baseRepo.toLowerCase() : undefined),
|
|
10551
11372
|
draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
|
|
10552
|
-
state: stringValue(payload.state),
|
|
11373
|
+
state: booleanValue(payload.merged) === true ? 'MERGED' : stringValue(payload.state),
|
|
10553
11374
|
url: stringValue(payload.url) ?? stringValue(payload.html_url),
|
|
10554
11375
|
};
|
|
10555
11376
|
}
|
|
@@ -11244,8 +12065,12 @@ const defaultRestartPolicy = (spec) =>
|
|
|
11244
12065
|
spec.role === 'implementer' || spec.role === 'reviewer' || spec.role === 'babysitter'
|
|
11245
12066
|
? { maxRestarts: 0 }
|
|
11246
12067
|
: spec.restartPolicy;
|
|
11247
|
-
const slackPayloadTs = (threadId) => threadId.replace(/_/g, '.');
|
|
11248
12068
|
const slackChannelMessagesPrefix = (channelDir) => `/slack/channels/${channelDir}/messages/`;
|
|
12069
|
+
const slackConversationId = (threadId) => `slack:${threadId}`;
|
|
12070
|
+
const slackMessageReceivedAtMs = (messageTs, fallback) => {
|
|
12071
|
+
const seconds = Number(messageTs);
|
|
12072
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds * 1_000) : fallback;
|
|
12073
|
+
};
|
|
11249
12074
|
const eventIdentity = (event) => {
|
|
11250
12075
|
const record = event;
|
|
11251
12076
|
const rawId = record.id ?? record.event_id ?? record.seq;
|
|
@@ -11475,28 +12300,6 @@ const dispatchResultFromLifecycle = (lifecycle) => lifecycle.result ? structured
|
|
|
11475
12300
|
agents: lifecycle.agents.map(({ name, tracked }) => ({ name, role: tracked.spec.role })),
|
|
11476
12301
|
dryRun: lifecycle.dryRun,
|
|
11477
12302
|
};
|
|
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
12303
|
const triageEscalationReason = (decision) => {
|
|
11501
12304
|
const reasons = [];
|
|
11502
12305
|
if (decision.confidence === 'low') {
|
|
@@ -11569,11 +12372,6 @@ const issueWithGithubClarification = (issue, text) => ({
|
|
|
11569
12372
|
text,
|
|
11570
12373
|
].join('\n'),
|
|
11571
12374
|
});
|
|
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
12375
|
const clarificationResumeTask = (baseTask, waiting) => {
|
|
11578
12376
|
const reply = waiting.reply;
|
|
11579
12377
|
return [
|
|
@@ -11585,6 +12383,27 @@ const clarificationResumeTask = (baseTask, waiting) => {
|
|
|
11585
12383
|
'Re-hydrate from the issue, branch, worktree, and any open PR, then continue the task. Do not repeat completed work.',
|
|
11586
12384
|
].join('\n');
|
|
11587
12385
|
};
|
|
12386
|
+
const slackConversationResumeTask = (session) => {
|
|
12387
|
+
const delivered = session.delivery?.messages ?? [];
|
|
12388
|
+
const renderMessages = (messages) => messages
|
|
12389
|
+
.map((message, index) => `${index + 1}. ${message.author ? `${message.author}: ` : ''}${message.text}`)
|
|
12390
|
+
.join('\n');
|
|
12391
|
+
return [
|
|
12392
|
+
`Continue the existing ${session.issue.key} session for one turn in its Factory-owned Slack thread.`,
|
|
12393
|
+
`Slack channel mount: /slack/channels/${session.context.channelDir ?? 'unknown'}`,
|
|
12394
|
+
`Slack thread timestamp: ${session.externalId}`,
|
|
12395
|
+
'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.',
|
|
12396
|
+
...(session.history.length > 0
|
|
12397
|
+
? ['', 'Earlier human messages in this Slack conversation:', renderMessages(session.history.slice(-20))]
|
|
12398
|
+
: []),
|
|
12399
|
+
'',
|
|
12400
|
+
'New human messages coalesced into this turn, in order:',
|
|
12401
|
+
renderMessages(delivered),
|
|
12402
|
+
'',
|
|
12403
|
+
'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.',
|
|
12404
|
+
'This input was supplied atomically as a fresh resume task. Do not request or depend on live PTY/session injection.',
|
|
12405
|
+
].join('\n');
|
|
12406
|
+
};
|
|
11588
12407
|
const isFactoryQuestionTarget = (target) => {
|
|
11589
12408
|
const normalized = target.trim().replace(/^@/u, '').toLowerCase();
|
|
11590
12409
|
return normalized === 'broker' || normalized === 'factory';
|
|
@@ -11680,8 +12499,6 @@ const clarificationStaleSlackText = (waiting, stakeholderUserIds = []) => [
|
|
|
11680
12499
|
`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
12500
|
].filter((line) => Boolean(line)).join('\n');
|
|
11682
12501
|
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
11683
|
-
const isOwnSlackBotReply = (payload, botUserId) => payload.user_is_bot === true ||
|
|
11684
|
-
stringValue(payload.user) === botUserId;
|
|
11685
12502
|
const unrefDelay = (ms) => new Promise((resolve) => {
|
|
11686
12503
|
const timer = setTimeout(resolve, ms);
|
|
11687
12504
|
timer.unref?.();
|