Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions packages/core/schema.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "7f833e4e-de8a-4792-a726-e70daa8b5a4e",
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
"ddl": [
{
"name": "account_state",
Expand Down Expand Up @@ -1406,7 +1406,7 @@
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_suspended",
"name": "execution_claimed_at",
"entityType": "columns",
"table": "session_v2"
},
Expand Down Expand Up @@ -2041,17 +2041,17 @@
{
"columns": [
{
"value": "time_suspended",
"value": "execution_claimed_at",
"isExpression": false
}
],
"isUnique": false,
"where": "\"session_v2\".\"time_suspended\" is not null",
"where": "\"session_v2\".\"execution_claimed_at\" is not null",
"origin": "manual",
"name": "session_v2_time_suspended_idx",
"name": "session_v2_execution_claimed_at_idx",
"entityType": "indexes",
"table": "session_v2"
}
],
"renames": []
"renames": ["session_v2.time_suspended->session_v2.execution_claimed_at"]
}
2 changes: 2 additions & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"

const migration: DatabaseMigration.Migration = {
id: "20260903010538_execution_claimed_at",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` RENAME COLUMN \`time_suspended\` TO \`execution_claimed_at\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_v2_time_suspended_idx\`;`)
yield* tx.run(
`CREATE INDEX \`session_v2_execution_claimed_at_idx\` ON \`session_v2\` (\`execution_claimed_at\`) WHERE "session_v2"."execution_claimed_at" is not null;`,
)
})
},
}

export default migration
4 changes: 2 additions & 2 deletions packages/core/src/database/schema.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`idle_outcome\` text,
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
\`execution_claimed_at\` integer,
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
Expand Down Expand Up @@ -272,7 +272,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
`CREATE INDEX \`session_v2_execution_claimed_at_idx\` ON \`session_v2\` (\`execution_claimed_at\`) WHERE "session_v2"."execution_claimed_at" is not null;`,
)
})
},
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/database/v1-migration.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,7 @@ function importNextDatabase(
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
time_archived, time_suspended
time_archived, execution_claimed_at
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/session/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,17 @@ export const SessionTable = sqliteTable(
idle_outcome: text().$type<NonNullable<Session.Info["outcome"]>>(),
time_compacting: integer(),
time_archived: integer(),
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
time_suspended: integer(),
/** Write-ahead recovery marker; see SessionStore.claim. */
execution_claimed_at: integer(),
resume_attempts: integer().notNull().default(0),
},
(table) => [
index("session_v2_project_idx").on(table.project_id),
index("session_v2_workspace_idx").on(table.workspace_id),
index("session_v2_parent_idx").on(table.parent_id),
index("session_v2_time_suspended_idx")
.on(table.time_suspended)
.where(sql`${table.time_suspended} is not null`),
index("session_v2_execution_claimed_at_idx")
.on(table.execution_claimed_at)
.where(sql`${table.execution_claimed_at} is not null`),
],
)

Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/session/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ const layer = Layer.effect(
return yield* db
.select({ sessionID: SessionTable.id })
.from(SessionTable)
.where(and(isNotNull(SessionTable.time_suspended), isNull(SessionTable.parent_id)))
.where(and(isNotNull(SessionTable.execution_claimed_at), isNull(SessionTable.parent_id)))
.all()
.pipe(
Effect.orDie,
Expand All @@ -205,26 +205,26 @@ const layer = Layer.effect(
// pinned so session ordering only moves on real changes.
yield* db
.update(SessionTable)
.set({ time_suspended: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.time_suspended)))
.set({ execution_claimed_at: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.execution_claimed_at)))
.run()
.pipe(Effect.orDie)
}),
release: Effect.fn("SessionStore.release")(function* (sessionID) {
yield* db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.set({ execution_claimed_at: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
}),
releaseChildClaims: Effect.fn("SessionStore.releaseChildClaims")((recoverable) =>
db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.set({ execution_claimed_at: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(
and(
isNotNull(SessionTable.time_suspended),
isNotNull(SessionTable.execution_claimed_at),
isNotNull(SessionTable.parent_id),
recoverable.length > 0 ? notInArray(SessionTable.id, Array.from(recoverable)) : undefined,
),
Expand Down
145 changes: 145 additions & 0 deletions packages/core/test/execution-claim-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect } from "bun:test"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import { migrations } from "@opencode-ai/core/database/migration.gen"
import executionClaimMigration from "@opencode-ai/core/database/migration/20260903010538_execution_claimed_at"
import { Global } from "@opencode-ai/util/global"
import { sql } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { testEffect } from "./lib/effect"

const it = testEffect(
Layer.mergeAll(
SqliteClient.layer({ filename: ":memory:", disableWAL: true }),
Layer.succeed(Global.Service, Global.make()),
),
)

describe("execution claim column", () => {
it.live("bootstraps the renamed column with an unclaimed default and its partial index", () =>
Effect.gen(function* () {
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
yield* DatabaseMigration.apply(db)
yield* db.run(sql`
INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES ('project', '/repo', 1, 2, '[]')
`)
yield* db.run(sql`
INSERT INTO session_v2 (id, project_id, slug, directory, version, time_created, time_updated)
VALUES ('session', 'project', 'session', '/repo', '2', 1, 2)
`)

expect(yield* db.get(sql`SELECT execution_claimed_at, resume_attempts FROM session_v2`)).toEqual({
execution_claimed_at: null,
resume_attempts: 0,
})
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${executionClaimMigration.id}`)).toEqual({
id: executionClaimMigration.id,
})
expect(
yield* db.all(sql`SELECT name FROM pragma_table_info('session_v2') WHERE name = 'time_suspended'`),
).toEqual([])
expect(
yield* db.get(sql`
SELECT name, partial FROM pragma_index_list('session_v2')
WHERE name = 'session_v2_execution_claimed_at_idx'
`),
).toEqual({ name: "session_v2_execution_claimed_at_idx", partial: 1 })
expect(yield* db.all(sql`SELECT name FROM pragma_index_info('session_v2_execution_claimed_at_idx')`)).toEqual([
{ name: "execution_claimed_at" },
])
yield* DatabaseMigration.apply(db)
}),
)

it.live("migrates existing claims without resetting attempts, activity, or dependent history", () =>
Effect.gen(function* () {
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
yield* db.run(sql`PRAGMA foreign_keys = ON`)
yield* db.run(sql`
CREATE TABLE session_v2 (
id text PRIMARY KEY,
parent_id text,
title text,
time_created integer NOT NULL,
time_updated integer NOT NULL,
time_suspended integer,
resume_attempts integer DEFAULT 0 NOT NULL
)
`)
yield* db.run(sql`
CREATE INDEX session_v2_time_suspended_idx ON session_v2 (time_suspended)
WHERE "session_v2"."time_suspended" IS NOT NULL
`)
yield* db.run(sql`
CREATE TABLE session_message (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session_v2(id) ON DELETE CASCADE,
data text NOT NULL
)
`)
yield* db.run(sql`
INSERT INTO session_v2 VALUES
('claimed', NULL, 'Claimed', 1, 2, 1234, 2),
('child', 'claimed', 'Child', 3, 4, 5678, 1),
('idle', NULL, 'Idle', 5, 6, NULL, 0)
`)
yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'claimed', '{"text":"preserved"}')`)
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* Effect.forEach(
migrations.filter((migration) => migration.id !== executionClaimMigration.id),
(migration) => db.run(sql`INSERT INTO migration VALUES (${migration.id}, 1)`),
)

yield* DatabaseMigration.apply(db)
yield* DatabaseMigration.apply(db)

expect(yield* db.all(sql`SELECT * FROM session_v2 ORDER BY id`)).toEqual([
{
id: "child",
parent_id: "claimed",
title: "Child",
time_created: 3,
time_updated: 4,
execution_claimed_at: 5678,
resume_attempts: 1,
},
{
id: "claimed",
parent_id: null,
title: "Claimed",
time_created: 1,
time_updated: 2,
execution_claimed_at: 1234,
resume_attempts: 2,
},
{
id: "idle",
parent_id: null,
title: "Idle",
time_created: 5,
time_updated: 6,
execution_claimed_at: null,
resume_attempts: 0,
},
])
expect(yield* db.all(sql`SELECT * FROM session_message`)).toEqual([
{ id: "message", session_id: "claimed", data: '{"text":"preserved"}' },
])
expect(yield* db.all(sql`PRAGMA foreign_key_check`)).toEqual([])
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: migrations.length })
expect(
yield* db.all(sql`
SELECT name FROM pragma_index_list('session_v2') WHERE name = 'session_v2_time_suspended_idx'
`),
).toEqual([])
expect(
yield* db.all(sql`
SELECT id FROM session_v2 INDEXED BY session_v2_execution_claimed_at_idx
WHERE execution_claimed_at IS NOT NULL ORDER BY id
`),
).toEqual([{ id: "child" }, { id: "claimed" }])
}),
)
})
2 changes: 1 addition & 1 deletion packages/core/test/session-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ describe("Session.create", () => {
).toMatchObject({
time_created: before.time_created,
time_updated: before.time_updated,
time_suspended: before.time_suspended,
execution_claimed_at: before.execution_claimed_at,
resume_attempts: before.resume_attempts,
})
// Repeated resolution announces the directory's identity exactly once.
Expand Down
Loading
Loading