Skip to content

Commit 8ec6dd4

Browse files
committed
fix(mothership): settle job runs before claiming, keep spend-cap links and failure counts durable
1 parent 1fd25b6 commit 8ec6dd4

15 files changed

Lines changed: 312 additions & 57 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,3 +523,30 @@ describe('CredentialDisplay link tag', () => {
523523
act(() => root.unmount())
524524
})
525525
})
526+
527+
describe('UsageUpgradeDisplay', () => {
528+
it("shows a job run's own spend cap without an account billing action", () => {
529+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
530+
const container = document.createElement('div')
531+
const root = createRoot(container)
532+
act(() => {
533+
root.render(
534+
<SpecialTags
535+
segment={{
536+
type: 'usage_upgrade',
537+
data: {
538+
reason: 'job_run_budget',
539+
action: 'increase_limit',
540+
message: 'This scheduled job run reached its $5.00 spend limit.',
541+
},
542+
}}
543+
/>
544+
)
545+
})
546+
547+
expect(container.textContent).toContain('Run Spend Limit Reached')
548+
expect(container.textContent).toContain('This scheduled job run reached its $5.00 spend limit.')
549+
expect(container.querySelector('a')).toBeNull()
550+
act(() => root.unmount())
551+
})
552+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3443,6 +3443,39 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
34433443
}
34443444

34453445
function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
3446+
return data.reason === 'job_run_budget' ? (
3447+
<JobRunBudgetDisplay message={data.message} />
3448+
) : (
3449+
<PlanUsageLimitDisplay data={data} />
3450+
)
3451+
}
3452+
3453+
/**
3454+
* A scheduled job's run reached its own spend cap. The cap belongs to the job, not to the
3455+
* account, so the card offers no billing action: the next run starts with a fresh cap.
3456+
*/
3457+
function JobRunBudgetDisplay({ message }: { message: string }) {
3458+
return (
3459+
<InteractionCard
3460+
title={
3461+
<span className='inline-flex items-center gap-2'>
3462+
<TriangleAlert
3463+
aria-hidden
3464+
className='size-[14px] shrink-0 text-[var(--badge-amber-text)]'
3465+
/>
3466+
Run Spend Limit Reached
3467+
</span>
3468+
}
3469+
>
3470+
<div className='px-2 pb-2'>
3471+
<p className='text-[var(--text-body)] text-small leading-5'>{message}</p>
3472+
</div>
3473+
</InteractionCard>
3474+
)
3475+
}
3476+
3477+
/** The payer's plan limit: the card links to where the plan or the limit can change. */
3478+
function PlanUsageLimitDisplay({ data }: { data: UsageUpgradeTagData }) {
34463479
const { data: session } = useSession()
34473480
const hostContext = useOptionalWorkspaceHostContext()
34483481
const organizationContext = useOptionalOrganizationContext()

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useQueryClient } from '@tanstack/react-query'
2424
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
2525
import { ComputerUseActivity } from '@/components/computer-use/activity'
2626
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
27+
import type { TurnOrigin } from '@/lib/mothership/generated/protocol'
2728
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
2829
import { inter } from '@/app/_styles/fonts/inter/inter'
2930
import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar'
@@ -187,24 +188,34 @@ interface UserMessageRowProps {
187188
}
188189

