Skip to content

fix(factory): resolve Linear states without pinned IDs#45

Merged
khaliqgant merged 1 commit into
mainfrom
fix/name-based-linear-state-resolution
Jun 24, 2026
Merged

fix(factory): resolve Linear states without pinned IDs#45
khaliqgant merged 1 commit into
mainfrom
fix/name-based-linear-state-resolution

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jun 24, 2026

Copy link
Copy Markdown
Member

Summary

  • add the Linear states catalog path to default relayfile read scopes
  • let state resolution fall back to synced Linear issue state data when /linear/states is absent
  • keep optional humanReview unresolved when missing, while preserving ambiguity errors

Verification

  • npm test
  • npm run build
  • live config verification with zero configured stateIds/teamIds resolved all AR roles

Summary by cubic

Resolve Linear state names without pinned IDs. Prefer the /linear/states catalog, and fall back to synced /linear/issues data when it's not readable, with bounded scanning.

  • Bug Fixes
    • Added relayfile:fs:read:/linear/states/** to default factory read scopes.
    • When /linear/states/_index.json is unavailable, derive state IDs from /linear/issues payloads; cap scanning at 150 files and stop early once all configured names are found.
    • Optional roles: with a readable catalog, misspelled names now error; with an absent or issue-derived fallback catalog, they remain unresolved; ambiguity still errors.

Written for commit 8ff01e3. Summary will update on new commits.

Review in cubic

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

More reviews will be available in 51 minutes and 8 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

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

🚦 How do rate 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 see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4b2b2dbe-b1e8-4625-b519-f0dd9cccaab6

📥 Commits

Reviewing files that changed from the base of the PR and between b229ff4 and 8ff01e3.

📒 Files selected for processing (4)
  • src/linear/state-resolver.test.ts
  • src/linear/state-resolver.ts
  • src/mount/relayfile-cloud-mount-client.test.ts
  • src/mount/relayfile-cloud-mount-client.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/name-based-linear-state-resolution

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.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements a fallback mechanism to reconstruct the Linear state catalog from synced issue files when the states catalog is absent, updating the reader interface, tests, and cloud mount scopes accordingly. The reviewer feedback highlights a potential performance bottleneck when scanning all issue files sequentially, recommending capping the scan limit and stopping early once target states are found. Additionally, the reviewer notes a regression where misspelled optional states are silently ignored, suggesting tracking whether the catalog is a fallback to safely handle missing optional states without swallowing configuration errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +182 to +223
async recordsFromIssues(): Promise<StateRecord[]> {
if (!this.reader.listTree) return []
let paths: string[]
try {
paths = await this.reader.listTree('/linear/issues')
} catch {
return []
}

const byKey = new Map<string, StateRecord>()
const issuePaths = paths.filter((path) =>
path.endsWith('.json') &&
!path.includes('/comments/') &&
!path.startsWith('/linear/issues/by-uuid/'),
)
for (let i = 0; i < issuePaths.length; i += CATALOG_READ_CONCURRENCY) {
const batch = await Promise.all(
issuePaths.slice(i, i + CATALOG_READ_CONCURRENCY).map(async (path): Promise<StateRecord | undefined> => {
try {
const payload = unwrap((await this.reader.readFile(path)).content)
const state = asRecord(payload.state)
const id = str(payload.stateId) ?? str(state?.id)
const name = str(state?.name)
if (!id || !name) return undefined
return {
id,
name,
teamKey: str(asRecord(payload.team)?.key) ?? str(payload.teamKey),
teamName: str(asRecord(payload.team)?.name) ?? str(payload.teamName),
}
} catch {
return undefined
}
}),
)
for (const record of batch) {
if (!record) continue
byKey.set(`${norm(record.teamKey) || norm(record.teamName)}:${norm(record.name)}:${record.id}`, record)
}
}
return [...byKey.values()]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Performance Bottleneck in Issue Fallback Scanning

Reading every single issue file sequentially in batches of 10 over a remote/cloud mount can cause severe performance degradation, startup timeouts, or rate limits if the workspace contains thousands of issues.

To optimize this, we can:

  1. Pass the set of configured target state names to StateCatalog and stop scanning issues early as soon as we have found a state ID for every target name.
  2. Cap the maximum number of issues scanned (e.g., 150) to prevent scanning the entire history if a configured state is never used in any issue.
  async recordsFromIssues(targetNames?: Set<string>): Promise<StateRecord[]> { 
    if (!this.reader.listTree) return []
    let paths: string[]
    try {
      paths = await this.reader.listTree('/linear/issues')
    } catch {
      return []
    }

    const byKey = new Map<string, StateRecord>()
    const issuePaths = paths.filter((path) =>
      path.endsWith('.json') &&
      !path.includes('/comments/') &&
      !path.startsWith('/linear/issues/by-uuid/'),
    )
    const foundNames = new Set<string>()
    const maxIssuesToScan = 150
    for (let i = 0; i < issuePaths.length && i < maxIssuesToScan; i += CATALOG_READ_CONCURRENCY) {
      if (targetNames && targetNames.size > 0 && foundNames.size >= targetNames.size) {
        break
      }
      const batch = await Promise.all(
        issuePaths.slice(i, i + CATALOG_READ_CONCURRENCY).map(async (path): Promise<StateRecord | undefined> => {
          try {
            const payload = unwrap((await this.reader.readFile(path)).content)
            const state = asRecord(payload.state)
            const id = str(payload.stateId) ?? str(state?.id)
            const name = str(state?.name)
            if (!id || !name) return undefined
            return {
              id,
              name,
              teamKey: str(asRecord(payload.team)?.key) ?? str(payload.teamKey),
              teamName: str(asRecord(payload.team)?.name) ?? str(payload.teamName),
            }
          } catch {
            return undefined
          }
        }),
      )
      for (const record of batch) {
        if (!record) continue
        byKey.set(norm(record.teamKey) || norm(record.teamName) + ":" + norm(record.name) + ":" + record.id, record)
        if (record.name) {
          foundNames.add(norm(record.name))
        }
      }
    }
    return [...byKey.values()]
  }

Comment thread src/linear/state-resolver.ts Outdated
Comment on lines +297 to +299
if (!REQUIRED_ROLES.includes(role) && !/ambiguous/u.test(error instanceof Error ? error.message : String(error))) {
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.

high

Regression: Misspelled Optional States Silently Ignored

Using !/ambiguous/u.test(...) to catch non-ambiguous errors will silently return undefined even when the catalog is healthy but the optional state name is misspelled (e.g., a typo in humanReview). This silently disables the optional role instead of failing startup with a clear error.

We should only tolerate missing optional states if the catalog is completely unavailable (CatalogUnavailableError) or if we had to fall back to reconstructing it from issues (catalog.isFallback).

        if (!REQUIRED_ROLES.includes(role)) {
          if (error instanceof CatalogUnavailableError) {
            return undefined
          }
          if (catalog.isFallback && !/ambiguous/u.test(error instanceof Error ? error.message : String(error))) {
            return undefined
          }
        }

Comment thread src/linear/state-resolver.ts Outdated
Comment on lines 138 to 140
class StateCatalog {
#records: StateRecord[] | undefined
constructor(private readonly reader: LinearStateReader) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Track Fallback State

We need to track whether the catalog was loaded from the official /linear/states/_index.json or reconstructed from issues, so we can safely decide whether to tolerate missing optional states.

class StateCatalog {
  #records: StateRecord[] | undefined
  isFallback = false
  constructor(
    private readonly reader: LinearStateReader,
    private readonly targetNames?: Set<string>,
  ) {}

Comment on lines +148 to +152
const issueRecords = await this.recordsFromIssues()
if (issueRecords.length > 0) {
this.#records = issueRecords
return issueRecords
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Set Fallback Flag

Mark the catalog as fallback when we successfully reconstruct it from issues.

      const issueRecords = await this.recordsFromIssues(this.targetNames)
      if (issueRecords.length > 0) {
        this.isFallback = true
        this.#records = issueRecords
        return issueRecords
      }

@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: 2fa7df1702

ℹ️ 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".

const payload = unwrap((await this.reader.readFile(path)).content)
const state = asRecord(payload.state)
const id = str(payload.stateId) ?? str(state?.id)
const name = str(state?.name)

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 Badge Preserve top-level state_name in issue fallback

When /linear/states/_index.json is absent and the fallback harvests state records from /linear/issues, synced issues that carry stateId/state.id plus top-level state_name are ignored because name only reads state.name. That sparse shape is already accepted by parseLinearIssue, so these mounts still fail startup with missing roles even though each issue contains the state id/name needed for resolution; include payload.state_name as a fallback here.

Useful? React with 👍 / 👎.

Comment thread src/linear/state-resolver.ts Outdated
// An OPTIONAL role (e.g. humanReview) tolerates an absent or incomplete
// catalog. Ambiguity still surfaces because silently picking one of many
// matching optional states would be a real misconfiguration.
if (!REQUIRED_ROLES.includes(role) && !/ambiguous/u.test(error instanceof Error ? error.message : String(error))) {

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 Badge Keep missing human-review states loud

In a workspace where /linear/states is readable but the configured humanReview name is misspelled or absent for a subscribed team, catalog.resolve() throws No Linear workflow state...; this new predicate treats that the same as an incomplete fallback catalog and returns undefined. Because hasHumanReview() then becomes false, terminalState: human-review silently falls back to Done instead of surfacing the misconfiguration; the previous CatalogUnavailableError guard only tolerated an unreadable catalog.

Useful? React with 👍 / 👎.

@agent-relay-code

Copy link
Copy Markdown
Contributor

ℹ️ pr-reviewer: review only — no file changes were applied to the PR (nothing to commit after review). The notes below are advisory and were not pushed.

Review: PR #45fix/name-based-linear-state-resolution

Summary of changes

The PR adds a fallback path so Linear workflow-state names can be resolved even when the /linear/states catalog is unreadable, by deriving state records from synced issue payloads:

  • src/linear/state-resolver.ts: LinearStateReader gains an optional listTree?(); StateCatalog.records() falls back to a new recordsFromIssues() when _index.json can't be read; adds asRecord helper; broadens the optional-role error tolerance in resolveRole.
  • src/mount/relayfile-cloud-mount-client.ts: adds relayfile:fs:read:/linear/states/** to FACTORY_RELAYFILE_SCOPES.
  • Test additions in both corresponding .test.ts files.

Verification

Ran the full CI pipeline (.github/workflows/ci.yml) end-to-end against the current checkout:

  • npm ci — ok
  • npm run build (tsc + tsc-alias) — ok, no type errors
  • npm test (vitest) — 548 tests passed (32 files)
  • npm pack --dry-run — ok

Traced impact across callers: the production reader passed to resolveFactoryStates is a MountClient (src/cli/fleet.ts:537), whose interface (src/ports/mount.ts) requires listTree, and both RelayfileCloudMountClient (src/mount/relayfile-cloud-mount-client.ts:277) and the test FakeMountClient implement it. So the new fallback is reachable in production and the optional-listTree guard (state-resolver.ts:183) correctly no-ops for minimal mock readers. No type or build breakage downstream of the changed files.

No auto-editable issues found (no lint/format/typo/import-order problems). I made no file edits.

Findings (for human judgment — left unchanged)

1. Optional-role error tolerance is broadened from "catalog unavailable" to "any non-ambiguous error" (state-resolver.ts:294-299).
Before, an optional role (e.g. humanReview) was silently dropped only when the catalog was entirely unavailable (CatalogUnavailableError); a genuine no-match (No Linear workflow state named "X") still threw. Now, only an ambiguous error throws — a plain no-match for an optional configured name resolves to undefined, silently disabling the role. This is a loosening of validation: a typo'd humanReview name would no longer fail startup. It is consistent with the PR's intent (the issue-derived catalog may legitimately be partial, so an optional name may not appear yet) and does not affect required roles (REQUIRED_ROLES still throw). It is not turning a fail-closed default into fail-open. I'm flagging it because it changes validation strictness for optional roles and is a judgment call, not a mechanical fix — please confirm the intent is to tolerate optional-role no-matches, not just absent/partial catalogs. No test currently covers "optional role, name not found, catalog present-but-partial"; consider whether one is warranted (test authoring is left to you).

Addressed comments

  • No PR review or bot comments were provided in .workforce/ (only pr.diff, changed-files.txt, context.json), and context.json carries no review threads. Nothing to address; if comments exist on the PR they were not surfaced to this run.

Advisory Notes

  • idForName(stateName, teamToken?) and hasHumanReview(teamToken?) accept a teamToken argument that the resolveFactoryStates implementations ignore (only the global name/id maps are consulted). The new test asserts idForName('Done', 'AR') and hasHumanReview('AR') and passes only because the global and team maps coincide in that fixture. This is pre-existing (the interface signature and implementations predate this PR and are untouched by the diff), so it is out of scope for this PR — noting it for awareness, not changing it.
  • memory/ (untracked in the working tree) is unrelated local workspace state, not part of this PR; left untouched.

The PR is well-scoped, the full CI command passes locally with the current tree, and the one finding above needs a human decision on intended validation strictness rather than a code fix.

@khaliqgant khaliqgant force-pushed the fix/name-based-linear-state-resolution branch from 2fa7df1 to 8ff01e3 Compare June 24, 2026 09:28
@khaliqgant

Copy link
Copy Markdown
Member Author

Addressed the review feedback in 8ff01e3:

  • bounded issue-derived fallback scanning with ISSUE_FALLBACK_SCAN_LIMIT=150
  • early-stops fallback scanning once configured team/state lookups have been found
  • tracks whether the catalog came from /linear/states or from issue fallback
  • restores strict optional-state validation for a healthy /linear/states catalog, while tolerating missing optional states only when the catalog is unavailable or issue-derived fallback is incomplete
  • added regression coverage for typoed optional states, early-stop scanning, and scan cap behavior

Verification:

  • npm test -- src/linear/state-resolver.test.ts
  • npm test
  • npm run build

@khaliqgant khaliqgant merged commit 2ad0060 into main Jun 24, 2026
2 checks passed
@khaliqgant khaliqgant deleted the fix/name-based-linear-state-resolution branch June 24, 2026 09:31
@agent-relay-code

Copy link
Copy Markdown
Contributor

Review: PR #45fix/name-based-linear-state-resolution

Summary of the change

The PR adds an issue-derived fallback catalog to the Linear state resolver. When /linear/states/_index.json is unreadable, StateCatalog.recordsFromIssues() scans up to 150 synced issue payloads (/linear/issues/**, batched at concurrency 10) and reconstructs {id, name, teamKey, teamName} records from each issue's stateId/state/team fields. It also:

  • Adds the optional listTree? method to LinearStateReader.
  • Adds relayfile:fs:read:/linear/states/** to FACTORY_RELAYFILE_SCOPES (the resolver couldn't previously read the states catalog under the factory mount token — this is the actual root-cause fix).
  • Relaxes optional-role handling so a missing optional name (e.g. humanReview) against an incomplete fallback catalog is tolerated, while a healthy /linear/states catalog still fails on a typoed/missing optional name.

Verification (the way CI runs it)

CI (.github/workflows/ci.yml) runs npm cinpm run buildnpm testnpm pack --dry-run. I installed deps and ran all of them against the current checkout:

  • npm run build (tsc + tsc-alias): passed
  • npm test (vitest): 551 tests / 32 files passed
  • npm pack --dry-run: passed

No lint step exists in CI. Working tree is clean (build artifacts are gitignored; memory/ is pre-existing).

Safety assessment

  • Fail-closed preserved for required roles. Required roles (readyForAgent, agentImplementing, inPlanning, done) still throw when unresolved — including against the fallback catalog (confirmed by the "caps issue fallback scanning" test, which expects a throw). The relaxation at state-resolver.ts:339-342 is gated on !REQUIRED_ROLES.includes(role), so it cannot turn a required-role failure into a silent success.
  • The optional-role relaxation is a justified, narrow behavior change: an issue-derived catalog is inherently partial, so a "no match" for an optional name there isn't misconfiguration — but ambiguous errors still throw (isAmbiguousStateError guard), and a healthy catalog still surfaces typos (new test at state-resolver.test.ts:35).
  • Dedup key includes record.id (state-resolver.ts:240), so two same-named states keep distinct records and correctly trip the ambiguity guard rather than silently picking one.
  • No lifecycle/reaper/dispatch/in-flight/broker-ownership code is touched.
  • The new scope is read-only and additive; it does not widen write access.

I made no code edits — there is no mechanical (lint/format/typo/import-order) issue to fix, and the logic is sound and fully covered by passing tests. Changing anything would be unwarranted.

Addressed comments

  • No prior bot or human review comments exist for this PR (.workforce/context.json carries no review/comment data, and there is no comments file in .workforce/). Nothing to reconcile.

Advisory Notes

  • (Non-blocking, out of scope for this PR — do not fold in.) recordsFromIssues() calls this.reader.listTree('/linear/issues') without a trailing slash, whereas the fleet caller uses /linear/issues/ at cli/fleet.ts:612. Both resolve fine through the SDK and the production RelayfileCloudMountClient.listTree, so this is purely cosmetic consistency, not a defect.
  • (Advisory.) The early-break at state-resolver.ts:218 treats a name-matching teamless record as "found", which can stop the scan before a team-scoped record of the same name is seen. Because resolve() prefers team-scoped matches and the fallback is explicitly best-effort, this is acceptable; flagging only so a human is aware of the heuristic.

The PR is mechanically clean, semantically sound, builds, and passes the full test suite. No human-judgment decision is currently blocked and I cannot confirm post-harness CI/merge state from here, so I am not printing READY.

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