fix(templates): a template stopped accepting edits once it minted an agent
`upsert_builtin` replaced the role set with DELETE + reinsert. That looks
equivalent to an upsert and is not: `agent_template_link` carries a plain FK
on (template_id, role_slot), so the delete is rejected as soon as one agent
has been minted from the template, rolling back the whole transaction.
The failure mode was silent and self-targeting. The loader logs the error and
continues, so the on-disk TOML and the DB drifted apart — and only for the
templates someone had actually used. Running the smoke mission against
insight_research is what put it on the boot log:
failed to load insight_research.toml: violates foreign key constraint
"agent_template_link_template_id_role_slot_fkey"
which also means that template never received the skill-name fix.
- Upsert each role in place via ON CONFLICT (template_id, slot), the table's
primary key.
- Prune only slots the TOML dropped, and skip a slot still referenced by a
live agent with a log line. Keeping one stale role row is a smaller failure
than discarding every edit to the template.
- Regression test drives the real sequence — upsert, mint an agent, link it,
upsert again — and asserts both the prompt and skill edits land. Verified to
fail without the fix with the same 23503 the server logged.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
09486ec759
commit
3b943df3c2
@@ -125,17 +125,26 @@ pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result<Uuid,
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Replace-in-place role set. Roles that get removed from the TOML
|
||||
// disappear from the DB; keeps the on-disk source authoritative.
|
||||
sqlx::query("DELETE FROM template_roles WHERE template_id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Upsert each role in place, then prune only the slots the TOML dropped.
|
||||
//
|
||||
// This was `DELETE FROM template_roles` + reinsert, which looks equivalent
|
||||
// and is not: `agent_template_link` carries a plain FK on
|
||||
// (template_id, role_slot), so once a template has minted a single agent
|
||||
// the delete is rejected and the whole upsert transaction rolls back. The
|
||||
// effect was that **a template stopped accepting edits the moment it was
|
||||
// first used** — the loader logged a foreign-key error and moved on, so
|
||||
// the on-disk TOML and the DB drifted apart silently, and only for the
|
||||
// templates anyone actually ran.
|
||||
for r in &b.roles {
|
||||
sqlx::query(
|
||||
"INSERT INTO template_roles
|
||||
(template_id, slot, order_idx, system_prompt, skills, brain_seed)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)",
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (template_id, slot) DO UPDATE SET
|
||||
order_idx = EXCLUDED.order_idx,
|
||||
system_prompt = EXCLUDED.system_prompt,
|
||||
skills = EXCLUDED.skills,
|
||||
brain_seed = EXCLUDED.brain_seed",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(r.slot)
|
||||
@@ -147,6 +156,43 @@ pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result<Uuid,
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Prune removed slots, but never at the cost of the whole upsert: a slot
|
||||
// still referenced by a live agent is left in place and reported. Losing
|
||||
// one stale role row is a smaller failure than losing every edit to the
|
||||
// template.
|
||||
let slots: Vec<String> = b.roles.iter().map(|r| r.slot.to_string()).collect();
|
||||
let stale: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT slot FROM template_roles
|
||||
WHERE template_id = $1 AND slot <> ALL($2)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&slots)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
for slot in stale {
|
||||
let referenced: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM agent_template_link
|
||||
WHERE template_id = $1 AND role_slot = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&slot)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if referenced > 0 {
|
||||
eprintln!(
|
||||
"team_templates: role {}.{slot} was removed from the TOML but {referenced} \
|
||||
agent(s) still reference it — keeping the row so the upsert can commit",
|
||||
b.key
|
||||
);
|
||||
continue;
|
||||
}
|
||||
sqlx::query("DELETE FROM template_roles WHERE template_id = $1 AND slot = $2")
|
||||
.bind(id)
|
||||
.bind(&slot)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user