Skip to content

Commit e333fdb

Browse files
committed
fix(mothership): time the lock lapse from its acquisition, type the re-run predicates as conditions, revoke access mid-recovery in the test
1 parent fe57d22 commit e333fdb

5 files changed

Lines changed: 81 additions & 18 deletions

File tree

‎apps/sim/lib/mothership/async-runs/repository.ts‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
like,
2525
lt,
2626
ne,
27+
not,
2728
notInArray,
2829
or,
2930
type SQL,
@@ -733,12 +734,23 @@ export type ToolExecutionClaim =
733734
/** Already claimed or settled. */
734735
| { outcome: 'existing' }
735736

737+
/**
738+
* Every condition at once, typed as a condition: drizzle's `and` may return undefined, which a
739+
* negation would render as `NOT (NULL)` and match nothing.
740+
*/
741+
function allOf(...conditions: SQL[]): SQL {
742+
return sql`(${sql.join(
743+
conditions.map((condition) => sql`(${condition})`),
744+
sql` and `
745+
)})`
746+
}
747+
736748
/**
737749
* A Sim handler's execution of a call that has no result yet. Only a Sim handler sets
738750
* `executionStartedAt` without a browser workflow execution, so desktop and browser
739751
* executions, which settle their own lapses, never match.
740752
*/
741-
const unfinishedSimExecution = and(
753+
const unfinishedSimExecution = allOf(
742754
eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.running),
743755
isNotNull(copilotAsyncToolCalls.executionStartedAt),
744756
isNull(copilotAsyncToolCalls.executionSettledAt),
@@ -751,7 +763,7 @@ const unfinishedSimExecution = and(
751763
* owner's heartbeat never lets happen. A takeover runs it again until it has started
752764
* {@link SIM_TOOL_EXECUTION_MAX_ATTEMPTS} times.
753765
*/
754-
const interruptedSimExecution = and(
766+
const interruptedSimExecution = allOf(
755767
unfinishedSimExecution,
756768
sql`${copilotAsyncToolCalls.executionLeaseExpiresAt} <= clock_timestamp()`,
757769
lt(copilotAsyncToolCalls.executionAttempts, SIM_TOOL_EXECUTION_MAX_ATTEMPTS)
@@ -761,7 +773,7 @@ const interruptedSimExecution = and(
761773
* An interrupted execution a takeover may still run again: its run still admits tools. Another
762774
* call's wait or workbench access must not revoke it as interrupted before that re-run.
763775
*/
764-
const rerunnableSimExecution = and(
776+
const rerunnableSimExecution = allOf(
765777
interruptedSimExecution,
766778
sql`EXISTS (SELECT 1 FROM ${copilotRuns} r WHERE r.id = ${copilotAsyncToolCalls.runId} AND r.tool_admission_closed_at IS NULL AND r.status NOT IN (${sql.join(
767779
TERMINAL_RUN_STATUSES.map((status) => sql`${status}`),
@@ -1021,10 +1033,7 @@ export async function revokeExpiredSimToolExecutions(input: {
10211033
tx,
10221034
and(
10231035
sql`EXISTS (SELECT 1 FROM ${copilotRuns} r WHERE r.id = ${copilotAsyncToolCalls.runId} AND r.id = ${input.runId}::uuid AND r.user_id = ${input.userId})`,
1024-
or(
1025-
eq(copilotAsyncToolCalls.toolCallId, input.toolCallId),
1026-
sql`NOT (${rerunnableSimExecution})`
1027-
)
1036+
or(eq(copilotAsyncToolCalls.toolCallId, input.toolCallId), not(rerunnableSimExecution))
10281037
)
10291038
)
10301039
)
@@ -1387,7 +1396,7 @@ export async function prepareWorkbenchAccess(
13871396
and(
13881397
sql`EXISTS (SELECT 1 FROM ${copilotRuns} r WHERE r.id = ${copilotAsyncToolCalls.runId} AND r.chat_id = ${owner.chatId} AND r.user_id = ${input.userId})`,
13891398
/** This run was just verified to admit tools; every older run's admission just closed. */
1390-
sql`NOT (${and(eq(copilotAsyncToolCalls.runId, input.runId), interruptedSimExecution)})`
1399+
not(allOf(eq(copilotAsyncToolCalls.runId, input.runId), interruptedSimExecution))
13911400
)
13921401
)
13931402
const previous = await tx

‎apps/sim/lib/mothership/request/application/abandoned-runs.integration.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,27 @@ const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => {
5757
vi.mock('@/lib/auth', () => authMock)
5858
vi.mock('@/lib/core/config/env-flags', () => envFlagsMock)
5959
vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
60-
/** Recovery's own workspace re-check, so a test can lose access after authorization passed. */
61-
const { workspacePermission } = vi.hoisted(() => ({ workspacePermission: { revoked: false } }))
60+
/**
61+
* Recovery's own workspace re-check runs only after authorization passed; `beforeRecheck` lets
62+
* a test change real access at exactly that point.
63+
*/
64+
const { workspacePermission } = vi.hoisted(() => ({
65+
workspacePermission: {
66+
rechecks: 0,
67+
beforeRecheck: undefined as (() => Promise<void>) | undefined,
68+
},
69+
}))
6270
vi.mock('@/lib/workspaces/permissions/utils', async (importOriginal) => {
6371
const original = await importOriginal<typeof import('@/lib/workspaces/permissions/utils')>()
6472
return {
6573
...original,
66-
getUserEntityPermissions: (...args: Parameters<typeof original.getUserEntityPermissions>) =>
67-
workspacePermission.revoked
68-
? Promise.resolve(null)
69-
: original.getUserEntityPermissions(...args),
74+
getUserEntityPermissions: async (
75+
...args: Parameters<typeof original.getUserEntityPermissions>
76+
) => {
77+
workspacePermission.rechecks++
78+
await workspacePermission.beforeRecheck?.()
79+
return original.getUserEntityPermissions(...args)
80+
},
7081
}
7182
})
7283

@@ -344,7 +355,8 @@ describe.runIf(Boolean(redisUrl))('taking over a run whose controller died', ()
344355
worker.requests.length = 0
345356
resetEnvFlagsMock()
346357
featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false)
347-
workspacePermission.revoked = false
358+
workspacePermission.rechecks = 0
359+
workspacePermission.beforeRecheck = undefined
348360
await redis().del(ABANDONED_RUN_CURSOR_KEY)
349361
await db
350362
.insert(permissions)
@@ -722,11 +734,18 @@ describe.runIf(Boolean(redisUrl))('taking over a run whose controller died', ()
722734
})
723735

724736
it('settles the run as revoked when access is lost after authorization passed', async () => {
725-
workspacePermission.revoked = true
726737
const { streamId, runId, toolCallId } = await runAbandonedMidTool()
738+
/** The user is removed from the workspace after authorization admitted them. */
739+
workspacePermission.beforeRecheck = async () => {
740+
await db
741+
.delete(permissions)
742+
.where(and(eq(permissions.userId, userId), eq(permissions.entityId, workspaceId)))
743+
}
727744

728745
await takeOverAbandonedRuns()
729746

747+
expect(workspacePermission.rechecks).toBe(1)
748+
730749
const run = await storedRun(runId)
731750
expect(run.status).toBe('error')
732751
expect(run.error).toBe(REVOKED_RUN_ERROR)

‎apps/sim/lib/mothership/request/session/abort.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,33 @@ describe('startAbortPoller heartbeat', () => {
127127
}
128128
})
129129

130+
it('times the lapse from when the lock was taken, not from when polling started', async () => {
131+
const controller = new AbortController()
132+
const streamId = 'stream-slow-setup'
133+
const chatId = 'chat-slow-setup'
134+
redisConfigMockFns.mockExtendLock.mockRejectedValue(new Error('redis unreachable'))
135+
136+
const interval = startAbortPoller(streamId, controller, {
137+
chatId,
138+
lease: {
139+
key: `copilot:chat-stream-lock:${chatId}`,
140+
value: `${streamId}\ncontroller-1`,
141+
acquiredAt: Date.now() - 50_000,
142+
},
143+
})
144+
145+
try {
146+
await vi.advanceTimersByTimeAsync(9_000)
147+
expect(controller.signal.aborted).toBe(false)
148+
149+
await vi.advanceTimersByTimeAsync(2_000)
150+
expect(controller.signal.aborted).toBe(true)
151+
expect(controller.signal.reason.name).toBe('StreamControllerSupersededError')
152+
} finally {
153+
clearInterval(interval)
154+
}
155+
})
156+
130157
it('aborts the controller before clearing the marker so the marker is never observable as cleared while the signal is still unaborted', async () => {
131158
const controller = new AbortController()
132159
const streamId = 'stream-order-1'

‎apps/sim/lib/mothership/request/session/abort.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,14 +227,19 @@ export async function acquirePendingChatStream(
227227
},
228228
async (span) => {
229229
const redis = getRedisClient()
230-
const lease = { key: chatStreamLockKey(chatId), value: `${streamId}\n${generateId()}` }
230+
const lease: ChatStreamLease = {
231+
key: chatStreamLockKey(chatId),
232+
value: `${streamId}\n${generateId()}`,
233+
}
231234
span.setAttribute(TraceAttr.LockBackend, redis ? AbortBackend.Redis : AbortBackend.InProcess)
232235
if (redis) {
233236
const deadline = Date.now() + timeoutMs
234237
for (;;) {
235238
try {
239+
const attemptedAt = Date.now()
236240
const acquired = await acquireLock(lease.key, lease.value, CHAT_STREAM_LOCK_TTL_SECONDS)
237241
if (acquired) {
242+
lease.acquiredAt = attemptedAt
238243
registerPendingChatStream(chatId, streamId, lease)
239244
span.setAttribute(TraceAttr.LockAcquired, true)
240245
return true
@@ -357,7 +362,8 @@ export function startAbortPoller(
357362
const chatId = options?.chatId
358363
const lease = options?.lease ?? (chatId ? getLocalChatStreamLease(chatId, streamId) : undefined)
359364

360-
let lastHeartbeatAt = Date.now()
365+
/** The lock's TTL runs from its last confirmed write: the acquisition until a renewal lands. */
366+
let lastHeartbeatAt = lease?.acquiredAt ?? Date.now()
361367
let heartbeatOwnershipLost = false
362368
const stopController = (message: string) => {
363369
heartbeatOwnershipLost = true

‎apps/sim/lib/mothership/request/session/controller-lease.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { getRedisClient } from '@/lib/core/config/redis'
44
export interface ChatStreamLease {
55
key: string
66
value: string
7+
/** When the lock was taken (ms epoch, read before the write), so its TTL is known to run from then. */
8+
acquiredAt?: number
79
}
810

911
export class StreamControllerSupersededError extends Error {

0 commit comments

Comments
 (0)