@agent-relay/factory 0.1.60 → 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 (162) hide show
  1. package/README.md +140 -16
  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 +23 -2
  7. package/dist/cli/fleet.d.ts.map +1 -1
  8. package/dist/cli/fleet.js +201 -42
  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 +175 -52
  15. package/dist/config/schema.d.ts.map +1 -1
  16. package/dist/config/schema.js +24 -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 +10 -6
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +6 -4
  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 +30 -0
  90. package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
  91. package/dist/orchestrator/batch-tracker.js +50 -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 +863 -98
  96. package/dist/orchestrator/factory.js.map +1 -1
  97. package/dist/orchestrator/index.d.ts +2 -2
  98. package/dist/orchestrator/index.d.ts.map +1 -1
  99. package/dist/orchestrator/index.js +2 -2
  100. package/dist/orchestrator/index.js.map +1 -1
  101. package/dist/orchestrator/reaper.d.ts +16 -2
  102. package/dist/orchestrator/reaper.d.ts.map +1 -1
  103. package/dist/orchestrator/reaper.js +79 -5
  104. package/dist/orchestrator/reaper.js.map +1 -1
  105. package/dist/ports/fleet.d.ts +6 -0
  106. package/dist/ports/fleet.d.ts.map +1 -1
  107. package/dist/ports/index.d.ts +1 -1
  108. package/dist/ports/index.d.ts.map +1 -1
  109. package/dist/ports/mount.d.ts +39 -0
  110. package/dist/ports/mount.d.ts.map +1 -1
  111. package/dist/ports/state.d.ts +35 -6
  112. package/dist/ports/state.d.ts.map +1 -1
  113. package/dist/state/document-store.d.ts +32 -0
  114. package/dist/state/document-store.d.ts.map +1 -0
  115. package/dist/state/document-store.js +7 -0
  116. package/dist/state/document-store.js.map +1 -0
  117. package/dist/state/file-state-store.d.ts +20 -3
  118. package/dist/state/file-state-store.d.ts.map +1 -1
  119. package/dist/state/file-state-store.js +89 -245
  120. package/dist/state/file-state-store.js.map +1 -1
  121. package/dist/state/in-memory-state-store.d.ts +2 -1
  122. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  123. package/dist/state/in-memory-state-store.js +12 -3
  124. package/dist/state/in-memory-state-store.js.map +1 -1
  125. package/dist/state/watch-state-document.d.ts +5 -0
  126. package/dist/state/watch-state-document.d.ts.map +1 -0
  127. package/dist/state/watch-state-document.js +444 -0
  128. package/dist/state/watch-state-document.js.map +1 -0
  129. package/dist/testing/deadline.d.ts +11 -0
  130. package/dist/testing/deadline.d.ts.map +1 -0
  131. package/dist/testing/deadline.js +23 -0
  132. package/dist/testing/deadline.js.map +1 -0
  133. package/dist/testing/fakes.d.ts +2 -0
  134. package/dist/testing/fakes.d.ts.map +1 -1
  135. package/dist/testing/fakes.js.map +1 -1
  136. package/dist/testing/index.d.ts +1 -0
  137. package/dist/testing/index.d.ts.map +1 -1
  138. package/dist/testing/index.js +1 -0
  139. package/dist/testing/index.js.map +1 -1
  140. package/dist/triage/heuristic.d.ts +5 -0
  141. package/dist/triage/heuristic.d.ts.map +1 -1
  142. package/dist/triage/heuristic.js +42 -3
  143. package/dist/triage/heuristic.js.map +1 -1
  144. package/dist/triage/index.d.ts +1 -1
  145. package/dist/triage/index.d.ts.map +1 -1
  146. package/dist/triage/index.js +1 -1
  147. package/dist/triage/index.js.map +1 -1
  148. package/dist/triage/schema.d.ts +73 -55
  149. package/dist/triage/schema.d.ts.map +1 -1
  150. package/dist/triage/schema.js +2 -1
  151. package/dist/triage/schema.js.map +1 -1
  152. package/dist/types.d.ts +51 -3
  153. package/dist/types.d.ts.map +1 -1
  154. package/dist/writeback/github.d.ts +22 -7
  155. package/dist/writeback/github.d.ts.map +1 -1
  156. package/dist/writeback/github.js +86 -16
  157. package/dist/writeback/github.js.map +1 -1
  158. package/dist/writeback/index.d.ts +1 -1
  159. package/dist/writeback/index.d.ts.map +1 -1
  160. package/dist/writeback/index.js +1 -1
  161. package/dist/writeback/index.js.map +1 -1
  162. package/package.json +13 -8
package/dist/cli/fleet.js CHANGED
@@ -1,13 +1,14 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, realpathSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { dirname, join, resolve } from 'node:path';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import readline from 'node:readline/promises';
6
6
  import { ensureCloudSession } from '@agent-relay/cloud';
7
7
  import { stringifyLogValue } from '../logging.js';
8
8
  import { resolveLocalFactoryConfig } from '../config/local-clone-paths.js';
9
9
  import { initializeFactory } from './init.js';
10
- import { FileStateStore, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, createFactoryCloudEventV1, ensureRelayBroker, FactoryCloudReporter, FileFactoryCloudEventOutbox, defaultGhRunner, explicitLinkedIssueKey, githubIssuePathParts, githubWatchStatePath, isInFactoryScope, parseGithubFactoryIssue, publishFactoryMountHealth, parseOwnedBrokerAgentExitTimeoutMs, parseStandaloneBabysitTarget, readStandalonePullRequest, readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, reapFactoryEnvironmentsOnce, readFactoryLoopHeartbeat, readFactoryInFlightRegistry, resolveFactoryStates, stateResolutionFromIds, standaloneBabysitterAgentName, renderAgentTask, resolveFactoryWorkspace, } from '../index.js';
10
+ import { FACTORY_EXIT, exitCodeForDispatchResult, exitCodeForError, exitCodeForIterationReport, exitCodeForLoopReports, exitCodeForRelayDispatch, } from './exit-codes.js';
11
+ import { FileStateStore, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, createFactoryCloudEventV1, ensureRelayBroker, FactoryCloudReporter, FileFactoryCloudEventOutbox, defaultGhRunner, explicitLinkedIssueKey, githubIssuePathParts, githubWatchStatePath, isAllowedFactoryGithubDraft, isAllowedFactoryGithubArtifactDraft, isInFactoryScope, parseGithubFactoryIssue, publishFactoryMountHealth, heldAgentsFromRegistry, parseOwnedBrokerAgentExitTimeoutMs, parseStandaloneBabysitTarget, readStandalonePullRequest, readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, reapFactoryEnvironmentsOnce, readFactoryInFlightRegistry, readFactoryLoopHeartbeat, resolveFactoryStates, stateResolutionFromIds, standaloneBabysitterAgentName, renderAgentTask, resolveFactoryWorkspace, } from '../index.js';
11
12
  import { resolveTestGuidance } from '../dispatch/test-guidance.js';
12
13
  import { FakeFleetClient, FakeMountClient } from '../testing/index.js';
13
14
  import { GitAgentWorktreeManager } from '../git/agent-worktree.js';
@@ -18,7 +19,7 @@ import { checkMountStaleness } from '../mount/relayfile-binary.js';
18
19
  import { MountAuthScopeError } from '../mount/mount-auth-error.js';
19
20
  import { resolveRelayWorkspaceKey } from '../fleet/relay-workspace-key.js';
20
21
  import { isMeaningfullyBehind, readFactoryVersionInfo, } from '../version-info.js';
21
- import { GhCliIssuePublisher, RelayChannelNotionClaimStore, RelayChannelNotionContractPublisher, loadNotionIntakeManifest, runNotionIntake, } from '../intake/index.js';
22
+ import { GhCliIssuePublisher, NotionApiFactoryTasksClient, RelayChannelNotionClaimStore, RelayChannelNotionContractPublisher, generateFactoryTasksManifest, loadNotionIntakeManifest, runNotionIntake, } from '../intake/index.js';
22
23
  const autoDetectedIssueSources = new WeakSet();
