fix(cleanup): force-fail orphan when agent unreachable during re-verify (#497) - #783
Conversation
…fy (#497) Before ------ `_process_stale_slot_reclaims`' Phase 3 re-verify branch (added by #378 to prevent FAILED→SUCCESS flicker) treated "agent unreachable" the same as "still running" — it `continue`-d the loop. The slot was already reclaimed from Redis by `capacity.reclaim_stale()` (TTL elapsed), but the DB execution row stayed in `running` until Phase 1's 120-min stale cleanup picked it up. Under sustained 3/3-capacity load with intermittent agent unreachability (CPU-pinned, network-stuck, container hung), this leaves zombie `running` rows for up to 2 hours, polluting dashboards and the operator-queue's view of fleet state. After ----- When `_get_agent_running_ids(agent)` returns None (unreachable), `_process_stale_slot_reclaims` now calls `db.fail_stale_slot_execution` — the same race-guarded writer already used for the "re-verify-confirmed-inactive" branch. The slot was reclaimed by TTL, so the execution is by construction older than `timeout + buffer`; Phase 1's 120-min wait is the same condition at a much longer window. We can apply it immediately and free the row. Race safety ----------- `fail_stale_slot_execution` is guarded by `WHERE status='running'`, so a SUCCESS / FAILED / CANCELLED that landed between the slot reclaim and this cleanup write is preserved (race-guard returns False, counter does not increment, cleanup log records a benign debug line). Documented residual flicker risk -------------------------------- If the agent later recovers and writes SUCCESS via `update_execution_status`, that path overwrites FAILED per #378's "SUCCESS wins" rule. The execution must have run past its configured `timeout + buffer` for the slot to be reclaimed in the first place, so a "late SUCCESS" represents a deliverable that exceeded its budget. This is documented behavior, accepted as a sharp narrowing of the 2-hour-zombie window. If it becomes a real operator complaint, follow-up work would narrow `update_execution_status`'s SUCCESS-over-FAILED rule to exclude cleanup-induced markers — see PR body for the proposed design. Investigation ------------- The issue (#497) noted "validation needed during implementation — root cause framing inferred from cleanup logs and DB-vs-slots divergence on a live instance. Not yet reproduced in isolation." Done now: - Traced every `status='success'` write path and its race guard (db/schedules.py:update_execution_status `WHERE status != 'cancelled'` is the relevant overwrite rule). - Confirmed that slot leakage per se does NOT happen — slot is removed from Redis ZSET permanently in `_cleanup_stale_slots_for_agent`. The symptom is DB-row zombie, not slot zombie. Capacity rejection under load is from real live tasks, not the orphan row. - Built a 6-test unit suite that drives the exact decision matrix. Tests ----- - `tests/unit/test_cleanup_unreachable_orphan.py` (new, 6 tests): - unreachable → force-fail with race-guarded writer + error tag - reachable + still-running → no action (#378 path) - reachable + not-running → fail (existing behavior) - confirmed_running set wins over unreachable - race-guard returns False → counter not incremented - mixed-batch routing: unreachable vs confirmed across agents - `tests/test_watchdog_unit.py`: two existing tests pinned the OLD defer-on-unreachable behavior — updated to assert the new force-fail semantics + the error-tag invariant. Related to #497. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced by /validate-pr — behavior change in `_process_stale_slot_reclaims` warranted a feature-flow update. Updates the Phase 3 per-execution decision matrix (unreachable → FAIL now instead of SKIP), notes the documented #497 residual flicker risk, and adds a row to the Error Handling table separating Phase 0 vs Phase 3 unreachable semantics. Related to #497. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR Validation Report (manual run of `/validate-pr` checklist)
Issues FoundCritical: none. Warnings (resolved during validation):
Suggestions (non-blocking):
RecommendationAPPROVE — ready for human review + merge to PR addresses the specific behavior the issue complained about: cleanup tick now immediately fails the orphan via the race-guarded writer instead of waiting Phase 1's 120-min stale-cleanup window. Race safety preserved via the existing 🤖 Generated with Claude Code |
…ssage The lint at `tests/lint_sys_modules.py` documents three ways out of a bare-`sys.modules` violation: `monkeypatch.setitem` / `delitem`, moving to `conftest.py`, and the `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` named-helper pair (precedent: `tests/unit/test_telegram_webhook_backfill.py`). Only the first two are surfaced in the per-line error message. For tests that load real backend modules in isolation with their heavy deps stubbed at import time, neither of the documented options applies: - `monkeypatch.*` is fixture-scoped and structurally cannot rewind a stub planted before any test runs (the stubs are needed during `spec_from_file_location` + `exec_module`, which executes at module-import time). - `conftest.py` would leak the stubs to every test in the directory, the exact #762 failure mode the lint exists to prevent. The named-helper escape hatch is the right answer for this case, and the lint already exempts it — but discoverability is poor. PR #783 and #606 both hit the lint, read the error, and re-discovered the escape hatch independently after digging into the script's docstring. Surface it in the error directly so the next author sees all three options at first encounter, not after archaeology. No behavioral change — pure error-text expansion. The lint's exit codes, detection logic, and baseline mechanics are unchanged. The committed baseline file is unaffected. Refs #762 Co-Authored-By: Claude <noreply@anthropic.com>
`tests/unit/test_cleanup_unreachable_orphan.py` (added in 79a6481, PR #783) plants three import-time stubs in `sys.modules`: - `docker` (line 64) — the unit venv doesn't ship the docker package - `services.docker_service` (line 76) — eager-imported by `services/__init__.py` - `database` (line 79) — hit transitively by cleanup_service The lint+baseline shipped in #791 (3043631) merged ~4 hours after #783, but `#791`'s baseline file omits this entry — almost certainly generated against a pre-#783 dev state and never refreshed before merge. Result: `lint-sys-modules` job in `backend-unit-test.yml` has been red on dev since both PRs landed, and the failure travels to every PR branched off that state — including #606's PR. Apply the same named-helper escape hatch used by the #606 file and the original `tests/unit/test_telegram_webhook_backfill.py` precedent: a top-level `_STUBBED_MODULE_NAMES` list naming the three slots, plus an autouse `_restore_sys_modules` fixture that snapshots them at test setup and restores at teardown. Bounds the cross-file pollution to this test's own scope — the exact invariant #762's lint protects. Verification: - `python tests/lint_sys_modules.py` — clean: 223 violations in 65 files, baseline allows 223, no new violations. - `pytest tests/test_lint_sys_modules.py` — all 25 tests pass, including `test_committed_baseline_matches_current_repo_state` (was failing). - `pytest tests/unit/test_cleanup_unreachable_orphan.py` — passes. Test-only — no production code changes. Cross-PR fix: clears CI on this branch and on dev. The underlying "stale baseline at PR merge" question (why didn't #791's own CI catch this?) is a separate concern worth a follow-up issue. Refs #762 Refs #783 Co-Authored-By: Claude <noreply@anthropic.com>
…ssage The lint at `tests/lint_sys_modules.py` documents three ways out of a bare-`sys.modules` violation: `monkeypatch.setitem` / `delitem`, moving to `conftest.py`, and the `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` named-helper pair (precedent: `tests/unit/test_telegram_webhook_backfill.py`). Only the first two are surfaced in the per-line error message. For tests that load real backend modules in isolation with their heavy deps stubbed at import time, neither of the documented options applies: - `monkeypatch.*` is fixture-scoped and structurally cannot rewind a stub planted before any test runs (the stubs are needed during `spec_from_file_location` + `exec_module`, which executes at module-import time). - `conftest.py` would leak the stubs to every test in the directory, the exact #762 failure mode the lint exists to prevent. The named-helper escape hatch is the right answer for this case, and the lint already exempts it — but discoverability is poor. PR #783 and #606 both hit the lint, read the error, and re-discovered the escape hatch independently after digging into the script's docstring. Surface it in the error directly so the next author sees all three options at first encounter, not after archaeology. No behavioral change — pure error-text expansion. The lint's exit codes, detection logic, and baseline mechanics are unchanged. The committed baseline file is unaffected. Refs #762 Co-Authored-By: Claude <noreply@anthropic.com>
`tests/unit/test_cleanup_unreachable_orphan.py` (added in 79a6481, PR #783) plants three import-time stubs in `sys.modules`: - `docker` (line 64) — the unit venv doesn't ship the docker package - `services.docker_service` (line 76) — eager-imported by `services/__init__.py` - `database` (line 79) — hit transitively by cleanup_service The lint+baseline shipped in #791 (3043631) merged ~4 hours after #783, but `#791`'s baseline file omits this entry — almost certainly generated against a pre-#783 dev state and never refreshed before merge. Result: `lint-sys-modules` job in `backend-unit-test.yml` has been red on dev since both PRs landed, and the failure travels to every PR branched off that state — including #606's PR. Apply the same named-helper escape hatch used by the #606 file and the original `tests/unit/test_telegram_webhook_backfill.py` precedent: a top-level `_STUBBED_MODULE_NAMES` list naming the three slots, plus an autouse `_restore_sys_modules` fixture that snapshots them at test setup and restores at teardown. Bounds the cross-file pollution to this test's own scope — the exact invariant #762's lint protects. Verification: - `python tests/lint_sys_modules.py` — clean: 223 violations in 65 files, baseline allows 223, no new violations. - `pytest tests/test_lint_sys_modules.py` — all 25 tests pass, including `test_committed_baseline_matches_current_repo_state` (was failing). - `pytest tests/unit/test_cleanup_unreachable_orphan.py` — passes. Test-only — no production code changes. Cross-PR fix: clears CI on this branch and on dev. The underlying "stale baseline at PR merge" question (why didn't #791's own CI catch this?) is a separate concern worth a follow-up issue. Refs #762 Refs #783 Co-Authored-By: Claude <noreply@anthropic.com>
…leanup_unreachable_orphan.py (#796) #791 (which added the tests/lint_sys_modules.py lint and its baseline) landed AFTER #783 (which added tests/unit/test_cleanup_unreachable_orphan.py). The baseline was generated without including the orphan-test file's 3 top-level `sys.modules[...] = stub` assignments at lines 64, 76, 79 (docker, services.docker_service, database stubs installed before the test module's services.cleanup_service import). Result: every PR opened against dev fails the lint job on these 3 lines, even when the PR's own changes are clean. Fix: grandfather the file into the baseline at its current count (3). This matches how the same shape is handled elsewhere — e.g. test_watchdog_unit.py (3 violations, the precedent the orphan-test file's own comment cites). The lint script's baseline path is the intended mechanism for accepting pre-existing module-level stub installs that cannot use monkeypatch. Verified locally: python3 tests/lint_sys_modules.py emits zero violations in the real test tree (only .venv site-packages remain, which CI runners don't have). Refs #762, #783, #791 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…form_default_model The lint job on #443's PR went red on a file that is NOT in the PR's diff — `tests/test_platform_default_model.py` carries 6 bare `sys.modules` mutations that were merged to dev without an entry in `tests/lint_sys_modules_baseline.txt`. Same #802 rebase-race shape as the original #791 vs #783 incident: baseline-introducing PR (#791) and violation-introducing PR landed disjointly, both green pre-merge, dev went red post-merge, the next PR off dev (this one) inherits it. Ratcheting the baseline here unblocks #443. Same precedent as #796 which baselined `test_cleanup_unreachable_orphan.py` for the same reason. The proper fix is to convert the file's bare sys.modules writes to `monkeypatch.setitem` / `monkeypatch.delitem`; that's scope-creep relative to the onboarding work this PR is doing and should land separately. (That file's count is currently 0 against the lint, so a follow-up that converts it can drop the baseline line altogether.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…race (#828) Issue #802: baseline-style lint (sys.modules pollution check) only ran on pull_request, so two PRs disjointly touching baseline + new test file could both pass pre-merge CI yet leave dev red post-merge. Concrete case: #791 landed the lint baseline 4h after CI green; meanwhile #783 merged a new test file with 3 baselined-elsewhere violations. No re-run on either side caught the resulting state — the next PR off dev (#606) inherited a red CI. Adds a push trigger on dev/main so lint-sys-modules fires post-merge. Per-PR diff jobs (test, diff) are gated to pull_request only since base/head don't exist on a push. Related to #802 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): six defects in self-host deploy path (#443) Friction surfaced on a fresh clone → start.sh → login walkthrough. Each fix is small and independent; bundled here per the issue's "all are onboarding hygiene" framing. 1. scripts/deploy/start.sh — auto-generate SECRET_KEY and INTERNAL_API_SECRET if blank (same pattern as the existing CREDENTIAL_ENCRYPTION_KEY block, extracted to a helper). Fail fast with a clear message if ADMIN_PASSWORD is blank rather than booting into a state the operator can't log into. 2. .env.example — document FRONTEND_PORT=80 with an inline comment explaining "remap if your host's port 80 is taken." The var was already read by docker-compose.yml and start.sh but wasn't reachable from the quickstart. 3. scripts/deploy/start.sh — replace "login: admin/password" hint with "login: admin / ADMIN_PASSWORD from .env". New users took "password" as the literal default. 4. src/mcp-server/src/index.ts + README.md — drop hardcoded http://localhost:3000/api-keys hint. Port was wrong (frontend default is 80) and ignored FRONTEND_PORT overrides. Replaced with "your Trinity web UI → Settings → MCP Keys". 5. scripts/deploy/clean.sh — new script. Stops compose + removes leftover agent-* containers + trinity-agent-network. Without this, a fresh install inherits Exited zombie agents from previous tests and the first new agent collides on AGENT_SSH_PORT_START (2222). Preserves data volumes intentionally — operators can wipe those manually. 6. src/mcp-server/Dockerfile — point healthcheck at /health, not /mcp. The /mcp endpoint rejects HEAD requests with 400 (which `wget --spider` sends), so `docker compose ps` reported the container as unhealthy despite serving traffic. /health returns 200 to both HEAD and GET. Verified live: post-rebuild, trinity-mcp-server reports `healthy` within 30s. No schema, API, or runtime-behavior changes. No migration needed. Related to #443 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(tests): baseline 6 pre-existing sys.modules writes in test_platform_default_model The lint job on #443's PR went red on a file that is NOT in the PR's diff — `tests/test_platform_default_model.py` carries 6 bare `sys.modules` mutations that were merged to dev without an entry in `tests/lint_sys_modules_baseline.txt`. Same #802 rebase-race shape as the original #791 vs #783 incident: baseline-introducing PR (#791) and violation-introducing PR landed disjointly, both green pre-merge, dev went red post-merge, the next PR off dev (this one) inherits it. Ratcheting the baseline here unblocks #443. Same precedent as #796 which baselined `test_cleanup_unreachable_orphan.py` for the same reason. The proper fix is to convert the file's bare sys.modules writes to `monkeypatch.setitem` / `monkeypatch.delitem`; that's scope-creep relative to the onboarding work this PR is doing and should land separately. (That file's count is currently 0 against the lint, so a follow-up that converts it can drop the baseline line altogether.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(tests): baseline 3 pre-existing sys.modules mutations in test_cleanup_unreachable_orphan.py #791 (which added the tests/lint_sys_modules.py lint and its baseline) landed AFTER #783 (which added tests/unit/test_cleanup_unreachable_orphan.py). The baseline was generated without including the orphan-test file's 3 top-level `sys.modules[...] = stub` assignments at lines 64, 76, 79 (docker, services.docker_service, database stubs installed before the test module's services.cleanup_service import). Result: every PR opened against dev fails the lint job on these 3 lines, even when the PR's own changes are clean. Fix: grandfather the file into the baseline at its current count (3). This matches how the same shape is handled elsewhere — e.g. test_watchdog_unit.py (3 violations, the precedent the orphan-test file's own comment cites). The lint script's baseline path is the intended mechanism for accepting pre-existing module-level stub installs that cannot use monkeypatch. Verified locally: python3 tests/lint_sys_modules.py emits zero violations in the real test tree (only .venv site-packages remain, which CI runners don't have). Refs #762, #783, #791 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): override "test" sentinel in security conftest (closes #804) `tests/conftest.py` setdefaults REDIS_PASSWORD/REDIS_BACKEND_PASSWORD to the literal "test" at global pytest import so backend modules can be imported without real Redis creds. `tests/security/conftest.py` was then using `os.environ.setdefault(...)` to overlay real `.env` values — a no-op because the sentinel was already set. Result: `redis-cli` ran with `-a test` against a healthy stack and the ACL acceptance tests failed for the wrong reason. Pop the sentinel before the `.env` overlay and use direct assignment. Add a regression test that asserts the live env value is not the sentinel and (when `.env` defines the keys) matches the `.env` value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssage The lint at `tests/lint_sys_modules.py` documents three ways out of a bare-`sys.modules` violation: `monkeypatch.setitem` / `delitem`, moving to `conftest.py`, and the `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` named-helper pair (precedent: `tests/unit/test_telegram_webhook_backfill.py`). Only the first two are surfaced in the per-line error message. For tests that load real backend modules in isolation with their heavy deps stubbed at import time, neither of the documented options applies: - `monkeypatch.*` is fixture-scoped and structurally cannot rewind a stub planted before any test runs (the stubs are needed during `spec_from_file_location` + `exec_module`, which executes at module-import time). - `conftest.py` would leak the stubs to every test in the directory, the exact #762 failure mode the lint exists to prevent. The named-helper escape hatch is the right answer for this case, and the lint already exempts it — but discoverability is poor. PR #783 and #606 both hit the lint, read the error, and re-discovered the escape hatch independently after digging into the script's docstring. Surface it in the error directly so the next author sees all three options at first encounter, not after archaeology. No behavioral change — pure error-text expansion. The lint's exit codes, detection logic, and baseline mechanics are unchanged. The committed baseline file is unaffected. Refs #762 Co-Authored-By: Claude <noreply@anthropic.com>
`tests/unit/test_cleanup_unreachable_orphan.py` (added in 79a6481, PR #783) plants three import-time stubs in `sys.modules`: - `docker` (line 64) — the unit venv doesn't ship the docker package - `services.docker_service` (line 76) — eager-imported by `services/__init__.py` - `database` (line 79) — hit transitively by cleanup_service The lint+baseline shipped in #791 (3043631) merged ~4 hours after #783, but `#791`'s baseline file omits this entry — almost certainly generated against a pre-#783 dev state and never refreshed before merge. Result: `lint-sys-modules` job in `backend-unit-test.yml` has been red on dev since both PRs landed, and the failure travels to every PR branched off that state — including #606's PR. Apply the same named-helper escape hatch used by the #606 file and the original `tests/unit/test_telegram_webhook_backfill.py` precedent: a top-level `_STUBBED_MODULE_NAMES` list naming the three slots, plus an autouse `_restore_sys_modules` fixture that snapshots them at test setup and restores at teardown. Bounds the cross-file pollution to this test's own scope — the exact invariant #762's lint protects. Verification: - `python tests/lint_sys_modules.py` — clean: 223 violations in 65 files, baseline allows 223, no new violations. - `pytest tests/test_lint_sys_modules.py` — all 25 tests pass, including `test_committed_baseline_matches_current_repo_state` (was failing). - `pytest tests/unit/test_cleanup_unreachable_orphan.py` — passes. Test-only — no production code changes. Cross-PR fix: clears CI on this branch and on dev. The underlying "stale baseline at PR merge" question (why didn't #791's own CI catch this?) is a separate concern worth a follow-up issue. Refs #762 Refs #783 Co-Authored-By: Claude <noreply@anthropic.com>
…rt (#606) (#800) * test(subscription): pin auto-switch chain skips .credentials.enc import (#606) Adds a chain-level regression test for #606 entered at `subscription_auto_switch._restart_agent` — pins that the SUB-003 auto-switch path reaches the `lifecycle.py:155` `subscription_mode` short-circuit and never re-enters the file-based credential import. The existing leaf-level test (`test_inject_assigned_credentials.py::test_subscription_mode_skips_import_with_clear_reason`) covers the short-circuit in isolation but would not catch a future refactor of `_perform_auto_switch` or `_restart_agent` that quietly bypassed the guard. The new test asserts on `reason == "subscription_mode"` specifically (not just `status == "skipped"`) so a refactor that moved the short-circuit into the `#421` `container_already_running` branch would fail explicitly. Test-only — no production code changes. Refs #606 Co-Authored-By: Claude <noreply@anthropic.com> * docs(feature-flows): sync session-tab cold-turn lock + subscription auto-switch test session-tab.md: step 6 documents the new `_ResumeLock(..., session.id, ...)` signature and the cold-turn key shape `session_lock:cold:{session_id}` (#779). Step 5 drops the "which the lock skips by design" claim about cold turns — no longer accurate. subscription-auto-switch.md: adds `tests/unit/test_subscription_auto_switch_no_cred_import.py` to the Tests table — chain-level regression for #606 that pins the SUB-003 auto-switch path reaches the `lifecycle.py:155` subscription_mode short-circuit and never re-enters file-based credential import. Refs #779 Refs #606 Co-Authored-By: Claude <noreply@anthropic.com> * fix(tests): adopt sys.modules snapshot/restore in #606 chain test The #606 chain-level regression test added in 967b874 introduced 17 new `tests/lint_sys_modules.py` violations — `sys.modules[...] = ...`, `.update(...)`, `.pop(...)`, `.setdefault(...)` writes at both module- import time (inside `_preload_credential_encryption`, `_load_lifecycle`, `_load_auto_switch`) and inside the test body and `_reset` fixture. `monkeypatch.setitem` is fixture-scoped and structurally cannot rewind the stubs planted during module import. Adopt the named snapshot/restore escape hatch documented in `tests/lint_sys_modules.py` (precedent: `tests/unit/test_telegram_webhook_backfill.py`): - Add top-level `_STUBBED_MODULE_NAMES` listing the 19 module slots this file stubs across its four loader paths. - Add autouse `_restore_sys_modules` fixture that snapshots those slots at test setup and restores them at teardown. This bounds the actual #762 concern (cross-file pollution within the same pytest session) without forcing a structurally-impossible rewrite of the import-time loaders. Verification: - `python tests/lint_sys_modules.py` — target file now reports 0 violations (was 17). The remaining `test_cleanup_unreachable_orphan.py` failure is a pre-existing unrelated case. - `pytest tests/unit/test_subscription_auto_switch_no_cred_import.py` — passes. - `pytest tests/unit/test_inject_assigned_credentials.py` — passes, confirming the snapshot/restore doesn't leak into the sibling leaf test that also loads `lifecycle.py`. Test-only — no production code changes. Refs #606 Refs #762 Co-Authored-By: Claude <noreply@anthropic.com> * docs(lint): mention named-helper escape hatch in sys.modules error message The lint at `tests/lint_sys_modules.py` documents three ways out of a bare-`sys.modules` violation: `monkeypatch.setitem` / `delitem`, moving to `conftest.py`, and the `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` named-helper pair (precedent: `tests/unit/test_telegram_webhook_backfill.py`). Only the first two are surfaced in the per-line error message. For tests that load real backend modules in isolation with their heavy deps stubbed at import time, neither of the documented options applies: - `monkeypatch.*` is fixture-scoped and structurally cannot rewind a stub planted before any test runs (the stubs are needed during `spec_from_file_location` + `exec_module`, which executes at module-import time). - `conftest.py` would leak the stubs to every test in the directory, the exact #762 failure mode the lint exists to prevent. The named-helper escape hatch is the right answer for this case, and the lint already exempts it — but discoverability is poor. PR #783 and #606 both hit the lint, read the error, and re-discovered the escape hatch independently after digging into the script's docstring. Surface it in the error directly so the next author sees all three options at first encounter, not after archaeology. No behavioral change — pure error-text expansion. The lint's exit codes, detection logic, and baseline mechanics are unchanged. The committed baseline file is unaffected. Refs #762 Co-Authored-By: Claude <noreply@anthropic.com> * fix(tests): adopt sys.modules snapshot/restore in #783 cleanup test `tests/unit/test_cleanup_unreachable_orphan.py` (added in 79a6481, PR #783) plants three import-time stubs in `sys.modules`: - `docker` (line 64) — the unit venv doesn't ship the docker package - `services.docker_service` (line 76) — eager-imported by `services/__init__.py` - `database` (line 79) — hit transitively by cleanup_service The lint+baseline shipped in #791 (3043631) merged ~4 hours after #783, but `#791`'s baseline file omits this entry — almost certainly generated against a pre-#783 dev state and never refreshed before merge. Result: `lint-sys-modules` job in `backend-unit-test.yml` has been red on dev since both PRs landed, and the failure travels to every PR branched off that state — including #606's PR. Apply the same named-helper escape hatch used by the #606 file and the original `tests/unit/test_telegram_webhook_backfill.py` precedent: a top-level `_STUBBED_MODULE_NAMES` list naming the three slots, plus an autouse `_restore_sys_modules` fixture that snapshots them at test setup and restores at teardown. Bounds the cross-file pollution to this test's own scope — the exact invariant #762's lint protects. Verification: - `python tests/lint_sys_modules.py` — clean: 223 violations in 65 files, baseline allows 223, no new violations. - `pytest tests/test_lint_sys_modules.py` — all 25 tests pass, including `test_committed_baseline_matches_current_repo_state` (was failing). - `pytest tests/unit/test_cleanup_unreachable_orphan.py` — passes. Test-only — no production code changes. Cross-PR fix: clears CI on this branch and on dev. The underlying "stale baseline at PR merge" question (why didn't #791's own CI catch this?) is a separate concern worth a follow-up issue. Refs #762 Refs #783 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
The first CI run this PR was ever able to produce (it was CONFLICTING until now, so GitHub could not build refs/pull/1827/merge and reported zero checks) failed `lint (sys.modules pollution check)`: 12 bare `sys.modules.pop(...)` calls, 3 per new file. Fixed by satisfying the guard, NOT by widening its baseline (`lint_sys_modules_baseline.txt` is untouched) — this guard has already caught this same class in #783, #606 and #875, and retiring it on a test-rigour PR would be backwards. Both eviction sites are out of monkeypatch's reach, or actively wrong for it: * the `utils*` shadow clear is IMPORT-time, before any fixture exists, so monkeypatch structurally cannot reach it; * the `ops` fixture must evict `db.*` so `from db.schedules import ...` re-imports against the harness-bound engine. `monkeypatch.delitem` records NO undo for a key absent on entry (verified against _pytest.monkeypatch), so the freshly imported harness-bound module would stay resident for later files — strictly worse isolation than the explicit pop it would replace. So both take the documented escape hatch instead: a top-level `_STUBBED_MODULE_NAMES` list plus an autouse `_restore_sys_modules` fixture, matching the shape of tests/unit/test_telegram_webhook_backfill.py. It is a teardown-time guarantee only — the snapshot is taken before the test body, so no in-test behaviour and no assertion changes. Verified: lint reports "203 violation(s) in 60 file(s); baseline allows 240 — no new violations"; the four files still report exactly 129 passed, 1 xfailed (same strict xfail); `git diff origin/dev...HEAD -- src/` still empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first CI run this PR was ever able to produce (it was CONFLICTING until now, so GitHub could not build refs/pull/1827/merge and reported zero checks) failed `lint (sys.modules pollution check)`: 12 bare `sys.modules.pop(...)` calls, 3 per new file. Fixed by satisfying the guard, NOT by widening its baseline (`lint_sys_modules_baseline.txt` is untouched) — this guard has already caught this same class in #783, #606 and #875, and retiring it on a test-rigour PR would be backwards. Both eviction sites are out of monkeypatch's reach, or actively wrong for it: * the `utils*` shadow clear is IMPORT-time, before any fixture exists, so monkeypatch structurally cannot reach it; * the `ops` fixture must evict `db.*` so `from db.schedules import ...` re-imports against the harness-bound engine. `monkeypatch.delitem` records NO undo for a key absent on entry (verified against _pytest.monkeypatch), so the freshly imported harness-bound module would stay resident for later files — strictly worse isolation than the explicit pop it would replace. So both take the documented escape hatch instead: a top-level `_STUBBED_MODULE_NAMES` list plus an autouse `_restore_sys_modules` fixture, matching the shape of tests/unit/test_telegram_webhook_backfill.py. It is a teardown-time guarantee only — the snapshot is taken before the test body, so no in-test behaviour and no assertion changes. Verified: lint reports "203 violation(s) in 60 file(s); baseline allows 240 — no new violations"; the four files still report exactly 129 passed, 1 xfailed (same strict xfail); `git diff origin/dev...HEAD -- src/` still empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ers and analytics aggregation (#1827) * test(db/schedules): edge cases + Hypothesis properties for CAS status writers (#1771) /edge-cases target 3, sub-area A: the #1082 status-as-projection CAS writers in db/schedules/executions.py and db/schedules/queue.py. - test_1771c_schedules_cas_edges.py: matrix rows A1b/A2/A3/A5/A5b/A6/A7/A8/ A12/A13/A14/A15/A16 as parametrized cases on the db_harness (#300) full production schema — SQLite always, PostgreSQL when TEST_POSTGRES_URL is set. - test_1771c_schedules_cas_properties.py: P-A1 bounded RuleBasedStateMachine ("terminal is absorbing" — canary E-02 at the DB layer) plus a meta-test that injects a phantom reversal and proves the machine reports it; P-A2/P-A3 no-crash + idempotence asserted PER RETURN CLASS (bool / int / Optional[Dict] are three different contracts); P-A4 lexicographic-ISO oracle. - hypothesis==6.161.5 added to tests/requirements-test.txt (exact pin so the three concurrent #1771 slices produce an identical, trivially-mergeable line). One strict xfail: A6 — a clock-skewed started_at persists a NEGATIVE duration_ms. No product-code fix (#1771 AC#4). Test-only. No src/ changes. Refs #1771 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(db/schedules): edge cases + Hypothesis properties for analytics aggregation (#1771) /edge-cases target 3, sub-area B: db/schedules/analytics.py — get_agent_analytics (#1107), get_schedule_analytics (#868), get_agent_schedules_summary (#1115) and the pure leaves _bucket_for_trigger / _schedule_command_label. - test_1771c_schedules_analytics_edges.py: matrix rows B1–B12. Highlights: B1 pins _TRIGGER_BUCKETS.values() subset of _BUCKET_ORDER (the existing literal assertion in test_agent_analytics.py omits "Reminders", so it was never a completeness check); B6b mechanises the locked "headline avg is full-set, never the capped pool" discipline; B10 freezes iso_cutoff so the strict-'>' boundary is deterministic rather than a clock race. - test_1771c_schedules_analytics_properties.py: P-B1 conservation (sum of by_type == total_executions, over arbitrary unicode triggers), P-B2 rate/count bounds incl. the zero-terminal-day-is-None rule, P-B3 contiguous UTC-day timeline over arbitrary windows, P-B4 no-crash-total on the pure leaves. Hypothesis `event()` markers make non-vacuity provable via --hypothesis-show-statistics rather than assumed. Five first-run failures were all WRONG TESTS (my expectations/mechanisms), not product bugs — reflection notes recorded in the docstrings. Test-only. No src/ changes. Refs #1771 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(registry): register the four #1771 edge-case/property test files /update-tests tail step. Appended with ensure_ascii=True so the existing \uXXXX escaping of 24 unrelated entries is preserved — the diff is +54/-0. The companion .claude/agents/test-runner.md catalog sync is deliberately NOT done: that path is inside the pinned private .claude submodule, and this wave must not move the submodule gitlink. Refs #1771 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(db/schedules): make the B9 day-bucketing case date-relative (#1771) Self-review fix. B9 hard-coded a 2026-03-01/02 pair, which forced a 10-year window to keep the rows in range — so the gap-fill loop built ~3650 day-dicts per call, and the test would have aged out of any tighter window. Derive the straddling instant from today instead and use a 168h window. Same assertion, cannot age out; the file's runtime drops 8.3s -> 2.4s. Refs #1771 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(db/schedules): close the get_schedule_analytics gap and de-vacuum P-B2b (#1771) /review findings on the #1771 target-3 edge-case slice, fixed in place. Test-only: `git diff f7aff46..HEAD -- src/` stays empty (AC#4). R1 REQUIREMENTS MISSING — `get_schedule_analytics` was named a subject-under-test by the plan (§3) and by the edges file's own docstring, yet no test invoked it: under a new-tests-only coverage run its whole body (analytics.py:113-259) was unexecuted, and the reported 92% came from bundling nine pre-existing neighbour files. Added rows B13/B13b/B13c/B15. R2 Closed the residual gap the matrix itself flagged: the `tool_calls` JSON-SHAPE guards. The column is agent-written, so valid-JSON-not-a-list, list-of-scalars, dict-without-`name` and non-numeric `duration_ms` are all reachable input, and a regressed guard turns an analytics read into a 500. Neighbours cover only the `json.loads` raise. Now pinned on BOTH surfaces (`get_schedule_analytics` + `get_agent_schedules_summary`, 20 cases). R3 P-B2b was half-vacuous: it asserted `sampled is (eligible > cap)` with the cap at its production 5000 and at most 12 seeded rows, so the `True` arm was unreachable for every example. The cap is now drawn (1-6) and monkeypatched, with over/at/empty `@example` pins. Verified load-bearing — a probe forcing `sampled is False` fails on the explicit example. R7 Renamed the `st` loop variable that shadowed the conventional `hypothesis.strategies as st` alias used in the sibling properties file. Also corrected in `.plan/edge-cases-1771c-matrix.md` (untracked by convention): `analytics.py:234`/`241` were mis-described as tool_calls guards (234 is the empty-day guard, 241 the FAILED timeline arm, now covered); `542->550` is defensive-dead, not malformed-`started_at`-reachable (`'' > cutoff` is false in SQL, so an empty `started_at` never reaches the loop); both coverage numbers are now reported instead of only the bundled one; and the Hypothesis `event()` percentages are labelled as one observed run rather than a stable contract. Declined: ruff F811 on the `db_backend` fixture import (pre-existing repo-wide pattern in every db_harness consumer; no CI workflow runs ruff). Refs #1771 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(deps): harmonize the #1771 hypothesis block across the three slices The three concurrent #1771 slices each added `hypothesis==6.161.5` to tests/requirements-test.txt at a different position with a different comment, so they conflicted with each other rather than merging cleanly. Rebuild the file from dev's copy and insert one byte-identical canonical block at one fixed position (after pytest-cov). All three slices now carry the same bytes in the same place, so they merge in any order and the file keeps exactly ONE hypothesis line. No product code, no test logic change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(#1771): restore sys.modules the four 1771c files evict (#762 lint) The first CI run this PR was ever able to produce (it was CONFLICTING until now, so GitHub could not build refs/pull/1827/merge and reported zero checks) failed `lint (sys.modules pollution check)`: 12 bare `sys.modules.pop(...)` calls, 3 per new file. Fixed by satisfying the guard, NOT by widening its baseline (`lint_sys_modules_baseline.txt` is untouched) — this guard has already caught this same class in #783, #606 and #875, and retiring it on a test-rigour PR would be backwards. Both eviction sites are out of monkeypatch's reach, or actively wrong for it: * the `utils*` shadow clear is IMPORT-time, before any fixture exists, so monkeypatch structurally cannot reach it; * the `ops` fixture must evict `db.*` so `from db.schedules import ...` re-imports against the harness-bound engine. `monkeypatch.delitem` records NO undo for a key absent on entry (verified against _pytest.monkeypatch), so the freshly imported harness-bound module would stay resident for later files — strictly worse isolation than the explicit pop it would replace. So both take the documented escape hatch instead: a top-level `_STUBBED_MODULE_NAMES` list plus an autouse `_restore_sys_modules` fixture, matching the shape of tests/unit/test_telegram_webhook_backfill.py. It is a teardown-time guarantee only — the snapshot is taken before the test body, so no in-test behaviour and no assertion changes. Verified: lint reports "203 violation(s) in 60 file(s); baseline allows 240 — no new violations"; the four files still report exactly 129 passed, 1 xfailed (same strict xfail); `git diff origin/dev...HEAD -- src/` still empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
The Phase 3 re-verify branch in
_process_stale_slot_reclaims(added by #378) treated "agent unreachable" the same as "still running" — itcontinue-d. The Redis slot was already reclaimed bycapacity.reclaim_stale()(TTL elapsed), but the DB execution row stayed inrunninguntil Phase 1's 120-min stale cleanup picked it up. Under sustained 3/3-capacity load with intermittent agent unreachability, this left zombierunningrows for up to 2 hours.After this PR, the unreachable branch calls
db.fail_stale_slot_executionimmediately — the same race-guarded writer already used by the "re-verify-confirmed-inactive" branch. The slot was reclaimed because TTL elapsed (timeout_seconds + buffer), so by construction the execution is already older than Phase 1's threshold at a much longer window.Related to #497.
Investigation
The issue noted: "validation needed during implementation — root cause framing inferred from cleanup logs and DB-vs-slots divergence on a live instance. Not yet reproduced in isolation."
Done:
1. Traced the SUCCESS-write paths and race guards
update_execution_status(SUCCESS)(db/schedules.py:1048)WHERE id=? AND status != 'cancelled'update_execution_status(FAILED)WHERE id=? AND status NOT IN (success,failed,cancelled,skipped)fail_stale_slot_execution(db/schedules.py:1615)WHERE id=? AND status='running'mark_execution_failed_by_watchdogThe
fail_stale_slot_executionrace guard is the right primitive: it preserves any SUCCESS / FAILED / CANCELLED that landed between the slot reclaim and the cleanup write.2. Confirmed the slot "leak" framing was imprecise
slot_service._cleanup_stale_slots_for_agentdoeszremrangebyscore(slot_service.py:301) — reclaimed slots are removed from Redis permanently. Capacity is Redis-based (zcard), so reclaimed slots do not hold capacity. The "Agent at capacity 3/3" the reporter saw must be 3 legitimate live tasks. The actual symptom is a DB-row zombie that lingers for up to 120 min, polluting dashboards and operator-queue visibility.3. Built an isolated repro
tests/unit/test_cleanup_unreachable_orphan.pydrives the exact decision matrix:test_agent_unreachable_force_fails_orphan— unreachable → race-guarded fail with error tagtest_agent_reachable_says_still_running_no_action— bug: Execution shows Failed then Success after cleanup (race condition) #378 path preservedtest_agent_reachable_says_not_running_fails— existing behavior preservedtest_confirmed_running_skipped_even_on_unreachable— Phase 0'sconfirmed_running_idsstill winstest_race_guard_no_increment_when_db_says_already_terminal— counter respects the WHERE guardtest_mixed_batch_some_unreachable_some_confirmed— per-execution decision matrix routingDocumented residual risk
If the agent later recovers and writes SUCCESS via
update_execution_status, that path overwrites FAILED per #378's "SUCCESS wins over FAILED" rule. The execution must have run pasttimeout + bufferfor its slot to be reclaimed in the first place, so a "late SUCCESS" represents a deliverable that exceeded its budget. Accepted as a sharp narrowing of the 2-hour-zombie window.Follow-up direction if it ever becomes a real complaint: narrow
update_execution_status's SUCCESS-over-FAILED rule to exclude FAILED rows tagged witherror LIKE 'Stale execution — agent % unresponsive%'. Mechanically straightforward, but expands behavior beyond this issue and was not bundled here.What stays unchanged
confirmed_running_idsshortcut from Phase 0 (bug: Stale slot cleanup uses fixed 20-min TTL regardless of agent timeout #226).reclaimed(still alive in Redis).Test plan
test_skips_when_agent_unreachable→test_force_fails_when_agent_unreachable;test_one_agent_raises_others_proceednow asserts both execs failed) and passtest_watchdog_unit.py+test_cleanup_unreachable_orphan.py+test_session_cold_turn_lock.py)test_watchdog_unit.pycollection error in isolation is pre-existing —dockerpy-pkg missing from the unit test venv, reproduces onorigin/devviagit stash(unrelated to this PR)Files
src/backend/services/cleanup_service.pycontinueon unreachable branch with race-guardedfail_stale_slot_execution. ~40 lines incl. updated docstring.tests/test_watchdog_unit.pytests/unit/test_cleanup_unreachable_orphan.py(new)🤖 Generated with Claude Code