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.
Summary
factory dispatch <number>cannot resolve a GitHub issue in any workspace with a non-trivial mount, because the resolver lists/github/reposonce and filters a single page.mount.listTreeis paginated; on a real workspace the first page never reaches the target repo, so it throwsfound 0 matchesfor 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)
Root cause
findIssuePath(src/cli/fleet.ts:722):listTreeis paginated — it returns a page plus a cursor, and the call site consumes neither. Observed on this workspace: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.jsonis 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
listTreeto exhaustion (or until a match is found), orissueSource: 'github'andrepos.default(or a singlerepos.namesentry) 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./github/repos/{owner}__{repo}/issues/by-id/rather than the whole/github/repostree.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). ConfirmgithubIssuePathPartshandles both deliberately, and thatgithubIssuePathPreferenceis ordering them the way you intend.Also:
issueSourceauto-detect silently picks Linear for GitHub reposRelated and worth fixing together.
issueSourceis.optional()with no default (src/config/schema.ts:151), and auto-detect (src/cli/fleet.ts:629) does: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 37then searches/linear/issues/for a key37and reports the genericUnable to resolve issue 37: found 0 matches, which gives no hint that it looked in the wrong system entirely. It also pays a 90sensureSubRootprobe to get there.Suggestions:
repos.org/repos.defaultis set and no Linear-specific config exists, prefergithub.Impact
Every GitHub user must pass
/github/repos/{org}__{repo}/issues/by-id/{n}.jsoninstead of11.factory-e2e-demo/run.shalready 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.