@agent-relay/factory 0.1.56 → 0.1.58

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 (141) hide show
  1. package/README.md +148 -2
  2. package/dist/cli/fleet.d.ts +13 -0
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +431 -103
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/schema.d.ts +1107 -348
  7. package/dist/config/schema.d.ts.map +1 -1
  8. package/dist/config/schema.js +67 -6
  9. package/dist/config/schema.js.map +1 -1
  10. package/dist/cost/index.d.ts +3 -0
  11. package/dist/cost/index.d.ts.map +1 -0
  12. package/dist/cost/index.js +3 -0
  13. package/dist/cost/index.js.map +1 -0
  14. package/dist/cost/ledger.d.ts +69 -0
  15. package/dist/cost/ledger.d.ts.map +1 -0
  16. package/dist/cost/ledger.js +162 -0
  17. package/dist/cost/ledger.js.map +1 -0
  18. package/dist/cost/pricing.d.ts +18 -0
  19. package/dist/cost/pricing.d.ts.map +1 -0
  20. package/dist/cost/pricing.js +54 -0
  21. package/dist/cost/pricing.js.map +1 -0
  22. package/dist/feature-guardian/conversation.d.ts +251 -0
  23. package/dist/feature-guardian/conversation.d.ts.map +1 -0
  24. package/dist/feature-guardian/conversation.js +1893 -0
  25. package/dist/feature-guardian/conversation.js.map +1 -0
  26. package/dist/feature-guardian/index.d.ts +2 -0
  27. package/dist/feature-guardian/index.d.ts.map +1 -0
  28. package/dist/feature-guardian/index.js +2 -0
  29. package/dist/feature-guardian/index.js.map +1 -0
  30. package/dist/fleet/internal-fleet-client.d.ts +3 -1
  31. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  32. package/dist/fleet/internal-fleet-client.js +81 -0
  33. package/dist/fleet/internal-fleet-client.js.map +1 -1
  34. package/dist/fleet/relay-fleet-client.d.ts +3 -1
  35. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  36. package/dist/fleet/relay-fleet-client.js +48 -0
  37. package/dist/fleet/relay-fleet-client.js.map +1 -1
  38. package/dist/github/index.d.ts +2 -0
  39. package/dist/github/index.d.ts.map +1 -1
  40. package/dist/github/index.js +1 -0
  41. package/dist/github/index.js.map +1 -1
  42. package/dist/github/routed-pr-babysitter.d.ts +48 -0
  43. package/dist/github/routed-pr-babysitter.d.ts.map +1 -0
  44. package/dist/github/routed-pr-babysitter.js +197 -0
  45. package/dist/github/routed-pr-babysitter.js.map +1 -0
  46. package/dist/hosted/index.d.ts +1 -1
  47. package/dist/hosted/index.d.ts.map +1 -1
  48. package/dist/hosted/index.js +1 -1
  49. package/dist/hosted/index.js.map +1 -1
  50. package/dist/hosted/orchestrator.d.ts +1 -0
  51. package/dist/hosted/orchestrator.d.ts.map +1 -1
  52. package/dist/hosted/orchestrator.js +190 -1
  53. package/dist/hosted/orchestrator.js.map +1 -1
  54. package/dist/hosted/types.d.ts +4 -0
  55. package/dist/hosted/types.d.ts.map +1 -1
  56. package/dist/index.d.ts +8 -4
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +5 -1
  59. package/dist/index.js.map +1 -1
  60. package/dist/intake/index.d.ts +4 -0
  61. package/dist/intake/index.d.ts.map +1 -0
  62. package/dist/intake/index.js +4 -0
  63. package/dist/intake/index.js.map +1 -0
  64. package/dist/intake/notion-relay-claim.d.ts +32 -0
  65. package/dist/intake/notion-relay-claim.d.ts.map +1 -0
  66. package/dist/intake/notion-relay-claim.js +173 -0
  67. package/dist/intake/notion-relay-claim.js.map +1 -0
  68. package/dist/intake/notion-relay-contract.d.ts +33 -0
  69. package/dist/intake/notion-relay-contract.d.ts.map +1 -0
  70. package/dist/intake/notion-relay-contract.js +163 -0
  71. package/dist/intake/notion-relay-contract.js.map +1 -0
  72. package/dist/intake/notion.d.ts +375 -0
  73. package/dist/intake/notion.d.ts.map +1 -0
  74. package/dist/intake/notion.js +773 -0
  75. package/dist/intake/notion.js.map +1 -0
  76. package/dist/mount/github-api-issue-read.d.ts +28 -0
  77. package/dist/mount/github-api-issue-read.d.ts.map +1 -0
  78. package/dist/mount/github-api-issue-read.js +163 -0
  79. package/dist/mount/github-api-issue-read.js.map +1 -0
  80. package/dist/mount/local-mount-preflight.d.ts +2 -0
  81. package/dist/mount/local-mount-preflight.d.ts.map +1 -1
  82. package/dist/mount/local-mount-preflight.js +59 -9
  83. package/dist/mount/local-mount-preflight.js.map +1 -1
  84. package/dist/mount/mount-auth-error.d.ts +41 -0
  85. package/dist/mount/mount-auth-error.d.ts.map +1 -0
  86. package/dist/mount/mount-auth-error.js +94 -0
  87. package/dist/mount/mount-auth-error.js.map +1 -0
  88. package/dist/mount/relayfile-binary.d.ts +5 -0
  89. package/dist/mount/relayfile-binary.d.ts.map +1 -1
  90. package/dist/mount/relayfile-binary.js +16 -2
  91. package/dist/mount/relayfile-binary.js.map +1 -1
  92. package/dist/mount/relayfile-cloud-mount-client.d.ts +18 -3
  93. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  94. package/dist/mount/relayfile-cloud-mount-client.js +157 -13
  95. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  96. package/dist/mount/workspace-mirror.d.ts +13 -0
  97. package/dist/mount/workspace-mirror.d.ts.map +1 -0
  98. package/dist/mount/workspace-mirror.js +94 -0
  99. package/dist/mount/workspace-mirror.js.map +1 -0
  100. package/dist/observability/events.d.ts +715 -47
  101. package/dist/observability/events.d.ts.map +1 -1
  102. package/dist/observability/events.js +41 -0
  103. package/dist/observability/events.js.map +1 -1
  104. package/dist/orchestrator/factory.d.ts +51 -0
  105. package/dist/orchestrator/factory.d.ts.map +1 -1
  106. package/dist/orchestrator/factory.js +574 -50
  107. package/dist/orchestrator/factory.js.map +1 -1
  108. package/dist/ports/fleet.d.ts +11 -0
  109. package/dist/ports/fleet.d.ts.map +1 -1
  110. package/dist/ports/index.d.ts +2 -2
  111. package/dist/ports/index.d.ts.map +1 -1
  112. package/dist/ports/mount.d.ts +58 -1
  113. package/dist/ports/mount.d.ts.map +1 -1
  114. package/dist/ports/state.d.ts +42 -5
  115. package/dist/ports/state.d.ts.map +1 -1
  116. package/dist/state/file-state-store.d.ts +10 -1
  117. package/dist/state/file-state-store.d.ts.map +1 -1
  118. package/dist/state/file-state-store.js +91 -0
  119. package/dist/state/file-state-store.js.map +1 -1
  120. package/dist/state/in-memory-state-store.d.ts +10 -1
  121. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  122. package/dist/state/in-memory-state-store.js +43 -0
  123. package/dist/state/in-memory-state-store.js.map +1 -1
  124. package/dist/telemetry.d.ts +9 -0
  125. package/dist/telemetry.d.ts.map +1 -0
  126. package/dist/telemetry.js +8 -0
  127. package/dist/telemetry.js.map +1 -0
  128. package/dist/testing/fakes.d.ts +8 -1
  129. package/dist/testing/fakes.d.ts.map +1 -1
  130. package/dist/testing/fakes.js +18 -0
  131. package/dist/testing/fakes.js.map +1 -1
  132. package/dist/triage/schema.d.ts +175 -42
  133. package/dist/triage/schema.d.ts.map +1 -1
  134. package/dist/triage/schema.js +19 -0
  135. package/dist/triage/schema.js.map +1 -1
  136. package/dist/types.d.ts +33 -0
  137. package/dist/types.d.ts.map +1 -1
  138. package/dist/writeback/github.d.ts.map +1 -1
  139. package/dist/writeback/github.js +39 -0
  140. package/dist/writeback/github.js.map +1 -1
  141. package/package.json +15 -2
