@agent-relay/factory 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -11
- package/dist/cli/fleet.d.ts +6 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +74 -4
- 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/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +13 -0
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +1 -0
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +4 -0
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/github/repo-identity.d.ts +8 -0
- package/dist/github/repo-identity.d.ts.map +1 -0
- package/dist/github/repo-identity.js +28 -0
- package/dist/github/repo-identity.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/hosted/index.d.ts +5 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +3 -0
- package/dist/hosted/index.js.map +1 -0
- package/dist/hosted/orchestrator.d.ts +12 -0
- package/dist/hosted/orchestrator.d.ts.map +1 -0
- package/dist/hosted/orchestrator.js +513 -0
- package/dist/hosted/orchestrator.js.map +1 -0
- package/dist/hosted/state-store.d.ts +48 -0
- package/dist/hosted/state-store.d.ts.map +1 -0
- package/dist/hosted/state-store.js +210 -0
- package/dist/hosted/state-store.js.map +1 -0
- package/dist/hosted/types.d.ts +173 -0
- package/dist/hosted/types.d.ts.map +1 -0
- package/dist/hosted/types.js +2 -0
- package/dist/hosted/types.js.map +1 -0
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +20 -2
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +209 -22
- package/dist/mount/relayfile-cloud-mount-client.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 +70 -5
- 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 +1466 -224
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -0
- package/dist/orchestrator/index.d.ts.map +1 -1
- package/dist/orchestrator/index.js +1 -0
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/ports/mount.d.ts +2 -0
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +66 -4
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/ports/writeback.d.ts +3 -0
- package/dist/ports/writeback.d.ts.map +1 -1
- package/dist/state/file-state-store.d.ts +17 -3
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +204 -3
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/in-memory-state-store.d.ts +14 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +112 -1
- 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 +16 -16
- package/dist/types.d.ts +17 -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/dist/writeback/index.d.ts +2 -0
- package/dist/writeback/index.d.ts.map +1 -1
- package/dist/writeback/index.js +1 -0
- package/dist/writeback/index.js.map +1 -1
- package/dist/writeback/mount-health.d.ts +15 -0
- package/dist/writeback/mount-health.d.ts.map +1 -0
- package/dist/writeback/mount-health.js +15 -0
- package/dist/writeback/mount-health.js.map +1 -0
- package/package.json +13 -2
|
@@ -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';
|
|
@@ -85,7 +89,11 @@ const STOP_TEARDOWN_TIMEOUT_MS = 2_500;
|
|
|
85
89
|
const DISPATCH_LIFECYCLE_LEASE_MS = 5 * 60_000;
|
|
86
90
|
const DISPATCH_LIFECYCLE_RENEW_MS = 60_000;
|
|
87
91
|
const DISPATCH_LIFECYCLE_RETRY_MS = 1_000;
|
|
92
|
+
const STARTUP_AGENT_EXIT_DRAIN_TIMEOUT_MS = 30_000;
|
|
88
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;
|
|
89
97
|
const MERGE_GATE_MAX_ATTEMPTS = 12;
|
|
90
98
|
const MERGE_GATE_POLL_DELAY_MS = 10_000;
|
|
91
99
|
const MAX_LABEL_IMPLEMENTERS = 4;
|
|
@@ -99,6 +107,8 @@ const GITHUB_MIRROR_TITLE_PREFIX = '[factory]';
|
|
|
99
107
|
const GITHUB_MIRROR_SOURCE_PREFIX = 'Source: ';
|
|
100
108
|
export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
|
|
101
109
|
export const DEFAULT_FACTORY_LOOP_REGISTRY_PATH = '/tmp/factory-run/factory-loop-registry.json';
|
|
110
|
+
class DispatchLifecycleCapacityError extends Error {
|
|
111
|
+
}
|
|
102
112
|
const realClock = {
|
|
103
113
|
now: () => Date.now(),
|
|
104
114
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
@@ -113,7 +123,9 @@ export class FactoryLoop {
|
|
|
113
123
|
#fleet;
|
|
114
124
|
#triage;
|
|
115
125
|
#linear;
|
|
126
|
+
#github;
|
|
116
127
|
#githubWriteback;
|
|
128
|
+
#githubWritebackProvided;
|
|
117
129
|
#slack;
|
|
118
130
|
#mergeGate;
|
|
119
131
|
#probeCloser;
|
|
@@ -130,6 +142,7 @@ export class FactoryLoop {
|
|
|
130
142
|
#terminationGraceMs;
|
|
131
143
|
#babysitterWakeUnreachableEscalateMs;
|
|
132
144
|
#babysitterWakeUnreachableRetryMs;
|
|
145
|
+
#startupAgentExitDrainTimeoutMs;
|
|
133
146
|
#state;
|
|
134
147
|
#workspaceId;
|
|
135
148
|
#relayflows;
|
|
@@ -143,6 +156,8 @@ export class FactoryLoop {
|
|
|
143
156
|
#dispatchInFlight = new Map();
|
|
144
157
|
#slackWatchers = new Map();
|
|
145
158
|
#slackWatcherStarts = new Map();
|
|
159
|
+
#slackConversationTurns;
|
|
160
|
+
#slackConversationOwner = `${process.pid}:${randomUUID()}`;
|
|
146
161
|
#githubIssueCommentWatchers = new Map();
|
|
147
162
|
#githubIssueCommentWatchStates = new Map();
|
|
148
163
|
#githubIssueCommentQueues = new Map();
|
|
@@ -164,6 +179,13 @@ export class FactoryLoop {
|
|
|
164
179
|
// issue (or the comment writeback's own change event) does not re-post the
|
|
165
180
|
// same notice every cycle. Cleared once the issue dispatches successfully.
|
|
166
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;
|
|
167
189
|
#pendingSlackClarifications = new Map();
|
|
168
190
|
#pendingGithubClarifications = new Map();
|
|
169
191
|
#clarificationIntents = new Map();
|
|
@@ -176,6 +198,7 @@ export class FactoryLoop {
|
|
|
176
198
|
#dispatchTerminalWaiters = new Map();
|
|
177
199
|
#dispatchLifecycleRetryTimers = new Map();
|
|
178
200
|
#dispatchLifecycleDrives = new Set();
|
|
201
|
+
#dispatchLifecycleCapacityWaitLogged = new Set();
|
|
179
202
|
#localReleaseCheckpoints = new Map();
|
|
180
203
|
#dispatchLifecycleRenewTimer;
|
|
181
204
|
#clarificationSweepTimer;
|
|
@@ -265,9 +288,10 @@ export class FactoryLoop {
|
|
|
265
288
|
this.#linear = ports.linear ?? MountLinearWriteback(ports.mount, {
|
|
266
289
|
safety: config.safety,
|
|
267
290
|
});
|
|
291
|
+
this.#githubWritebackProvided = Boolean(ports.githubWriteback);
|
|
268
292
|
this.#githubWriteback = ports.githubWriteback ?? new GhCliGithubWriteback();
|
|
269
293
|
this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
|
|
270
|
-
|
|
294
|
+
this.#github = ports.github ?? MountGithubRead(ports.mount);
|
|
271
295
|
this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
|
|
272
296
|
this.#probeCloser = ports.probeCloser ?? closeProbePr;
|
|
273
297
|
this.#customProbePrResolver = Boolean(ports.probePrResolver);
|
|
@@ -286,6 +310,7 @@ export class FactoryLoop {
|
|
|
286
310
|
this.#terminationGraceMs = ports.terminationGraceMs;
|
|
287
311
|
this.#babysitterWakeUnreachableEscalateMs = ports.babysitterWakeUnreachableEscalateMs ?? BABYSITTER_WAKE_UNREACHABLE_ESCALATE_MS;
|
|
288
312
|
this.#babysitterWakeUnreachableRetryMs = ports.babysitterWakeUnreachableRetryMs ?? BABYSITTER_WAKE_UNREACHABLE_RETRY_MS;
|
|
313
|
+
this.#startupAgentExitDrainTimeoutMs = ports.startupAgentExitDrainTimeoutMs ?? STARTUP_AGENT_EXIT_DRAIN_TIMEOUT_MS;
|
|
289
314
|
this.#workspaceId = config.workspaceId ?? 'default';
|
|
290
315
|
this.#relayflows = ports.relayflows;
|
|
291
316
|
this.#worktrees = ports.worktrees;
|
|
@@ -298,6 +323,19 @@ export class FactoryLoop {
|
|
|
298
323
|
this.#batchView = batch;
|
|
299
324
|
return batch;
|
|
300
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
|
+
});
|
|
301
339
|
this.#wireFleetEvents();
|
|
302
340
|
}
|
|
303
341
|
async #resolveIntegrationInstructions() {
|
|
@@ -480,7 +518,12 @@ export class FactoryLoop {
|
|
|
480
518
|
this.#wireFleetEvents();
|
|
481
519
|
await this.#adoptInFlightAgents(legacyRegistry);
|
|
482
520
|
this.#startupAgentAdoptionActive = false;
|
|
483
|
-
|
|
521
|
+
if (this.#config.babysitter.enabled) {
|
|
522
|
+
// Re-run the idempotent receipt fold after adoption returns. This
|
|
523
|
+
// catches records restored by lifecycle work that completed while the
|
|
524
|
+
// startup roster drain was in progress.
|
|
525
|
+
await this.#reconcileRestoredBabysitterReceipts();
|
|
526
|
+
}
|
|
484
527
|
}
|
|
485
528
|
catch (error) {
|
|
486
529
|
this.#startupAgentAdoptionActive = false;
|
|
@@ -559,6 +602,12 @@ export class FactoryLoop {
|
|
|
559
602
|
this.#completionSweepTimer = undefined;
|
|
560
603
|
this.#stoppingHeartbeatRefreshActive = await this.#stopLiveHeartbeat('stopping');
|
|
561
604
|
try {
|
|
605
|
+
// Relinquish durable ownership before waiting on mount-backed lifecycle
|
|
606
|
+
// drives. A slow Relayfile scan must not consume the shutdown deadline
|
|
607
|
+
// while every issue remains fenced to a publisher that is already
|
|
608
|
+
// stopping. The owner/epoch fence makes any late completion from those
|
|
609
|
+
// drives harmless; a second sweep below catches claims racing this one.
|
|
610
|
+
await this.#releaseOwnedDispatchLifecycleLeases();
|
|
562
611
|
await Promise.allSettled([...this.#dispatchLifecycleDrives]);
|
|
563
612
|
// Fence every source of new clarification work before touching the fleet.
|
|
564
613
|
// A wake already past the fence is allowed to unwind, and is awaited
|
|
@@ -581,10 +630,7 @@ export class FactoryLoop {
|
|
|
581
630
|
// non-durable (local/internal) records; terminal completion performs the
|
|
582
631
|
// normal remote release before clearing the lifecycle.
|
|
583
632
|
await this.#releaseInFlightAgents('factory-stopped', { preserveDurable: true });
|
|
584
|
-
|
|
585
|
-
await this.#state.releaseDispatchLifecycleLease(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch);
|
|
586
|
-
}
|
|
587
|
-
this.#dispatchLifecycleEpochs.clear();
|
|
633
|
+
await this.#releaseOwnedDispatchLifecycleLeases();
|
|
588
634
|
if (this.#livePollTimer)
|
|
589
635
|
clearTimeout(this.#livePollTimer);
|
|
590
636
|
this.#livePollTimer = undefined;
|
|
@@ -612,6 +658,7 @@ export class FactoryLoop {
|
|
|
612
658
|
this.#offAgentMessage?.();
|
|
613
659
|
this.#offAgentLifecycleSignal?.();
|
|
614
660
|
await Promise.allSettled([...this.#agentLifecycleSignalsInFlight.values()]);
|
|
661
|
+
await this.#slackConversationTurns.stop();
|
|
615
662
|
this.#offAgentExit = undefined;
|
|
616
663
|
this.#offDeliveryFailed = undefined;
|
|
617
664
|
this.#offAgentMessage = undefined;
|
|
@@ -622,6 +669,14 @@ export class FactoryLoop {
|
|
|
622
669
|
this.#stoppingHeartbeatRefreshActive = false;
|
|
623
670
|
}
|
|
624
671
|
}
|
|
672
|
+
async #releaseOwnedDispatchLifecycleLeases() {
|
|
673
|
+
for (const [key, epoch] of [...this.#dispatchLifecycleEpochs]) {
|
|
674
|
+
await this.#state.releaseDispatchLifecycleLease(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch);
|
|
675
|
+
if (this.#dispatchLifecycleEpochs.get(key) === epoch) {
|
|
676
|
+
this.#dispatchLifecycleEpochs.delete(key);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
625
680
|
async #drainClarificationWakesForStop() {
|
|
626
681
|
// A wake may add its promise just as the sweep that discovered it settles.
|
|
627
682
|
// Re-snapshot until the map is empty rather than assuming one await is a
|
|
@@ -1248,6 +1303,13 @@ export class FactoryLoop {
|
|
|
1248
1303
|
this.#logger.info?.('[factory] run-once started', { dryRun });
|
|
1249
1304
|
let report;
|
|
1250
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;
|
|
1251
1313
|
const issueSource = await this.#issueSource();
|
|
1252
1314
|
if (issueSource === 'linear') {
|
|
1253
1315
|
await this.#ingestGithubIssues({ dryRun });
|
|
@@ -1370,7 +1432,12 @@ export class FactoryLoop {
|
|
|
1370
1432
|
continue;
|
|
1371
1433
|
}
|
|
1372
1434
|
if (result.agents.length === 0 && !dryRun) {
|
|
1373
|
-
|
|
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 });
|
|
1374
1441
|
}
|
|
1375
1442
|
else {
|
|
1376
1443
|
dispatched.push(result);
|
|
@@ -1709,6 +1776,7 @@ export class FactoryLoop {
|
|
|
1709
1776
|
url: pr.url ?? `https://github.com/${pr.repo}/pull/${pr.prNumber}`,
|
|
1710
1777
|
path: pr.path,
|
|
1711
1778
|
headRef,
|
|
1779
|
+
authoritative: true,
|
|
1712
1780
|
});
|
|
1713
1781
|
const babysitter = [...record.agents.values()].find((tracked) => tracked.spec.role === 'babysitter');
|
|
1714
1782
|
if (!babysitter) {
|
|
@@ -1981,6 +2049,26 @@ export class FactoryLoop {
|
|
|
1981
2049
|
this.#recordTriageEscalation(dispatchDecision, escalationReason);
|
|
1982
2050
|
return replayedResult ?? { issue: dispatchDecision.issue, agents: [], dryRun };
|
|
1983
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);
|
|
1984
2072
|
// Full task rendering is part of the durable spawn specification. It must
|
|
1985
2073
|
// happen before a remote lifecycle is first claimed so takeover cannot
|
|
1986
2074
|
// recover a persisted minimal triage task after a crash in this gap.
|
|
@@ -2008,7 +2096,12 @@ export class FactoryLoop {
|
|
|
2008
2096
|
this.#scheduleDispatchLifecycleRetry(queuedRecord);
|
|
2009
2097
|
this.#increment('queued');
|
|
2010
2098
|
this.#emit('issue-queued', { issue: dispatchDecision.issue });
|
|
2011
|
-
return lifecycleClaim.lifecycle.result ?? {
|
|
2099
|
+
return lifecycleClaim.lifecycle.result ?? {
|
|
2100
|
+
issue: dispatchDecision.issue,
|
|
2101
|
+
agents: [],
|
|
2102
|
+
dryRun,
|
|
2103
|
+
hold: { kind: 'capacity' },
|
|
2104
|
+
};
|
|
2012
2105
|
}
|
|
2013
2106
|
if (!lifecycleClaim.created) {
|
|
2014
2107
|
const restored = batch.restore(inFlightRecordFromLifecycle(lifecycleClaim.lifecycle));
|
|
@@ -2019,12 +2112,12 @@ export class FactoryLoop {
|
|
|
2019
2112
|
if (!durableDispatch)
|
|
2020
2113
|
this.#consumePendingDispatchClarifications(dispatchDecision.issue);
|
|
2021
2114
|
await this.#recordDispatchAttempt(dispatchDecision.issue);
|
|
2022
|
-
const record = batch.start(dispatchDecision, dryRun);
|
|
2115
|
+
const record = batch.start(dispatchDecision, dryRun, dependencyAdmission);
|
|
2023
2116
|
if (!record) {
|
|
2024
2117
|
await this.#clearDispatchInFlight(dispatchDecision.issue);
|
|
2025
2118
|
this.#increment('queued');
|
|
2026
2119
|
this.#emit('issue-queued', { issue: dispatchDecision.issue });
|
|
2027
|
-
return { issue: dispatchDecision.issue, agents: [], dryRun };
|
|
2120
|
+
return { issue: dispatchDecision.issue, agents: [], dryRun, hold: { kind: 'capacity' } };
|
|
2028
2121
|
}
|
|
2029
2122
|
if (record.result) {
|
|
2030
2123
|
return record.result;
|
|
@@ -2152,6 +2245,12 @@ export class FactoryLoop {
|
|
|
2152
2245
|
return {
|
|
2153
2246
|
inFlight: batch?.inFlight.map((record) => record.issue) ?? [],
|
|
2154
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
|
+
})) ?? [],
|
|
2155
2254
|
counters: { ...this.#counters },
|
|
2156
2255
|
slackDegraded: this.#slackDegraded,
|
|
2157
2256
|
slackDegradedReason: this.#slackDegradedReason,
|
|
@@ -2279,6 +2378,14 @@ export class FactoryLoop {
|
|
|
2279
2378
|
if (agents.length > 0 && this.#fleet.hydrateTracked) {
|
|
2280
2379
|
this.#fleet.hydrateTracked(agents);
|
|
2281
2380
|
}
|
|
2381
|
+
if (this.#config.babysitter.enabled) {
|
|
2382
|
+
// Restore and reconcile exact PR ownership before asking the fleet to
|
|
2383
|
+
// report missing agents. Otherwise a stale weak-match babysitter from
|
|
2384
|
+
// the lifecycle can be resumed before its independently durable,
|
|
2385
|
+
// metadata-validated replacement session becomes authoritative.
|
|
2386
|
+
await this.#restoreBabysitterOwnership();
|
|
2387
|
+
await this.#reconcileRestoredBabysitterReceipts();
|
|
2388
|
+
}
|
|
2282
2389
|
this.#scheduleDispatchLifecycleRenewal();
|
|
2283
2390
|
if (this.#fleet.hydrateTracked) {
|
|
2284
2391
|
this.#startupAgentAdoptionActive = false;
|
|
@@ -2293,23 +2400,50 @@ export class FactoryLoop {
|
|
|
2293
2400
|
// but recovery work is asynchronous (issue reads, worktree restore,
|
|
2294
2401
|
// PR publication). Finish exits discovered by the startup reconcile
|
|
2295
2402
|
// before the full ready-issue backfill can monopolize mount I/O.
|
|
2296
|
-
|
|
2297
|
-
this.#
|
|
2403
|
+
const exitNames = new Set(agents.map((agent) => agent.name));
|
|
2404
|
+
const drained = await this.#drainAgentExitsInFlight(exitNames, this.#startMode === 'live' ? this.#startupAgentExitDrainTimeoutMs : undefined);
|
|
2405
|
+
if (drained) {
|
|
2406
|
+
this.#logger.info?.('[factory] durable startup reconciled exits drained');
|
|
2407
|
+
}
|
|
2408
|
+
else {
|
|
2409
|
+
this.#increment('startupAgentExitDrainTimeouts');
|
|
2410
|
+
this.#logger.warn?.('[factory] startup agent exit reconciliation is still running; continuing ready-issue discovery', {
|
|
2411
|
+
timeoutMs: this.#startupAgentExitDrainTimeoutMs,
|
|
2412
|
+
pendingExits: [...this.#agentExitsInFlight.keys()].filter((name) => exitNames.has(name)),
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2298
2415
|
}
|
|
2299
2416
|
}
|
|
2300
2417
|
catch (error) {
|
|
2301
2418
|
this.#logger.warn?.('[factory] failed to re-adopt durable in-flight agents', { error });
|
|
2302
2419
|
}
|
|
2303
2420
|
}
|
|
2304
|
-
async #drainAgentExitsInFlight(names) {
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2421
|
+
async #drainAgentExitsInFlight(names, timeoutMs) {
|
|
2422
|
+
const drain = async () => {
|
|
2423
|
+
for (;;) {
|
|
2424
|
+
const pending = [...this.#agentExitsInFlight]
|
|
2425
|
+
.filter(([name]) => !names || names.has(name))
|
|
2426
|
+
.map(([, handling]) => handling);
|
|
2427
|
+
if (pending.length === 0)
|
|
2428
|
+
return;
|
|
2429
|
+
await Promise.allSettled(pending);
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
if (timeoutMs === undefined) {
|
|
2433
|
+
await drain();
|
|
2434
|
+
return true;
|
|
2312
2435
|
}
|
|
2436
|
+
let timer;
|
|
2437
|
+
const completed = await Promise.race([
|
|
2438
|
+
drain().then(() => true),
|
|
2439
|
+
new Promise((resolve) => {
|
|
2440
|
+
timer = setTimeout(() => resolve(false), Math.max(0, timeoutMs));
|
|
2441
|
+
timer.unref?.();
|
|
2442
|
+
}),
|
|
2443
|
+
]);
|
|
2444
|
+
if (timer)
|
|
2445
|
+
clearTimeout(timer);
|
|
2446
|
+
return completed;
|
|
2313
2447
|
}
|
|
2314
2448
|
#scheduleDispatchLifecycleRenewal() {
|
|
2315
2449
|
if (this.#dispatchLifecycleRenewTimer || this.#dispatchLifecycleEpochs.size === 0)
|
|
@@ -2457,7 +2591,7 @@ export class FactoryLoop {
|
|
|
2457
2591
|
}
|
|
2458
2592
|
const previous = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
2459
2593
|
const pullRequests = mergePublishedPullRequests(previous, pullRequest);
|
|
2460
|
-
const primaryPullRequest = previous
|
|
2594
|
+
const primaryPullRequest = primaryPublishedPullRequest(previous, pullRequest, pullRequests);
|
|
2461
2595
|
const lifecycle = lifecycleFromInFlightRecord(record, previous?.runId ?? randomUUID(), phase, this.#clock.now(), primaryPullRequest, pullRequests, releaseReason ?? previous?.releaseReason);
|
|
2462
2596
|
for (const agent of lifecycle.agents) {
|
|
2463
2597
|
const previouslyReleasedAtMs = previous?.agents.find((candidate) => candidate.name === agent.name)?.releasedAtMs;
|
|
@@ -2502,11 +2636,27 @@ export class FactoryLoop {
|
|
|
2502
2636
|
const timer = setTimeout(() => {
|
|
2503
2637
|
this.#dispatchLifecycleRetryTimers.delete(key);
|
|
2504
2638
|
const drive = this.#driveDispatchLifecycle(key)
|
|
2639
|
+
.then(() => {
|
|
2640
|
+
this.#dispatchLifecycleCapacityWaitLogged.delete(key);
|
|
2641
|
+
})
|
|
2505
2642
|
.catch((error) => {
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2643
|
+
if (error instanceof DispatchLifecycleCapacityError) {
|
|
2644
|
+
if (!this.#dispatchLifecycleCapacityWaitLogged.has(key)) {
|
|
2645
|
+
this.#dispatchLifecycleCapacityWaitLogged.add(key);
|
|
2646
|
+
this.#increment('dispatchLifecycleCapacityWaits');
|
|
2647
|
+
this.#logger.warn?.('[factory] durable dispatch is queued for batch capacity; retries remain active', {
|
|
2648
|
+
issue: record.issue.key,
|
|
2649
|
+
retryMs: DISPATCH_LIFECYCLE_RETRY_MS,
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
else {
|
|
2654
|
+
this.#dispatchLifecycleCapacityWaitLogged.delete(key);
|
|
2655
|
+
this.#logger.warn?.('[factory] durable dispatch lifecycle retry failed', {
|
|
2656
|
+
issue: record.issue.key,
|
|
2657
|
+
error: describeError(error).errorMessage,
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2510
2660
|
this.#scheduleDispatchLifecycleRetry(record);
|
|
2511
2661
|
})
|
|
2512
2662
|
.finally(() => this.#dispatchLifecycleDrives.delete(drive));
|
|
@@ -2563,9 +2713,22 @@ export class FactoryLoop {
|
|
|
2563
2713
|
acquiredNow = true;
|
|
2564
2714
|
}
|
|
2565
2715
|
if (lifecycle.phase === 'queued') {
|
|
2716
|
+
const liveIssue = await this.#readIssue(lifecycle.issue.path);
|
|
2717
|
+
if (liveIssue) {
|
|
2718
|
+
const dependencyAdmission = await this.#dependencyAdmission(liveIssue, lifecycle.decision);
|
|
2719
|
+
const batch = await this.#batch();
|
|
2720
|
+
if (dependencyAdmission.blockers.length > 0 || dependencyAdmission.cycle) {
|
|
2721
|
+
batch.queue(lifecycle.decision, lifecycle.dryRun, dependencyAdmission);
|
|
2722
|
+
const parked = batch.getParked(lifecycle.issue);
|
|
2723
|
+
if (parked)
|
|
2724
|
+
await this.#reportDependencyPark(liveIssue, parked, lifecycle.dryRun);
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
this.#clearDependencyPark(batch, lifecycle.issue);
|
|
2728
|
+
}
|
|
2566
2729
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
2567
2730
|
if (epoch === undefined || !await this.#state.promoteDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now())) {
|
|
2568
|
-
throw new
|
|
2731
|
+
throw new DispatchLifecycleCapacityError(`durable dispatch ${lifecycle.issue.key} is waiting for batch capacity`);
|
|
2569
2732
|
}
|
|
2570
2733
|
const promoted = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
2571
2734
|
if (!promoted || promoted.phase !== 'dispatching') {
|
|
@@ -2601,6 +2764,8 @@ export class FactoryLoop {
|
|
|
2601
2764
|
})));
|
|
2602
2765
|
await this.#fleet.reconcileTrackedAgents?.();
|
|
2603
2766
|
}
|
|
2767
|
+
if (this.#config.babysitter.enabled)
|
|
2768
|
+
await this.#reconcileRestoredBabysitterReceipts(record);
|
|
2604
2769
|
return;
|
|
2605
2770
|
}
|
|
2606
2771
|
if (lifecycle.phase === 'parking') {
|
|
@@ -2637,6 +2802,7 @@ export class FactoryLoop {
|
|
|
2637
2802
|
prNumber: receipt.number,
|
|
2638
2803
|
url: receipt.url,
|
|
2639
2804
|
headRef: receipt.headRef,
|
|
2805
|
+
authoritative: true,
|
|
2640
2806
|
});
|
|
2641
2807
|
}
|
|
2642
2808
|
return;
|
|
@@ -2651,6 +2817,7 @@ export class FactoryLoop {
|
|
|
2651
2817
|
prNumber: receipt.number,
|
|
2652
2818
|
url: receipt.url,
|
|
2653
2819
|
headRef: receipt.headRef,
|
|
2820
|
+
authoritative: true,
|
|
2654
2821
|
});
|
|
2655
2822
|
}
|
|
2656
2823
|
return;
|
|
@@ -2876,6 +3043,9 @@ export class FactoryLoop {
|
|
|
2876
3043
|
if (issue && !isGithubIssue(issue)) {
|
|
2877
3044
|
await this.#recordCanonicalIssueState(issue);
|
|
2878
3045
|
}
|
|
3046
|
+
if (issue && this.#dependencyIssueIsTerminal(issue)) {
|
|
3047
|
+
await this.#markDependencyTerminalAndReconcile(issue);
|
|
3048
|
+
}
|
|
2879
3049
|
if (!issue || !this.#isIssueReady(issue)) {
|
|
2880
3050
|
return;
|
|
2881
3051
|
}
|
|
@@ -2904,14 +3074,7 @@ export class FactoryLoop {
|
|
|
2904
3074
|
this.#recordTriageEscalation(escalationDecision, escalationReason);
|
|
2905
3075
|
return;
|
|
2906
3076
|
}
|
|
2907
|
-
|
|
2908
|
-
await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
|
|
2909
|
-
}
|
|
2910
|
-
else {
|
|
2911
|
-
if (batch.queue(escalationDecision, this.#config.dryRun)) {
|
|
2912
|
-
this.#emit('issue-queued', { issue: escalationDecision.issue });
|
|
2913
|
-
}
|
|
2914
|
-
}
|
|
3077
|
+
await this.dispatch(escalationDecision, { dryRun: this.#config.dryRun, labelsValidated: routed.ok });
|
|
2915
3078
|
}
|
|
2916
3079
|
catch (error) {
|
|
2917
3080
|
if (error instanceof LiveDispatchStateChangedError) {
|
|
@@ -3040,40 +3203,55 @@ export class FactoryLoop {
|
|
|
3040
3203
|
async #githubIssuePaths() {
|
|
3041
3204
|
try {
|
|
3042
3205
|
const issuePaths = new Map();
|
|
3043
|
-
for (const
|
|
3044
|
-
const
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3206
|
+
for (const { owner, repo } of configuredGithubRepoParts(this.#config)) {
|
|
3207
|
+
const indexedPaths = await this.#githubIssuePathsFromIndex(owner, repo);
|
|
3208
|
+
const roots = githubIssueRepoRoots(owner, repo);
|
|
3209
|
+
// Keep the fallback roots as separate batches. Flattening a very large
|
|
3210
|
+
// provider result is synchronous work and can starve the durable loop
|
|
3211
|
+
// heartbeat before the bounded scan below gets a chance to yield.
|
|
3212
|
+
const pathBatches = indexedPaths
|
|
3213
|
+
? [indexedPaths]
|
|
3214
|
+
: await Promise.all(roots.map(async (root) => await this.#listRelayfileTree(root, 'GitHub issue ingestion')));
|
|
3215
|
+
if (indexedPaths) {
|
|
3216
|
+
this.#increment('githubIssueIndexReposUsed');
|
|
3217
|
+
}
|
|
3218
|
+
else {
|
|
3219
|
+
this.#increment('githubIssueIndexFallbacks');
|
|
3220
|
+
}
|
|
3221
|
+
for (const paths of pathBatches) {
|
|
3222
|
+
for (let index = 0; index < paths.length; index += 1) {
|
|
3223
|
+
const path = paths[index];
|
|
3224
|
+
const parts = githubIssuePathParts(path);
|
|
3225
|
+
if (parts) {
|
|
3226
|
+
const identity = githubIssueIdentity(parts.owner, parts.repo, parts.number);
|
|
3227
|
+
const existing = issuePaths.get(identity);
|
|
3228
|
+
if (!existing || githubIssuePathPreference(path) < githubIssuePathPreference(existing)) {
|
|
3229
|
+
if (existing)
|
|
3230
|
+
this.#increment('githubIssueAliasPathsSuppressed');
|
|
3231
|
+
issuePaths.set(identity, path);
|
|
3232
|
+
}
|
|
3233
|
+
else {
|
|
3053
3234
|
this.#increment('githubIssueAliasPathsSuppressed');
|
|
3054
|
-
|
|
3235
|
+
}
|
|
3055
3236
|
}
|
|
3056
|
-
else {
|
|
3057
|
-
|
|
3237
|
+
else if (githubIssueDirectoryPathParts(path) !== undefined) {
|
|
3238
|
+
// listTree returns the issue directory entry alongside its
|
|
3239
|
+
// meta.json file; githubIssuePathParts() already collected the
|
|
3240
|
+
// file, so skip the directory to avoid reading the same issue
|
|
3241
|
+
// twice in one backfill pass. Directory paths are only meaningful
|
|
3242
|
+
// for live change events, not the tree scan.
|
|
3243
|
+
continue;
|
|
3244
|
+
}
|
|
3245
|
+
else if (isGithubIssueTreePath(path)) {
|
|
3246
|
+
this.#increment('githubIssuesIgnoredByPathRegex');
|
|
3247
|
+
}
|
|
3248
|
+
if ((index + 1) % LIVE_EVENT_DRAIN_BATCH_SIZE === 0) {
|
|
3249
|
+
await this.#refreshLiveHeartbeatIfDue();
|
|
3250
|
+
await liveEventYield();
|
|
3058
3251
|
}
|
|
3059
3252
|
}
|
|
3060
|
-
|
|
3061
|
-
// listTree returns the issue directory entry alongside its
|
|
3062
|
-
// meta.json file; githubIssuePathParts() already collected the
|
|
3063
|
-
// file, so skip the directory to avoid reading the same issue
|
|
3064
|
-
// twice in one backfill pass. Directory paths are only meaningful
|
|
3065
|
-
// for live change events, not the tree scan.
|
|
3066
|
-
continue;
|
|
3067
|
-
}
|
|
3068
|
-
else if (isGithubIssueTreePath(path)) {
|
|
3069
|
-
this.#increment('githubIssuesIgnoredByPathRegex');
|
|
3070
|
-
}
|
|
3071
|
-
if ((index + 1) % LIVE_EVENT_DRAIN_BATCH_SIZE === 0) {
|
|
3072
|
-
await this.#refreshLiveHeartbeatIfDue();
|
|
3073
|
-
await liveEventYield();
|
|
3074
|
-
}
|
|
3253
|
+
await this.#refreshLiveHeartbeatIfDue();
|
|
3075
3254
|
}
|
|
3076
|
-
await this.#refreshLiveHeartbeatIfDue();
|
|
3077
3255
|
}
|
|
3078
3256
|
for (const [identity, path] of issuePaths) {
|
|
3079
3257
|
this.#githubIssuePreferredPaths.set(identity, path);
|
|
@@ -3088,6 +3266,41 @@ export class FactoryLoop {
|
|
|
3088
3266
|
return [];
|
|
3089
3267
|
}
|
|
3090
3268
|
}
|
|
3269
|
+
async #githubIssuePathsFromIndex(owner, repo) {
|
|
3270
|
+
const indexPath = `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/_index.json`;
|
|
3271
|
+
let parsed;
|
|
3272
|
+
try {
|
|
3273
|
+
const { content } = await this.#readRelayfileFile(indexPath, 'GitHub issue index discovery');
|
|
3274
|
+
parsed = parseJsonContent(content);
|
|
3275
|
+
}
|
|
3276
|
+
catch {
|
|
3277
|
+
return undefined;
|
|
3278
|
+
}
|
|
3279
|
+
if (!Array.isArray(parsed))
|
|
3280
|
+
return undefined;
|
|
3281
|
+
const requiredLabel = this.#config.safety.requireLabel.trim().toLowerCase();
|
|
3282
|
+
if (!requiredLabel)
|
|
3283
|
+
return undefined;
|
|
3284
|
+
const paths = [];
|
|
3285
|
+
for (const entry of parsed) {
|
|
3286
|
+
const row = asRecord(entry);
|
|
3287
|
+
const number = row?.number;
|
|
3288
|
+
const state = typeof row?.state === 'string' ? row.state.trim().toLowerCase() : undefined;
|
|
3289
|
+
const labels = row?.labels;
|
|
3290
|
+
// Labels were added to the public GitHub issue index contract after the
|
|
3291
|
+
// first index version. Fall back for the entire repository if any row is
|
|
3292
|
+
// legacy or malformed so an eligible issue can never be filtered out.
|
|
3293
|
+
if (!Number.isSafeInteger(number) || Number(number) <= 0 || !state || !Array.isArray(labels) ||
|
|
3294
|
+
!labels.every((label) => typeof label === 'string')) {
|
|
3295
|
+
return undefined;
|
|
3296
|
+
}
|
|
3297
|
+
if (state !== 'open' || !labels.some((label) => label.trim().toLowerCase() === requiredLabel)) {
|
|
3298
|
+
continue;
|
|
3299
|
+
}
|
|
3300
|
+
paths.push(`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`);
|
|
3301
|
+
}
|
|
3302
|
+
return paths;
|
|
3303
|
+
}
|
|
3091
3304
|
async #handleGithubIssueChange(path, opts = {}) {
|
|
3092
3305
|
if (this.#githubIngestionEnabled === false || !isGithubIssueFilePath(path)) {
|
|
3093
3306
|
return;
|
|
@@ -3097,13 +3310,17 @@ export class FactoryLoop {
|
|
|
3097
3310
|
if (!ghIssue) {
|
|
3098
3311
|
return;
|
|
3099
3312
|
}
|
|
3313
|
+
const closed = githubIssueIsClosed(ghIssue);
|
|
3314
|
+
if (closed) {
|
|
3315
|
+
await this.#markDependencyTerminalAndReconcile(githubIssueAsFactoryIssue(ghIssue));
|
|
3316
|
+
}
|
|
3100
3317
|
if (await this.#issueSource() === 'github') {
|
|
3101
|
-
if (!
|
|
3318
|
+
if (!closed && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
|
|
3102
3319
|
await this.#handleChange(ghIssue.path);
|
|
3103
3320
|
}
|
|
3104
3321
|
return;
|
|
3105
3322
|
}
|
|
3106
|
-
if (
|
|
3323
|
+
if (closed) {
|
|
3107
3324
|
const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
|
|
3108
3325
|
if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
|
|
3109
3326
|
if (!opts.dryRun) {
|
|
@@ -3156,7 +3373,9 @@ export class FactoryLoop {
|
|
|
3156
3373
|
for (const candidatePath of candidatePaths) {
|
|
3157
3374
|
try {
|
|
3158
3375
|
const { content } = await this.#readRelayfileFile(candidatePath, 'GitHub issue ingestion');
|
|
3159
|
-
|
|
3376
|
+
const githubIssue = parseGithubIssue(candidatePath, content);
|
|
3377
|
+
this.#indexDependencyIssue(githubIssueAsFactoryIssue(githubIssue));
|
|
3378
|
+
return githubIssue;
|
|
3160
3379
|
}
|
|
3161
3380
|
catch (error) {
|
|
3162
3381
|
if (isMissingIssueFileError(error) && candidatePath !== candidatePaths.at(-1)) {
|
|
@@ -3245,6 +3464,248 @@ export class FactoryLoop {
|
|
|
3245
3464
|
});
|
|
3246
3465
|
return entry?.[0];
|
|
3247
3466
|
}
|
|
3467
|
+
#indexDependencyIssue(issue) {
|
|
3468
|
+
if (!issue)
|
|
3469
|
+
return;
|
|
3470
|
+
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
3471
|
+
const identity = dependencyIdentityForIssue(issue, repo);
|
|
3472
|
+
if (!identity)
|
|
3473
|
+
return;
|
|
3474
|
+
const rank = isGithubIssueFilePath(issue.path) ? 2 : 1;
|
|
3475
|
+
const existing = this.#dependencyIssues.get(identity);
|
|
3476
|
+
if (!existing || rank >= existing.rank) {
|
|
3477
|
+
this.#dependencyIssues.set(identity, { issue, rank });
|
|
3478
|
+
if (this.#dependencyIssueIsTerminal(issue)) {
|
|
3479
|
+
this.#terminalDependencyIdentities.add(identity);
|
|
3480
|
+
}
|
|
3481
|
+
else {
|
|
3482
|
+
this.#terminalDependencyIdentities.delete(identity);
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
async #dependencyAdmission(issue, decision) {
|
|
3487
|
+
const declared = parseBlockedBy(issue.description);
|
|
3488
|
+
if (declared.length === 0)
|
|
3489
|
+
return { blockers: [] };
|
|
3490
|
+
const currentRepo = dependencyRepoForIssue(issue, decision, this.#config);
|
|
3491
|
+
const resolved = declared
|
|
3492
|
+
.map((dependency) => resolveDependency(dependency, currentRepo))
|
|
3493
|
+
.filter((dependency) => Boolean(dependency));
|
|
3494
|
+
await this.#loadMissingDependencyIssues(resolved.map((dependency) => dependency.identity));
|
|
3495
|
+
const resolvedTerminal = new Set();
|
|
3496
|
+
for (const dependency of resolved) {
|
|
3497
|
+
if (await this.#dependencyIsTerminalOrMerged(dependency.identity)) {
|
|
3498
|
+
resolvedTerminal.add(dependency.identity);
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
const blockers = resolved
|
|
3502
|
+
.filter((dependency) => !resolvedTerminal.has(dependency.identity))
|
|
3503
|
+
.map((dependency) => {
|
|
3504
|
+
const blocker = this.#dependencyIssues.get(dependency.identity)?.issue;
|
|
3505
|
+
return {
|
|
3506
|
+
identity: dependency.identity,
|
|
3507
|
+
key: blocker ? issueKey(issueRef(blocker)) : dependency.identity,
|
|
3508
|
+
label: dependency.label,
|
|
3509
|
+
};
|
|
3510
|
+
});
|
|
3511
|
+
// A bare #N without a resolvable single repository is itself unresolved.
|
|
3512
|
+
// Keep it visible and fail closed instead of guessing among routes.
|
|
3513
|
+
for (const dependency of declared) {
|
|
3514
|
+
if (dependency.repo || currentRepo)
|
|
3515
|
+
continue;
|
|
3516
|
+
blockers.push({
|
|
3517
|
+
identity: `unresolved:${issueKey(decision.issue)}:${dependency.raw}`,
|
|
3518
|
+
key: `unresolved:${dependency.raw}`,
|
|
3519
|
+
label: `${dependency.raw} (repository unresolved)`,
|
|
3520
|
+
});
|
|
3521
|
+
}
|
|
3522
|
+
const currentIdentity = dependencyIdentityForIssue(issue, currentRepo);
|
|
3523
|
+
const cycle = currentIdentity
|
|
3524
|
+
? findDependencyCycle(currentIdentity, this.#dependencyGraph())
|
|
3525
|
+
: undefined;
|
|
3526
|
+
return { blockers, cycle };
|
|
3527
|
+
}
|
|
3528
|
+
async #loadMissingDependencyIssues(identities) {
|
|
3529
|
+
const pending = [...new Set(identities)];
|
|
3530
|
+
const expanded = new Set();
|
|
3531
|
+
while (pending.length > 0) {
|
|
3532
|
+
const identity = pending.shift();
|
|
3533
|
+
if (expanded.has(identity))
|
|
3534
|
+
continue;
|
|
3535
|
+
expanded.add(identity);
|
|
3536
|
+
if (!this.#dependencyIssues.has(identity)) {
|
|
3537
|
+
// Startup discovery may use the ready-only GitHub issue index. A
|
|
3538
|
+
// blocker is often closed, in progress, or deliberately unlabeled, so
|
|
3539
|
+
// probe its stable by-id aliases directly before consulting that
|
|
3540
|
+
// filtered discovery set.
|
|
3541
|
+
const separator = identity.lastIndexOf('#');
|
|
3542
|
+
const repoIdentity = identity.slice(0, separator);
|
|
3543
|
+
const number = Number(identity.slice(separator + 1));
|
|
3544
|
+
const configuredRepo = configuredGithubRepoParts(this.#config)
|
|
3545
|
+
.find(({ owner, repo }) => `${owner}/${repo}`.toLowerCase() === repoIdentity);
|
|
3546
|
+
if (configuredRepo && Number.isSafeInteger(number) && number > 0) {
|
|
3547
|
+
const { owner, repo } = configuredRepo;
|
|
3548
|
+
for (const path of [
|
|
3549
|
+
`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`,
|
|
3550
|
+
`${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/by-id/${number}.json`,
|
|
3551
|
+
]) {
|
|
3552
|
+
await this.#readIssue(path);
|
|
3553
|
+
if (this.#dependencyIssues.has(identity))
|
|
3554
|
+
break;
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
if (!this.#dependencyIssues.has(identity)) {
|
|
3559
|
+
if (!this.#dependencyGithubPathsByIdentity) {
|
|
3560
|
+
this.#dependencyGithubPathsByIdentity = new Map();
|
|
3561
|
+
for (const path of await this.#githubIssuePaths()) {
|
|
3562
|
+
const parts = githubIssuePathParts(path);
|
|
3563
|
+
if (parts) {
|
|
3564
|
+
this.#dependencyGithubPathsByIdentity.set(githubIssueIdentity(parts.owner, parts.repo, parts.number), path);
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
const githubPath = this.#dependencyGithubPathsByIdentity.get(identity);
|
|
3569
|
+
if (githubPath)
|
|
3570
|
+
await this.#readIssue(githubPath);
|
|
3571
|
+
}
|
|
3572
|
+
if (!this.#dependencyIssues.has(identity) && !this.#dependencyLinearTreeLoaded) {
|
|
3573
|
+
// Linear paths do not encode the routed repository identity, so load
|
|
3574
|
+
// the tree once and let #indexDependencyIssue build the composite map.
|
|
3575
|
+
// The pass-scoped flag also lets every later blocked issue reuse the
|
|
3576
|
+
// resulting dependency index instead of rescanning the full tree.
|
|
3577
|
+
this.#dependencyLinearTreeLoaded = true;
|
|
3578
|
+
for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'dependency blocker discovery')) {
|
|
3579
|
+
if (isIssueFilePath(path))
|
|
3580
|
+
await this.#readIssue(path);
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
const blocker = this.#dependencyIssues.get(identity)?.issue;
|
|
3584
|
+
if (!blocker)
|
|
3585
|
+
continue;
|
|
3586
|
+
const repo = identity.slice(0, identity.lastIndexOf('#'));
|
|
3587
|
+
for (const dependency of parseBlockedBy(blocker.description)) {
|
|
3588
|
+
const resolved = resolveDependency(dependency, repo);
|
|
3589
|
+
if (resolved && !expanded.has(resolved.identity))
|
|
3590
|
+
pending.push(resolved.identity);
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
#clearDependencyPark(batch, issue) {
|
|
3595
|
+
batch.clearPark(issue);
|
|
3596
|
+
this.#dependencyParkNotices.delete(issueKey(issue));
|
|
3597
|
+
}
|
|
3598
|
+
#dependencyGraph() {
|
|
3599
|
+
const graph = new Map();
|
|
3600
|
+
for (const [identity, { issue }] of this.#dependencyIssues) {
|
|
3601
|
+
if (this.#dependencyIdentityIsTerminal(identity)) {
|
|
3602
|
+
graph.set(identity, []);
|
|
3603
|
+
continue;
|
|
3604
|
+
}
|
|
3605
|
+
const repo = identity.slice(0, identity.lastIndexOf('#'));
|
|
3606
|
+
const dependencies = parseBlockedBy(issue.description)
|
|
3607
|
+
.map((dependency) => resolveDependency(dependency, repo))
|
|
3608
|
+
.filter((dependency) => Boolean(dependency))
|
|
3609
|
+
.filter((dependency) => !this.#dependencyIdentityIsTerminal(dependency.identity))
|
|
3610
|
+
.map((dependency) => dependency.identity);
|
|
3611
|
+
graph.set(identity, [...new Set(dependencies)]);
|
|
3612
|
+
}
|
|
3613
|
+
return graph;
|
|
3614
|
+
}
|
|
3615
|
+
#dependencyIdentityIsTerminal(identity) {
|
|
3616
|
+
if (this.#terminalDependencyIdentities.has(identity))
|
|
3617
|
+
return true;
|
|
3618
|
+
const issue = this.#dependencyIssues.get(identity)?.issue;
|
|
3619
|
+
return Boolean(issue && this.#dependencyIssueIsTerminal(issue));
|
|
3620
|
+
}
|
|
3621
|
+
async #dependencyIsTerminalOrMerged(identity) {
|
|
3622
|
+
if (this.#dependencyIdentityIsTerminal(identity))
|
|
3623
|
+
return true;
|
|
3624
|
+
const issue = this.#dependencyIssues.get(identity)?.issue;
|
|
3625
|
+
if (!issue)
|
|
3626
|
+
return false;
|
|
3627
|
+
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
3628
|
+
if (!repo)
|
|
3629
|
+
return false;
|
|
3630
|
+
const pullRequest = await resolveIssuePrFromMount(this.#mount, this.#config, issue, {
|
|
3631
|
+
allowLegacyGithubBranch: true,
|
|
3632
|
+
repo,
|
|
3633
|
+
});
|
|
3634
|
+
if (normalizePrState(pullRequest?.state) !== 'MERGED')
|
|
3635
|
+
return false;
|
|
3636
|
+
this.#terminalDependencyIdentities.add(identity);
|
|
3637
|
+
return true;
|
|
3638
|
+
}
|
|
3639
|
+
#dependencyIssueIsTerminal(issue) {
|
|
3640
|
+
return isGithubIssue(issue)
|
|
3641
|
+
? githubFactoryIssueIsClosed(issue)
|
|
3642
|
+
: this.#states.isRole(issue.stateId, 'done');
|
|
3643
|
+
}
|
|
3644
|
+
async #reportDependencyPark(issue, parked, dryRun) {
|
|
3645
|
+
const cycle = parked.cycle?.join(' -> ');
|
|
3646
|
+
const blockers = parked.blockers.map((blocker) => blocker.label);
|
|
3647
|
+
const signature = JSON.stringify({ blockers, cycle, capacityBlocked: parked.capacityBlocked });
|
|
3648
|
+
const marker = `<!-- factory-dependency-park:${stableHash(signature)} -->`;
|
|
3649
|
+
const comment = parked.cycle
|
|
3650
|
+
? [
|
|
3651
|
+
marker,
|
|
3652
|
+
'Factory refused dispatch because it detected a dependency cycle.',
|
|
3653
|
+
`Cycle: ${cycle}`,
|
|
3654
|
+
blockers.length > 0 ? `Unresolved blockers: ${blockers.join(', ')}` : undefined,
|
|
3655
|
+
].filter((line) => Boolean(line)).join('\n')
|
|
3656
|
+
: [
|
|
3657
|
+
marker,
|
|
3658
|
+
'Factory parked this issue because declared dependencies are unresolved.',
|
|
3659
|
+
`Blocked by: ${blockers.join(', ')}`,
|
|
3660
|
+
parked.capacityBlocked ? 'Capacity is also currently unavailable.' : undefined,
|
|
3661
|
+
].filter((line) => Boolean(line)).join('\n');
|
|
3662
|
+
const key = issueKey(parked.issue);
|
|
3663
|
+
if (dryRun || this.#dependencyParkNotices.get(key) === signature)
|
|
3664
|
+
return comment;
|
|
3665
|
+
this.#increment(parked.cycle ? 'dependencyCycles' : 'dependencyParks');
|
|
3666
|
+
if (parked.cycle) {
|
|
3667
|
+
this.#error(new Error(`Dependency cycle detected: ${cycle}`), parked.issue);
|
|
3668
|
+
}
|
|
3669
|
+
try {
|
|
3670
|
+
await this.#postIssueComment(issue, comment);
|
|
3671
|
+
this.#dependencyParkNotices.set(key, signature);
|
|
3672
|
+
}
|
|
3673
|
+
catch (error) {
|
|
3674
|
+
this.#logger.warn?.('[factory] dependency park writeback skipped', {
|
|
3675
|
+
issue: parked.issue.key,
|
|
3676
|
+
error: describeError(error).errorMessage,
|
|
3677
|
+
});
|
|
3678
|
+
}
|
|
3679
|
+
return comment;
|
|
3680
|
+
}
|
|
3681
|
+
async #markDependencyTerminalAndReconcile(issue) {
|
|
3682
|
+
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
3683
|
+
const identity = dependencyIdentityForIssue(issue, repo);
|
|
3684
|
+
if (!identity)
|
|
3685
|
+
return;
|
|
3686
|
+
this.#indexDependencyIssue(issue);
|
|
3687
|
+
// A merge event can prove terminality before the issue snapshot reflects
|
|
3688
|
+
// its new state, so apply the explicit observation after indexing it.
|
|
3689
|
+
this.#terminalDependencyIdentities.add(identity);
|
|
3690
|
+
const batch = await this.#batch();
|
|
3691
|
+
this.#clearDependencyPark(batch, issueRef(issue));
|
|
3692
|
+
const candidates = batch.parked.filter((parked) => parked.blockers.some((blocker) => blocker.identity === identity));
|
|
3693
|
+
for (const parked of candidates) {
|
|
3694
|
+
try {
|
|
3695
|
+
const liveIssue = await this.#readIssue(parked.issue.path);
|
|
3696
|
+
if (!liveIssue || !this.#isIssueReady(liveIssue))
|
|
3697
|
+
continue;
|
|
3698
|
+
await this.dispatch(parked.decision, { dryRun: parked.dryRun });
|
|
3699
|
+
}
|
|
3700
|
+
catch (error) {
|
|
3701
|
+
this.#logger.warn?.('[factory] dependency park reconciliation failed', {
|
|
3702
|
+
blocker: identity,
|
|
3703
|
+
issue: parked.issue.key,
|
|
3704
|
+
error: describeError(error).errorMessage,
|
|
3705
|
+
});
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3248
3709
|
async #dispatchBlockReason(issue) {
|
|
3249
3710
|
const key = issueStateKey(issue);
|
|
3250
3711
|
const state = await this.#state.getDispatchAttempts(this.#workspaceId, key);
|
|
@@ -3529,12 +3990,14 @@ export class FactoryLoop {
|
|
|
3529
3990
|
// (relayfile-adapters#205). The factory matches state by UUID, so backfill
|
|
3530
3991
|
// the id from the name when the payload omitted it — otherwise every issue
|
|
3531
3992
|
// reads as stateId='' and no role (incl. readyForAgent) ever matches.
|
|
3993
|
+
let resolvedIssue = issue;
|
|
3532
3994
|
if (issue && !issue.stateId && issue.state?.name) {
|
|
3533
3995
|
const backfilled = this.#states.idForName(issue.state.name, issue.team);
|
|
3534
3996
|
if (backfilled)
|
|
3535
|
-
|
|
3997
|
+
resolvedIssue = { ...issue, stateId: backfilled };
|
|
3536
3998
|
}
|
|
3537
|
-
|
|
3999
|
+
this.#indexDependencyIssue(resolvedIssue);
|
|
4000
|
+
return resolvedIssue;
|
|
3538
4001
|
}
|
|
3539
4002
|
catch (error) {
|
|
3540
4003
|
if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
|
|
@@ -3976,7 +4439,7 @@ export class FactoryLoop {
|
|
|
3976
4439
|
let publishedPr;
|
|
3977
4440
|
if (exiting?.spec.role === 'implementer' &&
|
|
3978
4441
|
!record.dryRun &&
|
|
3979
|
-
|
|
4442
|
+
this.#shouldAttemptPullRequestPublication()) {
|
|
3980
4443
|
try {
|
|
3981
4444
|
await this.#saveDispatchLifecycle(record, 'publishing');
|
|
3982
4445
|
publishedPr = await this.#publishImplementerPullRequest(record, exiting);
|
|
@@ -4003,6 +4466,7 @@ export class FactoryLoop {
|
|
|
4003
4466
|
repo: publishedPr.repo,
|
|
4004
4467
|
prNumber: publishedPr.number,
|
|
4005
4468
|
url: publishedPr.url,
|
|
4469
|
+
authoritative: true,
|
|
4006
4470
|
});
|
|
4007
4471
|
}
|
|
4008
4472
|
else {
|
|
@@ -4032,8 +4496,42 @@ export class FactoryLoop {
|
|
|
4032
4496
|
});
|
|
4033
4497
|
}
|
|
4034
4498
|
if (hasCompletionPr) {
|
|
4499
|
+
let reconciledPr;
|
|
4500
|
+
if (tracingReconciledExit && tracked.spec.role === 'implementer') {
|
|
4501
|
+
// A restart can discover that the implementer is gone after its PR
|
|
4502
|
+
// reached GitHub but before the durable receipt was saved. Persist the
|
|
4503
|
+
// exact existing-branch receipt before handing off to a babysitter;
|
|
4504
|
+
// otherwise the lifecycle remains `running` forever and consumes a
|
|
4505
|
+
// batch slot even though useful implementation work has finished.
|
|
4506
|
+
if (!await this.#saveDispatchLifecycle(record, 'publishing'))
|
|
4507
|
+
return;
|
|
4508
|
+
try {
|
|
4509
|
+
reconciledPr = await this.#publishImplementerPullRequest(record, tracked, {
|
|
4510
|
+
reconcileExisting: true,
|
|
4511
|
+
});
|
|
4512
|
+
if (reconciledPr && !await this.#saveDispatchLifecycle(record, 'published', reconciledPr))
|
|
4513
|
+
return;
|
|
4514
|
+
}
|
|
4515
|
+
catch (error) {
|
|
4516
|
+
this.#increment('githubPullRequestPublishFailures');
|
|
4517
|
+
this.#error(error, record.issue);
|
|
4518
|
+
this.#scheduleDispatchLifecycleRetry(record);
|
|
4519
|
+
return;
|
|
4520
|
+
}
|
|
4521
|
+
}
|
|
4035
4522
|
if (this.#config.babysitter.enabled) {
|
|
4036
|
-
|
|
4523
|
+
if (reconciledPr) {
|
|
4524
|
+
await this.#ensureBabysitter(record, {
|
|
4525
|
+
repo: reconciledPr.repo,
|
|
4526
|
+
prNumber: reconciledPr.number,
|
|
4527
|
+
url: reconciledPr.url,
|
|
4528
|
+
headRef: reconciledPr.headRef,
|
|
4529
|
+
authoritative: true,
|
|
4530
|
+
});
|
|
4531
|
+
}
|
|
4532
|
+
else {
|
|
4533
|
+
await this.#ensureBabysitterForIssue(record);
|
|
4534
|
+
}
|
|
4037
4535
|
return;
|
|
4038
4536
|
}
|
|
4039
4537
|
if (await this.#allImplementersHaveCompletionPr(record))
|
|
@@ -4060,6 +4558,7 @@ export class FactoryLoop {
|
|
|
4060
4558
|
repo: publishedPr.repo,
|
|
4061
4559
|
prNumber: publishedPr.number,
|
|
4062
4560
|
url: publishedPr.url,
|
|
4561
|
+
authoritative: true,
|
|
4063
4562
|
});
|
|
4064
4563
|
}
|
|
4065
4564
|
else {
|
|
@@ -4175,11 +4674,14 @@ export class FactoryLoop {
|
|
|
4175
4674
|
}
|
|
4176
4675
|
// Publish a PR from the implementer's committed branch when it exited without
|
|
4177
4676
|
// opening one. Best-effort and idempotent: returns undefined when there is no
|
|
4178
|
-
//
|
|
4179
|
-
//
|
|
4180
|
-
//
|
|
4677
|
+
// clone or nothing publishable (no branch / no commits ahead of base —
|
|
4678
|
+
// `#publishImplementerPullRequest` refuses head==base), so the caller falls
|
|
4679
|
+
// back to its normal restart/conclude handling. Explicit app identity errors
|
|
4680
|
+
// remain fail-closed and propagate to the lifecycle error path.
|
|
4181
4681
|
async #tryPublishImplementerPr(record, implementer) {
|
|
4182
|
-
if (record.dryRun ||
|
|
4682
|
+
if (record.dryRun ||
|
|
4683
|
+
(!implementer.spec.clonePath && !implementer.spec.branch) ||
|
|
4684
|
+
!this.#shouldAttemptPullRequestPublication()) {
|
|
4183
4685
|
return undefined;
|
|
4184
4686
|
}
|
|
4185
4687
|
try {
|
|
@@ -4199,6 +4701,8 @@ export class FactoryLoop {
|
|
|
4199
4701
|
return published;
|
|
4200
4702
|
}
|
|
4201
4703
|
catch (error) {
|
|
4704
|
+
if (this.#config.github.identity === 'app' && !this.#mount.githubWrite)
|
|
4705
|
+
throw error;
|
|
4202
4706
|
this.#increment('exitPrPublishSkipped');
|
|
4203
4707
|
this.#logger.warn?.('[factory] could not publish implementer PR on exit; falling back', {
|
|
4204
4708
|
issue: record.issue.key,
|
|
@@ -4214,10 +4718,7 @@ export class FactoryLoop {
|
|
|
4214
4718
|
const cached = this.#publishedPullRequests.get(key);
|
|
4215
4719
|
if (cached)
|
|
4216
4720
|
return cached;
|
|
4217
|
-
const
|
|
4218
|
-
if (!githubWrite) {
|
|
4219
|
-
throw new Error('GitHub write path not available on this mount — connect GitHub to your workspace');
|
|
4220
|
-
}
|
|
4721
|
+
const { identity, publisher } = this.#githubPullRequestPublisher();
|
|
4221
4722
|
const remoteBranch = implementer.result?.locality === 'remote' && implementer.spec.branch
|
|
4222
4723
|
? implementer.spec.branch
|
|
4223
4724
|
: undefined;
|
|
@@ -4236,9 +4737,10 @@ export class FactoryLoop {
|
|
|
4236
4737
|
: undefined;
|
|
4237
4738
|
const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
|
|
4238
4739
|
const durableReceipt = publishedPullRequests(durable).find((receipt) => receipt.repo.toLowerCase() === repo.toLowerCase());
|
|
4239
|
-
if (durableReceipt)
|
|
4240
|
-
return durableReceipt;
|
|
4241
4740
|
const expectedHeadRef = implementer.spec.branch ?? remoteBranch;
|
|
4741
|
+
if (durableReceipt &&
|
|
4742
|
+
(!opts.reconcileExisting || !expectedHeadRef || durableReceipt.headRef === expectedHeadRef))
|
|
4743
|
+
return durableReceipt;
|
|
4242
4744
|
if (opts.reconcileExisting && expectedHeadRef) {
|
|
4243
4745
|
const existing = await this.#openPullRequestByHead(repo, expectedHeadRef);
|
|
4244
4746
|
if (existing) {
|
|
@@ -4254,32 +4756,67 @@ export class FactoryLoop {
|
|
|
4254
4756
|
}
|
|
4255
4757
|
}
|
|
4256
4758
|
const baseRef = await this.#githubDefaultBranch(repo);
|
|
4257
|
-
const result = await
|
|
4759
|
+
const result = await publisher.publishPullRequest({
|
|
4258
4760
|
repo,
|
|
4259
4761
|
...(remoteBranch ? { headRef: remoteBranch } : { clonePath: implementer.spec.clonePath }),
|
|
4260
4762
|
baseRef,
|
|
4261
4763
|
title: `${issue.key}: ${issue.title}`,
|
|
4262
4764
|
body: githubPullRequestBody(issue),
|
|
4263
4765
|
});
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4766
|
+
const published = result.author
|
|
4767
|
+
? result
|
|
4768
|
+
: { ...result, author: identity };
|
|
4769
|
+
if (published.repo.toLowerCase() !== repo.toLowerCase() ||
|
|
4770
|
+
published.headRef !== (remoteBranch ?? published.headRef) ||
|
|
4771
|
+
!Number.isInteger(published.number) ||
|
|
4772
|
+
published.number <= 0 ||
|
|
4773
|
+
!published.url) {
|
|
4269
4774
|
throw new Error(`GitHub PR publication returned an unexpected receipt for ${repo}/${remoteBranch ?? 'local HEAD'}`);
|
|
4270
4775
|
}
|
|
4271
4776
|
if (remoteBranch && this.#mount.writebackTransport === 'relayfile-cloud') {
|
|
4272
|
-
await this.#confirmPublishedRemotePullRequest(repo,
|
|
4777
|
+
await this.#confirmPublishedRemotePullRequest(repo, published, remoteBranch);
|
|
4273
4778
|
}
|
|
4274
|
-
this.#publishedPullRequests.set(key,
|
|
4779
|
+
this.#publishedPullRequests.set(key, published);
|
|
4275
4780
|
this.#increment('githubPullRequestsPublished');
|
|
4276
|
-
this.#logger.info?.('[factory] published PR
|
|
4781
|
+
this.#logger.info?.('[factory] published PR', {
|
|
4277
4782
|
issue: issue.key,
|
|
4278
|
-
repo:
|
|
4279
|
-
prNumber:
|
|
4280
|
-
url:
|
|
4783
|
+
repo: published.repo,
|
|
4784
|
+
prNumber: published.number,
|
|
4785
|
+
url: published.url,
|
|
4786
|
+
identity,
|
|
4787
|
+
author: published.author,
|
|
4281
4788
|
});
|
|
4282
|
-
return
|
|
4789
|
+
return published;
|
|
4790
|
+
}
|
|
4791
|
+
#githubPullRequestPublisher() {
|
|
4792
|
+
const configured = this.#config.github.identity;
|
|
4793
|
+
if (configured !== 'user' && this.#mount.githubWrite) {
|
|
4794
|
+
return { identity: 'app', publisher: this.#mount.githubWrite };
|
|
4795
|
+
}
|
|
4796
|
+
if (configured === 'app') {
|
|
4797
|
+
throw new Error('GitHub PR identity "app" requires a connected workspace GitHub App write path; refusing to fall back to the local gh user');
|
|
4798
|
+
}
|
|
4799
|
+
const publishPullRequest = this.#githubWriteback.publishPullRequest;
|
|
4800
|
+
if (!publishPullRequest) {
|
|
4801
|
+
throw new Error(`GitHub PR identity "${configured}" requires local gh user publication, but the configured GitHub writeback does not support it`);
|
|
4802
|
+
}
|
|
4803
|
+
return {
|
|
4804
|
+
identity: 'user',
|
|
4805
|
+
publisher: { publishPullRequest: publishPullRequest.bind(this.#githubWriteback) },
|
|
4806
|
+
};
|
|
4807
|
+
}
|
|
4808
|
+
#shouldAttemptPullRequestPublication() {
|
|
4809
|
+
if (this.#config.github.identity !== 'auto')
|
|
4810
|
+
return true;
|
|
4811
|
+
if (this.#mount.githubWrite)
|
|
4812
|
+
return true;
|
|
4813
|
+
// Fake mounts deliberately avoid invoking the host's real gh binary unless
|
|
4814
|
+
// a publisher was injected. Every production mount (cloud or custom) still
|
|
4815
|
+
// gets the configured auto fallback.
|
|
4816
|
+
if (this.#mount.writebackTransport === 'test') {
|
|
4817
|
+
return Boolean(this.#githubWritebackProvided && this.#githubWriteback.publishPullRequest);
|
|
4818
|
+
}
|
|
4819
|
+
return true;
|
|
4283
4820
|
}
|
|
4284
4821
|
async #openPullRequestByHead(repo, expectedHeadRef) {
|
|
4285
4822
|
if (this.#hasProbePrGhRunner) {
|
|
@@ -4678,7 +5215,17 @@ export class FactoryLoop {
|
|
|
4678
5215
|
if (!tracked.sessionRef) {
|
|
4679
5216
|
return;
|
|
4680
5217
|
}
|
|
5218
|
+
this.#logger.debug?.('[factory] tracked agent resume preparation started', {
|
|
5219
|
+
issue: record.issue.key,
|
|
5220
|
+
name,
|
|
5221
|
+
role: tracked.spec.role,
|
|
5222
|
+
});
|
|
4681
5223
|
await this.#prepareAgentWorktree(record, tracked.spec);
|
|
5224
|
+
this.#logger.debug?.('[factory] tracked agent resume spawn started', {
|
|
5225
|
+
issue: record.issue.key,
|
|
5226
|
+
name,
|
|
5227
|
+
role: tracked.spec.role,
|
|
5228
|
+
});
|
|
4682
5229
|
const result = await this.#fleet.resume({
|
|
4683
5230
|
name,
|
|
4684
5231
|
sessionRef: tracked.sessionRef,
|
|
@@ -4687,6 +5234,12 @@ export class FactoryLoop {
|
|
|
4687
5234
|
repo: tracked.spec.repo,
|
|
4688
5235
|
clonePath: tracked.spec.clonePath,
|
|
4689
5236
|
});
|
|
5237
|
+
this.#logger.debug?.('[factory] tracked agent resume spawn completed', {
|
|
5238
|
+
issue: record.issue.key,
|
|
5239
|
+
name,
|
|
5240
|
+
resumedName: result.name,
|
|
5241
|
+
role: tracked.spec.role,
|
|
5242
|
+
});
|
|
4690
5243
|
tracked.result = {
|
|
4691
5244
|
...result,
|
|
4692
5245
|
node: result.node ?? tracked.result?.node,
|
|
@@ -4695,35 +5248,156 @@ export class FactoryLoop {
|
|
|
4695
5248
|
tracked.sessionRef = result.sessionRef ?? tracked.sessionRef;
|
|
4696
5249
|
record.agents.delete(name);
|
|
4697
5250
|
record.agents.set(result.name, tracked);
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
5251
|
+
await this.#retargetBabysitterAgent(record, name, tracked);
|
|
5252
|
+
this.#logger.debug?.('[factory] tracked agent resume ownership retargeted', {
|
|
5253
|
+
issue: record.issue.key,
|
|
5254
|
+
name,
|
|
5255
|
+
resumedName: result.name,
|
|
5256
|
+
role: tracked.spec.role,
|
|
5257
|
+
});
|
|
5258
|
+
await this.#reportAgent(record, tracked, 'agent.resumed');
|
|
5259
|
+
}
|
|
5260
|
+
async #retargetBabysitterAgent(record, previousName, tracked) {
|
|
5261
|
+
if (tracked.spec.role !== 'babysitter')
|
|
5262
|
+
return;
|
|
5263
|
+
const currentName = tracked.result?.name ?? tracked.spec.name;
|
|
5264
|
+
this.#babysitterCriticalAgents.delete(previousName);
|
|
5265
|
+
const ownership = [...this.#babysitterPr.entries()]
|
|
5266
|
+
.find(([, candidate]) => candidate.agentName === previousName);
|
|
5267
|
+
const [ownershipKey, ref] = ownership ?? [];
|
|
5268
|
+
if (!ref)
|
|
5269
|
+
return;
|
|
5270
|
+
ref.agentName = currentName;
|
|
5271
|
+
// Resuming a session commonly preserves its Relaycast name. Iterate a
|
|
5272
|
+
// snapshot because deleting and re-inserting that same key while walking
|
|
5273
|
+
// the live Map would append it to the iterator again indefinitely.
|
|
5274
|
+
for (const [wakeKey, state] of [...this.#babysitterWakeStates]) {
|
|
5275
|
+
if (state.agentName !== previousName)
|
|
5276
|
+
continue;
|
|
5277
|
+
if (state.timer)
|
|
5278
|
+
clearTimeout(state.timer);
|
|
5279
|
+
this.#babysitterWakeStates.delete(wakeKey);
|
|
5280
|
+
state.timer = undefined;
|
|
5281
|
+
state.agentName = currentName;
|
|
5282
|
+
state.tracked = tracked;
|
|
5283
|
+
if (state.deferredSubmitTargets) {
|
|
5284
|
+
state.deferredSubmitTargets = undefined;
|
|
5285
|
+
state.deliveringKinds = undefined;
|
|
5286
|
+
state.kinds.add('pull-request-state');
|
|
5287
|
+
await this.#recordPendingBabysitterWake(state);
|
|
5288
|
+
}
|
|
5289
|
+
this.#babysitterWakeStates.set(babysitterWakeKey(record.issue, ref), state);
|
|
5290
|
+
if (state.kinds.size > 0)
|
|
5291
|
+
this.#scheduleBabysitterWake(state, BABYSITTER_EVENT_COALESCE_MS);
|
|
5292
|
+
}
|
|
5293
|
+
await this.#persistBabysitterSession(record.issue, ref, tracked, ownershipKey);
|
|
5294
|
+
}
|
|
5295
|
+
async #recoverUnreachableBabysitter(state) {
|
|
5296
|
+
const batch = await this.#batch();
|
|
5297
|
+
const record = batch.getIssueByAgent(state.agentName);
|
|
5298
|
+
const tracked = record?.agents.get(state.agentName);
|
|
5299
|
+
if (!record || !tracked || tracked.spec.role !== 'babysitter')
|
|
5300
|
+
return false;
|
|
5301
|
+
if (!await this.#assertIssueDispatchLifecycleOwner(record.issue))
|
|
5302
|
+
return false;
|
|
5303
|
+
const previousName = state.agentName;
|
|
5304
|
+
this.#fleet.markAgentTerminal?.(previousName, 'babysitter-unreachable');
|
|
5305
|
+
try {
|
|
5306
|
+
await this.#fleet.release(previousName, 'babysitter-unreachable');
|
|
5307
|
+
this.#logger.debug?.('[factory] unreachable babysitter release completed', {
|
|
5308
|
+
issue: record.issue.key,
|
|
5309
|
+
babysitter: previousName,
|
|
5310
|
+
});
|
|
5311
|
+
}
|
|
5312
|
+
catch (error) {
|
|
5313
|
+
// An unresolvable Relaycast identity is frequently already absent from
|
|
5314
|
+
// placement too. Release is best-effort; the fresh spawn below is the
|
|
5315
|
+
// recovery operation that matters.
|
|
5316
|
+
this.#increment('babysitterUnreachableReleaseFailures');
|
|
5317
|
+
this.#logger.warn?.('[factory] unreachable babysitter release failed; attempting session recovery', {
|
|
5318
|
+
issue: record.issue.key,
|
|
5319
|
+
babysitter: previousName,
|
|
5320
|
+
error: describeError(error).errorMessage,
|
|
5321
|
+
});
|
|
5322
|
+
}
|
|
5323
|
+
try {
|
|
5324
|
+
const configuredCapability = this.#config.agentCapabilities.babysitter;
|
|
5325
|
+
const capabilityChanged = tracked.spec.capability !== configuredCapability;
|
|
5326
|
+
if (tracked.sessionRef && !capabilityChanged) {
|
|
5327
|
+
await this.#resumeTrackedAgent(record, previousName, tracked);
|
|
5328
|
+
}
|
|
5329
|
+
else {
|
|
5330
|
+
const invocationId = `${batch.invocationIdFor(record.issue, tracked.spec)}:unreachable:${this.#clock.now()}`;
|
|
5331
|
+
const { sessionRef: _staleSessionRef, ...persistedSpec } = tracked.spec;
|
|
5332
|
+
const replacementSpec = capabilityChanged
|
|
5333
|
+
? {
|
|
5334
|
+
...persistedSpec,
|
|
5335
|
+
capability: configuredCapability,
|
|
5336
|
+
model: this.#config.models.babysitter,
|
|
4718
5337
|
}
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
5338
|
+
: persistedSpec;
|
|
5339
|
+
await this.#prepareAgentWorktree(record, replacementSpec);
|
|
5340
|
+
const result = await this.#fleet.spawn({
|
|
5341
|
+
name: replacementSpec.name,
|
|
5342
|
+
capability: replacementSpec.capability,
|
|
5343
|
+
node: tracked.result?.node ?? replacementSpec.node ?? 'self',
|
|
5344
|
+
repo: replacementSpec.repo,
|
|
5345
|
+
task: replacementSpec.task,
|
|
5346
|
+
model: replacementSpec.model,
|
|
5347
|
+
cwd: replacementSpec.clonePath,
|
|
5348
|
+
invocationId,
|
|
5349
|
+
restartPolicy: defaultRestartPolicy(replacementSpec),
|
|
5350
|
+
channel: replacementSpec.channel,
|
|
5351
|
+
});
|
|
5352
|
+
batch.recordSpawn(record, replacementSpec, invocationId, result);
|
|
5353
|
+
const restarted = record.agents.get(result.name);
|
|
5354
|
+
if (!restarted)
|
|
5355
|
+
throw new Error(`Recovered babysitter ${result.name} was not tracked`);
|
|
5356
|
+
await this.#retargetBabysitterAgent(record, previousName, restarted);
|
|
5357
|
+
await this.#reportAgent(record, restarted, 'agent.resumed');
|
|
5358
|
+
if (capabilityChanged) {
|
|
5359
|
+
this.#increment('babysitterCapabilityMigrations');
|
|
5360
|
+
this.#logger.info?.('[factory] cold-started unreachable babysitter on configured capability', {
|
|
5361
|
+
issue: record.issue.key,
|
|
5362
|
+
babysitter: result.name,
|
|
5363
|
+
previousCapability: tracked.spec.capability,
|
|
5364
|
+
capability: configuredCapability,
|
|
5365
|
+
});
|
|
4722
5366
|
}
|
|
4723
|
-
await this.#persistBabysitterSession(record.issue, ref, tracked, ownershipKey);
|
|
4724
5367
|
}
|
|
5368
|
+
this.#logger.debug?.('[factory] unreachable babysitter replacement started', {
|
|
5369
|
+
issue: record.issue.key,
|
|
5370
|
+
previousBabysitter: previousName,
|
|
5371
|
+
babysitter: state.agentName,
|
|
5372
|
+
});
|
|
5373
|
+
await this.#writeInFlightRegistry();
|
|
5374
|
+
this.#logger.debug?.('[factory] unreachable babysitter registry refreshed', {
|
|
5375
|
+
issue: record.issue.key,
|
|
5376
|
+
babysitter: state.agentName,
|
|
5377
|
+
});
|
|
5378
|
+
if (!await this.#saveDispatchLifecycle(record, 'running'))
|
|
5379
|
+
return false;
|
|
5380
|
+
this.#increment('babysitterEventWakeUnreachableRecoveries');
|
|
5381
|
+
this.#logger.info?.('[factory] restarted unreachable babysitter session', {
|
|
5382
|
+
issue: record.issue.key,
|
|
5383
|
+
repo: state.repo,
|
|
5384
|
+
prNumber: state.prNumber,
|
|
5385
|
+
previousBabysitter: previousName,
|
|
5386
|
+
babysitter: state.agentName,
|
|
5387
|
+
});
|
|
5388
|
+
return true;
|
|
5389
|
+
}
|
|
5390
|
+
catch (error) {
|
|
5391
|
+
this.#increment('babysitterEventWakeUnreachableRecoveryFailures');
|
|
5392
|
+
this.#logger.warn?.('[factory] failed to restart unreachable babysitter session', {
|
|
5393
|
+
issue: record.issue.key,
|
|
5394
|
+
repo: state.repo,
|
|
5395
|
+
prNumber: state.prNumber,
|
|
5396
|
+
babysitter: previousName,
|
|
5397
|
+
error: describeError(error).errorMessage,
|
|
5398
|
+
});
|
|
5399
|
+
return false;
|
|
4725
5400
|
}
|
|
4726
|
-
await this.#reportAgent(record, tracked, 'agent.resumed');
|
|
4727
5401
|
}
|
|
4728
5402
|
async #handleDeliveryFailed(info) {
|
|
4729
5403
|
const critical = await this.#state.consumeCritical(this.#workspaceId, info.msgId ?? '');
|
|
@@ -6083,27 +6757,38 @@ export class FactoryLoop {
|
|
|
6083
6757
|
const implementerNames = decision.implementers.map((implementer) => implementer.name);
|
|
6084
6758
|
const reviewerName = decision.reviewer.name;
|
|
6085
6759
|
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
6086
|
-
const render = (spec) =>
|
|
6087
|
-
|
|
6088
|
-
|
|
6760
|
+
const render = async (spec) => {
|
|
6761
|
+
const route = routeForSpec(decision, spec);
|
|
6762
|
+
const testGuidance = await resolveTestGuidance({
|
|
6763
|
+
repoPath: route.clonePath,
|
|
6089
6764
|
issue: templateIssue,
|
|
6090
|
-
route
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6765
|
+
route,
|
|
6766
|
+
previewUrl: previewUrlFromSpec(spec),
|
|
6767
|
+
});
|
|
6768
|
+
return {
|
|
6769
|
+
...spec,
|
|
6770
|
+
task: renderAgentTask({
|
|
6771
|
+
issue: templateIssue,
|
|
6772
|
+
route,
|
|
6773
|
+
role: spec.role,
|
|
6774
|
+
config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
|
|
6775
|
+
reviewerName,
|
|
6776
|
+
implementerNames,
|
|
6777
|
+
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
6778
|
+
integrationInstructions,
|
|
6779
|
+
testGuidance,
|
|
6780
|
+
branchName: spec.branch ?? decision.implementers.find((candidate) => candidate.repo === spec.repo)?.branch,
|
|
6781
|
+
branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
|
|
6782
|
+
agentName: spec.name,
|
|
6783
|
+
...(this.#fleet.lifecycleActionName ? { lifecycleActionName: this.#fleet.lifecycleActionName } : {}),
|
|
6784
|
+
}),
|
|
6785
|
+
};
|
|
6786
|
+
};
|
|
6787
|
+
const rendered = await Promise.all([...decision.implementers, decision.reviewer].map(render));
|
|
6103
6788
|
return {
|
|
6104
6789
|
...decision,
|
|
6105
|
-
implementers: decision.implementers.
|
|
6106
|
-
reviewer:
|
|
6790
|
+
implementers: rendered.slice(0, decision.implementers.length),
|
|
6791
|
+
reviewer: rendered[decision.implementers.length],
|
|
6107
6792
|
};
|
|
6108
6793
|
}
|
|
6109
6794
|
#consumePendingDispatchClarifications(issue) {
|
|
@@ -6195,11 +6880,15 @@ export class FactoryLoop {
|
|
|
6195
6880
|
continue;
|
|
6196
6881
|
}
|
|
6197
6882
|
const record = batch.getIssue(session.issue);
|
|
6198
|
-
const
|
|
6199
|
-
|
|
6883
|
+
const trackedEntry = record?.agents.has(session.agentName)
|
|
6884
|
+
? [session.agentName, record.agents.get(session.agentName)]
|
|
6885
|
+
: [...(record?.agents.entries() ?? [])].find(([, agent]) => agent.spec.role === 'babysitter' &&
|
|
6200
6886
|
githubPrIdentity(agent.spec.ownedPullRequest?.repo ?? '', agent.spec.ownedPullRequest?.number ?? 0) ===
|
|
6201
|
-
githubPrIdentity(session.repo, session.prNumber))
|
|
6887
|
+
githubPrIdentity(session.repo, session.prNumber));
|
|
6888
|
+
const tracked = trackedEntry?.[1]
|
|
6202
6889
|
?? durableBabysitterTrackedAgent(session, this.#config.agentCapabilities.babysitter);
|
|
6890
|
+
if (record && !trackedEntry)
|
|
6891
|
+
record.agents.set(session.agentName, tracked);
|
|
6203
6892
|
const ref = {
|
|
6204
6893
|
repo: session.repo,
|
|
6205
6894
|
prNumber: session.prNumber,
|
|
@@ -6223,6 +6912,74 @@ export class FactoryLoop {
|
|
|
6223
6912
|
}
|
|
6224
6913
|
}
|
|
6225
6914
|
}
|
|
6915
|
+
async #reconcileRestoredBabysitterReceipts(onlyRecord) {
|
|
6916
|
+
const records = onlyRecord ? [onlyRecord] : (await this.#batch()).inFlight;
|
|
6917
|
+
for (const record of records) {
|
|
6918
|
+
if (!await this.#assertIssueDispatchLifecycleOwner(record.issue))
|
|
6919
|
+
continue;
|
|
6920
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
|
|
6921
|
+
// Babysitter sessions are independently durable and restore only after
|
|
6922
|
+
// their mounted PR metadata passes the open/draft/issue-identity guard.
|
|
6923
|
+
// Fold those validated receipts back into the lifecycle on takeover.
|
|
6924
|
+
// This closes the crash gap where the babysitter session persisted but
|
|
6925
|
+
// the lifecycle PR receipt did not, and lets exact ownership retire any
|
|
6926
|
+
// earlier weak-match babysitter for the same repository. The lifecycle's
|
|
6927
|
+
// own exact receipts are authoritative independently of the session
|
|
6928
|
+
// index: session restoration can legitimately be delayed or skipped
|
|
6929
|
+
// while mounted PR metadata converges, but that must never preserve a
|
|
6930
|
+
// superseded weak-match babysitter already disproved by publication.
|
|
6931
|
+
const authoritative = new Map();
|
|
6932
|
+
for (const receipt of lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : [])) {
|
|
6933
|
+
if (!receipt.repo || !validPrNumber(receipt.number) || !receipt.url || !receipt.headRef)
|
|
6934
|
+
continue;
|
|
6935
|
+
const identity = githubPrIdentity(receipt.repo, receipt.number);
|
|
6936
|
+
if (identity)
|
|
6937
|
+
authoritative.set(identity, { ...receipt });
|
|
6938
|
+
}
|
|
6939
|
+
const restored = [...this.#babysitterPr.entries()]
|
|
6940
|
+
.filter(([ownershipKey, ref]) => ref.agentName && issueKey(this.#babysitterIssueRefs.get(ownershipKey) ?? record.issue) === issueKey(record.issue))
|
|
6941
|
+
.map(([, ref]) => ref);
|
|
6942
|
+
for (const receipt of restored) {
|
|
6943
|
+
if (!receipt.repo || !validPrNumber(receipt.prNumber))
|
|
6944
|
+
continue;
|
|
6945
|
+
const snapshot = await this.#readPrSnapshot(receipt);
|
|
6946
|
+
const headRef = snapshot?.headRef ?? record.decision.implementers
|
|
6947
|
+
.find((implementer) => implementer.repo.toLowerCase() === receipt.repo.toLowerCase())?.branch;
|
|
6948
|
+
if (!headRef)
|
|
6949
|
+
continue;
|
|
6950
|
+
const identity = githubPrIdentity(receipt.repo, receipt.prNumber);
|
|
6951
|
+
if (!identity)
|
|
6952
|
+
continue;
|
|
6953
|
+
authoritative.set(identity, {
|
|
6954
|
+
repo: receipt.repo,
|
|
6955
|
+
number: receipt.prNumber,
|
|
6956
|
+
url: snapshot?.url ?? `https://github.com/${receipt.repo}/pull/${receipt.prNumber}`,
|
|
6957
|
+
headRef,
|
|
6958
|
+
...(receipt.path ? { path: receipt.path } : {}),
|
|
6959
|
+
});
|
|
6960
|
+
}
|
|
6961
|
+
if (authoritative.size > 0) {
|
|
6962
|
+
this.#logger.debug?.('[factory] reconciling authoritative babysitter receipts', {
|
|
6963
|
+
issue: record.issue.key,
|
|
6964
|
+
lifecycleReceipts: lifecycle?.pullRequests?.map((receipt) => receipt.number) ?? [],
|
|
6965
|
+
restoredReceipts: restored.map((receipt) => receipt.prNumber),
|
|
6966
|
+
authoritativeReceipts: [...authoritative.values()].map((receipt) => receipt.number),
|
|
6967
|
+
});
|
|
6968
|
+
await this.#retireBabysittersOutsideCurrentRoutes(record);
|
|
6969
|
+
}
|
|
6970
|
+
for (const published of authoritative.values()) {
|
|
6971
|
+
if (!await this.#saveDispatchLifecycle(record, 'running', published))
|
|
6972
|
+
return;
|
|
6973
|
+
await this.#ensureBabysitter(record, {
|
|
6974
|
+
repo: published.repo,
|
|
6975
|
+
prNumber: published.number,
|
|
6976
|
+
url: published.url,
|
|
6977
|
+
path: published.path,
|
|
6978
|
+
authoritative: true,
|
|
6979
|
+
});
|
|
6980
|
+
}
|
|
6981
|
+
}
|
|
6982
|
+
}
|
|
6226
6983
|
async #drainBabysitterWakesForStop() {
|
|
6227
6984
|
for (const state of this.#babysitterWakeStates.values()) {
|
|
6228
6985
|
state.cancelled = true;
|
|
@@ -6516,6 +7273,7 @@ export class FactoryLoop {
|
|
|
6516
7273
|
await this.#fleet.sendMessage(input);
|
|
6517
7274
|
state.unreachableSinceMs = undefined;
|
|
6518
7275
|
state.unreachableEscalated = false;
|
|
7276
|
+
state.unreachableRecoveryAfterMs = undefined;
|
|
6519
7277
|
if (this.#stopping || state.cancelled) {
|
|
6520
7278
|
state.deliveringKinds = undefined;
|
|
6521
7279
|
return;
|
|
@@ -6531,6 +7289,7 @@ export class FactoryLoop {
|
|
|
6531
7289
|
// registration-lag backoff state accumulated by prior failures.
|
|
6532
7290
|
state.unreachableSinceMs = undefined;
|
|
6533
7291
|
state.unreachableEscalated = false;
|
|
7292
|
+
state.unreachableRecoveryAfterMs = undefined;
|
|
6534
7293
|
targets = ack.targets.length > 0 ? [...new Set(ack.targets)] : [input.to];
|
|
6535
7294
|
}
|
|
6536
7295
|
if (this.#stopping || state.cancelled)
|
|
@@ -6586,23 +7345,23 @@ export class FactoryLoop {
|
|
|
6586
7345
|
// unreachable window so a later genuine registration lag starts fresh.
|
|
6587
7346
|
state.unreachableSinceMs = undefined;
|
|
6588
7347
|
state.unreachableEscalated = false;
|
|
7348
|
+
state.unreachableRecoveryAfterMs = undefined;
|
|
6589
7349
|
}
|
|
6590
7350
|
const unreachableMs = state.unreachableSinceMs !== undefined
|
|
6591
7351
|
? this.#clock.now() - state.unreachableSinceMs
|
|
6592
7352
|
: 0;
|
|
6593
7353
|
if (registrationLag && unreachableMs >= this.#babysitterWakeUnreachableEscalateMs) {
|
|
6594
7354
|
// The agent is up but its relay identity never became resolvable. Stop
|
|
6595
|
-
// the tight 1s loop
|
|
6596
|
-
//
|
|
6597
|
-
//
|
|
6598
|
-
// failure spinning silently forever.
|
|
7355
|
+
// the tight 1s loop, reconcile once, and restart the session. A recovery
|
|
7356
|
+
// cooldown prevents a still-converging Relaycast registration from
|
|
7357
|
+
// turning that restart into another tight loop.
|
|
6599
7358
|
state.nextDelayMs = this.#babysitterWakeUnreachableRetryMs;
|
|
6600
7359
|
if (!state.unreachableEscalated) {
|
|
6601
7360
|
state.unreachableEscalated = true;
|
|
6602
7361
|
await this.#fleet.reconcileTrackedAgents?.();
|
|
6603
7362
|
this.#increment('babysitterEventWakeUnreachableReconciliations');
|
|
6604
7363
|
this.#increment('babysitterEventWakeUnreachableEscalations');
|
|
6605
|
-
this.#logger.warn?.('[factory] babysitter unreachable past grace window;
|
|
7364
|
+
this.#logger.warn?.('[factory] babysitter unreachable past grace window; reconciling and restarting its session', {
|
|
6606
7365
|
issue: state.issue.key,
|
|
6607
7366
|
repo: state.repo,
|
|
6608
7367
|
prNumber: state.prNumber,
|
|
@@ -6612,6 +7371,19 @@ export class FactoryLoop {
|
|
|
6612
7371
|
error: describeError(error).errorMessage,
|
|
6613
7372
|
});
|
|
6614
7373
|
}
|
|
7374
|
+
if (state.unreachableRecoveryAfterMs === undefined ||
|
|
7375
|
+
this.#clock.now() >= state.unreachableRecoveryAfterMs) {
|
|
7376
|
+
state.unreachableRecoveryAfterMs = this.#clock.now() + this.#babysitterWakeUnreachableRetryMs;
|
|
7377
|
+
if (await this.#recoverUnreachableBabysitter(state)) {
|
|
7378
|
+
// Probe the newly registered identity promptly. If Relaycast still
|
|
7379
|
+
// cannot resolve it, the recovery cooldown above prevents another
|
|
7380
|
+
// teardown loop and the next attempt uses the slow cadence.
|
|
7381
|
+
state.nextDelayMs = BABYSITTER_EVENT_RETRY_MS;
|
|
7382
|
+
}
|
|
7383
|
+
}
|
|
7384
|
+
else {
|
|
7385
|
+
state.nextDelayMs = Math.max(BABYSITTER_EVENT_RETRY_MS, state.unreachableRecoveryAfterMs - this.#clock.now());
|
|
7386
|
+
}
|
|
6615
7387
|
}
|
|
6616
7388
|
else {
|
|
6617
7389
|
state.nextDelayMs = BABYSITTER_EVENT_RETRY_MS;
|
|
@@ -6825,6 +7597,7 @@ export class FactoryLoop {
|
|
|
6825
7597
|
await this.#recordCanonicalIssueState({ ...issueRef(issue), stateId: doneStateId });
|
|
6826
7598
|
}
|
|
6827
7599
|
this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
|
|
7600
|
+
await this.#markDependencyTerminalAndReconcile(issue);
|
|
6828
7601
|
this.#increment('mergedPrAdvancedDone');
|
|
6829
7602
|
this.#increment('done');
|
|
6830
7603
|
this.#logger.info?.('[factory] merged PR advanced issue to Done', {
|
|
@@ -6910,6 +7683,7 @@ export class FactoryLoop {
|
|
|
6910
7683
|
prNumber: receipt.number,
|
|
6911
7684
|
url: receipt.url,
|
|
6912
7685
|
headRef: receipt.headRef,
|
|
7686
|
+
authoritative: true,
|
|
6913
7687
|
});
|
|
6914
7688
|
}
|
|
6915
7689
|
return;
|
|
@@ -6931,6 +7705,9 @@ export class FactoryLoop {
|
|
|
6931
7705
|
this.#increment('babysitterLifecycleOwnershipRejected');
|
|
6932
7706
|
return;
|
|
6933
7707
|
}
|
|
7708
|
+
const replacedSuperseded = prRef.authoritative
|
|
7709
|
+
? await this.#retireSupersededBabysitters(record, prRef)
|
|
7710
|
+
: false;
|
|
6934
7711
|
this.#babysitterIssueRefs.set(babysitterKey, { ...record.issue });
|
|
6935
7712
|
const existing = this.#babysitterPr.get(babysitterKey);
|
|
6936
7713
|
if (existing && githubPrIdentity(existing.repo, existing.prNumber) !== githubPrIdentity(prRef.repo, prRef.prNumber)) {
|
|
@@ -6974,6 +7751,7 @@ export class FactoryLoop {
|
|
|
6974
7751
|
});
|
|
6975
7752
|
this.#babysitterSpawned.add(babysitterKey);
|
|
6976
7753
|
await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
|
|
7754
|
+
await this.#retargetSlackConversationToBabysitter(record);
|
|
6977
7755
|
return;
|
|
6978
7756
|
}
|
|
6979
7757
|
// Reserve up-front so concurrent PR events in a drain don't double-spawn.
|
|
@@ -6991,7 +7769,7 @@ export class FactoryLoop {
|
|
|
6991
7769
|
const route = record.decision.routes.find((candidate) => candidate.repo === prRef.repo)
|
|
6992
7770
|
?? record.decision.routes[0];
|
|
6993
7771
|
const initialSpec = babysitterSpec(issue, this.#config, route);
|
|
6994
|
-
if ([...this.#babysitterIssueRefs.entries()].some(([key, candidate]) => key !== babysitterKey && issueKey(candidate) === issueKey(record.issue))) {
|
|
7772
|
+
if (replacedSuperseded || [...this.#babysitterIssueRefs.entries()].some(([key, candidate]) => key !== babysitterKey && issueKey(candidate) === issueKey(record.issue))) {
|
|
6995
7773
|
initialSpec.name = agentNameForRole(issue, 'babysit', {
|
|
6996
7774
|
repo: prRef.repo,
|
|
6997
7775
|
discriminator: `${sanitizeAgentSlug(prRef.repo)}-${prRef.prNumber}`,
|
|
@@ -7020,9 +7798,19 @@ export class FactoryLoop {
|
|
|
7020
7798
|
.filter((agent) => agent.spec.role === 'implementer')
|
|
7021
7799
|
.map((agent) => agent.result?.name ?? agent.spec.name);
|
|
7022
7800
|
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
7801
|
+
const templateIssue = templateIssueFromRecord(record, issue);
|
|
7802
|
+
const prSummary = await this.#github.getPr(prRef.repo, prRef.prNumber).catch(() => undefined);
|
|
7803
|
+
const taskRoute = { ...(route ?? { repo: prRef.repo }), clonePath: spec.clonePath ?? route?.clonePath };
|
|
7804
|
+
const testGuidance = await resolveTestGuidance({
|
|
7805
|
+
repoPath: taskRoute.clonePath,
|
|
7806
|
+
issue: templateIssue,
|
|
7807
|
+
route: taskRoute,
|
|
7808
|
+
changedFiles: prSummary?.filesChanged,
|
|
7809
|
+
previewUrl: previewUrlFromSpec(spec),
|
|
7810
|
+
});
|
|
7023
7811
|
const task = renderAgentTask({
|
|
7024
|
-
issue:
|
|
7025
|
-
route:
|
|
7812
|
+
issue: templateIssue,
|
|
7813
|
+
route: taskRoute,
|
|
7026
7814
|
role: 'babysitter',
|
|
7027
7815
|
config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
|
|
7028
7816
|
reviewerName,
|
|
@@ -7031,6 +7819,7 @@ export class FactoryLoop {
|
|
|
7031
7819
|
slackDispatchThread: await this.#slackDispatchThreadFor(record),
|
|
7032
7820
|
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
7033
7821
|
integrationInstructions,
|
|
7822
|
+
testGuidance,
|
|
7034
7823
|
branchName: spec.branch,
|
|
7035
7824
|
branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
|
|
7036
7825
|
agentName: spec.name,
|
|
@@ -7049,6 +7838,7 @@ export class FactoryLoop {
|
|
|
7049
7838
|
agentName: tracked?.result?.name ?? spawned.name,
|
|
7050
7839
|
});
|
|
7051
7840
|
await this.#persistBabysitterSession(record.issue, this.#babysitterPr.get(babysitterKey), tracked);
|
|
7841
|
+
await this.#retargetSlackConversationToBabysitter(record);
|
|
7052
7842
|
await this.#writeInFlightRegistry();
|
|
7053
7843
|
if (!await this.#saveDispatchLifecycle(record, 'running'))
|
|
7054
7844
|
return;
|
|
@@ -7093,6 +7883,108 @@ export class FactoryLoop {
|
|
|
7093
7883
|
}
|
|
7094
7884
|
}
|
|
7095
7885
|
}
|
|
7886
|
+
async #retireSupersededBabysitters(record, prRef) {
|
|
7887
|
+
const wanted = githubPrIdentity(prRef.repo, prRef.prNumber);
|
|
7888
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
|
|
7889
|
+
const trackedAgents = new Map([
|
|
7890
|
+
...(lifecycle?.agents ?? [])
|
|
7891
|
+
.filter((agent) => agent.releasedAtMs === undefined)
|
|
7892
|
+
.map((agent) => [agent.name, cloneTrackedAgent(agent.tracked)]),
|
|
7893
|
+
...record.agents,
|
|
7894
|
+
]);
|
|
7895
|
+
const superseded = [...trackedAgents.entries()].filter(([, tracked]) => {
|
|
7896
|
+
const owned = tracked.spec.ownedPullRequest;
|
|
7897
|
+
return tracked.spec.role === 'babysitter' &&
|
|
7898
|
+
owned?.repo.toLowerCase() === prRef.repo.toLowerCase() &&
|
|
7899
|
+
githubPrIdentity(owned.repo, owned.number) !== wanted;
|
|
7900
|
+
});
|
|
7901
|
+
for (const [agentName, tracked] of superseded) {
|
|
7902
|
+
const owned = tracked.spec.ownedPullRequest;
|
|
7903
|
+
const failed = await this.#releaseAndTerminateAgents([[agentName, tracked]], 'superseded-pr-receipt', 'completion');
|
|
7904
|
+
if (failed.length > 0) {
|
|
7905
|
+
this.#increment('supersededBabysitterReleaseFailures');
|
|
7906
|
+
throw new Error(`Failed to release superseded babysitter ${agentName}`);
|
|
7907
|
+
}
|
|
7908
|
+
if (owned) {
|
|
7909
|
+
const staleKey = babysitterOwnershipKey(record.issue, {
|
|
7910
|
+
repo: owned.repo,
|
|
7911
|
+
prNumber: owned.number,
|
|
7912
|
+
});
|
|
7913
|
+
await this.#cancelBabysitterWake(staleKey);
|
|
7914
|
+
if (await this.#assertIssueDispatchLifecycleOwner(record.issue)) {
|
|
7915
|
+
await this.#state.clearBabysitterSession(this.#workspaceId, staleKey);
|
|
7916
|
+
}
|
|
7917
|
+
}
|
|
7918
|
+
record.agents.delete(agentName);
|
|
7919
|
+
this.#babysitterCriticalAgents.delete(agentName);
|
|
7920
|
+
this.#increment('supersededBabysittersReleased');
|
|
7921
|
+
this.#logger.info?.('[factory] released babysitter superseded by exact PR receipt', {
|
|
7922
|
+
issue: record.issue.key,
|
|
7923
|
+
babysitter: agentName,
|
|
7924
|
+
previousRepo: owned?.repo,
|
|
7925
|
+
previousPrNumber: owned?.number,
|
|
7926
|
+
repo: prRef.repo,
|
|
7927
|
+
prNumber: prRef.prNumber,
|
|
7928
|
+
});
|
|
7929
|
+
}
|
|
7930
|
+
if (superseded.length > 0) {
|
|
7931
|
+
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
|
|
7932
|
+
if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
|
|
7933
|
+
throw new Error(`Failed to persist superseded babysitter cleanup for ${record.issue.key}`);
|
|
7934
|
+
}
|
|
7935
|
+
}
|
|
7936
|
+
return superseded.length > 0;
|
|
7937
|
+
}
|
|
7938
|
+
async #retireBabysittersOutsideCurrentRoutes(record) {
|
|
7939
|
+
const wantedRepos = new Set(record.decision.implementers.map((implementer) => implementer.repo.toLowerCase()));
|
|
7940
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
|
|
7941
|
+
const trackedAgents = new Map([
|
|
7942
|
+
...(lifecycle?.agents ?? [])
|
|
7943
|
+
.filter((agent) => agent.releasedAtMs === undefined)
|
|
7944
|
+
.map((agent) => [agent.name, cloneTrackedAgent(agent.tracked)]),
|
|
7945
|
+
...record.agents,
|
|
7946
|
+
]);
|
|
7947
|
+
const unrouted = [...trackedAgents.entries()].filter(([, tracked]) => {
|
|
7948
|
+
const owned = tracked.spec.ownedPullRequest;
|
|
7949
|
+
return tracked.spec.role === 'babysitter' &&
|
|
7950
|
+
Boolean(owned) &&
|
|
7951
|
+
!wantedRepos.has(owned.repo.toLowerCase());
|
|
7952
|
+
});
|
|
7953
|
+
for (const [agentName, tracked] of unrouted) {
|
|
7954
|
+
const owned = tracked.spec.ownedPullRequest;
|
|
7955
|
+
const failed = await this.#releaseAndTerminateAgents([[agentName, tracked]], 'superseded-pr-route', 'completion');
|
|
7956
|
+
if (failed.length > 0) {
|
|
7957
|
+
this.#increment('supersededBabysitterReleaseFailures');
|
|
7958
|
+
throw new Error(`Failed to release unrouted babysitter ${agentName}`);
|
|
7959
|
+
}
|
|
7960
|
+
if (owned) {
|
|
7961
|
+
const staleKey = babysitterOwnershipKey(record.issue, {
|
|
7962
|
+
repo: owned.repo,
|
|
7963
|
+
prNumber: owned.number,
|
|
7964
|
+
});
|
|
7965
|
+
await this.#cancelBabysitterWake(staleKey);
|
|
7966
|
+
if (await this.#assertIssueDispatchLifecycleOwner(record.issue)) {
|
|
7967
|
+
await this.#state.clearBabysitterSession(this.#workspaceId, staleKey);
|
|
7968
|
+
}
|
|
7969
|
+
}
|
|
7970
|
+
record.agents.delete(agentName);
|
|
7971
|
+
this.#babysitterCriticalAgents.delete(agentName);
|
|
7972
|
+
this.#increment('supersededBabysittersReleased');
|
|
7973
|
+
this.#logger.info?.('[factory] released babysitter outside the current dispatch routes', {
|
|
7974
|
+
issue: record.issue.key,
|
|
7975
|
+
babysitter: agentName,
|
|
7976
|
+
previousRepo: owned?.repo,
|
|
7977
|
+
previousPrNumber: owned?.number,
|
|
7978
|
+
currentRepos: [...wantedRepos],
|
|
7979
|
+
});
|
|
7980
|
+
}
|
|
7981
|
+
if (unrouted.length > 0) {
|
|
7982
|
+
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
|
|
7983
|
+
if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
|
|
7984
|
+
throw new Error(`Failed to persist unrouted babysitter cleanup for ${record.issue.key}`);
|
|
7985
|
+
}
|
|
7986
|
+
}
|
|
7987
|
+
}
|
|
7096
7988
|
// The babysitter owns the readiness verdict (CI green + conflicts resolved +
|
|
7097
7989
|
// review comments addressed) — it sees the per-event PR webhook data in its
|
|
7098
7990
|
// sandbox, exactly like AgentWorkforce/agents review. It signals readiness by
|
|
@@ -7286,6 +8178,8 @@ export class FactoryLoop {
|
|
|
7286
8178
|
await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
|
|
7287
8179
|
}
|
|
7288
8180
|
this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
|
|
8181
|
+
if (!humanReview)
|
|
8182
|
+
await this.#markDependencyTerminalAndReconcile(issue);
|
|
7289
8183
|
}
|
|
7290
8184
|
if (!await this.#saveDispatchLifecycle(record, 'writeback-applied'))
|
|
7291
8185
|
return;
|
|
@@ -7445,8 +8339,8 @@ export class FactoryLoop {
|
|
|
7445
8339
|
});
|
|
7446
8340
|
this.#emit('error', { error, ...describeError(error), issue });
|
|
7447
8341
|
}
|
|
7448
|
-
#increment(name) {
|
|
7449
|
-
this.#counters[name] = (this.#counters[name] ?? 0) +
|
|
8342
|
+
#increment(name, amount = 1) {
|
|
8343
|
+
this.#counters[name] = (this.#counters[name] ?? 0) + amount;
|
|
7450
8344
|
}
|
|
7451
8345
|
#recordTriageEscalation(decision, reason) {
|
|
7452
8346
|
this.#increment('triageEscalations');
|
|
@@ -7682,7 +8576,11 @@ export class FactoryLoop {
|
|
|
7682
8576
|
// the reply watcher instead of returning early, otherwise human replies in
|
|
7683
8577
|
// the existing thread are watched by nobody and silently dropped.
|
|
7684
8578
|
if (existingThread) {
|
|
7685
|
-
await this.#
|
|
8579
|
+
const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(existingThread));
|
|
8580
|
+
await this.#ensureSlackConversationSession(record, existingThread);
|
|
8581
|
+
await this.#rearmSlackWatcher(record, existingThread, {
|
|
8582
|
+
replayConversationReplies: Boolean(durableConversation),
|
|
8583
|
+
});
|
|
7686
8584
|
}
|
|
7687
8585
|
return;
|
|
7688
8586
|
}
|
|
@@ -7712,9 +8610,223 @@ export class FactoryLoop {
|
|
|
7712
8610
|
].join('\n'),
|
|
7713
8611
|
});
|
|
7714
8612
|
await this.#state.setSlackThread(this.#workspaceId, issueKey(record.issue), root.threadId);
|
|
8613
|
+
await this.#ensureSlackConversationSession(record, root.threadId);
|
|
7715
8614
|
await this.#watchSlackThread(record, root.threadId);
|
|
7716
8615
|
this.#recordSlackWritebackSuccess('dispatch-thread');
|
|
7717
8616
|
}
|
|
8617
|
+
// Once a PR is open, the babysitter is the one driving further conversation
|
|
8618
|
+
// with the human — prefer it over the implementer that originally opened the
|
|
8619
|
+
// Slack thread, matching the pre-conversation-resume routing that sent human
|
|
8620
|
+
// replies to both roles once a PR existed.
|
|
8621
|
+
#slackConversationOwnerCandidate(record) {
|
|
8622
|
+
const candidates = [...record.agents.entries()].map(([name, tracked]) => ({ name, tracked }));
|
|
8623
|
+
return candidates.find(({ tracked }) => tracked.spec.role === 'babysitter' && Boolean(tracked.sessionRef))
|
|
8624
|
+
?? candidates.find(({ tracked }) => tracked.spec.role === 'implementer' && Boolean(tracked.sessionRef));
|
|
8625
|
+
}
|
|
8626
|
+
async #ensureSlackConversationSession(record, threadId, options = {}) {
|
|
8627
|
+
const conversationId = slackConversationId(threadId);
|
|
8628
|
+
const owned = this.#slackConversationOwnerCandidate(record);
|
|
8629
|
+
const existing = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8630
|
+
if (existing) {
|
|
8631
|
+
const sessionRef = owned?.tracked.sessionRef;
|
|
8632
|
+
const agentName = owned ? (owned.tracked.result?.name ?? owned.name) : undefined;
|
|
8633
|
+
if (owned && sessionRef &&
|
|
8634
|
+
(agentName !== existing.agent.name || (options.forceAgentRebind === true && sessionRef !== existing.agent.sessionRef))) {
|
|
8635
|
+
const rebound = await this.#state.rebindConversationSession(this.#workspaceId, conversationId, {
|
|
8636
|
+
name: agentName,
|
|
8637
|
+
sessionRef,
|
|
8638
|
+
node: owned.tracked.result?.node ?? owned.tracked.spec.node,
|
|
8639
|
+
capability: owned.tracked.spec.capability,
|
|
8640
|
+
repo: owned.tracked.spec.repo,
|
|
8641
|
+
clonePath: owned.tracked.spec.clonePath,
|
|
8642
|
+
});
|
|
8643
|
+
if (rebound)
|
|
8644
|
+
this.#increment('slackConversationSessionsRebound');
|
|
8645
|
+
}
|
|
8646
|
+
if (existing.pending.length > 0 || existing.delivery) {
|
|
8647
|
+
const waiting = await this.#state.getWaitingClarification(this.#workspaceId, issueKey(existing.issue));
|
|
8648
|
+
if (!waiting)
|
|
8649
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
8650
|
+
}
|
|
8651
|
+
return;
|
|
8652
|
+
}
|
|
8653
|
+
const sessionRef = owned?.tracked.sessionRef;
|
|
8654
|
+
if (!owned || !sessionRef) {
|
|
8655
|
+
this.#increment('slackConversationSessionsSkippedMissingSession');
|
|
8656
|
+
return;
|
|
8657
|
+
}
|
|
8658
|
+
const channelDir = await this.#slackChannelDir() ?? this.#config.slack?.channel;
|
|
8659
|
+
if (!channelDir)
|
|
8660
|
+
return;
|
|
8661
|
+
const agentName = owned.tracked.result?.name ?? owned.name;
|
|
8662
|
+
const reserved = await this.#state.reserveConversationSession(this.#workspaceId, conversationId, {
|
|
8663
|
+
provider: 'slack',
|
|
8664
|
+
issue: { ...record.issue },
|
|
8665
|
+
externalId: threadId,
|
|
8666
|
+
context: { channelDir },
|
|
8667
|
+
agent: {
|
|
8668
|
+
name: agentName,
|
|
8669
|
+
sessionRef,
|
|
8670
|
+
node: owned.tracked.result?.node ?? owned.tracked.spec.node,
|
|
8671
|
+
capability: owned.tracked.spec.capability,
|
|
8672
|
+
repo: owned.tracked.spec.repo,
|
|
8673
|
+
clonePath: owned.tracked.spec.clonePath,
|
|
8674
|
+
},
|
|
8675
|
+
history: [],
|
|
8676
|
+
processedMessageIds: [],
|
|
8677
|
+
pending: [],
|
|
8678
|
+
});
|
|
8679
|
+
if (reserved)
|
|
8680
|
+
this.#increment('slackConversationSessionsOwned');
|
|
8681
|
+
}
|
|
8682
|
+
// Called right after a babysitter is spawned/reattached for an issue's PR so
|
|
8683
|
+
// an already-owned Slack conversation session (reserved earlier by the
|
|
8684
|
+
// implementer) retargets onto the babysitter for the next turn, instead of
|
|
8685
|
+
// resuming a session the babysitter has taken over from.
|
|
8686
|
+
async #retargetSlackConversationToBabysitter(record) {
|
|
8687
|
+
if (!this.#slack || !this.#config.slack)
|
|
8688
|
+
return;
|
|
8689
|
+
const threadId = await this.#persistedSlackThread(issueKey(record.issue));
|
|
8690
|
+
if (!threadId)
|
|
8691
|
+
return;
|
|
8692
|
+
await this.#ensureSlackConversationSession(record, threadId, { forceAgentRebind: true });
|
|
8693
|
+
}
|
|
8694
|
+
async #scheduleSlackConversationIfPending(threadId) {
|
|
8695
|
+
const conversationId = slackConversationId(threadId);
|
|
8696
|
+
const session = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8697
|
+
if (session && (session.pending.length > 0 || session.delivery)) {
|
|
8698
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
8699
|
+
}
|
|
8700
|
+
}
|
|
8701
|
+
async #resumeSlackConversationTurn(conversationId) {
|
|
8702
|
+
if (this.#stopping)
|
|
8703
|
+
return;
|
|
8704
|
+
const claimId = randomUUID();
|
|
8705
|
+
const claimed = await this.#state.claimConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now(), SLACK_CONVERSATION_TURN_LEASE_MS);
|
|
8706
|
+
if (!claimed?.delivery) {
|
|
8707
|
+
const current = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8708
|
+
if (current && (current.pending.length > 0 || current.delivery)) {
|
|
8709
|
+
this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
|
|
8710
|
+
}
|
|
8711
|
+
return;
|
|
8712
|
+
}
|
|
8713
|
+
if (!await this.#ownsActiveSlackConversationIssue(claimed.issue)) {
|
|
8714
|
+
await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
|
|
8715
|
+
this.#increment('slackConversationTurnsSuppressedStaleOwner');
|
|
8716
|
+
return;
|
|
8717
|
+
}
|
|
8718
|
+
let leaseLost = false;
|
|
8719
|
+
let renewalInFlight = false;
|
|
8720
|
+
const heartbeat = setInterval(() => {
|
|
8721
|
+
if (renewalInFlight || leaseLost || this.#stopping)
|
|
8722
|
+
return;
|
|
8723
|
+
renewalInFlight = true;
|
|
8724
|
+
void this.#state.renewConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, this.#clock.now()).then((renewed) => { leaseLost = !renewed; })
|
|
8725
|
+
.catch((error) => this.#logger.warn?.('[factory] Slack conversation lease renewal failed', {
|
|
8726
|
+
conversationId,
|
|
8727
|
+
error: describeError(error).errorMessage,
|
|
8728
|
+
}))
|
|
8729
|
+
.finally(() => { renewalInFlight = false; });
|
|
8730
|
+
}, Math.max(1_000, Math.floor(SLACK_CONVERSATION_TURN_LEASE_MS / 3)));
|
|
8731
|
+
heartbeat.unref?.();
|
|
8732
|
+
try {
|
|
8733
|
+
const result = await this.#fleet.resume({
|
|
8734
|
+
name: claimed.agent.name,
|
|
8735
|
+
sessionRef: claimed.agent.sessionRef,
|
|
8736
|
+
node: claimed.agent.node ?? 'self',
|
|
8737
|
+
capability: claimed.agent.capability,
|
|
8738
|
+
repo: claimed.agent.repo,
|
|
8739
|
+
clonePath: claimed.agent.clonePath,
|
|
8740
|
+
task: slackConversationResumeTask(claimed),
|
|
8741
|
+
});
|
|
8742
|
+
const completed = await this.#state.completeConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId, { name: result.name, sessionRef: result.sessionRef });
|
|
8743
|
+
// The token-fenced state mutation is authoritative. A renewal already
|
|
8744
|
+
// queued behind this completion can observe the cleared delivery and set
|
|
8745
|
+
// leaseLost even though completion committed successfully.
|
|
8746
|
+
if (!completed) {
|
|
8747
|
+
this.#increment('slackConversationTurnOwnershipLost');
|
|
8748
|
+
return;
|
|
8749
|
+
}
|
|
8750
|
+
await this.#recordSlackConversationResume(claimed, result);
|
|
8751
|
+
this.#increment('slackConversationTurnsResumed');
|
|
8752
|
+
if (claimed.delivery.messages.length > 1) {
|
|
8753
|
+
this.#increment('slackConversationRepliesCoalesced', claimed.delivery.messages.length - 1);
|
|
8754
|
+
}
|
|
8755
|
+
}
|
|
8756
|
+
catch (error) {
|
|
8757
|
+
try {
|
|
8758
|
+
await this.#state.releaseConversationTurn(this.#workspaceId, conversationId, this.#slackConversationOwner, claimId);
|
|
8759
|
+
}
|
|
8760
|
+
catch (releaseError) {
|
|
8761
|
+
this.#logger.warn?.('[factory] Slack conversation claim release failed; waiting for lease recovery', {
|
|
8762
|
+
conversationId,
|
|
8763
|
+
error: describeError(releaseError).errorMessage,
|
|
8764
|
+
});
|
|
8765
|
+
}
|
|
8766
|
+
this.#increment('slackConversationTurnResumeFailures');
|
|
8767
|
+
this.#logger.warn?.('[factory] Slack conversation resume failed; retaining the turn for retry', {
|
|
8768
|
+
issue: claimed.issue.key,
|
|
8769
|
+
threadId: claimed.externalId,
|
|
8770
|
+
sessionRef: claimed.agent.sessionRef,
|
|
8771
|
+
error: describeError(error).errorMessage,
|
|
8772
|
+
});
|
|
8773
|
+
this.#slackConversationTurns.schedule(conversationId, SLACK_CONVERSATION_TURN_RETRY_MS);
|
|
8774
|
+
return;
|
|
8775
|
+
}
|
|
8776
|
+
finally {
|
|
8777
|
+
clearInterval(heartbeat);
|
|
8778
|
+
}
|
|
8779
|
+
const remaining = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
8780
|
+
if (remaining && (remaining.pending.length > 0 || remaining.delivery)) {
|
|
8781
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
8782
|
+
}
|
|
8783
|
+
}
|
|
8784
|
+
async #recordSlackConversationResume(session, result) {
|
|
8785
|
+
const record = (await this.#batch()).getIssue(session.issue);
|
|
8786
|
+
if (!record)
|
|
8787
|
+
return;
|
|
8788
|
+
const entry = [...record.agents.entries()].find(([name, tracked]) => name === session.agent.name || tracked.result?.name === session.agent.name);
|
|
8789
|
+
if (!entry)
|
|
8790
|
+
return;
|
|
8791
|
+
const [previousName, tracked] = entry;
|
|
8792
|
+
tracked.result = {
|
|
8793
|
+
...tracked.result,
|
|
8794
|
+
...result,
|
|
8795
|
+
node: result.node ?? tracked.result?.node,
|
|
8796
|
+
locality: result.locality ?? tracked.result?.locality,
|
|
8797
|
+
};
|
|
8798
|
+
tracked.sessionRef = result.sessionRef ?? tracked.sessionRef;
|
|
8799
|
+
if (result.name !== previousName) {
|
|
8800
|
+
record.agents.delete(previousName);
|
|
8801
|
+
record.agents.set(result.name, tracked);
|
|
8802
|
+
}
|
|
8803
|
+
try {
|
|
8804
|
+
await this.#writeInFlightRegistry();
|
|
8805
|
+
await this.#saveDispatchLifecycle(record, 'running');
|
|
8806
|
+
}
|
|
8807
|
+
catch (error) {
|
|
8808
|
+
this.#logger.warn?.('[factory] Slack conversation resume bookkeeping failed', {
|
|
8809
|
+
issue: session.issue.key,
|
|
8810
|
+
agent: result.name,
|
|
8811
|
+
error: describeError(error).errorMessage,
|
|
8812
|
+
});
|
|
8813
|
+
}
|
|
8814
|
+
}
|
|
8815
|
+
async #ownsActiveSlackConversationIssue(issue) {
|
|
8816
|
+
if (!(await this.#batch()).getIssue(issue))
|
|
8817
|
+
return false;
|
|
8818
|
+
const key = issueKey(issue);
|
|
8819
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
8820
|
+
if (!lifecycle)
|
|
8821
|
+
return true;
|
|
8822
|
+
if (lifecycle.phase === 'releasing' || isTerminalDispatchLifecycle(lifecycle))
|
|
8823
|
+
return false;
|
|
8824
|
+
if (!lifecycle.lease)
|
|
8825
|
+
return false;
|
|
8826
|
+
return lifecycle.lease.owner === this.#dispatchLifecycleOwner &&
|
|
8827
|
+
lifecycle.lease.epoch === this.#dispatchLifecycleEpochs.get(key) &&
|
|
8828
|
+
lifecycle.lease.leaseUntilMs > this.#clock.now();
|
|
8829
|
+
}
|
|
7718
8830
|
async #escalateTriage(decision, reason, dryRun) {
|
|
7719
8831
|
if (dryRun) {
|
|
7720
8832
|
return;
|
|
@@ -7999,7 +9111,7 @@ export class FactoryLoop {
|
|
|
7999
9111
|
this.#recordSlackWritebackSuccess('triage-escalation');
|
|
8000
9112
|
return replayedResult;
|
|
8001
9113
|
}
|
|
8002
|
-
async #watchSlackThread(record, threadId) {
|
|
9114
|
+
async #watchSlackThread(record, threadId, options = {}) {
|
|
8003
9115
|
if (!this.#config.slack) {
|
|
8004
9116
|
return;
|
|
8005
9117
|
}
|
|
@@ -8011,12 +9123,14 @@ export class FactoryLoop {
|
|
|
8011
9123
|
const messagesPrefix = slackChannelMessagesPrefix(channelDir);
|
|
8012
9124
|
const preExistingPaths = new Set();
|
|
8013
9125
|
const preExistingPathOrder = [];
|
|
9126
|
+
const preExistingEvents = [];
|
|
8014
9127
|
const seenReplies = new Set();
|
|
8015
9128
|
const seenReplyMessages = new Set();
|
|
8016
9129
|
let missingIdentityLogged = false;
|
|
8017
9130
|
let cursor;
|
|
8018
9131
|
let stopped = false;
|
|
8019
9132
|
let pollTimer;
|
|
9133
|
+
const routeRetryTimers = new Map();
|
|
8020
9134
|
const markPreExisting = async () => {
|
|
8021
9135
|
try {
|
|
8022
9136
|
const page = await this.#mount.getEvents({ limit: SLACK_REPLY_EVENTS_LIMIT });
|
|
@@ -8026,6 +9140,7 @@ export class FactoryLoop {
|
|
|
8026
9140
|
if (path && path.startsWith(messagesPrefix)) {
|
|
8027
9141
|
preExistingPaths.add(path);
|
|
8028
9142
|
preExistingPathOrder.push(path);
|
|
9143
|
+
preExistingEvents.push(event);
|
|
8029
9144
|
}
|
|
8030
9145
|
}
|
|
8031
9146
|
}
|
|
@@ -8033,7 +9148,8 @@ export class FactoryLoop {
|
|
|
8033
9148
|
this.#logger.warn?.('[factory] unable to seed Slack reply watcher event cursor', error);
|
|
8034
9149
|
}
|
|
8035
9150
|
};
|
|
8036
|
-
const handle = async (event) => {
|
|
9151
|
+
const handle = async (event, allowPreExisting = false) => {
|
|
9152
|
+
const routeRetryKey = eventIdentity(event) ?? changeEventPath(event) ?? randomUUID();
|
|
8037
9153
|
try {
|
|
8038
9154
|
// Polling-fallback / degraded-sync events can lack `resource.path`
|
|
8039
9155
|
// despite the SDK type. Skip them quietly so one malformed event never
|
|
@@ -8049,7 +9165,7 @@ export class FactoryLoop {
|
|
|
8049
9165
|
this.#logger.warn?.('[factory] Slack reply event missing stable identity; falling back to path/content dedupe');
|
|
8050
9166
|
}
|
|
8051
9167
|
}
|
|
8052
|
-
if (preExistingPaths.has(path)) {
|
|
9168
|
+
if (!allowPreExisting && preExistingPaths.has(path)) {
|
|
8053
9169
|
return;
|
|
8054
9170
|
}
|
|
8055
9171
|
const reply = await this.#readSlackReply(path);
|
|
@@ -8066,21 +9182,34 @@ export class FactoryLoop {
|
|
|
8066
9182
|
this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path });
|
|
8067
9183
|
return;
|
|
8068
9184
|
}
|
|
8069
|
-
seenReplies.add(replyKey);
|
|
8070
9185
|
if (reply.isBot) {
|
|
9186
|
+
seenReplies.add(replyKey);
|
|
9187
|
+
seenReplyMessages.add(replyMessageKey);
|
|
8071
9188
|
return;
|
|
8072
9189
|
}
|
|
8073
|
-
seenReplyMessages.add(replyMessageKey);
|
|
8074
9190
|
await this.#routeSlackAnswerToImplementers(record, reply);
|
|
9191
|
+
// Acknowledge in memory only after the durable route succeeds. A read or
|
|
9192
|
+
// state-store failure must remain replayable by the poll/subscription.
|
|
9193
|
+
seenReplies.add(replyKey);
|
|
9194
|
+
seenReplyMessages.add(replyMessageKey);
|
|
8075
9195
|
}
|
|
8076
9196
|
catch (error) {
|
|
8077
9197
|
this.#logger.error?.('[factory] failed to handle Slack reply event', error);
|
|
9198
|
+
if (!stopped && !routeRetryTimers.has(routeRetryKey)) {
|
|
9199
|
+
const retryTimer = setTimeout(() => {
|
|
9200
|
+
routeRetryTimers.delete(routeRetryKey);
|
|
9201
|
+
if (!stopped)
|
|
9202
|
+
void handle(event, true);
|
|
9203
|
+
}, SLACK_REPLY_ROUTE_RETRY_MS);
|
|
9204
|
+
retryTimer.unref?.();
|
|
9205
|
+
routeRetryTimers.set(routeRetryKey, retryTimer);
|
|
9206
|
+
}
|
|
8078
9207
|
}
|
|
8079
9208
|
};
|
|
8080
9209
|
await markPreExisting();
|
|
8081
9210
|
let subscription;
|
|
8082
9211
|
try {
|
|
8083
|
-
subscription = this.#mount.subscribe([
|
|
9212
|
+
subscription = this.#mount.subscribe([slackThreadReplyGlob(channelDir)], (event) => {
|
|
8084
9213
|
// Receipt time is independent of the provider-authored sync timestamp.
|
|
8085
9214
|
// A healthy webhook can therefore override a frozen advisory status
|
|
8086
9215
|
// without trusting the same field that declared the provider stale.
|
|
@@ -8120,9 +9249,16 @@ export class FactoryLoop {
|
|
|
8120
9249
|
clearTimeout(pollTimer);
|
|
8121
9250
|
pollTimer = undefined;
|
|
8122
9251
|
}
|
|
9252
|
+
for (const retryTimer of routeRetryTimers.values())
|
|
9253
|
+
clearTimeout(retryTimer);
|
|
9254
|
+
routeRetryTimers.clear();
|
|
8123
9255
|
await this.#boundedStopTeardown('Slack reply subscription unsubscribe', () => subscription?.unsubscribe());
|
|
8124
9256
|
},
|
|
8125
9257
|
});
|
|
9258
|
+
if (options.replayConversationReplies) {
|
|
9259
|
+
for (const event of preExistingEvents)
|
|
9260
|
+
await handle(event, true);
|
|
9261
|
+
}
|
|
8126
9262
|
return await this.#replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPathOrder);
|
|
8127
9263
|
}
|
|
8128
9264
|
async #replayLatestSlackTriageAnswer(record, threadId, channelDir, preExistingPaths) {
|
|
@@ -8151,13 +9287,13 @@ export class FactoryLoop {
|
|
|
8151
9287
|
// exists (persisted thread id) but has no in-process watcher. #watchSlackThread
|
|
8152
9288
|
// is itself idempotent on #slackWatchers; the guard here keeps the counter (and
|
|
8153
9289
|
// the seeding getEvents call) limited to genuine re-arms.
|
|
8154
|
-
async #rearmSlackWatcher(record, threadId) {
|
|
9290
|
+
async #rearmSlackWatcher(record, threadId, options = {}) {
|
|
8155
9291
|
const key = issueKey(record.issue);
|
|
8156
9292
|
if (this.#slackWatchers.has(key) || this.#slackWatcherStarts.has(key)) {
|
|
8157
9293
|
return;
|
|
8158
9294
|
}
|
|
8159
9295
|
try {
|
|
8160
|
-
await this.#watchSlackThread(record, threadId);
|
|
9296
|
+
await this.#watchSlackThread(record, threadId, options);
|
|
8161
9297
|
this.#increment('slackWatchersRearmed');
|
|
8162
9298
|
}
|
|
8163
9299
|
catch (error) {
|
|
@@ -8172,7 +9308,8 @@ export class FactoryLoop {
|
|
|
8172
9308
|
if (!this.#slack || !this.#config.slack) {
|
|
8173
9309
|
return;
|
|
8174
9310
|
}
|
|
8175
|
-
|
|
9311
|
+
const batch = await this.#batch();
|
|
9312
|
+
for (const record of batch.inFlight) {
|
|
8176
9313
|
if (record.dryRun) {
|
|
8177
9314
|
continue;
|
|
8178
9315
|
}
|
|
@@ -8191,7 +9328,27 @@ export class FactoryLoop {
|
|
|
8191
9328
|
if (!threadId) {
|
|
8192
9329
|
continue;
|
|
8193
9330
|
}
|
|
8194
|
-
await this.#
|
|
9331
|
+
const durableConversation = await this.#state.getConversationSession(this.#workspaceId, slackConversationId(threadId));
|
|
9332
|
+
await this.#ensureSlackConversationSession(record, threadId);
|
|
9333
|
+
await this.#rearmSlackWatcher(record, threadId, {
|
|
9334
|
+
replayConversationReplies: Boolean(durableConversation),
|
|
9335
|
+
});
|
|
9336
|
+
}
|
|
9337
|
+
for (const [conversationId, session] of await this.#state.listConversationSessions(this.#workspaceId)) {
|
|
9338
|
+
if (session.provider !== 'slack')
|
|
9339
|
+
continue;
|
|
9340
|
+
const threadId = session.externalId;
|
|
9341
|
+
const record = batch.getIssue(session.issue);
|
|
9342
|
+
const key = issueKey(session.issue);
|
|
9343
|
+
if (record && !record.dryRun && !this.#slackWatchers.has(key) && !this.#slackWatcherStarts.has(key)) {
|
|
9344
|
+
await this.#state.setSlackThread(this.#workspaceId, key, threadId);
|
|
9345
|
+
await this.#ensureSlackConversationSession(record, threadId);
|
|
9346
|
+
await this.#rearmSlackWatcher(record, threadId, { replayConversationReplies: true });
|
|
9347
|
+
}
|
|
9348
|
+
const waiting = await this.#state.getWaitingClarification(this.#workspaceId, key);
|
|
9349
|
+
if (record && !waiting && (session.pending.length > 0 || session.delivery)) {
|
|
9350
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
9351
|
+
}
|
|
8195
9352
|
}
|
|
8196
9353
|
await this.#sweepWaitingClarifications();
|
|
8197
9354
|
for (const [, waiting] of await this.#state.listWaitingClarifications(this.#workspaceId)) {
|
|
@@ -8338,13 +9495,19 @@ export class FactoryLoop {
|
|
|
8338
9495
|
const key = issueKey(issue);
|
|
8339
9496
|
const watcher = this.#slackWatchers.get(key);
|
|
8340
9497
|
this.#slackWatchers.delete(key);
|
|
8341
|
-
await this.#state.
|
|
9498
|
+
const threadId = await this.#state.getSlackThread(this.#workspaceId, key);
|
|
8342
9499
|
await watcher?.stop();
|
|
9500
|
+
if (threadId) {
|
|
9501
|
+
const conversationId = slackConversationId(threadId);
|
|
9502
|
+
await this.#slackConversationTurns.cancel(conversationId);
|
|
9503
|
+
await this.#state.clearConversationSession(this.#workspaceId, conversationId);
|
|
9504
|
+
}
|
|
9505
|
+
await this.#state.clearSlackThread(this.#workspaceId, key);
|
|
8343
9506
|
}
|
|
8344
9507
|
async #readSlackReply(path) {
|
|
8345
9508
|
try {
|
|
8346
9509
|
const { content } = await this.#mount.readFile(path);
|
|
8347
|
-
return
|
|
9510
|
+
return parseSlackThreadReply(path, content, this.#config.slack?.botUserId ?? 'U0B2596R7EZ');
|
|
8348
9511
|
}
|
|
8349
9512
|
catch (error) {
|
|
8350
9513
|
this.#logger.warn?.(`Unable to read Slack reply ${path}`, error);
|
|
@@ -8367,20 +9530,61 @@ export class FactoryLoop {
|
|
|
8367
9530
|
return;
|
|
8368
9531
|
}
|
|
8369
9532
|
if (waiting?.threadId === reply.threadTs) {
|
|
9533
|
+
const replyId = `${reply.threadTs}:${reply.messageTs}`;
|
|
8370
9534
|
const claimed = await this.#state.claimClarificationReply(this.#workspaceId, clarificationKey, {
|
|
8371
|
-
id:
|
|
9535
|
+
id: replyId,
|
|
8372
9536
|
text,
|
|
8373
|
-
receivedAtMs: this.#clock.now(),
|
|
9537
|
+
receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
|
|
8374
9538
|
source: 'slack',
|
|
9539
|
+
author: reply.author,
|
|
8375
9540
|
});
|
|
8376
9541
|
if (!claimed) {
|
|
8377
|
-
this.#
|
|
9542
|
+
const currentWaiting = await this.#state.getWaitingClarification(this.#workspaceId, clarificationKey);
|
|
9543
|
+
if (currentWaiting?.reply?.id === replyId) {
|
|
9544
|
+
this.#increment('clarificationDuplicateWakesSuppressed');
|
|
9545
|
+
return;
|
|
9546
|
+
}
|
|
9547
|
+
// The first reply wakes the parked clarification team. Additional
|
|
9548
|
+
// rapid-fire replies are ordinary conversation turns and must not be
|
|
9549
|
+
// discarded merely because the one-shot clarification slot is full.
|
|
9550
|
+
const conversationId = slackConversationId(reply.threadTs);
|
|
9551
|
+
const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
|
|
9552
|
+
id: replyId,
|
|
9553
|
+
text,
|
|
9554
|
+
receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
|
|
9555
|
+
providerSequence: reply.messageTs,
|
|
9556
|
+
author: reply.author,
|
|
9557
|
+
});
|
|
9558
|
+
if (queued) {
|
|
9559
|
+
this.#increment('slackConversationRepliesQueued');
|
|
9560
|
+
}
|
|
9561
|
+
else {
|
|
9562
|
+
this.#increment('slackConversationDuplicateRepliesSuppressed');
|
|
9563
|
+
}
|
|
8378
9564
|
return;
|
|
8379
9565
|
}
|
|
8380
9566
|
this.#increment('clarificationRepliesClaimed');
|
|
8381
9567
|
await this.#wakeWaitingClarification(clarificationKey, claimed);
|
|
8382
9568
|
return;
|
|
8383
9569
|
}
|
|
9570
|
+
const conversationId = slackConversationId(reply.threadTs);
|
|
9571
|
+
const conversation = await this.#state.getConversationSession(this.#workspaceId, conversationId);
|
|
9572
|
+
if (conversation && issueKey(conversation.issue) === clarificationKey) {
|
|
9573
|
+
const queued = await this.#state.appendConversationMessage(this.#workspaceId, conversationId, {
|
|
9574
|
+
id: `${reply.threadTs}:${reply.messageTs}`,
|
|
9575
|
+
text,
|
|
9576
|
+
receivedAtMs: slackMessageReceivedAtMs(reply.messageTs, this.#clock.now()),
|
|
9577
|
+
providerSequence: reply.messageTs,
|
|
9578
|
+
author: reply.author,
|
|
9579
|
+
});
|
|
9580
|
+
if (!queued) {
|
|
9581
|
+
this.#increment('slackConversationDuplicateRepliesSuppressed');
|
|
9582
|
+
return;
|
|
9583
|
+
}
|
|
9584
|
+
this.#increment('slackConversationRepliesQueued');
|
|
9585
|
+
this.#slackConversationTurns.schedule(conversationId);
|
|
9586
|
+
return;
|
|
9587
|
+
}
|
|
8384
9588
|
const liveRecord = (await this.#batch()).getIssue(record.issue);
|
|
8385
9589
|
if (!liveRecord || liveRecord.dryRun) {
|
|
8386
9590
|
if (isTriageEscalationWatchRecord(record)) {
|
|
@@ -8389,23 +9593,7 @@ export class FactoryLoop {
|
|
|
8389
9593
|
this.#increment('slackAnswersIgnoredNoInFlight');
|
|
8390
9594
|
return;
|
|
8391
9595
|
}
|
|
8392
|
-
|
|
8393
|
-
return;
|
|
8394
|
-
}
|
|
8395
|
-
// Route human replies to the implementers AND the babysitter — once a PR is
|
|
8396
|
-
// open the babysitter is the one chatting with the human about it.
|
|
8397
|
-
const recipients = [...liveRecord.agents.values()]
|
|
8398
|
-
.filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
|
|
8399
|
-
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
8400
|
-
.filter((name) => Boolean(name));
|
|
8401
|
-
if (recipients.length === 0) {
|
|
8402
|
-
this.#increment('slackAnswersIgnoredNoImplementer');
|
|
8403
|
-
return;
|
|
8404
|
-
}
|
|
8405
|
-
for (const recipient of new Set(recipients)) {
|
|
8406
|
-
await this.#injectSlackReplyEvent(recipient, liveRecord.issue, text);
|
|
8407
|
-
this.#increment('slackAnswersInjected');
|
|
8408
|
-
}
|
|
9596
|
+
this.#increment('slackAnswersIgnoredNoConversationSession');
|
|
8409
9597
|
}
|
|
8410
9598
|
async #wakeWaitingClarification(key, waiting) {
|
|
8411
9599
|
const existing = this.#clarificationWakeInFlight.get(key);
|
|
@@ -8561,10 +9749,15 @@ export class FactoryLoop {
|
|
|
8561
9749
|
await renewLease();
|
|
8562
9750
|
await this.#writeInFlightRegistry();
|
|
8563
9751
|
await this.#saveDispatchLifecycle(record, 'running');
|
|
9752
|
+
if (waiting.threadId) {
|
|
9753
|
+
await this.#ensureSlackConversationSession(record, waiting.threadId, { forceAgentRebind: true });
|
|
9754
|
+
}
|
|
8564
9755
|
await renewLease();
|
|
8565
9756
|
const completed = await this.#state.completeClarificationWake(this.#workspaceId, key, this.#clarificationWakeOwner);
|
|
8566
9757
|
if (!completed)
|
|
8567
9758
|
throw new ClarificationWakeLeaseLostError('clarification wake completion lost ownership');
|
|
9759
|
+
if (waiting.threadId)
|
|
9760
|
+
await this.#scheduleSlackConversationIfPending(waiting.threadId);
|
|
8568
9761
|
this.#increment('clarificationTeamsWoken');
|
|
8569
9762
|
this.#logger.info?.('[factory] restarted team after human clarification', {
|
|
8570
9763
|
issue: waiting.issue.key,
|
|
@@ -8804,14 +9997,6 @@ export class FactoryLoop {
|
|
|
8804
9997
|
this.#emit('issue-queued', { issue: decision.issue });
|
|
8805
9998
|
}
|
|
8806
9999
|
}
|
|
8807
|
-
// Route ordinary Slack conversation into a live agent framed as the
|
|
8808
|
-
// <integration-event> the spawn prompt tells it to expect (not an ambiguous
|
|
8809
|
-
// "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
|
|
8810
|
-
// event. (A broker confirmed-delivery path via waitForInjected is a possible
|
|
8811
|
-
// robustness follow-up.)
|
|
8812
|
-
async #injectSlackReplyEvent(recipient, issue, text) {
|
|
8813
|
-
await this.#fleet.sendInput?.(recipient, slackReplyEvent(issue, text));
|
|
8814
|
-
}
|
|
8815
10000
|
// Absolute path to the local .integrations mount the daemon manages. The mount
|
|
8816
10001
|
// is created at the daemon's cwd (see ensureLocalMount), and spawned agents run
|
|
8817
10002
|
// in their repo clonePath, so writeback paths handed to agents must be absolute
|
|
@@ -9211,6 +10396,57 @@ const githubIssueSourceRef = (issue) => {
|
|
|
9211
10396
|
}
|
|
9212
10397
|
return { owner, repo, number: number, url };
|
|
9213
10398
|
};
|
|
10399
|
+
function dependencyRepoForIssue(issue, decision, config) {
|
|
10400
|
+
const source = githubIssueSourceRef(issue);
|
|
10401
|
+
if (source)
|
|
10402
|
+
return `${source.owner}/${source.repo}`;
|
|
10403
|
+
const mirroredRepo = githubMirrorRepoForIssue(issue);
|
|
10404
|
+
if (mirroredRepo)
|
|
10405
|
+
return mirroredRepo;
|
|
10406
|
+
const normalize = (repo) => {
|
|
10407
|
+
if (!repo)
|
|
10408
|
+
return undefined;
|
|
10409
|
+
try {
|
|
10410
|
+
return normalizeGithubRepo(repo, config.repos.org);
|
|
10411
|
+
}
|
|
10412
|
+
catch {
|
|
10413
|
+
return undefined;
|
|
10414
|
+
}
|
|
10415
|
+
};
|
|
10416
|
+
const decisionRepos = new Set((decision?.routes ?? [])
|
|
10417
|
+
.map((route) => normalize(route.repo))
|
|
10418
|
+
.filter((repo) => Boolean(repo)));
|
|
10419
|
+
if (decisionRepos.size === 1)
|
|
10420
|
+
return [...decisionRepos][0];
|
|
10421
|
+
if (decisionRepos.size > 1)
|
|
10422
|
+
return undefined;
|
|
10423
|
+
const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
|
|
10424
|
+
const labelRepos = new Set(Object.entries(config.repos.byLabel)
|
|
10425
|
+
.filter(([label]) => labels.has(label.trim().toLowerCase()))
|
|
10426
|
+
.map(([, repo]) => normalize(repo))
|
|
10427
|
+
.filter((repo) => Boolean(repo)));
|
|
10428
|
+
if (labelRepos.size === 1)
|
|
10429
|
+
return [...labelRepos][0];
|
|
10430
|
+
if (labelRepos.size > 1)
|
|
10431
|
+
return undefined;
|
|
10432
|
+
const projectRepo = issue.project
|
|
10433
|
+
? Object.entries(config.repos.byProject)
|
|
10434
|
+
.find(([project]) => project.trim().toLowerCase() === issue.project.trim().toLowerCase())?.[1]
|
|
10435
|
+
: undefined;
|
|
10436
|
+
return normalize(projectRepo) ?? normalize(config.repos.default);
|
|
10437
|
+
}
|
|
10438
|
+
function dependencyIdentityForIssue(issue, repo) {
|
|
10439
|
+
const source = githubIssueSourceRef(issue);
|
|
10440
|
+
if (source)
|
|
10441
|
+
return dependencyIdentity(`${source.owner}/${source.repo}`, source.number);
|
|
10442
|
+
const pathParts = githubIssuePathParts(issue.path);
|
|
10443
|
+
if (pathParts)
|
|
10444
|
+
return dependencyIdentity(`${pathParts.owner}/${pathParts.repo}`, pathParts.number);
|
|
10445
|
+
const number = Number(issue.key.match(/(\d+)$/u)?.[1]);
|
|
10446
|
+
return repo && Number.isSafeInteger(number) && number > 0
|
|
10447
|
+
? dependencyIdentity(repo, number)
|
|
10448
|
+
: undefined;
|
|
10449
|
+
}
|
|
9214
10450
|
const githubIssueAuthor = (issue) => {
|
|
9215
10451
|
const payload = wrappedPayload(issue.raw);
|
|
9216
10452
|
const source = asRecord(payload.source);
|
|
@@ -9612,10 +10848,14 @@ const routeForSpec = (decision, spec) => {
|
|
|
9612
10848
|
const route = decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? decision.routes.find((candidate) => candidate.repo === spec.repo);
|
|
9613
10849
|
return {
|
|
9614
10850
|
repo: spec.repo,
|
|
9615
|
-
clonePath: spec.clonePath,
|
|
10851
|
+
clonePath: spec.clonePath ?? route?.clonePath,
|
|
9616
10852
|
rationale: route?.rationale,
|
|
9617
10853
|
};
|
|
9618
10854
|
};
|
|
10855
|
+
const previewUrlFromSpec = (spec) => {
|
|
10856
|
+
const previewUrl = spec.previewUrl;
|
|
10857
|
+
return typeof previewUrl === 'string' && previewUrl.trim() ? previewUrl.trim() : undefined;
|
|
10858
|
+
};
|
|
9619
10859
|
const repoMapFromConfig = (config) => {
|
|
9620
10860
|
const repos = new Set([
|
|
9621
10861
|
...Object.values(config.repos.byLabel),
|
|
@@ -9829,7 +11069,7 @@ const linearIssueMirrorsGithubIssue = (issue, ghIssue) => {
|
|
|
9829
11069
|
const resolveIssuePrFromMount = async (mount, config, issue, opts = {}) => {
|
|
9830
11070
|
const candidates = [];
|
|
9831
11071
|
const listErrors = [];
|
|
9832
|
-
for (const repo of reposFromConfig(config)) {
|
|
11072
|
+
for (const repo of opts.repo ? [opts.repo] : reposFromConfig(config)) {
|
|
9833
11073
|
const paths = new Set();
|
|
9834
11074
|
for (const root of githubPullRoots(repo)) {
|
|
9835
11075
|
try {
|
|
@@ -10054,7 +11294,7 @@ const readProbePrCandidate = async (mount, path) => {
|
|
|
10054
11294
|
booleanValue(payload.crossRepository) ??
|
|
10055
11295
|
(headRepo && baseRepo ? headRepo.toLowerCase() !== baseRepo.toLowerCase() : undefined),
|
|
10056
11296
|
draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft),
|
|
10057
|
-
state: stringValue(payload.state),
|
|
11297
|
+
state: booleanValue(payload.merged) === true ? 'MERGED' : stringValue(payload.state),
|
|
10058
11298
|
url: stringValue(payload.url) ?? stringValue(payload.html_url),
|
|
10059
11299
|
};
|
|
10060
11300
|
}
|
|
@@ -10749,8 +11989,12 @@ const defaultRestartPolicy = (spec) =>
|
|
|
10749
11989
|
spec.role === 'implementer' || spec.role === 'reviewer' || spec.role === 'babysitter'
|
|
10750
11990
|
? { maxRestarts: 0 }
|
|
10751
11991
|
: spec.restartPolicy;
|
|
10752
|
-
const slackPayloadTs = (threadId) => threadId.replace(/_/g, '.');
|
|
10753
11992
|
const slackChannelMessagesPrefix = (channelDir) => `/slack/channels/${channelDir}/messages/`;
|
|
11993
|
+
const slackConversationId = (threadId) => `slack:${threadId}`;
|
|
11994
|
+
const slackMessageReceivedAtMs = (messageTs, fallback) => {
|
|
11995
|
+
const seconds = Number(messageTs);
|
|
11996
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds * 1_000) : fallback;
|
|
11997
|
+
};
|
|
10754
11998
|
const eventIdentity = (event) => {
|
|
10755
11999
|
const record = event;
|
|
10756
12000
|
const rawId = record.id ?? record.event_id ?? record.seq;
|
|
@@ -10947,6 +12191,12 @@ const mergePublishedPullRequests = (lifecycle, receipt) => {
|
|
|
10947
12191
|
{ ...candidate },
|
|
10948
12192
|
])).values()];
|
|
10949
12193
|
};
|
|
12194
|
+
const primaryPublishedPullRequest = (previous, receipt, receipts) => {
|
|
12195
|
+
if (receipt &&
|
|
12196
|
+
(!previous?.pullRequest || previous.pullRequest.repo.toLowerCase() === receipt.repo.toLowerCase()))
|
|
12197
|
+
return receipt;
|
|
12198
|
+
return previous?.pullRequest ?? receipt ?? receipts[0];
|
|
12199
|
+
};
|
|
10950
12200
|
const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequest, pullRequests = [], releaseReason) => ({
|
|
10951
12201
|
runId,
|
|
10952
12202
|
issue: { ...record.issue },
|
|
@@ -10974,28 +12224,6 @@ const dispatchResultFromLifecycle = (lifecycle) => lifecycle.result ? structured
|
|
|
10974
12224
|
agents: lifecycle.agents.map(({ name, tracked }) => ({ name, role: tracked.spec.role })),
|
|
10975
12225
|
dryRun: lifecycle.dryRun,
|
|
10976
12226
|
};
|
|
10977
|
-
const parseSlackReply = (path, content, botUserId) => {
|
|
10978
|
-
const raw = asRecord(parseJsonContent(content)) ?? {};
|
|
10979
|
-
const payload = wrappedPayload(raw);
|
|
10980
|
-
const channelDir = path.match(/^\/slack\/channels\/([^/]+)\//u)?.[1] ?? '';
|
|
10981
|
-
const pathMatch = path.match(/^\/slack\/channels\/[^/]+\/messages\/([^/]+)(?:\/replies\/([^/]+))?/u);
|
|
10982
|
-
const parentFromPath = pathMatch?.[2] ? slackPayloadTs(pathMatch[1]) : undefined;
|
|
10983
|
-
const messageFromPath = slackPayloadTs(pathMatch?.[2] ?? pathMatch?.[1] ?? '');
|
|
10984
|
-
const messageTs = stringValue(payload.ts) ?? messageFromPath;
|
|
10985
|
-
const threadTs = stringValue(payload.thread_ts) ?? parentFromPath;
|
|
10986
|
-
if (!channelDir || !threadTs || !messageTs) {
|
|
10987
|
-
return undefined;
|
|
10988
|
-
}
|
|
10989
|
-
return {
|
|
10990
|
-
channelDir,
|
|
10991
|
-
threadTs,
|
|
10992
|
-
messageTs,
|
|
10993
|
-
text: stringValue(payload.text) ?? '',
|
|
10994
|
-
isThreadReply: Boolean(parentFromPath) || threadTs !== messageTs,
|
|
10995
|
-
isBot: isOwnSlackBotReply(payload, botUserId),
|
|
10996
|
-
raw,
|
|
10997
|
-
};
|
|
10998
|
-
};
|
|
10999
12227
|
const triageEscalationReason = (decision) => {
|
|
11000
12228
|
const reasons = [];
|
|
11001
12229
|
if (decision.confidence === 'low') {
|
|
@@ -11068,11 +12296,6 @@ const issueWithGithubClarification = (issue, text) => ({
|
|
|
11068
12296
|
text,
|
|
11069
12297
|
].join('\n'),
|
|
11070
12298
|
});
|
|
11071
|
-
const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
|
|
11072
|
-
// The human's Slack-thread reply, framed as an <integration-event> the agent is
|
|
11073
|
-
// told (at spawn) to expect — a recognizable injected event, not an ambiguous
|
|
11074
|
-
// keystroke. Trailing CR submits it to the agent's PTY.
|
|
11075
|
-
const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
|
|
11076
12299
|
const clarificationResumeTask = (baseTask, waiting) => {
|
|
11077
12300
|
const reply = waiting.reply;
|
|
11078
12301
|
return [
|
|
@@ -11084,6 +12307,27 @@ const clarificationResumeTask = (baseTask, waiting) => {
|
|
|
11084
12307
|
'Re-hydrate from the issue, branch, worktree, and any open PR, then continue the task. Do not repeat completed work.',
|
|
11085
12308
|
].join('\n');
|
|
11086
12309
|
};
|
|
12310
|
+
const slackConversationResumeTask = (session) => {
|
|
12311
|
+
const delivered = session.delivery?.messages ?? [];
|
|
12312
|
+
const renderMessages = (messages) => messages
|
|
12313
|
+
.map((message, index) => `${index + 1}. ${message.author ? `${message.author}: ` : ''}${message.text}`)
|
|
12314
|
+
.join('\n');
|
|
12315
|
+
return [
|
|
12316
|
+
`Continue the existing ${session.issue.key} session for one turn in its Factory-owned Slack thread.`,
|
|
12317
|
+
`Slack channel mount: /slack/channels/${session.context.channelDir ?? 'unknown'}`,
|
|
12318
|
+
`Slack thread timestamp: ${session.externalId}`,
|
|
12319
|
+
'The session history, issue, branch, worktree, and open PR remain authoritative. Re-hydrate current repository state before changing code and do not repeat completed work.',
|
|
12320
|
+
...(session.history.length > 0
|
|
12321
|
+
? ['', 'Earlier human messages in this Slack conversation:', renderMessages(session.history.slice(-20))]
|
|
12322
|
+
: []),
|
|
12323
|
+
'',
|
|
12324
|
+
'New human messages coalesced into this turn, in order:',
|
|
12325
|
+
renderMessages(delivered),
|
|
12326
|
+
'',
|
|
12327
|
+
'Address every new message. Reply in the same Slack thread through the connected Slack mount/writeback surface, then continue or report the issue work as appropriate.',
|
|
12328
|
+
'This input was supplied atomically as a fresh resume task. Do not request or depend on live PTY/session injection.',
|
|
12329
|
+
].join('\n');
|
|
12330
|
+
};
|
|
11087
12331
|
const isFactoryQuestionTarget = (target) => {
|
|
11088
12332
|
const normalized = target.trim().replace(/^@/u, '').toLowerCase();
|
|
11089
12333
|
return normalized === 'broker' || normalized === 'factory';
|
|
@@ -11179,8 +12423,6 @@ const clarificationStaleSlackText = (waiting, stakeholderUserIds = []) => [
|
|
|
11179
12423
|
`Question from ${waiting.askerName}: ${waiting.question} Reply in this thread to wake the saved agent team, or move the issue out of Agent Implementing to cancel the wake.`,
|
|
11180
12424
|
].filter((line) => Boolean(line)).join('\n');
|
|
11181
12425
|
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
11182
|
-
const isOwnSlackBotReply = (payload, botUserId) => payload.user_is_bot === true ||
|
|
11183
|
-
stringValue(payload.user) === botUserId;
|
|
11184
12426
|
const unrefDelay = (ms) => new Promise((resolve) => {
|
|
11185
12427
|
const timer = setTimeout(resolve, ms);
|
|
11186
12428
|
timer.unref?.();
|