Skip to content

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

Open
khaliqgant wants to merge 8 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 8 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 or unavailable. Projection remains preferred; healthy misses still fail without calling GitHub.

  • Bug Fixes

    • Added a targeted GitHub API fallback used only after a projection miss and when health shows the projection cannot answer (degraded local mount, listener not subscribed/polling, or GitHub connection not ready).
    • Kept projection-first behavior; supports repo-qualified selectors like repo#number for a single authoritative lookup; treats PRs from the issues endpoint as misses; errors on ambiguous multi-repo matches.
    • Triage/dispatch now include issueResolution; dispatch re-reads fallback issues via the provider and applies the same scope/readiness/label gates; dispatch comments note the resolution source.
    • CLI preflight now warns if the GitHub projection isn’t ready but allows targeted triage/dispatch to continue when the fallback exists.
    • Introduced GithubApiIssueRead and wired it into the cloud mount for read-only lookups; all writes remain on Relayfile writeback; added focused tests across CLI, mount, and orchestrator.
  • Migration

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

Written for commit cff7303. 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

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a1628fbc-d476-4c7b-8b90-d720aae33694

📥 Commits

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

📒 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
✨ 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 👍 / 👎.

Comment thread src/cli/fleet.ts
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 👍 / 👎.

@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.

5 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/orchestrator/factory.ts">

<violation number="1" location="src/orchestrator/factory.ts:2432">
P1: Relay lifecycle recovery cannot re-read an API-fallback issue after Factory restarts, so it throws “issue is not currently readable” while the projection remains stale. Restore fallback eligibility from `record.decision.issueResolution` before recovery reads, rather than relying solely on this process-local set.</violation>

<violation number="2" location="src/orchestrator/factory.ts:2432">
P3: The new `#githubApiFallbackIssues` set is append-only and never pruned. Every GitHub-API-fallback dispatch permanently adds an issue identity for the lifetime of the FactoryLoop. In the long-running daemon (`factory start --mode live`) this set grows without bound and, because it is consulted on every `#readGithubIssue` missing-file path, it also keeps those identities eligible for re-fetch through `githubRead` indefinitely. Consider bounding it (e.g. track per-issue with an LRU, clear after terminal dispatch, or scope it to the current run) so stale fallback records don't accumulate for the process lifetime.</violation>
</file>

<file name="src/cli/fleet.test.ts">

<violation number="1" location="src/cli/fleet.test.ts:761">
P3: The test name contradicts its assertions: it is titled as if the GitHub API fallback resolves the issue, yet it verifies the projection is preferred (`source: relayfile-projection`, and `githubRead.getIssue` is not called). Rename it to reflect that a populated projection stays primary even when the connection is not ready (e.g. 'keeps a populated projection preferred over the API fallback when the connection is not ready').</violation>
</file>

<file name="src/mount/github-api-issue-read.ts">

<violation number="1" location="src/mount/github-api-issue-read.ts:44">
P2: This fallback makes unauthenticated GETs to api.github.com (no Authorization header/token is attached), and treats any HTTP 404 as an *authoritative* issue miss. For private repositories — which is precisely the org-wide Factory scenario this PR targets — GitHub deliberately returns 404 for unauthenticated access to hide existence, so a real issue in a private repo would be reported as 'not found' and the phantom-skip path would silently drop it. Since the object is described as 'provider-authoritative' / 'authoritative 404', this misclassification is misleading. Consider distinguishing a truly-missing issue from an inaccessible (private) repo, or degrading to a non-authoritative 'cannot determine' rather than a confident miss.</violation>
</file>

<file name="src/cli/fleet.ts">

<violation number="1" location="src/cli/fleet.ts:1878">
P2: parseGithubIssueSelector validates qualified `repo#number` selectors against `configuredGithubIssueRepos(config)`, which collapses to only `repos.default` when that option is set. This means a repo-qualified selector for any other configured route (present in `byLabel`, `byProject`, or `keywordRules`) is incorrectly rejected as unconfigured, even though it's a valid dispatch target. Qualified selectors should be validated against the full set of configured routes, not just the default.</violation>
</file>

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

