@agent-relay/factory 0.1.38 → 0.1.39

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