You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
bug(skills): a library skill assigned to an agent is missing from the Workspace and every playbook list until a manual Sync or restart — and the lists never refresh on assign/unassign #2703
A skill assigned from the Skills Library does not appear in the agent's "available skills" lists — the Workspace / typeahead and hint cards, the Agent Detail chat / popup and empty-state quick actions, the Playbooks tab, the connector playbook list, public chat — until someone presses Sync on that agent's Skills tab or the agent is restarted. And once it does land, no open surface refreshes: the lists are fetched once and never invalidated. Unassign has the mirror problem — the package is removed (ent#236) but the surfaces keep listing it.
Expected: a library skill shared with an agent shows up on every surface the same way a native agent skill does, and those lists update when library skills are assigned or unassigned.
Context
Verified against the code, not assumed:
Assign writes a row and stops. Every assign path persists the agent_skills row and returns — POST /api/agents/{name}/skills/{skill} (src/backend/routers/skills.py, assign_skill), the bulk PUT (update_agent_skills — which does remove packages for dropped names but injects nothing for added ones), the Library page's assign control (src/frontend/src/stores/skillsLibrary.js, assignSkill, ent#386), and MCP assign_skill_to_agent / set_agent_skills (src/mcp-server/src/tools/skills.ts). Injection only happens on manual POST .../skills/inject, on agent start (reconcile_agent_skills in services/agent_service/lifecycle.py), or on the library fleet sweep when the library commit moves (services/skills_sync_service.py). DELETE already delivers — _remove_unassigned_skills removes the package best-effort — so the write path is asymmetric.
Every list reads the container. All playbook surfaces proxy the agent-server's GET /api/skills (docker/base-image/agent_server/routers/skills.py), which scans ~/.claude/skills/: the Workspace roster (src/backend/client_portal/service.py, _agent_briefing → playbooks / searchable_playbooks), GET /api/agents/{name}/playbooks (routers/agent_files.py, consumed by ChatPanel.vue, PlaybooksPanel.vue, usePlaybookAutocomplete.js), routers/connector.py via connector_service.fetch_live_playbooks, and routers/public.py. Library packages are injected to exactly that directory (skill_service.py: "packages ALWAYS land at ~/.claude/skills/<name>/"), so an injected library skill already renders identically to a native one — the gap is purely delivery + invalidation.
Nothing refreshes. The Workspace roster is fetched once at mount (portalUtils.js: "the roster is fetched once at mount"); ChatPanel.vue and PlaybooksPanel.vue reload playbooks only on mount, on status→running, or on agent change. There is no WebSocket event for skill changes, while the sibling settings already have thin-trigger events (agent_tags_changed, agent_label_changed).
Why now: ent#386 (in dev) puts an assign control on the Library page with no Sync button next to it, so the most natural flow — assign from the Library, open the Workspace, type / — shows nothing. The Skills tab at least says "Saved. Sync now, or the agent picks them up on next start."; the Library page and MCP say nothing.
Prior art: ent#385 (incubating) sketches an opt-in?deliver=true on the single-assign endpoint with an honest delivery: injected | pending_start | not_delivered body. This issue is the product expectation that delivery is the default and that the surfaces follow — ent#385's design (always 200 once the row is written, row never rolled back, SkillInjectionBusy → not_delivered never "queued") is the right shape for the backend half.
Acceptance Criteria
Assigning a library skill to a running agent — via the Skills tab, the Library page, POST /api/agents/{name}/skills/{skill}, PUT /api/agents/{name}/skills, or MCP assign_skill_to_agent / set_agent_skills — delivers that skill's package to the agent without a separate Sync, and the response carries an honest per-skill delivery status (injected | pending_start | not_delivered + reason); the agent_skills row is never rolled back on a delivery failure and the HTTP status stays 200 once the row is written
Assigning to a stopped agent reports pending_start; the existing start-path reconcile delivers it (no regression)
PUT bulk replace is symmetric: added names are injected, dropped names are removed (today only the latter happens)
After assign or unassign, the Workspace / typeahead and hint cards, the Agent Detail chat / popup and empty-state quick actions, and the Playbooks tab reflect the change without a page reload — via a thin-trigger WebSocket event (identifiers only, listeners refetch through the access-controlled route, per the /ws broadcast rule) or an equivalent invalidation
A library skill that is assigned and delivered is indistinguishable from a native .claude/skills/ skill on every playbook surface (Workspace, chat, Playbooks tab, connector playbooks, public chat, MCP list_runnable_skills)
The Library page's assign control (ent#386) surfaces the delivery outcome inline — "assigned and delivered" vs "assigned, applies on next start" vs a named failure — rather than presenting assignment as silently complete
Existing named validation errors are preserved (unknown skill → 404, invalid name → 422)
Tests: assign-on-running injects exactly that skill (not a fleet force=True re-inject); assign-on-stopped is pending_start; the WS trigger fires on assign and unassign; a surface listening to it refetches
Technical Notes
skill_service.inject_skills(agent_name, skill_names=[name], force=...) already accepts a single-name list — no new injection machinery needed
SkillInjectionBusy is a per-agent Redis lock, not a queue: contention must read as not_delivered ("agent busy — applies on next start or manual Sync"), never "queued"; the manual inject route's 409 mapping must not be reused once the row is committed
Removal-on-unassign (_remove_unassigned_skills) is the contract to mirror: best-effort, never fails the committed write, start-path reconcile is the retry
Frontend invalidation: ChatPanel.vue / PlaybooksPanel.vue already own a loadPlaybooks(); the Workspace roster needs a per-agent refetch path rather than a full roster reload (layout stability — no scroll/state resets, per the design-system contract)
Summary
A skill assigned from the Skills Library does not appear in the agent's "available skills" lists — the Workspace
/typeahead and hint cards, the Agent Detail chat/popup and empty-state quick actions, the Playbooks tab, the connector playbook list, public chat — until someone presses Sync on that agent's Skills tab or the agent is restarted. And once it does land, no open surface refreshes: the lists are fetched once and never invalidated. Unassign has the mirror problem — the package is removed (ent#236) but the surfaces keep listing it.Expected: a library skill shared with an agent shows up on every surface the same way a native agent skill does, and those lists update when library skills are assigned or unassigned.
Context
Verified against the code, not assumed:
Assign writes a row and stops. Every assign path persists the
agent_skillsrow and returns —POST /api/agents/{name}/skills/{skill}(src/backend/routers/skills.py,assign_skill), the bulkPUT(update_agent_skills— which does remove packages for dropped names but injects nothing for added ones), the Library page's assign control (src/frontend/src/stores/skillsLibrary.js,assignSkill, ent#386), and MCPassign_skill_to_agent/set_agent_skills(src/mcp-server/src/tools/skills.ts). Injection only happens on manualPOST .../skills/inject, on agent start (reconcile_agent_skillsinservices/agent_service/lifecycle.py), or on the library fleet sweep when the library commit moves (services/skills_sync_service.py).DELETEalready delivers —_remove_unassigned_skillsremoves the package best-effort — so the write path is asymmetric.Every list reads the container. All playbook surfaces proxy the agent-server's
GET /api/skills(docker/base-image/agent_server/routers/skills.py), which scans~/.claude/skills/: the Workspace roster (src/backend/client_portal/service.py,_agent_briefing→playbooks/searchable_playbooks),GET /api/agents/{name}/playbooks(routers/agent_files.py, consumed byChatPanel.vue,PlaybooksPanel.vue,usePlaybookAutocomplete.js),routers/connector.pyviaconnector_service.fetch_live_playbooks, androuters/public.py. Library packages are injected to exactly that directory (skill_service.py: "packages ALWAYS land at~/.claude/skills/<name>/"), so an injected library skill already renders identically to a native one — the gap is purely delivery + invalidation.Nothing refreshes. The Workspace roster is fetched once at mount (
portalUtils.js: "the roster is fetched once at mount");ChatPanel.vueandPlaybooksPanel.vuereload playbooks only on mount, on status→running, or on agent change. There is no WebSocket event for skill changes, while the sibling settings already have thin-trigger events (agent_tags_changed,agent_label_changed).Why now: ent#386 (in dev) puts an assign control on the Library page with no Sync button next to it, so the most natural flow — assign from the Library, open the Workspace, type
/— shows nothing. The Skills tab at least says "Saved. Sync now, or the agent picks them up on next start."; the Library page and MCP say nothing.Prior art: ent#385 (incubating) sketches an opt-in
?deliver=trueon the single-assign endpoint with an honestdelivery: injected | pending_start | not_deliveredbody. This issue is the product expectation that delivery is the default and that the surfaces follow — ent#385's design (always 200 once the row is written, row never rolled back,SkillInjectionBusy→not_deliverednever "queued") is the right shape for the backend half.Acceptance Criteria
POST /api/agents/{name}/skills/{skill},PUT /api/agents/{name}/skills, or MCPassign_skill_to_agent/set_agent_skills— delivers that skill's package to the agent without a separate Sync, and the response carries an honest per-skill delivery status (injected | pending_start | not_delivered+ reason); theagent_skillsrow is never rolled back on a delivery failure and the HTTP status stays 200 once the row is writtenpending_start; the existing start-path reconcile delivers it (no regression)PUTbulk replace is symmetric: added names are injected, dropped names are removed (today only the latter happens)/typeahead and hint cards, the Agent Detail chat/popup and empty-state quick actions, and the Playbooks tab reflect the change without a page reload — via a thin-trigger WebSocket event (identifiers only, listeners refetch through the access-controlled route, per the/wsbroadcast rule) or an equivalent invalidation.claude/skills/skill on every playbook surface (Workspace, chat, Playbooks tab, connector playbooks, public chat, MCPlist_runnable_skills)assign_skill_to_agent/set_agent_skillsreturn the delivery status (three-surface sync, Invariant feat: SMARTS trading pipeline with Telegram notifications and Miro visualization #13)force=Truere-inject); assign-on-stopped ispending_start; the WS trigger fires on assign and unassign; a surface listening to it refetchesTechnical Notes
skill_service.inject_skills(agent_name, skill_names=[name], force=...)already accepts a single-name list — no new injection machinery neededSkillInjectionBusyis a per-agent Redis lock, not a queue: contention must read asnot_delivered("agent busy — applies on next start or manual Sync"), never "queued"; the manual inject route's 409 mapping must not be reused once the row is committed_remove_unassigned_skills) is the contract to mirror: best-effort, never fails the committed write, start-path reconcile is the retryChatPanel.vue/PlaybooksPanel.vuealready own aloadPlaybooks(); the Workspace roster needs a per-agent refetch path rather than a full roster reload (layout stability — no scroll/state resets, per the design-system contract)