fix(#913): scheduler honors per-agent execution_timeout_seconds - #922
Conversation
a098343 to
7b89ff7
Compare
vybe
left a comment
There was a problem hiding this comment.
Validation Report — REQUEST CHANGES
The core fix is well-reasoned and correctly implemented: round-tripping None through the scheduler boundary, the migration that nulls legacy 900/3600 rows, and the S-03 decay-invariance fix. One line was missed that turns every default-timeout schedule creation into a 500.
❌ Critical — TypeError on schedule creation
ScheduleCreate.timeout_seconds is now Optional[int] = None (the whole point of the PR), but create_schedule at routers/schedules.py:269 calls _enforce_timeout_below_agent_cap(name, schedule_data.timeout_seconds) unconditionally.
Inside that function:
if requested_seconds > cap: # None > int → TypeError in Python 3Result: POST /api/agents/{name}/schedules with the default timeout_seconds (i.e., every consumer that doesn't explicitly set it) returns 500 after this lands.
Fix — mirrors the already-correct UPDATE path at line 330:
# routers/schedules.py:269
if schedule_data.timeout_seconds is not None:
_enforce_timeout_below_agent_cap(name, schedule_data.timeout_seconds)⚠️ Warnings (non-blocking)
-
architecture.mdS-03 description is stale — the doc says S-03 checksTTL ≥ floordirectly; after this PR it compares reconstructed initial TTL (ttl + age) againstfloor - 1. Worth updating while the change is fresh. -
agent_schedulesschema inarchitecture.mdmissingtimeout_seconds— pre-existing gap, but worth addingtimeout_seconds INTEGER, -- #913: NULL = inherit per-agent timeoutto the DDL block since we're touching this column.
Required before merge
-
routers/schedules.py:269— addif schedule_data.timeout_seconds is not None:guard before the_enforce_timeout_below_agent_capcall
Everything else (DB layer, scheduler service, migration, S-03 fix, tests) looks correct. One guard and this is ready.
Before this change `agent_schedules.timeout_seconds` always carried a
concrete value (DEFAULT 900, later rewritten to 3600 by
`_migrate_default_execution_timeout_to_3600`). The scheduler passed it
to `/api/internal/execute-task`, so the per-agent fallback at
`task_execution_service.py:281` was dead code on the scheduler path and
`PUT /api/agents/{name}/timeout` was silently ineffective for cron-
triggered runs. The canary harness fired S-03 / E-01 on every cycle on
any agent whose per-agent timeout differed from the schedule default.
Option A — round-trip None through the scheduler boundary:
- ScheduleCreate / Schedule / ScheduleResponse / scheduler Schedule:
`timeout_seconds` becomes `Optional[int] = None`.
- `_row_to_schedule` (backend + scheduler) returns None instead of
falling back to 900/3600.
- `scheduler/service.py` carries `Optional[int]` through
`_call_backend_execute_task`, `_poll_execution_completion`,
`_execute_retry`. Polling deadline uses `_POLL_DEADLINE_WHEN_NULL =
7200` (the upper clamp of `PUT /api/agents/{name}/timeout`) when the
schedule inherits, so the scheduler never gives up before the
backend's enforcement does.
- `schema.py` drops `DEFAULT 3600` on `agent_schedules.timeout_seconds`.
- Migration `null_legacy_schedule_timeouts` nulls out existing rows at
the historical defaults (900, 3600). Same fidelity tradeoff as
`_migrate_default_execution_timeout_to_3600` — operators who
explicitly chose 900 or 3600 lose that fidelity; accepted in #665,
accepted here.
The cleanup-side `COALESCE(s.timeout_seconds, ao.execution_timeout_
seconds, 3600)` in `get_running_executions_with_agent_info` already
prefers the per-agent value when the schedule's timeout is NULL, so
the watchdog termination path becomes correct automatically.
Tests: `tests/scheduler_tests/test_per_agent_timeout.py` pins the
round-trip — DB NULL → `Schedule.timeout_seconds is None`, dispatch
payload carries `"timeout_seconds": null` (not a default integer),
polling deadline math falls back to 7200s without raising, and
explicit per-schedule overrides still propagate unchanged.
Verified locally on the canary fleet:
- canary-fleet-slow slot HASH: `timeout_seconds=180` (per-agent), TTL=480
- canary-fleet-burst slot HASH: `timeout_seconds=3600` (per-agent), TTL=3900
- 3-min live run spanning both cron cadences: 0 canary violations.
Fixes #913
S-03's `ttl < floor` raw comparison fires by ~1s on every fresh slot the moment any wall-clock time passes. Redis `TTL` returns the *current* remaining seconds, which decays linearly from `EXPIRE`, so a slot created with `EXPIRE(floor)` instantly reads `floor - 1` and trips the check — a 1-second false-positive on healthy slots. This was a latent issue masked by #913 (the schedule's `timeout_seconds` got stored as 900 → slot TTL=1200 → far below the floor of 3900, so the check always fired on a much larger margin). Once #913 makes the slot TTL match the floor exactly, the decay surfaces. Fix: reconstruct the slot's initial TTL via `ttl + age`, where `age = snapshot_time - slot_score` (slot_score is the unix epoch recorded by SlotService at ZADD time). A slot created with `EXPIRE(floor)` and aged `t` seconds satisfies `ttl + age = floor` regardless of when the snapshot is taken — invariant to wall-clock decay. A real #913-class bug where the initial TTL was `1200s` but the floor is `3900s` still surfaces as `initial_ttl < floor`. 1-second tolerance (`floor - 1`) absorbs the float→int rounding that Redis `TTL` does on the wire. A genuine slot-eviction bug class is hundreds of seconds below floor — not 1s. Test fixtures updated to use realistic acquire-time scores (`time.time()`) instead of unix epoch 1.0. Added `test_natural_decay_not_below_floor` as a regression guard.
- routers/schedules.py: guard `_enforce_timeout_below_agent_cap` call in `create_schedule` with `if schedule_data.timeout_seconds is not None`, mirroring the already-correct UPDATE path. Without this, the rebase onto #929 would 500 with TypeError on every consumer that left `timeout_seconds` at its default (None) — the entire point of #913. Also documents the precondition on the helper's docstring so a future caller can't unknowingly reintroduce the bug. - tests/unit/test_929_timeout_validation.py: three new regression tests pinning the fix from both sides — the helper TypeErrors on None (proving the guard is necessary), `create_schedule` does not invoke the enforcer when timeout is None, and the enforcer still runs when caller sets timeout explicitly. - docs/memory/architecture.md: * S-03 description now reflects the decay-invariance fix: compares reconstructed initial TTL (`ttl + age`, where `age = snapshot_time - slot_score`) against `floor - 1`, not raw `TTL ≥ floor`. * `agent_schedules` DDL adds the `timeout_seconds INTEGER` column with a note that NULL means inherit from `agent_ownership.execution_timeout_seconds`. Verification: 9/9 tests in test_929_timeout_validation.py pass, 91/91 in tests/scheduler_tests/test_per_agent_timeout.py and tests/test_canary_invariants.py, 23/23 in tests/test_schedules.py.
7b89ff7 to
854758d
Compare
|
Addressed all three points in 854758d (rebased onto current Critical — TypeError on schedule creation ✅ Architecture S-03 description (stale) ✅
Regression tests — three new in
Verification: 9/9 in |
`asyncio.get_event_loop()` raises `RuntimeError: There is no current event loop in thread 'MainThread'` on Python 3.11 when no loop has been created yet. Locally a prior test in the session leaves one behind so the call succeeds, but in CI the unit suite isolates more aggressively. Switch to `asyncio.run()`, which creates a fresh loop per call. Same semantics for our use case (one-shot await of a route handler).
vybe
left a comment
There was a problem hiding this comment.
All four items from the prior review are addressed cleanly:
- ✅
routers/schedules.py:271—is not Noneguard added on the create path, mirroring the UPDATE path. Helper docstring now documents the precondition. - ✅
architecture.mdS-03 description rewritten for decay-invariance (ttl + agevsfloor - 1). - ✅
agent_schedules.timeout_secondsadded to the schema DDL block with the inherit-on-NULL comment. - ✅ Regression tests in
tests/unit/test_929_timeout_validation.pypin both sides — helper TypeErrors on None (so the guard can't silently regress), create-path skips the enforcer on None, enforcer still runs when caller sets timeout explicitly.
Bonus catch: switched asyncio.get_event_loop() → asyncio.run() after a CI-only failure on Python 3.11 — clean test hygiene.
CI green across pytest (×6 seeds), CodeQL, schema-parity, regression diff. Approving.
…ilityai#874) After Abilityai#874 the backend and scheduler run as UID 1000 inside the container, but the dev VM's `/data` bind mount and `agent-configs` named volume were still owned by root from the prior root-container era. Earlier deploys survived because no migration *wrote* to existing rows — the first one that does (Abilityai#922's `null_legacy_schedule_timeouts` UPDATE) hits `sqlite3.OperationalError: attempt to write a readonly database`, /health returns 503 forever, and `up -d` times out with "container ***-backend is unhealthy". Confirmed root cause via `docker logs trinity-backend` on the dev VM: the migration framework crashes mid-run on the SQLite write before incrementing `schema_migrations`. Fix: add an idempotent pre-flight to the deploy workflow that checks ownership and re-owns to UID 1000 only when needed: - `${TRINITY_DATA_PATH:-./trinity-data}` bind mount: `stat -c %u` then `chown -R 1000:1000` if not 1000. - `${PROJECT}_agent-configs` named volume: same check via ephemeral alpine container, chown via second ephemeral container. Both checks no-op silently on healthy VMs, so this is safe to run on every deploy. Canonical recipe matches `docs/migrations/NON_ROOT_CONTAINERS_2026-05.md`. Related to Abilityai#958 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
agent_schedules.timeout_secondsbecomesOptional[int]and round-tripsNonethrough the scheduler to/api/internal/execute-task, so the backend's per-agent fallback attask_execution_service.py:281finally fires for cron-triggered runs.null_legacy_schedule_timeoutsnulls existing rows at the historical defaults (900, 3600) soPUT /api/agents/{name}/timeouttakes effect on already-deployed schedules.Root cause (#913)
Before this PR, every schedule row carried a concrete
timeout_seconds(DEFAULT 900, later rewritten to 3600 by_migrate_default_execution_timeout_to_3600). The scheduler passed that to the backend, so the backend'sif timeout_seconds is None: timeout_seconds = db.get_execution_timeout(agent_name)fallback was dead code on the scheduler path.PUT /api/agents/{name}/timeoutwas silently ineffective for cron — the per-agent value never reached slot acquisition, slot HASH metadata, or cleanup-watchdog termination.The canary harness fired S-03 / E-01 every cycle on any agent whose per-agent timeout differed from the schedule default.
Fix — Option A (round-trip None through scheduler)
ScheduleCreate/Schedule/ScheduleResponse/ schedulerSchedule:timeout_seconds: Optional[int] = None_row_to_schedule(backend + scheduler): NULL → None (not 900/3600)scheduler/service.py:_call_backend_execute_task,_poll_execution_completion,_execute_retryacceptOptional[int]; polling deadline falls back to_POLL_DEADLINE_WHEN_NULL = 7200(the upper clamp ofPUT /api/agents/{name}/timeout) so the scheduler never gives up before the backend's own enforcementschema.py: dropsDEFAULT 3600onagent_schedules.timeout_secondsnull_legacy_schedule_timeoutsnulls rows at 900/3600 (same fidelity tradeoff as the prior_migrate_default_execution_timeout_to_3600)The cleanup-side COALESCE in
get_running_executions_with_agent_infoalready preferred the per-agent value when the schedule's column was NULL; this PR is what finally makes the column actually carry NULL.S-03 decay-invariance follow-up
After #913 the slot TTL exactly equals the floor at creation, so the raw
ttl < floorcheck fires by ~1s on natural decay — RedisTTLdecays linearly fromEXPIRE. The fix reconstructs the initial TTL viattl + age, whereage = snapshot_time - slot_score(slot_score is the unix epoch recorded by SlotService at ZADD time). A real #226-class bug (initial TTL set to 1200s while floor is 3900s) still surfaces; natural decay does not.1s tolerance (
floor - 1) absorbs Redis's float→int rounding on the wire.Test plan
tests/scheduler_tests/test_per_agent_timeout.py— 6 new tests pin the round-trip:Schedule.timeout_seconds is None"timeout_seconds": null(not a default integer)_POLL_DEADLINE_WHEN_NULL == 7200_poll_execution_completiondoes not TypeError on Nonetests/test_canary_invariants.py— addedtest_natural_decay_not_below_flooras S-03 regression; existing S-03 fixtures updated to use realistic acquire scorestimeout_seconds=180(per-agent), TTL=480 — was 3600/3900 beforetimeout_seconds=3600(per-agent), TTL=3900 — was 900/1200 beforeNote on supersedence
PR #920 was opened earlier on
feature/882-canary-phase2, which carried duplicate Phase 2/3 canary commits already merged todevvia #884. Closed in favor of this clean two-commit PR offdev.Fixes #913
🤖 Generated with Claude Code