Re-trigger cubic


if (decision.issueResolution?.source === 'github-api-fallback') {
const parts = githubIssuePathParts(decision.issue.path) ?? githubIssueDirectoryPathParts(decision.issue.path)
if (parts) this.#githubApiFallbackIssues.add(githubIssueIdentity(parts.owner, parts.repo, parts.number))

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: Relay lifecycle recovery cannot re-read an API-fallback issue after Factory restarts, so it throws “issue is not currently readable” while the projection remains stale. Restore fallback eligibility from record.decision.issueResolution before recovery reads, rather than relying solely on this process-local set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/orchestrator/factory.ts, line 2432:

<comment>Relay lifecycle recovery cannot re-read an API-fallback issue after Factory restarts, so it throws “issue is not currently readable” while the projection remains stale. Restore fallback eligibility from `record.decision.issueResolution` before recovery reads, rather than relying solely on this process-local set.</comment>

<file context>
@@ -2424,6 +2427,10 @@ export class FactoryLoop implements Factory {
 
+    if (decision.issueResolution?.source === 'github-api-fallback') {
+      const parts = githubIssuePathParts(decision.issue.path) ?? githubIssueDirectoryPathParts(decision.issue.path)
+      if (parts) this.#githubApiFallbackIssues.add(githubIssueIdentity(parts.owner, parts.repo, parts.number))
+    }
     const liveIssue = await this.#readIssue(decision.issue.path)
</file context>

},
},
)
if (response.status === 404) return undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This fallback makes unauthenticated GETs to api.github.com (no Authorization header/token is attached), and treats any HTTP 404 as an authoritative issue miss. For private repositories — which is precisely the org-wide Factory scenario this PR targets — GitHub deliberately returns 404 for unauthenticated access to hide existence, so a real issue in a private repo would be reported as 'not found' and the phantom-skip path would silently drop it. Since the object is described as 'provider-authoritative' / 'authoritative 404', this misclassification is misleading. Consider distinguishing a truly-missing issue from an inaccessible (private) repo, or degrading to a non-authoritative 'cannot determine' rather than a confident miss.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mount/github-api-issue-read.ts, line 44:

<comment>This fallback makes unauthenticated GETs to api.github.com (no Authorization header/token is attached), and treats any HTTP 404 as an *authoritative* issue miss. For private repositories — which is precisely the org-wide Factory scenario this PR targets — GitHub deliberately returns 404 for unauthenticated access to hide existence, so a real issue in a private repo would be reported as 'not found' and the phantom-skip path would silently drop it. Since the object is described as 'provider-authoritative' / 'authoritative 404', this misclassification is misleading. Consider distinguishing a truly-missing issue from an inaccessible (private) repo, or degrading to a non-authoritative 'cannot determine' rather than a confident miss.</comment>

