Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
321b8a7
Added commands for a session nobody is sitting in front of.
moedash Aug 28, 2026
0f00c8d
Added a check that a session belongs to the deployment, not to a client.
moedash Aug 28, 2026
007a25e
Wrote down what makes a session outlive its client.
moedash Aug 28, 2026
f780202
Rebuilt the worktree into a directory that exists but is empty.
moedash Aug 30, 2026
8309358
Proved any-worker resume across two machines, not two processes.
moedash Aug 30, 2026
fba3c4c
Wrote down the two machines and what they caught.
moedash Aug 30, 2026
95bf7bb
Made claiming the event log a compare and set.
moedash Sep 2, 2026
3076522
Guarded the write side of the tree, and bounded the history.
moedash Sep 2, 2026
7e63695
Ordered the tree by its own chain, and stopped losing a tool's writes.
moedash Sep 2, 2026
9df4a8e
Wrote down what the tree rules actually are.
moedash Sep 2, 2026
634c75d
Closed the ways a host got stuck for the rest of a session.
moedash Sep 2, 2026
21e6337
Put the tool call's arguments back on the hand-off.
moedash Sep 2, 2026
2495a4e
Removed a failed rebuild by what the lock saw, not what preceded it.
moedash Sep 4, 2026
b0b42f3
Kept a watch's settling state across a reconnect.
moedash Sep 4, 2026
e38f835
Wrote down why taking the arguments off the hand-off failed.
moedash Sep 4, 2026
19b0ca9
Wrote down how a watch decides the turn is over.
moedash Sep 4, 2026
1884e92
Pinned the claim's compare-and-set under a real interleaving.
moedash Sep 4, 2026
3171c3d
Took the tool arguments off the hand-off, this time correctly.
moedash Sep 4, 2026
a992865
Kept a step on the worker that ran its model call.
moedash Sep 4, 2026
13db8fd
Pinned what a failed rebuild is allowed to remove.
moedash Sep 4, 2026
ed1b76e
Made a deployment something to pick rather than to assemble.
moedash Sep 4, 2026
616ca45
Gave a turn an ending of its own.
moedash Sep 4, 2026
a09556d
Gave a session a start that needs no client.
moedash Sep 4, 2026
bffb802
Kept a plaintext address from refusing to start.
moedash Sep 4, 2026
e0b8c1b
Closed what a fifth review found in dispatch, affinity and schedules.
moedash Sep 7, 2026
442ee26
Said which failures recover on their own, and which need a person.
moedash Sep 7, 2026
974dd00
Let a step whose worker stopped heartbeating move to another one.
moedash Sep 8, 2026
b3426f8
Moved a step off a worker that failed, rather than ending the turn.
moedash Sep 8, 2026
634d7e8
Kept a refused permission from becoming a continued turn.
moedash Sep 8, 2026
1e79925
Refused a directory a tool call never came back from.
moedash Sep 8, 2026
72b3d13
Said which failure the directory refusal answers.
moedash Sep 8, 2026
dbd4c92
Carried the turn past the worker that was running it.
moedash Sep 9, 2026
a6c9737
Wrote down what a lost host now costs.
moedash Sep 9, 2026
99e94b7
Followed a tool that leaves its worker's process group.
moedash Sep 9, 2026
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
26 changes: 13 additions & 13 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,6 @@
}
},
"devDependencies": {
"@opencode-ai/http-recorder": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
Expand All @@ -58,6 +49,15 @@
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@opencode-ai/http-recorder": "workspace:*",
"drizzle-kit": "catalog:"
},
"dependencies": {
Expand Down Expand Up @@ -85,23 +85,23 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@libsql/client": "^0.17.0",
"@lydell/node-pty": "catalog:",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@openrouter/ai-sdk-provider": "2.9.0",
"ai-gateway-provider": "3.2.0",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
Expand All @@ -114,8 +114,8 @@
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"ignore": "7.0.5",
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
Expand Down
98 changes: 91 additions & 7 deletions packages/core/src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Cause, Context, Duration, Effect, Layer, Option, PubSub, Queue, Schema
import { 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, inArray } from "drizzle-orm"
import { and, asc, eq, gt, inArray, isNull } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Flag } from "./flag/flag"
Expand Down Expand Up @@ -188,6 +188,39 @@ export interface LayerOptions {
/** Chosen to be well under what a person notices in a transcript while staying one cheap indexed
* read per subscribed session. In-process commits still wake instantly; this only catches what the
* wake cannot see, so it is worth its cost only where another process writes: see `pollingNode`. */
// Same-run tokens order retries and generated activity IDs. Cross-run age needs a separate epoch.
const supersededBy = (held: string, claimer: string): boolean => {
const split = (token: string) => {
const cut = token.lastIndexOf(":")
const head = token.slice(0, cut)
const idAt = head.lastIndexOf(":")
const id = head.slice(idAt + 1)
return {
run: head.slice(0, idAt),
id,
// Temporal hands out activity ids as an increasing sequence within a run, so they order the
// units of work. A token from an earlier step is a zombie, whatever its attempt number says.
activity: Number(id),
attempt: Number(token.slice(cut + 1)),
}
}
const a = split(held)
const b = split(claimer)
// Different runs cannot be ordered from the tokens alone, and a continue-as-new legitimately
// starts a new one, so those are allowed through. A zombie from a run that rolled over is the
// case this does not cover.
if (a.run !== b.run) return false
const ordered = Number.isInteger(a.activity) && Number.isInteger(b.activity)
// Activity ids are an increasing sequence when Temporal assigns them, but a caller may set its
// own. Without numbers to compare, two different units of work cannot be ordered, and only two
// attempts of the same one can.
if (ordered && a.activity !== b.activity) return a.activity > b.activity
// Compared as written, not as parsed: two ids that are not numbers both parse to NaN, and NaN
// read as equal made every later activity look like a retry of the one before it.
if (!ordered && a.id !== b.id) return false
return Number.isInteger(a.attempt) && Number.isInteger(b.attempt) && a.attempt > b.attempt
}

const DEFAULT_LIVE_POLL = Duration.seconds(1)

// An operator's override, in milliseconds, for either node. Read at layer build rather than at
Expand Down Expand Up @@ -570,13 +603,64 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
}

