@@ -23,10 +23,11 @@ import {
2323 type SQL ,
2424 sql ,
2525} from 'drizzle-orm'
26+ import type { DbTransaction } from '@/lib/db/types'
2627import { SIM_TOOL_EXECUTION_VERSION } from '@/lib/mothership/async-runs/lifecycle'
2728import { publishChatStatusChanged } from '@/lib/mothership/chat-status'
29+ import { getChatStreamLockOwners } from '@/lib/mothership/request/session/abort'
2830import { findStreamsWithReplay } from '@/lib/mothership/request/session/buffer'
29- import { findStreamsHoldingChatLock } from '@/lib/mothership/request/session/controller-lease'
3031
3132const logger = createLogger ( 'OrphanedCopilotRuns' )
3233
@@ -110,13 +111,8 @@ const unownedRunColumns = {
110111 controllerToken,
111112}
112113
113- type Transaction = Parameters < Parameters < typeof db . transaction > [ 0 ] > [ 0 ]
114-
115- /** A stopped run ends cancelled; the sweep ends it cancelled only if its user pressed Stop. */
116- function terminalValues ( reason : 'stopped' | 'orphaned' | 'legacy' ) {
117- if ( reason === 'stopped' ) {
118- return { status : sql `'cancelled'::copilot_run_status` , error : sql `NULL::text` }
119- }
114+ /** A run ends cancelled only if its user pressed Stop, whoever settles it. */
115+ function terminalValues ( reason : 'orphaned' | 'legacy' ) {
120116 const error = reason === 'orphaned' ? ORPHANED_RUN_ERROR : LEGACY_RUN_ERROR
121117 return {
122118 status : sql `(CASE WHEN ${ stopRequested } THEN 'cancelled' ELSE 'error' END)::copilot_run_status` ,
@@ -135,9 +131,8 @@ function terminalValues(reason: 'stopped' | 'orphaned' | 'legacy') {
135131 * touching the chat's ordering timestamp.
136132 */
137133async function settleRuns (
138- tx : Transaction ,
134+ tx : DbTransaction ,
139135 runs : UnownedRun [ ] ,
140- reason : 'stopped' | 'orphaned' ,
141136 guard : SQL | undefined
142137) : Promise < UnownedRun [ ] > {
143138 if ( runs . length === 0 ) return [ ]
@@ -182,7 +177,7 @@ async function settleRuns(
182177 for ( const run of runs ) {
183178 if ( run . controllerToken === null ) continue
184179 await apply ( [ run ] , eq ( controllerToken , run . controllerToken ) , {
185- ...terminalValues ( reason ) ,
180+ ...terminalValues ( 'orphaned' ) ,
186181 completedAt : sql `now()` ,
187182 updatedAt : sql `now()` ,
188183 } )
@@ -217,6 +212,23 @@ function announceSettled(runs: UnownedRun[]): void {
217212 }
218213}
219214
215+ /**
216+ * The streams among these whose own controller holds its chat lock, under any token: a
217+ * recovering controller locks the chat before it claims the run. Throws unless the
218+ * locks were read, since otherwise no stream is provably unowned.
219+ */
220+ async function findStreamsHoldingChatLock (
221+ runs : Array < { chatId : string ; streamId : string } >
222+ ) : Promise < Set < string > > {
223+ const { status, ownersByChatId } = await getChatStreamLockOwners ( [
224+ ...new Set ( runs . map ( ( run ) => run . chatId ) ) ,
225+ ] )
226+ if ( status !== 'verified' ) throw new Error ( 'Chat stream locks are unreadable' )
227+ return new Set (
228+ runs . filter ( ( run ) => ownersByChatId . get ( run . chatId ) === run . streamId ) . map ( ( run ) => run . streamId )
229+ )
230+ }
231+
220232/** The candidates no controller owns; leased runs are skipped when ownership is unreadable. */
221233async function withoutOwners ( candidates : UnownedRun [ ] ) : Promise < UnownedRun [ ] > {
222234 const leased = candidates . filter ( ( run ) => run . controllerToken !== null )
@@ -268,7 +280,7 @@ export async function sweepOrphanedRuns(): Promise<{ settledRunIds: string[] }>
268280
269281 try {
270282 const unowned = await withoutOwners ( candidates )
271- const settled = await db . transaction ( ( tx ) => settleRuns ( tx , unowned , 'orphaned' , orphanIdle ) )
283+ const settled = await db . transaction ( ( tx ) => settleRuns ( tx , unowned , orphanIdle ) )
272284 announceSettled ( settled . filter ( ( run ) => run . controllerToken !== null ) )
273285 settledRunIds . push ( ...settled . map ( ( run ) => run . id ) )
274286 } catch ( error ) {
@@ -289,7 +301,8 @@ export async function sweepOrphanedRuns(): Promise<{ settledRunIds: string[] }>
289301
290302/**
291303 * Settles a stopped run as cancelled when no controller of its stream holds the chat
292- * lock. A live controller observes the Stop and settles its own run.
304+ * lock. A live controller observes the Stop and settles its own run. The update itself
305+ * requires the recorded Stop, so this can never settle a run nobody stopped.
293306 */
294307export async function settleStoppedRunWithoutController ( runId : string ) : Promise < boolean > {
295308 const [ run ] = await db
@@ -299,7 +312,7 @@ export async function settleStoppedRunWithoutController(runId: string): Promise<
299312 . limit ( 1 )
300313 if ( ! run ?. controllerToken ) return false
301314 if ( ( await findStreamsHoldingChatLock ( [ run ] ) ) . has ( run . streamId ) ) return false
302- const settled = await db . transaction ( ( tx ) => settleRuns ( tx , [ run ] , 'stopped' , undefined ) )
315+ const settled = await db . transaction ( ( tx ) => settleRuns ( tx , [ run ] , stopRequested ) )
303316 announceSettled ( settled )
304317 return settled . length > 0
305318}
0 commit comments