From 1a10772197424c1a129679abd286eaefc6bcda3e Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 09:08:42 +0800 Subject: [PATCH 01/42] chore: record delivery binding for auto-close-linked --- .specgit.yaml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 5c6fea502..4e38d641f 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,11 +1,7 @@ version: 1 -delivery: sync-v1-0-39 +delivery: auto-close-linked context: kind: branch - branch: chore/517-sync-v1-0-39 + branch: feat/519-auto-close-linked issues: - - 517 -issueKinds: - - issue: 517 - kind: kind::chore -pr: 518 + - 519 From 1eca66a13dfa749a4edf6e829c3eb44006535422 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 09:09:04 +0800 Subject: [PATCH 02/42] chore: record delivery binding for auto-close-linked --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 4e38d641f..734775fd3 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/519-auto-close-linked issues: - 519 +pr: 520 From ff2dcc02d7cf0bdd856e0e0322ab43d7bc2a32c2 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 11:17:13 +0800 Subject: [PATCH 03/42] chore(ci): add dev-layer issue auto-close workflow --- .github/workflows/dev-issue-autoclose.yml | 98 +++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/dev-issue-autoclose.yml diff --git a/.github/workflows/dev-issue-autoclose.yml b/.github/workflows/dev-issue-autoclose.yml new file mode 100644 index 000000000..330a34489 --- /dev/null +++ b/.github/workflows/dev-issue-autoclose.yml @@ -0,0 +1,98 @@ +# ============================================================================ +# 🧹 Dev · Issue Auto-Close +# ---------------------------------------------------------------------------- +# Purpose: Mirror GitHub's native issue auto-close for PRs merged into `dev`. +# Native auto-close (`Closes #n` in the PR body) only fires when a PR +# merges into the DEFAULT branch (`main`). This repo delivers into +# `dev` first (two-tier Git Workflow), so dev-delivered issues would +# otherwise stay open until manual close (#433/#472/#496/#517 et al.). +# Trigger: `pull_request: types: [closed]`. The job-level `if` gates actual +# work to MERGED PRs whose base is `dev`; merges to `main` keep +# GitHub's native auto-close (no overlap). +# Jobs : autoclose — single Linux runner, pure event payload + `gh` CLI. +# No `actions/checkout`, no third-party actions. The PR body reaches +# the script ONLY via `env:` (script-injection safety); refs are +# matched case-insensitively against the official closing keywords +# followed by bare `#n` (plain-text scan of the whole body, matching +# GitHub's own scanner — refs inside fenced code blocks are included, +# best-effort native parity). Shared issue/PR number space guards: +# numbers resolving to a pull request are skipped, nonexistent +# numbers are skipped, already-CLOSED issues are skipped (no +# duplicate comments). Survivors are closed as `completed` with a +# comment naming the delivery PR. +# Notes : The whole matrix (extraction + guards) is exercised by the dry-run +# harness under /tmp/dev-issue-autoclose/ (see issue #519 evidence). +# Runs on every PR close event; non-dev or unmerged closes exit at +# the job-level `if` without consuming a runner step. +# ============================================================================ + +name: 🧹 Dev · Issue Auto-Close + +on: + pull_request: + types: [closed] + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + autoclose: + name: Auto-close linked issues + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev' + runs-on: ubuntu-latest + steps: + - name: Close linked issues referenced in the PR body + env: + GH_TOKEN: ${{ github.token }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_URL: ${{ github.event.pull_request.html_url }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + if [ -z "$PR_BODY" ]; then + echo "dev-issue-autoclose: PR #$PR_NUMBER has no body; nothing to do" + exit 0 + fi + + # Official closing keywords + bare #n, case-insensitive, deduped. + # Plain-text scan of the whole body (code fences included) mirrors + # GitHub's own scanner; qualified `owner/repo#n` and URL refs do not + # match (whitespace must sit directly before `#`). + refs=$(printf '%s' "$PR_BODY" \ + | grep -oiE '\b(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[0-9]+\b' \ + | grep -oE '#[0-9]+\b' \ + | tr -d '#' \ + | sort -nu \ + || true) + + if [ -z "$refs" ]; then + echo "dev-issue-autoclose: PR #$PR_NUMBER body has no closing-keyword refs; nothing to do" + exit 0 + fi + + echo "dev-issue-autoclose: PR #$PR_NUMBER -> refs: $(echo "$refs" | tr '\n' ' ')" + + for n in $refs; do + # Shared issue/PR number space: skip numbers that resolve to a PR. + if gh pr view "$n" --repo "$REPO" >/dev/null 2>&1; then + echo "dev-issue-autoclose: #$n is a pull request; skipping" + continue + fi + # Skip numbers that do not exist as issues. + if ! state=$(gh issue view "$n" --repo "$REPO" --json state --jq .state 2>/dev/null); then + echo "dev-issue-autoclose: #$n not found; skipping" + continue + fi + # Skip already-closed issues (no duplicate comments). + if [ "$state" = "CLOSED" ]; then + echo "dev-issue-autoclose: #$n is already CLOSED; skipping (no duplicate comment)" + continue + fi + gh issue close "$n" --repo "$REPO" --reason completed \ + --comment "Auto-closed: delivery PR #$PR_NUMBER ([view]($PR_URL)) merged into \`dev\` with a closing keyword for #$n in its body. GitHub's native auto-close only fires on the default branch (\`main\`); this mirrors it for the dev integration layer ([#519](https://github.com/$REPO/issues/519))." + echo "dev-issue-autoclose: #$n closed (reason: completed) by delivery PR #$PR_NUMBER" + done From 320e640358cadf06cfdbd234fbe29ad84e13dcc7 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 11:47:59 +0800 Subject: [PATCH 04/42] chore: record delivery binding for summary-diff-continue --- .specgit.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 734775fd3..3b53826df 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: auto-close-linked +delivery: summary-diff-continue context: kind: branch - branch: feat/519-auto-close-linked + branch: fix/525-summary-diff-continue issues: - - 519 -pr: 520 + - 525 From 9ffc93f5a0243d6ba349cfff43aea9b03f86db44 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 12:55:43 +0800 Subject: [PATCH 05/42] fix: skip oversized summary diffs individually and drop the dead summary_diffs column --- packages/core/schema.json | 14 +-- packages/core/src/database/migration.gen.ts | 1 + ...260903044702_drop_session_summary_diffs.ts | 11 ++ packages/core/src/database/schema.gen.ts | 1 - packages/core/src/session/projector.ts | 1 - packages/core/src/session/sql.ts | 2 - packages/core/test/database-migration.test.ts | 34 ++++++ packages/opencode/src/session/session.ts | 12 +- .../test/server/httpapi-session.test.ts | 34 ------ .../test/session/summary-diff-guard.test.ts | 110 ++++-------------- 10 files changed, 76 insertions(+), 144 deletions(-) create mode 100644 packages/core/src/database/migration/20260903044702_drop_session_summary_diffs.ts diff --git a/packages/core/schema.json b/packages/core/schema.json index 7df86d774..2afa95c92 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "874d8e74-d354-4dcb-b98c-c893660c9371", + "id": "abadf28b-1770-46c6-bbf6-b7800a9ca874", "prevIds": [ - "4142b961-0712-4834-b475-16ea4a74c43c" + "874d8e74-d354-4dcb-b98c-c893660c9371" ], "ddl": [ { @@ -1772,16 +1772,6 @@ "entityType": "columns", "table": "session" }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_diffs", - "entityType": "columns", - "table": "session" - }, { "type": "text", "notNull": false, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 3d44bcf97..c141dc82a 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -56,5 +56,6 @@ export const migrations = ( import("./migration/20260813040429_workflow_directory"), import("./migration/20260815044858_dag_graph_rev_view"), import("./migration/20260815083000_workflow_directory_convergence"), + import("./migration/20260903044702_drop_session_summary_diffs"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260903044702_drop_session_summary_diffs.ts b/packages/core/src/database/migration/20260903044702_drop_session_summary_diffs.ts new file mode 100644 index 000000000..79c3559aa --- /dev/null +++ b/packages/core/src/database/migration/20260903044702_drop_session_summary_diffs.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260903044702_drop_session_summary_diffs", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`summary_diffs\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 25c9b5657..851962d2e 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -274,7 +274,6 @@ export default { \`summary_additions\` integer, \`summary_deletions\` integer, \`summary_files\` integer, - \`summary_diffs\` text, \`metadata\` text, \`cost\` real DEFAULT 0 NOT NULL, \`tokens_input\` integer DEFAULT 0 NOT NULL, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 0064fcb1d..c47cbce2e 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -60,7 +60,6 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse summary_additions: info.summary?.additions, summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, - summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined, metadata: info.metadata, cost: info.cost ?? 0, tokens_input: (info.tokens ?? { input: 0 }).input, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index a7ce8df49..abdd60360 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -4,7 +4,6 @@ import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" import type { Prompt } from "./prompt" import type { SessionInput } from "./input" -import type { Snapshot } from "../snapshot" import { PermissionV1 } from "../v1/permission" import { ProjectV2 } from "../project" import type { SessionSchema } from "./schema" @@ -38,7 +37,6 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - summary_diffs: text({ mode: "json" }).$type(), metadata: text({ mode: "json" }).$type>(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 339417fbc..87da1346b 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -18,6 +18,7 @@ import simplifySessionInputMigration from "@opencode-ai/core/database/migration/ import capturedOutputMigration from "@opencode-ai/core/database/migration/20260715035022_captured_output" import fearlessCammiMigration from "@opencode-ai/core/database/migration/20260717034735_fearless_cammi" import dagWorkflowNodeIdentityMigration from "@opencode-ai/core/database/migration/20260720013828_dag-workflow-node-identity" +import dropSessionSummaryDiffsMigration from "@opencode-ai/core/database/migration/20260903044702_drop_session_summary_diffs" import { EventV2 } from "@opencode-ai/core/event" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -100,6 +101,39 @@ describe("DatabaseMigration", () => { ) }) + test("drops the legacy session summary_diffs column", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + // Legacy install: the column still exists with data, and only the drop migration is pending. + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, summary_diffs text)`) + yield* db.run( + sql`INSERT INTO session (id, summary_diffs) VALUES ('ses_legacy', '[{"file":"a.txt","patch":"p","additions":1,"deletions":0,"status":"modified"}]')`, + ) + + yield* DatabaseMigration.applyOnly(db, [dropSessionSummaryDiffsMigration]) + + expect( + yield* db.get(sql`SELECT name FROM pragma_table_info('session') WHERE name = 'summary_diffs'`), + ).toBeUndefined() + expect(yield* db.get(sql`SELECT id FROM session WHERE id = 'ses_legacy'`)).toEqual({ id: "ses_legacy" }) + expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${dropSessionSummaryDiffsMigration.id}`)).toEqual({ + id: dropSessionSummaryDiffsMigration.id, + }) + }), + ) + + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + expect( + yield* db.get(sql`SELECT name FROM pragma_table_info('session') WHERE name = 'summary_diffs'`), + ).toBeUndefined() + }), + ) + }) + test("upgrades DAG node storage without duplicate columns or cross-workflow collisions", async () => { await run( Effect.gen(function* () { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 36a6440a0..81c79c0e4 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -68,25 +68,21 @@ export const MAX_SUMMARY_DIFF_BYTES = 256 * 1024 // Byte accounting mirrors the JSON serialization: 2 bytes for the "[]" wrapper, // +1 per comma separator, so kept output never exceeds MAX_SUMMARY_DIFF_BYTES. +// Entries that do not fit are skipped individually so later, smaller entries +// are still kept. export function truncateSummaryDiffs(diffs: Snapshot.FileDiff[] | undefined) { if (!diffs) return undefined let total = 2 const kept: Snapshot.FileDiff[] = [] for (const item of diffs) { const size = Buffer.byteLength(JSON.stringify(item)) + (kept.length > 0 ? 1 : 0) - if (total + size > MAX_SUMMARY_DIFF_BYTES) break + if (total + size > MAX_SUMMARY_DIFF_BYTES) continue total += size kept.push(item) } return kept } -function stripOversizedDiffs(diffs: T[] | null | undefined) { - if (!diffs) return undefined - if (Buffer.byteLength(JSON.stringify(diffs)) > MAX_SUMMARY_DIFF_BYTES) return undefined - return diffs -} - export function fromRow(row: SessionRow): Info { const summary = row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null @@ -94,7 +90,6 @@ export function fromRow(row: SessionRow): Info { additions: row.summary_additions ?? 0, deletions: row.summary_deletions ?? 0, files: row.summary_files ?? 0, - diffs: stripOversizedDiffs(row.summary_diffs), } : undefined const share = row.share_url ? { url: row.share_url } : undefined @@ -165,7 +160,6 @@ export function toRow(info: Info) { summary_additions: info.summary?.additions, summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, - summary_diffs: truncateSummaryDiffs(info.summary?.diffs), metadata: info.metadata, cost: info.cost ?? 0, tokens_input: (info.tokens ?? EmptyTokens).input, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index ae72bc4d0..75257a8e1 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -182,22 +182,6 @@ const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) => .pipe(Effect.orDie) }) -const setLegacySummaryDiff = (sessionID: SessionIDType) => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .update(SessionTable) - .set({ - summary_additions: 1, - summary_deletions: 0, - summary_files: 1, - summary_diffs: [{ additions: 1, deletions: 0 }], - }) - .where(eq(SessionTable.id, sessionID)) - .run() - .pipe(Effect.orDie) - }) - const getWorkspaceID = (sessionID: SessionIDType) => Effect.gen(function* () { const { db } = yield* Database.Service @@ -690,24 +674,6 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) - it.instance( - "serves sessions with migrated summary diffs missing file details", - () => - Effect.gen(function* () { - const test = yield* TestInstance - const session = yield* createSession({ title: "legacy diff" }) - yield* setLegacySummaryDiff(session.id) - - const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { - headers: { "x-opencode-directory": test.directory }, - }) - - expect(response.status).toBe(200) - expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) - }), - { git: true, config: { formatter: false, lsp: false } }, - ) - it.instance( "serves lifecycle mutation routes", () => diff --git a/packages/opencode/test/session/summary-diff-guard.test.ts b/packages/opencode/test/session/summary-diff-guard.test.ts index 7d109b40c..ae8c2f038 100644 --- a/packages/opencode/test/session/summary-diff-guard.test.ts +++ b/packages/opencode/test/session/summary-diff-guard.test.ts @@ -3,12 +3,10 @@ import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionProjector } from "@opencode-ai/core/session/projector" -import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect, Layer } from "effect" -import { eq } from "drizzle-orm" import { Snapshot } from "@/snapshot" import { Session as SessionNs, truncateSummaryDiffs, MAX_SUMMARY_DIFF_BYTES } from "@/session/session" import { SessionSummary } from "@/session/summary" @@ -48,22 +46,6 @@ const giantDiffs = (count: number) => status: "modified" as const, })) -const setSummaryRow = (sessionID: SessionID, summary: { additions: number; deletions: number; files: number; diffs: Snapshot.FileDiff[] }) => - Effect.gen(function* () { - const database = yield* Database.Service - yield* database.db - .update(SessionTable) - .set({ - summary_additions: summary.additions, - summary_deletions: summary.deletions, - summary_files: summary.files, - summary_diffs: summary.diffs, - }) - .where(eq(SessionTable.id, sessionID)) - .run() - .pipe(Effect.orDie) - }) - const seedUserTurn = Effect.fnUntraced(function* (sessionID: SessionID) { const sessions = yield* SessionNs.Service const userMessageID = MessageID.ascending() @@ -140,34 +122,6 @@ describe("summary.diffs source truncation", () => { ) }) -describe("summary_diffs read guard", () => { - it.instance("strips oversized legacy summary_diffs on read and keeps stats", () => - Effect.gen(function* () { - const sessions = yield* SessionNs.Service - const database = yield* Database.Service - const session = yield* sessions.create({ title: "legacy-giant-diffs" }) - - yield* database.db - .update(SessionTable) - .set({ - summary_additions: 12, - summary_deletions: 34, - summary_files: 56, - summary_diffs: giantDiffs(300), - }) - .where(eq(SessionTable.id, session.id)) - .run() - .pipe(Effect.orDie) - - const info = yield* sessions.get(session.id) - expect(info.summary?.additions).toBe(12) - expect(info.summary?.deletions).toBe(34) - expect(info.summary?.files).toBe(56) - expect(info.summary?.diffs).toBeUndefined() - }), - ) -}) - describe("truncateSummaryDiffs boundaries", () => { const item = { file: "a.txt", @@ -194,45 +148,31 @@ describe("truncateSummaryDiffs boundaries", () => { expect(kept?.length).toBe(count) expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(MAX_SUMMARY_DIFF_BYTES) }) -}) -describe("summary diffs budget boundary", () => { - it.instance("keeps diffs at just under the budget on write and read", () => - Effect.gen(function* () { - const sessions = yield* SessionNs.Service - const session = yield* sessions.create({ title: "under-budget" }) - const info = yield* sessions.get(session.id) - - const diffs = giantDiffs(100) - expect(Buffer.byteLength(JSON.stringify(diffs))).toBeLessThan(SessionNs.MAX_SUMMARY_DIFF_BYTES) - const row = SessionNs.toRow({ ...info, summary: { additions: 5, deletions: 6, files: 100, diffs } }) - expect(row.summary_diffs).toEqual(diffs) - - yield* setSummaryRow(session.id, { additions: 5, deletions: 6, files: 100, diffs }) - const back = yield* sessions.get(session.id) - expect(back.summary?.diffs).toEqual(diffs) - expect(back.summary?.additions).toBe(5) - expect(back.summary?.deletions).toBe(6) - expect(back.summary?.files).toBe(100) - }), - ) + test("skips an oversized entry and keeps later entries that still fit", () => { + const huge = { ...item, file: "huge.txt", patch: "x".repeat(MAX_SUMMARY_DIFF_BYTES) } + const smallA = { ...item, file: "small-a.txt", patch: "y".repeat(64) } + const smallB = { ...item, file: "small-b.txt", patch: "z".repeat(64) } + const kept = truncateSummaryDiffs([huge, smallA, smallB]) + expect(kept).toEqual([smallA, smallB]) + expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(MAX_SUMMARY_DIFF_BYTES) + }) - it.instance("truncates oversized diffs on write within the budget", () => - Effect.gen(function* () { - const sessions = yield* SessionNs.Service - const session = yield* sessions.create({ title: "over-budget-write" }) - const info = yield* sessions.get(session.id) - - const row = SessionNs.toRow({ - ...info, - summary: { additions: 5, deletions: 6, files: 300, diffs: giantDiffs(300) }, - }) - const kept = row.summary_diffs - expect(kept?.length).toBeGreaterThan(0) - expect(kept?.length).toBeLessThan(300) - expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(SessionNs.MAX_SUMMARY_DIFF_BYTES) - expect(kept?.[0]?.file).toBe("f000.txt") - expect(kept?.at(-1)?.file).toBe(`f${String((kept?.length ?? 1) - 1).padStart(3, "0")}.txt`) - }), - ) + test("keeps serialized output within the budget for mixed oversized inputs", () => { + const huge = { ...item, patch: "x".repeat(MAX_SUMMARY_DIFF_BYTES) } + const nearBudget = { ...item, patch: "x".repeat(MAX_SUMMARY_DIFF_BYTES - 120) } + const shapes = [ + [huge, item, item], + [item, huge, item], + [item, item, huge], + [nearBudget, item, nearBudget, item], + [huge, nearBudget, huge, item], + [nearBudget, nearBudget], + [item, nearBudget, item, huge, item], + ] + shapes.forEach((shape) => { + const kept = truncateSummaryDiffs(shape) + expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(MAX_SUMMARY_DIFF_BYTES) + }) + }) }) From 898cc0bb1e96b8e14722c4d47fd84d0c915dcc48 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 13:08:54 +0800 Subject: [PATCH 06/42] chore: record delivery binding for summary-diff-continue --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 3b53826df..6b3d72ca4 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: fix/525-summary-diff-continue issues: - 525 +pr: 526 From 772ea03cb70a9dd14fb42f70b5ea5c6af457fe7c Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 14:23:49 +0800 Subject: [PATCH 07/42] fix: dedupe byte-identical durable event appends without consuming seq --- .specgit.yaml | 8 +- packages/core/schema.json | 14 +- packages/core/src/database/migration.gen.ts | 1 + .../20260903062324_add_event_data_hash.ts | 11 + packages/core/src/database/schema.gen.ts | 1 + packages/core/src/event.ts | 58 +++-- packages/core/src/event/sql.ts | 5 + packages/core/test/event.test.ts | 234 +++++++++++++++++- .../routes/instance/httpapi/handlers/sync.ts | 8 +- 9 files changed, 317 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/database/migration/20260903062324_add_event_data_hash.ts diff --git a/.specgit.yaml b/.specgit.yaml index 6b3d72ca4..63d268f98 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,8 @@ version: 1 -delivery: summary-diff-continue +delivery: event-idempotency-gate context: kind: branch - branch: fix/525-summary-diff-continue + branch: fix/523-event-idempotency-gate issues: - - 525 -pr: 526 + - 523 +pr: 527 diff --git a/packages/core/schema.json b/packages/core/schema.json index 2afa95c92..96ddf9fd6 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "abadf28b-1770-46c6-bbf6-b7800a9ca874", + "id": "7a2e2a70-584a-4604-bf73-4c6e116c20a3", "prevIds": [ - "874d8e74-d354-4dcb-b98c-c893660c9371" + "abadf28b-1770-46c6-bbf6-b7800a9ca874" ], "ddl": [ { @@ -1052,6 +1052,16 @@ "entityType": "columns", "table": "event" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data_hash", + "entityType": "columns", + "table": "event" + }, { "type": "text", "notNull": false, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index c141dc82a..de37ec19a 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -57,5 +57,6 @@ export const migrations = ( import("./migration/20260815044858_dag_graph_rev_view"), import("./migration/20260815083000_workflow_directory_convergence"), import("./migration/20260903044702_drop_session_summary_diffs"), + import("./migration/20260903062324_add_event_data_hash"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260903062324_add_event_data_hash.ts b/packages/core/src/database/migration/20260903062324_add_event_data_hash.ts new file mode 100644 index 000000000..a2ca39d72 --- /dev/null +++ b/packages/core/src/database/migration/20260903062324_add_event_data_hash.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260903062324_add_event_data_hash", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`event\` ADD \`data_hash\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 851962d2e..293e89b4b 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -149,6 +149,7 @@ export default { \`seq\` integer NOT NULL, \`type\` text NOT NULL, \`data\` text NOT NULL, + \`data_hash\` text, CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE ); `) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index ec80977a8..d5ee2e775 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -3,13 +3,14 @@ export * as EventV2 from "./event" import { Cause, Context, Effect, FiberSet, Layer, Option, PubSub, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" -import { and, asc, eq, gt } from "drizzle-orm" +import { and, asc, desc, eq, gt } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" import { LayerNode } from "./effect/layer-node" import { isDeepStrictEqual } from "node:util" import { Durable } from "@opencode-ai/schema/durable-event-manifest" +import { Hash } from "./util/hash" export const ID = Event.ID export type ID = import("@opencode-ai/schema/event").ID @@ -227,6 +228,30 @@ export const layerWith = (options?: LayerOptions) => if (input && row?.ownerID && row.ownerID !== input.ownerID) { return undefined } + const dataHash = Hash.sha256(JSON.stringify(encoded)) + if (!input) { + // Idempotency gate (#523): a fresh append that byte-for-byte repeats + // this aggregate's latest same-type event carries zero information + // delta. Skip it entirely — no seq consumed, no projectors, no commit + // hook, no durable wake — so the persisted sequence stays dense and + // both the replayAll contiguity check and gt(seq, after) readers are + // unaffected. Replay appends (input) keep their exact-seq contract, + // and legacy rows carry a NULL hash so they never match. + const previous = yield* db + .select({ dataHash: EventTable.data_hash }) + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, aggregateID), + eq(EventTable.type, versionedType(definition.type, durable.version)), + ), + ) + .orderBy(desc(EventTable.seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + if (previous && previous.dataHash === dataHash) return undefined + } const seq = input?.seq ?? latest + 1 if (input && seq !== latest + 1) { yield* Effect.die( @@ -278,6 +303,7 @@ export const layerWith = (options?: LayerOptions) => seq, type: versionedType(definition.type, durable.version), data: encoded, + data_hash: dataHash, }, ]) .run() @@ -479,7 +505,9 @@ export const layerWith = (options?: LayerOptions) => .transaction( () => Effect.gen(function* () { - const results = new Array<{ aggregateID: string; seq: number }>() + // Aligned with entries by index: a deduped entry yields + // undefined so the payload pairing below stays positional. + const results = new Array<{ aggregateID: string; seq: number } | undefined>() for (const entry of entries) { // No replay input: seq is allocated contiguously from the latest sequence inside the transaction. const result = yield* commitDurableEventInner( @@ -488,7 +516,7 @@ export const layerWith = (options?: LayerOptions) => undefined, entry.commit, ) - if (result) results.push(result) + results.push(result) } return results }), @@ -499,19 +527,19 @@ export const layerWith = (options?: LayerOptions) => return results }), ) - const payloads = entries.flatMap((entry, index) => { + const payloads = entries.map((entry, index) => { const result = committed[index] - if (!result) return [] - return [ - { - ...entry.event, - durable: { - aggregateID: result.aggregateID, - seq: result.seq, - version: entry.durable.version, - }, - } as Payload, - ] + // A deduped entry is still notified (mirrors the single-publish + // path) but stays unstamped: it occupies no sequence position. + if (!result) return entry.event + return { + ...entry.event, + durable: { + aggregateID: result.aggregateID, + seq: result.seq, + version: entry.durable.version, + }, + } as Payload }) for (const payload of payloads) { yield* notify(payload) diff --git a/packages/core/src/event/sql.ts b/packages/core/src/event/sql.ts index 38fe34f1e..74f868e97 100644 --- a/packages/core/src/event/sql.ts +++ b/packages/core/src/event/sql.ts @@ -17,6 +17,11 @@ export const EventTable = sqliteTable( seq: integer().notNull(), type: text().notNull(), data: text({ mode: "json" }).$type>().notNull(), + // sha256 of the serialized payload, written once at append time. The + // idempotency gate compares against the latest same-type row via + // event_aggregate_type_seq_idx instead of re-hashing MiB-scale payloads. + // Nullable: legacy rows predate the column and never match the gate. + data_hash: text(), }, (table) => [ uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq), diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 7034c1ccc..c6722ce2f 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -10,7 +10,7 @@ import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { eq } from "drizzle-orm" +import { asc, eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" @@ -382,6 +382,238 @@ describe("EventV2", () => { }), ) + it.effect("skips a byte-identical duplicate of the latest same-type durable event", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + const first = yield* events.publish(SyncMessage, { id: aggregateID, text: "hello" }) + const duplicate = yield* events.publish(SyncMessage, { id: aggregateID, text: "hello" }) + const rows = yield* db + .select({ seq: EventTable.seq, dataHash: EventTable.data_hash }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) + + expect(first.durable?.seq).toBe(0) + expect(duplicate.durable).toBeUndefined() + expect(rows).toHaveLength(1) + expect(rows[0]?.dataHash).toHaveLength(64) + }), + ) + + it.effect("keeps the persisted sequence dense when a duplicate is skipped", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* events.publish(SyncMessage, { id: aggregateID, text: "hello" }) + yield* events.publish(SyncMessage, { id: aggregateID, text: "hello" }) + const third = yield* events.publish(SyncMessage, { id: aggregateID, text: "world" }) + const rows = yield* db + .select({ seq: EventTable.seq, data: EventTable.data }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie) + + expect(third.durable?.seq).toBe(1) + expect(rows.map((row) => [row.seq, row.data["text"]])).toEqual([ + [0, "hello"], + [1, "world"], + ]) + }), + ) + + it.effect("appends when the payload differs from the latest same-type event", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* events.publish(SyncMessage, { id: aggregateID, text: "a" }) + yield* events.publish(SyncMessage, { id: aggregateID, text: "b" }) + yield* events.publish(SyncMessage, { id: aggregateID, text: "a" }) + const rows = yield* db + .select({ seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) + + expect(rows).toHaveLength(3) + }), + ) + + it.effect("dedupes only against the same aggregate and event type", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + const otherAggregateID = EventV2.ID.create() + + yield* events.publish(SyncMessage, { id: aggregateID, text: "same" }) + yield* events.publish(SyncSent, { messageID: aggregateID, text: "same" }) + yield* events.publish(SyncMessage, { id: otherAggregateID, text: "same" }) + const own = yield* db + .select({ type: EventTable.type }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) + const other = yield* db + .select({ seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, otherAggregateID)) + .all() + .pipe(Effect.orDie) + + expect(new Set(own.map((row) => row.type))).toHaveLength(2) + expect(other).toHaveLength(1) + }), + ) + + it.effect("skips duplicates inside a publishMany batch and keeps payloads aligned", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + const payloads = yield* events.publishMany([ + { definition: SyncMessage, data: { id: aggregateID, text: "a" } }, + { definition: SyncMessage, data: { id: aggregateID, text: "a" } }, + { definition: SyncMessage, data: { id: aggregateID, text: "b" } }, + ]) + const rows = yield* db + .select({ seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) + + expect(rows).toHaveLength(2) + expect(payloads.map((event) => event.data)).toEqual([ + { id: aggregateID, text: "a" }, + { id: aggregateID, text: "a" }, + { id: aggregateID, text: "b" }, + ]) + expect(payloads.map((event) => event.durable?.seq)).toEqual([0, undefined, 1]) + }), + ) + + it.effect("runs projectors and commit hooks only for persisted appends", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const projected = new Array() + yield* events.project(SyncMessage, (event) => + Effect.sync(() => { + projected.push(event) + }), + ) + const commits = new Array() + const aggregateID = EventV2.ID.create() + const publishWithCommit = () => + events.publish( + SyncMessage, + { id: aggregateID, text: "hello" }, + { commit: (seq) => Effect.sync(() => commits.push(seq)) }, + ) + + yield* publishWithCommit() + yield* publishWithCommit() + + expect(projected.map((event) => event.durable?.seq)).toEqual([0]) + expect(commits).toEqual([0]) + }), + ) + + it.effect("never dedupes against legacy rows with a NULL hash", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + + yield* db + .insert(EventSequenceTable) + .values([{ aggregate_id: aggregateID, seq: 0 }]) + .run() + .pipe(Effect.orDie) + yield* db + .insert(EventTable) + .values([ + { + id: EventV2.ID.create(), + aggregate_id: aggregateID, + seq: 0, + type: EventV2.versionedType(SyncMessage.type, 1), + data: { id: aggregateID, text: "legacy" }, + }, + ]) + .run() + .pipe(Effect.orDie) + + const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "legacy" }) + const rows = yield* db + .select({ seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) + + expect(published.durable?.seq).toBe(1) + expect(rows).toHaveLength(2) + }), + ) + + it.effect("replay with an explicit seq is never deduped", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = Session.ID.create() + + yield* events.publish(DurableMessage, durableData(aggregateID, "same")) + yield* events.replay({ + id: EventV2.ID.create(), + type: EventV2.versionedType(DurableMessage.type, 1), + seq: 1, + aggregateID, + data: durableData(aggregateID, "same"), + }) + const rows = yield* db + .select({ seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, aggregateID)) + .all() + .pipe(Effect.orDie) + + expect(rows).toHaveLength(2) + }), + ) + + it.effect("durable readers observe only persisted events after a skipped duplicate", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "zero")) + const fiber = yield* events + .durable({ aggregateID }) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + + yield* events.publish(DurableMessage, durableData(aggregateID, "zero")) + yield* events.publish(DurableMessage, durableData(aggregateID, "one")) + + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ + [0, durableData(aggregateID, "zero")], + [1, durableData(aggregateID, "one")], + ]) + }), + ) + it.effect("replays durable aggregate events after a sequence and tails new events", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index 28fd245a6..4bae9e965 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -72,7 +72,13 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { const exclude = Object.entries(ctx.payload) return yield* db - .select() + .select({ + id: EventTable.id, + aggregate_id: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) .from(EventTable) .where( exclude.length > 0 From 6e3eedf5ab3c8b7275829ee83ea578920870dde9 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 16:51:49 +0800 Subject: [PATCH 08/42] chore: record delivery binding for specgit-bootstrap-wrapper --- .specgit.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 63d268f98..0b0e6d3ea 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: event-idempotency-gate +delivery: specgit-bootstrap-wrapper context: kind: branch - branch: fix/523-event-idempotency-gate + branch: feat/521-specgit-bootstrap-wrapper issues: - - 523 -pr: 527 + - 521 From 39763198537ef6d8709872037141e146f1baf774 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 16:52:11 +0800 Subject: [PATCH 09/42] chore: record delivery binding for specgit-bootstrap-wrapper --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 0b0e6d3ea..155f3af9f 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/521-specgit-bootstrap-wrapper issues: - 521 +pr: 532 From 19882283ae509e0381f4486956b71a5c0d7120c0 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 3 Sep 2026 18:46:26 +0800 Subject: [PATCH 10/42] chore(specgit): add safe bootstrap wrapper --- AGENTS.md | 11 +- script/specgit-bootstrap.sh | 150 +++++++++++++++++ script/specgit-bootstrap.test.sh | 271 +++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 1 deletion(-) create mode 100755 script/specgit-bootstrap.sh create mode 100755 script/specgit-bootstrap.test.sh diff --git a/AGENTS.md b/AGENTS.md index 3a6020512..a4183a403 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ feat/**, fix/** ──PR(Typecheck + Unit Tests 门禁)──▶ dev ──push 新功能开发、Debug 等一切交付范畴恒定走此循环;后续所有工作必须遵守该方案,不得另起流程: 1. **确立条目**:明确条目的内容、范围、类型(`feat`/`fix`/…)。一个 issue = 一个可独立验证的 WHY,无法独立验证的先拆分再立项。 -2. **SpecGit 立项**:`specgit issue ` 创建/复用 issues 批次,确立交付分支与草稿 PR 脚手架(`.specgit.yaml` 绑定);立项前先查重,避免同一 WHY 双开。 +2. **SpecGit 立项**:`script/specgit-bootstrap.sh ` 创建/复用 issues 批次,确立交付分支与草稿 PR 脚手架(`.specgit.yaml` 绑定);立项前先查重,避免同一 WHY 双开。wrapper 是 canonical 入口(见 "SpecGit harness local specializations");直跑裸 `specgit issue` 预期被 harness currency gate 以 `harness_stale` (exit 2) 拒绝。 3. **超流执行**:安排 DAG workflow(超流)承载实现——并行开发 + 多角度 Review + 复合(synthesize),其产出作为交付证据基线。 4. **PR 过门禁**:SpecGit 发起/推进 PR,过 TDD 与 CI 门禁(Typecheck、Unit Tests、DAG gate;`specgit finish` exit 0 是唯一 "done")。 5. **修复门禁问题**:门禁失败在交付分支修代码/测试,永远不削弱门禁本身。 @@ -273,6 +273,15 @@ Kept OUTSIDE the managed block so `specgit init`/`--force` never rewrites them; - The wait script hand-parses `spec_git/policy.yaml` (minimal line-based parse) instead of importing the `yaml` package: no root-reachable `yaml` exists under workspace catalog isolation, so `import { parse } from 'yaml'` would fail to resolve on the runner. - `spec_git/policy.yaml` `required_checks` uses the template's canonical check IDs (`unit-tests`, `e2e-tests`), not display names. +#### specgit-bootstrap wrapper (canonical `specgit issue` entry, #521) + +`script/specgit-bootstrap.sh ` is THE canonical way to run `specgit issue` in this repository. Bare `specgit issue` is expected to fail with `harness_stale` (exit 2) whenever the pinned CLI's harness template moves — the wrapper satisfies that gate safely: it snapshots the full init write surface to a temp dir outside the repo, runs `specgit init --force --no-protect` (hardcoded, offline), then `specgit issue "$@"` with arguments, exit status, and diagnostics passed through verbatim, and restores the specialized bytes above on success and every failure path (EXIT/INT/TERM/HUP), verifying each file byte-for-byte via `git hash-object`. + +- Never run bare `specgit init --force` here: it overwrites the six specialized bytes; the wrapper exists to make that refresh transient. +- Fail-closed rejections: dirty write-surface paths (tracked/staged/untracked) → exit 2 with the offending paths listed; no SpecGit binding (`.specgit.yaml` or `spec_git/policy.yaml` missing) → exit 3; restore hash mismatch → exit 3 with the snapshot kept for forensics. Rejection paths print plain `specgit-bootstrap:` stderr lines and NEVER produce a `--json` envelope. +- The inner `.specgit.yaml` written by `specgit issue` is a legitimate binding artifact and is never rolled back. +- Managed-block guidance referencing bare `specgit issue` commands is superseded by this section for this repository. Behavior tests: `bash script/specgit-bootstrap.test.sh` (stubbed CLI, zero network; not CI-wired). + ## SpecGit delivery harness diff --git a/script/specgit-bootstrap.sh b/script/specgit-bootstrap.sh new file mode 100755 index 000000000..72ec0ebe4 --- /dev/null +++ b/script/specgit-bootstrap.sh @@ -0,0 +1,150 @@ +#!/bin/sh +# specgit-bootstrap — repository-local fail-safe wrapper around `specgit issue` (#521). +# +# Why: `specgit issue` (1.10.1) runs an unconditional harness-currency gate that +# exits 2 (`harness_stale`) unless the managed harness was refreshed by +# `specgit init --force`. But `init --force` overwrites this repository's six +# hand-applied specializations (see AGENTS.md, "SpecGit harness local +# specializations"). This wrapper makes the refresh safe: +# +# 1. refuses to run when any init write-surface path has uncommitted changes +# (tracked, staged, or untracked), or when the repo has no SpecGit binding; +# 2. snapshots every existing write-surface path to a temp directory OUTSIDE +# the repository, recording each file's `git hash-object` content hash; +# 3. runs `specgit init --force --no-protect` (hardcoded, offline; init's +# stdout prose is routed to stderr so a wrapped `--json` call's stdout +# stays exactly one JSON document) then `specgit issue "$@"` with all +# arguments preserved verbatim and stdin/stdout/stderr inherited; +# 4. restores the snapshots on every exit path (EXIT/INT/TERM/HUP) and +# verifies each restored file byte-for-byte against the recorded hash; +# any mismatch is reported loudly and exits 3. +# +# The `.specgit.yaml` binding written/updated by the inner `specgit issue` is a +# legitimate delivery artifact and is never rolled back. Wrapper rejections +# print plain stderr lines prefixed `specgit-bootstrap:` — never a `--json` +# envelope; only the inner CLI receives the wrapped arguments. +# +# Usage: script/specgit-bootstrap.sh +# +# Write surface below mirrors specgit 1.10.1 harness-placement; it is +# version-coupled to the pinned CLI in .github/workflows/specgit-accept.yml. + +set -u + +SURFACE=' +.github/workflows/specgit-accept.yml +AGENTS.md +CLAUDE.md +.opencode/hooks.json +.opencode/hooks/specgit-merge-guard.sh +.git/hooks/pre-push +.husky/_/pre-push +' + +say() { + printf 'specgit-bootstrap: %s\n' "$1" >&2 +} + +REPO=$(git rev-parse --show-toplevel 2>/dev/null) || { + say "not inside a git repository" + exit 3 +} +cd "$REPO" || exit 3 + +# Fail-closed: serve only bound delivery repositories; a fresh repo has no +# specializations to protect, so bare `specgit issue` is fine there. +if [ ! -f .specgit.yaml ] || [ ! -f spec_git/policy.yaml ]; then + say "no SpecGit binding (.specgit.yaml / spec_git/policy.yaml missing) - run bare 'specgit issue' instead" + exit 3 +fi + +# Fail-closed: ambiguous pre-existing changes on the write surface could be +# clobbered by init and could not be told apart from init's own writes. +# shellcheck disable=SC2086 +dirty=$(git status --porcelain -- $SURFACE) +if [ -n "$dirty" ]; then + say "refusing to run - init write-surface paths have uncommitted changes (inner CLI NOT executed):" + printf '%s\n' "$dirty" | sed 's/^/ /' >&2 + say "commit or stash those changes first, then retry" + exit 2 +fi + +SNAP=$(mktemp -d "${TMPDIR:-/tmp}/specgit-bootstrap.XXXXXX") || { + say "cannot create snapshot directory under \${TMPDIR:-/tmp}" + exit 3 +} +mkdir "$SNAP/tree" "$SNAP/hashes" || { + rm -rf "$SNAP" + say "cannot prepare snapshot directory layout" + exit 3 +} + +# shellcheck disable=SC2086 +for rel in $SURFACE; do + [ -f "$rel" ] || continue + mkdir -p "$SNAP/tree/$(dirname "$rel")" "$SNAP/hashes/$(dirname "$rel")" || { + rm -rf "$SNAP" + say "cannot stage snapshot for $rel" + exit 3 + } + cp "$rel" "$SNAP/tree/$rel" || { + rm -rf "$SNAP" + say "snapshot copy failed for $rel" + exit 3 + } + git hash-object -- "$rel" > "$SNAP/hashes/$rel" || { + rm -rf "$SNAP" + say "content hash failed for $rel" + exit 3 + } +done + +RESTORED=0 + +# Idempotent restore + byte verification. On mismatch the snapshot directory +# is KEPT for forensics and the wrapper exits 3 (fail-closed, aligning with +# the CLI's exit contract for "cannot proceed"). +restore_all() { + [ "$RESTORED" -eq 1 ] && return 0 + RESTORED=1 + mismatched=0 + # shellcheck disable=SC2086 + for rel in $SURFACE; do + [ -f "$SNAP/tree/$rel" ] || continue + cp "$SNAP/tree/$rel" "$rel" + now=$(git hash-object -- "$rel" 2>/dev/null) + want=$(cat "$SNAP/hashes/$rel" 2>/dev/null) + if [ "$now" != "$want" ]; then + printf 'specgit-bootstrap: RESTORE MISMATCH for %s (got %s, expected %s)\n' \ + "$rel" "${now:-}" "${want:-}" >&2 + mismatched=1 + fi + done + if [ "$mismatched" -eq 1 ]; then + say "restored bytes differ from pre-run snapshots - specialized harness bytes may be corrupted." + say "snapshot kept for forensics at $SNAP; inspect 'git diff' before continuing." + trap - EXIT + exit 3 + fi + rm -rf "$SNAP" +} + +trap 'restore_all' EXIT +trap 'restore_all; exit 129' HUP +trap 'restore_all; exit 130' INT +trap 'restore_all; exit 143' TERM + +# init's stdout prose must not pollute the wrapped --json parse surface. +specgit init --force --no-protect >&2 +init_status=$? +if [ "$init_status" -ne 0 ]; then + say "specgit init --force --no-protect failed (exit $init_status); restoring harness bytes" + restore_all + exit "$init_status" +fi + +# All arguments pass through verbatim; exit status and diagnostics inherit. +specgit issue "$@" +issue_status=$? +restore_all +exit "$issue_status" diff --git a/script/specgit-bootstrap.test.sh b/script/specgit-bootstrap.test.sh new file mode 100755 index 000000000..ddb600c94 --- /dev/null +++ b/script/specgit-bootstrap.test.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2015,SC2329 +# ok/bad always return 0, so `cond && ok .. || bad ..` cannot mis-fire (SC2015); +# cleanup() runs via the EXIT trap, which shellcheck does not count (SC2329). +# Behavior tests for script/specgit-bootstrap.sh (#521). +# +# Zero network, zero forge: `specgit` is a stub placed first on PATH; every +# fixture is a throwaway git repo under $TMPDIR. The real repository is never +# touched: wrapper invocations run with cwd set to the fixture, and all stub +# artifacts (log, captured output) live outside the fixture worktree. +# +# Not wired into CI (#521 scope): run manually from anywhere via +# bash script/specgit-bootstrap.test.sh +set -u + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +WRAPPER="$ROOT/script/specgit-bootstrap.sh" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/specgit-bootstrap-test.XXXXXX") +PASS=0 +FAIL=0 + +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +if command -v sha256sum >/dev/null 2>&1; then + digest() { sha256sum "$1" | cut -d' ' -f1; } +else + digest() { shasum -a 256 "$1" | cut -d' ' -f1; } +fi + +SURFACE_FILES=( + .github/workflows/specgit-accept.yml + AGENTS.md + .opencode/hooks.json + .opencode/hooks/specgit-merge-guard.sh + .git/hooks/pre-push +) + +ok() { PASS=$((PASS + 1)); printf 'ok - %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf 'FAIL - %s\n' "$1"; } + +surface_digests() { # + local fx="$1" f + for f in "${SURFACE_FILES[@]}"; do + printf '%s %s\n' "$(digest "$fx/$f")" "$f" + done +} + +assert_surface_restored() { # + diff <(surface_digests "$1") "$2" >/dev/null +} + +assert_clean() { # + [ -z "$(git -C "$1" status --porcelain)" ] +} + +assert_rc() { #