diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index e6e91576..bb85300a 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -268,6 +268,8 @@ jobs: ] LOOP EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO forge_app_test', table_name); END LOOP; + REVOKE ALL ON TABLE public.execution_outcomes FROM forge_app_test; + GRANT SELECT, INSERT, UPDATE ON TABLE public.execution_outcomes TO forge_app_test; GRANT USAGE, SELECT ON SEQUENCE public.task_logs_sequence_seq TO forge_app_test; END; $grant_s4_application_acl$; @@ -506,6 +508,9 @@ jobs: 'repository_command_audits', 'agent_configs', 'workforces', 'workforce_agents', 'app_settings', 'task_questions' ]; + operation_ledger_tables constant text[] := ARRAY[ + 'execution_outcomes' + ]; protected_tables constant text[] := ARRAY[ 'forge_release_signer_keys', 'forge_release_signer_key_lifecycle_audits', 'forge_epic_172_release_evidence', 'forge_epic_172_transition_authorizations', @@ -536,13 +541,23 @@ jobs: IF EXISTS ( SELECT 1 FROM pg_catalog.pg_tables WHERE schemaname = 'public' - AND tablename <> ALL (ordinary_tables || protected_tables || projection_tables) + AND tablename <> ALL (ordinary_tables || operation_ledger_tables || protected_tables || projection_tables) ) THEN RAISE EXCEPTION 'A public table is missing from the closed application ACL inventory'; END IF; FOREACH table_name IN ARRAY ordinary_tables LOOP EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO forge_app_test', table_name); END LOOP; + REVOKE ALL ON TABLE public.execution_outcomes FROM forge_app_test; + GRANT SELECT, INSERT, UPDATE ON TABLE public.execution_outcomes TO forge_app_test; + FOREACH table_privilege IN ARRAY ARRAY['DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER'] LOOP + IF has_table_privilege( + 'forge_app_test', 'public.execution_outcomes', table_privilege + ) THEN + RAISE EXCEPTION 'ordinary app has unexpected % on operation ledger table public.execution_outcomes', + table_privilege; + END IF; + END LOOP; FOREACH table_name IN ARRAY protected_tables LOOP EXECUTE format('REVOKE ALL ON TABLE public.%I FROM forge_app_test', table_name); FOREACH table_privilege IN ARRAY ARRAY[ diff --git a/docs/adr/0010-execution-outcome-contract.md b/docs/adr/0010-execution-outcome-contract.md new file mode 100644 index 00000000..8ccf44da --- /dev/null +++ b/docs/adr/0010-execution-outcome-contract.md @@ -0,0 +1,38 @@ +# ADR 0010: Canonical execution outcomes + +## Status + +Accepted. + +## Decision + +Forge records one versioned execution outcome for each authoritative execution +or admission boundary. The new `execution_outcomes` ledger is keyed by +`(task_id, attempt_key)`, so a recovered worker updates the same outcome rather +than creating contradictory records. + +The v1 contract separates provider transport (`ok` or `error`) from the +semantic result. A successful provider response that contains a refusal is +therefore `transportStatus: 'ok'` and `result: 'refused'`; it is never treated +as completion. Stop reasons use a closed taxonomy. Summaries are redacted and +limited to 1,000 characters. Evidence references contain UUID record IDs only; +raw diagnostics remain in the existing artifact, task-log, command-audit, and +MCP records. + +`task_id` is required. Links to a work package, agent run, and queue attempt +are nullable because an admission decision can block before an agent run starts. +The existing lifecycle tables remain the source of truth for their respective +states. The ledger is an interpretation layer for reliability and verification +features. + +For v1, worker writes occur at admission blocks and terminal implementation +success/failure boundaries. Historical rows are deliberately not backfilled: +callers must treat a missing outcome as unavailable legacy evidence, not as a +successful execution. + +## Consequences + +Future reliability scoring, independent verification, and autonomy policy read +this contract instead of deriving meaning from free-text errors. This change +does not redesign retry behavior, create automatic retries, or alter task and +work-package state transitions. diff --git a/scripts/ci/prove-installer-managed-migrations.sh b/scripts/ci/prove-installer-managed-migrations.sh index d2f744af..a3b198f3 100755 --- a/scripts/ci/prove-installer-managed-migrations.sh +++ b/scripts/ci/prove-installer-managed-migrations.sh @@ -49,8 +49,8 @@ assert_latest_and_clean() { PGPASSWORD="$FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD" PGHOST="$FORGE_INSTALLER_MANAGED_ADMIN_HOST" PGUSER="$FORGE_INSTALLER_MANAGED_ADMIN_USER" PGDATABASE="$database_name" psql --set ON_ERROR_STOP=1 <<'SQL' DO $proof$ BEGIN - IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 29 - OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1784274000000 THEN + IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 30 + OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1785820800000 THEN RAISE EXCEPTION 'Managed installer did not apply the exact latest migration ledger'; END IF; IF pg_catalog.to_regclass('public.forge_epic_172_s3_release_state') IS NULL THEN diff --git a/web/__tests__/execution-outcomes.test.ts b/web/__tests__/execution-outcomes.test.ts new file mode 100644 index 00000000..0cd88f2d --- /dev/null +++ b/web/__tests__/execution-outcomes.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { + admissionBlockedOutcome, + executionFailureOutcome, + isExecutionOutcome, + normalizeExecutionOutcome, + outcomeEvidenceRefsFromArtifact, +} from '@/lib/execution-outcomes' + +const artifactId = '11111111-1111-4111-8111-111111111111' + +describe('execution outcome v1', () => { + it('keeps a semantic refusal separate from provider transport success', () => { + const outcome = executionFailureOutcome({ message: 'I cannot do that.', refused: true, retryable: false }) + expect(outcome).toMatchObject({ + transportStatus: 'ok', + result: 'refused', + stopReasonCode: 'model_refusal', + retryable: false, + }) + }) + + it('maps admission denial to a retryable semantic block', () => { + expect(admissionBlockedOutcome('MCP capability missing')).toMatchObject({ + transportStatus: 'ok', + result: 'blocked', + stopReasonCode: 'admission_denied', + retryable: true, + }) + }) + + it('maps validation failures independently from transport errors', () => { + expect(executionFailureOutcome({ message: 'test failed', retryable: false, validationFailed: true })) + .toMatchObject({ transportStatus: 'ok', result: 'failed', stopReasonCode: 'validation_failed' }) + }) + + it('classifies an unparseable provider response as execution failure, not transport failure', () => { + expect(executionFailureOutcome({ message: 'Provider returned invalid output JSON', retryable: true })) + .toMatchObject({ transportStatus: 'ok', result: 'failed', stopReasonCode: 'invalid_output' }) + }) + + it('redacts and bounds a persisted summary while retaining UUID evidence references', () => { + const normalized = normalizeExecutionOutcome({ + schemaVersion: 1, + transportStatus: 'error', + result: 'failed', + stopReasonCode: 'provider_transport_failure', + stopReasonSummary: `token=secret ${'x'.repeat(1_500)}`, + retryable: true, + evidenceRefs: [artifactId, artifactId], + verifierRequired: false, + verificationStatus: 'not_required', + }, (value) => String(value).replace('secret', '[REDACTED]')) + + expect(normalized.stopReasonSummary).toContain('[REDACTED]') + expect(normalized.stopReasonSummary).toHaveLength(1_000) + expect(normalized.evidenceRefs).toEqual([artifactId]) + expect(isExecutionOutcome(normalized)).toBe(true) + }) + + it('rejects non-UUID evidence references', () => { + expect(isExecutionOutcome({ + schemaVersion: 1, + transportStatus: 'ok', + result: 'completed', + stopReasonCode: null, + stopReasonSummary: null, + retryable: false, + evidenceRefs: ['raw transcript'], + verifierRequired: false, + verificationStatus: 'not_required', + })).toBe(false) + }) + + it('keeps a failure outcome writable when its artifact row is unavailable', () => { + const outcome = { + ...executionFailureOutcome({ message: 'provider disconnected', retryable: true }), + evidenceRefs: outcomeEvidenceRefsFromArtifact(null), + } + expect(outcome.evidenceRefs).toEqual([]) + expect(isExecutionOutcome(outcome)).toBe(true) + }) +}) diff --git a/web/__tests__/local-projection-overlimit-archive.test.ts b/web/__tests__/local-projection-overlimit-archive.test.ts index 923f481e..b4283a20 100644 --- a/web/__tests__/local-projection-overlimit-archive.test.ts +++ b/web/__tests__/local-projection-overlimit-archive.test.ts @@ -378,8 +378,8 @@ describe('local-projection over-limit operator commands', () => { 'utf8', ) for (const evidence of [ - 'count(*) FROM drizzle.__drizzle_migrations) <> 29', - 'count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 29', + 'count(*) FROM drizzle.__drizzle_migrations) <> 30', + 'count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 30', 'created_at = 1784270400000', 'created_at = 1784274000000', "role.rolname = 'forge_local_projection_archiver'", diff --git a/web/__tests__/work-package-handoff-db.test.ts b/web/__tests__/work-package-handoff-db.test.ts index 068ec21c..80560147 100644 --- a/web/__tests__/work-package-handoff-db.test.ts +++ b/web/__tests__/work-package-handoff-db.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ publishTaskEvent: vi.fn(), projectionContributions: [] as Array>, recordTaskLogBestEffort: vi.fn(), + upsertExecutionOutcome: vi.fn(), readS4RuntimeModeV1: vi.fn(), recoverLinkedS4LifecycleV2: vi.fn(), claimWorkPackageLifecycleV2: vi.fn(), @@ -128,6 +129,10 @@ vi.mock('@/worker/work-package-executor', () => ({ ['architect', 'security', 'security-review', 'security_review'].includes(role.trim().toLowerCase()), })) +vi.mock('@/worker/execution-outcomes', () => ({ + upsertExecutionOutcome: mocks.upsertExecutionOutcome, +})) + vi.mock('@/lib/mcps/filesystem-grant-reconciliation', async (importOriginal) => ({ ...await importOriginal(), loadCurrentProjectFilesystemDecision: mocks.loadCurrentProjectFilesystemDecision, @@ -843,6 +848,20 @@ describe('handoffApprovedWorkPackages', () => { status: 'blocked', workPackageId: 'pkg-1', })) + expect(mocks.upsertExecutionOutcome).toHaveBeenCalledWith(expect.objectContaining({ + taskId: 'task-1', + workPackageId: 'pkg-1', + attemptKey: 'work-package:pkg-1:admission', + outcome: expect.objectContaining({ + result: 'blocked', + stopReasonCode: 'admission_denied', + stopReasonSummary: expect.stringContaining('planning context was not materialized'), + retryable: true, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + }), + })) }) it.each([ @@ -924,6 +943,20 @@ describe('handoffApprovedWorkPackages', () => { errorMessage: 'legacy_task_log_unavailable', status: 'failed', })) + expect(mocks.upsertExecutionOutcome).toHaveBeenCalledWith(expect.objectContaining({ + taskId: 'task-1', + workPackageId: 'pkg-review', + attemptKey: 'work-package:pkg-review:admission', + outcome: expect.objectContaining({ + result: 'blocked', + stopReasonCode: 'policy_blocked', + stopReasonSummary: expect.stringContaining('reserved for review gates'), + retryable: false, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + }), + })) }) it('rolls back a reserved-role block when ownership is lost inside its transaction', async () => { @@ -1023,6 +1056,20 @@ describe('handoffApprovedWorkPackages', () => { transition: 'hold', }), ]) + expect(mocks.upsertExecutionOutcome).toHaveBeenCalledWith(expect.objectContaining({ + taskId: 'task-1', + workPackageId: 'pkg-fs', + attemptKey: 'work-package:pkg-fs:admission', + outcome: expect.objectContaining({ + result: 'blocked', + stopReasonCode: 'missing_capability', + stopReasonSummary: expect.stringContaining('requires filesystem grant approval'), + retryable: true, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + }), + })) }) it('holds a stale project-level filesystem grant when the project grant was revoked', async () => { diff --git a/web/db/migrations/0029_execution_outcomes.sql b/web/db/migrations/0029_execution_outcomes.sql new file mode 100644 index 00000000..3fcf510c --- /dev/null +++ b/web/db/migrations/0029_execution_outcomes.sql @@ -0,0 +1,42 @@ +-- Canonical, idempotent execution-outcome ledger. Existing lifecycle tables +-- remain authoritative; nullable links allow a pre-run admission block. +CREATE TABLE "execution_outcomes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "task_id" uuid NOT NULL, + "work_package_id" uuid, + "agent_run_id" uuid, + "task_attempt_id" uuid, + "attempt_key" text NOT NULL, + "schema_version" integer DEFAULT 1 NOT NULL, + "transport_status" text NOT NULL, + "result" text NOT NULL, + "stop_reason_code" text, + "stop_reason_summary" text, + "retryable" boolean NOT NULL, + "evidence_refs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "verifier_required" boolean DEFAULT false NOT NULL, + "verification_status" text DEFAULT 'not_required' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "execution_outcomes_schema_version_check" CHECK ("schema_version" = 1), + CONSTRAINT "execution_outcomes_transport_status_check" CHECK ("transport_status" IN ('ok', 'error')), + CONSTRAINT "execution_outcomes_result_check" CHECK ("result" IN ('completed', 'partial', 'refused', 'blocked', 'needs_attention', 'failed', 'cancelled')), + CONSTRAINT "execution_outcomes_stop_reason_code_check" CHECK ("stop_reason_code" IS NULL OR "stop_reason_code" IN ('provider_transport_failure', 'model_refusal', 'invalid_output', 'validation_failed', 'missing_capability', 'admission_denied', 'policy_blocked', 'security_blocked', 'missing_repository_context', 'timeout', 'context_limit', 'output_limit', 'retry_exhausted', 'human_cancelled', 'unknown')), + CONSTRAINT "execution_outcomes_verification_status_check" CHECK ("verification_status" IN ('not_required', 'pending', 'passed', 'failed', 'inconclusive')), + CONSTRAINT "execution_outcomes_verifier_consistency_check" CHECK (("verifier_required" AND "verification_status" IN ('pending', 'passed', 'failed', 'inconclusive')) OR (NOT "verifier_required" AND "verification_status" = 'not_required')), + CONSTRAINT "execution_outcomes_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE restrict ON UPDATE no action, + CONSTRAINT "execution_outcomes_work_package_id_work_packages_id_fk" FOREIGN KEY ("work_package_id") REFERENCES "public"."work_packages"("id") ON DELETE set null ON UPDATE no action, + CONSTRAINT "execution_outcomes_agent_run_id_agent_runs_id_fk" FOREIGN KEY ("agent_run_id") REFERENCES "public"."agent_runs"("id") ON DELETE set null ON UPDATE no action, + CONSTRAINT "execution_outcomes_task_attempt_id_task_attempts_id_fk" FOREIGN KEY ("task_attempt_id") REFERENCES "public"."task_attempts"("id") ON DELETE set null ON UPDATE no action +); +-- `evidence_refs` is constrained to UUIDs by the application contract. A +-- PostgreSQL JSONB CHECK cannot safely validate each array element without +-- coupling this durable ledger to a brittle JSON expression. +--> statement-breakpoint +CREATE UNIQUE INDEX "execution_outcomes_task_attempt_key_idx" ON "execution_outcomes" USING btree ("task_id","attempt_key"); +--> statement-breakpoint +CREATE INDEX "execution_outcomes_work_package_id_idx" ON "execution_outcomes" USING btree ("work_package_id"); +--> statement-breakpoint +CREATE INDEX "execution_outcomes_agent_run_id_idx" ON "execution_outcomes" USING btree ("agent_run_id"); +--> statement-breakpoint +CREATE INDEX "execution_outcomes_task_attempt_id_idx" ON "execution_outcomes" USING btree ("task_attempt_id"); diff --git a/web/db/migrations/meta/_journal.json b/web/db/migrations/meta/_journal.json index 48021810..d10adedd 100644 --- a/web/db/migrations/meta/_journal.json +++ b/web/db/migrations/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1784274000000, "tag": "0028_epic_172_s5_recovery_actions", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1785820800000, + "tag": "0029_execution_outcomes", + "breakpoints": true } ] } diff --git a/web/db/schema.ts b/web/db/schema.ts index 211a71d5..cf65def9 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -1454,6 +1454,51 @@ export const agentRuns = pgTable( export type AgentRun = InferSelectModel export type NewAgentRun = InferInsertModel +// --------------------------------------------------------------------------- +// executionOutcomes +// --------------------------------------------------------------------------- +// Canonical, append-compatible outcome ledger. Existing task/package/run rows +// remain the authority for lifecycle state; this table records the normalized +// interpretation of one attempted execution. +export const executionOutcomes = pgTable( + 'execution_outcomes', + { + id: uuid('id').primaryKey().defaultRandom(), + taskId: uuid('task_id').notNull().references(() => tasks.id, { onDelete: 'restrict' }), + // Admission may stop before a run or queue-attempt row exists. + workPackageId: uuid('work_package_id').references(() => workPackages.id, { onDelete: 'set null' }), + agentRunId: uuid('agent_run_id').references(() => agentRuns.id, { onDelete: 'set null' }), + taskAttemptId: uuid('task_attempt_id').references(() => taskAttempts.id, { onDelete: 'set null' }), + attemptKey: text('attempt_key').notNull(), + schemaVersion: integer('schema_version').notNull().default(1), + transportStatus: text('transport_status').notNull(), + result: text('result').notNull(), + stopReasonCode: text('stop_reason_code'), + stopReasonSummary: text('stop_reason_summary'), + retryable: boolean('retryable').notNull(), + evidenceRefs: jsonb('evidence_refs').$type().notNull().default(sql`'[]'::jsonb`), + verifierRequired: boolean('verifier_required').notNull().default(false), + verificationStatus: text('verification_status').notNull().default('not_required'), + createdAt: timestamp('created_at', tsOpts).defaultNow().notNull(), + updatedAt: timestamp('updated_at', tsOpts).defaultNow().notNull(), + }, + (t) => [ + uniqueIndex('execution_outcomes_task_attempt_key_idx').on(t.taskId, t.attemptKey), + index('execution_outcomes_work_package_id_idx').on(t.workPackageId), + index('execution_outcomes_agent_run_id_idx').on(t.agentRunId), + index('execution_outcomes_task_attempt_id_idx').on(t.taskAttemptId), + check('execution_outcomes_schema_version_check', sql`${t.schemaVersion} = 1`), + check('execution_outcomes_transport_status_check', sql`${t.transportStatus} IN ('ok', 'error')`), + check('execution_outcomes_result_check', sql`${t.result} IN ('completed', 'partial', 'refused', 'blocked', 'needs_attention', 'failed', 'cancelled')`), + check('execution_outcomes_stop_reason_code_check', sql`${t.stopReasonCode} IS NULL OR ${t.stopReasonCode} IN ('provider_transport_failure', 'model_refusal', 'invalid_output', 'validation_failed', 'missing_capability', 'admission_denied', 'policy_blocked', 'security_blocked', 'missing_repository_context', 'timeout', 'context_limit', 'output_limit', 'retry_exhausted', 'human_cancelled', 'unknown')`), + check('execution_outcomes_verification_status_check', sql`${t.verificationStatus} IN ('not_required', 'pending', 'passed', 'failed', 'inconclusive')`), + check('execution_outcomes_verifier_consistency_check', sql`(${t.verifierRequired} AND ${t.verificationStatus} IN ('pending', 'passed', 'failed', 'inconclusive')) OR (NOT ${t.verifierRequired} AND ${t.verificationStatus} = 'not_required')`), + ], +) + +export type ExecutionOutcomeRow = InferSelectModel +export type NewExecutionOutcomeRow = InferInsertModel + // --------------------------------------------------------------------------- // artifacts // --------------------------------------------------------------------------- diff --git a/web/lib/execution-outcomes.ts b/web/lib/execution-outcomes.ts new file mode 100644 index 00000000..26624f12 --- /dev/null +++ b/web/lib/execution-outcomes.ts @@ -0,0 +1,142 @@ +/** Versioned, provider-neutral evidence contract for one attempted execution. */ +export const EXECUTION_OUTCOME_SCHEMA_VERSION = 1 as const + +export const EXECUTION_OUTCOME_RESULTS = [ + 'completed', 'partial', 'refused', 'blocked', 'needs_attention', 'failed', 'cancelled', +] as const +export type ExecutionOutcomeResult = typeof EXECUTION_OUTCOME_RESULTS[number] + +export const EXECUTION_STOP_REASON_CODES = [ + 'provider_transport_failure', + 'model_refusal', + 'invalid_output', + 'validation_failed', + 'missing_capability', + 'admission_denied', + 'policy_blocked', + 'security_blocked', + 'missing_repository_context', + 'timeout', + 'context_limit', + 'output_limit', + 'retry_exhausted', + 'human_cancelled', + 'unknown', +] as const +export type ExecutionStopReasonCode = typeof EXECUTION_STOP_REASON_CODES[number] + +export type ExecutionOutcome = { + schemaVersion: 1 + transportStatus: 'ok' | 'error' + result: ExecutionOutcomeResult + stopReasonCode: ExecutionStopReasonCode | null + stopReasonSummary: string | null + retryable: boolean + evidenceRefs: string[] + verifierRequired: boolean + verificationStatus: 'not_required' | 'pending' | 'passed' | 'failed' | 'inconclusive' +} + +const MAX_STOP_REASON_SUMMARY_LENGTH = 1_000 +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +export function isExecutionOutcome(value: unknown): value is ExecutionOutcome { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const outcome = value as Partial + return outcome.schemaVersion === EXECUTION_OUTCOME_SCHEMA_VERSION + && (outcome.transportStatus === 'ok' || outcome.transportStatus === 'error') + && EXECUTION_OUTCOME_RESULTS.includes(outcome.result as ExecutionOutcomeResult) + && (outcome.stopReasonCode === null || EXECUTION_STOP_REASON_CODES.includes(outcome.stopReasonCode as ExecutionStopReasonCode)) + && (outcome.stopReasonSummary === null || typeof outcome.stopReasonSummary === 'string') + && typeof outcome.retryable === 'boolean' + && Array.isArray(outcome.evidenceRefs) && outcome.evidenceRefs.every((ref) => typeof ref === 'string' && UUID_PATTERN.test(ref)) + && typeof outcome.verifierRequired === 'boolean' + && ['not_required', 'pending', 'passed', 'failed', 'inconclusive'].includes(outcome.verificationStatus ?? '') +} + +/** + * Converts raw boundary evidence to the storage-safe form. Evidence refs are + * UUIDs only; technical content remains in its existing artifact/log record. + */ +export function normalizeExecutionOutcome( + outcome: ExecutionOutcome, + sanitize: (value: unknown) => string, +): ExecutionOutcome { + if (!isExecutionOutcome(outcome)) throw new Error('Execution outcome does not match schema v1.') + const summary = outcome.stopReasonSummary === null + ? null + : sanitize(outcome.stopReasonSummary).slice(0, MAX_STOP_REASON_SUMMARY_LENGTH) + return { + ...outcome, + evidenceRefs: [...new Set(outcome.evidenceRefs)].sort(), + stopReasonSummary: summary || null, + } +} + +export function executionFailureOutcome(input: { + message: string + retryable: boolean + validationFailed?: boolean + repositoryContextMissing?: boolean + refused?: boolean +}): ExecutionOutcome { + const message = input.message.toLowerCase() + const inferredCode: ExecutionStopReasonCode = message.includes('refus') + ? 'model_refusal' + : message.includes('unparseable') || message.includes('invalid output') || message.includes('valid JSON') + ? 'invalid_output' + : message.includes('timeout') || message.includes('timed out') + ? 'timeout' + : message.includes('context') && message.includes('limit') + ? 'context_limit' + : message.includes('output') && message.includes('limit') + ? 'output_limit' + : 'unknown' + const code: ExecutionStopReasonCode = input.refused + ? 'model_refusal' + : input.validationFailed + ? 'validation_failed' + : input.repositoryContextMissing + ? 'missing_repository_context' + : inferredCode + const semanticResult = code === 'model_refusal' ? 'refused' : input.repositoryContextMissing ? 'blocked' : 'failed' + const transportStatus = code === 'model_refusal' + || code === 'invalid_output' + || code === 'validation_failed' + || code === 'timeout' + || code === 'context_limit' + || code === 'output_limit' + || input.repositoryContextMissing + ? 'ok' + : 'error' + return { + schemaVersion: 1, + transportStatus, + result: semanticResult, + stopReasonCode: code, + stopReasonSummary: input.message, + retryable: input.retryable, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + } +} + +export function admissionBlockedOutcome(message: string, code: 'admission_denied' | 'policy_blocked' | 'security_blocked' = 'admission_denied'): ExecutionOutcome { + return { + schemaVersion: 1, + transportStatus: 'ok', + result: 'blocked', + stopReasonCode: code, + stopReasonSummary: message, + retryable: code === 'admission_denied', + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + } +} + +/** A missing artifact must never suppress an outcome write. */ +export function outcomeEvidenceRefsFromArtifact(artifact: { id: string } | null | undefined): string[] { + return artifact ? [artifact.id] : [] +} diff --git a/web/scripts/ci/prove-installer-legacy-migration-repair.sh b/web/scripts/ci/prove-installer-legacy-migration-repair.sh index ee518549..035b995a 100644 --- a/web/scripts/ci/prove-installer-legacy-migration-repair.sh +++ b/web/scripts/ci/prove-installer-legacy-migration-repair.sh @@ -1307,8 +1307,8 @@ assert_unchanged managed-latest-once managed-latest-twice 'Managed latest rerun' admin_psql <<'SQL' DO $proof$ BEGIN - IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 29 - OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1784274000000 THEN + IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 30 + OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1785820800000 THEN RAISE EXCEPTION 'Managed sequence did not reach the exact latest ledger'; END IF; IF EXISTS ( @@ -1321,7 +1321,7 @@ END; $proof$; SQL -echo 'Proving exact installer-grant normalization and near-miss refusal at the 29-row ledger.' +echo 'Proving exact installer-grant normalization and near-miss refusal at the 0028 checkpoint.' ensure_forge_app_role admin_psql <<'SQL' CREATE TABLE public.forge_legacy_ordinary_acl_probe ( diff --git a/web/scripts/ci/sql/migration-0027-expansion-assertions.sql b/web/scripts/ci/sql/migration-0027-expansion-assertions.sql index eb6f1ef6..70971c77 100644 --- a/web/scripts/ci/sql/migration-0027-expansion-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-expansion-assertions.sql @@ -13,12 +13,12 @@ BEGIN -- role.rolpassword IS NULL is verified by the administrator-only S4 -- bootstrap; pg_roles intentionally masks it from this ordinary migration -- proof. This block verifies every attribute visible to the ordinary login. - IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 29 - OR (SELECT count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 29 + IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 30 + OR (SELECT count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 30 OR NOT EXISTS (SELECT 1 FROM drizzle.__drizzle_migrations WHERE created_at = 1784270400000) OR NOT EXISTS (SELECT 1 FROM drizzle.__drizzle_migrations WHERE created_at = 1784274000000) - OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1784274000000 THEN - RAISE EXCEPTION 'The normal migrator did not retain the immutable 0027 prefix and one ordered additive 0028 entry'; + OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1785820800000 THEN + RAISE EXCEPTION 'The normal migrator did not retain the immutable 0027/0028 release boundary while reaching the exact latest ledger'; END IF; IF (SELECT attnotnull FROM pg_catalog.pg_attribute diff --git a/web/scripts/repair-epic-172-legacy-release.ts b/web/scripts/repair-epic-172-legacy-release.ts index 612b4c29..94fdb1d1 100644 --- a/web/scripts/repair-epic-172-legacy-release.ts +++ b/web/scripts/repair-epic-172-legacy-release.ts @@ -1423,19 +1423,28 @@ async function main(): Promise { && lockedHashes.get(migration0025At) === legacy0025 ? 'legacy' : null - const position = count === 27 ? '0026' : count === 28 ? '0027' : count === 29 ? '0028' : null - const exactLedger = pair && position && lockedLedger.every((row, index) => { - const expected = currentLedger[index] - if (!expected || Number(row.created_at) !== expected[0]) return false - const expectedHash = pair === 'legacy' && expected[0] === migration0023At - ? legacy0023 - : pair === 'legacy' && expected[0] === migration0025At - ? legacy0025 - : expected[1] - return row.hash === expectedHash - }) + // Migrations applied after 0028 (e.g. 0029, 0030, ...) are outside this + // script's epic-172 legacy-release scope and are not individually + // fingerprinted here: their own migration files and the CI journal-parity + // gate are the source of truth for that later history. So a ledger that + // already reaches or exceeds the 0028 checkpoint is still an exact 0028 + // position for this script's purposes, as long as its first 29 entries + // are byte-for-byte the known-good 0000-0028 ledger. + const position = count === 27 ? '0026' : count === 28 ? '0027' : count >= 29 ? '0028' : null + const checkpointLength = position === '0026' ? 27 : position === '0027' ? 28 : 29 + const exactLedger = pair && position && lockedLedger.length >= checkpointLength + && lockedLedger.slice(0, checkpointLength).every((row, index) => { + const expected = currentLedger[index] + if (!expected || Number(row.created_at) !== expected[0]) return false + const expectedHash = pair === 'legacy' && expected[0] === migration0023At + ? legacy0023 + : pair === 'legacy' && expected[0] === migration0025At + ? legacy0025 + : expected[1] + return row.hash === expectedHash + }) if (!exactLedger || !position) { - throw new Error('Refusing legacy release repair: locked migration ledger is not an exact supported 0026, 0027, or 0028 position.') + throw new Error('Refusing legacy release repair: locked migration ledger is not an exact supported 0026, 0027, or 0028-or-later position.') } if (!await exactOptionalForgeAppRoleBoundary(sql)) { throw new Error('Refusing legacy release repair: literal forge role is outside the exact safe app-role boundary.') diff --git a/web/worker/execution-outcomes.ts b/web/worker/execution-outcomes.ts new file mode 100644 index 00000000..525cfb19 --- /dev/null +++ b/web/worker/execution-outcomes.ts @@ -0,0 +1,47 @@ +import { db } from '../db' +import { executionOutcomes } from '../db/schema' +import { normalizeExecutionOutcome, type ExecutionOutcome } from '../lib/execution-outcomes' +import { sanitizeWorkerMessage } from './redaction' + +/** Idempotently stores the latest canonical interpretation for one attempt. */ +export async function upsertExecutionOutcome(input: { + taskId: string + attemptKey: string + workPackageId?: string | null + agentRunId?: string | null + taskAttemptId?: string | null + outcome: ExecutionOutcome +}): Promise { + if (!input.attemptKey || input.attemptKey.length > 200) { + throw new Error('Execution outcome attempt key is required and bounded.') + } + const outcome = normalizeExecutionOutcome(input.outcome, sanitizeWorkerMessage) + await db + .insert(executionOutcomes) + .values({ + taskId: input.taskId, + attemptKey: input.attemptKey, + workPackageId: input.workPackageId ?? null, + agentRunId: input.agentRunId ?? null, + taskAttemptId: input.taskAttemptId ?? null, + ...outcome, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [executionOutcomes.taskId, executionOutcomes.attemptKey], + set: { + workPackageId: input.workPackageId ?? null, + agentRunId: input.agentRunId ?? null, + taskAttemptId: input.taskAttemptId ?? null, + transportStatus: outcome.transportStatus, + result: outcome.result, + stopReasonCode: outcome.stopReasonCode, + stopReasonSummary: outcome.stopReasonSummary, + retryable: outcome.retryable, + evidenceRefs: outcome.evidenceRefs, + verifierRequired: outcome.verifierRequired, + verificationStatus: outcome.verificationStatus, + updatedAt: new Date(), + }, + }) +} diff --git a/web/worker/work-package-handoff.ts b/web/worker/work-package-handoff.ts index 8f57fb31..a06c1038 100644 --- a/web/worker/work-package-handoff.ts +++ b/web/worker/work-package-handoff.ts @@ -70,6 +70,8 @@ import { } from './repository-evidence' import { explicitOptInFeatureFlagEnabled } from './feature-flags' import { sanitizeWorkerMessage } from './redaction' +import { upsertExecutionOutcome } from './execution-outcomes' +import { executionFailureOutcome, outcomeEvidenceRefsFromArtifact } from '../lib/execution-outcomes' import { recordTaskLogBestEffort } from './task-logs' import { packetCandidateGuard } from '../lib/mcps/packet-issuance-v2' import { localEffectCandidateGuard } from '../lib/mcps/local-run-evidence-v2' @@ -416,6 +418,25 @@ async function publishTaskEventBestEffort( } } +/** + * The outcome ledger is evidence for later verification/reporting, not the + * source of truth for work-package state. A ledger write failure must not + * block admission blocking, run completion, or run-failure recording (all of + * which have already durably persisted through their own writes) -- the + * upsert is idempotent on (taskId, attemptKey), so a missed write here is + * safe to retry or backfill later. + */ +async function upsertExecutionOutcomeBestEffort( + input: Parameters[0], +): Promise { + try { + await upsertExecutionOutcome(input) + } catch (err) { + const message = sanitizeWorkerMessage(err instanceof Error ? err.message : String(err)) + console.warn(`Failed to record execution outcome for ${input.attemptKey}: ${message}`) + } +} + async function continueWorkforceAfterPackageCompletionOrThrow( taskId: string, packageStatus: 'awaiting_review' | 'completed' | null, @@ -1896,9 +1917,10 @@ async function persistWorkPackageHandoffBlock(input: { }): Promise { input.assertOwned() const common = { pkg: input.pkg, project: input.project, taskId: input.taskId } + let result: HandoffAdmissionResult switch (input.decision.kind) { case 'broker': - return failWorkPackageForMcpBroker({ + result = await failWorkPackageForMcpBroker({ ...common, assertOwned: input.assertOwned, blocked: input.decision.blocked, @@ -1906,8 +1928,9 @@ async function persistWorkPackageHandoffBlock(input: { check: input.decision.check, warnings: input.decision.warnings, }) + break case 'filesystem_grant': - return failWorkPackageForFilesystemGrant({ + result = await failWorkPackageForFilesystemGrant({ ...common, assertOwned: input.assertOwned, blockedReason: input.decision.blockedReason, @@ -1916,13 +1939,38 @@ async function persistWorkPackageHandoffBlock(input: { requirementKeys: input.decision.requirementKeys, requestedCapabilities: input.decision.requestedCapabilities, }) + break case 'reserved_role': - return failWorkPackageForReservedRole({ + result = await failWorkPackageForReservedRole({ ...common, assertOwned: input.assertOwned, blockedReason: input.decision.blockedReason, }) + break + } + if (result.status === 'blocked') { + await upsertExecutionOutcomeBestEffort({ + taskId: input.taskId, + workPackageId: input.pkg.id, + attemptKey: `work-package:${input.pkg.id}:admission`, + outcome: { + schemaVersion: 1, + transportStatus: 'ok', + result: 'blocked', + stopReasonCode: input.decision.kind === 'reserved_role' + ? 'policy_blocked' + : input.decision.kind === 'broker' + ? 'admission_denied' + : 'missing_capability', + stopReasonSummary: result.blockedReason, + retryable: !result.terminalBlock, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + }, + }) } + return result } /** @@ -3260,6 +3308,23 @@ async function executeReadyWorkPackage( artifact = protectedArtifact ?? null } if (!artifact) throw new Error('Work package completion did not create a source artifact.') + await upsertExecutionOutcomeBestEffort({ + taskId, + workPackageId: nextPackage.id, + agentRunId: run.id, + attemptKey: `work-package:${nextPackage.id}:run:${run.id}`, + outcome: { + schemaVersion: 1, + transportStatus: 'ok', + result: 'completed', + stopReasonCode: null, + stopReasonSummary: null, + retryable: false, + evidenceRefs: [artifact.id], + verifierRequired: (nextPackage.reviewRequirement ?? 'both') !== 'none', + verificationStatus: (nextPackage.reviewRequirement ?? 'both') === 'none' ? 'not_required' : 'pending', + }, + }) const packageStatus = reviewGates.packageStatus === 'awaiting_review' || reviewGates.packageStatus === 'completed' ? reviewGates.packageStatus : null @@ -3484,6 +3549,25 @@ async function executeReadyWorkPackage( }) .returning() + // A failure outcome is authoritative even when the artifact write did not + // return a row. Keep evidence empty in that case rather than losing the + // outcome record or inventing an evidence reference. + await upsertExecutionOutcomeBestEffort({ + taskId, + workPackageId: nextPackage.id, + agentRunId: run.id, + attemptKey: `work-package:${nextPackage.id}:run:${run.id}`, + outcome: { + ...executionFailureOutcome({ + message, + retryable: !finalAttempt, + validationFailed: validationStatusForPackage === 'failed', + repositoryContextMissing: repositoryEvidenceBlocked, + }), + evidenceRefs: outcomeEvidenceRefsFromArtifact(artifact), + }, + }) + assertQueueClaimOwned(options) await publishTaskEventBestEffort(taskId, 'run:failed', { attemptNumber,