@agent-relay/factory 0.1.57 → 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 (102) hide show
  1. package/README.md +139 -1
  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/feature-guardian/conversation.d.ts +251 -0
  11. package/dist/feature-guardian/conversation.d.ts.map +1 -0
  12. package/dist/feature-guardian/conversation.js +1893 -0
  13. package/dist/feature-guardian/conversation.js.map +1 -0
  14. package/dist/feature-guardian/index.d.ts +2 -0
  15. package/dist/feature-guardian/index.d.ts.map +1 -0
  16. package/dist/feature-guardian/index.js +2 -0
  17. package/dist/feature-guardian/index.js.map +1 -0
  18. package/dist/github/index.d.ts +2 -0
  19. package/dist/github/index.d.ts.map +1 -1
  20. package/dist/github/index.js +1 -0
  21. package/dist/github/index.js.map +1 -1
  22. package/dist/github/routed-pr-babysitter.d.ts +48 -0
  23. package/dist/github/routed-pr-babysitter.d.ts.map +1 -0
  24. package/dist/github/routed-pr-babysitter.js +197 -0
  25. package/dist/github/routed-pr-babysitter.js.map +1 -0
  26. package/dist/index.d.ts +5 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/intake/index.d.ts +4 -0
  31. package/dist/intake/index.d.ts.map +1 -0
  32. package/dist/intake/index.js +4 -0
  33. package/dist/intake/index.js.map +1 -0
  34. package/dist/intake/notion-relay-claim.d.ts +32 -0
  35. package/dist/intake/notion-relay-claim.d.ts.map +1 -0
  36. package/dist/intake/notion-relay-claim.js +173 -0
  37. package/dist/intake/notion-relay-claim.js.map +1 -0
  38. package/dist/intake/notion-relay-contract.d.ts +33 -0
  39. package/dist/intake/notion-relay-contract.d.ts.map +1 -0
  40. package/dist/intake/notion-relay-contract.js +163 -0
  41. package/dist/intake/notion-relay-contract.js.map +1 -0
  42. package/dist/intake/notion.d.ts +375 -0
  43. package/dist/intake/notion.d.ts.map +1 -0
  44. package/dist/intake/notion.js +773 -0
  45. package/dist/intake/notion.js.map +1 -0
  46. package/dist/mount/github-api-issue-read.d.ts +28 -0
  47. package/dist/mount/github-api-issue-read.d.ts.map +1 -0
  48. package/dist/mount/github-api-issue-read.js +163 -0
  49. package/dist/mount/github-api-issue-read.js.map +1 -0
  50. package/dist/mount/local-mount-preflight.d.ts +2 -0
  51. package/dist/mount/local-mount-preflight.d.ts.map +1 -1
  52. package/dist/mount/local-mount-preflight.js +59 -9
  53. package/dist/mount/local-mount-preflight.js.map +1 -1
  54. package/dist/mount/mount-auth-error.d.ts +41 -0
  55. package/dist/mount/mount-auth-error.d.ts.map +1 -0
  56. package/dist/mount/mount-auth-error.js +94 -0
  57. package/dist/mount/mount-auth-error.js.map +1 -0
  58. package/dist/mount/relayfile-binary.d.ts +5 -0
  59. package/dist/mount/relayfile-binary.d.ts.map +1 -1
  60. package/dist/mount/relayfile-binary.js +16 -2
  61. package/dist/mount/relayfile-binary.js.map +1 -1
  62. package/dist/mount/relayfile-cloud-mount-client.d.ts +18 -3
  63. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  64. package/dist/mount/relayfile-cloud-mount-client.js +157 -13
  65. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  66. package/dist/mount/workspace-mirror.d.ts +13 -0
  67. package/dist/mount/workspace-mirror.d.ts.map +1 -0
  68. package/dist/mount/workspace-mirror.js +94 -0
  69. package/dist/mount/workspace-mirror.js.map +1 -0
  70. package/dist/observability/events.d.ts +32 -32
  71. package/dist/orchestrator/factory.d.ts +51 -0
  72. package/dist/orchestrator/factory.d.ts.map +1 -1
  73. package/dist/orchestrator/factory.js +263 -17
  74. package/dist/orchestrator/factory.js.map +1 -1
  75. package/dist/ports/index.d.ts +1 -1
  76. package/dist/ports/index.d.ts.map +1 -1
  77. package/dist/ports/mount.d.ts +58 -1
  78. package/dist/ports/mount.d.ts.map +1 -1
  79. package/dist/ports/state.d.ts +21 -0
  80. package/dist/ports/state.d.ts.map +1 -1
  81. package/dist/state/file-state-store.d.ts +10 -1
  82. package/dist/state/file-state-store.d.ts.map +1 -1
  83. package/dist/state/file-state-store.js +91 -0
  84. package/dist/state/file-state-store.js.map +1 -1
  85. package/dist/state/in-memory-state-store.d.ts +10 -1
  86. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  87. package/dist/state/in-memory-state-store.js +43 -0
  88. package/dist/state/in-memory-state-store.js.map +1 -1
  89. package/dist/testing/fakes.d.ts +4 -0
  90. package/dist/testing/fakes.d.ts.map +1 -1
  91. package/dist/testing/fakes.js +6 -0
  92. package/dist/testing/fakes.js.map +1 -1
  93. package/dist/triage/schema.d.ts +133 -0
  94. package/dist/triage/schema.d.ts.map +1 -1
  95. package/dist/triage/schema.js +19 -0
  96. package/dist/triage/schema.js.map +1 -1
  97. package/dist/types.d.ts +30 -0
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/writeback/github.d.ts.map +1 -1
  100. package/dist/writeback/github.js +39 -0
  101. package/dist/writeback/github.js.map +1 -1
  102. package/package.json +10 -1
@@ -132,6 +132,44 @@ const STALE_LOCAL_AGENT_RECLAIM_MAX_ATTEMPTS = 3;
132
132
  const STALE_LOCAL_AGENT_RECLAIM_BACKOFF_MS = 500;
133
133
  export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
134
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
+ }
135
173
  class DispatchLifecycleCapacityError extends Error {
136
174
  }
137
175
  class DispatchLifecycleOwnedElsewhereError extends Error {
@@ -199,6 +237,8 @@ export class FactoryLoop {
199
237
  #githubIssueAuthors = new Map();
200
238
  #githubIssueAuthorLookups = new Map();
201
239
  #githubIssuePreferredPaths = new Map();
240
+ #githubApiFallbackIssues = new Set();
241
+ #githubApiFallbackIssuesEvicted = new Set();
202
242
  #githubIssuePathIndexReady = false;
203
243
  #slackReporterUserIds = new Map();
204
244
  #slackReporterUserIdLookups = new Map();
@@ -251,6 +291,10 @@ export class FactoryLoop {
251
291
  #subscription;
252
292
  #livePollTimer;
253
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;
254
298
  #liveEventCursor;
255
299
  #liveEventHighWatermark;
256
300
  #liveConnectStartedAtMs = 0;
@@ -622,6 +666,11 @@ export class FactoryLoop {
622
666
  this.#schedulePreviewSweep();
623
667
  try {
624
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');
625
674
  await this.#rearmSlackReplyWatchers();
626
675
  await this.#drainReadyClarificationWake();
627
676
  await this.#rearmGithubIssueCommentWatchers();
@@ -825,6 +874,7 @@ export class FactoryLoop {
825
874
  }
826
875
  async #startLiveSubscription(issueSource, overrides = {}) {
827
876
  const options = this.#liveOptions(overrides);
877
+ this.#liveTransport = options.transport;
828
878
  this.#liveConnectStartedAtMs = this.#clock.now();
829
879
  this.#liveReplaySkewMarginMs = options.replaySkewMarginMs;
830
880
  const highWatermark = await this.#currentEventHighWatermark();
@@ -2067,7 +2117,10 @@ export class FactoryLoop {
2067
2117
  const dispatched = this.#dispatchUnlocked(decision, opts);
2068
2118
  this.#dispatchInFlight.set(key, dispatched);
2069
2119
  try {
2070
- return await dispatched;
2120
+ const result = await dispatched;
2121
+ if (decision.issueResolution)
2122
+ result.issueResolution = structuredClone(decision.issueResolution);
2123
+ return result;
2071
2124
  }
2072
2125
  finally {
2073
2126
  if (this.#dispatchInFlight.get(key) === dispatched) {
@@ -2101,7 +2154,11 @@ export class FactoryLoop {
2101
2154
  this.#error(error, decision.issue);
2102
2155
  throw error;
2103
2156
  }
2104
- 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);
2105
2162
  if (!liveIssue || !isInFactoryScope(liveIssue, this.#config.safety)) {
2106
2163
  const error = new Error(`Refusing to dispatch ${decision.issue.key}: not factory-e2e scope`);
2107
2164
  this.#error(error, decision.issue);
@@ -2456,8 +2513,27 @@ export class FactoryLoop {
2456
2513
  counters: { ...this.#counters },
2457
2514
  slackDegraded: this.#slackDegraded,
2458
2515
  slackDegradedReason: this.#slackDegradedReason,
2516
+ eventListener: this.#eventListenerStatus(),
2459
2517
  };
2460
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
+ }
2461
2537
  on(event, listener) {
2462
2538
  let listeners = this.#listeners.get(event);
2463
2539
  if (!listeners) {
@@ -3524,7 +3600,11 @@ export class FactoryLoop {
3524
3600
  async #resumeDurableDispatch(record) {
3525
3601
  let liveIssue;
3526
3602
  if (!record.dryRun) {
3527
- 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);
3528
3608
  if (!liveIssue) {
3529
3609
  throw new Error(`Unable to recover durable dispatch ${record.issue.key}: issue is not currently readable`);
3530
3610
  }
@@ -4126,7 +4206,62 @@ export class FactoryLoop {
4126
4206
  this.#logger.error?.('[factory] failed to ingest GitHub issue', error);
4127
4207
  }
4128
4208
  }
4129
- 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) {
4130
4265
  const preferredPath = await this.#preferredGithubIssuePath(path);
4131
4266
  const candidatePaths = [...new Set([
4132
4267
  ...githubIssueReadCandidatePaths(preferredPath),
@@ -4150,8 +4285,55 @@ export class FactoryLoop {
4150
4285
  }
4151
4286
  catch (error) {
4152
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
+ }
4153
4335
  this.#increment('githubIssuePhantomSkipped');
4154
- 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 });
4155
4337
  return undefined;
4156
4338
  }
4157
4339
  throw error;
@@ -4569,6 +4751,7 @@ export class FactoryLoop {
4569
4751
  updatedAt: new Date(updatedAtMs).toISOString(),
4570
4752
  updatedAtMs,
4571
4753
  registryPath,
4754
+ eventListener: this.#eventListenerStatus(),
4572
4755
  };
4573
4756
  await mkdir(dirname(path), { recursive: true });
4574
4757
  await writeFile(path, `${JSON.stringify(heartbeat, null, 2)}\n`, 'utf8');
@@ -4736,10 +4919,10 @@ export class FactoryLoop {
4736
4919
  }
4737
4920
  return [...pathsByKey.values()].sort();
4738
4921
  }
4739
- async #readIssue(path) {
4922
+ async #readIssue(path, decisionHint) {
4740
4923
  try {
4741
4924
  if (isGithubIssueFilePath(path)) {
4742
- const githubIssue = await this.#readGithubIssue(path);
4925
+ const githubIssue = await this.#readGithubIssue(path, decisionHint);
4743
4926
  return githubIssue ? githubIssueAsFactoryIssue(githubIssue) : undefined;
4744
4927
  }
4745
4928
  // Newly-synced issues land as a change-event STUB at the primary
@@ -5604,6 +5787,7 @@ export class FactoryLoop {
5604
5787
  baseRef,
5605
5788
  title: `${issue.key}: ${issue.title}`,
5606
5789
  body: githubPullRequestBody(issue, implementer.spec.preview),
5790
+ ...(implementer.sessionRef ? { sessionRef: implementer.sessionRef } : {}),
5607
5791
  });
5608
5792
  const published = result.author
5609
5793
  ? result
@@ -6258,6 +6442,20 @@ export class FactoryLoop {
6258
6442
  if (!tracked.sessionRef) {
6259
6443
  return;
6260
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
+ }
6261
6459
  this.#logger.debug?.('[factory] tracked agent resume preparation started', {
6262
6460
  issue: record.issue.key,
6263
6461
  name,
@@ -11403,7 +11601,7 @@ export class FactoryLoop {
11403
11601
  }, Math.max(1_000, Math.floor(CLARIFICATION_WAKE_LEASE_MS / 3)));
11404
11602
  heartbeat.unref?.();
11405
11603
  try {
11406
- if (!await this.#clarificationIssueStillActive(waiting.issue)) {
11604
+ if (!await this.#clarificationIssueStillActive(waiting.issue, waiting.decision)) {
11407
11605
  this.#assertClarificationWakeRunning();
11408
11606
  await renewLease();
11409
11607
  const completed = await this.#state.completeClarificationWake(this.#workspaceId, key, this.#clarificationWakeOwner);
@@ -11579,8 +11777,8 @@ export class FactoryLoop {
11579
11777
  if (this.#stopping)
11580
11778
  throw new ClarificationWakeStoppedError('factory is stopping');
11581
11779
  }
11582
- async #clarificationIssueStillActive(issueRef) {
11583
- const issue = await this.#readIssue(issueRef.path);
11780
+ async #clarificationIssueStillActive(issueRef, decisionHint) {
11781
+ const issue = await this.#readIssue(issueRef.path, decisionHint);
11584
11782
  if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
11585
11783
  this.#logger.info?.('[factory] clarification wake cancelled because issue left factory scope', {
11586
11784
  issue: issueRef.key,
@@ -11746,12 +11944,11 @@ export class FactoryLoop {
11746
11944
  this.#emit('issue-queued', { issue: decision.issue });
11747
11945
  }
11748
11946
  }
11749
- // Absolute path to the local .integrations mount the daemon manages. The mount
11750
- // is created at the daemon's cwd (see ensureLocalMount), and spawned agents run
11751
- // in their repo clonePath, so writeback paths handed to agents must be absolute
11752
- // 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.
11753
11950
  #integrationsMountRoot() {
11754
- return resolve(process.cwd(), '.integrations');
11951
+ return this.#mount.getLocalMountRoot?.() ?? resolve(process.cwd(), '.integrations');
11755
11952
  }
11756
11953
  async #slackChannelDir() {
11757
11954
  if (!this.#config.slack) {
@@ -12269,8 +12466,17 @@ const pidsFromSpawnResult = (result) => {
12269
12466
  }
12270
12467
  return [...pids].sort((a, b) => a - b);
12271
12468
  };
12272
- 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) => [
12273
12476
  `Factory dispatch for ${decision.issue.key}`,
12477
+ decision.issueResolution
12478
+ ? `Issue resolution: ${decision.issueResolution.source}`
12479
+ : undefined,
12274
12480
  `Implementers: ${agents.filter((agent) => agent.role === 'implementer').map((agent) => agent.name).join(', ') || 'none'}`,
12275
12481
  decision.scope === 'workflow' ? `Workflow: ${agents.find((agent) => agent.role === 'workflow')?.name ?? 'none'}` : undefined,
12276
12482
  `Reviewer: ${agents.find((agent) => agent.role === 'reviewer')?.name ?? 'none'}`,
@@ -12788,11 +12994,51 @@ export const githubIssuePathParts = (path) => {
12788
12994
  slug: match[7],
12789
12995
  };
12790
12996
  };
12791
- const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
12997
+ export const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
12792
12998
  const githubIssueRefIdentity = (issue) => {
12793
12999
  const parts = githubIssuePathParts(issue.path);
12794
13000
  return parts ? githubIssueIdentity(parts.owner, parts.repo, parts.number) : undefined;
12795
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
+ }
12796
13042
  const githubIssuePathPreference = (path) => {
12797
13043
  if (path.endsWith('/meta.json'))
12798
13044
  return 0;