Skip to content

feat(team): allow team lead reassignment - #1033

Open
harrytuckerr wants to merge 7 commits into
iOfficeAI:mainfrom
harrytuckerr:feat/team-lead-transfer
Open

harrytuckerr wants to merge 7 commits into
iOfficeAI:mainfrom
harrytuckerr:feat/team-lead-transfer

Conversation

@harrytuckerr

Copy link
Copy Markdown

Description

Users can promote an existing teammate when the current lead cannot continue, without rebuilding the team. POST /api/teams/{id}/lead accepts { "slot_id": "..." } (also slotId) and returns the updated team. The previous lead becomes a teammate and may then be removed through the existing API; removing the current lead remains forbidden.

The service persists roster roles, the lead pointer and lead-first ordering in one update, patches both conversation roles, and hands unread user/teammate mail to the new lead. Full report bodies, line breaks and file references are retained. Forwarded reports, read marking and both role notices share one mail transaction. The old lead's notice is stored read and does not wake it; an explicitly promoted paused member is resumed.

Runtime strategy

The swap updates the live session in place. Rebuilding the entire session would stop unrelated members' event loops. Both scheduler roles change under one lock, both next-wake role prompts are armed, and coordinator roles are refreshed. A shared routing barrier serializes lead selection and durable mail writes with the handoff, including idle/settle and failure notifications. Explicit slot-addressed messages keep their target.

Starting runtimes or active old/new lead batches return a conflict without cancellation. TEAM_LEAD_TRANSFER_BUSY includes slot, conversation and nullable turn identifiers for the companion UI's explicit interrupt-and-retry action. Re-selecting the current lead is an event-free no-op; single-member teams, unknown slots and cross-user access are rejected.

Source references

  • Request and route: crates/aionui-api-types/src/team.rs:188, crates/aionui-team/src/routes.rs:198, crates/aionui-team/src/routes.rs:448.
  • Ownership, validation, persistence and role configuration: crates/aionui-team/src/service.rs:1187.
  • Mail handoff and transaction: crates/aionui-team/src/mailbox.rs:228, crates/aionui-db/src/repository/sqlite_team.rs:323.
  • Live role/prompt handoff: crates/aionui-team/src/session.rs:1595, crates/aionui-team/src/scheduler/mod.rs:215.

Validation

  • Focused lead tests: 65 passed, including full report/file preservation, role authorization, stopped-session handoff, paused swap-back, busy guards and a gated late-write regression.
  • Required just push gate: Pass on 5d816a6e: migration immutability, lint, formatting, and 9,404 tests passed (52 skipped).
  • The full gate includes the HTTP lead-reassignment and CSRF test.
  • Live-provider handoff and native UI interaction: Not exercised; the local preview is for manual trial. Automated/source checks are not presented as native runtime acceptance.

Related and scope

Related: iOfficeAI/AionUi#4269 and the lead-removal guard in iOfficeAI/AionUi#2357. Companion UI: iOfficeAI/AionUi#4335 (draft pending a released backend version).

No database migration, automatic failover, model-callable handover tool, or in-place backend/model replacement. The existing task board, conversation bindings and workspace are retained. Public changes and PR text were checked for machine-specific paths and credential patterns; no raw logs or captures are attached, and commit email addresses use GitHub noreply.

POST /api/teams/{id}/lead {slot_id|slotId} promotes an existing member and
demotes the sitting lead without rebuilding the session:

- one update_team writes both roles, lead_agent_id, and lead-first order; both
  conversations' extra.role follow through the existing merge patch.
- a per-slot coordinator transfer hold blocks claims for the current and target
  slots only, so unrelated members keep running. An active batch returns
  TEAM_LEAD_TRANSFER_BUSY 409 with slot/conversation/turn ids; a starting member
  keeps the existing TEAM_MEMBER_RUNTIME_STARTING 409.
- unread lead-directed mail is re-pointed to the new lead by id (ids, read
  state, history preserved); the new lead's own stranded reports are settled
  read; late writes during the hold are caught up after the swap.
- the live swap updates both scheduler roles under ONE lock, arms both role
  prompts, resyncs coordinator roles/intents, and wakes only the new lead; the
  old lead gets a settled-read projection notice.
- failures before the live swap compensate the roster and both conversation
  roles while the hold is still up, so a retry re-applies the whole swap instead
  of silently keeping a partial one.

Tests cover persistence/order/extras, unknown/single/no-op/cross-user edges,
the busy hold, mail reroute and settlement, lead-only authorization handoff,
the pre-swap enqueue race, swap-then-remove, and route CSRF/cross-user.
Follow-up on the lead reassignment patch, from root source review:

- target-authored reports are no longer silently settled: the original row
  keeps its author and stays read in history, and the new lead gets one
  bounded attributed notice listing the excerpts (no payload dumps).
- only user mail and actual teammates are handed over; system/control authors
  and the old lead's own rows stay with the slot they addressed.
- the explicitly promoted slot is unpaused (a member paused after delivery
  failures must not become a permanently silent lead); the demoted slot's
  pause is preserved.
- success and no-op responses now go through build_team_response, matching
  get_team so icons, pending confirmations, and context-reset capabilities
  survive the UI cache mutation.

In-flight enqueue leases are never aborted by the retarget (a failed commit
would mark the row read), and the transfer hold is released before the new
lead is explicitly notified, so the wake is not lost to a held next().
…endent

Follow-up on the reassignment patch, from root source review:

- the move/forward/settle mail change now runs in ONE narrow repo transaction:
  eligible unread mail is re-pointed, the new lead's own reports are forwarded
  as an attributed notice, and only then are those rows settled read. A failure
  cannot leave a report settled without its forward, and no in-flight enqueue
  lease is aborted (a failed commit would mark the row read).
- stopped teams get the same durable handoff without starting any runtime: the
  live and no-session paths share one mailbox helper, so the next ensure_session
  finds the pending mail and role notice on the new lead.
- the forward notice is projected on the live path; the role notice texts are
  shared between both paths.
- sender eligibility stays user + current teammates; control notices and
  previously-read rows are untouched.
- the explicitly promoted slot is resumed on every promotion, including a
  swap-back after a pause (covered by the handoff test).

Tests: transactional forward/settle, no-session handoff, pause resume on
swap-back; all lead-focused tests and the e2e route test pass; rustfmt and
clippy -D warnings clean.
…ally

Mail-correctness follow-up from root review:

- the new lead's own pending reports are forwarded as full attributed copies
  through the normal mailbox path: body verbatim (line breaks included) and
  file references preserved; the original row is settled only in the same
  transaction, so nothing is acknowledged before its forward is durable. The
  160-char "settled without action" excerpt is gone.
- both role notices are stored by the same mail transaction (new lead unread,
  old lead already read), so the stopped-team path has no fallible writes
  after the mail commit and the live path writes nothing fallible either.
- the live path projects the stored rows and enqueues only the new lead's
  notice; the enqueue happens before the slot resyncs so the coordinator
  cannot create a duplicate intent for the notice.
- the stopped-team path is a single mailbox-helper call.
Lead-directed writers selected the current lead and then performed a
durable mailbox write / lease commit in separate steps. The handoff hold
only fenced `coordinator.next`, so a write could still land after the
catch-up and stay on, or wake, the demoted lead.

Add a per-team routing barrier on `TeammateManager`:

- Lead-directed writers take the shared side for the whole span from
  "select the current lead" through the durable mailbox write / lease
  commit (`send_message`, `mark_idle`/`finalize_turn` incl. the event
  loop's settle enqueue, and the interrupted/spawn-failed/delivery-
  exhausted/membership-removed/recovery and crash-report notices).
- `set_team_lead` takes the exclusive side after the transfer hold is
  secured and holds it through prepare, roster/conversation role commit,
  live role swap, and hold release. Direct-unit handoff callers acquire
  it explicitly.

Lock order is membership (`add_agent_locks`) -> routing barrier; the
shared side is never re-acquired (locked variants exist for nested
calls), and it is never held across a model turn. This replaces relying
on catch-up timing alone.

Add a delayed-write regression that parks a real `send_message` in the
slash recognizer between lead selection and the mailbox write, runs the
real prepare+commit swap in that window, and proves the row reaches the
promoted lead while the demoted lead has no unread mail.

Not run: no Cargo build/tests (root owns the shared build stream);
source-only change, rustfmt clean.
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