Skip to content

fix(backlog): repair drain spawn — lazy-import target after #95 (#496)#500

Merged
vybe merged 1 commit into
devfrom
feature/496-backlog-drain-fix
Apr 25, 2026
Merged

fix(backlog): repair drain spawn — lazy-import target after #95 (#496)#500
vybe merged 1 commit into
devfrom
feature/496-backlog-drain-fix

Conversation

@vybe

@vybe vybe commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Why it shipped silently

The pre-existing test injected a fake routers.chat module into sys.modules so the lazy import resolved against the test's stub, not the real source. The production failure mode (ImportError) never appeared in tests. Combined with the swallowed exception at the drain catch site, the only surface signal was the per-execution error column — invisible at fleet scale, which is exactly why this bug ran for weeks. The new AST guard catches symbolic drift at the unit-test layer; a stable log token (backlog_drain_spawn_failed) closes the fleet-detection gap so the next drift fails loudly via Vector / dashboards.

Changes

File What
src/backend/services/backlog_service.py Lazy-import → _run_async_task_with_persistence; metadata schema swap (drop task_activity_id, add is_self_task / self_task_activity_id / inject_result); stable log token backlog_drain_spawn_failed on the drain spawn catch site.
src/backend/routers/chat.py Pass is_self_task + self_task_activity_id to backlog.enqueue (already in scope from chat.py:790, 830).
tests/unit/test_backlog.py 4 new tests: TestLazyImportTarget (AST guard + inverse drift guard), test_enqueue_captures_self_task_fields, test_drain_threads_self_task_fields. Happy-path test updated to use the new symbol and kwarg surface.
tests/registry.json Updated coverage description.
docs/memory/feature-flows/persistent-task-backlog.md Code / JSON examples updated for the new lazy-import target and metadata shape; #496 changelog entry added.
docs/memory/feature-flows/parallel-headless-execution.md Stale _run_async_task_with_persistence signature corrected (the doc still showed pre-#95 task_activity_id / release_slot parameters).
docs/security-reports/cso-2026-04-25-496-diff.{md,json} CSO diff audit — zero findings; A09 logging posture improved.

Test plan

  • python -m pytest unit/test_backlog.py -v33/33 pass (was 29; +4 new for bug: backlog drain spawn fails — _execute_task_background no longer exists in routers/chat.py (BACKLOG-001 silently broken) #496)
  • AST + inverse-drift regression tests verified: LAZY_IMPORT_TARGETS = ("_run_async_task_with_persistence",) matches the actual import string in services/backlog_service.py and the function exists in routers/chat.py
  • Self-task enqueue → drain round-trip tests pass
  • Syntax + AST validation on all 3 changed source files
  • CSO --diff audit: 0 findings, attack-surface delta zero, log-observability posture improved (docs/security-reports/cso-2026-04-25-496-diff.md)
  • Post-merge ops verification (deferred): query SELECT count(*), agent_name FROM schedule_executions WHERE status='failed' AND error LIKE '%backlog drain spawn failed%' GROUP BY agent_name for 24h to size historical impact and confirm new count → 0
  • Manual integration (recommended): scenario 1 from persistent-task-backlog.md — pin agent to max_parallel_tasks=1, fire 3 async tasks, verify the second & third drain to success (not stuck in queued)

Follow-ups (filed as deferreds in the autoplan)

  1. Extract _run_async_task_with_persistence from routers/chat.py to a service module (Invariant Fix: Add missing Docker labels to system agent container #1: services hold non-HTTP logic; the function holds none and is misplaced).
  2. Make _on_drain_done deterministically release the slot + mark execution FAILED on background-task exception (current safety net is cleanup TTL).
  3. Extend test_parallel_task.py:797-808 BACKLOG-001 integration test to poll the queued execution to completion — the current test asserts only that enqueue returns status=queued, which is why the broken-drain regression slipped through CI for weeks.

Closes #496

🤖 Generated with Claude Code

services/backlog_service.py:240 lazy-imported _execute_task_background
from routers.chat, but #95 deleted that function. Every backlog drain
attempt failed with ImportError; the exception was swallowed at
backlog_service.py:218-228, so BACKLOG-001 (#260) was silently dead.
Live observation: 23 drain failures / 24h on a fan-out workload, only
surface signal was the per-execution `error` column.

Why it shipped silently: the unit happy-path test patched
sys.modules["routers.chat"] with a SimpleNamespace stub of whatever
attribute name it expected, masking the production breakage.

Changes:
- Lazy-import _run_async_task_with_persistence (the post-#95
  replacement) and adjust the call shape (drop release_slot, drop
  orphaned task_activity_id; the unified executor handles both).
- Capture self-task fields (is_self_task, self_task_activity_id,
  inject_result) at enqueue time and rehydrate on drain so
  SELF-EXEC-001 (#264) survives backlog overflow.
- Emit a stable log token `backlog_drain_spawn_failed` so log-based
  detection (Vector / dashboards) can catch import drift or similar
  spawn-time regressions at fleet scale rather than per-row.
- AST-based regression guard in tests/unit/test_backlog.py:
  TestLazyImportTarget parses routers/chat.py and asserts the import
  target exists; paired test asserts the lazy-import string matches
  the validated allow-list. Catches both directions of drift without
  booting the backend.
- Update happy-path test to use the new symbol and kwarg surface;
  add self-task enqueue+drain round-trip tests.

Closes #496

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vybe
vybe merged commit 1b68e41 into dev Apr 25, 2026
AndriiPasternak31 added a commit to AndriiPasternak31/trinity that referenced this pull request Apr 25, 2026
…esolve overlap)

PR Abilityai#500 (Eugene's, merged ahead of Abilityai#503) shipped a more comprehensive
fix for the same Abilityai#496 root cause, including:
- the self-task field round-trip through backlog_metadata that I noted
  in /review as the latent gap (out of scope at the time)
- AST-based regression guards for the lazy-import target
- self-task enqueue + drain unit tests
- a `backlog_drain_spawn_failed` log token for fleet-scale detection

Conflict resolution: take upstream for the three conflicted files —
`services/backlog_service.py`, `tests/unit/test_backlog.py`, and
`feature-flows/persistent-task-backlog.md`. Their version is the merged
authoritative one.

Local additions kept (non-conflicting):
- `tests/test_backlog_drain_unit.py` — five unit tests; AST contract +
  drain spy. Note: substantive overlap with upstream's
  `TestLazyImportTarget` and `test_drain_threads_self_task_fields` —
  recommend a follow-up cleanup to drop this file.
- `tests/registry.json` entry for the file above (auto-merged alongside
  upstream's update of the existing `unit/test_backlog.py` entry).
- `docs/memory/feature-flows.md` Recent Updates entry for Abilityai#496.
- `docs/memory/feature-flows/parallel-capacity.md` — two stale
  `release_slot=True` references fixed.
- `docs/memory/feature-flows/task-execution-service.md` — async-mode
  branch description refreshed.

One test in test_backlog_drain_unit.py adapted to upstream's new
contract: `is_self_task` is now read from metadata (captured at enqueue)
rather than derived from `x_source_agent == agent_name`. Test renamed
to reflect the pass-through semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AndriiPasternak31 added a commit to AndriiPasternak31/trinity that referenced this pull request Apr 25, 2026
PR Abilityai#500 (which superseded the original Abilityai#496 fix scope) shipped
equivalent contract coverage with a more robust setup:
- `TestLazyImportTarget` (AST guard for the lazy-import target)
- `test_drain_threads_self_task_fields` (round-trip via real
  BacklogService against sqlite)

The local file used sys.modules stubs which were strictly weaker.
Keeping it would only add maintenance burden for duplicate coverage,
so drop the file and its registry entry. Net effect on PR Abilityai#503 is
that it becomes a small, focused docs-cleanup PR (parallel-capacity.md
and task-execution-service.md drift from Abilityai#95, plus the missing
Recent Updates entry for Abilityai#496).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe added a commit that referenced this pull request Apr 26, 2026
…522)

* feat(webhooks): agent schedule webhook triggers (WEBHOOK-001, #291)

Add public webhook URLs so external systems (CI/CD, CRMs, monitoring) can
trigger agent schedule executions via a simple HTTP POST with no Trinity
account required — authenticated by a 256-bit opaque token embedded in the URL.

Changes:
- New public router POST /api/webhooks/{token}: rate-limited (10/60s per
  token), audit-logged, 202 Accepted, delegates to existing scheduler trigger
- JWT-auth CRUD: POST/GET/DELETE /api/agents/{name}/schedules/{id}/webhook
- DB migration: webhook_token (TEXT UNIQUE), webhook_enabled (INTEGER DEFAULT 0)
  on agent_schedules; partial unique index for O(1) token lookup
- Scheduler updated to accept triggered_by param in JSON body so executions
  record triggered_by="webhook" correctly
- Webhook context field framed as data to reduce prompt injection surface
- 12 integration tests in tests/test_webhook_triggers.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): patch 4 Dependabot alerts — happy-dom + vite (#486)

Bumps two dev-only dependencies to patched versions. Production is
unaffected (happy-dom is test-only; vite only runs in local dev).

- src/frontend: vite ^6.0.6 → ^6.4.2 (closes Dependabot #55, CVE-2026-39363)
- tests/git-sync: happy-dom ^15.11.7 → ^20.9.0 (closes #83/#84/#85:
  VM context escape RCE, ESM code exec, fetch cookie leakage)

Verified: frontend `vite build` clean, all 10 git-sync vitest tests pass
under happy-dom 20.9.0. No new critical/high alerts introduced.

Closes #485

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(webhooks): add WEBHOOK-001 to requirements and architecture (fixes #484 review)

Add missing documentation for the webhook trigger feature:
- requirements.md: WEBHOOK-001 entry with description, key features, DB changes,
  API endpoints, security model, and feature flow link
- architecture.md: webhooks.py listed in Routers table; Schedules table expanded
  from 9 to 12 endpoints with the 3 webhook management endpoints; new Webhook
  Triggers section documenting the public POST /api/webhooks/{token} endpoint;
  webhook_token and webhook_enabled columns added to agent_schedules schema block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#488): add status-in-dev label + PR-merge automation (#489)

Close the gap between "PR merged to dev" and "released to main".

- New GH Action `issue-status-on-merge.yml`: on PR merge to dev,
  parse Fixes/Closes/Resolves #N from PR body+title, add
  `status-in-dev`, remove `status-in-progress`.
- `/release` skill: read `gh issue list --label status-in-dev` as
  the authoritative shipping list for release notes; include
  `Closes #N` in the release PR body so issues auto-close on merge
  to main.
- `DEVELOPMENT_WORKFLOW.md`: SDLC is now Todo → In Progress →
  In Dev → Done, each stage mapped 1:1 to commit-graph location.

Fixes #488

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(channels): file upload Phase 2 — workspace delivery hardening (#487) (#494)

Phase 2 of #354 polishes the shared channel-agnostic file delivery path
in `message_router._handle_file_uploads`. Phase 1 (#355) added Telegram
extraction/download/validation; the actual workspace write path was
introduced for Slack inbound (#222). This change hardens the shared path
for both channels:

- New `_sanitize_filename` helper: NFKC unicode normalize → basename →
  safe-chars regex → empty/dotfile fallback to `file_{id}` → 200-char
  truncation preserving extension → collision dedup with `-1`, `-2`, …
- Spec injection format: `[File uploaded by {uploader}]: {name} ({size})
  saved to {path}`. Uploader is the verified email when present
  (Issue #311), else `adapter.get_source_identifier(message)`.
- All-writes-failed handling: when every workspace write attempt fails,
  the router replies on the channel with an explicit error and skips
  agent execution (#487 AC6). Validation rejections (size/MIME/download
  errors) still surface in the description block as before.
- Audit log entries gain an `uploader` field.

Per-session upload directory (`/home/developer/uploads/{session_id}/`)
preserved — keeps user uploads isolated and ephemeral, matches the
existing #222 model.

Tests: +17 unit tests across `TestFilenameSanitization` (12),
`TestFileDeliveryFormat` (2), `TestFileDeliveryFailures` (3). 28/28
passing in `tests/unit/test_file_upload.py`.

Docs: `telegram-integration.md` Phase 2 section + revision row;
`slack-file-sharing.md` flow / router / errors / security sections
updated for the shared change; `feature-flows.md` index row.

Closes #487

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webhooks): import Request in schedules router (#495)

The WEBHOOK-001 commits (c630931 / 8fdf736) added `request: Request`
parameters to `generate_webhook` and `get_webhook_status` without
importing `Request` from fastapi. Backend module import fails with
NameError on startup, blocking all dev deploys.

Integration tests in tests/test_webhook_triggers.py exercise these
endpoints but never caught the bug because the backend never starts —
test setup fails before any test runs.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backlog): repair drain spawn — lazy-import target after #95 (#496) (#500)

services/backlog_service.py:240 lazy-imported _execute_task_background
from routers.chat, but #95 deleted that function. Every backlog drain
attempt failed with ImportError; the exception was swallowed at
backlog_service.py:218-228, so BACKLOG-001 (#260) was silently dead.
Live observation: 23 drain failures / 24h on a fan-out workload, only
surface signal was the per-execution `error` column.

Why it shipped silently: the unit happy-path test patched
sys.modules["routers.chat"] with a SimpleNamespace stub of whatever
attribute name it expected, masking the production breakage.

Changes:
- Lazy-import _run_async_task_with_persistence (the post-#95
  replacement) and adjust the call shape (drop release_slot, drop
  orphaned task_activity_id; the unified executor handles both).
- Capture self-task fields (is_self_task, self_task_activity_id,
  inject_result) at enqueue time and rehydrate on drain so
  SELF-EXEC-001 (#264) survives backlog overflow.
- Emit a stable log token `backlog_drain_spawn_failed` so log-based
  detection (Vector / dashboards) can catch import drift or similar
  spawn-time regressions at fleet scale rather than per-row.
- AST-based regression guard in tests/unit/test_backlog.py:
  TestLazyImportTarget parses routers/chat.py and asserts the import
  target exists; paired test asserts the lazy-import string matches
  the validated allow-list. Catches both directions of drift without
  booting the backend.
- Update happy-path test to use the new symbol and kwarg surface;
  add self-task enqueue+drain round-trip tests.

Closes #496

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(announce): add Twitter/X support via API v2 + OAuth 1.0a

Bumps announce skill to v1.6. Adds a Python helper (scripts/post_twitter.py)
that reads tweet text from stdin and posts via Twitter API v2 using OAuth 1.0a
User Context — same exit-0/1 + structured-JSON contract as the existing
Discord/Slack/Telegram send paths so the sequential-only and no-blind-retry
rules apply uniformly. Credentials live in .env (gitignored) under
ANNOUNCE_TWITTER_* keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): sync /task long-polls on backlog at capacity (#498) (#515)

Sync parallel `/task` calls (parallel=true, async=false) at capacity used
to fail terminally with HTTP 429 — they never touched the BACKLOG-001
backlog because the spill block was nested under `if request.async_mode:`.
Observed in production: ~40% terminal-failure rate from one MCP fan-out
caller (214 capacity rejections / 24h, 0 enqueues from 541 dispatches).

Sync calls now spill to the same backlog the async path uses and long-poll
on the open HTTP connection until the queued execution reaches a terminal
status, then return the result inline. True 429 only when the backlog is
also full. Total connection hold capped at 2 × effective_timeout.

Implementation:
- New `services/sync_waiter.py` owns the in-process registry and the
  `signal_sync_waiter` / `wait_for_sync_terminal` primitives. Wait combines
  an asyncio.Future (set by the drain finally block) with a 5s DB-poll
  fallback that covers terminal flips routed outside the drain
  (corrupt-metadata, expire_stale, cleanup recovery).
- `routers/chat.py` sync branch now mirrors the async branch:
  pre-acquires the slot, on at-capacity calls `backlog.enqueue()` then
  `wait_for_sync_terminal()`, returns the inline result on wake.
- `_run_async_task_with_persistence` wraps its body in try/finally and
  signals any registered sync waiter with the rich TaskExecutionResult
  plus chat_session_id. No-op when no waiter is registered (the common
  async fire-and-forget path).

Tests (`tests/unit/test_chat_sync_backlog.py`, 13 new):
- Signal / wait / poll-fallback / timeout / cleanup / concurrent waiters
- Regression test pins TERMINAL_TASK_STATUSES to the enum so a new
  TaskExecutionStatus value forces a deliberate update (caught a missing
  SKIPPED entry pre-merge)

Trade-off (Policy B): worst-case connection hold doubles to
2 × effective_timeout when the request is queued. Honest envelope —
the caller chose to wait. Documented in the architecture diagram of
`persistent-task-backlog.md`.

Companion issue #505 covers the orchestration-education gap (MCP tool
description + platform prompt) so agents pick the right tool for the
job rather than relying on the platform absorbing every misuse.

Closes #498

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(groom): document SDLC stages and add status-label/board reconciliation

Adds SDLC context (Todo → In Progress → In Dev → Done) so grooming respects
in-flight work, and a Step 1b that reconciles status-* labels with board
columns (labels are authoritative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): classify signal-killed claude exits as 504, not fake auth failure (#517)

External signal terminations of the claude subprocess (timeout SIGKILL,
OOM-kill, parent SIGTERM, operator cancel) used to fall through to the
auth-fallback heuristics and surface as a misleading "Subscription token
may be expired" 503. Same shape as #361 (max-turns), different exit path.

Adds _classify_signal_exit() consulted before the auth heuristics: matches
Python-native signal exits (return_code < 0) and shell-encoded forms
(130/137/143 for SIGINT/SIGKILL/SIGTERM) and raises HTTP 504 with a clear
"killed by SIGKILL/SIGTERM/SIGINT — likely timeout, OOM, or operator
cancel" message. Tightens the zero-token heuristic with return_code > 0
so signal exits cannot reach it.

The bug became routinely reproducible after #61 (PR #326) added
backend-driven terminate_execution_on_agent() — every timeout now
produces a signal-killed claude subprocess on the agent side, which the
old heuristic block misclassified. Also de-risks PR #508 (auth-class
auto-switch): without this fix, every timeout would trigger an
unnecessary subscription rotation.

Backend's task_execution_service.py only flags AUTH on 503; 504 falls
through to the generic FAILED path. No backend changes required.

Closes #516

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sprint): align skill with DEVELOPMENT_WORKFLOW.md (#519)

Four divergences between the /sprint playbook and the SDLC documented in
docs/DEVELOPMENT_WORKFLOW.md:

- Step 3 used `gh issue edit --add-label status-in-progress` directly,
  bypassing .github/workflows/claim.yml and skipping self-assignment.
  Now posts `/claim` as an issue comment, which is the workflow's single
  source of truth for the In Progress transition.
- Step 8 invoked pytest directly via `cd tests && source .venv/bin/activate
  && python -m pytest …`. Now defers to `/test-runner [feature]`, with a
  documented fallback for brand-new files outside the runner's catalog.
- Step 10 commit + PR body used `closes #N`. Workflow §1 specifies
  `Fixes #N`; both auto-close on GitHub but the doc is the contract.
- Step 11 final report didn't mention the post-merge automation. Now
  warns that issue-status-on-merge.yml owns the
  status-in-progress → status-in-dev transition, so operators don't
  manually edit labels post-merge.

Non-breaking: argument signature, automation level (gated), state
dependencies, and pipeline overview unchanged. Net +19/-11.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): classify clean-exit empty-result as 502, not silent success (#520) (#521)

* fix(agent): classify clean-exit empty-result as 502, not silent success (#520)

Sibling of #516/#517 on the return_code == 0 path. When the claude
subprocess exits 0 but the final {"type":"result"} JSON line is dropped
before the reader thread captures it (typical cause: a child subprocess
inherited stdout, kept the pipe open past claude exit, the reader thread
leaked, the pgroup unwind closed the pipe), metadata.cost_usd and
metadata.duration_ms stay None. The success path used to return HTTP 200
anyway — agent-server logged "completed successfully" while backend
silently reaped the execution as an orphan minutes later, masking the
real failure with a misleading "completed on agent but recovered by
watchdog" message.

Adds _classify_empty_result(metadata, raw_message_count) consulted after
the return_code != 0 block (#516 + auth heuristics) and before response
building. When both cost_usd and duration_ms are None, raises HTTP 502
with diagnostic context (tools, turns, raw_messages, cause hint).
Backend's task_execution_service.py:542 only flags AUTH on 503, so 502
falls through to the generic FAILED path with the helpful detail
preserved — no backend changes needed.

The two-field check is conservative: single-field nullability could be a
Claude format quirk; both-None is a strong signal that the terminal
result message never arrived. Test coverage pins the scope so a future
edit can't silently broaden it.

Changes:
- docker/base-image/agent_server/services/claude_code.py — new
  _classify_empty_result() helper next to _classify_signal_exit; call
  site between the return_code != 0 block and response building.
- tests/unit/test_empty_result_classification.py — 9 new tests, all
  pass. Covers both-None → 502, populated metadata → None,
  single-field-only → None (Claude format quirk tolerance), zero-cost
  and zero-duration → None (is None vs falsy), missing metadata → None.
- docs/memory/feature-flows/parallel-headless-execution.md — changelog
  entry under Recent Updates.
- docs/memory/feature-flows/task-execution-service.md — row in
  error-translation table + new Empty-Result Pre-Check paragraph.

Requires base-image rebuild after merge:
./scripts/deploy/build-base-image.sh

Closes #520

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): index entry for agent error classification (#516, #520)

Combined Recent Updates entry covering the matching pair of agent-side
error-classification fixes that shipped this week — _classify_signal_exit
(#516, PR #517) and _classify_empty_result (#520, PR #521). Both touch
docker/base-image/agent_server/services/claude_code.py and share the
"agent surfaces the right HTTP status so backend records FAILED with a
useful detail" theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(planning): #408 dissolution gated on #428, not #306 (re-audit 2026-04-26)

Strike item 13's stale "close as dissolved" guidance. Re-audit of
services/task_execution_service.py:412-419 confirms the long-running
HTTP call (agent_post_with_retry awaiting full agent response with
timeout = timeout_seconds + 10) is still present. #306 changed only
WebSocket transport, not backend→agent dispatch, so it cannot dissolve
#408 on its own. Aligns item 13 with line 32, which already noted
this on 2026-04-24.

Refs #408

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Apr 27, 2026
* fix(backlog): repair drain spawn after #95 rename (#496)

`services/backlog_service.py:_spawn_drain` was lazy-importing
`_execute_task_background` from `routers.chat`, but #95 (PR #316) deleted
that function and replaced it with `_run_async_task_with_persistence`.
Every backlog drain raised `ImportError`, was caught at line 218-228, and
silently marked queued executions FAILED — leaving BACKLOG-001 (#260)
non-functional whenever an agent hit capacity.

Rewire the lazy import to the new helper and adjust the call shape:
- drop `task_activity_id` (not in new signature; chat router already
  passes None at enqueue)
- drop `release_slot=True` (the wrapper passes `slot_already_held=True`
  to TaskExecutionService, which manages release in its finally block)
- derive `is_self_task` from x_source_agent vs agent_name
- pass `self_task_activity_id=None` (queued items don't carry one;
  separate gap, not in scope here)

Add `tests/test_backlog_drain_unit.py` with five regression checks:
two AST-based contract tests that pin the function name and signature
in `routers/chat.py` (would have caught the original break), and three
runtime spy tests covering the kwarg shape `_spawn_drain` forwards. The
existing `tests/unit/test_backlog.py::test_drain_happy_path_spawns_background`
is updated to match the new contract.

Sync the BACKLOG-001 and TaskExecutionService feature-flow docs to
reference the renamed helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): sync index + parallel-capacity for #496

- Add #496 entry to feature-flows.md Recent Updates.
- Fix two more stale `release_slot=True` references in
  parallel-capacity.md left over from #95 — the param never existed
  on `_run_async_task_with_persistence` (slot release happens inside
  TaskExecutionService via slot_already_held=True).

Other stale `release_slot=True` references in
authenticated-chat-tab.md and parallel-headless-execution.md are
deeper drift (separate flows, not touched by #496) — leave for a
follow-up doc-cleanup pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(catalog): register test_backlog_drain_unit.py (#496)

Adds the new BACKLOG-001 regression test file to tests/registry.json
so it shows up in the catalog alongside test_event_bus.py and
unit/test_backlog.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: drop redundant test_backlog_drain_unit.py

PR #500 (which superseded the original #496 fix scope) shipped
equivalent contract coverage with a more robust setup:
- `TestLazyImportTarget` (AST guard for the lazy-import target)
- `test_drain_threads_self_task_fields` (round-trip via real
  BacklogService against sqlite)

The local file used sys.modules stubs which were strictly weaker.
Keeping it would only add maintenance burden for duplicate coverage,
so drop the file and its registry entry. Net effect on PR #503 is
that it becomes a small, focused docs-cleanup PR (parallel-capacity.md
and task-execution-service.md drift from #95, plus the missing
Recent Updates entry for #496).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe added a commit that referenced this pull request Apr 30, 2026
* feat(webhooks): agent schedule webhook triggers (WEBHOOK-001, #291)

Add public webhook URLs so external systems (CI/CD, CRMs, monitoring) can
trigger agent schedule executions via a simple HTTP POST with no Trinity
account required — authenticated by a 256-bit opaque token embedded in the URL.

Changes:
- New public router POST /api/webhooks/{token}: rate-limited (10/60s per
  token), audit-logged, 202 Accepted, delegates to existing scheduler trigger
- JWT-auth CRUD: POST/GET/DELETE /api/agents/{name}/schedules/{id}/webhook
- DB migration: webhook_token (TEXT UNIQUE), webhook_enabled (INTEGER DEFAULT 0)
  on agent_schedules; partial unique index for O(1) token lookup
- Scheduler updated to accept triggered_by param in JSON body so executions
  record triggered_by="webhook" correctly
- Webhook context field framed as data to reduce prompt injection surface
- 12 integration tests in tests/test_webhook_triggers.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): patch 4 Dependabot alerts — happy-dom + vite (#486)

Bumps two dev-only dependencies to patched versions. Production is
unaffected (happy-dom is test-only; vite only runs in local dev).

- src/frontend: vite ^6.0.6 → ^6.4.2 (closes Dependabot #55, CVE-2026-39363)
- tests/git-sync: happy-dom ^15.11.7 → ^20.9.0 (closes #83/#84/#85:
  VM context escape RCE, ESM code exec, fetch cookie leakage)

Verified: frontend `vite build` clean, all 10 git-sync vitest tests pass
under happy-dom 20.9.0. No new critical/high alerts introduced.

Closes #485

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(webhooks): add WEBHOOK-001 to requirements and architecture (fixes #484 review)

Add missing documentation for the webhook trigger feature:
- requirements.md: WEBHOOK-001 entry with description, key features, DB changes,
  API endpoints, security model, and feature flow link
- architecture.md: webhooks.py listed in Routers table; Schedules table expanded
  from 9 to 12 endpoints with the 3 webhook management endpoints; new Webhook
  Triggers section documenting the public POST /api/webhooks/{token} endpoint;
  webhook_token and webhook_enabled columns added to agent_schedules schema block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#488): add status-in-dev label + PR-merge automation (#489)

Close the gap between "PR merged to dev" and "released to main".

- New GH Action `issue-status-on-merge.yml`: on PR merge to dev,
  parse Fixes/Closes/Resolves #N from PR body+title, add
  `status-in-dev`, remove `status-in-progress`.
- `/release` skill: read `gh issue list --label status-in-dev` as
  the authoritative shipping list for release notes; include
  `Closes #N` in the release PR body so issues auto-close on merge
  to main.
- `DEVELOPMENT_WORKFLOW.md`: SDLC is now Todo → In Progress →
  In Dev → Done, each stage mapped 1:1 to commit-graph location.

Fixes #488

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(channels): file upload Phase 2 — workspace delivery hardening (#487) (#494)

Phase 2 of #354 polishes the shared channel-agnostic file delivery path
in `message_router._handle_file_uploads`. Phase 1 (#355) added Telegram
extraction/download/validation; the actual workspace write path was
introduced for Slack inbound (#222). This change hardens the shared path
for both channels:

- New `_sanitize_filename` helper: NFKC unicode normalize → basename →
  safe-chars regex → empty/dotfile fallback to `file_{id}` → 200-char
  truncation preserving extension → collision dedup with `-1`, `-2`, …
- Spec injection format: `[File uploaded by {uploader}]: {name} ({size})
  saved to {path}`. Uploader is the verified email when present
  (Issue #311), else `adapter.get_source_identifier(message)`.
- All-writes-failed handling: when every workspace write attempt fails,
  the router replies on the channel with an explicit error and skips
  agent execution (#487 AC6). Validation rejections (size/MIME/download
  errors) still surface in the description block as before.
- Audit log entries gain an `uploader` field.

Per-session upload directory (`/home/developer/uploads/{session_id}/`)
preserved — keeps user uploads isolated and ephemeral, matches the
existing #222 model.

Tests: +17 unit tests across `TestFilenameSanitization` (12),
`TestFileDeliveryFormat` (2), `TestFileDeliveryFailures` (3). 28/28
passing in `tests/unit/test_file_upload.py`.

Docs: `telegram-integration.md` Phase 2 section + revision row;
`slack-file-sharing.md` flow / router / errors / security sections
updated for the shared change; `feature-flows.md` index row.

Closes #487

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webhooks): import Request in schedules router (#495)

The WEBHOOK-001 commits (c630931 / 8fdf736) added `request: Request`
parameters to `generate_webhook` and `get_webhook_status` without
importing `Request` from fastapi. Backend module import fails with
NameError on startup, blocking all dev deploys.

Integration tests in tests/test_webhook_triggers.py exercise these
endpoints but never caught the bug because the backend never starts —
test setup fails before any test runs.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backlog): repair drain spawn — lazy-import target after #95 (#496) (#500)

services/backlog_service.py:240 lazy-imported _execute_task_background
from routers.chat, but #95 deleted that function. Every backlog drain
attempt failed with ImportError; the exception was swallowed at
backlog_service.py:218-228, so BACKLOG-001 (#260) was silently dead.
Live observation: 23 drain failures / 24h on a fan-out workload, only
surface signal was the per-execution `error` column.

Why it shipped silently: the unit happy-path test patched
sys.modules["routers.chat"] with a SimpleNamespace stub of whatever
attribute name it expected, masking the production breakage.

Changes:
- Lazy-import _run_async_task_with_persistence (the post-#95
  replacement) and adjust the call shape (drop release_slot, drop
  orphaned task_activity_id; the unified executor handles both).
- Capture self-task fields (is_self_task, self_task_activity_id,
  inject_result) at enqueue time and rehydrate on drain so
  SELF-EXEC-001 (#264) survives backlog overflow.
- Emit a stable log token `backlog_drain_spawn_failed` so log-based
  detection (Vector / dashboards) can catch import drift or similar
  spawn-time regressions at fleet scale rather than per-row.
- AST-based regression guard in tests/unit/test_backlog.py:
  TestLazyImportTarget parses routers/chat.py and asserts the import
  target exists; paired test asserts the lazy-import string matches
  the validated allow-list. Catches both directions of drift without
  booting the backend.
- Update happy-path test to use the new symbol and kwarg surface;
  add self-task enqueue+drain round-trip tests.

Closes #496

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(announce): add Twitter/X support via API v2 + OAuth 1.0a

Bumps announce skill to v1.6. Adds a Python helper (scripts/post_twitter.py)
that reads tweet text from stdin and posts via Twitter API v2 using OAuth 1.0a
User Context — same exit-0/1 + structured-JSON contract as the existing
Discord/Slack/Telegram send paths so the sequential-only and no-blind-retry
rules apply uniformly. Credentials live in .env (gitignored) under
ANNOUNCE_TWITTER_* keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): sync /task long-polls on backlog at capacity (#498) (#515)

Sync parallel `/task` calls (parallel=true, async=false) at capacity used
to fail terminally with HTTP 429 — they never touched the BACKLOG-001
backlog because the spill block was nested under `if request.async_mode:`.
Observed in production: ~40% terminal-failure rate from one MCP fan-out
caller (214 capacity rejections / 24h, 0 enqueues from 541 dispatches).

Sync calls now spill to the same backlog the async path uses and long-poll
on the open HTTP connection until the queued execution reaches a terminal
status, then return the result inline. True 429 only when the backlog is
also full. Total connection hold capped at 2 × effective_timeout.

Implementation:
- New `services/sync_waiter.py` owns the in-process registry and the
  `signal_sync_waiter` / `wait_for_sync_terminal` primitives. Wait combines
  an asyncio.Future (set by the drain finally block) with a 5s DB-poll
  fallback that covers terminal flips routed outside the drain
  (corrupt-metadata, expire_stale, cleanup recovery).
- `routers/chat.py` sync branch now mirrors the async branch:
  pre-acquires the slot, on at-capacity calls `backlog.enqueue()` then
  `wait_for_sync_terminal()`, returns the inline result on wake.
- `_run_async_task_with_persistence` wraps its body in try/finally and
  signals any registered sync waiter with the rich TaskExecutionResult
  plus chat_session_id. No-op when no waiter is registered (the common
  async fire-and-forget path).

Tests (`tests/unit/test_chat_sync_backlog.py`, 13 new):
- Signal / wait / poll-fallback / timeout / cleanup / concurrent waiters
- Regression test pins TERMINAL_TASK_STATUSES to the enum so a new
  TaskExecutionStatus value forces a deliberate update (caught a missing
  SKIPPED entry pre-merge)

Trade-off (Policy B): worst-case connection hold doubles to
2 × effective_timeout when the request is queued. Honest envelope —
the caller chose to wait. Documented in the architecture diagram of
`persistent-task-backlog.md`.

Companion issue #505 covers the orchestration-education gap (MCP tool
description + platform prompt) so agents pick the right tool for the
job rather than relying on the platform absorbing every misuse.

Closes #498

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(groom): document SDLC stages and add status-label/board reconciliation

Adds SDLC context (Todo → In Progress → In Dev → Done) so grooming respects
in-flight work, and a Step 1b that reconciles status-* labels with board
columns (labels are authoritative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): classify signal-killed claude exits as 504, not fake auth failure (#517)

External signal terminations of the claude subprocess (timeout SIGKILL,
OOM-kill, parent SIGTERM, operator cancel) used to fall through to the
auth-fallback heuristics and surface as a misleading "Subscription token
may be expired" 503. Same shape as #361 (max-turns), different exit path.

Adds _classify_signal_exit() consulted before the auth heuristics: matches
Python-native signal exits (return_code < 0) and shell-encoded forms
(130/137/143 for SIGINT/SIGKILL/SIGTERM) and raises HTTP 504 with a clear
"killed by SIGKILL/SIGTERM/SIGINT — likely timeout, OOM, or operator
cancel" message. Tightens the zero-token heuristic with return_code > 0
so signal exits cannot reach it.

The bug became routinely reproducible after #61 (PR #326) added
backend-driven terminate_execution_on_agent() — every timeout now
produces a signal-killed claude subprocess on the agent side, which the
old heuristic block misclassified. Also de-risks PR #508 (auth-class
auto-switch): without this fix, every timeout would trigger an
unnecessary subscription rotation.

Backend's task_execution_service.py only flags AUTH on 503; 504 falls
through to the generic FAILED path. No backend changes required.

Closes #516

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sprint): align skill with DEVELOPMENT_WORKFLOW.md (#519)

Four divergences between the /sprint playbook and the SDLC documented in
docs/DEVELOPMENT_WORKFLOW.md:

- Step 3 used `gh issue edit --add-label status-in-progress` directly,
  bypassing .github/workflows/claim.yml and skipping self-assignment.
  Now posts `/claim` as an issue comment, which is the workflow's single
  source of truth for the In Progress transition.
- Step 8 invoked pytest directly via `cd tests && source .venv/bin/activate
  && python -m pytest …`. Now defers to `/test-runner [feature]`, with a
  documented fallback for brand-new files outside the runner's catalog.
- Step 10 commit + PR body used `closes #N`. Workflow §1 specifies
  `Fixes #N`; both auto-close on GitHub but the doc is the contract.
- Step 11 final report didn't mention the post-merge automation. Now
  warns that issue-status-on-merge.yml owns the
  status-in-progress → status-in-dev transition, so operators don't
  manually edit labels post-merge.

Non-breaking: argument signature, automation level (gated), state
dependencies, and pipeline overview unchanged. Net +19/-11.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): classify clean-exit empty-result as 502, not silent success (#520) (#521)

* fix(agent): classify clean-exit empty-result as 502, not silent success (#520)

Sibling of #516/#517 on the return_code == 0 path. When the claude
subprocess exits 0 but the final {"type":"result"} JSON line is dropped
before the reader thread captures it (typical cause: a child subprocess
inherited stdout, kept the pipe open past claude exit, the reader thread
leaked, the pgroup unwind closed the pipe), metadata.cost_usd and
metadata.duration_ms stay None. The success path used to return HTTP 200
anyway — agent-server logged "completed successfully" while backend
silently reaped the execution as an orphan minutes later, masking the
real failure with a misleading "completed on agent but recovered by
watchdog" message.

Adds _classify_empty_result(metadata, raw_message_count) consulted after
the return_code != 0 block (#516 + auth heuristics) and before response
building. When both cost_usd and duration_ms are None, raises HTTP 502
with diagnostic context (tools, turns, raw_messages, cause hint).
Backend's task_execution_service.py:542 only flags AUTH on 503, so 502
falls through to the generic FAILED path with the helpful detail
preserved — no backend changes needed.

The two-field check is conservative: single-field nullability could be a
Claude format quirk; both-None is a strong signal that the terminal
result message never arrived. Test coverage pins the scope so a future
edit can't silently broaden it.

Changes:
- docker/base-image/agent_server/services/claude_code.py — new
  _classify_empty_result() helper next to _classify_signal_exit; call
  site between the return_code != 0 block and response building.
- tests/unit/test_empty_result_classification.py — 9 new tests, all
  pass. Covers both-None → 502, populated metadata → None,
  single-field-only → None (Claude format quirk tolerance), zero-cost
  and zero-duration → None (is None vs falsy), missing metadata → None.
- docs/memory/feature-flows/parallel-headless-execution.md — changelog
  entry under Recent Updates.
- docs/memory/feature-flows/task-execution-service.md — row in
  error-translation table + new Empty-Result Pre-Check paragraph.

Requires base-image rebuild after merge:
./scripts/deploy/build-base-image.sh

Closes #520

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): index entry for agent error classification (#516, #520)

Combined Recent Updates entry covering the matching pair of agent-side
error-classification fixes that shipped this week — _classify_signal_exit
(#516, PR #517) and _classify_empty_result (#520, PR #521). Both touch
docker/base-image/agent_server/services/claude_code.py and share the
"agent surfaces the right HTTP status so backend records FAILED with a
useful detail" theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): off-load synchronous terminate cleanup off the event loop (#523)

The async terminate_execution endpoint and the outer asyncio.TimeoutError
handlers in execute_claude_code and execute_headless_task were calling
registry.terminate() / _terminate_process_group() / _safe_close_pipes()
synchronously. Those helpers do up to 7s of process.wait() (SIGINT grace
+ SIGKILL grace), which blocks the asyncio event loop for the entire
window. While blocked, agent-server cannot serve /health, the backend
circuit breaker opens, and UI fan-out hangs for 5+ minutes per page.

This is the actual user-visible mechanism behind the #523 "agent-server
wedge" symptom, not the FD-inheritance / leaked-reader-thread theory in
the original report (see issue comment for the corrected diagnosis —
the FD_CLOEXEC fix as written would not have helped because dup2 strips
CLOEXEC during the child's stdout setup, and the existing post-#407
killpg + safe_close path actually works in the vast majority of cases).

Wrap the three call sites in loop.run_in_executor(None, ...) so the
blocking process.wait() runs on a thread-pool worker. Pipe-inheritance
fragility remains a slow-burn cleanup item to be filed separately.

- routers/chat.py: terminate_execution dispatches registry.terminate to
  the default executor
- services/claude_code.py: outer-timeout cleanup in both async paths
  off-loads _terminate_process_group + _safe_close_pipes
- tests/unit/test_terminate_async_executor.py: regression test asserts
  registry.terminate runs on a non-event-loop thread and the event-loop
  yield stays sub-50ms while terminate is in flight

Fixes #523

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(planning): add Tier 2.6 hardening + actor-model destination roadmap

Records the architectural critique from 2026-04-26 review:
- Tier 2.6 (Sprint D′): #524 state machine contract, #525 idempotency
  keys, #526 dispatch circuit breaker. Closes the three contract-level
  gaps that survive even after Sprint D's plumbing consolidation.
- Future considerations: 7 unranked recommendations (durable
  ProcessRegistry, retry-in-funnel, synchronous terminate ack, dual
  streams, fairness, EventBus backpressure, lifecycle contract doc).
- Target architecture section: names the actor model as the destination
  (mailbox + journal + processor), maps existing components to the
  concepts they already implement, defines a 4-phase gated transition
  roadmap, and gates Phase 2 (agent-to-agent experiment) on a one-page
  message-envelope + journal-format postcard.

Issues: #524, #525, #526 created and added to project board (Epic
#411 Orchestration Invariants, Theme Reliability).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(planning): mark #291 (WEBHOOK-001) shipped, Sprint C now 5/5

#291 closed 2026-04-24, shipped via PR #484 (token-in-URL trigger
through TaskExecutionService) with follow-up fix PR #493. The plan
doc still listed it as the next item to pick up; align it with the
ground truth and re-aim "what to do next" at #428 (after #306 soak)
plus Tier 2.6 hardening (#524/#525/#526) in parallel.

* refactor(capacity): consolidate three queue/slot primitives into CapacityManager (#428) (#527)

* refactor(capacity): consolidate ExecutionQueue + SlotService + BacklogService into CapacityManager (#428)

Single public facade for agent execution capacity. Composes SlotService
(Redis ZSET counter) and BacklogService (SQL persistent overflow) as
private internals; owns the in-memory overflow store (Redis LIST, depth 3,
lifted from the deleted ExecutionQueue).

Why:
- 7 caller sites now go through one API instead of orchestrating three.
- Each new trigger type (retry, webhook, self-exec, fan-out) gets one path
  for capacity, not a choice between three primitives.
- Unblocks #429 (CLEANUP-COLLAPSE) and the actor-model destination by
  reducing the surface a single capacity store has to expose.

API:
  capacity.acquire(agent, exec_id, max_concurrent, *,
                   overflow_policy='reject'|'queue_in_memory'|'queue_persistent',
                   overflow_payload=PersistentTaskPayload(...))
  capacity.release(agent, exec_id)        # idempotent
  capacity.release_if_matches(agent, eid) # TOCTOU-safe (watchdog)
  capacity.get_status(agent, max_concurrent)
  capacity.reclaim_stale(agent_timeouts)  # called by cleanup_service
  capacity.force_release(agent)           # emergency
  capacity.cancel_all_overflow(agent, reason)  # agent deletion
  capacity.run_maintenance(max_age_hours)  # 60s tick from main.py

Wire format unchanged: same Redis keys (agent:slots:*, agent:queue:*),
same SQL columns (schedule_executions.queued_at, backlog_metadata).
In-flight executions unaffected; clean revert path.

Deviations from issue spec (user-approved):
- No feature flag — single runtime path. dev-soak + clean revert is the
  rollback mechanism, simpler than a per-agent DB column + flag check at
  every call site.
- ExecutionQueue deleted in this PR rather than separate cleanup PR.
  SlotService and BacklogService kept as private internals (well-factored,
  one job each).

Soak deviation: shipped after 5 days of #306 soak rather than the planned
14 days. Mitigated by additive-style refactor (no wire-format change).

Files:
- NEW services/capacity_manager.py (~480 LOC)
- DELETE services/execution_queue.py (~360 LOC)
- 7 caller migrations: routers/chat.py (4 sites), routers/agents.py (2),
  routers/agent_config.py (1), services/cleanup_service.py (4),
  services/task_execution_service.py (1), services/agent_service/queue.py (3),
  main.py (1, callback wiring is now internal).
- NEW tests/unit/test_capacity_manager.py — 21 tests covering acquire/release
  for all three overflow policies, drain wiring, status, force_release,
  reclaim_stale, cancel_all_overflow.
- UPDATE tests/test_watchdog_unit.py — 11 mock decorator pairs collapsed to
  single get_capacity_manager mock.

Tests: 21 new + 35 watchdog + 33 backlog = 89 green for affected surface.

Fixes #428

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): add capacity-management.md, deprecate predecessor flows (#428)

- NEW capacity-management.md — public surface, overflow policies, end-to-end
  /chat and /task flows, storage map, maintenance & recovery, what-replaced-what.
- DEPRECATE notes on the three predecessor flows with redirects:
  - execution-queue.md (ExecutionQueue deleted)
  - parallel-capacity.md (SlotService internalized)
  - persistent-task-backlog.md (BacklogService internalized)
- "Now uses CapacityManager" notes on four downstream flows:
  - task-execution-service.md, parallel-headless-execution.md,
    cleanup-service.md, execution-termination.md
- Index: Recent Updates row + Core Agent Features row for capacity-management.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(requirements): note BACKLOG-001 is now internal to CapacityManager (#428)

Section 10.8 (Persistent Task Backlog) — replace direct SlotService callback
reference with the unified CapacityManager facade. Status bumped with the
2026-04-26 internalization date and #428 cross-ref.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): drain pipe before close to preserve final result line (#531) (#532)

* fix(agent): drain pipe before close to preserve final result line (#531)

drain_reader_threads previously called safe_close_pipes() immediately
after terminate_process_group(), discarding the kernel pipe buffer before
the reader thread could drain it. On long agentic tasks the final
{"type":"result"} JSON line (cost, duration, answer) was in that buffer
at the moment of close, causing the reader to raise ValueError and
metadata.cost_usd / duration_ms to remain None — triggering the HTTP 502
"Execution completed without a result message" classification from #521.

Fix: reorder so grandchildren are killed first, then the reader is given
post_kill_grace=30s to drain naturally (grandchildren dead → kernel
delivers EOF once the buffer is consumed → reader returns '' and exits
cleanly). safe_close_pipes() is now a true last resort — only called when
the reader is still alive after 30s, which indicates a genuine wedge, not
unfinished backlog drain.

Also extends _classify_empty_result to derive num_turns from raw_messages
when metadata.num_turns is None (result line lost), so the 502 detail
reports an honest turn count instead of always showing 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(tests): update test catalog for #531 drain_reader_threads fix

- Add test_subprocess_pgroup.py and test_empty_result_classification.py
  to Test Categories (Operations & Observability, unit section)
- Add 2026-04-27 Recent Test Additions entry with description of the
  pipe-drain ordering regression tests and raw_messages fallback tests
- Update unit test count: 165 → 170; total: 2,257 → 2,262

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(feature-flows): document drain_reader_threads pipe-ordering fix (#531)

Update parallel-headless-execution.md with the root cause fix for
the "Execution completed without a result message" HTTP 502: the old
drain_reader_threads sequence closed the pipe before the reader could
drain the kernel buffer (including the final result JSON line). New
sequence: kill grandchildren → natural drain (post_kill_grace=30s) →
force-close only as last resort.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): update orphaned-recovery mocks to CapacityManager (#533) (#534)

Replace stale services.slot_service sys-mock with services.capacity_manager
so all four recovery scenario tests pass after the #428 consolidation.
Assertions updated from release_slot → release to match the new API.

Fixes #533

Co-authored-by: Claude <noreply@anthropic.com>

* docs(skills): align validate-pr with DEVELOPMENT_WORKFLOW.md

Add quick triage block, base branch check, PR size warning, type-docs
label, Base Branch/PR Size rows in report table, and review pipeline
matrix linking /review and /cso --diff with their complementary roles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(validate-architecture): stale-citation filter + dedupe guard (#511) (#513)

* fix(validate-architecture): add stale-citation filter + issue dedupe guard (#511)

The /validate-architecture skill produced false-positive issue #479 by:
1. citing file paths the report's snapshot saw, but `main` no longer has
   (process engine deleted in #430 the same day);
2. running `gh issue create` with no check for existing open issues with
   the same finding fingerprint.

Two targeted edits to .claude/skills/validate-architecture/SKILL.md:

- New Step 2c "Filter Stale Citations" — `git ls-files --error-unmatch`
  every cited path before report. Drop ghosts. Downgrade FAIL → PASS
  when an invariant has zero remaining real citations.

- Modified Step 4 — fingerprint = sorted invariant numbers; query
  open `automated,priority-p1` issues with `--search "in:body
  validate-architecture fingerprint=<fp>"`; comment on existing
  issue instead of creating duplicate. Issue body now stamps the
  current commit SHA for evidence binding.

Closes #511.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(validate-architecture): clarify dedupe branching, distinct fingerprint marker, quote paths (#511)

Follow-up to review feedback on PR #513. Three small skill-prose
hardenings:

- I2 (LLM-driven flow control): the dedupe branch previously relied on
  `if [ -n "$EXISTING" ]; then ...; exit 0; fi` followed by a separate
  create block. `exit 0` halts a bash subshell, not an LLM walking the
  markdown — a future runner could execute both blocks. Replace with
  explicit "Path A — COMMENT, then STOP" / "Path B — CREATE" prose
  branching and an explicit DO-NOT note.

- I4 (fingerprint collision): replace free-text body search
  `validate-architecture fingerprint=$FP` with HTML-comment marker
  `<!-- validate-architecture::fingerprint=$FP -->` plus a quoted-phrase
  search. Self-evidently programmatic; won't collide with prose.

- I3 (path quoting): the Step 2c example now uses `"$path"` and a note
  about shell metachars, so implementers don't strip the quotes.

- Add concurrency caveat documenting that the dedupe is best-effort,
  not atomic (no GitHub primitive provides this).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(config): clean stale Auth0 / AUDIT_URL, document SMTP/SendGrid/FRONTEND_URL (#481) (#509)

- Remove dead Auth0 env vars + build args from docker-compose.prod.yml
  and docker/frontend/Dockerfile.prod (Auth0 removed 2026-01-01). The
  build-arg fallbacks were also leaking a real Auth0 domain + client ID
  into a public repo.
- Drop AUDIT_URL from .env.example (audit-logger service no longer exists;
  no Python references it).
- Add FRONTEND_URL to .env.example (required in prod for OAuth post-auth
  redirects in slack_service.py / public_links.py and SSH host
  auto-detection in ssh_service.py).
- Document SMTP_HOST/PORT/USER/PASSWORD and SENDGRID_API_KEY in
  .env.example so the advertised EMAIL_PROVIDER=smtp/sendgrid modes are
  actually configurable from the template.

Closes #481

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): require auth on /api/docs endpoints (#452) (#507)

Add Depends(get_current_user) to the three handlers in
src/backend/routers/docs.py so the file no longer violates
Architectural Invariant #8.

Note: this router is not currently registered in main.py, so
the endpoints are not reachable on the running API. The fix is
applied to the file as written so the invariant validator stops
flagging it and so the file is correct if it is ever remounted.

Closes #452

* fix(chat): wrap long unbroken strings in chat bubbles (#457) (#502)

Long URLs, tokens, base64 blobs, and other unbroken strings in agent
chat responses were overflowing their 85% bubble and forcing horizontal
scroll on the entire Chat tab.

Root cause: ChatBubble.vue capped the bubble width but never told
inner content how to handle unbreakable strings. Inline <code> and
the user-text <p> had no overflow-wrap; <pre> defaulted to white-space:
pre with no overflow-x: auto override.

Fix (CSS-only, all 3 render branches — user / self-task / assistant):
- min-w-0 on outer wrapper, overflow-hidden on inner bubble
- break-words on user text and prose container
- prose-pre:overflow-x-auto + prose-pre:max-w-full so code blocks
  scroll inside the bubble instead of expanding it
- prose-code:break-words for long inline tokens
- prose-a:break-words for long URLs in markdown links

Verified visually: before/after static test page shows BEFORE leaks
content well past the bubble border; AFTER wraps cleanly with no
regression on normal markdown (headings, lists, links, short code).

* fix(schedules): add missing Request import for webhook endpoints (#493)

Regression from c630931 (WEBHOOK-001 / #291): `routers/schedules.py`
uses `Request` as a type annotation on the `generate_webhook` and
`trigger_webhook` handlers but never imports it, so the module fails
to load and the backend won't start with a NameError.

Minimal fix: add `Request` to the existing `from fastapi import …`
line (line 11). No behavioral change — the annotation was already
intended.

Surfaced while dev-testing FILES-001 (PR #491). uvicorn reload pulled
in the dev branch state and blew up.

Co-authored-by:  Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(git): route orphan cleanup through db.delete_git_config (#451) (#501)

Replaces raw `DELETE FROM agent_git_config` SQL in routers/git.py with the
existing `db.delete_git_config()` method (already used at line 435 of the same
file for the init-failure rollback path). Restores Architectural Invariant #1
(Three-Layer Backend) for this router. No behavior change — identical SQL,
identical parameter binding.

Closes #451

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(subscription): auto-switch on first failure + auth-class triggers (#441) (#508)

Drop the 2-consecutive-429 gate in `subscription_auto_switch` so a single
subscription failure now triggers a switch — the 2h skip-list on
alternative selection (already pinned by #444 / #476 regression tests) is
sufficient as the lone thrash guard. Broaden the trigger surface to also
fire on auth-class failures (401/403/credit balance/expired OAuth token,
etc.), classified via a centralized `AUTH_INDICATORS` list, so a broken
subscription auto-recovers instead of failing every execution until
manual intervention. Flip the `auto_switch_subscriptions` default to
"true" — operators can still opt out, but the safe behavior is now the
default. Backward-compat shim `handle_rate_limit_error` preserved for
existing 429 callers.

- services/subscription_auto_switch.py: new `handle_subscription_failure`
  with `failure_kind` dispatch ("rate_limit" | "auth"); new
  `is_auth_failure` classifier; default flipped; notification + log
  wording adapts per kind; old shim retained.
- services/task_execution_service.py: 503 / auth-classified errors now
  also call the switch path alongside 429.
- routers/chat.py (sync): same broadening on the interactive chat
  surface; auth path returns 503+retry hint mirroring the 429 UX.
- routers/subscriptions.py: GET `/auto-switch` default also flipped to
  "true" so the UI toggle and runtime gate read the same value.
- scheduler/service.py: dedupe two inline `auth_indicators` copies into
  a single module-level constant; cross-reference the canonical list in
  backend (cross-container import not viable).
- tests/unit/test_subscription_auto_switch_pingpong.py: new
  TestIsAuthFailure + TestSingleEventThreshold classes (8 new tests, all
  pingpong + #476 aging tests still green).
- tests/test_subscription_auto_switch.py: flip default-off → default-on.
- docs: SUB-003 feature flow + requirements doc reflect the new
  threshold, broadened scope, and on-by-default behavior.

Closes #441

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): clean up #95 drift missed by #500 (#496) (#503)

* fix(backlog): repair drain spawn after #95 rename (#496)

`services/backlog_service.py:_spawn_drain` was lazy-importing
`_execute_task_background` from `routers.chat`, but #95 (PR #316) deleted
that function and replaced it with `_run_async_task_with_persistence`.
Every backlog drain raised `ImportError`, was caught at line 218-228, and
silently marked queued executions FAILED — leaving BACKLOG-001 (#260)
non-functional whenever an agent hit capacity.

Rewire the lazy import to the new helper and adjust the call shape:
- drop `task_activity_id` (not in new signature; chat router already
  passes None at enqueue)
- drop `release_slot=True` (the wrapper passes `slot_already_held=True`
  to TaskExecutionService, which manages release in its finally block)
- derive `is_self_task` from x_source_agent vs agent_name
- pass `self_task_activity_id=None` (queued items don't carry one;
  separate gap, not in scope here)

Add `tests/test_backlog_drain_unit.py` with five regression checks:
two AST-based contract tests that pin the function name and signature
in `routers/chat.py` (would have caught the original break), and three
runtime spy tests covering the kwarg shape `_spawn_drain` forwards. The
existing `tests/unit/test_backlog.py::test_drain_happy_path_spawns_background`
is updated to match the new contract.

Sync the BACKLOG-001 and TaskExecutionService feature-flow docs to
reference the renamed helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): sync index + parallel-capacity for #496

- Add #496 entry to feature-flows.md Recent Updates.
- Fix two more stale `release_slot=True` references in
  parallel-capacity.md left over from #95 — the param never existed
  on `_run_async_task_with_persistence` (slot release happens inside
  TaskExecutionService via slot_already_held=True).

Other stale `release_slot=True` references in
authenticated-chat-tab.md and parallel-headless-execution.md are
deeper drift (separate flows, not touched by #496) — leave for a
follow-up doc-cleanup pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(catalog): register test_backlog_drain_unit.py (#496)

Adds the new BACKLOG-001 regression test file to tests/registry.json
so it shows up in the catalog alongside test_event_bus.py and
unit/test_backlog.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: drop redundant test_backlog_drain_unit.py

PR #500 (which superseded the original #496 fix scope) shipped
equivalent contract coverage with a more robust setup:
- `TestLazyImportTarget` (AST guard for the lazy-import target)
- `test_drain_threads_self_task_fields` (round-trip via real
  BacklogService against sqlite)

The local file used sys.modules stubs which were strictly weaker.
Keeping it would only add maintenance burden for duplicate coverage,
so drop the file and its registry entry. Net effect on PR #503 is
that it becomes a small, focused docs-cleanup PR (parallel-capacity.md
and task-execution-service.md drift from #95, plus the missing
Recent Updates entry for #496).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(deploy): align scripts, configs, and docs with production operating patterns (#504)

* docs(generate-user-docs): add hub-and-spokes deployment structure and ops-pattern import

Restructure skill to produce guides/deploying/ as a hub plus six spokes
(local-development, single-server, public-access, upgrading,
backup-and-restore, monitoring) instead of one flat deploy guide.

Add an "operational guide" template (When to Run → Pre-flight →
Procedure → Verify → Rollback) for procedural docs that don't fit the
feature-shaped dual-audience template, plus verbatim-reuse snippets for
the load-bearing rules: never down/up, rebuild platform services only,
six-probe verification, resource-thresholds table, alpine cp backup.

Add Step 2h to draw operational patterns from the private ops runbook
under ../trinity-ops/, with explicit safe/forbidden import lists and a
sshpass→localhost rewrite rule.

Strengthen Step 2e to cross-check .env.example keys against each
compose's environment block — docs must not promise behavior the
chosen compose can't deliver.

Add public-safety greps in Step 7 (sshpass, trinity-ops, tailnet, real
IPs, instance-dir refs) so leaked private detail blocks completion.

Tracks issue #504.

* fix(deploy): align scripts, configs, and docs with production operating patterns (#504)

Fixes the first-run blocker (agent creation fails silently without base
image) and removes references to the removed audit-logger service that
caused verify-platform.sh and validate.sh to always fail.

Scripts:
- start.sh: detect missing base image and auto-build on first run; use
  `docker compose stop` in help text (not `down`, which destroys agents)
- verify-platform.sh: full rewrite — remove trinity-audit-logger and port
  8001 audit checks; fix frontend from port 3000 → 80; check scheduler
  health at :8001; add MCP/Vector probes; fix login hint
- validate.sh: remove non-existent `deployment/` dir, `QUICK_START.md`,
  and `src/audit-logger/audit_logger.py` from required paths; fix port 3000

Config:
- docker-compose.yml: wire 5 missing env vars into backend (PUBLIC_CHAT_URL,
  FRONTEND_URL, EXTRA_CORS_ORIGINS, SLACK_SIGNING_SECRET, SSH_HOST)
- .env.example: remove stale AUDIT_URL; annotate prod-only / overlay-only
  vars (SLACK_SIGNING_SECRET, PUBLIC_CHAT_URL, FRONTEND_URL, SSH_HOST,
  TRINITY_GIT_BASE_URL) so users know scope before setting them

Docs:
- deploying-trinity.md: add explicit build-base-image.sh step; fix
  /trinity:connect to use MCP API key flow (not username/password); add
  Upgrading, Health Verification, Resource Thresholds, and Common Recovery
  Patterns sections from ops runbook; use `docker compose` (v2 syntax)
- setup.md: remove false claim that start.sh builds the base image; correct
  admin account creation (env var driven, not wizard); clarify wizard path
  (used only when ADMIN_PASSWORD is unset)

New file:
- quickstart.sh: interactive one-command setup (checks Docker, generates
  secrets, sets ADMIN_PASSWORD, builds base image, starts services, verifies)

Skill:
- generate-user-docs: add deployment config reading rules (read scripts
  literally; cross-check env vars vs compose; never claim "auto" unless code
  proves it); add operational guide template (pre-flight/steps/verify/rollback);
  resolve conflict preserving the hub+spokes guide structure from branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(generate-user-docs): remove private repo name from SKILL.md

Replace explicit `trinity-ops` repo references with generic path aliases
(`../ops-runbook/`) so the private repo name is not embedded in this
public repository.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(reliability): CAS guards on execution status writes + state machine doc (#524) (#541)

Closes the FAILED→SUCCESS and SUCCESS→FAILED races that were patched by
#378 re-verify logic without eliminating the root cause.

Changes:
- update_execution_status: SUCCESS writes are unconditional (agent wins);
  non-success terminal writes blocked when row already terminal
- mark_stale_executions_failed / mark_no_session_executions_failed: inner
  UPDATE gains AND status='running' to close the SELECT→UPDATE TOCTOU window
- _recover_execution: routes through mark_execution_failed_by_watchdog
  (already CAS-guarded) instead of bare update_execution_status
- TaskExecutionStatus: state machine, transitions, and authorized writers
  documented in docstring; PENDING_RETRY added to enum
- Remove now-dead _STALE_SLOT_ERROR_PATTERN constant

Full projector architecture (ExecutionStateProjector, agent event emission,
projected_status shadow column) deferred — agents have no Redis access and
the restart-recovery design needs more thought before those land.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(public-chat): build context before storing user message to prevent duplication (#539) (#540)

* fix(public-chat): build context before storing user message to prevent duplication (#539)

In the public chat endpoint, the user message was persisted to the database
before build_public_chat_context read from it, causing the current message
to appear twice in every agent prompt — once in "Previous conversation:"
and once in "Current message:". Reordering the calls so context is built
first (from prior history only) then the user message is stored eliminates
the duplicate on every turn.

Adds unit tests that document both the old broken order (two occurrences)
and the corrected order (one occurrence), guarding against regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(feature-flows): update public-agent-links with #539 context ordering fix

- Correct PUB-005 data flow: build_public_chat_context before add_public_chat_message
- Update backend implementation step ordering to match fixed code
- Add revision history entry for the bug fix
- Add #539 entry to feature-flows.md index

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(agent-runtime): guard content_block isinstance in process_stream_line (#542) (#543)

Prevents AttributeError crash when Claude Code stream-json emits a
string element inside a message content array. Guards both
process_stream_line (real-time path) and parse_stream_json_output
(batch path) using the same isinstance(block, dict) pattern already
used by the error_content loop.

Fixes #542

Co-authored-by: Claude <noreply@anthropic.com>

* docs(#411): Phase 1 canary harness design + catalog Phase 1 subset additions (#544)

* docs(#411): Phase 1 canary harness design + catalog Phase 1 subset additions

- New design doc at docs/planning/CANARY_HARNESS_PHASE_1.md scoping the
  AC-required infrastructure (snapshot collector, canary_violations table,
  canary agent template, fleet, alerts) for the three required invariants
  (S-01, E-02, L-03).
- Catalog Phase 1 subset expanded 10 → 12: adds S-03 (slot TTL ≥ exec
  timeout, catches #226) and E-05 (dispatched rows have session, catches
  #106), since both bugs are cited in the catalog motivation but had no
  Phase 1 detector.

* docs(#411): scope fleet to strict minimum for AC's 3 invariants

* docs(#411): expand design doc to cover full Phase 1 (12 invariants, snapshot format)

* feat(settings): add Remove buttons for stored API keys + Slack (#459) (#483)

Settings page lets admins save/test Anthropic API Key, GitHub PAT, and
Slack OAuth credentials, but exposed no UI to clear them once stored.
Only workaround was calling DELETE endpoints directly or editing the DB.

Adds Remove buttons next to Save in each row, conditionally rendered
when the value lives in settings DB (source === 'settings'). Env-var
fallbacks stay uneditable from UI. Confirm dialog before deletion
(reuses ConfirmDialog component + pattern from ApiKeys.vue).

Backend DELETE endpoints already existed — no backend work:
- DELETE /api/settings/api-keys/anthropic
- DELETE /api/settings/api-keys/github
- DELETE /api/settings/slack

Audit of other Settings sections: Trinity Prompt has clearPrompt,
Skills Library blanks via deleteSetting, MCP URL has resetMcpUrl,
GitHub Templates/Email Whitelist have inline remove. Agent Quotas are
config values, not secrets.

Closes #459.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): swallow duplicate-column race on cold start (#456) (#537)

* fix(migrations): swallow duplicate-column race on cold start (#456)

`_migrate_sync_health` (#389) used a check-then-act PRAGMA → ALTER
pattern that is not atomic across uvicorn workers. On cold start with
`--workers 2`, both workers passed the PRAGMA before either committed
the ALTER, and the loser crashed its child process with
`sqlite3.OperationalError: duplicate column name: auto_sync_enabled`.

Fix:
- Add `_safe_add_column` helper that swallows the duplicate-column
  OperationalError (treats it as success — another worker won the race).
  Future migrations should route ALTER TABLE ADD COLUMN through it.
- Refactor `_migrate_sync_health` to use the helper for both column
  additions and switch the bare `CREATE TABLE` to `CREATE TABLE IF NOT
  EXISTS` (atomic in SQLite).

Tests:
- `test_safe_add_column_swallows_duplicate_column_race` — drives the
  exact production race via a PRAGMA-lying cursor proxy.
- `test_safe_add_column_propagates_other_errors` — non-duplicate errors
  still raise.
- `test_safe_add_column_returns_true_when_added` — happy path.
- `test_migrate_sync_health_idempotent_under_race` — `_migrate_sync_health`
  is now safe to re-run on already-migrated schemas, including under
  the simulated race.

The other ~50 ALTER ADD COLUMN sites are untouched: they're already
applied on production DBs (run_all_migrations short-circuits via the
schema_migrations tracking table). The race only bites new migrations
on first cold-start; the helper is available for them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(migrations): route all ALTER ADD COLUMN through _safe_add_column (#456)

Mechanical sweep of every check-then-act `PRAGMA table_info` →
`ALTER TABLE ADD COLUMN` site through the `_safe_add_column` helper, so
new migrations on a fresh cold-start with `--workers N` are race-safe by
default — not just `_migrate_sync_health` (the originally reported case).

22 migrations refactored. Bare `try/except Exception` swallows in
`_migrate_chat_messages_source_column` and
`_migrate_agent_ownership_voice_prompt` are also replaced with the
helper, which catches only the duplicate-column error instead of every
exception class.

Verification:
- Schema dump (init_schema + run_all_migrations on fresh DB) is
  byte-identical before and after the sweep — every column type,
  default, FK, and index preserved.
- run_all_migrations is idempotent across runs and across fresh
  connections (2nd/3rd runs print no add/create lines).
- 6-worker concurrent stress test (`threading.Barrier`-coordinated)
  completes without any worker crashing; final schema is intact.
- tests/unit/test_migrations_concurrent.py +
  tests/unit/test_migrations.py + tests/unit/test_guardrails.py:
  72 pass, 0 fail.

`tests/unit/test_guardrails.py::test_migration_is_idempotent` updated
to also exec the `_safe_add_column` helper into its isolated
namespace, since the migration now delegates to it.

The two remaining bare `CREATE TABLE` calls in the file
(`_migrate_agent_sharing_table`, `_migrate_agent_skills_table`) are
one-time DROP+CREATE data-recreation migrations that already shipped
on every existing install; they are out of scope for this sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(git): UI Push no longer commits runtime state (#462)

Expands the platform .gitignore deny-list to cover all runtime files
(.env, .mcp.json, .credentials.enc, instance dirs, content/, Claude
Code state, temp files). Adds idempotent migration that updates
existing agents on next Push and calls `git rm --cached` for files
that are now tracked but newly ignored.

Closes #462

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(frontend): semantic status color tokens (#67) (#553)

Introduce 5 semantic status tokens (`status-success/warning/danger/info/urgent`)
in tailwind.config.js as direct aliases of the green/yellow/red/blue/orange
palettes, then migrate 9 frontend files (4 components, 2 panel-local helpers,
1 composable, 1 utility) from raw color classes to the new tokens. Visual
output is byte-equivalent — tokens compile to identical RGB values.

Add CI safety net: `npm run check:tokens` script verifies token-palette
equivalence and catches typo'd token references in source. Wired into a new
frontend-build.yml workflow that runs `npm ci → check:tokens → build` on PRs
touching `src/frontend/**`.

Drive-by fix: rename postcss.config.js → postcss.config.mjs to fix Node
ESM/CJS interop for local `npm run build` (production Docker build was
masking the issue).

70+ raw-color files remain for follow-up sweep (per autoplan phasing).

Fixes #67

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(files): FILES-001 outbound file sharing — MVP + Phase 1 hardening (#491)

* feat(files): FILES-001 outbound file sharing MVP (Steps 1-6)

Implements outbound file sharing per docs/drafts/amazing-file-outbound.md:
- Schema + migration (agent_shared_files table with FK cascade)
- Per-agent opt-in toggle + Docker publish volume (agent-{name}-public)
- Internal share endpoint with path/MIME/size/quota validation
- Public download endpoint (/api/files/{id}?sig=...) with token auth
- share_file MCP tool (agent-scoped)
- SharingPanel UI: toggle, list, revoke, copy URL

Live-verified on Slack: agent→share_file→URL→download end-to-end.
Unit tests: 33 passed (migration, mixin, mount-match).
Known limitations + production readiness plan:
  docs/drafts/amazing-file-outbound-production-readiness.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(files): FILES-001 — requirements, architecture, feature-flow doc

- requirements.md §13.10 new entry marking FILES-001 Implemented (2026-04-24)
- architecture.md: add files.ts MCP module, agent_shared_files_service,
  routers/files.py, the 5 new API endpoints + dedicated section, and the
  agent_shared_files table schema + operational notes
- feature-flows.md: Recent Updates entry + Documented Flows index
- feature-flows/file-sharing-outbound.md: new full vertical-slice doc
  (UI → store → router → service → DB → download) matching the template

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(files): cap filename length at 255 chars (C2)

ShareFileRequest.filename and ShareFileMcpRequest.filename get
Field(max_length=255, min_length=1). display_name same cap.
Prevents 10KB+ filename edge cases from agent or attacker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(files): disk-space pre-check before write (C3)

New check_disk_space() helper using shutil.disk_usage('/data').
Refuses writes when /data has less than size_bytes + 500MB free
(HTTP 507 Insufficient Storage). Called before persisting.

Protects shared /data mount — SQLite DB, Vector logs, and log
archives live there too; letting an agent fill the disk causes
platform-wide outage, not just a failed share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cleanup): purge expired and old-revoked shared files (C4 / Step 7)

Adds delete_expired_and_revoked(revoke_grace_hours=24) to the DB ops
class (returns stored_filename list for disk unlink) + facade forward
+ wired into cleanup_service.py's 5-min tick.

Per cycle:
- SELECT rows where expires_at < now OR revoked_at < now - 24h
- DELETE them from DB
- unlink each /data/agent-files/{stored_filename}
- bumps CleanupReport.shared_files_purged

The 24h grace on revoked rows keeps them queryable for incident
diagnosis right after revocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(files): dedicated rate-limit bucket for downloads (C5)

/api/files/{id} now uses _check_file_download_rate_limit which keys
redis by file_downloads:{ip} instead of sharing the public_link_lookups
bucket used by /api/public/chat and friends. Limits unchanged (60/min
per IP).

Prevents heavy download traffic from starving the rate-limit quota for
public chat or other /api/public/* endpoints on the same IP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(files): HEAD handler mirroring GET validation (C6)

Link previewers (Slackbot, Twitterbot, Discordbot, facebookexternalhit)
HEAD-probe URLs before GET. Our endpoint was 405-ing those.

Extracted _validate_download_request() helper from GET; new HEAD
handler reuses it and returns Response(200) with the same headers
(Content-Disposition, nosniff, no-store, Content-Length) but no body,
no download counter bump, no audit row. Follows RFC 7231 §4.3.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(files): tighten list endpoint to owner+admin only (C7)

GET /api/agents/{name}/shared-files previously used can_user_access_agent
(owner/admin/shared). But the list response includes full download URLs
with signed tokens — so anyone able to see the list can reuse every
share. That's the same capability as share_file + revoke, both of which
already require can_user_share_agent.

Change to can_user_share_agent (owner + admin), 403 otherwise.
DELETE was already owner-only; no change needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(prompt): agent nudge for share_file MCP tool (C8)

Add a new 'Sharing Files with Users' section to the system-wide
PLATFORM_INSTRUCTIONS between Collaboration and Operator Communication.
Tells every agent:
  - write files to /home/developer/public/
  - call share_file MCP tool with the relative filename
  - return the URL as-is

This means new agents discover the capability without the user
needing to name the tool explicitly. Applies immediately to every
agent via compose_system_prompt() — no image rebuild needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pr-491): address validation findings — PII redaction + scope drift

PR #491 /validate-pr flagged two issues:

1. CRITICAL: pavshulin@gmail.com in two draft docs' Owner fields
   (amazing-file-outbound.md, amazing-file-outbound-production-readiness.md).
   Public repo + CLAUDE.md forbids real user emails.
   → Replaced with @pavshulin (GitHub handle).

2. WARNING: .claude/settings.json committed as new file — personal
   Claude Code permission allowlist unrelated to FILES-001 scope.
   → Merged 8 allowlist entries into .claude/settings.local.json
     (gitignored per .gitignore:67). Removed .claude/settings.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix test-ordering contamination from FILES-001 mixin fixture

Two adjustments surfaced by running the full unit suite:

1. test_file_sharing_mixin.py registered `sys.modules['db']` as a plain
   module (no `__path__`), which poisoned `from db.X import Y` lookups in
   sibling tests (e.g. test_fleet_sync_audit did `from db.schedules ...`
   and hit `'db' is not a package`). Now we give our stub a `__path__`
   pointing at the real db directory, and restore `sys.modules['db']` on
   fixture teardown so no leakage remains.

2. test_start_agent_skip_inject.py didn't mock the new
   `check_public_folder_mount_matches` import added by FILES-001 in
   services/agent_service/lifecycle.py. The Mock container lacked
   iterable `attrs["Mounts"]`, blowing up with TypeError. Stubbed the
   whole `file_sharing` submodule and bound the check on `_mod`
   per-test to return True by default.

Full unit suite now matches dev baseline: 17 pre-existing failures,
701 passing (+33 over dev, all from FILES-001).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by:  Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(channels): deliver images as vision content blocks via stream-json (#562) (#566)

Replaces the broken base64 data-URI-in-text approach (where Claude Code
received images as opaque markdown strings) with proper vision content
blocks fed via --input-format stream-json stdin. Images sent through
Telegram (and other channel adapters) are now visible to the agent.

- message_router: _handle_file_uploads returns 4-tuple (added image_data);
  image MIME files collected as {media_type, data} dicts instead of embedded
- task_execution_service: execute_task() accepts images param, forwards in payload
- agent_server models: ParallelTaskRequest.images field added
- agent_server chat router: passes images to runtime.execute_headless()
- claude_code: adds --input-format stream-json and builds JSON content-block
  stdin payload when images present; stdout/stderr threads start before stdin
  write to prevent pipe deadlock; write moved into executor (not event loop)
- runtime_adapter ABC + GeminiRuntime: images param added to prevent TypeError

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(frontend): add state, brand, accent token families (#555) (#561)

Extends the design-system token system from #67 with three additional families
for colors that don't fit the status taxonomy:

  state-*   agent operating modes (autonomous, locked)
  brand-*   third-party product identity (claude, gemini)
  accent-*  decorative highlights named after the literal color so future
            accents (accent-green, etc.) join cleanly

New tokens (all alias full Tailwind palettes, identical visual output):
  state-autonomous → amber   (AutonomyToggle AUTO mode)
  state-locked     → rose    (ReadOnlyToggle ON mode)
  brand-claude     → orange  (RuntimeBadge for Claude Code)
  brand-gemini     → blue    (RuntimeBadge for Gemini CLI)
  accent-purple    → purple  (DashboardPanel widget badges)

Also extends scripts/check-design-tokens.mjs to validate the new families
via a KNOWN_FAMILIES map; the reference scanner now flags typos within any
of the four families (status/state/brand/accent), not just status-*.

Migrates the 4 components blocked by #67's status-only scope:
  - RuntimeBadge.vue       → brand-claude, brand-gemini
  - AutonomyToggle.vue     → state-autonomous
  - ReadOnlyToggle.vue     → state-locked
  - DashboardPanel.vue     → accent-purple slot in getStatusColors

Fixes #555

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(scheduler): agent-owned pre-check hook (#454) (#455)

* feat(scheduler): agent-owned pre-check hook (#454)

New optional contract: agents implement POST /api/pre-check in their
container; scheduler calls it before firing a cron-triggered chat.
Endpoint absent or any error → fire as usual (fail-open). fire=false
records a skipped execution. fire=true with a message overrides the
schedule.message for that invocation.

- docker/base-image/agent_server/routers/pre_check.py: new router that
  dynamically loads /home/developer/.trinity/pre-check.py (template-
  supplied) and calls its check() function
- agent-server main.py: mount pre_check_router
- scheduler/agent_client.py: pre_check() method with fail-open semantics
  on 404/5xx/timeout/malformed-response
- scheduler/service.py: _run_pre_check + pre-check branch in
  _execute_schedule_with_lock (cron only; manual triggers bypass)
- tests/scheduler_tests/test_pre_check.py: 12 tests covering client-
  and service-level behavior; 161/161 scheduler suite passes

Zero schema change — reuses existing ExecutionStatus.SKIPPED and
create_skipped_execution. Closes the "wake agent on every cron tick"
cost gap noted in docs/planning/PR_REVIEWER_AGENT.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(#454): scheduler pre-check feature flow + arch + requirements

- feature-flows/scheduler-pre-check.md: new flow doc with contract,
  fail-open semantics, error table, testing summary
- architecture.md: add /api/pre-check to agent-server endpoint list
  and pre-check note to Scheduler Service row
- requirements.md: SCHED-COND-001 entry under §10 (Scheduling & Execution)
- feature-flows.md: index row
- docs/planning/PR_REVIEWER_AGENT.md: design doc from which this
  feature was extracted — committed for traceability

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: address PR #455 review feedback

- pre_check.py: asyncio.get_event_loop() → get_running_loop() (deprecated in 3.10+)
- pre_check.py: oversized message override no longer dropped silently —
  response now carries message_truncated="override dropped: N bytes exceeds
  32000 cap" so scheduler/operator can see what happened; log escalated to
  ERROR with size+limit details
- pre_check.py: module-level docstring expanded to note the security scope
  of check() (full Python interpreter access, same sandbox as chat tools —
  operators should review .trinity/pre-check.py like any executable template
  file) and the intentional no-cache behavior
- tests/unit/test_pre_check_router.py: 15 new router/unit tests covering
  oversized-message drop path and non-dict return → 500 (both previously
  only exercised by inspection). Uses importlib to load pre_check.py
  directly, avoiding python-multipart requirement from sibling routers
- feature-flows/scheduler-pre-check.md: document truncation behavior,
  security scope expectation, and updated test summary (12 scheduler +
  15 router = 176 total passing)

Lock-scope concern noted in review is not an issue: the skip path returns
from _execute_schedule_with_lock, and the outer _execute_schedule holds
the lock in a try/finally that covers the return. No leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(#454): docker exec instead of agent-server HTTP endpoint

Review feedback on #455 flagged that the HTTP-endpoint design introduced
a new system edge (scheduler → agent-server direct) and a novel code-
loading pattern (importlib in a router). Both broke with Trinity's
established convention that all "run something in an agent container"
flows go through `services/docker_service.execute_command_in_container`
— the same primitive used by:

- services/git_service.py (persistent-state allowlist, #384 S3)
- services/ssh_service.py (key provisioning)
- services/agent_service/terminal.py (web SSH)
- routers/system_agent.py (admin exec)
- adapters/message_router.py (Slack file ingest)
- routers/voice.py, monitoring_service.py

This commit swaps the design accordingly.

Changes:
- Delete docker/base-image/agent_server/routers/pre_check.py and its
  router registration. No new HTTP surface on agent-server.
- Delete tests/unit/test_pre_check_router.py (router is gone).
- Add src/backend/routers/internal.py →
  POST /api/internal/agents/{name}/pre-check. Runs the template-shipped
  `.trinity/pre-check.py` via execute_command_in_container. Two-step:
  `test -f` for existence, then `python3 .../pre-check.py`. Returns
  {hook_present, exit_code, stdout, stderr}. Gated by existing
  X-Internal-Secret header (C-003).
- Rewrite src/scheduler/service.py::_run_pre_check to call the backend
  endpoint (scheduler no longer opens a direct edge to agent-server).
  Translates …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant