@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.
Files changed (40) hide show
  1. package/dist/cli/fleet.d.ts.map +1 -1
  2. package/dist/cli/fleet.js +2 -0
  3. package/dist/cli/fleet.js.map +1 -1
  4. package/dist/dispatch/templates.d.ts +5 -1
  5. package/dist/dispatch/templates.d.ts.map +1 -1
  6. package/dist/dispatch/templates.js +27 -12
  7. package/dist/dispatch/templates.js.map +1 -1
  8. package/dist/fleet/internal-fleet-client.js +1 -1
  9. package/dist/fleet/internal-fleet-client.js.map +1 -1
  10. package/dist/git/agent-worktree.d.ts +18 -0
  11. package/dist/git/agent-worktree.d.ts.map +1 -0
  12. package/dist/git/agent-worktree.js +172 -0
  13. package/dist/git/agent-worktree.js.map +1 -0
  14. package/dist/mount/local-mount-preflight.js +6 -8
  15. package/dist/mount/local-mount-preflight.js.map +1 -1
  16. package/dist/mount/relayfile-cloud-mount-client.d.ts +1 -0
  17. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  18. package/dist/mount/relayfile-cloud-mount-client.js +18 -2
  19. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  20. package/dist/orchestrator/factory.d.ts.map +1 -1
  21. package/dist/orchestrator/factory.js +408 -60
  22. package/dist/orchestrator/factory.js.map +1 -1
  23. package/dist/ports/fleet.d.ts +2 -0
  24. package/dist/ports/fleet.d.ts.map +1 -1
  25. package/dist/ports/index.d.ts +1 -0
  26. package/dist/ports/index.d.ts.map +1 -1
  27. package/dist/ports/mount.d.ts +2 -0
  28. package/dist/ports/mount.d.ts.map +1 -1
  29. package/dist/ports/state.d.ts +3 -0
  30. package/dist/ports/state.d.ts.map +1 -1
  31. package/dist/ports/worktree.d.ts +12 -0
  32. package/dist/ports/worktree.d.ts.map +1 -0
  33. package/dist/ports/worktree.js +2 -0
  34. package/dist/ports/worktree.js.map +1 -0
  35. package/dist/types.d.ts +3 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/writeback/slack.d.ts.map +1 -1
  38. package/dist/writeback/slack.js +15 -8
  39. package/dist/writeback/slack.js.map +1 -1
  40. 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('[factory] Linear ready issue read progress', startedAtMs, lastReadyReadProgressAtMs, { read: readyIssueReads, total: paths.length, path });
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
- const lifecycleRunId = durableRemoteDispatch ? randomUUID() : undefined;
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
- await this.#persistDispatchFailureReaperHandoff(record, spawnedForReaperHandoff);
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 lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
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.#scheduleDispatchLifecycleRetry(record);
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.#state.getSlackThread(this.#workspaceId, key);
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.#state.getSlackThread(this.#workspaceId, key);
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.#state.getSlackThread(this.#workspaceId, key),
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.#state.getSlackThread(this.#workspaceId, issueKey(record.issue));
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 spec = babysitterSpec(issue, this.#config, route);
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
- this.#scheduleDispatchLifecycleRetry(record);
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.#state.getSlackThread(this.#workspaceId, key);
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.#state.getSlackThread(this.#workspaceId, key);
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
- `Factory needs clarification before dispatching ${decision.issue.key}.`,
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.#state.getSlackThread(this.#workspaceId, key);
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.#state.getSlackThread(this.#workspaceId, issueKey(record.issue));
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 withBranch = (spec) => {
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
- if (spec.role !== 'implementer')
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 ? { workflow: withBranch(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
- key: issue?.key ?? record.issue.key,
7741
- title: issue?.title ?? record.issue.key,
7742
- description: issue?.description ?? '',
7743
- github: issue ? githubIssueSourceRef(issue) : undefined,
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
- // Implementers and babysitters are both long-running and resumable — the
8721
- // babysitter shepherds an open PR over many CI/review cycles, so an abnormal
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: 3, strategy: 'resume' }
9073
+ ? { maxRestarts: 0 }
8726
9074
  : spec.restartPolicy;
8727
9075
  const slackPayloadTs = (threadId) => threadId.replace(/_/g, '.');
8728
9076
  const slackChannelMessagesPrefix = (channelDir) => `/slack/channels/${channelDir}/messages/`;