Skip to content

feat(skills): library lifecycle automation — auto-sync, fleet re-inject, removal-on-unassign (abilityai/trinity-enterprise#236) - #1883

Merged
vybe merged 7 commits into
devfrom
feature/ent-236-skills-lifecycle-automation
Jul 31, 2026
Merged

feat(skills): library lifecycle automation — auto-sync, fleet re-inject, removal-on-unassign (abilityai/trinity-enterprise#236)#1883
vybe merged 7 commits into
devfrom
feature/ent-236-skills-lifecycle-automation

Conversation

@obasilakis

Copy link
Copy Markdown
Contributor

Summary

Closes the three skills-lifecycle gaps documented as "Not Built" in requirements §21.1/§21.4. All three default OFF/no-op — a zero-config install behaves exactly as before.

  • Removal-on-unassign — unassigning a skill now actually removes the injected package, via the existing manifest-prune primitive. Wired into both the single DELETE and the bulk PUT (the primary UI/MCP path, which drops skills far more often).
  • Start-path reconciliation — how a removal reaches an agent that was stopped when it happened. The assignment row is gone by then, so it's reconciled (diff the agent's platform-managed skill dirs against the assignment set), not replayed. No tombstone table, no migration.
  • Scheduled auto-sync + fleet-wide re-inject — a leader-locked backend service that pulls the library on a timer and, when the commit actually changed, pushes updates to running agents at bounded concurrency.
  • Durable sync status — prerequisite for the "failures surface honestly" AC: the old _last_sync was per-process, so under --workers 2 the panel showed a stale timestamp and could never show an error at all.

Design decisions worth knowing

Reconciliation, not tombstones. Every removal route converges (single DELETE, bulk-PUT shrink, direct DB edit) with no schema change. It runs after injection and also at zero assigned skills — that's exactly the "unassigned the last skill" case an early return would strand forever.

Blast-radius guard. A reconcile proposing >10 removals for one agent refuses wholesale and alarms. A wiped agent_skills table is indistinguishable from a legitimate mass-unassign, and keeping files is the recoverable direction (#1638/#1644 discipline; the alarm carries counts, never skill names).

Backend-hosted, not the standalone scheduler. The sweep must reach agent containers; the scheduler sits on trinity-platform only.

Only platform-written files die. The delete set is the previous injection's own manifest, so agent-authored files and runtime artifacts survive; os.rmdir refuses a non-empty directory, so a skill dir holding agent files stays.

Hardening found by /review and /cso --diff on this branch

Four issues were found and fixed here, not deferred:

  1. PAT could reach durable admin-visible state. sync_library's outer handler passed a raw str(e) into a new system_settings row rendered in Settings, and the authenticated remote URL is an argument in the subprocess.run command list. Now scrubbed at the write (canary G-04 class).
  2. No lock on the shared clone. Scheduled + manual sync both run git fetch + git reset --hard on /data/skills-library. Two admins colliding was never realistic; a scheduled loop landing on a manual click is routine. Cross-worker lock added; contention returns 409 and writes no status row, so a contended click can't paint "Last sync failed".
  3. Bulk-PUT TOCTOU. previous - new is computed before the lock is taken, so concurrent PUTs could each hand the other's newly-assigned name to the remover. Assignments are now re-read inside the lock and a still-assigned skill is refused (bug: valid webhook token intermittently returns 404 under concurrent load #1445 pattern).
  4. The automation on-switch was gated by role only. PUT /api/settings/skills-library enables an unattended fleet-wide write of SKILL.md files — which Claude executes as instructions, not data. assert_admin answers "what role", never "is this a human": an agent-scoped MCP key resolves to its owner carrying the owner's role. Now reject_agent_principal. Third occurrence of the trinity-ops-agent#232 class (bug: retention prunes have no blast-radius guard — a mistyped window deletes most of a table within 5 minutes #1644, bug: trinity-system never adopts a rebuilt base image — ensure_deployed short-circuits on 'already running' #1816).

⚠️ The remaining half of that chain — skills_library_url writable by an agent-scoped key through the generic settings PUT, with the SSRF guard checking only the host — is pre-existing and still open, filed as abilityai/trinity-enterprise#293 (P1). It's filed privately rather than here because publishing an unfixed exploit chain on a public repo is the wrong trade. That issue asks for a structural fix rather than a fourth per-endpoint patch.

Changes

Area Files
New service services/skills_sync_service.py
Removal + reconcile + durable status services/skill_service.py, services/skill_packaging.py
Wiring routers/skills.py, services/agent_service/lifecycle.py, main.py
Settings surface routers/settings.py, services/settings_service.py, models.py, views/Settings.vue
Docs requirements §21.7, architecture.md, skill-injection.md, skills-library-sync.md, learnings.md
Tests tests/unit/test_ent236_skills_lifecycle.py (58), test_telegram_webhook_backfill.py (stub kept current)

Test Plan

  • New suite: pytest tests/unit/test_ent236_skills_lifecycle.py -q58 passed
  • Full unit suite vs a pristine origin/dev baseline, three orderings:
    • default order — 5573 passed / 0 failed (baseline 5525 / 0)
    • seed 12345 — 5575 passed / 0 failed
    • seed 777 — 5566 passed / 9 errors, the same 9 errors reproduced on pristine origin/dev with that seed (pre-existing order-dependence in test_1089_*)
  • Design-token check: node scripts/check-design-tokens.mjs → all references resolve
  • Vendored-parity tests re-run green (skill_packaging.py is backend-only, Invariant Setup improvements #5 unaffected)
  • Manual: enable auto-sync at 300s with a real library, confirm a commit change sweeps running agents and a no-op pull does not
  • Manual: unassign a skill on a stopped agent, start it, confirm the package is gone and CLAUDE.md no longer lists it

Fixes abilityai/trinity-enterprise#236

🤖 Generated with Claude Code

…ct, removal-on-unassign

Closes the three "Not Built" gaps in the skills lifecycle (requirements §21.1/§21.4).
All three default OFF/no-op, so a zero-config install is unchanged.

Removal-on-unassign
- `compute_removal` is `compute_prune` against an empty new manifest, so path
  confinement, `..` rejection, and the cap live in one place and cannot drift.
- `remove_skills` takes the SAME per-agent lock as injection (both mutate
  ~/.claude/skills and read-modify-write CLAUDE.md). Only manifest paths are
  deleted, so agent-authored files and runtime artifacts survive; a directory the
  removal empties is reaped via os.rmdir, which refuses a non-empty dir.
- Wired into BOTH the single DELETE and the bulk PUT — the bulk PUT is the primary
  UI/MCP path and drops skills far more often.
- The DB unassign is authoritative and always succeeds; a stopped agent, busy lock,
  or dead transport degrades to a named `removal_deferred:*`.
- A partially-failed removal keeps the meta AND the .gitignore line: dropping the
  meta strands survivors as unmanaged orphans, and dropping the ignore line lets
  the 15-min auto-sync commit leftover injected files (#1595/#1596 class).

Start-path reconciliation
- The assignment row is gone by the time a stopped agent starts, so removal is
  reconciled, not replayed: the agent's platform-managed skill dirs are diffed
  against the assignment set. No tombstone table, no migration, and every removal
  route converges. Runs after injection and also at zero assigned skills — that is
  exactly the "unassigned the last skill" case.
- Blast-radius guard: >10 removals for one agent refuses wholesale and alarms. A
  wiped agent_skills table is indistinguishable from a mass-unassign, and keeping
  files is the recoverable direction (#1638/#1644).

Scheduled auto-sync + fleet re-inject
- New leader-locked backend service (skills:sync:leader). Backend-hosted because the
  sweep must reach agent containers and the scheduler is platform-network-only.
- Sweeps only when the library commit actually changed; running non-ghost agents;
  force=False so the ent#183 tree-SHA skip makes unchanged skills free; bounded
  concurrency; inject-lock contention is skip-and-report.
- Honest aggregate report in Settings + operator alarm only when an agent failed.
- Config re-read each cycle, so an interval change needs no restart.

Durable sync status
- `_last_sync`/`_last_commit_sha` were per-process; under `--workers 2` the worker
  answering /status was usually not the one that synced, so the panel showed a stale
  timestamp and could never show an error at all. Now mirrored to system_settings on
  both the success and failure branches, and the commit-changed comparison reads the
  durable row (the in-memory field would make every restart look like a change).

Hardening found by /review and /cso on this branch
- The persisted sync error is PAT-scrubbed: sync_library's outer handler passed a raw
  str(e), and the authenticated remote URL is an argument in the subprocess command
  list, so an OSError could carry a token into system_settings and the admin panel.
- Cross-worker lock around the shared clone: scheduled + manual sync both run
  `git fetch` + `git reset --hard` on /data/skills-library. Contention returns 409 and
  writes no status row, so a contended click cannot paint "Last sync failed".
- remove_skills re-reads assignments inside the lock and refuses a still-assigned
  skill, closing the bulk-PUT check-then-act race (#1445 pattern).
- PUT /api/settings/skills-library is human-only (reject_agent_principal): it is the
  on-switch for an unattended fleet-wide write of SKILL.md files, which Claude
  executes as instructions. assert_admin answers "what role", never "is this a
  human" — third occurrence of the trinity-ops-agent#232 class. The remaining half of
  that chain (skills_library_url writable by an agent key via the generic settings
  PUT) is pre-existing and filed as Abilityai/trinity-enterprise#293.

Dedicated range-validated GET/PUT /api/settings/skills-library; the three keys are
blocked on the unvalidated generic PUT. 58 new tests.

Fixes Abilityai/trinity-enterprise#236
@obasilakis
obasilakis force-pushed the feature/ent-236-skills-lifecycle-automation branch from bd5f664 to f55a6cd Compare July 30, 2026 07:47
@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

…-lifecycle-automation

# Conflicts:
#	docs/memory/learnings.md
…example

`SKILLS_RECONCILE_MAX_REMOVALS` and `SKILLS_FLEET_INJECT_CONCURRENCY` were read
via os.getenv() but never reached the container — the #1056 / trinity-enterprise#31
packaging class. The reconcile-refusal alarm names the first var in its own
remediation text ("raise SKILLS_RECONCILE_MAX_REMOVALS"), so on a deployed stack
the operator was told to turn a lever that does not exist, leaving a legitimate
mass-unassign permanently blocked at the default cap of 10.

Found by /validate-pr on PR #1883.
…an honest Sync-now affordance

Three cosmetic fixes plus one UX gap on the agent detail tabs.

- SkillsPanel had no card wrapper, so its content sat flush against the tab
  bar unlike every sibling tab. Wrapped in the same card the other panels use.
- FoldersPanel and InfoPanel each had a `v-else` content wrapper with no
  spacing class, so the root `space-y-6` never reached the cards inside and
  they rendered touching. Same bug in both files.
- SkillsPanel: saving assignments writes the DB rows, but the files only reach
  the container on a sync or the next agent start. That was stated in muted
  text above the button, which is easy to miss — an operator can save, message
  the agent, and be told the skill doesn't exist, because it isn't there yet.
  "Sync now" now goes prominent while that gap is open, and only while the
  agent is running (shouting at a disabled control helps nobody). A failed or
  409-busy sync keeps it lit, since the gap is still open.
8 of the agent-detail tab wrappers use p-6 (overview, info, brain, dashboard,
schedules, playbooks, git, folders). Settings and Skills had none, so their
cards ran flush into the enclosing panel's left and right edges instead of
sitting inset like the rest.
…ke the panel's failures actionable

Three fixes found while testing this PR on a live instance. All pre-existing on
dev, but each is sharpened by putting sync on an unattended timer.

1. sync_library() chose pull-vs-clone on `library_path.exists()` — directory
   existence, not repo-ness. A path holding no `.git` (clone interrupted by a
   full disk, a stray mkdir, a restored backup) failed `git pull` with "not a
   git repository" on every attempt, with nothing able to re-clone: permanent,
   and recoverable only by shell access. Now tests for `.git` and re-clones.

   Detecting it is only half a fix, since `git clone` refuses a non-empty
   destination — so a non-repo directory is moved aside first. Renamed, never
   deleted: the platform owns the path exclusively so removal would probably be
   safe, but "probably safe" is not the standard for an unattended timer
   deleting a directory derived from an operator-supplied setting (#1638/#1644).
   One quarantine is kept, so a recurring fault cannot grow without bound.

2. syncSkillsLibrary() saves settings first (setting showSuccess), then syncs;
   the catch set `error` without clearing it, so a failed sync showed "Settings
   saved successfully!" and "Clone failed" together. Both true, which is
   precisely what makes the pair untrustworthy.

3. The panel never said a private library needs a GitHub PAT — it is in two
   internal docs only, and what an operator hits is raw git ("could not read
   Username"), which names no remedy and does not hint that the PAT field is
   one section above. Added that line. The error-string mapping is deliberately
   NOT done: matching git stderr is brittle across versions and locales, and
   the raw error is at least surfaced rather than swallowed.

Tests: 5 new (4 fail without the fix). Also fixes 7 existing tests that created
the library path without `.git` — under the corrected predicate they took the
clone path and reached for the network; the suite drops 5.01s -> 0.62s.
…-lifecycle-automation

# Conflicts:
#	docs/memory/learnings.md

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Validated via /validate-pr: requirements §21 + architecture + feature flows updated, ent#236 tuning vars wired into both compose files and .env.example, no secrets, blast-radius guard on reconcile, full CI green (6 pytest runs). Cross-tracker close — will set status-in-dev on ent#236 manually.

@vybe
vybe merged commit 553382c into dev Jul 31, 2026
24 checks passed
obasilakis added a commit that referenced this pull request Aug 4, 2026
…ecycle automation

ent#236 (PR #1883) landed on dev BEFORE this branch rather than after, so it
rewrote the same sync entry points ent#237 was replacing. The reconciliation,
not the textual conflicts, is the substance of this merge.

Kept from ent#236, adapted to N sources:
- The cross-worker sync lock now wraps the multi-source loop
  (`_sync_library_locked(url)` -> `_sync_sources_locked(sources)`). ONE lock for
  the whole sweep: per-source locking would let two workers interleave and each
  publish a merged listing built from a half-updated set of checkouts, and the
  listing, the cache invalidation and the durable status are all library-wide.
- Durable sync status (`skills_library_last_*`) stays library-wide and is still
  written on BOTH branches; per-source truth is on each `skill_sources` row.
- `commit_changed` — the gate the fleet re-inject fires on — is computed per
  source against that source's own DURABLE `last_commit_sha` and OR'd, so a
  fresh process cannot read "changed" and sweep the fleet on every restart.
- The non-repo-directory quarantine is ported into `SkillSourceClone`, where
  clone-vs-update now lives. Without it ent#236's forever-fail fix would have
  been silently dropped per source.

Superseded and removed: `_git_clone` / `_git_pull` / `_get_current_commit` /
`_quarantine_non_repo_dir` on SkillService — `SkillSourceClone` owns one
checkout's git lifecycle and `skill_service` orchestrates N of them.

Settings.vue keeps ent#236's automation card (auto-sync, interval, fleet
re-inject) alongside <SkillSourcesPanel />; taking this branch's side wholesale
would have deleted a shipped feature. Only the single-library URL/branch/sync
controls the source list supersedes are dropped. Its loader is wired into the
admin-only loaders — the old mount call was auto-merged away with the deletion.

Also caught in the auto-merged (non-conflicting) regions:
- `skills_sync_service` read top-level `commit_sha`/`action`, which the
  multi-source return no longer had. `commit_sha` is back as an explicitly
  documented library-wide summary marker; the audit row now records the
  per-source breakdown instead of one arbitrary source's action passed off as
  the library's.
- The default source ref was pinned to a `v1.0.0` tag that will never exist —
  ent#296 cuts v0.1.0. A fresh install would have seeded a source that can
  never sync, failing quietly. Documented in .env.example.

Tests: two files stub `utils.url_validation` with only one symbol while this
branch imports ALLOWED_SKILLS_LIBRARY_HOSTS from it; the stub installs only when
the module is absent from sys.modules, so this passed CI on file order alone and
failed in isolation on the pre-merge tip too. Both stubs completed. Nine ent#236
tests drove the deleted single-repo API and are retargeted to the new seams —
the properties (durable status, PAT scrubbing, commit-changed-vs-durable-row,
quarantine) are unchanged and now exercise real code paths.

Full unit suite at seed 12345: 7248 passed / 0 failed (clean dev: 7176 / 0).

Refs Abilityai/trinity-enterprise#237, Abilityai/trinity-enterprise#236
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.

2 participants