23
24
  export async function runFleetCli(argv, deps = {}) {
24
25
  const out = deps.stdout ?? process.stdout;
@@ -51,6 +52,23 @@ export async function runFleetCli(argv, deps = {}) {
51
52
  await initializeFactory({ repo: command.repo, workspaceId: command.workspaceId, stdout: out, stderr: err });
52
53
  return 0;
53
54
  }
55
+ if (command.kind === 'notion-manifest') {
56
+ const notion = deps.notionFactoryTasks ?? new NotionApiFactoryTasksClient({
57
+ token: (deps.env ?? process.env).NOTION_API_KEY ?? '',
58
+ });
59
+ const manifest = await generateFactoryTasksManifest({
60
+ client: notion,
61
+ ...(command.dataSourceId ? { dataSourceId: command.dataSourceId } : {}),
62
+ ...(command.mountRoot ? { mountRoot: command.mountRoot } : {}),
63
+ ...(command.workerMountRoot ? { workerMountRoot: command.workerMountRoot } : {}),
64
+ ...(command.workerMountTransport
65
+ ? { workerMountTransport: command.workerMountTransport }
66
+ : {}),
67
+ ...(command.statePath ? { statePath: command.statePath } : {}),
68
+ });
69
+ writeJson(out, manifest);
70
+ return 0;
71
+ }
54
72
  if (command.kind === 'notion-intake') {
55
73
  const manifest = await loadNotionIntakeManifest(command.manifestPath);
56
74
  if (!globals.dryRun) {
@@ -133,7 +151,7 @@ export async function runFleetCli(argv, deps = {}) {
133
151
  const workspaceId = (await (deps.resolveWorkspace ?? resolveFactoryWorkspace)()).workspaceId;
134
152
  mount = deps.mount ?? await (deps.cloudMountFromConfig ?? RelayfileCloudMountClient.fromConfig)({
135
153
  workspaceId,
136
- isAllowedDraft: (path, _content, opts) => isAllowedFactoryGithubDraft(path, opts),
154
+ isAllowedDraft: (path, _content, opts) => isAllowedFactoryGithubArtifactDraft(path, opts),
137
155
  });
138
156
  await prepareFactoryIntegrations(command, mount, undefined, globals, deps, workspaceId, err);
139
157
  githubWrite = mount.githubWrite;
@@ -201,8 +219,13 @@ export async function runFleetCli(argv, deps = {}) {
201
219
  registryPath: loaded.config.loop.registryPath,
202
220
  staleMs: loaded.config.loop.heartbeatStaleMs,
203
221
  fleet,
222
+ includeHeld: command.includeHeld,
223
+ logger: streamLogger(err),
224
+ });
225
+ writeJson(out, {
226
+ ...processes,
227
+ environments: await safeEnvironmentReap(Boolean(loaded.config.environments.kubernetes), deps.reapEnvironments),
204
228
  });
205
- writeJson(out, { ...processes, environments: await safeEnvironmentReap(deps.reapEnvironments) });
206
229
  return 0;
207
230
  }
208
231
  // Derive the workspace from the cloud session when it isn't pinned in
@@ -259,10 +282,17 @@ export async function runFleetCli(argv, deps = {}) {
259
282
  // A GitHub-only workspace has no /linear/states catalog and uses labels
260
283
  // for lifecycle state, so it must not depend on that provider at startup.
261
284
  const stateResolution = await resolveStatesForIssueSource(mount, loaded.config, deps.resolveStates);
262
- const stateStore = new FileStateStore({
263
- batchSize: loaded.config.batchSize,
264
- watchStatePath: githubWatchStatePath(loaded.config.loop.registryPath),
265
- });
285
+ const stateStore = deps.stateStoreFactory
286
+ ? await deps.stateStoreFactory(loaded.config)
287
+ : new FileStateStore({
288
+ batchSize: loaded.config.batchSize,
289
+ watchStatePath: githubWatchStatePath(loaded.config.loop.registryPath),
290
+ });
291
+ // An injected backend is an explicit dispatch-admission dependency.
292
+ // It must prove readiness before Factory construction; a host adapter
293
+ // may never turn an unreadable durable store into an empty document.
294
+ if (deps.stateStoreFactory)
295
+ await stateStore.assertReady();
266
296
  reporter = deps.reporter ?? await buildFactoryCloudReporter({
267
297
  config: loaded.config,
268
298
  backend: globals.backend,
@@ -294,7 +324,7 @@ export async function runFleetCli(argv, deps = {}) {
294
324
  reporter,
295
325
  worktrees: globals.backend === 'internal' ? new GitAgentWorktreeManager() : undefined,
296
326
  });
297
- return await runFactoryCommand(command, factory, mount, fleet, loaded.config, globals, out, deps, workspaceId, acceptableMountIds);
327
+ return await runFactoryCommand(command, factory, mount, fleet, loaded.config, globals, out, deps, workspaceId, acceptableMountIds, stateStore.backend ? { backend: stateStore.backend } : undefined);
298
328
  }
299
329
  }
300
330
  return 1;
@@ -314,7 +344,7 @@ export async function runFleetCli(argv, deps = {}) {
314
344
  }
315
345
  }
316
346
  err.write(`${error instanceof Error ? error.message : String(error)}\n`);
317
- return 1;
347
+ return exitCodeForError(error);
318
348
  }
