Skip to content

feat(ownership): make the native delegate the only agent ownership model - #31

Merged
muqsitnawaz merged 4 commits into
mainfrom
linear-delegate-ownership
Aug 6, 2026
Merged

feat(ownership): make the native delegate the only agent ownership model#31
muqsitnawaz merged 4 commits into
mainfrom
linear-delegate-ownership

Conversation

@muqsitnawaz

@muqsitnawaz muqsitnawaz commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Behavior changedelegate becomes the only thing that owns an issue; agent:<name> labels own nothing.

What was broken

linear tasks --agent <name> filtered on an agent:<name> label, and linear tasks --board grouped its columns by the same label. So an issue delegated to Claude through Linear's own delegation UI never appeared in Claude's queue, and an issue merely tagged agent:claude did — the tool disagreed with Linear about who owns the work.

What changed

Surface Before After
tasks --agent X issues labeled agent:X issues whose delegate is X
default view "unowned" issues with no agent:* label issues whose delegate is null
tasks --board columns agent:* label suffixes delegate names
unknown --agent empty list, exit 0 error + roster, exit 1
--label with --agent silently dropped applied

The unknown-agent change matters most for unattended callers: a drain read the silent empty list as "queue clear".

--label was suppressed only because ownership was itself a label and the two label filters fought. Ownership is a field now, so the two are orthogonal.

Also hardened save_config: a roster-cache refresh on a read path no longer kills the command when the config dir is unwritable (it warns and continues). setup still exits non-zero, because there persistence is the operation. This became reachable because a typo'd --agent forces a roster refresh.

New: linear migrate-agent-labels

One-time move off the labels. Dry run by default, --apply writes.

  • Sets the delegate from a resolvable agent:<name> label, then strips that label (other labels survive).
  • Never overwrites an existing delegate. An issue already delegated to a different agent than its label claims is reported CONFLICT and left byte-for-byte alone.
  • A label suffix that is not a delegatable agent — a machine name, a workflow flag — is reported UNRESOLVED and the label is kept.
  • Deletes each agent:* label only once nothing carries it.
  • Exits non-zero if anything was left behind, so a partial migration can't read as a clean one.

Run result

Run from this branch against the live workspace. Titles redacted — this repo is public. Full unredacted capture: yosemite-s0:/home/muqsit/.agents/artifacts/rush2272-cli-run.txt

=== linear --version ===
linear-cli 0.16.0

=== linear tasks --agent claude   (the filter is now the native delegate) ===
Cycle 23 -- 103 task(s)
  RUSH-953  Urgent  Review  19d overdue  Muqsit → Claude   <title redacted>
  RUSH-2172 Urgent  Doing       --       Bisma  → Claude   <title redacted>

=== linear tasks --agent nosuchagent   (fails loud instead of an empty queue) ===
Unknown agent 'nosuchagent'. Delegatable agents: Antigravity, Claude, Codex, Droid, Grok, Kimi, Linear, OpenClaw
exit=1

=== linear tasks --board   (columns are delegates, not agent:* labels) ===
Cycle 23 -- 334 task(s)
  @Antigravity (34)
    RUSH-265 Urgent  Todo  --  <title redacted>

=== linear migrate-agent-labels   (dry run, live workspace) ===
  UNRESOLVED RUSH-2228 agent:hold             'hold' is not a delegatable agent
  UNRESOLVED RUSH-1637 agent:yosemite-s0      'yosemite-s0' is not a delegatable agent
  UNRESOLVED RUSH-1595 agent:yosemite-s0      'yosemite-s0' is not a delegatable agent
  UNRESOLVED RUSH-1593 agent:yosemite-s0      'yosemite-s0' is not a delegatable agent
  UNRESOLVED RUSH-1590 agent:yosemite-s0      'yosemite-s0' is not a delegatable agent
Migration incomplete — resolve the entries above by hand, then re-run.
[DRY RUN] 5 issue(s) carry a legacy 'agent:*' label
  keep      label agent:hold — still in use by an unmigrated issue
  delete    label agent:mac-mini (0 uses after migration)
  keep      label agent:yosemite-s0 — still in use by an unmigrated issue
  delete    label agent:yosemite-s1 (0 uses after migration)
migrated=0 stripped=0 conflicts=0 unresolved=5
exit=1

=== python3 -m unittest ===
Ran 60 tests in 0.003s
OK

That dry run is the migration doing its job on a real workspace: every remaining agent:* label there is a machine name or a workflow flag, not an agent, so it refuses to guess and exits 1.

Tests

22 new tests, 60 total, all stdlib unittest like the rest of the file. They drive the real list_tasks, show_board, and cmd_migrate_agent_labels — only the two network edges (cycle resolution, the issue page) are substituted, so every line of ownership logic under test is shipping code. Covered: delegate filter, unowned rule, roster casing, --all, unknown-agent abort, --label composition, board grouping with an inert agent:* label present, and the migrate/strip/conflict/unresolved/dry-run/label-delete paths.

Ticket: RUSH-2272


Review round 2 (commit d54bdd9)

An independent non-author review found four blockers in migrate-agent-labels, each reproduced with a probe. All fixed:

Finding Fix
Two agent:* labels on one issue → two writes from one snapshot; the second resurrected the first's stripped label and silently overwrote the delegate, exit 0 Decisions are per issue, one write per issue; labels that disagree are a CONFLICT
Label deleted on a team-scoped scan, but list_team_labels also returns workspace-scoped labels → a label still owning another team's issues was destroyed Deletion gated on a workspace-wide use count (count_label_uses)
A failed issueUpdate was counted as both migrated and conflicted Counters report writes that landed; failures= added
A failed issueLabelDelete exited 0, contradicting the fail-loud claim in the docstring, README, and CHANGELOG It is a failure now

Two design corrections from the same review:

  • A dry run no longer exits 1. It used to, on any blocker — and since unresolved labels can never be cleared by this tool, --apply would have exited 1 forever with no path to a green run. A dry run is an inspection: exit 0, and name what needs a human. --apply still exits non-zero when anything is left behind.
  • The configured default agent only warns when the roster doesn't resolve. get_agents degrades to [] on a transient API error, so a users-query blip or an app rename bricked the most-used command. A typed --agent still exits 1. The filter compares delegate names case-insensitively either way, so the raw name still matches.

Nine new tests cover every branch above — 69 total, green:

$ python3 -m unittest
Ran 69 tests in 0.003s
OK

$ linear tasks --agent nosuchagent; echo $?     # typed typo
Unknown agent 'nosuchagent'. Delegatable agents: Antigravity, Claude, Codex, Droid, Grok, Kimi, Linear, OpenClaw
1
$ linear migrate-agent-labels; echo $?          # dry run, live workspace
[DRY RUN] 0 issue(s) carry a legacy 'agent:*' label
migrated=0 stripped=0 conflicts=0 unresolved=0 failures=0
Dry run only. Re-run with --apply to write.
0

The workspace now reports 0 agent:* labels — the migration ran for real (see the RUSH-2272 ticket for the full --apply transcript, including a CONFLICT that was correctly refused).

`linear tasks --agent <name>` filtered on an `agent:<name>` label and
`linear tasks --board` grouped its columns by the same label. An issue
delegated through Linear's own delegation UI never showed up in that agent's
queue, and an issue merely tagged `agent:claude` did. Both now read the native
`delegate` field; unowned means exactly `delegate` is null, and a leftover
`agent:*` label is an inert ordinary label.

Fallout of dropping the label model:

- An unknown `--agent` now resolves against the workspace agent roster and
  exits non-zero instead of printing an empty queue. An unattended drain read a
  silent empty list as 'queue clear'.
- `--label` composes with `--agent` again. It was suppressed only because
  ownership was itself a label and the two label filters fought.
- `save_config` no longer kills a read command when the config dir is
  unwritable; a roster cache refresh is not the operation. `setup` still
  exits non-zero, because there persistence IS the operation.

Adds `linear migrate-agent-labels` for the one-time move off the labels:
dry-run by default, never overwrites an existing delegate (reports CONFLICT),
never guesses at a suffix that is not a delegatable agent (reports UNRESOLVED),
deletes each `agent:*` label once nothing carries it, and exits non-zero if
anything was left behind.

RUSH-2272
@cursor

cursor Bot commented Aug 6, 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.

… counters

Non-author review found four blockers in migrate-agent-labels, all confirmed
with probes against the shipping code:

- An issue carrying two agent:* labels was written twice from the same
  pre-mutation snapshot. Because labelIds is a full replacement, the second
  write resurrected the first's stripped label and overwrote the delegate with
  no CONFLICT and exit 0. Decisions are per issue now: labels that disagree are
  a conflict, labels that agree produce one write.
- Label deletion was gated on a team-scoped issue scan, but list_team_labels
  also returns workspace-scoped labels. A label carrying ownership on another
  team's issues was deleted silently. Gated on a workspace-wide use count.
- A failed issueUpdate was appended to conflicts but left in planned, so one
  issue reported as both migrated and conflicted. Counters now report writes
  that landed.
- A failed issueLabelDelete exited 0, contradicting the fail-loud claim in the
  docstring, README, and CHANGELOG. It is a failure now.

Two design corrections from the same review:

- A dry run that found a blocker exited 1 and swallowed its own hint, and
  unresolved labels can never be cleared by this tool — so --apply exited 1
  forever with no path to green. A dry run is an inspection: it exits 0 and
  names what needs a human. --apply still exits non-zero when anything is left.
- The config-derived default agent no longer hard-exits when the roster does not
  resolve. get_agents degrades to [] on a transient error, so a users-query blip
  bricked the most-used command. A typed --agent still exits 1; the default
  warns and filters on the name as given, which matches anyway.

Nine new tests cover every branch above.
…l guard

Follow-up review caught a regression the previous round introduced, plus a
hazard neither round had checked:

- count_label_uses measured the LIVE state, but a dry run has written nothing
  yet — so every label the run planned to strip still had a carrier and was
  reported 'still carried by an issue outside this team'. Both halves were
  wrong: the preview documented in README went dead, and the reason named
  another team when the carrier was the issue listed three lines above. The
  gate now subtracts what this run clears, and the message distinguishes an
  unmigrated issue from a genuine outside carrier.
- The carrier query omitted includeArchived. Verified against the live API that
  Linear's issues connection excludes archived issues by default, so a label
  carried only by archived work counted 0 and would have been deleted.
- classify_agent_labels([]) raised IndexError. Unreachable from its one call
  site, but the docstring sells it as pure testable logic.
- The config-default fallback assigned the raw name where resolve_agent_name
  would have stripped it, so a config of ' claude ' matched nothing.
- A dry run whose carrier lookup failed still printed the resolve-the-conflicts
  hint, which named neither the API error nor anything a human could fix.

The test harness hardcoded label_uses=0 against a fixture whose issue still
carried the label — a state the real API cannot produce, and what let the
regression pass green. Carriers are now derived from the fixture the way the
API answers, and four tests cover the preview, the outside-carrier case, the
unmigrated-carrier case, and a lookup failure. 73 tests.
Final review found a blocker the previous fix introduced. label_carrier_ids
returned at most 200 ids with no truncation signal. That was safe while the
caller asked 'is anything left?' — 1 > 0 kept the label. It stopped being safe
when the gate became set subtraction: a PARTIAL page whose every entry happens
to be one this run clears subtracts to empty and reads as 'nothing carries it'.

Reproduced against the previous commit — 200 carriers in the scanned team, all
migrating, plus 50 on another team; the API returns the first 200; the label is
deleted, exit 0, no warning. Exactly the silent cross-team strip the function's
own docstring says it exists to prevent, and includeArchived made it easier to
hit by letting archived issues fill the page.

It now asks for one more than the page size and keeps any label whose carrier
list came back full, saying so.

Also from that review:
- The 'outside the scanned team' vs 'did not migrate' distinction was one dead
  branch and one wrong message: blocked_labels short-circuits every case that
  could reach the first, and an archived carrier INSIDE the scanned team was
  reported as outside it, because the team scan does not pass includeArchived.
  Both collapse into one statement that is always true.
- The dry-run hint made a lookup failure swallow the resolve-your-conflicts
  guidance. They are independent conditions and both print now.

74 tests.
@muqsitnawaz
muqsitnawaz merged commit 0342fa5 into main Aug 6, 2026
2 checks passed
@muqsitnawaz
muqsitnawaz deleted the linear-delegate-ownership branch August 6, 2026 04:13
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