fix(ai): close remaining duplication + stale-context gaps

Round two of AI-hardening based on what bit us with the twenty-* fan-out:

1. apps_create idempotency now covers ALL four pathways (template /
   image / composeRaw / repo), not just templates. Same dedup-by-name
   check inside the project, same alreadyExisted: true response shape.
   Pass force: true to opt out for legitimate dev/staging duplicates.

2. databases_create gets the same idempotency treatment — and now
   also scopes to the per-project Coolify project when projectId is
   supplied (previously only apps_create did this).

3. New shared helper findExistingResourceByName scans apps + services
   + databases in a project and matches case-insensitively on name.

4. System prompt: three new hard rules teaching the model how to
   handle tool results and anchor on reality:
   - Tool results are authoritative; conversation history is not.
     If a tool contradicts an earlier assertion, discard the
     assertion. Don't keep telling the user it's broken when
     apps_get now says it's healthy.
   - When the user reports an error, FIRST tool call is a
     current-state read (apps_get / databases_get / apps_logs).
     Stop re-debugging problems that were already fixed.
   - Trust idempotency. alreadyExisted means done; don't loop
     trying a different name.

Made-with: Cursor
This commit is contained in:
2026-04-30 11:07:14 -07:00
parent 3d525afdf7
commit eb4086d296
2 changed files with 155 additions and 1 deletions

View File