319
349
  finally {
320
350
  try {
@@ -388,6 +418,32 @@ function parseIntakeCommand(args) {
388
418
  const [source, manifestPath, ...rest] = args;
389
419
  if (source !== 'notion')
390
420
  throw new Error('factory intake currently requires the notion source');
421
+ if (manifestPath === 'generate') {
422
+ const parsed = parseFlags(rest);
423
+ const allowed = new Set([
424
+ 'data-source',
425
+ 'mount-root',
426
+ 'worker-mount-root',
427
+ 'worker-mount-transport',
428
+ 'state-path',
429
+ ]);
430
+ const unexpected = Object.keys(parsed).find((key) => !allowed.has(key));
431
+ if (unexpected)
432
+ throw new Error(`Unknown Factory Tasks manifest option: --${unexpected}`);
433
+ const workerMountTransport = parsed['worker-mount-transport'];
434
+ if (workerMountTransport !== undefined &&
435
+ workerMountTransport !== 'local' && workerMountTransport !== 'relay-channel') {
436
+ throw new Error('--worker-mount-transport must be local or relay-channel');
437
+ }
438
+ return {
439
+ kind: 'notion-manifest',
440
+ ...(parsed['data-source'] ? { dataSourceId: parsed['data-source'] } : {}),
441
+ ...(parsed['mount-root'] ? { mountRoot: parsed['mount-root'] } : {}),
442
+ ...(parsed['worker-mount-root'] ? { workerMountRoot: parsed['worker-mount-root'] } : {}),
443
+ ...(workerMountTransport ? { workerMountTransport } : {}),
444
+ ...(parsed['state-path'] ? { statePath: parsed['state-path'] } : {}),
445
+ };
446
+ }
391
447
  if (!manifestPath)
392
448
  throw new Error('factory intake notion requires a manifest path');
393
449
  if (rest.length > 0)
@@ -484,7 +540,7 @@ export function parseGlobalOptions(argv) {
484
540
  }
485
541
  return { globals, args };
486
542
  }
487
- async function runFactoryCommand(command, factory, mount, fleet, config, globals, out, deps = {}, workspaceId = config.workspaceId ?? '', acceptableMountIds) {
543
+ async function runFactoryCommand(command, factory, mount, fleet, config, globals, out, deps = {}, workspaceId = config.workspaceId ?? '', acceptableMountIds, stateStoreStatus) {
488
544
  const mountFn = resolveLocalMountFn(deps, mount);
489
545
  const mountStderr = deps.stderr ?? process.stderr;
490
546
  const debugMountRefreshes = (deps.env ?? process.env).FACTORY_LOG_LEVEL?.toLowerCase() === 'debug';
@@ -523,15 +579,20 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
523
579
  // would only spawn agents against a read-denied mirror. Fail fast with the
524
580
  // remediation and resolve the command with a non-zero code.
525
581
  const warmMount = () => warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes);
582
+ // Returns the code the failure classifies to, so the inline branch below
583
+ // can surface it directly. The background branch reads the same value
584
+ // back off the waiter; both must report one code for one failure.
526
585
  const handleWarmMountError = (error) => {
527
586
  if (error instanceof MountAuthScopeError) {
587
+ const code = exitCodeForError(error);
528
588
  mountStderr.write(`${error.message}\n`);
529
589
  mountStderr.write('[factory] aborting startup: local mount cannot obtain its filesystem scopes.\n');
530
- void flushAndResolve(1);
531
- return;
590
+ void flushAndResolve(code);
591
+ return code;
532
592
  }
533
593
  const message = error instanceof Error ? error.message : String(error);
534
594
  mountStderr.write(`[factory] warning: background relayfile mount warmup failed: ${message}\n`);
595
+ return FACTORY_EXIT.FAILED;
535
596
  };
536
597
  const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
537
598
  exit: (code) => {
@@ -551,14 +612,14 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
551
612
  mountStderr.write('[factory] aborting startup: Relayfile workspace mirror could not be resolved.\n');
552
613
  if (stoppedBySignal)
553
614
  return await waiter.promise;
554
- return 1;
615
+ return FACTORY_EXIT.FAILED;
555
616
  }
556
617
  }
557
618
  catch (error) {
558
- handleWarmMountError(error);
619
+ const code = handleWarmMountError(error);
559
620
  if (stoppedBySignal)
560
621
  return await waiter.promise;
561
- return 1;
622
+ return code;
562
623
  }
563
624
  }
564
625
  else {
@@ -580,12 +641,13 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
580
641
  }
581
642
  if (command.action === 'run-once') {
582
643
  await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, mountStderr);
583
- writeJson(out, await factory.runOnce({ dryRun: globals.dryRun }));
584
- return 0;
644
+ const report = await factory.runOnce({ dryRun: globals.dryRun });
645
+ writeJson(out, report);
646
+ return exitCodeForIterationReport(report);
585
647
  }
586
648
  if (command.action === 'status') {
587
649
  const versionInfo = await safeFactoryVersionInfo(deps.versionInfo, Boolean(deps.versionInfo) || !hasInjectedFactoryRuntime(deps));
588
- writeJson(out, await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs, versionInfo));
650
+ writeJson(out, await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs, versionInfo, stateStoreStatus));
589
651
  return 0;
590
652
  }
591
653
  if (command.action === 'loop-status') {
@@ -606,18 +668,20 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
606
668
  const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
607
669
  processLike: deps.stopSignalProcessLike,
608
670
  });
671
+ let loopCode = FACTORY_EXIT.OK;
609
672
  try {
610
673
  const reports = await factory.runLoop({ dryRun: globals.dryRun });
674
+ loopCode = exitCodeForLoopReports(reports);
611
675
  writeJson(out, {
612
676
  reports,
613
- status: await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs),
677
+ status: await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs, undefined, stateStoreStatus),
614
678
  });
615
679
  }
616
680
  finally {
617
681
  removeSignalHandlers();
618
682
  await factory.stop();
619
683
  }
620
- return 0;
684
+ return loopCode;
621
685
  }
622
686
  if (command.kind === 'factory-canary') {
623
687
  const report = await factory.runOnce({ dryRun: true });
@@ -642,15 +706,16 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
642
706
  try {
643
707
  const result = await factory.dispatch(decision, { dryRun: false });
644
708
  writeJson(out, result);
645
- await factory.waitForDispatchTerminal(result.issue);
646
- return 0;
709
+ const terminalPhase = await factory.waitForDispatchTerminal(result.issue);
710
+ return exitCodeForRelayDispatch(result, terminalPhase);
647
711
  }
648
712
  finally {
649
713
  await factory.stop();
650
714
  }
651
715
  }
652
- writeJson(out, await factory.dispatch(decision, { dryRun: globals.dryRun }));
653
- return 0;
716
+ const dispatched = await factory.dispatch(decision, { dryRun: globals.dryRun });
717
+ writeJson(out, dispatched);
718
+ return exitCodeForDispatchResult(dispatched);
654
719
  }
655
720
  async function warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug') {
656
721
  const result = await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, stderr);