<file context>
@@ -0,0 +1,116 @@
+        },
+      },
+    )
+    if (response.status === 404) return undefined
+    if (!response.ok) {
+      throw new Error(`GitHub API issue lookup failed (HTTP ${response.status})`)
</file context>

Comment thread src/cli/fleet.ts
if (!qualified) return { number }

const requested = qualified[1]!
const configured = configuredGithubIssueRepos(config)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: parseGithubIssueSelector validates qualified repo#number selectors against configuredGithubIssueRepos(config), which collapses to only repos.default when that option is set. This means a repo-qualified selector for any other configured route (present in byLabel, byProject, or keywordRules) is incorrectly rejected as unconfigured, even though it's a valid dispatch target. Qualified selectors should be validated against the full set of configured routes, not just the default.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/fleet.ts, line 1878:

<comment>parseGithubIssueSelector validates qualified `repo#number` selectors against `configuredGithubIssueRepos(config)`, which collapses to only `repos.default` when that option is set. This means a repo-qualified selector for any other configured route (present in `byLabel`, `byProject`, or `keywordRules`) is incorrectly rejected as unconfigured, even though it's a valid dispatch target. Qualified selectors should be validated against the full set of configured routes, not just the default.</comment>

<file context>
@@ -1709,6 +1782,116 @@ async function findIssuePath(mount: MountClient, key: string, config: FactoryCon
+  if (!qualified) return { number }
+
+  const requested = qualified[1]!
+  const configured = configuredGithubIssueRepos(config)
+  const expanded = requested.includes('/')
+    ? requested
</file context>


if (decision.issueResolution?.source === 'github-api-fallback') {
const parts = githubIssuePathParts(decision.issue.path) ?? githubIssueDirectoryPathParts(decision.issue.path)
if (parts) this.#githubApiFallbackIssues.add(githubIssueIdentity(parts.owner, parts.repo, parts.number))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The new #githubApiFallbackIssues set is append-only and never pruned. Every GitHub-API-fallback dispatch permanently adds an issue identity for the lifetime of the FactoryLoop. In the long-running daemon (factory start --mode live) this set grows without bound and, because it is consulted on every #readGithubIssue missing-file path, it also keeps those identities eligible for re-fetch through githubRead indefinitely. Consider bounding it (e.g. track per-issue with an LRU, clear after terminal dispatch, or scope it to the current run) so stale fallback records don't accumulate for the process lifetime.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/orchestrator/factory.ts, line 2432:

<comment>The new `#githubApiFallbackIssues` set is append-only and never pruned. Every GitHub-API-fallback dispatch permanently adds an issue identity for the lifetime of the FactoryLoop. In the long-running daemon (`factory start --mode live`) this set grows without bound and, because it is consulted on every `#readGithubIssue` missing-file path, it also keeps those identities eligible for re-fetch through `githubRead` indefinitely. Consider bounding it (e.g. track per-issue with an LRU, clear after terminal dispatch, or scope it to the current run) so stale fallback records don't accumulate for the process lifetime.</comment>

<file context>
@@ -2424,6 +2427,10 @@ export class FactoryLoop implements Factory {
 
+    if (decision.issueResolution?.source === 'github-api-fallback') {
+      const parts = githubIssuePathParts(decision.issue.path) ?? githubIssueDirectoryPathParts(decision.issue.path)
+      if (parts) this.#githubApiFallbackIssues.add(githubIssueIdentity(parts.owner, parts.repo, parts.number))
+    }
     const liveIssue = await this.#readIssue(decision.issue.path)
</file context>

Comment thread src/cli/fleet.test.ts
}
})

it('allows targeted GitHub resolution through the API seam when the connected projection is not ready', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The test name contradicts its assertions: it is titled as if the GitHub API fallback resolves the issue, yet it verifies the projection is preferred (source: relayfile-projection, and githubRead.getIssue is not called). Rename it to reflect that a populated projection stays primary even when the connection is not ready (e.g. 'keeps a populated projection preferred over the API fallback when the connection is not ready').

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/fleet.test.ts, line 761:

<comment>The test name contradicts its assertions: it is titled as if the GitHub API fallback resolves the issue, yet it verifies the projection is preferred (`source: relayfile-projection`, and `githubRead.getIssue` is not called). Rename it to reflect that a populated projection stays primary even when the connection is not ready (e.g. 'keeps a populated projection preferred over the API fallback when the connection is not ready').</comment>

<file context>
@@ -743,6 +758,93 @@ describe('fleet CLI runtime', () => {
     }
   })
 
+  it('allows targeted GitHub resolution through the API seam when the connected projection is not ready', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'fleet-cli-integration-github-fallback-'))
+    try {
</file context>
Suggested change
it('allows targeted GitHub resolution through the API seam when the connected projection is not ready', async () => {
it('keeps a populated projection preferred over the API fallback when the connection is not ready', async () => {

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.

1 participant