Compare commits

...
Author SHA1 Message Date
Kit Langton 9a366c3cab refactor(core): rename the execution claim column 2026-09-02 21:07:28 -04:00
12 changed files with 219 additions and 45 deletions
+7 -7
View File
@@ -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",
@@ -1406,7 +1406,7 @@
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_suspended",
"name": "execution_claimed_at",
"entityType": "columns",
"table": "session_v2"
},
@@ -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
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260903010538_execution_claimed_at.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -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
+2 -2
View File
@@ -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
);
@@ -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;`,
)
})
},
@@ -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},
+5 -5
View File
@@ -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`),
],
)
+6 -6
View File
@@ -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,
@@ -205,15 +205,15 @@ 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)
@@ -221,10 +221,10 @@ const layer = Layer.effect(
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,
),
@@ -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" }])
}),
)
})
+1 -1
View File
@@ -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.
+22 -16
View File
@@ -63,10 +63,10 @@ describe("SessionExecution lifecycle", () => {
const parent = Session.ID.make("ses_recover_parent")
const child = Session.ID.make("ses_recover_child")
const idle = Session.ID.make("ses_recover_idle")
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now() })
yield* seedSessions(database, [idle])
// Children recover through background Job records, never through the root claim sweep.
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
yield* seedSessions(database, [child], { execution_claimed_at: Date.now(), parent_id: parent })
expect(yield* store.listSuspended()).toEqual([parent])
@@ -224,7 +224,7 @@ describe("SessionExecution lifecycle", () => {
Effect.gen(function* () {
const database = yield* Database.Service
const sessionIDs = Array.from({ length: 5 }, (_, index) => Session.ID.make(`ses_resume_concurrent_${index}`))
yield* seedSessions(database, sessionIDs, { time_suspended: Date.now() })
yield* seedSessions(database, sessionIDs, { execution_claimed_at: Date.now() })
const fourStarted = yield* Deferred.make<void>()
const started: Session.ID[] = []
@@ -251,7 +251,7 @@ describe("SessionExecution lifecycle", () => {
const bus = yield* Bus.Service
const first = Session.ID.make("ses_resume_first")
const second = Session.ID.make("ses_resume_second")
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
yield* seedSessions(database, [first, second], { execution_claimed_at: Date.now() })
const drained: string[] = []
const bothDraining = yield* Deferred.make<void>()
@@ -297,7 +297,7 @@ describe("SessionExecution lifecycle", () => {
const bus = yield* Bus.Service
const sessionID = Session.ID.make("ses_resume_exhausted")
// A claim from a dead process, already resumed twice without completing.
yield* seedSessions(database, [sessionID], { time_suspended: Date.now(), resume_attempts: 2 })
yield* seedSessions(database, [sessionID], { execution_claimed_at: Date.now(), resume_attempts: 2 })
const drained: string[] = []
const failures: SessionEvent.Execution.Failed[] = []
@@ -322,7 +322,7 @@ describe("SessionExecution lifecycle", () => {
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_resume_counted")
yield* seedSessions(database, [sessionID], { time_suspended: Date.now() })
yield* seedSessions(database, [sessionID], { execution_claimed_at: Date.now() })
const draining = yield* Deferred.make<void>()
const scope = yield* Scope.make()
@@ -388,7 +388,7 @@ describe("SessionRestart background recovery", () => {
const parent = Session.ID.make("ses_background_recovery_parent")
const child = Session.ID.make("ses_background_recovery_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
yield* seedSessions(database, [child], { parent_id: parent, execution_claimed_at: Date.now() })
yield* seedBackground(jobs, parent, [
{ id: "sh_background_orphan", shellID: "sh_background_orphan", command: "sleep 60" },
])
@@ -600,7 +600,7 @@ describe("SessionRestart background recovery", () => {
const store = yield* SessionStore.Service
const bus = yield* Bus.Service
const parent = Session.ID.make("ses_background_claimed_parent")
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now() })
yield* seedBackground(jobs, parent, [{ id: "call-claimed-shell", shellID: "sh_claimed", command: "sleep 60" }])
const observed = yield* Deferred.make<string[]>()
@@ -641,7 +641,7 @@ describe("SessionRestart background recovery", () => {
const database = yield* Database.Service
const jobs = yield* Job.Service
const sessionID = Session.ID.make("ses_shell_recovery_exhausted")
yield* seedSessions(database, [sessionID], { time_suspended: Date.now(), resume_attempts: 2 })
yield* seedSessions(database, [sessionID], { execution_claimed_at: Date.now(), resume_attempts: 2 })
yield* seedBackground(jobs, sessionID, [
{ id: "call-exhausted-shell", shellID: "sh_exhausted", command: "sleep 60" },
])
@@ -688,7 +688,7 @@ describe("SessionRestart background recovery", () => {
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], {
parent_id: parent,
time_suspended: Date.now(),
execution_claimed_at: Date.now(),
resume_attempts: resumeAttempts,
})
const shell = seedBackground(jobs, child, [
@@ -782,8 +782,8 @@ describe("SessionRestart background recovery", () => {
const parent = Session.ID.make("ses_subagent_recovery_parent")
const child = Session.ID.make("ses_subagent_recovery_child")
const unrelated = Session.ID.make("ses_subagent_unrelated_child")
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: 1 })
yield* seedSessions(database, [child, unrelated], { parent_id: parent, time_suspended: Date.now() })
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now(), resume_attempts: 1 })
yield* seedSessions(database, [child, unrelated], { parent_id: parent, execution_claimed_at: Date.now() })
yield* jobs.start({
id: child,
type: "subagent",
@@ -959,7 +959,7 @@ describe("SessionRestart background recovery", () => {
Session.ID.make("ses_subagent_budget_child_1"),
Session.ID.make("ses_subagent_budget_child_2"),
]
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: resumeAttempts })
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now(), resume_attempts: resumeAttempts })
yield* seedSessions(database, children, { parent_id: parent })
const complete = yield* Deferred.make<string>()
for (const child of children) {
@@ -1033,7 +1033,11 @@ describe("SessionRestart background recovery", () => {
const parent = Session.ID.make("ses_subagent_exhausted_parent")
const child = Session.ID.make("ses_subagent_exhausted_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now(), resume_attempts: 2 })
yield* seedSessions(database, [child], {
parent_id: parent,
execution_claimed_at: Date.now(),
resume_attempts: 2,
})
yield* jobs.start({
id: child,
type: "subagent",
@@ -1273,7 +1277,9 @@ function seedInbox(
function seedSessions(
database: Database.Service["Service"],
sessionIDs: ReadonlyArray<Session.ID>,
values: Partial<Pick<typeof SessionTable.$inferInsert, "time_suspended" | "resume_attempts" | "parent_id">> = {},
values: Partial<
Pick<typeof SessionTable.$inferInsert, "execution_claimed_at" | "resume_attempts" | "parent_id">
> = {},
) {
return Effect.gen(function* () {
yield* database.db
@@ -1302,7 +1308,7 @@ function seedSessions(
function claims(database: Database.Service["Service"]) {
return database.db
.select({ id: SessionTable.id, claimed: SessionTable.time_suspended })
.select({ id: SessionTable.id, claimed: SessionTable.execution_claimed_at })
.from(SessionTable)
.all()
.pipe(
+4 -4
View File
@@ -549,19 +549,19 @@ describe("SessionProjector", () => {
Effect.gen(function* () {
const db = yield* seedSession()
const bus = yield* Bus.Service
const suspended = () =>
const claim = () =>
db
.select({ timeSuspended: SessionTable.time_suspended })
.select({ executionClaimedAt: SessionTable.execution_claimed_at })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
expect((yield* suspended())?.timeSuspended).toBeNull()
expect((yield* claim())?.executionClaimedAt).toBeNull()
yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
expect((yield* suspended())?.timeSuspended).toBeNull()
expect((yield* claim())?.executionClaimedAt).toBeNull()
}),
)
+7 -3
View File
@@ -65,7 +65,7 @@ const session = (
idle_outcome: null,
time_compacting: 3,
time_archived: null,
time_suspended: null,
execution_claimed_at: null,
resume_attempts: 0,
...overrides,
})
@@ -870,6 +870,7 @@ describe("V1Migration database workflow", () => {
('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next''s history","time":{"created":12}}'),
('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'),
('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}');
UPDATE session SET time_suspended = 1234 WHERE id = 'ses_next';
`)
source.close()
@@ -901,6 +902,9 @@ describe("V1Migration database workflow", () => {
agent: "build",
model: '{"id":"model","providerID":"provider"}',
})
expect(
yield* db.get(sql`SELECT execution_claimed_at, resume_attempts FROM session_v2 WHERE id = 'ses_next'`),
).toEqual({ execution_claimed_at: 1234, resume_attempts: 0 })
expect(
yield* db
.select({ directory: SessionTable.directory })
@@ -988,8 +992,8 @@ describe("V1Migration database workflow", () => {
const database = yield* Database.Service
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
expect(
yield* database.db.get(sql`SELECT fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_next'`),
).toEqual({ fork_boundary: null, time_suspended: null })
yield* database.db.get(sql`SELECT fork_boundary, execution_claimed_at FROM session_v2 WHERE id = 'ses_next'`),
).toEqual({ fork_boundary: null, execution_claimed_at: null })
expect(
yield* database.db.get(
sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'next-project'`,