@@ -824,19 +889,31 @@ async function ensureMountPath(mountFn, workspaceId, path, mountOpts, stderr, lo
824
889
  function resolveIntegrationsMountRoot(mount) {
825
890
  return mount.getLocalMountRoot?.() ?? resolve(process.cwd(), '.integrations');
826
891
  }
827
- async function factoryStatusWithMountHealth(factory, mount, heartbeatPath, registryPath, heartbeatStaleMs, versionInfo) {
892
+ async function factoryStatusWithMountHealth(factory, mount, heartbeatPath, registryPath, heartbeatStaleMs, versionInfo, stateStoreStatus) {
828
893
  const status = factory.status();
829
894
  const [heartbeat, registry] = await Promise.all([
830
895
  readFactoryLoopHeartbeat(heartbeatPath),
831
896
  readFactoryInFlightRegistry(registryPath),
832
897
  ]);
833
- const registryDispatches = registry?.heartbeatPath && registry.heartbeatPath !== heartbeatPath
834
- ? []
835
- : inFlightDispatchesFromRegistry(registry);
898
+ // A registry stamped for a different heartbeat file belongs to another
899
+ // daemon instance; neither its dispatch nor held-agent view applies here.
900
+ const registryMatchesHeartbeat = !registry?.heartbeatPath || registry.heartbeatPath === heartbeatPath;
901
+ const registryDispatches = registryMatchesHeartbeat ? inFlightDispatchesFromRegistry(registry) : [];
902
+ const registryHeldAgents = registryMatchesHeartbeat ? heldAgentsFromRegistry(registry) : [];
903
+ const heldAgents = registryHeldAgents.length > 0 ? registryHeldAgents : (status.heldAgents ?? []);
836
904
  const observableStatus = registryDispatches.length > 0
837
905
  ? { ...status, inFlightDispatches: registryDispatches }
838
906
  : status;
839
907
  const liveness = checkFactoryLoopLiveness(heartbeat, { staleMs: heartbeatStaleMs });
908
+ // A live heartbeat belongs to the daemon, while `status` is a fresh local
909
+ // Factory instance. Do not misreport that local instance's `not-running`
910
+ // state when an older daemon heartbeat predates this health field.
911
+ const readinessReconcile = liveness.ok
912
+ ? heartbeat?.readinessReconcile
913
+ : observableStatus.readinessReconcile;
914
+ const fleetControlPlane = liveness.ok
915
+ ? heartbeat?.fleetControlPlane
916
+ : observableStatus.fleetControlPlane;
840
917
  const eventListener = liveness.ok
841
918
  ? heartbeat?.eventListener ?? {
842
919
  state: 'unknown',
@@ -848,11 +925,23 @@ async function factoryStatusWithMountHealth(factory, mount, heartbeatPath, regis
848
925
  };
849
926
  const health = mount.getLocalMountHealth?.();
850
927
  if (!health)
851
- return { ...observableStatus, ...versionInfo, eventListener };
928
+ return {
929
+ ...observableStatus,
930
+ ...versionInfo,
931
+ ...(stateStoreStatus ? { stateStore: stateStoreStatus } : {}),
932
+ heldAgents,
933
+ eventListener,
934
+ readinessReconcile,
935
+ fleetControlPlane,
936
+ };
852
937
  return {
853
938
  ...observableStatus,
854
939
  ...versionInfo,
940
+ ...(stateStoreStatus ? { stateStore: stateStoreStatus } : {}),
941
+ heldAgents,
855
942
  eventListener,
943
+ readinessReconcile,
944
+ fleetControlPlane,
856
945
  localMountDegraded: health.degraded,
857
946
  ...(health.reason ? { localMountDegradedReason: health.reason } : {}),
858
947
  ...(health.localDir ? { localMountRoot: health.localDir } : {}),
@@ -990,7 +1079,18 @@ function parseFactoryCommand(args) {
990
1079
  if (action === 'start') {
991
1080
  return { kind: 'factory', action, ...parseFactoryStartFlags([issueOrPr, ...flags]) };
992
1081
  }
993
- if (action === 'run-once' || action === 'loop' || action === 'status' || action === 'loop-status' || action === 'kill-loop' || action === 'reap-orphans') {
1082
+ if (action === 'reap-orphans') {
1083
+ const values = [issueOrPr, ...flags].filter((value) => Boolean(value));
1084
+ const unexpected = values.find((value) => value !== '--include-held');
1085
+ if (unexpected)
1086
+ throw new Error(`Unknown factory reap-orphans option: ${unexpected}`);
1087
+ return {
1088
+ kind: 'factory',
1089
+ action,
1090
+ ...(values.includes('--include-held') ? { includeHeld: true } : {}),
1091
+ };
1092
+ }
1093
+ if (action === 'run-once' || action === 'loop' || action === 'status' || action === 'loop-status' || action === 'kill-loop') {
994
1094
  return { kind: 'factory', action };
995
1095
  }
996
1096
  if (action === 'canary') {
@@ -1160,11 +1260,16 @@ function hasInjectedFactoryRuntime(deps) {
1160
1260
  async function buildFleet(globals, loaded, deps) {
1161
1261
  if (deps.fleet)
1162
1262
  return deps.fleet;
1163
- if (globals.backend === 'internal' && hasExplicitFixtureFiles(loaded))
1263
+ // fixtureFiles are an explicit hermetic harness opt-in. Keep both backend
1264
+ // command paths off real fleet infrastructure so the durable relay CLI path
1265
+ // can be exercised end-to-end with deterministic fixtures too.
1266
+ if (hasExplicitFixtureFiles(loaded))
1164
1267
  return new FakeFleetClient();
1165
1268
  const cwd = process.cwd();
1166
1269
  const env = deps.env ?? process.env;
1167
- const connectionPath = resolveBrokerConnectionPath(cwd, env);
1270
+ const connectionPath = globals.backend === 'internal'
1271
+ ? resolveFactoryBrokerConnectionPath(cwd, env, loaded?.config.fleetHealth.requireDedicatedBroker ?? false)
1272
+ : resolveBrokerConnectionPath(cwd, env);
1168
1273
  // An injected createFleet owns fleet construction entirely (tests), so skip the
1169
1274
  // real broker bootstrap.
1170
1275
  if (deps.createFleet) {
@@ -1241,6 +1346,51 @@ export function resolveBrokerConnectionPath(startCwd = process.cwd(), env = proc
1241
1346
  current = parent;
1242
1347
  }
1243
1348
  }
1349
+ export function resolveFactoryBrokerConnectionPath(startCwd = process.cwd(), env = process.env, requireDedicatedBroker = false) {
1350
+ const connectionPath = resolveBrokerConnectionPath(startCwd, env);
1351
+ if (!requireDedicatedBroker)
1352
+ return connectionPath;
1353
+ const explicitStateDir = env.AGENT_RELAY_STATE_DIR?.trim();
1354
+ if (!explicitStateDir) {
1355
+ throw new Error('fleetHealth.requireDedicatedBroker requires AGENT_RELAY_STATE_DIR to name Factory\'s isolated relay state directory');
1356
+ }
1357
+ const inheritedConnectionPath = resolveBrokerConnectionPath(startCwd, {
1358
+ ...env,
1359
+ AGENT_RELAY_STATE_DIR: '',
1360
+ });
1361
+ // Always reject the checkout-local path, even if broker discovery currently
1362
+ // walks past it and finds an ancestor connection. An interactive relay
1363
+ // started in this checkout can claim the local path later.
1364
+ const sharedStateDirs = [
1365
+ join(resolve(startCwd), '.agentworkforce', 'relay'),
1366
+ ...(inheritedConnectionPath ? [dirname(inheritedConnectionPath)] : []),
1367
+ ];
1368
+ if (sharedStateDirs.some((stateDir) => sameFilesystemPath(explicitStateDir, stateDir))) {
1369
+ throw new Error('AGENT_RELAY_STATE_DIR resolves to the project broker; choose a separate state directory for Factory');
1370
+ }
1371
+ return connectionPath;
1372
+ }
1373
+ function sameFilesystemPath(left, right) {
1374
+ return canonicalPath(left) === canonicalPath(right);
1375
+ }
1376
+ function canonicalPath(path) {
1377
+ const absolute = resolve(path);
1378
+ const missingSegments = [];
1379
+ let existingAncestor = absolute;
1380
+ while (!existsSync(existingAncestor)) {
1381
+ const parent = dirname(existingAncestor);
1382
+ if (parent === existingAncestor)
1383
+ return absolute;
1384
+ missingSegments.unshift(basename(existingAncestor));
1385
+ existingAncestor = parent;
1386
+ }
1387
+ try {
1388
+ return resolve(realpathSync.native(existingAncestor), ...missingSegments);
1389
+ }
1390
+ catch {
1391
+ return absolute;
1392
+ }
1393
+ }
1244
1394
  async function prepareFactoryIntegrations(command, mount, config, globals, deps, workspaceId, err) {
1245
1395
  const connections = mount.integrationConnections;
1246
1396
  if (!connections)
@@ -1449,13 +1599,10 @@ async function isAllowedFactoryDraft(path, content, opts, mount, config) {
1449
1599
  if (/^\/slack\/channels\/[^/]+\/messages\/.+/u.test(path)) {
1450
1600
  return true;
1451
1601
  }
1452
- if (isAllowedFactoryGithubDraft(path, opts)) {
1602
+ if (await isAllowedFactoryGithubDraft(path, content, opts, mount, config))
1453
1603
  return true;
1454
- }
1455
1604
  return false;
1456
1605
  }
1457
- 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);
1458
- const isAllowedFactoryGithubDraft = (path, opts) => opts?.guarded === true && isFactoryGithubWritebackPath(path);
1459
1606
  const scopeIssueFromDraftContent = (content) => ({
1460
1607
  title: typeof asRecord(content)?.title === 'string' ? asRecord(content)?.title : '',
1461
1608
  team: typeof asRecord(asRecord(content)?.team)?.key === 'string'
@@ -1780,14 +1927,23 @@ function requireValue(args, index, flag) {
1780
1927
  function writeJson(out, value) {
1781
1928
  out.write(`${JSON.stringify(value, null, 2)}\n`);
1782
1929
  }
1783
- async function safeEnvironmentReap(reap = reapFactoryEnvironmentsOnce) {
1930
+ async function safeEnvironmentReap(configured, reap = reapFactoryEnvironmentsOnce) {
1931
+ if (!configured) {
1932
+ return {
1933
+ applicable: false,
1934
+ reason: 'kubernetes environment provider is not configured',
1935
+ reaped: [],
1936
+ retained: [],
1937
+ };
1938
+ }
1784
1939
  try {
1785
- return await reap();
1940
+ return { applicable: true, ...await reap() };
1786
1941
  }
1787
1942
  catch (error) {
1788
1943
  // Process cleanup remains useful on hosts without kubectl or an active
1789
1944
  // cluster. Surface the skipped backstop without failing the whole command.
1790
1945
  return {
1946
+ applicable: true,
1791
1947
  reaped: [],
1792
1948
  retained: [],
1793
1949
  error: error instanceof Error ? error.message : String(error),
@@ -1885,7 +2041,8 @@ Commands:
1885
2041
  loop Run the bounded loop configured in factory.config.json
1886
2042
  loop-status Print heartbeat/liveness status for the loop
1887
2043
  kill-loop Send SIGTERM to the heartbeat pid
1888
- reap-orphans Reap stale factory-owned agents
2044
+ reap-orphans [--include-held]
2045
+ Report stale processes and held agents; opt in to release held agents past deadline
1889
2046
  canary <KEY|path> Check that a known issue is dispatch-ready
1890
2047
  triage <KEY|path> Triage one issue and print the decision
1891
2048
  dispatch <KEY|path> Triage and dispatch one issue
@@ -1893,6 +2050,8 @@ Commands:
1893
2050
  close-probe <PR> Probe/close a PR for an issue
1894
2051
  featuremap check Validate .agentworkforce/features/manifest.yaml
1895
2052
  intake notion <file> Normalize mounted Notion specs into Factory work
2053
+ intake notion generate
2054
+ Emit a manifest for Ready for Agent Factory Tasks
1896
2055
  fleet <command> Low-level fleet commands: spawn, roster, release
1897
2056
 
1898
2057
  Options: