@agent-relay/factory 0.1.62 → 0.1.63

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 (154) hide show
  1. package/README.md +117 -15
  2. package/dist/cli/exit-codes.d.ts +81 -0
  3. package/dist/cli/exit-codes.d.ts.map +1 -0
  4. package/dist/cli/exit-codes.js +122 -0
  5. package/dist/cli/exit-codes.js.map +1 -0
  6. package/dist/cli/fleet.d.ts +22 -2
  7. package/dist/cli/fleet.d.ts.map +1 -1
  8. package/dist/cli/fleet.js +152 -33
  9. package/dist/cli/fleet.js.map +1 -1
  10. package/dist/cli/index.d.ts +3 -0
  11. package/dist/cli/index.d.ts.map +1 -0
  12. package/dist/cli/index.js +2 -0
  13. package/dist/cli/index.js.map +1 -0
  14. package/dist/config/schema.d.ts +156 -52
  15. package/dist/config/schema.d.ts.map +1 -1
  16. package/dist/config/schema.js +17 -5
  17. package/dist/config/schema.js.map +1 -1
  18. package/dist/dispatch/templates.d.ts +7 -0
  19. package/dist/dispatch/templates.d.ts.map +1 -1
  20. package/dist/dispatch/templates.js +64 -12
  21. package/dist/dispatch/templates.js.map +1 -1
  22. package/dist/dispatch/work-unit-identity.d.ts +19 -0
  23. package/dist/dispatch/work-unit-identity.d.ts.map +1 -0
  24. package/dist/dispatch/work-unit-identity.js +40 -0
  25. package/dist/dispatch/work-unit-identity.js.map +1 -0
  26. package/dist/environments/stack-descriptor.d.ts +3 -3
  27. package/dist/fleet/control-plane-circuit.d.ts +57 -0
  28. package/dist/fleet/control-plane-circuit.d.ts.map +1 -0
  29. package/dist/fleet/control-plane-circuit.js +219 -0
  30. package/dist/fleet/control-plane-circuit.js.map +1 -0
  31. package/dist/fleet/internal-fleet-client.d.ts +2 -1
  32. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  33. package/dist/fleet/internal-fleet-client.js +11 -7
  34. package/dist/fleet/internal-fleet-client.js.map +1 -1
  35. package/dist/fleet/relay-fleet-client.d.ts +3 -0
  36. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  37. package/dist/fleet/relay-fleet-client.js +97 -18
  38. package/dist/fleet/relay-fleet-client.js.map +1 -1
  39. package/dist/github/writeback-paths.d.ts +7 -0
  40. package/dist/github/writeback-paths.d.ts.map +1 -0
  41. package/dist/github/writeback-paths.js +28 -0
  42. package/dist/github/writeback-paths.js.map +1 -0
  43. package/dist/hosted/orchestrator.d.ts.map +1 -1
  44. package/dist/hosted/orchestrator.js +2 -0
  45. package/dist/hosted/orchestrator.js.map +1 -1
  46. package/dist/index.d.ts +8 -4
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +5 -3
  49. package/dist/index.js.map +1 -1
  50. package/dist/intake/index.d.ts +2 -1
  51. package/dist/intake/index.d.ts.map +1 -1
  52. package/dist/intake/index.js +2 -1
  53. package/dist/intake/index.js.map +1 -1
  54. package/dist/intake/notion-manifest.d.ts +48 -0
  55. package/dist/intake/notion-manifest.d.ts.map +1 -0
  56. package/dist/intake/notion-manifest.js +258 -0
  57. package/dist/intake/notion-manifest.js.map +1 -0
  58. package/dist/intake/notion-relay-claim.d.ts +5 -4
  59. package/dist/intake/notion-relay-claim.d.ts.map +1 -1
  60. package/dist/intake/notion-relay-claim.js +48 -7
  61. package/dist/intake/notion-relay-claim.js.map +1 -1
  62. package/dist/intake/notion.d.ts +6 -3
  63. package/dist/intake/notion.d.ts.map +1 -1
  64. package/dist/intake/notion.js +63 -13
  65. package/dist/intake/notion.js.map +1 -1
  66. package/dist/issue-key-match.d.ts +6 -0
  67. package/dist/issue-key-match.d.ts.map +1 -1
  68. package/dist/issue-key-match.js +6 -1
  69. package/dist/issue-key-match.js.map +1 -1
  70. package/dist/mount/local-mount-preflight.d.ts.map +1 -1
  71. package/dist/mount/local-mount-preflight.js +6 -2
  72. package/dist/mount/local-mount-preflight.js.map +1 -1
  73. package/dist/mount/mount-auth-error.d.ts +16 -3
  74. package/dist/mount/mount-auth-error.d.ts.map +1 -1
  75. package/dist/mount/mount-auth-error.js +34 -7
  76. package/dist/mount/mount-auth-error.js.map +1 -1
  77. package/dist/mount/relayfile-cloud-mount-client.d.ts +14 -1
  78. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  79. package/dist/mount/relayfile-cloud-mount-client.js +107 -6
  80. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  81. package/dist/mount/relayfile-github-connection-write.d.ts +24 -2
  82. package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
  83. package/dist/mount/relayfile-github-connection-write.js +90 -1
  84. package/dist/mount/relayfile-github-connection-write.js.map +1 -1
  85. package/dist/node/factory-node.d.ts.map +1 -1
  86. package/dist/node/factory-node.js +18 -4
  87. package/dist/node/factory-node.js.map +1 -1
  88. package/dist/observability/events.d.ts +52 -52
  89. package/dist/orchestrator/batch-tracker.d.ts +25 -0
  90. package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
  91. package/dist/orchestrator/batch-tracker.js +48 -2
  92. package/dist/orchestrator/batch-tracker.js.map +1 -1
  93. package/dist/orchestrator/factory.d.ts +36 -2
  94. package/dist/orchestrator/factory.d.ts.map +1 -1
  95. package/dist/orchestrator/factory.js +383 -31
  96. package/dist/orchestrator/factory.js.map +1 -1
  97. package/dist/orchestrator/index.d.ts +1 -1
  98. package/dist/orchestrator/index.d.ts.map +1 -1
  99. package/dist/orchestrator/index.js +1 -1
  100. package/dist/orchestrator/index.js.map +1 -1
  101. package/dist/ports/fleet.d.ts +6 -0
  102. package/dist/ports/fleet.d.ts.map +1 -1
  103. package/dist/ports/index.d.ts +1 -1
  104. package/dist/ports/index.d.ts.map +1 -1
  105. package/dist/ports/mount.d.ts +39 -0
  106. package/dist/ports/mount.d.ts.map +1 -1
  107. package/dist/ports/state.d.ts +15 -1
  108. package/dist/ports/state.d.ts.map +1 -1
  109. package/dist/state/document-store.d.ts +32 -0
  110. package/dist/state/document-store.d.ts.map +1 -0
  111. package/dist/state/document-store.js +7 -0
  112. package/dist/state/document-store.js.map +1 -0
  113. package/dist/state/file-state-store.d.ts +16 -2
  114. package/dist/state/file-state-store.d.ts.map +1 -1
  115. package/dist/state/file-state-store.js +45 -242
  116. package/dist/state/file-state-store.js.map +1 -1
  117. package/dist/state/watch-state-document.d.ts +5 -0
  118. package/dist/state/watch-state-document.d.ts.map +1 -0
  119. package/dist/state/watch-state-document.js +444 -0
  120. package/dist/state/watch-state-document.js.map +1 -0
  121. package/dist/testing/deadline.d.ts +11 -0
  122. package/dist/testing/deadline.d.ts.map +1 -0
  123. package/dist/testing/deadline.js +23 -0
  124. package/dist/testing/deadline.js.map +1 -0
  125. package/dist/testing/fakes.d.ts +2 -0
  126. package/dist/testing/fakes.d.ts.map +1 -1
  127. package/dist/testing/fakes.js.map +1 -1
  128. package/dist/testing/index.d.ts +1 -0
  129. package/dist/testing/index.d.ts.map +1 -1
  130. package/dist/testing/index.js +1 -0
  131. package/dist/testing/index.js.map +1 -1
  132. package/dist/triage/heuristic.d.ts +5 -0
  133. package/dist/triage/heuristic.d.ts.map +1 -1
  134. package/dist/triage/heuristic.js +42 -3
  135. package/dist/triage/heuristic.js.map +1 -1
  136. package/dist/triage/index.d.ts +1 -1
  137. package/dist/triage/index.d.ts.map +1 -1
  138. package/dist/triage/index.js +1 -1
  139. package/dist/triage/index.js.map +1 -1
  140. package/dist/triage/schema.d.ts +73 -55
  141. package/dist/triage/schema.d.ts.map +1 -1
  142. package/dist/triage/schema.js +2 -1
  143. package/dist/triage/schema.js.map +1 -1
  144. package/dist/types.d.ts +19 -3
  145. package/dist/types.d.ts.map +1 -1
  146. package/dist/writeback/github.d.ts +22 -7
  147. package/dist/writeback/github.d.ts.map +1 -1
  148. package/dist/writeback/github.js +86 -16
  149. package/dist/writeback/github.js.map +1 -1
  150. package/dist/writeback/index.d.ts +1 -1
  151. package/dist/writeback/index.d.ts.map +1 -1
  152. package/dist/writeback/index.js +1 -1
  153. package/dist/writeback/index.js.map +1 -1
  154. package/package.json +13 -8
@@ -5,6 +5,7 @@ import { FactoryConfigSchema } from '../config/schema.js';
5
5
  import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
6
6
  import { stateResolutionFromIds } from '../linear/state-resolver.js';
7
7
  import { GithubMergeGate, closeProbePr } from '../github/index.js';
8
+ import { factoryGithubIssueCommentDraftName, isFactoryGithubIssueCommentDraftName, isFactoryGithubOperationDraftName, } from '../github/writeback-paths.js';
8
9
  import { VerificationPipeline } from '../environments/verification-pipeline.js';
9
10
  import { factoryWorktreeIssueSlug, factoryWorktreePath } from '../git/agent-worktree.js';
10
11
  import { InMemoryStateStore } from '../state/in-memory-state-store.js';
@@ -12,13 +13,14 @@ import { containsExplicitIssueReference, containsIssueKey, factoryBranchBelongsT
12
13
  import { normalizeLogger, normalizeLogValue, setSafeErrorStack, stringifyLogValue } from '../logging.js';
13
14
  import { isInFactoryScope } from '../safety/factory-scope.js';
14
15
  import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
16
+ import { dispatchAgentIdentityKey } from '../dispatch/work-unit-identity.js';
15
17
  import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
16
18
  import { parseGithubHumanInputRequest, renderAgentTask, } from '../dispatch/templates.js';
17
19
  import { resolveTestGuidance } from '../dispatch/test-guidance.js';
18
- import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
20
+ import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels, swarmChannel, swarmMemberSlugs, swarmTaskFor } from '../triage/index.js';
19
21
  import { agentNameForRole, sanitizeAgentSlug } from '../triage/agent-names.js';
20
22
  import { isResourceSubscriptionsUnavailable } from '../subscriptions/index.js';
21
- import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
23
+ import { AppGithubWriteback, FACTORY_GITHUB_STATUS_LABELS, GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
22
24
  import { parseSlackThreadReply, slackThreadReplyGlob } from '../subscriptions/slack-filter.js';
23
25
  import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
24
26
  import { issueKey, } from './batch-tracker.js';
@@ -29,6 +31,7 @@ import { readFactoryInFlightRegistry, terminatePids } from './reaper.js';
29
31
  import { createFactoryCloudEventV1, factoryCloudReleaseReasonV1, } from '../observability/events.js';
30
32
  import { boundedRunCostTotal, CostLedger } from '../cost/ledger.js';
31
33
  import { createTicketDispatchDelivery } from '../delivery/ticket-dispatch.js';
34
+ import { FleetControlPlaneCircuit, FleetControlPlaneCircuitOpenError, guardFleetControlPlane, } from '../fleet/control-plane-circuit.js';
32
35
  class ClarificationWakeLeaseLostError extends Error {
33
36
  }
34
37
  class ClarificationQuestionDeliveryLeaseLostError extends Error {
@@ -224,6 +227,7 @@ export class FactoryLoop {
224
227
  #mount;
225
228
  #states;
226
229
  #fleet;
230
+ #fleetControlPlane;
227
231
  #ticketDispatchDelivery;
228
232
  #triage;
229
233
  #linear;
@@ -452,7 +456,6 @@ export class FactoryLoop {
452
456
  // synced records (state.name but no state.id) without the states catalog.
453
457
  this.#states = ports.stateResolution ?? stateResolutionFromIds(config.stateIds, config.linear.states);
454
458
  installFactoryDraftPredicate(this.#mount, config);
455
- this.#fleet = ports.fleet;
456
459
  this.#ticketDispatchDelivery = ports.ticketDispatchDelivery ?? createTicketDispatchDelivery({
457
460
  mountRoot: config.localMountRoot,
458
461
  });
@@ -461,7 +464,7 @@ export class FactoryLoop {
461
464
  safety: config.safety,
462
465
  });
463
466
  this.#githubWritebackProvided = Boolean(ports.githubWriteback);
464
- this.#githubWriteback = ports.githubWriteback ?? new GhCliGithubWriteback();
467
+ this.#githubWriteback = ports.githubWriteback ?? defaultGithubWriteback(config, ports.mount);
465
468
  this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
466
469
  this.#github = ports.github ?? MountGithubRead(ports.mount);
467
470
  this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
@@ -483,6 +486,13 @@ export class FactoryLoop {
483
486
  this.#probePrResolver = ports.probePrResolver ?? ((issue) => this.#resolveIssuePr(issue));
484
487
  this.#logger = normalizeLogger(ports.logger ?? console);
485
488
  this.#clock = ports.clock ?? realClock;
489
+ this.#fleetControlPlane = new FleetControlPlaneCircuit({
490
+ timeoutMs: config.fleetHealth.rosterTimeoutMs,
491
+ failureThreshold: config.fleetHealth.failureThreshold,
492
+ resetTimeoutMs: config.fleetHealth.resetTimeoutMs,
493
+ now: () => this.#clock.now(),
494
+ });
495
+ this.#fleet = guardFleetControlPlane(ports.fleet, this.#fleetControlPlane);
486
496
  this.#processIdentityReader = ports.processIdentityReader ?? readProcessIdentity;
487
497
  this.#processFinder = ports.processFinder ?? ((agentName, opts) => findAgentProcessByName(agentName, {
488
498
  readProcessIdentity: this.#processIdentityReader,
@@ -590,11 +600,32 @@ export class FactoryLoop {
590
600
  this.#batchView = batch;
591
601
  return batch;
592
602
  }
603
+ /**
604
+ * Resolves once this issue's durable dispatch row reaches a terminal phase,
605
+ * and reports which one. A caller that turns the run into an exit code needs
606
+ * the phase: a dispatch that hit capacity returns an empty hold result and
607
+ * schedules a durable retry, so the pre-wait result says nothing about how
608
+ * the run actually ended.
609
+ *
610
+ * `undefined` means no terminal phase was observed — there was no lifecycle
611
+ * row to wait on, or the wait ended because Factory is stopping.
612
+ */
593
613
  async waitForDispatchTerminal(issue) {
594
614
  const key = issueKey(issue);
595
615
  const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
596
- if (lifecycle && isTerminalDispatchLifecycle(lifecycle))
597
- return;
616
+ // No durable row means this dispatch never claimed a lifecycle: a
617
+ // dependency park, a triage escalation, and a label refusal all return
618
+ // before the claim. Nothing can ever become terminal, so polling would
619
+ // never stop and the caller would never produce an exit code at all.
620
+ if (!lifecycle)
621
+ return undefined;
622
+ if (isTerminalDispatchLifecycle(lifecycle))
623
+ return lifecycle.phase;
624
+ // Capture the phase at the moment this waiter observes it. The waiters are
625
+ // shared across callers of one row and carry no payload, so a re-read after
626
+ // the fact can race lifecycle cleanup or a reopened dispatch for the same
627
+ // issue and report a different run — or none.
628
+ let observedPhase;
598
629
  await new Promise((resolve) => {
599
630
  let settled = false;
600
631
  let timer;
@@ -603,10 +634,11 @@ export class FactoryLoop {
603
634
  waiters = new Set();
604
635
  this.#dispatchTerminalWaiters.set(key, waiters);
605
636
  }
606
- const finish = () => {
637
+ const finish = (phase) => {
607
638
  if (settled)
608
639
  return;
609
640
  settled = true;
641
+ observedPhase = phase;
610
642
  if (timer)
611
643
  clearTimeout(timer);
612
644
  const current = this.#dispatchTerminalWaiters.get(key);
@@ -629,7 +661,7 @@ export class FactoryLoop {
629
661
  try {
630
662
  const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
631
663
  if (latest && isTerminalDispatchLifecycle(latest)) {
632
- this.#resolveDispatchTerminalWaiters(issue);
664
+ this.#resolveDispatchTerminalWaiters(issue, latest.phase);
633
665
  return;
634
666
  }
635
667
  if (latest?.phase === 'waiting-for-human' && this.#startMode === 'dispatch-owner') {
@@ -659,6 +691,10 @@ export class FactoryLoop {
659
691
  };
660
692
  void poll();
661
693
  });
694
+ // `observedPhase` is whatever resolved THIS waiter. It stays undefined only
695
+ // when the wait ended without a terminal resolution at all — Factory is
696
+ // stopping — which is exactly what `undefined` reports.
697
+ return observedPhase;
662
698
  }
663
699
  async start(opts = {}) {
664
700
  if (this.#started) {
@@ -1831,8 +1867,30 @@ export class FactoryLoop {
1831
1867
  }
1832
1868
  }
1833
1869
  }
1870
+ async #assertFleetControlPlaneAvailable() {
1871
+ try {
1872
+ await this.#fleet.roster();
1873
+ this.#increment('fleetControlPlaneProbeSuccesses');
1874
+ }
1875
+ catch (error) {
1876
+ const health = this.#fleetControlPlane.status();
1877
+ this.#increment('fleetControlPlaneProbeFailures');
1878
+ if (health.state === 'open')
1879
+ this.#increment('fleetControlPlaneCircuitOpen');
1880
+ this.#logger.error?.('[factory] fleet control plane unavailable; dispatch paused', {
1881
+ state: health.state,
1882
+ consecutiveFailures: health.consecutiveFailures,
1883
+ retryAtMs: health.retryAtMs,
1884
+ error: health.lastError ?? 'unknown control-plane failure',
1885
+ });
1886
+ throw contextualError('Factory dispatch paused because the fleet control plane is unavailable', error);
1887
+ }
1888
+ }
1834
1889
  async #runOnceWithDiscoveryFence(opts) {
1835
1890
  const sweepStartedAtMs = this.#clock.now();
1891
+ if (!(opts.dryRun ?? this.#config.dryRun)) {
1892
+ await this.#assertFleetControlPlaneAvailable();
1893
+ }
1836
1894
  let claim = await this.#state.claimDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, this.#clock.now(), DISCOVERY_SWEEP_LEASE_MS);
1837
1895
  if (!claim.acquired && claim.reason === 'backoff') {
1838
1896
  const delayMs = Math.max(0, claim.state.backoffUntilMs - this.#clock.now());
@@ -2699,7 +2757,7 @@ export class FactoryLoop {
2699
2757
  await this.#state.clearBabysitterSession(this.#workspaceId, issueKey(lifecycle.issue));
2700
2758
  this.#dispatchLifecycleEpochs.delete(key);
2701
2759
  this.#abandonedDispatchReasons.delete(key);
2702
- this.#resolveDispatchTerminalWaiters(lifecycle.issue);
2760
+ this.#resolveDispatchTerminalWaiters(lifecycle.issue, 'abandoned');
2703
2761
  await this.#writeInFlightRegistry().catch((error) => {
2704
2762
  this.#logger.warn?.('[factory] failed to rewrite registry after orphaned claim release', {
2705
2763
  issue: issue.key,
@@ -3054,6 +3112,16 @@ export class FactoryLoop {
3054
3112
  reports.push(failedIterationReport(error, opts.dryRun ?? this.#config.dryRun));
3055
3113
  await this.#reapDispatchFailureHandoffsNow(heartbeatPath, registryPath);
3056
3114
  await this.#writeLoopHeartbeat(heartbeatPath, registryPath, 'running', iteration + 1, maxIterations);
3115
+ const fleetControlPlane = this.#fleetControlPlane.status();
3116
+ if (fleetControlPlane.state !== 'closed') {
3117
+ this.#increment('loopCircuitBreaks');
3118
+ this.#logger.error?.('[factory] stopping loop because dispatch is paused by the fleet control-plane circuit', {
3119
+ state: fleetControlPlane.state,
3120
+ consecutiveFailures: fleetControlPlane.consecutiveFailures,
3121
+ retryAtMs: fleetControlPlane.retryAtMs,
3122
+ });
3123
+ throw new FleetControlPlaneCircuitOpenError(fleetControlPlane.retryAtMs ?? this.#clock.now(), fleetControlPlane.state);
3124
+ }
3057
3125
  if (consecutiveFailures >= maxConsecutiveFailures) {
3058
3126
  this.#increment('loopCircuitBreaks');
3059
3127
  this.#logger.error?.('[factory] stopping loop after consecutive iteration failures', {
@@ -3209,6 +3277,13 @@ export class FactoryLoop {
3209
3277
  };
3210
3278
  }
3211
3279
  this.#clearDependencyPark(batch, dispatchDecision.issue);
3280
+ // Event-driven and direct dispatches do not necessarily pass through issue
3281
+ // discovery. Admit them before creating previews, claiming a lifecycle, or
3282
+ // consuming a dispatch attempt. The mutation proxy probes again at the
3283
+ // actual spawn/resume boundary so a later control-plane fault still fails
3284
+ // closed.
3285
+ if (!dryRun)
3286
+ await this.#assertFleetControlPlaneAvailable();
3212
3287
  const durableDispatch = !dryRun && this.#usesDurableDispatchLifecycle();
3213
3288
  // Local dispatches need the same deterministic branch identity as remote
3214
3289
  // ones. Without it, every worker starts in the configured shared checkout
@@ -3512,6 +3587,7 @@ export class FactoryLoop {
3512
3587
  capacityBlocked: parked.capacityBlocked,
3513
3588
  })) ?? [],
3514
3589
  counters: { ...this.#counters },
3590
+ fleetControlPlane: this.#fleetControlPlane.status(),
3515
3591
  slackDegraded: this.#slackDegraded,
3516
3592
  slackDegradedReason: this.#slackDegradedReason,
3517
3593
  eventListener: this.#eventListenerStatus(),
@@ -4415,12 +4491,25 @@ export class FactoryLoop {
4415
4491
  }
4416
4492
  for (const agent of lifecycle.agents) {
4417
4493
  const previousAgent = previous?.agents.find((candidate) => candidate.name === agent.name);
4418
- if (previousAgent?.releasedAtMs !== undefined)
4419
- agent.releasedAtMs = previousAgent.releasedAtMs;
4494
+ // The in-flight record is authoritative for release state: the stamp
4495
+ // now lives on the tracked agent and round-trips through the lifecycle,
4496
+ // so a still-released agent brings its own stamp here. Inheriting the
4497
+ // previous row's stamp instead would refile a respawned worker as
4498
+ // released — a respawn reuses both the agent name and the deterministic
4499
+ // invocation id, so neither can tell the generations apart — and every
4500
+ // consumer that filters on `releasedAtMs` would then treat a live
4501
+ // worker as gone.
4420
4502
  if (previousAgent?.costUsage)
4421
4503
  agent.costUsage = structuredClone(previousAgent.costUsage);
4422
- if (releasedAgentNames.has(agent.name))
4423
- agent.releasedAtMs ??= this.#clock.now();
4504
+ if (releasedAgentNames.has(agent.name)) {
4505
+ const releasedAtMs = agent.releasedAtMs ?? this.#clock.now();
4506
+ agent.releasedAtMs = releasedAtMs;
4507
+ // Keep the record the single source of the stamp, so the next save
4508
+ // carries it without having to read it back off the durable row.
4509
+ const tracked = record.agents.get(agent.name);
4510
+ if (tracked)
4511
+ tracked.releasedAtMs ??= releasedAtMs;
4512
+ }
4424
4513
  }
4425
4514
  const saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), lifecycle);
4426
4515
  if (!saved) {
@@ -4448,10 +4537,14 @@ export class FactoryLoop {
4448
4537
  return true;
4449
4538
  });
4450
4539
  }
4451
- #resolveDispatchTerminalWaiters(issue) {
4540
+ // Every caller of this knows the phase the row settled in, and each waiter
4541
+ // must be handed it directly. A waiter that instead re-read the shared row
4542
+ // after release could see it cleared, or see the next dispatch for the same
4543
+ // issue, and classify the wrong run.
4544
+ #resolveDispatchTerminalWaiters(issue, phase) {
4452
4545
  const key = issueKey(issue);
4453
4546
  for (const resolve of this.#dispatchTerminalWaiters.get(key) ?? [])
4454
- resolve();
4547
+ resolve(phase);
4455
4548
  this.#dispatchTerminalWaiters.delete(key);
4456
4549
  }
4457
4550
  #scheduleDispatchLifecycleRetry(record) {
@@ -4538,7 +4631,7 @@ export class FactoryLoop {
4538
4631
  if (!lifecycle)
4539
4632
  return;
4540
4633
  if (isTerminalDispatchLifecycle(lifecycle)) {
4541
- this.#resolveDispatchTerminalWaiters(lifecycle.issue);
4634
+ this.#resolveDispatchTerminalWaiters(lifecycle.issue, lifecycle.phase);
4542
4635
  return;
4543
4636
  }
4544
4637
  if (lifecycle.phase === 'waiting-for-human')
@@ -4919,7 +5012,7 @@ export class FactoryLoop {
4919
5012
  await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
4920
5013
  await this.#writeInFlightRegistry();
4921
5014
  this.#increment('dispatchLifecycleStaleIssuesAbandoned');
4922
- this.#resolveDispatchTerminalWaiters(record.issue);
5015
+ this.#resolveDispatchTerminalWaiters(record.issue, 'abandoned');
4923
5016
  this.#logger.info?.('[factory] abandoned durable dispatch whose live issue is no longer ready', {
4924
5017
  issue: record.issue.key,
4925
5018
  reason,
@@ -4995,7 +5088,7 @@ export class FactoryLoop {
4995
5088
  this.#increment(releaseReason === 'issue-human-review' ? 'humanReview' : 'done');
4996
5089
  this.#emit('issue-done', { issue: record.issue });
4997
5090
  await this.#writeInFlightRegistry();
4998
- this.#resolveDispatchTerminalWaiters(record.issue);
5091
+ this.#resolveDispatchTerminalWaiters(record.issue, 'complete');
4999
5092
  return true;
5000
5093
  }
5001
5094
  async #assertDispatchLifecycleOwner(record) {
@@ -6019,6 +6112,7 @@ export class FactoryLoop {
6019
6112
  registryPath,
6020
6113
  eventListener: this.#eventListenerStatus(),
6021
6114
  readinessReconcile: this.#readinessReconcileStatus(),
6115
+ fleetControlPlane: this.#fleetControlPlane.status(),
6022
6116
  };
6023
6117
  await mkdir(dirname(path), { recursive: true });
6024
6118
  await writeFile(path, `${JSON.stringify(heartbeat, null, 2)}\n`, 'utf8');
@@ -6277,6 +6371,22 @@ export class FactoryLoop {
6277
6371
  }
6278
6372
  try {
6279
6373
  await this.#fleet.release(agentName, reason);
6374
+ // Invalidate the spawn memory the moment the release is confirmed, so a
6375
+ // retry for the same deterministic invocation spawns a real worker
6376
+ // instead of inheriting this one's claim. Shutdown is excluded on
6377
+ // purpose: nothing is being retried there, and a takeover must still be
6378
+ // free to adopt agents this process merely stopped supervising.
6379
+ if (record && batch && context !== 'stop') {
6380
+ const releasedInvocationId = batch.recordRelease(record, agentName, this.#clock.now());
6381
+ if (releasedInvocationId) {
6382
+ this.#logger.debug?.('[factory] released agent invocation is no longer dispatchable', {
6383
+ issue: record.issue.key,
6384
+ agentName,
6385
+ reason,
6386
+ invocationId: releasedInvocationId,
6387
+ });
6388
+ }
6389
+ }
6280
6390
  if (record)
6281
6391
  await this.#reportAgent(record, tracked, 'agent.released', { releaseReason: reason });
6282
6392
  }
@@ -6506,7 +6616,10 @@ export class FactoryLoop {
6506
6616
  const batch = await this.#batch();
6507
6617
  const invocationId = batch.invocationIdFor(record.issue, spec);
6508
6618
  const existing = record.agents.get(spec.name);
6509
- if (existing?.result) {
6619
+ // A released placement keeps its bookkeeping entry, but it is not a worker.
6620
+ // Answering with its old spawn result here would report a synthetic success
6621
+ // for a process that no longer exists.
6622
+ if (existing?.result && existing.releasedAtMs === undefined) {
6510
6623
  this.#scheduleHeldAgentDeadline(record);
6511
6624
  return { name: existing.result?.name ?? spec.name };
6512
6625
  }
@@ -6556,6 +6669,7 @@ export class FactoryLoop {
6556
6669
  result = await this.#fleet.spawn({
6557
6670
  name: spec.name,
6558
6671
  capability: spec.capability,
6672
+ identityKey: dispatchAgentIdentityKey(record.issue, spec.role),
6559
6673
  node: spec.node ?? 'self',
6560
6674
  repo: spec.repo,
6561
6675
  task: spec.task,
@@ -6657,6 +6771,16 @@ export class FactoryLoop {
6657
6771
  return;
6658
6772
  }
6659
6773
  }
6774
+ // Swarm workers share the lead's checkout and lifecycle branch. If a worker
6775
+ // exit reached the publication/completion paths below, whichever worker
6776
+ // finished first would publish whatever partial state was on the shared
6777
+ // branch and mark every swarm member "done" via the shared-branch PR probe,
6778
+ // releasing the still-working lead. The lead alone is authoritative for
6779
+ // publication and completion in a swarm.
6780
+ if (exiting?.spec.swarmRole === 'worker') {
6781
+ this.#increment('swarmWorkerExitsSuppressed');
6782
+ return;
6783
+ }
6660
6784
  if (isCompletionReason(reason)) {
6661
6785
  if (exiting?.spec.role === 'implementer' && await this.#issueHasCompletionPr(record, {
6662
6786
  openOnly: this.#config.babysitter.enabled,
@@ -6901,6 +7025,7 @@ export class FactoryLoop {
6901
7025
  const result = await this.#fleet.spawn({
6902
7026
  name: tracked.spec.name,
6903
7027
  capability: tracked.spec.capability,
7028
+ identityKey: dispatchAgentIdentityKey(record.issue, tracked.spec.role),
6904
7029
  node: tracked.result?.node ?? tracked.spec.node ?? 'self',
6905
7030
  repo: tracked.spec.repo,
6906
7031
  task: tracked.spec.task,
@@ -7782,6 +7907,7 @@ export class FactoryLoop {
7782
7907
  const result = await this.#fleet.resume({
7783
7908
  name,
7784
7909
  sessionRef: tracked.sessionRef,
7910
+ identityKey: dispatchAgentIdentityKey(record.issue, tracked.spec.role),
7785
7911
  node: tracked.result?.node ?? tracked.spec.node ?? 'self',
7786
7912
  capability: tracked.spec.capability,
7787
7913
  repo: tracked.spec.repo,
@@ -7910,6 +8036,7 @@ export class FactoryLoop {
7910
8036
  const result = await this.#fleet.spawn({
7911
8037
  name: replacementSpec.name,
7912
8038
  capability: replacementSpec.capability,
8039
+ identityKey: dispatchAgentIdentityKey(record.issue, replacementSpec.role),
7913
8040
  node: tracked.result?.node ?? replacementSpec.node ?? 'self',
7914
8041
  repo: replacementSpec.repo,
7915
8042
  task: replacementSpec.task,
@@ -9377,6 +9504,15 @@ export class FactoryLoop {
9377
9504
  previewStartCommand: spec.preview?.startCommand,
9378
9505
  } : {}),
9379
9506
  ...(this.#fleet.lifecycleActionName ? { lifecycleActionName: this.#fleet.lifecycleActionName } : {}),
9507
+ ...(spec.swarmRole && spec.channel ? {
9508
+ swarm: {
9509
+ role: spec.swarmRole,
9510
+ channel: spec.channel,
9511
+ otherMemberNames: decision.implementers
9512
+ .filter((implementer) => implementer.channel === spec.channel && implementer.name !== spec.name)
9513
+ .map((implementer) => implementer.name),
9514
+ },
9515
+ } : {}),
9380
9516
  }),
9381
9517
  };
9382
9518
  };
@@ -10897,7 +11033,7 @@ export class FactoryLoop {
10897
11033
  });
10898
11034
  return;
10899
11035
  }
10900
- const issue = await this.#findMergeAdvanceIssueForPr(snapshot);
11036
+ const issue = await this.#findMergeAdvanceIssueForPr(snapshot, repo);
10901
11037
  if (!issue) {
10902
11038
  this.#increment('mergedPrAdvanceNoIssue');
10903
11039
  return;
@@ -10952,7 +11088,7 @@ export class FactoryLoop {
10952
11088
  this.#error(error, issueRef(issue));
10953
11089
  }
10954
11090
  }
10955
- async #findMergeAdvanceIssueForPr(snapshot) {
11091
+ async #findMergeAdvanceIssueForPr(snapshot, eventRepo) {
10956
11092
  // An issue is "upstream" of a merge if it sits in the agent-implementing or
10957
11093
  // human-review role for its team. UUIDs are globally unique, so the reverse
10958
11094
  // role lookup covers every team without per-team scoping here.
@@ -10961,16 +11097,34 @@ export class FactoryLoop {
10961
11097
  return role === 'agentImplementing' || role === 'humanReview';
10962
11098
  };
10963
11099
  let best;
11100
+ let ambiguous = false;
10964
11101
  const scanStartedAtMs = this.#clock.now();
10965
11102
  // This no-record path runs after agents are released, so there is no
10966
11103
  // tracked PR identity left. Keep the scan simple and prefer branch identity
10967
11104
  // over title/body references to avoid "related to AR-N" body false positives.
10968
11105
  const githubSource = await this.#issueSource() === 'github';
11106
+ // A GitHub-native issue key is a bare number; the same number exists
11107
+ // independently in every configured repository. Only a same-repo candidate
11108
+ // may complete the merge, so a merged PR in one repo can never close an
11109
+ // unrelated issue that happens to share its number in another. #276.
11110
+ const eventRepoKey = validGithubRepo(eventRepo) ? eventRepo.toLowerCase() : undefined;
11111
+ if (githubSource && !eventRepoKey) {
11112
+ this.#logger.warn?.('[factory] merge advance skipped: event repo is not identifiable', {
11113
+ prNumber: snapshot.number,
11114
+ });
11115
+ return undefined;
11116
+ }
10969
11117
  const paths = githubSource ? await this.#githubIssuePaths() : await this.#listRelayfileTree(ISSUE_ROOT, 'merge advance issue scan');
10970
11118
  for (const path of paths) {
10971
11119
  if (githubSource ? !isGithubIssueFilePath(path) : !isIssueFilePath(path)) {
10972
11120
  continue;
10973
11121
  }
11122
+ if (githubSource) {
11123
+ const issueParts = githubIssuePathParts(path);
11124
+ if (!issueParts || `${issueParts.owner}/${issueParts.repo}`.toLowerCase() !== eventRepoKey) {
11125
+ continue;
11126
+ }
11127
+ }
10974
11128
  const issue = await this.#readIssue(path);
10975
11129
  if (!issue || (githubSource
10976
11130
  ? issue.state?.name?.trim().toLowerCase() === 'closed'
@@ -10983,10 +11137,27 @@ export class FactoryLoop {
10983
11137
  const score = prSnapshotIssueMatchScore(snapshot, issue.key);
10984
11138
  if (score > 0 && (!best || score > best.score)) {
10985
11139
  best = { issue, score };
11140
+ ambiguous = false;
10986
11141
  }
11142
+ else if (score > 0 && best && score === best.score) {
11143
+ ambiguous = true;
11144
+ }
11145
+ }
11146
+ // Fail-closed on a tie. A silent first-wins on unrelated same-score
11147
+ // candidates is the exact class of quiet corruption we are removing.
11148
+ if (ambiguous) {
11149
+ this.#increment('mergedPrAdvanceAmbiguous');
11150
+ this.#logger.warn?.('[factory] merge advance skipped: multiple candidates tied on match score', {
11151
+ prNumber: snapshot.number,
11152
+ eventRepo,
11153
+ matchedIssue: best?.issue.key,
11154
+ matchScore: best?.score,
11155
+ });
11156
+ return undefined;
10987
11157
  }
10988
11158
  this.#logger.debug?.(`[factory] scanned ${githubSource ? 'GitHub' : 'Linear'} issues for merged PR advance`, {
10989
11159
  prNumber: snapshot.number,
11160
+ eventRepo,
10990
11161
  durationMs: this.#clock.now() - scanStartedAtMs,
10991
11162
  matchedIssue: best?.issue.key,
10992
11163
  matchScore: best?.score,
@@ -12157,6 +12328,7 @@ export class FactoryLoop {
12157
12328
  const rebound = await this.#state.rebindConversationSession(this.#workspaceId, conversationId, {
12158
12329
  name: agentName,
12159
12330
  sessionRef,
12331
+ role: owned.tracked.spec.role,
12160
12332
  node: owned.tracked.result?.node ?? owned.tracked.spec.node,
12161
12333
  capability: owned.tracked.spec.capability,
12162
12334
  repo: owned.tracked.spec.repo,
@@ -12189,6 +12361,7 @@ export class FactoryLoop {
12189
12361
  agent: {
12190
12362
  name: agentName,
12191
12363
  sessionRef,
12364
+ role: owned.tracked.spec.role,
12192
12365
  node: owned.tracked.result?.node ?? owned.tracked.spec.node,
12193
12366
  capability: owned.tracked.spec.capability,
12194
12367
  repo: owned.tracked.spec.repo,
@@ -12252,9 +12425,20 @@ export class FactoryLoop {
12252
12425
  }, Math.max(1_000, Math.floor(SLACK_CONVERSATION_TURN_LEASE_MS / 3)));
12253
12426
  heartbeat.unref?.();
12254
12427
  try {
12428
+ // Sessions bound after this field was introduced carry their role
12429
+ // durably, so resume never depends on a live lookup by the agent's
12430
+ // current name — which a since-completed rename (e.g. babysitter
12431
+ // retarget) can otherwise leave unresolvable forever. Older
12432
+ // already-persisted sessions fall back to the live lookup.
12433
+ const conversationRole = claimed.agent.role
12434
+ ?? (await this.#batch()).getIssueByAgent(claimed.agent.name)?.agents.get(claimed.agent.name)?.spec.role;
12435
+ if (!conversationRole) {
12436
+ throw new Error(`Cannot resume ${claimed.agent.name}: its dispatch role is unavailable for identity proof`);
12437
+ }
12255
12438
  const result = await this.#fleet.resume({
12256
12439
  name: claimed.agent.name,
12257
12440
  sessionRef: claimed.agent.sessionRef,
12441
+ identityKey: dispatchAgentIdentityKey(claimed.issue, conversationRole),
12258
12442
  node: claimed.agent.node ?? 'self',
12259
12443
  capability: claimed.agent.capability,
12260
12444
  repo: claimed.agent.repo,
@@ -13414,6 +13598,7 @@ export class FactoryLoop {
13414
13598
  const resumed = await this.#fleet.resume({
13415
13599
  name,
13416
13600
  sessionRef: tracked.sessionRef,
13601
+ identityKey: dispatchAgentIdentityKey(waiting.issue, tracked.spec.role),
13417
13602
  node: tracked.result?.node ?? tracked.spec.node ?? 'self',
13418
13603
  capability: tracked.spec.capability,
13419
13604
  repo: tracked.spec.repo,
@@ -13444,6 +13629,7 @@ export class FactoryLoop {
13444
13629
  return await this.#fleet.spawn({
13445
13630
  name,
13446
13631
  capability: tracked.spec.capability,
13632
+ identityKey: dispatchAgentIdentityKey(waiting.issue, tracked.spec.role),
13447
13633
  node: tracked.result?.node ?? tracked.spec.node ?? 'self',
13448
13634
  task,
13449
13635
  workflow: tracked.spec.workflow,
@@ -13757,6 +13943,15 @@ export class FactoryLoop {
13757
13943
  return hasTitlePrefix(issue.title, FACTORY_E2E_MARKER);
13758
13944
  }
13759
13945
  }
13946
+ const defaultGithubWriteback = (config, mount) => {
13947
+ if (config.github.identity !== 'app') {
13948
+ return new GhCliGithubWriteback();
13949
+ }
13950
+ if (!mount.githubWrite) {
13951
+ throw new Error('GitHub identity "app" requires a connected workspace GitHub App lifecycle write path; refusing to fall back to the local gh user');
13952
+ }
13953
+ return new AppGithubWriteback(mount.githubWrite);
13954
+ };
13760
13955
  export function parseLinearIssue(path, content) {
13761
13956
  const parsed = parseJsonContent(content);
13762
13957
  const payload = wrappedPayload(parsed);
@@ -14352,13 +14547,19 @@ function labelDerivedDispatchDecision(liveIssue, decision, config) {
14352
14547
  maxImplementers,
14353
14548
  };
14354
14549
  }
14355
- const implementers = routesByLabel.routes.map(({ slug, route }) => routeImplementerSpec(liveIssue, config, slug, route));
14356
- const selectedRoutes = scope === 'single' ? routesByLabel.routes.slice(0, 1) : routesByLabel.routes;
14550
+ // Swarm always shares one checkout (lead + workers collaborate live over a
14551
+ // shared relay channel), so — like single/workflow only the first matched
14552
+ // label route is used; it is never fanned out across repo labels like team.
14553
+ const selectedRoutes = scope === 'single' || scope === 'swarm'
14554
+ ? routesByLabel.routes.slice(0, 1)
14555
+ : routesByLabel.routes;
14357
14556
  const selectedImplementers = scope === 'team'
14358
- ? implementers
14557
+ ? routesByLabel.routes.map(({ slug, route }) => routeImplementerSpec(liveIssue, config, slug, route))
14359
14558
  : scope === 'single'
14360
- ? implementers.slice(0, 1)
14361
- : [];
14559
+ ? routesByLabel.routes.slice(0, 1).map(({ slug, route }) => routeImplementerSpec(liveIssue, config, slug, route))
14560
+ : scope === 'swarm'
14561
+ ? routeSwarmImplementerSpecs(liveIssue, config, selectedRoutes[0]?.route, maxImplementers)
14562
+ : [];
14362
14563
  const routes = selectedRoutes.map(({ route }) => route);
14363
14564
  const workflow = scope === 'workflow'
14364
14565
  ? routeWorkflowSpec(liveIssue, config, selectedRoutes, decision.workflow)
@@ -14487,6 +14688,24 @@ function routeImplementerSpec(issue, config, slug, route) {
14487
14688
  node: 'self',
14488
14689
  };
14489
14690
  }
14691
+ function routeSwarmImplementerSpecs(issue, config, route, maxImplementers) {
14692
+ if (!route) {
14693
+ return [];
14694
+ }
14695
+ const channel = swarmChannel(issue);
14696
+ return swarmMemberSlugs(maxImplementers).map((slug) => ({
14697
+ name: agentNameForRole(issue, 'impl', { repo: route.repo, discriminator: slug }),
14698
+ role: 'implementer',
14699
+ capability: config.agentCapabilities.implementer,
14700
+ model: config.models.implementer,
14701
+ task: swarmTaskFor(issue, route, slug, channel),
14702
+ repo: route.repo,
14703
+ clonePath: route.clonePath,
14704
+ channel,
14705
+ swarmRole: slug === 'lead' ? 'lead' : 'worker',
14706
+ node: 'self',
14707
+ }));
14708
+ }
14490
14709
  function decisionWithLifecycleBranches(decision, runId, opts = {}) {
14491
14710
  const implementerBranch = (spec) => {
14492
14711
  const runSuffix = `-${runId.slice(0, 8)}`;
@@ -15633,12 +15852,123 @@ const isAllowedFactoryDraft = async (path, content, opts, mount, config) => {
15633
15852
  if (/^\/slack\/channels\/[^/]+\/messages\/.+/u.test(path)) {
15634
15853
  return true;
15635
15854
  }
15636
- if (isFactoryGithubWritebackPath(path)) {
15855
+ if (await isAllowedFactoryGithubDraft(path, content, opts, mount, config))
15637
15856
  return true;
15857
+ return false;
15858
+ };
15859
+ const isFactoryGithubAuthoredArtifactPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/(?:factory\.json|refs%2Fheads%2Ffactory%2F[^/]+\.json)|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
15860
+ export const isAllowedFactoryGithubArtifactDraft = (path, opts) => opts?.guarded === true && isFactoryGithubAuthoredArtifactPath(path);
15861
+ const factoryGithubIssueWriteTarget = (path) => {
15862
+ const match = /^\/github\/repos\/([^/]+)\/([^/]+)\/issues\/([1-9]\d*)(?:\.json|\/(comments|labels)\/([^/]+))$/iu.exec(path);
15863
+ if (!match?.[1] || !match[2] || !match[3])
15864
+ return undefined;
15865
+ const child = match[4];
15866
+ const filename = match[5];
15867
+ if (child === 'comments' && (!filename || !isFactoryGithubIssueCommentDraftName(filename)))
15868
+ return undefined;
15869
+ if (child === 'labels' && (!filename || !isFactoryGithubOperationDraftName(filename)))
15870
+ return undefined;
15871
+ try {
15872
+ return {
15873
+ owner: decodeURIComponent(match[1]),
15874
+ repo: decodeURIComponent(match[2]),
15875
+ number: Number(match[3]),
15876
+ kind: child === 'comments' ? 'comment' : child === 'labels' ? 'label-operation' : 'issue-update',
15877
+ };
15878
+ }
15879
+ catch {
15880
+ return undefined;
15881
+ }
15882
+ };
15883
+ const factoryGithubRepositoryLabelWriteTarget = (path) => {
15884
+ const match = /^\/github\/repos\/([^/]+)\/([^/]+)\/labels\/([^/]+)$/iu.exec(path);
15885
+ if (!match?.[1] || !match[2] || !match[3] || !isFactoryGithubOperationDraftName(match[3]))
15886
+ return undefined;
15887
+ try {
15888
+ return { owner: decodeURIComponent(match[1]), repo: decodeURIComponent(match[2]) };
15889
+ }
15890
+ catch {
15891
+ return undefined;
15892
+ }
15893
+ };
15894
+ const githubLifecycleLabel = (name) => Object.values(FACTORY_GITHUB_STATUS_LABELS).find((label) => label.name === name);
15895
+ const hasExactKeys = (value, keys) => {
15896
+ const actual = Object.keys(value).sort();
15897
+ const expected = [...keys].sort();
15898
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
15899
+ };
15900
+ const isAllowedFactoryGithubIssueWriteContent = (kind, content) => {
15901
+ const value = asRecord(content);
15902
+ if (!value)
15903
+ return false;
15904
+ if (kind === 'issue-update') {
15905
+ return hasExactKeys(value, ['state']) && value.state === 'closed';
15906
+ }
15907
+ if (kind === 'comment') {
15908
+ return hasExactKeys(value, ['body']) && typeof value.body === 'string' && value.body.trim().length > 0;
15909
+ }
15910
+ if (value.operation === 'add') {
15911
+ return hasExactKeys(value, ['labels', 'operation']) &&
15912
+ Array.isArray(value.labels) && value.labels.length === 1 && Boolean(githubLifecycleLabel(value.labels[0]));
15913
+ }
15914
+ return value.operation === 'remove' && hasExactKeys(value, ['label', 'operation']) && Boolean(githubLifecycleLabel(value.label));
15915
+ };
15916
+ const isAllowedFactoryGithubRepositoryLabelContent = (content) => {
15917
+ const value = asRecord(content);
15918
+ if (!value || !hasExactKeys(value, ['color', 'description', 'name']))
15919
+ return false;
15920
+ const expected = githubLifecycleLabel(value.name);
15921
+ return Boolean(expected && expected.color === value.color && expected.description === value.description);
15922
+ };
15923
+ /**
15924
+ * Last-resort mount guard for Factory-authored GitHub drafts. PR/ref paths are
15925
+ * intrinsically Factory-owned. Issue mutations additionally require a current
15926
+ * in-scope issue projection in one of Relayfile's supported repository layouts.
15927
+ */
15928
+ export const isAllowedFactoryGithubDraft = async (path, content, opts, mount, config) => {
15929
+ if (!opts?.guarded)
15930
+ return false;
15931
+ if (isAllowedFactoryGithubArtifactDraft(path, opts))
15932
+ return true;
15933
+ const repositoryLabelTarget = factoryGithubRepositoryLabelWriteTarget(path);
15934
+ if (repositoryLabelTarget) {
15935
+ const repoPath = `/github/repos/${encodeURIComponent(repositoryLabelTarget.owner)}/${encodeURIComponent(repositoryLabelTarget.repo)}/`;
15936
+ return isConfiguredGithubRepoPath(repoPath, config) && isAllowedFactoryGithubRepositoryLabelContent(content);
15937
+ }
15938
+ const target = factoryGithubIssueWriteTarget(path);
15939
+ if (!target)
15940
+ return false;
15941
+ if (!isAllowedFactoryGithubIssueWriteContent(target.kind, content))
15942
+ return false;
15943
+ if (target.kind === 'comment') {
15944
+ const body = asRecord(content)?.body;
15945
+ const draftName = path.slice(path.lastIndexOf('/') + 1);
15946
+ if (typeof body !== 'string' || draftName !== factoryGithubIssueCommentDraftName(body))
15947
+ return false;
15948
+ }
15949
+ const repoPath = `/github/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`;
15950
+ if (!isConfiguredGithubRepoPath(`${repoPath}/`, config))
15951
+ return false;
15952
+ const compactRepo = `${encodeURIComponent(target.owner)}__${encodeURIComponent(target.repo)}`;
15953
+ const candidates = [
15954
+ `${repoPath}/issues/by-id/${target.number}.json`,
15955
+ `/github/repos/${compactRepo}/issues/by-id/${target.number}.json`,
15956
+ `${repoPath}/issues/${target.number}/meta.json`,
15957
+ `/github/repos/${compactRepo}/issues/${target.number}/meta.json`,
15958
+ `${repoPath}/issues/${target.number}.json`,
15959
+ `/github/repos/${compactRepo}/issues/${target.number}.json`,
15960
+ ];
15961
+ for (const candidate of candidates) {
15962
+ try {
15963
+ const issue = parseGithubFactoryIssue(candidate, (await mount.readFile(candidate)).content);
15964
+ return issue.state?.name === 'open' && isInFactoryScope(issue, config.safety);
15965
+ }
15966
+ catch {
15967
+ // Try the next canonical/alias shape. Any total miss fails closed.
15968
+ }
15638
15969
  }
15639
15970
  return false;
15640
15971
  };
15641
- const isFactoryGithubWritebackPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/(?:factory\.json|refs%2Fheads%2Ffactory%2F[^/]+\.json)|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
15642
15972
  const isIssuePathInFactoryScope = async (mount, path, config) => {
15643
15973
  try {
15644
15974
  return isInFactoryScope(parseLinearIssue(path, (await mount.readFile(path)).content), config.safety);
@@ -16091,6 +16421,7 @@ const cloneTrackedAgent = (tracked) => ({
16091
16421
  result: tracked.result ? { ...tracked.result } : undefined,
16092
16422
  sessionRef: tracked.sessionRef,
16093
16423
  unreachableWakeResumedSessionRef: tracked.unreachableWakeResumedSessionRef,
16424
+ releasedAtMs: tracked.releasedAtMs,
16094
16425
  });
16095
16426
  const durableBabysitterTrackedAgent = (session, capability = 'spawn:claude') => ({
16096
16427
  spec: {
@@ -16167,7 +16498,11 @@ const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequ
16167
16498
  decision: structuredClone(record.decision),
16168
16499
  dryRun: record.dryRun,
16169
16500
  phase,
16170
- agents: [...record.agents].map(([name, tracked]) => ({ name, tracked: cloneTrackedAgent(tracked) })),
16501
+ agents: [...record.agents].map(([name, tracked]) => ({
16502
+ name,
16503
+ tracked: cloneTrackedAgent(tracked),
16504
+ ...(tracked.releasedAtMs !== undefined ? { releasedAtMs: tracked.releasedAtMs } : {}),
16505
+ })),
16171
16506
  invocationIds: [...record.invocationIds],
16172
16507
  result: record.result ? structuredClone(record.result) : undefined,
16173
16508
  ...(record.dispatchClaim ? { dispatchClaim: { ...record.dispatchClaim } } : {}),
@@ -16182,7 +16517,13 @@ const inFlightRecordFromLifecycle = (lifecycle) => ({
16182
16517
  issue: { ...lifecycle.issue },
16183
16518
  decision: structuredClone(lifecycle.decision),
16184
16519
  dryRun: lifecycle.dryRun,
16185
- agents: new Map(lifecycle.agents.map((agent) => [agent.name, cloneTrackedAgent(agent.tracked)])),
16520
+ // Release state has to survive takeover with the agents themselves. Without
16521
+ // it the rebuilt record reads a released placement as a live worker and the
16522
+ // spawn gate answers for a process that no longer exists.
16523
+ agents: new Map(lifecycle.agents.map((agent) => [agent.name, {
16524
+ ...cloneTrackedAgent(agent.tracked),
16525
+ releasedAtMs: agent.releasedAtMs ?? agent.tracked.releasedAtMs,
16526
+ }])),
16186
16527
  invocationIds: new Set(lifecycle.invocationIds),
16187
16528
  result: lifecycle.result ? structuredClone(lifecycle.result) : undefined,
16188
16529
  ...(lifecycle.dispatchClaim ? { dispatchClaim: { ...lifecycle.dispatchClaim } } : {}),
@@ -16209,7 +16550,14 @@ const triageEscalationReason = (decision) => {
16209
16550
  }
16210
16551
  return `${reasons.join(' and ')}${decision.rationale ? `: ${decision.rationale}` : ''}`;
16211
16552
  };
16212
- class LiveDispatchStateChangedError extends Error {
16553
+ /**
16554
+ * The dispatch claim race: another writer changed the issue's live state
16555
+ * between this process reading it and writing back, so the dispatch was
16556
+ * abandoned. Exported so callers outside this module — notably the CLI, which
16557
+ * turns it into a distinct exit code — can recognize it by type rather than by
16558
+ * matching on `error.name`, and so tests can construct a genuine instance.
16559
+ */
16560
+ export class LiveDispatchStateChangedError extends Error {
16213
16561
  issueKey;
16214
16562
  constructor(issueKey) {
16215
16563
  super(`Live state changed before writeback for ${issueKey}`);
@@ -16217,6 +16565,10 @@ class LiveDispatchStateChangedError extends Error {
16217
16565
  this.issueKey = issueKey;
16218
16566
  }
16219
16567
  }
16568
+ /** Whether a thrown value is a {@link LiveDispatchStateChangedError}. */
16569
+ export function isLiveDispatchStateChangedError(error) {
16570
+ return error instanceof LiveDispatchStateChangedError;
16571
+ }
16220
16572
  const triageEscalationQuestion = (decision, issue) => {
16221
16573
  const routedRepos = decision.routes.map((route) => route.repo).filter(Boolean);
16222
16574
  const subject = issue?.title?.trim() || decision.issue.key;