docs/test: align scheduling cleanup

This commit is contained in:
lda
2026-09-10 05:26:40 +07:00 Verified
parent 4474135301
commit 422dd57db6
8 changed files with 59 additions and 35 deletions
+6 -5
View File
@@ -152,11 +152,12 @@ The active sequence can assume these foundations:
runs through the API runs through the API
The active sequence can also assume deployment scheduling: an opt-in The active sequence can also assume deployment scheduling: an opt-in
same-server scheduler starts ordinary deployment runs without a connected same-server scheduler for local/static servers only starts ordinary deployment
client (one-shot and recurring cron, durable admission, coalesced runs without a connected client (one-shot and recurring cron, durable
missed-start recovery, bounded parallel runs, occurrence inspection, and admission, coalesced missed-start recovery, bounded parallel runs, occurrence
API/Python-client administration). Scheduling is disabled by default and inspection, and API/Python-client administration). Scheduling is disabled by
enabled per server. The current contract is 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); [`deployment scheduling`](superpowers/specs/2026-09-08-deployment-scheduling-design.md);
operator usage is under operator usage is under
[`deployment scheduling operations`](deployment_scheduling.md). [`deployment scheduling operations`](deployment_scheduling.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 include an offset. Persist occurrence instants as UTC timestamps; retain the
cron time-zone name in the definition. Reject invalid zones and naive times. cron time-zone name in the definition. Reject invalid zones and naive times.
Proposed default lateness allowance: 60 seconds, configurable per schedule as Default lateness allowance: 60 seconds, configurable per schedule as a
a non-negative finite duration. During uninterrupted operation, a due instant non-negative finite duration. During uninterrupted operation, a due instant
within the allowance can be admitted. Older instants and enabled times missed within the allowance can be admitted. Older instants and enabled times missed
while the server was unavailable follow the configured misfire policy. while the server was unavailable follow the configured misfire policy.
With `skip`, startup selects the next future instant regardless of lateness 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, Existing expressions compose data; they do not implement date formatting,
arithmetic, template evaluation, Python execution, or arbitrary transforms. 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 ```json
{ {
"input_bindings": [ "input_bindings": [
{"target": "team", "value": "engineering"}, {
"target": "team",
"expression": {"kind": "literal", "value": "engineering"}
},
{ {
"target": "report_time", "target": "report_time",
"expression": {"kind": "occurrence", "field": "scheduled_at"} "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 `OccurrenceExpression` exposes `schedule_id`, `occurrence_id`, and
scheduled_at. Date-time values serialize as UTC RFC 3339 strings. The admitted `scheduled_at` from the admitted occurrence. `scheduled_at` is serialized as
run's resolved input is persisted once and never re-evaluated on restart. 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 Validate target conflicts, expression bounds, source fields, and the resulting
workflow input schema. Recheck the current deployment contract at admission; workflow input schema. Recheck the current deployment contract at admission;
an edit may have changed the expected input since schedule creation. 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 ## Durable admission and recovery
Current seams needing change: ### Historical pre-implementation seams
Before T01T14 were implemented, these were the seams that required change:
- wf_api/runs.py executes before persisting a stopped run. - wf_api/runs.py executes before persisting a stopped run.
- wf_artifacts/runs/models.py permits only stopped summaries, with a required - wf_artifacts/runs/models.py permits only stopped summaries, with a required
@@ -281,8 +293,10 @@ schedules at `<root>/schedules`, one lock at `<root>/scheduler.lock`).
## Lifecycle, administration, and resource bounds ## Lifecycle, administration, and resource bounds
Expose create/get/list/update/pause/resume/delete and paginated occurrence Expose create/get/list/update/pause/resume/delete and paginated occurrence
inspection through the workflow API and Python client. Public client names inspection through the workflow API and Python client. The implemented names
are finalized in the implementation plan, not treated as existing methods. 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. Reject stale schedule edits using revisions within the owning process.
Occurrence inspection distinguishes pending, coalesced/superseded, 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, - Public API/client round trips, pagination, inspection without a checkpoint,
and existing manual run/resume behavior remain valid. 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 The implementation plan is archived as historical context; no further
allowance, expression seam, and admission/recovery representation together. design-approval or implementation-planning step remains for this slice. When
The calendar-library probe is an explicit gate, not a claimed passing test. changing the calendar dependency or adapter, rerun the calendar-library probe
After approval, create a sequenced implementation plan with fault-injection and retain the calendar boundary coverage above. Keep fault-injection tests at
tests before enabling scheduling in the server. WaitNode is a later contract each persistence boundary, including resume, when changing admission or
that may reuse timed admission but must persist its own suspended execution. 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.
+1 -1
View File
@@ -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_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_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_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. | | 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. | | `wf_cli` | CLI frontend over `WorkflowApiSurface`; it may run locally against process-local stores or target a remote JSON-RPC backend. |
+2 -2
View File
@@ -94,8 +94,8 @@ def test_thesis_bundle_has_reproducible_agent_evaluation_assets() -> None:
assert "figure-format.lua" in generate_script assert "figure-format.lua" in generate_script
assert "thesisFigureFormat" in generate_script assert "thesisFigureFormat" in generate_script
assert ( assert (
generate_script.index("$include_markdown_filter `") generate_script.index('"--lua-filter", $include_markdown_filter')
< generate_script.index("$diagram_filter `") < generate_script.index('"--lua-filter", $diagram_filter')
< generate_script.index("--filter=pandoc-crossref") < generate_script.index("--filter=pandoc-crossref")
) )
assert "generate_agent_challenge_evaluation.py" in combined_build_script assert "generate_agent_challenge_evaluation.py" in combined_build_script
+4 -2
View File
@@ -265,8 +265,10 @@ async def test_settlement_does_not_block_the_event_loop(
await service.start() await service.start()
service.schedule_store.create_schedule(_sched_model("a", intended)) service.schedule_store.create_schedule(_sched_model("a", intended))
await service.poll_once(intended + timedelta(seconds=1)) await service.poll_once(intended + timedelta(seconds=1))
await asyncio.sleep(0.05) # The worker releases itself after two seconds as failure-safe cleanup.
assert settlement_started.is_set() # 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 not settlement_finished.is_set()
assert service.live_executions == 1 assert service.live_executions == 1
release.set() release.set()
+3 -1
View File
@@ -100,7 +100,9 @@ def test_competing_processes_share_the_canonical_lock(tmp_path: Path) -> None:
comp = tmp_path / "composition" comp = tmp_path / "composition"
comp.mkdir(parents=True) comp.mkdir(parents=True)
sched_store, run_store = _stores(comp) 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() holder = SchedulerOwnership(identity, owner="parent").acquire()
try: try:
script_path = tmp_path / "compete_canonical.py" script_path = tmp_path / "compete_canonical.py"
+2
View File
@@ -51,6 +51,7 @@ def test_competing_process_cannot_acquire_while_held(tmp_path: Path) -> None:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=30, timeout=30,
cwd=Path(__file__).resolve().parents[2],
) )
assert proc.returncode == 0, proc.stderr assert proc.returncode == 0, proc.stderr
assert "second-owner-rejected" in proc.stdout 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( proc = subprocess.Popen(
[sys.executable, str(script_path)], [sys.executable, str(script_path)],
cwd=Path(__file__).resolve().parents[2],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, text=True,
) )
@@ -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] 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]: def _run_ids(root: Path) -> list[str]:
return [record.id for record in FileRunStore(root).list_runs()] return [record.id for record in FileRunStore(root).list_runs()]
@@ -610,7 +616,7 @@ asyncio.run(main())
text=True, text=True,
) )
try: try:
output, _ = process.communicate(timeout=7) output, _ = process.communicate(timeout=30)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
process.kill() process.kill()
output, _ = process.communicate(timeout=5) output, _ = process.communicate(timeout=5)
@@ -1761,12 +1767,6 @@ async def test_shutdown_timeout_spares_healthy_sibling(tmp_path: Path) -> None:
await service.stop() 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: async def test_scheduled_resume_records_completion_live(tmp_path: Path) -> None:
"""A resumed completion lands in occurrence history live (B3). """A resumed completion lands in occurrence history live (B3).