From 422dd57db6d5ffbc9adc99a66bbb6af27de6b810 Mon Sep 17 00:00:00 2001 From: lda Date: Thu, 10 Sep 2026 05:26:40 +0700 Subject: [PATCH] docs/test: align scheduling cleanup --- docs/current_roadmap.md | 11 ++-- ...2026-09-08-deployment-scheduling-design.md | 51 ++++++++++++------- docs/wf_api_architecture.md | 2 +- tests/docs/test_big_doc_links.py | 4 +- tests/scheduling/test_lifecycle.py | 6 ++- tests/scheduling/test_lock_identity.py | 4 +- tests/scheduling/test_ownership.py | 2 + tests/wf_server/test_scheduler_integration.py | 14 ++--- 8 files changed, 59 insertions(+), 35 deletions(-) diff --git a/docs/current_roadmap.md b/docs/current_roadmap.md index eb3d3b4a..a4c869a8 100644 --- a/docs/current_roadmap.md +++ b/docs/current_roadmap.md @@ -152,11 +152,12 @@ The active sequence can assume these foundations: runs through the API The active sequence can also assume deployment scheduling: an opt-in -same-server scheduler starts ordinary deployment runs without a connected -client (one-shot and recurring cron, durable admission, coalesced -missed-start recovery, bounded parallel runs, occurrence inspection, and -API/Python-client administration). Scheduling is disabled by default and -enabled per server. The current contract is +same-server scheduler for local/static servers only starts ordinary deployment +runs without a connected client (one-shot and recurring cron, durable +admission, coalesced missed-start recovery, bounded parallel runs, occurrence +inspection, and API/Python-client administration). Scheduling is disabled by +default and enabled per server; MCP-backed servers reject scheduler +enablement. The current contract is [`deployment scheduling`](superpowers/specs/2026-09-08-deployment-scheduling-design.md); operator usage is under [`deployment scheduling operations`](deployment_scheduling.md). diff --git a/docs/superpowers/specs/2026-09-08-deployment-scheduling-design.md b/docs/superpowers/specs/2026-09-08-deployment-scheduling-design.md index ee299349..c088e7f1 100644 --- a/docs/superpowers/specs/2026-09-08-deployment-scheduling-design.md +++ b/docs/superpowers/specs/2026-09-08-deployment-scheduling-design.md @@ -56,8 +56,8 @@ Cron uses an explicit IANA time zone, default UTC. One-shot timestamps must include an offset. Persist occurrence instants as UTC timestamps; retain the cron time-zone name in the definition. Reject invalid zones and naive times. -Proposed default lateness allowance: 60 seconds, configurable per schedule as -a non-negative finite duration. During uninterrupted operation, a due instant +Default lateness allowance: 60 seconds, configurable per schedule as a +non-negative finite duration. During uninterrupted operation, a due instant within the allowance can be admitted. Older instants and enabled times missed while the server was unavailable follow the configured misfire policy. With `skip`, startup selects the next future instant regardless of lateness @@ -178,12 +178,20 @@ are invalid here. Do not extend GraphSourcePath with schedule-only roots. Existing expressions compose data; they do not implement date formatting, arithmetic, template evaluation, Python execution, or arbitrary transforms. -Proposed persisted binding example, pending concrete model names: +The persisted `Schedule.input_bindings` field is a list of +`ScheduleInputBinding` objects. Each binding has a `target: LocalPath` and a +discriminated `ScheduleExpression`, whose concrete models are +`LiteralExpression`, `OccurrenceExpression`, `ScheduleArrayExpression`, and +`ScheduleObjectExpression`. The API accepts and returns these bindings as +JSON through `Schedule.model_dump(mode="json")`; for example: ```json { "input_bindings": [ - {"target": "team", "value": "engineering"}, + { + "target": "team", + "expression": {"kind": "literal", "value": "engineering"} + }, { "target": "report_time", "expression": {"kind": "occurrence", "field": "scheduled_at"} @@ -192,9 +200,11 @@ Proposed persisted binding example, pending concrete model names: } ``` -Occurrence references initially expose schedule_id, occurrence_id, and -scheduled_at. Date-time values serialize as UTC RFC 3339 strings. The admitted -run's resolved input is persisted once and never re-evaluated on restart. +`OccurrenceExpression` exposes `schedule_id`, `occurrence_id`, and +`scheduled_at` from the admitted occurrence. `scheduled_at` is serialized as +a UTC RFC 3339 string; the other occurrence fields are strings as well. The +admitted run's resolved input is persisted once and never re-evaluated on +restart. Validate target conflicts, expression bounds, source fields, and the resulting workflow input schema. Recheck the current deployment contract at admission; an edit may have changed the expected input since schedule creation. @@ -211,7 +221,9 @@ values through their declared input bindings as before. ## Durable admission and recovery -Current seams needing change: +### Historical pre-implementation seams + +Before T01–T14 were implemented, these were the seams that required change: - wf_api/runs.py executes before persisting a stopped run. - wf_artifacts/runs/models.py permits only stopped summaries, with a required @@ -281,8 +293,10 @@ schedules at `/schedules`, one lock at `/scheduler.lock`). ## Lifecycle, administration, and resource bounds Expose create/get/list/update/pause/resume/delete and paginated occurrence -inspection through the workflow API and Python client. Public client names -are finalized in the implementation plan, not treated as existing methods. +inspection through the workflow API and Python client. The implemented names +are `create_schedule`, `get_schedule`, `list_schedules`, `update_schedule`, +`pause_schedule`, `resume_schedule`, `delete_schedule`, and +`list_schedule_occurrences`. Reject stale schedule edits using revisions within the owning process. Occurrence inspection distinguishes pending, coalesced/superseded, @@ -397,11 +411,14 @@ Use injected clocks and controlled executors, not real-time sleeps: - Public API/client round trips, pagination, inspection without a checkpoint, and existing manual run/resume behavior remain valid. -## Review before implementation planning +## Verification and operations guidance -User policy decisions above are settled. Review the proposed 60-second -allowance, expression seam, and admission/recovery representation together. -The calendar-library probe is an explicit gate, not a claimed passing test. -After approval, create a sequenced implementation plan with fault-injection -tests before enabling scheduling in the server. WaitNode is a later contract -that may reuse timed admission but must persist its own suspended execution. +The implementation plan is archived as historical context; no further +design-approval or implementation-planning step remains for this slice. When +changing the calendar dependency or adapter, rerun the calendar-library probe +and retain the calendar boundary coverage above. Keep fault-injection tests at +each persistence boundary, including resume, when changing admission or +recovery before enabling a changed scheduler build. Use the deployment +scheduling operations guide for local/static server configuration and runtime +operations. WaitNode is a later contract that may reuse timed admission but +must persist its own suspended execution. diff --git a/docs/wf_api_architecture.md b/docs/wf_api_architecture.md index d14e5fb5..56f504d6 100644 --- a/docs/wf_api_architecture.md +++ b/docs/wf_api_architecture.md @@ -20,7 +20,7 @@ frontends can share. | `wf_sources_mcp` | MCP-as-upstream-source implementation: source ids, source registry DTOs, auth/catalog stores, discovery, SDK client/facade, persistent runtime pool, and tool-wrapper helpers. | | `wf_mcp` | MCP frontend/compatibility package: old `wf-mcp` server entry points, broker glue around MCP-hosted services, proxy/admin tools, and compatibility shims while callers migrate. | | `wf_transport_rpc_http` | JSON-RPC-over-HTTP transport adapter and remote client over `WorkflowApiSurface`, not a reimplementation of workflow business logic. | -| `wf_client` | Async Python consumer facade over a narrow capability/artifact/deployment/run port. It reconstructs immutable snapshots and keeps representations bounded and inert. | +| `wf_client` | Async Python consumer facade over a narrow capability/artifact/deployment/run/schedule port. It reconstructs immutable snapshots and keeps representations bounded and inert. | | future `wf_http` / WebSocket / MCP server transports | Additional transports over `WorkflowApiSurface`, not new workflow application APIs. | | `wf_cli` | CLI frontend over `WorkflowApiSurface`; it may run locally against process-local stores or target a remote JSON-RPC backend. | diff --git a/tests/docs/test_big_doc_links.py b/tests/docs/test_big_doc_links.py index 87dcf5bb..19c26eed 100644 --- a/tests/docs/test_big_doc_links.py +++ b/tests/docs/test_big_doc_links.py @@ -94,8 +94,8 @@ def test_thesis_bundle_has_reproducible_agent_evaluation_assets() -> None: assert "figure-format.lua" in generate_script assert "thesisFigureFormat" in generate_script assert ( - generate_script.index("$include_markdown_filter `") - < generate_script.index("$diagram_filter `") + generate_script.index('"--lua-filter", $include_markdown_filter') + < generate_script.index('"--lua-filter", $diagram_filter') < generate_script.index("--filter=pandoc-crossref") ) assert "generate_agent_challenge_evaluation.py" in combined_build_script diff --git a/tests/scheduling/test_lifecycle.py b/tests/scheduling/test_lifecycle.py index 24efdc91..67139952 100644 --- a/tests/scheduling/test_lifecycle.py +++ b/tests/scheduling/test_lifecycle.py @@ -265,8 +265,10 @@ async def test_settlement_does_not_block_the_event_loop( await service.start() service.schedule_store.create_schedule(_sched_model("a", intended)) await service.poll_once(intended + timedelta(seconds=1)) - await asyncio.sleep(0.05) - assert settlement_started.is_set() + # The worker releases itself after two seconds as failure-safe cleanup. + # Keep this bounded wait shorter than that timeout so the assertion + # observes the intentionally blocked settlement rather than its guard. + assert await asyncio.to_thread(settlement_started.wait, 1.0) assert not settlement_finished.is_set() assert service.live_executions == 1 release.set() diff --git a/tests/scheduling/test_lock_identity.py b/tests/scheduling/test_lock_identity.py index 88eb8b1d..608fce69 100644 --- a/tests/scheduling/test_lock_identity.py +++ b/tests/scheduling/test_lock_identity.py @@ -100,7 +100,9 @@ def test_competing_processes_share_the_canonical_lock(tmp_path: Path) -> None: comp = tmp_path / "composition" comp.mkdir(parents=True) sched_store, run_store = _stores(comp) - identity = Path(canonical_lock_root(sched_store.root, run_store.root) or "") + resolved = canonical_lock_root(sched_store.root, run_store.root) + assert resolved is not None + identity = Path(resolved) holder = SchedulerOwnership(identity, owner="parent").acquire() try: script_path = tmp_path / "compete_canonical.py" diff --git a/tests/scheduling/test_ownership.py b/tests/scheduling/test_ownership.py index 6297679d..ec45a845 100644 --- a/tests/scheduling/test_ownership.py +++ b/tests/scheduling/test_ownership.py @@ -51,6 +51,7 @@ def test_competing_process_cannot_acquire_while_held(tmp_path: Path) -> None: capture_output=True, text=True, timeout=30, + cwd=Path(__file__).resolve().parents[2], ) assert proc.returncode == 0, proc.stderr assert "second-owner-rejected" in proc.stdout @@ -73,6 +74,7 @@ def test_lock_released_on_process_death(tmp_path: Path) -> None: ) proc = subprocess.Popen( [sys.executable, str(script_path)], + cwd=Path(__file__).resolve().parents[2], stdout=subprocess.PIPE, text=True, ) diff --git a/tests/wf_server/test_scheduler_integration.py b/tests/wf_server/test_scheduler_integration.py index bf4c8466..07e4ffad 100644 --- a/tests/wf_server/test_scheduler_integration.py +++ b/tests/wf_server/test_scheduler_integration.py @@ -356,6 +356,12 @@ def _entries(root: Path, schedule_id: str, kind: str) -> list[dict[str, Any]]: return [row for row in rows if row["kind"] == kind] +async def _kinds(root: Path, schedule_id: str) -> list[str]: + page = FileScheduleStore(root).list_occurrences(schedule_id, limit=100) + rows = cast(list[dict[str, Any]], page["occurrences"]) + return [row["kind"] for row in rows] + + def _run_ids(root: Path) -> list[str]: return [record.id for record in FileRunStore(root).list_runs()] @@ -610,7 +616,7 @@ asyncio.run(main()) text=True, ) try: - output, _ = process.communicate(timeout=7) + output, _ = process.communicate(timeout=30) except subprocess.TimeoutExpired: process.kill() output, _ = process.communicate(timeout=5) @@ -1761,12 +1767,6 @@ async def test_shutdown_timeout_spares_healthy_sibling(tmp_path: Path) -> None: await service.stop() -async def _kinds(root: Path, schedule_id: str) -> list[str]: - page = FileScheduleStore(root).list_occurrences(schedule_id, limit=100) - rows = cast(list[dict[str, Any]], page["occurrences"]) - return [row["kind"] for row in rows] - - async def test_scheduled_resume_records_completion_live(tmp_path: Path) -> None: """A resumed completion lands in occurrence history live (B3).