@agent-relay/factory 0.1.68 → 0.1.70

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 (64) hide show
  1. package/dist/cli/fleet.d.ts +31 -0
  2. package/dist/cli/fleet.d.ts.map +1 -1
  3. package/dist/cli/fleet.js +59 -3
  4. package/dist/cli/fleet.js.map +1 -1
  5. package/dist/config/schema.d.ts +18 -0
  6. package/dist/config/schema.d.ts.map +1 -1
  7. package/dist/config/schema.js +5 -0
  8. package/dist/config/schema.js.map +1 -1
  9. package/dist/dispatch/work-unit-identity.d.ts +18 -1
  10. package/dist/dispatch/work-unit-identity.d.ts.map +1 -1
  11. package/dist/dispatch/work-unit-identity.js +68 -0
  12. package/dist/dispatch/work-unit-identity.js.map +1 -1
  13. package/dist/fleet/create-fleet.d.ts +8 -1
  14. package/dist/fleet/create-fleet.d.ts.map +1 -1
  15. package/dist/fleet/create-fleet.js +3 -1
  16. package/dist/fleet/create-fleet.js.map +1 -1
  17. package/dist/fleet/relay-fleet-client.d.ts +44 -0
  18. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  19. package/dist/fleet/relay-fleet-client.js +221 -5
  20. package/dist/fleet/relay-fleet-client.js.map +1 -1
  21. package/dist/issue-key-match.d.ts +47 -0
  22. package/dist/issue-key-match.d.ts.map +1 -1
  23. package/dist/issue-key-match.js +174 -0
  24. package/dist/issue-key-match.js.map +1 -1
  25. package/dist/mount/relayfile-integration-preflight.js +7 -0
  26. package/dist/mount/relayfile-integration-preflight.js.map +1 -1
  27. package/dist/orchestrator/atomic-json-file.d.ts +44 -0
  28. package/dist/orchestrator/atomic-json-file.d.ts.map +1 -0
  29. package/dist/orchestrator/atomic-json-file.js +87 -0
  30. package/dist/orchestrator/atomic-json-file.js.map +1 -0
  31. package/dist/orchestrator/batch-tracker.d.ts +18 -0
  32. package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
  33. package/dist/orchestrator/batch-tracker.js +28 -13
  34. package/dist/orchestrator/batch-tracker.js.map +1 -1
  35. package/dist/orchestrator/factory.d.ts.map +1 -1
  36. package/dist/orchestrator/factory.js +456 -92
  37. package/dist/orchestrator/factory.js.map +1 -1
  38. package/dist/orchestrator/public-health.d.ts.map +1 -1
  39. package/dist/orchestrator/public-health.js +115 -13
  40. package/dist/orchestrator/public-health.js.map +1 -1
  41. package/dist/ports/state.d.ts +21 -2
  42. package/dist/ports/state.d.ts.map +1 -1
  43. package/dist/ports/state.js +18 -1
  44. package/dist/ports/state.js.map +1 -1
  45. package/dist/state/file-state-store.d.ts.map +1 -1
  46. package/dist/state/file-state-store.js +54 -11
  47. package/dist/state/file-state-store.js.map +1 -1
  48. package/dist/state/github-lifecycle-identity.d.ts +4 -4
  49. package/dist/state/github-lifecycle-identity.d.ts.map +1 -1
  50. package/dist/state/github-lifecycle-identity.js +4 -26
  51. package/dist/state/github-lifecycle-identity.js.map +1 -1
  52. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  53. package/dist/state/in-memory-state-store.js +40 -9
  54. package/dist/state/in-memory-state-store.js.map +1 -1
  55. package/dist/state/work-unit-lifecycle-migration.d.ts +85 -0
  56. package/dist/state/work-unit-lifecycle-migration.d.ts.map +1 -0
  57. package/dist/state/work-unit-lifecycle-migration.js +246 -0
  58. package/dist/state/work-unit-lifecycle-migration.js.map +1 -0
  59. package/dist/triage/heuristic.d.ts.map +1 -1
  60. package/dist/triage/heuristic.js +3 -1
  61. package/dist/triage/heuristic.js.map +1 -1
  62. package/dist/types.d.ts +79 -0
  63. package/dist/types.d.ts.map +1 -1
  64. 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';
@@ -482,6 +484,9 @@ export class FactoryLoop {
482
484
  #liveHeartbeatInFlight = false;
483
485
  #liveHeartbeatRefresh;
484
486
  #liveHeartbeatLastWriteMs = 0;
487
+ #heartbeatStartedAtMs;
488
+ #progressSequence = 0;
489
+ #lastProgressAtMs;
485
490
  #stoppingHeartbeatRefreshActive = false;
486
491
  #readinessReconcileTimer;
487
492
  #readinessReconcileInFlight;
@@ -643,7 +648,9 @@ export class FactoryLoop {
643
648
  #started = false;
644
649
  #startMode;
645
650
  #stopping = false;
651
+ #readOnly;
646
652
  constructor(config, ports) {
653
+ this.#readOnly = ports.readOnly ?? false;
647
654
  this.#config = config;
648
655
  this.#mount = ports.mount;
649
656
  // Resolved role<->state mapping. The CLI injects a name-resolved, per-team
@@ -729,7 +736,14 @@ export class FactoryLoop {
729
736
  }
730
737
  },
731
738
  });
732
- this.#wireFleetEvents();
739
+ // Read-only commands consume no fleet events, and subscribing is not free:
740
+ // the relay client mints this process's workspace identity to open the
741
+ // socket. Wiring it here unconditionally is what made `factory status`
742
+ // register an agent it then abandoned before presence (factory-cloud#55).
743
+ // The live paths wire in `#start()` instead, where the subscription is
744
+ // actually used and the identity is meant to persist.
745
+ if (!this.#readOnly)
746
+ this.#wireFleetEvents();
733
747
  }
734
748
  async #resolveIntegrationInstructions() {
735
749
  if (this.#integrationInstructionsRefresh) {
@@ -807,7 +821,7 @@ export class FactoryLoop {
807
821
  * row to wait on, or the wait ended because Factory is stopping.
808
822
  */
809
823
  async waitForDispatchTerminal(issue) {
810
- const key = issueKey(issue);
824
+ const key = dispatchLifecycleKey(issue);
811
825
  const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
812
826
  // No durable row means this dispatch never claimed a lifecycle: a
813
827
  // dependency park, a triage escalation, and a label refusal all return
@@ -893,6 +907,13 @@ export class FactoryLoop {
893
907
  return observedPhase;
894
908
  }
895
909
  async start(opts = {}) {
910
+ // A read-only instance was built with no fleet event wiring and, in the
911
+ // CLI, with a fleet client that cannot register. Starting one would be a
912
+ // half-live daemon; refuse rather than let a future caller reintroduce the
913
+ // side effect this mode exists to remove.
914
+ if (this.#readOnly) {
915
+ throw new Error('Factory was constructed read-only and cannot be started');
916
+ }
896
917
  if (this.#started) {
897
918
  return;
898
919
  }
@@ -1290,6 +1311,7 @@ export class FactoryLoop {
1290
1311
  }
1291
1312
  }
1292
1313
  async #startLiveHeartbeat() {
1314
+ this.#beginHeartbeatLifetime();
1293
1315
  this.#liveHeartbeatActive = true;
1294
1316
  await this.#writeLiveHeartbeat('running');
1295
1317
  this.#scheduleLiveHeartbeatRefresh();
@@ -1354,6 +1376,11 @@ export class FactoryLoop {
1354
1376
  await this.#writeLoopHeartbeat(this.#config.loop.heartbeatPath, this.#config.loop.registryPath, status, 0, 0);
1355
1377
  this.#liveHeartbeatLastWriteMs = this.#clock.now();
1356
1378
  }
1379
+ #beginHeartbeatLifetime() {
1380
+ this.#heartbeatStartedAtMs = this.#clock.now();
1381
+ this.#progressSequence = 0;
1382
+ this.#lastProgressAtMs = undefined;
1383
+ }
1357
1384
  #liveOptions(overrides) {
1358
1385
  return {
1359
1386
  transport: overrides.transport ?? this.#config.liveSubscription.transport,
@@ -1853,7 +1880,7 @@ export class FactoryLoop {
1853
1880
  try {
1854
1881
  const batch = await this.#batch();
1855
1882
  const records = batch.inFlight
1856
- .filter((record) => !record.dryRun && !this.#completionInFlight.has(issueKey(record.issue)));
1883
+ .filter((record) => !record.dryRun && !this.#completionInFlight.has(dispatchLifecycleKey(record.issue)));
1857
1884
  if (records.length === 0) {
1858
1885
  return;
1859
1886
  }
@@ -2009,7 +2036,7 @@ export class FactoryLoop {
2009
2036
  async #orphanedPrMetaPaths(record) {
2010
2037
  const wanted = [];
2011
2038
  if (this.#usesDurableDispatchLifecycle()) {
2012
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
2039
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
2013
2040
  const receipts = lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : []);
2014
2041
  for (const receipt of receipts)
2015
2042
  wanted.push({ repo: receipt.repo, prNumber: receipt.number });
@@ -2294,6 +2321,10 @@ export class FactoryLoop {
2294
2321
  leaseReleased = completed;
2295
2322
  if (!completed)
2296
2323
  throw new Error('discovery sweep lease was lost before completion');
2324
+ // This is the progress boundary consumed by deployment health. A timer
2325
+ // firing, a sweep starting, or a failed/deferred sweep must not move it.
2326
+ this.#progressSequence += 1;
2327
+ this.#lastProgressAtMs = this.#clock.now();
2297
2328
  this.#logger.info?.('[factory] discovery sweep checkpoint committed', {
2298
2329
  owner: claim.lease.owner,
2299
2330
  epoch: claim.lease.epoch,
@@ -2560,12 +2591,13 @@ export class FactoryLoop {
2560
2591
  const labels = isGithubIssue(issue)
2561
2592
  ? new Set(issue.labels.map((label) => label.trim().toLowerCase()))
2562
2593
  : undefined;
2563
- const requiredLabel = this.#config.safety.requireLabel.trim().toLowerCase();
2594
+ // Must stay the same scope test as dispatch and as
2595
+ // #reconcileOrphanedGithubInProgress. Gating on the scope label alone
2596
+ // left every title-scoped issue stuck in `factory:in-progress` forever.
2564
2597
  const mayRecoverGithubOrphan = !wasReady &&
2565
2598
  !dryRun &&
2566
2599
  issueSource === 'github' &&
2567
- Boolean(requiredLabel) &&
2568
- Boolean(labels?.has(requiredLabel)) &&
2600
+ isInFactoryScope(issue, this.#config.safety) &&
2569
2601
  Boolean(labels?.has('factory:in-progress')) &&
2570
2602
  !labels?.has('factory:human-review');
2571
2603
  if (!mayRecoverGithubOrphan) {
@@ -3182,9 +3214,13 @@ export class FactoryLoop {
3182
3214
  if (!isGithubIssue(issue))
3183
3215
  return { recovered: false, reason: 'issue is not GitHub-native' };
3184
3216
  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) ||
3217
+ // Recovery must admit exactly what dispatch admits. Dispatch accepts the
3218
+ // configured title prefix OR the scope label (`isInFactoryScope`), but this
3219
+ // gate used to demand the label alone — so an issue admitted by its title
3220
+ // could be dispatched and then never un-stuck, keeping `factory:in-progress`
3221
+ // forever once its dispatch died. factory#139 is the live instance: title
3222
+ // `[factory] ...`, and its only label is `factory:in-progress`.
3223
+ if (!isInFactoryScope(issue, this.#config.safety) ||
3188
3224
  !labels.has('factory:in-progress') ||
3189
3225
  labels.has('factory:human-review'))
3190
3226
  return { recovered: false, reason: 'issue is not an orphan-recovery candidate' };
@@ -3316,7 +3352,7 @@ export class FactoryLoop {
3316
3352
  }
3317
3353
  }
3318
3354
  async #releaseOrphanedGithubLifecycle(issue, identity, candidate) {
3319
- let key = candidate.key;
3355
+ const key = candidate.key;
3320
3356
  let lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
3321
3357
  if (!lifecycle)
3322
3358
  return true;
@@ -3356,7 +3392,6 @@ export class FactoryLoop {
3356
3392
  });
3357
3393
  return false;
3358
3394
  }
3359
- key = claim.key ?? key;
3360
3395
  lifecycle = claim.lifecycle;
3361
3396
  epoch = claim.lease.epoch;
3362
3397
  this.#dispatchLifecycleEpochs.set(key, epoch);
@@ -3499,7 +3534,7 @@ export class FactoryLoop {
3499
3534
  const record = batch.start(decision, false);
3500
3535
  if (!record) {
3501
3536
  if (durableAdoption) {
3502
- const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(decision.issue));
3537
+ const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(decision.issue));
3503
3538
  if (durable) {
3504
3539
  this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(durable));
3505
3540
  this.#increment('githubOrphanedPullRequestsAdopted');
@@ -3750,6 +3785,7 @@ export class FactoryLoop {
3750
3785
  const maxConsecutiveFailures = Math.min(5, Math.max(1, Math.trunc(opts.maxConsecutiveFailures ?? this.#config.loop.maxConsecutiveFailures)));
3751
3786
  const heartbeatPath = opts.heartbeatPath ?? this.#config.loop.heartbeatPath;
3752
3787
  const registryPath = opts.registryPath ?? this.#config.loop.registryPath;
3788
+ this.#beginHeartbeatLifetime();
3753
3789
  this.#loopReapPaths = { heartbeatPath, registryPath };
3754
3790
  const reports = [];
3755
3791
  let consecutiveFailures = 0;
@@ -3814,15 +3850,29 @@ export class FactoryLoop {
3814
3850
  }
3815
3851
  }
3816
3852
  async triageIssue(issue) {
3817
- return this.#triage.triage(issue, {
3853
+ return await this.#triageWithOrigin(issue);
3854
+ }
3855
+ /**
3856
+ * The mirror origin is a property of the issue Factory fed in, not of the
3857
+ * engine's answer, so it is re-attached here rather than trusted from the
3858
+ * engine. `TriageDecisionSchema` drops unknown keys, so `LlmTriage` returns a
3859
+ * decision that has lost it, and a custom `TriageEngine` never set it at all
3860
+ * — either way a Linear mirror would fall back to `linear:<uuid>` and
3861
+ * duplicate its own GitHub-native arrival, which is the defect #211 fixes.
3862
+ */
3863
+ async #triageWithOrigin(issue) {
3864
+ const decision = await this.#triage.triage(issue, {
3818
3865
  config: this.#config,
3819
3866
  repoMap: repoMapFromConfig(this.#config),
3820
3867
  });
3868
+ return decisionWithMirrorOrigin(decision, issue);
3821
3869
  }
3822
3870
  async dispatch(decision, opts = {}) {
3823
3871
  const dryRun = opts.dryRun ?? this.#config.dryRun;
3824
3872
  const phase = triageEscalationReason(decision) ? 'escalation' : 'dispatch';
3825
- const key = `${issueStateKey(decision.issue)}:${dryRun ? 'dry-run' : 'live'}:${phase}`;
3873
+ // The dispatch concurrency gate is per work unit: a mirror of an issue
3874
+ // already being dispatched must join that call, not start a second one.
3875
+ const key = `${dispatchLifecycleKey(decision.issue)}:${dryRun ? 'dry-run' : 'live'}:${phase}`;
3826
3876
  const inFlight = this.#dispatchInFlight.get(key);
3827
3877
  if (inFlight) {
3828
3878
  this.#increment('dispatchDuplicateSuppressed');
@@ -3850,9 +3900,9 @@ export class FactoryLoop {
3850
3900
  return existingRecord.result;
3851
3901
  }
3852
3902
  if (!dryRun && this.#usesDurableDispatchLifecycle()) {
3853
- const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(decision.issue));
3903
+ const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(decision.issue));
3854
3904
  if (durable && !isTerminalDispatchLifecycle(durable)) {
3855
- if (durable.result && this.#dispatchLifecycleEpochs.has(issueKey(decision.issue))) {
3905
+ if (durable.result && this.#dispatchLifecycleEpochs.has(dispatchLifecycleKey(decision.issue))) {
3856
3906
  return durable.result;
3857
3907
  }
3858
3908
  if (this.#startMode === 'dispatch-owner') {
@@ -4107,7 +4157,7 @@ export class FactoryLoop {
4107
4157
  state: 'pending',
4108
4158
  updatedAtMs: this.#clock.now(),
4109
4159
  };
4110
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
4160
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
4111
4161
  }
4112
4162
  await this.#writeInFlightRegistry();
4113
4163
  const comment = dispatchComment(dispatchDecision, agents);
@@ -4115,10 +4165,37 @@ export class FactoryLoop {
4115
4165
  if (!dryRun) {
4116
4166
  const issue = await this.#readIssue(dispatchDecision.issue.path);
4117
4167
  if (!issue || !this.#isIssueReady(issue)) {
4118
- throw new LiveDispatchStateChangedError(dispatchDecision.issue.key);
4168
+ // The agents spawned a few lines above can reach terminal before we
4169
+ // get here. Their completion writeback parks the issue — Linear
4170
+ // `humanReview`, or the GitHub human-review label — and stamps
4171
+ // *this same record's* lifecycle on the way past. Re-reading the
4172
+ // issue then shows "not ready", but the writer was us.
4173
+ //
4174
+ // Treating that as a foreign change is not a cosmetic misreport: the
4175
+ // catch below classifies LiveDispatchStateChangedError as terminal
4176
+ // and calls #releaseAndTerminateAgents, so a dispatch whose agents
4177
+ // finished quickly tore down its own completed work and reported
4178
+ // RETRYABLE to its supervisor (factory#319).
4179
+ //
4180
+ // Only THIS dispatch's own confirmed issue writeback may excuse the
4181
+ // change. Lifecycle phases are not enough: `publishing` is entered
4182
+ // before the PR is published and `parking` before anything touches
4183
+ // the issue, so a phase records local progress, not authorship — and
4184
+ // a foreign park landing during those awaits would be misread as
4185
+ // ours (codex review on #321). Losing the row to another owner is a
4186
+ // different condition and is still caught where it always was, by
4187
+ // #saveDispatchLifecycle returning false.
4188
+ if (record.issueWritebackConfirmedAtMs === undefined) {
4189
+ throw new LiveDispatchStateChangedError(dispatchDecision.issue.key);
4190
+ }
4191
+ // The claim is moot and would be wrong to write: it would drag an
4192
+ // issue our own lifecycle has already parked back to `implementing`.
4193
+ }
4194
+ else {
4195
+ implementingStateId = await this.#applyDispatchClaim(record, issue, comment);
4196
+ record.issueWritebackConfirmedAtMs = this.#clock.now();
4197
+ this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
4119
4198
  }
4120
- implementingStateId = await this.#applyDispatchClaim(record, issue, comment);
4121
- this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
4122
4199
  }
4123
4200
  const result = {
4124
4201
  issue: dispatchDecision.issue,
@@ -4245,7 +4322,7 @@ export class FactoryLoop {
4245
4322
  ...(tracked.result?.node ? { node: tracked.result.node } : {}),
4246
4323
  })),
4247
4324
  claim: {
4248
- ...(record.dispatchClaim ?? this.#dispatchClaimStatuses.get(issueKey(record.issue)) ?? {
4325
+ ...(record.dispatchClaim ?? this.#dispatchClaimStatuses.get(dispatchLifecycleKey(record.issue)) ?? {
4249
4326
  state: 'pending',
4250
4327
  updatedAtMs: nowMs,
4251
4328
  }),
@@ -4475,7 +4552,25 @@ export class FactoryLoop {
4475
4552
  if (previews.length > 0)
4476
4553
  this.#previewReferences.set(issueKey(lifecycle.issue), previews);
4477
4554
  hasNonterminalDurableLifecycle = true;
4478
- const claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, lifecycle, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
4555
+ // A work unit whose keys cannot be reconciled must not strand the rows
4556
+ // behind it — #315 is the same lesson from the shed-read path. Retain
4557
+ // the conflicting rows, leave that one unit blocked for an operator,
4558
+ // and carry on adopting the rest.
4559
+ let claim;
4560
+ try {
4561
+ claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, lifecycle, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
4562
+ }
4563
+ catch (error) {
4564
+ if (!(error instanceof DispatchLifecycleMigrationConflictError))
4565
+ throw error;
4566
+ this.#increment('dispatchLifecycleMigrationConflicts');
4567
+ this.#logger.error?.('[factory] dispatch is blocked for one work unit until its keys are reconciled', {
4568
+ issue: lifecycle.issue.key,
4569
+ canonicalKey: error.canonicalKey,
4570
+ conflictingKeys: error.conflictingKeys,
4571
+ });
4572
+ continue;
4573
+ }
4479
4574
  if (!claim.acquired || !claim.lease) {
4480
4575
  // The other process may have crashed while its nominal lease is
4481
4576
  // still live. Keep this process attached so it reclaims the row
@@ -4483,7 +4578,7 @@ export class FactoryLoop {
4483
4578
  this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(claim.lifecycle));
4484
4579
  continue;
4485
4580
  }
4486
- this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
4581
+ this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
4487
4582
  this.#hydrateCostLedger(claim.lifecycle);
4488
4583
  if (claim.lifecycle.phase === 'waiting-for-human')
4489
4584
  continue;
@@ -4492,7 +4587,37 @@ export class FactoryLoop {
4492
4587
  if (!durableRecord.dryRun &&
4493
4588
  claim.lifecycle.phase !== 'writeback-applied' &&
4494
4589
  claim.lifecycle.phase !== 'releasing') {
4495
- liveIssue = await this.#readIssue(durableRecord.issue.path);
4590
+ // `#readIssue` swallows every read failure except a relayfile 429,
4591
+ // which it rethrows so callers can back off. On this path that
4592
+ // rethrow aborted the whole adoption: this row was never restored
4593
+ // into the batch, every later row was skipped, and no hold deadline
4594
+ // was armed for any of them (#315). Startup already degrades safely
4595
+ // when the issue is simply unreadable — it treats the row as not
4596
+ // externally terminal and lets the retry driver carry it — so a shed
4597
+ // read must degrade the same way rather than strand the batch.
4598
+ try {
4599
+ liveIssue = await this.#readIssue(durableRecord.issue.path);
4600
+ }
4601
+ catch (error) {
4602
+ const overload = relayfileOverload(error);
4603
+ if (!overload)
4604
+ throw error;
4605
+ this.#increment('startupIssueReadsShed');
4606
+ this.#logger.warn?.('[factory] startup issue read was shed; adopting without it', {
4607
+ issue: durableRecord.issue.key,
4608
+ status: overload.status,
4609
+ reason: relayfileOverloadReasonLabel(overload.reason),
4610
+ });
4611
+ }
4612
+ // A row persisted before #211 has no `origin`, so a Linear mirror
4613
+ // still resolves to `linear:<uuid>` and a GitHub-native arrival for
4614
+ // the same work unit would claim alongside it — the rolling-upgrade
4615
+ // form of the duplicate this change removes. Nothing on the row links
4616
+ // it to its upstream, but the issue we just read does, so backfill it
4617
+ // here under the lease this process already holds. The next claim or
4618
+ // load then recognises the row by identity and re-keys it.
4619
+ if (liveIssue)
4620
+ await this.#backfillMirrorOrigin(key, claim, liveIssue);
4496
4621
  // A babysat Linear issue already at Done may have merged while this
4497
4622
  // process was down. Let authoritative PR restoration drive the
4498
4623
  // normal `complete` path so merged work is not mislabeled abandoned.
@@ -4522,7 +4647,7 @@ export class FactoryLoop {
4522
4647
  if (claim.lifecycle.phase === 'running' &&
4523
4648
  this.#ticketDispatchNotificationIsPending(claim.lifecycle)) {
4524
4649
  if (!liveIssue) {
4525
- this.#dispatchLifecycleEpochs.delete(claim.key ?? key);
4650
+ this.#dispatchLifecycleEpochs.delete(key);
4526
4651
  this.#scheduleDispatchLifecycleRetry(restored);
4527
4652
  throw new Error(`Unable to recover ticket-dispatch notification ${restored.issue.key}: issue is not currently readable`);
4528
4653
  }
@@ -4629,11 +4754,28 @@ export class FactoryLoop {
4629
4754
  });
4630
4755
  }
4631
4756
  }
4632
- this.#rescheduleHeldAgentDeadlineSweep();
4633
4757
  }
4634
4758
  catch (error) {
4635
4759
  this.#logger.warn?.('[factory] failed to re-adopt durable in-flight agents', { error });
4636
4760
  }
4761
+ finally {
4762
+ // Arming has to survive everything above it (#315).
4763
+ //
4764
+ // This is the only thing that arms a hold deadline for a row that
4765
+ // survived a restart, and it used to be the last statement of the `try`.
4766
+ // Two fleet awaits sit above it — `reconcileTrackedAgents`, which is not
4767
+ // circuit-proxied and so reaches the transport, and the guarded `roster`,
4768
+ // which throws outright once the circuit is open. With the broker down
4769
+ // either one throws, the catch logs, and no hold deadline is armed for
4770
+ // that boot: the #304 reaper is present in the build and was never
4771
+ // scheduled. That is the failure mode reaping exists to clear, so the
4772
+ // arming must not be reachable only along the happy path.
4773
+ //
4774
+ // A `dispatching` row is additionally armed by the per-row retry driver
4775
+ // below, which is why the wedge shows up on a `running` row: `running` is
4776
+ // the one slot-occupying phase that never gets a retry scheduled here.
4777
+ this.#rescheduleHeldAgentDeadlineSweep();
4778
+ }
4637
4779
  }
4638
4780
  async #deduplicateQueuedGithubLifecycleAliases(lifecycles) {
4639
4781
  const groups = new Map();
@@ -4852,7 +4994,7 @@ export class FactoryLoop {
4852
4994
  // never-placed — which also excludes its agents from the release below,
4853
4995
  // leaking live workers (#303 review, CodeRabbit).
4854
4996
  let effective = deadline;
4855
- const key = issueKey(record.issue);
4997
+ const key = dispatchLifecycleKey(record.issue);
4856
4998
  if (this.#abandonedDispatchReasons.has(key))
4857
4999
  continue;
4858
5000
  if (this.#usesDurableDispatchLifecycle()) {
@@ -4904,6 +5046,36 @@ export class FactoryLoop {
4904
5046
  }
4905
5047
  }
4906
5048
  }
5049
+ /**
5050
+ * Records the upstream origin on an adopted lifecycle that predates it.
5051
+ * Best-effort: a failed write leaves the row exactly as it was, and the next
5052
+ * startup tries again.
5053
+ */
5054
+ async #backfillMirrorOrigin(key, claim, liveIssue) {
5055
+ const epoch = claim.lease?.epoch;
5056
+ if (epoch === undefined || claim.lifecycle.issue.origin)
5057
+ return;
5058
+ const origin = mirrorWorkUnitOrigin(liveIssue);
5059
+ if (!origin)
5060
+ return;
5061
+ const issue = { ...claim.lifecycle.issue, origin };
5062
+ const enriched = {
5063
+ ...claim.lifecycle,
5064
+ issue,
5065
+ decision: { ...claim.lifecycle.decision, issue },
5066
+ };
5067
+ const saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), enriched).catch((error) => {
5068
+ this.#logger.warn?.('[factory] could not backfill mirror origin on an adopted lifecycle', {
5069
+ issue: claim.lifecycle.issue.key,
5070
+ error: describeError(error).errorMessage,
5071
+ });
5072
+ return false;
5073
+ });
5074
+ if (!saved)
5075
+ return;
5076
+ claim.lifecycle = enriched;
5077
+ this.#increment('dispatchLifecycleMirrorOriginsBackfilled');
5078
+ }
4907
5079
  async #renewDispatchLifecycles() {
4908
5080
  for (const [key, epoch] of [...this.#dispatchLifecycleEpochs]) {
4909
5081
  const renewed = await this.#state.renewDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
@@ -4928,7 +5100,7 @@ export class FactoryLoop {
4928
5100
  }
4929
5101
  }
4930
5102
  async #claimDispatchLifecycle(decision, dryRun, preparedRunId, initial = {}) {
4931
- const key = issueKey(decision.issue);
5103
+ const key = dispatchLifecycleKey(decision.issue);
4932
5104
  const seed = {
4933
5105
  runId: preparedRunId ?? randomUUID(),
4934
5106
  issue: { ...decision.issue },
@@ -4951,7 +5123,7 @@ export class FactoryLoop {
4951
5123
  : `dispatch lifecycle is owned by ${claim.lifecycle.lease?.owner ?? 'another publisher'}`;
4952
5124
  throw new DispatchLifecycleClaimRefusedError(decision.issue.key, terminal ? 'terminal' : 'owned-elsewhere', `Refusing to dispatch ${decision.issue.key}: ${reason}`);
4953
5125
  }
4954
- this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
5126
+ this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
4955
5127
  this.#hydrateCostLedger(claim.lifecycle);
4956
5128
  this.#scheduleDispatchLifecycleRenewal();
4957
5129
  if (claim.created) {
@@ -5007,7 +5179,7 @@ export class FactoryLoop {
5007
5179
  }
5008
5180
  async #reportAgent(record, tracked, type, options = {}) {
5009
5181
  const lifecycle = await this.#state
5010
- .getDispatchLifecycle(this.#workspaceId, issueKey(record.issue))
5182
+ .getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
5011
5183
  .catch(() => undefined);
5012
5184
  if (!lifecycle)
5013
5185
  return;
@@ -5035,7 +5207,7 @@ export class FactoryLoop {
5035
5207
  }
5036
5208
  async #handleAgentUsage(usage, key) {
5037
5209
  const record = (await this.#batch()).getIssueByAgent(usage.name);
5038
- const activeRecord = record && issueKey(record.issue) === key ? record : undefined;
5210
+ const activeRecord = record && dispatchLifecycleKey(record.issue) === key ? record : undefined;
5039
5211
  let tracked = activeRecord?.agents.get(usage.name);
5040
5212
  const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
5041
5213
  // Completion removes the in-memory batch record immediately after the
@@ -5143,7 +5315,7 @@ export class FactoryLoop {
5143
5315
  async #dispatchLifecycleKeyForAgent(name) {
5144
5316
  const record = (await this.#batch()).getIssueByAgent(name);
5145
5317
  if (record?.agents.has(name))
5146
- return issueKey(record.issue);
5318
+ return dispatchLifecycleKey(record.issue);
5147
5319
  return (await this.#findDispatchLifecycleAgent(name))?.key;
5148
5320
  }
5149
5321
  #serializeDispatchLifecyclePersistence(key, operation) {
@@ -5283,7 +5455,7 @@ export class FactoryLoop {
5283
5455
  return true;
5284
5456
  if (isTerminalDispatchPhase(phase))
5285
5457
  await this.#drainAgentUsage();
5286
- const key = issueKey(record.issue);
5458
+ const key = dispatchLifecycleKey(record.issue);
5287
5459
  return this.#serializeDispatchLifecyclePersistence(key, async () => {
5288
5460
  const epoch = this.#dispatchLifecycleEpochs.get(key);
5289
5461
  if (epoch === undefined) {
@@ -5368,7 +5540,7 @@ export class FactoryLoop {
5368
5540
  // after release could see it cleared, or see the next dispatch for the same
5369
5541
  // issue, and classify the wrong run.
5370
5542
  #resolveDispatchTerminalWaiters(issue, phase) {
5371
- const key = issueKey(issue);
5543
+ const key = dispatchLifecycleKey(issue);
5372
5544
  for (const resolve of this.#dispatchTerminalWaiters.get(key) ?? [])
5373
5545
  resolve(phase);
5374
5546
  this.#dispatchTerminalWaiters.delete(key);
@@ -5497,7 +5669,7 @@ export class FactoryLoop {
5497
5669
  this.#increment('dispatchCapacityBackoffResets');
5498
5670
  }
5499
5671
  #scheduleDispatchLifecycleRetry(record, delayMs = DISPATCH_LIFECYCLE_RETRY_MS) {
5500
- const key = issueKey(record.issue);
5672
+ const key = dispatchLifecycleKey(record.issue);
5501
5673
  if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
5502
5674
  return;
5503
5675
  const timer = setTimeout(() => {
@@ -5547,7 +5719,7 @@ export class FactoryLoop {
5547
5719
  this.#scheduleDispatchLifecycleRetry(record);
5548
5720
  return;
5549
5721
  }
5550
- const key = issueKey(record.issue);
5722
+ const key = dispatchLifecycleKey(record.issue);
5551
5723
  if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
5552
5724
  return;
5553
5725
  const timer = setTimeout(() => {
@@ -5585,7 +5757,7 @@ export class FactoryLoop {
5585
5757
  if (!claim.acquired || !claim.lease) {
5586
5758
  throw new DispatchLifecycleOwnedElsewhereError(claim.lifecycle.lease?.leaseUntilMs);
5587
5759
  }
5588
- this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
5760
+ this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
5589
5761
  this.#hydrateCostLedger(claim.lifecycle);
5590
5762
  this.#scheduleDispatchLifecycleRenewal();
5591
5763
  lifecycle = claim.lifecycle;
@@ -5838,7 +6010,7 @@ export class FactoryLoop {
5838
6010
  state: 'pending',
5839
6011
  updatedAtMs: this.#clock.now(),
5840
6012
  };
5841
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
6013
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
5842
6014
  }
5843
6015
  await this.#writeInFlightRegistry();
5844
6016
  if (!record.dryRun) {
@@ -5964,7 +6136,7 @@ export class FactoryLoop {
5964
6136
  async #finishDurableRelease(record, releaseReason) {
5965
6137
  const batch = await this.#batch();
5966
6138
  const reason = releaseReason ?? (this.#config.terminalState === 'human-review' ? 'issue-human-review' : 'issue-done');
5967
- const releaseKey = issueKey(record.issue);
6139
+ const releaseKey = dispatchLifecycleKey(record.issue);
5968
6140
  // Terminal writeback has already been acknowledged before this method is
5969
6141
  // entered. Remove externally reachable routes first so a stuck agent
5970
6142
  // release cannot leave a preview live after Human Review or Done.
@@ -6040,7 +6212,7 @@ export class FactoryLoop {
6040
6212
  async #assertIssueDispatchLifecycleOwner(issue) {
6041
6213
  if (!this.#usesDurableDispatchLifecycle())
6042
6214
  return true;
6043
- const key = issueKey(issue);
6215
+ const key = dispatchLifecycleKey(issue);
6044
6216
  const epoch = this.#dispatchLifecycleEpochs.get(key);
6045
6217
  if (epoch === undefined)
6046
6218
  return false;
@@ -6179,7 +6351,7 @@ export class FactoryLoop {
6179
6351
  state: 'verified',
6180
6352
  updatedAtMs: this.#clock.now(),
6181
6353
  };
6182
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
6354
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
6183
6355
  await this.#writeDispatchClaimRegistry(record.issue);
6184
6356
  return implementingStateId;
6185
6357
  }
@@ -6208,7 +6380,7 @@ export class FactoryLoop {
6208
6380
  ...(deadLettered ? { deadLettered: true } : {}),
6209
6381
  updatedAtMs: this.#clock.now(),
6210
6382
  };
6211
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
6383
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
6212
6384
  await this.#writeDispatchClaimRegistry(record.issue);
6213
6385
  if (deadLettered) {
6214
6386
  this.#increment('dispatchWritebackDeadLetters');
@@ -6437,7 +6609,16 @@ export class FactoryLoop {
6437
6609
  !labels.every((label) => typeof label === 'string')) {
6438
6610
  return undefined;
6439
6611
  }
6440
- if (state !== 'open' || !labels.some((label) => label.trim().toLowerCase() === requiredLabel)) {
6612
+ // Retain Factory's own lifecycle rows even when they lack the scope
6613
+ // label. The index carries no title, so a title-scoped issue cannot be
6614
+ // recognised here — and dropping it means its file is never read and the
6615
+ // orphan-recovery sweep never sees it. `factory:in-progress` is a label
6616
+ // only Factory applies, so a row carrying it is by definition
6617
+ // Factory-touched and worth reading; `isInFactoryScope` downstream
6618
+ // remains the authority on whether anything may be done with it.
6619
+ const rowLabels = labels.map((label) => label.trim().toLowerCase());
6620
+ if (state !== 'open' ||
6621
+ !(rowLabels.includes(requiredLabel) || rowLabels.includes('factory:in-progress'))) {
6441
6622
  continue;
6442
6623
  }
6443
6624
  paths.push(`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`);
@@ -7034,24 +7215,48 @@ export class FactoryLoop {
7034
7215
  await this.#state.recordDispatchAttempt(this.#workspaceId, key, dispatchState);
7035
7216
  this.#increment('dispatchTerminalReopened');
7036
7217
  }
7037
- for (const [key, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
7038
- if (lifecycle.issue.key !== issue.key || !isTerminalDispatchLifecycle(lifecycle))
7218
+ // Match on the work unit, not the surface key: a completed Linear mirror
7219
+ // persists `AR-448` while the GitHub-native arrival of the same issue is
7220
+ // `448`, and comparing surface keys would leave that terminal row in
7221
+ // place to refuse the reopened work forever.
7222
+ const reopenedIdentity = safeDispatchLifecycleKey(issueRefForState(issue));
7223
+ for (const [lifecycleKey, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
7224
+ if (!isTerminalDispatchLifecycle(lifecycle))
7039
7225
  continue;
7040
- await this.#state.clearDispatchLifecycle(this.#workspaceId, key);
7041
- this.#dispatchLifecycleEpochs.delete(key);
7226
+ const matches = reopenedIdentity !== undefined &&
7227
+ safeDispatchLifecycleKey(lifecycle.issue) === reopenedIdentity;
7228
+ if (!matches && lifecycle.issue.key !== issue.key)
7229
+ continue;
7230
+ await this.#state.clearDispatchLifecycle(this.#workspaceId, lifecycleKey);
7231
+ this.#dispatchLifecycleEpochs.delete(lifecycleKey);
7042
7232
  }
7043
7233
  }
7044
7234
  await this.#state.recordCanonicalState(this.#workspaceId, key, issue.stateId);
7045
7235
  }
7046
7236
  async #writeLoopHeartbeat(path, registryPath, status, iteration, maxIterations) {
7047
7237
  const updatedAtMs = this.#clock.now();
7238
+ this.#heartbeatStartedAtMs ??= updatedAtMs;
7048
7239
  const heartbeat = {
7049
7240
  pid: process.pid,
7050
7241
  status,
7242
+ source: maxIterations === 0 ? 'live-timer' : 'bounded-loop',
7051
7243
  iteration,
7052
7244
  maxIterations,
7245
+ startedAt: new Date(this.#heartbeatStartedAtMs).toISOString(),
7246
+ startedAtMs: this.#heartbeatStartedAtMs,
7247
+ progressContract: 'discovery-sweep-v1',
7053
7248
  updatedAt: new Date(updatedAtMs).toISOString(),
7054
7249
  updatedAtMs,
7250
+ ...(this.#lastProgressAtMs !== undefined
7251
+ ? {
7252
+ progress: {
7253
+ sequence: this.#progressSequence,
7254
+ operation: 'discovery-sweep',
7255
+ updatedAt: new Date(this.#lastProgressAtMs).toISOString(),
7256
+ updatedAtMs: this.#lastProgressAtMs,
7257
+ },
7258
+ }
7259
+ : {}),
7055
7260
  registryPath,
7056
7261
  eventListener: this.#eventListenerStatus(),
7057
7262
  readinessReconcile: this.#readinessReconcileStatus(),
@@ -7081,8 +7286,10 @@ export class FactoryLoop {
7081
7286
  error: describeError(error).errorMessage,
7082
7287
  });
7083
7288
  }
7084
- await mkdir(dirname(path), { recursive: true });
7085
- await writeFile(path, `${JSON.stringify(heartbeat, null, 2)}\n`, 'utf8');
7289
+ // Atomic publish (#323): `/healthz` and the crash reaper read this file
7290
+ // while it is being rewritten, and a torn read reaches them as a missing
7291
+ // heartbeat, i.e. as a dead daemon.
7292
+ await writeJsonFileAtomically(path, heartbeat);
7086
7293
  await this.#writeInFlightRegistry(registryPath, path);
7087
7294
  const batch = this.#batchView;
7088
7295
  await this.#report({
@@ -7369,7 +7576,7 @@ export class FactoryLoop {
7369
7576
  this.#logger.warn?.(`[factory] failed to release ${agentName} during ${context}`, error);
7370
7577
  if (record) {
7371
7578
  const lifecycle = await this.#state
7372
- .getDispatchLifecycle(this.#workspaceId, issueKey(record.issue))
7579
+ .getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
7373
7580
  .catch(() => undefined);
7374
7581
  if (lifecycle) {
7375
7582
  await this.#reportLifecycle(lifecycle, 'factory.failure', {
@@ -7544,7 +7751,7 @@ export class FactoryLoop {
7544
7751
  }
7545
7752
  }
7546
7753
  const fleetTracked = this.#fleet.trackedAgents?.().get(agentName);
7547
- const dispatchClaim = this.#dispatchClaimStatuses.get(issueKey(issue));
7754
+ const dispatchClaim = this.#dispatchClaimStatuses.get(dispatchLifecycleKey(issue));
7548
7755
  agents.push({
7549
7756
  name: agentName,
7550
7757
  role: tracked.spec.role,
@@ -7568,7 +7775,7 @@ export class FactoryLoop {
7568
7775
  if (record.dryRun)
7569
7776
  continue;
7570
7777
  if (record.dispatchClaim) {
7571
- this.#dispatchClaimStatuses.set(issueKey(record.issue), record.dispatchClaim);
7778
+ this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
7572
7779
  }
7573
7780
  for (const [agentName, tracked] of record.agents) {
7574
7781
  await appendAgent(record.issue, agentName, tracked, record);
@@ -7585,8 +7792,10 @@ export class FactoryLoop {
7585
7792
  updatedAtMs,
7586
7793
  agents,
7587
7794
  };
7588
- await mkdir(dirname(path), { recursive: true });
7589
- await writeFile(path, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
7795
+ // Atomic publish (#323): the crash reaper reads this file in the same
7796
+ // sweep as the heartbeat, so leaving it torn keeps the reaper racy even
7797
+ // with an atomic heartbeat.
7798
+ await writeJsonFileAtomically(path, registry);
7590
7799
  }
7591
7800
  async #spawnAgent(record, spec, dryRun) {
7592
7801
  const batch = await this.#batch();
@@ -7697,7 +7906,7 @@ export class FactoryLoop {
7697
7906
  * durable row, which is authoritative when another owner terminalized it.
7698
7907
  */
7699
7908
  async #dispatchLifecycleStillOwned(record) {
7700
- const key = issueKey(record.issue);
7909
+ const key = dispatchLifecycleKey(record.issue);
7701
7910
  if (this.#abandonedDispatchReasons.has(key))
7702
7911
  return false;
7703
7912
  if (!this.#usesDurableDispatchLifecycle())
@@ -7814,7 +8023,7 @@ export class FactoryLoop {
7814
8023
  if (tracingReconciledExit)
7815
8024
  this.#logger.info?.('[factory] reconciled agent exit telemetry completed', { issue: record.issue.key, name });
7816
8025
  if (this.#usesDurableDispatchLifecycle()) {
7817
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
8026
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
7818
8027
  if (lifecycle?.phase === 'parking') {
7819
8028
  this.#increment('clarificationParkingExitsSuppressed');
7820
8029
  return;
@@ -8004,7 +8213,7 @@ export class FactoryLoop {
8004
8213
  // that exited immediately consume a batch slot forever while Factory
8005
8214
  // continuously recreated it. One successful recovery is enough; a
8006
8215
  // subsequent no-PR implementer exit is terminal and frees the slot.
8007
- const recoveryLifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
8216
+ const recoveryLifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
8008
8217
  const recoveryRunIdentity = recoveryLifecycle?.runId
8009
8218
  ?? tracked.spec.branch
8010
8219
  ?? batch.invocationIdFor(record.issue, tracked.spec);
@@ -8210,7 +8419,7 @@ export class FactoryLoop {
8210
8419
  if (!matchesCurrentConvention && !matchesAuthorizedLegacyBranch) {
8211
8420
  throw new Error(`Refusing to publish ${record.issue.key}: branch ${expectedHeadRef} belongs to a different issue`);
8212
8421
  }
8213
- const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
8422
+ const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
8214
8423
  const cached = this.#publishedPullRequests.get(key);
8215
8424
  if (cached) {
8216
8425
  if (cached.headRef !== expectedHeadRef) {
@@ -8868,7 +9077,7 @@ export class FactoryLoop {
8868
9077
  }
8869
9078
  }
8870
9079
  #scheduleAbandonedDispatchRetry(record, reason) {
8871
- const key = issueKey(record.issue);
9080
+ const key = dispatchLifecycleKey(record.issue);
8872
9081
  if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
8873
9082
  return;
8874
9083
  const timer = setTimeout(() => {
@@ -8900,7 +9109,7 @@ export class FactoryLoop {
8900
9109
  ? githubIssuePathParts(record.issue.path)?.owner
8901
9110
  : undefined;
8902
9111
  const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
8903
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
9112
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
8904
9113
  if (publishedPullRequests(lifecycle).some((receipt) => receipt.repo.toLowerCase() === repo.toLowerCase()))
8905
9114
  return true;
8906
9115
  return Boolean(await this.#openPullRequestByHead(repo, implementer.spec.branch));
@@ -10468,10 +10677,7 @@ export class FactoryLoop {
10468
10677
  return false;
10469
10678
  }
10470
10679
  const clarifiedIssue = issueWithGithubClarification(issue, text);
10471
- const decision = await this.#triage.triage(clarifiedIssue, {
10472
- config: this.#config,
10473
- repoMap: repoMapFromConfig(this.#config),
10474
- });
10680
+ const decision = await this.#triageWithOrigin(clarifiedIssue);
10475
10681
  const escalationReason = triageEscalationReason(decision);
10476
10682
  if (escalationReason) {
10477
10683
  if (hasDispatchableRoute(decision)) {
@@ -10922,7 +11128,7 @@ export class FactoryLoop {
10922
11128
  for (const record of records) {
10923
11129
  if (!await this.#assertIssueDispatchLifecycleOwner(record.issue))
10924
11130
  continue;
10925
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
11131
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
10926
11132
  // Babysitter sessions are independently durable and restore only after
10927
11133
  // their mounted PR metadata passes the open/draft/issue-identity guard.
10928
11134
  // Fold those validated receipts back into the lifecycle on takeover.
@@ -11575,7 +11781,7 @@ export class FactoryLoop {
11575
11781
  });
11576
11782
  }
11577
11783
  async #suspendBabysitterWakeForHuman(state) {
11578
- const key = issueKey(state.issue);
11784
+ const key = dispatchLifecycleKey(state.issue);
11579
11785
  const [lifecycle, clarification] = await Promise.all([
11580
11786
  this.#usesDurableDispatchLifecycle()
11581
11787
  ? this.#state.getDispatchLifecycle(this.#workspaceId, key)
@@ -12082,6 +12288,49 @@ export class FactoryLoop {
12082
12288
  async #advanceMergedPrToDone(snapshot, repo, record) {
12083
12289
  const mergedPullRequest = { repo, number: snapshot.number, url: snapshot.url };
12084
12290
  if (record) {
12291
+ // An in-flight record's association comes from Factory's own dispatch,
12292
+ // not from the PR's prose, so closure here does NOT require a closing
12293
+ // keyword — requiring one would strand every ordinary dispatch whose PR
12294
+ // links its issue with a bare `Linear: AR-411`.
12295
+ //
12296
+ // What it does honour is an explicit disclaimer: a merged PR that says in
12297
+ // its own body that it does not fix this issue is taken at its word. That
12298
+ // is the exact shape of #313 (`Diagnostic for #155`).
12299
+ //
12300
+ // Ownership does NOT exempt a PR from this. Ownership settles which issue
12301
+ // the PR belongs to; it says nothing about whether the merge completed
12302
+ // that issue. Skipping the check for owned PRs would mean the same merged
12303
+ // body is refused before ownership is established and accepted after.
12304
+ if (prBodyDisclaimsClosing({ headRef: snapshot.headRef, body: snapshot.body, repo }, record.issue.key)) {
12305
+ this.#increment('mergedPrClosureRefused');
12306
+ this.#logger.warn?.('[factory] merged PR disclaims completing the issue it references', {
12307
+ issue: record.issue.key,
12308
+ prNumber: snapshot.number,
12309
+ repo,
12310
+ });
12311
+ return;
12312
+ }
12313
+ // The protected-label gate has to cover the tracked paths too, or an
12314
+ // incident issue that Factory itself dispatched is still auto-closed by
12315
+ // its own merge and `neverAutoCloseLabels` guarantees nothing.
12316
+ const tracked = await this.#readIssue(record.issue.path);
12317
+ const trackedBlockingLabel = tracked ? this.#neverAutoCloseLabel(tracked) : undefined;
12318
+ if (tracked && trackedBlockingLabel) {
12319
+ await this.#declineMergeClosure(tracked, snapshot, repo, `it carries the \`${trackedBlockingLabel}\` label, which Factory never auto-closes`, 'label');
12320
+ return;
12321
+ }
12322
+ if (!tracked) {
12323
+ // Proceed rather than block. The disclaimer check above already ran on
12324
+ // the merged body; wedging every completion behind a transient mount
12325
+ // read would strand dispatches far more often than it would protect an
12326
+ // incident, and stranded dispatches are what generate incidents here.
12327
+ this.#logger.warn?.('[factory] could not read issue to apply the never-auto-close label gate', {
12328
+ issue: record.issue.key,
12329
+ prNumber: snapshot.number,
12330
+ repo,
12331
+ });
12332
+ this.#increment('mergedPrLabelGateUnverified');
12333
+ }
12085
12334
  await this.#completeIssue(record, {
12086
12335
  targetState: 'done',
12087
12336
  runMergeGate: false,
@@ -12100,11 +12349,24 @@ export class FactoryLoop {
12100
12349
  this.#increment('mergedPrAdvanceDuplicatesSuppressed');
12101
12350
  return;
12102
12351
  }
12352
+ // A merged PR that merely *references* this issue has no authority to
12353
+ // complete it, and an incident issue is never closed by a merge at all.
12354
+ // Both refusals comment instead of closing, so the decision is auditable
12355
+ // rather than silent. #313.
12356
+ const authority = prClosureAuthority({ headRef: snapshot.headRef, body: snapshot.body, repo }, issue.key);
12357
+ const blockingLabel = this.#neverAutoCloseLabel(issue);
12358
+ if (!authority.authorised || blockingLabel) {
12359
+ const reason = blockingLabel
12360
+ ? `it carries the \`${blockingLabel}\` label, which Factory never auto-closes`
12361
+ : authority.evidence;
12362
+ await this.#declineMergeClosure(issue, snapshot, repo, reason, blockingLabel ? 'label' : 'authority');
12363
+ return;
12364
+ }
12103
12365
  this.#postMergeDoneAdvances.add(advanceKey);
12104
12366
  try {
12105
12367
  const githubIssue = isGithubIssue(issue);
12106
12368
  if (githubIssue) {
12107
- await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.`);
12369
+ await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.\n\nClosing authority: ${authority.evidence}.`);
12108
12370
  }
12109
12371
  else {
12110
12372
  const doneStateId = this.#states.idFor(issue.team, 'done');
@@ -12119,6 +12381,7 @@ export class FactoryLoop {
12119
12381
  issue: issue.key,
12120
12382
  prNumber: snapshot.number,
12121
12383
  url: snapshot.url,
12384
+ closureAuthority: authority.source,
12122
12385
  });
12123
12386
  if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('merge-done-thread')) {
12124
12387
  try {
@@ -12145,6 +12408,61 @@ export class FactoryLoop {
12145
12408
  this.#error(error, issueRef(issue));
12146
12409
  }
12147
12410
  }
12411
+ /** The configured label, if any, that bars this issue from ever auto-closing. */
12412
+ #neverAutoCloseLabel(issue) {
12413
+ const blocked = this.#config.safety.neverAutoCloseLabels;
12414
+ if (!blocked || blocked.length === 0)
12415
+ return undefined;
12416
+ const carried = new Set((issue.labels ?? []).map((label) => label.trim().toLowerCase()));
12417
+ return blocked.find((label) => carried.has(label));
12418
+ }
12419
+ /**
12420
+ * Leave the issue open and say why. A refusal has to be *louder* than a
12421
+ * closure, not quieter: the failure this replaces marked a live outage
12422
+ * COMPLETED and dropped it out of every open-issue view.
12423
+ */
12424
+ async #declineMergeClosure(issue, snapshot, repo, reason, kind) {
12425
+ this.#increment(kind === 'label' ? 'mergedPrClosureBlockedByLabel' : 'mergedPrClosureRefused');
12426
+ this.#logger.warn?.('[factory] declined to close issue on merged PR', {
12427
+ issue: issue.key,
12428
+ prNumber: snapshot.number,
12429
+ repo,
12430
+ reason,
12431
+ kind,
12432
+ });
12433
+ const marker = `factory-merge-close-declined:${repo}#${snapshot.number}`;
12434
+ const body = [
12435
+ `Factory observed pull request ${repo}#${snapshot.number} merge, and did **not** close this issue.`,
12436
+ '',
12437
+ `Reason: ${reason}.`,
12438
+ '',
12439
+ 'A merged pull request closes an issue only when it carries a GitHub closing keyword'
12440
+ + ' (`closes`/`fixes`/`resolves`) for it, or when its head is that issue\'s own'
12441
+ + ' implementation branch. A bare reference is an association, not a completion.',
12442
+ '',
12443
+ 'If this pull request really did complete the issue, close it by hand.',
12444
+ '',
12445
+ `<!-- ${marker} -->`,
12446
+ ].join('\n');
12447
+ try {
12448
+ // A Linear-backed candidate reaches this path too, and a refusal that is
12449
+ // invisible on the system of record is barely better than the silent
12450
+ // closure it replaces.
12451
+ if (!isGithubIssue(issue)) {
12452
+ await this.#linear.postComment(issue, body);
12453
+ return;
12454
+ }
12455
+ // Merge events can redeliver. Skip a repeat only on a positive
12456
+ // confirmation that the note already landed; an unavailable check must
12457
+ // not be read as "already commented".
12458
+ if (await this.#githubWriteback.hasCommentMarker?.(issue, marker))
12459
+ return;
12460
+ await this.#githubWriteback.postComment(issue, body);
12461
+ }
12462
+ catch (error) {
12463
+ this.#error(error, issueRef(issue));
12464
+ }
12465
+ }
12148
12466
  async #findMergeAdvanceIssueForPr(snapshot, eventRepo) {
12149
12467
  // An issue is "upstream" of a merge if it sits in the agent-implementing or
12150
12468
  // human-review role for its team. UUIDs are globally unique, so the reverse
@@ -12226,7 +12544,7 @@ export class FactoryLoop {
12226
12544
  // after opening its PR (an event, not a poll).
12227
12545
  async #ensureBabysitterForIssue(record) {
12228
12546
  if (this.#usesDurableDispatchLifecycle()) {
12229
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12547
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12230
12548
  const receipts = lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : []);
12231
12549
  if (receipts.length > 0) {
12232
12550
  for (const receipt of receipts) {
@@ -12456,7 +12774,7 @@ export class FactoryLoop {
12456
12774
  }
12457
12775
  async #retireSupersededBabysitters(record, prRef) {
12458
12776
  const wanted = githubPrIdentity(prRef.repo, prRef.prNumber);
12459
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12777
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12460
12778
  const trackedAgents = new Map([
12461
12779
  ...(lifecycle?.agents ?? [])
12462
12780
  .filter((agent) => agent.releasedAtMs === undefined)
@@ -12499,7 +12817,7 @@ export class FactoryLoop {
12499
12817
  });
12500
12818
  }
12501
12819
  if (superseded.length > 0) {
12502
- const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12820
+ const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12503
12821
  if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
12504
12822
  throw new Error(`Failed to persist superseded babysitter cleanup for ${record.issue.key}`);
12505
12823
  }
@@ -12508,7 +12826,7 @@ export class FactoryLoop {
12508
12826
  }
12509
12827
  async #retireBabysittersOutsideCurrentRoutes(record) {
12510
12828
  const wantedRepos = new Set(record.decision.implementers.map((implementer) => implementer.repo.toLowerCase()));
12511
- const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12829
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12512
12830
  const trackedAgents = new Map([
12513
12831
  ...(lifecycle?.agents ?? [])
12514
12832
  .filter((agent) => agent.releasedAtMs === undefined)
@@ -12550,7 +12868,7 @@ export class FactoryLoop {
12550
12868
  });
12551
12869
  }
12552
12870
  if (unrouted.length > 0) {
12553
- const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
12871
+ const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
12554
12872
  if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
12555
12873
  throw new Error(`Failed to persist unrouted babysitter cleanup for ${record.issue.key}`);
12556
12874
  }
@@ -12563,7 +12881,7 @@ export class FactoryLoop {
12563
12881
  // guards on the PR's OWN webhook-fed meta (still open, not a draft, not already
12564
12882
  // merged) before flipping the issue to Human Review. No `gh` call.
12565
12883
  async #maybeAdvanceToHumanReview(record, agentName) {
12566
- if (this.#completionInFlight.has(issueKey(record.issue))) {
12884
+ if (this.#completionInFlight.has(dispatchLifecycleKey(record.issue))) {
12567
12885
  return;
12568
12886
  }
12569
12887
  const ownership = [...this.#babysitterPr.entries()].find(([key, ref]) => ref.agentName === agentName && issueKey(this.#babysitterIssueRefs.get(key) ?? record.issue) === issueKey(record.issue));
@@ -12696,7 +13014,7 @@ export class FactoryLoop {
12696
13014
  return false;
12697
13015
  }
12698
13016
  async #completeIssue(record, opts = {}) {
12699
- const completionKey = issueKey(record.issue);
13017
+ const completionKey = dispatchLifecycleKey(record.issue);
12700
13018
  if (this.#completionInFlight.has(completionKey)) {
12701
13019
  return;
12702
13020
  }
@@ -12766,6 +13084,7 @@ export class FactoryLoop {
12766
13084
  await this.#linear.setState(issue, targetState);
12767
13085
  await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
12768
13086
  }
13087
+ record.issueWritebackConfirmedAtMs = this.#clock.now();
12769
13088
  this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
12770
13089
  if (!humanReview)
12771
13090
  await this.#markDependencyTerminalAndReconcile(issue);
@@ -12841,7 +13160,7 @@ export class FactoryLoop {
12841
13160
  // Cancellation must see the subscription identity so it can issue the
12842
13161
  // idempotent Relayfile DELETE before clearing the local owner maps.
12843
13162
  await this.#cancelBabysittersForIssue(record.issue);
12844
- const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue)).catch(() => undefined);
13163
+ const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue)).catch(() => undefined);
12845
13164
  if (!this.#usesDurableDispatchLifecycle() || (durable && isTerminalDispatchLifecycle(durable))) {
12846
13165
  for (const publishedKey of this.#publishedPullRequests.keys()) {
12847
13166
  if (publishedKey.startsWith(`${completionKey}:`))
@@ -12938,7 +13257,7 @@ export class FactoryLoop {
12938
13257
  async #claimTicketDispatchNotification(record) {
12939
13258
  if (record.dryRun || !this.#usesDurableDispatchLifecycle())
12940
13259
  return true;
12941
- const key = issueKey(record.issue);
13260
+ const key = dispatchLifecycleKey(record.issue);
12942
13261
  return this.#serializeDispatchLifecyclePersistence(key, async () => {
12943
13262
  const epoch = this.#dispatchLifecycleEpochs.get(key);
12944
13263
  if (epoch === undefined) {
@@ -13001,7 +13320,7 @@ export class FactoryLoop {
13001
13320
  void (async () => {
13002
13321
  try {
13003
13322
  const lifecycle = issue
13004
- ? await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(issue)).catch(() => undefined)
13323
+ ? await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(issue)).catch(() => undefined)
13005
13324
  : undefined;
13006
13325
  if (lifecycle) {
13007
13326
  await this.#reportLifecycle(lifecycle, 'factory.failure', {
@@ -13269,7 +13588,7 @@ export class FactoryLoop {
13269
13588
  }
13270
13589
  async #slackPullRequestRefs(record, additional = []) {
13271
13590
  const lifecycle = await this.#state
13272
- .getDispatchLifecycle(this.#workspaceId, issueKey(record.issue))
13591
+ .getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
13273
13592
  .catch(() => undefined);
13274
13593
  const lifecycleRefs = publishedPullRequests(lifecycle).map((receipt) => ({
13275
13594
  repo: receipt.repo,
@@ -13600,7 +13919,7 @@ export class FactoryLoop {
13600
13919
  async #ownsActiveSlackConversationIssue(issue) {
13601
13920
  if (!(await this.#batch()).getIssue(issue))
13602
13921
  return false;
13603
- const key = issueKey(issue);
13922
+ const key = dispatchLifecycleKey(issue);
13604
13923
  const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
13605
13924
  if (!lifecycle)
13606
13925
  return true;
@@ -14960,7 +15279,7 @@ export class FactoryLoop {
14960
15279
  if (!waiting.dryRun && this.#usesDurableDispatchLifecycle()) {
14961
15280
  try {
14962
15281
  const claim = await this.#claimDispatchLifecycle(waiting.decision, false);
14963
- const lifecycleKey = issueKey(waiting.issue);
15282
+ const lifecycleKey = dispatchLifecycleKey(waiting.issue);
14964
15283
  const epoch = this.#dispatchLifecycleEpochs.get(lifecycleKey);
14965
15284
  if (claim.lifecycle.phase === 'waiting-for-human' &&
14966
15285
  (epoch === undefined || !await this.#state.promoteDispatchLifecycle(this.#workspaceId, lifecycleKey, this.#dispatchLifecycleOwner, epoch, this.#clock.now()))) {
@@ -15252,10 +15571,7 @@ export class FactoryLoop {
15252
15571
  return;
15253
15572
  }
15254
15573
  const clarifiedIssue = issueWithSlackClarification(issue, text);
15255
- const decision = await this.#triage.triage(clarifiedIssue, {
15256
- config: this.#config,
15257
- repoMap: repoMapFromConfig(this.#config),
15258
- });
15574
+ const decision = await this.#triageWithOrigin(clarifiedIssue);
15259
15575
  const escalationReason = triageEscalationReason(decision);
15260
15576
  if (escalationReason) {
15261
15577
  if (hasDispatchableRoute(decision)) {
@@ -15819,7 +16135,54 @@ const issueRefFromPath = (path) => {
15819
16135
  const key = keyFromPath(path);
15820
16136
  return { uuid: uuidFromPath(path) ?? key, key, path };
15821
16137
  };
15822
- const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
16138
+ const issueRef = (issue) => {
16139
+ const origin = mirrorWorkUnitOrigin(issue);
16140
+ return { uuid: issue.uuid, key: issue.key, path: issue.path, ...(origin ? { origin } : {}) };
16141
+ };
16142
+ /**
16143
+ * An `IssueRef` for a canonical-state record, carrying the mirror origin from
16144
+ * whichever shape the caller had — a full `LinearIssue`, or an `IssueRef` that
16145
+ * already resolved one.
16146
+ */
16147
+ const issueRefForState = (issue) => {
16148
+ const origin = issue.origin ??
16149
+ (issue.raw === undefined ? undefined : mirrorWorkUnitOrigin({ path: issue.path, raw: issue.raw }));
16150
+ return { uuid: issue.uuid, key: issue.key, path: issue.path, ...(origin ? { origin } : {}) };
16151
+ };
16152
+ /** Work-unit key, or undefined for a row with no derivable provider identity. */
16153
+ const safeDispatchLifecycleKey = (issue) => {
16154
+ try {
16155
+ return dispatchLifecycleKey(issue);
16156
+ }
16157
+ catch {
16158
+ return undefined;
16159
+ }
16160
+ };
16161
+ /**
16162
+ * Re-attaches the provider origin a triage engine did not carry through.
16163
+ * Only fills a gap — an engine that already resolved an origin keeps it.
16164
+ */
16165
+ const decisionWithMirrorOrigin = (decision, issue) => {
16166
+ if (decision.issue.origin)
16167
+ return decision;
16168
+ const origin = mirrorWorkUnitOrigin(issue);
16169
+ return origin ? { ...decision, issue: { ...decision.issue, origin } } : decision;
16170
+ };
16171
+ /**
16172
+ * The claim/dedup/lifecycle authority key: the work unit, not the surface it
16173
+ * arrived through.
16174
+ *
16175
+ * `issueKey()` composes the Relayfile sense path, so the same issue reaching
16176
+ * Factory GitHub-native and through its own Linear mirror hashed to two keys
16177
+ * and the lease never saw a conflict — both dispatched (#211, AR-448). Every
16178
+ * durable lifecycle read and write goes through this instead.
16179
+ *
16180
+ * `issueKey()` deliberately stays as-is for the surface-scoped correlation
16181
+ * maps — waiting clarifications, babysitter sessions, Slack threads, preview
16182
+ * ownership — which are keyed by surface on disk and would be orphaned by a
16183
+ * global redefinition.
16184
+ */
16185
+ const dispatchLifecycleKey = (issue) => dispatchIssueIdentity(issue);
15823
16186
  // Preserve the historical Linear state namespace while keeping GitHub-native
15824
16187
  // issue numbers independent across repositories in the same workspace.
15825
16188
  const issueStateKey = (issue) => githubIssuePathParts(issue.path) ? issueKey(issue) : issue.key;
@@ -16992,9 +17355,10 @@ const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
16992
17355
  return 0;
16993
17356
  };
16994
17357
  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);
17358
+ // Single definition shared with closure authority, so the branch rule that
17359
+ // grants a score-30 association and the one that authorises a close can never
17360
+ // drift apart.
17361
+ const factoryBranchMatchesIssue = branchImplementsIssue;
16998
17362
  // Legacy Factory runs created GitHub-native branches as `<issue-number>-*`
16999
17363
  // before the current `factory/<issue>-*` convention. Keep this matcher out of
17000
17364
  // generic PR discovery: it is only enabled by orphan recovery, where the issue