Control Plane: split evie into daemon + CLI with Verbs and dynamic Schedules #55
Labels
No labels
epic
in-review
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
weiwen/evie#55
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem Statement
Scheduled prompts today are static
[[schedule]]TOML entries fired by an in-processscheduler_task. As a user I can only create a scheduled prompt by editing config and restarting the bot; I can't ask evie to remind me of something from a conversation, I can't schedule anything from an external script, and every scheduled prompt is forced to target one hardcoded chat with silent delivery. The agent can't schedule its own follow-ups, and nothing outside the process can drive evie at all.Solution
Split evie into a Daemon (
evie daemon— the long-running process: Telegram, HTTP API, Session manager, and a new Schedule store + timer) and a CLI (evie <verb>— short-lived invocations that issue one Verb to a running Daemon over a local Control Plane and exit). The same binary is both roles; the subcommand selects which.The Control Plane is a local Unix-domain socket (JSON-lines, mode 0600), never network-exposed, authed by filesystem permissions. Its Verbs let humans at a shell, external scripts, and — via a skill — the notes agent all drive evie through one interface:
pi).piand deliver the response to the chat.piand return the response to the caller, delivering nothing.A Schedule argv-wraps one delivery Verb and runs it once (
--in/--at) or recurring (--cron), persisted so it survives restarts. "Remind me in 1 hour to check X" becomes the agent runningevie schedule --in 1h -- prompt "..."; "morning digest" becomesevie schedule --cron "0 8 * * *" -- prompt "...", replacing the old static config entirely.User Stories
evie sendto post a verbatim message to my chat, so that my own tooling can notify me through evie without invokingpi.evie query "..."and receive the answer on stdout, so that I can use evie's notes knowledge headlessly in a pipeline without it appearing in my chat.evie prompt "..."and have the answer delivered to my chat, so that I can trigger a proactive message that goes throughpi.evie schedule, so that my automation can defer work without running its own cron.evie schedule --in 1h -- run /path/to/script.sh, so that arbitrary local logic runs later with evie's context available.EVIE_CHAT_IDandEVIE_SOCKETin my environment, so that myevie …callbacks resolve the right chat and daemon with no config.EVIE_SCHEDULE_NAMEandEVIE_SCHEDULE_TIMEin my environment, so that I know my own identity and scheduled fire time (e.g. to log, compute lateness, or re-schedule myself).evieCLI mid-turn, so that "remind me…" requests become real Schedules I created.evieCLI (andevie --help), so that I can discover and use the Verbs without a bespoke tool.EVIE_CHAT_IDandEVIE_SOCKETinjected into my process, so that myeviecalls target my own chat and daemon automatically.--name, so that I can cancel or update it later by that name.--namecollision to error by default, so that I don't silently clobber an existing Schedule.--updateto replace (or create) a Schedule by name, so that an agent re-issuing a Schedule is idempotent rather than failing.--name, I want evie to generate a handle and print it, so that anonymous one-shots can still be listed and cancelled.--in,--at, and--cronto be mutually exclusive and one required, so that a Schedule's timing is unambiguous.--cronand--atinterpreted in local time, so that scheduling matches how the previous scheduler behaved and how I think about time.send,prompt, orrun(notquery/list/cancel/schedule), so that nonsensical deferrals are rejected at creation.--chat→$EVIE_CHAT_ID→ the sole configured Telegram chat → error, so that the common single-user case needs no flag while explicit targeting stays possible.$XDG_RUNTIME_DIR/evie.sock(or/run/evie/evie.sockunder systemd) with mode 0600, so that only my user can drive the Daemon.[rpc] socket_pathand a--socketflag, so that I can relocate it when needed.~/.config/evie/schedules.json(atomic temp+rename, reloaded on startup), so that they survive restarts using the same idiom as chat sessions.[[schedule]]config to simply stop being read (no crash, no migration), so that upgrading is a clean break and I re-create anything I still want viaevie schedule --cron.api-sessions keeps working exactly as before.Implementation Decisions
evie daemonruns today's long-running process.evie <verb>invocations are CLIs that connect to a running Daemon over the Control Plane, issue one Verb, print the result, and exit. Single binary, subcommand selects the role. See ADR 0006.$XDG_RUNTIME_DIR/evie.sock, falling back to~/.config/evie/evie.sock; under the NixOS/systemd module setRuntimeDirectory=evieso it lands at/run/evie/evie.sock. Overridable via a new[rpc] socket_pathconfig key and a--socketflag; the CLI resolves--socket→$EVIE_SOCKET→ default.dispatch(verb, resolved_chat) → responsecore executes every Verb against the existingSessionManagerand the new Schedule store. The socket is a thin adapter over it, mirroring howhttp.rssits overSessionManager. This is the primary test seam.send(verbatim to chat, nopi),prompt(pi→ deliver to chat),query(pi→ return to caller, deliver nothing),run(spawn a local script),schedule/list/cancel.promptandqueryrun in throwawaypisessions, isolated from the chat's live conversation Session, so RPC-driven turns never abort/block/pollute an in-flight turn.senduses no session. This deletes any need for queueing or wait-for-idle machinery. The accepted tradeoff is context-blindness.evie schedule (--in <dur> | --at <ts> | --cron <expr>) [--name <name>] [--update] [--chat <id>] -- <verb> [args…]. Timing flags are a required, mutually-exclusive group. At creation the Daemon eagerly parses and validates the inner argv, rejects anything butsend|prompt|run, resolves and freezes the target chat, then stores the normalized argv. At fire time the Daemon re-dispatches the stored argv in-process (no re-exec), so fire-time behavior — notably prompt-script resolution viaPromptSource— is identical between an immediate and a scheduled run.(Chat ID, name).--nameomitted → generate a short opaque handle and print it. Name exists without--update→ error.--update→ replace if present, create if absent (upsert).~/.config/evie/schedules.json, atomic temp+rename, reloaded on Daemon startup — same idiom aschat_sessions.json. Each entry:{ name, chat, schedule: oneshot(ts) | cron(expr), argv, created_at }.now, out of thetokio::sleeptiming loop. Missed one-shot (fire time elapsed during downtime) fires late with a flag ("⏰ due at T (evie was offline)"); recurring fires at its next occurrence. Cron and--atevaluated inchrono::Local.listoutput. Human table by default;--jsonemits raw store entries ({name, chat, schedule, argv, created_at, next_fire}) for the agent to parse. Scoped to the resolved chat,--chatoverrides.canceloutput.evie cancel --name <name>, chat resolved via the standard chain. Prints confirmation; exits nonzero with a not-found message if absent. No--all.EVIE_CHAT_ID+EVIE_SOCKETalways;EVIE_SCHEDULE_NAME+EVIE_SCHEDULE_TIMEwhen fired from a Schedule. Fire-and-forget: stdout→debug, exit code→info, errors→warn.--chat→$EVIE_CHAT_ID→ the sole configured Telegram chat → error if ambiguous/none. Uses the existingChatIdgrammar.piSkill in-repo (wired via[pi].skills) documenting theevieCLI, withevie --helpcarrying the detail. The agent uses its existing shell tool to runevie …. The Daemon injectsEVIE_CHAT_ID+EVIE_SOCKETinto the interactivepiprocess env at spawn. Dependency to verify before building: the spawnedpisessions must actually expose a shell/command-exec tool; if that is gated, fall back to a thinpiextension that shells out toevie.[[schedule]],ScheduleConfig, andscheduler_task. No migration; a stale[[schedule]]table is simply ignored (serde unknown field).api-sessions stays as-is; the Control Plane is a separate, local surface.Testing Decisions
Good tests here assert external behavior at a seam, not internals: given a Verb and a resolved chat, what got delivered / returned / stored / fired — never how the socket framed bytes or how the timing loop slept. Follow the existing colocated
#[cfg(test)] mod testsstyle with a fakepi(PiProcess::fake_responder),tempdirfor on-disk state, and helpers likeinsert_slot_for_test.dispatch(verb, resolved_chat)directly withfake_responder+ atempdirschedule store. Cover:send/prompt/query/rundelivery-vs-return semantics; Ephemeral-Session isolation (a firedprompt/querynever touches or aborts a live slot inserted viainsert_slot_for_test); targeting resolution and the ambiguity error; Schedule CRUD (create,--namecollision → error,--updateupsert, per-chat name scoping,list,cancelincl. not-found). Prior art: thesession.rstests (get_status_does_not_block…,cleanup_idle_skips…) that driveSessionManagerwith fake slots.claptry_parse_fromoverevie <verb> …argv → request struct:schedule … -- <verb>argv-wrap, timing mutual-exclusion (--in/--at/--cron), thesend|prompt|runinner-verb whitelist,--name/--update. Pure, mirrors theconfig.rsparse/validation tests (test_schedule_config_parses,test_schedule_validation_fails_on_invalid_cron).now-injected functions: one-shot due, one-shot missed-during-downtime (fires late + flagged), recurring next-occurrence, local-time interpretation. Notokio::sleep, no wall-clock dependence.tempdirschedules.jsonand assert entries survive, including the frozen chat and argv. Prior art: session-map persistence patterns.The Unix socket adapter and the
tokio::sleeptiming loop stay thin and are left largely untested — behavior lives behind the seams above.Out of Scope
send/prompt/etc.). Local-only Unix socket; if a story ever needs remote, it wraps the same transport-agnostic dispatch core.piextension registering structured tools — replaced by the Skill + CLI approach (revisit only ifpisessions lack a shell tool).cancel+scheduleagain.query(nobody holds the connection at fire time), andlist/cancel --all.[[schedule]]entries into the store.Further Notes
CONTEXT.md(Control Plane cluster: Daemon, CLI, Control Plane, Verb, Schedule, Ephemeral Session) anddocs/adr/0006-daemon-cli-control-plane.md, which also captures the four rejected alternatives (external OS scheduler, HTTP control plane,piextension, flat schedule flags).pi); the interface is the Control Plane.pisessions expose a shell/command-exec tool (drives Skill-vs-extension for the agent path).Open dependency resolved:
piexposes a shell/command-exec tool, so the agent path is confirmed as skill-drives-CLI (agent runsevie …directly). Nopiextension fallback needed. ADR 0006 updated accordingly.