package/dist/cli/fleet.js CHANGED
@@ -15,14 +15,18 @@ import { checkFeatureMap } from '../featuremap/index.js';
15
15
  import { loadOrCreateFactoryInstanceId, resolveFactoryInstanceName } from '../observability/instance-identity.js';
16
16
  import { ensureFactoryIntegrations, inspectFactoryIntegration, openIntegrationUrl, } from '../mount/relayfile-integration-preflight.js';
17
17
  import { checkMountStaleness } from '../mount/relayfile-binary.js';
18
+ import { MountAuthScopeError } from '../mount/mount-auth-error.js';
19
+ import { resolveRelayWorkspaceKey } from '../fleet/relay-workspace-key.js';
20
+ import { GhCliIssuePublisher, RelayChannelNotionClaimStore, RelayChannelNotionContractPublisher, loadNotionIntakeManifest, runNotionIntake, } from '../intake/index.js';
18
21
  const autoDetectedIssueSources = new WeakSet();
19
- const CLONE_MOUNT_PREFLIGHT_CONCURRENCY = 4;
20
22
  export async function runFleetCli(argv, deps = {}) {
21
23
  const out = deps.stdout ?? process.stdout;
22
24
  const err = deps.stderr ?? process.stderr;
23
25
  let fleet;
24
26
  let mount;
25
27
  let reporter;
28
+ let notionContracts;
29
+ let notionClaims;
26
30
  try {
27
31
  if (argv.some(isHelpFlag)) {
28
32
  out.write(helpText());
@@ -46,6 +50,81 @@ export async function runFleetCli(argv, deps = {}) {
46
50
  await initializeFactory({ repo: command.repo, workspaceId: command.workspaceId, stdout: out, stderr: err });
47
51
  return 0;
48
52
  }
53
+ if (command.kind === 'notion-intake') {
54
+ const manifest = await loadNotionIntakeManifest(command.manifestPath);
55
+ if (!globals.dryRun) {
56
+ notionClaims = deps.notionClaims;
57
+ notionContracts = deps.notionContracts;
58
+ const needsWorkspaceKey = !notionClaims ||
59
+ (manifest.workerMountTransport.kind === 'relay-channel' && !notionContracts);
60
+ const workspaceKey = needsWorkspaceKey
61
+ ? resolveRelayWorkspaceKey({
62
+ env: deps.env ?? process.env,
63
+ ...(deps.env ? { activeWorkspaceKey: () => undefined } : {}),
64
+ })
65
+ : undefined;
66
+ if (needsWorkspaceKey && !workspaceKey) {
67
+ throw new Error('Notion dispatch requires an active Agent Relay workspace for its durable shared claim');
68
+ }
69
+ notionClaims ??= new RelayChannelNotionClaimStore({ workspaceKey: workspaceKey });
70
+ if (manifest.workerMountTransport.kind === 'relay-channel') {
71
+ notionContracts ??= new RelayChannelNotionContractPublisher({ workspaceKey: workspaceKey });
72
+ }
73
+ }
74
+ const workspace = {
75
+ find: async (name) => {
76
+ fleet ??= await buildFleet(globals, undefined, deps);
77
+ const running = (await fleet.roster()).agents.find((agent) => agent.name === name);
78
+ return running ? { agent: running.name, node: running.node } : undefined;
79
+ },
80
+ dispatch: async (task) => {
81
+ fleet ??= await buildFleet(globals, undefined, deps);
82
+ const running = (await fleet.roster()).agents.find((agent) => agent.name === task.name);
83
+ if (running)
84
+ return { agent: running.name, node: running.node, status: 'already-running' };
85
+ const spawned = await fleet.spawn({
86
+ name: task.name,
87
+ capability: 'spawn:codex',
88
+ node: task.node ?? 'self',
89
+ task: task.task,
90
+ cwd: task.projectPath,
91
+ invocationId: task.invocationId,
92
+ });
93
+ fleet.preserveInfrastructureOnDispose?.();
94
+ return { agent: spawned.name, node: spawned.node, status: 'spawned' };
95
+ },
96
+ redispatch: async (task) => {
97
+ fleet ??= await buildFleet(globals, undefined, deps);
98
+ const running = (await fleet.roster()).agents.find((agent) => agent.name === task.name);
99
+ if (running) {
100
+ await fleet.sendMessage({ to: `@${running.name}`, text: task.task, mode: 'steer' });
101
+ return { agent: running.name, node: running.node, status: 'updated-running' };
102
+ }
103
+ const spawned = await fleet.spawn({
104
+ name: task.name,
105
+ capability: 'spawn:codex',
106
+ node: task.node ?? 'self',
107
+ task: task.task,
108
+ cwd: task.projectPath,
109
+ invocationId: task.invocationId,
110
+ });
111
+ fleet.preserveInfrastructureOnDispose?.();
112
+ return { agent: spawned.name, node: spawned.node, status: 'respawned' };
113
+ },
114
+ };
115
+ const report = await runNotionIntake({
116
+ manifest,
117
+ dispatch: !globals.dryRun,
118
+ ...(!globals.dryRun ? {
119
+ github: new GhCliIssuePublisher(),
120
+ workspace,
121
+ ...(notionClaims ? { claims: notionClaims } : {}),
122
+ ...(notionContracts ? { contracts: notionContracts } : {}),
123
+ } : {}),
124
+ });
125
+ writeJson(out, report);
126
+ return report.ok ? 0 : 1;
127
+ }
49
128
  if (command.kind === 'factory-close-probe') {
50
129
  // Manual close-probe remains strict; the daemon relaxes the title marker only after issue-synthetic classification.
51
130
  let githubWrite;
@@ -238,27 +317,47 @@ export async function runFleetCli(argv, deps = {}) {
238
317
  }
239
318
  finally {
240
319
  try {
241
- await mount?.dispose?.();
320
+ try {
321
+ await notionClaims?.dispose?.();
322
+ }
323
+ catch {
324
+ err.write('[factory] warning: Notion claim store failed during shutdown\n');
325
+ }
242
326
  }
243
327
  finally {
244
328
  try {
245
- await fleet?.dispose();
329
+ try {
330
+ await notionContracts?.dispose?.();
331
+ }
332
+ catch {
333
+ err.write('[factory] warning: Notion contract publisher failed during shutdown\n');
334
+ }
246
335
  }
247
336
  finally {
248
- if (reporter) {
337
+ try {
338
+ await mount?.dispose?.();
339
+ }
340
+ finally {
249
341
  try {
250
- await reporter.report(createFactoryCloudEventV1({
251
- type: 'instance.stopping',
252
- attributes: { component: 'cli', operation: 'stop' },
253
- }));
254
- await reporter.report(createFactoryCloudEventV1({
255
- type: 'instance.stopped',
256
- attributes: { component: 'cli', operation: 'stop' },
257
- }));
258
- await reporter.close?.({ deadlineMs: 2_000 });
342
+ await fleet?.dispose();
259
343
  }
260
- catch {
261
- err.write('[factory] warning: Cloud progress reporter failed during shutdown\n');
344
+ finally {
345
+ if (reporter) {
346
+ try {
347
+ await reporter.report(createFactoryCloudEventV1({
348
+ type: 'instance.stopping',
349
+ attributes: { component: 'cli', operation: 'stop' },
350
+ }));
351
+ await reporter.report(createFactoryCloudEventV1({
352
+ type: 'instance.stopped',
353
+ attributes: { component: 'cli', operation: 'stop' },
354
+ }));
355
+ await reporter.close?.({ deadlineMs: 2_000 });
356
+ }
357
+ catch {
358
+ err.write('[factory] warning: Cloud progress reporter failed during shutdown\n');
359
+ }
360
+ }
262
361
  }
263
362
  }
264
363
  }
@@ -279,8 +378,21 @@ export function parseFleetCommand(args) {
279
378
  if (verb === 'featuremap') {
280
379
  return parseFeatureMapCommand(rest);
281
380
  }
381
+ if (verb === 'intake') {
382
+ return parseIntakeCommand(rest);
383
+ }
282
384
  throw new Error(`Unknown factory command: ${verb}`);
283
385
  }
386
+ function parseIntakeCommand(args) {
387
+ const [source, manifestPath, ...rest] = args;
388
+ if (source !== 'notion')
389
+ throw new Error('factory intake currently requires the notion source');
390
+ if (!manifestPath)
391
+ throw new Error('factory intake notion requires a manifest path');
392
+ if (rest.length > 0)
393
+ throw new Error(`Unexpected factory intake argument: ${rest[0]}`);
394
+ return { kind: 'notion-intake', manifestPath };
395
+ }
284
396
  function parseFeatureMapCommand(args) {
285
397
  const [action, ...flags] = args;
286
398
  if (action !== 'check') {
@@ -377,15 +489,6 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
377
489
  const debugMountRefreshes = (deps.env ?? process.env).FACTORY_LOG_LEVEL?.toLowerCase() === 'debug';
378
490
  if (command.kind === 'factory') {
379
491
  if (command.action === 'start') {
380
- // Local mirrors are a writeback aid, not the source of truth for remote
381
- // issue discovery. Start their SDK-backed supervisors immediately, but
382
- // do not serialize durable recovery behind a stale checkout's readiness
383
- // timeout. The mount client reports degradation and keeps retrying.
384
- void warmStartPathMounts(mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes)
385
- .catch((error) => {
386
- const message = error instanceof Error ? error.message : String(error);
387
- mountStderr.write(`[factory] warning: background relayfile mount warmup failed: ${message}\n`);
388
- });
389
492
  const waiter = createStopSignalWaiter();
390
493
  let stoppedBySignal = false;
391
494
  const flushAndResolve = async (code) => {
@@ -396,6 +499,27 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
396
499
  waiter.resolve(code);
397
500
  }
398
501
  };
502
+ // Once a workspace mirror is known, retain background stale-mount
503
+ // supervision so durable recovery is not serialized behind a refresh.
504
+ // If there is no registered root yet, however, wait for the single
505
+ // mount/admission fallback before Factory can dispatch: agents must not
506
+ // receive a provisional checkout-local `.integrations` path.
507
+ //
508
+ // A MountAuthScopeError is the exception: it is terminal (the cloud
509
+ // session lacks the filesystem scope the mount needs), so limping on
510
+ // would only spawn agents against a read-denied mirror. Fail fast with the
511
+ // remediation and resolve the command with a non-zero code.
512
+ const warmMount = () => warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes);
513
+ const handleWarmMountError = (error) => {
514
+ if (error instanceof MountAuthScopeError) {
515
+ mountStderr.write(`${error.message}\n`);
516
+ mountStderr.write('[factory] aborting startup: local mount cannot obtain its filesystem scopes.\n');
517
+ void flushAndResolve(1);
518
+ return;
519
+ }
520
+ const message = error instanceof Error ? error.message : String(error);
521
+ mountStderr.write(`[factory] warning: background relayfile mount warmup failed: ${message}\n`);
522
+ };
399
523
  const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
400
524
  exit: (code) => {
401
525
  stoppedBySignal = true;
@@ -407,6 +531,28 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
407
531
  processLike: deps.stopSignalProcessLike,
408
532
  });
409
533
  try {
534
+ if (mount.getLocalMountRoot?.() === undefined) {
535
+ try {
536
+ const result = await warmMount();
537
+ if (!result.mounted) {
538
+ mountStderr.write('[factory] aborting startup: Relayfile workspace mirror could not be resolved.\n');
539
+ if (stoppedBySignal)
540
+ return await waiter.promise;
541
+ return 1;
542
+ }
543
+ }
544
+ catch (error) {
545
+ handleWarmMountError(error);
546
+ if (stoppedBySignal)
547
+ return await waiter.promise;
548
+ return 1;
549
+ }
550
+ }
551
+ else {
552
+ void warmMount().catch(handleWarmMountError);
553
+ }
554
+ if (stoppedBySignal)
555
+ return await waiter.promise;
410
556
  await factory.start({ mode: command.mode });
411
557
  const code = await (deps.waitForStopSignal?.() ?? waiter.promise);
412
558
  return typeof code === 'number' ? code : 0;
@@ -419,12 +565,12 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
419
565
  }
420
566
  }
421
567
  if (command.action === 'run-once') {
422
- await ensureClonePathMounts(mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes);
568
+ await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, mountStderr);
423
569
  writeJson(out, await factory.runOnce({ dryRun: globals.dryRun }));
424
570
  return 0;
425
571
  }
426
572
  if (command.action === 'status') {
427
- writeJson(out, factory.status());
573
+ writeJson(out, await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.heartbeatStaleMs));
428
574
  return 0;
429
575
  }
430
576
  if (command.action === 'loop-status') {
@@ -441,13 +587,16 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
441
587
  writeJson(out, { killed: heartbeat.pid, signal: 'SIGTERM' });
442
588
  return 0;
443
589
  }
444
- await ensureClonePathMounts(mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes);
590
+ await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, mountStderr);
445
591
  const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
446
592
  processLike: deps.stopSignalProcessLike,
447
593
  });
448
594
  try {
449
595
  const reports = await factory.runLoop({ dryRun: globals.dryRun });
450
- writeJson(out, { reports, status: factory.status() });
596
+ writeJson(out, {
597
+ reports,
598
+ status: await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.heartbeatStaleMs),
599
+ });
451
600
  }
452
601
  finally {
453
602
  removeSignalHandlers();
@@ -461,8 +610,11 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
461
610
  writeJson(out, result);
462
611
  return result.ok ? 0 : 1;
463
612
  }
464
- const issue = await readIssueArg(mount, command.issue, config);
465
- const decision = await factory.triageIssue(issue);
613
+ const resolved = await resolveIssueArg(mount, command.issue, config, async () => issueProjectionStatus(factory, mount, config));
614
+ const decision = {
615
+ ...await factory.triageIssue(resolved.issue),
616
+ issueResolution: resolved.resolution,
617
+ };
466
618
  if (command.kind === 'factory-triage') {
467
619
  writeJson(out, decision);
468
620
  return 0;
@@ -485,23 +637,18 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
485
637
  writeJson(out, await factory.dispatch(decision, { dryRun: globals.dryRun }));
486
638
  return 0;
487
639
  }
488
- async function warmStartPathMounts(mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug') {
489
- const mountOpts = { acceptableWorkspaceIds: acceptableMountIds };
490
- const [daemonRefresh, cloneRefreshes] = await Promise.all([
491
- ensureMountPath(mountFn, workspaceId, process.cwd(), mountOpts, stderr),
492
- ensureClonePathMounts(mountFn, workspaceId, config, acceptableMountIds, stderr, debug, false),
493
- ]);
494
- writeMountRefreshSummary([...(daemonRefresh ? [daemonRefresh] : []), ...cloneRefreshes], stderr, debug);
640
+ async function warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug') {
641
+ const result = await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, stderr);
642
+ writeMountRefreshSummary(result.refreshed ? [result.refreshed] : [], stderr, debug);
643
+ stderr.write(`[factory] Relayfile workspace mirror preflight: mounted=${result.mounted ? 1 : 0} ` +
644
+ `failed=${result.mounted ? 0 : 1} routedRepos=${new Set(Object.values(config.repos.byLabel)).size}\n`);
645
+ return result;
495
646
  }
496
647
  async function runStandaloneBabysitCommand(command, mount, fleet, config, globals, out, deps, workspaceId, acceptableMountIds) {
497
648
  const repo = resolveStandaloneBabysitRepo(command.repo, config);
498
649
  const clonePath = standaloneBabysitClonePath(repo, config);
499
650
  const mountFn = resolveLocalMountFn(deps, mount);
500
- const mountOpts = { acceptableWorkspaceIds: acceptableMountIds };
501
- await ensureStandaloneBabysitMount(mountFn, workspaceId, process.cwd(), mountOpts, deps.stderr);
502
- if (clonePath && resolve(clonePath) !== resolve(process.cwd())) {
503
- await ensureStandaloneBabysitMount(mountFn, workspaceId, clonePath, mountOpts, deps.stderr);
504
- }
651
+ await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, deps.stderr);
505
652
  const pr = await readStandalonePullRequest(mount, { repo, prNumber: command.prNumber, url: command.url }, deps.babysitPrGhRunner ?? defaultGhRunner);
506
653
  const state = pr.state?.trim().toUpperCase();
507
654
  if (pr.merged || state === 'MERGED') {
@@ -561,7 +708,7 @@ async function runStandaloneBabysitCommand(command, mount, fleet, config, global
561
708
  maintainerCanModify: pr.maintainerCanModify,
562
709
  },
563
710
  standaloneBabysitter: { specSource },
564
- integrationsMountRoot: resolve(process.cwd(), '.integrations'),
711
+ integrationsMountRoot: resolveIntegrationsMountRoot(mount),
565
712
  testGuidance,
566
713
  });
567
714
  const receiptBase = {
@@ -597,16 +744,6 @@ async function runStandaloneBabysitCommand(command, mount, fleet, config, global
597
744
  writeJson(out, { status: 'spawned', ...receiptBase, agent: spawned.name });
598
745
  return 0;
599
746
  }
600
- async function ensureStandaloneBabysitMount(mountFn, workspaceId, startDir, options, stderr = process.stderr) {
601
- try {
602
- await mountFn(workspaceId, startDir, options);
603
- }
604
- catch (error) {
605
- const message = error instanceof Error ? error.message : String(error);
606
- stderr.write(`[factory] warning: could not start relayfile mount for standalone babysitter at ${resolve(startDir)}; ` +
607
- `the agent will use the GitHub CLI fallback: ${message}\n`);
608
- }
609
- }
610
747
  function resolveStandaloneBabysitRepo(repo, config) {
611
748
  const configured = repo ?? config.repos.default;
612
749
  if (!configured) {
@@ -636,50 +773,71 @@ function standaloneBabysitClonePath(repo, config) {
636
773
  return undefined;
637
774
  }
638
775
  /**
639
- * Ensures the relayfile mount is running at each configured clone path so
640
- * spawned agents can resolve `.integrations` relative to their working
641
- * directory (the checkout path). The mount daemon started at the daemon CWD
642
- * is not automatically accessible from a different directory, and agents need
643
- * these paths for integration writebacks (Slack, GitHub, etc.).
776
+ * Ensures exactly one Relayfile mirror for the workspace. Agents receive this
777
+ * absolute path in their tasks, so routing more repositories never asks
778
+ * Relayfile to re-home the mirror into each checkout.
644
779
  */
645
- async function ensureClonePathMounts(mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug', reportSummary = true) {
780
+ async function ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, stderr = process.stderr) {
646
781
  const mountOpts = { acceptableWorkspaceIds: acceptableMountIds };
647
- const daemonCwd = resolve(process.cwd());
648
- const clonePaths = [...new Set(Object.values(config.clonePaths ?? {}).map((clonePath) => resolve(clonePath)))]
649
- .filter((clonePath) => clonePath !== daemonCwd);
650
- const refreshedStaleMounts = [];
651
- let nextIndex = 0;
652
- const mountNext = async () => {
653
- while (nextIndex < clonePaths.length) {
654
- const resolved = clonePaths[nextIndex++];
655
- const refreshed = await ensureMountPath(mountFn, workspaceId, resolved, mountOpts, stderr);
656
- if (refreshed)
657
- refreshedStaleMounts.push(refreshed);
658
- }
659
- };
660
- await Promise.all(Array.from({ length: Math.min(CLONE_MOUNT_PREFLIGHT_CONCURRENCY, clonePaths.length) }, mountNext));
661
- if (reportSummary)
662
- writeMountRefreshSummary(refreshedStaleMounts, stderr, debug);
663
- return refreshedStaleMounts;
664
- }
665
- async function ensureMountPath(mountFn, workspaceId, path, mountOpts, stderr) {
666
- const resolved = resolve(path);
667
- const statePath = join(resolved, '.integrations', '.relay', 'state.json');
782
+ const localDir = mount.getLocalMountRoot?.();
783
+ return ensureMountPath(mountFn, workspaceId, localDir ? dirname(localDir) : process.cwd(), mountOpts, stderr, localDir);
784
+ }
785
+ async function ensureMountPath(mountFn, workspaceId, path, mountOpts, stderr, localDir = join(resolve(path), '.integrations')) {
786
+ const statePath = join(localDir, '.relay', 'state.json');
668
787
  const staleBefore = checkMountStaleness(statePath, workspaceId, mountOpts.acceptableWorkspaceIds);
669
788
  try {
670
- await mountFn(workspaceId, resolved, {
789
+ await mountFn(workspaceId, resolve(path), {
671
790
  ...mountOpts,
672
791
  ...(staleBefore.stale ? { suppressStaleRefreshLogs: true } : {}),
673
792
  });
674
793
  if (staleBefore.stale && !checkMountStaleness(statePath, workspaceId, mountOpts.acceptableWorkspaceIds).stale) {
675
- return { path: resolved, reason: staleBefore.reason };
794
+ return { mounted: true, refreshed: { path: localDir, reason: staleBefore.reason } };
676
795
  }
796
+ return { mounted: true };
677
797
  }
678
798
  catch (error) {
799
+ // A scope shortfall is terminal and identical across every clone path;
800
+ // propagate it so startup fails fast with one remediation instead of
801
+ // logging the same unfixable warning per path.
802
+ if (error instanceof MountAuthScopeError)
803
+ throw error;
679
804
  const message = error instanceof Error ? error.message : String(error);
680
- stderr.write(`[factory] warning: could not start relayfile mount at ${resolved}: ${message}\n`);
805
+ stderr.write(`[factory] warning: could not start Relayfile workspace mirror at ${localDir}: ${message}\n`);
681
806
  }
682
- return undefined;
807
+ return { mounted: false };
808
+ }
809
+ function resolveIntegrationsMountRoot(mount) {
810
+ return mount.getLocalMountRoot?.() ?? resolve(process.cwd(), '.integrations');
811
+ }
812
+ async function factoryStatusWithMountHealth(factory, mount, heartbeatPath, heartbeatStaleMs) {
813
+ const status = factory.status();
814
+ const heartbeat = await readFactoryLoopHeartbeat(heartbeatPath);
815
+ const liveness = checkFactoryLoopLiveness(heartbeat, { staleMs: heartbeatStaleMs });
816
+ const eventListener = liveness.ok
817
+ ? heartbeat?.eventListener ?? {
818
+ state: 'unknown',
819
+ reason: 'running daemon heartbeat does not report event listener state',
820
+ }
821
+ : {
822
+ state: 'not-listening',
823
+ reason: liveness.reason,
824
+ };
825
+ const health = mount.getLocalMountHealth?.();
826
+ if (!health)
827
+ return { ...status, eventListener };
828
+ return {
829
+ ...status,
830
+ eventListener,
831
+ localMountDegraded: health.degraded,
832
+ ...(health.reason ? { localMountDegradedReason: health.reason } : {}),
833
+ ...(health.localDir ? { localMountRoot: health.localDir } : {}),
834
+ localMountEventFeed: {
835
+ state: health.degraded ? 'degraded' : 'healthy',
836
+ livenessSignal: '.integrations/.relay/state.json',
837
+ ...(health.reason ? { reason: health.reason } : {}),
838
+ ...(health.localDir ? { root: health.localDir } : {}),
839
+ },
840
+ };
683
841
  }
684
842
  function writeMountRefreshSummary(refreshedStaleMounts, stderr, debug) {
685
843
  if (debug) {
@@ -1018,9 +1176,28 @@ async function prepareFactoryIntegrations(command, mount, config, globals, deps,
1018
1176
  if (providers.length === 0)
1019
1177
  return;
1020
1178
  const dryRun = globals.dryRun || command.kind === 'factory-canary';
1179
+ const providersToEnsure = [];
1180
+ for (const provider of providers) {
1181
+ const observation = observed.get(provider) ?? await inspectFactoryIntegration(connections, provider);
1182
+ observed.set(provider, observation);
1183
+ if (provider === 'github' &&
1184
+ observation.kind === 'connected-not-ready' &&
1185
+ mount.githubRead &&
1186
+ (command.kind === 'factory-triage' || command.kind === 'factory-dispatch')) {
1187
+ const details = [observation.state, observation.initialSyncState]
1188
+ .filter((value) => Boolean(value))
1189
+ .join(', ');
1190
+ err.write(`[factory] warning: GitHub projection is not ready${details ? ` (${details})` : ''}; ` +
1191
+ 'targeted resolution remains projection-first and will use the GitHub API fallback only for a miss.\n');
1192
+ continue;
1193
+ }
1194
+ providersToEnsure.push(provider);
1195
+ }
1196
+ if (providersToEnsure.length === 0)
1197
+ return;
1021
1198
  await ensureFactoryIntegrations({
1022
1199
  connections,
1023
- providers,
1200
+ providers: providersToEnsure,
1024
1201
  workspaceId,
1025
1202
  interactive: !dryRun && (deps.isInteractive?.() ?? Boolean(process.stdin.isTTY && process.stderr.isTTY)),
1026
1203
  dryRun,
@@ -1118,6 +1295,7 @@ async function buildMount(loaded, deps, observability = {}) {
1118
1295
  let mount;
1119
1296
  mount = await (deps.cloudMountFromConfig ?? RelayfileCloudMountClient.fromConfig)({
1120
1297
  workspaceId: loaded.config.workspaceId,
1298
+ localMountRoot: loaded.config.localMountRoot,
1121
1299
  logger: observability.logger,
1122
1300
  onLocalMountHealth: observability.onLocalMountHealth,
1123
1301
  isAllowedDraft: (path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, loaded.config),
@@ -1170,19 +1348,52 @@ const scopeIssueFromDraftContent = (content) => ({
1170
1348
  raw: asRecord(content),
1171
1349
  });
1172
1350
  async function readIssueArg(mount, issueArg, config) {
1173
- const path = issueArg.startsWith('/') ? issueArg : await findIssuePath(mount, issueArg, config);
1174
- if (githubIssuePathParts(path)) {
1175
- return parseGithubFactoryIssue(path, (await mount.readFile(path)).content);
1351
+ return (await resolveIssueArg(mount, issueArg, config)).issue;
1352
+ }
1353
+ async function resolveIssueArg(mount, issueArg, config, projectionStatus) {
1354
+ const explicitPath = issueArg.startsWith('/');
1355
+ const path = explicitPath ? issueArg : await findIssuePath(mount, issueArg, config);
1356
+ if (path) {
1357
+ const issue = githubIssuePathParts(path)
1358
+ ? parseGithubFactoryIssue(path, (await mount.readFile(path)).content)
1359
+ : await readLinearIssueWithCanonicalFallback(mount, path);
1360
+ return {
1361
+ issue,
1362
+ resolution: {
1363
+ source: 'relayfile-projection',
1364
+ detail: 'Resolved from the preferred Relayfile projection.',
1365
+ projection: { outcome: 'matched' },
1366
+ },
1367
+ };
1176
1368
  }
1177
- return readLinearIssueWithCanonicalFallback(mount, path);
1369
+ const selector = parseGithubIssueSelector(issueArg, config);
1370
+ const projection = projectionStatus
1371
+ ? await projectionStatus()
1372
+ : projectionStatusFromMount(mount);
1373
+ const unavailableReason = githubProjectionUnavailableReason(projection);
1374
+ if (!unavailableReason) {
1375
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: found 0 matches in the healthy Relayfile projection; ` +
1376
+ 'the GitHub API fallback was not used');
1377
+ }
1378
+ const fallback = await findGithubIssueThroughConnection(mount, selector, config, issueArg);
1379
+ if (!fallback) {
1380
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: found 0 matches in the projection and GitHub API`);
1381
+ }
1382
+ return {
1383
+ issue: parseGithubFactoryIssue(fallback.path, fallback.content),
1384
+ resolution: {
1385
+ source: 'github-api-fallback',
1386
+ repo: fallback.repo,
1387
+ detail: `Relayfile projection could not answer (${unavailableReason}); resolved authoritatively through the GitHub API fallback.`,
1388
+ projection,
1389
+ },
1390
+ };
1178
1391
  }
1179
1392
  async function findIssuePath(mount, key, config) {
1180
1393
  if (config.issueSource === 'github') {
1181
- const number = Number(key.replace(/^#/, ''));
1182
- if (!Number.isInteger(number) || number <= 0) {
1183
- throw new Error(`${githubIssueResolutionError(config, key)}: expected a positive issue number`);
1184
- }
1185
- const configuredRepos = configuredGithubIssueRepos(config);
1394
+ const selector = parseGithubIssueSelector(key, config);
1395
+ const number = selector.number;
1396
+ const configuredRepos = selector.repo ? [selector.repo] : configuredGithubIssueRepos(config);
1186
1397
  if (configuredRepos.length === 0 && hasConfiguredGithubIssueRoutes(config)) {
1187
1398
  throw new Error(`${githubIssueResolutionError(config, key)}: configured repository routes do not resolve to owner/repo; ` +
1188
1399
  'set repos.default to owner/repo or map its label in repos.byLabel');
@@ -1206,7 +1417,7 @@ async function findIssuePath(mount, key, config) {
1206
1417
  })
1207
1418
  .sort((left, right) => githubIssuePathPreference(left) - githubIssuePathPreference(right) || left.localeCompare(right));
1208
1419
  if (matches.length === 0) {
1209
- throw new Error(`${githubIssueResolutionError(config, key)}: found 0 matches`);
1420
+ return undefined;
1210
1421
  }
1211
1422
  const matchesByRepo = new Map();
1212
1423
  for (const path of matches) {
@@ -1232,14 +1443,106 @@ async function findIssuePath(mount, key, config) {
1232
1443
  }
1233
1444
  return matches[0];
1234
1445
  }
1235
- function configuredGithubIssueRepos(config) {
1236
- const candidates = config.repos.default
1237
- ? [config.repos.default]
1238
- : [
1239
- ...Object.values(config.repos.byLabel),
1240
- ...Object.values(config.repos.byProject),
1241
- ...config.repos.keywordRules.map((rule) => rule.repo),
1242
- ];
1446
+ async function findGithubIssueThroughConnection(mount, selector, config, issueArg) {
1447
+ const github = mount.githubRead;
1448
+ if (!github) {
1449
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: projection cannot answer and the GitHub API fallback is unavailable`);
1450
+ }
1451
+ const repos = selector.repo ? [selector.repo] : configuredGithubIssueRepos(config);
1452
+ if (repos.length === 0) {
1453
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: projection cannot answer and no configured owner/repo is available for the GitHub API fallback`);
1454
+ }
1455
+ const lookups = await Promise.all(repos.map((repo) => github.getIssue(repo, selector.number)));
1456
+ const found = lookups.filter((lookup) => lookup.outcome === 'found');
1457
+ const indeterminate = lookups.some((lookup) => lookup.outcome === 'indeterminate');
1458
+ if (found.length === 0) {
1459
+ if (indeterminate) {
1460
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: the projection could not answer and the GitHub API fallback ` +
1461
+ 'could not determine whether the issue exists (one or more configured repositories are not visible without authentication)');
1462
+ }
1463
+ return undefined;
1464
+ }
1465
+ if (!selector.repo && !config.repos.default) {
1466
+ if (found.length > 1) {
1467
+ const matchedRepos = found.map((match) => match.issue.repo).sort((left, right) => left.localeCompare(right));
1468
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: GitHub API matches multiple repositories (${matchedRepos.join(', ')}); ` +
1469
+ 'set repos.default or pass a repo-qualified argument');
1470
+ }
1471
+ if (indeterminate) {
1472
+ // A single confirmed match is not the same as a unique one: at least
1473
+ // one other configured repository could not be checked, so a same-
1474
+ // numbered issue could exist there too. Refuse rather than silently
1475
+ // dispatch to whichever repo happened to answer.
1476
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: GitHub API found a match in ${found[0].issue.repo} but could not ` +
1477
+ 'confirm it is unique because one or more other configured repositories could not be checked without authentication; ' +
1478
+ 'set repos.default or pass a repo-qualified argument');
1479
+ }
1480
+ }
1481
+ return found[0].issue;
1482
+ }
1483
+ async function issueProjectionStatus(factory, mount, config) {
1484
+ const status = await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.heartbeatStaleMs);
1485
+ const githubConnection = mount.integrationConnections
1486
+ ? await mount.integrationConnections.getStatus('github')
1487
+ : undefined;
1488
+ return {
1489
+ outcome: 'no-match',
1490
+ ...(status.localMountDegraded !== undefined ? { localMountDegraded: status.localMountDegraded } : {}),
1491
+ ...(status.localMountDegradedReason ? { localMountDegradedReason: status.localMountDegradedReason } : {}),
1492
+ ...(status.eventListener ? { eventListener: status.eventListener } : {}),
1493
+ ...(githubConnection ? { githubConnection } : {}),
1494
+ };
1495
+ }
1496
+ function projectionStatusFromMount(mount) {
1497
+ const health = mount.getLocalMountHealth?.();
1498
+ return {
1499
+ outcome: 'no-match',
1500
+ ...(health ? { localMountDegraded: health.degraded } : {}),
1501
+ ...(health?.reason ? { localMountDegradedReason: health.reason } : {}),
1502
+ };
1503
+ }
1504
+ function githubProjectionUnavailableReason(projection) {
1505
+ if (projection.githubConnection && !projection.githubConnection.ready) {
1506
+ const detail = [projection.githubConnection.state, projection.githubConnection.initialSyncState]
1507
+ .filter((value) => Boolean(value))
1508
+ .join(', ');
1509
+ return `GitHub projection connection is not ready${detail ? ` (${detail})` : ''}`;
1510
+ }
1511
+ if (projection.localMountDegraded) {
1512
+ return projection.localMountDegradedReason ?? 'local mount is degraded';
1513
+ }
1514
+ if (projection.eventListener && !['subscribed', 'polling'].includes(projection.eventListener.state)) {
1515
+ return projection.eventListener.reason ?? `event listener is ${projection.eventListener.state}`;
1516
+ }
1517
+ return undefined;
1518
+ }
1519
+ export function parseGithubIssueSelector(key, config) {
1520
+ const qualified = key.match(/^([^#]+)#([1-9]\d*)$/u);
1521
+ const bare = key.match(/^#?([1-9]\d*)$/u);
1522
+ const number = Number(qualified?.[2] ?? bare?.[1]);
1523
+ if (!Number.isSafeInteger(number) || number <= 0) {
1524
+ throw new Error(`${githubIssueResolutionError(config, key)}: expected a positive issue number or repo-qualified reference (repo#number)`);
1525
+ }
1526
+ if (!qualified)
1527
+ return { number };
1528
+ const requested = qualified[1];
1529
+ const configured = allConfiguredGithubIssueRepos(config);
1530
+ // Resolve what the caller typed through the exact same canonicalization
1531
+ // every configured route already goes through (label mapping, org
1532
+ // prefixing, canonical-route lookup) instead of a second, ad-hoc
1533
+ // expansion — three review rounds each found a different normalization
1534
+ // gap (default-only, org-only, bare-label-vs-normalized) in a hand-rolled
1535
+ // comparison here. Comparison is only ever normalized against normalized.
1536
+ const [resolved] = resolveGithubIssueRepoCandidates(config, [requested]);
1537
+ const repo = resolved
1538
+ ? configured.find((candidate) => candidate.toLowerCase() === resolved.toLowerCase())
1539
+ : undefined;
1540
+ if (!repo) {
1541
+ throw new Error(`${githubIssueResolutionError(config, key)}: repository ${requested} is not one of the configured Factory routes`);
1542
+ }
1543
+ return { number, repo };
1544
+ }
1545
+ function resolveGithubIssueRepoCandidates(config, candidates) {
1243
1546
  const repos = new Map();
1244
1547
  const routedRepos = [
1245
1548
  ...Object.values(config.repos.byLabel),
@@ -1260,6 +1563,30 @@ function configuredGithubIssueRepos(config) {
1260
1563
  }
1261
1564
  return [...repos.values()];
1262
1565
  }
1566
+ /** Bare-number resolution stays default-only: when repos.default is set, a
1567
+ * bare issue number resolves against that single repo, not every route. */
1568
+ function configuredGithubIssueRepos(config) {
1569
+ const candidates = config.repos.default
1570
+ ? [config.repos.default]
1571
+ : [
1572
+ ...Object.values(config.repos.byLabel),
1573
+ ...Object.values(config.repos.byProject),
1574
+ ...config.repos.keywordRules.map((rule) => rule.repo),
1575
+ ];
1576
+ return resolveGithubIssueRepoCandidates(config, candidates);
1577
+ }
1578
+ /** Qualified `repo#number` selectors must validate against every configured
1579
+ * route, not just repos.default — a route reachable only through byLabel,
1580
+ * byProject, or keywordRules is still a valid dispatch target. */
1581
+ function allConfiguredGithubIssueRepos(config) {
1582
+ const candidates = [
1583
+ ...(config.repos.default ? [config.repos.default] : []),
1584
+ ...Object.values(config.repos.byLabel),
1585
+ ...Object.values(config.repos.byProject),
1586
+ ...config.repos.keywordRules.map((rule) => rule.repo),
1587
+ ];
1588
+ return resolveGithubIssueRepoCandidates(config, candidates);
1589
+ }
1263
1590
  function hasConfiguredGithubIssueRoutes(config) {
1264
1591
  return Boolean(config.repos.default ||
1265
1592
  Object.keys(config.repos.byLabel).length > 0 ||
@@ -1449,6 +1776,7 @@ Commands:
1449
1776
  babysit <PR|URL> Shepherd an existing open PR to green
1450
1777
  close-probe <PR> Probe/close a PR for an issue
1451
1778
  featuremap check Validate .agentworkforce/features/manifest.yaml
1779
+ intake notion <file> Normalize mounted Notion specs into Factory work
1452
1780
  fleet <command> Low-level fleet commands: spawn, roster, release
1453
1781
 
1454
1782
  Options: