diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index cb505c206..50fbd7725 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -26,6 +26,7 @@ | 2026-08-16 | #1710 | refactor(auth): **retire the `slack.py` INV-8 carve-out** left by #1310. The 11 inline `if not db.can_user_*_agent(...): raise 403` sites (each carrying a `# noqa: inv8` waiver) migrated onto the shared imperative guards — `assert_agent_access` (3 read) / `assert_agent_owner` (8 owner) in `dependencies.py`. **Behavior-preserving** (AC #6): same 403 + `detail` string byte-for-byte; every site was already access-first so no 404-before-403 enumeration oracle is introduced (AC #5); the helpers add `_enforce_connector_scope` first, redundant belt-and-braces since a connector key is already 403'd at the auth entry point before reaching any slack handler. Site #10 (proactive-consent toggle) keeps its ent#223 human-only `reject_agent_principal` line — `assert_agent_owner` is agent-permissive. **Zero** `# noqa: inv8` markers remain in-tree; the static guard `test_1310_auth_wiring` still trips a re-introduced inline gate. Behavioral proof added to `test_1310_auth_consolidation` (A9: stranger→403, shared-reader read/owner matrix, site-#10 human-only vs site-#11 send asymmetry) + a runtime ordering proof in `test_223_slack_channel_proactive_consent`. IDOR/public-link/rate-limit logic untouched. | [slack-integration.md](feature-flows/slack-integration.md), [role-model.md](feature-flows/role-model.md) | | 2026-08-16 | #1704 | feat(plugins): **an agent's Claude Code plugin selection is a declared, committed, self-healing manifest**. **Reframe first**: the issue's premise ("a recreate loses plugins") is not reproducible — HOME (`/home/developer`) IS the durable `agent-{name}-workspace` volume, no recreate removes it, and startup.sh preserves untracked files, so a plain recreate keeps `~/.claude.json` and the `~/.claude/plugins/` cache both. The real gap is a **git-based reconstitution** onto a fresh/empty volume or a new host (the #1169 move exports `data/` only; the #834/#1581 hard-purge removes the volume) — a gap #1705 completed by removing the last incidental crutch (the cache used to be auto-committed). Mechanism = the agent-local half of trinity-enterprise#192: a `template.yaml plugins:` block → total tolerant reader `services/template_plugins.py` (the ent#89 shape; both catalog builders surface `plugins`+`plugin_errors`) → `_TemplateResolution.declared_plugins` fed by **all three** resolver branches (github source metadata / local `template_data` / copy snapshot — NOT `template_data`, which is `{}` on the github: path) → `git_service.materialize_plugins` writes nested `~/.trinity/plugins.yaml` via the shared injection-safe heredoc writer (`sort_keys=True` for byte-stability so the 15-min auto-sync loop never re-commits a churning manifest), opt-in + ghost-skipped + non-fatal in the rollback fence. **Committed** via `_TRINITY_AUTHORED_PATHS` (the #2070 `!` re-include + rm-cached exemption) so it survives the reconstitution — while `.claude.json`/`.claude/plugins/` stay gitignored (**#1705 intact**, never un-ignored). **Boot self-heal**: `startup.sh` runs `agent_server.plugins_reinstall` AFTER credential injection (a private marketplace needs a git credential — resolved from `GITHUB_PAT` env, never the manifest), hardened-parses the untrusted manifest (`AliasPolicy.REJECT`), re-charset-validates every name and the marketplace **`source`** (the dangerous arg — refuses `user:token@` userinfo/traversal/leading `-`), reads current state via `claude plugin [marketplace] list --json` and installs only what's missing (arg-lists, hard `timeout`+`stdin=DEVNULL` so a no-TTY prompt can't hang) — **zero subprocesses** when present. Base-image change → old images silently skip the hook (release-note ordering). **Deferred**: capturing runtime `/plugin install`s (a distill of Claude's undocumented moving internals) and a commit-pinned (`auto_update: off`) mode. | [agent-plugin-manifest.md](feature-flows/agent-plugin-manifest.md), [github-sync.md](feature-flows/github-sync.md), [agent-data-volumes.md](feature-flows/agent-data-volumes.md) | | 2026-08-14 | #2127 | fix(agent-runtime): **a fan-out turn is no longer killed mid-wait and booked as SUCCESS**. `claude --print` emits one `{"type":"result"}` per turn *segment* and deliberately stays alive to await background subagents/workflows — they are exempt from the ~5s background-shell grace "because their result is part of the final output" — but #970's early-completion set `result_seen` on **any** result line and SIGTERMed the process group 2s later with `return_code = 0`. So the CLI was killed *during a wait it was performing correctly*: the run recorded **success** with the model's "I'll wait for the notification" announcement stored as its response, cost billed, subagents killed, nothing logged. **The issue's own stated root cause was wrong** — it blamed the result classifier for trusting a clean `end_turn`, but the classifier never runs, and building its suggested transcript-scanner would have been a detector layered on our own kill. **#1870 is the same defect from the other end** (fast notification → interrupted follow-on turn → `error_during_execution`), i.e. one bug filed twice. Root-caused by instrumenting Claude Code 2.1.220 in a live agent container: first result t+37.82s, real answer t+40.32s, exit t+40.72s — the kill lands ≤t+39.82s. Fix requires **all three** of `result_seen`, an empty ledger, and a quiet stream. Ledger half: `system/background_tasks_changed` carries a full snapshot of in-flight work (`[A]→[A,B]→[A,B,C]→[B,C]→[B]→[]`), so the latest event is the whole truth. **Idle half — not optional**: the ledger alone misses the reported shape, because once the fan-out ends the ledger drains to `[]` while the model composes the synthesis (measured: 5 subagents done by t+27.8s, real `FINAL REPORT` at t+55.9s), so a ledger-only gate lets the kill land on `"ECHO reported."`. Idleness is what #970's failure *is* — claude alive, blocked on an MCP child, silent. `_IDLE_FINALIZE_S`=**300s** (`AGENT_IDLE_FINALIZE_S`, `<=0` refused), set from the worst silence a healthy run makes: without `--include-partial-messages` one long message is one stdout line emitted only when complete — **40.54s of dead air for 9,840 chars**, vs a 7.33s worst gap across three fan-out runs — so ~7.4× headroom, biased large because too-large only delays the pathological #970 finalize while too-small destroys a deliverable and calls it success. Free on the happy path: the branch is only reachable when the process does **not** exit. `_NON_WAITED_BG_TASK_TYPES = {local_bash, local_shell}` is a **denylist** because the failures are asymmetric — an allowlist that hasn't heard of a future waited type restores the bug *silently*, a denylist miss costs a bounded wait — and a background shell must never hold the finalize, since the CLI kills it after ~5s (measured 5.09s) and gating on one re-opens #970's lingering-child class. Malformed ledger ⇒ 0 (degrades to pre-fix behaviour, never a new hang class); the read takes `task_type` only, never `description`/`prompt`/`summary` (subagent prompt text and output). `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` derived from the execution timeout at **both** spawn sites — the chat path never had the kill but inherits the same 10-minute CLI truncation, and fixing only the reported path is the recurring one-of-two-call-sites escape (#686, #1264). When the CLI stops waiting anyway, finalize prepends a notice + sets `metadata.background_tasks_pending_at_exit`, after the #160 empty-response branch so it can't make an empty result look populated. ⚠️ Consequence: fan-outs now run to completion, so they spend more and can newly reach `--max-turns`. Base-image rebuild + cold recreate required. 20 tests, asserting **end state** (the late answer survives, not the placeholder) and verified failing without the gate | [parallel-headless-execution.md](feature-flows/parallel-headless-execution.md) | +| 2026-08-13 | #925 | feat(ui): **client-side cron validation + invalid-schedule warning icon** — the schedule form pre-validates as the user types via `utils/cronValidation.js`, a zero-dependency hand-rolled mirror of the backend's EXACT grammar (`schedule_validation.py` 5-field split + verbatim `_dow_to_apscheduler` port + APScheduler 3.11 field rules, incl. the prefix-match name expressions, the step-span rule and the Python-truthiness `last or MAX` fallback — JS `\|\|` on purpose, not `??`); cron libs were rejected on ≥8 proven verdict disagreements. Contract = `tests/fixtures/cron-grammar-cases.json` (111 rows PROBED against the live validator, never hand-typed) asserted row-for-row by BOTH `tests/unit/test_925_cron_grammar_fixture.py` (the drift alarm in the backend CI env) and the vitest spec — an APScheduler bump that moves the grammar fails CI loudly instead of desynchronizing the mirror. Form: the format-hint line doubles as the reserved error slot (no modal jump; every message shape measured ≤2 lines at the modal's real 400px width); submit disabled ONLY when non-empty-AND-invalid (empty keeps the native `required` bubble); editing shows an invalid stored cron unconditionally so a dead Update button is never unexplained. List: warning triangle inside the cron chip, tooltip exactly "Invalid cron expression". Fail-open (`{valid:true}` on internal error) — the backend 400 stays the authority; backend runtime untouched | [scheduling.md](feature-flows/scheduling.md) | | 2026-08-12 | ent#100 | feat(grid): **Recent failures info tile** — the first DATA tile on the ent#325 widget chassis: newest failed executions fleet-wide plus the 24h total, from the EXISTING `GET /api/executions?status=failed` + `/api/executions/stats`, ridden on the one 60s visibility-aware batch poll and gated on the tile being enabled. **Zero backend change, no new endpoint, no schema change, no new timer** — the tile never fetches, because viewport culling *unmounts* tiles and an `onMounted` fetch would re-issue on every pan. The green "No failures in 24h ✓" is treated as a POSITIVE claim needing positive evidence and is unreachable from all three faults that would otherwise manufacture it: a failed rows GET (#1926), a failed `/stats` GET (the 24h total is a second request, so its failure means UNKNOWN, not zero — hence one `{data,loaded,error}` triple per GET, never shared), and an **unenumerable fleet** (`accessible_agent_names` → `list_all_agents_fast()` returns `[]` on ANY Docker fault, so a non-admin gets HTTP 200 + zeros — the ent#384 hazard, closed client-side by requiring a non-empty roster). A *refresh* failure over loaded rows still stays `ready`; only the CONFIRMATION needs everything green. Named AC deviation: **no error-code taxonomy** — `schedule_executions` has no `error_code` column, so `failureCodeFromSummary` READS an anchored lower-case `[code]` marker when the platform emitted one and returns null otherwise; a JS re-classifier was rejected as a third unenforced mirror of the byte-identical `failure_classifier.py` pair guessing at a 200-char truncation. Chassis fixes: info tiles get the **unfiltered** roster (`orgAgents || agents`) so the ent#261 type-to-filter can't degrade fleet-tile labels to raw slugs per keystroke; the shared 1s tick only reaches catalog entries declaring `wantsTick`; `InfoTile` sets `inheritAttrs: false`. Both tile guards were narrower than their own stated job and were widened: `gridTokens` now auto-discovers tiles recursively (it was a hand-written two-entry list) and `gridTileLinks` now brace-balances `to:` object literals built in `