Skip to content

fix(#913): scheduler honors per-agent execution_timeout_seconds - #922

Merged
vybe merged 4 commits into
devfrom
fix/913-scheduler-per-agent-timeout
May 27, 2026
Merged

fix(#913): scheduler honors per-agent execution_timeout_seconds#922
vybe merged 4 commits into
devfrom
fix/913-scheduler-per-agent-timeout

Conversation

@obasilakis

Copy link
Copy Markdown
Contributor

Summary

  • agent_schedules.timeout_seconds becomes Optional[int] and round-trips None through the scheduler to /api/internal/execute-task, so the backend's per-agent fallback at task_execution_service.py:281 finally fires for cron-triggered runs.
  • Migration null_legacy_schedule_timeouts nulls existing rows at the historical defaults (900, 3600) so PUT /api/agents/{name}/timeout takes effect on already-deployed schedules.
  • Drive-by: S-03 canary invariant is made decay-invariant (it would fire by 1s on every fresh slot once bug: scheduled runs ignore per-agent execution_timeout_seconds (canary S-03 + E-01 surfaced) #913 makes slot TTL match the floor exactly).

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's if timeout_seconds is None: timeout_seconds = db.get_execution_timeout(agent_name) fallback was dead code on the scheduler path. PUT /api/agents/{name}/timeout was 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 / scheduler Schedule: 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_retry accept Optional[int]; polling deadline falls back to _POLL_DEADLINE_WHEN_NULL = 7200 (the upper clamp of PUT /api/agents/{name}/timeout) so the scheduler never gives up before the backend's own enforcement
  • schema.py: drops DEFAULT 3600 on agent_schedules.timeout_seconds
  • Migration: null_legacy_schedule_timeouts nulls 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_info already 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 < floor check fires by ~1s on natural decay — Redis TTL decays linearly from EXPIRE. The fix reconstructs the initial TTL via ttl + age, where age = 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:
    • DB NULL → Schedule.timeout_seconds is None
    • DB explicit int → unchanged on read
    • Dispatch payload carries "timeout_seconds": null (not a default integer)
    • Explicit per-schedule overrides still propagate unchanged
    • _POLL_DEADLINE_WHEN_NULL == 7200
    • _poll_execution_completion does not TypeError on None
  • tests/test_canary_invariants.py — added test_natural_decay_not_below_floor as S-03 regression; existing S-03 fixtures updated to use realistic acquire scores
  • All 85 canary unit tests + 6 scheduler tests pass locally
  • Slot HASH on canary-fleet-slow: timeout_seconds=180 (per-agent), TTL=480 — was 3600/3900 before
  • Slot HASH on canary-fleet-burst: timeout_seconds=3600 (per-agent), TTL=3900 — was 900/1200 before
  • 3-min live run spanning burst (/2) and slow (/3) cron cadences: 0 canary violations
  • Review

Note on supersedence

PR #920 was opened earlier on feature/882-canary-phase2, which carried duplicate Phase 2/3 canary commits already merged to dev via #884. Closed in favor of this clean two-commit PR off dev.

Fixes #913

🤖 Generated with Claude Code

@obasilakis
obasilakis force-pushed the fix/913-scheduler-per-agent-timeout branch from a098343 to 7b89ff7 Compare May 25, 2026 12:34
@obasilakis
obasilakis marked this pull request as ready for review May 25, 2026 12:41

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 3

Result: 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)

  1. architecture.md S-03 description is stale — the doc says S-03 checks TTL ≥ floor directly; after this PR it compares reconstructed initial TTL (ttl + age) against floor - 1. Worth updating while the change is fresh.

  2. agent_schedules schema in architecture.md missing timeout_seconds — pre-existing gap, but worth adding timeout_seconds INTEGER, -- #913: NULL = inherit per-agent timeout to the DDL block since we're touching this column.


Required before merge

  • routers/schedules.py:269 — add if schedule_data.timeout_seconds is not None: guard before the _enforce_timeout_below_agent_cap call

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.
@obasilakis
obasilakis force-pushed the fix/913-scheduler-per-agent-timeout branch from 7b89ff7 to 854758d Compare May 25, 2026 14:09
@obasilakis

Copy link
Copy Markdown
Contributor Author

Addressed all three points in 854758d (rebased onto current dev first so #929's _enforce_timeout_below_agent_cap is in scope).

Critical — TypeError on schedule creation
Added the if schedule_data.timeout_seconds is not None: guard in create_schedule at routers/schedules.py:271–273, mirroring the UPDATE path you flagged. Also tightened the helper's docstring so a future caller can't unknowingly reintroduce the bug.

Architecture S-03 description (stale)
Rewrote the S-03 paragraph in docs/memory/architecture.md to describe the reconstructed initial TTL (ttl + age, where age = snapshot_time − slot_score) compared against floor − 1, plus the 1s tolerance rationale.

agent_schedules DDL missing timeout_seconds
Added timeout_seconds INTEGER, -- #913: NULL = inherit agent_ownership.execution_timeout_seconds to the DDL block.

Regression tests — three new in tests/unit/test_929_timeout_validation.py:

  1. test_enforce_helper_raises_typeerror_on_none — pins the helper's behavior on None so the guard's necessity is documented in code.
  2. test_create_schedule_path_skips_enforcement_when_timeout_none — invokes the route function directly with stubbed deps; asserts _enforce_timeout_below_agent_cap is not called when caller omits timeout_seconds. Would have caught your reported 500.
  3. test_create_schedule_path_runs_enforcement_when_timeout_set — counterpart proving the guard doesn't accidentally disable enforcement when the caller does set the field.

Verification: 9/9 in test_929_timeout_validation.py, 91/91 across tests/scheduler_tests/test_per_agent_timeout.py + tests/test_canary_invariants.py, 23/23 in tests/test_schedules.py.

`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 vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four items from the prior review are addressed cleanly:

  • routers/schedules.py:271is not None guard added on the create path, mirroring the UPDATE path. Helper docstring now documents the precondition.
  • architecture.md S-03 description rewritten for decay-invariance (ttl + age vs floor - 1).
  • agent_schedules.timeout_seconds added to the schema DDL block with the inherit-on-NULL comment.
  • ✅ Regression tests in tests/unit/test_929_timeout_validation.py pin 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.

@vybe
vybe merged commit 33023f5 into dev May 27, 2026
13 checks passed
anubis770 pushed a commit to anubis770/trinity that referenced this pull request Jul 16, 2026
…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>
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.

2 participants