Feature/process engine - #10
Merged
Merged
Conversation
Implements DDD integration tests for ExecutionEngine with real infrastructure: - Real SQLite repositories (in-memory) - Real domain services (DependencyResolver, OutputStorage) - Real event bus with event capture - Mock only external boundary (AgentGateway) Test files (33 tests total): - test_execution_lifecycle.py (4 tests) - test_sequential_execution.py (4 tests) - test_parallel_execution.py (5 tests) - test_error_retry.py (5 tests) - test_gateway_routing.py (4 tests) - test_timer_steps.py (3 tests) - test_event_publishing.py (5 tests) - test_output_persistence.py (3 tests) Infrastructure: - MockAgentGateway with configurable responses and call tracking - EventCapturingBus for asserting domain events - IntegrationTestContext helper for async event handling - 6 process definition builders - 3 assertion helpers Reference: BACKLOG_RELIABILITY_IMPROVEMENTS.md (RI-01 through RI-09)
Implements execution recovery service that runs on backend startup to detect and recover executions interrupted by restarts (deployment, crash, OOM). Recovery Actions: - RESUME: Execution between steps → continue from next pending step - RETRY_STEP: Step was RUNNING → reset to PENDING and resume - MARK_FAILED: Execution > 24h old → fail with timeout message - SKIP: Already in terminal state → no action needed Implementation (RI-10 to RI-14): 1. ExecutionRecoveryService (services/process_engine/services/recovery.py) - recover_on_startup() method called from main.py lifespan - RecoveryConfig for customization (max_age_hours, dry_run) - RecoveryReport with detailed counts and errors 2. Domain Events (domain/events.py) - ExecutionRecoveryStarted: Scan begins - ExecutionRecovered: Per-execution recovery action - ExecutionRecoveryFailed: Recovery attempt errored - ExecutionRecoveryCompleted: Scan complete with summary 3. Backend Integration (main.py, routers/executions.py) - Recovery runs during startup lifespan - GET /api/executions/recovery/status endpoint for health checks - Non-blocking: startup continues even if recovery fails 4. Integration Tests (12 tests in test_execution_recovery.py) - test_resume_execution_between_steps - test_resume_pending_execution - test_retry_running_step - test_retry_first_step_running - test_mark_failed_old_execution - test_mark_failed_custom_age_threshold - test_skip_completed_execution - test_mixed_batch_recovery - test_recovery_continues_on_error - test_recovery_events_emitted - test_dry_run_mode - test_last_recovery_report_stored Documentation: - BACKLOG_RELIABILITY_IMPROVEMENTS.md: Added stories RI-10 to RI-14 - PROCESS_ENGINE_ROADMAP.md: Marked IT5 P0 as complete All 45 integration tests passing. Reference: PROCESS_DRIVEN_THINKING_IT5.md Section 2.3 Reference: BACKLOG_RELIABILITY_IMPROVEMENTS.md RI-10 to RI-14
- Add ProcessPermission enum (13 permissions) and ProcessRole (5 roles) - Implement ProcessAuthorizationService with role-based access checks - Add AuditService with append-only SqliteAuditRepository - Create audit API endpoints (GET /api/audit - admin only) - Add ExecutionLimitService (global: 50, per-process: 3 concurrent) - Integrate authorization into process and execution API routes - Add limits status endpoint (GET /api/executions/limits/status) - 64 unit tests for authorization (42) and audit (22) - Update BACKLOG_INDEX.md and PROCESS_ENGINE_ROADMAP.md Refs: IT5 Section 5 (Access Management)
Phase 1: Test Agents - process-echo: Minimal agent with predictable JSON output - process-worker: Standard agent with file operations - process-failer: Configurable failure modes for error testing Phase 2: Test Cases (22 total) - Tier 1 (T1.1-T1.4): Critical path - single step, sequential, dependencies - Tier 2 (T2.1-T2.4): Conditional logic - XOR gateway, default route, parallel - Tier 3 (T3.1-T3.4): Human approval - approved, rejected, timeout, artifacts - Tier 4 (T4.1-T4.5): Error handling - errors, timeout, retry, skip, cancel - Tier 5 (T5.1-T5.5): Edge cases - 10-step chain, diamond, nested expr, limits Phase 3: Results Tracking - Test run template for documenting findings - Initial test run document (2026-01-17) Documentation updates: - BACKLOG_INDEX.md: Added manual_run reference - PROCESS_ENGINE_ROADMAP.md: Marked infrastructure ready Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
Test Results (4/4 PASS): - T1.1: Single agent step (~10s) - T1.2: Two sequential steps (~20s) - T1.3: Three steps with dependencies (~50s) - T1.4: Four steps with parallel execution (~40s) Fixes applied to test files: - Changed trigger type 'manual' to 'webhook' (manual not supported) - Changed version '1.0.0' to '1.0' (only major.minor format) - Increased timeout from 30s to 120s for agent tasks - Replaced '/structured' command with natural language request - Updated CPU resource from '0.5' to '1' (integer required) Issues discovered: - CLAUDE.md not auto-injected during deploy-local (needs code fix) - Slash commands cause empty response in Claude agents Documentation: - Updated test-run-2026-01-17.md with full results - Documented root causes and fixes Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
When deploying an agent via the deploy-local endpoint, automatically inject the CLAUDE.md custom instructions into the agent's workspace if the file is present in the deployment archive. This ensures that agents deployed locally receive their custom instructions without requiring a manual injection step. Changes: - Added step 12 to deploy.py: check for CLAUDE.md in extracted archive - If present, POST content to /api/trinity/inject endpoint - Follows same pattern as credentials hot-reload (with 2s wait) - Non-blocking: logs warning if injection fails Discovered during Process Engine manual testing when agents didn't receive their custom instructions after deploy-local.
Test Results:
- T2.1: Exclusive gateway (XOR) routing ✅
- T2.2: Gateway with default route fallback ✅
- T2.3: Parallel execution (fork/join) ✅
- T2.4: Step-level conditional skip ✅
Key Findings:
- Gateway routes use 'target' field (not 'next')
- Default route via 'default_route' at step level
- Step conditions use path syntax (steps.x.output.y)
not template syntax ({{steps.x.output.y}})
Fixes Applied:
- Fixed all T2 YAMLs with correct gateway syntax
- Added depends_on and conditions for path steps
- Documented Issue #4 in test results
Running Total: 8/22 tests passing (36%)
Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
Test Results: - T3.1: Human approval (approved) ✅ - T3.2: Human approval (rejected) with on_error:skip_step ✅ - T3.3: Approval timeout⚠️ (not implemented - needs scheduler) - T3.4: Approval with artifacts/context ✅ Key Findings: - Approval API works: GET /api/approvals, POST .../approve, POST .../reject - Rejection causes step to FAIL by design (use on_error:skip_step) - Timeout enforcement NOT implemented (deadline set but not checked) - Approval description can include dynamic context Known Limitations: - Issue #5: Approval timeout requires scheduler to enforce - Issue #6: Rejection = failure (by design, documented) Running Total: 11/22 tests passing (50%) Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
Test Results: - T4.1: Agent error (AGENT_UNAVAILABLE) ✅ - T4.2: Agent timeout⚠️ (bug: step status not updated) - T4.3: Retry policy ✅ - T4.4: Skip on error (on_error:skip_step) ✅ - T4.5: Cancel execution ✅ Key Findings: - Non-existent agent triggers clean AGENT_UNAVAILABLE error - on_error: {action: skip_step} works correctly - Cancel API works immediately BUG FOUND (Issue #7): - Step timeout detected (error.code='TIMEOUT') - But step status remains 'running' instead of 'failed' - Execution doesn't transition to failed state - Impact: Timeout processes may hang indefinitely Running Total: 15/22 tests passing (68%) - ABOVE TARGET ✅ Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
Test Results: - T5.1: 10-step sequential chain ✅ (~60s) - T5.2: Diamond pattern (double fork/join) ✅ (~70s) - T5.3: Nested expressions ✅ - T5.4: 3 concurrent executions ✅ - T5.5: Recovery test⚠️ (manual - requires restart) Key Findings: - Long chains (10 steps) work without issues - Complex dependency graphs (diamond pattern) resolve correctly - Concurrent executions supported - Average ~6s per agent step FINAL SUMMARY: - Total: 19/22 tests PASSING (86%) - Target was: 14/22 (64%) - Exceeded target by 22% All 5 tiers complete! Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
Live testing with human approver: - Approval flow: ✅ Works end-to-end - Rejection flow: ✅ Works (triggers skip via on_error) UI observations noted: - 'Paused' status not clear it's waiting for approval - Approve/Reject buttons require extra click to reveal - No console errors ✅
Interactive Test Results: - I1: Approval Routes ✅ - I2: Multi-Stage Approval ✅ (revealed output bug) - I3: Complex Workflow (Gateway + Approval) ✅ - I4: Parallel Work + Approval ✅ NEW BUGS FOUND: - Issue #8: Approval decision NOT stored in step output - Conditions like 'steps.X.output.decision == approved' fail - Impact: Cannot route based on approval value UI ISSUES CONFIRMED: - Page doesn't auto-refresh when approval step becomes active - Must manually refresh to see Approve/Reject buttons - Confirmed across I1, I2, I3, I4 - Skipped steps don't show WHY they were skipped 4 interactive scenarios created for future testing: - processes/interactive/i1-approval-routes.yaml - processes/interactive/i2-multi-stage-approval.yaml - processes/interactive/i3-complex-workflow.yaml - processes/interactive/i4-parallel-work-approval.yaml
Bug Fixes: - T4.2 FIXED: Added TIMEOUT to non_retryable_errors - Timeouts now cause immediate step failure (no retries) - Step status correctly transitions to 'failed' - T5.5 PASS: Verified ExecutionRecoveryService works - Backend restart during execution - Recovery service resumed execution successfully Partial Fix: - T3.3: Added deadline check in human_approval handler - Checks deadline when handler is re-invoked - Full enforcement needs background scheduler Code Changes: - execution_engine.py: Added TIMEOUT, APPROVAL_TIMEOUT to non_retryable_errors - human_approval.py: Added deadline expiry check on execute() Test Results: 21/22 passing (95%) - Only T3.3 (approval timeout) needs scheduler - All other tests passing
UI Observations Addressed: 1. 'Paused' status now shows '🔔 Awaiting Approval' when step is waiting_approval 2. Prominent approval alert banner at top of ExecutionTimeline (auto-visible) 3. Added 'Review Now' button in banner to jump to approval step 4. Auto-refresh now includes 'paused' status (needed for approvals) 5. Skipped steps now show WHY they were skipped (reason, error code) 6. Added 'Awaiting Approval' filter option in ExecutionList Components Updated: - ExecutionTimeline.vue: Approval banner, skipped reason display - ProcessExecutionDetail.vue: Enhanced status, paused auto-refresh - ExecutionList.vue: Display status helpers, filter option - style.css: Subtle pulse animation for approval alerts
- Show 'Awaiting Approval' for ALL paused executions in list view - List view doesn't have step data, so can't check waiting_approval status - Since paused = waiting for approval, this is correct behavior
Implementation of premium onboarding experience and in-app documentation: **Backlog & Planning (E20-E24)** - Created BACKLOG_ONBOARDING.md with 17 stories across 5 epics - Updated BACKLOG_INDEX.md with E20-E24 epics (102 total stories) - Created feature flow documentation **E20: Empty States & Quick Wins (Sprint 7)** - E20-01: Enhanced ProcessList empty state with: - Hero section with value proposition - Quick-start template cards (3 templates) - Create from scratch / import YAML options - E20-02: OnboardingChecklist component: - 5-item progress checklist (3 required, 2 optional) - LocalStorage persistence per user - Auto-detect completion from API data - Collapsible/dismissible UI - useOnboarding composable for state management **E21: Documentation Tab (Sprint 8)** - E21-01: Added Docs tab to ProcessSubNav - E21-02: ProcessDocs view with: - Sidebar navigation with collapsible sections - Markdown rendering with syntax highlighting - Responsive mobile sidebar - Previous/next navigation - E21-04: Getting started content (3 docs) - E21-05/E21-06: Reference content (4 docs) - Backend docs router for content serving **Documentation Content** - getting-started/: what-are-processes, first-process, step-types - reference/: yaml-schema, variables, triggers, error-handling - troubleshooting/: common-errors Remaining for future sprints: E21-03 (search), E22 (contextual help), E23 (guided tours), E24 (wizard)
- Replace 'Start' with 'See above ↑' when already on target page - Add 'Restart Getting Started' button to Docs page sidebar - Add confirmation dialog before restart - Remove debug console.log statements - Remove hidden keyboard shortcut (replaced by UI option) - Add notification toast for restart feedback Refs: BACKLOG_ONBOARDING.md E20-05
- E21-07: Pattern Documentation (sequential, parallel, approvals) - E21-08: Missing Step Types (notification, sub_process) - E21-09: Progressive Learning Path with tutorials - Update story count to 21 - Update dependency graph with new stories Refs: Review of onboarding coverage gaps
- Getting Started: what-are-processes, first-process, step-types - Reference: yaml-schema, variables, triggers, error-handling - Troubleshooting: common-errors - Backend docs router to serve markdown content Refs: BACKLOG_ONBOARDING.md E21-04, E21-05, E21-06
- Enhanced empty state with quick-start templates - Onboarding checklist composable with localStorage persistence - Auto-detection of completion based on API data - Feature flow documentation Refs: BACKLOG_ONBOARDING.md E20-01, E20-02
- Sequential: linear chains, data flow, error handling in chains - Parallel: fan-out/fan-in, diamond pattern, handling partial failures - Approvals: single gate, multi-level chains, timeout handling, conditional paths - Each pattern includes diagrams, complete YAML examples, best practices Refs: BACKLOG_ONBOARDING.md E21-07
- notification: channels (slack, email, pagerduty), recipients, urgency - sub_process: process invocation, input_mapping, nested workflows - Updated overview table with all 6 step types - Examples for each use case and best practices Refs: BACKLOG_ONBOARDING.md E21-08
- Level 1: Getting Started (existing - what-are-processes, first-process, step-types) - Level 2: Intermediate tutorials (NEW) - second-process.md: Parallel execution, fan-out/fan-in - human-checkpoints.md: Approval gates, routing decisions - Level 3: Advanced tutorial (NEW) - complex-workflows.md: Gateways, multi-path routing, combining patterns - Updated index.json with tutorials section - Clear learning progression with prerequisites and 'What's Next' links Refs: BACKLOG_ONBOARDING.md E21-09
- E21-07: Pattern Documentation ✅ - E21-08: Missing Step Types ✅ - E21-09: Progressive Learning Path ✅ Refs: Sprint 8.5 complete
- Add ./config/process-docs:/app/config/process-docs:ro volume mount - Enables docs API to serve documentation content Fixes: 404 errors on /api/docs/* endpoints
- Make restart button amber-colored for better visibility - Add restart button to mobile sidebar menu - Minor doc content improvements for clarity
E20-04: First-Run Detection Service - Enhanced useOnboarding.js with isFirstRun, shouldShowOnboarding - Added markOnboardingComplete(), dataState tracking - Auto-detection based on process/execution counts E22-03: Execution Status Explainers - Added status explanations with hover tooltips - Execution-level tooltips in ProcessExecutionDetail - Step-level tooltips in ExecutionTimeline - Info icon on status badges E20-03: Template Cards in Empty State - Already implemented (verified working) E22-01: YAML Editor Help Panel - New EditorHelpPanel component with contextual help - Cursor position tracking in YamlEditor - Help content JSON with field descriptions - Toggle button to show/hide panel - State persisted in localStorage - Updated docs router to serve JSON files
- Created onboarding test tier with 35+ test scenarios - Test categories: UI navigation, empty states, checklist, docs, help panel, status tooltips - Sample process definitions for testing - Clear test execution steps and expected results
Only show 'See above ↑' when: 1. User is on the target page AND 2. Prerequisite steps are completed - runExecution: requires createProcess to be done - monitorExecution: requires runExecution to be done
- YAML from assistant auto-syncs to editor as it types (no manual apply needed) - Select code in YAML preview → 'Explain' or 'Edit this' buttons appear in chat - YamlEditor now emits 'selection-change' events - Shows 'Auto-synced ✓' indicator instead of 'Apply to Editor' button - Removed unused manual apply functionality
- Don't ask 'want me to show you?' - just show the updated YAML - Briefly explain the change, then include the full YAML - Updated both system agent CLAUDE.md and frontend context
- Pass processStatus prop to ProcessChatAssistant - Add context about published processes being read-only - Assistant now tells users to click 'New Version' for published processes - Also handles archived process status
oleksandr-korin
force-pushed
the
feature/process-engine
branch
from
January 19, 2026 17:52
188f316 to
ecf65e2
Compare
This was referenced Apr 20, 2026
vybe
added a commit
that referenced
this pull request
Apr 21, 2026
Closes warning W1 from PR #438 validation — records the `?last-event-id=` query param, regex gate, REPLAY_GAP_LIMIT ceiling, services/event_bus.py entry, and updates invariant #10 to name the new transport so future broadcast sites don't bypass the manager shims. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe
added a commit
that referenced
this pull request
Apr 21, 2026
#438) * docs(plan): pivot orchestration sprint to #306 keystone Adds CLAUDE.md pointer to the orchestration reliability plan and records the 2026-04-20 revision: pause #294/#291 pending #306, treat Redis Streams event bus as the keystone for Tier 2.5 simplification, and gate cleanup collapse on a 2-week push-path soak. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: Redis Streams event bus for WebSocket delivery (#306) Replaces the in-process ConnectionManager + `except: pass` broadcast with a Redis Streams transport. Reconnects no longer drop events: clients send `?last-event-id=<stream_id>` and receive missed events via XRANGE catchup (capped at 5000 entries; trimmed cursors trigger a `resync_required` marker that the frontend answers with a REST refetch). Key pieces: - services/event_bus.py — EventBus publisher (fire-and-forget XADD, bounded outbound queue, 10000 MAXLEN env-tunable) + StreamDispatcher (one XREAD BLOCK per process, in-memory fan-out, per-client asyncio.Queue(256), 3-failure eviction, supervised reader with exponential backoff, 2s graceful drain on shutdown). - main.py — ConnectionManager / FilteredWebSocketManager kept as thin shims over the bus so the 33 legacy broadcast call sites don't change. /ws and /ws/events accept an optional last-event-id query param (regex-validated to `^\d+-\d+$`). - Frontend WS clients capture `_eid` and replay on reconnect; resync handlers refetch authoritative state (agents + activity history + pending notifications). - 23 unit tests: id validation, scope visibility, XADD envelope, fallback buffer, eviction, slow-consumer resync, monotonic cursor guard, invalid-cursor resync. Plus live roundtrip + reconnect replay verified against localhost stack. Keystone for Tier 2.5 simplification per `docs/planning/ORCHESTRATION_RELIABILITY_2026-04.md`: #428 (capacity consolidate), #429 (cleanup collapse), #307 (heartbeat push) will reuse the same stream primitive; #408 dissolves once agent-push completion retires the 1h blocking HTTP call. Those are follow-ups — this PR is WebSocket delivery only. Closes #306 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(architecture): document Redis Streams WebSocket transport (#306) Closes warning W1 from PR #438 validation — records the `?last-event-id=` query param, regex gate, REPLAY_GAP_LIMIT ceiling, services/event_bus.py entry, and updates invariant #10 to name the new transport so future broadcast sites don't bypass the manager shims. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 22, 2026
vybe
pushed a commit
that referenced
this pull request
Aug 3, 2026
…ership retrofit (ent#109) (#1947) * fix(lifecycle): one owner for git env across both rebuild paths (ent#109) `recreate_container_with_updated_config` seeds env from the OLD container and re-derived only `GITHUB_PAT`, replaying whatever `GITHUB_REPO` / `GIT_SYNC_*` each container happened to be carrying. The git-env derivation lived only in `_apply_persisted_auth_env` (`recreate_missing_container`). That split is a pre-existing fleet-wide bug, not a cosmetic one: the recreate has exactly one production caller, `start_agent_internal`, which fires on nine config-drift predicates AND on base-image drift at cold start — so a base-image rebuild arms the replay for every agent at once. `_apply_git_env_from_db` is now the single writer. Three load-bearing details: * **The PAT gate is a parameter, never inherited.** The two paths gate differently on purpose. `per_agent_only` (config-drift recreate) preserves #211 verbatim — resolve the effective PAT only when the container already carries one or a per-agent PAT row exists — so a global-only platform PAT is never injected into a previously-tokenless container. A verbatim lift would have swapped that for the 2-tier per-agent -> GLOBAL resolver used by `effective` (the rebuild-from-nothing path, which has no old container to inherit a token from): `configure_push_remote` then clears the push blackhole and a tokenless agent can push a private KB to the shared public upstream. learnings.md ent#162 names this class exactly. * **Set-or-clear**, since the recreate writes into a carried-forward dict. A deleted `agent_git_config` row pops the whole owned set; a `source_mode` flip clears the mode/branch pair. `GITHUB_PAT` alone stays set-only while a repo is bound — clearing it would revoke a live agent`s push on an unrelated recreate. * **`GIT_SYNC_AUTO` = DB flag OR baked env**, plus a convergence backfill. crud.py`s two writers genuinely disagree (`and not config.ephemeral` sits inside a swallowing try/except on the DB side only; the column defaults to 0), so deriving from `auto_sync_enabled` alone would silently stop auto-push for that slice of the fleet. The backfill writes the column the moment the disagreement is observed, so the OR retires itself. Making the #389 toggle authoritative is a separate follow-up. ent#123 is preserved: the gate is the REPO, not the PAT, so a tokenless agent rebuilt after container loss still clones (#843/#1439 silent-empty class). One deliberate divergence from a verbatim lift, asserted by test: a container with a baked `GITHUB_PAT` and NO git binding previously had that token refreshed from the global platform PAT on every recreate; it is now popped. The per-agent PAT is a column ON `agent_git_config`, so "no row" means no per-agent credential and no repo to push to by construction. Tests: tests/unit/test_ent109_git_env_seam.py — each of the four behaviours proved to have teeth by mutation (un-gate the PAT, flip the call site to `effective`, derive GIT_SYNC_AUTO DB-only, drop the clear sweep, drop the source-mode clear, diverge the GIT_SYNC_AUTO literal, unguard the backfill: all seven go red). Plus a static call-site guard, so flipping either gate fails CI even though no behavioural test of the helper alone would catch it. Refs Abilityai/trinity-enterprise#109 (PR 1 of 3) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(architecture): _apply_git_env_from_db owns git env on both rebuild paths (ent#109) Adds the missing agent_service/lifecycle.py catalog entry and records the per-call-site PAT gate, the set-or-clear contract, and the GIT_SYNC_AUTO OR-derivation. Amends the ent#123 clause to point at the new shared seam instead of _apply_persisted_auth_env. Refs Abilityai/trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(registry): register test_ent109_git_env_seam.py Refs Abilityai/trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(feature-flows): sync the git env-derivation seam (ent#109) github-sync.md: retitle the rebuild-recovery section to "Container-rebuild env — lifecycle.py::_apply_git_env_from_db" and document the per-call-site PAT gate, the set-or-clear contract, the GIT_SYNC_AUTO OR-derivation, and the two vars deliberately NOT owned. git-sync-health.md: GIT_SYNC_AUTO is re-derived on every rebuild as auto_sync_enabled OR the baked env (the two creation writers disagree), with a self-retiring backfill; kill-switch row and file table corrected. agent-lifecycle.md: Revision History row. feature-flows.md: hand-added Recent Updates row (the skill drops it past ~400 lines). Note: that table is at 56 rows against its stated ~20 cap (#1360) — pre-existing drift, deliberately not trimmed here. Refs Abilityai/trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#735): re-anchor the lifecycle PAT call-site guard onto _apply_git_env_from_db ent#109 moved GITHUB_PAT derivation out of the inline block in recreate_container_with_updated_config (which the guard anchored on via the comment "Update GITHUB_PAT") into the shared _apply_git_env_from_db. The guard intent is unchanged and still enforced: that block resolves the effective per-agent PAT, never the platform-only get_github_pat(). Also fixes a silent-degradation flaw in the guard itself. str.find returns -1 on a miss, and src[-1:-1+300] slices to an EMPTY string — so a moved anchor made the guard assert "get_github_pat_for_agent in \x27\x27", failing with no hint about why. The anchor is now asserted first with a message naming the fix (re-point it, do not delete it), and the block is sliced to the next top-level def rather than a fixed byte window. Both failure modes proved red by mutation: swapping the helper to get_github_pat() and renaming the anchored function. Refs Abilityai/trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(lifecycle): correct-never-introduce git env; drop the auto-sync backfill (ent#109) Two defects found reviewing the ent#109 PR 1 env seam. 1. The config-drift recreate blackholed push for agents bound post-creation. The repo half of the block is repo-gated (ent#123) while the PAT half keeps #211's narrower per-agent gate, and those two disagree for one real row shape: an agent bound via POST /{agent}/git/initialize on the GLOBAL platform PAT. That path writes an agent_git_config row and pushes, but never recreates the container, never bakes git env, never persists a per-agent PAT row, and never writes the token into the workspace .env — so its only credential is the one embedded in .git/config's origin URL, and startup.sh's #1264 fallback does not cover it. Handing startup.sh GIT_SYNC_ENABLED=true with no GITHUB_PAT is exactly what it reads as "deliberately tokenless": the restart branch rewrites origin to the credential-less CLONE_URL, destroying that token, and configure_push_remote blackholes the push remote — silently, and fleet-wide on the same base-image drift this helper exists to fix. `per_agent_only` now writes the block only when the old container already carried GITHUB_REPO or a PAT resolves. It still corrects a stale repo, a flipped source_mode and a deleted row — every case the fix is about; a tokenless ent#123 agent carries GITHUB_REPO from creation, so the flagship is unaffected. `effective` is exempt: with no old container, NOT introducing the block is the #843/#1439 silently-empty-agent bug. 2. The GIT_SYNC_AUTO backfill erased an owner's explicit disable. PUT /{agent}/git/auto-sync writes the row and nothing else while the agent gates on container env, and creation sets both true for the ordinary non-source-mode PAT agent — so "baked true / DB 0" is also exactly what an owner's disable looks like. The backfill re-enabled it on the next recreate and erased the only record of the intent, so the toggle could never stick. It was a privilege boundary too: PUT .../auto-sync is OwnedAgentByName while POST .../start, which triggers the recreate, is AuthorizedAgentByName — so a shared non-owner, or an agent-scoped key resolving to its owner with the owner's role (trinity-ops-agent#232), flipped an owner-only flag arming a 15-minute background commit-and-push loop. The OR-derivation stays (crud.py's two creation writers genuinely disagree, and DB-only derivation would silently stop auto-push for that slice). The write-back is gone; the disagreement is logged. Making the #389 toggle authoritative remains the tracked follow-up that retires the OR honestly. Tests 17 -> 22: a TestIntroduceGuard class (unbaked container untouched, carried repo still corrected, resolvable PAT still introduces, effective exempt, clear sweep unaffected) and the derive-only assertion. Both fixes proved to have teeth by mutation — removing the guard and restoring the backfill each go red on exactly one test. Two learnings.md entries. Refs trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(ent#109): pin the git-env WRITER SET with an AST guard, not a source grep The previous call-site guard sliced `lifecycle.py` by function header and counted a single-line literal in each half. Two blind spots: 1. It pinned only the two KNOWN sites. ent#109's bug WAS that git env had two writers and one of them was wrong; a THIRD writer added later on any container-seeded path re-opens exactly that hole, and the grep version stayed green through a planted `pat_gate="effective"` writer (verified by mutation). 2. `lifecycle.py` names the helper in two comments, so a substring count read prose as call sites — the same first blind spot the #1871 guard hit. The AST walk maps `{enclosing function: pat_gate literal}` and asserts the set equals exactly `{recreate_container_with_updated_config: per_agent_only, _apply_persisted_auth_env: effective}`. It also fails loud on a non-literal or omitted `pat_gate` and on a duplicate call in one function — each of which would make the guard silently vacuous, which is worse than the leak it guards. Also drops the stale "convergence backfill" wording from the module docstring and the registry entry (d8da9d08 removed the backfill; the description still described it) and re-states the idempotence test as "the DB row is never mutated". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: describe the ent#109 guard as a writer-set gate, not a call-site check Follow-on to the AST guard: `architecture.md` and the `agent-lifecycle.md` change log both said the static guard "fails CI if either call site flips", which understates what it now enforces. It pins the whole writer SET, so a third writer on any container-seeded path fails CI too — the property that matters, since ent#109's bug was two writers with one of them wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(requirements): add github §11.12 post-creation repo binding (ent#109) Trinity Rule #1 — requirements before implementation. §11.12 specifies the "bind to your own repo" retrofit: FR-1 the explicit supported-row table keyed on source_mode (the column the partial unique index actually keys on) with named structural refusals for everything else, FR-2 source_mode preserved at 1 so no branch reservation is needed, FR-3 the destination-scoped fail-closed lock + CAS + compensating restore (never delete_git_config on a pre-existing row — that is destruction, not rollback), FR-4 the PAT persisted last, FR-5 the mandatory recreate because startup.sh rewrites origin unconditionally from baked env, FR-6 owner-only AND human-only with explicit PAT disclosure, FR-7 the no_write_credentials surfaces. Also amends §11.11 FR-5: the tokenless push refusal no longer teaches the create-a-new-agent-and-import workaround. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(fork-to-own): extract the shared destination primitive (ent#109 §4.5) AC #4 asks the post-creation rebind to reuse ent#93's machinery rather than build a parallel path. The seam is NOT the destination triage lifted whole — that is not expressible, because the create path's reuse branch IS the template-tip SHA comparison, interleaved with the triage in one if/elif/else. So the seam is one level lower: inspect_or_create_destination_repo() reports created | empty | branches and never decides. Reuse/refuse POLICY stays in each caller, because the two callers genuinely disagree — the create path compares against a template tip; the rebind has no template, its content source is the agent's workspace volume, so any existing branch is a refusal. validate_destination_pat() is a SIBLING, not folded in: the create path validates the PAT before resolving the template tip, so 'bad PAT + unreachable template' reports FORK_PAT_INVALID. Folding it into the inspect primitive (which runs after the tip resolves) would silently reorder that into a template error. Behaviour preservation is asserted, not claimed: the 40 pre-existing test_fork_to_own.py tests pass unchanged, and both new guards were shown to have teeth — making the primitive refuse instead of report turns the create path's SHA-match reuse red, and swapping the validate/resolve order turns the ordering guard red. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(git): bind an agent to a GitHub repo you own (ent#109) POST /api/agents/{name}/git/bind-to-own-repo — create a user-owned repo from a LIVE agent's current workspace, rebind origin in place, persist the per-agent PAT, and re-bake the container env so the rebind survives a restart. Plus a GET .../status companion so a client that eats a proxy timeout can resolve the outcome from state rather than from a remembered request. Shape, per requirements §11.12: - Orchestration in services/agent_service/repo_binding.py, NOT the router (Invariant #1). It raises BindError and never HTTPException; the router is a thin mapper owning only the two locks, the idempotency claim, and the audit. - Classification partitions on source_mode — the column the partial unique index actually keys on — and refuses every other shape BY NAME rather than mis-routing it. Credential state is an orthogonal column and is not used. - Concurrency: a DESTINATION-scoped lock is the one that serializes the real collision (two different agents, one destination repo); the agent-scoped lock only guards double-submit. Both FAIL CLOSED with 503 + Retry-After — agent_data's fail-open is calibrated for a tar round-trip, not for two repo creates and two concurrent recreates of one container. - The CAS in db.rebind_git_config is the whole commit point, its predicate named in the docstring. The loser path restores the captured previous values; it never calls delete_git_config, which on a pre-existing row is destruction (the next recreate would drop GITHUB_REPO — #843/#1439). - The PAT is persisted LAST and strictly before the recreate: earlier makes the agent look already-writable on a retry, later bakes a repo-bound container with no token that startup.sh then blackholes. - Post-rewire, origin is read back and confirmed — a set-url that exits 0 without taking effect is exactly the silent mismatch AC #5 forbids. - Owner-only AND human-only (reject_agent_principal): an agent-scoped key resolves to its owner carrying the owner's role, so a role gate alone is satisfied by any agent's injected key on a default admin-owned install. Decision #17 (check_github_repo_env_matches) is deliberately CUT: the only drift-proof way to build it is to call _apply_git_env_from_db, which turns PR 1's AST writer-set guard red, and idempotent retry already supplies the convergence it was meant to buy. BIND_RECREATE_FAILED states the retry path instead of a convergence promise, and warns against a plain restart. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(git): cover the repo-binding commit point, ordering and secret hygiene (ent#109) 31 tests over the properties that would otherwise only be true by inspection: - Classification: the supported shape succeeds; every other shape is refused BY NAME. Two cases asserted rather than argued — an already-writable agent is an ORDINARY rebind (the refusal that used to sit there is what made the documented retry unreachable), and trinity-system is refused through the no-git-config path so it never reaches the recreate that bypasses #1816's running-system gate. - Commit point: a moved row yields 409 with nothing partial, and the post-commit loser is RESTORED to its captured previous values. Asserts delete_git_config is never called — on a pre-existing row that is destruction, and the row is asserted to still exist afterwards. - Ordering: rebind -> pat -> recreate, proven by recorded call order. A push failure persists no PAT; a PAT-persist failure blocks the recreate; and fail-at-push -> retry -> success is an explicit regression test for the contradiction that a 409-on-retry used to produce. - The CAS statement runs against a REAL SQLite engine, not a double — the predicate is the whole safety argument, so a stub cannot verify it. Includes two racers reading the same expected value: exactly one wins. - Secret hygiene: the PAT is absent from the outcome, the audit dict and every error path, and a stale baked token in git output is redacted too. That last group found a real defect, now fixed: repo_binding composed its failure messages from foreign text (git output, a docker exception) and relied on the producer having scrubbed. git_service scrubs what it reads from a container, but the docker and GitHub exception paths arrive through libraries that never saw the token. Added _scrub() as a belt at the boundary where the PAT is actually in scope. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(git): retire the no_write_credentials create-a-new-agent workaround (ent#109) ent#230's sharpest AC, which ent#109 omitted: the no_write_credentials surfaces must point at the retrofit once it exists. Both change together (Invariant #13): - git_service.NO_WRITE_CREDENTIALS_MESSAGE (consumed by sync_to_github and reset_to_main_preserve_state, mapped 409 in routers/git.py) - the MCP 409 hint in src/mcp-server/src/tools/git.ts Neither now teaches 'create a new agent with fork-to-own and import your data' — an instruction that discards the agent's identity, its 180-day name reservation and its history. ent#123's carve-out is preserved: this branch still suppresses the chat_with_agent remedy, because a chat turn cannot conjure credentials. The third surface — startup.sh's push-remote blackhole sentinel — is deliberately unchanged and now asserted as such: it is a git remote URL (one shell-safe token) that already names a remedy, and editing it would force a base-image rebuild for cosmetics. The parity guard was teeth-checked in both directions: reverting the MCP hint turns it red, AND breaking the source anchor turns it red with a named error rather than silently asserting against an empty slice — the way a source-grep guard usually dies. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(ui): 'Bind to your own repo' panel on the Git tab (ent#109) A new BindRepoPanel.vue mounted in GitPanel.vue rather than more markup inside it, for two reasons: GitPanel is already 639 lines, and #1430's raw-color ratchet is PER FILE — appending a form there would raise counts that may only shrink. GitPanel's numbers are unchanged at 24 nongray / 146 gray; the new panel is at ZERO raw non-gray with 51 semantic tokens (its 46 grays are the contract's own surface/ink vocabulary — there are no Base* primitives in the repo yet to absorb them). Design-system contract (read first, per CLAUDE.md rule #10): semantic tokens only (action-primary / status-success / status-warning / status-danger), both themes first-class, gray-750 for dark chrome, and no dark:text-gray-500 — the dark ink floor. Behaviour worth noting: - The store method uses raw axios with an explicit 300s timeout, following the surrounding idiom. It deliberately does NOT use api.js, whose instance-wide 30s timeout is far below this call's worst case; aborting the client mid-bind strands the user past the commit point with no response, which is the exact situation the status endpoint exists to rescue rather than manufacture. - The PAT is read out of the reactive ref BEFORE the await and cleared immediately, so it never lingers regardless of how the request ends. - A client timeout is reported as PARTIAL, never as a clean failure — the request may well have landed. - Post-commit failures render as 'Partly applied — action needed' in warning colour rather than as an error, because the binding genuinely IS saved and telling the user it failed would send them looking in the wrong place. - The restart warning states what happens, what is preserved, and how long. Both SFCs verified against the real @vue/compiler-sfc (parse + script + template); npm run check:tokens passes. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: architecture + feature flow for post-creation repo binding (ent#109) - architecture.md: two endpoint rows, a Post-Creation Repo Binding subsystem block, the two Redis lock keyspaces, repo_binding.py + git_service's new primitives in the service catalog, the shared destination seam on the fork_to_own entry, and the ent#123 paragraph tail now that its no_write_credentials refusal points at the retrofit. PR 1's _apply_git_env_from_db prose is already present on this branch and was NOT re-added. - New feature-flows/agent-repo-binding.md: the end-to-end trace, the five decisions that carry the design (source_mode partition, destination lock, CAS + restore-not-delete, PAT-last, mandatory recreate), the error registry with which codes are partial, the ent#93 sharing seam, security, and known limits — including why Decision #17's drift predicate was cut. - feature-flows.md: Recent Updates row added BY HAND (/sync-feature-flows drops it past ~400 lines) plus the category-table entry. - Cross-linked the three affected flows: github-sync.md and mcp-git-tools.md had the retired workaround quoted verbatim in their prose, and github-repo-initialization.md now names its post-creation sibling and the boundary between them. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(git): cover the bind ENDPOINT surface (ent#109) /update-tests review found the router layer uncovered — its own rule says a new or changed endpoint needs a caller that exercises the path params and auth dependency (#1069's 422-every-call class). 26 tests over the five things no service-level test can see: - reject_agent_principal really called, and wired in the handler rather than merely imported (an agent-scoped key resolves to its owner CARRYING the owner's role, so an owner/role gate alone is satisfied by any agent's injected key on a default admin-owned install) - route path-param matches the handler parameter, for both routes - locks FAIL CLOSED on a Redis outage, on a raising SETNX, and on contention; the destination key is case-folded; locks release on success AND failure - idempotency key is verb-folded; absent header derives nothing; in-flight 409; completed replay returns the snapshot with X-Idempotent-Replay - audit on EVERY exit path incl. lock contention and the unexpected 500 Also fixes a regression this work introduced: test_ent123_tokenless_clone.py asserted the literal retired wording of NO_WRITE_CREDENTIALS_MESSAGE, and I had not re-run that suite after changing the shared constant. Re-anchored on the CONSTANT plus the invariant ent#123 actually cares about (named message, still actionable) — stronger than before, and it cannot drift again; the exact copy is owned by test_ent109_no_write_credentials_message.py. Both new guards mutation-verified: deleting reject_agent_principal and making the lock fail open each turn two tests red. Full unit suite: 6350 passed, 14 skipped, 0 failed. Identical under random and fixed order (no sys.modules pollution across the new modules). Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(flows): /sync-feature-flows pass over the ent#109 binding surface Verification of the hand-written docs against the code found two gaps: - template-processing.md described the fork-to-own copy pipeline's steps 1-2 as living inline in fork_to_own.py. They are now the SHARED half (validate_destination_pat + inspect_or_create_destination_repo), so a reader tracing the code would have found the triage in a different function than documented. Updated to name the seam and why it sits one level below the triage, with the reuse/refuse policy explicitly still owned by that caller. Behaviour there is unchanged. - The new flow's error registry was missing three codes that ARE reachable on the bind path: FORK_DESTINATION_UNREACHABLE (shared primitive), BIND_DESTINATION_UNREACHABLE (fail-closed guard-read failure) and BIND_UNEXPECTED_ERROR (router catch-all). Verified by diffing the codes in the source against the codes in the doc; the seven still absent are create-path-only and correctly omitted. Checked and deliberately NOT changed: git-sync-health.md and dark-mode-theme.md reference the touched files but document nothing this PR alters. The Recent Updates table is 66 rows against its own stated ~20 cap — pre-existing drift (65 before this PR); trimming 46 of other people's entries is unrelated churn on a feature PR. Refs ent#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(git): converge the documented bind retry; stop a PAT leaking on rejection (ent#109) Fixes from /review (C1, I1, I3, I6, I2) and /cso --diff (S1) on PR 2. ── /review C1: every post-commit failure promises an idempotent retry, and all four are refused ────────────────────────────────────────────────────────── The CAS is the commit point, so after it the row names the destination while the container's origin still names the old repo — and both pre-flight gates read that skew as a refusal: push/rewire fail -> row moved, origin did not -> 409 BIND_STATE_UNCLASSIFIED PAT/recreate fail -> destination holds our own pushed history -> 409 BIND_DESTINATION_EXISTS The §0.4 class the plan wrote a section to eliminate for the PAT ordering, re-entering through the classification guard. The vestige was in the signature: `_classify(agent_name, destination_repo)` never used `destination_repo` — the carve-out had been designed and not written. A row already naming the requested destination is now a resumption: * origin may lag — it never selects what is pushed (step 4 pushes refs/heads/<branch> from the workspace by explicit URL, writes origin after), and it cannot be tightened anyway: a committed CAS has overwritten the old repo name, so "still the old repo" and "something else" are indistinguishable, and treating the ambiguity as fatal strands the agent. * existing branches are accepted — bounded by git, not trust: the push carries no --force and no `+` refspec, so unrelated history is rejected non-fast-forward and an unrelated branch is untouched. * previous_repo=None on a resume leaves `upstream` alone instead of repointing it at the destination itself, erasing the provenance the rebind preserves. A mismatch against any OTHER repo stays BIND_STATE_UNCLASSIFIED. The regression test written for exactly this was green because its double returned `origin_repo=fake_db.config.github_repo` — the container's observed state WAS the row, so they could never disagree — and a hand-set `dest_state = "empty"` stepped around the other gate. The fixture now tracks the container independently and mirrors the real side effects. ── /cso S1: a GitHub PAT reaches the response body and the platform log ────── A PAT is sent as `Authorization: Bearer <pat>`, and h11 rejects an illegal header value by ECHOING it (verified: `LocalProtocolError: Illegal header value b'Bearer ghp_...\r'`). The validator only checked non-emptiness and returned the value UNSTRIPPED, so a token carrying a trailing \r or \n — what a paste from a terminal or clipboard routinely produces — surfaced raw in a 500 body and, via logger.exception, in the Vector-captured platform log. Trigger is far more often an ordinary paste than an attacker. * `models._validate_pat_secret` strips whitespace and rejects anything outside printable ASCII, on BOTH BindAgentRepoRequest and ForkToOwnRequest (ent#93's create path feeds the same GitHubService constructor). * That alone would only RELOCATE the leak: Pydantic v2 records the rejected value in errors()["input"] and FastAPI returns exc.errors() verbatim — proven against a real TestClient. `error_handlers.validation_error_without_input` strips `input` from every 422 entry. Dropped for all fields, not for names that look sensitive: a name allowlist is the new-producer-missing-from-the- consumer's-list class, and the caller already has the value they sent. * The router catch-all and the PAT-persist log line now scrub, and the dual-scrub itself collapses from two copies into one home in `utils/credential_sanitizer` (fork_to_own re-exports for its callers). ── Also ───────────────────────────────────────────────────────────────────── * The bind is `recreate_container_with_updated_config`'s SECOND production call site and skipped the `clear_agent_breakers` that `start_agent_internal` runs immediately before its own call — both breakers are agent-name-keyed with no TTL, so the replacement container inherited its predecessor's verdict (#1560). Cleared before the recreate, not after. Two stale "one production caller" claims corrected. * Audit rows on the two idempotency-replay exits, so "exactly once per exit path" (#905) is literally true. * Client timeout resolves against the status endpoint instead of telling the user to reload the tab. * Five test modules registered in tests/registry.json. Each of the six behaviour fixes was mutation-checked (revert -> red -> restore), including the breaker clear in both directions (absent, and after the recreate). Verified: 6332 backtest unit tests pass; the original C1 probe — written before the fix and unchanged — now reports both post-commit shapes converging; frontend `vite build` and the design-token check pass; GitPanel's raw-color counts are unchanged from baseline and BindRepoPanel is at raw_nongray 0. Refs Abilityai/trinity-enterprise#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(git): assert the bind routes are wired to the enumeration-safe deps (ent#109) Plan §7 lists "uniform 404 for unknown *and* inaccessible agent (Invariant #8)" as a PR 2 case, and it was the one bullet with no test behind it. The 404 BEHAVIOUR is not re-tested here — `test_186_enumeration_uniformity.py` already proves parametrically that both helpers evaluate existence and access before branching, so nonexistent and inaccessible come back byte-identical. Re-asserting that would only re-test the shared dependency. What no dependency-level test can see is whether *this* endpoint routes through it. So the assertion is the identity of the callable actually bound to `agent_name` on each route — `get_owned_agent_by_name` on the mutating verb, `get_authorized_agent_by_name` on the read-only status verb — mirroring the existing `reject_agent_principal(current_user)` getsource guard: an annotation that merely looks right in a diff, or a hand-rolled lookup with a 404-then-403 split, is how the enumeration oracle gets reintroduced. The two scopes are not interchangeable, so both are pinned: swapping them would either lock a shared reader out of a surface the Git tab already shows them, or let one rebind an agent they do not own. Not vacuous: the two dependencies are distinct objects, so binding the wrong one fails the assertion. Route introspection goes through `route.dependant`, not `get_flat_dependant` — that symbol drifts in the verify venv. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
This was referenced Aug 9, 2026
getsal
pushed a commit
to getsal/trinity
that referenced
this pull request
Aug 15, 2026
…ge + raw-color scanner & ratchet baseline Stands up the frontend design system's written layer per Abilityai#1430: - docs/memory/design-system.md — system of record: token taxonomy, both-theme rules incl. the dark ink ladder, type/spacing/radius scales, 10-primitive catalog with exact token recipes, the data-loading motion standard (scanline beam + wipe reveal; first load animates, background refresh is invisible), and 28 UI Construction Principles - docs/memory/design-system-contract.md — condensed binding contract to load before any src/frontend change - docs/memory/design-system-reference.html — approved visual spec (self- contained; both themes; live motion demo) - src/frontend/scripts/scan-raw-colors.mjs — raw-color scanner aligned with check-design-tokens.mjs token families - src/frontend/raw-color-baseline.json — ratchet seed at dev@5b28999: 753 raw non-gray / 383 hardcoded colors; counts may only shrink - CLAUDE.md — Rule of Engagement Abilityai#10 + Memory Files row making the design system the mandatory reference for frontend work Refs Abilityai#1430 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 tasks
5 tasks
vybe
pushed a commit
that referenced
this pull request
Aug 16, 2026
…at let the table rot (#2207) Codex reports no cost of its own, so `calculate_codex_cost` IS the number Trinity records — it feeds execution rows, analytics, cost alerts, and the loop budget `max_cost_usd` (#1155). A budget is a spend CONTROL, so the stale table was a control bypass, not a cosmetic metric error. CODEX_PRICING was stranded on the gpt-5.1 generation, so everything newer prefix-matched `gpt-5` at $1.25/$10. Measured on a real turn: 3.6x under for gpt-5.6-sol, and 21x under for gpt-5.5-pro (a model the issue did not list). The rows were the symptom; the matcher was the defect. `_resolve_pricing` used a bare longest-prefix `startswith`, which makes every key a catch-all for future generations — a `gpt-5.7-*` would have resolved to the oldest, cheapest entry, re-arming the identical bug with this fix already merged. The prefix match is now boundary-aware: `-` is a variant/date suffix, `.` is a new version. Deliberately NOT unified with `model_context._FAMILY_PREFIX_WINDOWS`, which keeps its bare `startswith` because there falling through picks the smallest window (over-reports usage = safe) while here it picks the cheapest rate (under-bills = dangerous). Both files now carry a comment saying so. - Rates restated per 1M tokens, exactly as OpenAI publishes them, so the table diffs against the rate card by eye; adds the 5.6 family, 5.5, 5.4, and the -pro tiers. Long-context tier (>272K input -> 2x in / 1.5x out) implemented. - Cache writes are now priced. They were documented as unobservable; a real turn showed `cache_write_input_tokens` present, a SUBSET of `input_tokens` (the `reasoning_output_tokens` trap again) and 99.97% of that turn's input. - `default` is the flagship rate, not the cheapest: an unpinned agent reaches it on EVERY turn, because `thread.started` carries no model id, while the CLI really runs gpt-5.6-sol. The template pins the model for the same reason. - Context window: the 5.6 family is 1,050,000. The old flat 272K is a PRICE break, not a window — conflating them made the gauge read ~3.9x too full. - `@openai/codex` 0.139.0 -> 0.147.0 (needs a base-image rebuild). Verified against the live rate card and a live `GET /v1/models`, which also corrected the issue's headline premise: `gpt-5.1-codex` still resolves for API-key callers (Trinity's auth mode), so that pin was stale, not broken. Real chat + task turns completed end-to-end on the rebuilt image and were priced by Trinity's own parser (AC #10): chat $0.071401, task $0.079468 with all three input components exercised — both reconcile to the card exactly. Filed #2208 separately: Codex API-key auth is inert (nothing writes $CODEX_HOME/auth.json, so every turn 401s). Pre-existing on both CLI versions, found while verifying this issue, out of scope here. Fixes #2207 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Aug 16, 2026
…at let the table rot (#2207) (#2209) * fix(codex): price every current GPT-5.x model, and fix the matcher that let the table rot (#2207) Codex reports no cost of its own, so `calculate_codex_cost` IS the number Trinity records — it feeds execution rows, analytics, cost alerts, and the loop budget `max_cost_usd` (#1155). A budget is a spend CONTROL, so the stale table was a control bypass, not a cosmetic metric error. CODEX_PRICING was stranded on the gpt-5.1 generation, so everything newer prefix-matched `gpt-5` at $1.25/$10. Measured on a real turn: 3.6x under for gpt-5.6-sol, and 21x under for gpt-5.5-pro (a model the issue did not list). The rows were the symptom; the matcher was the defect. `_resolve_pricing` used a bare longest-prefix `startswith`, which makes every key a catch-all for future generations — a `gpt-5.7-*` would have resolved to the oldest, cheapest entry, re-arming the identical bug with this fix already merged. The prefix match is now boundary-aware: `-` is a variant/date suffix, `.` is a new version. Deliberately NOT unified with `model_context._FAMILY_PREFIX_WINDOWS`, which keeps its bare `startswith` because there falling through picks the smallest window (over-reports usage = safe) while here it picks the cheapest rate (under-bills = dangerous). Both files now carry a comment saying so. - Rates restated per 1M tokens, exactly as OpenAI publishes them, so the table diffs against the rate card by eye; adds the 5.6 family, 5.5, 5.4, and the -pro tiers. Long-context tier (>272K input -> 2x in / 1.5x out) implemented. - Cache writes are now priced. They were documented as unobservable; a real turn showed `cache_write_input_tokens` present, a SUBSET of `input_tokens` (the `reasoning_output_tokens` trap again) and 99.97% of that turn's input. - `default` is the flagship rate, not the cheapest: an unpinned agent reaches it on EVERY turn, because `thread.started` carries no model id, while the CLI really runs gpt-5.6-sol. The template pins the model for the same reason. - Context window: the 5.6 family is 1,050,000. The old flat 272K is a PRICE break, not a window — conflating them made the gauge read ~3.9x too full. - `@openai/codex` 0.139.0 -> 0.147.0 (needs a base-image rebuild). Verified against the live rate card and a live `GET /v1/models`, which also corrected the issue's headline premise: `gpt-5.1-codex` still resolves for API-key callers (Trinity's auth mode), so that pin was stale, not broken. Real chat + task turns completed end-to-end on the rebuilt image and were priced by Trinity's own parser (AC #10): chat $0.071401, task $0.079468 with all three input components exercised — both reconcile to the card exactly. Filed #2208 separately: Codex API-key auth is inert (nothing writes $CODEX_HOME/auth.json, so every turn 401s). Pre-existing on both CLI versions, found while verifying this issue, out of scope here. Fixes #2207 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(architecture): Codex context window is family-dependent, not a flat 272K The context-window catalog entry stated `Codex→272K` unconditionally. This branch makes that family-dependent — gpt-5.6 (sol/terra/luna) resolves to the verified 1,050,000 window, and only the legacy families keep 272K — so the catalog now names a value that is wrong for the very model this same commit pins as the test-codex template default. Corrected in place rather than left for /validate-architecture: the stale number is the exact confusion #2207 documents (272K is the long-context PRICE break, not a universal ceiling). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the two remaining flat-272K Codex window claims The context-window catalog is restated in three places besides architecture.md. The branch already updated agent-runtimes.md and codex-runtime.md; these two still asserted a single Codex window: - feature-flows/model-selection.md — restates the fallback ladder verbatim - faq/chat-and-sessions.md — user-facing, said "Codex around 272K" Both now name the gpt-5.6 family separately. A catalog that disagrees with itself across four files is how the original stale value survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Aug 16, 2026
…) (#2231) * refactor(auth): retire slack.py inv8 carve-out — migrate 11 gates to #1310 helpers (#1710) Migrate all 11 inline auth gates in routers/slack.py onto the existing #1310 imperative guard helpers and delete all 11 `# noqa: inv8` markers, closing the expiry-less waiver #1310 left behind. - 3 read gates → assert_agent_access; 8 owner gates → assert_agent_owner. - Every detail= string preserved verbatim (byte-identical 403 body) — this is a wiring consolidation, not a policy change (AC #6). All sites are access-first, so they stay 403/self-uniform per Invariant #8 (imperative helpers, not the path-dependencies that would flip denial to 404). - Site #10 (set_slack_channel_proactive) keeps its ent#223 reject_agent_principal(current_user) human-only guard; site #11 stays agent-callable (deliberate grant-vs-send asymmetry). The helpers add _enforce_connector_scope first, a no-op for the REST flow (connector keys are already fenced at the auth entry point). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(architecture): drop the retired slack.py inv8 deferral clause (#1710) Invariant #8's permanent-exceptions list no longer names slack.py as a `# noqa: inv8`-marked deferred follow-up — the carve-out is retired. No new prose: the invariant already carries "Access-first inline handlers ... stay 403", which documents why the migrated slack sites keep imperative-403. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): stop calling the noqa hatch "the deferred slack.py sites" (#1710) The slack.py sites are migrated onto the shared helpers, so no `# noqa: inv8` marker remains in the tree. Update both docstrings (module + the live-tree guard) to describe the marker as a general, individually-reviewed escape hatch rather than a slack-specific deferral. The mechanism (`_line_has_noqa` + `test_noqa_inv8_suppresses_line`) stays dormant for a future exception. Guard verified green: 12 passed, zero slack offenders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): behavioral proof for the 11 migrated slack gates (#1710) Add section A9 to the #1310 consolidation suite — the behavioral half the AST wiring guard is blind to. Over all 11 migrated sites, driving the real db.can_user_* predicates against a temp schema (db_harness): - stranger → 403 + the exact per-site detail (catches a forgotten `detail=`); - a shared (non-owner) reader is ADMITTED on the 3 read gates and DENIED on all 8 owner gates (catches a mis-classified assert_agent_access/owner); - owner and admin are never denied by the migrated gate; - site #10 keeps its ent#223 human-only reject (agent principal → 403 with the human-only detail) while site #11 stays agent-callable (grant/send asymmetry). Verified adversarial: read→owner swap, detail drift, and dropping the site-#10 reject each break the matching test. 46 passed, order-independent across seeds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): runtime human-only proof for the site-#10 consent toggle (#1710) The existing ent#223 guard is source-grep only (`"reject_agent_principal" in src`) — it catches outright deletion but not a behavioral/ordering regression, and after #1710 the surrounding gate is a plain assert_agent_owner call the AST guard can't see. Add a runtime case: an agent-scoped principal calling set_slack_channel_proactive gets 403 + the human-only detail, with every post-gate db call (owner check + mutation) made loud to prove the reject fires first. Keep the source-grep as a cheap backstop. Verified adversarial: dropping the reject line fails both. 10 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(feature-flows): re-sync slack Authorization Checks table with #1710 The migration moved slack.py's auth gates off inline `db.can_user_*` onto the shared #1310 helpers, so a reader following the doc to slack.py no longer finds the predicate inline. Update the Authorization Checks Method column to name the helper (and the predicate it wraps), refresh the stale File:Line values to the current gate lines, and add a Revision History row. Behavior-preserving — the 403 + detail is unchanged. Index untouched (existing flow, name/desc unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: re-sync auth.md + feature-flows index with the retired slack inv8 carve-out (#1710) The #1310 follow-up updated architecture.md and slack-integration.md but two live docs still described the carve-out as active: - requirements/auth.md still said 'slack.py deferred (10 sites) ... each carries a # noqa: inv8 marker' — false after the migration, and it reintroduces the exact 'considered exception vs abandoned TODO' ambiguity #1710 exists to kill. - feature-flows.md index had no #1710 row (the flow doc's own changelog was updated, the index was not). Docs-only; no code change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): fix test_1649 slack router fixtures for the migrated owner gate (#1710) The 3 TestRoutersPersist cases call send_agent_slack_channel_message, now gated by assert_agent_owner → _enforce_connector_scope, which reads current_user.connector_agent. The SimpleNamespace fakes lacked that field, raising AttributeError before can_user_share_agent. Add connector_agent=None (a real non-connector User carries it); owner admission stays byte-preserving. Proven load-bearing: the 3 cases fail on the pre-fix fixtures, pass with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
vybe
added a commit
that referenced
this pull request
Aug 17, 2026
* test(e2e): List-mode spec + five specs adjusted for the retired Agents page (ent#260)
NEW dashboard-list-view.spec.js: system-row render (data-agent hooks, SYSTEM
badge, detail link; deliberately no toggle-visibility assertions — CI's only
agent is the guarded system agent), AC-4 mode persistence, AC-2 redirect
(param stripped, saved mode NOT rewritten), ?onboarding=1 survives the
redirect + opens the wizard, name-filter narrowing + filtered-empty Clear-all
recovery, three-mode round-trip.
smoke.spec.js: Agents nav-link assertion dropped (nav list comment updated);
'agents page loads' becomes the redirect assertion. browser-tab-titles: the
Agents hop removed from the SPA chain (Dashboard → Templates still proves the
client-side repaint); /agents → 'Trinity — Dashboard' joins the redirect-title
test. dashboard-grid-view: header comment (three modes; exact-name toggle
selectors unaffected). dashboard-stats-overflow: 'list' added to MODES — the
third toggle + chassis Create button widen the controls cluster in every mode.
navbar-overflow test 3: the 640px squeeze branch is now conditional on the
MEASURED overflow (4-link OSS bar may fit where 5 links overflowed; an
entitled build still exercises the scroll-recovery branch) — sm=640 is the
floor, below it the link row hides, so narrowing further was not an option.
Full e2e run defers to CI (frontend-e2e on the ui label) — the live local
stack is not exercised from this worktree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: Dashboard List view — architecture block, new feature flow, index + superseded banner (ent#260)
architecture.md: Grid-view paragraph reworded to three modes; new Dashboard
List view block (AgentListPanel extraction, visibleAgents seam ownership,
N+1 deletion, redirect + NavBar consolidation, zero backend change).
NEW feature-flows/dashboard-list-view.md (overview, data-flow diagram,
decisions D1/D2/D7/D8/D11 + the two flagged plumbing calls, teardown
state-loss note, testing). dashboard-grid-view.md mode-count wording.
agents-page-ui-improvements.md superseded banner (kept as history).
feature-flows.md: List-view index row, Agents-page row → superseded pointer,
changelog entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(flows): repoint cross-cutting flow docs off the retired Agents page (ent#260)
/sync-feature-flows coverage pass over the full branch diff: eleven flow docs
carried live current-state claims about views/Agents.vue (avatar/toggle/meter
usage tables, sync-health dot renderer, system-agent display, tag bulk-ops
entry points, skills-on-start entry, dark-mode coverage row, timeline-view
mode-toggle description). Live references now point at
components/AgentListPanel.vue / the Dashboard chassis; dated changelog entries
and explicitly-labelled historical sections are left as history. Also fixes
dashboard-timeline-view.md's pre-existing stale '[Graph] [Timeline]' toggle
claim to the current three-mode set.
Touched: agent-avatars, agent-lifecycle, agent-tags, autonomy-mode,
autonomy-toggle-component, dark-mode-theme, dashboard-timeline-view,
git-sync-health, internal-system-agent, parallel-capacity, read-only-mode,
skills-on-agent-start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): review fixes — Create-button icon-only degrade + gated fetch write-through (ent#260)
Two review-stage fixes on the ent#260 branch:
1. Create Agent label degrades to icon-only below `md` (title +
aria-label kept). The controls cluster is flex-shrink-0; measured
against the #1830 stats-ladder constants (agents-only floor 71px),
the full label at 640px in grid mode (+ the third mode button)
leaves ~47px — the stats-overflow spec's clip assertion would fire.
This is the plan's pre-decided degrade, applied ahead of CI.
2. The fetchAgents → agentsStore.agents write-through is now gated on
no active quick-tag filter (params.tags narrows the response
server-side; a filtered subset must not clobber the full-fleet list
agentsStore consumers read — the Executions dropdown's cold-start
self-heal is length===0-gated and never recovers from a non-empty
wrong value) and writes a shallow copy (array identity per store,
shared row objects so in-place status/label patches still propagate).
Docs synced (architecture block, flow doc, agents.js contract comment)
+ a learnings.md entry for the write-through class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(requirements): §9.9 seam wording matches the implemented deviation (ent#260)
The plan's D8 wired both convertAgentsToNodes call sites through the
visibleAgents computed; the implementation deliberately deferred the
timeline wiring to ent#261 (ReplayTimeline :agents prop switch —
rewiring the node paths was rejected as timeline-mutation risk) and the
code comment + flow doc + architecture all record that. §9.9 still
claimed the node-rebuild call sites consume the seam — align it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(frontend): rename Templates.vue to Library.vue (pure move) (ent#263)
Byte-pure git mv with zero content edits so git rename detection holds
and a parallel edit to Templates.vue (ent#260) resolves as a content
merge inside Library.vue rather than a modify/delete conflict. All
content changes land in follow-up commits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(frontend): Library page + route/nav rename with /templates redirect (ent#263)
- Library.vue: h1 'Library', new subtitle, templates content wrapped in an
'Agent Templates' section (own loading/error/empty states; inner headings
demoted h2->h3); fetch migrated to the shared api client (Invariant #7)
- router: /library route (meta.title Library) + /templates function-form
redirect carrying query AND hash; route name Templates->Library (no named
pushes exist)
- NavBar: label Library, to=/library, active via startsWith('/library')
- CreateAgentModal: its single raw-axios /api/templates call migrated to the
shared api client so no half-migrated consumer of the endpoint remains
Page-identity naming only (AC#4 reading): the asset-kind noun 'template'
survives inside the Library (Starter/GitHub Templates sections, Use Template).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(frontend): Library skills section — fleet browse over the skills library (ent#263)
- stores/skillsLibrary.js (new): fleet-scoped store, deliberately separate
from stores/skills.js (KeepAlive-cached AgentDetail means SkillsPanel's
clear() never fires on nav-away — shared refs would poison the cached tab);
imports nothing from stores/skills.js. 4-state emptyReason discriminator
(unconfigured/not_cloned/empty + error carried separately); sync() with a
180s timeout and ECONNABORTED -> status-refetch (a first clone can outlive
the 30s api.js default; client timeout != server failure)
- components/LibrarySkillsSection.vue (new): sync-state header leads with
commit_sha + skill_count (disk-derived; last_sync is per-worker in-memory
and renders only when truthy); repo URL admin-only, userinfo-stripped,
labeled 'Primary source', hidden when status.sources reports >1 (#1901
forward-compat); admin Sync now; per-kind empty states teaching the next
action; dormant source_name/shadowed_by slots; interpolation only
- components/skills/{SkillContractChips.vue,contract.js} (new): the #183
contract-chips seam extracted from SkillsPanel so both the per-agent tab
and the Library browse render package facts from one seam
- SkillsPanel.vue: consumes the shared seam (local SkillMeta/formatBytes/deps
removed); stores/skills.js untouched
- Library.vue: skills section wired in + header jump anchors (no ?kind=)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: Library e2e anchors + remove dead template-endpoint tests (ent#263)
- smoke.spec.js: '@smoke library page loads' asserts chrome-only getByRole
headings (h1 Library + both section headings — the old getByText(/template/i)
passed on the un-renamed page and proved nothing); new '@smoke templates path
redirects to library'; stale nav comment fixed (Health/Ops merged in #1109)
- browser-tab-titles.spec.js: nav click asserts 'Trinity — Library'; redirect
test also covers /templates -> /library title resolution
- tests/test_templates.py: remove TestEnvTemplate + TestTemplateRefresh —
GET /api/templates/env-template and POST /api/templates/refresh no longer
exist (router has exactly 2 GET routes; live-stack probe returns 404);
test_get_template_by_id now exercises the REAL detail endpoint instead of
the dead env-template detour that always skipped. 5 remaining tests pass
against the live stack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: Library page — requirements, architecture, feature flow, stale-endpoint rot sweep (ent#263)
- requirements/core-agent.md: new §4.5 Library Page — unified /library surface
(agent templates + fleet skills browse), query+hash-preserving /templates
redirect, stacked sections, per-kind empty states, the AC#4 page-identity
naming rule; fleet assignment visibility named as Not Built
- requirements/skills.md (surgical — §21.3/§22.2/new §22.3 only, avoiding PR
#1901's §21.1/§21.5 hunks): §21.3 stale 'Skills tab is hidden' note corrected
(visible since ent#235/PR #1877); §22.2 rewritten as visible/rebuilt; new
§22.3 Library Page fleet skills browse — browse-only over the existing
/api/skills/library reads, own skillsLibrary store + the KeepAlive rationale,
admin-only URL/Sync, #1901 forward-compat, assignment read = Not Built
- architecture.md: 'Top-nav IA — Library (ent#263)' paragraph beside the #1109
Operations one; stale 'Templates (4 endpoints)' table corrected to the 2 real
routes (POST /refresh AND GET /env-template both verified absent)
- feature-flows: templates-page.md git-mv'd to library-page.md + full rewrite
(the old file was deeply stale — AgentSubNav, dead endpoints); index row +
platform-settings.md Related-Flows link repointed
- template-processing.md + CREDENTIAL_MANAGEMENT.md: dead env-template
endpoint references removed/replaced (same rot class as the architecture
table); Templates.vue references repointed at Library.vue
- user docs (creating-agents.md, faq/agents.md): Templates page → Library
(+ redirect note)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(requirements): TGRAM-PROGRESS — Telegram in-progress status indicator (ent#264)
New §15.1h: reaction ack + elapsed-time placeholder + channel-agnostic
start/progress/resolve seam, group gating (mention/reply OR all-mode;
observe stays silent), degradation ladder, default-ON per-binding toggle,
GET /telegram access hardening. §15.1c/§15.1e touch-ups.
Requirements-first per Rules of Engagement #1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(channels): in-flight progress indicator seam + Telegram reaction ack and elapsed-time placeholder (ent#264)
Channel-agnostic start/progress/resolve seam (Invariant #9):
- base.py: default-no-op indicate_progress hook + progress_threshold_seconds/
progress_interval_seconds capability attrs (None => the router never arms a
driver, so Slack/WhatsApp/VoIP behave byte-identically).
- message_router.py: router-owned per-turn driver — _arm_progress_driver after
step 8 (call + arm wrapped so a raising hook can never abort the turn),
_progress_loop (elapsed origin captured BEFORE the threshold sleep; per-tick
try/except), _resolve_indicator at all three terminals (cancels AND awaits
the driver dead, settles the shielded in-flight placeholder send, THEN
indicate_done — closes the tick-after-resolve and resolve-vs-first-send
races by construction; carries success for the adapter's neutral fallback
line), and an idempotent try/finally _cancel_progress_driver backstop.
- telegram_adapter.py: indicate_processing upgraded (single binding read,
per-turn cfg stash, typing preserved verbatim, 👀 reaction ack — gated on
the default-ON per-binding toggle and ack-eligibility; whole body never
raises); NEW indicate_progress (placeholder send-then-edit, message_id
recorded INSIDE the shielded helper, disable_notification, explicit HTML,
2-consecutive-failure degraded flag over sends/edits/timeouts alike) and
indicate_done (clear reaction at every terminal — no success-👍 swap;
delete placeholder, neutral edit-to-done fallback); fail-soft primitives
_set_message_reaction/_edit_message_text/_delete_message/
_send_placeholder_message mirroring _send_message's shape (429 retry_after
capped 30s; never log the token-bearing URL).
- parse_message stashes progress_ack_eligible: DMs, @mention/reply group
turns, and `all`-trigger-mode groups; observe mode stays typing-only.
- All per-turn state on NormalizedMessage.metadata (the adapter is a shared
singleton); decrypt_telegram_bot_token facade passthrough so the hook
decrypts from the row already in hand.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(telegram): per-binding progress-indicator toggle — dual-track migration, API, UI (ent#264)
Default-ON toggle for the in-progress indicator, per-agent == per-binding:
- Dual-track migration (Invariant #3 / Rule #9), all six touch points:
SQLite `telegram_progress_indicator` in db/migrations.py + Alembic
0031_telegram_progress_indicator (down_revision 0030; renumber-at-rebase
rule vs ent#265 applies to whichever PR merges second) + schema.py +
tables.py DDL + _BINDING_COLUMNS + _row_to_binding. No backfill UPDATE:
ADD COLUMN ... DEFAULT 1 populates existing rows (default ON is the AC).
- Read predicate evaluated in Python, never SQL (`NULL != 0` is NULL):
enabled ⇔ `v is None or v != 0` — only an explicit 0 disables.
- db: set_progress_indicator_enabled + set_telegram_progress_indicator facade.
- API: GET /api/agents/{name}/telegram surfaces progress_indicator_enabled
(pinned in the hand-built response, #1809 lesson — configure PUT too) and is
access-hardened get_current_user → AuthorizedAgentByName (the response
carries webhook_url, which embeds the webhook secret — previously readable
by ANY authenticated user; uniform-404 accessor, Invariant #8). New
PUT /api/agents/{name}/telegram/progress-indicator — OwnedAgentByName +
reject_agent_principal (behavior toggles are human-only, ent#223 lesson),
404 when no binding; dedicated route so toggling never re-sends the token.
- models.py: TelegramProgressIndicatorRequest + response field (Invariant #14).
- UI: TelegramChannelPanel.vue switch (SlackChannelPanel allow_proactive
precedent), optimistic flip with revert-on-error, dark-mode aware.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(architecture): channel-adapter progress-indicator seam deltas (ent#264)
base.py indicate_progress hook + capability attrs; message_router.py per-turn
driver (arm/tick/resolve + backstop, inline-sync coupling named for #1081
pickers); telegram_adapter.py reaction ack + elapsed placeholder + default-ON
per-binding toggle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(telegram): progress-indicator unit suite — adapter, router driver, toggle (ent#264)
61 tests, no backend required. Highlights per the plan's scrutiny list:
resolve-vs-first-send race (shielded id recording → delete finds it),
cancel-and-await ordering at both terminal flavors, 429-backoff cancel
promptness, [NO_REPLY]-still-resolves, step-8 wrap regression guard,
singleton metadata isolation, degraded-quiesce after 2 consecutive failures,
static-template-only egress pin (ent#224 class), live-select column test
(4-file schema rule), real-DB facade round-trip (#1533 no-MagicMock),
legacy-table SQLite migration + renumber-safe Alembic-twin existence,
GET JSON field pinning (#1809) + AuthorizedAgentByName hardening pin,
PUT reject_agent_principal 403.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(feature-flows): telegram in-progress status indicator section (ent#264)
New section in telegram-integration.md: three fail-soft layers, the
channel-agnostic start/progress/resolve seam, race closures (tick-after-
resolve, resolve-vs-first-send, singleton concurrency), gating matrix
(incl. `all`-mode groups; observe stays typing-only), toggle surface,
degradation ladder / Bot API constraints, restart residual + the inline-sync
coupling named for #1081/#1083 pickers, test inventory. Index unchanged
(existing flow). Revision-history row added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(telegram): access-harden the groups GET; document the bounded settle residual (ent#264)
Review-stage fixes:
- GET /api/agents/{name}/telegram/groups moves from bare get_current_user
to the uniform-404 AuthorizedAgentByName accessor — the sibling read of
the panel flow this PR already hardened. Group chat ids/titles/welcome
text are tenant data; the only follow-up action (the group-message POST)
was already OwnedAgentByName, so the read tier is strictly broader than
every usable consumer and no flow narrows (incl. the MCP
list_channel_groups path, whose paired send is owner-gated).
- Route-dependency test pinning the groups-GET accessor (mirrors the
binding-GET hardening pin).
- Docs honesty: the resolve-path in-flight settle is bounded at 10s — a
first placeholder send slower than that (429 retry_after >= 10s) is
abandoned and can strand a self-dating placeholder. Named in the feature
flow Residuals + requirements Known residuals instead of implying the
race is closed unconditionally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(db): source_channel_agent + telegram allow_proactive columns (ent#265)
Dual-track migration (Invariant #3 / #1183): SQLite entry
channel_report_back_columns + Alembic 0031_channel_report_back (single
linear head off 0030), plus db/schema.py + db/tables.py so fresh builds
stay correct.
- schedule_executions.source_channel_agent (nullable TEXT): the agent
whose channel binding owns the execution's INHERITED context (D1
Option A). Written only at the /task row-creation point; NULL for
direct rows (reporter falls back to the executing agent).
- telegram_group_configs.allow_proactive INTEGER DEFAULT 1: per-group
completion-report consent, default ALLOW for existing AND new groups
(opt-out mute; no backfill UPDATE needed).
- create_task_execution accepts + inserts source_channel_agent; row
mapper + ScheduleExecution model surface it.
- AgentRef(schedule_executions.source_channel_agent, KEEP) so
cascade_rename re-keys the binding agent (D1a); parity-test locks
consciously updated (_AGENT_ID_COLUMNS + KEEP set).
- Telegram group-config ops carry the flag (columns tuple, row mapper
default-allow on NULL, update_group_config arm, explicit insert);
database.py facade adds get_telegram_chat_link and converts the
group-config passthrough to keyword args (eng M3).
- TelegramGroupConfigResponse/UpdateRequest carry allow_proactive
(Invariant #14; the response model IS the GET field allowlist).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(chat): persist inherited channel context at /task row creation (ent#265 D0)
The ent#224 inheritance wire was severed: run_async_task threaded
_inherited_channel_context's values into execute_task(source_channel=...),
but execute_task writes channel columns ONLY in its no-execution_id
creation branch — and the /task path always pre-creates the row in
create_task_execution_and_activities. Every delegated row carried NULL
channel context at terminal time, so the shipped Slack delegated
report-back was latent dead code on its flagship path.
- Resolve inheritance at the single row-creation point both the async and
sync /task branches route through (the fork to _dispatch_async/_dispatch_sync
happens AFTER creation), and persist all four fields on the row itself
via db.create_task_execution.
- _inherited_channel_context returns a 4-tuple: + source_channel_agent
(D1 Option A — parent's own binding agent, else the parent's agent name;
transitive across A→B→C; all-None when the parent lacks source_channel,
so a channel-less child never carries a dangling agent pointer).
- Provenance guard (security): db.get_execution(parent_id) is a global
lookup; the inherited identity now also resolves a bot token. Agent
caller (x_source_agent, already past the SELF-EXEC-001 spoof guard in
derive_source_and_trigger) must BE the parent's executing agent; human
caller must have access to the parent's agent. Failure → no inheritance,
info log — fail-open to no-context, never to someone else's chat.
- Remove the dead source_channel* threading from run_async_task
(execute_task keeps its channel params — message_router uses them for
direct turns).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(channels): Telegram completion report-back + failure-applier hook (ent#265)
Telegram edition of the ent#224 Slack report-back: a Telegram-triggered
long/delegated task posts its terminal back to the originating chat,
threaded to the triggering message.
channel_completion_report.py rework:
- D10 resolver dispatch map (_CHANNEL_RESOLVERS: slack + telegram);
SUPPORTED_CHANNELS derives from the map keys, so WhatsApp is additive.
- D1 binding-agent resolution for BOTH channels: consent + bot token
evaluate against binding_agent = row.source_channel_agent or
row.agent_name — the bot the user actually addressed delivers (on
Telegram no other bot even CAN deliver the DM). NULL column = legacy
fallback, byte-identical pre-#265 behavior. Also fixes Slack's
delegated case (previously suppressed whenever worker B wasn't bound
to the originating channel); narrowing direction (channel consented to
B but not to originating A → now suppressed) is intentional.
- D2 Telegram consent: known group → is_active AND allow_proactive
(default allow, opt-out mute); known DM chat link →
consent-by-construction (the user cold-started the bot; a block is a
403 the send handles gracefully); unknown destination → suppress+log.
- D5 rendering: pre-escape &/</> before _markdown_to_html (unescaped
`<class 'ValueError'>` trips "can't parse entities" and the strip-HTML
fallback deletes the substring), then post-conversion re-cap to 4096
("message too long" is a 400 the parse-fallback does not catch).
- D6 threading: reply_to_message_id when thread is numeric, DMs too;
allow_sending_without_reply makes a deleted original safe.
- D1c attribution: Telegram has no per-message sender name — when
binding_agent != executing agent the head line names the worker.
Slack keeps username=executing agent (unchanged).
- D9 pin: effect_guard keeps agent_name = the EXECUTING agent
(row.agent_name), never binding_agent — resolve_and_validate_execution
fail-opens on mismatch, silently disarming dedup for exactly the
delegated rows this feature exists for.
- D4: failed send returns False INSIDE the guard (claim completes) —
at-most-once bias, never blind-retry an ambiguous send.
task_execution_service.py (D3, channel-agnostic — fixes Slack AC#2 too):
the failure applier's CAS-won block emitted the #1578 event but never
the channel report — the path agent-reported failure envelopes take.
Sibling spawn added; CANCELLED envelopes report too (uniform with
_write_terminal_and_gate).
routers/telegram.py: PUT group-config passes allow_proactive through;
the allow_proactive arm ONLY is human-only (reject_agent_principal —
an agent-scoped key resolves to the owner and could self-grant consent,
ent#223's own post-ship pitfall).
TelegramChannelPanel.vue: per-group "Completion reports" opt-out
checkbox in the panel's house idiom (updateGroup passthrough).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(channels): ent#265 suite + facade passthrough fix caught by it
New tests/unit/test_265_telegram_completion_report.py (41 tests):
- Wired-mock layer: Telegram delivery/consent/rendering decisions —
delegated group report + threading, DM consent-by-construction,
muted/inactive/unknown-destination suppression, graceful bot-cannot-post,
inline-trigger no-double-post, D5 escape+cap, D1c attribution, D1
binding-agent resolution (telegram + slack) incl. the intentional Slack
consent-narrowing pin (D1b).
- Chokepoint layer: apply_result failure branch spawns the report on
CAS-win only (D3), success-branch pin, CANCELLED-envelope report.
- Real-DB layer (db_harness): D0 row READ-BACK inheritance tests (a
SimpleNamespace mock row cannot see a severed write path — the exact
reason ent#224 shipped broken), both provenance-guard arms, channel-less
parent all-NULL, two-hop transitive root binding agent; REAL effect_guard
replay with source_channel_agent != agent_name (D9/M1 — a binding_agent
passthrough fail-opens resolution and posts twice → red); fan-out
one-report-per-child (G3); live column SELECTs through db/tables.py
metadata; group-config default-allow + kwargs round-trip (eng M3);
router PUT round-trip incl. agent-principal 403 and the surgical-gate
proof that trigger_mode stays agent-callable.
Conscious test_224 edits: non-slack scope test → whatsapp-only (telegram
grew a leg); consent fixture keyed on the binding agent.
The read-back tests immediately caught a REAL gap: database.py's
create_task_execution facade passthrough was missing source_channel_agent
(the ops layer had it) — every /task dispatch would have raised TypeError.
Fixed here; the exact severed-wire class D0 exists to kill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(requirements): Dashboard type-to-filter — new §9.10, §9.9 seam note updated (ent#261)
Trinity Rule #1: requirements before implementation. §9.10 specifies the /
hotkey filter across Timeline/Grid/List — store-seam predicate, pre-query node
invariant, pill honesty, Esc layering, chassis query-empty overlay, kbd hint,
list-mode composition, and the deliberate timeline owner-filter behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(dashboard): / type-to-filter across timeline, grid, and list (ent#261)
Store seam (network.js): non-persisted filterQuery + setFilterQuery; the
visibleAgents seam splits into ownerFilteredAgents (tag ∘ owner, pre-query)
and the query-aware visibleAgents (slug + display label via agentDisplayName,
#1642). ALL THREE convertAgentsToNodes call sites now read the pre-query
ownerFilteredAgents — incl. the 30s poll that previously rebuilt from the RAW
list (nodes must never be query-filtered: timeline row enrichment would
degrade after Esc).
Dashboard chassis: document / keydown with guards (defaultPrevented/repeat,
chords, IME, editable targets, open modals) + Firefox quick-find preventDefault;
floating filter pill (open OR active — an applied-but-hidden filter is the
dishonest state) with live 'X of Y match', Esc hint, x button; input-scoped
Esc + gated document Esc backstop (tag-dropdown layered dismissal, native
select skip); Enter blurs and keeps the filter; header kbd / toggle button
(mouse/touch parity); ONE chassis query-empty overlay with panes MOUNTED
underneath; true-empty onboarding CTA branches guarded && !filterActive;
timeline :agents switched to the visibleAgents seam (owner filter now applies
to timeline rows too — deliberate, release-noted); query cleared on unmount.
AgentListPanel: N/M badge suppressed while the chassis query is active (two
disagreeing denominators never render simultaneously). ReplayTimeline:
data-agent test hook on row labels (no logic change).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): dashboard type-to-filter spec — 8 tests across all three modes (ent#261)
Covers: grid live-filter + query-empty with the pane MOUNTED + Esc restore
(@smoke); timeline row hiding via the :agents seam; list query-empty preceding
the onboarding CTA + pill-x clear; editable-target guard + literal '/' inside
the pill; kbd-hint toggle; non-persistence across reload; cross-mode filter
survival + the document-Esc backstop after focus wanders; Enter blur-and-keep.
Spec rules per plan: focus-wait before keyboard.type (nextTick focus race);
regex count assertions (/^1 of \d+ match$/ — X is the claim, Y is
environment). Full run defers to CI via the ui label.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(flows): type-to-filter folded into the three dashboard flows + architecture note (ent#261)
architecture.md: Grid-view block gains the type-to-filter seam sentence; the
List-view block's seam description now covers all three panes. Flow deltas:
grid — filter rides the absence-as-filtering layout path (pane stays mounted
under the query-empty overlay); timeline — FILTER-001 gains a deliberately
NOT-persisted row + a new ':agents = visibleAgents seam' section naming the
owner-filter behavior change and the pre-query node invariant; list — D8 seam
updated to landed state + the chassis-query ∘ panel-filter composition rules
(badge suppression, overlay precedence). feature-flows.md changelog entry (no
standalone flow doc, per plan D11).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): review fixes — modal z-order, dual empty-state CTA, Esc scope, structural e2e backstop (ent#261)
Four review findings on the type-to-filter, fixed in place:
- CreateAgentModal was fixed z-10 — UNDER the new z-30 filter pill and z-20
query-empty overlay, so chassis chrome floated above the open modal.
Raised to z-50, the house modal tier (SystemViewEditor / OnboardingWizard).
Safe in all three mounts (wizard renders it v-if-exclusive with its own
z-50 chrome).
- AgentListPanel's filtered-empty card assumed the panel never mounts with a
zero-agent prop — no longer true under a chassis query zero-match, so it
rendered a second contradicting CTA under the query-empty overlay. Gated
on a non-empty prop; the chassis overlay owns query-zero messaging (the
behavior the list-view flow doc already described). e2e test 3 now pins it.
- Document-Esc backstop generalized from the select-only guard to all
editable targets: Esc inside the list panel's search box (or any other
input/textarea/contenteditable) belongs to that control — it must not
clear the chassis filter. Pill input unaffected (own handler stops).
§9.10 wording updated to match.
- e2e test 7's coordinate click (400,250) on the timeline pane could land on
an agent row/toggle on seeded fleets — replaced with a structural
pillInput.blur().
npm run build green; spec collects 8 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(learnings): overlay-chrome z-order re-pricing + mount-invariant comments (ent#261 review)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(learnings): sibling-route access-hardening class from the ent#264 review
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(flows): sync pass — seam note in skill-assignment, Recent Updates row, dead skills-management link purge (ent#263)
/sync-feature-flows verification pass over the branch diff:
- skill-assignment.md: note that SkillsPanel's package-fact chips now render
via the shared components/skills/ contract seam (extracted in ent#263)
- feature-flows.md: Recent Updates row for ent#263; removed the stale 'Skills
Management UI' index row — skills-management.md was split/archived long ago
(the archive table records it) and the live row pointed at a missing file
- library-page.md: repointed its two skills-management.md links (propagated
from the stale index row) at skill-assignment.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(library): harden stripUserinfo against silent-no-op and non-parseable URL shapes; share it with SkillsPanel (ent#263 review)
Adversarial testing of the display-layer credential scrubber proved five
leak shapes. Two families: the regex fallback under-matched what new URL()
rejects (git+ssh:// schemes, protocol-relative //user:token@host, leading
whitespace), and — worse — schemeless/scp user:token@host shapes PARSE as
a WHATWG URL with an opaque hostless path where .username/.password
assignment is a silent no-op, so the credential sailed through the success
lane untouched.
- move stripUserinfo to the shared components/skills/contract.js seam
- trust the parsed lane only when host is present AND the strip verifiably
took (post-assignment username/password empty); otherwise fall through
to a widened textual scrub (scheme charset [A-Za-z][\w+.-]*, protocol-
relative, schemeless colon-user, trim)
- variant analysis: SkillsPanel's library_empty state rendered the RAW
stored URL to any agent accessor — now scrubbed through the same helper
- verified by an executed 26-case suite (23 adversarial leak shapes ALL
PASS + 3 must-survive-unmangled regressions: plain https, scp
git@host:path, path-@ preserved)
- durable class captured in docs/memory/learnings.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: purge the second dead skills-management link; cso --diff report (ent#263 review)
- feature-flows.md Archived Flows table pointed at
archive/skills-management.md, which does not exist (the archive/ dir
never received it) — same dead-link class d23005af purged from the
skills index; row now says 'document not preserved' and names the
dedicated flows it split into
- add the /cso --diff report for the ent#263 review: zero backend
surface delta; one MEDIUM display-layer scrubber finding (proven,
remediated in 820895bf with variant coverage); secrets/enterprise-
disclosure/XSS/supply-chain/CI categories clean
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(channels): channel report-back flow + requirements — pays the ent#224 debt (ent#265)
- requirements/public-access.md: NEW §15.1h "Channel Completion Report-Back
(CHANNEL-REPORT — ent#224 Slack, ent#265 Telegram)" — generic mechanism
(inherited-context-only, D0 row-creation persistence + provenance guard,
binding-agent resolution, chokepoints incl. D3, effect-guard at-most-once,
sanitize-before-truncate), per-channel consent units, the two-DM-consent-
regimes rationale (F6), known v1 limits, the deliberate ungated
proactive-send scope cut. (§15.1f was already taken by WHATSAPP-001 —
plan's placement kept, id shifted to h.)
- feature-flows/channel-completion-report.md: NEW flow doc — entry points,
D0/D3 fixes, chokepoint coverage table with the v1 boundaries
(lease-reaper, bulk sweeps, pull sink, operator-terminate, restart
mid-inline-turn, FAILED→SUCCESS resurrection, fan-out per-child,
pre-migration NULL rows), destination/consent resolution per channel,
D1 identity design with every rejected alternative, failure modes,
Testing. Pays the #1763 flow-doc debt (ent#224 shipped undocumented).
- feature-flows.md: Recent Updates row + Collaboration-table entry.
- telegram-integration.md: ent#265 section (column + toggle + pointer) +
revision row.
- task-completion-events.md: sibling-spawn paragraph (report rides beside
the #1578 emit at the same CAS-won chokepoints incl. the failure applier).
- architecture.md: services-catalog entry for channel_completion_report.py,
schedule_executions DDL line for source_channel_agent, telegram DB-module
line mentions allow_proactive.
- learnings.md: the D0 class — a threaded parameter only one callee branch
consumes is a severed wire; mock-row suites are blind to it AND to its
facade-passthrough sibling (caught pre-merge by the row-read-back tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(channels): key the inheritance provenance guard on the principal, not the header (ent#265)
The D0 provenance guard selected its arm from the raw `X-Source-Agent`
header. The SELF-EXEC-001 spoof guard in `derive_source_and_trigger` only
fires when `current_user.agent_name` is set, so for a human caller that
header is unvalidated client input (routers/chat.py documents the identical
trap for the resume-session IDOR). Setting it to the parent execution's own
agent name — a value the row itself discloses — satisfied the agent arm
trivially and skipped the human arm entirely: any user with access to ANY
agent could POST /task with someone else's `parent_execution_id` and have
that task's terminal reported into the parent agent's Telegram DM or Slack
thread, delivered by a bot binding they do not own.
Second, narrower issue in the same guard: the human arm used
`can_user_access_agent`, which admits share recipients. Posting into a
channel chat is a proactive-send capability and every other proactive
surface is owner-gated (`OwnedAgentByName` for group sends) or
per-recipient-consented (#321); a share recipient can already read the
owner's execution ids (`GET /api/executions` is accessor-scoped), so an
accessor arm let them push a report into the owner's chat.
Fix: arms are selected by the authenticated principal —
- agent-scoped key must BE the parent's executing agent,
- human must OWN the parent agent (`can_user_share_agent`) or be admin,
- connector key (consumption-only, ent#46) never inherits,
- no principal at all refuses.
`x_source_agent` is still passed, now for logging only.
Three regression tests, each verified red against the pre-fix guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(learnings): header-selected auth-guard arms collapse to the weakest arm (ent#265)
Second instance of a class the codebase already warned about: routers/chat.py
documents the same X-Source-Agent trap for the resume-session IDOR, and the
ent#265 provenance guard reproduced it a few hundred lines away.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(design-system): system of record, builder contract, reference page + raw-color scanner & ratchet baseline
Stands up the frontend design system's written layer per #1430:
- docs/memory/design-system.md — system of record: token taxonomy,
both-theme rules incl. the dark ink ladder, type/spacing/radius scales,
10-primitive catalog with exact token recipes, the data-loading motion
standard (scanline beam + wipe reveal; first load animates, background
refresh is invisible), and 28 UI Construction Principles
- docs/memory/design-system-contract.md — condensed binding contract to
load before any src/frontend change
- docs/memory/design-system-reference.html — approved visual spec (self-
contained; both themes; live motion demo)
- src/frontend/scripts/scan-raw-colors.mjs — raw-color scanner aligned
with check-design-tokens.mjs token families
- src/frontend/raw-color-baseline.json — ratchet seed at dev@5b28999:
753 raw non-gray / 383 hardcoded colors; counts may only shrink
- CLAUDE.md — Rule of Engagement #10 + Memory Files row making the
design system the mandatory reference for frontend work
Refs #1430
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: fleet restart adopts rebuilt base images through the canonical lifecycle path (#1860) (#1912)
* fix: route fleet restart through the canonical lifecycle path so agents adopt rebuilt base images (#1860)
POST /api/ops/fleet/restart stopped/started agents with raw Docker calls,
bypassing start_agent_internal — no config-drift predicate ran and a rebuilt
trinity-agent-base was never adopted on "Restart All" (#1809's cold-start
gate never fired).
- lifecycle.restart_agent_internal(): the canonical stop→cold-start helper
(explicit stop is load-bearing for the #1809 image predicate; future home
of #1817's per-agent start lock)
- restart_fleet routes through it; per-agent recreated/recreate_reason
surfaced via explicit allowlist copy, summary.recreated count
- skips ephemeral ghosts (config predicates aren't ephemeral-gated — a
recreate would destroy a volume-less ghost workspace, ent#69)
- reject_agent_principal beside assert_admin (the endpoint now replaces
containers — Invariant #8 escalation rule, #1816 precedent)
- single-flight Redis SETNX lock ops:fleet_restart (409 on contention,
own-lease refresh, compare-and-delete release, fail-open) — guards the
client-timeout→retry overlap (#799/#1817 wedge class)
- partial-safe fleet_restart audit entry with a per-agent recreate map
(restores the entry dropped in 0ec3a7fc); sync cleanup ordered before the
awaited audit so a shutdown CancelledError can't leave the lock held
- actionable containerless-recovery errors (#1559), context-stats cache
invalidation, 16 mocked unit tests, flow/architecture docs, learnings,
CSO diff report (PASS)
Fixes #1860
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: never flow a raw exception message into fleet-restart results (CodeQL py/stack-trace-exposure)
Per-agent failure rows now carry HTTPException .detail (platform-authored)
or the exception class name only; the full message + traceback go to the
backend log (exc_info). The #1559 containerless recovery hint is preserved.
Tests strengthened into leak regression guards (raw message asserted absent);
flow-doc line refs re-verified.
Refs #1860
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(auth): bulk portal-session revocation primitive + operator client panel (#1902)
* feat(auth): bulk portal-session revocation primitive + operator client panel
The OSS half of an operator kill switch for signed-in portal clients. A client
who signs in through sharing holds a 12-hour portal session with no way to end
it short of a backend restart, which logs out everyone.
`revoke_portal_sessions_for_email(email)` is the edition-agnostic primitive:
OSS owns the mint, the decode and this bulk revoke; the entitled module that
mints portal sessions decides WHEN to call it (the same split as the delegated
mint).
Mechanism is a per-email CUTOFF, not a jti list. `jti` is random per token and
nothing indexes email -> issued jtis, so answering "which tokens does this
address hold?" would need a write-side index maintained at every mint. One
timestamp per email is O(1) to write and read, self-expiring at the max session
lifetime (same bounded-growth property as the #187 blacklist, no sweep), and —
the property that matters — covers every mint path by construction, since they
all go through `create_portal_session_token`. That is the failure mode a
hand-maintained index has and this does not.
`create_portal_session_token` now stamps an explicit `iat` so tokens can be
dated against the cutoff; set there rather than in `create_access_token` so no
other token type's claim set changes.
`decode_portal_session` rejects `iat <= cutoff`. Rounding toward revoking is
deliberate: for a kill switch, a token minted in the same second as the revoke
must die. A token with NO `iat` is treated as revoked — fail closed. Only
sessions minted before this shipped lack one (all expired within
PORTAL_SESSION_EXPIRE_HOURS of the upgrade), and only for an email an operator
actively revoked; letting an undatable token survive an explicit kill switch is
the worse failure.
The Redis read stays fail-OPEN, matching #187 and the platform posture. That is
why revocation is not the whole feature: the durable half lives in the entitled
module and keeps working with Redis down. `revoke_...` returns a bool so a
caller can report honestly instead of claiming a success the operator would act
on.
Frontend: `PortalClientsPanel.vue` on the Sharing tab — per-client log out /
block / unblock with current state. Entitlement-gated (`client_portal`), so an
OSS build renders nothing. Block is hidden for non-admins rather than offered
and 403'd, and the panel reports a failed revoke as a failure rather than
"signed out". It deliberately shows no live-session count: portal sessions are
stateless JWTs with no server-side store, so any number would be a guess.
Related to trinity-enterprise#281
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): use the real status-danger design token in the portal clients panel
`status-error-*` does not exist — the token family is
success/warning/danger/info/urgent (tailwind.config.js), and
`npm run check:tokens` caught every reference. No visual change intended:
danger is the red alias the invented name was reaching for.
Related to trinity-enterprise#281
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(skills): library lifecycle automation — auto-sync, fleet re-inject, removal-on-unassign (abilityai/trinity-enterprise#236) (#1883)
* feat(skills): library lifecycle automation — auto-sync, fleet re-inject, 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
* fix(skills): wire ent#236 tuning vars into both compose files + .env.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.
* fix(ui): agent tab panel polish — card borders, section spacing, and 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.
* fix(ui): inset the Settings and Skills tab content like every other tab
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.
* fix(skills): re-clone a library path that is not a repository, and make 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.
* fix: ownership-checked fleet-restart lease with loss detection + acquire inside try/finally (#1919) (#1928)
The per-iteration refresh was a bare EXPIRE gated on a local flag — after a
TTL lapse it extended a concurrent caller's lease while both loops ran. The
refresh is now a pre-action ownership gate (GET-compare via the shared
redis_breaker_util.lock_token_matches): a foreign token stops the run with
honest partial accounting (summary/audit gain processed + stopped_early), an
absent token is re-acquired via SETNX so an unraced run completes instead of
aborting, EXPIRE→0 routes to the absent path, and refresh Redis errors stay
fail-open with one throttled warning per run. list_all_agents_fast() moved
inside the try/finally so nothing can ever leak the lock between acquire and
release; an abnormal exit audits as stopped_early="error" + exception class
name only. TTL 900→2100, sized above the slowest single agent (skill
injection alone is bounded at 1800s) so a mid-agent lapse is no longer
arithmetically guaranteed. Release stays compare-and-delete, attempted even
after detected loss (foreign-safe by construction).
+11 unit tests (27 total in the file); live-validated on the local stack
(foreign takeover mid-run, absent re-acquire to completion, 409 concurrency,
TTL 2098 observed, release verified). Sibling hand-rolled lock sites and the
system_seed_service unconditional release → #1920.
Fixes #1919
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(templates): contain local: template ids and stop deriving credential paths from name: (#1900) (#1935)
* fix(templates): contain local: template id resolution on the read path (#1900)
`GET /api/templates/{template_id:path}` handed `local:<name>` straight to
`get_local_template`, which joined `<name>` onto the templates root with no
validation. The `:path` converter permits `/`, so `local:../<x>`,
`local:/<abs>/<x>` and a root-escaping symlink each read
`<escaped-dir>/template.yaml` and echoed its contents in an authenticated 200.
Reachable by any authenticated principal of any role — including an
agent-scoped MCP key, so a prompt-injected agent qualifies. In a container the
reachable set includes `/data/deployed-templates/<victim>`, where every user's
uploaded template archive lands: a cross-tenant read.
Two corrections to the issue's framing, both verified here:
* it is NOT arbitrary file read — the filename is fixed (`template.yaml`), it
must parse as a YAML mapping, and only a fixed key set is echoed. But those
keys' VALUES are arbitrary YAML subtrees, not just strings.
* `local:..` alone is not an existence oracle: a directory with no
`template.yaml` returns the same 404 as an unknown id.
Fix: `contained_template_dir(name, root)` — the two-step barrier the CREATE
path has had since #950 (`crud._safe_local_template_path`), brought to the read
path. A name allowlist runs BEFORE any path math (this is also what CodeQL
recognises as a `py/path-injection` barrier; resolve-only was flagged
high-severity twice on this codebase), then `resolve()` on BOTH sides plus
`is_relative_to`. `str.startswith` is not equivalent — it passes the sibling
escape `<root>-evil`.
An escaping id returns `None`, so the router's 404 stays byte-identical to an
unknown template: no error code, no path, no root name. A distinct error would
be a NEW enumeration oracle, which is what #1759's single-sentence 404 exists
to close. Rejections log at DEBUG, sanitized — the endpoint has no rate limit,
so a per-rejection WARNING would be an authenticated log-flood primitive.
The helper is public: the remote-template-registry work (trinity-enterprise#14)
edits this same resolver family in this same module and should import it rather
than copy it.
Tests: every rejection test PLANTS a real `template.yaml` at the escaped
location, because unpatched code returns `None` for any id whose target simply
does not exist — a rejection test with nothing planted is green before and
after and proves nothing. Each is labelled REPRO (verified red pre-fix) or
HYGIENE (cannot be made red; contract only). The router guard lives in
`tests/unit/` because no gating CI job collects `tests/test_templates.py`.
`test_1900_containment_survives_a_symlinked_root` is the landmine guard for
resolving both sides: a half-resolved variant passes all 130 pre-existing tests
and only that test catches it.
Refs #1900
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(templates): stage .mcp.json from the validated template dir, not template name: (#1900)
The second traversal sink, found by this issue's own AC #4 audit ("audit the
by-name create path for the same join"). It is NOT the create path's `local:`
id resolution — that has been contained since #950/#1759 via
`crud._safe_local_template_path`, applied at both seams #1759 named. It is the
create path's CREDENTIAL STAGING, which threw that validated path away and
re-derived a directory from scratch:
template_name = template_data.get("name", "") # untrusted
mcp_template_path = templates_dir / template_name / ".mcp.json"
`name:` comes from an uploaded template.yaml, so any `creator` reaches it via
`deploy_local_agent`. `name: ../../data/deployed-templates/<victim>` read
another tenant's `.mcp.json` — a credential-bearing file type under
Invariant #12 — into the attacker's OWN agent, where they read it at leisure.
A victim who hardcoded a token rather than a `${VAR}` placeholder leaks it.
Assessed on its own axes, NOT inherited from the read sink: different trigger
(`creator` role + an upload + an agent create, vs a bare authenticated GET) and
a higher impact ceiling (credential values, not template metadata). Also P2, for
different reasons.
The derivation was also simply wrong. `name:` is not a directory name — 5
shipped templates declare a display string there ("Test Echo Agent"), so
`local:test-echo` resolved to `<curated>/Test Echo Agent/.mcp.json`, which does
not exist. That kills the "validate the name" framing: the value should not
resolve paths at all.
Fix is root-cause, not another guard: `_stage_config_files` already calls
`_safe_local_template_path` itself for the `/template` bind decision, so the
validated directory is available in the same function. Extract the two-root
ladder as `_resolve_local_template_dir` and pass its result as
`template_base_path`. The untrusted join is gone from the live path, and the
#1759 "seams must agree" property becomes structural across all THREE seams
(resolver, bind decision, credential stager) instead of two.
Deliberately NOT threaded through `_TemplateResolution` or the return tuple:
`_resolve_local_template` returns a 2-tuple that three existing tests depend
on, two as monkeypatched `lambda config: ({}, None)` doubles — widening it
breaks the test doubles, not just the callers. Its signature, its return arity,
`_safe_local_template_path`, `_LOCAL_TEMPLATE_ROOTS`, and everything inside the
CodeQL-sensitive `if template_yaml.exists():` block are untouched (#1793 had to
revert exactly that reshape).
The residual `template_base_path is None` arm is kept and made fail-closed: it
is a public function with a `template_base_path=None` default, so a future
caller can still reach it. It now contains through the same barrier as the id,
which also absorbs a non-string `name:` — `Path(root) / 123` raised TypeError,
i.e. an uncaught HTTP 500 during agent creation (the ent#128 bug class, one
seam over).
One disclosed behaviour change: a deploy-local template that BOTH declares
`credentials.mcp_servers` AND ships a `.mcp.json` now gets `${VAR}`
substitution, where the old curated-root lookup always missed. Verified no
collision with `deploy._prepopulate_workspace_from_template`, which writes the
archive's raw copy into the workspace volume: `startup.sh` copies
`/generated-creds/.mcp.json` unconditionally (gated only on the directory
existing) and AFTER the template-copy block (gated on `.trinity-initialized`),
so the substituted file deterministically wins — which is the intended
behaviour, the raw copy still carrying unsubstituted placeholders. Not one of
the 26 shipped curated templates contains a `.mcp.json`, so the curated rows
are provably unchanged.
The crud seam tests are mandatory, not decorative: every service-level test
calls `generate_credential_files` directly, so an "extracted but never wired"
mistake leaves all of them green while deploy-local resolution silently
regresses into the fallback arm.
Refs #1900
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: record the #1900 containment contracts for template id + credential staging
Rule #1 (requirements before implementation) — `core-agent.md`:
* §4.1 gains the **read-path resolution contract** as a sibling to the existing
create-time contract: `GET /api/templates/{id}` resolves `local:<name>`
through the same two-step barrier the create path has had since #950, and a
failing name returns a 404 byte-identical to an unknown template (the #1759
non-disclosure rule). Records the deliberate, known asymmetry that
`get_local_templates()` still enumerates by `iterdir()` and so could LIST a
root-escaping symlink that detail and create both refuse — the listing is the
outlier, and planting one needs local filesystem write access, not a request.
* §4.3 records that the `credentials.mcp_servers` template lookup now resolves
from the validated path rather than the template's own untrusted `name:`
field, including the one disclosed behaviour change (deploy-local templates
now get `${VAR}` substitution) and why the substituted file wins over the
archive's raw copy.
`architecture.md` gets one clause on the `templates.py` router catalog entry
(the catalog rule caps entries at 2 lines, and no Cross-Cutting Subsystems
block is warranted). A bug fix would normally be commit-message-only under the
tiered-docs rule; the exception is that this ships a public, importable
containment primitive in the exact module and resolver family the remote
template registry (trinity-enterprise#14) will edit, and one catalog clause is
the cheapest way that author finds it instead of copying the flaw.
Not updated, deliberately: no feature-flow doc (no new vertical slice), no user
docs (no user-visible change for honest callers), no schema/migration (no DB
change, so the dual-track SQLite/Alembic rule does not apply).
Refs #1900
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(feature-flows): sync template-processing + local-agent-deploy for #1900
`/sync-feature-flows` was NOT a no-op here — three concrete staleness points in
`template-processing.md`, which owns the `local:` resolution surface:
* The inlined two-root ladder is now `_resolve_local_template_dir`; the code
block showed the pre-extraction form.
* "**Two** seams read `_LOCAL_TEMPLATE_ROOTS` and must stay in agreement" was
the #1759 claim and is now wrong in the direction that matters: there was
always a third seam (the credential-file stager) which did NOT agree — it
re-derived the directory from the template's untrusted `name:`. Corrected to
three, with the extraction as the structural guarantee.
* `generate_credential_files` was cited by stale line range (`:228-299`) and
documented none of where the `.mcp.json` template is actually located.
Replaced the fragile line-range citation with a symbol reference and added
the provenance, the residual fail-closed arm, and the disclosed deploy-local
substitution delta.
Also documents the read-path containment (`get_local_template` →
`contained_template_dir`) beside the existing #1513 catalog-curation note,
including the deliberate list-vs-detail asymmetry for a planted symlink.
`local-agent-deploy.md` gets one line at the credential-merge step: a
deploy-local template's `.mcp.json` now resolves from the deploy-local
directory, so a hostile `name:` cannot read another tenant's file and
`${VAR}` substitution finally applies to that template.
`credential-injection.md` was checked and NOT touched — its `.mcp.json`
references are unrelated (credential inject/export/import), and the template
lookup live…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Introduces comprehensive process documentation and onboarding content, updates process templates/schema, extends the system agent with a Process Creation Assistant, and tweaks docker config for mounting docs/templates and configurable frontend port.
config/process-docs(getting started, patterns, reference, tutorials) withindex.jsonandeditor-help.jsontrinity-system/CLAUDE.mdwith Process Creation Assistant, YAML schema/patterns, and MCP usageversion: "1.0", replace legacy condition/user_task withgateway/human_approval, and switch outputsvalue→sourceconfig/process-templatesandconfig/process-docs; make frontend port configurable viaFRONTEND_PORTWritten by Cursor Bugbot for commit 037770d. Configure here.