@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
@@ -27,6 +27,7 @@ import { CoalescedTaskQueue } from './coalesced-task-queue.js';
27
27
  import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
28
28
  import { readFactoryInFlightRegistry, terminatePids } from './reaper.js';
29
29
  import { createFactoryCloudEventV1, factoryCloudReleaseReasonV1, } from '../observability/events.js';
30
+ import { boundedRunCostTotal, CostLedger } from '../cost/ledger.js';
30
31
  class ClarificationWakeLeaseLostError extends Error {
31
32
  }
32
33
  class ClarificationQuestionDeliveryLeaseLostError extends Error {
@@ -131,6 +132,44 @@ const STALE_LOCAL_AGENT_RECLAIM_MAX_ATTEMPTS = 3;
131
132
  const STALE_LOCAL_AGENT_RECLAIM_BACKOFF_MS = 500;
132
133
  export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
133
134
  export const DEFAULT_FACTORY_LOOP_REGISTRY_PATH = '/tmp/factory-run/factory-loop-registry.json';
135
+ // #githubApiFallbackIssues is append-only in memory for the life of the
136
+ // process; bound it so a long-running `factory start --mode live` daemon
137
+ // doesn't accumulate one entry per fallback dispatch forever.
138
+ const GITHUB_API_FALLBACK_ISSUES_MAX = 2_000;
139
+ // A small, separately-bounded record of recently evicted identities, so a
140
+ // later miss can be reported as "eligibility was evicted" rather than
141
+ // silently folded into the same signal as "never was eligible" — an
142
+ // operator investigating a false phantom-skip needs to tell those apart.
143
+ const GITHUB_API_FALLBACK_ISSUES_EVICTED_MAX = 200;
144
+ /**
145
+ * Adds `identity` to `eligible`, evicting the least-recently-registered
146
+ * entry once at capacity (Sets preserve insertion order; delete+add
147
+ * refreshes an existing identity's recency instead of leaving it at its
148
+ * original position). An evicted identity moves into `evicted` — itself
149
+ * bounded — so a later caller can tell "eligibility was evicted" apart from
150
+ * "never was eligible" instead of collapsing both into the same signal.
151
+ * Exported standalone (pure, no class state) so the bounding and eviction
152
+ * behavior is directly testable without driving thousands of dispatches
153
+ * through a full FactoryLoop to fill the production-sized set.
154
+ */
155
+ export function rememberBoundedFallbackEligibility(eligible, evicted, identity, maxEligible, maxEvicted) {
156
+ evicted.delete(identity);
157
+ eligible.delete(identity);
158
+ eligible.add(identity);
159
+ if (eligible.size <= maxEligible)
160
+ return;
161
+ const oldest = eligible.values().next().value;
162
+ if (oldest === undefined)
163
+ return;
164
+ eligible.delete(oldest);
165
+ evicted.delete(oldest);
166
+ evicted.add(oldest);
167
+ if (evicted.size <= maxEvicted)
168
+ return;
169
+ const oldestEvicted = evicted.values().next().value;
170
+ if (oldestEvicted !== undefined)
171
+ evicted.delete(oldestEvicted);
172
+ }
134
173
  class DispatchLifecycleCapacityError extends Error {
135
174
  }
136
175
  class DispatchLifecycleOwnedElsewhereError extends Error {
@@ -180,6 +219,7 @@ export class FactoryLoop {
180
219
  #relayflows;
181
220
  #worktrees;
182
221
  #reporter;
222
+ #costLedger;
183
223
  #batchView;
184
224
  #batchReady;
185
225
  #listeners = new Map();
@@ -197,6 +237,8 @@ export class FactoryLoop {
197
237
  #githubIssueAuthors = new Map();
198
238
  #githubIssueAuthorLookups = new Map();
199
239
  #githubIssuePreferredPaths = new Map();
240
+ #githubApiFallbackIssues = new Set();
241
+ #githubApiFallbackIssuesEvicted = new Set();
200
242
  #githubIssuePathIndexReady = false;
201
243
  #slackReporterUserIds = new Map();
202
244
  #slackReporterUserIdLookups = new Map();
@@ -249,6 +291,10 @@ export class FactoryLoop {
249
291
  #subscription;
250
292
  #livePollTimer;
251
293
  #livePollInFlight = false;
294
+ // The effective transport includes per-start overrides, which can differ
295
+ // from config.liveSubscription. Persist it so status/heartbeat describes
296
+ // the listener that was actually registered.
297
+ #liveTransport;
252
298
  #liveEventCursor;
253
299
  #liveEventHighWatermark;
254
300
  #liveConnectStartedAtMs = 0;
@@ -275,6 +321,9 @@ export class FactoryLoop {
275
321
  #reconciledAgentExitsActive = 0;
276
322
  #reconciledAgentExitWaiters = [];
277
323
  #agentLifecycleSignalsInFlight = new Map();
324
+ #agentUsageInFlight = new Set();
325
+ #dispatchLifecyclePersistenceSerial = new Map();
326
+ #agentUsageGroups = new Set();
278
327
  #startupAgentAdoptionActive = false;
279
328
  #startupRosterExitSignals;
280
329
  // Composite issue + PR identities for which a babysitter has already been spawned, so repeated
@@ -313,6 +362,8 @@ export class FactoryLoop {
313
362
  #offDeliveryFailed;
314
363
  #offAgentMessage;
315
364
  #offAgentLifecycleSignal;
365
+ #offAgentUsage;
366
+ #offUnpricedModel;
316
367
  // undefined = readiness not yet probed; resolved lazily so the standalone
317
368
  // runOnce() path (which skips #start) still ingests when the mount is present.
318
369
  #githubIngestionEnabled;
@@ -375,6 +426,10 @@ export class FactoryLoop {
375
426
  this.#relayflows = ports.relayflows;
376
427
  this.#worktrees = ports.worktrees;
377
428
  this.#reporter = ports.reporter;
429
+ this.#costLedger = ports.costLedger ?? new CostLedger();
430
+ this.#offUnpricedModel = this.#costLedger.onUnpricedModel((record) => {
431
+ void this.#reportUnpricedModel(record);
432
+ });
378
433
  this.#state = ports.stateStore ?? new InMemoryStateStore({
379
434
  batchSize: config.batchSize,
380
435
  agentQuestionDedupeLimit: AGENT_QUESTION_DEDUPE_LIMIT,
@@ -611,6 +666,11 @@ export class FactoryLoop {
611
666
  this.#schedulePreviewSweep();
612
667
  try {
613
668
  await this.#startLiveSubscription(issueSource, opts.liveSubscription);
669
+ // The initial live heartbeat is intentionally written before startup
670
+ // so crash reapers can see a daemon while it bootstraps. Write again
671
+ // once the listener is actually registered so external `factory
672
+ // status` can distinguish a quiet feed from no listener.
673
+ await this.#writeLiveHeartbeat('running');
614
674
  await this.#rearmSlackReplyWatchers();
615
675
  await this.#drainReadyClarificationWake();
616
676
  await this.#rearmGithubIssueCommentWatchers();
@@ -736,12 +796,17 @@ export class FactoryLoop {
736
796
  this.#offDeliveryFailed?.();
737
797
  this.#offAgentMessage?.();
738
798
  this.#offAgentLifecycleSignal?.();
799
+ this.#offAgentUsage?.();
800
+ this.#offUnpricedModel?.();
739
801
  await Promise.allSettled([...this.#agentLifecycleSignalsInFlight.values()]);
802
+ await this.#drainAgentUsage();
740
803
  await this.#slackConversationTurns.stop();
741
804
  this.#offAgentExit = undefined;
742
805
  this.#offDeliveryFailed = undefined;
743
806
  this.#offAgentMessage = undefined;
744
807
  this.#offAgentLifecycleSignal = undefined;
808
+ this.#offAgentUsage = undefined;
809
+ this.#offUnpricedModel = undefined;
745
810
  await this.#fleet.dispose();
746
811
  }
747
812
  finally {
@@ -809,6 +874,7 @@ export class FactoryLoop {
809
874
  }
810
875
  async #startLiveSubscription(issueSource, overrides = {}) {
811
876
  const options = this.#liveOptions(overrides);
877
+ this.#liveTransport = options.transport;
812
878
  this.#liveConnectStartedAtMs = this.#clock.now();
813
879
  this.#liveReplaySkewMarginMs = options.replaySkewMarginMs;
814
880
  const highWatermark = await this.#currentEventHighWatermark();
@@ -2051,7 +2117,10 @@ export class FactoryLoop {
2051
2117
  const dispatched = this.#dispatchUnlocked(decision, opts);
2052
2118
  this.#dispatchInFlight.set(key, dispatched);
2053
2119
  try {
2054
- return await dispatched;
2120
+ const result = await dispatched;
2121
+ if (decision.issueResolution)
2122
+ result.issueResolution = structuredClone(decision.issueResolution);
2123
+ return result;
2055
2124
  }
2056
2125
  finally {
2057
2126
  if (this.#dispatchInFlight.get(key) === dispatched) {
@@ -2085,7 +2154,11 @@ export class FactoryLoop {
2085
2154
  this.#error(error, decision.issue);
2086
2155
  throw error;
2087
2156
  }
2088
- const liveIssue = await this.#readIssue(decision.issue.path);
2157
+ // This read runs before `decision` has any tracked record for
2158
+ // #deriveGithubApiFallbackEligibility to find (batch insertion happens
2159
+ // later, once scope/readiness are validated), so pass it directly
2160
+ // rather than registering it into the cache first.
2161
+ const liveIssue = await this.#readIssue(decision.issue.path, decision);
2089
2162
  if (!liveIssue || !isInFactoryScope(liveIssue, this.#config.safety)) {
2090
2163
  const error = new Error(`Refusing to dispatch ${decision.issue.key}: not factory-e2e scope`);
2091
2164
  this.#error(error, decision.issue);
@@ -2440,8 +2513,27 @@ export class FactoryLoop {
2440
2513
  counters: { ...this.#counters },
2441
2514
  slackDegraded: this.#slackDegraded,
2442
2515
  slackDegradedReason: this.#slackDegradedReason,
2516
+ eventListener: this.#eventListenerStatus(),
2443
2517
  };
2444
2518
  }
2519
+ #eventListenerStatus() {
2520
+ if (this.#startMode !== 'live') {
2521
+ return {
2522
+ state: 'not-listening',
2523
+ reason: this.#startMode ? `factory mode is ${this.#startMode}` : 'factory has not started',
2524
+ };
2525
+ }
2526
+ if (!this.#liveHeartbeatActive) {
2527
+ return { state: 'not-listening', reason: 'live daemon heartbeat is inactive' };
2528
+ }
2529
+ if (this.#subscription) {
2530
+ return { state: 'subscribed' };
2531
+ }
2532
+ if (this.#liveTransport === 'poll') {
2533
+ return { state: 'polling' };
2534
+ }
2535
+ return { state: 'starting' };
2536
+ }
2445
2537
  on(event, listener) {
2446
2538
  let listeners = this.#listeners.get(event);
2447
2539
  if (!listeners) {
@@ -2489,6 +2581,9 @@ export class FactoryLoop {
2489
2581
  return handling;
2490
2582
  });
2491
2583
  }
2584
+ if (!this.#offAgentUsage) {
2585
+ this.#offAgentUsage = this.#fleet.onAgentUsage?.((usage) => this.#queueAgentUsage(usage));
2586
+ }
2492
2587
  }
2493
2588
  #queueAgentExit(name, reason) {
2494
2589
  const startupSignals = this.#startupRosterExitSignals;
@@ -2554,6 +2649,7 @@ export class FactoryLoop {
2554
2649
  continue;
2555
2650
  }
2556
2651
  this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
2652
+ this.#hydrateCostLedger(claim.lifecycle);
2557
2653
  if (claim.lifecycle.phase === 'waiting-for-human')
2558
2654
  continue;
2559
2655
  const durableRecord = inFlightRecordFromLifecycle(claim.lifecycle);
@@ -2844,6 +2940,7 @@ export class FactoryLoop {
2844
2940
  throw new Error(`Refusing to dispatch ${decision.issue.key}: ${reason}`);
2845
2941
  }
2846
2942
  this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
2943
+ this.#hydrateCostLedger(claim.lifecycle);
2847
2944
  this.#scheduleDispatchLifecycleRenewal();
2848
2945
  if (claim.created) {
2849
2946
  await this.#reportLifecycle(claim.lifecycle, 'run.started');
@@ -2924,48 +3021,296 @@ export class FactoryLoop {
2924
3021
  },
2925
3022
  });
2926
3023
  }
2927
- async #saveDispatchLifecycle(record, phase, pullRequest, releaseReason, releasedAgentNames = new Set(), telemetry = {}) {
2928
- if (record.dryRun || !this.#usesDurableDispatchLifecycle())
2929
- return true;
2930
- const key = issueKey(record.issue);
3024
+ async #handleAgentUsage(usage, key) {
3025
+ const record = (await this.#batch()).getIssueByAgent(usage.name);
3026
+ const activeRecord = record && issueKey(record.issue) === key ? record : undefined;
3027
+ let tracked = activeRecord?.agents.get(usage.name);
3028
+ const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
3029
+ // Completion removes the in-memory batch record immediately after the
3030
+ // terminal lifecycle write. A broker usage snapshot can arrive in that
3031
+ // narrow window; consult durable state so the drop is observable instead
3032
+ // of silently looking like an unknown agent.
3033
+ if (!lifecycle)
3034
+ return;
3035
+ tracked ??= lifecycle.agents.find((agent) => agent.name === usage.name)?.tracked;
3036
+ if (!tracked)
3037
+ return;
3038
+ if (isTerminalDispatchLifecycle(lifecycle)) {
3039
+ await this.#noteDroppedTerminalCostUsage(lifecycle, tracked, usage);
3040
+ return;
3041
+ }
3042
+ const lifecycleRecord = activeRecord ?? inFlightRecordFromLifecycle(lifecycle);
2931
3043
  const epoch = this.#dispatchLifecycleEpochs.get(key);
2932
3044
  if (epoch === undefined) {
2933
- this.#scheduleDispatchLifecycleRetry(record);
2934
- return false;
3045
+ this.#increment('costUsagePersistenceUnavailable');
3046
+ this.#logger.warn?.('[factory] unable to persist agent token usage without a durable lifecycle lease', {
3047
+ agentName: usage.name,
3048
+ issue: lifecycle.issue.key,
3049
+ });
3050
+ await this.#reportLifecycle(lifecycle, 'factory.anomaly', {
3051
+ level: 'error',
3052
+ errorCode: 'cost_usage_lease_unavailable',
3053
+ });
3054
+ return;
2935
3055
  }
2936
- const previous = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
2937
- const pullRequests = mergePublishedPullRequests(previous, pullRequest);
2938
- const primaryPullRequest = primaryPublishedPullRequest(previous, pullRequest, pullRequests);
2939
- const lifecycle = lifecycleFromInFlightRecord(record, previous?.runId ?? randomUUID(), phase, this.#clock.now(), primaryPullRequest, pullRequests, releaseReason ?? previous?.releaseReason);
2940
- for (const agent of lifecycle.agents) {
2941
- const previouslyReleasedAtMs = previous?.agents.find((candidate) => candidate.name === agent.name)?.releasedAtMs;
2942
- if (previouslyReleasedAtMs !== undefined)
2943
- agent.releasedAtMs = previouslyReleasedAtMs;
2944
- if (releasedAgentNames.has(agent.name))
2945
- agent.releasedAtMs ??= this.#clock.now();
3056
+ const lifecycleAgent = lifecycle.agents.find((agent) => agent.name === usage.name);
3057
+ if (!lifecycleAgent) {
3058
+ this.#increment('costUsageLifecycleAgentMissing');
3059
+ this.#logger.warn?.('[factory] unable to persist agent token usage for an untracked durable agent', {
3060
+ agentName: usage.name,
3061
+ issue: lifecycle.issue.key,
3062
+ });
3063
+ return;
2946
3064
  }
2947
- const saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), lifecycle);
2948
- if (!saved) {
2949
- this.#dispatchLifecycleEpochs.delete(key);
2950
- this.#increment('dispatchLifecycleFencesRejected');
3065
+ const model = usage.model ?? tracked.spec.model ?? 'unknown';
3066
+ const groupId = costUsageGroupId(lifecycle.runId, tracked);
3067
+ this.#costLedger.record({
3068
+ runId: lifecycle.runId,
3069
+ role: tracked.spec.role,
3070
+ model,
3071
+ inputTokens: usage.inputTokens,
3072
+ outputTokens: usage.outputTokens,
3073
+ }, { entryId: costEntryId(groupId, model) });
3074
+ const nextUsage = mergeDispatchLifecycleAgentUsage(lifecycleAgent.costUsage, {
3075
+ model,
3076
+ inputTokens: usage.inputTokens,
3077
+ outputTokens: usage.outputTokens,
3078
+ });
3079
+ const nextLifecycle = {
3080
+ ...lifecycle,
3081
+ agents: lifecycle.agents.map((agent) => agent.name === usage.name
3082
+ ? { ...agent, costUsage: nextUsage }
3083
+ : agent),
3084
+ cost: boundedRunCostTotal(this.#costLedger.getRunTotal(lifecycle.runId)),
3085
+ updatedAtMs: this.#clock.now(),
3086
+ };
3087
+ let saved;
3088
+ try {
3089
+ saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), nextLifecycle);
3090
+ }
3091
+ catch (error) {
2951
3092
  await this.#reportLifecycle(lifecycle, 'factory.anomaly', {
2952
3093
  level: 'error',
2953
- errorCode: 'fence_rejected',
3094
+ errorCode: 'cost_usage_persistence_failed',
2954
3095
  });
2955
- this.#scheduleDispatchLifecycleRetry(record);
2956
- return false;
2957
- }
2958
- if (previous?.phase !== lifecycle.phase) {
2959
- await this.#reportLifecycle(lifecycle, lifecycle.phase === 'complete'
2960
- ? 'run.succeeded'
2961
- : lifecycle.phase === 'abandoned'
2962
- ? 'run.cancelled'
2963
- : 'run.phase_changed', { previousPhase: previous?.phase, cancellationReason: telemetry.cancellationReason });
3096
+ throw error;
2964
3097
  }
2965
- if (isTerminalDispatchLifecycle(lifecycle)) {
3098
+ if (!saved) {
2966
3099
  this.#dispatchLifecycleEpochs.delete(key);
3100
+ const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
3101
+ if (latest && isTerminalDispatchLifecycle(latest)) {
3102
+ await this.#noteDroppedTerminalCostUsage(latest, tracked, usage);
3103
+ }
3104
+ else {
3105
+ this.#increment('costUsagePersistenceFailures');
3106
+ this.#logger.warn?.('[factory] unable to persist agent token usage', {
3107
+ agentName: usage.name,
3108
+ issue: lifecycle.issue.key,
3109
+ reason: 'fence_rejected',
3110
+ });
3111
+ await this.#reportLifecycle(lifecycle, 'factory.anomaly', {
3112
+ level: 'error',
3113
+ errorCode: 'cost_usage_persistence_failed',
3114
+ });
3115
+ this.#scheduleDispatchLifecycleRetry(lifecycleRecord);
3116
+ }
3117
+ return;
2967
3118
  }
2968
- return true;
3119
+ this.#agentUsageGroups.add(groupId);
3120
+ }
3121
+ async #findDispatchLifecycleAgent(name) {
3122
+ let match;
3123
+ for (const [key, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
3124
+ const agent = lifecycle.agents.find((candidate) => candidate.name === name);
3125
+ if (!agent || (match && match.lifecycle.updatedAtMs >= lifecycle.updatedAtMs))
3126
+ continue;
3127
+ match = { key, lifecycle, tracked: agent.tracked };
3128
+ }
3129
+ return match;
3130
+ }
3131
+ async #dispatchLifecycleKeyForAgent(name) {
3132
+ const record = (await this.#batch()).getIssueByAgent(name);
3133
+ if (record?.agents.has(name))
3134
+ return issueKey(record.issue);
3135
+ return (await this.#findDispatchLifecycleAgent(name))?.key;
3136
+ }
3137
+ #serializeDispatchLifecyclePersistence(key, operation) {
3138
+ const queued = (this.#dispatchLifecyclePersistenceSerial.get(key) ?? Promise.resolve())
3139
+ .catch(() => undefined)
3140
+ .then(operation);
3141
+ const settled = queued.then(() => undefined, () => undefined);
3142
+ this.#dispatchLifecyclePersistenceSerial.set(key, settled);
3143
+ void settled.then(() => {
3144
+ if (this.#dispatchLifecyclePersistenceSerial.get(key) === settled) {
3145
+ this.#dispatchLifecyclePersistenceSerial.delete(key);
3146
+ }
3147
+ });
3148
+ return queued;
3149
+ }
3150
+ #queueAgentUsage(usage) {
3151
+ // Usage reports are cumulative snapshots. Serializing their read/modify/
3152
+ // save cycle with phase changes prevents either mutation from replacing
3153
+ // the other's durable per-agent cost just before an owner restart.
3154
+ const handling = this.#dispatchLifecycleKeyForAgent(usage.name)
3155
+ .then((key) => {
3156
+ return key
3157
+ ? this.#serializeDispatchLifecyclePersistence(key, () => this.#handleAgentUsage(usage, key))
3158
+ : undefined;
3159
+ })
3160
+ .catch(async (error) => {
3161
+ this.#increment('costUsageRecordingFailures');
3162
+ this.#logger.warn?.('[factory] unable to record agent token usage', {
3163
+ agentName: usage.name,
3164
+ errorClass: telemetryErrorClass(error),
3165
+ });
3166
+ })
3167
+ .finally(() => {
3168
+ this.#agentUsageInFlight.delete(handling);
3169
+ });
3170
+ this.#agentUsageInFlight.add(handling);
3171
+ return handling;
3172
+ }
3173
+ async #noteDroppedTerminalCostUsage(lifecycle, tracked, usage) {
3174
+ this.#increment('costUsageDroppedAfterTerminal');
3175
+ await this.#report({
3176
+ type: 'factory.anomaly',
3177
+ level: 'warn',
3178
+ runId: lifecycle.runId,
3179
+ attributes: {
3180
+ errorCode: 'cost_usage_after_terminal',
3181
+ agentRole: tracked.spec.role,
3182
+ model: usage.model ?? tracked.spec.model ?? 'unknown',
3183
+ inputTokens: usage.inputTokens,
3184
+ outputTokens: usage.outputTokens,
3185
+ },
3186
+ });
3187
+ }
3188
+ #hydrateCostLedger(lifecycle) {
3189
+ for (const agent of lifecycle.agents) {
3190
+ const usage = agent.costUsage ?? [];
3191
+ if (usage.length === 0)
3192
+ continue;
3193
+ const groupId = costUsageGroupId(lifecycle.runId, agent.tracked);
3194
+ for (const entry of usage) {
3195
+ this.#costLedger.record({
3196
+ runId: lifecycle.runId,
3197
+ role: agent.tracked.spec.role,
3198
+ model: entry.model,
3199
+ inputTokens: entry.inputTokens,
3200
+ outputTokens: entry.outputTokens,
3201
+ }, {
3202
+ entryId: costEntryId(groupId, entry.model),
3203
+ notifyUnpriced: false,
3204
+ });
3205
+ }
3206
+ this.#agentUsageGroups.add(groupId);
3207
+ }
3208
+ }
3209
+ async #drainAgentUsage() {
3210
+ while (this.#agentUsageInFlight.size > 0) {
3211
+ await Promise.allSettled([...this.#agentUsageInFlight]);
3212
+ }
3213
+ }
3214
+ async #reportUnpricedModel(record) {
3215
+ await this.#report({
3216
+ type: 'cost.model.unpriced',
3217
+ level: 'warn',
3218
+ runId: record.runId,
3219
+ attributes: {
3220
+ agentRole: record.role,
3221
+ model: record.model,
3222
+ inputTokens: record.inputTokens,
3223
+ outputTokens: record.outputTokens,
3224
+ usd: null,
3225
+ },
3226
+ });
3227
+ }
3228
+ #finalizeRunCost(record, runId) {
3229
+ for (const tracked of record.agents.values()) {
3230
+ const groupId = costUsageGroupId(runId, tracked);
3231
+ if (this.#agentUsageGroups.has(groupId))
3232
+ continue;
3233
+ this.#costLedger.record({
3234
+ runId,
3235
+ role: tracked.spec.role,
3236
+ model: tracked.spec.model ?? 'unknown',
3237
+ inputTokens: null,
3238
+ outputTokens: null,
3239
+ }, { entryId: costEntryId(groupId, 'missing') });
3240
+ }
3241
+ return boundedRunCostTotal(this.#costLedger.getRunTotal(runId));
3242
+ }
3243
+ async #reportRunCost(lifecycle) {
3244
+ if (!lifecycle.cost)
3245
+ return;
3246
+ const { runId: _runId, ...cost } = lifecycle.cost;
3247
+ await this.#report({
3248
+ type: 'run.cost.v1',
3249
+ level: 'info',
3250
+ runId: lifecycle.runId,
3251
+ status: telemetryRunStatus(lifecycle.phase),
3252
+ attributes: {
3253
+ inputTokens: lifecycle.cost.inputTokens,
3254
+ outputTokens: lifecycle.cost.outputTokens,
3255
+ usd: lifecycle.cost.usd,
3256
+ },
3257
+ cost,
3258
+ });
3259
+ }
3260
+ async #saveDispatchLifecycle(record, phase, pullRequest, releaseReason, releasedAgentNames = new Set(), telemetry = {}) {
3261
+ if (record.dryRun || !this.#usesDurableDispatchLifecycle())
3262
+ return true;
3263
+ if (isTerminalDispatchPhase(phase))
3264
+ await this.#drainAgentUsage();
3265
+ const key = issueKey(record.issue);
3266
+ return this.#serializeDispatchLifecyclePersistence(key, async () => {
3267
+ const epoch = this.#dispatchLifecycleEpochs.get(key);
3268
+ if (epoch === undefined) {
3269
+ this.#scheduleDispatchLifecycleRetry(record);
3270
+ return false;
3271
+ }
3272
+ const previous = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
3273
+ const pullRequests = mergePublishedPullRequests(previous, pullRequest);
3274
+ const primaryPullRequest = primaryPublishedPullRequest(previous, pullRequest, pullRequests);
3275
+ const lifecycleRunId = previous?.runId ?? randomUUID();
3276
+ const cost = isTerminalDispatchPhase(phase)
3277
+ ? this.#finalizeRunCost(record, lifecycleRunId)
3278
+ : previous?.cost;
3279
+ const lifecycle = lifecycleFromInFlightRecord(record, lifecycleRunId, phase, this.#clock.now(), primaryPullRequest, pullRequests, releaseReason ?? previous?.releaseReason, cost);
3280
+ for (const agent of lifecycle.agents) {
3281
+ const previousAgent = previous?.agents.find((candidate) => candidate.name === agent.name);
3282
+ if (previousAgent?.releasedAtMs !== undefined)
3283
+ agent.releasedAtMs = previousAgent.releasedAtMs;
3284
+ if (previousAgent?.costUsage)
3285
+ agent.costUsage = structuredClone(previousAgent.costUsage);
3286
+ if (releasedAgentNames.has(agent.name))
3287
+ agent.releasedAtMs ??= this.#clock.now();
3288
+ }
3289
+ const saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), lifecycle);
3290
+ if (!saved) {
3291
+ this.#dispatchLifecycleEpochs.delete(key);
3292
+ this.#increment('dispatchLifecycleFencesRejected');
3293
+ await this.#reportLifecycle(lifecycle, 'factory.anomaly', {
3294
+ level: 'error',
3295
+ errorCode: 'fence_rejected',
3296
+ });
3297
+ this.#scheduleDispatchLifecycleRetry(record);
3298
+ return false;
3299
+ }
3300
+ if (previous?.phase !== lifecycle.phase) {
3301
+ await this.#reportLifecycle(lifecycle, lifecycle.phase === 'complete'
3302
+ ? 'run.succeeded'
3303
+ : lifecycle.phase === 'abandoned'
3304
+ ? 'run.cancelled'
3305
+ : 'run.phase_changed', { previousPhase: previous?.phase, cancellationReason: telemetry.cancellationReason });
3306
+ if (isTerminalDispatchLifecycle(lifecycle))
3307
+ await this.#reportRunCost(lifecycle);
3308
+ }
3309
+ if (isTerminalDispatchLifecycle(lifecycle)) {
3310
+ this.#dispatchLifecycleEpochs.delete(key);
3311
+ }
3312
+ return true;
3313
+ });
2969
3314
  }
2970
3315
  #resolveDispatchTerminalWaiters(issue) {
2971
3316
  const key = issueKey(issue);
@@ -3069,6 +3414,7 @@ export class FactoryLoop {
3069
3414
  throw new DispatchLifecycleOwnedElsewhereError(claim.lifecycle.lease?.leaseUntilMs);
3070
3415
  }
3071
3416
  this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
3417
+ this.#hydrateCostLedger(claim.lifecycle);
3072
3418
  this.#scheduleDispatchLifecycleRenewal();
3073
3419
  lifecycle = claim.lifecycle;
3074
3420
  acquiredNow = true;
@@ -3254,7 +3600,11 @@ export class FactoryLoop {
3254
3600
  async #resumeDurableDispatch(record) {
3255
3601
  let liveIssue;
3256
3602
  if (!record.dryRun) {
3257
- liveIssue = await this.#readIssue(record.issue.path);
3603
+ // record is already inserted into the batch by the time any phase
3604
+ // handler runs (BatchTracker.restore, called before this), so
3605
+ // #deriveGithubApiFallbackEligibility would find it there too — the
3606
+ // explicit hint is defense in depth, not a requirement.
3607
+ liveIssue = await this.#readIssue(record.issue.path, record.decision);
3258
3608
  if (!liveIssue) {
3259
3609
  throw new Error(`Unable to recover durable dispatch ${record.issue.key}: issue is not currently readable`);
3260
3610
  }
@@ -3856,7 +4206,62 @@ export class FactoryLoop {
3856
4206
  this.#logger.error?.('[factory] failed to ingest GitHub issue', error);
3857
4207
  }
3858
4208
  }
3859
- async #readGithubIssue(path) {
4209
+ #rememberGithubApiFallbackEligible(identity) {
4210
+ rememberBoundedFallbackEligibility(this.#githubApiFallbackIssues, this.#githubApiFallbackIssuesEvicted, identity, GITHUB_API_FALLBACK_ISSUES_MAX, GITHUB_API_FALLBACK_ISSUES_EVICTED_MAX);
4211
+ }
4212
+ /**
4213
+ * `#githubApiFallbackIssues` is a cache, not a source of truth: eligibility
4214
+ * is derived here, not remembered by every caller. A restart (or any read
4215
+ * path added later that never registered anything) still resolves
4216
+ * correctly because this checks the actual current state instead of
4217
+ * requiring every entry point to have called a registration method first.
4218
+ *
4219
+ * A decision can durably outlive the original dispatch call in three
4220
+ * shapes, and this checks all three — a fourth found later means this
4221
+ * enumeration is incomplete, not that the approach is wrong:
4222
+ * 1. `batch.inFlight` — an active live-dispatched or durably-restored
4223
+ * record (`BatchTracker.restore`/`start` insert it before any phase
4224
+ * handler runs).
4225
+ * 2. `#state.listWaitingClarifications` — an issue parked awaiting a
4226
+ * human reply. Restoring it (`#restoreClarifications` et al.)
4227
+ * rebuilds an `InFlightIssue`-shaped record locally to re-arm the
4228
+ * watcher and does not insert it into the batch, so (1) alone misses
4229
+ * it.
4230
+ * 3. `#state.listDispatchLifecycles`, non-terminal — a durably-tracked
4231
+ * dispatch that is not currently in-memory at all yet (e.g. a
4232
+ * non-durable-fleet dispatch never reaches the batch either).
4233
+ *
4234
+ * `decisionHint` stays a pure optimization ahead of all three: the one
4235
+ * read that happens before its own record exists anywhere (the initial
4236
+ * live dispatch, validating scope before it is tracked) can skip the
4237
+ * scan entirely, but no read site is required to pass it — omitting it
4238
+ * only costs the scan, never correctness.
4239
+ *
4240
+ * Deliberately scans by GitHub identity (owner/repo/number) against each
4241
+ * candidate's own issue path rather than looking up the durable record by
4242
+ * its composite key (uuid/key/path): the real uuid is built from GitHub's
4243
+ * node_id/id when content is available, which a bare path cannot
4244
+ * reconstruct, so a keyed lookup would not reliably match.
4245
+ */
4246
+ async #deriveGithubApiFallbackEligibility(identity, decisionHint) {
4247
+ if (decisionHint?.issueResolution?.source === 'github-api-fallback' &&
4248
+ githubIssueRefIdentity(decisionHint.issue) === identity) {
4249
+ return true;
4250
+ }
4251
+ const inFlight = (await this.#batch()).inFlight;
4252
+ if (isGithubApiFallbackEligible(inFlight, identity))
4253
+ return true;
4254
+ const [waitingClarifications, dispatchLifecycles] = await Promise.all([
4255
+ this.#state.listWaitingClarifications(this.#workspaceId),
4256
+ this.#state.listDispatchLifecycles(this.#workspaceId),
4257
+ ]);
4258
+ const durable = [
4259
+ ...githubApiFallbackCandidatesFromWaitingClarifications(waitingClarifications),
4260
+ ...githubApiFallbackCandidatesFromDispatchLifecycles(dispatchLifecycles),
4261
+ ];
4262
+ return isGithubApiFallbackEligible(durable, identity);
4263
+ }
4264
+ async #readGithubIssue(path, decisionHint) {
3860
4265
  const preferredPath = await this.#preferredGithubIssuePath(path);
3861
4266
  const candidatePaths = [...new Set([
3862
4267
  ...githubIssueReadCandidatePaths(preferredPath),
@@ -3880,8 +4285,55 @@ export class FactoryLoop {
3880
4285
  }
3881
4286
  catch (error) {
3882
4287
  if (isMissingIssueFileError(error)) {
4288
+ const parts = githubIssuePathParts(path) ?? githubIssueDirectoryPathParts(path);
4289
+ const identity = parts ? githubIssueIdentity(parts.owner, parts.repo, parts.number) : undefined;
4290
+ const eligible = identity
4291
+ ? this.#githubApiFallbackIssues.has(identity) ||
4292
+ await this.#deriveGithubApiFallbackEligibility(identity, decisionHint)
4293
+ : false;
4294
+ if (eligible && identity)
4295
+ this.#rememberGithubApiFallbackEligible(identity);
4296
+ if (parts && identity && eligible && this.#mount.githubRead) {
4297
+ const lookup = await this.#mount.githubRead.getIssue(`${parts.owner}/${parts.repo}`, parts.number);
4298
+ if (lookup.outcome === 'found') {
4299
+ const githubIssue = parseGithubIssue(lookup.issue.path, lookup.issue.content);
4300
+ this.#indexDependencyIssue(githubIssueAsFactoryIssue(githubIssue));
4301
+ this.#logger.warn?.('[factory] Relayfile projection missed GitHub issue; using GitHub API fallback', {
4302
+ repo: lookup.issue.repo,
4303
+ number: lookup.issue.number,
4304
+ source: 'github-api-fallback',
4305
+ });
4306
+ return githubIssue;
4307
+ }
4308
+ if (lookup.outcome === 'indeterminate') {
4309
+ // The provider lookup could not confirm absence (e.g. an
4310
+ // unauthenticated 404 against a repo it cannot prove is public).
4311
+ // That is not the same claim as "confirmed gone" — count and log
4312
+ // it separately so it stays visible instead of being folded into
4313
+ // the phantom-skip metric.
4314
+ this.#increment('githubIssueUnverifiable');
4315
+ this.#logger.warn?.('[factory] GitHub API fallback could not determine issue existence', {
4316
+ path,
4317
+ repo: `${parts.owner}/${parts.repo}`,
4318
+ number: parts.number,
4319
+ reason: lookup.reason,
4320
+ });
4321
+ return undefined;
4322
+ }
4323
+ }
4324
+ if (identity && this.#githubApiFallbackIssuesEvicted.has(identity)) {
4325
+ // identity was eligible for the API fallback but aged out of the
4326
+ // bounded set — a different claim from "never was eligible", and
4327
+ // one this signal must not silently collapse into a confirmed miss.
4328
+ this.#increment('githubIssueApiFallbackEligibilityEvicted');
4329
+ this.#logger.warn?.('[factory] GitHub API fallback eligibility was evicted from the bounded cache before this read', {
4330
+ path,
4331
+ identity,
4332
+ });
4333
+ return undefined;
4334
+ }
3883
4335
  this.#increment('githubIssuePhantomSkipped');
3884
- this.#logger.debug?.('[factory] skipped missing GitHub issue file discovered from issue tree', { path });
4336
+ this.#logger.debug?.('[factory] skipped missing GitHub issue after projection and provider lookup', { path });
3885
4337
  return undefined;
3886
4338
  }
3887
4339
  throw error;
@@ -4299,6 +4751,7 @@ export class FactoryLoop {
4299
4751
  updatedAt: new Date(updatedAtMs).toISOString(),
4300
4752
  updatedAtMs,
4301
4753
  registryPath,
4754
+ eventListener: this.#eventListenerStatus(),
4302
4755
  };
4303
4756
  await mkdir(dirname(path), { recursive: true });
4304
4757
  await writeFile(path, `${JSON.stringify(heartbeat, null, 2)}\n`, 'utf8');
@@ -4466,10 +4919,10 @@ export class FactoryLoop {
4466
4919
  }
4467
4920
  return [...pathsByKey.values()].sort();
4468
4921
  }
4469
- async #readIssue(path) {
4922
+ async #readIssue(path, decisionHint) {
4470
4923
  try {
4471
4924
  if (isGithubIssueFilePath(path)) {
4472
- const githubIssue = await this.#readGithubIssue(path);
4925
+ const githubIssue = await this.#readGithubIssue(path, decisionHint);
4473
4926
  return githubIssue ? githubIssueAsFactoryIssue(githubIssue) : undefined;
4474
4927
  }
4475
4928
  // Newly-synced issues land as a change-event STUB at the primary
@@ -5334,6 +5787,7 @@ export class FactoryLoop {
5334
5787
  baseRef,
5335
5788
  title: `${issue.key}: ${issue.title}`,
5336
5789
  body: githubPullRequestBody(issue, implementer.spec.preview),
5790
+ ...(implementer.sessionRef ? { sessionRef: implementer.sessionRef } : {}),
5337
5791
  });
5338
5792
  const published = result.author
5339
5793
  ? result
@@ -5988,6 +6442,20 @@ export class FactoryLoop {
5988
6442
  if (!tracked.sessionRef) {
5989
6443
  return;
5990
6444
  }
6445
+ // Never resume an agent against a read-denied mirror: the mount lacks the
6446
+ // filesystem scope it needs, so the spawn would fail opaquely (roster PID
6447
+ // never resolves) and risk operating on stale integration state. Skip with
6448
+ // one clear message; a later attempt succeeds once the session is re-authed
6449
+ // and Factory restarted.
6450
+ if (this.#mount.isLocalMountAuthDegraded?.()) {
6451
+ this.#increment('resumeSkippedMountAuthDegraded');
6452
+ this.#logger.warn?.('[factory] tracked agent resume skipped: local mount is auth-degraded (cloud session missing relayfile fs scope); re-authenticate and restart Factory', {
6453
+ issue: record.issue.key,
6454
+ name,
6455
+ role: tracked.spec.role,
6456
+ });
6457
+ return;
6458
+ }
5991
6459
  this.#logger.debug?.('[factory] tracked agent resume preparation started', {
5992
6460
  issue: record.issue.key,
5993
6461
  name,
@@ -11133,7 +11601,7 @@ export class FactoryLoop {
11133
11601
  }, Math.max(1_000, Math.floor(CLARIFICATION_WAKE_LEASE_MS / 3)));
11134
11602
  heartbeat.unref?.();
11135
11603
  try {
11136
- if (!await this.#clarificationIssueStillActive(waiting.issue)) {
11604
+ if (!await this.#clarificationIssueStillActive(waiting.issue, waiting.decision)) {
11137
11605
  this.#assertClarificationWakeRunning();
11138
11606
  await renewLease();
11139
11607
  const completed = await this.#state.completeClarificationWake(this.#workspaceId, key, this.#clarificationWakeOwner);
@@ -11309,8 +11777,8 @@ export class FactoryLoop {
11309
11777
  if (this.#stopping)
11310
11778
  throw new ClarificationWakeStoppedError('factory is stopping');
11311
11779
  }
11312
- async #clarificationIssueStillActive(issueRef) {
11313
- const issue = await this.#readIssue(issueRef.path);
11780
+ async #clarificationIssueStillActive(issueRef, decisionHint) {
11781
+ const issue = await this.#readIssue(issueRef.path, decisionHint);
11314
11782
  if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
11315
11783
  this.#logger.info?.('[factory] clarification wake cancelled because issue left factory scope', {
11316
11784
  issue: issueRef.key,
@@ -11476,12 +11944,11 @@ export class FactoryLoop {
11476
11944
  this.#emit('issue-queued', { issue: decision.issue });
11477
11945
  }
11478
11946
  }
11479
- // Absolute path to the local .integrations mount the daemon manages. The mount
11480
- // is created at the daemon's cwd (see ensureLocalMount), and spawned agents run
11481
- // in their repo clonePath, so writeback paths handed to agents must be absolute
11482
- // against this root rather than a bare relative `.integrations/...`.
11947
+ // Absolute path to the one registered workspace mirror. Spawned agents run in
11948
+ // repo clone paths, so writeback instructions must name the shared mirror,
11949
+ // not a relative `.integrations` path or a per-repository re-home attempt.
11483
11950
  #integrationsMountRoot() {
11484
- return resolve(process.cwd(), '.integrations');
11951
+ return this.#mount.getLocalMountRoot?.() ?? resolve(process.cwd(), '.integrations');
11485
11952
  }
11486
11953
  async #slackChannelDir() {
11487
11954
  if (!this.#config.slack) {
@@ -11999,8 +12466,17 @@ const pidsFromSpawnResult = (result) => {
11999
12466
  }
12000
12467
  return [...pids].sort((a, b) => a - b);
12001
12468
  };
12002
- const dispatchComment = (decision, agents) => [
12469
+ // `issueResolution.detail` and `projection.localMountDegradedReason` are
12470
+ // free text that can carry an absolute local filesystem path (e.g. "mount
12471
+ // state is missing at /Users/<name>/...") — safe in the JSON result and in
12472
+ // logs, but this comment is posted to a public GitHub issue. Emit only
12473
+ // `source`, a closed enum, rather than trying to scrub paths out of free
12474
+ // text; free text is not a safe thing to scrub, only a safe thing to omit.
12475
+ export const dispatchComment = (decision, agents) => [
12003
12476
  `Factory dispatch for ${decision.issue.key}`,
12477
+ decision.issueResolution
12478
+ ? `Issue resolution: ${decision.issueResolution.source}`
12479
+ : undefined,
12004
12480
  `Implementers: ${agents.filter((agent) => agent.role === 'implementer').map((agent) => agent.name).join(', ') || 'none'}`,
12005
12481
  decision.scope === 'workflow' ? `Workflow: ${agents.find((agent) => agent.role === 'workflow')?.name ?? 'none'}` : undefined,
12006
12482
  `Reviewer: ${agents.find((agent) => agent.role === 'reviewer')?.name ?? 'none'}`,
@@ -12518,11 +12994,51 @@ export const githubIssuePathParts = (path) => {
12518
12994
  slug: match[7],
12519
12995
  };
12520
12996
  };
12521
- const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
12997
+ export const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
12522
12998
  const githubIssueRefIdentity = (issue) => {
12523
12999
  const parts = githubIssuePathParts(issue.path);
12524
13000
  return parts ? githubIssueIdentity(parts.owner, parts.repo, parts.number) : undefined;
12525
13001
  };
13002
+ /**
13003
+ * Whether a GitHub API fallback read is eligible for `identity`, derived
13004
+ * from currently-tracked state rather than a separately-remembered flag:
13005
+ * `decisionHint` (the one read that happens before its own record is
13006
+ * tracked anywhere) or any `{issue, decision}` candidate — drawn from
13007
+ * whichever durable or in-memory store the caller has gathered — whose
13008
+ * decision was resolved through the fallback. Exported standalone so this
13009
+ * is directly testable — no caller has to have registered anything for
13010
+ * this to return true for a candidate that is genuinely present, which is
13011
+ * the property that keeps a future read call site from silently
13012
+ * reintroducing the restart-eligibility bug by omission.
13013
+ *
13014
+ * A decision durably outlives the in-memory batch in more than one shape
13015
+ * (in-flight dispatch, a parked clarification, a durable dispatch
13016
+ * lifecycle — see #deriveGithubApiFallbackEligibility for the full list
13017
+ * this class gathers); this function does not care which shape a
13018
+ * candidate came from, only whether one matches.
13019
+ */
13020
+ export function isGithubApiFallbackEligible(candidates, identity, decisionHint) {
13021
+ if (decisionHint?.issueResolution?.source === 'github-api-fallback' &&
13022
+ githubIssueRefIdentity(decisionHint.issue) === identity) {
13023
+ return true;
13024
+ }
13025
+ return candidates.some((candidate) => candidate.decision.issueResolution?.source === 'github-api-fallback' &&
13026
+ githubIssueRefIdentity(candidate.issue) === identity);
13027
+ }
13028
+ /** Maps durable waiting-clarification records to isGithubApiFallbackEligible candidates. */
13029
+ export function githubApiFallbackCandidatesFromWaitingClarifications(waitingClarifications) {
13030
+ return waitingClarifications.map(([, waiting]) => ({ issue: waiting.issue, decision: waiting.decision }));
13031
+ }
13032
+ /**
13033
+ * Maps durable dispatch-lifecycle records to isGithubApiFallbackEligible
13034
+ * candidates, excluding terminal ones — a completed or abandoned dispatch's
13035
+ * decision is no longer a reason to trust a live read for that issue.
13036
+ */
13037
+ export function githubApiFallbackCandidatesFromDispatchLifecycles(dispatchLifecycles) {
13038
+ return dispatchLifecycles
13039
+ .filter(([, lifecycle]) => !isTerminalDispatchLifecycle(lifecycle))
13040
+ .map(([, lifecycle]) => ({ issue: lifecycle.issue, decision: lifecycle.decision }));
13041
+ }
12526
13042
  const githubIssuePathPreference = (path) => {
12527
13043
  if (path.endsWith('/meta.json'))
12528
13044
  return 0;
@@ -13833,6 +14349,9 @@ const durableBabysitterTrackedAgent = (session, capability = 'spawn:claude') =>
13833
14349
  result: { name: session.agentName },
13834
14350
  });
13835
14351
  const isTerminalDispatchLifecycle = (lifecycle) => lifecycle.phase === 'complete' || lifecycle.phase === 'abandoned';
14352
+ const isTerminalDispatchPhase = (phase) => phase === 'complete' || phase === 'abandoned';
14353
+ const costUsageGroupId = (runId, tracked) => JSON.stringify([runId, tracked.spec.invocationId ?? tracked.spec.name]);
14354
+ const costEntryId = (groupId, model) => JSON.stringify([groupId, model]);
13836
14355
  const publishedPullRequests = (lifecycle) => {
13837
14356
  const receipts = [
13838
14357
  ...(lifecycle?.pullRequests ?? []).filter(Boolean),
@@ -13858,7 +14377,11 @@ const primaryPublishedPullRequest = (previous, receipt, receipts) => {
13858
14377
  return receipt;
13859
14378
  return previous?.pullRequest ?? receipt ?? receipts[0];
13860
14379
  };
13861
- const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequest, pullRequests = [], releaseReason) => ({
14380
+ const mergeDispatchLifecycleAgentUsage = (current, next) => [
14381
+ ...(current ?? []).filter((entry) => entry.model !== next.model),
14382
+ structuredClone(next),
14383
+ ].sort((left, right) => left.model.localeCompare(right.model));
14384
+ const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequest, pullRequests = [], releaseReason, cost) => ({
13862
14385
  runId,
13863
14386
  issue: { ...record.issue },
13864
14387
  decision: structuredClone(record.decision),
@@ -13870,6 +14393,7 @@ const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequ
13870
14393
  ...(pullRequests.length > 0 ? { pullRequests: pullRequests.map((receipt) => ({ ...receipt })) } : {}),
13871
14394
  ...(pullRequest ? { pullRequest: { ...pullRequest } } : {}),
13872
14395
  ...(releaseReason ? { releaseReason } : {}),
14396
+ ...(cost ? { cost: structuredClone(cost) } : {}),
13873
14397
  updatedAtMs,
13874
14398
  });
13875
14399
  const inFlightRecordFromLifecycle = (lifecycle) => ({