189190
/**
190-
* A background-task notification that opened a turn (mothership 21-background-tasks.md
191-
* §6.4): a muted system chip, never a user bubble — the user did not type it. Shows the
192-
* outcome line; the provenance header and the agent's own note stay out of the way.
191+
* A turn automation opened, never a user bubble — the user did not type it: a muted system chip
192+
* with one line. A background-task notification (mothership 21-background-tasks.md §6.4) shows
193+
* its outcome line; a scheduled job's run shows the brief's first line (the job and its run).
193194
*/
194-
const TaskNotificationRow = memo(function TaskNotificationRow({
195+
const AutomatedTurnRow = memo(function AutomatedTurnRow({
196+
origin,
195197
content,
196198
rowClassName,
197199
}: {
200+
origin: TurnOrigin
198201
content: string
199202
rowClassName: string
200203
}) {
201204
const lines = content.split('\n').filter((line) => line.length > 0)
202-
const outcome = lines.find((line) => line.startsWith('Task ')) ?? lines[lines.length - 1] ?? ''
205+
const summary =
206+
origin === 'task'
207+
? (lines.find((line) => line.startsWith('Task ')) ?? lines[lines.length - 1] ?? '').replace(
208+
/^Task [0-9a-f-]+ /,
209+
''
210+
)
211+
: (lines[0] ?? '').replace(/^Job: /, '')
203212
return (
204213
<div className={cn('flex w-full justify-center', rowClassName)}>
205214
<div className='max-w-[85%] rounded-full border border-[var(--border)] bg-[var(--bg)] px-3 py-1 text-[12px] text-[var(--text-secondary)]'>
206-
<span className='mr-1.5 font-medium text-[var(--text-primary)]'>Background task</span>
207-
{outcome.replace(/^Task [0-9a-f-]+ /, '')}
215+
<span className='mr-1.5 font-medium text-[var(--text-primary)]'>
216+
{origin === 'task' ? 'Background task' : 'Scheduled job'}
217+
</span>
218+
{summary}
208219
</div>
209220
</div>
210221
)
@@ -930,8 +941,12 @@ export function MothershipChat({
930941
style={{ top: virtualItem.start }}
931942
>
932943
{msg.role === 'user' ? (
933-
interactionPairing.hiddenUserByIndex[index] ? null : msg.origin === 'task' ? (
934-
<TaskNotificationRow content={msg.content} rowClassName={styles.rowGap} />
944+
interactionPairing.hiddenUserByIndex[index] ? null : msg.origin ? (
945+
<AutomatedTurnRow
946+
origin={msg.origin}
947+
content={msg.content}
948+
rowClassName={styles.rowGap}
949+
/>
935950
) : (
936951
<UserMessageRow
937952
content={msg.content}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.test.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@ describe('useChatFind', () => {
112112
expect(find.current).toMatchObject({ isOpen: false, query: '', count: 0 })
113113
})
114114

115+
it('leaves turns automation opened out of the search, whatever opened them', () => {
116+
const find = renderFind({
117+
messages: [
118+
{ id: 't', role: 'user', origin: 'task', content: 'Task abc finished: triage done.' },
119+
{ id: 'j', role: 'user', origin: 'job', content: 'Job: Nightly triage (Run 1)' },
120+
assistant('a', 'The triage is done.'),
121+
],
122+
})
123+
find.search('triage')
124+
expect(find.current.count).toBe(1)
125+
})
126+
115127
it('counts the same footnoted matches in the index as it highlights in the rendered message', async () => {
116128
const find = renderFind({
117129
chatId: 'chat',

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export function useChatFind({
163163
if (term) {
164164
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) {
165165
const message = messages[messageIndex]
166-
if (!message || hiddenUserByIndex[messageIndex] || message.origin === 'task') continue
166+
if (!message || hiddenUserByIndex[messageIndex] || message.origin) continue
167167
const text = getMessageSearchText(message, textCache)
168168
const occurrences: ChatFindMatch[] = []
169169
let found = 0

‎apps/sim/lib/mothership/jobs/dispatch.ts‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { db } from '@sim/db'
21
import { createLogger } from '@sim/logger'
32
import { getErrorMessage } from '@sim/utils/errors'
43
import {
@@ -9,7 +8,7 @@ import { isHosted } from '@/lib/core/config/env-flags'
98
import { asOrchestrationError } from '@/lib/core/orchestration/types'
109
import { buildJobBrief } from '@/lib/mothership/jobs/brief'
1110
import { jobRunBudgetUsd } from '@/lib/mothership/jobs/budget'
12-
import { settleJobSchedule } from '@/lib/mothership/jobs/finalize'
11+
import { failUnstartedJobRun } from '@/lib/mothership/jobs/finalize'
1312
import {
1413
countJobDispatchFailure,
1514
disableJob,
@@ -119,14 +118,7 @@ export async function dispatchJobOccurrence(executionId: string): Promise<JobDis
119118
error: getErrorMessage(error),
120119
})
121120
if (failures < MAX_JOB_DISPATCH_ATTEMPTS) return 'retry'
122-
if (await endUnstartedJobRun(executionId, 'failed', 'The run could not be started.')) {
123-
if (await settleJobSchedule(db, run.scheduleId, 'failed')) {
124-
await notifyScheduleAutoDisabled({
125-
scheduleId: run.scheduleId,
126-
reason: 'consecutive_failures',
127-
})
128-
}
129-
}
121+
await failUnstartedJobRun(executionId, run.scheduleId, 'The run could not be started.')
130122
return 'failed'
131123
}
132124
}

‎apps/sim/lib/mothership/jobs/finalize.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { and, desc, eq, sql } from 'drizzle-orm'
55
import type { DbOrTx } from '@/lib/db/types'
66
import { TERMINAL_RUN_STATUSES } from '@/lib/mothership/async-runs/repository'
77
import {
8+
endedRunStatus,
9+
endUnstartedJobRun,
810
type JobRunOutcome,
911
MAX_CONSECUTIVE_JOB_FAILURES,
1012
REPORTED_JOB_OUTCOMES,
@@ -19,7 +21,7 @@ const SUMMARY_CHARS = 2_000
1921
* turns the job off. A met `until_complete` job completes. Returns whether the job was turned
2022
* off, so the caller can tell its people.
2123
*/
22-
export async function settleJobSchedule(
24+
async function settleJobSchedule(
2325
tx: DbOrTx,
2426
scheduleId: string,
2527
outcome: JobRunOutcome
@@ -95,7 +97,7 @@ export async function finalizeJobRun(executionId: string): Promise<boolean> {
9597
and(eq(jobExecutionLogs.executionId, executionId), eq(jobExecutionLogs.status, 'running'))
9698
)
9799
.for('update')
98-
if (!record?.scheduleId) return false
100+
if (!record) return false
99101
const [run] = await tx
100102
.select({
101103
status: copilotRuns.status,
@@ -129,15 +131,15 @@ export async function finalizeJobRun(executionId: string): Promise<boolean> {
129131
await tx
130132
.update(jobExecutionLogs)
131133
.set({
132-
status: outcome === 'failed' ? 'error' : 'complete',
133-
level: outcome === 'failed' ? 'error' : 'info',
134+
...endedRunStatus(outcome),
134135
outcome,
135136
summary,
136137
endedAt,
137138
totalDurationMs: Math.max(0, endedAt.getTime() - record.startedAt.getTime()),
138139
})
139140
.where(eq(jobExecutionLogs.executionId, executionId))
140-
if (await settleJobSchedule(tx, record.scheduleId, outcome)) {
141+
/** A record whose job was deleted mid-run still ends; there is no streak left to count. */
142+
if (record.scheduleId && (await settleJobSchedule(tx, record.scheduleId, outcome))) {
141143
disabledScheduleId = record.scheduleId
142144
}
143145
return true
@@ -150,3 +152,19 @@ export async function finalizeJobRun(executionId: string): Promise<boolean> {
150152
}
151153
return settled
152154
}
155+
156+
/**
157+
* Ends an occurrence that could never be started as failed, counting it toward the job's failure
158+
* streak in the same transaction, so a crash cannot close the record without the count.
159+
*/
160+
export async function failUnstartedJobRun(
161+
executionId: string,
162+
scheduleId: string,
163+
summary: string
164+
): Promise<void> {
165+
const disabled = await db.transaction(async (tx) => {
166+
if (!(await endUnstartedJobRun(executionId, 'failed', summary, tx))) return false
167+
return settleJobSchedule(tx, scheduleId, 'failed')
168+
})
169+
if (disabled) await notifyScheduleAutoDisabled({ scheduleId, reason: 'consecutive_failures' })
170+
}

‎apps/sim/lib/mothership/jobs/lane.integration.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,14 +602,67 @@ describe.runIf(Boolean(redisUrl))('the scheduled job lane', () => {
602602
.set({ outcome: 'met', summary: 'Queue empty.' })
603603
.where(eq(jobExecutionLogs.executionId, occurrenceId))
604604
worker.finish(occurrenceId, chatId)
605-
await reconcileUntilSettled(occurrenceId)
605+
await waitFor(async () => {
606+
const [run] = await db
607+
.select({ status: copilotRuns.status })
608+
.from(copilotRuns)
609+
.where(eq(copilotRuns.streamId, occurrenceId))
610+
return run?.status === 'complete'
611+
}, 'the turn to complete')
612+
/** The next occurrence comes due before any pass settled the met run. */
613+
await dueAgain(scheduleId)
614+
await runJobLane(new Date())
606615

607616
expect(await runsOf(scheduleId)).toEqual([
608617
expect.objectContaining({ status: 'complete', outcome: 'met', summary: 'Queue empty.' }),
609618
])
610619
expect(await schedule(scheduleId)).toMatchObject({ status: 'completed', nextRunAt: null })
611620
})
612621

622+
it('gives a recovered run back the spend-cap link its dead process never wrote', async () => {
623+
const { chatId, scheduleId, occurrenceId } = await seedJob()
624+
worker.legs.set(occurrenceId, [{ text: 'Working.', mode: 'hang' }])
625+
await runJobLane(new Date())
626+
await waitFor(async () => worker.open.has(occurrenceId), 'the leg')
627+
const billingRequestId = generateId()
628+
/** Its admission was recorded on the run, then the process died before linking the record. */
629+
await db
630+
.update(copilotRuns)
631+
.set({
632+
requestContext: sql`jsonb_set(${copilotRuns.requestContext}, '{recovery,billingAdmission}', ${JSON.stringify({ billingRequestId, serializedAttribution: 'fixture' })}::jsonb)`,
633+
})
634+
.where(eq(copilotRuns.streamId, occurrenceId))
635+
await db
636+
.update(jobExecutionLogs)
637+
.set({ billingRequestId: null })
638+
.where(eq(jobExecutionLogs.executionId, occurrenceId))
639+
640+
await reconcileJobRuns(new Date())
641+
642+
const [record] = await db
643+
.select({ billingRequestId: jobExecutionLogs.billingRequestId })
644+
.from(jobExecutionLogs)
645+
.where(eq(jobExecutionLogs.scheduleId, scheduleId))
646+
expect(record?.billingRequestId).toBe(billingRequestId)
647+
worker.finish(occurrenceId, chatId)
648+
await reconcileUntilSettled(occurrenceId)
649+
})
650+
651+
it('ends a queued occurrence whose job was deleted, so the lane goes idle', async () => {
652+
const { scheduleId, occurrenceId } = await seedJob()
653+
await claimDueJobOccurrences(new Date(), 10)
654+
await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId))
655+
656+
await reconcileJobRuns(new Date())
657+
658+
const [record] = await db
659+
.select({ status: jobExecutionLogs.status, outcome: jobExecutionLogs.outcome })
660+
.from(jobExecutionLogs)
661+
.where(eq(jobExecutionLogs.executionId, occurrenceId))
662+
expect(record).toEqual({ status: 'complete', outcome: 'blocked' })
663+
await db.delete(jobExecutionLogs).where(eq(jobExecutionLogs.executionId, occurrenceId))
664+
})
665+
613666
it('turns a job off after three failed runs in a row, and a verdict clears the streak', async () => {
614667
const { scheduleId, occurrenceId } = await seedJob()
615668
const fail = (id: string) =>

‎apps/sim/lib/mothership/jobs/lane.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ export function jobLaneWorkQuery(now: Date) {
2727
}
2828

2929
/**
30-
* The schedule tick's job lane: claims due occurrences, starts each as a durable turn, then
31-
* drives every open occurrence (including ones a crashed tick left) towards its end.
30+
* The schedule tick's job lane: drives every open occurrence (including ones a crashed tick
31+
* left) towards its end, then claims due occurrences and starts each as a durable turn. Settling
32+
* first means a run that met its condition, or a third failure that turned its job off, stops
33+
* the job before its next occurrence is claimed.
3234
*
3335
* Jobs run only where their turns are durable: the worker is configured, and Redis holds the
3436
* chat locks that let Sim take a turn over after its process dies. Elsewhere the lane does
@@ -37,6 +39,7 @@ export function jobLaneWorkQuery(now: Date) {
3739
export async function runJobLane(now: Date = new Date()): Promise<{ claimed: number }> {
3840
if (!env.COPILOT_API_KEY || !env.REDIS_URL) return { claimed: 0 }
3941
const [work] = await db.execute<{ due: boolean; open: boolean }>(jobLaneWorkQuery(now))
42+
if (work?.open) await reconcileJobRuns(now)
4043
const claimed = work?.due ? await claimDueJobOccurrences(now, JOB_CLAIM_LIMIT) : []
4144
for (const occurrence of claimed) {
4245
await dispatchJobOccurrence(occurrence.executionId).catch((error) =>
@@ -46,6 +49,5 @@ export async function runJobLane(now: Date = new Date()): Promise<{ claimed: num
4649
})
4750
)
4851
}
49-
if (work?.open || claimed.length > 0) await reconcileJobRuns(now)
5052
return { claimed: claimed.length }
5153
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { nextJobOccurrence } from '@/lib/mothership/jobs/occurrence'
6+
7+
const quarterHourly = {
8+
cronExpression: '*/15 * * * *',
9+
timezone: 'UTC',
10+
excludedDates: null,
11+
endsAt: null,
12+
}
13+
14+
describe('nextJobOccurrence', () => {
15+
it('keeps the next slot when the occurrence is claimed on time', () => {
16+
const occurrenceAt = new Date('2026-10-22T10:00:00Z')
17+
expect(
18+
nextJobOccurrence(quarterHourly, occurrenceAt, new Date('2026-10-22T10:00:30Z'))
19+
).toEqual(new Date('2026-10-22T10:15:00Z'))
20+
})
21+
22+
it('never runs again within the minimum cadence of a late claim', () => {
23+
const occurrenceAt = new Date('2026-10-22T10:00:00Z')
24+
expect(
25+
nextJobOccurrence(quarterHourly, occurrenceAt, new Date('2026-10-22T10:14:00Z'))
26+
).toEqual(new Date('2026-10-22T10:30:00Z'))
27+
})
28+
29+
it('runs a cron tighter than the minimum cadence at most every fifteen minutes', () => {
30+
const everyMinute = { ...quarterHourly, cronExpression: '* * * * *' }
31+
const occurrenceAt = new Date('2026-10-22T10:00:00Z')
32+
expect(nextJobOccurrence(everyMinute, occurrenceAt, occurrenceAt)).toEqual(
33+
new Date('2026-10-22T10:15:00Z')
34+
)
35+
})
36+
})

0 commit comments

Comments
 (0)