@agent-relay/factory 0.1.20 → 0.1.21

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.
@@ -11,7 +11,7 @@ import { normalizeLogger, normalizeLogValue, setSafeErrorStack, stringifyLogValu
11
11
  import { isInFactoryScope } from '../safety/factory-scope.js';
12
12
  import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
13
13
  import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
14
- import { renderAgentTask } from '../dispatch/templates.js';
14
+ import { parseGithubHumanInputRequest, renderAgentTask, } from '../dispatch/templates.js';
15
15
  import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
16
16
  import { agentNameForRole, sanitizeAgentSlug } from '../triage/agent-names.js';
17
17
  import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
@@ -1483,8 +1483,18 @@ export class FactoryLoop {
1483
1483
  // A valid label resolution clears any prior failure notice so a later
1484
1484
  // regression posts a fresh, actionable comment instead of being deduped.
1485
1485
  this.#labelDispatchFailures.delete(issueStateKey(dispatchDecision.issue));
1486
- if (!dryRun && this.#fleet.placementLocality === 'remote') {
1487
- const lifecycleClaim = await this.#claimDispatchLifecycle(dispatchDecision, dryRun);
1486
+ // Full task rendering is part of the durable spawn specification. It must
1487
+ // happen before a remote lifecycle is first claimed so takeover cannot
1488
+ // recover a persisted minimal triage task after a crash in this gap.
1489
+ const durableRemoteDispatch = !dryRun && this.#fleet.placementLocality === 'remote';
1490
+ const lifecycleRunId = durableRemoteDispatch ? randomUUID() : undefined;
1491
+ if (lifecycleRunId) {
1492
+ dispatchDecision = decisionWithLifecycleBranches(dispatchDecision, lifecycleRunId);
1493
+ }
1494
+ dispatchDecision = await this.#withRenderedDispatchTasks(dispatchDecision, liveIssue);
1495
+ if (durableRemoteDispatch) {
1496
+ const lifecycleClaim = await this.#claimDispatchLifecycle(dispatchDecision, dryRun, lifecycleRunId);
1497
+ this.#consumePendingDispatchClarifications(dispatchDecision.issue);
1488
1498
  dispatchDecision = structuredClone(lifecycleClaim.lifecycle.decision);
1489
1499
  if (lifecycleClaim.lifecycle.phase === 'waiting-for-human') {
1490
1500
  return lifecycleClaim.lifecycle.result ?? { issue: dispatchDecision.issue, agents: [], dryRun };
@@ -1502,6 +1512,8 @@ export class FactoryLoop {
1502
1512
  return restored.result;
1503
1513
  }
1504
1514
  }
1515
+ if (!durableRemoteDispatch)
1516
+ this.#consumePendingDispatchClarifications(dispatchDecision.issue);
1505
1517
  await this.#recordDispatchAttempt(dispatchDecision.issue);
1506
1518
  const record = batch.start(dispatchDecision, dryRun);
1507
1519
  if (!record) {
@@ -1514,6 +1526,8 @@ export class FactoryLoop {
1514
1526
  return record.result;
1515
1527
  }
1516
1528
  await this.#saveDispatchLifecycle(record, 'dispatching');
1529
+ if (!dryRun)
1530
+ await this.#ensureGithubAgentQuestionWatch(record, liveIssue);
1517
1531
  const spawnedForReaperHandoff = [];
1518
1532
  try {
1519
1533
  const specs = dispatchSpecs(dispatchDecision);
@@ -1567,10 +1581,6 @@ export class FactoryLoop {
1567
1581
  this.#emit('dispatched', { issue: dispatchDecision.issue, result });
1568
1582
  if (!dryRun) {
1569
1583
  await this.#ensureSlackDispatchThread(record, result);
1570
- await this.#sendImplementerTask(record);
1571
- await this.#sendCriticalReviewerMessage(record);
1572
- await this.#injectPendingSlackClarification(record);
1573
- await this.#injectPendingGithubClarification(record);
1574
1584
  }
1575
1585
  return result;
1576
1586
  }
@@ -1713,10 +1723,10 @@ export class FactoryLoop {
1713
1723
  this.#dispatchLifecycleRenewTimer = undefined;
1714
1724
  }
1715
1725
  }
1716
- async #claimDispatchLifecycle(decision, dryRun) {
1726
+ async #claimDispatchLifecycle(decision, dryRun, preparedRunId) {
1717
1727
  const key = issueKey(decision.issue);
1718
1728
  const seed = {
1719
- runId: randomUUID(),
1729
+ runId: preparedRunId ?? randomUUID(),
1720
1730
  issue: { ...decision.issue },
1721
1731
  decision: structuredClone(decision),
1722
1732
  dryRun,
@@ -1725,7 +1735,8 @@ export class FactoryLoop {
1725
1735
  invocationIds: [],
1726
1736
  updatedAtMs: this.#clock.now(),
1727
1737
  };
1728
- seed.decision = decisionWithLifecycleBranches(seed.decision, seed.runId);
1738
+ if (!preparedRunId)
1739
+ seed.decision = decisionWithLifecycleBranches(seed.decision, seed.runId);
1729
1740
  const claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, seed, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
1730
1741
  if (!claim.acquired || !claim.lease) {
1731
1742
  const reason = isTerminalDispatchLifecycle(claim.lifecycle)
@@ -1893,7 +1904,6 @@ export class FactoryLoop {
1893
1904
  }
1894
1905
  }
1895
1906
  async #resumeDurableDispatch(record) {
1896
- const hadResult = Boolean(record.result);
1897
1907
  const agents = [];
1898
1908
  const specs = dispatchSpecs(record.decision);
1899
1909
  const plannedNames = new Set(specs.map((spec) => spec.name));
@@ -1912,6 +1922,7 @@ export class FactoryLoop {
1912
1922
  const issue = await this.#readIssue(record.issue.path);
1913
1923
  if (!issue)
1914
1924
  throw new Error(`Unable to recover durable dispatch ${record.issue.key}: issue is no longer readable`);
1925
+ await this.#ensureGithubAgentQuestionWatch(record, issue);
1915
1926
  if (isGithubIssue(issue)) {
1916
1927
  await this.#githubWriteback.setStatus(issue, 'in-progress');
1917
1928
  }
@@ -1928,10 +1939,6 @@ export class FactoryLoop {
1928
1939
  if (!await this.#saveDispatchLifecycle(record, 'running'))
1929
1940
  return;
1930
1941
  if (!record.dryRun) {
1931
- if (!hadResult) {
1932
- await this.#sendImplementerTask(record);
1933
- await this.#sendCriticalReviewerMessage(record);
1934
- }
1935
1942
  for (const tracked of record.agents.values()) {
1936
1943
  const owned = tracked.spec.ownedPullRequest;
1937
1944
  if (tracked.spec.role !== 'babysitter' || !owned)
@@ -2877,6 +2884,14 @@ export class FactoryLoop {
2877
2884
  });
2878
2885
  return;
2879
2886
  }
2887
+ // The issue-comment subscription and the fleet exit callback are separate
2888
+ // event streams. Reconcile comments that are already durable in the mount
2889
+ // before interpreting a clean exit as task completion, so an agent that
2890
+ // writes its question and immediately exits cannot race the sync callback.
2891
+ if (await this.#reconcileGithubQuestionBeforeAgentExit(record, name)) {
2892
+ this.#increment('githubQuestionExitsSuppressed');
2893
+ return;
2894
+ }
2880
2895
  const exiting = record.agents.get(name);
2881
2896
  if (this.#fleet.placementLocality === 'remote') {
2882
2897
  const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
@@ -3502,6 +3517,9 @@ export class FactoryLoop {
3502
3517
  return;
3503
3518
  }
3504
3519
  }
3520
+ // Compatibility for pre-durable prompts and for Linear-only tasks that
3521
+ // have no source GitHub issue to use as a durable record. New GitHub-source
3522
+ // tasks use the structured comment handled by #handleGithubIssueComment.
3505
3523
  const question = parseAgentQuestion(message);
3506
3524
  if (!question || !isFactoryQuestionTarget(message.target)) {
3507
3525
  return;
@@ -3694,6 +3712,9 @@ export class FactoryLoop {
3694
3712
  return true;
3695
3713
  }
3696
3714
  }
3715
+ if (!claimed.threadId) {
3716
+ return await this.#deliverClarificationQuestionToGithub(key, claimed, 'no Slack dispatch thread exists');
3717
+ }
3697
3718
  if (await this.#shouldSkipSlackWriteback('agent-question')) {
3698
3719
  return await this.#deliverClarificationQuestionToGithub(key, claimed, `Slack writeback is degraded${this.#slackDegradedReason ? `: ${this.#slackDegradedReason}` : ''}`);
3699
3720
  }
@@ -3832,6 +3853,7 @@ export class FactoryLoop {
3832
3853
  decision: structuredClone(record.decision),
3833
3854
  dryRun: record.dryRun,
3834
3855
  threadId,
3856
+ questionSource: 'slack',
3835
3857
  askerName: question.agentName,
3836
3858
  question: question.question,
3837
3859
  askedAtMs: this.#clock.now(),
@@ -3851,6 +3873,66 @@ export class FactoryLoop {
3851
3873
  this.#scheduleClarificationSweep(CLARIFICATION_STALE_WARN_MS);
3852
3874
  return waiting;
3853
3875
  }
3876
+ async #reserveGithubHumanClarification(record, question) {
3877
+ const key = issueKey(record.issue);
3878
+ const agents = [...record.agents].map(([name, tracked]) => ({
3879
+ name,
3880
+ tracked: structuredClone(tracked),
3881
+ }));
3882
+ if (agents.length === 0) {
3883
+ this.#increment('agentQuestionReleaseSkippedNoAgents');
3884
+ return false;
3885
+ }
3886
+ const nowMs = this.#clock.now();
3887
+ const waiting = {
3888
+ issue: { ...record.issue },
3889
+ decision: structuredClone(record.decision),
3890
+ dryRun: record.dryRun,
3891
+ threadId: await this.#state.getSlackThread(this.#workspaceId, key),
3892
+ questionSource: 'github',
3893
+ askerName: question.agentName,
3894
+ question: question.question,
3895
+ askedAtMs: nowMs,
3896
+ questionPostedAtMs: nowMs,
3897
+ agents,
3898
+ };
3899
+ if (!await this.#state.reserveWaitingClarification(this.#workspaceId, key, waiting)) {
3900
+ this.#increment('agentQuestionClarificationAlreadyReserved');
3901
+ this.#logger.info?.('[factory] ignored a second GitHub agent question while clarification is already reserved', {
3902
+ issue: record.issue.key,
3903
+ asker: question.agentName,
3904
+ });
3905
+ return false;
3906
+ }
3907
+ this.#scheduleClarificationSweep(CLARIFICATION_STALE_WARN_MS);
3908
+ return waiting;
3909
+ }
3910
+ async #mirrorGithubAgentQuestionToSlack(record, question) {
3911
+ if (!this.#slack || !this.#config.slack)
3912
+ return;
3913
+ if (await this.#shouldSkipSlackWriteback('agent-question-mirror')) {
3914
+ this.#increment('agentQuestionSlackMirrorsSkippedDegraded');
3915
+ return;
3916
+ }
3917
+ const threadId = await this.#state.getSlackThread(this.#workspaceId, issueKey(record.issue));
3918
+ if (!threadId) {
3919
+ this.#increment('agentQuestionSlackMirrorsSkippedMissingThread');
3920
+ return;
3921
+ }
3922
+ try {
3923
+ await this.#slack.reply(threadId, agentQuestionSlackText(record.issue, question, this.#config.slack.stakeholderUserIds));
3924
+ this.#increment('agentQuestionsMirroredToSlack');
3925
+ this.#recordSlackWritebackSuccess('agent-question-mirror');
3926
+ }
3927
+ catch (error) {
3928
+ this.#markSlackWritebackFailure('agent-question-mirror', error);
3929
+ this.#increment('agentQuestionSlackMirrorFailures');
3930
+ this.#logger.warn?.('[factory] optional Slack question mirror failed', {
3931
+ issue: record.issue.key,
3932
+ error: describeError(error).errorMessage,
3933
+ });
3934
+ }
3935
+ }
3854
3936
  async #parkForHumanClarification(record, waiting) {
3855
3937
  if (this.#stopping)
3856
3938
  return;
@@ -4087,6 +4169,52 @@ export class FactoryLoop {
4087
4169
  await this.#watchGithubIssueComments(watch);
4088
4170
  return added;
4089
4171
  }
4172
+ async #ensureGithubAgentQuestionWatch(record, issue) {
4173
+ const source = githubIssueSourceRef(issue);
4174
+ if (!source)
4175
+ return;
4176
+ const key = githubIssueSourceKey(source);
4177
+ let watch = this.#githubIssueCommentWatchStates.get(key);
4178
+ if (!watch) {
4179
+ const persisted = await this.#state.listGithubIssueCommentWatches(this.#workspaceId);
4180
+ watch = persisted.find(([persistedKey]) => persistedKey === key)?.[1];
4181
+ }
4182
+ if (!watch) {
4183
+ const sinceCommentId = await this.#latestGithubIssueCommentId(source);
4184
+ watch = {
4185
+ issue: { ...record.issue },
4186
+ source,
4187
+ pending: [],
4188
+ detectAgentQuestions: true,
4189
+ sinceCommentId,
4190
+ lastSeenCommentId: sinceCommentId,
4191
+ processedCommentIds: [],
4192
+ };
4193
+ }
4194
+ else {
4195
+ watch.issue = { ...record.issue };
4196
+ watch.detectAgentQuestions = true;
4197
+ }
4198
+ if (this.#githubIssueCommentWatchers.has(key)) {
4199
+ const normalizedWatch = normalizeGithubIssueCommentWatch(watch);
4200
+ this.#githubIssueCommentWatchStates.set(key, normalizedWatch);
4201
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, normalizedWatch);
4202
+ return;
4203
+ }
4204
+ await this.#watchGithubIssueComments(watch);
4205
+ if (!this.#githubIssueCommentWatchStates.has(key)) {
4206
+ throw new Error(`Unable to watch source GitHub issue comments for ${record.issue.key}`);
4207
+ }
4208
+ }
4209
+ async #reconcileGithubQuestionBeforeAgentExit(record, agentName) {
4210
+ const watchEntry = [...this.#githubIssueCommentWatchStates].find(([, watch]) => (watch.detectAgentQuestions === true
4211
+ && (watch.issue.uuid === record.issue.uuid || watch.issue.path === record.issue.path)));
4212
+ if (!watchEntry)
4213
+ return false;
4214
+ await this.#replayGithubIssueComments(watchEntry[0]);
4215
+ const waiting = await this.#state.getWaitingClarification(this.#workspaceId, issueKey(record.issue));
4216
+ return waiting?.questionSource === 'github' && waiting.agents.some(({ name }) => name === agentName);
4217
+ }
4090
4218
  async #watchGithubIssueComments(watch) {
4091
4219
  watch = normalizeGithubIssueCommentWatch(watch);
4092
4220
  const key = githubIssueSourceKey(watch.source);
@@ -4172,7 +4300,7 @@ export class FactoryLoop {
4172
4300
  if (!watch)
4173
4301
  return;
4174
4302
  watch.pending = watch.pending.filter((pending) => pending.correlationId !== correlationId);
4175
- if (watch.pending.length === 0) {
4303
+ if (watch.pending.length === 0 && !watch.detectAgentQuestions) {
4176
4304
  await this.#stopGithubIssueCommentWatcher(source);
4177
4305
  return;
4178
4306
  }
@@ -4180,7 +4308,7 @@ export class FactoryLoop {
4180
4308
  }
4181
4309
  async #rearmGithubIssueCommentWatchers() {
4182
4310
  for (const [, watch] of await this.#state.listGithubIssueCommentWatches(this.#workspaceId)) {
4183
- if (watch.pending.length === 0)
4311
+ if (watch.pending.length === 0 && !watch.detectAgentQuestions)
4184
4312
  continue;
4185
4313
  try {
4186
4314
  await this.#watchGithubIssueComments(watch);
@@ -4288,10 +4416,24 @@ export class FactoryLoop {
4288
4416
  this.#increment('githubIssueCommentDuplicatesSuppressed');
4289
4417
  return;
4290
4418
  }
4419
+ const request = watch.detectAgentQuestions
4420
+ ? parseGithubHumanInputRequest(comment.body)
4421
+ : undefined;
4422
+ if (request) {
4423
+ await this.#handleGithubAgentQuestionComment(watch, comment, request);
4424
+ processedCommentIds.add(normalizedCommentId);
4425
+ watch.processedCommentIds = [...processedCommentIds];
4426
+ watch.lastSeenCommentId = String(Math.max(commentId, githubCommentNumericId(watch.lastSeenCommentId)));
4427
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
4428
+ return;
4429
+ }
4291
4430
  const reply = githubCorrelatedReply(comment.body);
4292
4431
  const pending = reply
4293
4432
  ? watch.pending.find((candidate) => candidate.correlationId === reply.correlationId)
4294
- : undefined;
4433
+ : watch.pending.find((candidate) => candidate.kind === 'agent-question' &&
4434
+ candidate.replyAfterCommentId !== undefined &&
4435
+ commentId > githubCommentNumericId(candidate.replyAfterCommentId));
4436
+ const answerText = reply?.text ?? comment.body.trim();
4295
4437
  let resolved = false;
4296
4438
  let discardClaimedPending = false;
4297
4439
  if (pending?.claimedByCommentId === normalizedCommentId) {
@@ -4306,10 +4448,14 @@ export class FactoryLoop {
4306
4448
  correlationId: pending.correlationId,
4307
4449
  });
4308
4450
  }
4309
- else if (pending && comment.author?.toLowerCase() === pending.authorizedAuthor.toLowerCase()) {
4451
+ else if (pending &&
4452
+ answerText &&
4453
+ typeof pending.authorizedAuthor === 'string' &&
4454
+ pending.authorizedAuthor.length > 0 &&
4455
+ comment.author?.toLowerCase() === pending.authorizedAuthor.toLowerCase()) {
4310
4456
  pending.claimedByCommentId = normalizedCommentId;
4311
4457
  await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
4312
- resolved = await this.#routeGithubAnswerToImplementers(watch, pending, comment, reply.text);
4458
+ resolved = await this.#routeGithubAnswerToImplementers(watch, pending, comment, answerText);
4313
4459
  if (!resolved) {
4314
4460
  delete pending.claimedByCommentId;
4315
4461
  }
@@ -4341,13 +4487,88 @@ export class FactoryLoop {
4341
4487
  if ((resolved || discardClaimedPending) && pending) {
4342
4488
  watch.pending = watch.pending.filter((candidate) => candidate.correlationId !== pending.correlationId);
4343
4489
  }
4344
- if (watch.pending.length === 0) {
4490
+ if (watch.pending.length === 0 && !watch.detectAgentQuestions) {
4345
4491
  await this.#stopGithubIssueCommentWatcher(watch.source);
4346
4492
  }
4347
4493
  else {
4348
4494
  await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
4349
4495
  }
4350
4496
  }
4497
+ async #handleGithubAgentQuestionComment(watch, comment, request) {
4498
+ const record = (await this.#batch()).getIssue(watch.issue);
4499
+ if (!record || record.dryRun || request.issueKey.toLowerCase() !== record.issue.key.toLowerCase()) {
4500
+ this.#increment('githubAgentQuestionsIgnoredNoInFlight');
4501
+ return;
4502
+ }
4503
+ const tracked = record.agents.get(request.agentName);
4504
+ if (!tracked || !['implementer', 'reviewer', 'babysitter'].includes(tracked.spec.role)) {
4505
+ this.#increment('githubAgentQuestionsIgnoredUnknownAgent');
4506
+ return;
4507
+ }
4508
+ // Agents write through the connected GitHub App, whose comments are
4509
+ // provider-authored bot records. Never let an arbitrary repository
4510
+ // commenter forge the predictable structured fields and park a live team.
4511
+ if (!comment.isBot) {
4512
+ this.#increment('githubAgentQuestionsIgnoredUntrustedAuthor');
4513
+ this.#logger.info?.('[factory] ignored GitHub agent question from an untrusted commenter', {
4514
+ issue: watch.issue,
4515
+ commentId: comment.commentId,
4516
+ author: comment.author,
4517
+ });
4518
+ return;
4519
+ }
4520
+ const question = {
4521
+ agentName: request.agentName,
4522
+ issueKey: request.issueKey,
4523
+ question: request.question,
4524
+ eventId: `github:${watch.source.owner}/${watch.source.repo}#${watch.source.number}:${comment.commentId}`,
4525
+ };
4526
+ const correlationId = githubEscalationCorrelationId('agent-question', record.issue, `${comment.commentId}:${question.question}`);
4527
+ const issue = await this.#readIssue(record.issue.path);
4528
+ const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
4529
+ if (!authorizedAuthor) {
4530
+ this.#increment('githubAgentQuestionsIgnoredMissingAuthorizedAuthor');
4531
+ this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'source GitHub issue has no identifiable reporter authorized to answer the durable question');
4532
+ return;
4533
+ }
4534
+ this.#clarificationIntents.set(question.agentName, (this.#clarificationIntents.get(question.agentName) ?? 0) + 1);
4535
+ let durableClarificationOwnsExit = false;
4536
+ try {
4537
+ const dedupeKey = agentQuestionDedupeKey(record.issue, question);
4538
+ if (!await this.#state.claimAgentQuestion(this.#workspaceId, dedupeKey)) {
4539
+ this.#increment('agentQuestionDuplicatesSuppressed');
4540
+ return;
4541
+ }
4542
+ const reserved = await this.#reserveGithubHumanClarification(record, question);
4543
+ if (reserved === false) {
4544
+ const existing = await this.#state.getWaitingClarification(this.#workspaceId, issueKey(record.issue));
4545
+ durableClarificationOwnsExit = Boolean(existing?.agents.some(({ name }) => name === question.agentName));
4546
+ return;
4547
+ }
4548
+ durableClarificationOwnsExit = true;
4549
+ if (!watch.pending.some((pending) => pending.correlationId === correlationId)) {
4550
+ watch.pending.push({
4551
+ correlationId,
4552
+ kind: 'agent-question',
4553
+ authorizedAuthor,
4554
+ replyAfterCommentId: comment.commentId,
4555
+ });
4556
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, githubIssueSourceKey(watch.source), watch);
4557
+ }
4558
+ await this.#parkForHumanClarification(record, reserved);
4559
+ this.#increment('githubAgentQuestionsDetected');
4560
+ void this.#mirrorGithubAgentQuestionToSlack(record, question);
4561
+ }
4562
+ finally {
4563
+ if (!durableClarificationOwnsExit) {
4564
+ const remaining = (this.#clarificationIntents.get(question.agentName) ?? 1) - 1;
4565
+ if (remaining > 0)
4566
+ this.#clarificationIntents.set(question.agentName, remaining);
4567
+ else
4568
+ this.#clarificationIntents.delete(question.agentName);
4569
+ }
4570
+ }
4571
+ }
4351
4572
  async #routeGithubAnswerToImplementers(watch, pending, comment, text) {
4352
4573
  if (pending.kind === 'triage' && pending.decision) {
4353
4574
  return await this.#handleTriageEscalationGithubAnswer(escalationWatchRecord(pending.decision), text);
@@ -4371,26 +4592,8 @@ export class FactoryLoop {
4371
4592
  await this.#wakeWaitingClarification(clarificationKey, claimed);
4372
4593
  return true;
4373
4594
  }
4374
- const liveRecord = (await this.#batch()).getIssue(watch.issue);
4375
- if (!liveRecord || liveRecord.dryRun) {
4376
- this.#increment('githubAnswersIgnoredNoInFlight');
4377
- return false;
4378
- }
4379
- if (!this.#fleet.sendInput)
4380
- return false;
4381
- const recipients = [...liveRecord.agents.values()]
4382
- .filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
4383
- .map((agent) => agent.result?.name ?? agent.spec.name)
4384
- .filter((name) => Boolean(name));
4385
- if (recipients.length === 0) {
4386
- this.#increment('githubAnswersIgnoredNoImplementer');
4387
- return false;
4388
- }
4389
- for (const recipient of new Set(recipients)) {
4390
- await this.#fleet.sendInput(recipient, githubReplyEvent(liveRecord.issue, text, comment.author));
4391
- this.#increment('githubAnswersInjected');
4392
- }
4393
- return true;
4595
+ this.#increment('githubAnswersIgnoredNoDurableClarification');
4596
+ return false;
4394
4597
  }
4395
4598
  async #handleTriageEscalationGithubAnswer(record, text) {
4396
4599
  const issue = await this.#readIssue(record.issue.path);
@@ -4460,57 +4663,46 @@ export class FactoryLoop {
4460
4663
  latencyMs,
4461
4664
  });
4462
4665
  }
4463
- async #sendCriticalReviewerMessage(record) {
4464
- if (!this.#fleet.waitForInjected) {
4465
- return;
4466
- }
4467
- const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer');
4468
- if (!reviewer) {
4469
- return;
4470
- }
4471
- const input = {
4472
- to: reviewer.result?.name ?? reviewer.spec.name,
4473
- text: `Review is queued for ${record.issue.key}. Watch implementer PR handoff and report readiness.`,
4474
- from: 'factory',
4475
- data: { issue: record.issue },
4666
+ async #withRenderedDispatchTasks(decision, issue) {
4667
+ if (decision.scope === 'workflow')
4668
+ return decision;
4669
+ const key = issueKey(decision.issue);
4670
+ const slackClarification = this.#pendingSlackClarifications.get(key);
4671
+ const githubClarification = this.#pendingGithubClarifications.get(key);
4672
+ const templateIssue = templateIssueFromRecord({ issue: decision.issue }, issue);
4673
+ templateIssue.description = [
4674
+ templateIssue.description,
4675
+ slackClarification ? `Human clarification from Slack:\n${slackClarification}` : undefined,
4676
+ githubClarification ? `Human clarification from GitHub:\n${githubClarification}` : undefined,
4677
+ ].filter((part) => Boolean(part)).join('\n\n');
4678
+ const implementerNames = decision.implementers.map((implementer) => implementer.name);
4679
+ const reviewerName = decision.reviewer.name;
4680
+ const integrationInstructions = await this.#resolveIntegrationInstructions();
4681
+ const render = (spec) => ({
4682
+ ...spec,
4683
+ task: renderAgentTask({
4684
+ issue: templateIssue,
4685
+ route: routeForSpec(decision, spec),
4686
+ role: spec.role,
4687
+ config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
4688
+ reviewerName,
4689
+ implementerNames,
4690
+ integrationsMountRoot: this.#integrationsMountRoot(),
4691
+ integrationInstructions,
4692
+ branchName: spec.branch,
4693
+ agentName: spec.name,
4694
+ }),
4695
+ });
4696
+ return {
4697
+ ...decision,
4698
+ implementers: decision.implementers.map(render),
4699
+ reviewer: render(decision.reviewer),
4476
4700
  };
4477
- const ack = await this.#waitForInjectedAndSubmit(input);
4478
- await this.#state.recordCritical(this.#workspaceId, ack.eventId, { issue: record.issue, input });
4479
4701
  }
4480
- async #sendImplementerTask(record) {
4481
- if (!this.#fleet.waitForInjected) {
4482
- return;
4483
- }
4484
- const implementers = [...record.agents.values()].filter((agent) => agent.spec.role === 'implementer');
4485
- if (implementers.length === 0) {
4486
- return;
4487
- }
4488
- const issue = await this.#readIssue(record.issue.path);
4489
- const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer');
4490
- const reviewerName = reviewer?.result?.name ?? reviewer?.spec.name ?? 'reviewer';
4491
- const implementerNames = implementers.map((agent) => agent.result?.name ?? agent.spec.name);
4492
- const integrationInstructions = await this.#resolveIntegrationInstructions();
4493
- for (const implementer of implementers) {
4494
- const input = {
4495
- to: implementer.result?.name ?? implementer.spec.name,
4496
- text: renderAgentTask({
4497
- issue: templateIssueFromRecord(record, issue),
4498
- route: routeForImplementer(record, implementer.spec),
4499
- role: 'implementer',
4500
- config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
4501
- reviewerName,
4502
- implementerNames,
4503
- slackDispatchThread: await this.#slackDispatchThreadFor(record),
4504
- integrationsMountRoot: this.#integrationsMountRoot(),
4505
- integrationInstructions,
4506
- branchName: implementer.spec.branch,
4507
- }),
4508
- from: 'factory',
4509
- data: { issue: record.issue },
4510
- };
4511
- const ack = await this.#waitForInjectedAndSubmit(input);
4512
- await this.#state.recordCritical(this.#workspaceId, ack.eventId, { issue: record.issue, input });
4513
- }
4702
+ #consumePendingDispatchClarifications(issue) {
4703
+ const key = issueKey(issue);
4704
+ this.#pendingSlackClarifications.delete(key);
4705
+ this.#pendingGithubClarifications.delete(key);
4514
4706
  }
4515
4707
  async #waitForInjectedAndSubmit(input) {
4516
4708
  if (!this.#fleet.waitForInjected) {
@@ -5298,6 +5490,7 @@ export class FactoryLoop {
5298
5490
  slackDispatchThread: await this.#slackDispatchThreadFor(record),
5299
5491
  integrationsMountRoot: this.#integrationsMountRoot(),
5300
5492
  integrationInstructions,
5493
+ agentName: spec.name,
5301
5494
  });
5302
5495
  const spawned = await this.#spawnAgent(record, {
5303
5496
  ...spec,
@@ -6170,6 +6363,8 @@ export class FactoryLoop {
6170
6363
  }
6171
6364
  await this.#sweepWaitingClarifications();
6172
6365
  for (const [, waiting] of await this.#state.listWaitingClarifications(this.#workspaceId)) {
6366
+ if (!waiting.threadId)
6367
+ continue;
6173
6368
  const key = issueKey(waiting.issue);
6174
6369
  // stop() clears ephemeral thread lookup state, but the durable
6175
6370
  // clarification record owns the canonical thread while parked. Restore
@@ -6240,6 +6435,8 @@ export class FactoryLoop {
6240
6435
  continue;
6241
6436
  if (waiting.reply || waiting.escalatedAtMs)
6242
6437
  continue;
6438
+ if (!waiting.threadId)
6439
+ continue;
6243
6440
  const waitingAgeMs = this.#clock.now() - waiting.askedAtMs;
6244
6441
  const untilEscalationMs = CLARIFICATION_STALE_WARN_MS - waitingAgeMs;
6245
6442
  if (untilEscalationMs > 0) {
@@ -6259,7 +6456,7 @@ export class FactoryLoop {
6259
6456
  waitingAgeMs,
6260
6457
  });
6261
6458
  try {
6262
- await this.#slack.reply(escalated.threadId, clarificationStaleSlackText(escalated, this.#config.slack.stakeholderUserIds));
6459
+ await this.#slack.reply(waiting.threadId, clarificationStaleSlackText(escalated, this.#config.slack.stakeholderUserIds));
6263
6460
  const completed = await this.#state.completeClarificationEscalation(this.#workspaceId, key, this.#clarificationWakeOwner, this.#clock.now());
6264
6461
  if (!completed) {
6265
6462
  this.#increment('clarificationEscalationOwnershipLost');
@@ -6333,6 +6530,10 @@ export class FactoryLoop {
6333
6530
  }
6334
6531
  const clarificationKey = issueKey(record.issue);
6335
6532
  const waiting = await this.#state.getWaitingClarification(this.#workspaceId, clarificationKey);
6533
+ if (waiting?.questionSource === 'github' && waiting.threadId === reply.threadTs) {
6534
+ this.#increment('slackClarificationRepliesIgnoredGithubRecord');
6535
+ return;
6536
+ }
6336
6537
  if (waiting?.threadId === reply.threadTs) {
6337
6538
  const claimed = await this.#state.claimClarificationReply(this.#workspaceId, clarificationKey, {
6338
6539
  id: `${reply.threadTs}:${reply.messageTs}`,
@@ -6525,30 +6726,6 @@ export class FactoryLoop {
6525
6726
  this.#assertClarificationWakeRunning();
6526
6727
  await renewLease();
6527
6728
  }
6528
- const event = reply.source === 'github'
6529
- ? githubReplyEvent(waiting.issue, reply.text, reply.author)
6530
- : slackReplyEvent(waiting.issue, reply.text);
6531
- for (const [name] of resumed) {
6532
- if (waiting.wake?.injectedAgents.includes(name))
6533
- continue;
6534
- this.#assertClarificationWakeRunning();
6535
- await renewLease();
6536
- if (this.#fleet.sendInput) {
6537
- await this.#fleet.sendInput(name, event);
6538
- }
6539
- else {
6540
- await this.#fleet.sendMessage({
6541
- to: name,
6542
- from: 'factory',
6543
- text: event.replace(/\r$/u, ''),
6544
- });
6545
- }
6546
- this.#assertClarificationWakeRunning();
6547
- const marked = await this.#state.markClarificationAgentInjected(this.#workspaceId, key, this.#clarificationWakeOwner, name);
6548
- if (!marked)
6549
- throw new ClarificationWakeLeaseLostError('clarification wake lease lost after injection');
6550
- this.#increment('clarificationReplyInjections');
6551
- }
6552
6729
  await renewLease();
6553
6730
  await this.#writeInFlightRegistry();
6554
6731
  await this.#saveDispatchLifecycle(record, 'running');
@@ -6557,7 +6734,7 @@ export class FactoryLoop {
6557
6734
  if (!completed)
6558
6735
  throw new ClarificationWakeLeaseLostError('clarification wake completion lost ownership');
6559
6736
  this.#increment('clarificationTeamsWoken');
6560
- this.#logger.info?.('[factory] woke team after human clarification', {
6737
+ this.#logger.info?.('[factory] restarted team after human clarification', {
6561
6738
  issue: waiting.issue.key,
6562
6739
  agents: resumed.map(([name]) => name),
6563
6740
  coldStarts: resumed.filter(([, tracked]) => !tracked.sessionRef).length,
@@ -6667,6 +6844,7 @@ export class FactoryLoop {
6667
6844
  return role === 'agentImplementing';
6668
6845
  }
6669
6846
  async #resumeOrColdStartClarificationAgent(name, tracked, waiting) {
6847
+ const task = clarificationResumeTask(tracked.spec.task, waiting);
6670
6848
  if (tracked.sessionRef) {
6671
6849
  try {
6672
6850
  const resumed = await this.#fleet.resume({
@@ -6676,6 +6854,7 @@ export class FactoryLoop {
6676
6854
  capability: tracked.spec.capability,
6677
6855
  repo: tracked.spec.repo,
6678
6856
  clonePath: tracked.spec.clonePath,
6857
+ task,
6679
6858
  });
6680
6859
  return {
6681
6860
  ...resumed,
@@ -6698,19 +6877,11 @@ export class FactoryLoop {
6698
6877
  this.#increment('clarificationResumeFallbacks');
6699
6878
  }
6700
6879
  this.#assertClarificationWakeRunning();
6701
- const reply = waiting.reply?.text ?? '';
6702
6880
  return await this.#fleet.spawn({
6703
6881
  name,
6704
6882
  capability: tracked.spec.capability,
6705
6883
  node: tracked.result?.node ?? tracked.spec.node ?? 'self',
6706
- task: [
6707
- tracked.spec.task,
6708
- '',
6709
- 'Factory released this team while waiting for human input and could not restore the prior harness session.',
6710
- `The blocked question was: ${waiting.question}`,
6711
- `The human replied: ${reply}`,
6712
- 'Re-hydrate from the issue, branch, worktree, and any open PR, then continue the task. Do not repeat completed work.',
6713
- ].join('\n'),
6884
+ task,
6714
6885
  workflow: tracked.spec.workflow,
6715
6886
  inputs: tracked.spec.inputs,
6716
6887
  model: tracked.spec.model,
@@ -6800,61 +6971,7 @@ export class FactoryLoop {
6800
6971
  this.#emit('issue-queued', { issue: decision.issue });
6801
6972
  }
6802
6973
  }
6803
- async #injectPendingSlackClarification(record) {
6804
- const key = issueKey(record.issue);
6805
- const text = this.#pendingSlackClarifications.get(key);
6806
- if (!text || !this.#fleet.sendInput) {
6807
- return;
6808
- }
6809
- const recipients = [...record.agents.values()]
6810
- .filter((agent) => agent.spec.role === 'implementer' ||
6811
- agent.spec.role === 'workflow' ||
6812
- agent.spec.role === 'babysitter')
6813
- .map((agent) => agent.result?.name ?? agent.spec.name)
6814
- .filter((name) => Boolean(name));
6815
- for (const recipient of new Set(recipients)) {
6816
- try {
6817
- await this.#injectSlackReplyEvent(recipient, record.issue, text);
6818
- this.#increment('slackTriageAnswersInjectedToAgents');
6819
- }
6820
- catch (error) {
6821
- this.#logger.warn?.('[factory] failed to inject Slack triage clarification into agent', {
6822
- issue: record.issue.key,
6823
- recipient,
6824
- error,
6825
- });
6826
- }
6827
- }
6828
- this.#pendingSlackClarifications.delete(key);
6829
- }
6830
- async #injectPendingGithubClarification(record) {
6831
- const key = issueKey(record.issue);
6832
- const text = this.#pendingGithubClarifications.get(key);
6833
- if (!text || !this.#fleet.sendInput) {
6834
- return;
6835
- }
6836
- const recipients = [...record.agents.values()]
6837
- .filter((agent) => agent.spec.role === 'implementer' ||
6838
- agent.spec.role === 'workflow' ||
6839
- agent.spec.role === 'babysitter')
6840
- .map((agent) => agent.result?.name ?? agent.spec.name)
6841
- .filter((name) => Boolean(name));
6842
- for (const recipient of new Set(recipients)) {
6843
- try {
6844
- await this.#fleet.sendInput(recipient, githubReplyEvent(record.issue, text));
6845
- this.#increment('githubTriageAnswersInjectedToAgents');
6846
- }
6847
- catch (error) {
6848
- this.#logger.warn?.('[factory] failed to inject GitHub triage clarification into agent', {
6849
- issue: record.issue.key,
6850
- recipient,
6851
- error,
6852
- });
6853
- }
6854
- }
6855
- this.#pendingGithubClarifications.delete(key);
6856
- }
6857
- // Inject the human's Slack reply into the agent framed as the
6974
+ // Route ordinary Slack conversation into a live agent framed as the
6858
6975
  // <integration-event> the spawn prompt tells it to expect (not an ambiguous
6859
6976
  // "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
6860
6977
  // event. (A broker confirmed-delivery path via waitForInjected is a possible
@@ -7611,9 +7728,10 @@ const templateIssueFromRecord = (record, issue) => ({
7611
7728
  key: issue?.key ?? record.issue.key,
7612
7729
  title: issue?.title ?? record.issue.key,
7613
7730
  description: issue?.description ?? '',
7731
+ github: issue ? githubIssueSourceRef(issue) : undefined,
7614
7732
  });
7615
- const routeForImplementer = (record, spec) => {
7616
- const route = record.decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? record.decision.routes.find((candidate) => candidate.repo === spec.repo);
7733
+ const routeForSpec = (decision, spec) => {
7734
+ const route = decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? decision.routes.find((candidate) => candidate.repo === spec.repo);
7617
7735
  return {
7618
7736
  repo: spec.repo,
7619
7737
  clonePath: spec.clonePath,
@@ -8808,7 +8926,17 @@ const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text
8808
8926
  // told (at spawn) to expect — a recognizable injected event, not an ambiguous
8809
8927
  // keystroke. Trailing CR submits it to the agent's PTY.
8810
8928
  const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
8811
- const githubReplyEvent = (issue, text, author) => `<integration-event source="github" issue="${issue.key}">\nHuman reply${author ? ` from @${author}` : ''} on the GitHub issue:\n${text}\n</integration-event>\r`;
8929
+ const clarificationResumeTask = (baseTask, waiting) => {
8930
+ const reply = waiting.reply;
8931
+ return [
8932
+ baseTask,
8933
+ '',
8934
+ 'Factory released this team while waiting for human input and is now starting a fresh task after the durable issue-comment response.',
8935
+ `The blocked question was: ${waiting.question}`,
8936
+ `The human answered${reply?.author ? ` as @${reply.author}` : ''}: ${reply?.text ?? ''}`,
8937
+ 'Re-hydrate from the issue, branch, worktree, and any open PR, then continue the task. Do not repeat completed work.',
8938
+ ].join('\n');
8939
+ };
8812
8940
  const isFactoryQuestionTarget = (target) => {
8813
8941
  const normalized = target.trim().replace(/^@/u, '').toLowerCase();
8814
8942
  return normalized === 'broker' || normalized === 'factory';