@@ -237,6 +237,12 @@ When you write to Plan, the user does NOT need a long acknowledgment. "Logged th
- ALWAYS call \`apps_templates_search\` BEFORE \`apps_create\` when the user names a known third-party app. Hand-rolling a Dockerfile when a maintained template exists is how supply-chain bugs ship.
- **NEVER delete-and-recreate a service to escape an error.** When a deploy fails with "Conflict. The container name … is already in use" or any orphan-container symptom, the recovery is: \`apps_unstick { uuid }\`\`apps_deploy { uuid }\`. Deleting the service to side-step the conflict creates a new uuid with new container names AND leaves the orphan running AND forks a duplicate stack. We've shipped 4 orphan twenty-* services this way before. Don't repeat it.
- **If a deploy fails twice in a row with the same error, STOP.** Don't loop. Surface the error and the two recovery attempts you've already tried, and ask the user how to proceed.
- **Tool results are authoritative; conversation history is not.** When a tool result contradicts something you said earlier in this thread, DISCARD your prior assertion. State the new ground truth from the tool. Do not paper over the contradiction or restate the old belief. Example: if you told the user "X is broken" earlier and \`apps_get\` now reports \`status: running:healthy\`, say "X is actually healthy — my earlier read was stale." Don't keep telling them it's broken.
- **Anchor on current state before troubleshooting.** When the user reports an error, your FIRST tool call must be a current-state read: \`apps_get { uuid }\` for an app, \`databases_get { uuid }\` for a db, \`apps_logs { uuid, lines: 50 }\` for runtime errors. Don't react to symptoms the user described 30 minutes ago — the world has probably moved. We've burned a session re-debugging a problem that was already fixed.
- **Trust idempotency.** \`apps_create\` and \`databases_create\` will return \`alreadyExisted: true\` with the existing uuid when a duplicate is detected. When you see this flag, your job is DONE — don't try to "make sure" it's right by calling apps_create again with a different name. Use the returned uuid and proceed to whatever comes next (env vars, domains, deploy).
- Destructive ops (\`*_delete\`, \`*_volumes_wipe\`) require \`confirm\` equal to the resource's exact name. Always fetch the name first with a \`*_get\` call. Confirm with the user before executing irreversible deletes unless they explicitly said "delete X".
- Long-running ops (deploys, DNS provisioning, db provisioning) take 15 min. Tell the user up front so they don't think you're stuck. Don't poll in a tight loop — it wastes tool rounds.
- After a \`ship\` or \`apps.deploy\`, the result is authoritative. Don't call gitea_*, shell_exec, or apps_* to "verify" — read the response and report.

View File

@@ -1426,6 +1426,21 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
if (params.projectId && !(params.force === true || params.force === 'true')) {
const existing = await findExistingResourceByName(targetCoolifyProjectUuid, appName);
if (existing) {
await linkIfRequested(existing.uuid, existing.type);
return NextResponse.json({
result: {
uuid: existing.uuid,
name: existing.name,
alreadyExisted: true,
summaryHint: dedupHint(existing, 'image'),
},
});
}
}
const created = await createDockerImageApp({
...commonOpts,
image,
@@ -1447,6 +1462,21 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
if (params.projectId && !(params.force === true || params.force === 'true')) {
const existing = await findExistingResourceByName(targetCoolifyProjectUuid, appName);
if (existing) {
await linkIfRequested(existing.uuid, existing.type);
return NextResponse.json({
result: {
uuid: existing.uuid,
name: existing.name,
alreadyExisted: true,
summaryHint: dedupHint(existing, 'composeRaw'),
},
});
}
}
const created = await createDockerComposeApp({
...commonOpts,
composeRaw,
@@ -1552,6 +1582,21 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
if (params.projectId && !(params.force === true || params.force === 'true')) {
const existing = await findExistingResourceByName(targetCoolifyProjectUuid, appName);
if (existing) {
await linkIfRequested(existing.uuid, existing.type);
return NextResponse.json({
result: {
uuid: existing.uuid,
name: existing.name,
alreadyExisted: true,
summaryHint: dedupHint(existing, 'repo'),
},
});
}
}
const created = await createPublicApp({
...commonOpts,
gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token),
@@ -2277,8 +2322,30 @@ async function toolDatabasesList(principal: Principal) {
async function toolDatabasesCreate(principal: Principal, params: Record<string, any>) {
const ws = principal.workspace;
const projectUuid = requireCoolifyProject(principal);
let projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
// Scope to the Vibn project's dedicated Coolify project when projectId
// is supplied (auto-mint if needed). This both narrows the dedup scope
// and prevents cross-project leaks.
if (params.projectId) {
const projectId = String(params.projectId);
const projectRow = await queryOne<{ id: string; data: any; slug: string }>(
`SELECT id, data, slug FROM fs_projects
WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3) LIMIT 1`,
[projectId, ws.id, ws.slug],
);
if (!projectRow) {
return NextResponse.json({ error: `Project ${projectId} not found in this workspace` }, { status: 404 });
}
const projectName = projectRow.data?.productName || projectRow.data?.name || projectRow.slug;
const ensured = await ensureProjectCoolifyProject(projectId, ws, {
projectSlug: projectRow.slug,
projectName,
});
if (ensured) projectUuid = ensured;
}
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const type = String(params.type ?? '').toLowerCase() as CoolifyDatabaseType;
if (!DB_TYPES.includes(type)) {
@@ -2288,6 +2355,30 @@ async function toolDatabasesCreate(principal: Principal, params: Record<string,
);
}
const name = slugify(String(params.name ?? `${type}-${Date.now().toString(36)}`));
// Idempotency: when a database with the same name already exists in
// this Coolify project, return it instead of creating a duplicate.
// The AI fans out duplicate dbs the same way it fans out apps when
// its first attempt has issues — same fix.
if (params.projectId && !(params.force === true || params.force === 'true')) {
const existing = await findExistingResourceByName(projectUuid as string, name);
if (existing) {
if (params.projectId) {
try {
await linkResourceToProject(String(params.projectId), ws.slug, existing.uuid, existing.type);
} catch {}
}
return NextResponse.json({
result: {
uuid: existing.uuid,
name: existing.name,
alreadyExisted: true,
summaryHint: dedupHint(existing, 'databases.create'),
},
});
}
}
const { uuid } = await createDatabase({
type,
name,
@@ -3922,6 +4013,63 @@ async function findExistingTemplateService(
return null;
}
/**
* Find an existing app/service/database in the given Coolify project by
* name (case-insensitive). Used to short-circuit duplicate apps_create
* and databases_create calls regardless of pathway (template / image /
* compose / repo). Returns the first match across all resource types.
*/
async function findExistingResourceByName(
coolifyProjectUuid: string,
name: string,
): Promise<{ uuid: string; name: string; type: 'application' | 'service' | 'database' } | null> {
const target = name.trim().toLowerCase();
if (!target) return null;
try {
const apps = await listApplicationsInProject(coolifyProjectUuid);
for (const a of apps) {
if ((a.name ?? '').toLowerCase() === target) {
return { uuid: a.uuid, name: a.name, type: 'application' };
}
}
} catch (e) {
console.warn('[findExistingResourceByName] apps lookup failed', e);
}
try {
const services = await listServicesInProject(coolifyProjectUuid);
for (const s of services) {
if ((s.name ?? '').toLowerCase() === target) {
return { uuid: s.uuid, name: s.name, type: 'service' };
}
}
} catch (e) {
console.warn('[findExistingResourceByName] services lookup failed', e);
}
try {
const dbs = await listDatabasesInProject(coolifyProjectUuid);
for (const d of dbs) {
if ((d.name ?? '').toLowerCase() === target) {
return { uuid: d.uuid, name: d.name, type: 'database' };
}
}
} catch (e) {
console.warn('[findExistingResourceByName] databases lookup failed', e);
}
return null;
}
function dedupHint(
existing: { uuid: string; name: string; type: string },
pathway: string,
): string {
return (
`A ${existing.type} named "${existing.name}" (uuid ${existing.uuid}) already exists in this project ` +
`(detected via ${pathway} path). Returning it instead of creating a duplicate. ` +
`If the existing one is broken, call apps_unstick { uuid } and apps_deploy { uuid } — DO NOT delete-and-recreate. ` +
`If you genuinely need a SECOND independent instance, re-call with { force: true } and a different { name: }.`
);
}
/**
* apps.unstick — recover a service stuck on a "container name already
* in use" Docker conflict. Force-removes the orphan containers (and