@@ -9,6 +9,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
99import { InstanceState } from "@/effect/instance-state"
1010import { EventV2Bridge } from "@/event-v2-bridge"
1111import { DagEvent } from "@opencode-ai/schema/dag-event"
12+ import { SessionEvent } from "@opencode-ai/schema/session-event"
1213import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
1314import { DagStore } from "@opencode-ai/core/dag/store"
1415import { DagLocation } from "../location"
@@ -433,6 +434,13 @@ const serviceLayer = Layer.effect(
433434 // publishing now would leak an inert entry the sweep can no longer
434435 // reach. The ensuring below still clears the recovering reservation.
435436 if ( ! ( yield * DagLocation . ownsWorkflow ( dagID , ctx . directory ) ) ) return
437+ // #270 atomic-admission fence (C3): the durable read above and the
438+ // runtimes publish below are still two statements — a deletion can
439+ // commit between them. Collapse the admission into ONE conditional
440+ // UPDATE that matches only while the row exists and is non-terminal.
441+ // A cascade committed in that final window matches zero rows and the
442+ // adoption aborts here, before it ever publishes an entry.
443+ if ( ! ( yield * store . tryClaimAdoption ( dagID ) ) ) return
436444 runtimes . set ( dagID , entry )
437445 yield * automation . register ( SessionID . make ( wf . sessionId ) , { kind : "dag" , id : dagID } )
438446 // Reconciliation settles every persisted running attempt before the
@@ -552,6 +560,12 @@ const serviceLayer = Layer.effect(
552560 // through. A row cascade-deleted after the first guard must not
553561 // be adopted into an inert entry.
554562 if ( ! ( yield * DagLocation . ownsWorkflow ( dagID , ctx . directory ) ) ) return
563+ // #270 atomic-admission fence (C3, same as recoverWorkflow): the
564+ // durable read and the runtimes publish are two statements a
565+ // deletion can slip between; collapse the admission into one
566+ // conditional UPDATE (exists + non-terminal). A cascade committed
567+ // in the final window matches zero rows and the adoption aborts.
568+ if ( ! ( yield * store . tryClaimAdoption ( dagID ) ) ) return
555569 runtimes . set ( dagID , entry )
556570 yield * automation . register ( SessionID . make ( wf . sessionId ) , { kind : "dag" , id : dagID } )
557571 yield * entry . evalLock . withPermits ( 1 ) (
@@ -1392,6 +1406,52 @@ const serviceLayer = Layer.effect(
13921406 Effect . forkScoped ( { startImmediately : true } ) ,
13931407 )
13941408
1409+ // #269 SessionMoved ownership convergence: the Moved projection
1410+ // (core session projector) re-stamps the session's workflow rows to the
1411+ // destination directory in the SAME durable transaction, so by the time
1412+ // this handler runs the durable rows already agree on ONE directory.
1413+ // Converge the in-memory side: (a) the instance that no LONGER owns the
1414+ // moved session's workflows evicts its stale runtime entries (fail-closed
1415+ // — its directory must not keep acting on them), and (b) the NEW owner
1416+ // re-forks the serialized wake drain so a terminal wake that was wedged
1417+ // behind the old mixed stamps delivers immediately (bounded time) instead
1418+ // of waiting for a fresh idle event or a restart.
1419+ yield * events . subscribe ( SessionEvent . Moved ) . pipe (
1420+ Stream . runForEach ( ( evt ) =>
1421+ Effect . gen ( function * ( ) {
1422+ const sessionID = evt . data . sessionID as string
1423+ // Map iteration is mutation-safe for deletions of visited entries —
1424+ // only entries of THIS session are deleted, each inside its own
1425+ // evalLock. Evict only entries the re-stamp moved AWAY from this
1426+ // instance (ownsWorkflow re-reads the durable row).
1427+ for ( const [ dagID , entry ] of runtimes ) {
1428+ if ( entry . parentSessionID !== sessionID ) continue
1429+ if ( yield * DagLocation . ownsWorkflow ( dagID , ctx . directory ) ) continue
1430+ yield * entry . evalLock . withPermits ( 1 ) (
1431+ Effect . gen ( function * ( ) {
1432+ for ( const [ nodeID , fiber ] of entry . fibers ) {
1433+ const node = yield * store . getNode ( dagID , nodeID )
1434+ yield * abortChild ( nodeID , node ?. childSessionId ?? null ) . pipe ( Effect . ignore )
1435+ yield * Fiber . interrupt ( fiber ) . pipe ( Effect . ignore )
1436+ const watcher = entry . watchers . get ( nodeID )
1437+ if ( watcher ) yield * Fiber . interrupt ( watcher ) . pipe ( Effect . ignore )
1438+ }
1439+ entry . fibers . clear ( )
1440+ entry . watchers . clear ( )
1441+ runtimes . delete ( dagID )
1442+ } ) ,
1443+ )
1444+ }
1445+ // New owner: the re-stamp moved ownership HERE, so wake rows that
1446+ // were wedged (mixed stamps → no owner) are now deliverable.
1447+ if ( yield * DagLocation . ownsSession ( sessionID , ctx . directory ) ) {
1448+ yield * tryDeliverWake ( sessionID ) . pipe ( Effect . ignore , Effect . forkScoped )
1449+ }
1450+ } ) . pipe ( guarded ( "SessionMoved" ) ) ,
1451+ ) ,
1452+ Effect . forkScoped ( { startImmediately : true } ) ,
1453+ )
1454+
13951455 // Install all live event handlers before spawning recovery watchers so
13961456 // a child that settles immediately cannot leave the runtime stale.
13971457 // Orphan-pending sweep first: the WorkflowStarted it publishes for the
0 commit comments