From 35c5e71afd739e8007c2d5e76062178672a61d72 Mon Sep 17 00:00:00 2001 From: John Hu Date: Sun, 16 Aug 2026 10:38:56 +0800 Subject: [PATCH 1/7] feat(engine): the chain records what it published; a lost CAS fails publish_conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0019 P2 — the engine wiring for the expected-tip CAS: - runs.published_sha (additive migration 0034), written by the git.push handler right after the push resolves — the work_branch pattern, so a crash between push and record re-runs an idempotent push on resume and the record lands then. The branch.pushed event carries the commit so the timeline names what actually landed. - GitScmService.push derives the chain's expected tip (latest non-null published_sha across the workspace_key group by run number), passes it to applyApprovedPatch, and maps TipConflictError to a typed tip_conflict result. PushResult grows the arm; the engine maps it to RunFailure(publish_conflict) — terminal, because a retry reproduces the refusal deterministically, and the remote was never touched. - FakeScmService can now lie (tipConflictNext) — fails-closed is untestable until the fake can express the conflict. Tests: compliance through both transports (publication recorded + event stamped; lost CAS fails typed with no record and no push) and a real-git chain walk in the worker suite (follow-up advance parented on the ancestor's tip, cumulative content, then a human advance conflicting typed). All five neuter-verified. --- apps/worker/src/deps/scm.ts | 50 +- apps/worker/src/deps/workspace.test.ts | 76 + packages/db/drizzle/0034_published_sha.sql | 1 + packages/db/drizzle/meta/0034_snapshot.json | 5141 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/runs.ts | 9 + packages/orchestration/src/engine/deps.ts | 8 +- .../src/engine/engine.integration.test.ts | 30 + packages/orchestration/src/engine/engine.ts | 20 +- packages/orchestration/src/engine/fakes.ts | 15 +- 10 files changed, 5340 insertions(+), 17 deletions(-) create mode 100644 packages/db/drizzle/0034_published_sha.sql create mode 100644 packages/db/drizzle/meta/0034_snapshot.json diff --git a/apps/worker/src/deps/scm.ts b/apps/worker/src/deps/scm.ts index 56e369f..0957abd 100644 --- a/apps/worker/src/deps/scm.ts +++ b/apps/worker/src/deps/scm.ts @@ -6,8 +6,13 @@ import { type ScmService, workspaceKeyOf, } from "@agrippa/orchestration"; -import { applyApprovedPatch, platformGitDirFor, workspaceIntact } from "@agrippa/workspace"; -import { and, desc, eq } from "drizzle-orm"; +import { + applyApprovedPatch, + platformGitDirFor, + TipConflictError, + workspaceIntact, +} from "@agrippa/workspace"; +import { and, desc, eq, isNotNull } from "drizzle-orm"; import { credentialedUrl, git, @@ -145,15 +150,38 @@ export class GitScmService implements ScmService { baseSha = pinnedBase; } - const { commitSha } = await applyApprovedPatch({ - fetchSource, - fetchRef, - baseSha, - branch: spec.branch, - patch, - pushUrl, - }); - return { status: "pushed", commitSha }; + // The chain's expected tip E (ADR-0019): the latest snapshot any run of + // this workspace chain published. Absent → first publish (creation CAS); + // present → the new commit parents on it and the push advances it. + const expectedTip = await this.chainExpectedTip(key); + try { + const { commitSha } = await applyApprovedPatch({ + fetchSource, + fetchRef, + baseSha, + branch: spec.branch, + patch, + pushUrl, + ...(expectedTip === undefined ? {} : { expectedTip }), + }); + return { status: "pushed", commitSha }; + } catch (err) { + if (err instanceof TipConflictError) { + return { status: "tip_conflict", observedTip: err.observedTip }; + } + throw err; + } + } + + /** E = the latest non-null published_sha across the workspace chain. */ + private async chainExpectedTip(workspaceKey: string): Promise { + const [row] = await this.db + .select({ publishedSha: runs.publishedSha }) + .from(runs) + .where(and(eq(runs.workspaceKey, workspaceKey), isNotNull(runs.publishedSha))) + .orderBy(desc(runs.number)) + .limit(1); + return row?.publishedSha ?? undefined; } /** The base the SERVER pinned at checkout (runs.workspace_ref, remote runs). */ diff --git a/apps/worker/src/deps/workspace.test.ts b/apps/worker/src/deps/workspace.test.ts index 733fdb9..b9045d1 100644 --- a/apps/worker/src/deps/workspace.test.ts +++ b/apps/worker/src/deps/workspace.test.ts @@ -347,6 +347,82 @@ describe.skipIf(!dbUp)("GitWorkspaceManager + GitScmService (real git)", () => { expect(await workspace.diff(runId)).toContain("committed line"); }); + it("a follow-up publish advances the chain tip; a human advance conflicts typed (ADR-0019)", async () => { + const branch = publishBranch; + const dir = workspaceDirFor(workspaceKey); + // the ancestor's publication record, as the engine's git.push handler writes it + const tip1 = (await git(["rev-parse", branch], sourceDir)).trim(); + await db.update(runs).set({ publishedSha: tip1 }).where(eq(runs.id, runId)); + + const followupRow = async (): Promise => { + runNumber += 1; + const [row] = await db + .insert(runs) + .values({ + ...newRunIdentity(), + workspaceKey, // the CHAIN's directory, inherited like the API does + kind: "followup", + parentRunId: runId, + taskId, + projectId, + number: runNumber, + templateVersionId, + faberId, + executorId: "fake", + paramsSnapshot: {}, + modelResolution: {}, + createdBy: userId, + }) + .returning(); + return row?.id as string; + }; + + // the steer adds one more thing; evidence stays cumulative against the base + const followupId = await followupRow(); + await Bun.write(path.join(dir, "steer-addition.txt"), "asked for one more thing\n"); + const approved = await workspace.diff(followupId); + const advanced = await scm.push(followupId, { + projectId, + repo: { repoConnectionId }, + branch, + expectedPatch: approved, + }); + if (advanced.status !== "pushed") throw new Error(`expected pushed, got ${advanced.status}`); + // exactly one new commit, parented on what the chain last published + expect((await git(["rev-parse", `${branch}^`], sourceDir)).trim()).toBe(tip1); + expect((await git(["rev-list", "--count", `main..${branch}`], sourceDir)).trim()).toBe("2"); + // cumulative: the ancestor's work rides along with the steer's + const show = (spec: string) => + Bun.spawnSync(["git", "show", spec], { cwd: sourceDir, stdout: "pipe", stderr: "pipe" }); + expect(show(`${branch}:left-uncommitted.txt`).exitCode).toBe(0); + expect(show(`${branch}:steer-addition.txt`).exitCode).toBe(0); + await db.update(runs).set({ publishedSha: advanced.commitSha }).where(eq(runs.id, followupId)); + + // a human advances the branch; the next steer's publish must refuse typed + const human = ( + await gitIn(sourceDir, [ + "commit-tree", + `${advanced.commitSha}^{tree}`, + "-p", + advanced.commitSha, + "-m", + "human touch-up", + ]) + ).trim(); + await git(["update-ref", `refs/heads/${branch}`, human], sourceDir); + + const conflictedId = await followupRow(); + await Bun.write(path.join(dir, "another-steer.txt"), "and one more\n"); + const conflicted = await scm.push(conflictedId, { + projectId, + repo: { repoConnectionId }, + branch, + expectedPatch: await workspace.diff(conflictedId), + }); + expect(conflicted).toEqual({ status: "tip_conflict", observedTip: human }); + expect((await git(["rev-parse", branch], sourceDir)).trim()).toBe(human); + }); + it("refuses to publish a run with no commits and no changes", async () => { const emptyRunId = await newRunRow(); await workspace.checkout(emptyRunId, { diff --git a/packages/db/drizzle/0034_published_sha.sql b/packages/db/drizzle/0034_published_sha.sql new file mode 100644 index 0000000..38eedcf --- /dev/null +++ b/packages/db/drizzle/0034_published_sha.sql @@ -0,0 +1 @@ +ALTER TABLE "runs" ADD COLUMN "published_sha" text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0034_snapshot.json b/packages/db/drizzle/meta/0034_snapshot.json new file mode 100644 index 0000000..7e206af --- /dev/null +++ b/packages/db/drizzle/meta/0034_snapshot.json @@ -0,0 +1,5141 @@ +{ + "id": "9f5c5861-d5ba-4d41-8f49-f4dbd651ec2d", + "prevId": "b545b7c5-8e83-44af-ad56-f448dee9c512", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_prefix_uq": { + "name": "api_keys_prefix_uq", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_org_id_orgs_id_fk": { + "name": "api_keys_org_id_orgs_id_fk", + "tableFrom": "api_keys", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "api_keys_project_id_projects_id_fk": { + "name": "api_keys_project_id_projects_id_fk", + "tableFrom": "api_keys", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "api_keys_created_by_users_id_fk": { + "name": "api_keys_created_by_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_api_key_id": { + "name": "actor_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_runtime_id": { + "name": "actor_runtime_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_org_time_idx": { + "name": "audit_logs_org_time_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_org_id_orgs_id_fk": { + "name": "audit_logs_org_id_orgs_id_fk", + "tableFrom": "audit_logs", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_project_id_projects_id_fk": { + "name": "audit_logs_project_id_projects_id_fk", + "tableFrom": "audit_logs", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_actor_user_id_users_id_fk": { + "name": "audit_logs_actor_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": ["actor_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_actor_api_key_id_api_keys_id_fk": { + "name": "audit_logs_actor_api_key_id_api_keys_id_fk", + "tableFrom": "audit_logs", + "tableTo": "api_keys", + "columnsFrom": ["actor_api_key_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_actor_runtime_id_runtimes_id_fk": { + "name": "audit_logs_actor_runtime_id_runtimes_id_fk", + "tableFrom": "audit_logs", + "tableTo": "runtimes", + "columnsFrom": ["actor_runtime_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "org_role": { + "name": "org_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'org_member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_org_id_orgs_id_fk": { + "name": "users_org_id_orgs_id_fk", + "tableFrom": "users", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dispatch_events": { + "name": "dispatch_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dispatch_events_seq_uq": { + "name": "dispatch_events_seq_uq", + "columns": [ + { + "expression": "dispatch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dispatch_events_dispatch_id_dispatches_id_fk": { + "name": "dispatch_events_dispatch_id_dispatches_id_fk", + "tableFrom": "dispatch_events", + "tableTo": "dispatches", + "columnsFrom": ["dispatch_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dispatches": { + "name": "dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_row_id": { + "name": "step_row_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "runtime_id": { + "name": "runtime_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_contact_at": { + "name": "last_contact_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dispatches_claim_idx": { + "name": "dispatches_claim_idx", + "columns": [ + { + "expression": "runtime_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"dispatches\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "dispatches_step_live_uq": { + "name": "dispatches_step_live_uq", + "columns": [ + { + "expression": "step_row_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"dispatches\".\"status\" in ('pending', 'claimed')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dispatches_run_id_runs_id_fk": { + "name": "dispatches_run_id_runs_id_fk", + "tableFrom": "dispatches", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dispatches_step_row_id_run_steps_id_fk": { + "name": "dispatches_step_row_id_run_steps_id_fk", + "tableFrom": "dispatches", + "tableTo": "run_steps", + "columnsFrom": ["step_row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dispatches_runtime_id_runtimes_id_fk": { + "name": "dispatches_runtime_id_runtimes_id_fk", + "tableFrom": "dispatches", + "tableTo": "runtimes", + "columnsFrom": ["runtime_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'org_member'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_user_id": { + "name": "accepted_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitations_org_idx": { + "name": "invitations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_token_idx": { + "name": "invitations_token_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_org_id_orgs_id_fk": { + "name": "invitations_org_id_orgs_id_fk", + "tableFrom": "invitations", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitations_created_by_users_id_fk": { + "name": "invitations_created_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitations_accepted_user_id_users_id_fk": { + "name": "invitations_accepted_user_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": ["accepted_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_token_hash_unique": { + "name": "invitations_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_deliveries": { + "name": "notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_snippet": { + "name": "response_snippet", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_deliveries_dedupe_uq": { + "name": "notification_deliveries_dedupe_uq", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification_deliveries\".\"event_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_deliveries_project_idx": { + "name": "notification_deliveries_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_deliveries_pending_idx": { + "name": "notification_deliveries_pending_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notification_deliveries\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_deliveries_endpoint_id_notification_endpoints_id_fk": { + "name": "notification_deliveries_endpoint_id_notification_endpoints_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "notification_endpoints", + "columnsFrom": ["endpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_deliveries_project_id_projects_id_fk": { + "name": "notification_deliveries_project_id_projects_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_deliveries_run_id_runs_id_fk": { + "name": "notification_deliveries_run_id_runs_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_deliveries_event_id_run_events_id_fk": { + "name": "notification_deliveries_event_id_run_events_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "run_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_endpoints": { + "name": "notification_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ref": { + "name": "secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "events": { + "name": "events", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'zh-CN'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_endpoints_project_idx": { + "name": "notification_endpoints_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_endpoints_project_id_projects_id_fk": { + "name": "notification_endpoints_project_id_projects_id_fk", + "tableFrom": "notification_endpoints", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_endpoints_secret_ref_secrets_id_fk": { + "name": "notification_endpoints_secret_ref_secrets_id_fk", + "tableFrom": "notification_endpoints", + "tableTo": "secrets", + "columnsFrom": ["secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notification_endpoints_created_by_users_id_fk": { + "name": "notification_endpoints_created_by_users_id_fk", + "tableFrom": "notification_endpoints", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orgs": { + "name": "orgs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orgs_slug_unique": { + "name": "orgs_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_members_uq": { + "name": "project_members_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_quotas": { + "name": "project_quotas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monthly'" + }, + "token_limit": { + "name": "token_limit", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "hard_stop": { + "name": "hard_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_quotas_project_id_projects_id_fk": { + "name": "project_quotas_project_id_projects_id_fk", + "tableFrom": "project_quotas", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_quotas_project_id_unique": { + "name": "project_quotas_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_resource_grants": { + "name": "project_resource_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config_override": { + "name": "config_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_grants_uq": { + "name": "project_grants_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_resource_grants_project_id_projects_id_fk": { + "name": "project_resource_grants_project_id_projects_id_fk", + "tableFrom": "project_resource_grants", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_resource_grants_granted_by_users_id_fk": { + "name": "project_resource_grants_granted_by_users_id_fk", + "tableFrom": "project_resource_grants", + "tableTo": "users", + "columnsFrom": ["granted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_org_slug_uq": { + "name": "projects_org_slug_uq", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_org_id_orgs_id_fk": { + "name": "projects_org_id_orgs_id_fk", + "tableFrom": "projects", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_ref": { + "name": "secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_uq": { + "name": "provider_credentials_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_project_id_projects_id_fk": { + "name": "provider_credentials_project_id_projects_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "provider_credentials_secret_ref_secrets_id_fk": { + "name": "provider_credentials_secret_ref_secrets_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "secrets", + "columnsFrom": ["secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repo_connections": { + "name": "repo_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "credential_secret_ref": { + "name": "credential_secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "repo_connections_project_id_projects_id_fk": { + "name": "repo_connections_project_id_projects_id_fk", + "tableFrom": "repo_connections", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repo_connections_credential_secret_ref_secrets_id_fk": { + "name": "repo_connections_credential_secret_ref_secrets_id_fk", + "tableFrom": "repo_connections", + "tableTo": "secrets", + "columnsFrom": ["credential_secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fabri": { + "name": "fabri", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "persona_i18n": { + "name": "persona_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model_role_policy": { + "name": "default_model_role_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "fabri_org_id_orgs_id_fk": { + "name": "fabri_org_id_orgs_id_fk", + "tableFrom": "fabri", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fabri_slug_unique": { + "name": "fabri_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "auth_secret_ref": { + "name": "auth_secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "config_revision": { + "name": "config_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_org_id_orgs_id_fk": { + "name": "mcp_servers_org_id_orgs_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mcp_servers_auth_secret_ref_secrets_id_fk": { + "name": "mcp_servers_auth_secret_ref_secrets_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "secrets", + "columnsFrom": ["auth_secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_servers_slug_unique": { + "name": "mcp_servers_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.models": { + "name": "models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_model_id": { + "name": "provider_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "models_org_id_orgs_id_fk": { + "name": "models_org_id_orgs_id_fk", + "tableFrom": "models", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "models_provider_model_id_unique": { + "name": "models_provider_model_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_model_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orchestration_templates": { + "name": "orchestration_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "latest_published_version_id": { + "name": "latest_published_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "orchestration_templates_org_id_orgs_id_fk": { + "name": "orchestration_templates_org_id_orgs_id_fk", + "tableFrom": "orchestration_templates", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orchestration_templates_scenario_id_scenarios_id_fk": { + "name": "orchestration_templates_scenario_id_scenarios_id_fk", + "tableFrom": "orchestration_templates", + "tableTo": "scenarios", + "columnsFrom": ["scenario_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orchestration_templates_latest_published_version_id_template_versions_id_fk": { + "name": "orchestration_templates_latest_published_version_id_template_versions_id_fk", + "tableFrom": "orchestration_templates", + "tableTo": "template_versions", + "columnsFrom": ["latest_published_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orchestration_templates_created_by_users_id_fk": { + "name": "orchestration_templates_created_by_users_id_fk", + "tableFrom": "orchestration_templates", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orchestration_templates_slug_unique": { + "name": "orchestration_templates_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_catalog": { + "name": "provider_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_urls": { + "name": "base_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url_hosts": { + "name": "base_url_hosts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "provider_catalog_org_id_orgs_id_fk": { + "name": "provider_catalog_org_id_orgs_id_fk", + "tableFrom": "provider_catalog", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_catalog_provider_id_unique": { + "name": "provider_catalog_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scenarios": { + "name": "scenarios", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "description_i18n": { + "name": "description_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "scenarios_org_id_orgs_id_fk": { + "name": "scenarios_org_id_orgs_id_fk", + "tableFrom": "scenarios", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scenarios_slug_unique": { + "name": "scenarios_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_versions": { + "name": "skill_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_ref": { + "name": "content_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_versions_uq": { + "name": "skill_versions_uq", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_versions_skill_id_skills_id_fk": { + "name": "skill_versions_skill_id_skills_id_fk", + "tableFrom": "skill_versions", + "tableTo": "skills", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "description_i18n": { + "name": "description_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_version_id": { + "name": "latest_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "skills_org_id_orgs_id_fk": { + "name": "skills_org_id_orgs_id_fk", + "tableFrom": "skills", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "skills_latest_version_id_skill_versions_id_fk": { + "name": "skills_latest_version_id_skill_versions_id_fk", + "tableFrom": "skills", + "tableTo": "skill_versions", + "columnsFrom": ["latest_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_slug_unique": { + "name": "skills_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_types": { + "name": "task_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "description_i18n": { + "name": "description_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "default_faber_id": { + "name": "default_faber_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_types_uq": { + "name": "task_types_uq", + "columns": [ + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_types_scenario_id_scenarios_id_fk": { + "name": "task_types_scenario_id_scenarios_id_fk", + "tableFrom": "task_types", + "tableTo": "scenarios", + "columnsFrom": ["scenario_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_types_template_id_orchestration_templates_id_fk": { + "name": "task_types_template_id_orchestration_templates_id_fk", + "tableFrom": "task_types", + "tableTo": "orchestration_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_types_default_faber_id_fabri_id_fk": { + "name": "task_types_default_faber_id_fabri_id_fk", + "tableFrom": "task_types", + "tableTo": "fabri", + "columnsFrom": ["default_faber_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_versions": { + "name": "template_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "source_yaml": { + "name": "source_yaml", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compiled": { + "name": "compiled", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "template_versions_uq": { + "name": "template_versions_uq", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_versions_template_id_orchestration_templates_id_fk": { + "name": "template_versions_template_id_orchestration_templates_id_fk", + "tableFrom": "template_versions", + "tableTo": "orchestration_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_versions_created_by_users_id_fk": { + "name": "template_versions_created_by_users_id_fk", + "tableFrom": "template_versions", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.worker_heartbeats": { + "name": "worker_heartbeats", + "schema": "", + "columns": { + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumers_ready_at": { + "name": "consumers_ready_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executors": { + "name": "executors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_host": { + "name": "workspace_host", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifacts": { + "name": "artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "storage_ref": { + "name": "storage_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inline": { + "name": "inline", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "artifacts_run_id_runs_id_fk": { + "name": "artifacts_run_id_runs_id_fk", + "tableFrom": "artifacts", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "artifacts_step_id_run_steps_id_fk": { + "name": "artifacts_step_id_run_steps_id_fk", + "tableFrom": "artifacts", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoints": { + "name": "checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approval'" + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_by": { + "name": "decided_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "checkpoints_run_ckpt_iter_uq": { + "name": "checkpoints_run_ckpt_iter_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "iteration", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "checkpoints_run_id_runs_id_fk": { + "name": "checkpoints_run_id_runs_id_fk", + "tableFrom": "checkpoints", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "checkpoints_step_id_run_steps_id_fk": { + "name": "checkpoints_step_id_run_steps_id_fk", + "tableFrom": "checkpoints", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkpoints_decided_by_users_id_fk": { + "name": "checkpoints_decided_by_users_id_fk", + "tableFrom": "checkpoints", + "tableTo": "users", + "columnsFrom": ["decided_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_comments": { + "name": "run_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_comments_run_idx": { + "name": "run_comments_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_comments_run_id_runs_id_fk": { + "name": "run_comments_run_id_runs_id_fk", + "tableFrom": "run_comments", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_comments_user_id_users_id_fk": { + "name": "run_comments_user_id_users_id_fk", + "tableFrom": "run_comments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_events": { + "name": "run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_events_run_seq_uq": { + "name": "run_events_run_seq_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_events_type_created_idx": { + "name": "run_events_type_created_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_events_run_id_runs_id_fk": { + "name": "run_events_run_id_runs_id_fk", + "tableFrom": "run_events", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_events_step_id_run_steps_id_fk": { + "name": "run_events_step_id_run_steps_id_fk", + "tableFrom": "run_events", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_steps": { + "name": "run_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "agent_ref": { + "name": "agent_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "executor_session_id": { + "name": "executor_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "run_steps_uq": { + "name": "run_steps_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "iteration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_steps_run_id_runs_id_fk": { + "name": "run_steps_run_id_runs_id_fk", + "tableFrom": "run_steps", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.runs": { + "name": "runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "template_version_id": { + "name": "template_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "faber_id": { + "name": "faber_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "executor_id": { + "name": "executor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params_snapshot": { + "name": "params_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_resolution": { + "name": "model_resolution", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "resource_manifest": { + "name": "resource_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"mcpServers\":[],\"skills\":[]}'::jsonb" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'initial'" + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_key": { + "name": "workspace_key", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_expires_at": { + "name": "workspace_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workspace_host": { + "name": "workspace_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steering_message": { + "name": "steering_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_totals": { + "name": "usage_totals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "next_event_seq": { + "name": "next_event_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "workspace_ref": { + "name": "workspace_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cancel_requested": { + "name": "cancel_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "agent_bindings": { + "name": "agent_bindings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "work_branch": { + "name": "work_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_sha": { + "name": "published_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "runtime_id": { + "name": "runtime_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "origin_key": { + "name": "origin_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "runs_task_number_uq": { + "name": "runs_task_number_uq", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_origin_key_uq": { + "name": "runs_origin_key_uq", + "columns": [ + { + "expression": "origin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"runs\".\"origin_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_project_idx": { + "name": "runs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_workspace_key_idx": { + "name": "runs_workspace_key_idx", + "columns": [ + { + "expression": "workspace_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_workspace_expiry_idx": { + "name": "runs_workspace_expiry_idx", + "columns": [ + { + "expression": "workspace_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"runs\".\"workspace_expires_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_lease_sweep_idx": { + "name": "runs_lease_sweep_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"runs\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_runtime_reap_idx": { + "name": "runs_runtime_reap_idx", + "columns": [ + { + "expression": "runtime_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"runs\".\"runtime_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "runs_task_id_tasks_id_fk": { + "name": "runs_task_id_tasks_id_fk", + "tableFrom": "runs", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "runs_project_id_projects_id_fk": { + "name": "runs_project_id_projects_id_fk", + "tableFrom": "runs", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_template_version_id_template_versions_id_fk": { + "name": "runs_template_version_id_template_versions_id_fk", + "tableFrom": "runs", + "tableTo": "template_versions", + "columnsFrom": ["template_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_faber_id_fabri_id_fk": { + "name": "runs_faber_id_fabri_id_fk", + "tableFrom": "runs", + "tableTo": "fabri", + "columnsFrom": ["faber_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_parent_run_id_runs_id_fk": { + "name": "runs_parent_run_id_runs_id_fk", + "tableFrom": "runs", + "tableTo": "runs", + "columnsFrom": ["parent_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_runtime_id_runtimes_id_fk": { + "name": "runs_runtime_id_runtimes_id_fk", + "tableFrom": "runs", + "tableTo": "runtimes", + "columnsFrom": ["runtime_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_created_by_users_id_fk": { + "name": "runs_created_by_users_id_fk", + "tableFrom": "runs", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "runs_followup_has_parent": { + "name": "runs_followup_has_parent", + "value": "\"runs\".\"kind\" <> 'followup' or \"runs\".\"parent_run_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_type_id": { + "name": "task_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "agent_overrides": { + "name": "agent_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "latest_run_id": { + "name": "latest_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_org_id_orgs_id_fk": { + "name": "tasks_org_id_orgs_id_fk", + "tableFrom": "tasks", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_task_type_id_task_types_id_fk": { + "name": "tasks_task_type_id_task_types_id_fk", + "tableFrom": "tasks", + "tableTo": "task_types", + "columnsFrom": ["task_type_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_latest_run_id_runs_id_fk": { + "name": "tasks_latest_run_id_runs_id_fk", + "tableFrom": "tasks", + "tableTo": "runs", + "columnsFrom": ["latest_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_created_by_users_id_fk": { + "name": "tasks_created_by_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.runtimes": { + "name": "runtimes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "executors": { + "name": "executors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notified_offline_at": { + "name": "notified_offline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "runtimes_token_prefix_uq": { + "name": "runtimes_token_prefix_uq", + "columns": [ + { + "expression": "token_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runtimes_org_status_idx": { + "name": "runtimes_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "runtimes_org_id_orgs_id_fk": { + "name": "runtimes_org_id_orgs_id_fk", + "tableFrom": "runtimes", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runtimes_created_by_users_id_fk": { + "name": "runtimes_created_by_users_id_fk", + "tableFrom": "runtimes", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_schedules": { + "name": "task_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_type_id": { + "name": "task_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "agent_overrides": { + "name": "agent_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_schedules_project_idx": { + "name": "task_schedules_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_schedules_org_id_orgs_id_fk": { + "name": "task_schedules_org_id_orgs_id_fk", + "tableFrom": "task_schedules", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_schedules_project_id_projects_id_fk": { + "name": "task_schedules_project_id_projects_id_fk", + "tableFrom": "task_schedules", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_schedules_task_type_id_task_types_id_fk": { + "name": "task_schedules_task_type_id_task_types_id_fk", + "tableFrom": "task_schedules", + "tableTo": "task_types", + "columnsFrom": ["task_type_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_schedules_last_run_id_runs_id_fk": { + "name": "task_schedules_last_run_id_runs_id_fk", + "tableFrom": "task_schedules", + "tableTo": "runs", + "columnsFrom": ["last_run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_schedules_created_by_users_id_fk": { + "name": "task_schedules_created_by_users_id_fk", + "tableFrom": "task_schedules", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "secrets_org_id_orgs_id_fk": { + "name": "secrets_org_id_orgs_id_fk", + "tableFrom": "secrets", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "secrets_created_by_users_id_fk": { + "name": "secrets_created_by_users_id_fk", + "tableFrom": "secrets", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trigger_deliveries": { + "name": "trigger_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trigger_deliveries_dedupe_uq": { + "name": "trigger_deliveries_dedupe_uq", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_deliveries\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "trigger_deliveries_project_idx": { + "name": "trigger_deliveries_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trigger_deliveries_pending_idx": { + "name": "trigger_deliveries_pending_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_deliveries\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trigger_deliveries_endpoint_id_trigger_endpoints_id_fk": { + "name": "trigger_deliveries_endpoint_id_trigger_endpoints_id_fk", + "tableFrom": "trigger_deliveries", + "tableTo": "trigger_endpoints", + "columnsFrom": ["endpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trigger_deliveries_project_id_projects_id_fk": { + "name": "trigger_deliveries_project_id_projects_id_fk", + "tableFrom": "trigger_deliveries", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trigger_deliveries_task_id_tasks_id_fk": { + "name": "trigger_deliveries_task_id_tasks_id_fk", + "tableFrom": "trigger_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "trigger_deliveries_run_id_runs_id_fk": { + "name": "trigger_deliveries_run_id_runs_id_fk", + "tableFrom": "trigger_deliveries", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trigger_endpoints": { + "name": "trigger_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_type_id": { + "name": "task_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "agent_overrides": { + "name": "agent_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ref": { + "name": "secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trigger_endpoints_token_prefix_uq": { + "name": "trigger_endpoints_token_prefix_uq", + "columns": [ + { + "expression": "token_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trigger_endpoints_project_idx": { + "name": "trigger_endpoints_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trigger_endpoints_org_id_orgs_id_fk": { + "name": "trigger_endpoints_org_id_orgs_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trigger_endpoints_project_id_projects_id_fk": { + "name": "trigger_endpoints_project_id_projects_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trigger_endpoints_task_type_id_task_types_id_fk": { + "name": "trigger_endpoints_task_type_id_task_types_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "task_types", + "columnsFrom": ["task_type_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trigger_endpoints_secret_ref_secrets_id_fk": { + "name": "trigger_endpoints_secret_ref_secrets_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "secrets", + "columnsFrom": ["secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trigger_endpoints_created_by_users_id_fk": { + "name": "trigger_endpoints_created_by_users_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_usage": { + "name": "token_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "model_id": { + "name": "model_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "token_usage_project_time_idx": { + "name": "token_usage_project_time_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "token_usage_run_idx": { + "name": "token_usage_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "token_usage_org_id_orgs_id_fk": { + "name": "token_usage_org_id_orgs_id_fk", + "tableFrom": "token_usage", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "token_usage_project_id_projects_id_fk": { + "name": "token_usage_project_id_projects_id_fk", + "tableFrom": "token_usage", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "token_usage_run_id_runs_id_fk": { + "name": "token_usage_run_id_runs_id_fk", + "tableFrom": "token_usage", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "token_usage_step_id_run_steps_id_fk": { + "name": "token_usage_step_id_run_steps_id_fk", + "tableFrom": "token_usage", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "token_usage_model_id_models_id_fk": { + "name": "token_usage_model_id_models_id_fk", + "tableFrom": "token_usage", + "tableTo": "models", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index cf79ad3..9a31432 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1786842827801, "tag": "0033_workspace_host", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1786847414942, + "tag": "0034_published_sha", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/runs.ts b/packages/db/src/schema/runs.ts index 9375cb9..54af3b8 100644 --- a/packages/db/src/schema/runs.ts +++ b/packages/db/src/schema/runs.ts @@ -144,6 +144,15 @@ export const runs = pgTable( .notNull() .default({}), workBranch: text("work_branch"), // created by the git.branch system action + /** + * The snapshot commit this run's git.push landed (ADR-0019). The chain's + * expected tip E = the latest non-null value across the `workspace_key` + * group by run number — what the next follow-up's publish parents on and + * force-with-leases against. Written by the engine right after the push + * resolves (the work_branch pattern): a crash between push and record + * re-runs an idempotent push and the record lands. + */ + publishedSha: text("published_sha"), // Execution lease (ADR-0017 Decision 4, resolving ADR-0009's future work): // claiming a run takes the lease by CAS, the owning worker renews it, the // sweeper expires dead leases and re-enqueues. A second at-least-once diff --git a/packages/orchestration/src/engine/deps.ts b/packages/orchestration/src/engine/deps.ts index cdd2cde..9f82013 100644 --- a/packages/orchestration/src/engine/deps.ts +++ b/packages/orchestration/src/engine/deps.ts @@ -141,7 +141,13 @@ export type PushSpec = { expectedPatch?: string; }; -export type PushResult = { status: "pushed"; commitSha: string } | { status: "evidence_mismatch" }; +export type PushResult = + | { status: "pushed"; commitSha: string } + | { status: "evidence_mismatch" } + /** The expected-tip CAS lost (ADR-0019): the observed branch tip is neither + * the deterministic commit nor what this chain last published. The remote + * was not touched; the engine fails the run typed (`publish_conflict`). */ + | { status: "tip_conflict"; observedTip: string | null }; /** * Platform-side git write-path (ADR-0011): branch creation before the diff --git a/packages/orchestration/src/engine/engine.integration.test.ts b/packages/orchestration/src/engine/engine.integration.test.ts index 04d69e0..42fc691 100644 --- a/packages/orchestration/src/engine/engine.integration.test.ts +++ b/packages/orchestration/src/engine/engine.integration.test.ts @@ -2729,6 +2729,36 @@ for (const transport of TRANSPORTS) { expect(fx.scm.pushes).toHaveLength(0); }); + it("records the chain's publication and stamps the commit on the timeline", async () => { + // ADR-0019: published_sha is the chain's expected-tip record — the + // next follow-up's publish parents on it; the branch.pushed event + // carries the commit so the timeline names what actually landed + const fx = await setupV2Fixture(); + const { impl, rev } = await walkBigPatchToDecidedGate(fx); + expect(await executeRun(fx.makeDeps(impl, rev), fx.runId)).toBe("succeeded"); + + const [run] = await fx.db.select().from(runs).where(eq(runs.id, fx.runId)); + expect(run?.publishedSha).toBe("fake-1"); + const events = await fx.db.select().from(runEvents).where(eq(runEvents.runId, fx.runId)); + const pushed = events.find((e) => e.type === "branch.pushed"); + expect((pushed?.payload as { commitSha?: string } | null)?.commitSha).toBe("fake-1"); + }); + + it("a lost expected-tip CAS fails the run publish_conflict, with no record", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await walkBigPatchToDecidedGate(fx); + fx.scm.tipConflictNext = "1111111111111111111111111111111111111111"; + + expect(await executeRun(fx.makeDeps(impl, rev), fx.runId)).toBe("failed"); + const [run] = await fx.db.select().from(runs).where(eq(runs.id, fx.runId)); + const error = run?.error as { code: string; message: string } | null; + expect(error?.code).toBe("publish_conflict"); + expect(error?.message).toContain("refusing to overwrite"); + // nothing landed, nothing recorded: the chain's E is unchanged + expect(run?.publishedSha).toBeNull(); + expect(fx.scm.pushes).toHaveLength(0); + }); + it("fails instead of publishing drifted, empty, or atomically changed evidence", async () => { for (const scenario of [ { diff: "diff --git a/drifted b/drifted\n+somebody touched this\n", atomic: false }, diff --git a/packages/orchestration/src/engine/engine.ts b/packages/orchestration/src/engine/engine.ts index 3888454..75ca50e 100644 --- a/packages/orchestration/src/engine/engine.ts +++ b/packages/orchestration/src/engine/engine.ts @@ -1386,7 +1386,25 @@ class RunEngine { "workspace changed while preparing the approved snapshot — refusing to publish", ); } - await this.emit("branch.pushed", { branch }); + if (result.status === "tip_conflict") { + // The expected-tip CAS lost (ADR-0019): the branch no longer matches + // what this chain last published — a human push wins by default, the + // remote was not touched, and reconciliation is a human decision. A + // retry reproduces the refusal deterministically, so this is terminal. + throw new RunFailure( + "publish_conflict", + "the publish branch no longer matches what this chain last published — refusing to overwrite", + ); + } + // The chain's publication record, written where the work happened (the + // work_branch pattern): a crash between push and record re-runs an + // idempotent push on resume, and the record lands then. + await this.db + .update(runs) + .set({ publishedSha: result.commitSha }) + .where(eq(runs.id, this.run.id)); + this.run.publishedSha = result.commitSha; + await this.emit("branch.pushed", { branch, commitSha: result.commitSha }); return; } diff --git a/packages/orchestration/src/engine/fakes.ts b/packages/orchestration/src/engine/fakes.ts index 24ac64a..937efc7 100644 --- a/packages/orchestration/src/engine/fakes.ts +++ b/packages/orchestration/src/engine/fakes.ts @@ -6,6 +6,7 @@ import type { Logger, ResolvedMcpServer, ResolvedSkill } from "@agrippa/executor import type { ArtifactStore, PullRequestSpec, + PushResult, ResourceMaterializer, ScmService, StoredArtifact, @@ -201,6 +202,10 @@ export class FakeScmService implements ScmService { readonly pushes: Array<{ runId: string; branch: string }> = []; readonly pullRequests: Array<{ runId: string; spec: PullRequestSpec }> = []; evidenceMismatchNext = false; + /** Set to make the next push lose the expected-tip CAS (ADR-0019) — the + * value is the observed tip the conflict reports. "Fails closed" is + * untestable until the fake can lie. */ + tipConflictNext: string | null | undefined = undefined; /** Set to make the next call of that action throw once (retry testing). */ failNext: Partial> = {}; @@ -217,15 +222,17 @@ export class FakeScmService implements ScmService { this.branches.push({ runId, name }); } - async push( - runId: string, - spec: { branch: string }, - ): Promise<{ status: "pushed"; commitSha: string } | { status: "evidence_mismatch" }> { + async push(runId: string, spec: { branch: string }): Promise { this.consumeFailure("push"); if (this.evidenceMismatchNext) { this.evidenceMismatchNext = false; return { status: "evidence_mismatch" }; } + if (this.tipConflictNext !== undefined) { + const observedTip = this.tipConflictNext; + this.tipConflictNext = undefined; + return { status: "tip_conflict", observedTip }; + } this.pushes.push({ runId, branch: spec.branch }); return { status: "pushed", commitSha: `fake-${this.pushes.length}` }; } From c59559b50900f409c98b5afc58a924d7de81bbbb Mon Sep 17 00:00:00 2001 From: John Hu Date: Sun, 16 Aug 2026 10:47:16 +0800 Subject: [PATCH 2/7] feat(worker): a chain's jobs land on the host that holds its workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0018 amendment, slice A2 — the per-host queue. A new name family run.host. (disjoint from the executor-set queues, generated and never parsed back) carries central runs whose workspace_host is stamped. The routing decision lives in ONE place: the enqueue-side resolver generalizes from executor-id derivation to queue-name derivation (dbRunQueueResolver) — runtime_id IS NULL AND workspace_host set routes to the host queue, a daemon pin keeps its executor-set routing (the pin owns affinity), everything else is unchanged. Every send path — submit, follow-up, straggler sweep, stranded-checkpoint sweep, lease sweeper, drain re-enqueue — funnels through enqueueRun/enqueueRunAfter, so follow-ups AND initial-run resumes become host-bound with zero call-site changes. The consumer side is one line: selectRunQueues adds the worker's own host queue (never a peer's), advertised from the storage identity A1 minted. The claim-time WorkspaceElsewhereError decline and its five-minute grace remain as the deploy-skew fallback. Tests: resolver routing against real rows (host pin → host queue, runtime pin wins, unknown → loud null), pure queue selection, and a fetch-level two-worker test — the holder claims the pinned run, a peer with the identical executor set never sees it, an unpinned run stays fair game. Routing tests neuter-verified. --- apps/api/src/index.ts | 4 +- apps/worker/src/consumer.integration.test.ts | 59 ++++++++++++++++++- apps/worker/src/deps/workspace.test.ts | 36 ++++++++++- .../heterogeneous-fleet.integration.test.ts | 4 +- apps/worker/src/index.ts | 5 +- apps/worker/src/run-queues.test.ts | 24 +++++++- apps/worker/src/run-queues.ts | 5 ++ packages/core/src/queue.ts | 14 +++++ .../src/queue.integration.test.ts | 4 +- packages/orchestration/src/queue.ts | 55 ++++++++++------- 10 files changed, 175 insertions(+), 35 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8628510..f493c17 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { createDb, migrateDb, seed } from "@agrippa/db"; import { createRunQueue, - dbRunExecutorResolver, + dbRunQueueResolver, RedisEventBus, seedBuiltinTemplates, } from "@agrippa/orchestration"; @@ -20,7 +20,7 @@ if (process.env.AGRIPPA_MIGRATE_ON_BOOT !== "0") { } const queue = await createRunQueue(process.env.DATABASE_URL as string, { - resolveRunExecutors: dbRunExecutorResolver(db), + resolveRunQueue: dbRunQueueResolver(db), }); const bus = process.env.REDIS_URL ? new RedisEventBus(process.env.REDIS_URL) : null; if (!bus) console.warn("[api] REDIS_URL not set — SSE falls back to DB polling"); diff --git a/apps/worker/src/consumer.integration.test.ts b/apps/worker/src/consumer.integration.test.ts index 18ffb51..ab75faa 100644 --- a/apps/worker/src/consumer.integration.test.ts +++ b/apps/worker/src/consumer.integration.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, it } from "bun:test"; -import { runExecuteQueueName, runExecuteSubsetQueues } from "@agrippa/core"; +import { runExecuteQueueName, runExecuteSubsetQueues, runHostQueueName } from "@agrippa/core"; import { createDb } from "@agrippa/db"; import { type BossQueue, createRunQueue } from "@agrippa/orchestration"; import { sql } from "drizzle-orm"; @@ -60,9 +60,17 @@ describe.skipIf(!dbUp)("executor-set queue routing + fetch loop", () => { await queue?.stop(); }); + // name-level routes (host queues) win over executor-id derivation — the + // same precedence dbRunQueueResolver gives a stamped workspace_host + const routes = new Map(); const setup = async (): Promise => { queue ??= await createRunQueue(TEST_DATABASE_URL, { - resolveRunExecutors: async (runId) => resolvers.get(runId) ?? [], + resolveRunQueue: async (runId) => { + const named = routes.get(runId); + if (named) return named; + const ids = resolvers.get(runId); + return ids && ids.length > 0 ? runExecuteQueueName(ids) : null; + }, }); return queue; }; @@ -81,7 +89,7 @@ describe.skipIf(!dbUp)("executor-set queue routing + fetch loop", () => { const runId = Bun.randomUUIDv7(); // no resolver entry → [] // post-M2-flush nothing consumes `run.execute` — parking the job there // would strand it silently, so the enqueue must throw instead - await expect(q.enqueueRun(runId)).rejects.toThrow(/cannot derive an executor set/); + await expect(q.enqueueRun(runId)).rejects.toThrow(/cannot derive a queue/); expect(await jobRow(runId)).toBeUndefined(); }); @@ -130,6 +138,51 @@ describe.skipIf(!dbUp)("executor-set queue routing + fetch loop", () => { expect(deliveries).toBe(1); }); + it("a host-pinned run lands only on the worker mounting that storage", async () => { + // the two-root fleet, at the fetch level (ADR-0018 amendment): the holder + // polls its own run.host. queue; a peer with the SAME executor set + // does not, so it can never fetch a chain whose directory it lacks + const q = await setup(); + const execH = `exec-h-${suite}`; + const hostQueue = runHostQueueName(`host-fleet-${suite}`); + for (const name of [...runExecuteSubsetQueues([execH]), hostQueue]) { + await q.boss.createQueue(name); + } + const seenByHolder: string[] = []; + const seenByPeer: string[] = []; + loops.push( + startRunFetchLoop({ + boss: q.boss, + queues: [...runExecuteSubsetQueues([execH]), hostQueue], + slots: 2, + handler: async (job) => void seenByHolder.push(job.data.runId), + logger: noopLogger, + tickMs: 100, + }), + startRunFetchLoop({ + boss: q.boss, + queues: runExecuteSubsetQueues([execH]), + slots: 2, + handler: async (job) => void seenByPeer.push(job.data.runId), + logger: noopLogger, + tickMs: 100, + }), + ); + + const pinned = Bun.randomUUIDv7(); + routes.set(pinned, hostQueue); + await q.enqueueRun(pinned); + await waitFor(() => seenByHolder.includes(pinned)); + expect(seenByPeer).not.toContain(pinned); + await waitFor(async () => (await jobRow(pinned))?.state === "completed"); + + // an unpinned run of the same executor set stays fair game for either + const unpinned = Bun.randomUUIDv7(); + resolvers.set(unpinned, [execH]); + await q.enqueueRun(unpinned); + await waitFor(() => seenByHolder.includes(unpinned) || seenByPeer.includes(unpinned)); + }); + it("updateQueues extends a live loop to a daemon-covered set queue", async () => { const q = await setup(); // fresh executor ids so the loops from earlier tests cannot consume these diff --git a/apps/worker/src/deps/workspace.test.ts b/apps/worker/src/deps/workspace.test.ts index b9045d1..1e192c3 100644 --- a/apps/worker/src/deps/workspace.test.ts +++ b/apps/worker/src/deps/workspace.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "node:fs"; import { appendFile, chmod, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { runExecuteQueueName, runHostQueueName } from "@agrippa/core"; import { createDb, encryptSecret, @@ -20,7 +21,7 @@ import { taskTypes, users, } from "@agrippa/db"; -import { seedBuiltinTemplates } from "@agrippa/orchestration"; +import { dbRunQueueResolver, seedBuiltinTemplates } from "@agrippa/orchestration"; import { eq, sql } from "drizzle-orm"; // WORKSPACE_ROOT is read at module load — point it at a scratch dir BEFORE @@ -252,6 +253,39 @@ describe.skipIf(!dbUp)("GitWorkspaceManager + GitScmService (real git)", () => { expect(await hostOf(pinnedRunId)).toBeNull(); }); + it("dbRunQueueResolver: a host pin routes to the host queue; a runtime pin never does", async () => { + const resolve = dbRunQueueResolver(db); + + const hostPinned = await newRunRow(); + await db.update(runs).set({ workspaceHost: "host-q" }).where(eq(runs.id, hostPinned)); + expect(await resolve(hostPinned)).toBe(runHostQueueName("host-q")); + + const plain = await newRunRow(); + expect(await resolve(plain)).toBe(runExecuteQueueName(["fake"])); + + // a daemon-pinned run's affinity IS its runtime pin — it routes by + // executor set even if a workspace host somehow got stamped + const [runtime] = await db + .insert(runtimes) + .values({ + orgId, + name: "route-guard", + tokenHash: "h", + tokenPrefix: `agrd_${Bun.randomUUIDv7().slice(-7)}`, + executors: [], + createdBy: userId, + }) + .returning({ id: runtimes.id }); + const daemonPinned = await newRunRow(); + await db + .update(runs) + .set({ runtimeId: runtime?.id as string, workspaceHost: "host-q" }) + .where(eq(runs.id, daemonPinned)); + expect(await resolve(daemonPinned)).toBe(runExecuteQueueName(["fake"])); + + expect(await resolve(Bun.randomUUIDv7())).toBeNull(); + }); + it("records the clone base and keeps sanitization out of the diff", async () => { const dir = workspaceDirFor(workspaceKey); const base = await platformBaseSha(workspaceKey); diff --git a/apps/worker/src/heterogeneous-fleet.integration.test.ts b/apps/worker/src/heterogeneous-fleet.integration.test.ts index 256eed5..b848818 100644 --- a/apps/worker/src/heterogeneous-fleet.integration.test.ts +++ b/apps/worker/src/heterogeneous-fleet.integration.test.ts @@ -24,7 +24,7 @@ import { type BossQueue, buildParamsValidator, createRunQueue, - dbRunExecutorResolver, + dbRunQueueResolver, type EngineDeps, FakeResourceMaterializer, FakeWorkspaceManager, @@ -179,7 +179,7 @@ describe.skipIf(!dbUp)("heterogeneous fleet routing (Phase A verify)", () => { defaultFaberId = taskType?.defaultFaberId as string; queue = await createRunQueue(TEST_DATABASE_URL, { - resolveRunExecutors: dbRunExecutorResolver(db), + resolveRunQueue: dbRunQueueResolver(db), }); const workerA = createRunConsumer(db, depsFor({ fake: fakeOnA }), queue); diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index f37f075..c0ab5ea 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -21,7 +21,7 @@ import { collectExpiredWorkspaces, createRunQueue, DiskArtifactStore, - dbRunExecutorResolver, + dbRunQueueResolver, decideCheckpoint, type EngineDeps, enqueueAfterCommit, @@ -135,7 +135,7 @@ const bus = process.env.REDIS_URL ? new RedisEventBus(process.env.REDIS_URL) : new InProcessEventBus(); const queue = await createRunQueue(process.env.DATABASE_URL as string, { - resolveRunExecutors: dbRunExecutorResolver(db), + resolveRunQueue: dbRunQueueResolver(db), }); const deps: EngineDeps = { @@ -185,6 +185,7 @@ async function computeRunQueues(): Promise { const liveWorkers = await liveCentralWorkerSets(db); const list = selectRunQueues({ localExecutorIds: Object.keys(executors), + ownWorkspaceHost: workspaceHost, centralWorkerSets: liveWorkers.map((ads) => ads.map((e) => e.id)), runtimeAds: liveRuntimes.map((r) => ({ name: r.name, diff --git a/apps/worker/src/run-queues.test.ts b/apps/worker/src/run-queues.test.ts index fa00e9d..4402b9b 100644 --- a/apps/worker/src/run-queues.test.ts +++ b/apps/worker/src/run-queues.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { runExecuteQueueName } from "@agrippa/core"; +import { runExecuteQueueName, runHostQueueName } from "@agrippa/core"; import { selectRunQueues } from "./run-queues"; const collect = (): { warnings: string[]; logger: { warn: (m: string) => void } } => { @@ -92,6 +92,28 @@ describe("selectRunQueues (dynamic coverage, codex round-2)", () => { expect(warnings.some((w) => w.includes("skipping its queues"))).toBe(true); }); + it("polls its own host queue, never a peer's, and none without an identity", () => { + const { logger } = collect(); + const withHost = selectRunQueues({ + localExecutorIds: ["fake"], + ownWorkspaceHost: "host-a", + centralWorkerSets: [], + runtimeAds: [], + logger, + }); + expect(withHost).toContain(runHostQueueName("host-a")); + // exactly one host queue — a worker never fetches a peer's pinned runs + expect(withHost.filter((n) => n.startsWith("run.host."))).toHaveLength(1); + + const withoutHost = selectRunQueues({ + localExecutorIds: ["fake"], + centralWorkerSets: [], + runtimeAds: [], + logger, + }); + expect(withoutHost.some((n) => n.startsWith("run.host."))).toBe(false); + }); + it("hard-caps the total queue set and logs", () => { const { warnings, logger } = collect(); // 8-id ads expand to 255 subsets each; 6 distinct ads overshoot 1024 diff --git a/apps/worker/src/run-queues.ts b/apps/worker/src/run-queues.ts index d1d0d9e..a0cd237 100644 --- a/apps/worker/src/run-queues.ts +++ b/apps/worker/src/run-queues.ts @@ -4,6 +4,7 @@ import { isExecutorId, runExecuteQueueName, runExecuteSubsetQueues, + runHostQueueName, } from "@agrippa/core"; /** A daemon ad expands to at most 2^8−1 = 255 subset queues; legit daemons advertise ≤3. */ @@ -37,6 +38,9 @@ export type RunQueueLogger = { warn(message: string): void }; */ export function selectRunQueues(input: { localExecutorIds: readonly string[]; + /** This worker's storage identity — it polls its own host's queue, where + * host-pinned central runs land (ADR-0018 amendment). Never a peer's. */ + ownWorkspaceHost?: string | null; /** Executor-id sets of live central workers (from worker_heartbeats). */ centralWorkerSets: readonly (readonly string[])[]; /** Executor ids advertised by each live runtime, with a label for logging. */ @@ -47,6 +51,7 @@ export function selectRunQueues(input: { }): string[] { const isAllowedId = input.isAllowedId ?? isExecutorId; const names = new Set(runExecuteSubsetQueues(input.localExecutorIds)); + if (input.ownWorkspaceHost) names.add(runHostQueueName(input.ownWorkspaceHost)); const centralSets = input.centralWorkerSets.map((ids) => new Set(ids)); const centrallyCovered = (ids: readonly string[]): boolean => centralSets.some((set) => ids.every((id) => set.has(id))); diff --git a/packages/core/src/queue.ts b/packages/core/src/queue.ts index d4cffca..b63d2f9 100644 --- a/packages/core/src/queue.ts +++ b/packages/core/src/queue.ts @@ -14,6 +14,20 @@ export const QUEUE_SCHEDULE_FIRE = "schedule.fire"; export const QUEUE_TRIGGER_FIRE = "trigger.fire"; export const RUN_EXECUTE_QUEUE_PREFIX = "run.execute."; +export const RUN_HOST_QUEUE_PREFIX = "run.host."; + +/** + * Queue for runs pinned to a workspace host (the per-host queue, ADR-0018 + * amendment): central runs whose `workspace_host` is stamped route here, and + * only workers advertising that storage identity poll it — the chain lands on + * the host that holds its directory instead of bouncing off claim-time + * declines. Cleanly disjoint from the executor-set family; names are only + * ever generated, never parsed back. + */ +export function runHostQueueName(hostId: string): string { + if (!hostId) throw new Error("runHostQueueName: empty host id"); + return RUN_HOST_QUEUE_PREFIX + hostId; +} /** * Queue name for a run's required executor set: `run.execute.` plus the diff --git a/packages/orchestration/src/queue.integration.test.ts b/packages/orchestration/src/queue.integration.test.ts index e25a233..b1b9461 100644 --- a/packages/orchestration/src/queue.integration.test.ts +++ b/packages/orchestration/src/queue.integration.test.ts @@ -35,7 +35,7 @@ describe.skipIf(!dbUp)("notification queue dedupe (exclusive policy)", () => { it("converges the queue to exclusive and keeps one live job per delivery", async () => { queue = await createRunQueue(TEST_DATABASE_URL, { - resolveRunExecutors: async () => ["fake"], + resolveRunQueue: async () => "run.execute.fake", }); const [queueRow] = (await db.execute( @@ -57,7 +57,7 @@ describe.skipIf(!dbUp)("notification queue dedupe (exclusive policy)", () => { // sweeper re-enqueue and an operator replay from becoming three runs — and // on the default 'standard' policy that key enforces nothing at all queue ??= await createRunQueue(TEST_DATABASE_URL, { - resolveRunExecutors: async () => ["fake"], + resolveRunQueue: async () => "run.execute.fake", }); const [queueRow] = (await db.execute( diff --git a/packages/orchestration/src/queue.ts b/packages/orchestration/src/queue.ts index 020afde..6482930 100644 --- a/packages/orchestration/src/queue.ts +++ b/packages/orchestration/src/queue.ts @@ -7,6 +7,7 @@ import { type RunQueue, requiredExecutorIds, runExecuteQueueName, + runHostQueueName, } from "@agrippa/core"; import { type Db, runs } from "@agrippa/db"; import { eq } from "drizzle-orm"; @@ -45,20 +46,36 @@ export async function enqueueAfterCommit( } /** - * Resolves the executor ids a run requires, so enqueueRun can derive its - * executor-set queue name. Returning [] (unknown run) falls back to the - * legacy queue — the job then fails visibly instead of being silently lost. + * Resolves a run to the queue its job belongs on — null for an unknown run + * (the enqueue then fails loudly instead of parking the job where nobody + * polls). Owning the name derivation here means every send path — submit, + * follow-up, straggler sweep, stranded-checkpoint sweep, lease sweeper, drain + * re-enqueue — routes identically with zero call-site changes. */ -export type RunExecutorResolver = (runId: string) => Promise; +export type RunQueueNameResolver = (runId: string) => Promise; -/** The standard resolver: one indexed select on the run row. */ -export function dbRunExecutorResolver(db: Db): RunExecutorResolver { +/** + * The standard resolver: one indexed select on the run row. A central run + * with a stamped workspace host routes to that host's queue (ADR-0018 + * amendment — follow-ups AND resumes of initial runs land where the + * directory lives); a daemon-pinned run's affinity is its runtime pin, so it + * routes by executor set like any other; everything else routes by the + * executor-set queue family. + */ +export function dbRunQueueResolver(db: Db): RunQueueNameResolver { return async (runId) => { const [run] = await db - .select({ executorId: runs.executorId, agentBindings: runs.agentBindings }) + .select({ + executorId: runs.executorId, + agentBindings: runs.agentBindings, + runtimeId: runs.runtimeId, + workspaceHost: runs.workspaceHost, + }) .from(runs) .where(eq(runs.id, runId)); - return run ? requiredExecutorIds(run) : []; + if (!run) return null; + if (run.runtimeId === null && run.workspaceHost) return runHostQueueName(run.workspaceHost); + return runExecuteQueueName(requiredExecutorIds(run)); }; } @@ -76,7 +93,7 @@ export function dbRunExecutorResolver(db: Db): RunExecutorResolver { */ export async function createRunQueue( connectionString: string, - opts: { resolveRunExecutors: RunExecutorResolver }, + opts: { resolveRunQueue: RunQueueNameResolver }, ): Promise { const boss = new PgBoss({ connectionString }); boss.on("error", (err: Error) => console.error("[pg-boss]", err)); @@ -130,23 +147,17 @@ export async function createRunQueue( boss, stop: () => boss.stop({ graceful: true }), async enqueueRun(runId: string): Promise { - const executorIds = await opts.resolveRunExecutors(runId); - // Post-M2-flush there is no legacy fallback queue: a run whose executor - // set cannot be derived has no consumer, so failing the enqueue loudly - // beats parking the job on a queue nobody polls. - if (executorIds.length === 0) { - throw new Error(`enqueueRun: cannot derive an executor set for run ${runId}`); - } - const name = runExecuteQueueName(executorIds); + // Post-M2-flush there is no legacy fallback queue: a run whose queue + // cannot be derived has no consumer, so failing the enqueue loudly + // beats parking the job where nobody polls. + const name = await opts.resolveRunQueue(runId); + if (!name) throw new Error(`enqueueRun: cannot derive a queue for run ${runId}`); await ensureQueue(name); await boss.send(name, { runId }, { singletonKey: runId, retryLimit: 2, retryDelay: 5 }); }, async enqueueRunAfter(runId: string, delaySeconds: number): Promise { - const executorIds = await opts.resolveRunExecutors(runId); - if (executorIds.length === 0) { - throw new Error(`enqueueRunAfter: cannot derive an executor set for run ${runId}`); - } - const name = runExecuteQueueName(executorIds); + const name = await opts.resolveRunQueue(runId); + if (!name) throw new Error(`enqueueRunAfter: cannot derive a queue for run ${runId}`); await ensureQueue(name); await boss.sendAfter( name, From eda18dc2c78e1cb74f3d688e47b3fb41d6fc51ac Mon Sep 17 00:00:00 2001 From: John Hu Date: Sun, 16 Aug 2026 10:47:26 +0800 Subject: [PATCH 3/7] docs(plan): tick P2 and A2 --- docs/plan/m3-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plan/m3-plan.md b/docs/plan/m3-plan.md index 6df63a3..d2c16a8 100644 --- a/docs/plan/m3-plan.md +++ b/docs/plan/m3-plan.md @@ -21,7 +21,7 @@ A follow-up produces a patch today but can never push — publication "remains t - [x] **ADR first**: [ADR-0019](../adr/0019-followup-publication-expected-tip-cas.md) + Amendment pointers appended in ADR-0012 and ADR-0018 (this plan's first commit) - [x] **P1** `applyApprovedPatch` learns `expectedTip`: parent selection (E if present, else base), branch-ref fetch for the parent object, no-op tree guard (tree == E's tree → return E, push nothing), `tip_conflict` on any other observed tip; real-git tests — create / advance / idempotent retry / conflict / no-op -- [ ] **P2** additive migration `runs.published_sha`, written by the engine in the `git.push` handler post-push (the `work_branch` pattern, crash-safe by determinism); `GitScmService` derives E from the `workspace_key` chain; `PushResult` gains `tip_conflict` and the engine maps it to `RunFailure("publish_conflict")`; `FakeScmService` learns to lie; `commitSha` on the `branch.pushed` event +- [x] **P2** additive migration `runs.published_sha`, written by the engine in the `git.push` handler post-push (the `work_branch` pattern, crash-safe by determinism); `GitScmService` derives E from the `workspace_key` chain; `PushResult` gains `tip_conflict` and the engine maps it to `RunFailure("publish_conflict")`; `FakeScmService` learns to lie; `commitSha` on the `branch.pushed` event - [ ] **P3** synthetic publish tail in `followupTemplate` — approval checkpoint presenting the cumulative patch (decision 2, `onTimeout: cancel`) → `git.push` → `pr.open` iff the base flow had one; engine-computed guards read *approved* and *published* separately: whole tail skipped only on an empty cumulative patch, checkpoint alone skipped on byte-identical re-approval, push/`pr.open` always run (idempotent) so an approved-but-unpublished chain completes delivery; i18n error strings; manual en+zh-CN 03; CHANGELOG - Verify: ☐ compliance suite through **both transports** — publish gated by approval, empty steers skip the tail, an unchanged steer on an approved-but-unpublished chain completes delivery without a new checkpoint, conflict fails typed, chained follow-ups advance E twice; ☐ live smoke on the deployed stack: steer a finished repo run → approve the presented patch → the branch/PR advances by exactly one deterministic commit; a push retry is a no-op; a manual push to the branch followed by a second steer fails `publish_conflict` typed @@ -30,7 +30,7 @@ A follow-up produces a patch today but can never push — publication "remains t ADR-0018 named the per-host queue as the real fix for "declines pre-claim for five minutes, then fails `workspace_lost`". Host identity is the identity of the *storage*, not the container — compose replicas sharing the workspaces volume share it. Recorded as an Amendment on ADR-0018. - [x] **A1** host-id helper in `@agrippa/workspace` (uuid persisted at `WORKSPACE_ROOT/.agrippa-host-id`); additive migration `runs.workspace_host` (stamped first-writer-wins at checkout, inherited by follow-up inserts, never for remote runs) + `worker_heartbeats.workspace_host`; heartbeat advertisement plumbing -- [ ] **A2** `runHostQueueName` (`run.host.`) in core; the queue resolver routes `runtime_id IS NULL AND workspace_host IS NOT NULL` to the host queue (every enqueue path already funnels through `enqueueRun`/`enqueueRunAfter`, so follow-ups **and** initial-run resumes — decision 4 — become host-bound with no call-site changes); worker polls its own host queue via `computeRunQueues`; the 5-min `WorkspaceElsewhereError` decline stays as the deploy-skew fallback +- [x] **A2** `runHostQueueName` (`run.host.`) in core; the queue resolver routes `runtime_id IS NULL AND workspace_host IS NOT NULL` to the host queue (every enqueue path already funnels through `enqueueRun`/`enqueueRunAfter`, so follow-ups **and** initial-run resumes — decision 4 — become host-bound with no call-site changes); worker polls its own host queue via `computeRunQueues`; the 5-min `WorkspaceElsewhereError` decline stays as the deploy-skew fallback - [ ] **A3** `dead-host-runs` sweeper stage: host unheartbeated > 5 min ∧ run waited > 5 min → `finalizeRun("workspace_lost")` + notification sync (ADR-0018's "a dead pin is never re-routed", made mechanical); design 04 + 08 (volume-identity note); manual en+zh-CN 06; CHANGELOG - Verify: ☐ two-root fleet integration test — a follow-up is claimed only by the workspace holder, zero bounces; the skew fallback still declines when an old producer sends to a set queue; a dead host's parked run fails typed at the (test-shortened) deadline From 0771aabdddeecef13ca8af9c1d71fe75df0b96d9 Mon Sep 17 00:00:00 2001 From: John Hu Date: Sun, 16 Aug 2026 11:04:36 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(engine):=20a=20follow-up=20publishes?= =?UTF-8?q?=20what=20it=20was=20steered=20to=20produce=20=E2=80=94=20gated?= =?UTF-8?q?,=20guarded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0019 P3 — the synthetic publish tail. followupTemplate appends a publish phase when the whole publication story exists (base flow has git.push, base declares a workspace, the seed found a patch key, the chain has a work branch): an approval checkpoint presenting the CUMULATIVE patch, then the base's own git.push and pr.open steps verbatim — their with-config is author intent the synthetic flow must not lose (their when-conditions are stripped; the engine's guards replace them). The guards key on the synthetic phase id, never expressible in the template language, and read approved and published from separate records: the whole tail is skipped only when the steer's patch is EMPTY; the checkpoint alone is skipped when the patch is byte-identical to bytes the chain already approved (an earlier follow-up's publish checkpoint, or anything published — nothing publishes unapproved bytes); push and pr.open always run, both idempotent, so an approved-but-unpublished chain completes instead of being skipped past. A base-flow approval that never reached a push is deliberately not counted — the conservative direction re-asks a human. Compliance through both transports: changed steer pauses at its own approval then advances the chain twice; unchanged steer completes in one leg with the checkpoint skipped and the push run; question-only steer skips the tail entirely. All neuter-verified (guards off, tail off). i18n gains publish_conflict in both locales; manual 03 en+zh-CN rewrites the 'publishing stays with the original run' bullet the tail supersedes; CHANGELOG covers publication and host routing. --- CHANGELOG.md | 5 + docs/manual/en/03-running-tasks.md | 3 +- docs/manual/zh-CN/03-running-tasks.md | 3 +- packages/i18n/locales/en/errors.json | 3 +- packages/i18n/locales/zh-CN/errors.json | 3 +- .../src/engine/engine.integration.test.ts | 135 +++++++++++++++++- packages/orchestration/src/engine/engine.ts | 97 ++++++++++++- packages/orchestration/src/followup.ts | 64 ++++++++- 8 files changed, 302 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5fc5d..fcd56b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to Agrippa are documented here. The format follows ## [Unreleased] +### Added + +- **A follow-up can publish what it was steered to produce** (ADR-0019). When the base flow delivers to a branch, a follow-up that changed the workspace pauses at its own approval presenting the full cumulative patch, then pushes exactly one deterministic commit on top of what the chain last published (an expected-tip CAS — `runs.published_sha` is the chain's record) and re-targets the same PR. A steer that changed nothing publishes nothing; a byte-identical patch carries its earlier approval forward instead of re-asking; and a branch that no longer matches the chain's record — a human push, a post-merge deletion — fails typed as `publish_conflict` with the remote untouched. +- **Central runs route to the host that holds their workspace** (the per-host queue, ADR-0018 amendment). Hosts are identified by their storage (`WORKSPACE_ROOT/.agrippa-host-id`); repo checkouts stamp `runs.workspace_host` first-writer-wins; follow-ups inherit it; producers send host-pinned jobs to `run.host.`, which only workers mounting that storage poll — follow-ups and resumes land where the directory lives instead of bouncing off claim-time declines, which remain as the deploy-skew fallback. + ### Fixed - A Codex resume whose session is gone (collected or migrated session home — the CLI dies before `thread.started` with "no rollout found") now reports the resume **rejected**, so the engine runs its context-loss disclosure and continues fresh, instead of failing the step `model_error` and re-offering the dead session on every retry. diff --git a/docs/manual/en/03-running-tasks.md b/docs/manual/en/03-running-tasks.md index 0f0f6b5..0ff19ae 100644 --- a/docs/manual/en/03-running-tasks.md +++ b/docs/manual/en/03-running-tasks.md @@ -49,7 +49,8 @@ A few things worth knowing: - **Several messages in a row become one run.** Send two thoughts in quick succession and they are delivered together rather than starting two runs. Once the follow-up begins, the next message becomes the next follow-up. - **Your message is always on the timeline**, immediately, whether or not the run starts promptly. - **There is a window.** A finished run holds its workspace for a while (one hour by default; an administrator can change it). After that the workspace is collected and **Continue** disappears — submit a new task instead. -- **Publishing stays with the original run.** A follow-up produces an updated patch and diff you can read; pushing to the branch and opening the PR remain the first run's business. +- **A follow-up that changed the work publishes it — after you approve.** When the original flow delivers to a branch, a follow-up that modified the workspace pauses at its own approval presenting the **full updated patch** (everything delivered so far, not just this message's edits); approving pushes exactly one new commit on top of what the chain last published and re-targets the same PR. A follow-up that only answered a question publishes nothing, and a patch identical to one you already approved is not re-asked. +- **A branch someone pushed to by hand is never overwritten.** If the delivery branch no longer matches what the chain last published — a teammate committed to it, or it was deleted after a merge — the follow-up's publish fails with *publish conflict* and the branch is left untouched. Reconcile by pushing manually, or submit a new task from the branch's current state. ## Cancel, retry, and failures diff --git a/docs/manual/zh-CN/03-running-tasks.md b/docs/manual/zh-CN/03-running-tasks.md index 3565106..0332fd1 100644 --- a/docs/manual/zh-CN/03-running-tasks.md +++ b/docs/manual/zh-CN/03-running-tasks.md @@ -49,7 +49,8 @@ - **连续几条消息会合并为一次执行。** 短时间内发两条想法,它们会一起送达,而不是启动两次执行。一旦追加执行开始,下一条消息就会成为下一次追加执行。 - **你的消息始终立刻出现在时间线上**,无论执行是否马上开始。 - **有时间窗口。** 已结束的执行会保留其工作区一段时间(默认一小时,管理员可调整)。超过之后工作区会被回收,**继续执行**也随之消失——这时请提交一个新任务。 -- **发布仍属于原执行。** 追加执行会产出更新后的补丁与差异供你查看;推送分支与创建 PR 仍由第一次执行负责。 +- **改动了工作内容的追加执行会发布——但要先经你确认。** 当原流程会交付到分支时,修改了工作区的追加执行会停在它自己的审批处,展示**完整的更新后补丁**(是迄今交付的全部内容,而不只是这条消息的改动);确认后会在该链上次发布的提交之上恰好推送一个新提交,并更新到同一个 PR。只回答了问题的追加执行不会发布任何东西;与你已确认过的补丁完全一致时也不会重复询问。 +- **人工推送过的分支绝不会被覆盖。** 如果交付分支与该链上次发布的记录不再一致——有同事直接提交过,或分支在合并后被删除——追加执行的发布会以*发布冲突*失败,分支保持原样。此时请手动推送,或基于分支当前状态提交新任务。 ## 取消、重试与失败 diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 8f602de..f769cff 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -58,5 +58,6 @@ "invite_already_accepted": "This invite has already been used", "invite_self": "You cannot invite yourself", "contract_violation": "An agent output violated its contract", - "workspace_lost": "The run's workspace is no longer on this worker host" + "workspace_lost": "The run's workspace is no longer on this worker host", + "publish_conflict": "The delivery branch no longer matches what this chain last published — a human push wins; publish manually or submit a new task" } diff --git a/packages/i18n/locales/zh-CN/errors.json b/packages/i18n/locales/zh-CN/errors.json index 58d805f..71f79aa 100644 --- a/packages/i18n/locales/zh-CN/errors.json +++ b/packages/i18n/locales/zh-CN/errors.json @@ -58,5 +58,6 @@ "invite_already_accepted": "该邀请已被使用", "invite_self": "不能邀请自己", "contract_violation": "智能体输出违反了约定契约", - "workspace_lost": "该执行的工作区已不在当前工作节点上" + "workspace_lost": "该执行的工作区已不在当前工作节点上", + "publish_conflict": "交付分支与该链最近一次发布的记录不再一致——人工推送优先;请手动推送或提交新任务" } diff --git a/packages/orchestration/src/engine/engine.integration.test.ts b/packages/orchestration/src/engine/engine.integration.test.ts index 42fc691..1942d9c 100644 --- a/packages/orchestration/src/engine/engine.integration.test.ts +++ b/packages/orchestration/src/engine/engine.integration.test.ts @@ -2015,7 +2015,7 @@ for (const transport of TRANSPORTS) { }); const seed = await followupSeed(db, runId, base); - const synthetic = followupTemplate(base, seed); + const synthetic = followupTemplate(base, seed, { workBranch: null }); const [steer] = synthetic.spec.phases.flatMap((p) => "steps" in p ? p.steps : [], ) as Array<{ skills: string[] }>; @@ -2729,6 +2729,139 @@ for (const transport of TRANSPORTS) { expect(fx.scm.pushes).toHaveLength(0); }); + /** A follow-up row of the v2 fixture's run — the endpoint's insert shape. */ + const v2FollowupOf = async (fx: V2Fixture, message: string): Promise => { + const [parent] = await fx.db.select().from(runs).where(eq(runs.id, fx.runId)); + const [head] = await fx.db + .select({ number: runs.number }) + .from(runs) + .where(eq(runs.taskId, parent?.taskId as string)) + .orderBy(desc(runs.number)) + .limit(1); + const [row] = await fx.db + .insert(runs) + .values({ + ...newRunIdentity(), + workspaceKey: parent?.workspaceKey as string, + kind: "followup", + parentRunId: fx.runId, + steeringMessage: message, + taskId: parent?.taskId as string, + projectId: parent?.projectId as string, + number: (head?.number ?? 1) + 1, + templateVersionId: parent?.templateVersionId as string, + faberId: parent?.faberId as string, + executorId: parent?.executorId as string, + agentBindings: parent?.agentBindings ?? {}, + paramsSnapshot: parent?.paramsSnapshot ?? {}, + modelResolution: parent?.modelResolution ?? {}, + resourceManifest: parent?.resourceManifest ?? { mcpServers: [], skills: [] }, + workBranch: parent?.workBranch ?? null, + workspaceRef: parent?.workspaceRef ?? null, + createdBy: parent?.createdBy as string, + }) + .returning(); + return row?.id as string; + }; + + const publishParent = async (fx: V2Fixture) => { + const { impl, rev } = await walkBigPatchToDecidedGate(fx); + expect(await executeRun(fx.makeDeps(impl, rev), fx.runId)).toBe("succeeded"); + return { + impl: { ...impl, steer: { kind: "succeed", output: "steered" } as FakeStepBehavior }, + rev, + }; + }; + + it("a changed follow-up publishes through its approval-gated tail, and a chain advances twice", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await publishParent(fx); + expect(fx.scm.pushes).toHaveLength(1); + + // the steer changes the workspace further — cumulative evidence + fx.workspace.diffOutput = `${BIG_DIFF}+one more thing\n`; + const f1 = await v2FollowupOf(fx, "also handle the empty-list case"); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("waiting_approval"); + const [pending] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f1), eq(checkpoints.status, "pending"))); + // the tail's own approval, presenting the cumulative patch + expect(pending?.checkpointId).toBe("approve-publish"); + await decideCheckpoint(fx.db, pending?.id as string, { + status: "approved", + decidedBy: fx.userId, + response: { kind: "approval", outcome: "approved" }, + }); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("succeeded"); + expect(fx.scm.pushes).toHaveLength(2); + const [f1Row] = await fx.db.select().from(runs).where(eq(runs.id, f1)); + expect(f1Row?.publishedSha).toBe("fake-2"); + // the PR is re-targeted, never duplicated (same head/base recovers) + expect(fx.scm.pullRequests).toHaveLength(1); + + // the chain advances AGAIN: a second changed steer publishes on top + fx.workspace.diffOutput = `${BIG_DIFF}+one more thing\n+and another\n`; + const f2 = await v2FollowupOf(fx, "and another"); + expect(await executeRun(fx.makeDeps(impl, rev), f2)).toBe("waiting_approval"); + const [pending2] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f2), eq(checkpoints.status, "pending"))); + await decideCheckpoint(fx.db, pending2?.id as string, { + status: "approved", + decidedBy: fx.userId, + response: { kind: "approval", outcome: "approved" }, + }); + expect(await executeRun(fx.makeDeps(impl, rev), f2)).toBe("succeeded"); + const [f2Row] = await fx.db.select().from(runs).where(eq(runs.id, f2)); + expect(f2Row?.publishedSha).toBe("fake-3"); + }); + + it("an unchanged steer carries the approval forward: push runs, nobody is re-asked", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await publishParent(fx); + + // byte-identical to what the parent already published (and had + // approved) — the checkpoint is skipped, the idempotent push still + // runs, and the whole follow-up completes in one leg + const f1 = await v2FollowupOf(fx, "just answer a question about it"); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("succeeded"); + const steps = await fx.db + .select() + .from(runSteps) + .where(eq(runSteps.runId, f1)) + .orderBy(asc(runSteps.seq)); + const byId = new Map(steps.map((s) => [s.stepId, s.status])); + expect(byId.get("approve-publish")).toBe("skipped"); + expect(byId.get("push")).toBe("succeeded"); + expect( + await fx.db.select().from(checkpoints).where(eq(checkpoints.runId, f1)), + ).toHaveLength(0); + }); + + it("a steer that changed nothing to publish skips the tail entirely", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await publishParent(fx); + const pushesBefore = fx.scm.pushes.length; + + fx.workspace.diffOutput = ""; + const f1 = await v2FollowupOf(fx, "what did you change and why?"); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("succeeded"); + const steps = await fx.db + .select() + .from(runSteps) + .where(eq(runSteps.runId, f1)) + .orderBy(asc(runSteps.seq)); + const byId = new Map(steps.map((s) => [s.stepId, s.status])); + expect(byId.get("steer")).toBe("succeeded"); + expect(byId.get("approve-publish")).toBe("skipped"); + expect(byId.get("push")).toBe("skipped"); + expect(fx.scm.pushes).toHaveLength(pushesBefore); + const [f1Row] = await fx.db.select().from(runs).where(eq(runs.id, f1)); + expect(f1Row?.publishedSha).toBeNull(); + }); + it("records the chain's publication and stamps the commit on the timeline", async () => { // ADR-0019: published_sha is the chain's expected-tip record — the // next follow-up's publish parents on it; the branch.pushed event diff --git a/packages/orchestration/src/engine/engine.ts b/packages/orchestration/src/engine/engine.ts index 75ca50e..7372b94 100644 --- a/packages/orchestration/src/engine/engine.ts +++ b/packages/orchestration/src/engine/engine.ts @@ -46,10 +46,12 @@ import { UsageLimitExceededError, UsageMeter, } from "@agrippa/executor-core"; -import { and, eq, gte, inArray, max, ne, notInArray, sql } from "drizzle-orm"; +import { and, desc, eq, gte, inArray, max, ne, notInArray, sql } from "drizzle-orm"; import { upgradeCompiledTemplate } from "../compile"; import { evaluateCondition, evaluateExpression, interpolate } from "../expression"; import { + FOLLOWUP_PUBLISH_CHECKPOINT_ID, + FOLLOWUP_PUBLISH_PHASE_ID, FOLLOWUP_STEER_STEP_ID, type FollowupSeed, followupSeed, @@ -313,7 +315,7 @@ export async function executeRun( // an ancestor's workspace, publishing included. Missing parentage costs the // inherited session (an honest fresh start); it must never cost the flow. const seed = run.kind === "followup" ? await followupSeed(db, run.parentRunId, base) : null; - const template = seed ? followupTemplate(base, seed) : base; + const template = seed ? followupTemplate(base, seed, { workBranch: run.workBranch }) : base; const [task] = await db.select().from(tasks).where(eq(tasks.id, run.taskId)); const [project] = await db.select().from(projects).where(eq(projects.id, run.projectId)); @@ -1040,6 +1042,11 @@ class RunEngine { loop: LoopNode | null, ): Promise<"done" | "waiting"> { const spec = step.checkpoint; + const tailSkip = await this.followupTailSkip(phase, step); + if (tailSkip) { + await this.markSkipped(phase, step, tailSkip); + return "done"; + } if (step.when && !evaluateCondition(step.when, this.expressionContext())) { await this.markSkipped(phase, step, "when_false"); return "done"; @@ -1203,6 +1210,11 @@ class RunEngine { const startAttempt = (this.stepRows.get(this.rowKey(step.id))?.attempt ?? 0) + 1; // conditional / requires gating + const tailSkip = await this.followupTailSkip(phase, step); + if (tailSkip) { + await this.markSkipped(phase, step, tailSkip); + return; + } if (step.when && !evaluateCondition(step.when, this.expressionContext())) { await this.markSkipped(phase, step, "when_false"); return; @@ -2176,6 +2188,87 @@ class RunEngine { row.error = error; } + /** Lazily computed once per engine leg — see {@link computeTailGuards}. */ + private tailGuardState?: Promise<{ skipTail: boolean; skipCheckpoint: boolean }>; + + /** + * The publish tail's guards (ADR-0019), keyed on the synthetic phase id so + * the template language never sees them. Returns the skip reason, or null + * to run the step. + */ + private async followupTailSkip( + phase: TemplatePhaseV2, + step: TemplateStepV2, + ): Promise { + if (this.run.kind !== "followup" || phase.id !== FOLLOWUP_PUBLISH_PHASE_ID) return null; + this.tailGuardState ??= this.computeTailGuards(); + const state = await this.tailGuardState; + if (state.skipTail) return "nothing_to_publish"; + if (step.id === FOLLOWUP_PUBLISH_CHECKPOINT_ID && state.skipCheckpoint) { + return "approved_bytes_carried_forward"; + } + return null; + } + + /** + * Approved and published are separate facts, each read from its own record + * (the review round that shaped ADR-0019 Decision 3 found the first draft + * conflating them). The WHOLE tail is skipped only when the steer's + * cumulative patch is empty — nothing to publish. The CHECKPOINT alone is + * skipped when the patch is byte-identical (store-time sha256) to bytes + * this chain already approved: at an earlier follow-up's publish + * checkpoint, or by publishing them (nothing publishes unapproved bytes). + * A base-flow approval that never reached a push is deliberately not + * counted — the conservative direction re-asks a human; it never pushes + * unapproved bytes. Push and pr.open always run when the tail runs: both + * are idempotent, so an approved-but-unpublished chain is completed by the + * next follow-up rather than skipped past. + */ + private async computeTailGuards(): Promise<{ skipTail: boolean; skipCheckpoint: boolean }> { + const patchKey = this.template.spec.outputs.artifacts.find((a) => a.kind === "patch")?.key; + if (!patchKey) return { skipTail: true, skipCheckpoint: false }; + const [own] = await this.db + .select({ sha256: artifacts.sha256 }) + .from(artifacts) + .where(and(eq(artifacts.runId, this.run.id), eq(artifacts.artifactKey, patchKey))) + .orderBy(desc(artifacts.iteration)) + .limit(1); + const emptyDigest = new Bun.CryptoHasher("sha256").update("").digest("hex"); + if (!own?.sha256 || own.sha256 === emptyDigest) { + return { skipTail: true, skipCheckpoint: false }; + } + + const chainRuns = await this.db + .select({ id: runs.id, publishedSha: runs.publishedSha }) + .from(runs) + .where(and(eq(runs.workspaceKey, this.run.workspaceKey), ne(runs.id, this.run.id))); + const approvedRunIds = new Set( + chainRuns.filter((r) => r.publishedSha !== null).map((r) => r.id), + ); + const chainIds = chainRuns.map((r) => r.id); + if (chainIds.length > 0) { + const approvedCheckpoints = await this.db + .select({ runId: checkpoints.runId }) + .from(checkpoints) + .where( + and( + inArray(checkpoints.runId, chainIds), + eq(checkpoints.checkpointId, FOLLOWUP_PUBLISH_CHECKPOINT_ID), + eq(checkpoints.status, "approved"), + ), + ); + for (const row of approvedCheckpoints) approvedRunIds.add(row.runId); + } + if (approvedRunIds.size === 0) return { skipTail: false, skipCheckpoint: false }; + const digests = await this.db + .select({ sha256: artifacts.sha256 }) + .from(artifacts) + .where( + and(inArray(artifacts.runId, [...approvedRunIds]), eq(artifacts.artifactKey, patchKey)), + ); + return { skipTail: false, skipCheckpoint: digests.some((d) => d.sha256 === own.sha256) }; + } + private async markSkipped( phase: TemplatePhaseV2, step: TemplateStepV2, diff --git a/packages/orchestration/src/followup.ts b/packages/orchestration/src/followup.ts index 99bee79..60bb6ea 100644 --- a/packages/orchestration/src/followup.ts +++ b/packages/orchestration/src/followup.ts @@ -9,8 +9,11 @@ import { flattenPhases } from "./template-schema"; * A follow-up does not re-enter the compiled template. Re-entry replays * answered checkpoints and reopens loops the run already closed, and the * obvious target templates end in `git.push` + `pr.open` — a steering message - * would republish. Nor is it a new template kind: that would make every - * template author responsible for a flow with exactly one correct shape. + * would republish UNGATED, behind approvals answered for different bytes. The + * synthetic flow instead carries its own publish tail (ADR-0019), always + * approval-gated on the cumulative patch. Nor is it a new template kind: that + * would make every template author responsible for a flow with exactly one + * correct shape. * * What executes is built in memory from the base template: the same agents, * models, resources and workspace, with a single agent step bound to the slot @@ -21,6 +24,9 @@ import { flattenPhases } from "./template-schema"; /** The synthetic phase and step ids, stable so the timeline and tests can name them. */ export const FOLLOWUP_PHASE_ID = "followup"; export const FOLLOWUP_STEER_STEP_ID = "steer"; +/** The publish tail (ADR-0019): its phase id is what the engine's guards key on. */ +export const FOLLOWUP_PUBLISH_PHASE_ID = "publish"; +export const FOLLOWUP_PUBLISH_CHECKPOINT_ID = "approve-publish"; /** * Token bound for one follow-up. A follow-up's meter starts at zero, so the @@ -169,14 +175,30 @@ export async function followupSeed( /** * The in-memory template a follow-up executes: everything the base declares - * about *what the agent is*, and a one-step flow for what it does now. + * about *what the agent is*, and a one-step flow for what it does now — plus, + * when the base flow publishes, a publish tail of its own (ADR-0019): an + * approval checkpoint presenting the CUMULATIVE patch, then the base's own + * `git.push` and `pr.open` steps verbatim. The checkpoint is unconditional by + * decision — the ancestor's approval covered the ancestor's bytes, and even a + * base flow that auto-published does not exempt its follow-ups — while the + * engine's guards (keyed on the phase id, never expressible in the template + * language) skip the whole tail for a steer that changed nothing to publish + * and carry a byte-identical approval forward instead of re-asking. * * The instructions here are engine-authored and free of template syntax; the * operator's message is appended by the engine AFTER interpolation, so a * message containing `${...}` is delivered rather than evaluated (ADR-0018 * Decision 6). */ -export function followupTemplate(base: CompiledTemplate, seed: FollowupSeed): CompiledTemplate { +export function followupTemplate( + base: CompiledTemplate, + seed: FollowupSeed, + opts: { + /** The chain's delivery branch (runs.work_branch, inherited) — without + * one there is nothing to advance, so no tail is appended. */ + workBranch: string | null; + }, +): CompiledTemplate { const produces = seed.patchArtifactKey && base.spec.workspace ? [seed.patchArtifactKey] : []; const steer: TemplateStepV2 = { id: FOLLOWUP_STEER_STEP_ID, @@ -195,6 +217,39 @@ export function followupTemplate(base: CompiledTemplate, seed: FollowupSeed): Co onFailure: "fail", }; + // The tail exists only when every part of the publication story does: the + // base flow pushes, the base declares a workspace, the seed found a patch + // key to present, and the chain has a branch to advance. The base's own + // push/pr steps ride along verbatim — their `with` config (custom PR + // titles, base branch) is author intent the synthetic flow must not lose. + const baseSteps = flattenPhases(base.spec.phases).flatMap(({ phase }) => phase.steps); + const pushStep = baseSteps.find((s) => s.kind === "system" && s.action === "git.push"); + const prStep = baseSteps.find((s) => s.kind === "system" && s.action === "pr.open"); + const publishPhases = + pushStep && base.spec.workspace && seed.patchArtifactKey && opts.workBranch + ? [ + { + id: FOLLOWUP_PUBLISH_PHASE_ID, + name: { en: "Publish", "zh-CN": "发布" }, + steps: [ + { + id: FOLLOWUP_PUBLISH_CHECKPOINT_ID, + kind: "checkpoint", + checkpoint: { + kind: "approval", + title: { en: "Approve the updated delivery", "zh-CN": "确认更新后的交付" }, + present: [seed.patchArtifactKey], + timeout: "24h", + onTimeout: "cancel", + }, + } satisfies TemplateStepV2, + { ...pushStep, when: undefined }, + ...(prStep ? [{ ...prStep, when: undefined }] : []), + ], + }, + ] + : []; + return { ...base, spec: { @@ -205,6 +260,7 @@ export function followupTemplate(base: CompiledTemplate, seed: FollowupSeed): Co name: { en: "Follow-up", "zh-CN": "追加" }, steps: [steer], }, + ...publishPhases, ], // Nothing is required of a follow-up's outputs: it may only answer a // question. The patch key stays declared so a change to the workspace is From 283841e8b1ee2cfff0c152815b5202f2b5ca815f Mon Sep 17 00:00:00 2001 From: John Hu Date: Sun, 16 Aug 2026 11:12:18 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(worker):=20a=20dead=20pin=20fails=20wo?= =?UTF-8?q?rkspace=5Flost=20=E2=80=94=20mechanically,=20with=20a=20notific?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0018 amendment, slice A3 — the dead-host sweeper, the honesty half of the per-host queue. A run parked on a host queue only its holder can serve waits forever if that host is gone; ADR-0018's rule is that a dead pin fails workspace_lost and is never re-routed (re-routing lands the agent in an empty directory and lets it report success). sweepDeadHostRuns (run-lifecycle, beside its sibling sweeps) fails central host-pinned runs when no live-and-ready heartbeat has advertised their host for five minutes AND the run itself has been parked that long — three states, each with its own parked-since marker: queued (queued_at), running with no lease (the crash-recovery re-enqueue only the holder could claim), and waiting_approval fully decided (a resume enqueued to a queue nobody polls). Finalization is the same CAS as every other path, so racing replicas fail each run exactly once; the worker stage syncs notifications like the offline-runtimes stage. A host back within grace drains its queue instead, and rolling deploys never trip it — the identity belongs to the storage. Docs: design 04's affinity paragraph rewritten around the shipped queue (the 'per-host queue is the real fix' promise resolves), design 08 notes the volume IS the host, manual 06 gains a host-affinity section in both locales. Sweeper tests neuter-verified. --- CHANGELOG.md | 2 +- apps/worker/src/index.ts | 11 +++ docs/design/04-execution-runtime.md | 2 +- docs/design/08-deployment.md | 2 +- docs/manual/en/06-operations.md | 7 ++ docs/manual/zh-CN/06-operations.md | 7 ++ docs/plan/m3-plan.md | 4 +- .../src/engine/engine.integration.test.ts | 62 +++++++++++++++ .../orchestration/src/engine/run-lifecycle.ts | 79 ++++++++++++++++++- 9 files changed, 169 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd56b9..846ea90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to Agrippa are documented here. The format follows ### Added - **A follow-up can publish what it was steered to produce** (ADR-0019). When the base flow delivers to a branch, a follow-up that changed the workspace pauses at its own approval presenting the full cumulative patch, then pushes exactly one deterministic commit on top of what the chain last published (an expected-tip CAS — `runs.published_sha` is the chain's record) and re-targets the same PR. A steer that changed nothing publishes nothing; a byte-identical patch carries its earlier approval forward instead of re-asking; and a branch that no longer matches the chain's record — a human push, a post-merge deletion — fails typed as `publish_conflict` with the remote untouched. -- **Central runs route to the host that holds their workspace** (the per-host queue, ADR-0018 amendment). Hosts are identified by their storage (`WORKSPACE_ROOT/.agrippa-host-id`); repo checkouts stamp `runs.workspace_host` first-writer-wins; follow-ups inherit it; producers send host-pinned jobs to `run.host.`, which only workers mounting that storage poll — follow-ups and resumes land where the directory lives instead of bouncing off claim-time declines, which remain as the deploy-skew fallback. +- **Central runs route to the host that holds their workspace** (the per-host queue, ADR-0018 amendment). Hosts are identified by their storage (`WORKSPACE_ROOT/.agrippa-host-id`); repo checkouts stamp `runs.workspace_host` first-writer-wins; follow-ups inherit it; producers send host-pinned jobs to `run.host.`, which only workers mounting that storage poll — follow-ups and resumes land where the directory lives instead of bouncing off claim-time declines, which remain as the deploy-skew fallback. A dead pin fails typed rather than circulating: runs parked on a host no live worker has advertised for five minutes fail `workspace_lost` with a notification (the `dead-host-runs` sweeper stage). ### Fixed diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index c0ab5ea..314214a 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -34,6 +34,7 @@ import { RedisEventBus, reconcileScheduleCalendar, type ScheduleFireOutcome, + sweepDeadHostRuns, sweepNotificationDeliveries, sweepOfflineRuntimes, sweepRunLeases, @@ -503,6 +504,16 @@ setInterval(async () => { // runs paused on an approval that has since been decided but whose resume // enqueue was lost (e.g. the API/worker died between the decision and the // send) — re-enqueue so the decision actually takes effect + // the dead-pin rule made mechanical (ADR-0018 amendment): a run parked on + // a host queue no live-and-ready worker serves fails workspace_lost, + // typed, instead of waiting forever on a directory that is gone + await stage("dead-host-runs", async () => { + for (const runId of await sweepDeadHostRuns(db)) { + deps.logger.warn(`run ${runId}: workspace host dead past grace — failed workspace_lost`); + await consumer.syncNotificationsBestEffort(runId); + } + }); + await stage("stranded-checkpoints", async () => { for (const runId of await findStrandedCheckpointRuns(db)) { await enqueueAfterCommit(() => queue.enqueueRun(runId), `stranded ${runId}`); diff --git a/docs/design/04-execution-runtime.md b/docs/design/04-execution-runtime.md index a1381df..53b2689 100644 --- a/docs/design/04-execution-runtime.md +++ b/docs/design/04-execution-runtime.md @@ -95,7 +95,7 @@ What executes is synthesized in memory (`followup.ts`), never the compiled flow: **Attach, or fail — remotely too.** Centrally the engine proves the workspace is present with `isIntact`; a daemon cannot be asked that (the server only knows the runtime is alive), so the dispatch carries `mustAttach` for a follow-up and the daemon fails `workspace_lost` rather than cloning the pinned base when the directory is gone. Cloning there would report success while dropping everything the ancestor did — the one failure this feature cannot tolerate, and the reason follow-ups are refused to daemons that do not advertise `workspace-key`. -**Central host affinity.** A remote follow-up inherits ADR-0017's runtime pin, so it lands where its workspace is. A *central* run carries no pin and workspaces are host-local, so on a multi-worker fleet a follow-up can be delivered to a worker that does not hold the directory. That worker **declines** before the claim (`workspace_on_another_host` → `run.deferred`, no status change, no retry burned) and the straggler sweep re-enqueues, giving another host a turn. Bounded at five minutes from `queued_at` — roughly ten attempts across the fleet — after which the engine stops declining and fails `workspace_lost`, because a directory nobody holds should say so rather than circulate. A per-host queue is the real fix and follows the same route-by-capability shape Phase B established. +**Central host affinity — the per-host queue** (ADR-0018 amendment). A remote follow-up inherits ADR-0017's runtime pin, so it lands where its workspace is. A *central* run pins to its **storage**: a uuid persisted at `WORKSPACE_ROOT/.agrippa-host-id` (minted once, shared by every replica mounting the volume, surviving redeploys), stamped onto `runs.workspace_host` first-writer-wins at repo checkout — with a DB-enforced `runtime_id IS NULL` guard — and inherited by follow-ups. The producer-side queue resolver (`dbRunQueueResolver`) routes any central run with a stamped host to `run.host.`, which only workers advertising that identity poll (`selectRunQueues` adds exactly its own), so follow-ups **and** resumes of initial runs land where the directory lives; every send path funnels through `enqueueRun`/`enqueueRunAfter`, so the routing has no per-call-site logic. The claim-time decline (`workspace_on_another_host` → `run.deferred`, five-minute grace from `queued_at`) survives as the deploy-skew fallback for jobs an old producer routed to a set queue. **A dead pin fails typed rather than circulating**: the `dead-host-runs` sweeper stage fails runs parked past a five-minute grace — `queued`, `running` with no lease, or `waiting_approval` fully decided — whose host no live-and-ready heartbeat (`worker_heartbeats.workspace_host`) has advertised for that long, as `workspace_lost` with a notification; a host back within the grace simply drains its queue, and a rolling deploy never trips it because the identity belongs to the volume. Scratch runs deliberately do not stamp a host this milestone (presence-attach with honest resume degradation, as shipped in M2). **Workspace retention (ADR-0018 Decision 4).** Deleting a workspace used to be a side effect of finalizing a run, which only works while a directory belongs to exactly one run. Finalize now **releases**: `finalizeRun` stamps `runs.workspace_expires_at` (`now() + AGRIPPA_WORKSPACE_RETENTION_MINUTES`, default **60**) inside the same CAS that writes the terminal status, and deletes nothing. Inside that transaction on purpose — every finalization path releases, not just the engine's: a run cancelled while queued or paused, and one failed by retry exhaustion, otherwise got no retention window at all, and a failed run's workspace is the one most worth keeping. Collection groups by `workspace_key` and requires that *every* run sharing the key has released it and the group's **latest** expiry has passed, so a follow-up extends its ancestor's workspace merely by existing and no writer has to reason about who else holds the directory. "Released" falls back to `finished_at`, which is what stops a run that finalized before retention existed — or lost its release write — from holding a directory nothing would ever collect. The server owns the policy; each host owns its own filesystem — the same split ADR-0017 made for credentials: diff --git a/docs/design/08-deployment.md b/docs/design/08-deployment.md index 69624d9..c5961cd 100644 --- a/docs/design/08-deployment.md +++ b/docs/design/08-deployment.md @@ -38,7 +38,7 @@ infra/docker-compose.yml | `ANTHROPIC_API_KEY` | Claude executor (worker only) | | `OPENAI_API_KEY` / `CODEX_API_KEY` | Codex executor's `openai` provider (worker only); both optional — a keyless worker still registers `codex-cli` and defers runs needing env auth it lacks | | `WORKER_SLOTS` | run concurrency per worker (default 2) | -| `WORKSPACE_ROOT` | workspaces volume, one directory per workspace key (default `/work/runs`) | +| `WORKSPACE_ROOT` | workspaces volume, one directory per workspace key (default `/work/runs`); its top-level `.agrippa-host-id` **is the host identity** central runs pin to (ADR-0018 amendment) — replicas sharing the volume are one host, and recreating the volume makes a new host, after which runs pinned to the old one fail `workspace_lost` once the five-minute dead-host grace passes | | `AGRIPPA_WORKSPACE_RETENTION_MINUTES` | how long a released workspace stays on disk — and therefore how long a finished run can be steered, since a follow-up attaches to that directory (default `60`). `0` restores immediate collection and gives up follow-ups; a live run in the chain holds its workspace regardless | | `AGRIPPA_FOLLOWUP_MAX_TOKENS` | per-follow-up token bound (default `200000`, capped by the template's own `maxTokens`) | | `AGRIPPA_DAEMON_WORKSPACE_TTL_DAYS` | daemon-side floor for deleting workspaces it was never told about (default `30`) | diff --git a/docs/manual/en/06-operations.md b/docs/manual/en/06-operations.md index f786ecb..6ad687a 100644 --- a/docs/manual/en/06-operations.md +++ b/docs/manual/en/06-operations.md @@ -95,6 +95,13 @@ Documented in `infra/env/.env.example`; the full set: Workers advertise the executors they can actually run on their per-container heartbeat row (`worker_heartbeats`, refreshed every 60 s); the API refuses a submission for an executor no live worker advertises — and one whose full executor set fits no single worker. `claude-agent-sdk` and `fake` always register. `codex-cli` registers only if a Codex CLI new enough for `codex exec --ignore-user-config` / `--ignore-rules` is on the worker's `PATH` — the worker image installs one at `/opt/codex` and its build fails if that check doesn't pass. +## Workspace host affinity + +A run's workspace lives on one host's storage, and follow-ups (plus resumes) of a repository run are routed back to it: the workspaces volume identifies itself with a `.agrippa-host-id` file, workers advertise that identity on their heartbeat, and pinned runs wait on that host's own queue. Two operational consequences: + +- **The workspaces volume is the host.** Replicas sharing it are one host; deleting or recreating the volume makes a *new* host, and runs pinned to the old one fail with `workspace_lost` about five minutes after its last heartbeat — with a notification, so nothing waits silently. A rolling redeploy that keeps the volume never trips this. +- **Decommissioning a worker host** fails its parked pinned runs the same way after the same grace. That is deliberate — a directory nobody holds should say so rather than circulate. Re-submit the affected tasks on the surviving fleet. + ## Remote runtime daemons (bring your own compute) A daemon on a team member's machine can execute agent work with that machine's own CLI logins — quotas, checkpoints, audit, and git publication stay on the platform (see [design/03](../../design/03-executor-abstraction.md) and ADR-0017). diff --git a/docs/manual/zh-CN/06-operations.md b/docs/manual/zh-CN/06-operations.md index 6d0dfb4..6b8d48c 100644 --- a/docs/manual/zh-CN/06-operations.md +++ b/docs/manual/zh-CN/06-operations.md @@ -105,6 +105,13 @@ docker compose -p agrippa exec -T postgres psql -U agrippa -d agrippa \ 即使执行器已注册,步骤解析到的服务商仍需要凭证。`openai` 与 `anthropic` 可用 worker 环境变量(如 `OPENAI_API_KEY`);`dashscope` 以及组织自行注册的自定义服务商**只能用项目凭证**。注意 `dashscope` 根本无法支撑 `codex-cli` 代理位——它在目录中只提供 `anthropic` 线路协议,因为 Codex ≥ 0.122 移除了百炼 OpenAI 兼容模式所用的 chat 线路 API。这类代理位请改指向提供 `openai` 协议的服务商,或改用 `claude-agent-sdk`。 +## 工作区主机亲和 + +执行的工作区存放在某一台主机的存储上,仓库执行的追加执行(以及恢复)会被路由回这台主机:工作区卷用一个 `.agrippa-host-id` 文件标识自身,工作节点在心跳中通告该标识,被固定的执行会在这台主机专属的队列上等待。由此带来两条运维影响: + +- **工作区卷就是主机。** 共享该卷的多个副本视为同一台主机;删除或重建该卷会产生一台*新*主机,固定在旧主机上的执行会在其最后一次心跳约五分钟后以 `workspace_lost` 失败——并发送通知,不会有执行静默等待。保留卷的滚动重新部署不会触发此机制。 +- **下线一台工作节点主机**会在同样的宽限期后以同样的方式让停靠在它上面的执行失败。这是有意为之——没有任何主机持有的目录应当明确说出来,而不是让执行无限流转。请在存续的节点上重新提交受影响的任务。 + ## 远程运行时守护进程(自带算力) 团队成员机器上的守护进程可以用该机器自己的 CLI 登录凭证执行智能体工作——配额、检查点、审计与 git 发布仍留在平台侧(见 [design/03](../../design/03-executor-abstraction.md) 与 ADR-0017)。 diff --git a/docs/plan/m3-plan.md b/docs/plan/m3-plan.md index d2c16a8..55d0eff 100644 --- a/docs/plan/m3-plan.md +++ b/docs/plan/m3-plan.md @@ -22,7 +22,7 @@ A follow-up produces a patch today but can never push — publication "remains t - [x] **ADR first**: [ADR-0019](../adr/0019-followup-publication-expected-tip-cas.md) + Amendment pointers appended in ADR-0012 and ADR-0018 (this plan's first commit) - [x] **P1** `applyApprovedPatch` learns `expectedTip`: parent selection (E if present, else base), branch-ref fetch for the parent object, no-op tree guard (tree == E's tree → return E, push nothing), `tip_conflict` on any other observed tip; real-git tests — create / advance / idempotent retry / conflict / no-op - [x] **P2** additive migration `runs.published_sha`, written by the engine in the `git.push` handler post-push (the `work_branch` pattern, crash-safe by determinism); `GitScmService` derives E from the `workspace_key` chain; `PushResult` gains `tip_conflict` and the engine maps it to `RunFailure("publish_conflict")`; `FakeScmService` learns to lie; `commitSha` on the `branch.pushed` event -- [ ] **P3** synthetic publish tail in `followupTemplate` — approval checkpoint presenting the cumulative patch (decision 2, `onTimeout: cancel`) → `git.push` → `pr.open` iff the base flow had one; engine-computed guards read *approved* and *published* separately: whole tail skipped only on an empty cumulative patch, checkpoint alone skipped on byte-identical re-approval, push/`pr.open` always run (idempotent) so an approved-but-unpublished chain completes delivery; i18n error strings; manual en+zh-CN 03; CHANGELOG +- [x] **P3** synthetic publish tail in `followupTemplate` — approval checkpoint presenting the cumulative patch (decision 2, `onTimeout: cancel`) → `git.push` → `pr.open` iff the base flow had one; engine-computed guards read *approved* and *published* separately: whole tail skipped only on an empty cumulative patch, checkpoint alone skipped on byte-identical re-approval, push/`pr.open` always run (idempotent) so an approved-but-unpublished chain completes delivery; i18n error strings; manual en+zh-CN 03; CHANGELOG - Verify: ☐ compliance suite through **both transports** — publish gated by approval, empty steers skip the tail, an unchanged steer on an approved-but-unpublished chain completes delivery without a new checkpoint, conflict fails typed, chained follow-ups advance E twice; ☐ live smoke on the deployed stack: steer a finished repo run → approve the presented patch → the branch/PR advances by exactly one deterministic commit; a push retry is a no-op; a manual push to the branch followed by a second steer fails `publish_conflict` typed ### Track A — central host affinity (the per-host queue) @@ -31,7 +31,7 @@ ADR-0018 named the per-host queue as the real fix for "declines pre-claim for fi - [x] **A1** host-id helper in `@agrippa/workspace` (uuid persisted at `WORKSPACE_ROOT/.agrippa-host-id`); additive migration `runs.workspace_host` (stamped first-writer-wins at checkout, inherited by follow-up inserts, never for remote runs) + `worker_heartbeats.workspace_host`; heartbeat advertisement plumbing - [x] **A2** `runHostQueueName` (`run.host.`) in core; the queue resolver routes `runtime_id IS NULL AND workspace_host IS NOT NULL` to the host queue (every enqueue path already funnels through `enqueueRun`/`enqueueRunAfter`, so follow-ups **and** initial-run resumes — decision 4 — become host-bound with no call-site changes); worker polls its own host queue via `computeRunQueues`; the 5-min `WorkspaceElsewhereError` decline stays as the deploy-skew fallback -- [ ] **A3** `dead-host-runs` sweeper stage: host unheartbeated > 5 min ∧ run waited > 5 min → `finalizeRun("workspace_lost")` + notification sync (ADR-0018's "a dead pin is never re-routed", made mechanical); design 04 + 08 (volume-identity note); manual en+zh-CN 06; CHANGELOG +- [x] **A3** `dead-host-runs` sweeper stage: host unheartbeated > 5 min ∧ run waited > 5 min → `finalizeRun("workspace_lost")` + notification sync (ADR-0018's "a dead pin is never re-routed", made mechanical); design 04 + 08 (volume-identity note); manual en+zh-CN 06; CHANGELOG - Verify: ☐ two-root fleet integration test — a follow-up is claimed only by the workspace holder, zero bounces; the skew fallback still declines when an old producer sends to a set queue; a dead host's parked run fails typed at the (test-shortened) deadline ### Ride-along diff --git a/packages/orchestration/src/engine/engine.integration.test.ts b/packages/orchestration/src/engine/engine.integration.test.ts index 1942d9c..4c2207b 100644 --- a/packages/orchestration/src/engine/engine.integration.test.ts +++ b/packages/orchestration/src/engine/engine.integration.test.ts @@ -23,6 +23,7 @@ import { templateVersions, tokenUsage, users, + workerHeartbeats, } from "@agrippa/db"; import { type Executor, @@ -63,6 +64,7 @@ import { findStrandedCheckpointRuns, releaseRunLease, renewRunLeases, + sweepDeadHostRuns, sweepRunLeases, transitionRun, } from "./run-lifecycle"; @@ -1503,6 +1505,66 @@ describe.skipIf(!dbUp)("run-lifecycle module", () => { expect(await findStrandedCheckpointRuns(db)).toContain(runId); }); + it("sweepDeadHostRuns fails only parked runs whose host is silent past grace", async () => { + const { db, runId } = await setupFixture(); + await db + .update(runs) + .set({ workspaceHost: "host-dying", queuedAt: sql`now() - interval '10 minutes'` }) + .where(eq(runs.id, runId)); + + // a live-and-ready worker advertising the host keeps the run waiting + await db.insert(workerHeartbeats).values({ + containerId: "w-live", + workspaceHost: "host-dying", + consumersReadyAt: sql`now()`, + heartbeatAt: sql`now()`, + }); + expect(await sweepDeadHostRuns(db)).not.toContain(runId); + + // the host goes silent — but a freshly parked run still gets its grace + await db + .update(workerHeartbeats) + .set({ heartbeatAt: sql`now() - interval '10 minutes'` }) + .where(eq(workerHeartbeats.containerId, "w-live")); + await db.update(runs).set({ queuedAt: sql`now()` }).where(eq(runs.id, runId)); + expect(await sweepDeadHostRuns(db)).not.toContain(runId); + + // silent host + parked past grace → the dead-pin rule, typed + await db + .update(runs) + .set({ queuedAt: sql`now() - interval '10 minutes'` }) + .where(eq(runs.id, runId)); + expect(await sweepDeadHostRuns(db)).toContain(runId); + const [row] = await db.select().from(runs).where(eq(runs.id, runId)); + expect(row?.status).toBe("failed"); + expect((row?.error as { code: string } | null)?.code).toBe("workspace_lost"); + const events = await db.select().from(runEvents).where(eq(runEvents.runId, runId)); + expect(events.some((e) => e.type === "run.failed")).toBe(true); + }); + + it("sweepDeadHostRuns ignores unpinned runs and catches a leaseless running one", async () => { + const { db, runId } = await setupFixture(); + // unpinned: not a candidate however long it queues + await db + .update(runs) + .set({ queuedAt: sql`now() - interval '10 minutes'` }) + .where(eq(runs.id, runId)); + expect(await sweepDeadHostRuns(db)).not.toContain(runId); + + // running with no lease on a silent host: the crash-recovery re-enqueue + // only the holder could ever claim + await db + .update(runs) + .set({ + workspaceHost: "host-gone", + status: "running", + leaseOwner: null, + leaseExpiresAt: sql`now() - interval '10 minutes'`, + }) + .where(eq(runs.id, runId)); + expect(await sweepDeadHostRuns(db)).toContain(runId); + }); + it("finalizeRun lets a late cancel win over a success (atomic, no read/CAS gap)", async () => { const { db, runId } = await setupFixture(); await transitionRun(db, runId, "queued", "running"); diff --git a/packages/orchestration/src/engine/run-lifecycle.ts b/packages/orchestration/src/engine/run-lifecycle.ts index 92f3a6e..4fc4ef4 100644 --- a/packages/orchestration/src/engine/run-lifecycle.ts +++ b/packages/orchestration/src/engine/run-lifecycle.ts @@ -1,6 +1,6 @@ import { type CheckpointStoredResponse, canTransitionRun, type RunStatus } from "@agrippa/core"; -import { checkpoints, type Db, type DbOrTx, runEvents, runs } from "@agrippa/db"; -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { checkpoints, type Db, type DbOrTx, runEvents, runs, workerHeartbeats } from "@agrippa/db"; +import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm"; import { workspaceExpiryAt } from "../workspace-retention"; /** @@ -116,6 +116,81 @@ export async function findStrandedCheckpointRuns(db: DbOrTx): Promise return rows.map((r) => r.id); } +/** How long a host must be silent, and a run parked, before the dead-pin rule fires. */ +export const DEAD_HOST_GRACE_MS = 5 * 60_000; + +/** + * ADR-0018's "a dead pin fails `workspace_lost` and is never re-routed", made + * mechanical for the per-host queue. A central run pinned to a workspace host + * waits on a queue only that host's workers poll; when no live-and-ready + * heartbeat has advertised the host for the grace window AND the run itself + * has been parked at least as long, honesty beats hope: it finalizes failed, + * typed. A host back within the grace simply drains its queue and the runs + * proceed — and a rolling deploy never trips this, because the identity + * belongs to the storage and survives the container. + * + * Covered states, each with its own parked-since marker: + * - `queued` (queued_at): a follow-up or re-enqueued resume nobody can claim; + * - `running` with no lease and a stale-or-cleared expiry: the host died + * mid-run, the lease sweeper re-enqueued it, and only the holder could + * ever pick it back up; + * - `waiting_approval` with every checkpoint decided (grace past the + * decision): the resume enqueue went to a queue nobody polls, so the + * decision would never take effect. + * + * Finalization is the same CAS every other path uses, so replicas racing + * this sweep fail each run exactly once; like the worker's retry-exhaustion + * path, usage rows stay the source of truth for accounting. + */ +export async function sweepDeadHostRuns( + db: Db, + graceMs: number = DEAD_HOST_GRACE_MS, +): Promise { + const grace = sql`${Math.round(graceMs / 1000)} * interval '1 second'`; + const rows = await db + .select({ id: runs.id, status: runs.status }) + .from(runs) + .where( + and( + isNull(runs.runtimeId), + isNotNull(runs.workspaceHost), + sql`not exists (select 1 from ${workerHeartbeats} + where ${workerHeartbeats.workspaceHost} = ${runs.workspaceHost} + and ${workerHeartbeats.consumersReadyAt} is not null + and ${workerHeartbeats.heartbeatAt} > now() - ${grace})`, + sql`( + (${runs.status} = 'queued' and ${runs.queuedAt} < now() - ${grace}) + or (${runs.status} = 'running' and ${runs.leaseOwner} is null + and coalesce(${runs.leaseExpiresAt}, ${runs.queuedAt}) < now() - ${grace}) + or (${runs.status} = 'waiting_approval' + and not exists (select 1 from ${checkpoints} + where ${checkpoints.runId} = ${runs.id} + and ${checkpoints.status} = 'pending') + and exists (select 1 from ${checkpoints} + where ${checkpoints.runId} = ${runs.id} + and ${checkpoints.decidedAt} < now() - ${grace})) + )`, + ), + ); + const failed: string[] = []; + for (const row of rows) { + const result = await finalizeRun(db, { + runId: row.id, + from: row.status as RunStatus, + to: "failed", + error: { + code: "workspace_lost", + message: + "the host holding this run's workspace has stopped heartbeating — a dead pin is never re-routed", + }, + usageTotals: {}, + eventPayload: { workspaceHost: "dead" }, + }); + if (result.outcome === "finalized") failed.push(row.id); + } + return failed; +} + /** Default execution-lease TTL: survives two missed 30s renewals. */ export const RUN_LEASE_TTL_MS = 90_000; From 56a8a4b9b552fac5c7b499e4308e10c58842bc87 Mon Sep 17 00:00:00 2001 From: John Hu Date: Sun, 16 Aug 2026 22:07:32 +0800 Subject: [PATCH 6/7] fix(engine): the tail guard reads the newest patch row, not a tied stale attempt's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review on PR #25, two findings, both verified valid: 1. (Major) computeTailGuards ordered the run's own patch lookup by iteration alone — but a retried steer plain-INSERTS a second row with the same key and the same iteration, so the order ties and Postgres may return the stale attempt's digest (reproduced: the regression test fails deterministically without the fix, both transports). A stale digest deciding publication could suppress the approval checkpoint for bytes a human never saw, or push an empty patch. createdAt is now the tiebreaker — the same latest-row-wins rule priorArtifacts already applies, so the guard and the evidence machinery agree on which row is canonical. 2. (Minor) design 04's steering paragraph still ended with the superseded 'publication is out of scope … publishing stays the ancestor's business' — the P3 docs pass rewrote the affinity paragraph and missed this one four lines up. It now describes the approval-gated tail and the expected-tip CAS. --- docs/design/04-execution-runtime.md | 2 +- .../src/engine/engine.integration.test.ts | 28 +++++++++++++++++++ packages/orchestration/src/engine/engine.ts | 7 ++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/design/04-execution-runtime.md b/docs/design/04-execution-runtime.md index 53b2689..3dc5c50 100644 --- a/docs/design/04-execution-runtime.md +++ b/docs/design/04-execution-runtime.md @@ -89,7 +89,7 @@ finalize: usage_totals, workspace cleanup, terminal event **Follow-up steering (ADR-0018).** A finished run can be continued rather than resubmitted. The follow-up is a **new run** (`kind = 'followup'`, `parent_run_id` set) — terminal stays absorbing — that inherits its predecessor's `workspace_key`, `runtime_id`, template version, params snapshot, bindings, model resolution, resource manifest and work branch **verbatim**. That is a deliberate divergence from retry (ADR-0014 re-resolves): the inherited session id was minted by one executor against one model, and the workspace on disk contains what *that* run's manifest materialized, so re-resolution could hand a Claude session to Codex or authorize skills the directory does not have. The corollary is worth telling operators plainly: **a follow-up cannot heal a configuration failure — retry is the tool for that.** -What executes is synthesized in memory (`followup.ts`), never the compiled flow: re-entry would replay answered checkpoints, reopen closed loops, and reach `git.push` again. The synthetic template keeps the base's agents, models, resources and workspace, and replaces the phases with one `steer` step bound to the slot and model role the parent's **last agent step** used, seeded with that step's `executor_session_id`. Attaching is asserted rather than executed: the engine's existing pre-flight `isIntact` check gains the follow-up case and fails `workspace_lost` before invoking anything, because an agent working in an empty directory would report success. The steer step carries the parent step's declared subagents, skills and MCP servers — the run's `resource_manifest` still bounds what is *permitted*, so this widens what is requested and never what is allowed. Nothing is *required* of the outputs (a follow-up may only answer a question), the declared patch key is still re-diffed as evidence, and the token limit is bounded independently by `AGRIPPA_FOLLOWUP_MAX_TOKENS` (default 200k, capped by the base's own limit) — the base run's budget is sized for a whole flow and the follow-up's meter starts at zero. The operator's message is appended to the step's instructions **after** interpolation, so a message containing `${...}` is delivered verbatim rather than evaluated. Publication is out of scope for now (ADR-0012's push refuses to advance a tip it did not create), so a follow-up produces a patch and a diff while publishing stays the ancestor's business. +What executes is synthesized in memory (`followup.ts`), never the compiled flow: re-entry would replay answered checkpoints, reopen closed loops, and reach `git.push` again. The synthetic template keeps the base's agents, models, resources and workspace, and replaces the phases with one `steer` step bound to the slot and model role the parent's **last agent step** used, seeded with that step's `executor_session_id`. Attaching is asserted rather than executed: the engine's existing pre-flight `isIntact` check gains the follow-up case and fails `workspace_lost` before invoking anything, because an agent working in an empty directory would report success. The steer step carries the parent step's declared subagents, skills and MCP servers — the run's `resource_manifest` still bounds what is *permitted*, so this widens what is requested and never what is allowed. Nothing is *required* of the outputs (a follow-up may only answer a question), the declared patch key is still re-diffed as evidence, and the token limit is bounded independently by `AGRIPPA_FOLLOWUP_MAX_TOKENS` (default 200k, capped by the base's own limit) — the base run's budget is sized for a whole flow and the follow-up's meter starts at zero. The operator's message is appended to the step's instructions **after** interpolation, so a message containing `${...}` is delivered verbatim rather than evaluated. A follow-up that changed the workspace **publishes through its own approval-gated tail** (ADR-0019): the synthetic flow appends the base's `git.push`/`pr.open` steps behind a checkpoint presenting the *cumulative* patch; engine-side guards (keyed on the synthetic phase id, inexpressible in the template language) skip the whole tail for a steer with nothing to publish and carry a byte-identical approval forward instead of re-asking; and the push is an expected-tip CAS on what the chain last published (`runs.published_sha`) — any other observed tip fails typed as `publish_conflict`, with the remote never overwritten. **One live run per workspace.** A message arriving while a follow-up runs becomes the next link in the chain, which is only safe if the next link waits: jobs and leases are keyed by run id and a worker has several slots, so two links would otherwise edit one directory at once and each report success. The engine probes before the claim and **declines** (`workspace_busy` → `run.deferred`, no retries burned) while any non-terminal run shares the `workspace_key`. No grace window, unlike the affinity decline below — the blocker finishing is what releases it, and a blocker that wedges is the lease sweeper's problem. The next link also parents to the **active** one rather than to the run the reader was looking at, so it continues the conversation actually in flight. diff --git a/packages/orchestration/src/engine/engine.integration.test.ts b/packages/orchestration/src/engine/engine.integration.test.ts index 4c2207b..c3f706b 100644 --- a/packages/orchestration/src/engine/engine.integration.test.ts +++ b/packages/orchestration/src/engine/engine.integration.test.ts @@ -2902,6 +2902,34 @@ for (const transport of TRANSPORTS) { ).toHaveLength(0); }); + it("the tail guard reads the run's newest patch row, never a stale attempt's", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await publishParent(fx); + + // a retried steer plain-INSERTS a second patch row with the same key + // and the same iteration; ordering by iteration alone ties and lets + // Postgres hand publication a stale attempt's digest. The stale row + // here carries the empty digest — picked, it would skip the whole + // tail and publish nothing. + const f1 = await v2FollowupOf(fx, "just answer a question about it"); + await fx.db.insert(artifacts).values({ + runId: f1, + artifactKey: "changes", + iteration: 1, + kind: "patch", + name: "changes", + inline: "", + sha256: new Bun.CryptoHasher("sha256").update("").digest("hex"), + createdAt: new Date(Date.now() - 3600_000), + }); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("succeeded"); + const steps = await fx.db.select().from(runSteps).where(eq(runSteps.runId, f1)); + const byId = new Map(steps.map((s) => [s.stepId, s.status])); + // the NEWEST row (the steer's real, approved-matching patch) decided + expect(byId.get("push")).toBe("succeeded"); + expect(byId.get("approve-publish")).toBe("skipped"); + }); + it("a steer that changed nothing to publish skips the tail entirely", async () => { const fx = await setupV2Fixture(); const { impl, rev } = await publishParent(fx); diff --git a/packages/orchestration/src/engine/engine.ts b/packages/orchestration/src/engine/engine.ts index 7372b94..a4d44b5 100644 --- a/packages/orchestration/src/engine/engine.ts +++ b/packages/orchestration/src/engine/engine.ts @@ -2231,7 +2231,12 @@ class RunEngine { .select({ sha256: artifacts.sha256 }) .from(artifacts) .where(and(eq(artifacts.runId, this.run.id), eq(artifacts.artifactKey, patchKey))) - .orderBy(desc(artifacts.iteration)) + // createdAt tiebreaker: a retried steer INSERTS a second row with the + // same key and iteration, and the newest is what the rest of the engine + // treats as canonical (priorArtifacts loads last-write-wins) — a stale + // attempt's digest deciding publication could suppress the approval for + // bytes a human never saw + .orderBy(desc(artifacts.iteration), desc(artifacts.createdAt)) .limit(1); const emptyDigest = new Bun.CryptoHasher("sha256").update("").digest("hex"); if (!own?.sha256 || own.sha256 === emptyDigest) { From eb4526164bfdff4fe988f92ab8782578161f4f60 Mon Sep 17 00:00:00 2001 From: John Hu Date: Sun, 16 Aug 2026 22:36:52 +0800 Subject: [PATCH 7/7] fix(engine): approval binds to exact presented bytes; a pinned run declines everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 4 on PR #25, four findings, all verified valid: 1. (P1) The carry-forward treated every published run as approved and every patch row of an approved run as approved bytes — an auto-published base flow could exempt its follow-ups from the gate, and an earlier loop iteration's never-presented patch counted. The publish checkpoint now RECORDS the digest it presents (payload.patchSha256), and the carry-forward matches only those records. Publication is deliberately not approval; the first unchanged steer after any base flow re-asks once, and the approval then carries. Compliance test rewritten as the two-stage walk. 2. (P1) pg-boss's retry of a crashed job bypasses the enqueue-side resolver, so a host-pinned run could be redelivered to the wrong host and fail a false workspace_lost while the holder was healthy. The engine now declines any central run pinned to another host's storage — unconditionally, before the claim (EngineDeps grows the worker's storage identity); the consumer completes such declines for crashed running runs too, and the lease sweeper's re-enqueue routes them through the resolver onto the host queue. A dead host stays the dead-host sweeper's terminal decision. 3. (P2) The dead-host grace accepted ANY decision older than the window — a run whose newest checkpoint was approved seconds ago could fail immediately. The grace now measures from max(decided_at). 4. (P2) A recovery PR (ancestor pushed, PR creation failed) composed from the follow-up's empty context: body interpolation lost ancestor artifacts and the waiver section lost the ancestors' accepted findings. Follow-up runs now backfill chain artifact VALUES and decided-checkpoint responses (produced/digest state stays their own), and composePrBody reads review gates across the chain, chronologically, so the last human decision per finding still wins. All four neuter-verified (each disabled in turn; seven test failures across both transports, zero passes). --- apps/worker/src/consumer.ts | 11 +- apps/worker/src/index.ts | 2 + packages/orchestration/src/engine/deps.ts | 8 + .../src/engine/engine.integration.test.ts | 140 +++++++++++-- packages/orchestration/src/engine/engine.ts | 189 ++++++++++++------ .../orchestration/src/engine/run-lifecycle.ts | 5 +- 6 files changed, 281 insertions(+), 74 deletions(-) diff --git a/apps/worker/src/consumer.ts b/apps/worker/src/consumer.ts index 3ce90e6..dc17e01 100644 --- a/apps/worker/src/consumer.ts +++ b/apps/worker/src/consumer.ts @@ -215,7 +215,16 @@ export function createRunConsumer(db: Db, deps: EngineDeps, queue: BossQueue): R err instanceof WorkspaceBusyError ) { const [run] = await db.select({ status: runs.status }).from(runs).where(eq(runs.id, runId)); - if (run && (run.status === "queued" || run.status === "waiting_approval")) { + if ( + run && + (run.status === "queued" || + run.status === "waiting_approval" || + // a crashed run's pg-boss retry delivered to the wrong host: the + // pinned-elsewhere decline completes the job, and the LEASE + // sweeper re-enqueues the leaseless running run through the + // resolver — onto the host queue this delivery bypassed + (run.status === "running" && err instanceof WorkspaceElsewhereError)) + ) { deps.logger.warn(`run ${runId}: declining — ${String((err as Error).message)}`); // one timeline event so the SPA can show WHY the run is waiting await appendRunEvent(db, { diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 314214a..24e3f1a 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -154,6 +154,8 @@ const deps: EngineDeps = { }, // execution-lease identity: claims, renewals, and releases all key on it lease: { owner: containerId }, + // claim-side half of the per-host queue: a run pinned elsewhere declines + workspaceHost, }; const SLOTS = Number(process.env.WORKER_SLOTS ?? 2); diff --git a/packages/orchestration/src/engine/deps.ts b/packages/orchestration/src/engine/deps.ts index 9f82013..510b794 100644 --- a/packages/orchestration/src/engine/deps.ts +++ b/packages/orchestration/src/engine/deps.ts @@ -184,6 +184,14 @@ export type EngineDeps = { * renewal is unavailable. */ lease?: { owner: string; ttlMs?: number }; + /** + * This worker's workspace-storage identity (ADR-0018 amendment — the + * per-host queue). A central run pinned to another host's storage is + * declined before the claim: pg-boss's own retry of a crashed job bypasses + * the enqueue-side resolver, so the pin must also hold here. Absent (tests, + * daemons via RemoteExecutor deps), the guard is off. + */ + workspaceHost?: string | null; }; export type RunOutcome = diff --git a/packages/orchestration/src/engine/engine.integration.test.ts b/packages/orchestration/src/engine/engine.integration.test.ts index c3f706b..e733624 100644 --- a/packages/orchestration/src/engine/engine.integration.test.ts +++ b/packages/orchestration/src/engine/engine.integration.test.ts @@ -1109,6 +1109,27 @@ for (const transport of TRANSPORTS) { expect(deps.executor.requests.filter((r) => r.stepId === "steer")).toHaveLength(0); }); + it("a run pinned to another host's storage declines — even a crashed initial run", async () => { + // pg-boss's own retry of a crashed job bypasses the enqueue-side + // resolver, so the wrong host can still be handed a pinned run — + // failing it there would be a false workspace_lost while the holder + // is healthy (codex round 4) + const { db, runId, makeDeps } = await setupFixture(); + await db + .update(runs) + .set({ workspaceHost: "host-a", status: "running", startedAt: new Date() }) + .where(eq(runs.id, runId)); + + const away = { ...makeDeps(HAPPY_SCRIPT), workspaceHost: "host-b" }; + await expect(executeRun(away, runId)).rejects.toThrow("not on this host"); + const [row] = await db.select().from(runs).where(eq(runs.id, runId)); + expect(row?.status).toBe("running"); // untouched: no claim, no false terminal + + // on ITS host the pin is satisfied and the run proceeds normally + const home = { ...makeDeps(HAPPY_SCRIPT), workspaceHost: "host-a" }; + expect(await executeRun(home, runId)).toBe("waiting_approval"); + }); + it("a follow-up whose workspace is gone for good fails workspace_lost", async () => { const { db, runId, makeDeps, workspace } = await setupFixture(); await runToSuccess(db, runId, makeDeps); @@ -1542,6 +1563,37 @@ describe.skipIf(!dbUp)("run-lifecycle module", () => { expect(events.some((e) => e.type === "run.failed")).toBe(true); }); + it("sweepDeadHostRuns measures the grace from the NEWEST decision", async () => { + const { db, runId } = await setupFixture(); + await db + .update(runs) + .set({ workspaceHost: "host-quiet", status: "waiting_approval" }) + .where(eq(runs.id, runId)); + // an hour-old decision plus a seconds-old one: the run became actionable + // just now, so the dead-pin grace has not elapsed for it (codex round 4 — + // any-old-decision satisfied the first draft's EXISTS) + await db.insert(checkpoints).values({ + runId, + checkpointId: "cp-old", + status: "approved", + decidedAt: new Date(Date.now() - 3600_000), + }); + await db.insert(checkpoints).values({ + runId, + checkpointId: "cp-new", + status: "approved", + decidedAt: new Date(), + }); + expect(await sweepDeadHostRuns(db)).not.toContain(runId); + + // once the NEWEST decision ages past the grace, the dead pin fails + await db + .update(checkpoints) + .set({ decidedAt: new Date(Date.now() - 600_000) }) + .where(and(eq(checkpoints.runId, runId), eq(checkpoints.checkpointId, "cp-new"))); + expect(await sweepDeadHostRuns(db)).toContain(runId); + }); + it("sweepDeadHostRuns ignores unpinned runs and catches a leaseless running one", async () => { const { db, runId } = await setupFixture(); // unpinned: not a candidate however long it queues @@ -2880,25 +2932,47 @@ for (const transport of TRANSPORTS) { expect(f2Row?.publishedSha).toBe("fake-3"); }); - it("an unchanged steer carries the approval forward: push runs, nobody is re-asked", async () => { + it("approval carry-forward binds to the EXACT bytes a publish checkpoint approved", async () => { const fx = await setupV2Fixture(); const { impl, rev } = await publishParent(fx); - // byte-identical to what the parent already published (and had - // approved) — the checkpoint is skipped, the idempotent push still - // runs, and the whole follow-up completes in one leg + // Publication is NOT approval: the parent's bytes rode out through a + // skipped gate (outcome pass auto-publishes), so the FIRST unchanged + // steer must still present its checkpoint — codex round 4 caught the + // first draft treating every published run's artifacts as approved. const f1 = await v2FollowupOf(fx, "just answer a question about it"); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("waiting_approval"); + const [pending] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f1), eq(checkpoints.status, "pending"))); + expect(pending?.checkpointId).toBe("approve-publish"); + // the presented digest is recorded ON the checkpoint — the record a + // later identical steer proves its carry-forward against + expect((pending?.payload as { patchSha256?: string })?.patchSha256).toMatch( + /^[0-9a-f]{64}$/, + ); + await decideCheckpoint(fx.db, pending?.id as string, { + status: "approved", + decidedBy: fx.userId, + response: { kind: "approval", outcome: "approved" }, + }); expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("succeeded"); + + // NOW the bytes carry a real approval: a second identical steer + // completes in one leg, checkpoint skipped, idempotent push still run + const f2 = await v2FollowupOf(fx, "one more question"); + expect(await executeRun(fx.makeDeps(impl, rev), f2)).toBe("succeeded"); const steps = await fx.db .select() .from(runSteps) - .where(eq(runSteps.runId, f1)) + .where(eq(runSteps.runId, f2)) .orderBy(asc(runSteps.seq)); const byId = new Map(steps.map((s) => [s.stepId, s.status])); expect(byId.get("approve-publish")).toBe("skipped"); expect(byId.get("push")).toBe("succeeded"); expect( - await fx.db.select().from(checkpoints).where(eq(checkpoints.runId, f1)), + await fx.db.select().from(checkpoints).where(eq(checkpoints.runId, f2)), ).toHaveLength(0); }); @@ -2922,12 +2996,54 @@ for (const transport of TRANSPORTS) { sha256: new Bun.CryptoHasher("sha256").update("").digest("hex"), createdAt: new Date(Date.now() - 3600_000), }); - expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("succeeded"); - const steps = await fx.db.select().from(runSteps).where(eq(runSteps.runId, f1)); - const byId = new Map(steps.map((s) => [s.stepId, s.status])); - // the NEWEST row (the steer's real, approved-matching patch) decided - expect(byId.get("push")).toBe("succeeded"); - expect(byId.get("approve-publish")).toBe("skipped"); + // the NEWEST row (the steer's real patch) decided: the tail RUNS and + // pauses at its approval — the stale empty digest would have skipped + // the whole tail and reported success without ever presenting one + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("waiting_approval"); + const [pending] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f1), eq(checkpoints.status, "pending"))); + expect(pending?.checkpointId).toBe("approve-publish"); + }); + + it("a follow-up recovers the ancestor's failed PR with the chain's full story", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await walkBigPatchToDecidedGate(fx); + // the publish leg pushes, then PR creation dies for good — the run + // fails AFTER recording its publication (exactly one attempt: the + // fixture's open-pr declares no retry) + fx.scm.failNext = { pr: 1 }; + expect(await executeRun(fx.makeDeps(impl, rev), fx.runId)).toBe("failed"); + const [parent] = await fx.db.select().from(runs).where(eq(runs.id, fx.runId)); + expect(parent?.publishedSha).toBe("fake-1"); + expect(fx.scm.pullRequests).toHaveLength(0); + + // an unchanged steer completes the delivery: approval, idempotent + // push, and a recovery PR carrying the ANCESTOR's accepted findings — + // its gate rows live on the ancestor run, and the body interpolates + // from the chain's context, not the follow-up's empty one + const script = { + ...impl, + steer: { kind: "succeed", output: "steered" } as FakeStepBehavior, + }; + const f1 = await v2FollowupOf(fx, "please finish the delivery"); + expect(await executeRun(fx.makeDeps(script, rev), f1)).toBe("waiting_approval"); + const [pending] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f1), eq(checkpoints.status, "pending"))); + await decideCheckpoint(fx.db, pending?.id as string, { + status: "approved", + decidedBy: fx.userId, + response: { kind: "approval", outcome: "approved" }, + }); + expect(await executeRun(fx.makeDeps(script, rev), f1)).toBe("succeeded"); + expect(fx.scm.pullRequests).toHaveLength(1); + const pr = fx.scm.pullRequests[0]?.spec; + expect(pr?.body).toContain("Delivered:"); + expect(pr?.body).toContain("## Accepted review findings"); + expect(pr?.body).toContain("Unhandled null"); }); it("a steer that changed nothing to publish skips the tail entirely", async () => { diff --git a/packages/orchestration/src/engine/engine.ts b/packages/orchestration/src/engine/engine.ts index a4d44b5..568aee6 100644 --- a/packages/orchestration/src/engine/engine.ts +++ b/packages/orchestration/src/engine/engine.ts @@ -207,16 +207,18 @@ export class WorkspaceBusyError extends Error { } /** - * Raised when a follow-up's workspace is not on THIS host and might still be - * on another (ADR-0018 Consequences: central host affinity). + * Raised when a central run's workspace is not on THIS host and might still + * be on another (ADR-0018: central host affinity). * - * Remote runs are pinned, so affinity is free for them. A central run has no - * pin, and workspaces are host-local — so on a multi-worker fleet the run can - * land on a worker that does not hold the directory. Declining lets the - * sweeper re-enqueue and another worker try, which is bounded on purpose: past - * the grace window the honest answer is that nobody has it, and the engine - * fails `workspace_lost` rather than ping-ponging the run forever. The real - * fix is a per-host queue, the same route-by-capability shape Phase B took. + * Two cases share the decline. A run PINNED to another host's storage + * (`runs.workspace_host`, the per-host queue) declines unconditionally — + * pg-boss's own retry of a crashed job bypasses the enqueue-side resolver, + * so the wrong host can still be handed the job, and failing it there would + * be a false `workspace_lost` while the holder is healthy; the sweeps + * re-enqueue through the resolver onto the host queue, and a DEAD host is + * the dead-host sweeper's terminal decision. An UNPINNED follow-up (skew: a + * chain from before the pin existed) declines within a bounded grace, past + * which the engine fails `workspace_lost` rather than ping-ponging forever. */ export class WorkspaceElsewhereError extends Error { readonly code = "workspace_on_another_host"; @@ -399,15 +401,29 @@ export async function executeRun( if (holder.length > 0) throw new WorkspaceBusyError(run.id, holder[0]?.id as string); } - // Central host affinity (ADR-0018). A follow-up continues a directory that - // exists on exactly one host; remote runs carry a pin, central ones do not. - // Probed BEFORE the claim so a decline costs nothing — no status change, no - // retry burned — and bounded, so a workspace nobody holds becomes an honest - // `workspace_lost` from the check inside initialize rather than a run that - // circulates forever. + // The per-host queue's claim-side guard (ADR-0018 amendment): a central run + // pinned to another host's storage is never this worker's to claim — or to + // fail. Probed before the claim, unconditional (no grace): the sweeps + // re-enqueue through the resolver onto the host queue, and a dead host is + // the dead-host sweeper's terminal decision, not this worker's guess. + if ( + run.runtimeId === null && + run.workspaceHost !== null && + deps.workspaceHost != null && + run.workspaceHost !== deps.workspaceHost + ) { + throw new WorkspaceElsewhereError(run.id); + } + + // Central host affinity for UNPINNED chains (deploy skew: a follow-up whose + // checkout predates the host pin). Probed BEFORE the claim so a decline + // costs nothing — no status change, no retry burned — and bounded, so a + // workspace nobody holds becomes an honest `workspace_lost` from the check + // inside initialize rather than a run that circulates forever. if ( run.kind === "followup" && run.runtimeId === null && + run.workspaceHost === null && // a pinned run on ITS host answers with isIntact honestly template.spec.workspace !== undefined && run.status === "queued" && Date.now() - run.queuedAt.getTime() < WORKSPACE_AFFINITY_GRACE_MS && @@ -669,6 +685,32 @@ class RunEngine { this.stepOutputs[row.stepId] = { outputs: { result: row.output ?? "" } }; } } + // A follow-up's publish tail speaks the base flow's language: the cloned + // pr.open interpolates artifact and checkpoint references the ANCESTORS + // produced (implementation plan, clarify answers), and a recovery PR — + // ancestor pushed, PR creation failed — would otherwise compose from an + // empty context. VALUES only: produced/digest state stays this run's own + // (contracts and evidence untouched), and the run's own loads below + // overwrite anything it produced itself. + if (run.kind === "followup") { + const chainIds = await this.chainRunIds(); + if (chainIds.length > 0) { + const chainArtifacts = await db + .select() + .from(artifacts) + .where(inArray(artifacts.runId, chainIds)) + .orderBy(artifacts.createdAt); + for (const a of chainArtifacts) this.artifactValues[a.artifactKey] = a.inline ?? ""; + const chainDecided = await db + .select() + .from(checkpoints) + .where(and(inArray(checkpoints.runId, chainIds), eq(checkpoints.status, "approved"))) + .orderBy(checkpoints.decidedAt); + for (const row of chainDecided) { + this.checkpointResponses[row.checkpointId] = this.responseOf(row); + } + } + } const priorArtifacts = await db .select() .from(artifacts) @@ -1091,6 +1133,14 @@ class RunEngine { return "done"; } const snapshot = this.sourceSnapshot(spec, step.id); + // The publish checkpoint records the EXACT digest it presents: approval + // attaches to bytes (ADR-0019), and a later identical steer proves its + // carry-forward against this record — never against publication or + // whatever patch rows a run happens to have. + const presentedPatch = + step.id === FOLLOWUP_PUBLISH_CHECKPOINT_ID && this.tailGuardState + ? (await this.tailGuardState).ownSha256 + : null; await this.db.insert(checkpoints).values({ runId: this.run.id, checkpointId: step.id, @@ -1105,6 +1155,7 @@ class RunEngine { iteration, timeoutMinutes: durationToMinutes(spec.timeout), onTimeout: spec.onTimeout, + ...(presentedPatch ? { patchSha256: presentedPatch } : {}), ...snapshot, }, }); @@ -1473,18 +1524,26 @@ class RunEngine { */ private async composePrBody(step: SystemStep, ctx: Record): Promise { let body = interpolate(step.with.body ?? "", ctx); + // A follow-up's PR describes the CHAIN's delivery, so its waivers come + // from every run of the chain — a recovery PR (ancestor pushed, PR + // creation failed) must carry the findings the ancestor's reviewers + // accepted, not silently drop them. + const waiverRunIds = + this.run.kind === "followup" ? [this.run.id, ...(await this.chainRunIds())] : [this.run.id]; const gateRows = await this.db .select({ row: checkpoints, deciderName: users.name, deciderEmail: users.email }) .from(checkpoints) .leftJoin(users, eq(checkpoints.decidedBy, users.id)) .where( and( - eq(checkpoints.runId, this.run.id), + inArray(checkpoints.runId, waiverRunIds), eq(checkpoints.kind, "review-gate"), eq(checkpoints.status, "approved"), ), ) - .orderBy(checkpoints.iteration); + // chronological across the chain, so the last human decision per + // finding id wins exactly as it does within one run + .orderBy(checkpoints.decidedAt); // Waivers accumulate across rounds: a finding accepted in round N stays // waived unless a later round selected it for fixing. Walking iterations // in order keeps the last human decision per finding id. @@ -2189,7 +2248,26 @@ class RunEngine { } /** Lazily computed once per engine leg — see {@link computeTailGuards}. */ - private tailGuardState?: Promise<{ skipTail: boolean; skipCheckpoint: boolean }>; + private tailGuardState?: Promise<{ + skipTail: boolean; + skipCheckpoint: boolean; + /** The steer's cumulative-patch digest — recorded on the publish + * checkpoint's payload so a later identical steer can prove "these + * exact bytes were approved". */ + ownSha256: string | null; + }>; + + /** The OTHER runs of this workspace chain, cached per engine leg. */ + private chainRunIdsPromise?: Promise; + + private chainRunIds(): Promise { + this.chainRunIdsPromise ??= this.db + .select({ id: runs.id }) + .from(runs) + .where(and(eq(runs.workspaceKey, this.run.workspaceKey), ne(runs.id, this.run.id))) + .then((rows) => rows.map((r) => r.id)); + return this.chainRunIdsPromise; + } /** * The publish tail's guards (ADR-0019), keyed on the synthetic phase id so @@ -2211,22 +2289,28 @@ class RunEngine { } /** - * Approved and published are separate facts, each read from its own record - * (the review round that shaped ADR-0019 Decision 3 found the first draft - * conflating them). The WHOLE tail is skipped only when the steer's + * Approved and published are separate facts, and approval attaches to + * EXACT bytes (the review rounds that shaped ADR-0019 Decision 3 caught + * both conflations). The WHOLE tail is skipped only when the steer's * cumulative patch is empty — nothing to publish. The CHECKPOINT alone is - * skipped when the patch is byte-identical (store-time sha256) to bytes - * this chain already approved: at an earlier follow-up's publish - * checkpoint, or by publishing them (nothing publishes unapproved bytes). - * A base-flow approval that never reached a push is deliberately not - * counted — the conservative direction re-asks a human; it never pushes - * unapproved bytes. Push and pr.open always run when the tail runs: both - * are idempotent, so an approved-but-unpublished chain is completed by the - * next follow-up rather than skipped past. + * skipped when the patch digest equals one a human approved at a publish + * checkpoint of this chain — the digest recorded on the checkpoint's + * payload at presentation, never derived after the fact. Publication is + * deliberately NOT approval: an auto-published base flow's bytes were + * never reviewed, and a published run's other patch rows (earlier loop + * iterations) were never presented — either would let identical bytes skip + * a gate nobody answered. The conservative direction re-asks a human; it + * never pushes unapproved bytes. Push and pr.open always run when the tail + * runs: both are idempotent, so an approved-but-unpublished chain is + * completed by the next follow-up rather than skipped past. */ - private async computeTailGuards(): Promise<{ skipTail: boolean; skipCheckpoint: boolean }> { + private async computeTailGuards(): Promise<{ + skipTail: boolean; + skipCheckpoint: boolean; + ownSha256: string | null; + }> { const patchKey = this.template.spec.outputs.artifacts.find((a) => a.kind === "patch")?.key; - if (!patchKey) return { skipTail: true, skipCheckpoint: false }; + if (!patchKey) return { skipTail: true, skipCheckpoint: false, ownSha256: null }; const [own] = await this.db .select({ sha256: artifacts.sha256 }) .from(artifacts) @@ -2240,38 +2324,27 @@ class RunEngine { .limit(1); const emptyDigest = new Bun.CryptoHasher("sha256").update("").digest("hex"); if (!own?.sha256 || own.sha256 === emptyDigest) { - return { skipTail: true, skipCheckpoint: false }; + return { skipTail: true, skipCheckpoint: false, ownSha256: null }; } - const chainRuns = await this.db - .select({ id: runs.id, publishedSha: runs.publishedSha }) - .from(runs) - .where(and(eq(runs.workspaceKey, this.run.workspaceKey), ne(runs.id, this.run.id))); - const approvedRunIds = new Set( - chainRuns.filter((r) => r.publishedSha !== null).map((r) => r.id), - ); - const chainIds = chainRuns.map((r) => r.id); - if (chainIds.length > 0) { - const approvedCheckpoints = await this.db - .select({ runId: checkpoints.runId }) - .from(checkpoints) - .where( - and( - inArray(checkpoints.runId, chainIds), - eq(checkpoints.checkpointId, FOLLOWUP_PUBLISH_CHECKPOINT_ID), - eq(checkpoints.status, "approved"), - ), - ); - for (const row of approvedCheckpoints) approvedRunIds.add(row.runId); + const chainIds = await this.chainRunIds(); + if (chainIds.length === 0) { + return { skipTail: false, skipCheckpoint: false, ownSha256: own.sha256 }; } - if (approvedRunIds.size === 0) return { skipTail: false, skipCheckpoint: false }; - const digests = await this.db - .select({ sha256: artifacts.sha256 }) - .from(artifacts) + const approvedDigests = await this.db + .select({ payload: checkpoints.payload }) + .from(checkpoints) .where( - and(inArray(artifacts.runId, [...approvedRunIds]), eq(artifacts.artifactKey, patchKey)), + and( + inArray(checkpoints.runId, chainIds), + eq(checkpoints.checkpointId, FOLLOWUP_PUBLISH_CHECKPOINT_ID), + eq(checkpoints.status, "approved"), + ), ); - return { skipTail: false, skipCheckpoint: digests.some((d) => d.sha256 === own.sha256) }; + const skipCheckpoint = approvedDigests.some( + (c) => (c.payload as { patchSha256?: string } | null)?.patchSha256 === own.sha256, + ); + return { skipTail: false, skipCheckpoint, ownSha256: own.sha256 }; } private async markSkipped( diff --git a/packages/orchestration/src/engine/run-lifecycle.ts b/packages/orchestration/src/engine/run-lifecycle.ts index 4fc4ef4..c5952ae 100644 --- a/packages/orchestration/src/engine/run-lifecycle.ts +++ b/packages/orchestration/src/engine/run-lifecycle.ts @@ -166,9 +166,8 @@ export async function sweepDeadHostRuns( and not exists (select 1 from ${checkpoints} where ${checkpoints.runId} = ${runs.id} and ${checkpoints.status} = 'pending') - and exists (select 1 from ${checkpoints} - where ${checkpoints.runId} = ${runs.id} - and ${checkpoints.decidedAt} < now() - ${grace})) + and (select max(${checkpoints.decidedAt}) from ${checkpoints} + where ${checkpoints.runId} = ${runs.id}) < now() - ${grace}) )`, ), );