Skip to content

fix(factory): fall back to direct GitHub API when the Relayfile issue projection is stale - #225

Open
khaliqgant wants to merge 19 commits into
mainfrom
agent/factory-dispatch-api-fallback
Open

fix(factory): fall back to direct GitHub API when the Relayfile issue projection is stale#225
khaliqgant wants to merge 19 commits into
mainfrom
agent/factory-dispatch-api-fallback

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 8, 2026

Copy link
Copy Markdown
Member

Why

Factory currently dispatches nothing org-wide, and the cause is not in Factory's routing.

Targeted issue resolution is projection-only: runFactoryCommandreadIssueArgfindIssuePath tries canonical repo-scoped paths, lists the configured Relayfile GitHub issue roots, and throws on zero matches before triage can run. When the Relayfile projection is stale, every lookup misses and factory triage reports found 0 matches — which reads identically to "there is no such issue".

The projection has been frozen since 2026-08-03T07:26Z. So the failure mode is silent: Factory is healthy, its routing is correct, and it dispatches nothing.

What changed

A read-only direct GitHub REST fallback, used only after the projection has demonstrably failed.

  • Projection first, always. A healthy projection miss fails without calling GitHub — an issue that genuinely does not exist still resolves to not-found.
  • The fallback is eligible only when PR fix: share one Relayfile workspace mirror across routed repos #220's health facts say the projection cannot answer: a degraded local mount, or a listener state other than subscribed/polling.
  • An authoritative empty API result is treated as not-found, not as an error.
  • repo#number and owner/repo#number selectors make a targeted fallback exactly one authoritative lookup instead of an ambiguous org-wide probe.
  • Triage and dispatch results carry issueResolution; fallback records also carry localMountDegraded, localMountDegradedReason, and eventListener, so a decision states which source answered it.
  • Dispatch re-reads a fallback issue through the provider before applying the existing scope, readiness, dispatchability and repo-label gates. The safety gates are unchanged and still authoritative.

Verification

Live CLI through runFleetCli: factory#222exit 0, source github-api-fallback, projection no-match, routed only to AgentWorkforce/factory. factory#999999exit 1, no decision emitted. Dry-run dispatch of an issue missing both Factory markers → exit 1, no dispatch; dry-run dispatch of factory#222exit 0, carrying github-api-fallback in both the result and its dispatch comment.

Red-checked in both directions rather than assumed:

  • Forced projection hits past the preferred branch → targeted test exit 1. Restored → exit 0. The projection really is preferred.
  • Made the intentionally-unsafe fixture satisfy the GitHub label/title markers → rejection test exit 1. Restored → exit 0. The safety gate really is doing the rejecting.

Focused build and suites: exit 0, 158 tests passed.

Known caveats, stated rather than buried

  • Default-timeout full suite exits 1: 1,461 passed, six timing-sensitive tests failed. Re-running the three affected non-orchestrator files with a 20s ceiling exits 0 (65 tests). One heartbeat timing assertion (600 < 500) fails in isolation and is pre-existing — not introduced here.
  • A one-shot shutdown hang was observed: the successful decision is emitted, then the shutdown path stays open until SIGINT before returning 0. Separate from issue resolution, not addressed here.
  • An earlier attempt routed through the Cloud GitHub GraphQL route and returned Forbidden — that route additionally requires a deployed sponsor persona, which a local Factory workspace join is not. Replaced with the read-only direct REST client. GitHub writes remain on Relayfile app-authored writeback; Factory never handles a GitHub token.
  • A bare-number live check exposed GitHub's REST issues endpoint returning pull requests. Those are now authoritative issue misses rather than malformed records.

Scope

No queue, Cloudflare, mount, daemon or launchd mutation. No default-branch push. Read-only on the GitHub side.


Authored by the factory-dispatch-api-fallback lane, which completed the work and red-checks but could not open a PR: sanctioned Relayfile app-authored write returned HTTP 403, and it correctly declined to shell out to gh instead. Opened on its behalf; that 403 is a real gap worth fixing separately — a lane that cannot publish its own work is blocked no matter how finished the work is.

🤖 Generated with Claude Code


Summary by cubic

Restores Factory triage/dispatch by adding a read-only GitHub REST fallback when the Relayfile issue projection is stale. Keeps projection-first behavior, hardens selector validation and privacy, and derives fallback eligibility across dispatch, publishing, clarifications, and durable lifecycles so restarts keep working.

  • Bug Fixes

    • Use the GitHub API only after a projection miss when health shows the projection cannot answer (degraded mount, listener not subscribed/polling, or GitHub connection not ready); healthy misses never call GitHub.
    • Confirm repo visibility before trusting 404s; return “indeterminate” when visibility can’t be confirmed; cache visibility; treat PRs from the issues endpoint as misses.
    • Canonicalize qualified selectors through one path: support repo#number and owner/repo#number, validate against all configured routes (including cross‑org label routes and repos.org expansions), and match labels case‑insensitively; bare numbers remain default‑only; refuse dispatch if uniqueness across configured repos can’t be confirmed.
    • Keep projection‑first resolution; triage/dispatch include issueResolution. Dispatch re‑reads fallback issues via the provider and applies the same scope/readiness/label gates. The orchestrator only uses the provider read for issues already resolved via the fallback.
    • Derive GitHub API fallback eligibility on read from the in‑flight batch, waiting clarifications, or non‑terminal dispatch lifecycles (plus an optional decision hint), then warm a bounded cache (2,000) with a small evicted record (200). Covers publishing, clarifications, and other late‑phase reads, survives restarts, and logs/counts evictions and “indeterminate” separately from phantom skips.
    • Redact fallback details from public GitHub comments: post only issueResolution.source (no local paths or reasons).
    • Stabilize the API‑fallback publishing restart check by baselining attempts post‑stop and asserting a strict increase, removing a pre‑restart retry race.
  • Migration

    • If you parse triage/dispatch JSON, handle the new optional issueResolution field.

Written for commit 91add5f. Summary will update on new commits.

Review in cubic

@cursor

cursor Bot commented Aug 8, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds projection-first GitHub issue resolution with an unauthenticated REST fallback. It records resolution metadata, supports qualified selectors, revalidates fallback-resolved issues during dispatch, and expands CLI, adapter, and recovery test coverage.

Changes

GitHub issue resolution

Layer / File(s) Summary
Provider contracts and REST issue reads
src/ports/mount.ts, src/types.ts, src/triage/schema.ts, src/mount/github-api-issue-read.ts, src/mount/relayfile-cloud-mount-client.ts, src/index.ts, src/ports/index.ts, src/mount/github-api-issue-read.test.ts
Adds tri-state GitHub issue reads, resolution metadata, REST retrieval, visibility caching, mount wiring, public exports, and adapter tests.
Projection-first CLI resolution
src/cli/fleet.ts, src/cli/fleet.test.ts
Prefers projections, permits REST fallback when projection health prevents an answer, validates selectors and repository uniqueness, reports resolution state, and preserves safety-label checks.
Dispatch fallback and resolution reporting
src/orchestrator/factory.ts, src/orchestrator/factory.test.ts, .agent-notes/factory-dispatch-unblock.md
Bounds fallback eligibility, re-reads missing projections, distinguishes indeterminate results from confirmed misses, preserves resolution metadata, sanitizes dispatch comments, and tests restart recovery.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FleetCLI
  participant RelayfileProjection
  participant GithubApiIssueRead
  participant FactoryLoop
  FleetCLI->>RelayfileProjection: Resolve issue selector
  RelayfileProjection-->>FleetCLI: Return issue or projection health
  FleetCLI->>GithubApiIssueRead: Read eligible fallback issue
  GithubApiIssueRead-->>FleetCLI: Return found, not-found, or indeterminate
  FleetCLI->>FactoryLoop: Dispatch resolved issue
  FactoryLoop->>GithubApiIssueRead: Revalidate missing projection
  GithubApiIssueRead-->>FactoryLoop: Return issue or provider result
  FactoryLoop-->>FleetCLI: Return dispatch result with resolution metadata
Loading

Possibly related PRs

Suggested reviewers: kjgbot, miyaontherelay

Poem

I hop through projections, then query GitHub.
Found, absent, unknown states stay clear.
Dispatch checks each fallback issue again.
Metadata follows every hop.
— A careful rabbit 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly explains the GitHub API fallback, projection-first behavior, safety checks, testing, and known limitations.
Title check ✅ Passed The title clearly and concisely describes the primary change: using the direct GitHub API when the Relayfile projection is stale.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/factory-dispatch-api-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cff7303c6a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +38 to +41
headers: {
accept: 'application/vnd.github+json',
'user-agent': '@agent-relay/factory',
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authenticate fallback reads for private repositories

When the configured repository is private, this request carries no GitHub authentication, so GitHub returns the same 404 used for nonexistent resources; line 44 then converts that response to undefined, and triage/dispatch incorrectly reports zero matches. Because every RelayfileCloudMountClient receives this reader even when its connected GitHub App can access private repositories, the new stale-projection recovery path cannot recover private issues.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3eb01ca. GithubConnectionRead.getIssue now returns a three-outcome GithubIssueLookup (found / not-found / indeterminate) instead of GithubConnectionIssue | undefined. The reader confirms repo visibility with a cached, unauthenticated repo-level GET before trusting an issue-level 404 as not-found; a 404 against a repo it can't confirm public now returns indeterminate instead. Both consumers (orchestrator/factory.ts#readGithubIssue, cli/fleet.ts findGithubIssueThroughConnection) were updated to stop collapsing indeterminate into absence — the CLI path now also refuses to dispatch to a single found match when another probed repo in the same org-wide lookup is indeterminate, since a same-numbered issue could exist there too and silently returning the one repo that answered would misroute dispatch.

Comment thread src/cli/fleet.ts Outdated
Comment on lines 1895 to 1897
function configuredGithubIssueRepos(config: FactoryConfig): string[] {
const candidates = config.repos.default
? [config.repos.default]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate qualified selectors against every configured route

When a multi-repository config has repos.default, this reduces the configured repository set to only that default. parseGithubIssueSelector reuses this set to validate explicit repo#number selectors, so a configured non-default route such as cloud#222 is rejected as unconfigured even if it appears in byLabel, byProject, or keywordRules. Keep the default-only behavior for bare numbers, but validate qualified selectors against all configured routes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1cef8c3. Split configuredGithubIssueRepos (kept as-is, still collapses to repos.default for bare-number resolution — that behavior is intentional) from a new allConfiguredGithubIssueRepos (default ∪ every route from byLabel/byProject/keywordRules), and pointed parseGithubIssueSelector's qualified-selector validation at the new one. A route like cloud#222 reachable only through byLabel now validates correctly even when repos.default is set to something else.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 12 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/orchestrator/factory.ts Outdated
Comment thread src/mount/github-api-issue-read.ts Outdated
Comment thread src/cli/fleet.ts Outdated
Comment thread src/orchestrator/factory.ts Outdated
Comment thread src/cli/fleet.test.ts Outdated
kjgbot added 2 commits August 9, 2026 13:06
…ed route

configuredGithubIssueRepos collapsed to repos.default whenever it was
set, so parseGithubIssueSelector rejected a valid repo-qualified
selector (e.g. cloud#222) reachable only through byLabel, byProject,
or keywordRules. Bare-number resolution intentionally stays
default-only; qualified selectors now validate against the full
configured route set via a new allConfiguredGithubIssueRepos.
… misses

GithubApiIssueRead made every lookup unauthenticated, then treated a 404
as a confirmed absence. GitHub returns the same 404 for "issue does not
exist" and "repository is private, existence hidden from an
unauthenticated caller" — so a real issue in a private repo was
misreported as gone, and the reader's own doc comment ("provider-
authoritative", "returns undefined only when GitHub authoritatively
reports no issue") was already false for anything it couldn't prove
public.

GithubConnectionRead.getIssue now returns a three-outcome
GithubIssueLookup (found / not-found / indeterminate) instead of
GithubConnectionIssue | undefined. The reader confirms repository
visibility with one unauthenticated, cached repo-level GET before
trusting an issue-level 404 as not-found; a 404 against a repo it
cannot confirm public, or a rate-limited/ambiguous probe response,
degrades to indeterminate rather than throwing or manufacturing
absence.

Both real consumers of the port are updated to stop collapsing
indeterminate into absence:
- orchestrator/factory.ts #readGithubIssue no longer folds an
  indeterminate result into the phantomSkipped ("confirmed gone")
  counter; it now counts and warn-logs it separately.
- cli/fleet.ts findGithubIssueThroughConnection throws a distinct
  "could not determine" error instead of a false "found 0 matches",
  and — the sharper bug — refuses to dispatch to a single found match
  when another configured repo in the same org-wide probe came back
  indeterminate, since a same-numbered issue could exist there too and
  silently picking the one repo that answered would misroute dispatch.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/orchestrator/factory.ts (1)

2389-2399: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not mutate the resolved DispatchResult in place.

Line 2393 assigns issueResolution onto the object returned by #dispatchUnlocked. That object is frequently a retained record, not a fresh value:

  • Line 2407 returns existingRecord.result (held by the batch).
  • Line 2413 returns durable.result (loaded from the durable lifecycle).
  • Line 2548 returns recoveredRecord.result.
  • Line 2619 returns record.result, which #saveDispatchLifecycle later persists through lifecycleFromInFlightRecord.

The mutation therefore writes CLI-scoped resolution provenance into batch and durable lifecycle state, and a later #saveDispatchLifecycle can persist it. The duplicate-suppression path at line 2386 also returns the same shared promise, so a second caller observes the first caller's issueResolution.

Return a copy instead of mutating the shared object.

🐛 Proposed fix to return a copy
     try {
       const result = await dispatched
-      if (decision.issueResolution) result.issueResolution = structuredClone(decision.issueResolution)
-      return result
+      return decision.issueResolution
+        ? { ...result, issueResolution: structuredClone(decision.issueResolution) }
+        : result
     } finally {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/orchestrator/factory.ts` around lines 2389 - 2399, In the dispatch flow
around `#dispatchUnlocked`, stop assigning issueResolution directly onto the
resolved DispatchResult because it may be shared by batch or durable lifecycle
records. Return a shallow copy of the result with a cloned issueResolution when
decision.issueResolution exists, while preserving the original result for
callers without issue resolution and leaving the in-flight cleanup unchanged.
🧹 Nitpick comments (2)
src/mount/github-api-issue-read.ts (1)

132-162: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider coalescing concurrent visibility probes.

#isRepoPublic caches only the resolved verdict. Two concurrent getIssue calls for the same repository send two probe requests. Unauthenticated GitHub REST allows roughly 60 requests per hour per IP, and each lookup already costs up to two requests. Caching the in-flight promise keeps the probe to one request per repository.

♻️ Proposed change to cache the in-flight probe
-  readonly `#repoIsPublic` = new Map<string, boolean>()
+  readonly `#repoIsPublic` = new Map<string, boolean>()
+  readonly `#repoIsPublicInFlight` = new Map<string, Promise<boolean | undefined>>()
   async `#isRepoPublic`(owner: string, name: string): Promise<boolean | undefined> {
     const key = `${owner}/${name}`.toLowerCase()
     const cached = this.#repoIsPublic.get(key)
     if (cached !== undefined) return cached
+    const inFlight = this.#repoIsPublicInFlight.get(key)
+    if (inFlight) return inFlight
+    const probe = this.#probeRepoIsPublic(key, owner, name)
+      .finally(() => this.#repoIsPublicInFlight.delete(key))
+    this.#repoIsPublicInFlight.set(key, probe)
+    return probe
+  }
 
+  async `#probeRepoIsPublic`(key: string, owner: string, name: string): Promise<boolean | undefined> {
     let response: Response
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mount/github-api-issue-read.ts` around lines 132 - 162, Update
`#isRepoPublic` to cache and reuse the in-flight visibility probe promise per
normalized repository key, so concurrent callers share one request. Ensure the
entry is cleared after the probe settles, while preserving the existing resolved
true/false cache behavior and undefined result handling.
src/orchestrator/factory.ts (1)

4679-4692: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

A fallback-resolved issue is re-fetched on every read.

The provider result is parsed and returned but never cached. #dispatchUnlocked calls #readIssue for the same path at line 2434, again at line 2629, and again at line 2655. A live dispatch of a fallback-resolved issue therefore performs three GitHub lookups, and each lookup costs up to two unauthenticated requests through GithubApiIssueRead. The unauthenticated GitHub REST budget is roughly 60 requests per hour per IP, so a single dispatch can consume a noticeable share of it and later reads can degrade to indeterminate.

Cache the parsed issue per identity for the duration of the dispatch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/orchestrator/factory.ts` around lines 4679 - 4692, Cache the parsed
fallback issue by its GitHub identity within the dispatch lifecycle so repeated
`#readIssue` calls reuse it instead of invoking `#mount.githubRead` again. Update
the fallback branch around githubIssueIdentity and `#indexDependencyIssue` to
consult and populate a per-dispatch cache, while preserving the existing lookup,
parsing, warning, and return behavior for uncached issues.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cli/fleet.ts`:
- Around line 1902-1916: Update the qualified repository resolution around
requested, configured, and expanded so label-based selectors can resolve routes
outside config.repos.org. When requested lacks “/”, try the org-qualified
candidate and the matching config.repos.byLabel value, then retain the first
candidate that matches configured; preserve direct owner/repository selectors
and the existing error when neither candidate is configured.

In `@src/orchestrator/factory.ts`:
- Around line 13473-13475: Update the issue comment formatting around
decision.issueResolution so dispatchComment/#postIssueComment outputs only
issueResolution.source, omitting issueResolution.detail. Preserve detail in the
JSON result and logging paths, and leave the existing undefined behavior when no
resolution exists unchanged.

---

Outside diff comments:
In `@src/orchestrator/factory.ts`:
- Around line 2389-2399: In the dispatch flow around `#dispatchUnlocked`, stop
assigning issueResolution directly onto the resolved DispatchResult because it
may be shared by batch or durable lifecycle records. Return a shallow copy of
the result with a cloned issueResolution when decision.issueResolution exists,
while preserving the original result for callers without issue resolution and
leaving the in-flight cleanup unchanged.

---

Nitpick comments:
In `@src/mount/github-api-issue-read.ts`:
- Around line 132-162: Update `#isRepoPublic` to cache and reuse the in-flight
visibility probe promise per normalized repository key, so concurrent callers
share one request. Ensure the entry is cleared after the probe settles, while
preserving the existing resolved true/false cache behavior and undefined result
handling.

In `@src/orchestrator/factory.ts`:
- Around line 4679-4692: Cache the parsed fallback issue by its GitHub identity
within the dispatch lifecycle so repeated `#readIssue` calls reuse it instead of
invoking `#mount.githubRead` again. Update the fallback branch around
githubIssueIdentity and `#indexDependencyIssue` to consult and populate a
per-dispatch cache, while preserving the existing lookup, parsing, warning, and
return behavior for uncached issues.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b35f072-178d-4f5d-9ca7-f6e5f13521c3

📥 Commits

Reviewing files that changed from the base of the PR and between 33cda42 and 3eb01ca.

📒 Files selected for processing (12)
  • .agent-notes/factory-dispatch-unblock.md
  • src/cli/fleet.test.ts
  • src/cli/fleet.ts
  • src/index.ts
  • src/mount/github-api-issue-read.test.ts
  • src/mount/github-api-issue-read.ts
  • src/mount/relayfile-cloud-mount-client.ts
  • src/orchestrator/factory.ts
  • src/ports/index.ts
  • src/ports/mount.ts
  • src/triage/schema.ts
  • src/types.ts

Comment thread src/cli/fleet.ts
Comment thread src/orchestrator/factory.ts
kjgbot added 3 commits August 9, 2026 13:24
If the repo-visibility probe cleanly confirms a repo is not visible
unauthenticated (a 404, not an ambiguous/rate-limited response), the
issue-level GET was still being made and only then discarded as
indeterminate at its own 404. That second call can never inform the
result — an invisible repo's issues are invisible too — so it only
burned rate-limit budget (60 req/hr, unauthenticated) for nothing, and
in this org private is the common case, not the edge.

isPublic === false now returns indeterminate immediately, before the
issue fetch. Added a test asserting the issue endpoint is never called
when the repo probe cleanly 404s, checked on fetch call count rather
than outcome alone.
allConfiguredGithubIssueRepos (added in 1cef8c3) includes every
byLabel/byProject/keywordRules route regardless of owner, but
parseGithubIssueSelector's expansion only ever tried
`${repos.org}/${requested}` when repos.org was set, and fell back to
the label's own route only when repos.org was unset. A label routed to
a different owner than repos.org was still rejected as unconfigured
even though it is in the validated set.

Try the org expansion first, then the label's own route, and keep the
first that matches a configured repo.
issueResolution.detail (and the localMountDegradedReason it can embed)
is free text describing local operator state — e.g. "mount state is
missing at /Users/<name>/.relayfile/<workspace>/.relay/state.json".
dispatchComment interpolated it directly into the comment body posted
to the issue via #postIssueComment, and the same string was mirrored
into DispatchResult.comments. factory is a public repo; this PR
introduced issueResolution, so this was a regression the PR added.

Audited every consumer of issueResolution.detail and
localMountDegradedReason: dispatchComment (this PR's only construction
site of a comment string containing either) is the sole render path
that reaches a posted comment. Other #postIssueComment callers in this
file (label-dispatch-failure and dependency-park notices) don't touch
issueResolution at all.

Emit only issueResolution.source in the comment — a closed two-value
enum, so an allowlist rather than trying to scrub paths out of free
text. detail remains untouched in the JSON issueResolution field and
in logs, per the existing dispatch/triage JSON output and #logger
calls, which read the field directly rather than through this
function.

Exported dispatchComment for a direct unit test (matching this file's
existing pattern for testing pure helpers, e.g. githubIssuePathParts)
asserting the rendered comment string never contains an absolute path
for a degraded-mount fallback resolution.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 9 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/cli/fleet.ts Outdated
Comment thread src/cli/fleet.test.ts
kjgbot added 3 commits August 9, 2026 13:49
…ity set

Unit 2 — restart recovery. #githubApiFallbackIssues is process-local,
so after a restart it starts empty even though a durably-persisted
decision still records issueResolution.source === 'github-api-fallback'.
Durable dispatch resume (#resumeDurableDispatch) re-reads the issue
before continuing and, without eligibility restored, throws "issue is
not currently readable" on every retry — the lifecycle never leaves
'retryable'. Extracted the existing #dispatchUnlocked registration
logic into #registerGithubApiFallbackEligibility(decision) and call it
from both #dispatchUnlocked and #resumeDurableDispatch, so eligibility
is restored from the decision itself rather than assumed lost.

Unit 4 — bound the set. #githubApiFallbackIssues was append-only for
the life of the process, growing without bound in a long-running
`factory start --mode live` daemon. Bounded it at 2,000 entries,
evicting the least-recently-registered identity at capacity.

Bounding a set unit 2 depends on being complete risks quietly
re-breaking unit 2: eviction and "never was eligible" look identical
to a reader unless kept distinguishable. Evicted identities move into
a second, separately-bounded record (200 entries) purely for that
distinction; #readGithubIssue now checks it on a miss and counts/logs
"eligibility evicted" (githubIssueApiFallbackEligibilityEvicted)
separately from a confirmed-gone phantom skip (githubIssuePhantomSkipped),
so an operator investigating a false phantom-skip has a way to tell
the two apart instead of both collapsing into the same silent signal.

The bounding/eviction/distinguishability mechanics are exported as a
pure function (rememberBoundedFallbackEligibility, no class state) and
tested directly against small max sizes — bounding, eviction, recency
refresh on re-registration, eviction-record restoration, and bounding
of the eviction record itself. Testing the full wiring at the
production-sized cap (2,000 entries) through real dispatch cycles
would need thousands of spawns per test run; the pure function is the
same code path #rememberGithubApiFallbackEligible calls, so this is
exhaustive on the mechanics without that cost. Flagging that tradeoff
rather than presenting it as full end-to-end coverage.
Titled as if the GitHub API fallback resolves the issue, but the
assertions verify the opposite: the projection stays preferred
(source: relayfile-projection) and githubRead.getIssue is never
called. Applied cubic's exact suggested title.
…ization path

Three consecutive review rounds found a different normalization gap in
parseGithubIssueSelector's qualified-selector resolution: round 1
collapsed to repos.default; round 2's org-expansion never consulted
byLabel for a cross-owner route; round 3 compared a bare byLabel route
against a normalized configured entry and never matched. Each round
patched the specific case found rather than the shared cause: an
ad-hoc, hand-rolled candidate/comparison list that only handled
whichever combination of org/label/qualification it happened to be
built against.

Replaced it with the invariant every configured entry already
satisfies: resolve the requested selector through the same
resolveGithubIssueRepoCandidates canonicalization (label mapping, org
prefixing, canonical-route lookup) used to build the configured route
set, then compare only normalized against normalized. Deleted the
separate ad-hoc expansion entirely.

Tested as a 3-dimensional matrix (bare vs qualified selector x
repos.org set vs unset x label route written bare vs owner/repo, plus
case-insensitivity and an already-qualified selector) via a direct
unit test of the exported parseGithubIssueSelector, rather than one
regression case per bug a reviewer happened to name. Confirmed the
two rows matching round 3's exact bug shape fail against the prior
(round 2) resolution logic and pass with this one; the other seven
rows already passed under round 2's logic, isolating exactly the case
that was actually broken.

Also fixed an internally inconsistent fixture in the round-2 cross-
owner test: its mock issue content hardcoded owner "AgentWorkforce"
while the test's own scenario is a repo owned by "OtherOrg" — an
inconsistent fixture can pass for the wrong reason. githubIssueFile
now takes an optional owner parameter (default unchanged) instead of
hardcoding one.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/orchestrator/factory.ts Outdated
… per call site

Registering eligibility at exactly two sites (#dispatchUnlocked,
#resumeDurableDispatch) against ~21 #readIssue/#readGithubIssue call
sites is the same shape that produced three rounds of selector bugs:
a fallback-backed record that reached 'publishing' (PR publication)
after a restart could not reread its issue, because nothing on that
path ever registered it — #publishImplementerPullRequest reads the
issue directly and had no idea the eligibility cache existed. Adding
a third registration call at that one site would only repeat the
mistake for whichever site is found next.

Eligibility is now derived, not remembered. #readGithubIssue resolves
it itself on a cache miss via #deriveGithubApiFallbackEligibility,
which checks (a) an optional decisionHint, for the one read that
happens before its own record exists in the batch (the initial live
dispatch, which validates scope before it is tracked at all), or (b)
the currently in-flight batch, scanned for a record whose decision was
resolved through the fallback. BatchTracker.restore/start insert a
durably-resumed or freshly-dispatched record into the batch before any
phase handler runs, so every other call site — publishing, parking,
question-handling, completion, and any future one — resolves correctly
with no changes and nothing to remember. A successful derived lookup
warms the existing bounded cache so repeated reads of the same issue
stay cheap.

Derivation intentionally scans the in-memory batch rather than looking
up the durable record directly by its composite key (uuid/key/path):
the real uuid is built from GitHub's node_id/id when content is
available, which a path alone cannot reconstruct, so a direct
durable-store lookup keyed that way would not reliably match. The
in-memory batch instead matches by GitHub identity (owner/repo/number)
against a repository's own issue path, sidestepping that problem
entirely, at the cost of only ever finding an issue that is currently
tracked in this process (a durably-restored record always is by the
time any phase handler reads it).

Exported the derivation as a pure function (isGithubApiFallbackEligible)
and tested it directly: eligible from a tracked record alone with no
hint and nothing registered (the guard against a future call site
reintroducing this by omission), not eligible with no match, not
eligible for a projection-sourced (non-fallback) record, not eligible
for a different identity, eligible from decisionHint alone before any
record is tracked, and decisionHint does not leak to a different
identity.

Also added a full FactoryLoop-level red check: a fallback-backed
GitHub issue whose PR publication is deliberately parked at
'publishing' pre-restart (publisher fails until reactivated), then
resumed by a fresh createFactory instance with an empty in-memory
cache. Confirmed failing before this fix — the restarted process's
publish retries never reach the publisher a second time, stuck
throwing "issue is no longer readable" on every attempt — and passing
after it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/orchestrator/factory.ts (2)

2434-2436: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Avoid mutating a shared DispatchResult object.

#dispatchUnlocked can return objects that are stored elsewhere: existingRecord.result, record.result, and durable.result. Line 2435 mutates that same object, so issueResolution is written into the batch record and into any other holder of the reference. A later #saveDispatchLifecycle then persists it. Return a copy instead.

Also note the duplicate-suppression path at Line 2426 returns inFlight directly, so a coalesced caller can observe the result before this enrichment runs. Enriching a copy inside a shared wrapper promise removes both problems.

♻️ Proposed change
-    const dispatched = this.#dispatchUnlocked(decision, opts)
+    const dispatched = this.#dispatchUnlocked(decision, opts).then((result) =>
+      decision.issueResolution
+        ? { ...result, issueResolution: structuredClone(decision.issueResolution) }
+        : result)
     this.#dispatchInFlight.set(key, dispatched)
     try {
-      const result = await dispatched
-      if (decision.issueResolution) result.issueResolution = structuredClone(decision.issueResolution)
-      return result
+      return await dispatched
     } finally {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/orchestrator/factory.ts` around lines 2434 - 2436, Update the dispatch
flow around `#dispatchUnlocked` so issueResolution is added to a new
DispatchResult copy rather than mutating the shared result. Enrich the result
inside the shared wrapper promise used for duplicate suppression, ensuring
coalesced callers receive the enriched copy instead of returning inFlight
directly.

4758-4806: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Distinguish "no GitHub reader mounted" from a confirmed miss.

If identity is eligible but this.#mount.githubRead is undefined, control falls to Line 4804. The code then increments githubIssuePhantomSkipped and logs "after projection and provider lookup", although no provider lookup ran. That mislabels an unavailable provider as a confirmed absence, which is the exact distinction the rest of this block preserves.

🔍 Proposed change
         if (eligible && identity) this.#rememberGithubApiFallbackEligible(identity)
+        if (parts && identity && eligible && !this.#mount.githubRead) {
+          this.#increment('githubIssueUnverifiable')
+          this.#logger.warn?.('[factory] GitHub API fallback is eligible but no GitHub reader is mounted', {
+            path,
+            repo: `${parts.owner}/${parts.repo}`,
+            number: parts.number,
+          })
+          return undefined
+        }
         if (parts && identity && eligible && this.#mount.githubRead) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/orchestrator/factory.ts` around lines 4758 - 4806, Handle the
eligible-identity case where this.#mount.githubRead is unavailable before the
confirmed-miss path in the GitHub issue fallback flow. In that branch, avoid
incrementing githubIssuePhantomSkipped or logging that provider lookup occurred;
instead preserve the unavailable-provider distinction using the existing
fallback outcome signaling used by this block.
🧹 Nitpick comments (1)
src/orchestrator/factory.test.ts (1)

6736-6738: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Set explicit timeouts on these vi.waitFor calls.

Both waits use the default Vitest timeout, while the later waits in the same test use 4_000. The PR notes timing-sensitive suite failures. Aligning the timeouts reduces that risk at no cost.

♻️ Proposed change
-      await vi.waitFor(() => expect(attempts).toBe(1))
+      await vi.waitFor(() => expect(attempts).toBe(1), { timeout: 4_000 })
       await vi.waitFor(async () => expect(await state().getDispatchLifecycle('factory-test', issueKey(decision.issue)))
-        .toMatchObject({ phase: 'publishing' }))
+        .toMatchObject({ phase: 'publishing' }), { timeout: 4_000 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/orchestrator/factory.test.ts` around lines 6736 - 6738, Set an explicit
4,000 ms timeout on both vi.waitFor calls in the test, including the attempts
assertion and the dispatch lifecycle publishing assertion, matching the later
waits in the same test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/orchestrator/factory.test.ts`:
- Around line 6732-6764: Update the retry assertion in the test around
first.stop(), providerReady, and restarted.start() to capture the attempts count
immediately after the first process stops, then require the restarted process to
increase that baseline rather than asserting an absolute value of 2. Keep the
subsequent lifecycle assertion unchanged so the test still verifies publishing
completed after restart.

---

Outside diff comments:
In `@src/orchestrator/factory.ts`:
- Around line 2434-2436: Update the dispatch flow around `#dispatchUnlocked` so
issueResolution is added to a new DispatchResult copy rather than mutating the
shared result. Enrich the result inside the shared wrapper promise used for
duplicate suppression, ensuring coalesced callers receive the enriched copy
instead of returning inFlight directly.
- Around line 4758-4806: Handle the eligible-identity case where
this.#mount.githubRead is unavailable before the confirmed-miss path in the
GitHub issue fallback flow. In that branch, avoid incrementing
githubIssuePhantomSkipped or logging that provider lookup occurred; instead
preserve the unavailable-provider distinction using the existing fallback
outcome signaling used by this block.

---

Nitpick comments:
In `@src/orchestrator/factory.test.ts`:
- Around line 6736-6738: Set an explicit 4,000 ms timeout on both vi.waitFor
calls in the test, including the attempts assertion and the dispatch lifecycle
publishing assertion, matching the later waits in the same test.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 253c0c1c-bb5a-40f0-9726-aefcb16408af

📥 Commits

Reviewing files that changed from the base of the PR and between 3eb01ca and 793f252.

📒 Files selected for processing (6)
  • src/cli/fleet.test.ts
  • src/cli/fleet.ts
  • src/mount/github-api-issue-read.test.ts
  • src/mount/github-api-issue-read.ts
  • src/orchestrator/factory.test.ts
  • src/orchestrator/factory.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/mount/github-api-issue-read.ts
  • src/mount/github-api-issue-read.test.ts
  • src/cli/fleet.test.ts
  • src/cli/fleet.ts

Comment thread src/orchestrator/factory.test.ts
attempts incremented before the providerReady gate in both the round-4
API-fallback-publishing red check and its pre-existing Linear sibling
("takes over a persisted publishing phase after the owner stops"), so
a failed publish attempt counted regardless of why it failed. The
still-running first process's 1s dispatch-lifecycle retry timer
(DISPATCH_LIFECYCLE_RETRY_MS) can fire another failing attempt in the
window between the 'publishing' phase check and first.stop() resolving,
which could push the API-fallback test's attempts to 2 before the
restarted process ever read the issue — satisfying the exact absolute
count the test asserted without exercising the behavior it exists to
prove. The sibling's primary gate (restarted.status().counters.done)
is race-immune, but its trailing exact-count assertion was vulnerable
to the same race in the opposite direction (a spurious failure if an
extra pre-restart retry occurred).

Both now baseline attempts immediately after first.stop() and assert
the restarted process strictly increased it, rather than asserting an
absolute count — correct regardless of how many retries occurred
before the restart.

Confirmed the corrected API-fallback test still red-checks: reverted
#readGithubIssue to the pre-derivation (registration-only) design and
reran — fails on attempts never exceeding the pre-restart baseline,
timing out as intended. Restored the fix; passes, stable across
repeated runs.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/orchestrator/factory.ts Outdated
…ifications and lifecycles

A restarted fallback-backed clarification cancelled a valid human reply
instead of resuming: #clarificationIssueStillActive (which gates whether
a wake resumes or is cancelled) reads the issue via #readIssue with no
hint, and #deriveGithubApiFallbackEligibility only scanned
batch.inFlight. The clarification restore path
(#restoreClarifications-adjacent code near #resumeWaitingClarification)
rebuilds an InFlightIssue locally from the persisted decision purely to
re-arm the Slack watcher — it never inserts it into the batch, so the
in-flight scan alone missed it, the read failed, the issue was treated
as having left factory scope, and the wake was cancelled
(clarificationWakesCancelledStaleIssue) — silently discarding the
human's answer.

Confirmed the stated limitation from 793f252 ("a durably-restored
record is always in the batch") is false specifically for this path:
a decision can durably outlive the original dispatch call in (at
least) three distinct shapes, not one. Widened
#deriveGithubApiFallbackEligibility to gather candidates from all
three: batch.inFlight (existing), #state.listWaitingClarifications
(new — the clarification-parked case), and
#state.listDispatchLifecycles filtered to non-terminal (new — a
durable dispatch not currently reflected in the in-memory batch at
all, e.g. a non-durable-fleet dispatch never reaches the batch
either). decisionHint remains a pure optimization ahead of the scan,
now also passed at the one clarification call site that already holds
its decision (#clarificationIssueStillActive, from
#resumeWaitingClarification) — cheap, but not required for
correctness, since 20-odd other read sites still pass none and rely
entirely on the scan.

isGithubApiFallbackEligible's candidate parameter was already
source-agnostic (generalized from round 4's "inFlight" naming); added
two small pure mapping functions
(githubApiFallbackCandidatesFromWaitingClarifications,
githubApiFallbackCandidatesFromDispatchLifecycles, the latter
excluding terminal lifecycles) so the gathering logic for the two new
sources is directly unit-tested in isolation, not only through the
full-process red check.

Red check: parks a fallback-backed GitHub clarification, restarts with
an empty in-memory cache, delivers a human reply directly through the
durable state store, and asserts the team resumes
(clarificationTeamsWoken reaches 1) rather than the wake being
cancelled. Confirmed failing before this fix (the counter never
increments, hand-reverted to the round-5 in-flight-only derivation)
and passing after, stable across 5 repeated runs.

Guard tests: a fallback-backed decision reachable from ONLY the
waiting-clarifications source, and from ONLY a non-terminal dispatch
lifecycle, both resolve eligible; a terminal (complete/abandoned)
lifecycle is excluded from candidates entirely, individually and
alongside a non-terminal one for a different issue.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants