Skip to content
Merged
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
1 change: 1 addition & 0 deletions bun.lock

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

1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
Expand Down
58 changes: 58 additions & 0 deletions packages/app/src/utils/id-wrap-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import { create } from "@opencode-ai/schema/identifier"

const WRAP_BOUNDARY = 1786706395136
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

describe("app identifier seam: 48-bit wrap", () => {
test("ascending ids stay lexicographically ascending across the wrap boundary", () => {
const preWrap = create(false, WRAP_BOUNDARY - 1)
const postWrap = create(false, WRAP_BOUNDARY + 1)
expect(preWrap < postWrap).toBe(true)
})

test("descending ids stay lexicographically descending across the wrap boundary", () => {
const preWrap = create(true, WRAP_BOUNDARY - 1)
const postWrap = create(true, WRAP_BOUNDARY + 1)
expect(postWrap < preWrap).toBe(true)
})

test("same-millisecond ids are strictly ascending and unique", () => {
const ids = Array.from({ length: 10 }, (_, index) => create(false, WRAP_BOUNDARY + 1000 + index))
for (let i = 1; i < ids.length; i++) {
expect(ids[i - 1] < ids[i]).toBe(true)
}
expect(new Set(ids).size).toBe(ids.length)
})

test("same-millisecond descending ids are strictly descending and unique", () => {
const ids = Array.from({ length: 10 }, (_, index) => create(true, WRAP_BOUNDARY + 2000 + index))
for (let i = 1; i < ids.length; i++) {
expect(ids[i - 1] > ids[i]).toBe(true)
}
expect(new Set(ids).size).toBe(ids.length)
})

test("id format is 12 hex chars plus 14 base62 chars", () => {
const id = create(false, WRAP_BOUNDARY + 1)
expect(id).toHaveLength(26)
expect(id.slice(0, 12)).toMatch(/^[0-9a-f]{12}$/)
for (const char of id.slice(12)) {
expect(chars).toContain(char)
}
})

test("ids stay ascending when the clock regresses (latch absorbs regression)", () => {
const first = create(false, WRAP_BOUNDARY + 3000)
const regressed = create(false, WRAP_BOUNDARY + 3000 - 50)
const later = create(false, WRAP_BOUNDARY + 3000 + 50)
expect(first < regressed).toBe(true)
expect(regressed < later).toBe(true)
})

test("new-scheme ids sort below historical pre-wrap ids (comparisons must be time-based)", () => {
const now = create(false, Date.now())
const historical = create(false, WRAP_BOUNDARY - 1)
expect(now < historical).toBe(true)
})
})
75 changes: 75 additions & 0 deletions packages/app/src/utils/id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test"
import { create } from "@opencode-ai/core/id/id"
import { Identifier } from "./id"

const prefixes = {
session: "ses",
message: "msg",
permission: "per",
user: "usr",
part: "prt",
pty: "pty",
} as const

function decodeTime(id: string): number {
const start = id.indexOf("_") + 1
return Number(BigInt("0x" + id.slice(start, start + 12)))
}

describe("Identifier", () => {
test("every prefix keeps the prefix_underscore_26-char shape", () => {
for (const prefix of ["session", "message", "permission", "user", "part", "pty"] as const) {
const ascending = Identifier.ascending(prefix)
const descending = Identifier.descending(prefix)
expect(ascending.startsWith(`${prefixes[prefix]}_`)).toBe(true)
expect(descending.startsWith(`${prefixes[prefix]}_`)).toBe(true)
for (const id of [ascending, descending]) {
expect(id).toHaveLength(prefixes[prefix].length + 1 + 26)
expect(id.slice(prefixes[prefix].length + 1, prefixes[prefix].length + 13)).toMatch(/^[0-9a-f]{12}$/)
}
}
})

test("ascending ids decode their time prefix back to raw wall-clock ms", () => {
// The legacy encoding shifted the millisecond value 12 bits and wrapped at
// 2026-08-14 (issue 271), so under it a live id no longer decodes near
// Date.now().
const before = Date.now()
const id = Identifier.ascending("message")
const after = Date.now()
const decoded = decodeTime(id)
expect(decoded).toBeGreaterThanOrEqual(before - 5000)
expect(decoded).toBeLessThanOrEqual(after + 5000)
})

test("app ids sort consistently with core ids for the same wall-clock", () => {
const appID = Identifier.ascending("message")
const coreID = create("msg", "ascending")
expect(Math.abs(decodeTime(appID) - decodeTime(coreID))).toBeLessThanOrEqual(5000)
})

test("same-millisecond bursts stay strictly ascending and unique", () => {
const ids = Array.from({ length: 20 }, () => Identifier.ascending("message"))
for (let index = 1; index < ids.length; index++) {
expect(ids[index - 1] < ids[index]).toBe(true)
}
expect(new Set(ids).size).toBe(ids.length)
})

test("descending ids stay strictly descending and unique", () => {
const ids = Array.from({ length: 20 }, () => Identifier.descending("session"))
for (let index = 1; index < ids.length; index++) {
expect(ids[index - 1] > ids[index]).toBe(true)
}
expect(new Set(ids).size).toBe(ids.length)
})

test("given id passes through when the prefix matches", () => {
expect(Identifier.ascending("session", "ses_abc123")).toBe("ses_abc123")
expect(Identifier.descending("message", "msg_abc123")).toBe("msg_abc123")
})

test("given id with a wrong prefix throws", () => {
expect(() => Identifier.ascending("message", "ses_abc123")).toThrow("ID ses_abc123 does not start with msg")
})
})
66 changes: 3 additions & 63 deletions packages/app/src/utils/id.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { create } from "@opencode-ai/schema/identifier"

const prefixes = {
session: "ses",
message: "msg",
Expand All @@ -7,10 +9,6 @@ const prefixes = {
pty: "pty",
} as const

const LENGTH = 26
let lastTimestamp = 0
let counter = 0

type Prefix = keyof typeof prefixes
export namespace Identifier {
export function ascending(prefix: Prefix, given?: string) {
Expand All @@ -24,7 +22,7 @@ export namespace Identifier {

function generateID(prefix: Prefix, descending: boolean, given?: string): string {
if (!given) {
return create(prefix, descending)
return prefixes[prefix] + "_" + create(descending)
}

if (!given.startsWith(prefixes[prefix])) {
Expand All @@ -33,61 +31,3 @@ function generateID(prefix: Prefix, descending: boolean, given?: string): string

return given
}

function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()

if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}

counter += 1

let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)

if (descending) {
now = ~now
}

const timeBytes = new Uint8Array(6)
for (let i = 0; i < 6; i += 1) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}

return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
}

function bytesToHex(bytes: Uint8Array): string {
let hex = ""
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0")
}
return hex
}

function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const bytes = getRandomBytes(length)
let result = ""
for (let i = 0; i < length; i += 1) {
result += chars[bytes[i] % 62]
}
return result
}

function getRandomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length)
const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined

if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
cryptoObj.getRandomValues(bytes)
return bytes
}

for (let i = 0; i < length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}

return bytes
}
15 changes: 10 additions & 5 deletions packages/core/src/dag/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,16 @@ export const WorkflowTable = sqliteTable(
session_id: text()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
// Execution-location key (DAG-LOC-01): the creating instance's directory,
// stamped at dag.create. Only the instance whose directory matches may
// adopt, recover, wake, or spawn for this workflow. Nullable: legacy rows
// predating the column match no instance (conservative — never adopted
// until recreated).
// Execution-location key (DAG-LOC-01): the directory that owns this workflow.
// Only the instance whose directory matches may adopt, recover, wake, or spawn
// for it. TWO-WRITER WHITELIST (#269): the stamp is written at dag.create
// (WorkflowCreated projection INSERT, onConflictDoNothing — a replay can never
// rewrite an existing stamp) and re-stamped ONLY by the session projector's
// SessionEvent.Moved projection (the stamp moves WITH the session in one
// transaction, payload-sourced from the Moved event — no SessionTable read).
// No other writer may set it; R7-ext pins the whitelist. Nullable: legacy rows
// predating the column match no instance (fail-closed — never adopted until
// recreated).
directory: text(),
title: text().notNull(),
status: text().notNull(),
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/dag/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const wakeDeliverableNodePredicate = or(

export interface Interface {
readonly getWorkflow: (id: string) => Effect.Effect<WorkflowRow | undefined>
readonly tryClaimAdoption: (id: string) => Effect.Effect<boolean>
readonly listWorkflows: () => Effect.Effect<WorkflowRow[]>
readonly listBySession: (sessionId: string) => Effect.Effect<WorkflowRow[]>
readonly listByProject: (projectId: string) => Effect.Effect<WorkflowRow[]>
Expand Down Expand Up @@ -182,6 +183,31 @@ export const layer = Layer.effect(
return row ? mapWorkflow(row) : undefined
}),

// #270 atomic-adoption fence (C2). The adoption sites previously re-read the
// row (ownsWorkflow) and then published their entry into the in-memory map —
// a check-then-act pair a deletion cascade could commit between. The claim
// collapses the admission into ONE conditional UPDATE: it matches the row
// only while the row STILL EXISTS and is in an adoptable (non-terminal)
// status, and returns whether it claimed. A Session.remove (FK cascade) or a
// terminal transition that commits before the claim therefore makes the claim
// match zero rows and the adoption aborts atomically — no post-deletion
// admission survives. Directory ownership is NOT re-asserted here: the caller
// has already passed DagLocation.ownsWorkflow, which canonicalizes both sides;
// duplicating a directory comparison in SQL would diverge from that
// canonicalization (create stamps are realpathed, Moved re-stamps are not),
// so status conditionality is the fence and the read authority keeps the
// directory key. No lease column — the status conditionality IS the claim.
tryClaimAdoption: Effect.fn("DagStore.tryClaimAdoption")(function* (id) {
const claimed = yield* db
.update(WorkflowTable)
.set({ time_updated: Date.now() })
.where(and(eq(WorkflowTable.id, id), inArray(WorkflowTable.status, ["pending", "running", "paused", "stepping"])))
.returning({ id: WorkflowTable.id })
.get()
.pipe(Effect.orDie)
return claimed !== undefined
}),

listWorkflows: Effect.fn("DagStore.listWorkflows")(function* () {
const rows = yield* db.select().from(WorkflowTable).orderBy(desc(WorkflowTable.time_created)).all().pipe(Effect.orDie)
return rows.map(mapWorkflow)
Expand Down
1 change: 1 addition & 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,35 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"

export default {
id: "20260815083000_workflow_directory_convergence",
up(tx) {
return Effect.gen(function* () {
// #269 atomic-adoption convergence (C6). The execution-location stamp now
// moves WITH the session at SessionEvent.Moved time (session projector),
// but installs that moved a session BEFORE that transition shipped carry
// divergent stamps: the session row points at the destination directory
// while its pre-move workflow rows stay pinned at the old one. With the
// fail-closed ownership conjunct, those mixed stamps leave the session's
// wakes with NO owner (the wedge from v1.0.13). Converge every NON-NULL
// workflow stamp to its session's CURRENT directory — the same direction
// the live Moved re-stamp moves it. No ALTER: the directory column already
// exists. Preserves the fail-closed invariants: a NULL stamp (legacy row
// never backfilled) stays NULL, and a workflow whose session has no
// directory is left untouched. Idempotent — re-running converges to the
// same state.
yield* tx.run(`
UPDATE \`workflow\`
SET \`directory\` = (
SELECT \`directory\` FROM \`session\` WHERE \`session\`.\`id\` = \`workflow\`.\`session_id\`
)
WHERE \`workflow\`.\`directory\` IS NOT NULL
AND EXISTS (
SELECT 1 FROM \`session\`
WHERE \`session\`.\`id\` = \`workflow\`.\`session_id\`
AND \`session\`.\`directory\` IS NOT NULL
);
`)
})
},
} satisfies DatabaseMigration.Migration
16 changes: 16 additions & 0 deletions packages/core/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input"
import { WorkspaceV2 } from "../workspace"
import { SessionContextEpoch } from "./context-epoch"
import { WorkflowTable } from "../dag/sql"
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
import { SessionMessageID } from "./message-id"
Expand Down Expand Up @@ -254,6 +255,21 @@ export const layer = Layer.effectDiscard(
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
// #269 atomic-adoption resolution: the execution-location stamp must move
// WITH the session in one transaction. Re-stamp every durable workflow
// row of the session to the payload-sourced destination directory (NO
// SessionTable read — the Moved payload carries it). This runs inside the
// same durable publish transaction as the SessionTable update, so there is
// never a window where the session's rows carry mixed stamps (fail-closed
// ownership would otherwise wedge every wake for the session). This is the
// second whitelisted directory writer (see WorkflowTable.directory): the
// create-time INSERT is the first; only SessionEvent.Moved may re-stamp.
yield* db
.update(WorkflowTable)
.set({ directory: event.data.location.directory })
.where(eq(WorkflowTable.session_id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* SessionContextEpoch.reset(db, event.data.sessionID)
}),
)
Expand Down
Loading
Loading