@agent-relay/factory 0.1.67 → 0.1.69

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 (63) hide show
  1. package/dist/cli/fleet.d.ts.map +1 -1
  2. package/dist/cli/fleet.js +14 -2
  3. package/dist/cli/fleet.js.map +1 -1
  4. package/dist/config/schema.d.ts +87 -0
  5. package/dist/config/schema.d.ts.map +1 -1
  6. package/dist/config/schema.js +59 -0
  7. package/dist/config/schema.js.map +1 -1
  8. package/dist/dispatch/work-unit-identity.d.ts +18 -1
  9. package/dist/dispatch/work-unit-identity.d.ts.map +1 -1
  10. package/dist/dispatch/work-unit-identity.js +68 -0
  11. package/dist/dispatch/work-unit-identity.js.map +1 -1
  12. package/dist/fleet/create-fleet.d.ts +6 -0
  13. package/dist/fleet/create-fleet.d.ts.map +1 -1
  14. package/dist/fleet/create-fleet.js +1 -0
  15. package/dist/fleet/create-fleet.js.map +1 -1
  16. package/dist/fleet/relay-fleet-client.d.ts +23 -0
  17. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  18. package/dist/fleet/relay-fleet-client.js +206 -5
  19. package/dist/fleet/relay-fleet-client.js.map +1 -1
  20. package/dist/issue-key-match.d.ts +47 -0
  21. package/dist/issue-key-match.d.ts.map +1 -1
  22. package/dist/issue-key-match.js +174 -0
  23. package/dist/issue-key-match.js.map +1 -1
  24. package/dist/mount/relayfile-integration-preflight.js +7 -0
  25. package/dist/mount/relayfile-integration-preflight.js.map +1 -1
  26. package/dist/orchestrator/atomic-json-file.d.ts +44 -0
  27. package/dist/orchestrator/atomic-json-file.d.ts.map +1 -0
  28. package/dist/orchestrator/atomic-json-file.js +87 -0
  29. package/dist/orchestrator/atomic-json-file.js.map +1 -0
  30. package/dist/orchestrator/batch-tracker.d.ts +18 -0
  31. package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
  32. package/dist/orchestrator/batch-tracker.js +28 -13
  33. package/dist/orchestrator/batch-tracker.js.map +1 -1
  34. package/dist/orchestrator/factory.d.ts.map +1 -1
  35. package/dist/orchestrator/factory.js +410 -91
  36. package/dist/orchestrator/factory.js.map +1 -1
  37. package/dist/orchestrator/public-health.d.ts.map +1 -1
  38. package/dist/orchestrator/public-health.js +115 -13
  39. package/dist/orchestrator/public-health.js.map +1 -1
  40. package/dist/ports/state.d.ts +21 -2
  41. package/dist/ports/state.d.ts.map +1 -1
  42. package/dist/ports/state.js +18 -1
  43. package/dist/ports/state.js.map +1 -1
  44. package/dist/state/file-state-store.d.ts.map +1 -1
  45. package/dist/state/file-state-store.js +54 -11
  46. package/dist/state/file-state-store.js.map +1 -1
  47. package/dist/state/github-lifecycle-identity.d.ts +4 -4
  48. package/dist/state/github-lifecycle-identity.d.ts.map +1 -1
  49. package/dist/state/github-lifecycle-identity.js +4 -26
  50. package/dist/state/github-lifecycle-identity.js.map +1 -1
  51. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  52. package/dist/state/in-memory-state-store.js +40 -9
  53. package/dist/state/in-memory-state-store.js.map +1 -1
  54. package/dist/state/work-unit-lifecycle-migration.d.ts +85 -0
  55. package/dist/state/work-unit-lifecycle-migration.d.ts.map +1 -0
  56. package/dist/state/work-unit-lifecycle-migration.js +246 -0
  57. package/dist/state/work-unit-lifecycle-migration.js.map +1 -0
  58. package/dist/triage/heuristic.d.ts.map +1 -1
  59. package/dist/triage/heuristic.js +3 -1
  60. package/dist/triage/heuristic.js.map +1 -1
  61. package/dist/types.d.ts +51 -0
  62. package/dist/types.d.ts.map +1 -1
  63. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { readFile } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, resolve } from 'node:path';
4
4
  import { DEFAULT_READINESS_RECONCILE_TIMEOUT_MS, FactoryConfigSchema } from '../config/schema.js';
5
5
  import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
@@ -10,17 +10,18 @@ import { VerificationPipeline } from '../environments/verification-pipeline.js';
10
10
  import { factoryWorktreeIssueSlug, factoryWorktreePath } from '../git/agent-worktree.js';
11
11
  import { InMemoryStateStore } from '../state/in-memory-state-store.js';
12
12
  import { dispatchHandedOffToBabysitters, dispatchLifecycleOccupiesSlot, dispatchPhaseOccupiesSlot, } from '../state/dispatch-lifecycle-slot.js';
13
- import { containsExplicitIssueReference, containsIssueKey, factoryBranchBelongsToIssue } from '../issue-key-match.js';
13
+ import { branchImplementsIssue, containsExplicitIssueReference, containsIssueKey, factoryBranchBelongsToIssue, prBodyDisclaimsClosing, prClosureAuthority } from '../issue-key-match.js';
14
14
  import { normalizeLogger, normalizeLogValue, setSafeErrorStack, stringifyLogValue } from '../logging.js';
15
15
  import { isInFactoryScope } from '../safety/factory-scope.js';
16
16
  import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
17
- import { dispatchAgentIdentityKey } from '../dispatch/work-unit-identity.js';
17
+ import { dispatchAgentIdentityKey, dispatchIssueIdentity, mirrorWorkUnitOrigin } from '../dispatch/work-unit-identity.js';
18
18
  import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
19
19
  import { parseGithubHumanInputRequest, renderAgentTask, } from '../dispatch/templates.js';
20
20
  import { resolveTestGuidance } from '../dispatch/test-guidance.js';
21
21
  import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels, swarmChannel, swarmMemberSlugs, swarmTaskFor } from '../triage/index.js';
22
22
  import { agentNameForRole, sanitizeAgentSlug } from '../triage/agent-names.js';
23
23
  import { isResourceSubscriptionsUnavailable } from '../subscriptions/index.js';
