fix(templates): a template stopped accepting edits once it minted an agent
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 9s
ci / frontend (push) Failing after 34s
ci / e2e (push) Skipped
ci / publish (push) Skipped

`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:
Omar Sobh
2026-07-31 20:26:38 -07:00
co-authored by Claude Opus 5
parent 09486ec759
commit 3b943df3c2
2 changed files with 131 additions and 8 deletions
+53 -7
View File
@@ -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)
}