// A compare and set, not a write. Two attempts of one activity can be alive at once and they
// do not arrive in order: a paused attempt 1 that resumes after attempt 2 has claimed used to
// take the log back, and then every publish from attempt 2's tool activities died on the
// fence for a step that was going fine.
function claim(aggregateID: string, ownerID: string) {
return db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
return Effect.gen(function* () {
const row = yield* db
.select({ ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
// No sequence row yet, so there is nothing to fence and nothing to lose a race to: the
// first publish inserts the row with this owner on it.
if (row === undefined) return
if (row.ownerID != null && supersededBy(row.ownerID, ownerID)) {
yield* Effect.die(
new InvalidDurableEventError({
type: "session.claim",
message: `Stale claim for aggregate ${aggregateID}: held by ${row.ownerID}, claimer ${ownerID}`,
}),
)
}
// Conditional on what was just read, because the read and the write are two statements
// and over a network store they are two requests. Two attempts of one activity reaching
// here together both passed the check above, and an unconditional write let the loser
// land last and fence out the winner's tools.
yield* db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(
and(
eq(EventSequenceTable.aggregate_id, aggregateID),
row.ownerID == null
? isNull(EventSequenceTable.owner_id)
: eq(EventSequenceTable.owner_id, row.ownerID),
),
)
.run()
.pipe(Effect.orDie)
// Read back rather than trusting a driver-specific affected-row count. Losing means
// somebody claimed between the two statements, and a loser that carried on would publish
// under a token the fence rejects.
const after = yield* db
.select({ ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
if (after?.ownerID !== ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
type: "session.claim",
message: `Lost the claim for aggregate ${aggregateID}: held by ${after?.ownerID}, claimer ${ownerID}`,
}),
)
}
})
}

const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Expand Down
Loading
Loading