diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index 9010f115..a089b7b9 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -14,7 +14,7 @@ Tools (hyperhive surface): - `mcp__hyperhive__request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` — queue an approval for the operator to add a scheduled prompt. On approve hive-c0re inserts a schedule row and the worker fans `body` out to each agent in `targets` at `first_fire_at_unix` (recurring every `interval_seconds` if set, one-shot when absent). Even self-targeted schedules go through approval — the existing `remind` tool stays the quick no-approval self-wake path. Catch-up clamp: long downtime fires ONCE per recurring row on resume (skipped count surfaces in per-target `last_result`), not N stacked pulses. - `mcp__hyperhive__cancel_schedule(id, targets?)` — cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients (the schedule keeps firing for any remaining active targets, auto-cancels when every target is gone). Authorization: you can cancel schedules you own OR any owned by a sub-agent in your subtree per topology.json. - `mcp__hyperhive__fire_schedule_now(id)` — fire a scheduled prompt out of band. Runs the per-target fan-out once immediately. Recurring schedules keep their cadence intact (the manual fire is additive); one-shot schedules are CONSUMED by the manual fire (cancelled afterwards). Same authorization as `cancel_schedule`. -- `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` — partial-update a schedule's mutable fields (#474). Pass only the fields you want to change. `targets_add` / `targets_remove` mutate the recipient list in the same transaction; re-adding a previously-cancelled target drops the tombstone + history (operator intent: "fresh start"). Refuses cancelled rows. Same authorization as `cancel_schedule`. Note: clearing scalar fields (e.g. flipping recurring→one-shot) is operator-only via the dashboard PATCH — the agent surface only supports positive sets on `description` / `interval_seconds`. +- `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?)` — partial-update a schedule's mutable fields (#474). Pass only the fields you want to change. Targets stay immutable (cancel + new schedule is the workaround). Refuses cancelled rows. Same authorization as `cancel_schedule`. Note: clearing fields (e.g. flipping recurring→one-shot) is operator-only via the dashboard PATCH — the agent surface only supports positive sets. - `mcp__hyperhive__list_schedules()` — snapshot every schedule in the queue (active + cancelled-but-not-reaped). Returns id, owner, body, target set with per-target `last_fired_at` + `last_result`, `next_fire_at_unix`, recurring `interval_seconds`. Use to look up an id before cancelling, or to audit upcoming wake-ups across the swarm. - `mcp__hyperhive__get_logs(agent, lines?)` — fetch recent journal lines for a sub-agent container. Use to diagnose MCP-server registration failures, startup crashes, or harness issues you can't see from inside. Pass the plain logical agent name; `lines` defaults to 50 (capped at 500). - `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the operator (default, or `to: "operator"`) OR a sub-agent (`to: ""`). Returns immediately with a question id; the answer arrives later as a system `question_answered { id, question, answer, answerer }` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline (capped at 6h server-side) — on expiry the answer is `[expired]` and `answerer` is `"ttl-watchdog"`. Do not poll inside the same turn — finish the current work and react when the event lands. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 1a28c02b..227c7a66 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -1006,16 +1006,6 @@ pub struct EditScheduleArgs { /// leave the schedule on its current cadence. #[serde(default)] pub next_fire_at_unix: Option, - /// Names of new targets to add. Replace-on-conflict: re-adding - /// a previously cancelled target resets its history (operator - /// intent on re-add = "this target is active again"). - #[serde(default)] - pub targets_add: Option>, - /// Names of targets to cancel. Tombstones preserve per-target - /// audit; when no active targets remain the schedule - /// auto-cancels. - #[serde(default)] - pub targets_remove: Option>, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -1368,12 +1358,9 @@ impl ManagerServer { description = "Edit an existing scheduled prompt's mutable fields (#474). Pass only \ the fields you want to change — anything omitted keeps its current value. Editable: \ `body`, `description`, `interval_seconds` (positive only via this tool; flipping \ - recurring→one-shot is operator-only via the dashboard), `next_fire_at_unix`, and \ - the target set via `targets_add` / `targets_remove` (#478). Both target lists are \ - applied in the same transaction with removes-before-adds, so a single edit can \ - swap a target atomically. Re-adding a previously-removed target starts a fresh \ - per-target history (drops the tombstone). Draining all targets auto-cancels the \ - parent schedule. \n\n\ + recurring→one-shot is operator-only via the dashboard), `next_fire_at_unix`. \ + Targets are immutable: per-target last-result history is keyed on them. To change \ + the recipient list, cancel and submit a new schedule. \n\n\ Authorization mirrors `cancel_schedule` / `fire_schedule_now`: you can edit your \ own schedules + any owned by a sub-agent in your subtree per topology.json. \ Refuses cancelled schedules (the row's terminal — submit a fresh one)." @@ -1395,8 +1382,6 @@ impl ManagerServer { description: args.description.map(Some), interval_seconds: args.interval_seconds.map(Some), next_fire_at_unix: args.next_fire_at_unix, - targets_add: args.targets_add, - targets_remove: args.targets_remove, }) .await; annotate_retries( diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 0ff15d86..a3ba2b2b 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1509,17 +1509,6 @@ struct EditScheduleForm { interval_seconds: Option>, #[serde(default)] next_fire_at_unix: Option, - /// New targets to add. Replace-on-conflict: re-adding a - /// previously-cancelled target drops the tombstone and the - /// target starts fresh (operator intent on re-add = "this - /// target is active again, fresh start"). - #[serde(default)] - targets_add: Option>, - /// Targets to cancel. Same path as `cancel_targets`: - /// tombstones preserve audit and the parent schedule - /// auto-cancels when no active targets remain. - #[serde(default)] - targets_remove: Option>, } /// serde adaptor: turns missing-key into `None`, explicit-null @@ -1536,16 +1525,14 @@ where /// `PATCH /api/schedules/{id}` — partial update of an existing /// schedule (#474). Mutable fields: `body`, `description`, -/// `interval_seconds`, `next_fire_at_unix`, plus the target set -/// via `targets_add` / `targets_remove` (#478). Both target lists -/// are applied in the same transaction as the scalar fields with -/// removes-before-adds; re-adding a previously-removed target -/// resets per-target history (fresh start); draining all targets -/// auto-cancels the parent schedule. JSON body uses missing-key -/// = "leave alone", explicit null = "clear" for `description` + -/// `interval_seconds`. Cancelled schedules are refused — submit -/// a new one instead. Returns the updated `WireSchedule` so the -/// caller's post-edit refresh has the new state inline. +/// `interval_seconds`, `next_fire_at_unix`. Targets stay +/// immutable (per-target last-result history is keyed on them; +/// the "I want different targets" workaround is cancel + submit +/// a new schedule). JSON body uses missing-key = "leave alone", +/// explicit null = "clear" for `description` + `interval_seconds`. +/// Cancelled schedules are refused — submit a new one instead. +/// Returns the updated `WireSchedule` so the caller's post-edit +/// refresh has the new state inline. async fn patch_schedule( State(state): State, AxumPath(id): AxumPath, @@ -1556,8 +1543,6 @@ async fn patch_schedule( description: form.description, interval_seconds: form.interval_seconds, next_fire_at_unix: form.next_fire_at_unix, - targets_add: form.targets_add, - targets_remove: form.targets_remove, }; if let Err(e) = state.coord.scheduled_prompts.update(id, patch) { return error_response(&format!("edit schedule {id}: {e:#}")); diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 303d69f1..19d19e50 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -356,8 +356,6 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp description, interval_seconds, next_fire_at_unix, - targets_add, - targets_remove, } => handle_edit_schedule( coord, hive_sh4re::MANAGER_AGENT, @@ -366,8 +364,6 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp description.clone(), *interval_seconds, *next_fire_at_unix, - targets_add.clone(), - targets_remove.clone(), ), ManagerRequest::ListSchedules => match coord.scheduled_prompts.list() { Ok(schedules) => ManagerResponse::Schedules { @@ -899,8 +895,6 @@ fn handle_edit_schedule( description: Option>, interval_seconds: Option>, next_fire_at_unix: Option, - targets_add: Option>, - targets_remove: Option>, ) -> ManagerResponse { let schedule = match coord.scheduled_prompts.get(schedule_id) { Ok(Some(s)) => s, @@ -928,8 +922,6 @@ fn handle_edit_schedule( description, interval_seconds, next_fire_at_unix, - targets_add, - targets_remove, }; match coord.scheduled_prompts.update(schedule_id, patch) { Ok(()) => ManagerResponse::Ok, diff --git a/hive-c0re/src/scheduled_prompts.rs b/hive-c0re/src/scheduled_prompts.rs index 8b958452..9de7f799 100644 --- a/hive-c0re/src/scheduled_prompts.rs +++ b/hive-c0re/src/scheduled_prompts.rs @@ -153,24 +153,12 @@ pub struct NewSchedule { /// `Some(Some(n))` = "set to n seconds." Same encoding as JSON /// "missing key" vs "explicit null" — the dashboard surface /// preserves the distinction. -/// -/// Target add/remove (#474 fast-follow): `targets_add` / `targets_remove` -/// run inside the same transaction as the scalar field updates so -/// "save my changes" is atomic. Remove delegates to the same -/// cancel-targets path used by `cancel_targets` (tombstoning, preserves -/// audit, auto-cancels parent when no active targets remain). Add uses -/// `INSERT OR REPLACE` so re-adding a previously-cancelled target -/// drops the tombstone and starts fresh (operator intent on re-add = -/// "this target is active again"; prior history was already visible -/// at cancel time). #[derive(Debug, Clone, Default)] pub struct UpdateSchedule { pub body: Option, pub description: Option>, pub interval_seconds: Option>, pub next_fire_at_unix: Option, - pub targets_add: Option>, - pub targets_remove: Option>, } pub struct ScheduledPrompts { @@ -360,25 +348,15 @@ impl ScheduledPrompts { } /// Partial-update an existing schedule's mutable fields (#474). - /// Every scalar field is `Option<_>`; `None` means "leave the - /// existing value alone", `Some(_)` means "set it to this." - /// Refuses cancelled rows (no point editing a tombstone — - /// operator can just submit a new schedule). Refuses - /// `interval_seconds = Some(0)` — same rule as submit-time - /// validation. - /// - /// Targets are mutable via `targets_remove` + `targets_add` - /// (#478 fast-follow). Both are processed in the same - /// transaction, with **removes before adds** so a single PATCH - /// can swap a target without ever leaving the schedule - /// target-less mid-tx. Remove writes a tombstone via - /// `COALESCE(cancelled_at_unix, ?1)` so it's idempotent. Add - /// uses `INSERT OR REPLACE` so re-adding a previously-removed - /// target drops the tombstone and resets per-target history - /// (fresh start — option C from the iris design thread). If - /// the post-tx active-target count is zero the parent schedule - /// is auto-cancelled — the worker would otherwise spin forever - /// firing nothing. + /// Every field is `Option<_>`; `None` means "leave the existing + /// value alone", `Some(_)` means "set it to this." Refuses + /// cancelled rows (no point editing a tombstone — operator can + /// just submit a new schedule). Refuses `interval_seconds = Some(0)` + /// — same rule as submit-time validation. Targets stay + /// immutable: per-target last-result history is keyed on + /// (schedule_id, target); changing the target set would orphan + /// or duplicate history rows. Operator workaround for "I want + /// different targets" is cancel-target + submit a new schedule. pub fn update(&self, id: i64, patch: UpdateSchedule) -> Result<()> { // Pre-flight: row must exist and not be cancelled. let mut conn = self.conn.lock().unwrap(); @@ -426,59 +404,6 @@ impl ScheduledPrompts { params![next, id], )?; } - // Target removals first, then adds. Order matters: an - // operator who sends `{add: ["alice"], remove: ["alice"]}` - // probably typo'd, but treating add-after-remove as the - // surviving intent means a clean re-arm rather than an - // orphaned tombstone. Plus removing-then-adding is the - // natural way to "restart history" on one target without - // a separate flow. - let now = now_unix(); - if let Some(remove) = patch.targets_remove.as_deref() { - for target in remove { - tx.execute( - "UPDATE scheduled_prompt_targets - SET cancelled_at_unix = COALESCE(cancelled_at_unix, ?1) - WHERE schedule_id = ?2 AND target = ?3", - params![now, id, target], - )?; - } - } - if let Some(add) = patch.targets_add.as_deref() { - for target in add { - // INSERT OR REPLACE drops any prior row (cancelled - // tombstone or otherwise) and inserts a fresh one - // with empty history. Operator intent on re-add = - // "this target is active again, fresh start." - tx.execute( - "INSERT OR REPLACE INTO scheduled_prompt_targets - (schedule_id, target, cancelled_at_unix, - last_fired_at_unix, last_result) - VALUES (?1, ?2, NULL, NULL, NULL)", - params![id, target], - )?; - } - } - // If the remove set drained the last active target (and - // the add set didn't bring any back), auto-cancel the - // parent so the worker stops scanning it. Mirrors - // `cancel_targets`'s behaviour for the same edge case. - if patch.targets_remove.is_some() || patch.targets_add.is_some() { - let active_targets: i64 = tx.query_row( - "SELECT COUNT(*) FROM scheduled_prompt_targets - WHERE schedule_id = ?1 AND cancelled_at_unix IS NULL", - params![id], - |row| row.get(0), - )?; - if active_targets == 0 { - tx.execute( - "UPDATE scheduled_prompts - SET cancelled_at_unix = COALESCE(cancelled_at_unix, ?1) - WHERE id = ?2", - params![now, id], - )?; - } - } tx.commit()?; Ok(()) } @@ -921,115 +846,6 @@ mod tests { assert!(s.description.is_none()); } - #[test] - fn update_adds_new_targets() { - let (_dir, db) = open(); - let id = submit_one_shot(&db, 100, &["alice"]); - db.update( - id, - UpdateSchedule { - targets_add: Some(vec!["bob".into(), "carol".into()]), - ..Default::default() - }, - ) - .expect("update"); - let s = db.get(id).expect("get").expect("present"); - let names: Vec<_> = s.targets.iter().map(|t| t.target.as_str()).collect(); - assert!(names.contains(&"alice")); - assert!(names.contains(&"bob")); - assert!(names.contains(&"carol")); - } - - #[test] - fn update_removes_targets_via_cancel_tombstone() { - let (_dir, db) = open(); - let id = submit_one_shot(&db, 100, &["alice", "bob"]); - db.update( - id, - UpdateSchedule { - targets_remove: Some(vec!["alice".into()]), - ..Default::default() - }, - ) - .expect("update"); - let s = db.get(id).expect("get").expect("present"); - let alice = s.targets.iter().find(|t| t.target == "alice").unwrap(); - let bob = s.targets.iter().find(|t| t.target == "bob").unwrap(); - // alice tombstoned, bob still active, parent still active. - assert!(alice.cancelled_at_unix.is_some()); - assert!(bob.cancelled_at_unix.is_none()); - assert!(s.cancelled_at_unix.is_none()); - } - - #[test] - fn update_removing_last_target_auto_cancels_parent() { - let (_dir, db) = open(); - let id = submit_one_shot(&db, 100, &["alice"]); - db.update( - id, - UpdateSchedule { - targets_remove: Some(vec!["alice".into()]), - ..Default::default() - }, - ) - .expect("update"); - let s = db.get(id).expect("get").expect("present"); - assert!(s.cancelled_at_unix.is_some()); - } - - #[test] - fn update_re_add_resurrects_cancelled_target_fresh() { - let (_dir, db) = open(); - let id = submit_one_shot(&db, 100, &["alice", "bob"]); - // Record some history on alice, then cancel her. - db.record_target_result(id, "alice", 50, "ok").expect("record"); - db.update( - id, - UpdateSchedule { - targets_remove: Some(vec!["alice".into()]), - ..Default::default() - }, - ) - .expect("cancel alice"); - // Re-add alice — should drop the tombstone + prior history. - db.update( - id, - UpdateSchedule { - targets_add: Some(vec!["alice".into()]), - ..Default::default() - }, - ) - .expect("re-add alice"); - let s = db.get(id).expect("get").expect("present"); - let alice = s.targets.iter().find(|t| t.target == "alice").unwrap(); - assert!(alice.cancelled_at_unix.is_none(), "fresh row, no tombstone"); - assert!(alice.last_fired_at_unix.is_none(), "history reset"); - assert!(alice.last_result.is_none(), "history reset"); - } - - #[test] - fn update_add_and_remove_in_one_transaction() { - let (_dir, db) = open(); - let id = submit_one_shot(&db, 100, &["alice"]); - db.update( - id, - UpdateSchedule { - targets_remove: Some(vec!["alice".into()]), - targets_add: Some(vec!["bob".into()]), - ..Default::default() - }, - ) - .expect("update"); - let s = db.get(id).expect("get").expect("present"); - let alice = s.targets.iter().find(|t| t.target == "alice").unwrap(); - let bob = s.targets.iter().find(|t| t.target == "bob").unwrap(); - assert!(alice.cancelled_at_unix.is_some()); - assert!(bob.cancelled_at_unix.is_none()); - // Bob arrived AFTER alice was tombstoned in the same tx — - // active-target count > 0, so parent stays alive. - assert!(s.cancelled_at_unix.is_none()); - } - #[test] fn approval_source_round_trips() { let (_dir, db) = open(); diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index e5411178..8d53ddac 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -954,10 +954,8 @@ pub enum ManagerRequest { /// doubly-wrapped so `Some(None)` (set explicit null) can flip /// a recurring schedule back to one-shot / clear the /// description, while plain `None` keeps the current value. - /// `targets_add` / `targets_remove` mutate the recipient list - /// in the same transaction; re-adding a previously-cancelled - /// target drops the tombstone (replace-on-conflict). Refuses - /// cancelled rows. Authorization mirrors `CancelSchedule`. + /// Targets stay immutable. Refuses cancelled rows. + /// Authorization mirrors `CancelSchedule`. EditSchedule { id: i64, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -968,10 +966,6 @@ pub enum ManagerRequest { interval_seconds: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] next_fire_at_unix: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - targets_add: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - targets_remove: Option>, }, }