fix(#1953): route approval helper-events to the submitter, not the root agent

This commit is contained in:
damocles 2026-06-23 20:09:40 +02:00 committed by mara
commit 3618399d94
7 changed files with 187 additions and 73 deletions

View file

@ -546,15 +546,18 @@ fn finish_approval(
(ApprovalStatus::Failed, Some(note), false)
}
};
coord.notify_manager(&HelperEvent::ApprovalResolved {
id: approval.id,
agent: approval.agent.clone(),
commit_ref: approval.commit_ref.clone(),
status,
note: note.clone(),
sha: approval.fetched_sha.clone(),
tag: terminal_tag.clone(),
});
coord.notify_submitter(
approval.id,
&HelperEvent::ApprovalResolved {
id: approval.id,
agent: approval.agent.clone(),
commit_ref: approval.commit_ref.clone(),
status,
note: note.clone(),
sha: approval.fetched_sha.clone(),
tag: terminal_tag.clone(),
},
);
// Phase 5b: also fire on the dashboard event channel so the
// browser moves the row out of pending into history without a
// snapshot refetch. `approved` rows that succeed get the
@ -590,37 +593,49 @@ fn finish_approval(
match approval.kind {
ApprovalKind::InitConfig => {
if ok {
coord.notify_manager(&HelperEvent::ConfigReady {
agent: approval.agent.clone(),
});
coord.notify_submitter(
approval.id,
&HelperEvent::ConfigReady {
agent: approval.agent.clone(),
},
);
}
}
ApprovalKind::Spawn => coord.notify_manager(&HelperEvent::Spawned {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
}),
ApprovalKind::ApplyCommit if is_first_spawn => {
coord.notify_manager(&HelperEvent::Spawned {
ApprovalKind::Spawn => coord.notify_submitter(
approval.id,
&HelperEvent::Spawned {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
});
},
),
ApprovalKind::ApplyCommit if is_first_spawn => {
coord.notify_submitter(
approval.id,
&HelperEvent::Spawned {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
},
);
}
// MergeConfigPr ends in a container rebuild just like a
// non-first-spawn ApplyCommit, so both surface the same Rebuilt
// lifecycle event. (MergeConfigPr is never a first spawn — the
// agent already exists — so it never hits the Spawned arm above.)
ApprovalKind::ApplyCommit | ApprovalKind::MergeConfigPr => {
coord.notify_manager(&HelperEvent::Rebuilt {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
tag: terminal_tag,
});
coord.notify_submitter(
approval.id,
&HelperEvent::Rebuilt {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
tag: terminal_tag,
},
);
}
// UpdateMetaInputs / SchedulePrompt: ApprovalResolved already
// carries the result. No separate lifecycle event needed.
@ -1037,15 +1052,18 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
let description = a.description.clone();
let agent_owned = a.agent.clone();
coord.notify_manager(&HelperEvent::ApprovalResolved {
id: a.id,
agent: a.agent,
commit_ref: a.commit_ref,
status: ApprovalStatus::Denied,
note: note.map(String::from),
sha,
tag,
});
coord.notify_submitter(
a.id,
&HelperEvent::ApprovalResolved {
id: a.id,
agent: a.agent,
commit_ref: a.commit_ref,
status: ApprovalStatus::Denied,
note: note.map(String::from),
sha,
tag,
},
);
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id,
agent: &agent_owned,

View file

@ -70,6 +70,21 @@ fn ensure_fetched_sha_column(conn: &Connection) -> Result<()> {
Ok(())
}
/// Same shape as `ensure_fetched_sha_column` but for `submitter` — the
/// agent that submitted the approval (the authenticated socket caller).
/// Approval-scoped helper events route to this agent. Legacy rows have
/// NULL; callers fall back to the root agent for those.
fn ensure_submitter_column(conn: &Connection) -> Result<()> {
let has: bool = conn
.prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'submitter'")?
.exists([])?;
if !has {
conn.execute_batch("ALTER TABLE approvals ADD COLUMN submitter TEXT;")
.context("add approvals.submitter column")?;
}
Ok(())
}
pub struct Approvals {
conn: Mutex<Connection>,
}
@ -87,6 +102,7 @@ impl Approvals {
ensure_kind_column(&conn).context("migrate approvals.kind")?;
ensure_fetched_sha_column(&conn).context("migrate approvals.fetched_sha")?;
ensure_description_column(&conn).context("migrate approvals.description")?;
ensure_submitter_column(&conn).context("migrate approvals.submitter")?;
Ok(Self {
conn: Mutex::new(conn),
})
@ -98,22 +114,41 @@ impl Approvals {
kind: ApprovalKind,
commit_ref: &str,
description: Option<&str>,
submitter: &str,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, description)
VALUES (?1, ?2, ?3, ?4, 'pending', ?5)",
"INSERT INTO approvals
(agent, kind, commit_ref, requested_at, status, description, submitter)
VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6)",
params![
agent,
kind_to_str(kind),
commit_ref,
now_unix(),
description
description,
submitter
],
)?;
Ok(conn.last_insert_rowid())
}
/// The agent that submitted approval `id` (the authenticated socket
/// caller at submit time). `None` for legacy rows predating the
/// `submitter` column — callers route those to the root agent.
pub fn submitter_of(&self, id: i64) -> Result<Option<String>> {
let conn = self.conn.lock().unwrap();
let submitter: Option<String> = conn
.query_row(
"SELECT submitter FROM approvals WHERE id = ?1",
params![id],
|row| row.get(0),
)
.optional()?
.flatten();
Ok(submitter)
}
/// Record the canonical sha hive-c0re fetched from the proposed repo
/// into applied at submission time. Idempotent on identical values.
pub fn set_fetched_sha(&self, id: i64, sha: &str) -> Result<()> {
@ -428,7 +463,13 @@ mod tests {
// approval then vanished from the dashboard.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind("bitburner", ApprovalKind::InitConfig, "", Some("scaffold"))
.submit_kind(
"bitburner",
ApprovalKind::InitConfig,
"",
Some("scaffold"),
"bitburner",
)
.expect("submit init_config");
let pending = db
.pending()
@ -441,10 +482,11 @@ mod tests {
#[test]
fn mixed_kinds_all_listed() {
let (_dir, _path, db) = open_temp();
db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None)
db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None, "a")
.unwrap();
db.submit_kind("b", ApprovalKind::Spawn, "", None).unwrap();
db.submit_kind("c", ApprovalKind::InitConfig, "", None)
db.submit_kind("b", ApprovalKind::Spawn, "", None, "b")
.unwrap();
db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c")
.unwrap();
let pending = db.pending().expect("pending");
assert_eq!(pending.len(), 3, "all three kinds must be visible");
@ -462,6 +504,7 @@ mod tests {
ApprovalKind::ApplyCommit,
"cafef00d",
Some("test"),
"bitburner",
)
.unwrap();
let row = db.mark_cancelled(id, "manager").expect("cancel");
@ -481,7 +524,7 @@ mod tests {
// final — re-cancelling errors instead of silently overwriting.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind("a", ApprovalKind::Spawn, "deadbeef", None)
.submit_kind("a", ApprovalKind::Spawn, "deadbeef", None, "a")
.unwrap();
db.mark_cancelled(id, "manager").expect("first cancel");
let err = db
@ -496,7 +539,7 @@ mod tests {
// whole list — collect_lenient skips it instead of failing.
let (_dir, path, db) = open_temp();
let good = db
.submit_kind("good", ApprovalKind::ApplyCommit, "cafe", None)
.submit_kind("good", ApprovalKind::ApplyCommit, "cafe", None, "good")
.unwrap();
let raw = Connection::open(&path).unwrap();
raw.execute(
@ -511,4 +554,26 @@ mod tests {
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, good);
}
#[test]
fn submitter_round_trips_and_legacy_reads_none() {
// A submitted approval records its submitter; a legacy row
// (inserted without the column) reads back as None so callers
// fall back to the root agent.
let (_dir, path, db) = open_temp();
let id = db
.submit_kind("child", ApprovalKind::ApplyCommit, "cafe", None, "parent")
.unwrap();
assert_eq!(db.submitter_of(id).unwrap().as_deref(), Some("parent"));
let raw = Connection::open(&path).unwrap();
raw.execute(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
VALUES ('old', 'apply_commit', '', 0, 'pending')",
[],
)
.unwrap();
let legacy_id = raw.last_insert_rowid();
assert_eq!(db.submitter_of(legacy_id).unwrap(), None);
}
}

View file

@ -1295,6 +1295,20 @@ impl Coordinator {
self.notify_agent(hive_sh4re::MANAGER_AGENT, event);
}
/// Route an approval-scoped helper event to the agent that submitted
/// approval `approval_id` (the authenticated socket caller at submit
/// time). Legacy rows with no recorded submitter — and any lookup
/// failure — fall back to the root agent, preserving the prior
/// always-root behaviour.
pub fn notify_submitter(&self, approval_id: i64, event: &hive_sh4re::HelperEvent) {
let target = self
.approvals
.submitter_of(approval_id)
.unwrap_or_default()
.unwrap_or_else(|| hive_sh4re::MANAGER_AGENT.to_owned());
self.notify_agent(&target, event);
}
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
/// body = JSON-encoded event). Used to route `QuestionAnswered`

View file

@ -1650,11 +1650,13 @@ async fn post_request_spawn(
if name.is_empty() {
return error_response("spawn: `name` required");
}
match state
.coord
.approvals
.submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "", None)
{
match state.coord.approvals.submit_kind(
&name,
hive_sh4re::ApprovalKind::Spawn,
"",
None,
hive_sh4re::MANAGER_AGENT,
) {
Ok(id) => {
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
// Phase 5b: notify the dashboard event channel so live

View file

@ -21,10 +21,10 @@ use hive_sh4re::{LooseEnd, MANAGER_AGENT};
use crate::coordinator::Coordinator;
/// Open threads pending against `agent`:
/// - pending approvals where this agent is the submitter (only ever
/// true for the manager — sub-agents don't submit approvals — but
/// we keep the rule per-agent so the manager's MCP surface gets
/// the same shape via a different code path);
/// - pending approvals where this agent is the submitter (a parent
/// agent with the `approvals` group submits for its children; the
/// root submits for top-level agents). Legacy rows with no recorded
/// submitter count as the root's;
/// - unanswered questions where `agent` is the asker (waiting on
/// someone) OR the target (owes a reply);
/// - pending reminders this agent scheduled (`owner == self`).
@ -35,20 +35,24 @@ use crate::coordinator::Coordinator;
pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
let now = now_unix();
let mut out = Vec::new();
// Approvals are only submitted by the manager today. When that
// expands (e.g. sub-agents propose changes to their own configs),
// teach the approvals table to track the submitter and filter
// here on that column — for now MANAGER_AGENT == sole submitter.
if agent == MANAGER_AGENT {
for a in coord.approvals.pending()? {
out.push(LooseEnd::Approval {
id: a.id,
agent: a.agent,
commit_ref: a.commit_ref,
description: a.description,
age_seconds: saturating_age(now, a.requested_at),
});
// Show each pending approval to the agent that submitted it. The
// submitter column is NULL for rows predating it; those count as
// the root agent's.
for a in coord.approvals.pending()? {
let submitter = coord
.approvals
.submitter_of(a.id)?
.unwrap_or_else(|| MANAGER_AGENT.to_owned());
if submitter != agent {
continue;
}
out.push(LooseEnd::Approval {
id: a.id,
agent: a.agent,
commit_ref: a.commit_ref,
description: a.description,
age_seconds: saturating_age(now, a.requested_at),
});
}
for q in coord.questions.pending_all()? {
let role_match = q.asker == agent || q.target.as_deref() == Some(agent);

View file

@ -80,10 +80,13 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostRequest::Spawn { name } => handle_spawn(&coord, name).await?,
HostRequest::RequestSpawn { name } => {
tracing::info!(%name, "request_spawn");
let id =
coord
.approvals
.submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "", None)?;
let id = coord.approvals.submit_kind(
name,
hive_sh4re::ApprovalKind::Spawn,
"",
None,
hive_sh4re::MANAGER_AGENT,
)?;
tracing::info!(%id, %name, "spawn approval queued");
HostResponse::success()
}

View file

@ -989,7 +989,7 @@ async fn handle_request_apply_commit(
return err;
}
tracing::info!(%agent, %target_agent, %commit_ref, "request_apply_commit");
match submit_apply_commit(coord, target_agent, commit_ref, description).await {
match submit_apply_commit(coord, target_agent, commit_ref, description, agent).await {
Ok((id, sha)) => {
tracing::info!(%id, %target_agent, %sha, "apply_commit approval queued");
AgentResponse::Ok
@ -1427,6 +1427,7 @@ fn handle_request_update_meta_inputs(
hive_sh4re::ApprovalKind::UpdateMetaInputs,
&commit_ref,
description,
requester,
)
.map_err(|e| anyhow::anyhow!("{e:#}"))
{
@ -1527,6 +1528,7 @@ fn handle_request_schedule_prompt(
hive_sh4re::ApprovalKind::SchedulePrompt,
&commit_ref,
payload.description.as_deref(),
requester,
) {
Ok(id) => id,
Err(e) => {
@ -1801,6 +1803,10 @@ pub(crate) fn submit_init_config(
hive_sh4re::ApprovalKind::InitConfig,
parent.unwrap_or(""),
description.as_deref(),
// `parent` is the requesting agent (becomes the new child's
// parent); it's also the submitter the approval events route
// back to. No declared parent = operator/root path.
parent.unwrap_or(hive_sh4re::MANAGER_AGENT),
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
tracing::info!(%id, %name, "init_config approval queued");
@ -1832,6 +1838,7 @@ pub(crate) async fn submit_apply_commit(
agent: &str,
commit_ref: &str,
description: Option<&str>,
submitter: &str,
) -> anyhow::Result<(i64, String)> {
validate_commit_ref(commit_ref)?;
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(agent);
@ -1859,6 +1866,7 @@ pub(crate) async fn submit_apply_commit(
hive_sh4re::ApprovalKind::ApplyCommit,
commit_ref,
description,
submitter,
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
let tag = format!("proposal/{id}");