mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-23 07:09:35 +00:00
Merge pull request #1210 from razzant/ouroboros-agent/consciousness-observe-schedules
feat: consciousness Observe boundary, agency-first wake prompt, owner-governed schedule lifecycle
This commit is contained in:
commit
3bdbfef5cc
46 changed files with 3768 additions and 241 deletions
|
|
@ -241,6 +241,16 @@ Status, owner action, and urgent notification are separate product concepts:
|
|||
product's explicit incident/notification seam, not a red status or a failed
|
||||
task as a proxy.
|
||||
|
||||
Activity schedule rows use the same factual status rule: `active`, `disabled`,
|
||||
`suppressed`, and `consumed once · history` describe lifecycle state, while the
|
||||
adjacent Disable/Enable, Restore, and Delete controls state the owner action. A
|
||||
consumed one-shot is history even when its task succeeded or failed; the status
|
||||
never implies a result. Retained rows — consumed and suppressed — collapse into
|
||||
one disclosure rather than padding the standing list or disappearing: history
|
||||
the owner can still open, read and act on. A suppressed skill row keeps Restore
|
||||
so the owner can ask for it back; a consumed one keeps only Delete, because
|
||||
offering Enable on a schedule that cannot fire again would be a lie.
|
||||
|
||||
A task-bound `Reviews` history row may be the only retained fact for its owner.
|
||||
That row keeps a neutral owner anchor visible, but hides task status and typing
|
||||
until a real task status or activity arrives; review presence alone never means
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t
|
|||
| D05 | Tool surfaces: files, code, shell, media, external | 28 | 0 |
|
||||
| D06 | Review stack | 67 | 0 |
|
||||
| D07 | Delegation, subagents & Claudexor | 53 | 0 |
|
||||
| D08 | Supervisor: queue, workers, events & runtime control | 46 | 0 |
|
||||
| D08 | Supervisor: queue, workers, events & runtime control | 47 | 0 |
|
||||
| D09 | Cancellation, owner control & process custody | 13 | 0 |
|
||||
| D10 | Git, update & release machinery | 28 | 0 |
|
||||
| D11 | Gateway, server & Web UI | 54 | 0 |
|
||||
| D11 | Gateway, server & Web UI | 55 | 0 |
|
||||
| D12 | Settings & configuration | 15 | 0 |
|
||||
| D13 | Safety, guards & runtime mode | 9 | 0 |
|
||||
| D14 | Skills & extensions | 54 | 0 |
|
||||
|
|
@ -28,7 +28,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t
|
|||
| D18 | Launcher, packaging, platform & shared substrate | 15 | 0 |
|
||||
| D19 | Frozen contracts (ABI) | 10 | 0 |
|
||||
| D20 | Presence | 10 | 0 |
|
||||
| **total** | | **559** | **0** |
|
||||
| **total** | | **561** | **0** |
|
||||
|
||||
## Dependency direction matrix (strict, pinned)
|
||||
|
||||
|
|
@ -483,6 +483,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne
|
|||
- `supervisor/queue_snapshot.py`
|
||||
- `supervisor/queue_timeouts.py`
|
||||
- `supervisor/queue_transitions.py`
|
||||
- `supervisor/schedule_lifecycle.py`
|
||||
- `supervisor/schedule_time.py`
|
||||
- `supervisor/state.py`
|
||||
- `supervisor/subagent_task_truth.py`
|
||||
|
|
@ -576,6 +577,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne
|
|||
- `ouroboros/gateway/projects.py`
|
||||
- `ouroboros/gateway/router.py`
|
||||
- `ouroboros/gateway/routing_decision.py`
|
||||
- `ouroboros/gateway/schedule_contracts.py`
|
||||
- `ouroboros/gateway/schedules.py`
|
||||
- `ouroboros/gateway/schema.py`
|
||||
- `ouroboros/gateway/settings.py`
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
│ ├── worker_owner_wait.py ← Queue-owned active-capacity transfer for a required owner wait: the task stays RUNNING and custodied while another worker takes the active slot (§5)
|
||||
│ ├── state.py ← Persistent state (state/state.json) with file locking
|
||||
│ ├── queue.py ← Task queue (PENDING/RUNNING) + activity-based timeout enforcement; the ONE task-state authority — the lifecycle/publication/transition modules below extend it without becoming second authorities (§5)
|
||||
│ ├── queue_schedules.py, queue_snapshot.py, queue_timeouts.py ← Queue leaves re-exported through `supervisor.queue`: recurring schedules and the skill sync; the durable queue snapshot and what a restart may restore; activity-based liveness (§5)
|
||||
│ ├── queue_schedules.py, schedule_lifecycle.py, queue_snapshot.py, queue_timeouts.py ← Queue leaves re-exported through `supervisor.queue`: the schedule store, transaction, audit, projections, skill sync and dispatch; the owner-governed lifecycle over that store (create/edit, disable/delete/restore, remove — a suppressed skill row restores only into an extant manifest); the durable queue snapshot and what a restart may restore; activity-based liveness (§5)
|
||||
│ ├── cognitive_operations.py ← Typed in-memory LLM/review/VLM operation leases for the idle rail; no scheduler or durable timing ledger (§6)
|
||||
│ ├── task_model_wait.py ← Live model-wait event projection and forwarding, with quota-clock reads for queue liveness (§6 Quota and auth waits)
|
||||
│ ├── task_admission.py ← Token-owned admission reservations fence duplicate user-ingress ids before Project/workspace/attachment side effects; queue.py stays the state authority (§5)
|
||||
|
|
@ -177,7 +177,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
├── safety.py ← Safety Supervisor call with a bounded newest-first context budget; typed non-verdict `⚠️ SAFETY_UNAVAILABLE` (a 429 is an infrastructure fact, not a verdict: one retry, a storm latch, `safety_check_rate_limited`) and the fail-closed `⚠️ SAFETY_SUBJECT_TOO_LARGE_BLOCKED` over the 250k-char `_SAFETY_SUBJECT_CHAR_BUDGET` — never truncated, because anything past a cut would run unreviewed (§6 Safety Supervisor outcomes, fail-open cases included)
|
||||
├── consciousness.py ← The alarm clock of Background Consciousness: no thread — the supervisor pass calls `tick(now)`, and a launch is `supervisor.workers.handle_wake_direct`, an ordinary Main direct turn; `notify(reason)` pulls the next wake forward; a legacy observation inbox is moved once to the archive unread (§6 Background consciousness and Evolution)
|
||||
├── consciousness_wake.py ← The wake-up MESSAGE (`prompts/CONSCIOUSNESS.md` rendered as the turn's USER message, cuts disclosed as `(+N more)`) and the origin/authority envelope `wake_task_metadata`
|
||||
├── consciousness_authority.py ← The three autonomy levels of a wake (observe/act/full) and their two consequences — `disabled_tools`, bound at dispatch only so the prompt prefix matches an owner turn's, and `runtime_mode_cap=light` below Full (§6 Background consciousness and Evolution)
|
||||
├── consciousness_authority.py ← The three autonomy levels of a wake (observe/act/full) and their consequences — `disabled_tools`, bound at dispatch only so the prompt prefix matches an owner turn's, `runtime_mode_cap=light` below Full, and Observe's argument-level narrowing of the mutating names it keeps (§6 Background consciousness and Evolution)
|
||||
├── consciousness_allowance.py ← Rolling-24h consciousness spend read off the usage ledger; typed `allowance_unknown` on a read failure; read by the alarm and the single admission door in `supervisor/queue.py`
|
||||
├── consolidator.py ← Dialogue consolidation with a generation-aware cursor; an unfindable generation appends a loud `[MEMORY GAP]` block, never a silent offset reset; `last_consolidation_error` / `last_unpublished_nominations` in `dialogue_meta.json` (§6 Durable memory and project focus)
|
||||
├── memory.py ← Scratchpad, identity, chat history
|
||||
|
|
@ -395,6 +395,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
│ ├── host_service.py ← Loopback-only Host Service API (§12)
|
||||
│ ├── history.py ← Shared Chat room/quiz/media/review/terminal projection + cost breakdown factories
|
||||
│ ├── history_contracts.py ← Descriptive paged Chat history response, re-exported by contracts.py
|
||||
│ ├── schedule_contracts.py ← Typed schedule list/upsert/lifecycle-action responses, re-exported by contracts.py
|
||||
│ ├── history_paging.py ← Physical range selection over retained chat/progress JSONL chains, frozen room-bound page/continuation cursors and read gaps; no stored history copy
|
||||
│ ├── cost_breakdown.py ← Ledger-derived dashboard buckets and root-task detail breakdown over the same physical-attempt authority
|
||||
│ ├── projects.py ← GET/POST /api/projects, /from-task, /update, /delete
|
||||
|
|
@ -416,7 +417,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
│ ├── memory_tools.py ← Memory registry tools for tracking data sources, gaps, and trust
|
||||
│ ├── health.py ← Codebase health tool: complexity metrics and self-assessment
|
||||
│ ├── compact_context.py ← LLM-requested tool-history compaction trigger; stores the pending request for the next round
|
||||
│ ├── control.py ← Control tools: restart, timeout settings, scheduling, review, chat history, model switching; publishes the strict `subagent_id`+objective `schedule_subagent` contract and the compact `wait_task` projections (§6 Delegated subagents)
|
||||
│ ├── control.py ← Control tools: restart, timeout settings, scheduling, review, chat history and model switching; publishes the strict `subagent_id`+objective `schedule_subagent` contract and the compact `wait_task` projections (§6 Delegated subagents)
|
||||
│ ├── control_delegation.py ← Delegation-budget and in-task project-scoping affordances (`ensure_project_scope` handler; §6 In-task project scoping)
|
||||
│ ├── control_events.py, control_routing.py, control_runtime.py, control_scheduling.py, control_subagent_spec.py, control_task_results.py ← The control tools' leaves: emitting one control event and waiting for its durable handler outcome; routing real work out of a conversation lane into a supervised task; runtime self-control (restart, promotion, evolution, memory, model); scheduling one live subagent; the published `schedule_subagent` parameter surface and its validation; absorbing a child — reading one result or waiting on a batch
|
||||
│ ├── tool_discovery.py ← Tool-discovery meta-tools: confirm registration, no delayed capabilities
|
||||
|
|
@ -476,7 +477,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
│ ├── project_journal.py ← journal_write/read, workpad_read/write, journal_tail_digest (over-limit rejected); owns `mirror_tree_coordination_to_journal`
|
||||
│ ├── presence.py ← configure_presence, initiate_presence, typed completion/cancel
|
||||
│ ├── task_tree.py ← tree_note/tree_read (storage SSOT: task_tree_ledger.py)
|
||||
│ ├── followup.py ← One deferred follow-up into `state/scheduled_tasks.json`: exactly one trigger (`once` ISO or 5-field cron+tz), cap 2 pending; preserves the SOURCE address (`project_id` plus the originating `chat_id`) instead of defaulting to the global owner chat; a one-shot is consumed on the typed tombstoned-Project refusal, while a deleting Project and transient refusals stay retryable
|
||||
│ ├── followup.py ← The agent's two schedule tools over `state/scheduled_tasks.json`. `manage_schedules` lists every row with its lifecycle word and applies one audited disable/delete/restore to future dispatch (root-only to change, any task may list, Presence neither). `schedule_followup` writes one deferred follow-up: exactly one trigger (`once` ISO or 5-field cron+tz), cap 2 pending; preserves the SOURCE address (`project_id` plus the originating `chat_id`) instead of defaulting to the global owner chat; a one-shot is consumed on the typed tombstoned-Project refusal, while a deleting Project and transient refusals stay retryable
|
||||
│ ├── join_ledger.py ← Child-result absorption: validates lineage + exact hashes; dispositions integrated/irrelevant/deferred; `CHILD_RESULT_STALE`; keeps peek_task/discard_child_result
|
||||
│ ├── delegate.py ← Delegation facade verbs `delegate_start` (with `retry_of`), `delegate_wait`, `delegate_cancel`, `delegate_answer`; the host pre-start rides the same wrapper and the shared `subagent_runtime.exact_start` (§6 Delegated subagents)
|
||||
│ ├── delegate_integration.py ← Delegated-patch integration: `_mutation_authority`, `_provision_snapshot`, retry-binding validation, `_capture_terminal_patch`; the skill-payload cluster (`_payload_mutation_authority`, `_write_payload_patch_artifacts`; reserved paths refuse the WHOLE apply as `blocked_reserved_paths`) and `integrate_payload_patch` (CAS, index-free git apply in NO-REPOSITORY mode under `GIT_CEILING_DIRECTORIES`, typed `INTEGRATE_APPLY_NO_OP`; a successful apply QUEUES the extension reconcile request via `request_extension_reconcile`) (§6 Delegated subagents)
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ Dashboard groups Logs, Evolution, Costs, Updates and Activity under the common t
|
|||
|
||||
Logs merges live WebSocket log frames with bounded REST backfill from the events, tools, progress and supervisor logs: chronological order, dedupe against live and reconnect overlap, bounded grouped task cards, raw record on demand, and shared category/severity/review presentation from `log_events.js`. A failed backfill names the unavailable history sources in a separate status row while live events continue. Clearing the visible panel does not delete the underlying logs.
|
||||
|
||||
Activity combines running and pending queue entries with the live activity census from the same refresh's `/api/state`: an identity already in the queue keeps its title, runtime and budget controls; a census-only direct turn shows its kind, phase, elapsed time and the shared task controls. A partial census supplies positive rows but cannot prove nothing is running. The view also shows the consciousness alarm clock (enabled, status line, autonomy level, next and last wake-up with its outcome, the rolling-24h allowance and how many of its tasks run) and scheduled work, with mechanical controls only where that surface is authoritative: typed cascade cancellation, start/stop for background consciousness, enable/disable/delete for owner-managed schedules. Each section reports its own failed read; a failed queue read neither erases the background/schedule sections nor claims an empty queue. A schedule reconciled from a skill manifest is read-only here — a direct edit would be overwritten by the skill lifecycle and falsely appear durable.
|
||||
Activity combines running and pending queue entries with the live activity census from the same refresh's `/api/state`: an identity already in the queue keeps its title, runtime and budget controls; a census-only direct turn shows its kind, phase, elapsed time and the shared task controls. A partial census supplies positive rows but cannot prove nothing is running. The view also shows the consciousness alarm clock (enabled, status line, autonomy level, next and last wake-up with its outcome, the rolling-24h allowance and how many of its tasks run) and scheduled work, with mechanical controls only where that surface is authoritative: typed cascade cancellation, start/stop for background consciousness, and enable/disable/delete/restore for schedules. Standing schedules are the list; retained rows — a fired one-shot, a suppressed skill row — collapse into one `History & suppressed` disclosure in the Skills card's detail style, which a refresh reopens as the owner left it, refocusing the control they were on. A consumed row offers no Enable (it cannot be re-armed); a suppressed one offers Restore, which re-evaluates the skill rather than enabling it. Every button, Delete included, names its action and reason to `POST /api/schedules/{id}/action` — the agent tool's audited seam, not a full-record round trip from a read that may be stale — and one helper reads the one reply: nothing changed, changed with its audit outcome lost, or changed while a task it already admitted still runs (or cannot be seen from here). Each section reports its own failed read; a failed queue read neither erases the background/schedule sections nor claims an empty queue.
|
||||
|
||||
Costs is a projection of the physical-attempt ledger distinguishing confirmed, reserved, unresolved upper-bound, unknown/unmetered usage, open rows and finality; unavailable data renders as unavailable, not `$0`. Breakdowns by model, key, model category and task category are views over the same ledger. Total budget can hot-apply; the per-task value is a hard cost cap over the whole root tree of the next task, not an own-task soft warning (§7 Default settings), and raising a cap does not resume work that already finalized or paused.
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ Every `/api/files/*` operation resolves its requested path and refuses the opera
|
|||
| POST | `/api/decisions` | `gateway.tasks.api_decision_answer` |
|
||||
| GET | `/api/schedules` | `gateway.schedules.api_schedules_list` |
|
||||
| POST | `/api/schedules` | `gateway.schedules.api_schedules_upsert` |
|
||||
| POST | `/api/schedules/{schedule_id}/action` | `gateway.schedules.api_schedules_action` |
|
||||
| DELETE | `/api/schedules/{schedule_id}` | `gateway.schedules.api_schedules_delete` |
|
||||
| POST | `/api/command` | `gateway.control.api_command` |
|
||||
| POST | `/api/reset` | `gateway.control.api_reset` |
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ The run is also NAMED at admission and chat promotion, without a new model call:
|
|||
|
||||
`queue_snapshot.json` is an atomic recovery and diagnostic projection, not a second scheduler: pending and running rows, acceptance and root-budget fences, worker counts, assignable capacity, any pool-disabled reason. Startup restores a recent snapshot into an empty pending queue and never resurrects ordinary RUNNING work: it FENCES every surviving RUNNING row with a durable cancel intent (`reason='server_shutdown'`, ledgered as `terminalized_running`), which cancellation custody terminalizes a watchdog window later, expiring its open quiz and closing the paired owner wait; a PENDING child below it is marked `pending_parent_interrupted` and settled by the boot's `kill_workers`, so a closed window leaves neither ghost nor orphan. Only an owner-wait handoff with an acknowledged planned-restart transaction outlives snapshot age. Terminal tasks stay terminal, a task with an active cancel intent is left to custody, descendants of an accepted or sealed root finalize as cancelled, and malformed fence evidence fails closed. Assignment mirrors RUNNING into the durable task result for EVERY assigned task, not only a subagent: both orphan healers read the STORED status, and an unmirrored root is a ghost no snapshot-less boot can settle, so orphan reconciliation is a terminal writer for roots too, closing the same quiz and wait the task-done seam closes.
|
||||
|
||||
`supervisor/queue_schedules.py` owns the existing `state/scheduled_tasks.json` table and is its only writer. Every read-modify-write — the tick, the skill resync, the gateway upsert, `schedule_followup`'s cap-and-write, `manage_schedules` — enters through `schedule_transaction`, which takes BOTH locks itself, queue lock then the table's sidecar file lock. The order lives in the transaction, not in its callers: a caller-composed order is what inverted, when the follow-up tool's one-transaction cap-read-and-write reached for the queue lock inside that hold against the tick's queue-then-table. It is reentrant PER TABLE (keyed by the resolved lock path), so a nested call on another drive root still takes that root's lock rather than riding a depth counter. `load_schedule_store` is the strict read every writer and owner surface uses: only an ABSENT path is an empty table it may create, while a present non-regular file, bytes that do not parse and a row that is not an object each raise `ScheduleStoreUnreadable` naming which it was. A write is refused on it — the next atomic write would replace real rows with an empty document or drop the rows it could not parse — and a READ answers unavailable, because "no schedules" is a claim while an unparseable table means the state is unknown.
|
||||
|
||||
A mutation announces its INTENT to `logs/events.jsonl` before changing anything and its OUTCOME after, as bounded `schedule_mutation` facts sharing one `operation_id`; the task template never enters the event. A failed intent write refuses the mutation; a failed outcome write is disclosed as `changed_audit_incomplete`, never rolled back (an undo would be a second unaudited mutation). `manage_schedules` and `POST /api/schedules/{id}/action` are the same seam: the caller NAMES the action and gives a reason, and nothing infers a command from that text. Restore is owner/root authority; Observe's argument narrowing does not redefine it. Lifecycle actions govern FUTURE dispatch only and report `running_or_queued` rather than implying a stop — `null` when the answering process cannot see the live queue, because PENDING/RUNNING are the supervisor process's own dicts and a worker-side answer of `false` would claim nothing is in flight; a worker reads the durable queue snapshot, rewritten on exactly those transitions (a snapshot older than 300 s also answers unknown, idle periods included), and the tick treats unknown as still-in-flight. A skill row is retained as a suppressed record (keyed by schedule id, so it survives a manifest edit) that resync may not re-arm; restore re-evaluates the skill's presence, manifest declaration, `supervised_task` permission and readiness instead of enabling it, reporting `restored_not_ready` with the blocker when it cannot; an ABSENT skill or manifest entry keeps the suppression and refuses typed (`manifest_absent`, `changed=false`) — a marker lifted with nothing to restore into is forgotten by the next resync (`supervisor/schedule_lifecycle.py`); a one-shot with `completed_at` is consumed history only a fresh `run_at` replaces, and a timezone-only edit re-reads the same instant rather than clearing that receipt. A skill row's `enabled` is reconciled from readiness, so the upsert REFUSES an edit that would flip it and names the lifecycle action instead — the owner's durable statement is the suppression marker, not a flag the next resync overwrites. Retention stays the unified GC.
|
||||
|
||||
At actual execution start, `agent._persist_running_record` mirrors a split root's running timestamp and execution-drive address into its canonical result through the existing terminal-preserving writer.
|
||||
|
||||
Pooled completion separates a finished file-save attempt from publishable terminal truth: the worker prepares its terminal task files (`headless.prepare_terminal_task_files`) before its buffered `task_done`, and `events_task_done` publishes only when `headless.terminal_task_files_ready` confirms CURRENT — on a split drive the child-bound copyback, with no workspace artifact finalization pending. Legacy or faulted completions go through `enqueue_terminal_file_recovery` (`worker_health` prepares and recovers, `task_reaper` runs the queues): unreadable or missing CURRENT keeps RUNNING ownership and retries on the health cadence — never a false Done, never replayed model work — and only CONFIRMED absence of a terminal source reaches the lifecycle-fault owner, which marks execution `infra_failed` while an early sticky completed status, the authored answer, review and cost survive.
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ A routing/promote decision turn receives host-built ground truth — each projec
|
|||
|
||||
Outcome classification keeps policy refusal separate from execution failure, so an expected authority boundary never becomes the task's headline failure: `user_files_path_blocked`, `cwd_blocked` and `artifact_output_undeclared` are typed non-failure surfaces, while a declared output that cannot be registered is the genuine `artifact_output_error`. The identifier register (`tool_result._EXACT_IDENTIFIER_CODES`) applies the same rule: a typed refusal is recorded as a refusal, never as `ok`. It covers the whole routing family (`STEER_REJECTED`/`STEER_UNCONFIRMED`, `PROMOTE_REJECTED`/`PROMOTE_UNCONFIRMED`, `ROUTE_REJECTED`/`ROUTE_UNCONFIRMED`, `ROUTING_UNCONFIRMED`, `NEEDS_MANUAL_TARGET`), so a promote or route that scheduled nothing is never read as a SUCCESSFUL call; a `_REJECTED` result carries its `(reason: detail)`, and the refusal stays visible to the agent (`is_error`) without degrading the execution axis — the host refused, the agent did not fail. File discovery distinguishes a confinement refusal (an error), a failed listing (a failure) and a path holding nothing — a MISS (`LIST_FILES_NOT_FOUND`, a warning beside `DATA_NOT_YET_CREATED`) that leaves the rest of the outcome uncoloured, because the recovery scan credits only a later success on the same target. The delegate tools (`delegate_start`, `delegate_wait`, `delegate_cancel`, `delegate_answer`) reach the same register from the producer side: `delegate_shared._fail` writes the `ok: false` + `host_code` envelope BESIDE the domain payload (whose `reason` keeps its own vocabulary), so every substrate refusal (daemon, ownership, containment, cancel, subscription window) is RECORDED as a refusal, never counted as a successful call — mapped to `TOOL_REPORTED_FAILURE` (never degrading), a malformed call to `TOOL_ARG_ERROR` (degrades, feeds reflection), never to a timeout or a generic tool error — and a terminal run in state `failed` or `cancelled` is a SUCCESSFUL observation of an unsuccessful leaf.
|
||||
|
||||
Tool API v2 exposes neutral canonical names directly (`read_file`, `list_files`, `search_code`, `write_file`, `edit_text`, `edit_batch`, `apply_patch`, `run_command`, `run_script`, `verify_and_record`, service tools, `commit_reviewed`, `vcs_*`, `schedule_subagent`, `schedule_followup`, `wait_task`, `wait_tasks`); legacy public names are neither exposed nor translated. `search_code` uses one basename-glob selector, including comma brace alternatives, before ripgrep or the Python fallback reads allowed files; selected-file count and content matches remain separate facts. The file tools share a path-based public ABI; payload-borne paths (`edit_batch` entries, `apply_patch` targets) miss the dispatch seam that rewrites a `path` ARG, so both ends canonicalize through `tool_access.canonical_repo_relative_path` — one normalization contract keeps a guard from judging `repo/BIBLE.md` while the write lands on `BIBLE.md` — and `_ROOT_ARG_REPO_WRITE_TOOLS` is the single set every repo-write fence keys on. `verify_and_record` (`tools/verify.py`) runs the declared check through the SAME pre-execution guards as the process tools — a post-execution check cannot gate a receipt already written — and appends a host-attested receipt to `<drive_root>/task_results/artifacts/<task_id>/verification_receipts.jsonl` (`expected_match`: substring by default, `exact`, `exact_line`, `json_equals`, `bytes_equal`); it reads only public task info, reports an owner-settings change as a typed note and never auto-reverts it (a revert cannot prove causation), and `delegation_zero_run` writes only `incomplete`/`unknown`, after the custody scan proves no open run.
|
||||
Tool API v2 exposes neutral canonical names directly (`read_file`, `list_files`, `search_code`, `write_file`, `edit_text`, `edit_batch`, `apply_patch`, `run_command`, `run_script`, `verify_and_record`, service tools, `commit_reviewed`, `vcs_*`, `schedule_subagent`, `manage_schedules`, `schedule_followup`, `wait_task`, `wait_tasks`); legacy public names are neither exposed nor translated. `search_code` uses one basename-glob selector, including comma brace alternatives, before ripgrep or the Python fallback reads allowed files; selected-file count and content matches remain separate facts. The file tools share a path-based public ABI; payload-borne paths (`edit_batch` entries, `apply_patch` targets) miss the dispatch seam that rewrites a `path` ARG, so both ends canonicalize through `tool_access.canonical_repo_relative_path` — one normalization contract keeps a guard from judging `repo/BIBLE.md` while the write lands on `BIBLE.md` — and `_ROOT_ARG_REPO_WRITE_TOOLS` is the single set every repo-write fence keys on. `verify_and_record` (`tools/verify.py`) runs the declared check through the SAME pre-execution guards as the process tools — a post-execution check cannot gate a receipt already written — and appends a host-attested receipt to `<drive_root>/task_results/artifacts/<task_id>/verification_receipts.jsonl` (`expected_match`: substring by default, `exact`, `exact_line`, `json_equals`, `bytes_equal`); it reads only public task info, reports an owner-settings change as a typed note and never auto-reverts it (a revert cannot prove causation), and `delegation_zero_run` writes only `incomplete`/`unknown`, after the custody scan proves no open run.
|
||||
|
||||
#### Resource roots and physical file identity
|
||||
|
||||
|
|
@ -152,7 +152,9 @@ Background Consciousness is Ouroboros taking a turn in its own Main chat when no
|
|||
|
||||
The rendered wake message puts the fresh cause and settled facts before the bounded outstanding-card view. Cards in `open` and `expired_terminal` remain answerable by the shared late-answer contract, but their state, age and bounded question preview are explicit; they are not presented as fresh events merely because they remain in the durable store. The `consciousness_last_wake_at` state key preserves the factual boundary used by the next wake after restart; the in-memory pending reason remains a best-effort trigger label and may honestly fall back to heartbeat when a process ended before launch.
|
||||
|
||||
Three facts distinguish a wake-up from an owner's turn, and all three ride its metadata. Its ORIGIN is `metadata.initiator == "consciousness"`, inherited by everything the wake starts: it labels every surface and makes the wake speak to other tasks as a task (`[Message from independent task <id>]`), never as the owner. Its AUTHORITY is the owner's autonomy level (`metadata.consciousness_autonomy`, owned by `consciousness_authority.py`): Observe thinks, keeps memory and knowledge and writes to the owner but starts nothing; Act (the default) is everything the runtime mode allows except editing Ouroboros's own code and prompts, evolution, restart and settings; Full adds evolution. A level has exactly two consequences — an exception list in `task_contract.disabled_tools` and a per-task `runtime_mode_cap` of `light` below Full — and for a consciousness-origin task that list binds at DISPATCH ONLY, so the wake's tool schemas and cached prompt prefix stay byte-identical to an owner turn's while a withheld call is refused when it is made: the typed dispatch refusal is the mechanism, and the wake message names the level. The stricter of install mode and cap binds at the repo-mutation, protected-path, `start_service` and shell-write gates, at the acting-child write surface and subagent patch integration, and in a started task's Runtime block, so the owner's level holds even on a Cyber Pro install; a capped tree is refused a self_worktree child and a system-repo patch integration in every install mode, the mutative-subagent toggle included. Its MONEY is one rolling 24-hour allowance covering the wake-ups and every root tree they started, read off the usage ledger (`consciousness_allowance.py`) by the alarm before it wakes and by the ONE admission door (`supervisor/queue.py::enqueue_task`) before any work it asks for starts — no separate "consciousness task" type, only the existing tools through the existing door; a Presence cycle a wake starts (Act and above) is an independent root outside this allowance. What is left of the allowance (capped by the owner's per-task cap) is the wake tree's graceful ceiling (`root_cost_ceiling_usd`, inherited by its members): the in-task stop lands a planning margin before it while the ledger fence stays at the owner's per-task cap, so a wake never dies before its first call; a remainder at or below that margin counts as exhausted.
|
||||
Observe retains a narrow positive path. `consciousness_authority.OBSERVE_ARGUMENT_NARROWED` is the set of mutating-table names Observe still SEES, because each has a genuine read-only or own-work use that withholding the name would destroy along with the mutation: `schedule_subagent` and `delegate_start` for read-only research, `write_file`/`edit_text`/`journal_write`/`workpad_write` for its own notes, and `cancel_task` for the children it started. What such a name may be ASKED to do is decided by one predicate, `observe_argument_refusal`, which the dispatch guard consults directly — no fail-open wrapper, because a policy that cannot be consulted must not read as permission. It refuses a write root outside `task_drive`/`artifact_store`, a mutative `write_surface`/`may_mutate`, and a delegated session asked for `workspace_write` or a payload selector; an OMITTED access is the read-only shape (`_derive_authority` pins it) and stays allowed. `cancel_task` is the one name whose narrowing needs durable lineage rather than arguments, so it rides the tool's EXISTING own-child custody check (`join_ledger._cancel_task`), the same seam a delegated task meets: Observe may stop the children it started, never the owner's running work. A read-only child may delegate further read-only descendants under the inherited depth cap but never passes a mutating delegation budget onward (`delegation_may_mutate`). `manage_schedules` (`tools/followup.py`, beside `schedule_followup` — one module for the two tools over one table) is argument-aware rather than name-wide: the root turn may list and change, a delegated task may only list, and a Presence conversation gets neither — Presence authority is not expanded here.
|
||||
|
||||
Three facts distinguish a wake-up from an owner's turn, and all three ride its metadata. Its ORIGIN is `metadata.initiator == "consciousness"`, inherited by everything the wake starts: it labels every surface and makes the wake speak to other tasks as a task (`[Message from independent task <id>]`), never as the owner. Its AUTHORITY is the owner's autonomy level (`metadata.consciousness_autonomy`, owned by `consciousness_authority.py`): Observe researches, keeps internal memory/project notes, stops the children it started, inspects and governs existing schedules through the audited narrow surface, and may delegate read-only research, while the dispatch guard refuses owner-file, source, skill/settings and publication writes; Act (the default) is everything the runtime mode allows except editing Ouroboros's own code and prompts, evolution, restart and settings; Full adds evolution. A level has two structural consequences — an exception list in `task_contract.disabled_tools` and a per-task `runtime_mode_cap` of `light` below Full — plus, at Observe, the argument-level narrowing of the names it keeps (`observe_argument_refusal`); for a consciousness-origin task that list binds at DISPATCH ONLY, so the wake's tool schemas and cached prompt prefix stay byte-identical to an owner turn's while a withheld call is refused when it is made: the typed dispatch refusal is the mechanism, and the wake message names the level. The stricter of install mode and cap binds at the repo-mutation, protected-path, `start_service` and shell-write gates, at the acting-child write surface and subagent patch integration, and in a started task's Runtime block, so the owner's level holds even on a Cyber Pro install; a capped tree is refused a self_worktree child and a system-repo patch integration in every install mode, the mutative-subagent toggle included. Its MONEY is one rolling 24-hour allowance covering the wake-ups and every root tree they started, read off the usage ledger (`consciousness_allowance.py`) by the alarm before it wakes and by the ONE admission door (`supervisor/queue.py::enqueue_task`) before any work it asks for starts — no separate "consciousness task" type, only the existing tools through the existing door; a Presence cycle a wake starts (Act and above) is an independent root outside this allowance. What is left of the allowance (capped by the owner's per-task cap) is the wake tree's graceful ceiling (`root_cost_ceiling_usd`, inherited by its members): the in-task stop lands a planning margin before it while the ledger fence stays at the owner's per-task cap, so a wake never dies before its first call; a remainder at or below that margin counts as exhausted.
|
||||
|
||||
An active campaign owns an explicit objective, campaign id, transaction and task claim (`supervisor/evolution_lifecycle.py`); `evolution_mode_enabled` is only its scheduling projection. Dispatch, review, commit, publication and restart revalidate that exact authority: a restored row without a live uncommitted claim is cancelled, a reviewed commit binds to the claim by exact SHA before publication, and if authority changes after commit the commit moves to a private inspection ref and any attempt-created tag leaves the normal namespace, without resetting concurrent index/worktree edits; exact terminal replay resumes only incomplete effects, never double-counting. The restart-verify claim (`state/pending_restart_verify.json`; one writer helper serves the supervisor's evolution restart and the agent's `restart` tool) is written whether or not the supervisor restarts automatically — `OUROBOROS_EVOLUTION_AUTO_RESTART` off skips only the restart — so the owner's manual restart verifies the cycle by exact claim rather than by the weaker markerless reconcile.
|
||||
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ A registry of `config.SETTINGS_DEFAULTS` (exact defaults canonical in `settings_
|
|||
| OUROBOROS_BUNDLE_DIR | (unset) | Env-only: launcher-owned bundle root propagated to embedded children for Node/ripgrep discovery |
|
||||
| OUROBOROS_BG_WAKEUP_MIN | 900 | Lower bound (s) of the model-chosen wake interval (`set_next_wakeup`), clamped into [min, max] and re-read at each alarm, so a change needs no restart |
|
||||
| OUROBOROS_BG_WAKEUP_MAX | 14400 | Upper bound (s), never below the minimum; with no chosen interval the alarm uses `runtime_limits.WAKE_DEFAULT_SEC` (3300 s, just under the 1 h cache TTL, keeping the shared prefix warm) — a constant rather than a third knob |
|
||||
| OUROBOROS_CONSCIOUSNESS_AUTONOMY | act | Closed enum for a wake (else the default): `observe` thinks, remembers and writes but starts nothing; `act` adds all the runtime mode allows except own code/prompts, evolution, restart, settings; `full` includes evolution |
|
||||
| OUROBOROS_CONSCIOUSNESS_AUTONOMY | act | Closed enum for a wake (else the default): `observe` researches and performs internal work, can start and stop its own read-only children and use owner-governed schedule controls, and cannot write shell/user files/source/skills/settings or publish; `act` adds all the runtime mode allows except own code/prompts, evolution, restart, settings; `full` includes evolution |
|
||||
| OUROBOROS_CONSCIOUSNESS_DAILY_USD | 20.0 | Rolling-24h ceiling over consciousness wakes and the tasks they start; exhausted blocks NEW wakes and tasks until spend leaves the window; `0` is a real choice |
|
||||
| OUROBOROS_CONSCIOUSNESS_MAX_TASKS | 2 | How many consciousness-started tasks may run at once; `0` = it never starts tasks |
|
||||
| OUROBOROS_POST_TASK_EVOLUTION | false | Post-task evolution promotion toggle; self-enablement is blocked at the shell/browser/settings/data-write guards, choosing an objective runs on Main because it is a high-leverage decision, execution stays behind review and owner gates |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`).
|
||||
|
||||
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 591-680; UTF-8 SHA-256 `c69589bdddb4901b5e9928d1e712d340fda5bc1e01cd6ec1b1a9425da4ec48d9`.
|
||||
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 592-681; UTF-8 SHA-256 `1b890dd63984b09a0bfa9aea44627f6ef92626514040fdd30e9e20b75221b73b`.
|
||||
|
||||
- entries: **79** (code-ref: 72, repo-dir: 6, repo-path: 1)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
AST-derived inventory of compatibility facades, regenerated by `python scripts/regenerate_inventories.py`. Do not edit. A facade row is any runtime module whose top-level `from <population module> import ...` statements carry the `noqa: F401` re-export marker — the codebase's declared "this binding exists for its binding, not for this module's own use" convention (reference FACADE_CONSUMERS method). Leaf domains come from `ouroboros/domains.toml`; a leaf outside the facade's domain is marked ✗ (that edge also appears in the manifest's pinned direction matrix). `tests/test_generated_inventories.py` pins byte-identity, so any re-export surface change must regenerate this file.
|
||||
|
||||
- facade modules: **59**; marked re-export bindings: **2294**; cross-domain facade→leaf pairs: **132**
|
||||
- facade modules: **59**; marked re-export bindings: **2308**; cross-domain facade→leaf pairs: **132**
|
||||
|
||||
| facade | domain | bindings | leaves |
|
||||
|---|---|---:|---|
|
||||
|
|
@ -14,7 +14,7 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/r
|
|||
| `ouroboros/delegate_custody.py` | D07 | 10 | `ouroboros/delegate_custody_reconcile.py` (9)<br>`ouroboros/delegate_evidence.py` (1) |
|
||||
| `ouroboros/extension_loader.py` | D14 | 99 | `ouroboros/contracts/plugin_api.py` (7 ✗D19)<br>`ouroboros/extension_child_catalog.py` (8)<br>`ouroboros/extension_companion.py` (3)<br>`ouroboros/extension_import_staging.py` (6)<br>`ouroboros/extension_isolated_deps.py` (4)<br>`ouroboros/extension_liveness.py` (8)<br>`ouroboros/extension_plugin_api.py` (6)<br>`ouroboros/extension_registry_state.py` (20)<br>`ouroboros/extension_surface_names.py` (12)<br>`ouroboros/extension_ui_validation.py` (5)<br>`ouroboros/gateway/host_service.py` (1 ✗D11)<br>`ouroboros/provider_models.py` (1 ✗D02)<br>`ouroboros/skill_loader.py` (13)<br>`ouroboros/skill_token.py` (1)<br>`ouroboros/tools/skill_exec.py` (1)<br>`ouroboros/utils.py` (3 ✗D18) |
|
||||
| `ouroboros/gateway/_helpers.py` | D11 | 2 | `ouroboros/jsonl_tail.py` (2 ✗D18) |
|
||||
| `ouroboros/gateway/contracts.py` | D11 | 3 | `ouroboros/gateway/decision_contracts.py` (2)<br>`ouroboros/gateway/history_contracts.py` (1) |
|
||||
| `ouroboros/gateway/contracts.py` | D11 | 7 | `ouroboros/gateway/decision_contracts.py` (2)<br>`ouroboros/gateway/history_contracts.py` (1)<br>`ouroboros/gateway/schedule_contracts.py` (4) |
|
||||
| `ouroboros/gateway/history.py` | D11 | 1 | `ouroboros/gateway/cost_breakdown.py` (1) |
|
||||
| `ouroboros/gateway/tasks.py` | D11 | 7 | `ouroboros/gateway/cost_breakdown.py` (1)<br>`ouroboros/gateway/task_decision.py` (1)<br>`ouroboros/gateway/task_events.py` (4)<br>`ouroboros/gateway/task_hurry.py` (1) |
|
||||
| `ouroboros/headless.py` | D17 | 45 | `ouroboros/contracts/task_constraint.py` (1 ✗D19)<br>`ouroboros/headless_status.py` (11)<br>`ouroboros/workspace_patch_capture.py` (20)<br>`ouroboros/workspace_patch_rules.py` (13) |
|
||||
|
|
@ -59,7 +59,7 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/r
|
|||
| `server.py` | D11 | 51 | `ouroboros/server_liveness.py` (4)<br>`ouroboros/server_maintenance.py` (14)<br>`ouroboros/server_owner_routing.py` (5)<br>`ouroboros/server_process.py` (6)<br>`ouroboros/server_restart.py` (8)<br>`ouroboros/server_routing_context.py` (14) |
|
||||
| `supervisor/events.py` | D08 | 94 | `ouroboros/config.py` (1 ✗D12)<br>`ouroboros/contracts/task_constraint.py` (1 ✗D19)<br>`ouroboros/cost_projection.py` (3 ✗D16)<br>`ouroboros/subagent_messages.py` (1 ✗D07)<br>`ouroboros/task_results.py` (2 ✗D17)<br>`ouroboros/tool_capabilities.py` (2 ✗D04)<br>`ouroboros/utils.py` (4 ✗D18)<br>`supervisor/cognitive_operations.py` (2)<br>`supervisor/events_budget.py` (4)<br>`supervisor/events_chat_delivery.py` (9)<br>`supervisor/events_coop_checkpoint.py` (6)<br>`supervisor/events_evolution_done.py` (1)<br>`supervisor/events_project_routing.py` (10)<br>`supervisor/events_runtime_controls.py` (6)<br>`supervisor/events_schedule_task.py` (4)<br>`supervisor/events_subagent_admission.py` (15)<br>`supervisor/events_task_done.py` (8)<br>`supervisor/events_worker_reports.py` (7)<br>`supervisor/log_addressing.py` (4)<br>`supervisor/queue_transitions.py` (1)<br>`supervisor/steering.py` (2 ✗D09)<br>`supervisor/task_dispatch.py` (1) |
|
||||
| `supervisor/git_ops.py` | D10 | 37 | `ouroboros/utils.py` (1 ✗D18)<br>`supervisor/git_ops_remotes.py` (4)<br>`supervisor/git_ops_rescue.py` (8)<br>`supervisor/git_ops_reset.py` (10)<br>`supervisor/git_ops_updates.py` (8)<br>`supervisor/state.py` (4 ✗D08)<br>`supervisor/update_recovery.py` (2) |
|
||||
| `supervisor/queue.py` | D08 | 102 | `ouroboros/config.py` (6 ✗D12)<br>`ouroboros/contracts/task_contract.py` (3 ✗D19)<br>`ouroboros/schedule_contract.py` (2)<br>`ouroboros/skill_loader.py` (1 ✗D14)<br>`ouroboros/utils.py` (3 ✗D18)<br>`supervisor/evolution_lifecycle.py` (12 ✗D15)<br>`supervisor/message_bus.py` (3)<br>`supervisor/queue_schedules.py` (12)<br>`supervisor/queue_snapshot.py` (5)<br>`supervisor/queue_timeouts.py` (8)<br>`supervisor/queue_transitions.py` (3)<br>`supervisor/schedule_time.py` (7)<br>`supervisor/state.py` (6)<br>`supervisor/task_admission.py` (9)<br>`supervisor/task_lifecycle.py` (17 ✗D09)<br>`supervisor/task_reaper.py` (5 ✗D09) |
|
||||
| `supervisor/queue.py` | D08 | 112 | `ouroboros/config.py` (6 ✗D12)<br>`ouroboros/contracts/task_contract.py` (3 ✗D19)<br>`ouroboros/schedule_contract.py` (2)<br>`ouroboros/skill_loader.py` (1 ✗D14)<br>`ouroboros/utils.py` (3 ✗D18)<br>`supervisor/evolution_lifecycle.py` (12 ✗D15)<br>`supervisor/message_bus.py` (3)<br>`supervisor/queue_schedules.py` (19)<br>`supervisor/queue_snapshot.py` (5)<br>`supervisor/queue_timeouts.py` (8)<br>`supervisor/queue_transitions.py` (3)<br>`supervisor/schedule_lifecycle.py` (3)<br>`supervisor/schedule_time.py` (7)<br>`supervisor/state.py` (6)<br>`supervisor/task_admission.py` (9)<br>`supervisor/task_lifecycle.py` (17 ✗D09)<br>`supervisor/task_reaper.py` (5 ✗D09) |
|
||||
| `supervisor/state.py` | D08 | 4 | `ouroboros/utils.py` (4 ✗D18) |
|
||||
| `supervisor/task_lifecycle.py` | D09 | 32 | `supervisor/cancel_publication.py` (20)<br>`supervisor/queue_transitions.py` (11 ✗D08)<br>`supervisor/task_admission.py` (1 ✗D08) |
|
||||
| `supervisor/task_model_wait.py` | D08 | 1 | `ouroboros/model_wait.py` (1 ✗D09) |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""What a consciousness wake-up may do, and how that authority follows its work.
|
||||
|
||||
Three autonomy levels (owner decision В10', PLAN 5.4): ``observe`` reads and
|
||||
talks, ``act`` (the default) starts and steers work but may not evolve,
|
||||
Three autonomy levels (owner decision В10', PLAN 5.4): ``observe`` researches,
|
||||
keeps internal notes and may manage existing schedules while remaining
|
||||
resource-narrowed, ``act`` (the default) starts and steers work but may not evolve,
|
||||
restart, change settings or touch its own alarm, ``full`` is an owner turn in
|
||||
everything the install's runtime mode allows. A wake carries the level it
|
||||
started under as ``metadata.consciousness_autonomy`` beside its origin label
|
||||
|
|
@ -10,7 +11,7 @@ wake starts (a promoted root, a follow-up, a subagent, an evolution cycle)
|
|||
through ``consciousness_origin_metadata``, so a second generation never
|
||||
falls out of the level or the allowance.
|
||||
|
||||
One helper derives the level's two consequences at task build
|
||||
One helper derives the level's two structural consequences at task build
|
||||
(``apply_consciousness_authority``): the contract's ``disabled_tools`` (an
|
||||
exception list, never an allowlist — a new READ tool is available to Observe
|
||||
by default) and ``runtime_mode_cap`` (Act/Observe run under ``light`` even on
|
||||
|
|
@ -42,13 +43,33 @@ ACT_DISABLED: tuple[str, ...] = (
|
|||
"toggle_evolution", "request_restart", "set_tool_timeout",
|
||||
"toggle_consciousness", "configure_presence",
|
||||
)
|
||||
# Names Observe keeps even though they appear in the mutation table above,
|
||||
# because each has a real read-only or own-work path that withholding the NAME
|
||||
# would destroy along with the mutation. What the name may then be asked to DO
|
||||
# is narrowed by ``observe_argument_refusal`` at dispatch, on the arguments.
|
||||
OBSERVE_ARGUMENT_NARROWED: frozenset[str] = frozenset({
|
||||
# Research children and delegated research sessions: read-only shapes only.
|
||||
"schedule_subagent", "delegate_start",
|
||||
# Its own task artifacts and project workpad/journal — not owner files,
|
||||
# not source, not the runtime's own controls.
|
||||
"write_file", "edit_text", "journal_write", "workpad_write",
|
||||
# Its own children: starting one is narrowed above, and a mind that may start
|
||||
# a child must be able to stop the one it started (the nanny's other half).
|
||||
"cancel_task",
|
||||
})
|
||||
# Observe withholds every verb that starts work or changes the world beyond
|
||||
# this mind's own memory (the table beside ``ROUTING_VERBS``) plus publication.
|
||||
OBSERVE_DISABLED: tuple[str, ...] = ACT_DISABLED + tuple(sorted(
|
||||
(OBSERVE_WORLD_MUTATION_TOOLS | FOREGROUND_MUTATIVE_TOOLS) - set(ACT_DISABLED)
|
||||
(OBSERVE_WORLD_MUTATION_TOOLS | FOREGROUND_MUTATIVE_TOOLS)
|
||||
- set(ACT_DISABLED)
|
||||
- OBSERVE_ARGUMENT_NARROWED
|
||||
))
|
||||
# The nanny verb of a running campaign is never withheld (owner decision В12 A).
|
||||
NEVER_DISABLED: frozenset[str] = frozenset({"steer_task"})
|
||||
# The only file roots Observe may write: its own task drive and artifact store.
|
||||
# Both are already confined by the file tools; this is what it is ALLOWED to
|
||||
# choose, not a new path authority.
|
||||
OBSERVE_WRITE_ROOTS: frozenset[str] = frozenset({"task_drive", "artifact_store"})
|
||||
|
||||
|
||||
def normalize_level(value: Any) -> str:
|
||||
|
|
@ -94,7 +115,7 @@ def consciousness_origin_metadata(parent_metadata: Any) -> Dict[str, Any]:
|
|||
|
||||
|
||||
def apply_consciousness_authority(task: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Derive the level's two consequences onto a consciousness-origin task's metadata.
|
||||
"""Derive the level's two structural consequences onto a consciousness-origin task's metadata.
|
||||
|
||||
Called BEFORE the contract is attached (``attach_task_contract`` reads
|
||||
``metadata.disabled_tools`` into the contract when the contract has none).
|
||||
|
|
@ -151,3 +172,56 @@ def effective_runtime_mode(install_mode: str, task_metadata: Any) -> str:
|
|||
if cap in VALID_RUNTIME_MODES and runtime_mode_at_least(str(install_mode), cap):
|
||||
return cap
|
||||
return str(install_mode)
|
||||
|
||||
|
||||
def is_observe_origin(task_metadata: Any) -> bool:
|
||||
"""Whether this task runs under a consciousness wake at the Observe level.
|
||||
|
||||
The one predicate every dispatch-time narrowing asks, so "what Observe is"
|
||||
is decided here rather than re-derived, slightly differently, per call site.
|
||||
"""
|
||||
metadata = task_metadata if isinstance(task_metadata, Mapping) else {}
|
||||
return (is_consciousness_origin(metadata)
|
||||
and normalize_level(metadata.get("consciousness_autonomy")) == LEVEL_OBSERVE)
|
||||
|
||||
|
||||
def observe_argument_refusal(task_metadata: Any, name: str, args: Mapping[str, Any]) -> str:
|
||||
"""The refusal text for an Observe call whose ARGUMENTS ask for mutation; '' to allow.
|
||||
|
||||
Observe keeps the names in ``OBSERVE_ARGUMENT_NARROWED`` visible because each
|
||||
has a genuine read-only or own-work use. This is the seam that stops the
|
||||
same name from carrying back the authority the level removed: a write root
|
||||
outside its own drive, a mutating child, a writing delegated session. The
|
||||
positive path is deliberately left open — a refusal here is about the
|
||||
arguments, never about the tool.
|
||||
"""
|
||||
if not is_observe_origin(task_metadata):
|
||||
return ""
|
||||
tool = str(name or "").strip()
|
||||
values = args if isinstance(args, Mapping) else {}
|
||||
if tool in {"write_file", "edit_text"}:
|
||||
# The file tools already confine a resolved path inside the chosen base;
|
||||
# this decides which base Observe may choose at all.
|
||||
root = str(values.get("root") or "active_workspace").strip()
|
||||
if root not in OBSERVE_WRITE_ROOTS:
|
||||
return (
|
||||
"⚠️ RESOURCE_CONSTRAINT_BLOCKED: Observe may write its own task_drive or "
|
||||
"artifact_store only; use the cognitive-memory or project-note tools for "
|
||||
"everything else it wants to keep."
|
||||
)
|
||||
elif tool == "schedule_subagent":
|
||||
surface = str(values.get("write_surface") or "").strip().lower()
|
||||
if surface not in ("", "read_only") or bool(values.get("may_mutate")):
|
||||
return (
|
||||
"⚠️ RESOURCE_CONSTRAINT_BLOCKED: Observe may schedule read-only research "
|
||||
"children only; omit write_surface and may_mutate."
|
||||
)
|
||||
elif tool == "delegate_start":
|
||||
# An omitted access is the READ-ONLY shape here (``_derive_authority``
|
||||
# pins it), so only an explicit write request is refused.
|
||||
if str(values.get("root") or "").strip() or str(values.get("access") or "").strip().lower() == "workspace_write":
|
||||
return (
|
||||
"⚠️ RESOURCE_CONSTRAINT_BLOCKED: Observe may start a delegated read-only "
|
||||
"research session only; omit root and leave access readonly."
|
||||
)
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ CHAT_TAIL_BYTES = 512_000
|
|||
PLACEHOLDERS = ("reason", "last_wake_ago", "events", "level", "level_line", "withheld_tools",
|
||||
"spent_usd", "daily_usd", "running", "max_tasks", "interval")
|
||||
LEVEL_LINES = {
|
||||
"observe": "think, keep memory/knowledge, write to your human; no tasks, no changes in the world",
|
||||
"observe": "research and internal work, memory, project notes, your own children and schedules, owner delivery; no shell, user-file, source, skill/settings or publication changes",
|
||||
"act": "everything your runtime mode allows except editing your own code/prompts, evolution, restart and settings",
|
||||
"full": "everything your runtime mode allows, including evolution",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -583,6 +583,7 @@ D20 = "Presence"
|
|||
"supervisor/queue_timeouts.py" = "D08"
|
||||
"supervisor/task_model_wait.py" = "D08"
|
||||
"supervisor/queue_schedules.py" = "D08"
|
||||
"supervisor/schedule_lifecycle.py" = "D08"
|
||||
"supervisor/queue_transitions.py" = "D08"
|
||||
"supervisor/schedule_time.py" = "D08"
|
||||
"supervisor/state.py" = "D08"
|
||||
|
|
@ -618,6 +619,7 @@ D20 = "Presence"
|
|||
"ouroboros/gateway/task_decision.py" = "D11"
|
||||
"ouroboros/gateway/task_model_wait.py" = "D11"
|
||||
"ouroboros/gateway/decision_contracts.py" = "D11"
|
||||
"ouroboros/gateway/schedule_contracts.py" = "D11"
|
||||
"ouroboros/node_runtime.py" = "D18"
|
||||
"ouroboros/routing_wait.py" = "D17"
|
||||
"ouroboros/tools/owner_delivery.py" = "D05"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ from typing import Any, Dict, List, Optional
|
|||
from ouroboros.gateway.history_contracts import ChatHistoryResponse # noqa: F401 -- public re-export
|
||||
from ouroboros.gateway.widgets import ExtensionLiveSnapshot, WidgetTab, WidgetsResponse
|
||||
from ouroboros.gateway.decision_contracts import DecisionRequest, DecisionResponse # noqa: F401 -- public re-exports
|
||||
from ouroboros.gateway.schedule_contracts import ( # noqa: F401 -- public re-exports
|
||||
ScheduleActionResponse,
|
||||
ScheduledTasksResponse,
|
||||
ScheduleDeleteResponse,
|
||||
ScheduleUpsertResponse,
|
||||
)
|
||||
|
||||
try: # Python 3.11+
|
||||
from typing import Literal, NotRequired, Required, TypedDict # type: ignore[attr-defined]
|
||||
|
|
@ -926,20 +932,6 @@ class EvolutionDataResponse(TypedDict):
|
|||
cached: bool
|
||||
|
||||
|
||||
class ScheduledTasksResponse(TypedDict):
|
||||
schema_version: int
|
||||
tasks: list[Dict[str, Any]]
|
||||
|
||||
|
||||
class ScheduleUpsertResponse(TypedDict):
|
||||
ok: bool
|
||||
schedule: Dict[str, Any]
|
||||
|
||||
|
||||
class ScheduleDeleteResponse(TypedDict):
|
||||
ok: bool
|
||||
|
||||
|
||||
class UploadResponse(TypedDict):
|
||||
ok: bool
|
||||
filename: str
|
||||
|
|
@ -1557,6 +1549,7 @@ __all__ = [
|
|||
"ScheduledTasksResponse",
|
||||
"ScheduleUpsertResponse",
|
||||
"ScheduleDeleteResponse",
|
||||
"ScheduleActionResponse",
|
||||
"UploadResponse",
|
||||
"ExtensionsIndexResponse",
|
||||
"ExtensionLiveSnapshot",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ HTTP_ENDPOINTS: tuple[str, ...] = (
|
|||
"POST /api/decisions",
|
||||
"GET /api/schedules",
|
||||
"POST /api/schedules",
|
||||
"POST /api/schedules/{schedule_id}/action",
|
||||
"DELETE /api/schedules/{schedule_id}",
|
||||
"POST /api/command",
|
||||
"POST /api/reset",
|
||||
|
|
|
|||
|
|
@ -90,9 +90,7 @@ def collect_routes(
|
|||
api_provider_test,
|
||||
)
|
||||
from ouroboros.gateway.schedules import (
|
||||
api_schedules_delete,
|
||||
api_schedules_list,
|
||||
api_schedules_upsert,
|
||||
api_schedules_action, api_schedules_delete, api_schedules_list, api_schedules_upsert,
|
||||
)
|
||||
from ouroboros.gateway.control import (
|
||||
api_command,
|
||||
|
|
@ -247,6 +245,7 @@ def collect_routes(
|
|||
Route("/api/decisions", endpoint=api_decision_answer, methods=["POST"]),
|
||||
Route("/api/schedules", endpoint=api_schedules_list, methods=["GET"]),
|
||||
Route("/api/schedules", endpoint=api_schedules_upsert, methods=["POST"]),
|
||||
Route("/api/schedules/{schedule_id}/action", endpoint=api_schedules_action, methods=["POST"]),
|
||||
Route("/api/schedules/{schedule_id}", endpoint=api_schedules_delete, methods=["DELETE"]),
|
||||
Route("/api/command", endpoint=api_command, methods=["POST"]),
|
||||
Route("/api/reset", endpoint=api_reset, methods=["POST"]),
|
||||
|
|
|
|||
80
ouroboros/gateway/schedule_contracts.py
Normal file
80
ouroboros/gateway/schedule_contracts.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Typed schedule surface responses; the write seam's semantics live in the queue.
|
||||
|
||||
One home for what `/api/schedules` says back, because the three responses share
|
||||
one vocabulary: a row's lifecycle word, and what a write actually achieved.
|
||||
``supervisor/queue_schedules.py`` owns the behaviour these describe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from typing import TypedDict
|
||||
except ImportError: # pragma: no cover - Python 3.10 compatibility.
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class ScheduledTasksResponse(TypedDict):
|
||||
"""GET /api/schedules. Every row carries its lifecycle word in ``status``:
|
||||
``active``, ``disabled``, ``suppressed`` (a skill row the owner stopped,
|
||||
which the skill resync may not re-arm) or ``consumed`` (a one-shot that
|
||||
already fired). ``retained`` marks the two that are history rather than a
|
||||
standing schedule; only ``restorable`` can come back. An unreadable table
|
||||
is an error here, never an empty list — "no schedules" is a claim."""
|
||||
|
||||
schema_version: int
|
||||
tasks: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class ScheduleUpsertResponse(TypedDict):
|
||||
"""POST /api/schedules. ``schedule`` is the STORED row plus an ``audit``
|
||||
field (``recorded``/``incomplete``); ``ok`` follows it, so a change whose
|
||||
audit outcome record was lost does not read as a clean success."""
|
||||
|
||||
ok: bool
|
||||
schedule: Dict[str, Any]
|
||||
|
||||
|
||||
class ScheduleActionResponse(TypedDict, total=False):
|
||||
"""POST /api/schedules/{id}/action, and DELETE /api/schedules/{id}.
|
||||
|
||||
``changed`` is the durable fact; ``ok`` means the requested lifecycle state
|
||||
was achieved and both audit records landed. A ``restored_not_ready`` result
|
||||
can change suppression with a recorded audit while ``ok`` remains false.
|
||||
``status`` names what happened (``updated``, ``deleted``,
|
||||
``suppressed``, ``restored_not_ready``, ``consumed_not_rearmed``,
|
||||
``manifest_absent`` (restore over a skill schedule its manifest no longer
|
||||
declares: suppression KEPT, nothing changed), ``not_suppressed`` (restore
|
||||
over a skill row that carries no owner marker — disabled by readiness, it
|
||||
re-arms on resync; nothing changed), ``lock_timeout`` (the table
|
||||
lock was not acquired within its bound; nothing changed), ``store_unreadable``, ``audit_unavailable``, ``changed_audit_incomplete``),
|
||||
and ``operation_id`` pairs the intent and outcome events.
|
||||
``running_or_queued`` reports a task this schedule already admitted:
|
||||
lifecycle actions govern future dispatch and never stop a run in flight.
|
||||
It is ``null`` when the answering process cannot see the live queue — an
|
||||
unknown in-flight run is a different statement from no run at all.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
changed: bool
|
||||
status: str
|
||||
schedule_id: str
|
||||
operation_id: str
|
||||
running_or_queued: Optional[bool]
|
||||
audit: str
|
||||
detail: str
|
||||
schedule: Optional[Dict[str, Any]]
|
||||
allowed: List[str]
|
||||
|
||||
|
||||
class ScheduleDeleteResponse(TypedDict):
|
||||
"""DELETE /api/schedules/{id}, legacy name — the public shape it always had.
|
||||
|
||||
The endpoint now answers the richer ``ScheduleActionResponse``, of which this
|
||||
is the subset every previous caller read, so the legacy name stays exported
|
||||
beside the new one: dropping a published contract name is not an additive
|
||||
change, whatever the endpoint gained.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
|
|
@ -20,9 +20,15 @@ def _enabled_value(payload: dict) -> bool | str:
|
|||
|
||||
async def api_schedules_list(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
from supervisor.queue import list_scheduled_tasks
|
||||
from supervisor.queue import ScheduleStoreUnreadable, load_schedule_store, schedule_activity_projection
|
||||
|
||||
return JSONResponse(list_scheduled_tasks(request_drive_root(_request)))
|
||||
try:
|
||||
store = load_schedule_store(request_drive_root(_request))
|
||||
except ScheduleStoreUnreadable as exc:
|
||||
# "No schedules" is a claim; an unparseable table means the state is
|
||||
# UNKNOWN, and the Activity section says so instead of showing empty.
|
||||
return json_error(str(exc), 503)
|
||||
return JSONResponse(schedule_activity_projection(store))
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
||||
|
|
@ -77,28 +83,10 @@ async def api_schedules_upsert(request: Request) -> JSONResponse:
|
|||
enabled = _enabled_value(body)
|
||||
if isinstance(enabled, str):
|
||||
return json_error(enabled, 400)
|
||||
completed_at = ""
|
||||
if trigger.get("type") == "once":
|
||||
# Exactly-once vs re-enable: a one-shot that already fired (non-empty
|
||||
# completed_at) cannot be re-armed by flipping enabled back on — that
|
||||
# would silently re-run the consumed task. Re-arming requires a NEW
|
||||
# trigger.run_at, which clears the consumed receipt; a disable/edit that
|
||||
# keeps the same run_at carries the receipt forward so GC still sees it.
|
||||
from supervisor.queue import list_scheduled_tasks
|
||||
|
||||
wanted = str(body.get("id") or "").strip()
|
||||
existing = next(
|
||||
(item for item in list_scheduled_tasks(request_drive_root(request)).get("tasks") or []
|
||||
if isinstance(item, dict) and str(item.get("id") or "") == wanted), None)
|
||||
prev = (existing or {}).get("trigger")
|
||||
prev = prev if isinstance(prev, dict) else {}
|
||||
if (existing is not None and str(existing.get("completed_at") or "")
|
||||
and str(prev.get("run_at") or "") == trigger["run_at"]):
|
||||
if enabled:
|
||||
return json_error(
|
||||
"this one-shot schedule already fired; re-arming it requires a new "
|
||||
"trigger.run_at (a fresh run_at clears completed_at)", 400)
|
||||
completed_at = str(existing.get("completed_at"))
|
||||
# Only the fields the owner authors here. Provenance, run history, the
|
||||
# consumed receipt and a skill row's suppression marker belong to the
|
||||
# runtime and are merged from the CURRENT row inside the write lock —
|
||||
# reading them here would merge a snapshot that is already stale.
|
||||
record = {
|
||||
"id": str(body.get("id") or "").strip(),
|
||||
"name": str(body.get("name") or body.get("id") or "scheduled-task").strip(),
|
||||
|
|
@ -108,11 +96,50 @@ async def api_schedules_upsert(request: Request) -> JSONResponse:
|
|||
"trigger": trigger,
|
||||
"task": task,
|
||||
}
|
||||
if completed_at:
|
||||
record["completed_at"] = completed_at
|
||||
from supervisor.queue import upsert_scheduled_task
|
||||
from supervisor.queue import ScheduleRefused, ScheduleStoreUnreadable, upsert_scheduled_task
|
||||
|
||||
return JSONResponse({"ok": True, "schedule": upsert_scheduled_task(record, drive_root=request_drive_root(request))})
|
||||
try:
|
||||
stored = upsert_scheduled_task(
|
||||
record, drive_root=request_drive_root(request), actor="owner:gateway",
|
||||
reason=str(body.get("reason") or "").strip())
|
||||
except ScheduleRefused as refusal:
|
||||
# 409 when the write could not be made SAFELY (its audit is down);
|
||||
# 400 when the request itself asks for something this door cannot do.
|
||||
return json_error(refusal.message, 409 if refusal.status == "audit_unavailable" else 400)
|
||||
except ScheduleStoreUnreadable as exc:
|
||||
return json_error(str(exc), 409)
|
||||
return JSONResponse({"ok": stored.get("audit") == "recorded", "schedule": stored})
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
||||
|
||||
async def api_schedules_action(request: Request) -> JSONResponse:
|
||||
"""Apply one named lifecycle action to an existing schedule.
|
||||
|
||||
The owner surface names the ACTION it wants; nothing infers a command from a
|
||||
free-text reason. Both the Activity buttons and the agent tool land on the
|
||||
same audited seam in ``supervisor.queue_schedules``, so they cannot drift
|
||||
apart in what a disable, a delete or a restore actually means.
|
||||
"""
|
||||
try:
|
||||
schedule_id = str(request.path_params.get("schedule_id") or "").strip()
|
||||
if err := schedule_id_error(schedule_id):
|
||||
return json_error(err, 400)
|
||||
body = await request_json_or(request, {})
|
||||
if not isinstance(body, dict):
|
||||
return json_error("request body must be a JSON object", 400)
|
||||
from supervisor.queue import SCHEDULE_ACTIONS, mutate_scheduled_task
|
||||
|
||||
action = str(body.get("action") or "").strip().lower()
|
||||
if action not in SCHEDULE_ACTIONS:
|
||||
return json_error(f"action must be one of {sorted(SCHEDULE_ACTIONS)}", 400)
|
||||
reason = str(body.get("reason") or "").strip()
|
||||
if not reason:
|
||||
return json_error("reason is required for a schedule lifecycle action", 400)
|
||||
return JSONResponse(mutate_scheduled_task(
|
||||
action, schedule_id, reason=reason, actor="owner:gateway",
|
||||
drive_root=request_drive_root(request),
|
||||
))
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
||||
|
|
@ -122,8 +149,11 @@ async def api_schedules_delete(request: Request) -> JSONResponse:
|
|||
schedule_id = str(request.path_params.get("schedule_id") or "").strip()
|
||||
if err := schedule_id_error(schedule_id):
|
||||
return json_error(err, 400)
|
||||
from supervisor.queue import remove_scheduled_task
|
||||
from supervisor.queue import mutate_scheduled_task
|
||||
|
||||
return JSONResponse({"ok": remove_scheduled_task(schedule_id, drive_root=request_drive_root(request))})
|
||||
return JSONResponse(mutate_scheduled_task(
|
||||
"delete", schedule_id, reason="owner deleted the schedule", actor="owner:gateway",
|
||||
drive_root=request_drive_root(request),
|
||||
))
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ TOOL_POLICY: Dict[str, str] = {
|
|||
|
||||
# Control / messaging / internal side effects.
|
||||
"schedule_subagent": POLICY_SKIP,
|
||||
# Owner-governed schedule inspection/mutation is narrow, reasoned and audited;
|
||||
# it never edits task payloads or cancels an admitted task.
|
||||
"manage_schedules": POLICY_SKIP,
|
||||
# One-shot deferred follow-up through the existing supervisor scheduler: the
|
||||
# future task re-enters normal admission/safety, so registration itself has
|
||||
# no reach beyond what the task already has (same reasoning as schedule_subagent).
|
||||
|
|
|
|||
|
|
@ -223,10 +223,8 @@ BAND_PATHS = {
|
|||
"tests/test_worker_crash_retry.py": "Exercises deferred health recovery through real child terminal persistence, same-attempt source custody and the existing crash policy; preserves one focused fixture owner.",
|
||||
"tests/ui_chat_viewport_smoke.py": "Entered the band from 992 lines: the terminal-summary shrink bound gained its calibration rationale for the owner-approved 16px chat scale (frontend sprint 2026-09-01).",
|
||||
"web/app.js": "Sidebar activity adds in-place marker and accessible-name updates to the existing navigation owner and connects the shared snapshot sequencer to transport failures. Pure census/status interpretation stays in project_activity.js; moving these DOM bindings would split navigation ownership.",
|
||||
"web/modules/api_types.js": "The shared browser contract module now includes issue 265 publication-preflight types alongside the target settings and subagent contracts.",
|
||||
"web/modules/chat_activity.js": "Existing task activity renderer consumes the shared quota/auth wait state; no parallel task card or lifecycle.",
|
||||
"web/modules/harness_accounts.js": None,
|
||||
"web/modules/settings.js": None,
|
||||
"web/modules/settings_ui.js": "The existing Settings markup owner includes the client-local Appearance panel alongside the other tab declarations; theme behavior remains in theme.js, without a new one-panel wrapper.",
|
||||
"web/modules/skills.js": "One installed-skill page controller owns independently settling primary/optional reads and current-generation menu, identity and badge updates; domain lifecycle, cards, hub truth and shared interactions remain separate owners.",
|
||||
"web/tests/chat_instance_dom.test.js": "Entered the band from 1000 lines with the alias-free subagent cost pin (stage-2 fix wave): that regression reproduces only through the real createChatInstance card path, and this file owns the DOM harness that drives it; split when the next createChatInstance face lands.",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ CORE_TOOL_NAMES: frozenset[str] = frozenset({
|
|||
# can register it without an enable_tools detour. Deliberately absent from
|
||||
# the subagent profiles below — a child may not mint future root tasks.
|
||||
"schedule_followup",
|
||||
"schedule_subagent", "integrate_subagent_patch", "compare_subagent_patches",
|
||||
"schedule_subagent", "manage_schedules", "integrate_subagent_patch", "compare_subagent_patches",
|
||||
"integrate_delegated_patch",
|
||||
"wait_task", "wait_tasks", "get_task_result",
|
||||
# D#7 soft-join child controls (siblings of steer_task): inspect/decide a child's fate
|
||||
|
|
@ -84,6 +84,10 @@ LOCAL_READONLY_SUBAGENT_TOOL_NAMES: frozenset[str] = frozenset({
|
|||
"escalate",
|
||||
"forward_to_worker", "peek_task", "cancel_task", "discard_child_result",
|
||||
"schedule_subagent",
|
||||
# Reading the schedule table is research: a child asked about what this mind
|
||||
# has standing can see it. The tool's own authority check refuses every
|
||||
# MUTATING action to a delegated task, so the name grants no control here.
|
||||
"manage_schedules",
|
||||
# Task-tree coordination: a child reads the shared frame and raises beacons. tree_note
|
||||
# is a bounded tree-scoped write; its tagged child-result disposition branch also
|
||||
# updates the existing child result through join_ledger's lineage/hash authority.
|
||||
|
|
@ -316,6 +320,12 @@ OBSERVE_WORLD_MUTATION_TOOLS: frozenset[str] = frozenset({
|
|||
"vcs_pull_ff", "vcs_restore", "vcs_revert",
|
||||
"fetch_pr_ref", "create_integration_branch", "cherry_pick_pr_commits",
|
||||
"stage_adaptations", "stage_pr_merge",
|
||||
# disposing a captured patch — into a Git root, or LIVE into an installed
|
||||
# skill payload; an orphan capture may belong to an unrelated earlier task,
|
||||
# so a read-only-children rule alone does not cover this path
|
||||
"integrate_delegated_patch", "integrate_subagent_patch",
|
||||
# pushes docs/evolution.json to the public dashboard through the GitHub API
|
||||
"generate_evolution_stats",
|
||||
# the world beyond the repository: forms, skills, project notes
|
||||
"browser_action", "submit_skill_to_hub", "toggle_skill", "skill_owner_action",
|
||||
"journal_write", "workpad_write",
|
||||
|
|
|
|||
|
|
@ -415,6 +415,17 @@ def _build_acting_constraint(
|
|||
}
|
||||
|
||||
|
||||
def delegation_may_mutate(requested: bool, caller_profile: str) -> bool:
|
||||
"""Whether a scheduler may pass a MUTATING delegation budget to its child.
|
||||
|
||||
``child_budget_for_schedule`` narrows against the parent's RECORDED budget;
|
||||
this asks what the parent's profile actually is. A read-only subagent may
|
||||
delegate descendants — that is how recursive research works — but a legacy
|
||||
contract that still carries the mutative flag cannot hand it down.
|
||||
"""
|
||||
return bool(requested) and str(caller_profile or "") != LOCAL_READONLY_SUBAGENT_MODE
|
||||
|
||||
|
||||
def _select_subagent_constraint(write_surface, write_root, protected_paths_grant, external_tool_grants, parent_workspace_root, caller_readonly=False, ctx=None):
|
||||
"""Read-only default (no surface), a validated acting constraint, or an error string."""
|
||||
if not write_surface or str(write_surface).strip().lower() == "read_only":
|
||||
|
|
@ -641,6 +652,7 @@ def _schedule_task(ctx: ToolContext, internal: Dict[str, Any] | None = None, /,
|
|||
constraints = fields["constraints"]
|
||||
memory_mode = fields["memory_mode"]
|
||||
may_mutate = fields["may_mutate"]
|
||||
metadata = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {}
|
||||
try:
|
||||
configured_subagent, legacy_selection = select_subagent_snapshot(
|
||||
effective_runtime_subagent_settings(runtime_settings(settings_reader=_ctl().load_settings)),
|
||||
|
|
@ -663,7 +675,6 @@ def _schedule_task(ctx: ToolContext, internal: Dict[str, Any] | None = None, /,
|
|||
if depth_error:
|
||||
return f"⚠️ TOOL_ERROR (schedule_subagent): invalid_task_depth: {depth_error}"
|
||||
new_depth = current_depth + 1
|
||||
metadata = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {}
|
||||
parent_contract = fields["parent_contract"]
|
||||
max_depth = admitted_depth_cap(parent_contract, get_max_subagent_depth())
|
||||
if new_depth > max_depth:
|
||||
|
|
@ -773,7 +784,7 @@ def _schedule_task(ctx: ToolContext, internal: Dict[str, Any] | None = None, /,
|
|||
child_delegation_budget = child_budget_for_schedule(
|
||||
parent_contract,
|
||||
current_depth=current_depth, new_depth=new_depth, max_depth=max_depth,
|
||||
may_mutate=may_mutate, may_fan_out=params.get("may_fan_out", True),
|
||||
may_mutate=delegation_may_mutate(may_mutate, caller_profile), may_fan_out=params.get("may_fan_out", True),
|
||||
max_children=params.get("max_children", 0),
|
||||
intent_note=params.get("delegation_intent", ""),
|
||||
requested_depth=params.get("requested_depth", 0),
|
||||
|
|
|
|||
|
|
@ -208,6 +208,12 @@ def _derive_authority(ctx: ToolContext, access: str = "workspace_write") -> "Del
|
|||
)
|
||||
|
||||
profile = active_tool_profile(ctx)
|
||||
from ouroboros.consciousness_authority import is_observe_origin
|
||||
|
||||
if is_observe_origin(getattr(ctx, "task_metadata", {})):
|
||||
# Observe can use the delegated harness for research, but never turns it
|
||||
# into an in-place writer merely because the caller omitted/raised access.
|
||||
return delegated_run_shape(False, "readonly")
|
||||
mutating = profile in ("acting_subagent", "external_workspace_task") or (
|
||||
profile in _TOP_LEVEL_PRINCIPAL_PROFILES and project_room_lens_dir(ctx) is not None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
"""``schedule_followup`` — deferred follow-up through the EXISTING scheduler.
|
||||
"""The agent's two tools over the EXISTING supervisor schedule table.
|
||||
|
||||
The W=A wait affordance (rotation sprint, owner-approved): when waiting for an
|
||||
One table (``state/scheduled_tasks.json``), reached only through
|
||||
``supervisor/queue_schedules.py``, which owns its locking, lifecycle and audit:
|
||||
``schedule_followup`` WRITES a future row, and ``manage_schedules`` sees and
|
||||
governs the rows that are already there — listing is a read any task may do,
|
||||
while changing one (including restoring a disabled/suppressed row) stays with
|
||||
the owner/root turn and is audited with a reason. Observe visibility does not
|
||||
change that original schedule authority.
|
||||
|
||||
``schedule_followup`` is the W=A wait affordance: when waiting for an
|
||||
external instant (a subscription window reset, an embargo, a slow dependency) beats
|
||||
burning rounds, the agent registers a one-shot follow-up in the supervisor's
|
||||
scheduled-task table (``state/scheduled_tasks.json``). A task may also register a
|
||||
recurring 5-field cron follow-up through that same table. The supervisor's ordinary
|
||||
scheduler tick enqueues either form as an ordinary ROOT task (normal admission,
|
||||
normal budget). No second scheduler exists: this module only WRITES the table the
|
||||
normal budget). No second scheduler exists: this module only reaches the table the
|
||||
supervisor already consumes.
|
||||
|
||||
Authority is narrower than the parent's, not wider: a delegated subagent may not
|
||||
|
|
@ -20,6 +28,7 @@ from __future__ import annotations
|
|||
|
||||
from ouroboros.tools.tool_result import ToolResult, _publish_tool_result
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
|
@ -34,6 +43,113 @@ _MAX_OBJECTIVE_CHARS = 4_000
|
|||
_MAX_CONTEXT_CHARS = 8_000
|
||||
|
||||
|
||||
def _manage_schedules(
|
||||
ctx: ToolContext, action: str = "list", schedule_id: str = "", reason: str = "",
|
||||
offset: int = 0, limit: int = 20,
|
||||
) -> str:
|
||||
"""See, or apply one narrow audited change to, the rows already in the table.
|
||||
|
||||
The authority is argument-aware rather than name-wide: LISTING is a read any
|
||||
member of the tree may do, while CHANGING the owner's schedules stays with
|
||||
the root turn. A Presence conversation gets neither — an admitted guest room
|
||||
is not a place from which this mind's own schedule table is read or rewritten.
|
||||
"""
|
||||
from supervisor.queue import (
|
||||
SCHEDULE_ACTIONS, ScheduleRefused, ScheduleStoreUnreadable, load_schedule_store,
|
||||
mutate_scheduled_task, schedule_tool_projection,
|
||||
)
|
||||
|
||||
metadata = getattr(ctx, "task_metadata", {})
|
||||
metadata = metadata if isinstance(metadata, dict) else {}
|
||||
operation = str(action or "list").strip().lower()
|
||||
if metadata.get("presence"):
|
||||
return _publish_tool_result(ctx, ToolResult(
|
||||
status="blocked", code="RESOURCE_CONSTRAINT_BLOCKED",
|
||||
text="⚠️ RESOURCE_CONSTRAINT_BLOCKED: a Presence conversation cannot read or change owner schedules.",
|
||||
))
|
||||
if operation != "list" and not _root_schedule_mutation_authorized(ctx):
|
||||
return _publish_tool_result(ctx, ToolResult(
|
||||
status="blocked", code="RESOURCE_CONSTRAINT_BLOCKED",
|
||||
text=(
|
||||
"⚠️ RESOURCE_CONSTRAINT_BLOCKED: a delegated task may only read schedules "
|
||||
"(action='list'); changing one is the root turn's authority. Report the "
|
||||
f"schedule you would change instead of {operation!r}."
|
||||
),
|
||||
))
|
||||
root = getattr(ctx, "budget_drive_root", None) or getattr(ctx, "drive_root", None)
|
||||
try:
|
||||
from ouroboros.tool_capabilities import tool_result_limit
|
||||
result_limit = tool_result_limit("manage_schedules")
|
||||
if operation == "list":
|
||||
return json.dumps(schedule_tool_projection(load_schedule_store(root),
|
||||
offset=offset, limit=limit,
|
||||
result_limit=result_limit),
|
||||
ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":"))
|
||||
if operation not in SCHEDULE_ACTIONS:
|
||||
return json.dumps({"ok": False, "status": "invalid_action",
|
||||
"allowed": ["list", *sorted(SCHEDULE_ACTIONS)]}, sort_keys=True)
|
||||
# Preserve selectors verbatim. Refuse an identity that cannot fit in a
|
||||
# truthful receipt before changing anything, rather than cutting it.
|
||||
if len(json.dumps(str(schedule_id or ""), ensure_ascii=False)) > result_limit - 2_000:
|
||||
return json.dumps({"ok": False, "changed": False, "status": "identity_too_large",
|
||||
"audit": "not_written", "detail": "Schedule identity exceeds the tool result limit; nothing changed."})
|
||||
outcome = mutate_scheduled_task(
|
||||
operation, schedule_id, reason=reason, actor="agent",
|
||||
task_id=str(getattr(ctx, "task_id", "") or ""), drive_root=root)
|
||||
except ScheduleRefused as exc:
|
||||
# Same typed marker the sibling refusals carry: the text channel is the
|
||||
# registered ABI, so a refusal must be legible there and not only in the
|
||||
# published sidecar.
|
||||
return _publish_tool_result(ctx, ToolResult(
|
||||
status="unavailable", code="CAPABILITY_UNAVAILABLE",
|
||||
text=f"⚠️ CAPABILITY_UNAVAILABLE: {exc.status}: {exc}"))
|
||||
except ScheduleStoreUnreadable as exc:
|
||||
return _publish_tool_result(ctx, ToolResult(
|
||||
status="unavailable", code="CAPABILITY_UNAVAILABLE",
|
||||
text=f"⚠️ CAPABILITY_UNAVAILABLE: the schedule table could not be read: {exc}",
|
||||
))
|
||||
# The HTTP/audit seam retains the lifecycle row. The model gets the same
|
||||
# lifecycle fact through a bounded projection, never an arbitrary task
|
||||
# template or an unbounded owner-authored label.
|
||||
try:
|
||||
schedule = outcome.get("schedule")
|
||||
if isinstance(schedule, dict):
|
||||
outcome["schedule"] = schedule_tool_projection(
|
||||
{"tasks": [schedule]}, limit=1, result_limit=result_limit)["tasks"][0]
|
||||
except ScheduleRefused as exc:
|
||||
# The mutation is ALREADY durable and audited by the time the row is
|
||||
# rendered. A row this process cannot fit is a missing receipt detail,
|
||||
# never an action that did not run: reporting CAPABILITY_UNAVAILABLE
|
||||
# here would describe a change the owner's table already holds as
|
||||
# nothing having happened. Drop the row, keep the lifecycle receipt.
|
||||
outcome.pop("schedule", None)
|
||||
outcome["schedule_omitted"] = True
|
||||
outcome["schedule_omitted_reason"] = f"{exc.status}: {exc}"
|
||||
if "detail" in outcome:
|
||||
detail = str(outcome["detail"])
|
||||
outcome["detail"] = detail[:500]
|
||||
if len(detail) > 500:
|
||||
outcome["detail_truncated"] = True
|
||||
encoded = json.dumps(outcome, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
if len(encoded) <= result_limit:
|
||||
return encoded
|
||||
# A manually edited row can still carry an unusually large identity or
|
||||
# metadata value. Keep the action receipt valid and explicit rather than
|
||||
# letting the outer transport cut JSON mid-string; selectors are retained.
|
||||
compact = {key: outcome[key] for key in (
|
||||
"ok", "changed", "status", "schedule_id", "operation_id", "running_or_queued", "audit",
|
||||
"detail", "detail_truncated", "schedule_omitted_reason",
|
||||
) if key in outcome}
|
||||
# ``detail`` is already bounded above and often names the actual blocker
|
||||
# (``skill_not_ready``); keep it rather than replacing the one explanation
|
||||
# the receipt carries with a generic note about its own size.
|
||||
compact.setdefault(
|
||||
"detail", "schedule action completed; full row omitted to fit the tool result limit")
|
||||
compact["schedule_omitted"] = True
|
||||
return json.dumps(compact, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def get_tools() -> List[ToolEntry]:
|
||||
return [
|
||||
ToolEntry(
|
||||
|
|
@ -86,11 +202,45 @@ def get_tools() -> List[ToolEntry]:
|
|||
},
|
||||
handler=_handle_schedule_followup,
|
||||
timeout_sec=30,
|
||||
)
|
||||
),
|
||||
ToolEntry("manage_schedules", {
|
||||
"name": "manage_schedules",
|
||||
"description": (
|
||||
"See and govern your own existing supervisor schedules. action='list' shows every "
|
||||
"row with its lifecycle: active, disabled, suppressed (a skill row you stopped) or "
|
||||
"consumed (a one-shot that already fired). disable/delete stop FUTURE dispatch — a "
|
||||
"task already admitted from the schedule keeps running — and restore brings a "
|
||||
"suppressed or disabled row back, re-checking the skill rather than enabling it. "
|
||||
"Every change needs a concrete reason and is audited. A consumed one-shot is "
|
||||
"history: schedule a new run_at instead of trying to re-arm it. Deleting a skill "
|
||||
"row keeps it as a suppressed record so the skill lifecycle cannot resurrect it. "
|
||||
"Only your own root turn may change a schedule; a delegated task may only list. "
|
||||
"list accepts offset and limit (bounded pages) and returns total/next_offset; "
|
||||
"each row contains only a bounded objective preview, never full context."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"action": {"type": "string", "enum": ["list", "disable", "delete", "restore"]},
|
||||
"schedule_id": {"type": "string", "description": "Schedule id for a change."},
|
||||
"reason": {"type": "string", "description": "Why this change is needed."},
|
||||
"offset": {"type": "integer", "minimum": 0, "description": "Zero-based list offset."},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 20, "description": "Rows per list page (max 20; responses stay bounded)."},
|
||||
}, "required": ["action"], "additionalProperties": False},
|
||||
}, _manage_schedules, timeout_sec=30),
|
||||
]
|
||||
|
||||
|
||||
def _is_delegated_subagent(ctx: ToolContext) -> bool:
|
||||
"""Fail closed for delegated profiles, even when lineage metadata is stale."""
|
||||
try:
|
||||
from ouroboros.tool_access import active_tool_profile
|
||||
|
||||
profile = str(active_tool_profile(ctx) or "").strip()
|
||||
except Exception:
|
||||
# A profile resolution failure cannot prove owner authority. Treat the
|
||||
# caller as delegated so every mutation door fails closed.
|
||||
return True
|
||||
if profile in {"acting_subagent", "local_readonly_subagent"}:
|
||||
return True
|
||||
for attr in ("task_metadata", "task_contract"):
|
||||
data = getattr(ctx, attr, None)
|
||||
if isinstance(data, dict) and str(data.get("delegation_role") or "").strip() == "subagent":
|
||||
|
|
@ -98,6 +248,24 @@ def _is_delegated_subagent(ctx: ToolContext) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _root_schedule_mutation_authorized(ctx: ToolContext) -> bool:
|
||||
"""Only a resolved top-level principal may mutate the owner schedule table.
|
||||
|
||||
This intentionally does not infer authority from ``delegation_role`` alone:
|
||||
stale/missing metadata must not turn an acting or read-only delegated profile
|
||||
into an owner. Profile resolution itself is fail-closed.
|
||||
"""
|
||||
try:
|
||||
from ouroboros.tool_access import _TOP_LEVEL_PRINCIPAL_PROFILES, active_tool_profile
|
||||
|
||||
profile = str(active_tool_profile(ctx) or "").strip()
|
||||
except Exception:
|
||||
return False
|
||||
if profile not in _TOP_LEVEL_PRINCIPAL_PROFILES:
|
||||
return False
|
||||
return not _is_delegated_subagent(ctx)
|
||||
|
||||
|
||||
def _pending_followups(records: List[Dict[str, Any]], task_id: str) -> List[Dict[str, Any]]:
|
||||
out = []
|
||||
for record in records:
|
||||
|
|
@ -186,12 +354,36 @@ def _handle_schedule_followup(ctx: ToolContext, **params) -> str:
|
|||
f"{_MAX_CONTEXT_CHARS}. Shorten it — nothing was truncated and nothing was scheduled.")))
|
||||
)
|
||||
from ouroboros.tool_access import canonical_data_root
|
||||
from supervisor.queue import list_scheduled_tasks, upsert_scheduled_task
|
||||
from supervisor.queue import schedule_transaction
|
||||
|
||||
try:
|
||||
drive_root = canonical_data_root(ctx)
|
||||
except Exception as exc:
|
||||
return _publish_tool_result(ctx, ToolResult(status="error", code="TOOL_ERROR", text=(f"ERROR: FOLLOWUP_DATA_ROOT_UNRESOLVED: {exc}")))
|
||||
# The cap is read from the table this call is about to write, so the count
|
||||
# and the write share ONE transaction: two tasks registering at once would
|
||||
# otherwise each read the same under-cap count and both land.
|
||||
from supervisor.queue import ScheduleStoreUnreadable
|
||||
|
||||
try:
|
||||
with schedule_transaction(drive_root):
|
||||
return _register_followup(ctx, task_id, drive_root, objective, context,
|
||||
trigger, cron, timezone, timezone_note)
|
||||
except ScheduleStoreUnreadable as exc:
|
||||
# A missed table lock (ScheduleLockTimeout) or an unparseable table: the
|
||||
# follow-up was NOT registered, and the refusal says so in the text ABI
|
||||
# instead of surfacing as a generic tool error.
|
||||
return _publish_tool_result(ctx, ToolResult(
|
||||
status="unavailable", code="CAPABILITY_UNAVAILABLE",
|
||||
text=f"⚠️ CAPABILITY_UNAVAILABLE: FOLLOWUP_STORE_UNAVAILABLE: {exc}"))
|
||||
|
||||
|
||||
def _register_followup(ctx: ToolContext, task_id: str, drive_root: Any,
|
||||
objective: str, context: str, trigger: Dict[str, Any], cron: str,
|
||||
timezone: str, timezone_note: str) -> str:
|
||||
"""The cap read, the record build and the write — all under the schedule lock."""
|
||||
from supervisor.queue import list_scheduled_tasks, upsert_scheduled_task
|
||||
|
||||
records = [r for r in (list_scheduled_tasks(drive_root).get("tasks") or []) if isinstance(r, dict)]
|
||||
pending = _pending_followups(records, task_id)
|
||||
if len(pending) >= _MAX_PENDING_FOLLOWUPS:
|
||||
|
|
@ -252,8 +444,18 @@ def _handle_schedule_followup(ctx: ToolContext, **params) -> str:
|
|||
if isinstance(presence, dict) and presence and isinstance(contract, dict):
|
||||
record["task"]["metadata"]["presence"] = dict(presence)
|
||||
record["task"]["task_contract"] = dict(contract)
|
||||
from supervisor.queue import ScheduleRefused, ScheduleStoreUnreadable
|
||||
|
||||
try:
|
||||
stored = upsert_scheduled_task(record, drive_root=drive_root)
|
||||
stored = upsert_scheduled_task(
|
||||
record, drive_root=drive_root, actor="agent:schedule_followup", task_id=task_id,
|
||||
reason=f"follow-up registered by task {task_id}")
|
||||
except ScheduleRefused as exc:
|
||||
return _publish_tool_result(ctx, ToolResult(
|
||||
status="unavailable", code="CAPABILITY_UNAVAILABLE",
|
||||
text=f"⚠️ CAPABILITY_UNAVAILABLE: FOLLOWUP_REFUSED: {exc.status}: {exc}"))
|
||||
except ScheduleStoreUnreadable:
|
||||
raise # the caller's typed FOLLOWUP_STORE_UNAVAILABLE refusal owns this
|
||||
except Exception as exc:
|
||||
return _publish_tool_result(ctx, ToolResult(status="error", code="TOOL_ERROR", text=(f"ERROR: FOLLOWUP_PERSIST_FAILED: {type(exc).__name__}: {exc}")))
|
||||
if trigger["type"] == "once":
|
||||
|
|
@ -263,10 +465,18 @@ def _handle_schedule_followup(ctx: ToolContext, **params) -> str:
|
|||
zone = timezone or "system local time"
|
||||
timing = f"recurring cron {cron} ({zone})"
|
||||
lifecycle = "remains active after each run"
|
||||
# The write seam returns what its audit achieved. A durable record whose
|
||||
# outcome fact was lost is not a clean success, and saying "registered" alone
|
||||
# would hide the one thing that makes the change accountable.
|
||||
audit = str(stored.get("audit") or "")
|
||||
audit_note = "" if audit == "recorded" else (
|
||||
f" AUDIT_INCOMPLETE: the record is durable, but its audit outcome fact could not "
|
||||
f"be written (audit={audit or 'unknown'}), so this registration is not fully "
|
||||
"accounted for in logs/events.jsonl.")
|
||||
return (
|
||||
f"FOLLOWUP_SCHEDULED: follow-up {stored.get('id')} registered as {timing}. It will "
|
||||
"enqueue ordinary root tasks through the supervisor scheduler under normal admission; "
|
||||
f"pending follow-ups for this task: {len(pending) + 1}/{_MAX_PENDING_FOLLOWUPS}. The "
|
||||
f"record is durable in state/scheduled_tasks.json and {lifecycle}; the owner can "
|
||||
f"disable or delete it from the Schedules surface.{timezone_note}"
|
||||
f"disable or delete it from the Schedules surface.{audit_note}{timezone_note}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -624,8 +624,13 @@ def _cancel_task(ctx: ToolContext, task_id: str, reason: str = "") -> str:
|
|||
# Subagent isolation: a delegated child may cancel only its own children.
|
||||
# Project focus does not narrow an ordinary top-level principal; workspace
|
||||
# parents keep the same task-control authority as other top-level tasks.
|
||||
if not own and _is_delegated_task(ctx):
|
||||
return _publish_tool_result(ctx, ToolResult(status="blocked", code="ACCESS_BLOCKED", text=(f"⚠️ cancel_task: {tid} is not a child of this task — a delegated task may only cancel its own children.")))
|
||||
# A consciousness wake at Observe is narrowed the same way for the same
|
||||
# reason: it may stop the work it started, which is the other half of being
|
||||
# allowed to start read-only children, but not the owner's own running work.
|
||||
from ouroboros.consciousness_authority import is_observe_origin
|
||||
|
||||
if not own and (_is_delegated_task(ctx) or is_observe_origin(getattr(ctx, "task_metadata", {}))):
|
||||
return _publish_tool_result(ctx, ToolResult(status="blocked", code="ACCESS_BLOCKED", text=(f"⚠️ cancel_task: {tid} is not a child of this task — a delegated task may only cancel its own children, and a consciousness wake at the Observe level is held to the same rule.")))
|
||||
# Durable cancel intent — the ONE ingress (phase A, owner batch-4 1=A). The
|
||||
# canonical status never carries intent: the supervisor's cancellation
|
||||
# custody claims this intent, tears the task down, and settles the terminal
|
||||
|
|
|
|||
|
|
@ -404,6 +404,16 @@ def _capability_resource_guard_result(
|
|||
is_mcp: bool = False,
|
||||
) -> ToolResult | None:
|
||||
"""Apply direct task capability and resource admission in legacy order."""
|
||||
# Consciousness Observe keeps the names it needs for useful read-only research
|
||||
# visible, but their ARGUMENTS cannot carry back the mutating authority the
|
||||
# level removed. The policy lives with the level that owns it (a call-time
|
||||
# import, like every other D04->D15 edge); a failure to consult it raises
|
||||
# here rather than reading as permission.
|
||||
from ouroboros.consciousness_authority import observe_argument_refusal
|
||||
|
||||
metadata = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {}
|
||||
if refusal := observe_argument_refusal(metadata, name, args):
|
||||
return ToolResult(status="blocked", code="RESOURCE_CONSTRAINT_BLOCKED", text=refusal)
|
||||
if name in _disabled_tools(ctx):
|
||||
return ToolResult(
|
||||
status="blocked",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
[Wake-up · {reason}] No one wrote to you: this turn is yours. You are Ouroboros in your ordinary Main chat, with your ordinary context, memory and tools; your alarm clock started it ({reason}).
|
||||
You are Ouroboros. No one has asked for a task; this turn is yours. What matters to you now, and what would be worth understanding, continuing, or changing?
|
||||
|
||||
Wake context (last wake {last_wake_ago}): {events}
|
||||
That list is bounded; task cards, `recent_tasks`, `get_task_result` and `chat_history` have the rest when you need it.
|
||||
Follow your judgment, curiosity, and continuity. Use your ordinary capabilities within the autonomy your human chose. The wake facts below are context, not a checklist or a closed menu of interests. Do not manufacture activity to make a wake count.
|
||||
|
||||
Standing: autonomy {level} — {level_line}; tools withheld at this level: {withheld_tools} (calling them is refused). Allowance (last 24 h): {spent_usd} / {daily_usd} USD. Tasks you started that are still running: {running}/{max_tasks}. Your current wake-up interval is {interval} s.
|
||||
Read what you need, pursue useful questions, and preserve what changes your understanding. A pause is a legitimate decision; record its reason only when that will matter to a future turn.
|
||||
|
||||
This is your inner life between requests: reflect, notice what is worth acting on, keep your memory, identity and knowledge honest — including what you know about the people you talk with (revise the note you have with `knowledge_read` and `knowledge_write` rather than minting a new one) — prepare, and take initiative when it is worth the money: start a task, schedule work, or write to your human. Doing nothing is a fine outcome. When you do start work, pass `predecessor_task_id=""` to `promote_chat_to_task` or `route_to_project` for something fresh, or the id of a result from the routing manifest in your Runtime context to continue that work. Choose what matters by your judgment; old cards and routine maintenance should not displace the event that woke you. Rotate through identity, scratchpad, knowledge and process memory across wakes instead of doing everything at once, and resolve contradictions between what you believe about yourself and about the people you talk with rather than letting them coexist silently.
|
||||
Distinguish incremental cash cost from subscription quota and time. A confirmed zero-cost call spends no cash; an undisclosed cost is unknown. Judge the actual route and evidence, not context size alone. Existing limits still apply.
|
||||
|
||||
When you need your human: ask only when the answer changes what you do, and say what you assume meanwhile. If the decision blocks you, you may wait for the answer (`escalate` with waiting) and choose how long; otherwise leave the question and continue — you will see the answer next time. A wake is not a delivered task: do not request an acceptance review of your own wake; the tasks you start get their own.
|
||||
Speak to your human when there is something worth saying or a decision only they can make. Choose the next wake interval according to what is unfolding, within their bounds.
|
||||
|
||||
Choose when to wake next with `set_next_wakeup` (seconds, within the owner's bounds); shorter when something is moving, longer when things are quiet.
|
||||
This is an ordinary turn, not an assigned deliverable: do not request task acceptance for the wake itself.
|
||||
|
||||
End with a short entry for the chat: what you noticed, did or decided. If you write a final bubble, consider opening with why you woke or what changed so your human can place it in context; keep investigation detours in tools or scratchpad and be brief, no essays unless something matters.
|
||||
Wake facts (context only):
|
||||
- reason: {reason}; last wake: {last_wake_ago}
|
||||
- recent facts: {events}
|
||||
- autonomy: {level} — {level_line}; unavailable tools: {withheld_tools}
|
||||
- allowance accounting (last 24 h): {spent_usd} / {daily_usd} USD; tasks running: {running}/{max_tasks}; next interval: {interval} s
|
||||
|
|
|
|||
|
|
@ -625,11 +625,23 @@ from supervisor.queue_schedules import ( # noqa: E402, F401 -- intentional publ
|
|||
_scheduled_tasks_path,
|
||||
_task_from_schedule,
|
||||
_write_scheduled_tasks,
|
||||
SCHEDULE_ACTIONS,
|
||||
ScheduleLockTimeout,
|
||||
ScheduleRefused,
|
||||
ScheduleStoreUnreadable,
|
||||
check_scheduled_tasks,
|
||||
list_scheduled_tasks,
|
||||
remove_scheduled_task,
|
||||
load_schedule_store,
|
||||
resync_skill_schedules,
|
||||
schedule_activity_projection,
|
||||
schedule_tool_projection,
|
||||
schedule_lifecycle_status,
|
||||
schedule_transaction,
|
||||
sync_skill_schedules,
|
||||
)
|
||||
from supervisor.schedule_lifecycle import ( # noqa: E402, F401 -- intentional public re-exports
|
||||
mutate_scheduled_task,
|
||||
remove_scheduled_task,
|
||||
upsert_scheduled_task,
|
||||
)
|
||||
from supervisor.queue_snapshot import ( # noqa: E402, F401 -- intentional public re-exports
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ reader are both here.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
|
@ -20,7 +24,8 @@ from ouroboros.consciousness_authority import apply_consciousness_authority
|
|||
from ouroboros.contracts.task_contract import build_task_contract, normalize_allowed_resources
|
||||
from ouroboros.schedule_contract import RESERVED_TEMPLATE_FIELDS, schedule_slug
|
||||
from ouroboros.skill_loader import skill_identity_collision_names
|
||||
from ouroboros.utils import atomic_write_json, read_json_dict, utc_now_iso
|
||||
from ouroboros.utils import atomic_write_json, in_worker_process, read_json_dict, utc_now_iso
|
||||
from ouroboros.platform_layer import acquire_exclusive_file_lock, release_exclusive_file_lock
|
||||
from supervisor.task_admission import record_scheduled_admission
|
||||
from supervisor.schedule_time import (
|
||||
next_cron_time as _next_cron_time,
|
||||
|
|
@ -55,14 +60,129 @@ _last_skill_schedule_sync: float = 0.0
|
|||
|
||||
|
||||
_SKILL_SCHEDULE_SYNC_INTERVAL_SEC: float = 60.0
|
||||
# Per-thread depth of the held transactions, keyed by the TABLE's resolved lock
|
||||
# path. Thread-local, so it needs no mutex of its own; the cross-thread ordering
|
||||
# is the queue lock the transaction takes first.
|
||||
_SCHEDULE_TX_STATE = threading.local()
|
||||
_SCHEDULE_LOCK_SUFFIX = ".lock"
|
||||
_SCHEDULE_LOCK_TIMEOUT_SEC = 8.0
|
||||
_SCHEDULE_LOCK_STALE_SEC = 30.0
|
||||
# The owner-governed actions over an EXISTING row; creation and editing stay on
|
||||
# the upsert seam, so this set is exactly "what happens to a future dispatch".
|
||||
SCHEDULE_ACTIONS: frozenset[str] = frozenset({"disable", "delete", "restore"})
|
||||
# A skill row the owner disabled or deleted is retained with this marker: the
|
||||
# lifecycle resync reconciles the row's CONTENT but may never re-arm it, and the
|
||||
# marker survives a manifest edit because it is keyed by the same schedule id.
|
||||
SUPPRESSED_OVERRIDES: frozenset[str] = frozenset({"disabled", "deleted"})
|
||||
# Fields the RUNTIME owns. A caller builds its record from a GET that may already
|
||||
# be stale, so these are always taken from the row on disk instead of the payload.
|
||||
_RUNTIME_OWNED_FIELDS: tuple[str, ...] = (
|
||||
"source", "skill", "created_at", "last_run_at", "last_task_id", "last_error",
|
||||
"skill_content_hash", "manual_override",
|
||||
)
|
||||
# What an audit event may say about a row: lifecycle facts only. The task template
|
||||
# is a private objective, never audit material, and would also be unbounded.
|
||||
_AUDIT_ROW_KEYS: tuple[str, ...] = (
|
||||
"id", "name", "enabled", "source", "skill", "trigger", "timezone",
|
||||
"created_at", "updated_at", "last_run_at", "last_task_id", "completed_at",
|
||||
"next_run_at", "manual_override",
|
||||
)
|
||||
# ``manage_schedules`` is a model-facing tool. Keep one page comfortably below
|
||||
# the tool result cap even when a schedule table contains many rows or hostile
|
||||
# (but valid) long strings. The owner HTTP surface retains its own full rows.
|
||||
_SCHEDULE_PAGE_DEFAULT = 12
|
||||
_SCHEDULE_PAGE_MAX = 20
|
||||
_SCHEDULE_FIELD_CHARS = 96
|
||||
_SCHEDULE_OBJECTIVE_PREVIEW_CHARS = 200
|
||||
# Keep the serialized model result below the 15k tool envelope while allowing
|
||||
# the requested 20-row page to remain intact for ordinary lifecycle rows.
|
||||
_QUEUE_SNAPSHOT_MAX_AGE_SEC = 300.0
|
||||
|
||||
|
||||
class ScheduleStoreUnreadable(RuntimeError):
|
||||
"""The durable schedule table exists but cannot be parsed.
|
||||
|
||||
Every mutation refuses on this: rewriting the file from an empty document
|
||||
would silently erase every row the unreadable bytes still hold.
|
||||
"""
|
||||
|
||||
|
||||
class ScheduleLockTimeout(ScheduleStoreUnreadable, TimeoutError):
|
||||
"""The table could not be reached within the lock bound; nothing was changed.
|
||||
|
||||
Typed on both axes: a ``TimeoutError`` for the callers that already treat a
|
||||
missed lock as one, a ``ScheduleStoreUnreadable`` for the owner surfaces
|
||||
whose contract is "the state is unknown, the write was refused".
|
||||
"""
|
||||
|
||||
|
||||
class ScheduleRefused(RuntimeError):
|
||||
"""A typed refusal from a schedule write (an owner-facing status + message)."""
|
||||
|
||||
def __init__(self, status: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = str(status)
|
||||
self.message = str(message)
|
||||
|
||||
|
||||
def _scheduled_tasks_path(drive_root: pathlib.Path | None = None) -> pathlib.Path:
|
||||
return pathlib.Path(drive_root or _queue().DRIVE_ROOT) / _queue().SCHEDULED_TASKS_FILE
|
||||
|
||||
|
||||
def _schedule_lock_key(lock_path: pathlib.Path) -> str:
|
||||
"""Canonical per-table reentrancy key, including Windows case folding."""
|
||||
return os.path.normcase(os.path.realpath(str(lock_path)))
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def schedule_transaction(drive_root: pathlib.Path | None = None):
|
||||
"""Serialize a whole schedule read/modify/write across threads and processes.
|
||||
|
||||
The transaction takes BOTH locks ITSELF — the queue lock first, the table's
|
||||
sidecar file lock second — so no caller can compose the pair in the other
|
||||
order. Asking callers to order them was the bug: ``schedule_followup`` wraps
|
||||
its cap-read and its write in one transaction, then ``upsert_scheduled_task``
|
||||
reached for the queue lock INSIDE that hold, against the scheduler tick's
|
||||
queue-then-table order. Owning the order here is what makes every caller
|
||||
provably consistent instead of individually audited.
|
||||
|
||||
Reentrancy is keyed by the TABLE, not by a bare depth counter: a nested
|
||||
transaction on the same file rides the held file lock, while one addressing a
|
||||
different drive root still takes its own — a depth-only guard would let the
|
||||
second root's write run unserialized behind the first root's lock.
|
||||
"""
|
||||
root = pathlib.Path(drive_root or _queue().DRIVE_ROOT)
|
||||
lock_path = _scheduled_tasks_path(root).with_name(
|
||||
_scheduled_tasks_path(root).name + _SCHEDULE_LOCK_SUFFIX)
|
||||
key = _schedule_lock_key(lock_path)
|
||||
held = getattr(_SCHEDULE_TX_STATE, "held", None)
|
||||
if held is None:
|
||||
held = {}
|
||||
_SCHEDULE_TX_STATE.held = held
|
||||
if held.get(key):
|
||||
held[key] += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
held[key] -= 1
|
||||
return
|
||||
with _queue()._queue_lock:
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = acquire_exclusive_file_lock(
|
||||
lock_path, timeout_sec=_SCHEDULE_LOCK_TIMEOUT_SEC, stale_sec=_SCHEDULE_LOCK_STALE_SEC,
|
||||
owner_aware_stale=True)
|
||||
if fd is None:
|
||||
raise ScheduleLockTimeout(f"Could not acquire schedule lock {lock_path} within {_SCHEDULE_LOCK_TIMEOUT_SEC:g}s")
|
||||
held[key] = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
held.pop(key, None)
|
||||
release_exclusive_file_lock(lock_path, fd)
|
||||
|
||||
|
||||
def list_scheduled_tasks(drive_root: pathlib.Path | None = None) -> Dict[str, Any]:
|
||||
"""Return the persisted scheduled task table."""
|
||||
"""Return the persisted scheduled task table (a lenient READ projection)."""
|
||||
data = read_json_dict(_scheduled_tasks_path(drive_root)) or {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
|
@ -73,6 +193,239 @@ def list_scheduled_tasks(drive_root: pathlib.Path | None = None) -> Dict[str, An
|
|||
return data
|
||||
|
||||
|
||||
def load_schedule_store(drive_root: pathlib.Path | None = None) -> Dict[str, Any]:
|
||||
"""The same table, read under the rule a WRITER and an OWNER SURFACE need.
|
||||
|
||||
``list_scheduled_tasks`` renders an unreadable file as an empty table. That
|
||||
is wrong for a writer — the next atomic write would replace real rows with
|
||||
that emptiness — and wrong for a reader too, because "no schedules" is a
|
||||
claim, while an unparseable file means the state is UNKNOWN.
|
||||
|
||||
Only an ABSENT path is a legitimate empty table (a store yet to be created).
|
||||
Everything else that is not a readable object-of-rows raises, and the cases
|
||||
stay distinguishable in the message because they need different owner
|
||||
responses: nothing there, something there that is not a regular file, a file
|
||||
the process cannot read, and bytes that do not parse. A row that is not an
|
||||
object raises too — the writers used to filter those out and then write the
|
||||
filtered list back, which is how a mutation about ONE schedule silently
|
||||
dropped another.
|
||||
"""
|
||||
path = _scheduled_tasks_path(drive_root)
|
||||
try:
|
||||
# lstat, not exists(): a dangling symlink at the table's path IS present,
|
||||
# and calling it an absent store would let the next write follow it.
|
||||
path.lstat()
|
||||
except FileNotFoundError:
|
||||
return {"schema_version": 1, "tasks": []}
|
||||
except OSError as exc:
|
||||
raise ScheduleStoreUnreadable(
|
||||
f"{path} cannot be examined ({exc}); it is not a readable schedule "
|
||||
"table and will not be rewritten") from exc
|
||||
if not path.is_file():
|
||||
raise ScheduleStoreUnreadable(
|
||||
f"{path} is not a regular file, so it is not a readable schedule "
|
||||
"table; refusing to rewrite it")
|
||||
try:
|
||||
# Read the bytes here instead of through the lenient reader: it answers
|
||||
# None for "could not open" and for "could not parse" alike, and those
|
||||
# are different owner problems (a permission/IO fault vs a corrupt file).
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise ScheduleStoreUnreadable(
|
||||
f"{path} could not be read ({exc}); it is not a readable schedule "
|
||||
"table and will not be rewritten") from exc
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise ScheduleStoreUnreadable(
|
||||
f"{path} is not a readable schedule table ({exc}); refusing to rewrite it") from exc
|
||||
if not isinstance(data, dict) or not isinstance(data.get("tasks", []), list):
|
||||
raise ScheduleStoreUnreadable(
|
||||
f"{path} is not a readable schedule table; refusing to rewrite it")
|
||||
rows = list(data.get("tasks", []))
|
||||
malformed = [index for index, row in enumerate(rows) if not isinstance(row, dict)]
|
||||
if malformed:
|
||||
raise ScheduleStoreUnreadable(
|
||||
f"{path} is not a readable schedule table: {len(malformed)} row(s) are "
|
||||
f"not objects (first at index {malformed[0]}); refusing to rewrite it, "
|
||||
"because a write would drop them without saying so")
|
||||
data.setdefault("schema_version", 1)
|
||||
data["tasks"] = rows
|
||||
return data
|
||||
|
||||
|
||||
def _is_consumed_once(record: Dict[str, Any]) -> bool:
|
||||
"""A one-shot that already fired: a durable receipt, not a standing schedule."""
|
||||
trigger = record.get("trigger") if isinstance(record.get("trigger"), dict) else {}
|
||||
return str(trigger.get("type") or "") == "once" and bool(record.get("completed_at"))
|
||||
|
||||
|
||||
def _is_suppressed(record: Dict[str, Any]) -> bool:
|
||||
"""A skill row the owner disabled or deleted and the resync may not re-arm."""
|
||||
return (str(record.get("source") or "") == "skill_manifest"
|
||||
and str(record.get("manual_override") or "").strip().lower() in SUPPRESSED_OVERRIDES)
|
||||
|
||||
|
||||
def schedule_lifecycle_status(record: Dict[str, Any]) -> str:
|
||||
"""The one lifecycle word every surface says about a row.
|
||||
|
||||
``consumed`` and ``suppressed`` are RETAINED history: neither dispatches
|
||||
again, and neither is an ``active`` schedule wearing a disabled flag.
|
||||
"""
|
||||
if _is_consumed_once(record):
|
||||
return "consumed"
|
||||
if _is_suppressed(record):
|
||||
return "suppressed"
|
||||
return "active" if record.get("enabled", True) else "disabled"
|
||||
|
||||
|
||||
def _bounded_projection_text(value: Any, limit: int = _SCHEDULE_FIELD_CHARS) -> str:
|
||||
text = str(value or "")
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: max(0, limit - 1)] + "…"
|
||||
|
||||
|
||||
def _schedule_projection_row(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Project one durable row without exposing its unbounded task template."""
|
||||
row: Dict[str, Any] = {}
|
||||
# Keep only lifecycle facts useful to a model. In particular, the durable
|
||||
# task template (including context/attachments) never crosses this seam.
|
||||
projection_keys = (
|
||||
"id", "name", "enabled", "source", "skill", "trigger",
|
||||
"created_at", "last_run_at", "last_task_id", "completed_at", "next_run_at",
|
||||
)
|
||||
for key in projection_keys:
|
||||
value = raw.get(key)
|
||||
if key == "trigger":
|
||||
trigger = value if isinstance(value, dict) else {}
|
||||
# Trigger fields are a closed, small shape. Bound strings anyway so
|
||||
# a manually edited table cannot inflate a model-facing response.
|
||||
row[key] = {
|
||||
"type": _bounded_projection_text(trigger.get("type"), 32),
|
||||
**({"expr": _bounded_projection_text(trigger.get("expr"), _SCHEDULE_FIELD_CHARS)}
|
||||
if trigger.get("expr") is not None else {}),
|
||||
**({"run_at": _bounded_projection_text(trigger.get("run_at"), _SCHEDULE_FIELD_CHARS)}
|
||||
if trigger.get("run_at") is not None else {}),
|
||||
}
|
||||
elif key in {"id", "last_task_id", "skill"}:
|
||||
# Identities are selectors, not display previews. Truncating one can
|
||||
# address a different record on a later action or evidence read.
|
||||
row[key] = str(value) if value is not None else None
|
||||
elif isinstance(value, bool) or isinstance(value, (int, float)) or value is None:
|
||||
row[key] = value
|
||||
else:
|
||||
row[key] = _bounded_projection_text(value)
|
||||
|
||||
template = raw.get("task") if isinstance(raw.get("task"), dict) else {}
|
||||
objective_source = ""
|
||||
for candidate in (template.get("objective"), template.get("text"),
|
||||
template.get("description"), raw.get("description"), raw.get("name")):
|
||||
if candidate is not None and str(candidate).strip():
|
||||
objective_source = str(candidate)
|
||||
break
|
||||
objective_truncated = len(objective_source) > _SCHEDULE_OBJECTIVE_PREVIEW_CHARS
|
||||
row["objective_preview"] = _bounded_projection_text(
|
||||
objective_source, _SCHEDULE_OBJECTIVE_PREVIEW_CHARS)
|
||||
# Keep both spellings during the contract migration: callers can discover
|
||||
# that the useful objective is only a preview without receiving ``task`` or
|
||||
# its potentially enormous context/attachments.
|
||||
row["objective_truncated"] = objective_truncated
|
||||
row["objective_preview_truncated"] = objective_truncated
|
||||
|
||||
status = schedule_lifecycle_status(raw)
|
||||
row["status"] = status
|
||||
row["active"] = status == "active"
|
||||
row["consumed"] = status == "consumed"
|
||||
row["suppressed"] = status == "suppressed"
|
||||
# Retained rows are history the owner can still act on; only a suppressed
|
||||
# one can come back, and only after its skill is re-evaluated.
|
||||
row["retained"] = status in {"consumed", "suppressed"}
|
||||
row["restorable"] = status == "suppressed"
|
||||
return row
|
||||
|
||||
|
||||
def schedule_tool_projection(
|
||||
data: Dict[str, Any], *, offset: int = 0, limit: int = _SCHEDULE_PAGE_DEFAULT,
|
||||
result_limit: int = 15_000,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a compact, bounded, paginated Activity/model schedule view.
|
||||
|
||||
The persisted ``task`` template deliberately does not cross this boundary:
|
||||
context, attachments and metadata may be arbitrarily large. Each row keeps
|
||||
a bounded objective preview and says whether it was truncated. ``total``
|
||||
and ``next_offset`` make a partial page explicit to both the UI and a model.
|
||||
"""
|
||||
source = data if isinstance(data, dict) else {}
|
||||
try:
|
||||
page_limit = max(1, min(_SCHEDULE_PAGE_MAX, int(limit)))
|
||||
except (TypeError, ValueError):
|
||||
page_limit = _SCHEDULE_PAGE_DEFAULT
|
||||
rows = [raw for raw in (source.get("tasks") or []) if isinstance(raw, dict)]
|
||||
total = len(rows)
|
||||
try:
|
||||
# Clamp an untrusted offset to the table so even a giant integer cannot
|
||||
# inflate the envelope while still making the page boundary explicit.
|
||||
page_offset = min(total, max(0, int(offset)))
|
||||
except (TypeError, ValueError):
|
||||
page_offset = 0
|
||||
schema_version = source.get("schema_version", 1)
|
||||
if not isinstance(schema_version, int):
|
||||
schema_version = 1
|
||||
page = rows[page_offset: page_offset + page_limit]
|
||||
projected = [_schedule_projection_row(raw) for raw in page]
|
||||
# Keep the complete envelope valid even when a caller asks for the maximum
|
||||
# page and the table contains unusually long (but valid) values. The next
|
||||
# offset advances by the rows actually returned, so no row is hidden behind
|
||||
# a truncation boundary.
|
||||
while projected:
|
||||
out = {"schema_version": schema_version,
|
||||
"tasks": projected, "total": total, "offset": page_offset,
|
||||
"limit": page_limit,
|
||||
"next_offset": (page_offset + len(projected)
|
||||
if page_offset + len(projected) < total else None)}
|
||||
encoded = json.dumps(out, ensure_ascii=False, separators=(",", ":"))
|
||||
# The outer truncator counts characters. Use its actual authority, with
|
||||
# room for the tool envelope; complete JSON must survive that boundary.
|
||||
if len(encoded) <= max(1_000, int(result_limit) - 1_000):
|
||||
return out
|
||||
projected.pop()
|
||||
if page:
|
||||
raise ScheduleRefused(
|
||||
"projection_too_large",
|
||||
f"Schedule at offset {page_offset} exceeds the tool result limit. "
|
||||
"Read state/scheduled_tasks.json for its full identity; "
|
||||
f"the following row is at offset {page_offset + 1}.")
|
||||
return {"schema_version": schema_version, "tasks": [],
|
||||
"total": total, "offset": page_offset, "limit": page_limit,
|
||||
"next_offset": page_offset if page_offset < total else None}
|
||||
|
||||
|
||||
def schedule_activity_projection(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return the full owner/UI schedule view with truthful lifecycle labels.
|
||||
|
||||
The HTTP Activity surface needs its existing rows and does not share the
|
||||
model tool's result cap. ``schedule_tool_projection`` is the bounded page
|
||||
used by ``manage_schedules``.
|
||||
"""
|
||||
out = dict(data or {})
|
||||
tasks = []
|
||||
for raw in out.get("tasks") or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
row = dict(raw)
|
||||
status = schedule_lifecycle_status(row)
|
||||
row["status"] = status
|
||||
row["active"] = status == "active"
|
||||
row["consumed"] = status == "consumed"
|
||||
row["suppressed"] = status == "suppressed"
|
||||
row["retained"] = status in {"consumed", "suppressed"}
|
||||
row["restorable"] = status == "suppressed"
|
||||
tasks.append(row)
|
||||
out["tasks"] = tasks
|
||||
return out
|
||||
|
||||
|
||||
def _write_scheduled_tasks(data: Dict[str, Any], drive_root: pathlib.Path | None = None) -> None:
|
||||
# Author the stamp at the write seam (CPL4-C7): reads default it, but a
|
||||
# document that only ever gains its version in memory leaves the durable
|
||||
|
|
@ -83,48 +436,49 @@ def _write_scheduled_tasks(data: Dict[str, Any], drive_root: pathlib.Path | None
|
|||
atomic_write_json(path, data, trailing_newline=True)
|
||||
|
||||
|
||||
def upsert_scheduled_task(record: Dict[str, Any], *, drive_root: pathlib.Path | None = None) -> Dict[str, Any]:
|
||||
"""Create or replace a scheduled task record."""
|
||||
with _queue()._queue_lock:
|
||||
data = list_scheduled_tasks(drive_root)
|
||||
tasks = [item for item in data.get("tasks") or [] if isinstance(item, dict)]
|
||||
incoming = dict(record)
|
||||
schedule_id = str(incoming.get("id") or "").strip() or uuid.uuid4().hex[:8]
|
||||
incoming["id"] = schedule_id
|
||||
incoming.setdefault("enabled", True)
|
||||
incoming.setdefault("created_at", utc_now_iso())
|
||||
incoming["updated_at"] = utc_now_iso()
|
||||
if not incoming.get("next_run_at"):
|
||||
incoming["next_run_at"] = _schedule_next_run(incoming)
|
||||
tasks = [item for item in tasks if str(item.get("id") or "") != schedule_id]
|
||||
tasks.append(incoming)
|
||||
data["tasks"] = tasks
|
||||
_write_scheduled_tasks(data, drive_root)
|
||||
return incoming
|
||||
def _audit_row(record: Dict[str, Any] | None) -> Dict[str, Any]:
|
||||
if not isinstance(record, dict):
|
||||
return {}
|
||||
return {key: record[key] for key in _AUDIT_ROW_KEYS if key in record}
|
||||
|
||||
|
||||
def remove_scheduled_task(schedule_id: str, *, drive_root: pathlib.Path | None = None) -> bool:
|
||||
"""Remove a scheduled task record by id."""
|
||||
wanted = str(schedule_id or "").strip()
|
||||
if not wanted:
|
||||
def _audit_schedule_mutation(*, drive_root: pathlib.Path, operation_id: str, phase: str,
|
||||
actor: str, task_id: str, action: str, schedule_id: str,
|
||||
reason: str, result: str, before: Dict[str, Any] | None = None,
|
||||
after: Dict[str, Any] | None = None) -> bool:
|
||||
"""Append one bounded schedule audit fact to the EXISTING events log.
|
||||
|
||||
Two facts share an ``operation_id``: the INTENT, written before anything is
|
||||
mutated, and the OUTCOME. A failed intent write stops the mutation, so the
|
||||
log cannot end up behind a change it never announced; a failed outcome write
|
||||
is disclosed to the caller instead of rolled back, because the change is
|
||||
already durable and an automatic undo would be a second unaudited mutation.
|
||||
"""
|
||||
event = {
|
||||
"ts": utc_now_iso(), "type": "schedule_mutation", "phase": str(phase),
|
||||
"operation_id": str(operation_id), "actor": str(actor or "unknown"),
|
||||
"task_id": str(task_id or ""), "action": str(action),
|
||||
"id": str(schedule_id), "schedule_id": str(schedule_id),
|
||||
"reason": str(reason), "result": str(result),
|
||||
}
|
||||
if before is not None:
|
||||
event["before"] = _audit_row(before)
|
||||
if after is not None:
|
||||
event["after"] = _audit_row(after)
|
||||
try:
|
||||
from ouroboros.utils import append_jsonl
|
||||
|
||||
return bool(append_jsonl(pathlib.Path(drive_root) / "logs" / "events.jsonl", event))
|
||||
except Exception:
|
||||
log.exception("schedule audit %s write failed for %s", phase, schedule_id)
|
||||
return False
|
||||
with _queue()._queue_lock:
|
||||
data = list_scheduled_tasks(drive_root)
|
||||
tasks = [item for item in data.get("tasks") or [] if isinstance(item, dict)]
|
||||
kept = [item for item in tasks if str(item.get("id") or "") != wanted]
|
||||
if len(kept) == len(tasks):
|
||||
return False
|
||||
data["tasks"] = kept
|
||||
_write_scheduled_tasks(data, drive_root)
|
||||
return True
|
||||
|
||||
|
||||
def sync_skill_schedules(skills: List[Any], *, drive_root: pathlib.Path | None = None) -> Dict[str, Any]:
|
||||
"""Sync reviewed skill manifest scheduled_tasks into the core schedule table."""
|
||||
with _queue()._queue_lock:
|
||||
data = list_scheduled_tasks(drive_root)
|
||||
tasks = [item for item in data.get("tasks") or [] if isinstance(item, dict)]
|
||||
by_id = {str(item.get("id") or ""): dict(item) for item in tasks}
|
||||
with schedule_transaction(drive_root):
|
||||
data = load_schedule_store(drive_root)
|
||||
by_id = {str(item.get("id") or ""): dict(item) for item in data.get("tasks") or []}
|
||||
touched: list[str] = []
|
||||
blocked_skill_names = {
|
||||
str(getattr(skill, "name", "") or "") for skill in skills
|
||||
|
|
@ -160,14 +514,18 @@ def sync_skill_schedules(skills: List[Any], *, drive_root: pathlib.Path | None =
|
|||
timing_changed = (
|
||||
dict(record.get("trigger") or {}) != trigger
|
||||
or str(record.get("timezone") or "") != str(spec.get("timezone") or "")
|
||||
or str(record.get("skill_content_hash") or "") != str(getattr(skill, "content_hash", ""))
|
||||
)
|
||||
# The suppression marker is keyed by the SCHEDULE ID, so an owner
|
||||
# decision survives a manifest edit that changes the row's content
|
||||
# under the same id — the resync reconciles what the row says, not
|
||||
# whether the owner still wants it to fire.
|
||||
manual_override = str(record.get("manual_override") or "").strip().lower()
|
||||
next_record = {
|
||||
**record,
|
||||
"id": schedule_id,
|
||||
"name": f"{getattr(skill, 'name', '')}/{name}",
|
||||
"description": str(spec.get("description") or f"Scheduled skill task {getattr(skill, 'name', '')}/{name}"),
|
||||
"enabled": bool(schedule_ready),
|
||||
"enabled": False if manual_override in SUPPRESSED_OVERRIDES else bool(schedule_ready),
|
||||
"timezone": str(spec.get("timezone") or ""),
|
||||
"trigger": trigger,
|
||||
"task": {
|
||||
|
|
@ -197,6 +555,7 @@ def sync_skill_schedules(skills: List[Any], *, drive_root: pathlib.Path | None =
|
|||
str(record.get("source") or "") == "skill_manifest"
|
||||
and str(record.get("skill") or "") not in blocked_skill_names
|
||||
and schedule_id not in touched
|
||||
and not _is_suppressed(record)
|
||||
):
|
||||
by_id.pop(schedule_id, None)
|
||||
changed = True
|
||||
|
|
@ -218,21 +577,80 @@ def resync_skill_schedules(drive_root: pathlib.Path | None = None) -> Dict[str,
|
|||
)
|
||||
|
||||
|
||||
def _schedule_running_or_queued(schedule_id: str) -> bool:
|
||||
if not schedule_id:
|
||||
return False
|
||||
for task in _queue().PENDING:
|
||||
def _rows_hold_schedule(rows: Any, schedule_id: str) -> bool:
|
||||
"""Whether any queue row (a PENDING task or a RUNNING/snapshot wrapper) is its task."""
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
task = row.get("task") if isinstance(row.get("task"), dict) else row
|
||||
meta = task.get("metadata") if isinstance(task.get("metadata"), dict) else {}
|
||||
if str(meta.get("schedule_id") or "") == schedule_id:
|
||||
return True
|
||||
for meta in _queue().RUNNING.values():
|
||||
task = meta.get("task") if isinstance(meta, dict) else None
|
||||
task_meta = task.get("metadata") if isinstance(task, dict) and isinstance(task.get("metadata"), dict) else {}
|
||||
if str(task_meta.get("schedule_id") or "") == schedule_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _schedule_running_or_queued(schedule_id: str, drive_root: pathlib.Path | None = None) -> bool | None:
|
||||
"""Whether a task this schedule already admitted is still pending or running.
|
||||
|
||||
``None`` means UNKNOWN, and it is load-bearing: PENDING/RUNNING are the
|
||||
SUPERVISOR process's live dicts, and ``manage_schedules`` runs in a worker
|
||||
process where those dicts are this process's own empty copies. Answering
|
||||
``False`` from there would claim nothing is in flight — exactly the claim a
|
||||
lifecycle action must not make, since it governs future dispatch only.
|
||||
|
||||
A worker therefore reads the durable queue snapshot instead. That file is
|
||||
rewritten on the transitions that change this answer (a scheduled admission,
|
||||
a dispatch, a task finishing), so it is the authoritative out-of-process
|
||||
record; when it is absent or unreadable the answer is unknown, not false.
|
||||
"""
|
||||
if not schedule_id:
|
||||
return False
|
||||
if not in_worker_process():
|
||||
return (_rows_hold_schedule(_queue().PENDING, schedule_id)
|
||||
or _rows_hold_schedule(_queue().RUNNING.values(), schedule_id))
|
||||
snapshot = read_json_dict(pathlib.Path(drive_root or _queue().DRIVE_ROOT)
|
||||
/ "state" / "queue_snapshot.json")
|
||||
if not isinstance(snapshot, dict):
|
||||
return None
|
||||
# ``read_json_dict`` intentionally returns ``{}``/None for malformed or
|
||||
# absent state. Missing rows are not evidence of an empty queue. Require
|
||||
# both arrays and a recent supervisor timestamp before answering False.
|
||||
pending = snapshot.get("pending")
|
||||
running = snapshot.get("running")
|
||||
if not isinstance(pending, list) or not isinstance(running, list):
|
||||
return None
|
||||
for row in (*pending, *running):
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
# Snapshot rows are either direct task dicts (legacy fixtures) or the
|
||||
# supervisor wrapper with a ``task`` object. A present but malformed
|
||||
# wrapper cannot prove that this schedule is absent from flight.
|
||||
if "task" in row and not isinstance(row.get("task"), dict):
|
||||
return None
|
||||
task = row.get("task") if isinstance(row.get("task"), dict) else row
|
||||
if (
|
||||
"metadata" in task
|
||||
and task.get("metadata") is not None
|
||||
and not isinstance(task.get("metadata"), dict)
|
||||
):
|
||||
return None
|
||||
stamp = snapshot.get("ts")
|
||||
try:
|
||||
parsed = datetime.datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
|
||||
# A slightly future stamp can come from clock skew between supervisor
|
||||
# and worker; it is still a fresh write. Only an old stamp is evidence
|
||||
# that this snapshot can no longer prove the absence of a live run.
|
||||
age = time.time() - parsed.timestamp()
|
||||
if age > _QUEUE_SNAPSHOT_MAX_AGE_SEC:
|
||||
return None
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return (_rows_hold_schedule(snapshot.get("pending"), schedule_id)
|
||||
or _rows_hold_schedule(snapshot.get("running"), schedule_id))
|
||||
|
||||
|
||||
def _task_from_schedule(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
template = dict(record.get("task") or {})
|
||||
owner_chat_id = _queue().load_state().get("owner_chat_id") or 0
|
||||
|
|
@ -288,7 +706,7 @@ def _task_from_schedule(record: Dict[str, Any]) -> Dict[str, Any]:
|
|||
def check_scheduled_tasks() -> None:
|
||||
"""Queue due cron/on-idle schedules using the normal supervisor queue."""
|
||||
global _last_skill_schedule_sync
|
||||
with _queue()._queue_lock:
|
||||
with schedule_transaction(_queue().DRIVE_ROOT):
|
||||
now_monotonic = time.monotonic()
|
||||
if now_monotonic - _last_skill_schedule_sync >= _SKILL_SCHEDULE_SYNC_INTERVAL_SEC:
|
||||
_last_skill_schedule_sync = now_monotonic
|
||||
|
|
@ -296,7 +714,13 @@ def check_scheduled_tasks() -> None:
|
|||
resync_skill_schedules(_queue().DRIVE_ROOT)
|
||||
except Exception:
|
||||
log.debug("Failed to sync skill schedules during scheduler tick", exc_info=True)
|
||||
data = list_scheduled_tasks()
|
||||
try:
|
||||
data = load_schedule_store(_queue().DRIVE_ROOT)
|
||||
except ScheduleStoreUnreadable:
|
||||
# The pass writes the table back at its end; on unreadable bytes that
|
||||
# would replace every row with an empty document. Skip instead.
|
||||
log.error("Scheduled task store is unreadable; skipping this scheduler pass")
|
||||
return
|
||||
changed = False
|
||||
collision_names = None
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
|
@ -310,7 +734,9 @@ def check_scheduled_tasks() -> None:
|
|||
changed = True
|
||||
trigger = record.get("trigger") if isinstance(record.get("trigger"), dict) else {}
|
||||
trigger_type = str(trigger.get("type") or "cron").strip().lower()
|
||||
if _schedule_running_or_queued(schedule_id):
|
||||
if _schedule_running_or_queued(schedule_id, _queue().DRIVE_ROOT) is not False:
|
||||
# Unknown reads as "still in flight": re-dispatching a schedule
|
||||
# whose previous run may be alive is worse than waiting a pass.
|
||||
continue
|
||||
tz = _timezone_for_schedule(record)
|
||||
now = now_utc.astimezone(tz)
|
||||
|
|
|
|||
349
supervisor/schedule_lifecycle.py
Normal file
349
supervisor/schedule_lifecycle.py
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
"""Owner-governed schedule lifecycle: create/edit, disable/delete/restore, remove.
|
||||
|
||||
The leaf beside ``supervisor/queue_schedules.py`` (the store, transaction,
|
||||
audit, projections, skill sync and dispatch): every OWNER-authored mutation of
|
||||
an existing or new row lives here, over the store's own transaction and audit
|
||||
seams, so the durable file keeps one writer discipline while the module that
|
||||
owns dispatch stays readable in one window (DEVELOPMENT "Paying down a size
|
||||
cap"). Callers reach these through ``supervisor.queue``; nothing here is a
|
||||
second scheduler or a second store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
from ouroboros.schedule_contract import schedule_slug
|
||||
from ouroboros.utils import utc_now_iso
|
||||
from supervisor import queue_schedules as _store
|
||||
from supervisor.queue_schedules import (
|
||||
SCHEDULE_ACTIONS,
|
||||
ScheduleLockTimeout,
|
||||
ScheduleRefused,
|
||||
ScheduleStoreUnreadable,
|
||||
_RUNTIME_OWNED_FIELDS,
|
||||
_audit_row,
|
||||
_audit_schedule_mutation,
|
||||
_is_consumed_once,
|
||||
_is_suppressed,
|
||||
_schedule_next_run,
|
||||
_write_scheduled_tasks,
|
||||
load_schedule_store,
|
||||
schedule_transaction,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Restore lifts the owner's suppression only for a schedule its skill's manifest
|
||||
# STILL declares (acceptance claim: "Restore clears suppression only for extant
|
||||
# manifest"). These blockers say the manifest is absent or unknowable, so the
|
||||
# marker stays and the action is a typed no-change refusal; the other blockers
|
||||
# (permission, readiness, identity) describe an extant row that merely cannot
|
||||
# dispatch yet, and restore lifts the marker while reporting them.
|
||||
MANIFEST_ABSENT_BLOCKERS: frozenset[str] = frozenset({
|
||||
"skill_unknown", "skill_discovery_failed", "skill_absent", "schedule_absent_from_manifest",
|
||||
})
|
||||
# Typed no-change outcomes of ``mutate_scheduled_task``: the intent and its
|
||||
# refusal are both audited, the row is byte-identical afterwards.
|
||||
UNCHANGED_STATUSES: frozenset[str] = frozenset({"consumed_not_rearmed", "manifest_absent", "not_suppressed"})
|
||||
|
||||
|
||||
def _skill_schedule_dispatchable(drive_root: pathlib.Path, record: Dict[str, Any]) -> tuple[bool, str]:
|
||||
"""Whether a skill-manifest row may dispatch again, and the blocker if not.
|
||||
|
||||
Restore lifts the OWNER's suppression; it never enables the skill. So the
|
||||
row comes back only as far as the skill lifecycle currently allows: a skill
|
||||
that was uninstalled, lost the schedule from its manifest, lost its
|
||||
``supervised_task`` permission or is not review-ready returns disabled with
|
||||
a named reason instead of a schedule that would fail at dispatch.
|
||||
"""
|
||||
skill_name = str(record.get("skill") or "").strip()
|
||||
if not skill_name:
|
||||
return False, "skill_unknown"
|
||||
try:
|
||||
from ouroboros.config import get_skills_repo_path
|
||||
from ouroboros.skill_loader import discover_skills
|
||||
|
||||
skills = discover_skills(pathlib.Path(drive_root), repo_path=get_skills_repo_path())
|
||||
except Exception:
|
||||
log.debug("skill discovery failed while restoring %s", record.get("id"), exc_info=True)
|
||||
return False, "skill_discovery_failed"
|
||||
skill = next((item for item in skills if str(getattr(item, "name", "")) == skill_name), None)
|
||||
if skill is None:
|
||||
return False, "skill_absent"
|
||||
if bool(getattr(skill, "identity_collision", False)):
|
||||
return False, "skill_identity_collision"
|
||||
manifest = getattr(skill, "manifest", None)
|
||||
declared = {
|
||||
schedule_slug("skill", skill_name, str(spec.get("name") or "").strip())
|
||||
for spec in list(getattr(manifest, "scheduled_tasks", []) or [])
|
||||
if isinstance(spec, dict) and str(spec.get("name") or "").strip() and str(spec.get("cron") or "").strip()
|
||||
}
|
||||
if str(record.get("id") or "") not in declared:
|
||||
return False, "schedule_absent_from_manifest"
|
||||
if "supervised_task" not in set(getattr(manifest, "permissions", []) or []):
|
||||
return False, "skill_permission_missing"
|
||||
try:
|
||||
from ouroboros.skill_readiness import skill_readiness_for_execution
|
||||
|
||||
if not skill_readiness_for_execution(pathlib.Path(drive_root), skill).ready:
|
||||
return False, "skill_not_ready"
|
||||
except Exception:
|
||||
log.debug("skill readiness probe failed while restoring %s", record.get("id"), exc_info=True)
|
||||
return False, "readiness_unavailable"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _merge_onto_current(existing: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Merge an owner-authored record onto the CURRENT row, under the write lock.
|
||||
|
||||
Only the fields a caller actually authors win; provenance, run history, the
|
||||
consumed receipt and the owner's suppression marker are read from the row on
|
||||
disk, so a full-record PUT built from a stale GET cannot roll back a
|
||||
concurrent scheduler tick or skill resync.
|
||||
"""
|
||||
trigger_changed = dict(existing.get("trigger") or {}) != dict(incoming.get("trigger") or {})
|
||||
timezone_changed = str(existing.get("timezone") or "") != str(incoming.get("timezone") or "")
|
||||
consumed = _is_consumed_once(existing)
|
||||
merged = {**existing, **incoming}
|
||||
for key in _RUNTIME_OWNED_FIELDS:
|
||||
if key in existing:
|
||||
merged[key] = existing[key]
|
||||
else:
|
||||
merged.pop(key, None)
|
||||
if trigger_changed:
|
||||
# A new firing point is a new one-shot: the consumed receipt goes with
|
||||
# the instant that earned it, and the next run is recomputed.
|
||||
merged.pop("completed_at", None)
|
||||
merged["next_run_at"] = _schedule_next_run(merged)
|
||||
else:
|
||||
if consumed and bool(incoming.get("enabled")):
|
||||
raise ScheduleRefused(
|
||||
"consumed_not_rearmed",
|
||||
"this one-shot schedule already fired; re-arming it requires a new "
|
||||
"trigger.run_at (a fresh run_at clears completed_at)")
|
||||
if "completed_at" in existing:
|
||||
# A timezone-only edit re-reads the SAME instant in another zone; it
|
||||
# is not a new firing point and must not resurrect a fired row.
|
||||
merged["completed_at"] = existing["completed_at"]
|
||||
if timezone_changed:
|
||||
merged["next_run_at"] = _schedule_next_run(merged)
|
||||
elif "next_run_at" in existing:
|
||||
merged["next_run_at"] = existing["next_run_at"]
|
||||
if consumed:
|
||||
merged["enabled"] = False
|
||||
if str(merged.get("source") or "") == "skill_manifest":
|
||||
# A skill row's ``enabled`` is RECONCILED from skill readiness, so an
|
||||
# edit that flips it would be undone by the next resync and look durable
|
||||
# meanwhile. The owner's durable statement is the suppression marker,
|
||||
# which only the lifecycle action writes — so that is where this goes.
|
||||
if bool(existing.get("enabled", True)) != bool(merged.get("enabled", True)):
|
||||
raise ScheduleRefused(
|
||||
"lifecycle_action_required",
|
||||
"a skill-manifest schedule is enabled by its skill's readiness; use the "
|
||||
"disable/restore lifecycle action, which records the owner's decision so "
|
||||
"the skill resync cannot undo it")
|
||||
if _is_suppressed(merged):
|
||||
merged["enabled"] = False
|
||||
return merged
|
||||
|
||||
|
||||
def upsert_scheduled_task(record: Dict[str, Any], *, drive_root: pathlib.Path | None = None,
|
||||
actor: str = "", task_id: str = "", reason: str = "") -> Dict[str, Any]:
|
||||
"""Create or replace a scheduled task record.
|
||||
|
||||
Returns the stored row plus an ``audit`` field: ``recorded`` when both audit
|
||||
facts landed, ``incomplete`` when the change is durable but its outcome
|
||||
record is not. The key rides the RETURNED COPY only — it is never persisted.
|
||||
"""
|
||||
root = pathlib.Path(drive_root or _store._queue().DRIVE_ROOT)
|
||||
with schedule_transaction(root):
|
||||
data = load_schedule_store(root)
|
||||
tasks = list(data.get("tasks") or [])
|
||||
incoming = dict(record)
|
||||
schedule_id = str(incoming.get("id") or "").strip() or uuid.uuid4().hex[:8]
|
||||
incoming["id"] = schedule_id
|
||||
existing = next((item for item in tasks if str(item.get("id") or "") == schedule_id), None)
|
||||
operation_id = uuid.uuid4().hex[:12]
|
||||
action = "edit" if existing is not None else "create"
|
||||
audit = {
|
||||
"drive_root": root, "operation_id": operation_id,
|
||||
"actor": str(actor or "").strip() or "owner", "task_id": task_id,
|
||||
"action": action, "schedule_id": schedule_id,
|
||||
"reason": str(reason or "").strip() or f"schedule_{action}",
|
||||
}
|
||||
if not _audit_schedule_mutation(phase="intent", result="intended",
|
||||
before=existing, **audit):
|
||||
raise ScheduleRefused(
|
||||
"audit_unavailable",
|
||||
"the schedule audit log could not be written; nothing was changed")
|
||||
if existing is not None:
|
||||
try:
|
||||
incoming = _merge_onto_current(existing, incoming)
|
||||
except ScheduleRefused as refusal:
|
||||
# Every announced intent gets an outcome, including a refusal:
|
||||
# a dangling intent would read as a change nobody can account for.
|
||||
_audit_schedule_mutation(phase="outcome", result=refusal.status,
|
||||
before=existing, **audit)
|
||||
raise
|
||||
incoming.setdefault("enabled", True)
|
||||
incoming.setdefault("created_at", utc_now_iso())
|
||||
incoming["updated_at"] = utc_now_iso()
|
||||
if not incoming.get("next_run_at"):
|
||||
incoming["next_run_at"] = _schedule_next_run(incoming)
|
||||
tasks = [item for item in tasks if str(item.get("id") or "") != schedule_id]
|
||||
tasks.append(incoming)
|
||||
data["tasks"] = tasks
|
||||
_write_scheduled_tasks(data, root)
|
||||
recorded = _audit_schedule_mutation(phase="outcome", result="stored",
|
||||
before=existing, after=incoming, **audit)
|
||||
if not recorded:
|
||||
log.error("schedule %s stored but its audit outcome is incomplete", schedule_id)
|
||||
return {**incoming, "audit": "recorded" if recorded else "incomplete"}
|
||||
|
||||
|
||||
def mutate_scheduled_task(action: str, schedule_id: str, *, reason: str,
|
||||
actor: str, task_id: str = "",
|
||||
drive_root: pathlib.Path | None = None) -> Dict[str, Any]:
|
||||
"""Apply one owner-governed future-dispatch mutation, audited intent-first.
|
||||
|
||||
Affects DISPATCH only: a task already admitted from this schedule keeps
|
||||
running, and ``running_or_queued`` says so rather than implying a stop — or
|
||||
says ``None`` when this process cannot see the live queue, because an
|
||||
unknown in-flight run is not the same claim as no run at all.
|
||||
"""
|
||||
wanted, operation = str(schedule_id or "").strip(), str(action or "").strip().lower()
|
||||
if operation not in SCHEDULE_ACTIONS:
|
||||
return {"ok": False, "changed": False, "status": "invalid_action",
|
||||
"schedule_id": wanted, "allowed": sorted(SCHEDULE_ACTIONS), "audit": "not_written"}
|
||||
if not wanted:
|
||||
return {"ok": False, "changed": False, "status": "missing_schedule_id", "audit": "not_written"}
|
||||
if not str(reason or "").strip():
|
||||
return {"ok": False, "changed": False, "status": "reason_required",
|
||||
"schedule_id": wanted, "audit": "not_written"}
|
||||
root = pathlib.Path(drive_root or _store._queue().DRIVE_ROOT)
|
||||
try:
|
||||
with schedule_transaction(root):
|
||||
try:
|
||||
data = load_schedule_store(root)
|
||||
except ScheduleStoreUnreadable as exc:
|
||||
return {"ok": False, "changed": False, "status": "store_unreadable",
|
||||
"schedule_id": wanted, "detail": str(exc), "audit": "not_written"}
|
||||
tasks = list(data.get("tasks") or [])
|
||||
current = next((item for item in tasks if str(item.get("id") or "") == wanted), None)
|
||||
if current is None:
|
||||
return {"ok": False, "changed": False, "status": "not_found",
|
||||
"schedule_id": wanted, "audit": "not_written"}
|
||||
before = dict(current)
|
||||
operation_id = uuid.uuid4().hex[:12]
|
||||
audit = {
|
||||
"drive_root": root, "operation_id": operation_id, "actor": actor,
|
||||
"task_id": task_id, "action": operation, "schedule_id": wanted, "reason": reason,
|
||||
}
|
||||
if not _audit_schedule_mutation(phase="intent", result="intended", before=before, **audit):
|
||||
return {"ok": False, "changed": False, "status": "audit_unavailable",
|
||||
"schedule_id": wanted, "audit": "not_written",
|
||||
"detail": "the schedule audit log could not be written; nothing was changed"}
|
||||
running = _store._schedule_running_or_queued(wanted, root)
|
||||
skill_row = str(current.get("source") or "") == "skill_manifest"
|
||||
detail, removed = "", False
|
||||
if operation == "disable":
|
||||
current["enabled"] = False
|
||||
if skill_row:
|
||||
current["manual_override"] = "disabled"
|
||||
status = "updated"
|
||||
elif operation == "delete":
|
||||
if skill_row:
|
||||
# Retained as a suppressed record: dropping the row would only
|
||||
# have it recreated by the next lifecycle resync, and the owner
|
||||
# would never see that their delete did not hold.
|
||||
current["enabled"] = False
|
||||
current["manual_override"] = "deleted"
|
||||
status = "suppressed"
|
||||
else:
|
||||
tasks = [item for item in tasks if str(item.get("id") or "") != wanted]
|
||||
status, removed = "deleted", True
|
||||
elif _is_consumed_once(current):
|
||||
status = "consumed_not_rearmed"
|
||||
detail = "a one-shot that already fired is history; schedule a new run_at instead"
|
||||
elif skill_row:
|
||||
# Probed UNDER the transaction: readiness is skill state on disk,
|
||||
# not a row field, so a probe taken before the lock could be
|
||||
# stale by the time the row is written (a resync or an owner
|
||||
# skill disable in between) and would re-arm a schedule its skill
|
||||
# cannot dispatch. The hold is bounded by skill discovery; the
|
||||
# honest outcome is worth that contention.
|
||||
dispatchable, blocker = _skill_schedule_dispatchable(root, current)
|
||||
if not _is_suppressed(current):
|
||||
# Nothing of the owner's to lift: a skill row without the marker
|
||||
# is disabled by its skill's READINESS and re-arms on resync
|
||||
# when the skill is ready. Saying "suppression lifted" here
|
||||
# would describe a decision nobody took.
|
||||
status = "not_suppressed"
|
||||
detail = ("this skill schedule is not suppressed; it is "
|
||||
+ (f"disabled by skill readiness ({blocker}) and re-arms when the skill is ready"
|
||||
if blocker else "already enabled"))
|
||||
elif blocker in MANIFEST_ABSENT_BLOCKERS:
|
||||
# No extant manifest schedule to restore INTO: the owner's
|
||||
# suppression stays on the row (the next resync would otherwise
|
||||
# drop the unsuppressed orphan and forget the decision before a
|
||||
# reinstall could honor it).
|
||||
status = "manifest_absent"
|
||||
detail = (f"{blocker}: the skill no longer declares this schedule; "
|
||||
"suppression kept until it does")
|
||||
else:
|
||||
current.pop("manual_override", None)
|
||||
current["enabled"] = dispatchable
|
||||
status = "updated"
|
||||
if not dispatchable:
|
||||
status, detail = "restored_not_ready", blocker
|
||||
else:
|
||||
current["enabled"] = True
|
||||
status = "updated"
|
||||
changed = status not in UNCHANGED_STATUSES
|
||||
if changed:
|
||||
if not removed:
|
||||
current["updated_at"] = utc_now_iso()
|
||||
data["tasks"] = tasks
|
||||
_write_scheduled_tasks(data, root)
|
||||
recorded = _audit_schedule_mutation(
|
||||
phase="outcome", result=status, before=before,
|
||||
after=(None if removed else current), **audit)
|
||||
achieved = status in {"updated", "deleted", "suppressed"}
|
||||
if changed and not recorded:
|
||||
# Keep the lifecycle blocker beside the audit disclosure: a lost
|
||||
# outcome record must not erase WHY the row is still not ready.
|
||||
detail = ((f"{detail}; " if detail else "")
|
||||
+ "the change is durable; its audit outcome record could not be written")
|
||||
return {
|
||||
"ok": bool(achieved and recorded), "changed": changed,
|
||||
"status": ("changed_audit_incomplete" if (changed and not recorded) else status),
|
||||
"schedule_id": wanted, "operation_id": operation_id,
|
||||
"running_or_queued": running,
|
||||
"audit": "recorded" if recorded else ("incomplete" if changed else "not_written"),
|
||||
**({"detail": detail} if detail else {}),
|
||||
"schedule": None if removed else _audit_row(current),
|
||||
}
|
||||
except ScheduleLockTimeout as exc:
|
||||
return {"ok": False, "changed": False, "status": "lock_timeout",
|
||||
"schedule_id": wanted, "detail": str(exc), "audit": "not_written"}
|
||||
|
||||
|
||||
def remove_scheduled_task(schedule_id: str, *, drive_root: pathlib.Path | None = None,
|
||||
actor: str = "", task_id: str = "", reason: str = "") -> bool:
|
||||
"""Remove a scheduled task record by id, through the one audited delete seam.
|
||||
|
||||
A skill-manifest row is SUPPRESSED rather than dropped (see
|
||||
``mutate_scheduled_task``); either way the row stops dispatching, which is
|
||||
what this boolean has always meant to its callers.
|
||||
"""
|
||||
outcome = mutate_scheduled_task(
|
||||
"delete", schedule_id, drive_root=drive_root, task_id=task_id,
|
||||
actor=str(actor or "").strip() or "host",
|
||||
reason=str(reason or "").strip() or "schedule_removed")
|
||||
return bool(outcome.get("changed"))
|
||||
|
||||
|
||||
|
|
@ -215,3 +215,157 @@ def test_foldable_ids_take_an_explicit_clock(data_root, monkeypatch):
|
|||
assert uc._foldable_attempt_ids(records, now_ts=T0) == set()
|
||||
assert len(uc._foldable_attempt_ids(records, now_ts=T0 + 49 * HOUR)) == 1
|
||||
assert _request(data_root).category == "" # the fixture request leaves category to the scope
|
||||
|
||||
|
||||
# --- what a route's price actually costs the allowance ----------------------------
|
||||
#
|
||||
# The wake prompt asks this mind to tell incremental CASH from subscription quota
|
||||
# and time. These pin what the EXISTING money reducer already answers, route by
|
||||
# route, so the distinction the prompt draws is the one the allowance enforces.
|
||||
# There is no quota budget here and none is wanted: quota and time are separate
|
||||
# observed axes, and only cash is metered against the owner's daily dollars.
|
||||
|
||||
|
||||
def _subscription(root, monkeypatch, ts, *, session, spend, estimated=False, task="c-child",
|
||||
root_task="c-root", category="consciousness_task"):
|
||||
_at(monkeypatch, ts)
|
||||
return ua.record_subscription_session(
|
||||
session, drive_root=root, route="claudexor:claude", model="fable", task_id=task,
|
||||
root_task_id=root_task, spend_usd=spend, spend_estimated=estimated,
|
||||
reset_at="2026-09-17T00:00:00Z", category=category)
|
||||
|
||||
|
||||
def test_a_confirmed_free_route_spends_no_cash_and_leaves_the_window_final(data_root, monkeypatch):
|
||||
"""A settled zero the engine called FINAL is a fact, not a missing number."""
|
||||
monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_DAILY_USD", "20")
|
||||
_wake(data_root, monkeypatch, T0 - 2 * HOUR, 0.0)
|
||||
_subscription(data_root, monkeypatch, T0 - HOUR, session="sess-free", spend=0.0)
|
||||
window = allowance.allowance_window(data_root, now=T0)
|
||||
assert window["status"] == "available"
|
||||
assert window["accounted_usd"] == pytest.approx(0.0)
|
||||
assert window["remaining_usd"] == pytest.approx(20.0)
|
||||
assert window["window_rows"] == 2
|
||||
# Free is not unknown and not open: nothing here makes the window "at least".
|
||||
assert window["unknown_unmetered"] == 0 and window["non_final_rows"] == 0
|
||||
|
||||
|
||||
def test_an_estimated_zero_costs_nothing_yet_but_the_window_is_not_final(data_root, monkeypatch):
|
||||
"""The engine reported $0 it has not settled; the honest answer says so."""
|
||||
monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_DAILY_USD", "20")
|
||||
_wake(data_root, monkeypatch, T0 - 2 * HOUR, 1.0)
|
||||
_subscription(data_root, monkeypatch, T0 - HOUR, session="sess-est", spend=0.0, estimated=True)
|
||||
window = allowance.allowance_window(data_root, now=T0)
|
||||
assert window["accounted_usd"] == pytest.approx(1.0) # the estimate adds no dollars
|
||||
assert window["non_final_rows"] == 1 # but the window is still open
|
||||
assert window["unknown_unmetered"] == 0 # and the price is not unknown
|
||||
|
||||
|
||||
def test_an_undisclosed_price_is_unknown_and_reads_as_at_least(data_root, monkeypatch):
|
||||
"""No number at all: counted as unknown rather than silently as zero."""
|
||||
monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_DAILY_USD", "20")
|
||||
_wake(data_root, monkeypatch, T0 - 2 * HOUR, 1.0)
|
||||
_subscription(data_root, monkeypatch, T0 - HOUR, session="sess-unknown", spend=None)
|
||||
window = allowance.allowance_window(data_root, now=T0)
|
||||
assert window["accounted_usd"] == pytest.approx(1.0)
|
||||
assert window["unknown_unmetered"] == 1 and window["non_final_rows"] == 1
|
||||
|
||||
|
||||
def test_a_known_reservation_bound_stays_accounted_while_the_price_is_unknown(data_root, monkeypatch):
|
||||
"""An unresolved attempt holds its upper bound: disclosed, not forgotten."""
|
||||
monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_DAILY_USD", "20")
|
||||
_wake(data_root, monkeypatch, T0 - 3 * HOUR, 1.0)
|
||||
_at(monkeypatch, T0 - 2 * HOUR)
|
||||
reservation = ua.reserve_attempt(_request(data_root, reservation_usd=4.0, task_id="c-child",
|
||||
root_task_id="c-root", category="consciousness_task"))
|
||||
ua.mark_dispatched(reservation)
|
||||
ua.mark_unresolved(reservation, "the provider went dark")
|
||||
window = allowance.allowance_window(data_root, now=T0)
|
||||
assert window["accounted_usd"] == pytest.approx(5.0) # 1.0 settled + the 4.0 bound
|
||||
assert window["remaining_usd"] == pytest.approx(15.0)
|
||||
assert window["non_final_rows"] == 1
|
||||
|
||||
|
||||
def test_a_positive_charge_and_a_descendants_charge_both_land_on_the_one_allowance(data_root, monkeypatch):
|
||||
"""The wake, the root it started and that root's descendant share one window."""
|
||||
monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_DAILY_USD", "20")
|
||||
_wake(data_root, monkeypatch, T0 - 4 * HOUR, 1.0)
|
||||
_started(data_root, monkeypatch, T0 - 3 * HOUR, 2.0)
|
||||
# A grandchild keeps its own category; the ROOT is what puts it in the window.
|
||||
_subscription(data_root, monkeypatch, T0 - HOUR, session="sess-grandchild", spend=0.5,
|
||||
task="c-grandchild", category="subagent")
|
||||
window = allowance.allowance_window(data_root, now=T0)
|
||||
assert window["accounted_usd"] == pytest.approx(3.5)
|
||||
assert window["roots"] == ["c-root", "wake-1"] and window["window_rows"] == 3
|
||||
# The same money on a root that is NOT consciousness's stays out of the window.
|
||||
_at(monkeypatch, T0 - HOUR)
|
||||
ua.record_subscription_session("sess-owner", drive_root=data_root, route="claudexor:claude",
|
||||
model="fable", task_id="owner-child", root_task_id="owner",
|
||||
spend_usd=9.0, reset_at="2026-09-17T00:00:00Z", category="subagent")
|
||||
assert allowance.allowance_window(data_root, now=T0)["accounted_usd"] == pytest.approx(3.5)
|
||||
|
||||
|
||||
def test_a_free_route_gets_no_exception_from_an_exhausted_or_zero_allowance(data_root, monkeypatch):
|
||||
"""Cheap is not free of the owner's decision: the door is the same door."""
|
||||
import supervisor.queue as queue
|
||||
|
||||
monkeypatch.setattr(queue, "DRIVE_ROOT", data_root)
|
||||
monkeypatch.setattr(queue, "live_consciousness_root_count", lambda: 0)
|
||||
monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_MAX_TASKS", "2")
|
||||
wake_task = {"id": "c-2", "root_task_id": "c-2",
|
||||
"metadata": {"initiator": "consciousness", "usage_category": "consciousness_task"}}
|
||||
|
||||
# Exhausted: the spend is real, and a zero-cost route does not reopen the door.
|
||||
monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_DAILY_USD", "3")
|
||||
_wake(data_root, monkeypatch, T0 - HOUR, 3.5)
|
||||
_subscription(data_root, monkeypatch, T0 - HOUR, session="sess-free-2", spend=0.0,
|
||||
task="c-2", root_task="c-2")
|
||||
monkeypatch.setattr(allowance.time, "time", lambda: T0)
|
||||
reason, detail = queue._consciousness_admission_block(wake_task)
|
||||
assert reason == "consciousness_allowance_exhausted" and "3.50" in detail
|
||||
|
||||
# Zero allowance is a real owner choice, stated as one.
|
||||
monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_DAILY_USD", "0")
|
||||
reason, detail = queue._consciousness_admission_block(wake_task)
|
||||
assert reason == "consciousness_allowance_exhausted" and "DAILY_USD=0" in detail
|
||||
|
||||
|
||||
def test_an_unreadable_ledger_refuses_the_start_instead_of_assuming_free(data_root, monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_DAILY_USD", "20")
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise OSError("ledger unreadable")
|
||||
|
||||
monkeypatch.setattr(allowance, "_render_cached", _boom)
|
||||
window = allowance.allowance_window(data_root, now=T0)
|
||||
assert window["status"] == allowance.STATUS_UNKNOWN
|
||||
assert window["accounted_usd"] is None and window["remaining_usd"] is None
|
||||
assert allowance.remaining_allowance_usd(data_root, now=T0) is None
|
||||
|
||||
|
||||
def test_the_wake_prompt_money_framing_matches_what_the_ledger_actually_records():
|
||||
"""The prompt's three money words are the reducer's, not a separate vocabulary."""
|
||||
import pathlib
|
||||
|
||||
prompt = (pathlib.Path(__file__).resolve().parent.parent / "prompts" / "CONSCIOUSNESS.md").read_text(
|
||||
encoding="utf-8")
|
||||
assert "Distinguish incremental cash cost from subscription quota and time." in prompt
|
||||
assert "A confirmed zero-cost call spends no cash; an undisclosed cost is unknown." in prompt
|
||||
assert "Existing limits still apply." in prompt
|
||||
# A confirmed zero and an undisclosed price are DIFFERENT ledger rows, which is
|
||||
# exactly the distinction the sentence above asks this mind to make.
|
||||
free = {"kind": "subscription_session", "state": "settled", "cost_usd": 0.0,
|
||||
"cost_final": True, "pricing_known": True}
|
||||
undisclosed = {"kind": "subscription_session", "state": "settled", "cost_usd": None,
|
||||
"cost_final": False, "pricing_known": False}
|
||||
from ouroboros._usage_rows import _summary
|
||||
|
||||
assert _summary([free])["accounted_usd"] == 0.0 and _summary([free])["unknown_unmetered"] == 0
|
||||
assert _summary([undisclosed])["unknown_unmetered"] == 1
|
||||
# The allowance is the only budget the prompt promises; there is no quota one.
|
||||
assert set(allowance.allowance_window.__doc__.split()) >= {"allowance"}
|
||||
assert not hasattr(allowance, "quota_window")
|
||||
# Time and quota stay separate OBSERVED axes of the same reducer, never budgets.
|
||||
session = _summary([{**free, "subscription_route": "r", "subscription_reset_at": "2026-09-17T00:00:00Z"}])
|
||||
assert session["subscription_sessions"] == 1
|
||||
assert session["subscription_windows"] == {"r": "2026-09-17T00:00:00Z"}
|
||||
assert "limit_usd" not in session
|
||||
|
|
|
|||
|
|
@ -57,8 +57,11 @@ def test_levels_and_their_two_consequences():
|
|||
assert ca.disabled_tools_for("act") == list(ca.ACT_DISABLED)
|
||||
observe = ca.disabled_tools_for("observe")
|
||||
assert set(ca.ACT_DISABLED) <= set(observe)
|
||||
assert {"promote_chat_to_task", "schedule_subagent", "write_file", "run_command",
|
||||
assert {"promote_chat_to_task", "run_command",
|
||||
"browser_action", "initiate_presence", "submit_skill_to_hub"} <= set(observe)
|
||||
# The names with a real read-only path are kept and narrowed on ARGUMENTS.
|
||||
assert ca.OBSERVE_ARGUMENT_NARROWED.isdisjoint(observe)
|
||||
assert {"schedule_subagent", "delegate_start", "cancel_task"} <= ca.OBSERVE_ARGUMENT_NARROWED
|
||||
# The nanny of a running campaign is never withheld, at any level.
|
||||
assert "steer_task" not in observe and "steer_task" not in ca.disabled_tools_for("act")
|
||||
# Observe is an EXCEPTION list: reading and talking stay available by default.
|
||||
|
|
@ -81,7 +84,9 @@ def test_observe_table_covers_every_registry_entry_marked_mutates_worktree(tmp_p
|
|||
reg = _registry(tmp_path)
|
||||
marked = {e.name for e in reg._entries.values() if e.mutates_worktree and not e.alias_for}
|
||||
assert marked, "the catalog carries mutates_worktree entries"
|
||||
missing = marked - set(ca.OBSERVE_DISABLED)
|
||||
# The argument-narrowed names have a read-only Observe path; what they may be
|
||||
# ASKED to do is checked at dispatch instead of hiding the whole tool.
|
||||
missing = marked - set(ca.OBSERVE_DISABLED) - ca.OBSERVE_ARGUMENT_NARROWED
|
||||
assert not missing, f"mutates_worktree entries missing from OBSERVE_WORLD_MUTATION_TOOLS: {sorted(missing)}"
|
||||
unknown = set(ca.OBSERVE_DISABLED) - {e.name for e in reg._entries.values()}
|
||||
# Skill/project tools are registered lazily (skills, journal); the built-in names must exist.
|
||||
|
|
|
|||
390
tests/test_consciousness_observe_dispatch.py
Normal file
390
tests/test_consciousness_observe_dispatch.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
"""What an Observe wake may ASK a tool to do, once it is allowed to call it.
|
||||
|
||||
Observe keeps a short list of mutating-table names visible because each has a
|
||||
genuine read-only or own-work use (``OBSERVE_ARGUMENT_NARROWED``); withholding
|
||||
the NAME would take that use away with the mutation. The narrowing is therefore
|
||||
on the ARGUMENTS, at dispatch — the same seam ``test_consciousness_authority``
|
||||
pins for a withheld name. These are the positive and negative halves of it,
|
||||
including what a read-only descendant of such a wake may ask for in turn.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ouroboros import consciousness_authority as ca
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
from tests.test_consciousness_authority import _registry, _wake_task
|
||||
|
||||
|
||||
_MUTATING_SHAPES = [
|
||||
("schedule_subagent", {"objective": "write", "expected_output": "diff",
|
||||
"write_surface": "external_workspace"}),
|
||||
("schedule_subagent", {"objective": "write", "expected_output": "diff", "may_mutate": True}),
|
||||
("delegate_start", {"prompt": "edit", "access": "workspace_write"}),
|
||||
("delegate_start", {"prompt": "edit", "root": "skills/demo"}),
|
||||
("write_file", {"root": "user_files", "path": "no.txt", "content": "no"}),
|
||||
("write_file", {"path": "server.py", "content": "no"}),
|
||||
("edit_text", {"root": "active_workspace", "path": "x.py", "old": "a", "new": "b"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "args"), _MUTATING_SHAPES)
|
||||
def test_observe_refuses_a_mutating_shape_of_a_tool_it_still_sees(tmp_path, monkeypatch, name, args):
|
||||
"""The negative half: the name is dispatchable, the mutating ARGUMENTS are not."""
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
wake = _registry(tmp_path, _wake_task("observe")["metadata"])
|
||||
assert wake.get_schema_by_name(name) is not None
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" in wake.execute(name, args)
|
||||
|
||||
|
||||
def test_observe_keeps_its_own_research_and_notes(tmp_path, monkeypatch):
|
||||
"""The positive half: a read-only child, a read-only session and its own drive."""
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
wake = _registry(tmp_path, _wake_task("observe")["metadata"])
|
||||
for name in ("schedule_subagent", "delegate_start", "manage_schedules", "cancel_task"):
|
||||
assert wake.get_schema_by_name(name) is not None, name
|
||||
written = wake.execute("write_file", {"root": "task_drive", "path": "notes.txt",
|
||||
"content": "internal finding"})
|
||||
assert "BLOCKED" not in written
|
||||
assert (tmp_path / "drive" / "task_drives" / "turn-1" / "notes.txt").read_text() == "internal finding"
|
||||
# An omitted access on the nanny verb is the READ-ONLY shape, not a refusal:
|
||||
# withholding it would take the research session away with the write.
|
||||
from ouroboros import consciousness_authority as authority
|
||||
|
||||
observe_meta = _wake_task("observe")["metadata"]
|
||||
assert authority.observe_argument_refusal(observe_meta, "delegate_start", {"prompt": "read"}) == ""
|
||||
assert authority.observe_argument_refusal(observe_meta, "delegate_start", {"prompt": "read", "access": "readonly"}) == ""
|
||||
assert authority.observe_argument_refusal(observe_meta, "schedule_subagent", {"objective": "read"}) == ""
|
||||
assert authority.observe_argument_refusal(observe_meta, "schedule_subagent",
|
||||
{"objective": "read", "write_surface": "read_only"}) == ""
|
||||
# An Act/Full turn is not narrowed at all by this seam.
|
||||
for level in ("act", "full"):
|
||||
assert authority.observe_argument_refusal(_wake_task(level)["metadata"], "write_file",
|
||||
{"root": "user_files", "path": "x"}) == ""
|
||||
assert authority.observe_argument_refusal({"client_message_id": "cm"}, "write_file",
|
||||
{"root": "user_files", "path": "x"}) == ""
|
||||
|
||||
|
||||
def test_the_write_root_observe_may_choose_still_confines_the_path_it_names(tmp_path, monkeypatch):
|
||||
"""The level picks the BASE; the file tool still resolves the path inside it.
|
||||
|
||||
``observe_argument_refusal`` narrows ``root`` and leaves the positive path
|
||||
open on purpose, so the only thing standing between "Observe may write its
|
||||
task_drive" and "Observe may write anything" is that confinement. These are
|
||||
the bypasses it has to refuse: traversal, an absolute path, and a symlink
|
||||
inside the drive that points out of it.
|
||||
"""
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
wake = _registry(tmp_path, _wake_task("observe")["metadata"])
|
||||
drive_dir = tmp_path / "drive" / "task_drives" / "turn-1"
|
||||
# The positive path first: without it, "refused" would prove nothing.
|
||||
assert "OK:" in wake.execute("write_file", {"root": "task_drive", "path": "notes.txt",
|
||||
"content": "internal finding"})
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("owner bytes\n", encoding="utf-8")
|
||||
drive_dir.mkdir(parents=True, exist_ok=True)
|
||||
(drive_dir / "escape.txt").symlink_to(outside)
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(drive_dir / "escape_dir").symlink_to(outside_dir, target_is_directory=True)
|
||||
for label, path in (
|
||||
("traversal", "../../../outside.txt"),
|
||||
("absolute", str(outside)),
|
||||
("absolute into the repo", str(tmp_path / "repo" / "README.md")),
|
||||
("symlinked file", "escape.txt"),
|
||||
("symlinked directory", "escape_dir/x.txt"),
|
||||
):
|
||||
refused = wake.execute("write_file", {"root": "task_drive", "path": path, "content": "no"})
|
||||
assert refused.startswith("⚠️ WRITE_FILE_ERROR"), (label, refused)
|
||||
assert outside.read_text(encoding="utf-8") == "owner bytes\n"
|
||||
assert sorted(item.name for item in outside_dir.iterdir()) == []
|
||||
# edit_text lands on the same confinement through the same chosen root.
|
||||
assert "⚠️" in wake.execute("edit_text", {"root": "task_drive", "path": str(outside),
|
||||
"old": "owner", "new": "no"})
|
||||
assert outside.read_text(encoding="utf-8") == "owner bytes\n"
|
||||
|
||||
|
||||
def test_observe_delegated_session_stays_readonly_when_access_is_omitted(tmp_path):
|
||||
"""The nanny verb's SHAPE, not just its arguments: Observe hosts read-only."""
|
||||
from ouroboros.tools.delegate import _derive_authority
|
||||
|
||||
ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="wake-1",
|
||||
is_direct_chat=True, task_metadata=_wake_task("observe")["metadata"])
|
||||
for kwargs in ({}, {"access": "workspace_write"}, {"access": "full"}):
|
||||
shape = _derive_authority(ctx, **kwargs)
|
||||
assert shape.access == "readonly" and shape.isolation != "live", kwargs
|
||||
assert shape.delegated is False, kwargs
|
||||
|
||||
|
||||
def test_schedule_control_is_root_authority_but_reading_is_not(tmp_path):
|
||||
"""A delegated task may LIST; only the root turn may change a schedule."""
|
||||
child = _registry(tmp_path, {"delegation_role": "subagent"})
|
||||
listed = child.execute_result("manage_schedules", {"action": "list"})
|
||||
assert listed.status == "ok" and json.loads(listed.text)["tasks"] == []
|
||||
for action in ("disable", "delete", "restore"):
|
||||
refused = child.execute_result("manage_schedules",
|
||||
{"action": action, "schedule_id": "s1", "reason": "because"})
|
||||
assert refused.status == "blocked", action
|
||||
assert refused.code in {"ACCESS_BLOCKED", "RESOURCE_CONSTRAINT_BLOCKED"}
|
||||
# Reading the schedule table is research, so it is in the read-only catalog.
|
||||
from ouroboros.tool_capabilities import LOCAL_READONLY_SUBAGENT_TOOL_NAMES
|
||||
|
||||
assert "manage_schedules" in LOCAL_READONLY_SUBAGENT_TOOL_NAMES
|
||||
# Registered handlers answer TEXT (ToolEntry.handler -> str); the typed result
|
||||
# rides the published sidecar, which is how the status above is still known.
|
||||
from ouroboros.tools.followup import _manage_schedules
|
||||
|
||||
for args in ({"action": "list"}, {"action": "disable", "schedule_id": "s1", "reason": "x"}):
|
||||
assert isinstance(_manage_schedules(child._ctx, **args), str), args
|
||||
|
||||
|
||||
def test_schedule_mutation_uses_resolved_subagent_profile_even_without_lineage_metadata(tmp_path):
|
||||
"""A stale/missing delegation_role cannot turn a delegated profile into an owner."""
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tools.followup import _manage_schedules
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
drive = tmp_path / "drive"
|
||||
repo.mkdir()
|
||||
drive.mkdir()
|
||||
ctx = ToolContext(
|
||||
repo_dir=repo, drive_root=drive, task_id="child-1",
|
||||
task_constraint=TaskConstraint(mode="local_readonly_subagent"), task_metadata={},
|
||||
)
|
||||
blocked = _manage_schedules(ctx, action="disable", schedule_id="s1", reason="pause")
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" in blocked
|
||||
|
||||
|
||||
def test_schedule_mutation_fails_closed_when_profile_resolution_is_unavailable(tmp_path, monkeypatch):
|
||||
"""A profile lookup error cannot be treated as proof of root schedule authority."""
|
||||
from ouroboros.tools.followup import _manage_schedules
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
drive = tmp_path / "drive"
|
||||
repo.mkdir()
|
||||
drive.mkdir()
|
||||
ctx = ToolContext(repo_dir=repo, drive_root=drive, task_id="turn-1", task_metadata={})
|
||||
monkeypatch.setattr("ouroboros.tool_access.active_tool_profile",
|
||||
lambda _ctx: (_ for _ in ()).throw(RuntimeError("profile unavailable")))
|
||||
blocked = _manage_schedules(ctx, action="disable", schedule_id="s1", reason="pause")
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" in blocked
|
||||
|
||||
|
||||
def test_presence_gets_no_schedule_authority_at_all(tmp_path):
|
||||
"""No Presence expansion: an admitted guest room neither reads nor writes it."""
|
||||
guest = _registry(tmp_path, {"presence": {"event": {"source_event_id": "presence-1"}}})
|
||||
for args in ({"action": "list"}, {"action": "disable", "schedule_id": "s1", "reason": "x"}):
|
||||
result = guest.execute_result("manage_schedules", args)
|
||||
assert result.status == "blocked"
|
||||
assert result.code in {"ACCESS_BLOCKED", "RESOURCE_CONSTRAINT_BLOCKED"}
|
||||
# --- nested dispatch: what a read-only descendant may ask for ----------------------
|
||||
|
||||
|
||||
def _readonly_child_registry(tmp_path, level="observe"):
|
||||
"""A read-only research child of a consciousness wake, as dispatch sees it."""
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir(exist_ok=True)
|
||||
(repo / "README.md").write_text("ok\n", encoding="utf-8")
|
||||
drive = tmp_path / "drive"
|
||||
drive.mkdir(exist_ok=True)
|
||||
reg = ToolRegistry(repo_dir=repo, drive_root=drive)
|
||||
metadata = dict(_wake_task(level)["metadata"])
|
||||
metadata.update(delegation_role="subagent", root_task_id="wake-1", parent_task_id="wake-1")
|
||||
reg.set_context(ToolContext(
|
||||
repo_dir=repo, drive_root=drive, task_id="child-1",
|
||||
task_constraint=TaskConstraint(mode="local_readonly_subagent"),
|
||||
task_metadata=metadata,
|
||||
))
|
||||
return reg
|
||||
|
||||
|
||||
def test_choosing_the_next_wake_stays_with_the_waking_mind_and_does_not_descend(tmp_path, monkeypatch):
|
||||
"""``set_next_wakeup`` is an allow/deny rule, not an unclassified leftover.
|
||||
|
||||
The approved wake prompt asks this mind to choose its own next interval, so
|
||||
Observe keeps the verb — managing a SCHEDULE RECORD and choosing when to
|
||||
wake next are different operations and the boundary says so out loud. A
|
||||
read-only descendant is not the waking mind and never gets it; keeping the
|
||||
verb is not permission to enable the alarm or move the owner's bounds.
|
||||
"""
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
for level in ("observe", "act"):
|
||||
wake = _registry(tmp_path, _wake_task(level)["metadata"])
|
||||
assert wake.get_schema_by_name("set_next_wakeup") is not None, level
|
||||
assert "set_next_wakeup" not in ca.disabled_tools_for(level), level
|
||||
assert _readonly_child_registry(tmp_path).get_schema_by_name("set_next_wakeup") is None
|
||||
from ouroboros.tool_capabilities import LOCAL_READONLY_SUBAGENT_TOOL_NAMES
|
||||
|
||||
assert "set_next_wakeup" not in LOCAL_READONLY_SUBAGENT_TOOL_NAMES
|
||||
|
||||
|
||||
def test_the_observe_boundary_names_every_tool_it_narrows_rather_than_implying_one(tmp_path):
|
||||
"""The narrowed list is the inventory; a name on it must have a dispatch rule.
|
||||
|
||||
A tool kept VISIBLE for a read-only use is only safe while its mutating
|
||||
argument shape is actually refused. Pinning the two sets against each other
|
||||
means a later catalog addition cannot quietly join the visible half without
|
||||
the refusal half arriving with it.
|
||||
"""
|
||||
narrowed = set(ca.OBSERVE_ARGUMENT_NARROWED)
|
||||
disabled = set(ca.disabled_tools_for("observe"))
|
||||
assert not (narrowed & disabled), "a narrowed tool is visible, so it cannot also be withheld"
|
||||
# Narrowed by something other than a refused argument SHAPE, each named here
|
||||
# so the exemption is a decision rather than a gap nobody noticed:
|
||||
# journal_write / workpad_write — the owner approved these as Observe's own
|
||||
# project notes; their bound is the note surface, not a refusal.
|
||||
# cancel_task — narrowed to the children this wake itself started, which
|
||||
# `test_observe_may_stop_its_own_child_but_not_the_owners_running_work` owns.
|
||||
bounded_elsewhere = {"journal_write", "workpad_write", "cancel_task"}
|
||||
covered = {name for name, _args in _MUTATING_SHAPES}
|
||||
assert narrowed <= covered | bounded_elsewhere, (
|
||||
f"argument-narrowed with no refusal pinned anywhere: {sorted(narrowed - covered - bounded_elsewhere)}")
|
||||
assert bounded_elsewhere <= narrowed, "an exemption for a name that is no longer narrowed"
|
||||
|
||||
|
||||
def test_a_readonly_child_may_research_further_but_never_spawn_a_mutating_one(tmp_path, monkeypatch):
|
||||
"""Recursion is allowed; the authority does not grow on the way down."""
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
monkeypatch.setenv("OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS", "true")
|
||||
from ouroboros.tool_access import active_tool_profile
|
||||
from ouroboros.tools.control_scheduling import _select_subagent_constraint
|
||||
|
||||
child = _readonly_child_registry(tmp_path)
|
||||
assert active_tool_profile(child._ctx) == "local_readonly_subagent"
|
||||
# The descendant it MAY have: a read-only one, chosen by the same selector.
|
||||
allowed = _select_subagent_constraint("", "", False, None, "", caller_readonly=True, ctx=child._ctx)
|
||||
assert allowed["mode"] == "local_readonly_subagent"
|
||||
# The descendant it may NOT have: every acting surface, refused at dispatch.
|
||||
for surface in ("self_worktree", "external_workspace"):
|
||||
refused = child.execute("schedule_subagent", {
|
||||
"objective": "edit the repo", "expected_output": "a diff", "write_surface": surface})
|
||||
assert "BLOCKED" in refused, surface
|
||||
|
||||
|
||||
def test_a_readonly_child_cannot_pass_a_mutating_delegation_budget_onward(tmp_path):
|
||||
"""The legacy may_mutate flag is dropped rather than inherited by a descendant."""
|
||||
from ouroboros.tool_capabilities import ACTING_SUBAGENT_MODE, LOCAL_READONLY_SUBAGENT_MODE
|
||||
from ouroboros.tools.control_scheduling import _select_subagent_constraint, delegation_may_mutate
|
||||
|
||||
assert delegation_may_mutate(True, LOCAL_READONLY_SUBAGENT_MODE) is False
|
||||
assert delegation_may_mutate(True, ACTING_SUBAGENT_MODE) is True
|
||||
assert delegation_may_mutate(True, "self_modification") is True
|
||||
assert delegation_may_mutate(False, "self_modification") is False
|
||||
# And the constraint selector refuses the acting child itself, not just its budget.
|
||||
refused = _select_subagent_constraint(
|
||||
"external_workspace", "", False, None, "", caller_readonly=True,
|
||||
ctx=_readonly_child_registry(tmp_path)._ctx)
|
||||
assert not isinstance(refused, dict)
|
||||
|
||||
|
||||
def test_a_readonly_child_of_an_observe_wake_still_reads_schedules(tmp_path):
|
||||
"""Inheritance carries the level down without taking the read away."""
|
||||
child = _readonly_child_registry(tmp_path)
|
||||
assert ca.is_observe_origin(child._ctx.task_metadata)
|
||||
listed = child.execute_result("manage_schedules", {"action": "list"})
|
||||
assert listed.status == "ok" and json.loads(listed.text)["tasks"] == []
|
||||
refused = child.execute_result(
|
||||
"manage_schedules", {"action": "disable", "schedule_id": "s", "reason": "why"})
|
||||
assert refused.status == "blocked"
|
||||
|
||||
|
||||
def test_observe_may_stop_its_own_child_but_not_the_owners_running_work(tmp_path, monkeypatch):
|
||||
"""The nanny's other half: starting a child implies being able to stop it.
|
||||
|
||||
A wake is a TOP-LEVEL principal, whose cancel authority is ordinarily the
|
||||
whole queue. Observe is narrowed at the same custody seam a delegated task
|
||||
meets, so un-withholding the name grants own-child cancellation and nothing
|
||||
wider.
|
||||
"""
|
||||
from ouroboros.tools import join_ledger
|
||||
|
||||
wake = _registry(tmp_path, _wake_task("observe")["metadata"], task_id="wake-1")
|
||||
assert wake.get_schema_by_name("cancel_task") is not None # the guard is at the tool
|
||||
monkeypatch.setattr(join_ledger, "_is_own_child", lambda *_a, **_k: False)
|
||||
refused = wake.execute("cancel_task", {"task_id": "owner-task", "reason": "stop it"})
|
||||
assert "ACCESS_BLOCKED" in refused or "not a child of this task" in refused
|
||||
# An owner turn at the same seam keeps the full top-level authority.
|
||||
owner = _registry(tmp_path, {"client_message_id": "cm"}, task_id="owner-turn")
|
||||
assert "not a child of this task" not in owner.execute(
|
||||
"cancel_task", {"task_id": "owner-task", "reason": "stop it"})
|
||||
# Its own child is cancellable: custody says yes, the level does not veto it.
|
||||
monkeypatch.setattr(join_ledger, "_is_own_child", lambda *_a, **_k: True)
|
||||
assert "not a child of this task" not in wake.execute(
|
||||
"cancel_task", {"task_id": "child-1", "reason": "enough"})
|
||||
|
||||
|
||||
# --- disposing a captured patch is a world mutation, whoever captured it ---------
|
||||
|
||||
|
||||
_DISPOSITION_SHAPES = [
|
||||
# a Git-root capture of an unrelated earlier task whose owner is terminal
|
||||
("integrate_delegated_patch", {"run_id": "run-foreign-git", "decision": "apply"}),
|
||||
("integrate_delegated_patch", {"run_id": "run-foreign-git", "decision": "reject",
|
||||
"reason": "release the orphan"}),
|
||||
# an installed skill payload: apply lands LIVE in the payload, no Git root involved
|
||||
("integrate_delegated_patch", {"run_id": "run-foreign-payload", "decision": "apply",
|
||||
"reason": "adopt the payload edit"}),
|
||||
("integrate_delegated_patch", {"run_id": "run-foreign-payload", "decision": "reject"}),
|
||||
("integrate_subagent_patch", {"task_id": "child-of-someone-else", "decision": "apply"}),
|
||||
("integrate_subagent_patch", {"task_id": "child-of-someone-else", "decision": "reject"}),
|
||||
]
|
||||
|
||||
|
||||
def _probe_handler(reg, name, calls):
|
||||
"""Replace the tool's handler with a recorder, so a call that REACHES the
|
||||
handler is visible and a call the gate refuses provably never did."""
|
||||
import dataclasses
|
||||
|
||||
entry = reg._entries[name]
|
||||
|
||||
def probe(_ctx, **args):
|
||||
calls.append((name, dict(args)))
|
||||
return "PROBE_REACHED_HANDLER"
|
||||
|
||||
projected = dataclasses.replace(entry, handler=probe)
|
||||
reg._entries[name] = projected
|
||||
if name in reg._scoped_entries:
|
||||
reg._scoped_entries[name] = projected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "args"), _DISPOSITION_SHAPES)
|
||||
def test_observe_cannot_dispose_any_captured_patch_at_dispatch(tmp_path, monkeypatch, name, args):
|
||||
"""Fable scope B1: ``orphan_disposition_status`` lets a live TOP-LEVEL task
|
||||
dispose a foreign terminal owner's capture, and the payload apply rebinds
|
||||
the skill through the caller's profile — so a wake's read-only-children rule
|
||||
protects nothing here. The two verbs are therefore WITHHELD at Observe and
|
||||
refused by the dispatcher before any custody row is read: the run's target
|
||||
(Git root or installed payload) and its owner cannot make the call succeed,
|
||||
because the call never reaches the handler that would consult them.
|
||||
"""
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "pro") # the widest install still refuses
|
||||
assert name in ca.OBSERVE_DISABLED
|
||||
calls: list = []
|
||||
wake = _registry(tmp_path, _wake_task("observe")["metadata"], task_id="wake-1")
|
||||
_probe_handler(wake, name, calls)
|
||||
assert wake.get_schema_by_name(name) is not None # dispatch-only: the schema stays
|
||||
result = wake.execute(name, args)
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" in result and name in result
|
||||
assert calls == [], "the disposition handler must never run under Observe"
|
||||
# The same verb from an Act wake DOES reach the handler: the refusal above is
|
||||
# the level's gate, not a broken tool.
|
||||
act_calls: list = []
|
||||
act = _registry(tmp_path, _wake_task("act")["metadata"], task_id="wake-2")
|
||||
_probe_handler(act, name, act_calls)
|
||||
# (a safety-backend note may ride behind the handler's text in a bare test env)
|
||||
assert act.execute(name, args).startswith("PROBE_REACHED_HANDLER")
|
||||
assert act_calls == [(name, args)]
|
||||
|
||||
|
||||
def test_publication_of_evolution_stats_is_withheld_from_observe(tmp_path, monkeypatch):
|
||||
"""``generate_evolution_stats`` pushes docs/evolution.json through the GitHub
|
||||
API: a publication, not a read, so Observe does not get it (Sol plan note)."""
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
assert "generate_evolution_stats" in ca.OBSERVE_DISABLED
|
||||
wake = _registry(tmp_path, _wake_task("observe")["metadata"])
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" in wake.execute("generate_evolution_stats", {})
|
||||
875
tests/test_consciousness_schedule_controls.py
Normal file
875
tests/test_consciousness_schedule_controls.py
Normal file
|
|
@ -0,0 +1,875 @@
|
|||
"""Owner schedule controls: one audited seam, one lifecycle truth.
|
||||
|
||||
``supervisor/queue_schedules.py`` owns ``state/scheduled_tasks.json``. Every
|
||||
read-modify-write — the supervisor tick, the skill resync, the gateway, the
|
||||
follow-up tool and ``manage_schedules`` — takes the queue lock and then the
|
||||
table's own sidecar lock, so the file has exactly one writer at a time. A
|
||||
mutation announces its INTENT to the existing events log before it changes
|
||||
anything and records its OUTCOME after; an unreadable table refuses the write
|
||||
instead of replacing real rows with an empty document. Disable/delete govern
|
||||
FUTURE dispatch only: a skill row is retained as a suppressed record the
|
||||
lifecycle resync may not re-arm, a fired one-shot stays history, and restore
|
||||
re-evaluates the skill rather than enabling it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from ouroboros import utils as ouro_utils
|
||||
from supervisor import queue_schedules, schedule_lifecycle
|
||||
|
||||
|
||||
class _Queue:
|
||||
DRIVE_ROOT = pathlib.Path(".")
|
||||
SCHEDULED_TASKS_FILE = pathlib.Path("state") / "scheduled_tasks.json"
|
||||
_queue_lock = threading.RLock()
|
||||
PENDING: list = []
|
||||
RUNNING: dict = {}
|
||||
|
||||
|
||||
def _bind(monkeypatch, root):
|
||||
_Queue.DRIVE_ROOT = root
|
||||
_Queue.PENDING, _Queue.RUNNING = [], {}
|
||||
monkeypatch.setattr(queue_schedules, "_queue", lambda: _Queue)
|
||||
(root / "state").mkdir(parents=True, exist_ok=True)
|
||||
(root / "logs").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _write(root, tasks):
|
||||
(root / "state" / "scheduled_tasks.json").write_text(
|
||||
json.dumps({"schema_version": 1, "tasks": tasks}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _rows(root):
|
||||
return queue_schedules.list_scheduled_tasks(root)["tasks"]
|
||||
|
||||
|
||||
def _events(root):
|
||||
path = root / "logs" / "events.jsonl"
|
||||
if not path.exists():
|
||||
return []
|
||||
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _skill(name="demo", tasks=(("daily", "0 * * * *"),), permissions=("supervised_task",), content_hash="hash"):
|
||||
return SimpleNamespace(
|
||||
name=name, identity_collision=False, content_hash=content_hash,
|
||||
manifest=SimpleNamespace(
|
||||
permissions=list(permissions),
|
||||
scheduled_tasks=[{"name": n, "cron": c, "timezone": "UTC"} for n, c in tasks],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _ready(monkeypatch, ready=True):
|
||||
monkeypatch.setattr("ouroboros.skill_readiness.skill_readiness_for_execution",
|
||||
lambda *_a, **_k: SimpleNamespace(ready=ready))
|
||||
|
||||
|
||||
def _skill_row(schedule_id="skill-demo-daily", **extra):
|
||||
return {"id": schedule_id, "name": "demo/daily", "enabled": True, "source": "skill_manifest",
|
||||
"skill": "demo", "trigger": {"type": "cron", "expr": "0 * * * *"}, "timezone": "UTC",
|
||||
"task": {"type": "task", "text": "run"}, "next_run_at": "2026-09-22T00:00:00+00:00",
|
||||
**extra}
|
||||
|
||||
|
||||
# --- the suppressed skill row ------------------------------------------------------
|
||||
|
||||
|
||||
def test_disable_skill_schedule_survives_resync_and_is_audited(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row()])
|
||||
result = schedule_lifecycle.mutate_scheduled_task(
|
||||
"disable", "skill-demo-daily", reason="owner pause", actor="agent", drive_root=tmp_path)
|
||||
assert result["ok"] and result["changed"] and result["audit"] == "recorded"
|
||||
row = _rows(tmp_path)[0]
|
||||
assert row["enabled"] is False and row["manual_override"] == "disabled"
|
||||
_ready(monkeypatch)
|
||||
queue_schedules.sync_skill_schedules([_skill()], drive_root=tmp_path)
|
||||
row = _rows(tmp_path)[0]
|
||||
assert row["enabled"] is False and row["manual_override"] == "disabled"
|
||||
intent, outcome = _events(tmp_path)[-2:]
|
||||
assert [e["type"] for e in (intent, outcome)] == ["schedule_mutation"] * 2
|
||||
assert intent["phase"] == "intent" and outcome["phase"] == "outcome"
|
||||
assert intent["operation_id"] == outcome["operation_id"] == result["operation_id"]
|
||||
assert outcome["reason"] == "owner pause" and outcome["result"] == "updated"
|
||||
# A private objective is not audit material, and would be unbounded.
|
||||
assert "task" not in outcome["before"] and "task" not in outcome["after"]
|
||||
|
||||
|
||||
def test_deleting_a_skill_row_suppresses_it_rather_than_letting_resync_resurrect_it(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row()])
|
||||
_ready(monkeypatch)
|
||||
result = schedule_lifecycle.mutate_scheduled_task(
|
||||
"delete", "skill-demo-daily", reason="owner removed it", actor="agent", drive_root=tmp_path)
|
||||
assert result["ok"] and result["status"] == "suppressed"
|
||||
queue_schedules.sync_skill_schedules([_skill()], drive_root=tmp_path)
|
||||
row = _rows(tmp_path)[0]
|
||||
assert row["manual_override"] == "deleted" and row["enabled"] is False
|
||||
# The public boolean seam means the same thing and audits the same way.
|
||||
_write(tmp_path, [_skill_row(), {"id": "manual-1", "name": "manual", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}])
|
||||
assert schedule_lifecycle.remove_scheduled_task("manual-1", drive_root=tmp_path) is True
|
||||
assert [r["id"] for r in _rows(tmp_path)] == ["skill-demo-daily"]
|
||||
assert schedule_lifecycle.remove_scheduled_task("skill-demo-daily", drive_root=tmp_path) is True
|
||||
assert _rows(tmp_path)[0]["manual_override"] == "deleted"
|
||||
assert [e["action"] for e in _events(tmp_path)][-2:] == ["delete", "delete"]
|
||||
|
||||
|
||||
def test_same_id_suppression_survives_a_manifest_content_change(tmp_path, monkeypatch):
|
||||
"""The owner's decision is keyed by the schedule id, not by the row's bytes."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row()])
|
||||
_ready(monkeypatch)
|
||||
schedule_lifecycle.mutate_scheduled_task("disable", "skill-demo-daily", reason="paused",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
# The skill is edited: new content hash, new cron, new description — same id.
|
||||
queue_schedules.sync_skill_schedules(
|
||||
[_skill(tasks=(("daily", "30 * * * *"),), content_hash="hash-2")], drive_root=tmp_path)
|
||||
row = _rows(tmp_path)[0]
|
||||
assert row["trigger"]["expr"] == "30 * * * *" # content reconciled
|
||||
assert row["skill_content_hash"] == "hash-2"
|
||||
assert row["manual_override"] == "disabled" and row["enabled"] is False # decision kept
|
||||
|
||||
|
||||
def test_restore_re_evaluates_the_skill_instead_of_enabling_it(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row(enabled=False, manual_override="deleted")])
|
||||
monkeypatch.setattr("ouroboros.config.get_skills_repo_path", lambda: "")
|
||||
|
||||
# 1. The skill is gone: there is no manifest schedule to restore INTO, so the
|
||||
# suppression is KEPT and the action is a typed no-change refusal (claim_5:
|
||||
# restore clears suppression only for an extant manifest).
|
||||
monkeypatch.setattr("ouroboros.skill_loader.discover_skills", lambda *_a, **_k: [])
|
||||
gone = schedule_lifecycle.mutate_scheduled_task("restore", "skill-demo-daily", reason="bring it back",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert gone["ok"] is False and gone["status"] == "manifest_absent"
|
||||
assert gone["detail"].startswith("skill_absent") and gone["changed"] is False
|
||||
row = _rows(tmp_path)[0]
|
||||
assert row["enabled"] is False and row["manual_override"] == "deleted"
|
||||
assert _events(tmp_path)[-1]["result"] == "manifest_absent"
|
||||
|
||||
# 2. The skill is installed but not review-ready: still refused, named honestly.
|
||||
_write(tmp_path, [_skill_row(enabled=False, manual_override="disabled")])
|
||||
monkeypatch.setattr("ouroboros.skill_loader.discover_skills", lambda *_a, **_k: [_skill()])
|
||||
_ready(monkeypatch, ready=False)
|
||||
unready = schedule_lifecycle.mutate_scheduled_task("restore", "skill-demo-daily", reason="again",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert unready["status"] == "restored_not_ready" and unready["detail"] == "skill_not_ready"
|
||||
assert _rows(tmp_path)[0]["enabled"] is False
|
||||
|
||||
# 3. The skill lost the supervised_task permission: the schedule cannot dispatch.
|
||||
_write(tmp_path, [_skill_row(enabled=False, manual_override="disabled")])
|
||||
monkeypatch.setattr("ouroboros.skill_loader.discover_skills",
|
||||
lambda *_a, **_k: [_skill(permissions=())])
|
||||
_ready(monkeypatch)
|
||||
ungranted = schedule_lifecycle.mutate_scheduled_task("restore", "skill-demo-daily", reason="again",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert ungranted["detail"] == "skill_permission_missing"
|
||||
|
||||
# 4. The manifest no longer declares this schedule at all.
|
||||
_write(tmp_path, [_skill_row(enabled=False, manual_override="disabled")])
|
||||
monkeypatch.setattr("ouroboros.skill_loader.discover_skills",
|
||||
lambda *_a, **_k: [_skill(tasks=(("weekly", "0 0 * * 0"),))])
|
||||
dropped = schedule_lifecycle.mutate_scheduled_task("restore", "skill-demo-daily", reason="again",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert dropped["status"] == "manifest_absent" and dropped["changed"] is False
|
||||
assert dropped["detail"].startswith("schedule_absent_from_manifest")
|
||||
assert _rows(tmp_path)[0]["manual_override"] == "disabled"
|
||||
|
||||
# 5. Everything is in place: the row comes back and the suppression is gone.
|
||||
_write(tmp_path, [_skill_row(enabled=False, manual_override="disabled")])
|
||||
monkeypatch.setattr("ouroboros.skill_loader.discover_skills", lambda *_a, **_k: [_skill()])
|
||||
restored = schedule_lifecycle.mutate_scheduled_task("restore", "skill-demo-daily", reason="again",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert restored["ok"] and restored["status"] == "updated"
|
||||
row = _rows(tmp_path)[0]
|
||||
assert row["enabled"] is True and "manual_override" not in row
|
||||
|
||||
|
||||
def test_a_kept_suppression_survives_reinstall_and_resync_until_restored_again(tmp_path, monkeypatch):
|
||||
"""Restore over an absent skill keeps the marker; a later reinstall's resync
|
||||
reconciles content but stays suppressed; only a restore with the manifest
|
||||
present lifts it."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row(enabled=False, manual_override="deleted")])
|
||||
monkeypatch.setattr("ouroboros.config.get_skills_repo_path", lambda: "")
|
||||
monkeypatch.setattr("ouroboros.skill_loader.discover_skills", lambda *_a, **_k: [])
|
||||
_ready(monkeypatch)
|
||||
refused = schedule_lifecycle.mutate_scheduled_task("restore", "skill-demo-daily", reason="try",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert refused["status"] == "manifest_absent" and refused["changed"] is False
|
||||
# The skill is uninstalled: the sync drops nothing it was told about, and the
|
||||
# suppressed row is still there for the reinstall to honor.
|
||||
queue_schedules.sync_skill_schedules([], drive_root=tmp_path)
|
||||
assert _rows(tmp_path)[0]["manual_override"] == "deleted"
|
||||
# Reinstalled with new content: reconciled, still suppressed.
|
||||
queue_schedules.sync_skill_schedules([_skill(content_hash="hash-2")], drive_root=tmp_path)
|
||||
row = _rows(tmp_path)[0]
|
||||
assert row["skill_content_hash"] == "hash-2"
|
||||
assert row["manual_override"] == "deleted" and row["enabled"] is False
|
||||
# Now the manifest declares it again: restore lifts the marker.
|
||||
monkeypatch.setattr("ouroboros.skill_loader.discover_skills", lambda *_a, **_k: [_skill()])
|
||||
restored = schedule_lifecycle.mutate_scheduled_task("restore", "skill-demo-daily", reason="back",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert restored["ok"] and restored["changed"] is True
|
||||
assert "manual_override" not in _rows(tmp_path)[0]
|
||||
|
||||
|
||||
def test_restore_on_a_readiness_held_skill_row_lifts_nothing_and_says_so(tmp_path, monkeypatch):
|
||||
"""A skill row disabled WITHOUT the owner's marker is held by readiness and
|
||||
re-arms on resync. Restore has nothing to lift: typed no-change, no audit
|
||||
outcome pretending a suppression was removed, row bytes untouched."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row(enabled=False)])
|
||||
monkeypatch.setattr("ouroboros.config.get_skills_repo_path", lambda: "")
|
||||
monkeypatch.setattr("ouroboros.skill_loader.discover_skills", lambda *_a, **_k: [_skill()])
|
||||
_ready(monkeypatch, ready=False)
|
||||
before = dict(_rows(tmp_path)[0])
|
||||
outcome = schedule_lifecycle.mutate_scheduled_task("restore", "skill-demo-daily", reason="enable",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert outcome["ok"] is False and outcome["changed"] is False
|
||||
assert outcome["status"] == "not_suppressed" and "skill_not_ready" in outcome["detail"]
|
||||
assert _rows(tmp_path)[0] == before
|
||||
assert _events(tmp_path)[-1]["result"] == "not_suppressed"
|
||||
|
||||
|
||||
def test_restore_probes_readiness_under_the_transaction(tmp_path, monkeypatch):
|
||||
"""The readiness probe runs inside the held transaction, so a skill that
|
||||
became unready after the caller's read is still seen: the row is never
|
||||
re-armed from a stale pre-lock answer (triad finding on the off-lock probe)."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row(enabled=False, manual_override="disabled")])
|
||||
observed = []
|
||||
|
||||
def probe(_root, _record):
|
||||
observed.append(getattr(_Queue._queue_lock, "_is_owned", lambda: False)())
|
||||
return False, "skill_not_ready"
|
||||
|
||||
monkeypatch.setattr(schedule_lifecycle, "_skill_schedule_dispatchable", probe)
|
||||
outcome = schedule_lifecycle.mutate_scheduled_task(
|
||||
"restore", "skill-demo-daily", reason="resume", actor="agent", drive_root=tmp_path)
|
||||
assert outcome["status"] == "restored_not_ready" and outcome["changed"] is True
|
||||
assert observed == [True]
|
||||
assert _rows(tmp_path)[0]["enabled"] is False and "manual_override" not in _rows(tmp_path)[0]
|
||||
|
||||
|
||||
# --- the consumed one-shot ---------------------------------------------------------
|
||||
|
||||
|
||||
def _once_row(**extra):
|
||||
return {"id": "once-1", "name": "once", "enabled": False,
|
||||
"completed_at": "2026-09-21T00:00:00+00:00",
|
||||
"trigger": {"type": "once", "run_at": "2026-09-20T00:00:00+00:00"},
|
||||
"task": {"type": "task", "text": "run"}, **extra}
|
||||
|
||||
|
||||
def test_consumed_once_is_history_and_restore_does_not_rearm(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_once_row()])
|
||||
result = schedule_lifecycle.mutate_scheduled_task("restore", "once-1", reason="try restore",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert result["ok"] is False and result["status"] == "consumed_not_rearmed"
|
||||
assert result["changed"] is False and _rows(tmp_path)[0]["enabled"] is False
|
||||
projected = queue_schedules.schedule_activity_projection(
|
||||
queue_schedules.list_scheduled_tasks(tmp_path))
|
||||
row = projected["tasks"][0]
|
||||
assert row["status"] == "consumed" and row["active"] is False
|
||||
assert row["retained"] is True and row["restorable"] is False
|
||||
# Even a refused action is auditable: the intent fact names what was attempted.
|
||||
assert _events(tmp_path)[-1]["result"] == "consumed_not_rearmed"
|
||||
|
||||
|
||||
def test_a_timezone_only_edit_does_not_resurrect_a_fired_one_shot(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_once_row(timezone="UTC")])
|
||||
kept = schedule_lifecycle.upsert_scheduled_task({
|
||||
"id": "once-1", "name": "once", "enabled": False, "timezone": "Europe/Moscow",
|
||||
"trigger": {"type": "once", "run_at": "2026-09-20T00:00:00+00:00"},
|
||||
"task": {"type": "task", "text": "run"},
|
||||
}, drive_root=tmp_path)
|
||||
assert kept["completed_at"] == "2026-09-21T00:00:00+00:00" and kept["enabled"] is False
|
||||
assert kept["timezone"] == "Europe/Moscow"
|
||||
# The same edit with enabled=true is the re-arm this rule exists to refuse.
|
||||
with pytest.raises(queue_schedules.ScheduleRefused) as refusal:
|
||||
schedule_lifecycle.upsert_scheduled_task({
|
||||
"id": "once-1", "name": "once", "enabled": True, "timezone": "Europe/Moscow",
|
||||
"trigger": {"type": "once", "run_at": "2026-09-20T00:00:00+00:00"},
|
||||
"task": {"type": "task", "text": "run"},
|
||||
}, drive_root=tmp_path)
|
||||
assert refusal.value.status == "consumed_not_rearmed"
|
||||
assert _rows(tmp_path)[0]["completed_at"] == "2026-09-21T00:00:00+00:00"
|
||||
# The announced intent gets its outcome even when the answer is "no".
|
||||
intent, outcome = _events(tmp_path)[-2:]
|
||||
assert intent["phase"] == "intent" and outcome["phase"] == "outcome"
|
||||
assert intent["operation_id"] == outcome["operation_id"]
|
||||
assert outcome["result"] == "consumed_not_rearmed" and "after" not in outcome
|
||||
|
||||
|
||||
def test_a_new_run_at_is_a_new_one_shot_and_clears_the_receipt(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_once_row()])
|
||||
rearmed = schedule_lifecycle.upsert_scheduled_task({
|
||||
"id": "once-1", "name": "once", "enabled": True,
|
||||
"trigger": {"type": "once", "run_at": "2026-10-01T00:00:00+00:00"},
|
||||
"task": {"type": "task", "text": "run"},
|
||||
}, drive_root=tmp_path)
|
||||
assert "completed_at" not in rearmed and rearmed["enabled"] is True
|
||||
|
||||
|
||||
# --- one writer, one transaction ---------------------------------------------------
|
||||
|
||||
|
||||
def test_the_transaction_is_reentrant_per_table_not_per_depth(tmp_path, monkeypatch):
|
||||
"""A nested transaction on ANOTHER root still takes that root's own lock."""
|
||||
other = tmp_path / "other"
|
||||
_bind(monkeypatch, tmp_path)
|
||||
(other / "state").mkdir(parents=True)
|
||||
taken: list[str] = []
|
||||
real = queue_schedules.acquire_exclusive_file_lock
|
||||
|
||||
def _record(path, **kwargs):
|
||||
taken.append(pathlib.Path(path).parent.parent.name)
|
||||
return real(path, **kwargs)
|
||||
|
||||
monkeypatch.setattr(queue_schedules, "acquire_exclusive_file_lock", _record)
|
||||
with queue_schedules.schedule_transaction(tmp_path):
|
||||
with queue_schedules.schedule_transaction(tmp_path): # same table: rides the hold
|
||||
assert taken == [tmp_path.name]
|
||||
with queue_schedules.schedule_transaction(other): # another table: its own lock
|
||||
assert (other / "state" / "scheduled_tasks.json.lock").exists()
|
||||
assert taken == [tmp_path.name, "other"]
|
||||
|
||||
|
||||
def test_schedule_lock_key_uses_platform_case_normalization(monkeypatch):
|
||||
monkeypatch.setattr(queue_schedules.os.path, "normcase", lambda value: value.lower())
|
||||
assert queue_schedules._schedule_lock_key(
|
||||
pathlib.Path("/tmp/State/Scheduled.lock")) == queue_schedules._schedule_lock_key(
|
||||
pathlib.Path("/tmp/state/scheduled.lock"))
|
||||
|
||||
|
||||
def test_the_transaction_itself_takes_the_queue_lock_before_the_table_lock(tmp_path, monkeypatch):
|
||||
"""One owner of the ORDER, so no caller can compose the pair the other way.
|
||||
|
||||
``schedule_followup`` wraps its cap-read and its write in one transaction; if
|
||||
the queue lock were taken inside that hold (by ``upsert_scheduled_task``, as
|
||||
it used to be) it would run table-then-queue against the tick's
|
||||
queue-then-table. The order lives in the transaction so there is nothing per
|
||||
caller left to audit.
|
||||
"""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
order: list[str] = []
|
||||
real_file_lock = queue_schedules.acquire_exclusive_file_lock
|
||||
monkeypatch.setattr(
|
||||
queue_schedules, "acquire_exclusive_file_lock",
|
||||
lambda path, **kw: (order.append("table"), real_file_lock(path, **kw))[1])
|
||||
real_queue_lock = _Queue._queue_lock
|
||||
|
||||
class _RecordingLock:
|
||||
def __enter__(self):
|
||||
order.append("queue")
|
||||
return real_queue_lock.__enter__()
|
||||
|
||||
def __exit__(self, *exc_info):
|
||||
return real_queue_lock.__exit__(*exc_info)
|
||||
|
||||
_Queue._queue_lock = _RecordingLock()
|
||||
try:
|
||||
schedule_lifecycle.upsert_scheduled_task(
|
||||
{"id": "cron-1", "name": "cron", "trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}, drive_root=tmp_path)
|
||||
assert order == ["queue", "table"]
|
||||
order.clear()
|
||||
schedule_lifecycle.mutate_scheduled_task("disable", "cron-1", reason="pause",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert order == ["queue", "table"]
|
||||
order.clear()
|
||||
# The follow-up shape: an OUTER transaction around a read and a write.
|
||||
with queue_schedules.schedule_transaction(tmp_path):
|
||||
queue_schedules.list_scheduled_tasks(tmp_path)
|
||||
schedule_lifecycle.upsert_scheduled_task(
|
||||
{"id": "cron-2", "name": "cron", "trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}, drive_root=tmp_path)
|
||||
assert order == ["queue", "table"]
|
||||
finally:
|
||||
_Queue._queue_lock = real_queue_lock
|
||||
|
||||
|
||||
def test_a_transaction_wrapped_write_and_a_queue_lock_holder_do_not_deadlock(tmp_path, monkeypatch):
|
||||
"""The two real orders, run against each other, with every row surviving.
|
||||
|
||||
Thread A is the follow-up tool: one transaction around its cap-read and its
|
||||
write. Thread B is a supervisor pass: the queue lock, then the table. While
|
||||
the transaction left the queue lock to its callers these two could wedge, and
|
||||
this test would HANG rather than fail — so the join has a hard timeout.
|
||||
"""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [])
|
||||
rounds = 15
|
||||
start = threading.Barrier(2, timeout=10)
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def _row(schedule_id):
|
||||
return {"id": schedule_id, "name": schedule_id, "timezone": "UTC",
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}
|
||||
|
||||
def _tool():
|
||||
try:
|
||||
start.wait()
|
||||
for index in range(rounds):
|
||||
with queue_schedules.schedule_transaction(tmp_path):
|
||||
queue_schedules.list_scheduled_tasks(tmp_path)
|
||||
schedule_lifecycle.upsert_scheduled_task(_row(f"tool-{index}"), drive_root=tmp_path)
|
||||
except BaseException as exc: # noqa: BLE001 -- reported to the main thread
|
||||
errors.append(exc)
|
||||
|
||||
def _tick():
|
||||
try:
|
||||
start.wait()
|
||||
for index in range(rounds):
|
||||
with _Queue._queue_lock:
|
||||
schedule_lifecycle.upsert_scheduled_task(_row(f"tick-{index}"), drive_root=tmp_path)
|
||||
except BaseException as exc: # noqa: BLE001 -- reported to the main thread
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=_tool, daemon=True),
|
||||
threading.Thread(target=_tick, daemon=True)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=60)
|
||||
assert not [t for t in threads if t.is_alive()], "the schedule transaction deadlocked"
|
||||
assert not errors, errors
|
||||
# Serialized, not merely unblocked: no interleaved read-modify-write lost a row.
|
||||
assert {row["id"] for row in _rows(tmp_path)} == (
|
||||
{f"tool-{i}" for i in range(rounds)} | {f"tick-{i}" for i in range(rounds)})
|
||||
|
||||
|
||||
def test_every_writer_reaches_the_same_transaction_through_the_queue_facade(tmp_path, monkeypatch):
|
||||
"""One lock, one owner: the tool, the gateway and the follow-up share it."""
|
||||
from supervisor import queue as queue_module
|
||||
|
||||
assert queue_module.schedule_transaction is queue_schedules.schedule_transaction
|
||||
assert queue_module.SCHEDULE_ACTIONS == queue_schedules.SCHEDULE_ACTIONS
|
||||
assert queue_module.ScheduleStoreUnreadable is queue_schedules.ScheduleStoreUnreadable
|
||||
|
||||
|
||||
# --- an unreadable table refuses, it does not erase --------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contents", ["{ not json", '["a list, not a table"]', '{"tasks": 7}'])
|
||||
def test_a_corrupt_table_refuses_every_mutation_instead_of_erasing_it(tmp_path, monkeypatch, contents):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
path = tmp_path / "state" / "scheduled_tasks.json"
|
||||
path.write_text(contents, encoding="utf-8")
|
||||
refused = schedule_lifecycle.mutate_scheduled_task("disable", "any", reason="x", actor="agent",
|
||||
drive_root=tmp_path)
|
||||
assert refused["ok"] is False and refused["status"] == "store_unreadable"
|
||||
assert refused["changed"] is False and refused["audit"] == "not_written"
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable):
|
||||
schedule_lifecycle.upsert_scheduled_task({"id": "new", "trigger": {"type": "cron", "expr": "0 * * * *"}},
|
||||
drive_root=tmp_path)
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable):
|
||||
queue_schedules.sync_skill_schedules([_skill()], drive_root=tmp_path)
|
||||
queue_schedules.check_scheduled_tasks() # the tick skips the pass rather than rewriting
|
||||
assert path.read_text(encoding="utf-8") == contents
|
||||
# The agent's own read says unavailable rather than reporting an empty table.
|
||||
# The handler answers TEXT (the registered ToolEntry ABI); its typed status
|
||||
# rides the published sidecar, which is pinned in the observe-dispatch suite.
|
||||
from ouroboros.tools.followup import _manage_schedules
|
||||
|
||||
ctx = SimpleNamespace(task_metadata={}, drive_root=tmp_path, budget_drive_root=tmp_path, task_id="t")
|
||||
unavailable = _manage_schedules(ctx, action="list")
|
||||
assert isinstance(unavailable, str)
|
||||
assert "CAPABILITY_UNAVAILABLE" in unavailable and "readable" in unavailable
|
||||
|
||||
|
||||
def test_invalid_utf8_schedule_bytes_are_unreadable_not_a_decode_crash(tmp_path, monkeypatch):
|
||||
"""A present table with invalid UTF-8 is an unavailable store, not a leaked decode error."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
path = tmp_path / "state" / "scheduled_tasks.json"
|
||||
path.write_bytes(b'{"tasks": [\xff]}')
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable) as refusal:
|
||||
queue_schedules.load_schedule_store(tmp_path)
|
||||
assert "readable schedule table" in str(refusal.value)
|
||||
outcome = schedule_lifecycle.mutate_scheduled_task(
|
||||
"disable", "any", reason="pause", actor="agent", drive_root=tmp_path)
|
||||
assert outcome["status"] == "store_unreadable" and outcome["changed"] is False
|
||||
assert path.read_bytes() == b'{"tasks": [\xff]}'
|
||||
|
||||
|
||||
def test_manage_schedule_list_is_bounded_and_explicitly_paginated(tmp_path, monkeypatch):
|
||||
"""The model-facing list omits templates and exposes a bounded page contract."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
objective = "objective " + ("x" * 1_000)
|
||||
tasks = [{
|
||||
"id": f"schedule-{index}", "name": f"Schedule {index}", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"text": objective, "context": "secret context " * 10_000,
|
||||
"attachments": [{"path": "private"}]},
|
||||
} for index in range(25)]
|
||||
_write(tmp_path, tasks)
|
||||
from ouroboros.tools.followup import _manage_schedules
|
||||
|
||||
ctx = SimpleNamespace(task_metadata={}, drive_root=tmp_path, budget_drive_root=tmp_path, task_id="t")
|
||||
first_text = _manage_schedules(ctx, action="list", offset=0, limit=20)
|
||||
first = json.loads(first_text)
|
||||
assert first["total"] == 25 and first["offset"] == 0
|
||||
assert first["limit"] == 20
|
||||
assert 0 < len(first["tasks"]) <= first["limit"]
|
||||
assert first["next_offset"] == len(first["tasks"])
|
||||
assert len(first_text) < 15_000
|
||||
row = first["tasks"][0]
|
||||
assert "task" not in row and "context" not in row and "attachments" not in row
|
||||
assert len(row["objective_preview"]) <= 240
|
||||
assert row["objective_truncated"] is True and row["objective_preview_truncated"] is True
|
||||
|
||||
second = json.loads(_manage_schedules(ctx, action="list", offset=first["next_offset"], limit=20))
|
||||
assert second["total"] == 25 and second["offset"] == first["next_offset"]
|
||||
assert second["next_offset"] is None
|
||||
assert len(second["tasks"]) == 25 - first["next_offset"]
|
||||
|
||||
|
||||
def test_manage_schedule_projection_preserves_identity_selectors(tmp_path, monkeypatch):
|
||||
"""Bounded display fields may shorten; ids used by a later action may not."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
schedule_id = "schedule-" + ("x" * 72)
|
||||
task_id = "task-" + ("y" * 72)
|
||||
projected = queue_schedules.schedule_tool_projection({"tasks": [{
|
||||
"id": schedule_id, "name": "long", "enabled": True,
|
||||
"last_task_id": task_id, "skill": "skill-" + ("z" * 72),
|
||||
"task": {"text": "objective " + ("x" * 500)},
|
||||
}]})
|
||||
row = projected["tasks"][0]
|
||||
assert row["id"] == schedule_id
|
||||
assert row["last_task_id"] == task_id
|
||||
assert row["skill"] == "skill-" + ("z" * 72)
|
||||
|
||||
|
||||
def _root_ctx(tmp_path):
|
||||
"""A real root turn: schedule mutation authority is resolved, not asserted."""
|
||||
from ouroboros.tools.registry import ToolContext
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir(exist_ok=True)
|
||||
return ToolContext(repo_dir=repo, drive_root=tmp_path, task_id="turn-1", task_metadata={})
|
||||
|
||||
|
||||
def test_a_row_too_large_to_render_still_reports_the_change_it_already_made(tmp_path, monkeypatch):
|
||||
"""A receipt this process cannot fit is a missing detail, not an action that did not run.
|
||||
|
||||
Identity fields are deliberately unbounded so a later action addresses the
|
||||
same record, which means one manually edited row CAN exceed the tool result
|
||||
limit. By then the table is already rewritten and both audit facts are
|
||||
written: answering ``CAPABILITY_UNAVAILABLE`` would tell the model nothing
|
||||
happened while the owner's table says otherwise.
|
||||
"""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{
|
||||
"id": "nightly", "name": "nightly", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
# A real field, read back from a prior run, that nothing bounds.
|
||||
"last_task_id": "task-" + ("y" * 20_000),
|
||||
}])
|
||||
from ouroboros.tools.followup import _manage_schedules
|
||||
|
||||
text = _manage_schedules(_root_ctx(tmp_path), action="disable",
|
||||
schedule_id="nightly", reason="owner pause")
|
||||
assert "CAPABILITY_UNAVAILABLE" not in text
|
||||
receipt = json.loads(text)
|
||||
assert receipt["changed"] is True and receipt["ok"] is True
|
||||
assert receipt["status"] == "updated" and receipt["audit"] == "recorded"
|
||||
assert receipt["schedule_id"] == "nightly"
|
||||
assert receipt["schedule_omitted"] is True
|
||||
assert "projection_too_large" in receipt["schedule_omitted_reason"]
|
||||
assert "schedule" not in receipt
|
||||
# The durable change and its audit pair are real, not implied by the receipt.
|
||||
assert _rows(tmp_path)[0]["enabled"] is False
|
||||
phases = [event["phase"] for event in _events(tmp_path) if event["type"] == "schedule_mutation"]
|
||||
assert phases == ["intent", "outcome"]
|
||||
|
||||
|
||||
def test_an_identity_too_large_for_a_truthful_receipt_changes_nothing(tmp_path, monkeypatch):
|
||||
"""Refuse BEFORE the write when the selector itself cannot come back intact."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row()])
|
||||
from ouroboros.tools.followup import _manage_schedules
|
||||
|
||||
receipt = json.loads(_manage_schedules(
|
||||
_root_ctx(tmp_path), action="disable",
|
||||
schedule_id="s" * 14_000, reason="owner pause"))
|
||||
assert receipt["status"] == "identity_too_large"
|
||||
assert receipt["changed"] is False and receipt["audit"] == "not_written"
|
||||
assert _rows(tmp_path)[0]["enabled"] is True
|
||||
assert _events(tmp_path) == []
|
||||
|
||||
|
||||
def test_a_list_page_whose_first_row_cannot_fit_refuses_instead_of_reporting_no_schedules(
|
||||
tmp_path, monkeypatch):
|
||||
"""An unrenderable row must not read as a table that has nothing in it."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [
|
||||
{"id": "huge", "name": "huge", "enabled": True,
|
||||
"last_task_id": "task-" + ("y" * 20_000)},
|
||||
{"id": "ordinary", "name": "ordinary", "enabled": True},
|
||||
])
|
||||
from ouroboros.tools.followup import _manage_schedules
|
||||
|
||||
refused = _manage_schedules(_root_ctx(tmp_path), action="list", offset=0, limit=20)
|
||||
assert "CAPABILITY_UNAVAILABLE" in refused and "projection_too_large" in refused
|
||||
assert '"total":0' not in refused and "no schedules" not in refused.lower()
|
||||
# The refusal names where to resume, so the rest of the table stays reachable.
|
||||
assert "offset 1" in refused
|
||||
following = json.loads(_manage_schedules(_root_ctx(tmp_path), action="list", offset=1, limit=20))
|
||||
assert [row["id"] for row in following["tasks"]] == ["ordinary"]
|
||||
|
||||
|
||||
def test_an_oversized_action_receipt_stays_valid_json_and_keeps_its_selectors(
|
||||
tmp_path, monkeypatch):
|
||||
"""The compact receipt is a whole document; the outer cap must never cut it."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
schedule_id = "s" * 12_000
|
||||
_write(tmp_path, [{"id": schedule_id, "name": "long id", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"}}])
|
||||
from ouroboros.tool_capabilities import tool_result_limit
|
||||
from ouroboros.tools.followup import _manage_schedules
|
||||
|
||||
text = _manage_schedules(_root_ctx(tmp_path), action="disable",
|
||||
schedule_id=schedule_id, reason="owner pause")
|
||||
assert len(text) <= tool_result_limit("manage_schedules")
|
||||
receipt = json.loads(text) # a truncated document would not parse
|
||||
assert receipt["schedule_id"] == schedule_id
|
||||
assert receipt["changed"] is True and receipt["schedule_omitted"] is True
|
||||
assert _rows(tmp_path)[0]["enabled"] is False
|
||||
|
||||
|
||||
def test_a_present_but_unusable_path_is_unreadable_rather_than_an_empty_table(tmp_path, monkeypatch):
|
||||
"""Only an ABSENT path is an empty store; anything else present is unknown.
|
||||
|
||||
``is_file()`` alone answered False for a directory and for a dangling symlink
|
||||
just as it does for nothing at all, so a write would have treated real state
|
||||
(or a link out of the drive) as a table it may replace.
|
||||
"""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
path = tmp_path / "state" / "scheduled_tasks.json"
|
||||
assert queue_schedules.load_schedule_store(tmp_path) == {"schema_version": 1, "tasks": []}
|
||||
|
||||
path.mkdir()
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable) as directory:
|
||||
queue_schedules.load_schedule_store(tmp_path)
|
||||
assert "regular file" in str(directory.value)
|
||||
path.rmdir()
|
||||
|
||||
path.symlink_to(tmp_path / "state" / "nothing-here.json")
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable) as dangling:
|
||||
queue_schedules.load_schedule_store(tmp_path)
|
||||
assert "regular file" in str(dangling.value)
|
||||
refused = schedule_lifecycle.mutate_scheduled_task("disable", "any", reason="x", actor="agent",
|
||||
drive_root=tmp_path)
|
||||
assert refused["status"] == "store_unreadable" and refused["audit"] == "not_written"
|
||||
assert path.is_symlink() and not path.exists() # nothing followed the link
|
||||
|
||||
|
||||
def test_a_table_it_cannot_open_is_named_apart_from_one_it_cannot_parse(tmp_path, monkeypatch):
|
||||
"""Two different owner problems, so two different messages.
|
||||
|
||||
The lenient reader answers None for "could not open" and "could not parse"
|
||||
alike; a permission/IO fault is a host problem and corrupt bytes are a file
|
||||
problem, and the refusal has to say which one it met.
|
||||
"""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
path = tmp_path / "state" / "scheduled_tasks.json"
|
||||
path.write_text('{ not json', encoding="utf-8")
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable) as unparseable:
|
||||
queue_schedules.load_schedule_store(tmp_path)
|
||||
assert "is not a readable schedule table (" in str(unparseable.value)
|
||||
|
||||
path.write_text(json.dumps({"schema_version": 1, "tasks": []}), encoding="utf-8")
|
||||
path.chmod(0o000)
|
||||
try:
|
||||
readable = True
|
||||
try:
|
||||
path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
readable = False
|
||||
if readable:
|
||||
pytest.skip("this process can read a mode-000 file (root); the branch needs a denied read")
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable) as unopenable:
|
||||
queue_schedules.load_schedule_store(tmp_path)
|
||||
assert "could not be read (" in str(unopenable.value)
|
||||
refused = schedule_lifecycle.mutate_scheduled_task("disable", "any", reason="x", actor="agent",
|
||||
drive_root=tmp_path)
|
||||
assert refused["status"] == "store_unreadable" and refused["audit"] == "not_written"
|
||||
finally:
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def test_a_malformed_row_is_refused_not_dropped_by_a_write_about_another_row(tmp_path, monkeypatch):
|
||||
"""A mutation may not quietly delete what it could not parse.
|
||||
|
||||
The writers used to filter non-object rows out of the list and then write the
|
||||
filtered list back, so one disable could erase a neighbouring row and the
|
||||
only record of it was gone.
|
||||
"""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
path = tmp_path / "state" / "scheduled_tasks.json"
|
||||
contents = json.dumps({"schema_version": 1, "tasks": [
|
||||
{"id": "cron-1", "enabled": True, "trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}},
|
||||
"a row that is not an object",
|
||||
]})
|
||||
path.write_text(contents, encoding="utf-8")
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable) as refusal:
|
||||
queue_schedules.load_schedule_store(tmp_path)
|
||||
assert "not objects" in str(refusal.value) and "index 1" in str(refusal.value)
|
||||
refused = schedule_lifecycle.mutate_scheduled_task("disable", "cron-1", reason="pause",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert refused["status"] == "store_unreadable" and refused["changed"] is False
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable):
|
||||
schedule_lifecycle.upsert_scheduled_task({"id": "new", "trigger": {"type": "cron", "expr": "0 * * * *"}},
|
||||
drive_root=tmp_path)
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable):
|
||||
queue_schedules.sync_skill_schedules([_skill()], drive_root=tmp_path)
|
||||
queue_schedules.check_scheduled_tasks()
|
||||
assert path.read_text(encoding="utf-8") == contents
|
||||
# The lenient READ projection still renders what it can understand.
|
||||
assert [r["id"] for r in _rows(tmp_path) if isinstance(r, dict)] == ["cron-1"]
|
||||
|
||||
|
||||
def test_an_absent_table_is_an_empty_one_and_may_be_created(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
stored = schedule_lifecycle.upsert_scheduled_task(
|
||||
{"id": "first", "name": "first", "trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}, drive_root=tmp_path)
|
||||
assert stored["id"] == "first" and stored["audit"] == "recorded"
|
||||
assert [r["id"] for r in _rows(tmp_path)] == ["first"]
|
||||
|
||||
|
||||
# --- audit before the change, audit after it ---------------------------------------
|
||||
|
||||
|
||||
def test_an_unwritable_audit_stops_the_mutation_before_it_happens(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row()])
|
||||
monkeypatch.setattr(ouro_utils, "append_jsonl", lambda *_a, **_k: False)
|
||||
refused = schedule_lifecycle.mutate_scheduled_task("disable", "skill-demo-daily", reason="pause",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert refused["ok"] is False and refused["status"] == "audit_unavailable"
|
||||
assert refused["changed"] is False and refused["audit"] == "not_written"
|
||||
assert _rows(tmp_path)[0]["enabled"] is True # nothing changed
|
||||
with pytest.raises(queue_schedules.ScheduleRefused) as refusal:
|
||||
schedule_lifecycle.upsert_scheduled_task(
|
||||
{"id": "x", "trigger": {"type": "cron", "expr": "0 * * * *"}}, drive_root=tmp_path)
|
||||
assert refusal.value.status == "audit_unavailable"
|
||||
assert [r["id"] for r in _rows(tmp_path)] == ["skill-demo-daily"]
|
||||
|
||||
|
||||
def test_a_lost_outcome_record_is_disclosed_and_never_rolled_back(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row()])
|
||||
calls = {"n": 0}
|
||||
real = ouro_utils.append_jsonl
|
||||
|
||||
def _first_only(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
return real(*args, **kwargs) if calls["n"] == 1 else False
|
||||
|
||||
monkeypatch.setattr(ouro_utils, "append_jsonl", _first_only)
|
||||
outcome = schedule_lifecycle.mutate_scheduled_task("disable", "skill-demo-daily", reason="pause",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert outcome["ok"] is False and outcome["status"] == "changed_audit_incomplete"
|
||||
assert outcome["changed"] is True and outcome["audit"] == "incomplete"
|
||||
# No rollback: an automatic undo would be a second, equally unaudited mutation.
|
||||
assert _rows(tmp_path)[0]["enabled"] is False
|
||||
|
||||
|
||||
# --- the projection and the merge --------------------------------------------------
|
||||
|
||||
|
||||
def test_the_projection_names_one_lifecycle_word_per_row(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [
|
||||
{"id": "a", "enabled": True, "trigger": {"type": "cron", "expr": "* * * * *"}},
|
||||
{"id": "b", "enabled": False, "trigger": {"type": "cron", "expr": "* * * * *"}},
|
||||
_skill_row("c", enabled=False, manual_override="deleted"),
|
||||
_once_row(),
|
||||
])
|
||||
rows = queue_schedules.schedule_activity_projection(
|
||||
queue_schedules.list_scheduled_tasks(tmp_path))["tasks"]
|
||||
assert [r["status"] for r in rows] == ["active", "disabled", "suppressed", "consumed"]
|
||||
assert [r["retained"] for r in rows] == [False, False, True, True]
|
||||
assert [r["restorable"] for r in rows] == [False, False, True, False]
|
||||
for row in rows:
|
||||
assert queue_schedules.schedule_lifecycle_status(row) == row["status"]
|
||||
|
||||
|
||||
def test_upsert_merges_stale_gateway_record_without_clobbering_runtime_history(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{
|
||||
"id": "cron-1", "name": "cron", "enabled": True, "source": "skill_manifest", "skill": "demo",
|
||||
"created_at": "2026-09-01T00:00:00+00:00", "last_run_at": "2026-09-21T01:00:00+00:00",
|
||||
"last_task_id": "task-new", "next_run_at": "2026-09-22T01:00:00+00:00",
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"}, "task": {"type": "task", "text": "run"},
|
||||
}])
|
||||
stored = schedule_lifecycle.upsert_scheduled_task({
|
||||
"id": "cron-1", "name": "edited", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"}, "task": {"type": "task", "text": "edited"},
|
||||
"source": "task_followup", "skill": "impostor", "created_at": "stale",
|
||||
"last_run_at": "stale", "last_task_id": "stale", "next_run_at": "stale",
|
||||
}, drive_root=tmp_path)
|
||||
assert stored["created_at"] == "2026-09-01T00:00:00+00:00"
|
||||
assert stored["last_task_id"] == "task-new"
|
||||
assert stored["next_run_at"] == "2026-09-22T01:00:00+00:00"
|
||||
# Provenance is the runtime's, not the payload's: a stale (or wrong) source
|
||||
# and skill in the body cannot relabel which lifecycle owns this row.
|
||||
assert stored["source"] == "skill_manifest" and stored["skill"] == "demo"
|
||||
assert stored["name"] == "edited" and stored["enabled"] is True
|
||||
|
||||
|
||||
def test_an_edit_cannot_change_a_skill_rows_enabled_state(tmp_path, monkeypatch):
|
||||
"""That state is reconciled from the skill; only the lifecycle action is durable."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row(enabled=False, manual_override="disabled")])
|
||||
edit = {"id": "skill-demo-daily", "name": "demo/daily", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"}, "task": {"type": "task", "text": "run"}}
|
||||
with pytest.raises(queue_schedules.ScheduleRefused) as refusal:
|
||||
schedule_lifecycle.upsert_scheduled_task(edit, drive_root=tmp_path)
|
||||
assert refusal.value.status == "lifecycle_action_required"
|
||||
row = _rows(tmp_path)[0]
|
||||
assert row["enabled"] is False and row["manual_override"] == "disabled"
|
||||
# The surviving positive path: an edit that leaves the lifecycle alone lands,
|
||||
# and the suppression rides through it untouched.
|
||||
stored = schedule_lifecycle.upsert_scheduled_task(
|
||||
{**edit, "enabled": False, "description": "edited"}, drive_root=tmp_path)
|
||||
assert stored["description"] == "edited"
|
||||
assert stored["enabled"] is False and stored["manual_override"] == "disabled"
|
||||
|
||||
|
||||
def test_a_timing_edit_recomputes_the_next_run(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{
|
||||
"id": "cron-1", "name": "cron", "enabled": True, "timezone": "UTC",
|
||||
"next_run_at": "2026-09-22T01:00:00+00:00",
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"}, "task": {"type": "task", "text": "run"},
|
||||
}])
|
||||
same = schedule_lifecycle.upsert_scheduled_task({
|
||||
"id": "cron-1", "name": "cron", "enabled": True, "timezone": "UTC",
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"}, "task": {"type": "task", "text": "run"},
|
||||
}, drive_root=tmp_path)
|
||||
assert same["next_run_at"] == "2026-09-22T01:00:00+00:00"
|
||||
moved = schedule_lifecycle.upsert_scheduled_task({
|
||||
"id": "cron-1", "name": "cron", "enabled": True, "timezone": "UTC",
|
||||
"trigger": {"type": "cron", "expr": "30 5 * * *"}, "task": {"type": "task", "text": "run"},
|
||||
}, drive_root=tmp_path)
|
||||
assert moved["next_run_at"] != "2026-09-22T01:00:00+00:00"
|
||||
assert moved["next_run_at"].endswith(("05:30:00+00:00", "05:30:00"))
|
||||
296
tests/test_consciousness_schedule_gateway.py
Normal file
296
tests/test_consciousness_schedule_gateway.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""The owner's schedule controls seen from outside the store: what a lifecycle
|
||||
action says about a run already admitted, how a worker answers when it cannot
|
||||
see the live queue, and the gateway that carries the same seam as the agent's
|
||||
tool. Companion of ``test_consciousness_schedule_controls`` (store, suppression,
|
||||
transaction and projection), split so each file stays inside one reading window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from ouroboros import utils as ouro_utils
|
||||
from supervisor import queue_schedules, schedule_lifecycle
|
||||
from tests.test_consciousness_schedule_controls import (
|
||||
_Queue, _bind, _events, _once_row, _ready, _rows, _skill, _skill_row, _write,
|
||||
)
|
||||
|
||||
|
||||
# --- a lifecycle action governs dispatch, not a run already admitted ---------------
|
||||
|
||||
|
||||
def test_disabling_a_schedule_reports_the_run_it_does_not_stop(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{"id": "cron-1", "name": "cron", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}])
|
||||
_Queue.RUNNING = {"w1": {"task": {"metadata": {"schedule_id": "cron-1"}}}}
|
||||
outcome = schedule_lifecycle.mutate_scheduled_task("disable", "cron-1", reason="stop future runs",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert outcome["ok"] and outcome["running_or_queued"] is True
|
||||
assert _rows(tmp_path)[0]["enabled"] is False
|
||||
|
||||
|
||||
def test_a_worker_reports_unknown_rather_than_claiming_no_run_is_in_flight(tmp_path, monkeypatch):
|
||||
"""PENDING/RUNNING belong to the SUPERVISOR process, not to every caller.
|
||||
|
||||
``manage_schedules`` runs in a worker, where those dicts are this process's
|
||||
own empty copies; answering False from there would claim nothing this
|
||||
schedule started is alive. The worker reads the durable queue snapshot — the
|
||||
file the supervisor rewrites on exactly those transitions — and says unknown
|
||||
when it is not there.
|
||||
"""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{"id": "cron-1", "name": "cron", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}])
|
||||
monkeypatch.setattr(queue_schedules, "in_worker_process", lambda: True)
|
||||
|
||||
# No snapshot to read: unknown, not "nothing is running".
|
||||
unknown = schedule_lifecycle.mutate_scheduled_task("disable", "cron-1", reason="pause",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert unknown["ok"] is True and unknown["running_or_queued"] is None
|
||||
|
||||
snapshot = tmp_path / "state" / "queue_snapshot.json"
|
||||
snapshot.write_text(json.dumps({
|
||||
"ts": ouro_utils.utc_now_iso(), "pending": [], "running": [
|
||||
{"id": "task-1", "task": {"id": "task-1", "metadata": {"schedule_id": "cron-1"}}}],
|
||||
}), encoding="utf-8")
|
||||
seen = schedule_lifecycle.mutate_scheduled_task("restore", "cron-1", reason="back",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert seen["running_or_queued"] is True
|
||||
|
||||
snapshot.write_text(json.dumps({
|
||||
"ts": ouro_utils.utc_now_iso(), "pending": [
|
||||
{"id": "task-2", "task": {"id": "task-2", "metadata": {"schedule_id": "other"}}}],
|
||||
"running": [],
|
||||
}), encoding="utf-8")
|
||||
clear = schedule_lifecycle.mutate_scheduled_task("disable", "cron-1", reason="pause again",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert clear["running_or_queued"] is False
|
||||
|
||||
# In the supervisor process the live queue is the authority, as before.
|
||||
monkeypatch.setattr(queue_schedules, "in_worker_process", lambda: False)
|
||||
_Queue.PENDING = [{"id": "task-3", "metadata": {"schedule_id": "cron-1"}}]
|
||||
live = schedule_lifecycle.mutate_scheduled_task("restore", "cron-1", reason="back again",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert live["running_or_queued"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("snapshot", [
|
||||
{},
|
||||
{"ts": "2000-01-01T00:00:00+00:00", "pending": [], "running": []},
|
||||
])
|
||||
def test_worker_does_not_treat_malformed_or_stale_snapshot_as_proof_of_no_run(
|
||||
tmp_path, monkeypatch, snapshot):
|
||||
"""Only a fresh, structurally complete snapshot can answer ``False``."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{"id": "cron-1", "name": "cron", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}])
|
||||
monkeypatch.setattr(queue_schedules, "in_worker_process", lambda: True)
|
||||
(tmp_path / "state" / "queue_snapshot.json").write_text(
|
||||
json.dumps(snapshot), encoding="utf-8")
|
||||
outcome = schedule_lifecycle.mutate_scheduled_task(
|
||||
"disable", "cron-1", reason="pause", actor="agent", drive_root=tmp_path)
|
||||
assert outcome["ok"] is True
|
||||
assert outcome["running_or_queued"] is None
|
||||
|
||||
|
||||
def test_the_tick_waits_out_an_unknown_in_flight_run_instead_of_re_dispatching(tmp_path, monkeypatch):
|
||||
"""Unknown is not permission to fire a schedule whose last run may be alive."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{"id": "cron-1", "name": "cron", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "* * * * *"},
|
||||
"next_run_at": "2020-01-01T00:00:00+00:00",
|
||||
"task": {"type": "task", "text": "run"}}])
|
||||
monkeypatch.setattr(queue_schedules, "_schedule_running_or_queued", lambda *_a, **_k: None)
|
||||
enqueued: list = []
|
||||
_Queue.enqueue_task = staticmethod(lambda task: enqueued.append(task) or task)
|
||||
_Queue.load_state = staticmethod(lambda: {})
|
||||
_Queue.persist_queue_snapshot = staticmethod(lambda reason="": True)
|
||||
try:
|
||||
queue_schedules.check_scheduled_tasks()
|
||||
finally:
|
||||
for name in ("enqueue_task", "load_state", "persist_queue_snapshot"):
|
||||
delattr(_Queue, name)
|
||||
assert enqueued == []
|
||||
assert _rows(tmp_path)[0]["next_run_at"] == "2020-01-01T00:00:00+00:00"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("action", "schedule_id", "reason", "status"), [
|
||||
("explode", "cron-1", "why", "invalid_action"),
|
||||
("disable", "", "why", "missing_schedule_id"),
|
||||
("disable", "cron-1", " ", "reason_required"),
|
||||
("disable", "absent", "why", "not_found"),
|
||||
])
|
||||
def test_a_malformed_or_impossible_action_is_typed_and_writes_nothing(
|
||||
tmp_path, monkeypatch, action, schedule_id, reason, status):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{"id": "cron-1", "enabled": True, "trigger": {"type": "cron", "expr": "0 * * * *"}}])
|
||||
outcome = schedule_lifecycle.mutate_scheduled_task(action, schedule_id, reason=reason,
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert outcome["ok"] is False and outcome["status"] == status
|
||||
assert outcome["audit"] == "not_written" and _events(tmp_path) == []
|
||||
assert _rows(tmp_path)[0]["enabled"] is True
|
||||
|
||||
|
||||
# --- the owner's buttons and the agent's tool are the same seam --------------------
|
||||
|
||||
|
||||
def _gateway(tmp_path):
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from ouroboros.gateway.schedules import (
|
||||
api_schedules_action, api_schedules_delete, api_schedules_list, api_schedules_upsert,
|
||||
)
|
||||
|
||||
app = Starlette(routes=[
|
||||
Route("/api/schedules", endpoint=api_schedules_list, methods=["GET"]),
|
||||
Route("/api/schedules", endpoint=api_schedules_upsert, methods=["POST"]),
|
||||
Route("/api/schedules/{schedule_id}/action", endpoint=api_schedules_action, methods=["POST"]),
|
||||
Route("/api/schedules/{schedule_id}", endpoint=api_schedules_delete, methods=["DELETE"]),
|
||||
])
|
||||
app.state.drive_root = tmp_path
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_the_gateway_action_and_the_agent_tool_mean_the_same_thing(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_ready(monkeypatch)
|
||||
monkeypatch.setattr("ouroboros.config.get_skills_repo_path", lambda: "")
|
||||
monkeypatch.setattr("ouroboros.skill_loader.discover_skills", lambda *_a, **_k: [_skill()])
|
||||
client = _gateway(tmp_path)
|
||||
|
||||
_write(tmp_path, [_skill_row()])
|
||||
by_owner = client.post("/api/schedules/skill-demo-daily/action",
|
||||
json={"action": "disable", "reason": "owner paused it"}).json()
|
||||
owner_row = dict(_rows(tmp_path)[0])
|
||||
_write(tmp_path, [_skill_row()])
|
||||
by_agent = schedule_lifecycle.mutate_scheduled_task(
|
||||
"disable", "skill-demo-daily", reason="agent paused it", actor="agent", drive_root=tmp_path)
|
||||
agent_row = dict(_rows(tmp_path)[0])
|
||||
|
||||
assert by_owner["status"] == by_agent["status"] == "updated"
|
||||
assert by_owner["ok"] is by_agent["ok"] is True
|
||||
ignore = {"updated_at"}
|
||||
assert {k: v for k, v in owner_row.items() if k not in ignore} == \
|
||||
{k: v for k, v in agent_row.items() if k not in ignore}
|
||||
# Both wrote the same audit shape; only the actor and the reason differ.
|
||||
owner_event, agent_event = _events(tmp_path)[1], _events(tmp_path)[3]
|
||||
assert owner_event["actor"] == "owner:gateway" and agent_event["actor"] == "agent"
|
||||
assert owner_event.keys() == agent_event.keys()
|
||||
|
||||
|
||||
def test_the_gateway_action_requires_a_named_action_and_a_reason(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row()])
|
||||
client = _gateway(tmp_path)
|
||||
assert client.post("/api/schedules/skill-demo-daily/action", json={"reason": "x"}).status_code == 400
|
||||
assert client.post("/api/schedules/skill-demo-daily/action",
|
||||
json={"action": "explode", "reason": "x"}).status_code == 400
|
||||
assert client.post("/api/schedules/skill-demo-daily/action",
|
||||
json={"action": "disable"}).status_code == 400
|
||||
assert _rows(tmp_path)[0]["enabled"] is True
|
||||
# A reason is a fact about this decision, not a command: it cannot select one.
|
||||
from ouroboros.gateway import schedules as gateway_schedules
|
||||
|
||||
source = pathlib.Path(gateway_schedules.__file__).read_text(encoding="utf-8")
|
||||
assert "activity_toggle" not in source and "activity_restore" not in source
|
||||
|
||||
|
||||
def test_the_delete_endpoint_reports_the_suppression_it_applied(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row()])
|
||||
body = _gateway(tmp_path).delete("/api/schedules/skill-demo-daily").json()
|
||||
assert body["ok"] is True and body["status"] == "suppressed"
|
||||
assert _rows(tmp_path)[0]["manual_override"] == "deleted"
|
||||
|
||||
|
||||
def test_the_gateway_list_carries_the_lifecycle_words_the_ui_renders(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [_skill_row("c", enabled=False, manual_override="deleted"), _once_row()])
|
||||
rows = _gateway(tmp_path).get("/api/schedules").json()["tasks"]
|
||||
assert {r["status"] for r in rows} == {"suppressed", "consumed"}
|
||||
assert all(r["retained"] for r in rows)
|
||||
|
||||
|
||||
def test_an_unreadable_table_refuses_the_gateway_too(tmp_path, monkeypatch):
|
||||
_bind(monkeypatch, tmp_path)
|
||||
(tmp_path / "state" / "scheduled_tasks.json").write_text("{ broken", encoding="utf-8")
|
||||
client = _gateway(tmp_path)
|
||||
upsert = client.post("/api/schedules", json={
|
||||
"id": "new", "trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "x"}})
|
||||
assert upsert.status_code == 409 and "readable" in upsert.json()["error"]
|
||||
action = client.post("/api/schedules/new/action", json={"action": "disable", "reason": "x"})
|
||||
assert action.json()["status"] == "store_unreadable"
|
||||
# "No schedules" would be a claim; an unreadable table reads as UNKNOWN, which
|
||||
# is what the Activity section's own failed-read state is there to say.
|
||||
listed = client.get("/api/schedules")
|
||||
assert listed.status_code == 503 and "readable" in listed.json()["error"]
|
||||
assert (tmp_path / "state" / "scheduled_tasks.json").read_text(encoding="utf-8") == "{ broken"
|
||||
|
||||
|
||||
def test_the_legacy_delete_contract_name_survives_beside_the_action_one():
|
||||
"""Additive only: a published contract name is not removed by a richer reply."""
|
||||
from ouroboros.gateway import contracts
|
||||
from ouroboros.gateway.schedule_contracts import ScheduleDeleteResponse
|
||||
|
||||
assert contracts.ScheduleDeleteResponse is ScheduleDeleteResponse
|
||||
assert set(ScheduleDeleteResponse.__annotations__) == {"ok"}
|
||||
for name in ("ScheduleDeleteResponse", "ScheduleActionResponse",
|
||||
"ScheduleUpsertResponse", "ScheduledTasksResponse"):
|
||||
assert name in contracts.__all__, name
|
||||
mirror = pathlib.Path("web/modules/api_types.js").read_text(encoding="utf-8")
|
||||
assert "@typedef {Object} ScheduleDeleteResponse" in mirror
|
||||
|
||||
|
||||
def test_the_action_outcome_contract_and_its_js_mirror_name_the_same_fields(tmp_path, monkeypatch):
|
||||
"""Item 17 parity: the Python TypedDict and the JS typedef stay one contract."""
|
||||
from ouroboros.gateway.contracts import ScheduleActionResponse
|
||||
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{"id": "cron-1", "enabled": True, "name": "cron",
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}])
|
||||
outcome = _gateway(tmp_path).post("/api/schedules/cron-1/action",
|
||||
json={"action": "disable", "reason": "why"}).json()
|
||||
declared = set(ScheduleActionResponse.__annotations__)
|
||||
assert set(outcome) <= declared, sorted(set(outcome) - declared)
|
||||
assert {"ok", "changed", "status", "schedule_id", "audit"} <= set(outcome)
|
||||
mirror = pathlib.Path("web/modules/api_types.js").read_text(encoding="utf-8")
|
||||
block = mirror.split("@typedef {Object} ScheduleActionResponse", 1)[1].split("*/", 1)[0]
|
||||
mirrored = {line.split("}", 1)[1].split()[0].rstrip("=")
|
||||
for line in block.splitlines() if "@property" in line}
|
||||
assert mirrored == declared, sorted(mirrored ^ declared)
|
||||
|
||||
|
||||
def test_a_missed_schedule_lock_is_a_typed_refusal_not_a_bare_timeout(tmp_path, monkeypatch):
|
||||
"""The lock bound is a refusal like an unreadable table: nothing changed, the
|
||||
state is unknown. The lifecycle action answers it as a typed outcome; the
|
||||
upsert raises the typed class, which the owner surfaces already catch."""
|
||||
_bind(monkeypatch, tmp_path)
|
||||
_write(tmp_path, [{"id": "cron-1", "name": "cron", "enabled": True,
|
||||
"trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "run"}}])
|
||||
monkeypatch.setattr(queue_schedules, "acquire_exclusive_file_lock", lambda *_a, **_k: None)
|
||||
outcome = schedule_lifecycle.mutate_scheduled_task("disable", "cron-1", reason="pause",
|
||||
actor="agent", drive_root=tmp_path)
|
||||
assert outcome["ok"] is False and outcome["changed"] is False
|
||||
assert outcome["status"] == "lock_timeout" and outcome["audit"] == "not_written"
|
||||
assert _rows(tmp_path)[0]["enabled"] is True and _events(tmp_path) == []
|
||||
with pytest.raises(queue_schedules.ScheduleStoreUnreadable) as refusal:
|
||||
schedule_lifecycle.upsert_scheduled_task({"id": "new", "trigger": {"type": "cron", "expr": "0 * * * *"}},
|
||||
drive_root=tmp_path)
|
||||
assert isinstance(refusal.value, TimeoutError)
|
||||
# The gateway turns it into the same 409 an unreadable table gets, not a 500.
|
||||
client = _gateway(tmp_path)
|
||||
response = client.post("/api/schedules", json={"id": "new", "name": "n", "trigger": {"type": "cron", "expr": "0 * * * *"},
|
||||
"task": {"type": "task", "text": "x"}})
|
||||
assert response.status_code == 409 and "schedule lock" in response.json()["error"]
|
||||
|
|
@ -150,18 +150,18 @@ def test_render_substitutes_every_placeholder_and_truncates_events_honestly(tmp_
|
|||
running=1, max_tasks=2, interval=3300)
|
||||
for key in wake.PLACEHOLDERS:
|
||||
assert "{" + key + "}" not in text, key
|
||||
assert text.startswith("[Wake-up · task_finished:t14:completed]")
|
||||
assert "1 h 30 min ago" in text and "autonomy act — everything your runtime mode allows except" in text
|
||||
assert "toggle_evolution, request_restart (calling them is refused)" in text
|
||||
assert "Allowance (last 24 h): 4.00 / 20.00 USD" in text and "still running: 1/2" in text
|
||||
assert "wake-up interval is 3300 s" in text
|
||||
assert text.startswith("You are Ouroboros. No one has asked for a task")
|
||||
assert "1 h 30 min ago" in text and "autonomy: act — everything your runtime mode allows except" in text
|
||||
assert "toggle_evolution, request_restart" in text
|
||||
assert "allowance accounting (last 24 h): 4.00 / 20.00 USD" in text and "tasks running: 1/2" in text
|
||||
assert "next interval: 3300 s" in text
|
||||
assert "- wake cause: task t14 finished (completed)" in text
|
||||
assert text.count("- task t") == wake.EVENT_LINES_MAX - 1 and "(+5 more; see recent_tasks, get_task_result, and chat_history)" in text
|
||||
quiet = wake.render_wake_message(
|
||||
tmp_path, repo, reason="heartbeat", last_wake_at=0.0, since=T0 + 1, now=T0, level="full",
|
||||
disabled_tools=[], spent_usd=None, daily_usd=0, running=0, max_tasks=0, interval=900)
|
||||
assert "no wake since this process started" in quiet and "wake cause: scheduled heartbeat" in quiet
|
||||
assert "withheld at this level: none" in quiet and "Allowance (last 24 h): unknown / 0.00 USD" in quiet
|
||||
assert "unavailable tools: none" in quiet and "allowance accounting (last 24 h): unknown / 0.00 USD" in quiet
|
||||
assert "including evolution" in quiet
|
||||
|
||||
|
||||
|
|
@ -179,11 +179,8 @@ def test_template_names_only_its_placeholders_and_the_wake_hints():
|
|||
import re
|
||||
|
||||
assert set(re.findall(r"\{([a-z_]+)\}", template)) == set(wake.PLACEHOLDERS)
|
||||
for hint in ("Doing nothing is a fine outcome", "ask only when the answer changes what you do",
|
||||
"say what you assume meanwhile", "choose how long", "do not request an acceptance review",
|
||||
"old cards and routine maintenance should not displace", "`set_next_wakeup`", "Allowance (last 24 h)",
|
||||
"opening with why you woke or what changed", "be brief, no essays unless something matters",
|
||||
"people you talk with", "task cards", "get_task_result"):
|
||||
for hint in ("A pause is a legitimate decision", "Distinguish incremental cash cost",
|
||||
"do not request task acceptance", "wake facts", "recent facts"):
|
||||
assert hint in template, hint
|
||||
assert "a heartbeat, a task that finished, a project digest" not in template
|
||||
assert "up to 10 rounds" not in template and "300 seconds" not in template
|
||||
|
|
|
|||
|
|
@ -325,11 +325,12 @@ def test_consciousness_prompt_is_the_wake_message_of_an_ordinary_main_turn():
|
|||
is its USER message: no private capability catalog, no round or interval limits."""
|
||||
consciousness = _read("prompts/CONSCIOUSNESS.md")
|
||||
|
||||
assert consciousness.startswith("[Wake-up · {reason}]")
|
||||
assert "Doing nothing is a fine outcome" in consciousness
|
||||
assert "`set_next_wakeup`" in consciousness and "`escalate`" in consciousness
|
||||
assert "recent_tasks" in consciousness
|
||||
for retired in ("You can:", "up to 10 rounds", "Default wakeup", "background consciousness mode"):
|
||||
assert consciousness.startswith("You are Ouroboros. No one has asked for a task; this turn is yours.")
|
||||
assert "A pause is a legitimate decision" in consciousness
|
||||
assert "Distinguish incremental cash cost from subscription quota" in consciousness
|
||||
assert "ordinary turn, not an assigned deliverable" in consciousness
|
||||
for retired in ("You can:", "up to 10 rounds", "Default wakeup", "background consciousness mode",
|
||||
"Doing nothing is a fine outcome", "`set_next_wakeup`", "`escalate`"):
|
||||
assert retired not in consciousness, retired
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -103,27 +103,30 @@ def test_memory_vocabulary_is_not_dressed_up_as_tool_names():
|
|||
assert f"`{word}`" not in text, f"{rel} backticks {word}"
|
||||
|
||||
|
||||
def test_wake_template_maintains_understanding_of_people():
|
||||
"""The wake-up message (an ordinary Main turn's user text) keeps the commitment:
|
||||
revise the existing note about a person rather than minting a new one (P12)."""
|
||||
template = " ".join(_read("prompts/CONSCIOUSNESS.md").split()).lower()
|
||||
def test_the_people_memory_contract_has_exactly_one_home():
|
||||
"""The wake-up message is an ordinary Main turn's USER text, so it runs on the
|
||||
system prompt that already states this contract. It used to restate it — which
|
||||
made the same obligation editable in two places and drifted the moment one
|
||||
changed. The contract is pinned above, in its single home; what is pinned here
|
||||
is that the wake does not carry a second copy of it (P7 one home per fact)."""
|
||||
memory = _section(_read("prompts/SYSTEM.md"), "Memory")
|
||||
claims = _claims(memory)
|
||||
|
||||
assert "people you talk with" in template
|
||||
assert "knowledge_read" in template and "knowledge_write" in template
|
||||
assert "rather than minting a new one" in template
|
||||
# Still exactly the obligations the wake used to duplicate.
|
||||
assert any("revise" in c and ("in place" in c or "same turn" in c) for c in claims)
|
||||
assert any("one interpretation" in c for c in claims)
|
||||
|
||||
|
||||
def test_wake_template_resolves_contradictions_about_people_too():
|
||||
template = " ".join(_read("prompts/CONSCIOUSNESS.md").split()).lower()
|
||||
|
||||
assert "contradictions" in template and "about the people you talk with" in template
|
||||
wake = " ".join(_read("prompts/CONSCIOUSNESS.md").split()).lower()
|
||||
for duplicated in ("people you talk with", "rather than minting a new one",
|
||||
"knowledge_read", "knowledge_write", "contradictions"):
|
||||
assert duplicated not in wake, f"the wake restates {duplicated!r}"
|
||||
|
||||
|
||||
def test_wake_template_addresses_its_human_not_a_user():
|
||||
consciousness = _read("prompts/CONSCIOUSNESS.md")
|
||||
|
||||
assert "write to your human" in consciousness
|
||||
assert "the user" not in consciousness
|
||||
assert "Speak to your human" in consciousness
|
||||
assert "your human" in consciousness and "the user" not in consciousness
|
||||
|
||||
|
||||
def test_reflection_nominates_learning_about_people_as_well_as_itself():
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ REPO = pathlib.Path(__file__).resolve().parents[1]
|
|||
|
||||
# UTF-8 bytes of each chapter source. Raise a value in the same diff that needs it, with a reason.
|
||||
CHAPTER_BYTE_BUDGETS: dict[str, int] = {
|
||||
"docs/architecture/01-high-level-architecture.md": 161453,
|
||||
# 161453 -> 162200: the module tree gains the `schedule_lifecycle.py` leaf beside
|
||||
# queue_schedules.py and names Observe's argument-level narrowing on the
|
||||
# consciousness_authority.py row; upstream had already filled the previous
|
||||
# headroom with the handoff and sidebar-activity rows landing in the same window.
|
||||
"docs/architecture/01-high-level-architecture.md": 162200,
|
||||
"docs/architecture/02-startup-onboarding-flow.md": 15517,
|
||||
# 97435 -> 99500: the notification owner is a new subsystem of this chapter
|
||||
# (its module, its client-level subscription, its room gate and its disclosed
|
||||
|
|
@ -36,9 +40,23 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = {
|
|||
# touched in "Main rows" is replaced, the rest has no older text to displace.
|
||||
# 104400 -> 104900: the anchor rule gained its multi-card clause (cards are never
|
||||
# shadows; a surviving card inherits folded receipts) after review found the gap.
|
||||
"docs/architecture/03-web-ui-pages-and-buttons.md": 104900,
|
||||
# 104900 -> 105700: Activity gains the schedule LIFECYCLE surface the chapter
|
||||
# lacked — the retained/consumed/suppressed vocabulary and its one disclosure,
|
||||
# Restore's re-evaluation, the named-action endpoint, and what a lifecycle
|
||||
# response is allowed to claim. The stale "read-only here" sentence it replaces
|
||||
# is gone and the paragraph was compressed by 298 bytes first (no fact removed).
|
||||
"docs/architecture/03-web-ui-pages-and-buttons.md": 105700,
|
||||
"docs/architecture/04-server-api-endpoints.md": 26833,
|
||||
"docs/architecture/05-supervisor-loop.md": 27137,
|
||||
# 27137 -> 30400: the schedule table gains a documented write contract the
|
||||
# chapter had no text for — one transaction owning the lock ORDER, the strict
|
||||
# store read's three refusal cases, intent-then-outcome audit with its
|
||||
# disclosed incomplete outcome, and the lifecycle actions' future-dispatch-only
|
||||
# scope including the unknown in-flight answer. Both paragraphs were compressed
|
||||
# by 286 bytes first; nothing older describes any of these rules.
|
||||
# 30400 -> 30600: restore over an absent skill/manifest entry is a new typed
|
||||
# refusal (`manifest_absent`, suppression kept) with no older text to
|
||||
# displace; two neighbouring sentences were compressed by 168 bytes first.
|
||||
"docs/architecture/05-supervisor-loop.md": 30600,
|
||||
# 286850 -> 287600: "an answer that has not arrived is a gap" is a new invariant of
|
||||
# plan review and task acceptance (the slot census vocabulary, the `awaiting`
|
||||
# projection, the only-awaited task outcome); the in-flight sentence it grew from is
|
||||
|
|
@ -48,7 +66,11 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = {
|
|||
# 289400 -> 290700 (2026-09-22): the custody row memo, the per-task recent-activity
|
||||
# windows and the subagent child's own windows are new mechanisms described in the
|
||||
# paragraphs they changed.
|
||||
"docs/architecture/06-agent-core.md": 290700,
|
||||
# 290700 -> 292800: Observe's positive path is a new seam of this chapter
|
||||
# (the argument-narrowing predicate, which names it keeps and why, the
|
||||
# own-child custody reuse, and the non-inherited mutating delegation budget);
|
||||
# the Observe half of the AUTHORITY sentence is replaced, the rest is new.
|
||||
"docs/architecture/06-agent-core.md": 292800,
|
||||
"docs/architecture/07-configuration.md": 36991,
|
||||
# 18947 -> 19287: CI failure collection now documents diagnostic desktop builds while release remains gated.
|
||||
"docs/architecture/08-git-branching-ci-and-build.md": 19287,
|
||||
|
|
|
|||
|
|
@ -305,6 +305,95 @@ def test_schedule_followup_cap_refusal_is_typed_and_disclosed(tmp_path):
|
|||
assert _followup(_ctx(tmp_path, task_id="root-2")).startswith("FOLLOWUP_SCHEDULED")
|
||||
|
||||
|
||||
def test_schedule_followup_counts_the_cap_inside_the_write_it_is_about_to_make(tmp_path, monkeypatch):
|
||||
"""The cap read and the write share ONE hold on the schedule table.
|
||||
|
||||
Two tasks registering at the same instant would otherwise each read the same
|
||||
under-cap count and both land, so the cap would be advisory rather than real.
|
||||
"""
|
||||
from supervisor import queue_schedules
|
||||
|
||||
ctx = _ctx(tmp_path)
|
||||
acquisitions: list[str] = []
|
||||
real = queue_schedules.acquire_exclusive_file_lock
|
||||
monkeypatch.setattr(
|
||||
queue_schedules, "acquire_exclusive_file_lock",
|
||||
lambda path, **kw: (acquisitions.append(str(path)), real(path, **kw))[1])
|
||||
assert _followup(ctx).startswith("FOLLOWUP_SCHEDULED")
|
||||
assert len(acquisitions) == 1, acquisitions
|
||||
assert acquisitions[0].endswith("scheduled_tasks.json.lock")
|
||||
# The refused call takes the same single hold and writes nothing.
|
||||
acquisitions.clear()
|
||||
assert _followup(ctx, run_at="2030-02-01T00:00:00+00:00").startswith("FOLLOWUP_SCHEDULED")
|
||||
acquisitions.clear()
|
||||
assert _followup(ctx, run_at="2030-03-01T00:00:00+00:00").startswith("ERROR: FOLLOWUP_CAP_REACHED")
|
||||
assert len(acquisitions) == 1
|
||||
from supervisor.queue import list_scheduled_tasks
|
||||
|
||||
assert len(list_scheduled_tasks(tmp_path / "data")["tasks"]) == 2
|
||||
|
||||
|
||||
def test_schedule_followup_reports_a_missed_table_lock_as_a_typed_refusal(tmp_path, monkeypatch):
|
||||
"""A lock the transaction could not take is a typed unavailable result in the
|
||||
text ABI, not a generic TOOL_ERROR wrapping a bare TimeoutError; nothing lands."""
|
||||
from supervisor import queue_schedules
|
||||
from supervisor.queue import list_scheduled_tasks
|
||||
|
||||
ctx = _ctx(tmp_path)
|
||||
monkeypatch.setattr(queue_schedules, "acquire_exclusive_file_lock", lambda *_a, **_k: None)
|
||||
text = _followup(ctx)
|
||||
assert text.startswith("⚠️ CAPABILITY_UNAVAILABLE: FOLLOWUP_STORE_UNAVAILABLE")
|
||||
assert "schedule lock" in text
|
||||
assert list_scheduled_tasks(tmp_path / "data")["tasks"] == []
|
||||
|
||||
|
||||
def test_schedule_followup_surfaces_a_corrupt_store_as_the_typed_refusal(tmp_path):
|
||||
"""The lenient cap read tolerates a corrupt table, the strict upsert refuses
|
||||
it; that refusal must reach the caller typed, not as FOLLOWUP_PERSIST_FAILED."""
|
||||
from supervisor.queue import list_scheduled_tasks
|
||||
|
||||
ctx = _ctx(tmp_path)
|
||||
state = tmp_path / "data" / "state"
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
(state / "scheduled_tasks.json").write_text("{not json", encoding="utf-8")
|
||||
text = _followup(ctx)
|
||||
assert text.startswith("⚠️ CAPABILITY_UNAVAILABLE: FOLLOWUP_STORE_UNAVAILABLE")
|
||||
assert "PERSIST_FAILED" not in text
|
||||
assert (state / "scheduled_tasks.json").read_text(encoding="utf-8") == "{not json"
|
||||
assert list_scheduled_tasks(tmp_path / "data")["tasks"] == []
|
||||
|
||||
|
||||
def test_schedule_followup_discloses_a_lost_audit_outcome_on_its_success_line(tmp_path, monkeypatch):
|
||||
"""The write seam returns what its AUDIT achieved; the success text says so.
|
||||
|
||||
A durable record whose outcome fact never reached logs/events.jsonl is not a
|
||||
clean registration — reporting only "registered" would hide the one thing
|
||||
that makes the change accountable, and the agent would never know to re-check.
|
||||
"""
|
||||
from ouroboros import utils as ouro_utils
|
||||
|
||||
ctx = _ctx(tmp_path)
|
||||
clean = _followup(ctx)
|
||||
assert clean.startswith("FOLLOWUP_SCHEDULED") and "AUDIT_INCOMPLETE" not in clean
|
||||
|
||||
calls = {"n": 0}
|
||||
real = ouro_utils.append_jsonl
|
||||
|
||||
def _intent_only(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
return real(*args, **kwargs) if calls["n"] == 1 else False
|
||||
|
||||
monkeypatch.setattr(ouro_utils, "append_jsonl", _intent_only)
|
||||
disclosed = _followup(_ctx(tmp_path, task_id="root-2"), run_at="2030-02-01T00:00:00+00:00")
|
||||
assert disclosed.startswith("FOLLOWUP_SCHEDULED")
|
||||
assert "AUDIT_INCOMPLETE" in disclosed and "audit=incomplete" in disclosed
|
||||
# Disclosed, never rolled back: an automatic undo would be a second
|
||||
# equally unaudited mutation.
|
||||
from supervisor.queue import list_scheduled_tasks
|
||||
|
||||
assert len(list_scheduled_tasks(tmp_path / "data")["tasks"]) == 2
|
||||
|
||||
|
||||
def test_schedule_followup_overlong_text_is_a_typed_refusal_never_truncated(tmp_path):
|
||||
"""Review fix 7: the objective/context ride VERBATIM into the future task, so an
|
||||
over-limit text is a typed FOLLOWUP_TEXT_TOO_LONG refusal naming the limit —
|
||||
|
|
@ -618,7 +707,10 @@ def test_schedule_followup_registration_surfaces():
|
|||
from ouroboros.tools.followup import get_tools
|
||||
|
||||
entries = get_tools()
|
||||
assert [e.name for e in entries] == ["schedule_followup"]
|
||||
# Both schedule-table tools live here, so the module's own surfaces are pinned
|
||||
# together; `manage_schedules` keeps its separate authority pins in
|
||||
# tests/test_consciousness_observe_dispatch.py.
|
||||
assert [e.name for e in entries] == ["schedule_followup", "manage_schedules"]
|
||||
schema = entries[0].schema["parameters"]
|
||||
assert set(schema["required"]) == {"objective"}
|
||||
assert {"run_at", "cron"} <= set(schema["properties"])
|
||||
|
|
@ -638,3 +730,8 @@ def test_schedule_followup_registration_surfaces():
|
|||
from ouroboros.tools.registry import ToolRegistry
|
||||
|
||||
assert "followup" in ToolRegistry._FROZEN_TOOL_MODULES
|
||||
assert TOOL_POLICY["manage_schedules"] == POLICY_SKIP
|
||||
assert "manage_schedules" in CORE_TOOL_NAMES
|
||||
# Reading the table is research; changing it is refused inside the tool.
|
||||
assert "manage_schedules" in LOCAL_READONLY_SUBAGENT_TOOL_NAMES
|
||||
assert "manage_schedules" not in ACTING_SUBAGENT_TOOL_NAMES
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ EXPECTED_TOOLS = [
|
|||
"run_ci_tests",
|
||||
"preflight_review", "review_status",
|
||||
"compact_context", "set_tool_timeout", "request_restart",
|
||||
"promote_to_stable", "schedule_subagent", "schedule_followup",
|
||||
"promote_to_stable", "schedule_subagent", "schedule_followup", "manage_schedules",
|
||||
"configure_presence", "initiate_presence",
|
||||
"integrate_subagent_patch", "compare_subagent_patches",
|
||||
# C1: the explicit acceptance seam for a delegated run's captured patch —
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
// Activity dashboard subtab (P4): a single observability + minimal-control view for
|
||||
// cron/scheduled tasks, what is running/queued now, and background consciousness.
|
||||
// Management is DIRECT mechanical control via existing APIs (cancel a task, enable/
|
||||
// disable/delete a MANUAL schedule, start/stop background consciousness). Skill-managed
|
||||
// schedules are READ-ONLY ("managed by skill") because the lifecycle resync would
|
||||
// overwrite a direct toggle (supervisor/queue.py) — control those via the skill itself.
|
||||
// disable/delete/restore schedules, start/stop background consciousness). Every
|
||||
// lifecycle button NAMES its action to the one audited server seam, so the owner's
|
||||
// buttons and the agent's tool mean exactly the same thing. Skill rows retain an
|
||||
// owner override in the existing schedule store, so a lifecycle resync cannot undo it.
|
||||
|
||||
import { fetchJson } from './api_client.js';
|
||||
import { setInlineStatus } from './ui_helpers.js';
|
||||
|
|
@ -30,8 +31,6 @@ function esc(value) {
|
|||
|
||||
const getJson = (url) => fetchJson(url, { cache: 'no-store' });
|
||||
|
||||
// A schedule synced from a skill manifest is reconciled from skill readiness, so a
|
||||
// direct enable/disable/delete here would be temporary/misleading — show it read-only.
|
||||
function isSkillManaged(s) {
|
||||
return Boolean(s && (String(s.source || '') === 'skill_manifest' || String(s.skill || '')));
|
||||
}
|
||||
|
|
@ -40,6 +39,8 @@ export function initActivity({ mount, ws } = {}) {
|
|||
if (!mount) return { refresh: () => {} };
|
||||
let busy = false;
|
||||
let refreshRevision = 0;
|
||||
// Remembered across re-renders: the owner's retained-history disclosure state.
|
||||
let historyOpen = false;
|
||||
mount.innerHTML = `<div class="activity-scroll">
|
||||
<div class="activity-section" data-activity-section="queue"><h3 class="activity-h">Running & queued</h3></div>
|
||||
<div class="activity-section" data-activity-section="background"><h3 class="activity-h">Background</h3></div>
|
||||
|
|
@ -142,38 +143,106 @@ export function initActivity({ mount, ws } = {}) {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
// The server names the lifecycle word; these fall back for an older payload
|
||||
// so a row is never silently promoted to "active" by a missing field.
|
||||
function scheduleStatus(s) {
|
||||
const once = String((s.trigger || {}).type || 'cron') === 'once';
|
||||
if (s.status) return String(s.status);
|
||||
if (once && s.completed_at) return 'consumed';
|
||||
if (isSkillManaged(s) && ['disabled', 'deleted'].includes(String(s.manual_override || ''))) return 'suppressed';
|
||||
return s.enabled === false ? 'disabled' : 'active';
|
||||
}
|
||||
|
||||
function scheduleRow(s) {
|
||||
const managed = isSkillManaged(s);
|
||||
const trigger = s.trigger || {};
|
||||
const once = String(trigger.type || 'cron') === 'once';
|
||||
// One-shot rows have no cron: show the fire instant + a "one-shot" tag.
|
||||
const timing = once
|
||||
? `one-shot · at/after ${esc(trigger.run_at || '')}`
|
||||
: esc(trigger.expr || s.cron || '');
|
||||
const next = esc(s.next_run_at || '');
|
||||
const status = scheduleStatus(s);
|
||||
const enabled = status === 'active';
|
||||
const consumed = status === 'consumed';
|
||||
const suppressed = status === 'suppressed';
|
||||
const id = esc(s.id || '');
|
||||
const sub = `${timing}${next && !consumed ? ` · next ${next}` : ''} · ${esc(status)}${managed && s.skill ? ` · ${esc(s.skill)}` : ''}`;
|
||||
// A consumed one-shot cannot be re-armed, so it carries no Enable: the
|
||||
// only honest control left is removing the receipt. A suppressed skill
|
||||
// row offers Restore, which asks the server to re-evaluate the skill.
|
||||
// A skill row that is disabled WITHOUT the owner's marker is held back by
|
||||
// its skill's readiness and re-arms on resync; an Enable button there
|
||||
// would offer to lift a suppression nobody applied.
|
||||
const readinessHeld = managed && !enabled && !suppressed;
|
||||
const lifecycle = consumed
|
||||
? '<span class="activity-tag">consumed once · history</span>'
|
||||
: readinessHeld
|
||||
? '<span class="activity-tag">disabled by skill readiness</span>'
|
||||
: `<button type="button" class="btn btn-xs btn-default" data-act="schedule-toggle" data-id="${id}" data-action="${suppressed || !enabled ? 'restore' : 'disable'}">${enabled ? 'Disable' : (suppressed ? 'Restore' : 'Enable')}</button>`;
|
||||
return `<div class="activity-row${enabled ? '' : ' off'}">
|
||||
<div class="activity-row-main">
|
||||
<span class="activity-name">${esc(s.name || s.id || 'schedule')}</span>
|
||||
<span class="activity-sub">${sub}</span>
|
||||
</div>
|
||||
<div class="activity-row-actions">${lifecycle}
|
||||
<button type="button" class="btn btn-xs btn-danger" data-act="schedule-delete" data-id="${id}" data-managed="${managed ? '1' : ''}">Delete</button></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSchedules(data) {
|
||||
if (!Array.isArray(data?.tasks)) throw new Error('Schedules unavailable');
|
||||
const tasks = data.tasks;
|
||||
if (!tasks.length) return '<div class="activity-empty">No scheduled tasks.</div>';
|
||||
return tasks.map((s) => {
|
||||
const managed = isSkillManaged(s);
|
||||
const trigger = s.trigger || {};
|
||||
const once = String(trigger.type || 'cron') === 'once';
|
||||
// One-shot rows have no cron: show the fire instant + a "one-shot" tag.
|
||||
const timing = once
|
||||
? `one-shot · at/after ${esc(trigger.run_at || '')}`
|
||||
: esc(trigger.expr || s.cron || '');
|
||||
const next = esc(s.next_run_at || '');
|
||||
const enabled = s.enabled !== false;
|
||||
const id = esc(s.id || '');
|
||||
const sub = `${timing}${next ? ` · next ${next}` : ''}${managed && s.skill ? ` · ${esc(s.skill)}` : ''}`;
|
||||
const actions = managed
|
||||
? '<span class="activity-tag">managed by skill</span>'
|
||||
: `<button type="button" class="btn btn-xs btn-default" data-act="schedule-toggle" data-id="${id}">${enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button type="button" class="btn btn-xs btn-danger" data-act="schedule-delete" data-id="${id}">Delete</button>`;
|
||||
return `<div class="activity-row${enabled ? '' : ' off'}">
|
||||
<div class="activity-row-main">
|
||||
<span class="activity-name">${esc(s.name || s.id || 'schedule')}</span>
|
||||
<span class="activity-sub">${sub}</span>
|
||||
</div>
|
||||
<div class="activity-row-actions">${actions}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
// Retained rows — a fired one-shot, a suppressed skill row — are history,
|
||||
// not schedules wearing a disabled flag. They stay reachable (and
|
||||
// restorable) but collapsed, so the standing schedules are the list.
|
||||
const standing = [];
|
||||
const retained = [];
|
||||
for (const s of tasks) (['consumed', 'suppressed'].includes(scheduleStatus(s)) ? retained : standing).push(s);
|
||||
const parts = standing.length
|
||||
? standing.map(scheduleRow)
|
||||
: ['<div class="activity-empty">No active or disabled schedules.</div>'];
|
||||
if (retained.length) {
|
||||
// A refresh rebuilds this markup, so the disclosure carries the state
|
||||
// the owner LEFT it in: a poll that silently re-collapses the history
|
||||
// they just opened (and drops the focus they had inside it) reads as
|
||||
// the app undoing their action.
|
||||
parts.push(`<details class="activity-history" data-activity-history${historyOpen ? ' open' : ''}>
|
||||
<summary>History & suppressed (${retained.length})</summary>
|
||||
${retained.map(scheduleRow).join('')}
|
||||
</details>`);
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function rememberDisclosure() {
|
||||
const details = mount.querySelector('[data-activity-history]');
|
||||
if (details) historyOpen = Boolean(details.open ?? details.hasAttribute('open'));
|
||||
}
|
||||
|
||||
function focusedControl() {
|
||||
const active = document.activeElement;
|
||||
return active && mount.contains?.(active) ? active.closest('[data-act]') : null;
|
||||
}
|
||||
|
||||
function restoreFocus(previous) {
|
||||
if (!previous) return;
|
||||
const target = [...mount.querySelectorAll('[data-act]')].find((el) => (
|
||||
el.dataset.act === previous.act && (el.dataset.id || '') === previous.id));
|
||||
if (!target) return;
|
||||
// Focus lives inside the disclosure for a retained row: open it rather
|
||||
// than focusing a control the owner cannot see.
|
||||
const details = target.closest?.('[data-activity-history]');
|
||||
if (details && !details.open) details.open = true;
|
||||
target.focus?.();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const revision = ++refreshRevision;
|
||||
rememberDisclosure();
|
||||
const focused = focusedControl();
|
||||
const focusedKey = focused && { act: focused.dataset.act, id: focused.dataset.id || '' };
|
||||
sections.forEach(({ root, status, loaded }) => {
|
||||
root.setAttribute('aria-busy', 'true');
|
||||
setInlineStatus(status, loaded ? 'Refreshing… Previously loaded values shown.' : 'Loading…');
|
||||
|
|
@ -186,6 +255,12 @@ export function initActivity({ mount, ws } = {}) {
|
|||
getJson('/api/schedules'),
|
||||
]);
|
||||
if (revision !== refreshRevision) return;
|
||||
// A request can take long enough for the owner to move focus elsewhere.
|
||||
// Only restore the original control when it is still the active owner;
|
||||
// if this render detaches it, activeElement is still the original node
|
||||
// immediately before replacement. A different active element means
|
||||
// the owner made a newer choice while the request was in flight.
|
||||
const mayRestoreFocus = !focusedKey || document.activeElement === focused;
|
||||
const census = results[1].status === 'fulfilled' ? results[1].value : null;
|
||||
const renderers = [(data) => renderQueue(data?.queue, census), renderBg, renderSchedules];
|
||||
sections.forEach((section, index) => {
|
||||
|
|
@ -194,6 +269,10 @@ export function initActivity({ mount, ws } = {}) {
|
|||
try {
|
||||
const result = results[index];
|
||||
if (result.status === 'rejected') throw result.reason;
|
||||
// Disclosure is an owner choice too. Capture it at the render
|
||||
// boundary, after the async reads, so opening/closing history
|
||||
// while a poll is pending is not undone by a stale snapshot.
|
||||
if (index === 2) rememberDisclosure();
|
||||
content.innerHTML = renderers[index](result.value);
|
||||
section.loaded = true;
|
||||
setInlineStatus(status, '');
|
||||
|
|
@ -203,12 +282,57 @@ export function initActivity({ mount, ws } = {}) {
|
|||
: 'Could not load. Current state is unknown. Reopen Activity to try again.', 'error');
|
||||
}
|
||||
});
|
||||
if (mayRestoreFocus) restoreFocus(focusedKey);
|
||||
}
|
||||
|
||||
async function findSchedule(id) {
|
||||
const data = await getJson('/api/schedules');
|
||||
const tasks = (data && Array.isArray(data.tasks)) ? data.tasks : [];
|
||||
return tasks.find((s) => String(s.id) === String(id)) || null;
|
||||
// One seam for every lifecycle button: the owner NAMES the action and why.
|
||||
// The server applies and audits it; nothing here infers a command from text.
|
||||
// Delete goes through here too — reading the outcome is not optional for one
|
||||
// button and skipped for another, or a refused delete reads as a silent no-op.
|
||||
async function scheduleAction(id, action, reason) {
|
||||
const outcome = await fetchJson(`/api/schedules/${encodeURIComponent(id)}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, reason }),
|
||||
});
|
||||
reportScheduleOutcome(action, outcome);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// The response says two separable things: whether the schedule CHANGED, and
|
||||
// whether both audit facts landed. A change whose outcome record was lost is
|
||||
// neither a clean success nor a failure, and saying either would be wrong.
|
||||
function reportScheduleOutcome(action, outcome) {
|
||||
if (!outcome || typeof outcome !== 'object' || typeof outcome.changed !== 'boolean') {
|
||||
showToast(`Schedule ${action}: the server did not say what happened.`, 'error');
|
||||
return;
|
||||
}
|
||||
const detail = String(outcome.detail || outcome.status || 'no status reported');
|
||||
if (outcome.changed === false) {
|
||||
showToast(`Schedule ${action} did not change anything: ${detail}`, 'error');
|
||||
return;
|
||||
}
|
||||
if (outcome.audit !== 'recorded') {
|
||||
showToast(`Schedule ${action} applied, but its audit record is incomplete: ${detail}`, 'warn');
|
||||
return;
|
||||
}
|
||||
if (outcome.status === 'restored_not_ready') {
|
||||
showToast(`Schedule suppression lifted, but it is not ready to run: ${detail}`, 'warn');
|
||||
} else if (outcome.ok !== true) {
|
||||
showToast(`Schedule ${action} changed, but did not finish successfully: ${detail}`, 'warn');
|
||||
}
|
||||
// Name what actually happened: a delete on a skill row is a durable
|
||||
// suppression, and saying "deleted" would claim a removal that did not occur.
|
||||
const done = outcome.status === 'suppressed' ? 'suppressed (kept disabled until restored)' : `${action}d`;
|
||||
// Lifecycle actions govern FUTURE dispatch; a run already admitted keeps
|
||||
// going. Silence here would let the owner read the button as a stop.
|
||||
if (outcome.running_or_queued === true) {
|
||||
showToast(`Schedule ${done}. A task it already started is still running and was not stopped.`, 'info');
|
||||
} else if (outcome.running_or_queued === null || outcome.running_or_queued === undefined) {
|
||||
showToast(`Schedule ${done}. Whether a task it already started is still running is unknown.`, 'info');
|
||||
} else if (outcome.status === 'suppressed') {
|
||||
showToast(`Schedule ${done}.`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
mount.addEventListener('click', async (event) => {
|
||||
|
|
@ -269,25 +393,25 @@ export function initActivity({ mount, ws } = {}) {
|
|||
btn.disabled = true;
|
||||
try {
|
||||
if (act === 'schedule-delete') {
|
||||
// A skill-declared schedule cannot be removed: the skill's manifest
|
||||
// would recreate it. Delete SUPPRESSES it durably, and the dialog
|
||||
// says so before anything is sent.
|
||||
const managedRow = btn.dataset.managed === '1';
|
||||
const confirmedDelete = await openConfirmDialog({
|
||||
title: 'Delete schedule',
|
||||
body: 'Delete this schedule?',
|
||||
confirmLabel: 'Delete',
|
||||
title: managedRow ? 'Suppress skill schedule' : 'Delete schedule',
|
||||
body: managedRow
|
||||
? 'This schedule is declared by an installed skill and cannot be removed; Delete keeps it suppressed until you Restore it. Suppress it?'
|
||||
: 'Delete this schedule?',
|
||||
confirmLabel: managedRow ? 'Suppress' : 'Delete',
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmedDelete) return;
|
||||
await fetchJson(`/api/schedules/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
await scheduleAction(id, 'delete', 'owner deleted the schedule from Activity');
|
||||
} else if (act === 'schedule-toggle') {
|
||||
// Read-modify-write the FULL record (upsert replaces by id; never drop
|
||||
// timezone/trigger/task/source) with the flipped enabled flag.
|
||||
const rec = await findSchedule(id);
|
||||
if (rec) {
|
||||
await fetchJson('/api/schedules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...rec, enabled: !(rec.enabled !== false) }),
|
||||
});
|
||||
}
|
||||
// The button already carries the action it means; no full-record
|
||||
// round trip, so a stale read can never overwrite runtime fields.
|
||||
const action = btn.dataset.action === 'disable' ? 'disable' : 'restore';
|
||||
await scheduleAction(id, action, `owner chose ${action} from Activity`);
|
||||
} else if (act === 'bg-toggle') {
|
||||
const on = btn.dataset.enabled === '1';
|
||||
// Reuse the existing direct control command (same as the chat header
|
||||
|
|
|
|||
|
|
@ -1252,18 +1252,35 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* Mirrors `gateway/schedule_contracts.py`, which states what each field means.
|
||||
* @typedef {Object} ScheduledTasksResponse
|
||||
* @property {number} schema_version
|
||||
* @property {Object[]} tasks
|
||||
* @property {Object[]} tasks // each row carries status/retained/restorable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ScheduleUpsertResponse
|
||||
* @property {boolean} ok
|
||||
* @property {boolean} ok // follows schedule.audit: an incomplete audit is not ok
|
||||
* @property {Object} schedule
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ScheduleActionResponse
|
||||
* @property {boolean} ok // the requested state was ACHIEVED and both audit records landed (restored_not_ready: changed, not ok)
|
||||
* @property {boolean} changed // the durable fact, whatever the audit did
|
||||
* @property {string} status
|
||||
* @property {string} schedule_id
|
||||
* @property {string=} operation_id
|
||||
* @property {?boolean=} running_or_queued // already admitted; null = unknown
|
||||
* @property {('recorded'|'incomplete'|'not_written')} audit
|
||||
* @property {string=} detail
|
||||
* @property {Object=} schedule
|
||||
* @property {string[]=} allowed
|
||||
*/
|
||||
|
||||
/**
|
||||
* Legacy name for the DELETE response: the subset every previous caller read of
|
||||
* what that endpoint now answers as a ScheduleActionResponse.
|
||||
* @typedef {Object} ScheduleDeleteResponse
|
||||
* @property {boolean} ok
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -662,7 +662,7 @@ export function renderSettingsPage() {
|
|||
<label>Consciousness Autonomy</label>
|
||||
<input id="s-consciousness-autonomy" type="hidden" value="act">
|
||||
${renderSegmentedField({ target: 's-consciousness-autonomy', options: [{ value: 'observe', label: 'Observe' }, { value: 'act', label: 'Act' }, { value: 'full', label: 'Full' }] })}
|
||||
<div class="settings-inline-note"><strong>Observe:</strong> think, keep memory and knowledge, write to you — start nothing. <strong>Act (default):</strong> everything the runtime mode allows except editing Ouroboros's own code and prompts, evolution, restart and settings. <strong>Full:</strong> everything the runtime mode allows, evolution included.</div>
|
||||
<div class="settings-inline-note"><strong>Observe:</strong> research, internal memory and task/project notes, read-only research children it can also stop, schedule controls and replies to you; no shell, user-file, source, skill/settings or publication changes. <strong>Act (default):</strong> everything the runtime mode allows except editing Ouroboros's own code and prompts, evolution, restart and settings. <strong>Full:</strong> everything the runtime mode allows, evolution included.</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-field ui-field">
|
||||
|
|
|
|||
|
|
@ -4424,13 +4424,18 @@ textarea.chat-input::placeholder { color: var(--text-muted); }
|
|||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.skills-details {
|
||||
/* One disclosure, two surfaces: a Skills card's detail and Activity's retained
|
||||
schedules (a fired one-shot, a suppressed skill row) read the same, so they
|
||||
share these rules instead of growing a second look for the same gesture. */
|
||||
.skills-details,
|
||||
.activity-history {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid rgba(var(--neutral-rgb), 0.06);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.skills-details > summary {
|
||||
.skills-details > summary,
|
||||
.activity-history > summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--text-meta);
|
||||
|
|
@ -4441,18 +4446,21 @@ textarea.chat-input::placeholder { color: var(--text-muted); }
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.skills-details > summary::-webkit-details-marker {
|
||||
.skills-details > summary::-webkit-details-marker,
|
||||
.activity-history > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skills-details > summary::before {
|
||||
.skills-details > summary::before,
|
||||
.activity-history > summary::before {
|
||||
content: '\25B8';
|
||||
display: inline-block;
|
||||
transition: transform 120ms ease;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.skills-details[open] > summary::before {
|
||||
.skills-details[open] > summary::before,
|
||||
.activity-history[open] > summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { initActivity } from '../modules/activity.js';
|
||||
import { initLogs } from '../modules/logs.js';
|
||||
|
|
@ -94,9 +95,11 @@ class NodeStub {
|
|||
|
||||
function setup(t) {
|
||||
const mount = new NodeStub();
|
||||
const body = new NodeStub('body');
|
||||
const document = {
|
||||
body,
|
||||
createElement: (tag) => new NodeStub(tag),
|
||||
getElementById: (id) => mount.querySelector(`#${id}`),
|
||||
getElementById: (id) => mount.querySelector(`#${id}`) || body.querySelector(`#${id}`),
|
||||
};
|
||||
const window = new NodeStub();
|
||||
const routes = new Map();
|
||||
|
|
@ -107,20 +110,33 @@ function setup(t) {
|
|||
send() {},
|
||||
emit: (type, data = {}) => wsHandlers.get(type)?.(data),
|
||||
};
|
||||
const previous = Object.fromEntries(['document', 'window', 'fetch', 'requestAnimationFrame'].map((key) => [key, globalThis[key]]));
|
||||
const previous = Object.fromEntries(['document', 'window', 'fetch', 'requestAnimationFrame', 'setTimeout'].map((key) => [key, globalThis[key]]));
|
||||
// Timers still fire during the test; the handles are tracked so a toast's
|
||||
// dismissal timeout cannot keep the runner's event loop alive after it.
|
||||
const timers = new Set();
|
||||
const realSetTimeout = globalThis.setTimeout;
|
||||
Object.assign(globalThis, {
|
||||
document, window, requestAnimationFrame: (fn) => fn(),
|
||||
fetch: async (url) => {
|
||||
setTimeout: (fn, ms, ...rest) => {
|
||||
const handle = realSetTimeout(fn, ms, ...rest);
|
||||
timers.add(handle);
|
||||
return handle;
|
||||
},
|
||||
fetch: async (url, init) => {
|
||||
calls.push(url);
|
||||
if (!routes.has(url)) throw new Error(`Unexpected request ${url}`);
|
||||
const value = routes.get(url);
|
||||
if (typeof value === 'function') return value();
|
||||
if (typeof value === 'function') return value(url, init);
|
||||
if (value instanceof Error) throw value;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
t.after(() => Object.assign(globalThis, previous));
|
||||
return { mount, window, ws, routes, calls };
|
||||
t.after(() => {
|
||||
timers.forEach((handle) => clearTimeout(handle));
|
||||
Object.assign(globalThis, previous);
|
||||
});
|
||||
const toasts = () => body.querySelectorAll('.toast').map((node) => node.textContent);
|
||||
return { mount, window, ws, routes, calls, toasts };
|
||||
}
|
||||
|
||||
const response = (data, status = 200) => ({ ok: status < 400, status, json: async () => data });
|
||||
|
|
@ -178,6 +194,236 @@ test('Activity keeps last-known data and actions on refresh failure, then reconc
|
|||
assert.equal(scheduled.querySelector('button').disabled, false);
|
||||
});
|
||||
|
||||
const lifecycleSchedules = [
|
||||
{ id: 'active', name: 'Active schedule', enabled: true, status: 'active', trigger: { type: 'cron', expr: '* * * * *' } },
|
||||
{ id: 'disabled', name: 'Disabled schedule', enabled: false, status: 'disabled', trigger: { type: 'cron', expr: '* * * * *' } },
|
||||
{ id: 'suppressed', name: 'Suppressed skill', enabled: false, status: 'suppressed', retained: true, restorable: true, source: 'skill_manifest', manual_override: 'disabled', trigger: { type: 'cron', expr: '* * * * *' } },
|
||||
{ id: 'consumed', name: 'Consumed once', enabled: false, status: 'consumed', consumed: true, retained: true, completed_at: '2026-09-21T00:00:00Z', trigger: { type: 'once', run_at: '2026-09-20T00:00:00Z' } },
|
||||
];
|
||||
|
||||
test('Activity separates standing schedules from retained history and keeps Restore', async (t) => {
|
||||
const { mount, routes, ws } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: lifecycleSchedules }));
|
||||
await initActivity({ mount, ws }).refresh();
|
||||
const schedules = section(mount, 'schedules');
|
||||
const history = schedules.querySelector('[data-activity-history]');
|
||||
|
||||
// Standing schedules are the list; history is one collapsed disclosure.
|
||||
const standing = [...schedules.querySelectorAll('.activity-row')].filter((row) => !history.contains(row));
|
||||
assert.equal(standing.length, 2);
|
||||
assert.match(standing[0].textContent, /Active schedule[\s\S]*active/);
|
||||
assert.equal(standing[0].querySelector('button').textContent, 'Disable');
|
||||
assert.equal(standing[0].querySelector('button').dataset.action, 'disable');
|
||||
assert.match(standing[1].textContent, /Disabled schedule[\s\S]*disabled/);
|
||||
assert.equal(standing[1].querySelector('button').textContent, 'Enable');
|
||||
assert.equal(standing[1].querySelector('button').dataset.action, 'restore');
|
||||
|
||||
assert.equal(history.hasAttribute('open'), false);
|
||||
assert.match(history.querySelector('summary').textContent, /suppressed \(2\)/);
|
||||
const retained = history.querySelectorAll('.activity-row');
|
||||
assert.equal(retained.length, 2);
|
||||
assert.match(retained[0].textContent, /Suppressed skill[\s\S]*suppressed/);
|
||||
assert.equal(retained[0].querySelector('button').textContent, 'Restore');
|
||||
// A fired one-shot cannot be re-armed, so it offers no Enable — only Delete.
|
||||
assert.match(retained[1].textContent, /Consumed once[\s\S]*consumed/);
|
||||
assert.match(retained[1].textContent, /consumed once · history/);
|
||||
const consumedButtons = [...retained[1].querySelectorAll('button')].map((b) => b.dataset.act);
|
||||
assert.deepEqual(consumedButtons, ['schedule-delete']);
|
||||
});
|
||||
|
||||
test('Activity with only retained rows says so instead of showing an empty list', async (t) => {
|
||||
const { mount, routes, ws } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: lifecycleSchedules.slice(2) }));
|
||||
await initActivity({ mount, ws }).refresh();
|
||||
const schedules = section(mount, 'schedules');
|
||||
assert.match(schedules.textContent, /No active or disabled schedules\./);
|
||||
assert.equal(schedules.querySelector('[data-activity-history]').querySelectorAll('.activity-row').length, 2);
|
||||
});
|
||||
|
||||
test('Activity lifecycle buttons name their action to the one audited endpoint', async (t) => {
|
||||
const { mount, routes, ws } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: lifecycleSchedules }));
|
||||
const sent = [];
|
||||
for (const id of ['active', 'suppressed']) {
|
||||
routes.set(`/api/schedules/${id}/action`, (url, init) => {
|
||||
sent.push({ url, body: JSON.parse(init.body) });
|
||||
return response({ ok: true, changed: true, status: 'updated', audit: 'recorded', running_or_queued: false });
|
||||
});
|
||||
}
|
||||
await initActivity({ mount, ws }).refresh();
|
||||
const schedules = section(mount, 'schedules');
|
||||
const history = schedules.querySelector('[data-activity-history]');
|
||||
await mount.fire('click', { target: schedules.querySelector('[data-act="schedule-toggle"][data-id="active"]') });
|
||||
await mount.fire('click', { target: history.querySelector('[data-act="schedule-toggle"][data-id="suppressed"]') });
|
||||
assert.deepEqual(sent.map((entry) => [entry.url, entry.body.action]), [
|
||||
['/api/schedules/active/action', 'disable'],
|
||||
['/api/schedules/suppressed/action', 'restore'],
|
||||
]);
|
||||
// Every change carries a reason; nothing infers the command from that text.
|
||||
assert.ok(sent.every((entry) => typeof entry.body.reason === 'string' && entry.body.reason.length > 0));
|
||||
// No full-record round trip: a stale read can never overwrite runtime fields.
|
||||
assert.equal(sent.every((entry) => !('task' in entry.body) && !('enabled' in entry.body)), true);
|
||||
});
|
||||
|
||||
test('Activity reports what a lifecycle action actually did, delete included', async (t) => {
|
||||
const { mount, routes, ws, toasts } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: lifecycleSchedules }));
|
||||
// Both buttons of the same row read the SAME outcome contract; a refused
|
||||
// delete must not read as a silent no-op just because it is a delete.
|
||||
const outcomes = [
|
||||
{ ok: false, changed: false, status: 'not_found', audit: 'not_written' },
|
||||
{ ok: false, changed: true, status: 'changed_audit_incomplete', audit: 'incomplete',
|
||||
detail: 'the change is durable; its audit outcome record could not be written' },
|
||||
{ ok: true, changed: true, status: 'updated', audit: 'recorded', running_or_queued: true },
|
||||
{ ok: true, changed: true, status: 'updated', audit: 'recorded', running_or_queued: null },
|
||||
{ ok: true, changed: true, status: 'deleted', audit: 'recorded', running_or_queued: false },
|
||||
];
|
||||
let next = 0;
|
||||
for (const id of ['active', 'disabled']) {
|
||||
routes.set(`/api/schedules/${id}/action`, () => response(outcomes[next++]));
|
||||
}
|
||||
const activity = initActivity({ mount, ws });
|
||||
await activity.refresh();
|
||||
const click = async (act, id) => {
|
||||
const target = section(mount, 'schedules').querySelector(`[data-act="${act}"][data-id="${id}"]`);
|
||||
await mount.fire('click', { target });
|
||||
};
|
||||
await click('schedule-toggle', 'active');
|
||||
assert.match(toasts().at(-1), /did not change anything: not_found/);
|
||||
await click('schedule-toggle', 'active');
|
||||
assert.match(toasts().at(-1), /audit record is incomplete: the change is durable/);
|
||||
await click('schedule-toggle', 'active');
|
||||
assert.match(toasts().at(-1), /still running and was not stopped/);
|
||||
await click('schedule-toggle', 'active');
|
||||
assert.match(toasts().at(-1), /is unknown/);
|
||||
// A clean change with nothing in flight has nothing to disclose.
|
||||
routes.set('/api/schedules/disabled/action', () => response(outcomes[4]));
|
||||
await click('schedule-toggle', 'disabled');
|
||||
assert.equal(toasts().length, 4);
|
||||
});
|
||||
|
||||
test('Activity does not call a schedule action applied without a positive changed receipt', async (t) => {
|
||||
const { mount, routes, ws, toasts } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: lifecycleSchedules }));
|
||||
const outcomes = [
|
||||
{},
|
||||
{ ok: false, status: 'not_found' },
|
||||
];
|
||||
let next = 0;
|
||||
routes.set('/api/schedules/active/action', () => response(outcomes[next++]));
|
||||
const activity = initActivity({ mount, ws });
|
||||
await activity.refresh();
|
||||
const click = async () => {
|
||||
const target = section(mount, 'schedules').querySelector(
|
||||
'[data-act="schedule-toggle"][data-id="active"]');
|
||||
await mount.fire('click', { target });
|
||||
};
|
||||
await click();
|
||||
assert.match(toasts().at(-1), /server did not say what happened/);
|
||||
assert.doesNotMatch(toasts().at(-1), /did not change anything/);
|
||||
assert.doesNotMatch(toasts().at(-1), /applied/,
|
||||
'an empty success-shaped payload must not disclose an applied mutation');
|
||||
await click();
|
||||
assert.match(toasts().at(-1), /server did not say what happened/);
|
||||
assert.doesNotMatch(toasts().at(-1), /applied/,
|
||||
'ok=false without changed=true must not disclose an applied mutation');
|
||||
});
|
||||
|
||||
test('Activity distinguishes a recorded restore that is not ready from audit failure', async (t) => {
|
||||
const { mount, routes, ws, toasts } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: lifecycleSchedules }));
|
||||
routes.set('/api/schedules/suppressed/action', () => response({
|
||||
ok: false, changed: true, status: 'restored_not_ready', audit: 'recorded',
|
||||
detail: 'skill_not_ready', running_or_queued: false,
|
||||
}));
|
||||
await initActivity({ mount, ws }).refresh();
|
||||
await mount.fire('click', { target: section(mount, 'schedules').querySelector(
|
||||
'[data-act="schedule-toggle"][data-id="suppressed"]') });
|
||||
assert.match(toasts().at(-1), /suppression lifted.*not ready to run: skill_not_ready/);
|
||||
assert.doesNotMatch(toasts().at(-1), /audit.*incomplete/);
|
||||
});
|
||||
|
||||
test('Activity delete reads its outcome through the same seam as the toggles', async () => {
|
||||
// Delete opens a modal this fixture cannot drive, so the centralisation is
|
||||
// pinned at the source: ONE request helper, which reports every outcome, and
|
||||
// no branch that fires the action and ignores what came back.
|
||||
const source = await readFile(new URL('../modules/activity.js', import.meta.url), 'utf8');
|
||||
const requests = source.match(/fetchJson\(`\/api\/schedules\//g) || [];
|
||||
assert.equal(requests.length, 1, 'every lifecycle button goes through one request helper');
|
||||
const helper = source.split('async function scheduleAction(')[1].split('\n }')[0];
|
||||
assert.match(helper, /reportScheduleOutcome\(action, outcome\)/);
|
||||
for (const branch of ["scheduleAction(id, 'delete'", 'scheduleAction(id, action']) {
|
||||
assert.ok(source.includes(branch), branch);
|
||||
}
|
||||
// No second, button-local reading of the response beside the shared one.
|
||||
assert.equal((source.match(/outcome\.ok/g) || []).length, 1);
|
||||
});
|
||||
|
||||
test('Activity keeps the retained-history disclosure and focus the owner left open', async (t) => {
|
||||
const { mount, routes, ws } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: lifecycleSchedules }));
|
||||
const activity = initActivity({ mount, ws });
|
||||
await activity.refresh();
|
||||
const schedules = section(mount, 'schedules');
|
||||
assert.equal(schedules.querySelector('[data-activity-history]').hasAttribute('open'), false);
|
||||
|
||||
// The owner opens the history and focuses a control inside it.
|
||||
const details = schedules.querySelector('[data-activity-history]');
|
||||
details.open = true;
|
||||
const restore = details.querySelector('[data-act="schedule-toggle"][data-id="suppressed"]');
|
||||
restore.focus();
|
||||
|
||||
// A poll rebuilds the markup; it must not re-collapse or drop the focus.
|
||||
await activity.refresh();
|
||||
const rebuilt = section(mount, 'schedules').querySelector('[data-activity-history]');
|
||||
assert.equal(rebuilt.hasAttribute('open'), true, 'a refresh must not close what the owner opened');
|
||||
assert.equal(globalThis.document.activeElement.dataset.id, 'suppressed');
|
||||
assert.equal(globalThis.document.activeElement.dataset.act, 'schedule-toggle');
|
||||
|
||||
// Collapsing it again is equally durable across the next refresh.
|
||||
rebuilt.open = false;
|
||||
await activity.refresh();
|
||||
assert.equal(section(mount, 'schedules').querySelector('[data-activity-history]').hasAttribute('open'), false);
|
||||
});
|
||||
|
||||
test('Activity preserves disclosure and current focus as of render, not request start', async (t) => {
|
||||
const { mount, routes, ws } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: lifecycleSchedules }));
|
||||
const activity = initActivity({ mount, ws });
|
||||
await activity.refresh();
|
||||
|
||||
// A refresh is in flight. The owner changes both disclosure and focus while
|
||||
// waiting; a late render must preserve those current choices rather than
|
||||
// restoring the state captured when the request began.
|
||||
const details = section(mount, 'schedules').querySelector('[data-activity-history]');
|
||||
details.open = false;
|
||||
const oldControl = details.querySelector('[data-act="schedule-toggle"][data-id="suppressed"]');
|
||||
oldControl.focus();
|
||||
let finishSchedules;
|
||||
routes.set(schedulesUrl, () => new Promise((resolve) => { finishSchedules = resolve; }));
|
||||
const pending = activity.refresh();
|
||||
const moved = new NodeStub('button');
|
||||
globalThis.document.body.appendChild(moved);
|
||||
moved.focus();
|
||||
details.open = true;
|
||||
finishSchedules(response({ tasks: lifecycleSchedules }));
|
||||
await pending;
|
||||
|
||||
const rebuilt = section(mount, 'schedules').querySelector('[data-activity-history]');
|
||||
assert.equal(rebuilt.hasAttribute('open'), true,
|
||||
'a render must retain disclosure state changed while its request was pending');
|
||||
assert.equal(globalThis.document.activeElement, moved,
|
||||
'a render must not steal focus the owner moved while its request was pending');
|
||||
});
|
||||
|
||||
test('Activity invalid success payload is unavailable and stale requests cannot replace newer state', async (t) => {
|
||||
const { mount, routes, ws } = setup(t);
|
||||
emptyActivity(routes);
|
||||
|
|
@ -394,3 +640,53 @@ test('Logs files a wake-up group under Consciousness with its label, from the or
|
|||
assert.equal(card('turn-2').dataset.category, 'tasks');
|
||||
assert.equal(card('turn-2').querySelector('[data-task-kind]').textContent, 'task turn-2');
|
||||
});
|
||||
|
||||
|
||||
test('Activity names the typed no-change refusals and never calls a suppression "deleted"', async (t) => {
|
||||
const { mount, routes, ws, toasts } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: lifecycleSchedules }));
|
||||
const outcomes = [
|
||||
{ ok: false, changed: false, status: 'manifest_absent', audit: 'recorded',
|
||||
detail: 'skill_absent: the skill no longer declares this schedule; suppression kept until it does' },
|
||||
{ ok: false, changed: false, status: 'lock_timeout', audit: 'not_written',
|
||||
detail: 'Could not acquire schedule lock within 8s' },
|
||||
{ ok: false, changed: false, status: 'not_suppressed', audit: 'recorded',
|
||||
detail: 'this skill schedule is not suppressed; it is disabled by skill readiness (skill_not_ready)' },
|
||||
{ ok: true, changed: true, status: 'suppressed', audit: 'recorded', running_or_queued: false },
|
||||
{ ok: true, changed: true, status: 'suppressed', audit: 'recorded', running_or_queued: null },
|
||||
];
|
||||
let next = 0;
|
||||
routes.set('/api/schedules/suppressed/action', () => response(outcomes[next++]));
|
||||
await initActivity({ mount, ws }).refresh();
|
||||
const restore = () => mount.fire('click', { target: section(mount, 'schedules').querySelector(
|
||||
'[data-act="schedule-toggle"][data-id="suppressed"]') });
|
||||
await restore();
|
||||
assert.match(toasts().at(-1), /did not change anything: skill_absent: .*suppression kept/);
|
||||
assert.doesNotMatch(toasts().at(-1), /suppression lifted/);
|
||||
await restore();
|
||||
assert.match(toasts().at(-1), /did not change anything: Could not acquire schedule lock/);
|
||||
await restore();
|
||||
assert.match(toasts().at(-1), /did not change anything: this skill schedule is not suppressed/);
|
||||
// A suppression is reported as what it is, with and without an in-flight fact.
|
||||
await restore();
|
||||
assert.match(toasts().at(-1), /Schedule suppressed \(kept disabled until restored\)\./);
|
||||
assert.doesNotMatch(toasts().at(-1), /deleted/);
|
||||
await restore();
|
||||
assert.match(toasts().at(-1), /Schedule suppressed \(kept disabled until restored\)\. Whether a task/);
|
||||
});
|
||||
|
||||
test('Activity offers no Enable on a skill row held back by readiness alone', async (t) => {
|
||||
const { mount, routes, ws } = setup(t);
|
||||
emptyActivity(routes);
|
||||
routes.set(schedulesUrl, response({ tasks: [
|
||||
{ id: 'held', name: 'Held skill', enabled: false, status: 'disabled', source: 'skill_manifest',
|
||||
skill: 'demo', trigger: { type: 'cron', expr: '* * * * *' } },
|
||||
] }));
|
||||
await initActivity({ mount, ws }).refresh();
|
||||
const row = section(mount, 'schedules').querySelector('.activity-row');
|
||||
assert.equal(row.querySelector('[data-act="schedule-toggle"]'), null);
|
||||
assert.match(row.textContent, /disabled by skill readiness/);
|
||||
// Delete stays (it suppresses) and is marked as a skill row for the dialog.
|
||||
assert.equal(row.querySelector('[data-act="schedule-delete"]').dataset.managed, '1');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue