@agent-relay/factory 0.1.25 → 0.1.26
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/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +2 -0
- package/dist/cli/fleet.js.map +1 -1
- package/dist/dispatch/templates.d.ts +5 -1
- package/dist/dispatch/templates.d.ts.map +1 -1
- package/dist/dispatch/templates.js +27 -12
- package/dist/dispatch/templates.js.map +1 -1
- package/dist/fleet/internal-fleet-client.js +1 -1
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/git/agent-worktree.d.ts +18 -0
- package/dist/git/agent-worktree.d.ts.map +1 -0
- package/dist/git/agent-worktree.js +172 -0
- package/dist/git/agent-worktree.js.map +1 -0
- package/dist/mount/local-mount-preflight.js +6 -8
- package/dist/mount/local-mount-preflight.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +1 -0
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +18 -2
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +408 -60
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/fleet.d.ts +2 -0
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/index.d.ts +1 -0
- package/dist/ports/index.d.ts.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 +3 -0
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/ports/worktree.d.ts +12 -0
- package/dist/ports/worktree.d.ts.map +1 -0
- package/dist/ports/worktree.js +2 -0
- package/dist/ports/worktree.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/slack.d.ts.map +1 -1
- package/dist/writeback/slack.js +15 -8
- package/dist/writeback/slack.js.map +1 -1
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import { FactoryConfigSchema } from '../config/schema.js';
|
|
|
5
5
|
import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
|
|
6
6
|
import { stateResolutionFromIds } from '../linear/state-resolver.js';
|
|
7
7
|
import { GithubMergeGate, closeProbePr } from '../github/index.js';
|
|
8
|
+
import { factoryWorktreePath } from '../git/agent-worktree.js';
|
|
8
9
|
import { InMemoryStateStore } from '../state/in-memory-state-store.js';
|
|
9
10
|
import { containsExplicitIssueReference, containsIssueKey } from '../issue-key-match.js';
|
|
10
11
|
import { normalizeLogger, normalizeLogValue, setSafeErrorStack, stringifyLogValue } from '../logging.js';
|
|
@@ -116,6 +117,7 @@ export class FactoryLoop {
|
|
|
116
117
|
#state;
|
|
117
118
|
#workspaceId;
|
|
118
119
|
#relayflows;
|
|
120
|
+
#worktrees;
|
|
119
121
|
#batchView;
|
|
120
122
|
#batchReady;
|
|
121
123
|
#listeners = new Map();
|
|
@@ -149,6 +151,7 @@ export class FactoryLoop {
|
|
|
149
151
|
#dispatchTerminalWaiters = new Map();
|
|
150
152
|
#dispatchLifecycleRetryTimers = new Map();
|
|
151
153
|
#dispatchLifecycleDrives = new Set();
|
|
154
|
+
#localReleaseCheckpoints = new Map();
|
|
152
155
|
#dispatchLifecycleRenewTimer;
|
|
153
156
|
#clarificationSweepTimer;
|
|
154
157
|
#clarificationSweepDueAtMs;
|
|
@@ -251,6 +254,7 @@ export class FactoryLoop {
|
|
|
251
254
|
this.#terminationGraceMs = ports.terminationGraceMs;
|
|
252
255
|
this.#workspaceId = config.workspaceId ?? 'default';
|
|
253
256
|
this.#relayflows = ports.relayflows;
|
|
257
|
+
this.#worktrees = ports.worktrees;
|
|
254
258
|
this.#state = ports.stateStore ?? new InMemoryStateStore({
|
|
255
259
|
batchSize: config.batchSize,
|
|
256
260
|
agentQuestionDedupeLimit: AGENT_QUESTION_DEDUPE_LIMIT,
|
|
@@ -1181,7 +1185,9 @@ export class FactoryLoop {
|
|
|
1181
1185
|
for (const path of paths) {
|
|
1182
1186
|
const issue = await this.#readIssue(path);
|
|
1183
1187
|
readyIssueReads += 1;
|
|
1184
|
-
lastReadyReadProgressAtMs = this.#logTimedProgress(
|
|
1188
|
+
lastReadyReadProgressAtMs = this.#logTimedProgress(this.#config.issueSource === 'github'
|
|
1189
|
+
? '[factory] GitHub ready issue read progress'
|
|
1190
|
+
: '[factory] Linear ready issue read progress', startedAtMs, lastReadyReadProgressAtMs, { read: readyIssueReads, total: paths.length, path });
|
|
1185
1191
|
if (issue && issueSource === 'linear') {
|
|
1186
1192
|
await this.#recordCanonicalIssueState(issue);
|
|
1187
1193
|
}
|
|
@@ -1499,9 +1505,15 @@ export class FactoryLoop {
|
|
|
1499
1505
|
// happen before a remote lifecycle is first claimed so takeover cannot
|
|
1500
1506
|
// recover a persisted minimal triage task after a crash in this gap.
|
|
1501
1507
|
const durableRemoteDispatch = !dryRun && this.#fleet.placementLocality === 'remote';
|
|
1502
|
-
|
|
1508
|
+
// Local dispatches need the same deterministic branch identity as remote
|
|
1509
|
+
// ones. Without it, every worker starts in the configured shared checkout
|
|
1510
|
+
// and concurrent issues can switch each other back to the base branch.
|
|
1511
|
+
const isolateLocalWorktree = this.#fleet.placementLocality === 'local' && Boolean(this.#worktrees);
|
|
1512
|
+
const lifecycleRunId = !dryRun && (durableRemoteDispatch || isolateLocalWorktree) ? randomUUID() : undefined;
|
|
1503
1513
|
if (lifecycleRunId) {
|
|
1504
|
-
dispatchDecision = decisionWithLifecycleBranches(dispatchDecision, lifecycleRunId
|
|
1514
|
+
dispatchDecision = decisionWithLifecycleBranches(dispatchDecision, lifecycleRunId, {
|
|
1515
|
+
isolateLocalWorktree,
|
|
1516
|
+
});
|
|
1505
1517
|
}
|
|
1506
1518
|
dispatchDecision = await this.#withRenderedDispatchTasks(dispatchDecision, liveIssue);
|
|
1507
1519
|
if (durableRemoteDispatch) {
|
|
@@ -1553,6 +1565,7 @@ export class FactoryLoop {
|
|
|
1553
1565
|
name: spawned.name,
|
|
1554
1566
|
tracked: cloneTrackedAgent(tracked),
|
|
1555
1567
|
persistedAtMs: this.#clock.now(),
|
|
1568
|
+
worktree: this.#agentWorktree(record, tracked.spec),
|
|
1556
1569
|
});
|
|
1557
1570
|
}
|
|
1558
1571
|
agents.push({ name: spawned.name, role: spec.role });
|
|
@@ -1597,7 +1610,12 @@ export class FactoryLoop {
|
|
|
1597
1610
|
return result;
|
|
1598
1611
|
}
|
|
1599
1612
|
catch (error) {
|
|
1600
|
-
|
|
1613
|
+
// A spawn can fail after the broker accepted it but before its ack
|
|
1614
|
+
// reached Factory. Include every planned worktree agent, not only the
|
|
1615
|
+
// acknowledged spawns, so cleanup never races a name-only survivor.
|
|
1616
|
+
const failureHandoffs = this.#dispatchFailureHandoffs(record, spawnedForReaperHandoff);
|
|
1617
|
+
await this.#persistDispatchFailureReaperHandoff(record, failureHandoffs);
|
|
1618
|
+
const worktreesTornDown = await this.#teardownFailedDispatchWorktrees(failureHandoffs);
|
|
1601
1619
|
await this.#recordDispatchFailure(decision.issue);
|
|
1602
1620
|
const failedState = await this.#state.getDispatchAttempts(this.#workspaceId, decision.issue.key);
|
|
1603
1621
|
await this.#saveDispatchLifecycle(record, failedState?.terminal ? 'abandoned' : 'retryable');
|
|
@@ -1605,6 +1623,20 @@ export class FactoryLoop {
|
|
|
1605
1623
|
if (!failedState?.terminal)
|
|
1606
1624
|
this.#scheduleDispatchLifecycleRetry(record);
|
|
1607
1625
|
this.#error(error, decision.issue);
|
|
1626
|
+
// The teardown runs while the record still exists so it can safely
|
|
1627
|
+
// derive every shared checkout. Rewrite the registry only after abandon
|
|
1628
|
+
// removes those agents from the ordinary in-flight view.
|
|
1629
|
+
if (worktreesTornDown) {
|
|
1630
|
+
try {
|
|
1631
|
+
await this.#writeInFlightRegistry();
|
|
1632
|
+
}
|
|
1633
|
+
catch (registryError) {
|
|
1634
|
+
this.#logger.warn?.('[factory] failed to rewrite registry after dispatch worktree teardown', {
|
|
1635
|
+
issue: record.issue,
|
|
1636
|
+
error: describeError(registryError).errorMessage,
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1608
1640
|
throw error;
|
|
1609
1641
|
}
|
|
1610
1642
|
}
|
|
@@ -1815,6 +1847,31 @@ export class FactoryLoop {
|
|
|
1815
1847
|
}, DISPATCH_LIFECYCLE_RETRY_MS);
|
|
1816
1848
|
this.#dispatchLifecycleRetryTimers.set(key, timer);
|
|
1817
1849
|
}
|
|
1850
|
+
#scheduleReleaseRetry(record, reason) {
|
|
1851
|
+
if (this.#fleet.placementLocality === 'remote') {
|
|
1852
|
+
this.#scheduleDispatchLifecycleRetry(record);
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
const key = issueKey(record.issue);
|
|
1856
|
+
if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
|
|
1857
|
+
return;
|
|
1858
|
+
const timer = setTimeout(() => {
|
|
1859
|
+
this.#dispatchLifecycleRetryTimers.delete(key);
|
|
1860
|
+
const drive = this.#finishDurableRelease(record, reason)
|
|
1861
|
+
.then(() => undefined)
|
|
1862
|
+
.catch((error) => {
|
|
1863
|
+
this.#logger.warn?.('[factory] local completion cleanup retry failed', {
|
|
1864
|
+
issue: record.issue.key,
|
|
1865
|
+
error: describeError(error).errorMessage,
|
|
1866
|
+
});
|
|
1867
|
+
this.#scheduleReleaseRetry(record, reason);
|
|
1868
|
+
})
|
|
1869
|
+
.finally(() => this.#dispatchLifecycleDrives.delete(drive));
|
|
1870
|
+
this.#dispatchLifecycleDrives.add(drive);
|
|
1871
|
+
}, DISPATCH_LIFECYCLE_RETRY_MS);
|
|
1872
|
+
timer.unref?.();
|
|
1873
|
+
this.#dispatchLifecycleRetryTimers.set(key, timer);
|
|
1874
|
+
}
|
|
1818
1875
|
async #driveDispatchLifecycle(key) {
|
|
1819
1876
|
if (this.#stopping)
|
|
1820
1877
|
return;
|
|
@@ -1965,12 +2022,12 @@ export class FactoryLoop {
|
|
|
1965
2022
|
}
|
|
1966
2023
|
async #finishDurableRelease(record, releaseReason) {
|
|
1967
2024
|
const batch = await this.#batch();
|
|
1968
|
-
const next = this.#fleet.placementLocality === 'remote' ? undefined : batch.complete(record.issue);
|
|
1969
2025
|
const reason = releaseReason ?? (this.#config.terminalState === 'human-review' ? 'issue-human-review' : 'issue-done');
|
|
1970
|
-
const
|
|
2026
|
+
const releaseKey = issueKey(record.issue);
|
|
2027
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, releaseKey);
|
|
1971
2028
|
const released = new Set(lifecycle?.agents
|
|
1972
2029
|
.filter((agent) => agent.releasedAtMs !== undefined)
|
|
1973
|
-
.map((agent) => agent.name) ?? []);
|
|
2030
|
+
.map((agent) => agent.name) ?? this.#localReleaseCheckpoints.get(releaseKey) ?? []);
|
|
1974
2031
|
const failed = [];
|
|
1975
2032
|
for (const agent of record.agents) {
|
|
1976
2033
|
if (released.has(agent[0]))
|
|
@@ -1981,19 +2038,37 @@ export class FactoryLoop {
|
|
|
1981
2038
|
continue;
|
|
1982
2039
|
}
|
|
1983
2040
|
released.add(agent[0]);
|
|
2041
|
+
if (this.#fleet.placementLocality === 'local') {
|
|
2042
|
+
this.#localReleaseCheckpoints.set(releaseKey, new Set(released));
|
|
2043
|
+
}
|
|
1984
2044
|
// Persist each acknowledged release independently. A takeover retries
|
|
1985
2045
|
// only agents whose release did not reach a fenced durable checkpoint.
|
|
1986
2046
|
if (!await this.#saveDispatchLifecycle(record, 'releasing', undefined, reason, released))
|
|
1987
2047
|
return false;
|
|
1988
2048
|
}
|
|
1989
|
-
if (next)
|
|
1990
|
-
await this.dispatch(next.decision, { dryRun: next.dryRun });
|
|
1991
2049
|
await this.#writeInFlightRegistry();
|
|
1992
2050
|
if (failed.length > 0) {
|
|
1993
2051
|
this.#increment('dispatchLifecycleReleaseRetries');
|
|
1994
|
-
this.#
|
|
2052
|
+
this.#scheduleReleaseRetry(record, reason);
|
|
2053
|
+
return false;
|
|
2054
|
+
}
|
|
2055
|
+
// The PR branch is already pushed and the babysitter has declared the
|
|
2056
|
+
// current PR green with review feedback addressed. Release is now fenced,
|
|
2057
|
+
// so no agent can race cleanup of the shared per-issue worktree.
|
|
2058
|
+
try {
|
|
2059
|
+
await this.#cleanupAgentWorktrees(record);
|
|
2060
|
+
}
|
|
2061
|
+
catch {
|
|
2062
|
+
// Completion remains in-flight until the isolated checkout is gone.
|
|
2063
|
+
// Remote lifecycles retry from their durable `releasing` phase; local
|
|
2064
|
+
// lifecycles retain this record and retry directly from the same fence.
|
|
2065
|
+
this.#scheduleReleaseRetry(record, reason);
|
|
1995
2066
|
return false;
|
|
1996
2067
|
}
|
|
2068
|
+
const next = this.#fleet.placementLocality === 'remote' ? undefined : batch.complete(record.issue);
|
|
2069
|
+
this.#localReleaseCheckpoints.delete(releaseKey);
|
|
2070
|
+
if (next)
|
|
2071
|
+
await this.dispatch(next.decision, { dryRun: next.dryRun });
|
|
1997
2072
|
// Terminal lifecycle saves intentionally relinquish the owner epoch. Clear
|
|
1998
2073
|
// the babysitter's durable ownership/wake/critical state while that epoch
|
|
1999
2074
|
// is still valid so a later reopened issue cannot inherit a stale PR owner.
|
|
@@ -2470,6 +2545,7 @@ export class FactoryLoop {
|
|
|
2470
2545
|
try {
|
|
2471
2546
|
const protectedPids = await this.#protectedPids();
|
|
2472
2547
|
let registryChanged = false;
|
|
2548
|
+
const readyToClear = new Set();
|
|
2473
2549
|
for (const [key, handoff] of handoffs) {
|
|
2474
2550
|
const roots = await this.#terminationRoots(handoff.name, handoff.tracked, protectedPids);
|
|
2475
2551
|
if (roots.pids.length === 0 && roots.status === 'unresolved') {
|
|
@@ -2482,8 +2558,6 @@ export class FactoryLoop {
|
|
|
2482
2558
|
unresolvedAgeMs,
|
|
2483
2559
|
});
|
|
2484
2560
|
if (unresolvedAgeMs >= DISPATCH_FAILURE_HANDOFF_UNRESOLVED_TTL_MS) {
|
|
2485
|
-
await this.#state.clearFailureHandoff(this.#workspaceId, key);
|
|
2486
|
-
registryChanged = true;
|
|
2487
2561
|
this.#increment('dispatchFailureReaperHandoffsDroppedStaleUnresolved');
|
|
2488
2562
|
this.#logger.warn?.('[factory] dropped stale unresolved dispatch-failed handoff', {
|
|
2489
2563
|
agentName: handoff.name,
|
|
@@ -2493,6 +2567,7 @@ export class FactoryLoop {
|
|
|
2493
2567
|
});
|
|
2494
2568
|
try {
|
|
2495
2569
|
await this.#fleet.release(handoff.name, 'dispatch failed');
|
|
2570
|
+
readyToClear.add(key);
|
|
2496
2571
|
}
|
|
2497
2572
|
catch (error) {
|
|
2498
2573
|
this.#logger.warn?.('[factory] failed to release unresolved dispatch-failure handoff after pruning', {
|
|
@@ -2526,16 +2601,55 @@ export class FactoryLoop {
|
|
|
2526
2601
|
}
|
|
2527
2602
|
}
|
|
2528
2603
|
if (!blockingSkip) {
|
|
2529
|
-
await this.#state.clearFailureHandoff(this.#workspaceId, key);
|
|
2530
|
-
registryChanged = true;
|
|
2531
2604
|
try {
|
|
2532
2605
|
await this.#fleet.release(handoff.name, 'dispatch failed');
|
|
2606
|
+
readyToClear.add(key);
|
|
2533
2607
|
}
|
|
2534
2608
|
catch (error) {
|
|
2535
2609
|
this.#logger.warn?.(`[factory] failed to release ${handoff.name} after dispatch-failure reap`, error);
|
|
2536
2610
|
}
|
|
2537
2611
|
}
|
|
2538
2612
|
}
|
|
2613
|
+
if (readyToClear.size > 0) {
|
|
2614
|
+
const worktreeGroups = new Map();
|
|
2615
|
+
for (const entry of handoffs) {
|
|
2616
|
+
const worktreePath = entry[1].worktree?.worktreePath;
|
|
2617
|
+
if (!worktreePath)
|
|
2618
|
+
continue;
|
|
2619
|
+
const group = worktreeGroups.get(worktreePath) ?? [];
|
|
2620
|
+
group.push(entry);
|
|
2621
|
+
worktreeGroups.set(worktreePath, group);
|
|
2622
|
+
}
|
|
2623
|
+
for (const group of worktreeGroups.values()) {
|
|
2624
|
+
if (!group.every(([key]) => readyToClear.has(key)))
|
|
2625
|
+
continue;
|
|
2626
|
+
try {
|
|
2627
|
+
await this.#cleanupFailureHandoffWorktrees(group.map(([, handoff]) => handoff));
|
|
2628
|
+
}
|
|
2629
|
+
catch (error) {
|
|
2630
|
+
this.#increment('agentWorktreeCleanupFailures');
|
|
2631
|
+
this.#logger.warn?.('[factory] retained dispatch-failure handoff after worktree cleanup failed', {
|
|
2632
|
+
issue: group[0]?.[1].issue,
|
|
2633
|
+
worktreePath: group[0]?.[1].worktree?.worktreePath,
|
|
2634
|
+
error: describeError(error).errorMessage,
|
|
2635
|
+
});
|
|
2636
|
+
continue;
|
|
2637
|
+
}
|
|
2638
|
+
for (const [key] of group) {
|
|
2639
|
+
await this.#state.clearFailureHandoff(this.#workspaceId, key);
|
|
2640
|
+
readyToClear.delete(key);
|
|
2641
|
+
registryChanged = true;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
// Legacy and non-worktree handoffs can be cleared directly once their
|
|
2645
|
+
// process is gone and the broker accepted the release.
|
|
2646
|
+
for (const [key, handoff] of handoffs) {
|
|
2647
|
+
if (!readyToClear.has(key) || handoff.worktree)
|
|
2648
|
+
continue;
|
|
2649
|
+
await this.#state.clearFailureHandoff(this.#workspaceId, key);
|
|
2650
|
+
registryChanged = true;
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2539
2653
|
if (registryChanged) {
|
|
2540
2654
|
await this.#writeInFlightRegistry(registryPath, heartbeatPath);
|
|
2541
2655
|
}
|
|
@@ -2750,6 +2864,62 @@ export class FactoryLoop {
|
|
|
2750
2864
|
this.#error(error, record.issue);
|
|
2751
2865
|
}
|
|
2752
2866
|
}
|
|
2867
|
+
#dispatchFailureHandoffs(record, acknowledged) {
|
|
2868
|
+
const handoffs = new Map(acknowledged.map((handoff) => [handoff.name, handoff]));
|
|
2869
|
+
if (!this.#worktrees)
|
|
2870
|
+
return [...handoffs.values()];
|
|
2871
|
+
for (const [name, tracked] of record.agents) {
|
|
2872
|
+
const worktree = this.#agentWorktree(record, tracked.spec);
|
|
2873
|
+
if (!worktree)
|
|
2874
|
+
continue;
|
|
2875
|
+
const existing = handoffs.get(name);
|
|
2876
|
+
handoffs.set(name, {
|
|
2877
|
+
issue: record.issue,
|
|
2878
|
+
name,
|
|
2879
|
+
tracked: cloneTrackedAgent(tracked),
|
|
2880
|
+
persistedAtMs: existing?.persistedAtMs ?? this.#clock.now(),
|
|
2881
|
+
worktree,
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
return [...handoffs.values()];
|
|
2885
|
+
}
|
|
2886
|
+
async #teardownFailedDispatchWorktrees(handoffs) {
|
|
2887
|
+
if (!this.#worktrees || !handoffs.some((handoff) => handoff.worktree))
|
|
2888
|
+
return false;
|
|
2889
|
+
const failed = await this.#releaseAndTerminateAgents(handoffs.map((handoff) => [handoff.name, handoff.tracked]), 'dispatch failed', 'completion');
|
|
2890
|
+
if (failed.length > 0)
|
|
2891
|
+
return false;
|
|
2892
|
+
try {
|
|
2893
|
+
await this.#cleanupFailureHandoffWorktrees(handoffs);
|
|
2894
|
+
for (const handoff of handoffs) {
|
|
2895
|
+
await this.#state.clearFailureHandoff(this.#workspaceId, registryHandoffKey(handoff.issue, handoff.name));
|
|
2896
|
+
}
|
|
2897
|
+
return true;
|
|
2898
|
+
}
|
|
2899
|
+
catch (error) {
|
|
2900
|
+
// Keep the durable handoffs. The loop reaper will retry cleanup only
|
|
2901
|
+
// after it has reconfirmed every agent sharing the checkout is gone.
|
|
2902
|
+
this.#increment('agentWorktreeCleanupFailures');
|
|
2903
|
+
this.#logger.warn?.('[factory] retained dispatch-failure handoffs after worktree cleanup failed', {
|
|
2904
|
+
issue: handoffs[0]?.issue,
|
|
2905
|
+
error: describeError(error).errorMessage,
|
|
2906
|
+
});
|
|
2907
|
+
return false;
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
async #cleanupFailureHandoffWorktrees(handoffs) {
|
|
2911
|
+
if (!this.#worktrees)
|
|
2912
|
+
return;
|
|
2913
|
+
const unique = new Map();
|
|
2914
|
+
for (const handoff of handoffs) {
|
|
2915
|
+
if (handoff.worktree)
|
|
2916
|
+
unique.set(handoff.worktree.worktreePath, handoff.worktree);
|
|
2917
|
+
}
|
|
2918
|
+
for (const worktree of unique.values()) {
|
|
2919
|
+
await this.#worktrees.cleanup(worktree);
|
|
2920
|
+
this.#increment('agentWorktreesCleaned');
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2753
2923
|
async #writeInFlightRegistry(path = this.#config.loop.registryPath, heartbeatPath = this.#config.loop.heartbeatPath, empty = false) {
|
|
2754
2924
|
const updatedAtMs = this.#clock.now();
|
|
2755
2925
|
const agents = [];
|
|
@@ -2844,6 +3014,7 @@ export class FactoryLoop {
|
|
|
2844
3014
|
}
|
|
2845
3015
|
return { name: spec.name };
|
|
2846
3016
|
}
|
|
3017
|
+
await this.#prepareAgentWorktree(record, spec);
|
|
2847
3018
|
let result;
|
|
2848
3019
|
try {
|
|
2849
3020
|
result = await this.#fleet.spawn({
|
|
@@ -3061,6 +3232,7 @@ export class FactoryLoop {
|
|
|
3061
3232
|
else {
|
|
3062
3233
|
const invocationId = `${batch.invocationIdFor(record.issue, tracked.spec)}:restart:${this.#clock.now()}`;
|
|
3063
3234
|
try {
|
|
3235
|
+
await this.#prepareAgentWorktree(record, tracked.spec);
|
|
3064
3236
|
const result = await this.#fleet.spawn({
|
|
3065
3237
|
name: tracked.spec.name,
|
|
3066
3238
|
capability: tracked.spec.capability,
|
|
@@ -3190,6 +3362,66 @@ export class FactoryLoop {
|
|
|
3190
3362
|
});
|
|
3191
3363
|
return result;
|
|
3192
3364
|
}
|
|
3365
|
+
async #prepareAgentWorktree(record, spec) {
|
|
3366
|
+
const worktree = this.#agentWorktree(record, spec);
|
|
3367
|
+
if (!worktree || !this.#worktrees)
|
|
3368
|
+
return;
|
|
3369
|
+
try {
|
|
3370
|
+
await this.#worktrees.prepare(worktree);
|
|
3371
|
+
this.#increment('agentWorktreesPrepared');
|
|
3372
|
+
}
|
|
3373
|
+
catch (error) {
|
|
3374
|
+
throw contextualError(`Unable to prepare isolated worktree for ${record.issue.key}/${spec.repo} at ${worktree.worktreePath}`, error);
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
#agentWorktree(record, spec) {
|
|
3378
|
+
if (!spec.baseClonePath || !spec.clonePath || spec.baseClonePath === spec.clonePath)
|
|
3379
|
+
return undefined;
|
|
3380
|
+
const implementer = record.decision.implementers.find((candidate) => candidate.repo === spec.repo && candidate.branch)
|
|
3381
|
+
?? [...record.agents.values()]
|
|
3382
|
+
.map((tracked) => tracked.spec)
|
|
3383
|
+
.find((candidate) => candidate.repo === spec.repo && candidate.role === 'implementer' && candidate.branch);
|
|
3384
|
+
const branch = spec.branch ?? implementer?.branch;
|
|
3385
|
+
if (!branch)
|
|
3386
|
+
return undefined;
|
|
3387
|
+
return {
|
|
3388
|
+
repo: spec.repo,
|
|
3389
|
+
issueKey: record.issue.key,
|
|
3390
|
+
baseClonePath: spec.baseClonePath,
|
|
3391
|
+
worktreePath: spec.clonePath,
|
|
3392
|
+
branch,
|
|
3393
|
+
};
|
|
3394
|
+
}
|
|
3395
|
+
async #cleanupAgentWorktrees(record) {
|
|
3396
|
+
if (!this.#worktrees)
|
|
3397
|
+
return;
|
|
3398
|
+
const unique = new Map();
|
|
3399
|
+
for (const tracked of record.agents.values()) {
|
|
3400
|
+
const worktree = this.#agentWorktree(record, tracked.spec);
|
|
3401
|
+
if (worktree)
|
|
3402
|
+
unique.set(worktree.worktreePath, worktree);
|
|
3403
|
+
}
|
|
3404
|
+
const failures = [];
|
|
3405
|
+
for (const worktree of unique.values()) {
|
|
3406
|
+
try {
|
|
3407
|
+
await this.#worktrees.cleanup(worktree);
|
|
3408
|
+
this.#increment('agentWorktreesCleaned');
|
|
3409
|
+
}
|
|
3410
|
+
catch (error) {
|
|
3411
|
+
failures.push(`${worktree.worktreePath}: ${describeError(error).errorMessage}`);
|
|
3412
|
+
this.#increment('agentWorktreeCleanupFailures');
|
|
3413
|
+
this.#logger.warn?.('[factory] failed to clean completed issue worktree', {
|
|
3414
|
+
issue: record.issue.key,
|
|
3415
|
+
repo: worktree.repo,
|
|
3416
|
+
worktreePath: worktree.worktreePath,
|
|
3417
|
+
error: describeError(error).errorMessage,
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
if (failures.length > 0) {
|
|
3422
|
+
throw new Error(`Factory worktree cleanup incomplete for ${record.issue.key}: ${failures.join('; ')}`);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3193
3425
|
async #confirmPublishedRemotePullRequest(repo, result, expectedHeadRef) {
|
|
3194
3426
|
const parts = githubRepoParts(repo);
|
|
3195
3427
|
if (!parts)
|
|
@@ -3377,6 +3609,7 @@ export class FactoryLoop {
|
|
|
3377
3609
|
if (!tracked.sessionRef) {
|
|
3378
3610
|
return;
|
|
3379
3611
|
}
|
|
3612
|
+
await this.#prepareAgentWorktree(record, tracked.spec);
|
|
3380
3613
|
const result = await this.#fleet.resume({
|
|
3381
3614
|
name,
|
|
3382
3615
|
sessionRef: tracked.sessionRef,
|
|
@@ -3619,7 +3852,7 @@ export class FactoryLoop {
|
|
|
3619
3852
|
catch {
|
|
3620
3853
|
// The initiator logs Slack watcher startup failures.
|
|
3621
3854
|
}
|
|
3622
|
-
const threadId = await this.#
|
|
3855
|
+
const threadId = await this.#persistedSlackThread(key);
|
|
3623
3856
|
if (!threadId) {
|
|
3624
3857
|
this.#increment('agentQuestionsSkippedMissingThread');
|
|
3625
3858
|
this.#logger.warn?.('[factory] agent question has no Slack dispatch thread', {
|
|
@@ -3847,7 +4080,7 @@ export class FactoryLoop {
|
|
|
3847
4080
|
return undefined;
|
|
3848
4081
|
}
|
|
3849
4082
|
const key = issueKey(record.issue);
|
|
3850
|
-
const threadId = await this.#
|
|
4083
|
+
const threadId = await this.#persistedSlackThread(key);
|
|
3851
4084
|
if (!threadId) {
|
|
3852
4085
|
this.#increment('agentQuestionReleaseSkippedMissingThread');
|
|
3853
4086
|
return undefined;
|
|
@@ -3900,7 +4133,7 @@ export class FactoryLoop {
|
|
|
3900
4133
|
issue: { ...record.issue },
|
|
3901
4134
|
decision: structuredClone(record.decision),
|
|
3902
4135
|
dryRun: record.dryRun,
|
|
3903
|
-
threadId: await this.#
|
|
4136
|
+
threadId: await this.#persistedSlackThread(key),
|
|
3904
4137
|
questionSource: 'github',
|
|
3905
4138
|
askerName: question.agentName,
|
|
3906
4139
|
question: question.question,
|
|
@@ -3926,7 +4159,7 @@ export class FactoryLoop {
|
|
|
3926
4159
|
this.#increment('agentQuestionSlackMirrorsSkippedDegraded');
|
|
3927
4160
|
return;
|
|
3928
4161
|
}
|
|
3929
|
-
const threadId = await this.#
|
|
4162
|
+
const threadId = await this.#persistedSlackThread(issueKey(record.issue));
|
|
3930
4163
|
if (!threadId) {
|
|
3931
4164
|
this.#increment('agentQuestionSlackMirrorsSkippedMissingThread');
|
|
3932
4165
|
return;
|
|
@@ -4701,7 +4934,8 @@ export class FactoryLoop {
|
|
|
4701
4934
|
implementerNames,
|
|
4702
4935
|
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
4703
4936
|
integrationInstructions,
|
|
4704
|
-
branchName: spec.branch,
|
|
4937
|
+
branchName: spec.branch ?? decision.implementers.find((candidate) => candidate.repo === spec.repo)?.branch,
|
|
4938
|
+
branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
|
|
4705
4939
|
agentName: spec.name,
|
|
4706
4940
|
}),
|
|
4707
4941
|
});
|
|
@@ -5483,7 +5717,20 @@ export class FactoryLoop {
|
|
|
5483
5717
|
}
|
|
5484
5718
|
const route = record.decision.routes.find((candidate) => candidate.repo === prRef.repo)
|
|
5485
5719
|
?? record.decision.routes[0];
|
|
5486
|
-
const
|
|
5720
|
+
const initialSpec = babysitterSpec(issue, this.#config, route);
|
|
5721
|
+
const sharedCheckout = [...record.agents.values()]
|
|
5722
|
+
.map((agent) => agent.spec)
|
|
5723
|
+
.find((candidate) => candidate.repo === initialSpec.repo && candidate.baseClonePath && candidate.clonePath);
|
|
5724
|
+
const implementerBranch = record.decision.implementers
|
|
5725
|
+
.find((candidate) => candidate.repo === initialSpec.repo && candidate.branch)?.branch;
|
|
5726
|
+
const spec = sharedCheckout
|
|
5727
|
+
? {
|
|
5728
|
+
...initialSpec,
|
|
5729
|
+
baseClonePath: sharedCheckout.baseClonePath,
|
|
5730
|
+
clonePath: sharedCheckout.clonePath,
|
|
5731
|
+
...(implementerBranch ? { branch: implementerBranch } : {}),
|
|
5732
|
+
}
|
|
5733
|
+
: initialSpec;
|
|
5487
5734
|
const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer');
|
|
5488
5735
|
const reviewerName = reviewer?.result?.name ?? reviewer?.spec.name
|
|
5489
5736
|
?? agentNameForRole(issue, 'review', { repo: route?.repo ?? prRef.repo });
|
|
@@ -5493,7 +5740,7 @@ export class FactoryLoop {
|
|
|
5493
5740
|
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
5494
5741
|
const task = renderAgentTask({
|
|
5495
5742
|
issue: templateIssueFromRecord(record, issue),
|
|
5496
|
-
route: route ?? { repo: prRef.repo },
|
|
5743
|
+
route: { ...(route ?? { repo: prRef.repo }), clonePath: spec.clonePath },
|
|
5497
5744
|
role: 'babysitter',
|
|
5498
5745
|
config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
|
|
5499
5746
|
reviewerName,
|
|
@@ -5502,6 +5749,8 @@ export class FactoryLoop {
|
|
|
5502
5749
|
slackDispatchThread: await this.#slackDispatchThreadFor(record),
|
|
5503
5750
|
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
5504
5751
|
integrationInstructions,
|
|
5752
|
+
branchName: spec.branch,
|
|
5753
|
+
branchPrepared: Boolean(spec.baseClonePath && spec.clonePath && spec.baseClonePath !== spec.clonePath),
|
|
5505
5754
|
agentName: spec.name,
|
|
5506
5755
|
});
|
|
5507
5756
|
const spawned = await this.#spawnAgent(record, {
|
|
@@ -5670,6 +5919,7 @@ export class FactoryLoop {
|
|
|
5670
5919
|
return;
|
|
5671
5920
|
}
|
|
5672
5921
|
this.#completionInFlight.add(completionKey);
|
|
5922
|
+
let releaseReasonForRetry;
|
|
5673
5923
|
try {
|
|
5674
5924
|
if (!await this.#assertDispatchLifecycleOwner(record))
|
|
5675
5925
|
return;
|
|
@@ -5755,6 +6005,7 @@ export class FactoryLoop {
|
|
|
5755
6005
|
await this.#runCompletionMergeGate(issue);
|
|
5756
6006
|
}
|
|
5757
6007
|
const releaseReason = humanReview ? 'issue-human-review' : 'issue-done';
|
|
6008
|
+
releaseReasonForRetry = releaseReason;
|
|
5758
6009
|
if (this.#fleet.placementLocality === 'remote') {
|
|
5759
6010
|
// Durable capacity is released as soon as terminal writeback is
|
|
5760
6011
|
// acknowledged. Agent cleanup remains fenced/retryable in `releasing`.
|
|
@@ -5771,7 +6022,10 @@ export class FactoryLoop {
|
|
|
5771
6022
|
}
|
|
5772
6023
|
catch (error) {
|
|
5773
6024
|
this.#error(error, record.issue);
|
|
5774
|
-
|
|
6025
|
+
if (releaseReasonForRetry)
|
|
6026
|
+
this.#scheduleReleaseRetry(record, releaseReasonForRetry);
|
|
6027
|
+
else
|
|
6028
|
+
this.#scheduleDispatchLifecycleRetry(record);
|
|
5775
6029
|
}
|
|
5776
6030
|
finally {
|
|
5777
6031
|
this.#completionInFlight.delete(completionKey);
|
|
@@ -6025,6 +6279,19 @@ export class FactoryLoop {
|
|
|
6025
6279
|
this.#lastObservedSlackEventAtMs = this.#clock.now();
|
|
6026
6280
|
this.#increment('slackWebhookEventsObserved');
|
|
6027
6281
|
}
|
|
6282
|
+
async #persistedSlackThread(key) {
|
|
6283
|
+
const threadId = await this.#state.getSlackThread(this.#workspaceId, key);
|
|
6284
|
+
if (!threadId || /^\d+[._]\d+$/u.test(threadId))
|
|
6285
|
+
return threadId;
|
|
6286
|
+
// Older Factory versions persisted the Relayfile draft client id when the
|
|
6287
|
+
// acknowledged file had not yet reconciled its provider payload. Slack
|
|
6288
|
+
// cannot use that value as thread_ts. Drop it so the caller establishes a
|
|
6289
|
+
// fresh provider-backed root instead of producing invalid_thread_ts.
|
|
6290
|
+
await this.#state.clearSlackThread(this.#workspaceId, key);
|
|
6291
|
+
this.#increment('invalidSlackThreadsCleared');
|
|
6292
|
+
this.#logger.warn?.('[factory] cleared invalid persisted Slack thread id', { issue: key });
|
|
6293
|
+
return undefined;
|
|
6294
|
+
}
|
|
6028
6295
|
async #ensureSlackDispatchThread(record, result) {
|
|
6029
6296
|
if (!this.#slack || !this.#config.slack || result.dryRun) {
|
|
6030
6297
|
return;
|
|
@@ -6033,7 +6300,7 @@ export class FactoryLoop {
|
|
|
6033
6300
|
return;
|
|
6034
6301
|
}
|
|
6035
6302
|
const key = issueKey(record.issue);
|
|
6036
|
-
const existingThread = await this.#
|
|
6303
|
+
const existingThread = await this.#persistedSlackThread(key);
|
|
6037
6304
|
const watcherStart = this.#slackWatcherStarts.get(key);
|
|
6038
6305
|
if (existingThread || watcherStart) {
|
|
6039
6306
|
try {
|
|
@@ -6086,6 +6353,16 @@ export class FactoryLoop {
|
|
|
6086
6353
|
if (dryRun) {
|
|
6087
6354
|
return;
|
|
6088
6355
|
}
|
|
6356
|
+
// A source GitHub issue is the durable stakeholder record. Keep both the
|
|
6357
|
+
// question and authorized response there, then mirror the escalation once
|
|
6358
|
+
// to Slack for stakeholder visibility without making Slack a competing
|
|
6359
|
+
// clarification workflow.
|
|
6360
|
+
const sourceIssue = await this.#readIssue(decision.issue.path);
|
|
6361
|
+
if (sourceIssue && githubIssueSourceRef(sourceIssue)) {
|
|
6362
|
+
const result = await this.#escalateTriageToGithub(decision, reason);
|
|
6363
|
+
await this.#mirrorGithubTriageEscalationToSlack(decision, sourceIssue, reason);
|
|
6364
|
+
return result;
|
|
6365
|
+
}
|
|
6089
6366
|
if (!this.#slack || !this.#config.slack) {
|
|
6090
6367
|
return await this.#escalateTriageToGithub(decision, reason);
|
|
6091
6368
|
}
|
|
@@ -6093,7 +6370,7 @@ export class FactoryLoop {
|
|
|
6093
6370
|
return;
|
|
6094
6371
|
}
|
|
6095
6372
|
const key = issueKey(decision.issue);
|
|
6096
|
-
const existingThread = await this.#
|
|
6373
|
+
const existingThread = await this.#persistedSlackThread(key);
|
|
6097
6374
|
const watcherStart = this.#slackWatcherStarts.get(key);
|
|
6098
6375
|
if (existingThread || watcherStart) {
|
|
6099
6376
|
try {
|
|
@@ -6146,7 +6423,7 @@ export class FactoryLoop {
|
|
|
6146
6423
|
}
|
|
6147
6424
|
try {
|
|
6148
6425
|
await this.#githubWriteback.postComment(issue, [
|
|
6149
|
-
|
|
6426
|
+
`@${authorizedAuthor}, Factory needs clarification before dispatching ${decision.issue.key}.`,
|
|
6150
6427
|
`Reason: ${reason}`,
|
|
6151
6428
|
`Question: ${question}`,
|
|
6152
6429
|
`Authorized responder: @${authorizedAuthor} (the issue reporter).`,
|
|
@@ -6161,15 +6438,79 @@ export class FactoryLoop {
|
|
|
6161
6438
|
this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'GitHub issue comment writeback failed', error);
|
|
6162
6439
|
}
|
|
6163
6440
|
}
|
|
6441
|
+
async #mirrorGithubTriageEscalationToSlack(decision, issue, reason) {
|
|
6442
|
+
if (!this.#slack || !this.#config.slack)
|
|
6443
|
+
return;
|
|
6444
|
+
if (await this.#shouldSkipSlackWriteback('triage-escalation-mirror')) {
|
|
6445
|
+
this.#increment('triageEscalationSlackMirrorsSkippedDegraded');
|
|
6446
|
+
return;
|
|
6447
|
+
}
|
|
6448
|
+
const key = issueKey(decision.issue);
|
|
6449
|
+
const existingThread = await this.#persistedSlackThread(key);
|
|
6450
|
+
const inFlight = this.#slackWatcherStarts.get(key);
|
|
6451
|
+
if (existingThread || inFlight) {
|
|
6452
|
+
if (inFlight) {
|
|
6453
|
+
try {
|
|
6454
|
+
await inFlight;
|
|
6455
|
+
}
|
|
6456
|
+
catch {
|
|
6457
|
+
// The initiator records the optional mirror failure.
|
|
6458
|
+
}
|
|
6459
|
+
}
|
|
6460
|
+
this.#increment('triageEscalationSlackMirrorDuplicatesSuppressed');
|
|
6461
|
+
return;
|
|
6462
|
+
}
|
|
6463
|
+
const start = this.#postGithubTriageSlackMirror(decision, issue, reason);
|
|
6464
|
+
this.#slackWatcherStarts.set(key, start);
|
|
6465
|
+
try {
|
|
6466
|
+
await start;
|
|
6467
|
+
}
|
|
6468
|
+
catch (error) {
|
|
6469
|
+
this.#markSlackWritebackFailure('triage-escalation-mirror', error);
|
|
6470
|
+
this.#increment('triageEscalationSlackMirrorFailures');
|
|
6471
|
+
this.#logger.warn?.('[factory] optional GitHub triage Slack mirror failed', {
|
|
6472
|
+
issue: decision.issue.key,
|
|
6473
|
+
error: describeError(error).errorMessage,
|
|
6474
|
+
});
|
|
6475
|
+
}
|
|
6476
|
+
finally {
|
|
6477
|
+
this.#slackWatcherStarts.delete(key);
|
|
6478
|
+
}
|
|
6479
|
+
}
|
|
6480
|
+
async #postGithubTriageSlackMirror(decision, issue, reason) {
|
|
6481
|
+
if (!this.#slack || !this.#config.slack)
|
|
6482
|
+
return;
|
|
6483
|
+
const source = githubIssueSourceRef(issue);
|
|
6484
|
+
const stakeholderMentions = slackMentions(this.#config.slack.stakeholderUserIds);
|
|
6485
|
+
const reporter = githubIssueAuthor(issue);
|
|
6486
|
+
const audience = [stakeholderMentions, reporter ? `GitHub reporter: @${reporter}.` : undefined]
|
|
6487
|
+
.filter((part) => Boolean(part))
|
|
6488
|
+
.join(' ');
|
|
6489
|
+
const replyInstruction = source?.url
|
|
6490
|
+
? `Reply on the GitHub issue so Factory can resume: ${source.url}`
|
|
6491
|
+
: 'Reply on the source GitHub issue so Factory can resume.';
|
|
6492
|
+
const root = await this.#slack.postThread({
|
|
6493
|
+
channel: await this.#slackChannelDir() ?? this.#config.slack.channel,
|
|
6494
|
+
text: [
|
|
6495
|
+
`${audience ? `${audience} ` : ''}${decision.issue.key}: factory triage escalation for ${issue.title}`,
|
|
6496
|
+
`Reason: ${reason}`,
|
|
6497
|
+
`Question: ${triageEscalationQuestion(decision)} ${replyInstruction}`,
|
|
6498
|
+
].join('\n'),
|
|
6499
|
+
});
|
|
6500
|
+
await this.#state.setSlackThread(this.#workspaceId, issueKey(decision.issue), root.threadId);
|
|
6501
|
+
this.#increment('triageEscalationsMirroredToSlack');
|
|
6502
|
+
this.#recordSlackWritebackSuccess('triage-escalation-mirror');
|
|
6503
|
+
}
|
|
6164
6504
|
async #postAndWatchSlackEscalationThread(decision, reason) {
|
|
6165
6505
|
if (!this.#slack || !this.#config.slack) {
|
|
6166
6506
|
return;
|
|
6167
6507
|
}
|
|
6168
6508
|
const issue = await this.#readIssue(decision.issue.path);
|
|
6509
|
+
const stakeholderMentions = slackMentions(this.#config.slack.stakeholderUserIds);
|
|
6169
6510
|
const root = await this.#slack.postThread({
|
|
6170
6511
|
channel: await this.#slackChannelDir() ?? this.#config.slack.channel,
|
|
6171
6512
|
text: [
|
|
6172
|
-
`${decision.issue.key}: factory triage escalation for ${issue?.title ?? decision.issue.key}`,
|
|
6513
|
+
`${stakeholderMentions ? `${stakeholderMentions} ` : ''}${decision.issue.key}: factory triage escalation for ${issue?.title ?? decision.issue.key}`,
|
|
6173
6514
|
`Reason: ${reason}`,
|
|
6174
6515
|
`Question: ${triageEscalationQuestion(decision)}`,
|
|
6175
6516
|
].join('\n'),
|
|
@@ -6362,7 +6703,7 @@ export class FactoryLoop {
|
|
|
6362
6703
|
}
|
|
6363
6704
|
let threadId;
|
|
6364
6705
|
try {
|
|
6365
|
-
threadId = await this.#
|
|
6706
|
+
threadId = await this.#persistedSlackThread(key);
|
|
6366
6707
|
}
|
|
6367
6708
|
catch (error) {
|
|
6368
6709
|
this.#logger.warn?.('[factory] unable to read persisted Slack thread during watcher rehydration', { issue: record.issue.key, error });
|
|
@@ -6857,6 +7198,7 @@ export class FactoryLoop {
|
|
|
6857
7198
|
}
|
|
6858
7199
|
async #resumeOrColdStartClarificationAgent(name, tracked, waiting) {
|
|
6859
7200
|
const task = clarificationResumeTask(tracked.spec.task, waiting);
|
|
7201
|
+
await this.#prepareAgentWorktree(waitingRecord(waiting), tracked.spec);
|
|
6860
7202
|
if (tracked.sessionRef) {
|
|
6861
7203
|
try {
|
|
6862
7204
|
const resumed = await this.#fleet.resume({
|
|
@@ -7066,7 +7408,7 @@ export class FactoryLoop {
|
|
|
7066
7408
|
if (!this.#config.slack) {
|
|
7067
7409
|
return undefined;
|
|
7068
7410
|
}
|
|
7069
|
-
const threadId = await this.#
|
|
7411
|
+
const threadId = await this.#persistedSlackThread(issueKey(record.issue));
|
|
7070
7412
|
const channel = await this.#slackChannelDir() ?? this.#config.slack.channel;
|
|
7071
7413
|
return threadId
|
|
7072
7414
|
? { channel, threadId, mountRoot: this.#integrationsMountRoot() }
|
|
@@ -7607,36 +7949,35 @@ function routeImplementerSpec(issue, config, slug, route) {
|
|
|
7607
7949
|
node: 'self',
|
|
7608
7950
|
};
|
|
7609
7951
|
}
|
|
7610
|
-
function decisionWithLifecycleBranches(decision, runId) {
|
|
7611
|
-
const
|
|
7952
|
+
function decisionWithLifecycleBranches(decision, runId, opts = {}) {
|
|
7953
|
+
const implementerBranch = (spec) => {
|
|
7954
|
+
const runSuffix = `-${runId.slice(0, 8)}`;
|
|
7955
|
+
const stem = `${sanitizeAgentSlug(decision.issue.key)}-${sanitizeAgentSlug(spec.repo)}`
|
|
7956
|
+
.slice(0, 120 - 'factory/'.length - runSuffix.length);
|
|
7957
|
+
return `factory/${stem}${runSuffix}`;
|
|
7958
|
+
};
|
|
7959
|
+
const branchByRepo = new Map(decision.implementers.map((spec) => [spec.repo, implementerBranch(spec)]));
|
|
7960
|
+
const withBranch = (spec, branch) => {
|
|
7961
|
+
const baseClonePath = spec.baseClonePath ?? spec.clonePath;
|
|
7962
|
+
const clonePath = opts.isolateLocalWorktree && baseClonePath && branch
|
|
7963
|
+
? factoryWorktreePath(baseClonePath, decision.issue.key, spec.repo, runId)
|
|
7964
|
+
: spec.clonePath;
|
|
7612
7965
|
const lifecycleSpec = {
|
|
7613
7966
|
...spec,
|
|
7967
|
+
...(opts.isolateLocalWorktree && baseClonePath && branch ? { baseClonePath, clonePath } : {}),
|
|
7614
7968
|
// The same persisted lifecycle reuses this id after takeover, while a
|
|
7615
7969
|
// genuine reopen gets a new id and cannot replay an old placement ack.
|
|
7616
7970
|
invocationId: `factory:${decision.issue.key}:${runId}:${spec.role}:${sanitizeAgentSlug(spec.name)}`,
|
|
7617
7971
|
};
|
|
7618
|
-
|
|
7619
|
-
return lifecycleSpec;
|
|
7620
|
-
const runSuffix = `-${runId.slice(0, 8)}`;
|
|
7621
|
-
const stem = `${sanitizeAgentSlug(decision.issue.key)}-${sanitizeAgentSlug(spec.repo)}`
|
|
7622
|
-
.slice(0, 120 - 'factory/'.length - runSuffix.length);
|
|
7623
|
-
const branch = `factory/${stem}${runSuffix}`;
|
|
7624
|
-
return {
|
|
7625
|
-
...lifecycleSpec,
|
|
7626
|
-
branch,
|
|
7627
|
-
task: [
|
|
7628
|
-
spec.task,
|
|
7629
|
-
'',
|
|
7630
|
-
`Factory publication branch: ${branch}`,
|
|
7631
|
-
'Before editing, create or reset that exact branch from the repository default branch. Commit and push only that branch.',
|
|
7632
|
-
].join('\n'),
|
|
7633
|
-
};
|
|
7972
|
+
return branch ? { ...lifecycleSpec, branch } : lifecycleSpec;
|
|
7634
7973
|
};
|
|
7635
7974
|
return {
|
|
7636
7975
|
...structuredClone(decision),
|
|
7637
|
-
implementers: decision.implementers.map(withBranch),
|
|
7638
|
-
reviewer: withBranch(decision.reviewer),
|
|
7639
|
-
...(decision.workflow
|
|
7976
|
+
implementers: decision.implementers.map((spec) => withBranch(spec, branchByRepo.get(spec.repo))),
|
|
7977
|
+
reviewer: withBranch(decision.reviewer, branchByRepo.get(decision.reviewer.repo)),
|
|
7978
|
+
...(decision.workflow
|
|
7979
|
+
? { workflow: withBranch(decision.workflow, branchByRepo.get(decision.workflow.repo)) }
|
|
7980
|
+
: {}),
|
|
7640
7981
|
};
|
|
7641
7982
|
}
|
|
7642
7983
|
function routeReviewerSpec(issue, config, route, reviewer) {
|
|
@@ -7736,12 +8077,21 @@ function taskForDispatch(issue, route, role) {
|
|
|
7736
8077
|
issue.description,
|
|
7737
8078
|
].join('\n\n');
|
|
7738
8079
|
}
|
|
7739
|
-
const templateIssueFromRecord = (record, issue) =>
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
|
|
8080
|
+
const templateIssueFromRecord = (record, issue) => {
|
|
8081
|
+
const github = issue ? githubIssueSourceRef(issue) : undefined;
|
|
8082
|
+
const reporter = issue ? githubIssueAuthor(issue) : undefined;
|
|
8083
|
+
return {
|
|
8084
|
+
key: issue?.key ?? record.issue.key,
|
|
8085
|
+
title: issue?.title ?? record.issue.key,
|
|
8086
|
+
description: issue?.description ?? '',
|
|
8087
|
+
github: github
|
|
8088
|
+
? {
|
|
8089
|
+
...github,
|
|
8090
|
+
...(reporter ? { reporter } : {}),
|
|
8091
|
+
}
|
|
8092
|
+
: undefined,
|
|
8093
|
+
};
|
|
8094
|
+
};
|
|
7745
8095
|
const routeForSpec = (decision, spec) => {
|
|
7746
8096
|
const route = decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? decision.routes.find((candidate) => candidate.repo === spec.repo);
|
|
7747
8097
|
return {
|
|
@@ -8717,12 +9067,10 @@ const isAgentAlreadyExistsError = (error) => {
|
|
|
8717
9067
|
return /already exists/iu.test(message);
|
|
8718
9068
|
};
|
|
8719
9069
|
const defaultRestartPolicy = (spec) =>
|
|
8720
|
-
//
|
|
8721
|
-
//
|
|
8722
|
-
// exit should resume its session rather than drop the PR. The reviewer is
|
|
8723
|
-
// short-lived and keeps the fleet default.
|
|
9070
|
+
// Factory owns durable resume/respawn decisions. Broker-level retries race
|
|
9071
|
+
// that lifecycle and can re-register the same name before Factory resumes it.
|
|
8724
9072
|
spec.role === 'implementer' || spec.role === 'babysitter'
|
|
8725
|
-
? { maxRestarts:
|
|
9073
|
+
? { maxRestarts: 0 }
|
|
8726
9074
|
: spec.restartPolicy;
|
|
8727
9075
|
const slackPayloadTs = (threadId) => threadId.replace(/_/g, '.');
|
|
8728
9076
|
const slackChannelMessagesPrefix = (channelDir) => `/slack/channels/${channelDir}/messages/`;
|