24
+ import { DispatchLifecycleMigrationConflictError } from '../ports/state.js';
24
25
  import { AppGithubWriteback, FACTORY_GITHUB_STATUS_LABELS, GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
25
26
  import { parseSlackThreadReply, slackThreadReplyGlob } from '../subscriptions/slack-filter.js';
26
27
  import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
@@ -28,6 +29,7 @@ import { issueKey, } from './batch-tracker.js';
28
29
  import { dependencyIdentity, findDependencyCycle, parseBlockedBy, resolveDependency, } from './dependencies.js';
29
30
  import { CoalescedTaskQueue } from './coalesced-task-queue.js';
30
31
  import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
32
+ import { writeJsonFileAtomically } from './atomic-json-file.js';
31
33
  import { readFactoryInFlightRegistry, terminatePids } from './reaper.js';
32
34
  import { createFactoryCloudEventV1, factoryCloudReleaseReasonV1, } from '../observability/events.js';
33
35
  import { telemetryErrorClass } from '../observability/error-class.js';
@@ -807,7 +809,7 @@ export class FactoryLoop {
807
809
  * row to wait on, or the wait ended because Factory is stopping.
808
810
  */
809
811
  async waitForDispatchTerminal(issue) {
810
- const key = issueKey(issue);
812
+ const key = dispatchLifecycleKey(issue);
811
813
  const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
812
814
  // No durable row means this dispatch never claimed a lifecycle: a
813
815
  // dependency park, a triage escalation, and a label refusal all return
@@ -1853,7 +1855,7 @@ export class FactoryLoop {
1853
1855
  try {
1854
1856
  const batch = await this.#batch();
1855
1857
  const records = batch.inFlight
1856
- .filter((record) => !record.dryRun && !this.#completionInFlight.has(issueKey(record.issue)));
1858
+ .filter((record) => !record.dryRun && !this.#completionInFlight.has(dispatchLifecycleKey(record.issue)));
1857
1859
  if (records.length === 0) {
1858
1860
  return;
1859
1861
  }
@@ -2009,7 +2011,7 @@ export class FactoryLoop {
2009
2011
  async #orphanedPrMetaPaths(record) {
2010
2012
  const wanted = [];
2011
2013
  if (this.#usesDurableDispatchLifecycle()) {
2012
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
2014
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
2013
2015
  const receipts = lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : []);
2014
2016
  for (const receipt of receipts)
2015
2017
  wanted.push({ repo: receipt.repo, prNumber: receipt.number });
@@ -2560,12 +2562,13 @@ export class FactoryLoop {
2560
2562
  const labels = isGithubIssue(issue)
2561
2563
  ? new Set(issue.labels.map((label) => label.trim().toLowerCase()))
2562
2564
  : undefined;
2563
- const requiredLabel = this.#config.safety.requireLabel.trim().toLowerCase();
2565
+ // Must stay the same scope test as dispatch and as
2566
+ // #reconcileOrphanedGithubInProgress. Gating on the scope label alone
2567
+ // left every title-scoped issue stuck in `factory:in-progress` forever.
2564
2568
  const mayRecoverGithubOrphan = !wasReady &&
2565
2569
  !dryRun &&
2566
2570
  issueSource === 'github' &&
2567
- Boolean(requiredLabel) &&
2568
- Boolean(labels?.has(requiredLabel)) &&
2571
+ isInFactoryScope(issue, this.#config.safety) &&
2569
2572
  Boolean(labels?.has('factory:in-progress')) &&
2570
2573
  !labels?.has('factory:human-review');
2571
2574
  if (!mayRecoverGithubOrphan) {
@@ -3182,9 +3185,13 @@ export class FactoryLoop {
3182
3185
  if (!isGithubIssue(issue))
3183
3186
  return { recovered: false, reason: 'issue is not GitHub-native' };
3184
3187
  const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
3185
- const required = this.#config.safety.requireLabel.trim().toLowerCase();
3186
- if (!required ||
3187
- !labels.has(required) ||
3188
+ // Recovery must admit exactly what dispatch admits. Dispatch accepts the
3189
+ // configured title prefix OR the scope label (`isInFactoryScope`), but this
3190
+ // gate used to demand the label alone — so an issue admitted by its title
3191
+ // could be dispatched and then never un-stuck, keeping `factory:in-progress`
3192
+ // forever once its dispatch died. factory#139 is the live instance: title
3193
+ // `[factory] ...`, and its only label is `factory:in-progress`.
3194
+ if (!isInFactoryScope(issue, this.#config.safety) ||
3188
3195
  !labels.has('factory:in-progress') ||
3189
3196
  labels.has('factory:human-review'))
3190
3197
  return { recovered: false, reason: 'issue is not an orphan-recovery candidate' };
@@ -3316,7 +3323,7 @@ export class FactoryLoop {
3316
3323
  }
3317
3324
  }
3318
3325
  async #releaseOrphanedGithubLifecycle(issue, identity, candidate) {
3319
- let key = candidate.key;
3326
+ const key = candidate.key;
3320
3327
  let lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
3321
3328
  if (!lifecycle)
3322
3329
  return true;
@@ -3356,7 +3363,6 @@ export class FactoryLoop {
3356
3363
  });
3357
3364
  return false;
3358
3365
  }
3359
- key = claim.key ?? key;
3360
3366
  lifecycle = claim.lifecycle;
3361
3367
  epoch = claim.lease.epoch;
3362
3368
  this.#dispatchLifecycleEpochs.set(key, epoch);
@@ -3499,7 +3505,7 @@ export class FactoryLoop {
3499
3505
  const record = batch.start(decision, false);
3500
3506
  if (!record) {
3501
3507
  if (durableAdoption) {
3502
- const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(decision.issue));
3508
+ const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(decision.issue));
3503
3509
  if (durable) {
3504
3510
  this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(durable));
3505
3511
  this.#increment('githubOrphanedPullRequestsAdopted');
@@ -3814,15 +3820,29 @@ export class FactoryLoop {
3814
3820
  }
3815
3821
  }
3816
3822
  async triageIssue(issue) {
3817
- return this.#triage.triage(issue, {
3823
+ return await this.#triageWithOrigin(issue);
3824
+ }
3825
+ /**
3826
+ * The mirror origin is a property of the issue Factory fed in, not of the
3827
+ * engine's answer, so it is re-attached here rather than trusted from the
3828
+ * engine. `TriageDecisionSchema` drops unknown keys, so `LlmTriage` returns a
3829
+ * decision that has lost it, and a custom `TriageEngine` never set it at all
3830
+ * — either way a Linear mirror would fall back to `linear:<uuid>` and
3831
+ * duplicate its own GitHub-native arrival, which is the defect #211 fixes.
3832
+ */
3833
+ async #triageWithOrigin(issue) {
3834
+ const decision = await this.#triage.triage(issue, {
3818
3835
  config: this.#config,
3819
3836
  repoMap: repoMapFromConfig(this.#config),
3820
3837
  });
3838
+ return decisionWithMirrorOrigin(decision, issue);
3821
3839
  }
3822
3840
  async dispatch(decision, opts = {}) {
3823
3841
  const dryRun = opts.dryRun ?? this.#config.dryRun;
3824
3842
  const phase = triageEscalationReason(decision) ? 'escalation' : 'dispatch';
3825
- const key = `${issueStateKey(decision.issue)}:${dryRun ? 'dry-run' : 'live'}:${phase}`;
3843
+ // The dispatch concurrency gate is per work unit: a mirror of an issue
3844
+ // already being dispatched must join that call, not start a second one.
3845
+ const key = `${dispatchLifecycleKey(decision.issue)}:${dryRun ? 'dry-run' : 'live'}:${phase}`;
3826
3846
  const inFlight = this.#dispatchInFlight.get(key);
3827
3847
  if (inFlight) {
3828
3848
  this.#increment('dispatchDuplicateSuppressed');
@@ -3850,9 +3870,9 @@ export class FactoryLoop {
3850
3870
  return existingRecord.result;
3851
3871
  }
3852
3872
  if (!dryRun && this.#usesDurableDispatchLifecycle()) {
3853
- const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(decision.issue));
3873
+ const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(decision.issue));
3854
3874
  if (durable && !isTerminalDispatchLifecycle(durable)) {
3855
- if (durable.result && this.#dispatchLifecycleEpochs.has(issueKey(decision.issue))) {
3875
+ if (durable.result && this.#dispatchLifecycleEpochs.has(dispatchLifecycleKey(decision.issue))) {
3856
3876
  return durable.result;
3857
3877
  }
3858
3878
  if (this.#startMode === 'dispatch-owner') {
@@ -4107,7 +4127,7 @@ export class FactoryLoop {
4107
4127
  state: 'pending',
4108
4128
  updatedAtMs: this.#clock.now(),
4109
4129
  };
4110
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
4130
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
4111
4131
  }
4112
4132
  await this.#writeInFlightRegistry();
4113
4133
  const comment = dispatchComment(dispatchDecision, agents);
@@ -4115,10 +4135,37 @@ export class FactoryLoop {
4115
4135
  if (!dryRun) {
4116
4136
  const issue = await this.#readIssue(dispatchDecision.issue.path);
4117
4137
  if (!issue || !this.#isIssueReady(issue)) {
4118
- throw new LiveDispatchStateChangedError(dispatchDecision.issue.key);
4138
+ // The agents spawned a few lines above can reach terminal before we
4139
+ // get here. Their completion writeback parks the issue — Linear
4140
+ // `humanReview`, or the GitHub human-review label — and stamps
4141
+ // *this same record's* lifecycle on the way past. Re-reading the
4142
+ // issue then shows "not ready", but the writer was us.
4143
+ //
4144
+ // Treating that as a foreign change is not a cosmetic misreport: the
4145
+ // catch below classifies LiveDispatchStateChangedError as terminal
4146
+ // and calls #releaseAndTerminateAgents, so a dispatch whose agents
4147
+ // finished quickly tore down its own completed work and reported
4148
+ // RETRYABLE to its supervisor (factory#319).
4149
+ //
4150
+ // Only THIS dispatch's own confirmed issue writeback may excuse the
4151
+ // change. Lifecycle phases are not enough: `publishing` is entered
4152
+ // before the PR is published and `parking` before anything touches
4153
+ // the issue, so a phase records local progress, not authorship — and
4154
+ // a foreign park landing during those awaits would be misread as
4155
+ // ours (codex review on #321). Losing the row to another owner is a
4156
+ // different condition and is still caught where it always was, by
4157
+ // #saveDispatchLifecycle returning false.
4158
+ if (record.issueWritebackConfirmedAtMs === undefined) {
4159
+ throw new LiveDispatchStateChangedError(dispatchDecision.issue.key);
4160
+ }
4161
+ // The claim is moot and would be wrong to write: it would drag an
4162
+ // issue our own lifecycle has already parked back to `implementing`.
4163
+ }
4164
+ else {
4165
+ implementingStateId = await this.#applyDispatchClaim(record, issue, comment);
4166
+ record.issueWritebackConfirmedAtMs = this.#clock.now();
4167
+ this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
4119
4168
  }
4120
- implementingStateId = await this.#applyDispatchClaim(record, issue, comment);
4121
- this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
4122
4169
  }
4123
4170
  const result = {
4124
4171
  issue: dispatchDecision.issue,
@@ -4245,7 +4292,7 @@ export class FactoryLoop {
4245
4292
  ...(tracked.result?.node ? { node: tracked.result.node } : {}),
4246
4293
  })),
4247
4294
  claim: {
4248
- ...(record.dispatchClaim ?? this.#dispatchClaimStatuses.get(issueKey(record.issue)) ?? {
4295
+ ...(record.dispatchClaim ?? this.#dispatchClaimStatuses.get(dispatchLifecycleKey(record.issue)) ?? {
4249
4296
  state: 'pending',
4250
4297
  updatedAtMs: nowMs,
4251
4298
  }),
@@ -4475,7 +4522,25 @@ export class FactoryLoop {
4475
4522
  if (previews.length > 0)
4476
4523
  this.#previewReferences.set(issueKey(lifecycle.issue), previews);
4477
4524
  hasNonterminalDurableLifecycle = true;
4478
- const claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, lifecycle, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
4525
+ // A work unit whose keys cannot be reconciled must not strand the rows
4526
+ // behind it — #315 is the same lesson from the shed-read path. Retain
4527
+ // the conflicting rows, leave that one unit blocked for an operator,
4528
+ // and carry on adopting the rest.
4529
+ let claim;
4530
+ try {
4531
+ claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, lifecycle, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
4532
+ }
4533
+ catch (error) {
4534
+ if (!(error instanceof DispatchLifecycleMigrationConflictError))
4535
+ throw error;
4536
+ this.#increment('dispatchLifecycleMigrationConflicts');
4537
+ this.#logger.error?.('[factory] dispatch is blocked for one work unit until its keys are reconciled', {
4538
+ issue: lifecycle.issue.key,
4539
+ canonicalKey: error.canonicalKey,
4540
+ conflictingKeys: error.conflictingKeys,
4541
+ });
4542
+ continue;
4543
+ }
4479
4544
  if (!claim.acquired || !claim.lease) {
4480
4545
  // The other process may have crashed while its nominal lease is
4481
4546
  // still live. Keep this process attached so it reclaims the row
@@ -4483,7 +4548,7 @@ export class FactoryLoop {
4483
4548
  this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(claim.lifecycle));
4484
4549
  continue;
4485
4550
  }
4486
- this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
4551
+ this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
4487
4552
  this.#hydrateCostLedger(claim.lifecycle);
4488
4553
  if (claim.lifecycle.phase === 'waiting-for-human')
4489
4554
  continue;
@@ -4492,7 +4557,37 @@ export class FactoryLoop {
4492
4557
  if (!durableRecord.dryRun &&
4493
4558
  claim.lifecycle.phase !== 'writeback-applied' &&
4494
4559
  claim.lifecycle.phase !== 'releasing') {
4495
- liveIssue = await this.#readIssue(durableRecord.issue.path);
4560
+ // `#readIssue` swallows every read failure except a relayfile 429,
4561
+ // which it rethrows so callers can back off. On this path that
4562
+ // rethrow aborted the whole adoption: this row was never restored
4563
+ // into the batch, every later row was skipped, and no hold deadline
4564
+ // was armed for any of them (#315). Startup already degrades safely
4565
+ // when the issue is simply unreadable — it treats the row as not
4566
+ // externally terminal and lets the retry driver carry it — so a shed
4567
+ // read must degrade the same way rather than strand the batch.
4568
+ try {
4569
+ liveIssue = await this.#readIssue(durableRecord.issue.path);
4570
+ }
4571
+ catch (error) {
4572
+ const overload = relayfileOverload(error);
4573
+ if (!overload)
4574
+ throw error;
4575
+ this.#increment('startupIssueReadsShed');
4576
+ this.#logger.warn?.('[factory] startup issue read was shed; adopting without it', {
4577
+ issue: durableRecord.issue.key,
4578
+ status: overload.status,
4579
+ reason: relayfileOverloadReasonLabel(overload.reason),
4580
+ });
4581
+ }
4582
+ // A row persisted before #211 has no `origin`, so a Linear mirror
4583
+ // still resolves to `linear:<uuid>` and a GitHub-native arrival for
4584
+ // the same work unit would claim alongside it — the rolling-upgrade
4585
+ // form of the duplicate this change removes. Nothing on the row links
4586
+ // it to its upstream, but the issue we just read does, so backfill it
4587
+ // here under the lease this process already holds. The next claim or
4588
+ // load then recognises the row by identity and re-keys it.
4589
+ if (liveIssue)
4590
+ await this.#backfillMirrorOrigin(key, claim, liveIssue);
4496
4591
  // A babysat Linear issue already at Done may have merged while this
4497
4592
  // process was down. Let authoritative PR restoration drive the
4498
4593
  // normal `complete` path so merged work is not mislabeled abandoned.
@@ -4522,7 +4617,7 @@ export class FactoryLoop {
4522
4617
  if (claim.lifecycle.phase === 'running' &&
4523
4618
  this.#ticketDispatchNotificationIsPending(claim.lifecycle)) {
4524
4619
  if (!liveIssue) {
4525
- this.#dispatchLifecycleEpochs.delete(claim.key ?? key);
4620
+ this.#dispatchLifecycleEpochs.delete(key);
4526
4621
  this.#scheduleDispatchLifecycleRetry(restored);
4527
4622
  throw new Error(`Unable to recover ticket-dispatch notification ${restored.issue.key}: issue is not currently readable`);
4528
4623
  }
@@ -4629,11 +4724,28 @@ export class FactoryLoop {
4629
4724
  });
4630
4725
  }
4631
4726
  }
4632
- this.#rescheduleHeldAgentDeadlineSweep();
4633
4727
  }
4634
4728
  catch (error) {
4635
4729
  this.#logger.warn?.('[factory] failed to re-adopt durable in-flight agents', { error });
4636
4730
  }
4731
+ finally {
4732
+ // Arming has to survive everything above it (#315).
4733
+ //
4734
+ // This is the only thing that arms a hold deadline for a row that
4735
+ // survived a restart, and it used to be the last statement of the `try`.
4736
+ // Two fleet awaits sit above it — `reconcileTrackedAgents`, which is not
4737
+ // circuit-proxied and so reaches the transport, and the guarded `roster`,
4738
+ // which throws outright once the circuit is open. With the broker down
4739
+ // either one throws, the catch logs, and no hold deadline is armed for
4740
+ // that boot: the #304 reaper is present in the build and was never
4741
+ // scheduled. That is the failure mode reaping exists to clear, so the
4742
+ // arming must not be reachable only along the happy path.
4743
+ //
4744
+ // A `dispatching` row is additionally armed by the per-row retry driver
4745
+ // below, which is why the wedge shows up on a `running` row: `running` is
4746
+ // the one slot-occupying phase that never gets a retry scheduled here.
4747
+ this.#rescheduleHeldAgentDeadlineSweep();
4748
+ }
4637
4749
  }
4638
4750
  async #deduplicateQueuedGithubLifecycleAliases(lifecycles) {
4639
4751
  const groups = new Map();
@@ -4852,7 +4964,7 @@ export class FactoryLoop {
4852
4964
  // never-placed — which also excludes its agents from the release below,
4853
4965
  // leaking live workers (#303 review, CodeRabbit).
4854
4966
  let effective = deadline;
4855
- const key = issueKey(record.issue);
4967
+ const key = dispatchLifecycleKey(record.issue);
4856
4968
  if (this.#abandonedDispatchReasons.has(key))
4857
4969
  continue;
4858
4970
  if (this.#usesDurableDispatchLifecycle()) {
@@ -4904,6 +5016,36 @@ export class FactoryLoop {
4904
5016
  }
4905
5017
  }
4906
5018
  }
5019
+ /**
5020
+ * Records the upstream origin on an adopted lifecycle that predates it.
5021
+ * Best-effort: a failed write leaves the row exactly as it was, and the next
5022
+ * startup tries again.
5023
+ */
5024
+ async #backfillMirrorOrigin(key, claim, liveIssue) {
5025
+ const epoch = claim.lease?.epoch;
5026
+ if (epoch === undefined || claim.lifecycle.issue.origin)
5027
+ return;
5028
+ const origin = mirrorWorkUnitOrigin(liveIssue);
5029
+ if (!origin)
5030
+ return;
5031
+ const issue = { ...claim.lifecycle.issue, origin };
5032
+ const enriched = {
5033
+ ...claim.lifecycle,
5034
+ issue,
5035
+ decision: { ...claim.lifecycle.decision, issue },
5036
+ };
5037
+ const saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), enriched).catch((error) => {
5038
+ this.#logger.warn?.('[factory] could not backfill mirror origin on an adopted lifecycle', {
5039
+ issue: claim.lifecycle.issue.key,
5040
+ error: describeError(error).errorMessage,
5041
+ });
5042
+ return false;
5043
+ });
5044
+ if (!saved)
5045
+ return;
5046
+ claim.lifecycle = enriched;
5047
+ this.#increment('dispatchLifecycleMirrorOriginsBackfilled');
5048
+ }
4907
5049
  async #renewDispatchLifecycles() {
4908
5050
  for (const [key, epoch] of [...this.#dispatchLifecycleEpochs]) {
4909
5051
  const renewed = await this.#state.renewDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
@@ -4928,7 +5070,7 @@ export class FactoryLoop {
4928
5070
  }
4929
5071
  }
4930
5072
  async #claimDispatchLifecycle(decision, dryRun, preparedRunId, initial = {}) {
4931
- const key = issueKey(decision.issue);
5073
+ const key = dispatchLifecycleKey(decision.issue);
4932
5074
  const seed = {
4933
5075
  runId: preparedRunId ?? randomUUID(),
4934
5076
  issue: { ...decision.issue },
@@ -4951,7 +5093,7 @@ export class FactoryLoop {
4951
5093
  : `dispatch lifecycle is owned by ${claim.lifecycle.lease?.owner ?? 'another publisher'}`;
4952
5094
  throw new DispatchLifecycleClaimRefusedError(decision.issue.key, terminal ? 'terminal' : 'owned-elsewhere', `Refusing to dispatch ${decision.issue.key}: ${reason}`);
4953
5095
  }
4954
- this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
5096
+ this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
4955
5097
  this.#hydrateCostLedger(claim.lifecycle);
4956
5098
  this.#scheduleDispatchLifecycleRenewal();
4957
5099
  if (claim.created) {
@@ -5007,7 +5149,7 @@ export class FactoryLoop {
5007
5149
  }
5008
5150
  async #reportAgent(record, tracked, type, options = {}) {
5009
5151
  const lifecycle = await this.#state
5010
- .getDispatchLifecycle(this.#workspaceId, issueKey(record.issue))
5152
+ .getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
5011
5153
  .catch(() => undefined);
5012
5154
  if (!lifecycle)
5013
5155
  return;
@@ -5035,7 +5177,7 @@ export class FactoryLoop {
5035
5177
  }
5036
5178
  async #handleAgentUsage(usage, key) {
5037
5179
  const record = (await this.#batch()).getIssueByAgent(usage.name);
5038
- const activeRecord = record && issueKey(record.issue) === key ? record : undefined;
5180
+ const activeRecord = record && dispatchLifecycleKey(record.issue) === key ? record : undefined;
5039
5181
  let tracked = activeRecord?.agents.get(usage.name);
5040
5182
  const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
5041
5183
  // Completion removes the in-memory batch record immediately after the
@@ -5143,7 +5285,7 @@ export class FactoryLoop {
5143
5285
  async #dispatchLifecycleKeyForAgent(name) {
5144
5286
  const record = (await this.#batch()).getIssueByAgent(name);
5145
5287
  if (record?.agents.has(name))
5146
- return issueKey(record.issue);
5288
+ return dispatchLifecycleKey(record.issue);
5147
5289
  return (await this.#findDispatchLifecycleAgent(name))?.key;
5148
5290
  }
5149
5291
  #serializeDispatchLifecyclePersistence(key, operation) {
@@ -5283,7 +5425,7 @@ export class FactoryLoop {
5283
5425
  return true;
5284
5426
  if (isTerminalDispatchPhase(phase))
5285
5427
  await this.#drainAgentUsage();
5286
- const key = issueKey(record.issue);
5428
+ const key = dispatchLifecycleKey(record.issue);
5287
5429
  return this.#serializeDispatchLifecyclePersistence(key, async () => {
5288
5430
  const epoch = this.#dispatchLifecycleEpochs.get(key);
5289
5431
  if (epoch === undefined) {
@@ -5368,7 +5510,7 @@ export class FactoryLoop {
5368
5510
  // after release could see it cleared, or see the next dispatch for the same
5369
5511
  // issue, and classify the wrong run.
5370
5512
  #resolveDispatchTerminalWaiters(issue, phase) {
5371
- const key = issueKey(issue);
5513
+ const key = dispatchLifecycleKey(issue);
5372
5514
  for (const resolve of this.#dispatchTerminalWaiters.get(key) ?? [])
5373
5515
  resolve(phase);
5374
5516
  this.#dispatchTerminalWaiters.delete(key);
@@ -5497,7 +5639,7 @@ export class FactoryLoop {
5497
5639
  this.#increment('dispatchCapacityBackoffResets');
5498
5640
  }
5499
5641
  #scheduleDispatchLifecycleRetry(record, delayMs = DISPATCH_LIFECYCLE_RETRY_MS) {
5500
- const key = issueKey(record.issue);
5642
+ const key = dispatchLifecycleKey(record.issue);
5501
5643
  if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
5502
5644
  return;
5503
5645
  const timer = setTimeout(() => {
@@ -5547,7 +5689,7 @@ export class FactoryLoop {
5547
5689
  this.#scheduleDispatchLifecycleRetry(record);
5548
5690
  return;
5549
5691
  }
5550
- const key = issueKey(record.issue);
5692
+ const key = dispatchLifecycleKey(record.issue);
5551
5693
  if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
5552
5694
  return;
5553
5695
  const timer = setTimeout(() => {
@@ -5585,7 +5727,7 @@ export class FactoryLoop {
5585
5727
  if (!claim.acquired || !claim.lease) {
5586
5728
  throw new DispatchLifecycleOwnedElsewhereError(claim.lifecycle.lease?.leaseUntilMs);
5587
5729
  }
5588
- this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
5730
+ this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
5589
5731
  this.#hydrateCostLedger(claim.lifecycle);
5590
5732
  this.#scheduleDispatchLifecycleRenewal();
5591
5733
  lifecycle = claim.lifecycle;
@@ -5838,7 +5980,7 @@ export class FactoryLoop {
5838
5980
  state: 'pending',
5839
5981
  updatedAtMs: this.#clock.now(),
5840
5982
  };
5841
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
5983
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
5842
5984
  }
5843
5985
  await this.#writeInFlightRegistry();
5844
5986
  if (!record.dryRun) {
@@ -5964,7 +6106,7 @@ export class FactoryLoop {
5964
6106
  async #finishDurableRelease(record, releaseReason) {
5965
6107
  const batch = await this.#batch();
5966
6108
  const reason = releaseReason ?? (this.#config.terminalState === 'human-review' ? 'issue-human-review' : 'issue-done');
5967
- const releaseKey = issueKey(record.issue);
6109
+ const releaseKey = dispatchLifecycleKey(record.issue);
5968
6110
  // Terminal writeback has already been acknowledged before this method is
5969
6111
  // entered. Remove externally reachable routes first so a stuck agent
5970
6112
  // release cannot leave a preview live after Human Review or Done.
@@ -6040,7 +6182,7 @@ export class FactoryLoop {
6040
6182
  async #assertIssueDispatchLifecycleOwner(issue) {
6041
6183
  if (!this.#usesDurableDispatchLifecycle())
6042
6184
  return true;
6043
- const key = issueKey(issue);
6185
+ const key = dispatchLifecycleKey(issue);
6044
6186
  const epoch = this.#dispatchLifecycleEpochs.get(key);
6045
6187
  if (epoch === undefined)
6046
6188
  return false;
@@ -6179,7 +6321,7 @@ export class FactoryLoop {
6179
6321
  state: 'verified',
6180
6322
  updatedAtMs: this.#clock.now(),
6181
6323
  };
6182
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
6324
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
6183
6325
  await this.#writeDispatchClaimRegistry(record.issue);
6184
6326
  return implementingStateId;
6185
6327
  }
@@ -6208,7 +6350,7 @@ export class FactoryLoop {
6208
6350
  ...(deadLettered ? { deadLettered: true } : {}),
6209
6351
  updatedAtMs: this.#clock.now(),
6210
6352
  };
6211
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
6353
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
6212
6354
  await this.#writeDispatchClaimRegistry(record.issue);
6213
6355
  if (deadLettered) {
6214
6356
  this.#increment('dispatchWritebackDeadLetters');
@@ -6437,7 +6579,16 @@ export class FactoryLoop {
6437
6579
  !labels.every((label) => typeof label === 'string')) {
6438
6580
  return undefined;
6439
6581
  }
6440
- if (state !== 'open' || !labels.some((label) => label.trim().toLowerCase() === requiredLabel)) {
6582
+ // Retain Factory's own lifecycle rows even when they lack the scope
6583
+ // label. The index carries no title, so a title-scoped issue cannot be
6584
+ // recognised here — and dropping it means its file is never read and the
6585
+ // orphan-recovery sweep never sees it. `factory:in-progress` is a label
6586
+ // only Factory applies, so a row carrying it is by definition
6587
+ // Factory-touched and worth reading; `isInFactoryScope` downstream
6588
+ // remains the authority on whether anything may be done with it.
6589
+ const rowLabels = labels.map((label) => label.trim().toLowerCase());
6590
+ if (state !== 'open' ||
6591
+ !(rowLabels.includes(requiredLabel) || rowLabels.includes('factory:in-progress'))) {
6441
6592
  continue;
6442
6593
  }
6443
6594
  paths.push(`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`);
@@ -7034,11 +7185,20 @@ export class FactoryLoop {
7034
7185
  await this.#state.recordDispatchAttempt(this.#workspaceId, key, dispatchState);
7035
7186
  this.#increment('dispatchTerminalReopened');
7036
7187
  }
7037
- for (const [key, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
7038
- if (lifecycle.issue.key !== issue.key || !isTerminalDispatchLifecycle(lifecycle))
7188
+ // Match on the work unit, not the surface key: a completed Linear mirror
7189
+ // persists `AR-448` while the GitHub-native arrival of the same issue is
7190
+ // `448`, and comparing surface keys would leave that terminal row in
7191
+ // place to refuse the reopened work forever.
7192
+ const reopenedIdentity = safeDispatchLifecycleKey(issueRefForState(issue));
7193
+ for (const [lifecycleKey, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
7194
+ if (!isTerminalDispatchLifecycle(lifecycle))
7039
7195
  continue;
7040
- await this.#state.clearDispatchLifecycle(this.#workspaceId, key);
7041
- this.#dispatchLifecycleEpochs.delete(key);
7196
+ const matches = reopenedIdentity !== undefined &&
7197
+ safeDispatchLifecycleKey(lifecycle.issue) === reopenedIdentity;
7198
+ if (!matches && lifecycle.issue.key !== issue.key)
7199
+ continue;
7200
+ await this.#state.clearDispatchLifecycle(this.#workspaceId, lifecycleKey);
7201
+ this.#dispatchLifecycleEpochs.delete(lifecycleKey);
7042
7202
  }
7043
7203
  }
7044
7204
  await this.#state.recordCanonicalState(this.#workspaceId, key, issue.stateId);
@@ -7081,8 +7241,10 @@ export class FactoryLoop {
7081
7241
  error: describeError(error).errorMessage,
7082
7242
  });
7083
7243
  }
7084
- await mkdir(dirname(path), { recursive: true });
7085
- await writeFile(path, `${JSON.stringify(heartbeat, null, 2)}\n`, 'utf8');
7244
+ // Atomic publish (#323): `/healthz` and the crash reaper read this file
7245
+ // while it is being rewritten, and a torn read reaches them as a missing
7246
+ // heartbeat, i.e. as a dead daemon.
7247
+ await writeJsonFileAtomically(path, heartbeat);
7086
7248
  await this.#writeInFlightRegistry(registryPath, path);
7087
7249
  const batch = this.#batchView;
7088
7250
  await this.#report({
@@ -7369,7 +7531,7 @@ export class FactoryLoop {
7369
7531
  this.#logger.warn?.(`[factory] failed to release ${agentName} during ${context}`, error);
7370
7532
  if (record) {
7371
7533
  const lifecycle = await this.#state
7372
- .getDispatchLifecycle(this.#workspaceId, issueKey(record.issue))
7534
+ .getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
7373
7535
  .catch(() => undefined);
7374
7536
  if (lifecycle) {
7375
7537
  await this.#reportLifecycle(lifecycle, 'factory.failure', {
@@ -7544,7 +7706,7 @@ export class FactoryLoop {
7544
7706
  }
7545
7707
  }
7546
7708
  const fleetTracked = this.#fleet.trackedAgents?.().get(agentName);
7547
- const dispatchClaim = this.#dispatchClaimStatuses.get(issueKey(issue));
7709
+ const dispatchClaim = this.#dispatchClaimStatuses.get(dispatchLifecycleKey(issue));
7548
7710
  agents.push({
7549
7711
  name: agentName,
7550
7712
  role: tracked.spec.role,
@@ -7568,7 +7730,7 @@ export class FactoryLoop {
7568
7730
  if (record.dryRun)
7569
7731
  continue;
7570
7732
  if (record.dispatchClaim) {
7571
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
7733
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
7572
7734
  }
7573
7735
  for (const [agentName, tracked] of record.agents) {
7574
7736
  await appendAgent(record.issue, agentName, tracked, record);
@@ -7585,8 +7747,10 @@ export class FactoryLoop {
7585
7747
  updatedAtMs,
7586
7748
  agents,
7587
7749
  };
7588
- await mkdir(dirname(path), { recursive: true });
7589
- await writeFile(path, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
7750
+ // Atomic publish (#323): the crash reaper reads this file in the same
7751
+ // sweep as the heartbeat, so leaving it torn keeps the reaper racy even
7752
+ // with an atomic heartbeat.
7753
+ await writeJsonFileAtomically(path, registry);
7590
7754
  }
7591
7755
  async #spawnAgent(record, spec, dryRun) {
7592
7756
  const batch = await this.#batch();
@@ -7697,7 +7861,7 @@ export class FactoryLoop {
7697
7861
  * durable row, which is authoritative when another owner terminalized it.
7698
7862
  */
7699
7863
  async #dispatchLifecycleStillOwned(record) {
7700
- const key = issueKey(record.issue);
7864
+ const key = dispatchLifecycleKey(record.issue);
7701
7865
  if (this.#abandonedDispatchReasons.has(key))
7702
7866
  return false;
7703
7867
  if (!this.#usesDurableDispatchLifecycle())
@@ -7814,7 +7978,7 @@ export class FactoryLoop {
7814
7978
  if (tracingReconciledExit)
7815
7979
  this.#logger.info?.('[factory] reconciled agent exit telemetry completed', { issue: record.issue.key, name });
7816
7980
  if (this.#usesDurableDispatchLifecycle()) {
7817
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
7981
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
7818
7982
  if (lifecycle?.phase === 'parking') {
7819
7983
  this.#increment('clarificationParkingExitsSuppressed');
7820
7984
  return;
@@ -8004,7 +8168,7 @@ export class FactoryLoop {
8004
8168
  // that exited immediately consume a batch slot forever while Factory
8005
8169
  // continuously recreated it. One successful recovery is enough; a
8006
8170
  // subsequent no-PR implementer exit is terminal and frees the slot.
8007
- const recoveryLifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
8171
+ const recoveryLifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
8008
8172
  const recoveryRunIdentity = recoveryLifecycle?.runId
8009
8173
  ?? tracked.spec.branch
8010
8174
  ?? batch.invocationIdFor(record.issue, tracked.spec);
@@ -8210,7 +8374,7 @@ export class FactoryLoop {
8210
8374
  if (!matchesCurrentConvention && !matchesAuthorizedLegacyBranch) {
8211
8375
  throw new Error(`Refusing to publish ${record.issue.key}: branch ${expectedHeadRef} belongs to a different issue`);
8212
8376
  }
8213
- const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
8377
+ const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
8214
8378
  const cached = this.#publishedPullRequests.get(key);
8215
8379
  if (cached) {
8216
8380
  if (cached.headRef !== expectedHeadRef) {
@@ -8868,7 +9032,7 @@ export class FactoryLoop {
8868
9032
  }
8869
9033
  }
8870
9034
  #scheduleAbandonedDispatchRetry(record, reason) {
8871
- const key = issueKey(record.issue);
9035
+ const key = dispatchLifecycleKey(record.issue);
8872
9036
  if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
8873
9037
  return;
8874
9038
  const timer = setTimeout(() => {
@@ -8900,7 +9064,7 @@ export class FactoryLoop {
8900
9064
  ? githubIssuePathParts(record.issue.path)?.owner
8901
9065
  : undefined;
8902
9066
  const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
8903
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
9067
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
8904
9068
  if (publishedPullRequests(lifecycle).some((receipt) => receipt.repo.toLowerCase() === repo.toLowerCase()))
8905
9069
  return true;
8906
9070
  return Boolean(await this.#openPullRequestByHead(repo, implementer.spec.branch));
@@ -10468,10 +10632,7 @@ export class FactoryLoop {
10468
10632
  return false;
10469
10633
  }
10470
10634
  const clarifiedIssue = issueWithGithubClarification(issue, text);
10471
- const decision = await this.#triage.triage(clarifiedIssue, {
10472
- config: this.#config,
10473
- repoMap: repoMapFromConfig(this.#config),
10474
- });
10635
+ const decision = await this.#triageWithOrigin(clarifiedIssue);
10475
10636
  const escalationReason = triageEscalationReason(decision);
10476
10637
  if (escalationReason) {
10477
10638
  if (hasDispatchableRoute(decision)) {
@@ -10922,7 +11083,7 @@ export class FactoryLoop {
10922
11083
  for (const record of records) {
10923
11084
  if (!await this.#assertIssueDispatchLifecycleOwner(record.issue))
10924
11085
  continue;
10925
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
11086
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
10926
11087
  // Babysitter sessions are independently durable and restore only after
10927
11088
  // their mounted PR metadata passes the open/draft/issue-identity guard.
10928
11089
  // Fold those validated receipts back into the lifecycle on takeover.
@@ -11575,7 +11736,7 @@ export class FactoryLoop {
11575
11736
  });
11576
11737
  }
11577
11738
  async #suspendBabysitterWakeForHuman(state) {
11578
- const key = issueKey(state.issue);
11739
+ const key = dispatchLifecycleKey(state.issue);
11579
11740
  const [lifecycle, clarification] = await Promise.all([
11580
11741
  this.#usesDurableDispatchLifecycle()
11581
11742
  ? this.#state.getDispatchLifecycle(this.#workspaceId, key)
@@ -12082,6 +12243,49 @@ export class FactoryLoop {
12082
12243
  async #advanceMergedPrToDone(snapshot, repo, record) {
12083
12244
  const mergedPullRequest = { repo, number: snapshot.number, url: snapshot.url };
12084
12245
  if (record) {
12246
+ // An in-flight record's association comes from Factory's own dispatch,
12247
+ // not from the PR's prose, so closure here does NOT require a closing
12248
+ // keyword — requiring one would strand every ordinary dispatch whose PR
12249
+ // links its issue with a bare `Linear: AR-411`.
12250
+ //
12251
+ // What it does honour is an explicit disclaimer: a merged PR that says in
12252
+ // its own body that it does not fix this issue is taken at its word. That
12253
+ // is the exact shape of #313 (`Diagnostic for #155`).
12254
+ //
12255
+ // Ownership does NOT exempt a PR from this. Ownership settles which issue
12256
+ // the PR belongs to; it says nothing about whether the merge completed
12257
+ // that issue. Skipping the check for owned PRs would mean the same merged
12258
+ // body is refused before ownership is established and accepted after.
12259
+ if (prBodyDisclaimsClosing({ headRef: snapshot.headRef, body: snapshot.body, repo }, record.issue.key)) {
12260
+ this.#increment('mergedPrClosureRefused');
12261
+ this.#logger.warn?.('[factory] merged PR disclaims completing the issue it references', {
12262
+ issue: record.issue.key,
12263
+ prNumber: snapshot.number,
12264
+ repo,
12265
+ });
12266
+ return;
12267
+ }
12268
+ // The protected-label gate has to cover the tracked paths too, or an
12269
+ // incident issue that Factory itself dispatched is still auto-closed by
12270
+ // its own merge and `neverAutoCloseLabels` guarantees nothing.
12271
+ const tracked = await this.#readIssue(record.issue.path);
12272
+ const trackedBlockingLabel = tracked ? this.#neverAutoCloseLabel(tracked) : undefined;
12273
+ if (tracked && trackedBlockingLabel) {
12274
+ await this.#declineMergeClosure(tracked, snapshot, repo, `it carries the \`${trackedBlockingLabel}\` label, which Factory never auto-closes`, 'label');
12275
+ return;
12276
+ }
12277
+ if (!tracked) {
12278
+ // Proceed rather than block. The disclaimer check above already ran on
12279
+ // the merged body; wedging every completion behind a transient mount
12280
+ // read would strand dispatches far more often than it would protect an
12281
+ // incident, and stranded dispatches are what generate incidents here.
12282
+ this.#logger.warn?.('[factory] could not read issue to apply the never-auto-close label gate', {
12283
+ issue: record.issue.key,
12284
+ prNumber: snapshot.number,
12285
+ repo,
12286
+ });
12287
+ this.#increment('mergedPrLabelGateUnverified');
12288
+ }
12085
12289
  await this.#completeIssue(record, {
12086
12290
  targetState: 'done',
12087
12291
  runMergeGate: false,
@@ -12100,11 +12304,24 @@ export class FactoryLoop {
12100
12304
  this.#increment('mergedPrAdvanceDuplicatesSuppressed');
12101
12305
  return;
12102
12306
  }
12307
+ // A merged PR that merely *references* this issue has no authority to
12308
+ // complete it, and an incident issue is never closed by a merge at all.
12309
+ // Both refusals comment instead of closing, so the decision is auditable
12310
+ // rather than silent. #313.
12311
+ const authority = prClosureAuthority({ headRef: snapshot.headRef, body: snapshot.body, repo }, issue.key);
12312
+ const blockingLabel = this.#neverAutoCloseLabel(issue);
12313
+ if (!authority.authorised || blockingLabel) {
12314
+ const reason = blockingLabel
12315
+ ? `it carries the \`${blockingLabel}\` label, which Factory never auto-closes`
12316
+ : authority.evidence;
12317
+ await this.#declineMergeClosure(issue, snapshot, repo, reason, blockingLabel ? 'label' : 'authority');
12318
+ return;
12319
+ }
12103
12320
  this.#postMergeDoneAdvances.add(advanceKey);
12104
12321
  try {
12105
12322
  const githubIssue = isGithubIssue(issue);
12106
12323
  if (githubIssue) {
12107
- await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.`);
12324
+ await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.\n\nClosing authority: ${authority.evidence}.`);
12108
12325
  }
12109
12326
  else {
12110
12327
  const doneStateId = this.#states.idFor(issue.team, 'done');
@@ -12119,6 +12336,7 @@ export class FactoryLoop {
12119
12336
  issue: issue.key,
12120
12337
  prNumber: snapshot.number,
12121
12338
  url: snapshot.url,
12339
+ closureAuthority: authority.source,
12122
12340
  });
12123
12341
  if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('merge-done-thread')) {
12124
12342
  try {
@@ -12145,6 +12363,61 @@ export class FactoryLoop {
12145
12363
  this.#error(error, issueRef(issue));
12146
12364
  }
12147
12365
  }
12366
+ /** The configured label, if any, that bars this issue from ever auto-closing. */
12367
+ #neverAutoCloseLabel(issue) {
12368
+ const blocked = this.#config.safety.neverAutoCloseLabels;
12369
+ if (!blocked || blocked.length === 0)
12370
+ return undefined;
12371
+ const carried = new Set((issue.labels ?? []).map((label) => label.trim().toLowerCase()));
12372
+ return blocked.find((label) => carried.has(label));
12373
+ }
12374
+ /**
12375
+ * Leave the issue open and say why. A refusal has to be *louder* than a
12376
+ * closure, not quieter: the failure this replaces marked a live outage
12377
+ * COMPLETED and dropped it out of every open-issue view.
12378
+ */
12379
+ async #declineMergeClosure(issue, snapshot, repo, reason, kind) {
12380
+ this.#increment(kind === 'label' ? 'mergedPrClosureBlockedByLabel' : 'mergedPrClosureRefused');
12381
+ this.#logger.warn?.('[factory] declined to close issue on merged PR', {
12382
+ issue: issue.key,
12383
+ prNumber: snapshot.number,
12384
+ repo,
12385
+ reason,
12386
+ kind,
12387
+ });
12388
+ const marker = `factory-merge-close-declined:${repo}#${snapshot.number}`;
12389
+ const body = [
12390
+ `Factory observed pull request ${repo}#${snapshot.number} merge, and did **not** close this issue.`,
12391
+ '',
12392
+ `Reason: ${reason}.`,
12393
+ '',
12394
+ 'A merged pull request closes an issue only when it carries a GitHub closing keyword'
12395
+ + ' (`closes`/`fixes`/`resolves`) for it, or when its head is that issue\'s own'
12396
+ + ' implementation branch. A bare reference is an association, not a completion.',
12397
+ '',
12398
+ 'If this pull request really did complete the issue, close it by hand.',
12399
+ '',
12400
+ `<!-- ${marker} -->`,
12401
+ ].join('\n');
12402
+ try {
12403
+ // A Linear-backed candidate reaches this path too, and a refusal that is
12404
+ // invisible on the system of record is barely better than the silent
12405
+ // closure it replaces.
12406
+ if (!isGithubIssue(issue)) {
12407
+ await this.#linear.postComment(issue, body);
12408
+ return;
12409
+ }
12410
+ // Merge events can redeliver. Skip a repeat only on a positive
12411
+ // confirmation that the note already landed; an unavailable check must
12412
+ // not be read as "already commented".
12413
+ if (await this.#githubWriteback.hasCommentMarker?.(issue, marker))
12414
+ return;
12415
+ await this.#githubWriteback.postComment(issue, body);
12416
+ }
12417
+ catch (error) {
12418
+ this.#error(error, issueRef(issue));
12419
+ }
12420
+ }
12148
12421
  async #findMergeAdvanceIssueForPr(snapshot, eventRepo) {
12149
12422
  // An issue is "upstream" of a merge if it sits in the agent-implementing or
12150
12423
  // human-review role for its team. UUIDs are globally unique, so the reverse
@@ -12226,7 +12499,7 @@ export class FactoryLoop {
12226
12499
  // after opening its PR (an event, not a poll).
12227
12500
  async #ensureBabysitterForIssue(record) {
12228
12501
  if (this.#usesDurableDispatchLifecycle()) {
12229
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12502
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12230
12503
  const receipts = lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : []);
12231
12504
  if (receipts.length > 0) {
12232
12505
  for (const receipt of receipts) {
@@ -12456,7 +12729,7 @@ export class FactoryLoop {
12456
12729
  }
12457
12730
  async #retireSupersededBabysitters(record, prRef) {
12458
12731
  const wanted = githubPrIdentity(prRef.repo, prRef.prNumber);
12459
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12732
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12460
12733
  const trackedAgents = new Map([
12461
12734
  ...(lifecycle?.agents ?? [])
12462
12735
  .filter((agent) => agent.releasedAtMs === undefined)
@@ -12499,7 +12772,7 @@ export class FactoryLoop {
12499
12772
  });
12500
12773
  }
12501
12774
  if (superseded.length > 0) {
12502
- const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12775
+ const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12503
12776
  if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
12504
12777
  throw new Error(`Failed to persist superseded babysitter cleanup for ${record.issue.key}`);
12505
12778
  }
@@ -12508,7 +12781,7 @@ export class FactoryLoop {
12508
12781
  }
12509
12782
  async #retireBabysittersOutsideCurrentRoutes(record) {
12510
12783
  const wantedRepos = new Set(record.decision.implementers.map((implementer) => implementer.repo.toLowerCase()));
12511
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12784
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12512
12785
  const trackedAgents = new Map([
12513
12786
  ...(lifecycle?.agents ?? [])
12514
12787
  .filter((agent) => agent.releasedAtMs === undefined)
@@ -12550,7 +12823,7 @@ export class FactoryLoop {
12550
12823
  });
12551
12824
  }
12552
12825
  if (unrouted.length > 0) {
12553
- const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12826
+ const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12554
12827
  if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
12555
12828
  throw new Error(`Failed to persist unrouted babysitter cleanup for ${record.issue.key}`);
12556
12829
  }
@@ -12563,7 +12836,7 @@ export class FactoryLoop {
12563
12836
  // guards on the PR's OWN webhook-fed meta (still open, not a draft, not already
12564
12837
  // merged) before flipping the issue to Human Review. No `gh` call.
12565
12838
  async #maybeAdvanceToHumanReview(record, agentName) {
12566
- if (this.#completionInFlight.has(issueKey(record.issue))) {
12839
+ if (this.#completionInFlight.has(dispatchLifecycleKey(record.issue))) {
12567
12840
  return;
12568
12841
  }
12569
12842
  const ownership = [...this.#babysitterPr.entries()].find(([key, ref]) => ref.agentName === agentName && issueKey(this.#babysitterIssueRefs.get(key) ?? record.issue) === issueKey(record.issue));
@@ -12696,7 +12969,7 @@ export class FactoryLoop {
12696
12969
  return false;
12697
12970
  }
12698
12971
  async #completeIssue(record, opts = {}) {
12699
- const completionKey = issueKey(record.issue);
12972
+ const completionKey = dispatchLifecycleKey(record.issue);
12700
12973
  if (this.#completionInFlight.has(completionKey)) {
12701
12974
  return;
12702
12975
  }
@@ -12766,6 +13039,7 @@ export class FactoryLoop {
12766
13039
  await this.#linear.setState(issue, targetState);
12767
13040
  await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
12768
13041
  }
13042
+ record.issueWritebackConfirmedAtMs = this.#clock.now();
12769
13043
  this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
12770
13044
  if (!humanReview)
12771
13045
  await this.#markDependencyTerminalAndReconcile(issue);
@@ -12841,7 +13115,7 @@ export class FactoryLoop {
12841
13115
  // Cancellation must see the subscription identity so it can issue the
12842
13116
  // idempotent Relayfile DELETE before clearing the local owner maps.
12843
13117
  await this.#cancelBabysittersForIssue(record.issue);
12844
- const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue)).catch(() => undefined);
13118
+ const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue)).catch(() => undefined);
12845
13119
  if (!this.#usesDurableDispatchLifecycle() || (durable && isTerminalDispatchLifecycle(durable))) {
12846
13120
  for (const publishedKey of this.#publishedPullRequests.keys()) {
12847
13121
  if (publishedKey.startsWith(`${completionKey}:`))
@@ -12938,7 +13212,7 @@ export class FactoryLoop {
12938
13212
  async #claimTicketDispatchNotification(record) {
12939
13213
  if (record.dryRun || !this.#usesDurableDispatchLifecycle())
12940
13214
  return true;
12941
- const key = issueKey(record.issue);
13215
+ const key = dispatchLifecycleKey(record.issue);
12942
13216
  return this.#serializeDispatchLifecyclePersistence(key, async () => {
12943
13217
  const epoch = this.#dispatchLifecycleEpochs.get(key);
12944
13218
  if (epoch === undefined) {
@@ -13001,7 +13275,7 @@ export class FactoryLoop {
13001
13275
  void (async () => {
13002
13276
  try {
13003
13277
  const lifecycle = issue
13004
- ? await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(issue)).catch(() => undefined)
13278
+ ? await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(issue)).catch(() => undefined)
13005
13279
  : undefined;
13006
13280
  if (lifecycle) {
13007
13281
  await this.#reportLifecycle(lifecycle, 'factory.failure', {
@@ -13269,7 +13543,7 @@ export class FactoryLoop {
13269
13543
  }
13270
13544
  async #slackPullRequestRefs(record, additional = []) {
13271
13545
  const lifecycle = await this.#state
13272
- .getDispatchLifecycle(this.#workspaceId, issueKey(record.issue))
13546
+ .getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
13273
13547
  .catch(() => undefined);
13274
13548
  const lifecycleRefs = publishedPullRequests(lifecycle).map((receipt) => ({
13275
13549
  repo: receipt.repo,
@@ -13600,7 +13874,7 @@ export class FactoryLoop {
13600
13874
  async #ownsActiveSlackConversationIssue(issue) {
13601
13875
  if (!(await this.#batch()).getIssue(issue))
13602
13876
  return false;
13603
- const key = issueKey(issue);
13877
+ const key = dispatchLifecycleKey(issue);
13604
13878
  const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
13605
13879
  if (!lifecycle)
13606
13880
  return true;
@@ -14960,7 +15234,7 @@ export class FactoryLoop {
14960
15234
  if (!waiting.dryRun && this.#usesDurableDispatchLifecycle()) {
14961
15235
  try {
14962
15236
  const claim = await this.#claimDispatchLifecycle(waiting.decision, false);
14963
- const lifecycleKey = issueKey(waiting.issue);
15237
+ const lifecycleKey = dispatchLifecycleKey(waiting.issue);
14964
15238
  const epoch = this.#dispatchLifecycleEpochs.get(lifecycleKey);
14965
15239
  if (claim.lifecycle.phase === 'waiting-for-human' &&
14966
15240
  (epoch === undefined || !await this.#state.promoteDispatchLifecycle(this.#workspaceId, lifecycleKey, this.#dispatchLifecycleOwner, epoch, this.#clock.now()))) {
@@ -15252,10 +15526,7 @@ export class FactoryLoop {
15252
15526
  return;
15253
15527
  }
15254
15528
  const clarifiedIssue = issueWithSlackClarification(issue, text);
15255
- const decision = await this.#triage.triage(clarifiedIssue, {
15256
- config: this.#config,
15257
- repoMap: repoMapFromConfig(this.#config),
15258
- });
15529
+ const decision = await this.#triageWithOrigin(clarifiedIssue);
15259
15530
  const escalationReason = triageEscalationReason(decision);
15260
15531
  if (escalationReason) {
15261
15532
  if (hasDispatchableRoute(decision)) {
@@ -15819,7 +16090,54 @@ const issueRefFromPath = (path) => {
15819
16090
  const key = keyFromPath(path);
15820
16091
  return { uuid: uuidFromPath(path) ?? key, key, path };
15821
16092
  };
15822
- const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
16093
+ const issueRef = (issue) => {
16094
+ const origin = mirrorWorkUnitOrigin(issue);
16095
+ return { uuid: issue.uuid, key: issue.key, path: issue.path, ...(origin ? { origin } : {}) };
16096
+ };
16097
+ /**
16098
+ * An `IssueRef` for a canonical-state record, carrying the mirror origin from
16099
+ * whichever shape the caller had — a full `LinearIssue`, or an `IssueRef` that
16100
+ * already resolved one.
16101
+ */
16102
+ const issueRefForState = (issue) => {
16103
+ const origin = issue.origin ??
16104
+ (issue.raw === undefined ? undefined : mirrorWorkUnitOrigin({ path: issue.path, raw: issue.raw }));
16105
+ return { uuid: issue.uuid, key: issue.key, path: issue.path, ...(origin ? { origin } : {}) };
16106
+ };
16107
+ /** Work-unit key, or undefined for a row with no derivable provider identity. */
16108
+ const safeDispatchLifecycleKey = (issue) => {
16109
+ try {
16110
+ return dispatchLifecycleKey(issue);
16111
+ }
16112
+ catch {
16113
+ return undefined;
16114
+ }
16115
+ };
16116
+ /**
16117
+ * Re-attaches the provider origin a triage engine did not carry through.
16118
+ * Only fills a gap — an engine that already resolved an origin keeps it.
16119
+ */
16120
+ const decisionWithMirrorOrigin = (decision, issue) => {
16121
+ if (decision.issue.origin)
16122
+ return decision;
16123
+ const origin = mirrorWorkUnitOrigin(issue);
16124
+ return origin ? { ...decision, issue: { ...decision.issue, origin } } : decision;
16125
+ };
16126
+ /**
16127
+ * The claim/dedup/lifecycle authority key: the work unit, not the surface it
16128
+ * arrived through.
16129
+ *
16130
+ * `issueKey()` composes the Relayfile sense path, so the same issue reaching
16131
+ * Factory GitHub-native and through its own Linear mirror hashed to two keys
16132
+ * and the lease never saw a conflict — both dispatched (#211, AR-448). Every
16133
+ * durable lifecycle read and write goes through this instead.
16134
+ *
16135
+ * `issueKey()` deliberately stays as-is for the surface-scoped correlation
16136
+ * maps — waiting clarifications, babysitter sessions, Slack threads, preview
16137
+ * ownership — which are keyed by surface on disk and would be orphaned by a
16138
+ * global redefinition.
16139
+ */
16140
+ const dispatchLifecycleKey = (issue) => dispatchIssueIdentity(issue);
15823
16141
  // Preserve the historical Linear state namespace while keeping GitHub-native
15824
16142
  // issue numbers independent across repositories in the same workspace.
15825
16143
  const issueStateKey = (issue) => githubIssuePathParts(issue.path) ? issueKey(issue) : issue.key;
@@ -16992,9 +17310,10 @@ const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
16992
17310
  return 0;
16993
17311
  };
16994
17312
  const hasTitlePrefix = (title, marker) => title === marker || title.startsWith(`${marker} `);
16995
- const factoryBranchMatchesIssue = (headRef, issueKey) => /^\d+$/u.test(issueKey)
16996
- ? factoryBranchBelongsToIssue(headRef, issueKey)
16997
- : containsIssueKey(headRef, issueKey);
17313
+ // Single definition shared with closure authority, so the branch rule that
17314
+ // grants a score-30 association and the one that authorises a close can never
17315
+ // drift apart.
17316
+ const factoryBranchMatchesIssue = branchImplementsIssue;
16998
17317
  // Legacy Factory runs created GitHub-native branches as `<issue-number>-*`
16999
17318
  // before the current `factory/<issue>-*` convention. Keep this matcher out of
17000
17319
  // generic PR discovery: it is only enabled by orphan recovery, where the issue