feat(#929): agent cap enforces schedule ceiling + clearer SIGKILL msg - #930
Conversation
Validate at write time so `schedule.timeout_seconds <= agent.execution_timeout_seconds`
is enforced as an invariant instead of a silent expectation. The two settings
previously coexisted with no enforcement between them — schedules silently
won, the agent cap applied only to the chat fallback path. That divergence
trapped operators who assumed `min(agent, schedule)` semantics from the
side-by-side UI. Approach A from the issue thread: refuse out-of-band values
at config time instead of debugging them via SIGKILL at run time.
Changes:
- `POST /api/agents/{name}/schedules` + `PUT /.../schedules/{id}` — 400 with
`error=schedule_timeout_exceeds_agent_cap` when the request would breach
the agent cap. Update path only re-checks when the PUT actually touches
`timeout_seconds`, so pre-existing rows stay editable.
- `PUT /api/agents/{name}/timeout` — 400 with
`error=agent_timeout_below_active_schedules` when the new cap would drop
below any non-deleted schedule's `timeout_seconds`; response carries
`blocking_schedules` so the operator can see which schedules need
shrinking first.
- `db.get_max_active_schedule_timeout` + `find_active_schedules_exceeding_timeout`
— read accessors for the agent-cap-lowering check.
- Orthogonal SIGKILL message cleanup in
`agent_server/services/error_classifier.py` — drops the misleading
"schedule/agent timeout exceeded" disjunction now that the agent cap
can never silently truncate a schedule.
Tests: `tests/unit/test_929_timeout_validation.py` — DB accessors against
ephemeral SQLite (soft-delete exclusion, MAX semantics, offender list), plus
the router helper's 400 detail contract loaded via importlib to dodge
routers/__init__.py.
Related to #929.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…odules writes Conform to the sys.modules-pollution lint baseline. Three bare `sys.modules[...] = ...` writes (passlib stub at import time, the lazy routers.schedules load) now go through the canonical pattern: - `_STUBBED_MODULE_NAMES` + autouse `_restore_sys_modules` fixture snapshots and restores entries after each test (precedent: test_telegram_webhook_backfill.py). - `monkeypatch.setitem(sys.modules, ...)` for the writes themselves so the audit lint sees them as managed mutations. Helper `_load_sched_router(monkeypatch)` now takes monkeypatch as a parameter; passlib stub is built lazily inside that helper instead of running at module import. All 7 tests still pass; `python tests/lint_sys_modules.py` reports 230 violations vs 237 baseline. Related to #929. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
| Category | Count | Action |
|---|---|---|
| Critical | 0 | Clear to merge on correctness |
| Informational | 6 | Review before merging |
| Scope | CLEAN | — |
Recommended fixes for this PR (minimum-friction set)
The two with the most signal-per-line:
- I1 + I2: delete
get_max_active_schedule_timeout+ its 3 tests + the bullet in requirements.md (dead code today, requirements doc lies about it) - I5: add a one-screen unit test pinning the SIGKILL error_classifier message
I3 (frontend) and I6 (agent-timeout router test) are reasonable follow-up issues.
I4 (race) deserves a code comment but no behavioral fix.
🤖 Generated by /review skill (.claude/skills/review)
Apply review feedback I1, I2, and I5 from the /review pass on PR #930: - I1: delete `get_max_active_schedule_timeout` from `db/schedules.py` and `database.py` + its three unit tests. No production caller wires it up — the agent-timeout endpoint uses `find_active_schedules_exceeding_timeout` instead, which returns the offender list directly. Dead code under CLAUDE.md "no abstractions beyond what the task requires." - I2: correct the requirements §35.1 bullet that named the wrong accessor. Documents `find_active_schedules_exceeding_timeout` as the real load- bearing call instead. - I5: pin the cleaned SIGKILL error message with two unit tests against `agent_server.services.error_classifier._classify_signal_exit`. Guards against a regression that re-introduces the misleading "schedule/agent timeout exceeded" disjunction. Covers SIGKILL (-9), SIGTERM (-15), and the shell-encoded 128+N variants (137, 143). I3 (frontend dict-detail rendering) and I6 (agent-timeout router test) remain as follow-up issues; I4 (TOCTOU window) is documented in requirements as "next edit fixes it" and acceptable for this PR. Tests: 6 pass locally; sys.modules lint at 230/237 baseline. Related to #929. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
| Pass 1 finding | Status |
|---|---|
I1 dead get_max_active_schedule_timeout |
✅ Resolved — accessor + facade + 3 tests deleted |
| I2 requirements doc names wrong accessor | ✅ Resolved — bullet rewritten to describe find_active_schedules_exceeding_timeout |
| I5 no test pins SIGKILL message | ✅ Resolved — 2 tests added (test_sigkill_message_drops_schedule_agent_disjunction, test_sigkill_message_handles_sigterm_and_shell_encoded_signals); covers SIGKILL/SIGTERM + shell-encoded 137/143 |
| I3 frontend dict-detail render | ⏭️ Deferred to follow-up |
| I4 TOCTOU race | ⏭️ Acceptable (documented limitation) |
I6 PUT /timeout router unit test |
⏭️ Deferred to follow-up |
Critical Findings (block merge)
None.
Informational Findings (new on this pass)
None.
Verifications Run
- `pytest tests/unit/test_929_timeout_validation.py` → 6 passed (3 DB + 2 router-helper + 2 SIGKILL — minus 3 deleted MAX-accessor tests)
- Sibling-load: `pytest -k "929 or 904 or error_classifier"` → 37 passed, no regressions (cross-load with
test_904_*which patchesdbglobally) - `python tests/lint_sys_modules.py` → 230 / 237 baseline (no new sys.modules violations)
- `gh pr checks 930` → all green (lint sys.modules, pytest base + head × 3 seeds, schema-parity, verify-non-root, CodeQL, Analyze)
Clean Categories (re-verified)
- SQL safety:
find_active_schedules_exceeding_timeoutparameterized. - Auth boundaries: validation helpers fire after
get_current_user/AuthorizedAgentgates. - Architectural invariants (CLAUDE.md): Fix: Add missing Docker labels to system agent container #1 (Router→Service→DB), Feature/gemini runtime support #2 (class-per-domain), security: implement safe tar extraction with symlink/hardlink validation #8 (auth pattern), Add internal health route, without which main didn't start #14 (Pydantic centralized), Security: Agent Credential Leakage in Execution Logs [FIXED] #15 (URL nesting) all preserved.
- MCP server sync (Invariant feat: SMARTS trading pipeline with Telegram notifications and Miro visualization #13): MCP
create_agent_schedule/update_agent_scheduleproxy through backend HTTP — 400 propagates without TS changes. - No new credential exposure / WebSocket / Docker / migration concerns.
Note on POST /schedules ordering
create_schedule runs cron-validation + timeout-validation BEFORE the access check (the access check happens implicitly inside db.create_schedule). Considered flagging this as an info leak (probing timeouts could reveal an agent's cap to non-owners) — but GET /api/agents/{name}/timeout already returns the cap to any authenticated user without an ownership check, so no new leak. Not a finding.
Summary
| Category | Count | Action |
|---|---|---|
| Critical | 0 | Clear to merge |
| Informational (new) | 0 | — |
| Informational (deferred) | 3 | I3, I4, I6 documented as follow-up |
| Scope | CLEAN | — |
PR is ready to merge from a structural-review standpoint. Outstanding deferred items (I3/I4/I6) are acknowledged.
🤖 Generated by `/review` skill (`.claude/skills/review`)
vybe
left a comment
There was a problem hiding this comment.
Clean, focused implementation. Write-time enforcement is the right model — fail at config time rather than SIGKILL at runtime. DB accessor, router helper, and SIGKILL classifier all unit-tested with good boundary coverage. Two deferred items (I3 frontend dict-detail rendering, I6 agent-timeout router test) are low-risk and explicitly documented. Address requirements §35 status (🚧 → ✅) and frontend rendering check before next release cut.
Summary
Approach A from #929 — write-time validation makes
agent_ownership.execution_timeout_secondsa hard ceiling foragent_schedules.timeout_seconds. Plus the orthogonal SIGKILL error-message cleanup the issue called for regardless.The reporter's mental model was right; the code wasn't matching it
Exploration found
task_execution_servicedoes not computemin(agent, schedule)(issue's stated root cause was wrong). What actually happens today:schedule.timeout_secondsis passed straight through, agent cap is ignored.db.get_execution_timeout(agent_name)is used; no schedule involved.So the two settings coexisted as independent knobs with no enforcement between them — schedules silently won. The reporter's
min()assumption was a reasonable read of the UI, but it never held in code. Approach A snaps the implementation to match the mental model: schedule timeout can't exceed the agent cap, and the cap can't drop below active schedules. Both fail at config time with a 400, not at SIGKILL after waiting 60 minutes.Changes
Validation (the meat):
POST /api/agents/{name}/schedules— 400error=schedule_timeout_exceeds_agent_capwhen the create would breach the cap.PUT /api/agents/{name}/schedules/{id}— same 400, only when the PUT actually touchestimeout_seconds(pre-existing rows that predate the validation stay editable).PUT /api/agents/{name}/timeout— 400error=agent_timeout_below_active_scheduleswhen the new cap would drop below any non-deleted schedule'stimeout_seconds. Response includes the list of blocking schedules so the operator knows what to edit first.DB:
db.get_max_active_schedule_timeout(agent_name)— MAX across non-soft-deleted rows;Noneif empty.db.find_active_schedules_exceeding_timeout(agent_name, ceiling)— thin offender list{id, name, timeout_seconds}for the 400 payload.Orthogonal (same PR per issue's "do regardless"):
agent_server/services/error_classifier.py— SIGKILL message drops the misleading "schedule/agent timeout exceeded" disjunction. Now that the agent cap can never silently truncate a schedule, "schedule timeout exceeded" is unambiguous. Remediation hint links the schedule'stimeout_secondsto the agent cap explicitly.No retro-validation: rows that predate this PR and already violate the invariant are left alone. The agent-cap-lowering check still sees them, so the operator can't widen the gap.
Approach B (effective-timeout surface) is intentionally left out: under A,
timeout_effective == timeout_configuredalways (no further cap layer), making the new field pure clutter. If we ever add a second cap layer, B is additive.Tests
tests/unit/test_929_timeout_validation.py— 7 tests:_enforce_timeout_below_agent_cap— at-cap + under-cap accept silently, over-cap raises 400 with the structured detail dict.Router helper loaded via
importlib.spec_from_file_location(the same dodgetest_voice_auth.pyandtest_monitoring_router_signatures.pyuse) so the test doesn't pull inrouters/__init__.py's 50+ siblings.Local results:
pytest tests/unit/test_929_timeout_validation.py→ 7 passed. Schedule/timeout-tagged subset under broader load → 68 passed, 1 skipped (no regressions).Test plan
timeout_seconds=7200against agent cap 3600 → 400timeout=60while a schedule hastimeout=1800→ 400 withblocking_schedules./scripts/deploy/build-base-image.sh)Files
src/backend/routers/schedules.py(+36) — validation helper + applied to POST/PUTsrc/backend/routers/agent_config.py(+24) — agent-timeout PUT validationsrc/backend/db/schedules.py(+43) — two new accessorssrc/backend/database.py(+8) — facade exposuredocker/base-image/agent_server/services/error_classifier.py(~5 lines) — SIGKILL message cleanupdocs/memory/requirements.md(+63) — §35 "Schedule Timeout Validation"docs/memory/architecture.md(3 cells) — 400 response notestests/unit/test_929_timeout_validation.py(new, 7 tests)Related to #929.
🤖 Generated with Claude Code