Skip to content

dispatch <number> can't resolve GitHub issues: resolver reads one page of listTree; issueSource auto-detect silently picks Linear #81

Description

@khaliqgant

Summary

factory dispatch <number> cannot resolve a GitHub issue in any workspace with a non-trivial mount, because the resolver lists /github/repos once and filters a single page. mount.listTree is paginated; on a real workspace the first page never reaches the target repo, so it throws found 0 matches for an issue that is demonstrably in the mount.

This forces every user to pass the full mount path instead of the issue number.

Repro (factory 0.1.17, 2026-07-16)

$ cat factory.config.json
{ "issueSource": "github",
  "repos": { "org": "AgentWorkforce", "names": ["hoopsheet"],
             "cloneRoot": "/Users/…/AgentWorkforce", "default": "AgentWorkforce/hoopsheet" }, … }

$ factory dispatch 11 --dry-run
Unable to resolve GitHub issue 11: found 0 matches

# but the issue IS in the mount:
$ relayfile ls /github/repos/AgentWorkforce__hoopsheet/issues/by-id/ | grep '/11.json'
file /github/repos/AgentWorkforce__hoopsheet/issues/by-id/11.json  provider=github  size=836  rev=rev_1245477

# and the full path resolves fine:
$ factory dispatch /github/repos/AgentWorkforce__hoopsheet/issues/by-id/11.json --dry-run
{ "issue": { "uuid": "AgentWorkforce/hoopsheet#11", "key": "11", … },
  "agents": [ { "name": "ar-11-impl-hoopsheet", "role": "implementer" },
              { "name": "ar-11-review", "role": "reviewer" } ], "dryRun": true }

Root cause

findIssuePath (src/cli/fleet.ts:722):

const matches = (await mount.listTree('/github/repos'))
  .filter((path) => path.endsWith('.json'))
  .filter((path) => { const parts = githubIssuePathParts(path);  })
if (matches.length === 0) throw new Error(`Unable to resolve GitHub issue ${key}: found 0 matches`)

listTree is paginated — it returns a page plus a cursor, and the call site consumes neither. Observed on this workspace:

$ relayfile ls /github/repos
Tree /github/repos
dir  /github/repos/AgentWorkforce  rev=dir
next cursor: /github/repos/AgentWorkforce/agents/issues/36__fix-pr-reviewer-…/comments/4616124226.json

The tree spans every repo, issue and comment in the workspace, so page 1 is still inside another repo's comment threads. hoopsheet/issues/by-id/11.json is never in the filtered set.

Note the Linear branch a few lines below lists the far narrower /linear/issues/ — which is why this only bites GitHub.

Fix

  • Paginate listTree to exhaustion (or until a match is found), or
  • Construct the path directly — with issueSource: 'github' and repos.default (or a single repos.names entry) the path is deterministic: /github/repos/{owner}__{repo}/issues/by-id/{number}.json. Read it and fall back to a scan only if absent. This is O(1) instead of walking the entire workspace tree, and is strictly better for the common single-repo case.
  • Either way, scope the listing to /github/repos/{owner}__{repo}/issues/by-id/ rather than the whole /github/repos tree.

Also worth checking: the mount appears to contain two path shapes/github/repos/AgentWorkforce__hoopsheet/issues/by-id/11.json (owner__repo) and /github/repos/AgentWorkforce/agents/issues/36__… (owner/repo). Confirm githubIssuePathParts handles both deliberately, and that githubIssuePathPreference is ordering them the way you intend.

Also: issueSource auto-detect silently picks Linear for GitHub repos

Related and worth fixing together. issueSource is .optional() with no default (src/config/schema.ts:151), and auto-detect (src/cli/fleet.ts:629) does:

const linearReady = await mount.ensureSubRoot('/linear/issues', { timeoutMs: 90_000 })
if (linearReady !== 'ready') { config.issueSource = 'github' } else { config.issueSource = 'linear' }

So in a workspace with both Linear and GitHub connected — which is the normal case here — a GitHub-only factory config silently resolves as Linear. factory dispatch 37 then searches /linear/issues/ for a key 37 and reports the generic Unable to resolve issue 37: found 0 matches, which gives no hint that it looked in the wrong system entirely. It also pays a 90s ensureSubRoot probe to get there.

Suggestions:

  • Infer from config before probing the mount: if repos.org/repos.default is set and no Linear-specific config exists, prefer github.
  • Make the error name the source it searched: "Unable to resolve issue 37 in Linear (issueSource=linear, auto-detected); set issueSource: 'github' if these are GitHub issues." The current message is the same string for both branches minus one word, which is what made this take a while to spot.

Impact

Every GitHub user must pass /github/repos/{org}__{repo}/issues/by-id/{n}.json instead of 11. factory-e2e-demo/run.sh already constructs that path by hand (ISSUE_PATH_ROOT="/github/repos/${ORG}__${REPO}/issues/by-id"), which is the tell — the demo works around this rather than using the ergonomic form the CLI advertises (dispatch <KEY|path>).

Related: #78 (config ergonomics — tilde expansion + cwd default). Same theme: the minimum viable local invocation is harder than it needs to be.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions