stagent 0.9.5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/README.md +5 -42
  2. package/dist/cli.js +42 -18
  3. package/docs/.coverage-gaps.json +13 -55
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/provider-runtimes.md +4 -0
  6. package/docs/features/schedules.md +32 -4
  7. package/docs/features/settings.md +28 -5
  8. package/docs/features/tables.md +9 -2
  9. package/docs/features/workflows.md +10 -4
  10. package/docs/journeys/developer.md +15 -1
  11. package/docs/journeys/personal-use.md +21 -4
  12. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  13. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  14. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  15. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  16. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  17. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  18. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  19. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  20. package/package.json +3 -2
  21. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  22. package/src/app/analytics/page.tsx +1 -21
  23. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  24. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  25. package/src/app/api/instance/config/route.ts +41 -0
  26. package/src/app/api/instance/init/route.ts +34 -0
  27. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  28. package/src/app/api/instance/upgrade/route.ts +96 -0
  29. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  30. package/src/app/api/memory/route.ts +0 -11
  31. package/src/app/api/notifications/route.ts +4 -2
  32. package/src/app/api/projects/[id]/route.ts +5 -155
  33. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  34. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  35. package/src/app/api/schedules/[id]/route.ts +9 -1
  36. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  37. package/src/app/api/schedules/route.ts +3 -12
  38. package/src/app/api/settings/openai/login/route.ts +22 -0
  39. package/src/app/api/settings/openai/logout/route.ts +7 -0
  40. package/src/app/api/settings/openai/route.ts +21 -1
  41. package/src/app/api/settings/providers/route.ts +35 -8
  42. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  43. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  44. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  45. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  46. package/src/app/api/tasks/[id]/execute/route.ts +0 -21
  47. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  48. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  49. package/src/app/api/workspace/context/route.ts +2 -0
  50. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  51. package/src/app/chat/page.tsx +11 -0
  52. package/src/app/inbox/page.tsx +12 -5
  53. package/src/app/layout.tsx +42 -21
  54. package/src/app/page.tsx +0 -2
  55. package/src/app/settings/page.tsx +6 -9
  56. package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
  57. package/src/components/chat/chat-command-popover.tsx +2 -2
  58. package/src/components/chat/chat-input.tsx +2 -3
  59. package/src/components/chat/chat-session-provider.tsx +720 -0
  60. package/src/components/chat/chat-shell.tsx +92 -401
  61. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  62. package/src/components/instance/instance-section.tsx +382 -0
  63. package/src/components/instance/upgrade-badge.tsx +219 -0
  64. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  65. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  66. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  67. package/src/components/notifications/inbox-list.tsx +11 -2
  68. package/src/components/notifications/notification-item.tsx +56 -2
  69. package/src/components/notifications/pending-approval-host.tsx +56 -37
  70. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  71. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  72. package/src/components/schedules/schedule-form.tsx +31 -0
  73. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  74. package/src/components/settings/auth-method-selector.tsx +19 -4
  75. package/src/components/settings/auth-status-badge.tsx +28 -3
  76. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  77. package/src/components/settings/openai-runtime-section.tsx +7 -1
  78. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  79. package/src/components/shared/app-sidebar.tsx +4 -3
  80. package/src/components/shared/command-palette.tsx +4 -5
  81. package/src/components/shared/theme-toggle.tsx +5 -24
  82. package/src/components/shared/workspace-indicator.tsx +61 -2
  83. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  84. package/src/components/tables/table-create-sheet.tsx +4 -0
  85. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  86. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  87. package/src/components/tables/table-spreadsheet.tsx +29 -5
  88. package/src/components/tables/table-toolbar.tsx +10 -1
  89. package/src/components/tasks/kanban-board.tsx +1 -0
  90. package/src/components/tasks/kanban-column.tsx +53 -14
  91. package/src/components/tasks/task-bento-grid.tsx +19 -0
  92. package/src/components/tasks/task-card.tsx +26 -3
  93. package/src/components/tasks/task-chip-bar.tsx +24 -0
  94. package/src/components/tasks/task-result-renderer.tsx +1 -1
  95. package/src/components/workflows/delay-step-body.tsx +109 -0
  96. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  97. package/src/components/workflows/loop-status-view.tsx +1 -1
  98. package/src/components/workflows/shared/step-result.tsx +78 -0
  99. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  100. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  101. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  102. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  103. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  104. package/src/components/workflows/workflow-form-view.tsx +133 -16
  105. package/src/components/workflows/workflow-status-view.tsx +30 -740
  106. package/src/instrumentation-node.ts +94 -0
  107. package/src/instrumentation.ts +4 -48
  108. package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
  109. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  110. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  111. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  112. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  113. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  114. package/src/lib/agents/claude-agent.ts +155 -18
  115. package/src/lib/agents/execution-manager.ts +0 -35
  116. package/src/lib/agents/learned-context.ts +0 -12
  117. package/src/lib/agents/learning-session.ts +18 -5
  118. package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
  119. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
  120. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
  121. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  122. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  123. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  124. package/src/lib/agents/runtime/openai-codex.ts +29 -60
  125. package/src/lib/agents/runtime/types.ts +8 -0
  126. package/src/lib/book/chapter-mapping.ts +11 -0
  127. package/src/lib/book/content.ts +10 -0
  128. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  129. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  130. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  131. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  132. package/src/lib/chat/active-streams.ts +27 -0
  133. package/src/lib/chat/codex-engine.ts +16 -17
  134. package/src/lib/chat/context-builder.ts +5 -3
  135. package/src/lib/chat/engine.ts +50 -3
  136. package/src/lib/chat/reconcile.ts +117 -0
  137. package/src/lib/chat/stagent-tools.ts +1 -0
  138. package/src/lib/chat/stream-telemetry.ts +132 -0
  139. package/src/lib/chat/suggested-prompts.ts +28 -1
  140. package/src/lib/chat/system-prompt.ts +26 -1
  141. package/src/lib/chat/tool-catalog.ts +2 -1
  142. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  143. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  144. package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
  145. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
  146. package/src/lib/chat/tools/document-tools.ts +29 -13
  147. package/src/lib/chat/tools/helpers.ts +39 -0
  148. package/src/lib/chat/tools/notification-tools.ts +9 -5
  149. package/src/lib/chat/tools/project-tools.ts +33 -0
  150. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  151. package/src/lib/chat/tools/table-tools.ts +71 -0
  152. package/src/lib/chat/tools/task-tools.ts +84 -20
  153. package/src/lib/chat/tools/workflow-tools.ts +234 -32
  154. package/src/lib/constants/settings.ts +8 -18
  155. package/src/lib/data/__tests__/clear.test.ts +56 -2
  156. package/src/lib/data/clear.ts +20 -15
  157. package/src/lib/data/delete-project.ts +171 -0
  158. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  159. package/src/lib/db/bootstrap.ts +45 -16
  160. package/src/lib/db/index.ts +5 -0
  161. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  162. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  163. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  164. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  165. package/src/lib/db/migrations/meta/_journal.json +21 -0
  166. package/src/lib/db/schema.ts +68 -23
  167. package/src/lib/environment/workspace-context.ts +13 -1
  168. package/src/lib/import/dedup.ts +4 -54
  169. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  170. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  171. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  172. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  173. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  174. package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
  175. package/src/lib/instance/bootstrap.ts +270 -0
  176. package/src/lib/instance/detect.ts +49 -0
  177. package/src/lib/instance/fingerprint.ts +78 -0
  178. package/src/lib/instance/git-ops.ts +95 -0
  179. package/src/lib/instance/settings.ts +61 -0
  180. package/src/lib/instance/types.ts +77 -0
  181. package/src/lib/instance/upgrade-poller.ts +153 -0
  182. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  183. package/src/lib/notifications/visibility.ts +33 -0
  184. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  185. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  186. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  187. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  188. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  189. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  190. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  191. package/src/lib/schedules/collision-check.ts +105 -0
  192. package/src/lib/schedules/config.ts +53 -0
  193. package/src/lib/schedules/scheduler.ts +232 -13
  194. package/src/lib/schedules/slot-claim.ts +105 -0
  195. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  196. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  197. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  198. package/src/lib/settings/openai-auth.ts +105 -10
  199. package/src/lib/settings/openai-login-manager.ts +260 -0
  200. package/src/lib/settings/runtime-setup.ts +14 -4
  201. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  202. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  203. package/src/lib/tables/enrichment-planner.ts +454 -0
  204. package/src/lib/tables/enrichment.ts +328 -0
  205. package/src/lib/tables/query-builder.ts +5 -2
  206. package/src/lib/tables/trigger-evaluator.ts +3 -2
  207. package/src/lib/theme.ts +71 -0
  208. package/src/lib/usage/ledger.ts +2 -18
  209. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  210. package/src/lib/util/similarity.ts +77 -0
  211. package/src/lib/utils/format-timestamp.ts +24 -0
  212. package/src/lib/utils/stagent-paths.ts +12 -0
  213. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  214. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  215. package/src/lib/validators/blueprint.ts +70 -9
  216. package/src/lib/validators/profile.ts +2 -2
  217. package/src/lib/validators/settings.ts +3 -1
  218. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  219. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  220. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  221. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  222. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  223. package/src/lib/workflows/blueprints/types.ts +10 -2
  224. package/src/lib/workflows/delay.ts +106 -0
  225. package/src/lib/workflows/engine.ts +207 -4
  226. package/src/lib/workflows/loop-executor.ts +349 -24
  227. package/src/lib/workflows/post-action.ts +91 -0
  228. package/src/lib/workflows/types.ts +166 -1
  229. package/src/app/api/license/checkout/route.ts +0 -28
  230. package/src/app/api/license/portal/route.ts +0 -26
  231. package/src/app/api/license/route.ts +0 -89
  232. package/src/app/api/license/usage/route.ts +0 -63
  233. package/src/app/api/marketplace/browse/route.ts +0 -15
  234. package/src/app/api/marketplace/import/route.ts +0 -28
  235. package/src/app/api/marketplace/publish/route.ts +0 -40
  236. package/src/app/api/onboarding/email/route.ts +0 -53
  237. package/src/app/api/settings/telemetry/route.ts +0 -14
  238. package/src/app/api/sync/export/route.ts +0 -54
  239. package/src/app/api/sync/restore/route.ts +0 -37
  240. package/src/app/api/sync/sessions/route.ts +0 -24
  241. package/src/app/auth/callback/route.ts +0 -73
  242. package/src/app/marketplace/page.tsx +0 -19
  243. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  244. package/src/components/marketplace/blueprint-card.tsx +0 -61
  245. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  246. package/src/components/onboarding/email-capture-card.tsx +0 -104
  247. package/src/components/settings/activation-form.tsx +0 -95
  248. package/src/components/settings/cloud-account-section.tsx +0 -147
  249. package/src/components/settings/cloud-sync-section.tsx +0 -155
  250. package/src/components/settings/subscription-section.tsx +0 -410
  251. package/src/components/settings/telemetry-section.tsx +0 -80
  252. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  253. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  254. package/src/components/shared/upgrade-banner.tsx +0 -112
  255. package/src/hooks/use-supabase-auth.ts +0 -79
  256. package/src/lib/billing/email.ts +0 -54
  257. package/src/lib/billing/products.ts +0 -80
  258. package/src/lib/billing/stripe.ts +0 -101
  259. package/src/lib/cloud/supabase-browser.ts +0 -32
  260. package/src/lib/cloud/supabase-client.ts +0 -56
  261. package/src/lib/license/__tests__/features.test.ts +0 -56
  262. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  263. package/src/lib/license/__tests__/manager.test.ts +0 -64
  264. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  265. package/src/lib/license/cloud-validation.ts +0 -60
  266. package/src/lib/license/features.ts +0 -44
  267. package/src/lib/license/key-format.ts +0 -101
  268. package/src/lib/license/limit-check.ts +0 -111
  269. package/src/lib/license/limit-queries.ts +0 -51
  270. package/src/lib/license/manager.ts +0 -345
  271. package/src/lib/license/notifications.ts +0 -59
  272. package/src/lib/license/tier-limits.ts +0 -71
  273. package/src/lib/marketplace/marketplace-client.ts +0 -107
  274. package/src/lib/sync/cloud-sync.ts +0 -235
  275. package/src/lib/telemetry/conversion-events.ts +0 -71
  276. package/src/lib/telemetry/queue.ts +0 -122
  277. package/src/lib/validators/license.ts +0 -33
@@ -0,0 +1,242 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { tasks, schedules, projects, settings } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { randomUUID } from "crypto";
6
+ import { claimSlot, countRunningScheduledSlots } from "../slot-claim";
7
+ import { reapExpiredLeases } from "../slot-claim";
8
+
9
+ function seedProject(): string {
10
+ const id = randomUUID();
11
+ const now = new Date();
12
+ db.insert(projects)
13
+ .values({ id, name: "test", status: "active", createdAt: now, updatedAt: now })
14
+ .run();
15
+ return id;
16
+ }
17
+
18
+ function seedSchedule(projectId: string): string {
19
+ const id = randomUUID();
20
+ const now = new Date();
21
+ db.insert(schedules)
22
+ .values({
23
+ id,
24
+ projectId,
25
+ name: `sched-${id.slice(0, 4)}`,
26
+ prompt: "test",
27
+ cronExpression: "* * * * *",
28
+ status: "active",
29
+ type: "scheduled",
30
+ firingCount: 0,
31
+ suppressionCount: 0,
32
+ heartbeatSpentToday: 0,
33
+ failureStreak: 0,
34
+ turnBudgetBreachStreak: 0,
35
+ createdAt: now,
36
+ updatedAt: now,
37
+ })
38
+ .run();
39
+ return id;
40
+ }
41
+
42
+ function seedQueuedTask(scheduleId: string): string {
43
+ const id = randomUUID();
44
+ const now = new Date();
45
+ db.insert(tasks)
46
+ .values({
47
+ id,
48
+ scheduleId,
49
+ title: "test firing",
50
+ status: "queued",
51
+ priority: 2,
52
+ sourceType: "scheduled",
53
+ resumeCount: 0,
54
+ createdAt: now,
55
+ updatedAt: now,
56
+ })
57
+ .run();
58
+ return id;
59
+ }
60
+
61
+ describe("claimSlot", () => {
62
+ beforeEach(() => {
63
+ db.delete(tasks).run();
64
+ db.delete(schedules).run();
65
+ db.delete(projects).run();
66
+ db.delete(settings).where(eq(settings.key, "schedule.maxConcurrent")).run();
67
+ });
68
+
69
+ it("claims a slot when capacity available, transitioning queued→running", () => {
70
+ const pid = seedProject();
71
+ const sid = seedSchedule(pid);
72
+ const tid = seedQueuedTask(sid);
73
+
74
+ const result = claimSlot(tid, 2, 1200);
75
+
76
+ expect(result.claimed).toBe(true);
77
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
78
+ expect(row?.status).toBe("running");
79
+ expect(row?.slotClaimedAt).not.toBeNull();
80
+ expect(row?.leaseExpiresAt).not.toBeNull();
81
+ });
82
+
83
+ it("refuses to claim when cap=0", () => {
84
+ const pid = seedProject();
85
+ const sid = seedSchedule(pid);
86
+ const tid = seedQueuedTask(sid);
87
+
88
+ const result = claimSlot(tid, 0, 1200);
89
+
90
+ expect(result.claimed).toBe(false);
91
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
92
+ expect(row?.status).toBe("queued");
93
+ });
94
+
95
+ it("refuses when cap already full", () => {
96
+ const pid = seedProject();
97
+ const sid1 = seedSchedule(pid);
98
+ const sid2 = seedSchedule(pid);
99
+ const tid1 = seedQueuedTask(sid1);
100
+ const tid2 = seedQueuedTask(sid2);
101
+
102
+ expect(claimSlot(tid1, 1, 1200).claimed).toBe(true);
103
+ expect(claimSlot(tid2, 1, 1200).claimed).toBe(false);
104
+
105
+ const row2 = db.select().from(tasks).where(eq(tasks.id, tid2)).get();
106
+ expect(row2?.status).toBe("queued");
107
+ });
108
+
109
+ it("two concurrent claim attempts for the same task yield exactly one winner", () => {
110
+ const pid = seedProject();
111
+ const sid = seedSchedule(pid);
112
+ const tid = seedQueuedTask(sid);
113
+
114
+ const first = claimSlot(tid, 10, 1200);
115
+ const second = claimSlot(tid, 10, 1200);
116
+
117
+ expect(first.claimed).toBe(true);
118
+ expect(second.claimed).toBe(false); // task already running, can't re-claim
119
+ });
120
+
121
+ it("respects cap across multiple tasks from different schedules", () => {
122
+ const pid = seedProject();
123
+ const tids: string[] = [];
124
+ for (let i = 0; i < 5; i++) {
125
+ const sid = seedSchedule(pid);
126
+ tids.push(seedQueuedTask(sid));
127
+ }
128
+
129
+ // Cap of 3 → first 3 claim, last 2 fail
130
+ const results = tids.map((tid) => claimSlot(tid, 3, 1200));
131
+ expect(results.filter((r) => r.claimed).length).toBe(3);
132
+ expect(results.filter((r) => !r.claimed).length).toBe(2);
133
+
134
+ expect(countRunningScheduledSlots()).toBe(3);
135
+ });
136
+
137
+ it("countRunningScheduledSlots ignores non-scheduled tasks", () => {
138
+ const pid = seedProject();
139
+ const sid = seedSchedule(pid);
140
+ const schedTid = seedQueuedTask(sid);
141
+ claimSlot(schedTid, 10, 1200);
142
+
143
+ // Insert a manual running task — must not count against scheduled cap
144
+ const manualId = randomUUID();
145
+ const now = new Date();
146
+ db.insert(tasks)
147
+ .values({
148
+ id: manualId,
149
+ title: "manual",
150
+ status: "running",
151
+ priority: 2,
152
+ sourceType: "manual",
153
+ resumeCount: 0,
154
+ createdAt: now,
155
+ updatedAt: now,
156
+ })
157
+ .run();
158
+
159
+ expect(countRunningScheduledSlots()).toBe(1);
160
+ });
161
+
162
+ it("writes leaseExpiresAt = slotClaimedAt + leaseSec", () => {
163
+ const pid = seedProject();
164
+ const sid = seedSchedule(pid);
165
+ const tid = seedQueuedTask(sid);
166
+
167
+ const before = Date.now();
168
+ claimSlot(tid, 10, 60);
169
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
170
+
171
+ expect(row?.slotClaimedAt?.getTime()).toBeGreaterThanOrEqual(before);
172
+ expect(
173
+ row!.leaseExpiresAt!.getTime() - row!.slotClaimedAt!.getTime(),
174
+ ).toBe(60 * 1000);
175
+ });
176
+ });
177
+
178
+ describe("reapExpiredLeases", () => {
179
+ beforeEach(() => {
180
+ db.delete(tasks).run();
181
+ db.delete(schedules).run();
182
+ db.delete(projects).run();
183
+ });
184
+
185
+ it("marks an expired running task as failed with failure_reason=lease_expired", () => {
186
+ const pid = seedProject();
187
+ const sid = seedSchedule(pid);
188
+ const tid = seedQueuedTask(sid);
189
+
190
+ // Claim with a 1-second lease, then fast-forward via direct DB edit
191
+ claimSlot(tid, 10, 1);
192
+ const past = new Date(Date.now() - 5000);
193
+ db.update(tasks)
194
+ .set({ leaseExpiresAt: past })
195
+ .where(eq(tasks.id, tid))
196
+ .run();
197
+
198
+ const reaped = reapExpiredLeases();
199
+
200
+ expect(reaped).toEqual([tid]);
201
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
202
+ expect(row?.status).toBe("failed");
203
+ expect(row?.failureReason).toBe("lease_expired");
204
+ });
205
+
206
+ it("leaves fresh running tasks alone", () => {
207
+ const pid = seedProject();
208
+ const sid = seedSchedule(pid);
209
+ const tid = seedQueuedTask(sid);
210
+
211
+ claimSlot(tid, 10, 3600); // 1-hour lease
212
+
213
+ const reaped = reapExpiredLeases();
214
+
215
+ expect(reaped).toEqual([]);
216
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
217
+ expect(row?.status).toBe("running");
218
+ });
219
+
220
+ it("reaps multiple expired tasks in one sweep", () => {
221
+ const pid = seedProject();
222
+ const tids: string[] = [];
223
+ for (let i = 0; i < 3; i++) {
224
+ const sid = seedSchedule(pid);
225
+ const tid = seedQueuedTask(sid);
226
+ claimSlot(tid, 10, 1);
227
+ tids.push(tid);
228
+ }
229
+ const past = new Date(Date.now() - 5000);
230
+ for (const tid of tids) {
231
+ db.update(tasks)
232
+ .set({ leaseExpiresAt: past })
233
+ .where(eq(tasks.id, tid))
234
+ .run();
235
+ }
236
+
237
+ const reaped = reapExpiredLeases();
238
+
239
+ expect(reaped.sort()).toEqual([...tids].sort());
240
+ expect(countRunningScheduledSlots()).toBe(0);
241
+ });
242
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { tasks, schedules, projects, settings, scheduleFiringMetrics } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { randomUUID } from "crypto";
6
+ import { tickScheduler } from "../scheduler";
7
+ import { registerChatStream, unregisterChatStream } from "@/lib/chat/active-streams";
8
+
9
+ // Stub the runtime — we're testing coordination, not the SDK
10
+ vi.mock("@/lib/agents/runtime", () => ({
11
+ executeTaskWithRuntime: vi.fn().mockResolvedValue(undefined),
12
+ }));
13
+
14
+ function seedProject(): string {
15
+ const id = randomUUID();
16
+ const now = new Date();
17
+ db.insert(projects)
18
+ .values({ id, name: "test", status: "active", createdAt: now, updatedAt: now })
19
+ .run();
20
+ return id;
21
+ }
22
+
23
+ function seedScheduleDue(projectId: string, nextFireAt: Date): string {
24
+ const id = randomUUID();
25
+ const now = new Date();
26
+ db.insert(schedules)
27
+ .values({
28
+ id,
29
+ projectId,
30
+ name: `sched-${id.slice(0, 4)}`,
31
+ prompt: "test prompt",
32
+ cronExpression: "* * * * *",
33
+ status: "active",
34
+ type: "scheduled",
35
+ firingCount: 0,
36
+ suppressionCount: 0,
37
+ heartbeatSpentToday: 0,
38
+ failureStreak: 0,
39
+ turnBudgetBreachStreak: 0,
40
+ nextFireAt,
41
+ createdAt: now,
42
+ updatedAt: now,
43
+ })
44
+ .run();
45
+ return id;
46
+ }
47
+
48
+ describe("tickScheduler with concurrency cap", () => {
49
+ beforeEach(() => {
50
+ db.delete(scheduleFiringMetrics).run();
51
+ db.delete(tasks).run();
52
+ db.delete(schedules).run();
53
+ db.delete(projects).run();
54
+ db.delete(settings).where(eq(settings.key, "schedule.maxConcurrent")).run();
55
+ db.insert(settings)
56
+ .values({ key: "schedule.maxConcurrent", value: "2", updatedAt: new Date() })
57
+ .run();
58
+ for (const id of ["x", "y", "z"]) unregisterChatStream(id);
59
+ });
60
+
61
+ it("fires up to cap schedules, queues the rest", async () => {
62
+ const pid = seedProject();
63
+ const past = new Date(Date.now() - 10_000);
64
+ for (let i = 0; i < 5; i++) seedScheduleDue(pid, past);
65
+
66
+ await tickScheduler();
67
+
68
+ const runningCount = db
69
+ .select()
70
+ .from(tasks)
71
+ .where(eq(tasks.status, "running"))
72
+ .all().length;
73
+ const queuedCount = db
74
+ .select()
75
+ .from(tasks)
76
+ .where(eq(tasks.status, "queued"))
77
+ .all().length;
78
+
79
+ expect(runningCount).toBe(2); // cap=2
80
+ expect(queuedCount).toBe(3); // remaining 3 waiting
81
+ });
82
+
83
+ it("defers new firings when chat is active", async () => {
84
+ const pid = seedProject();
85
+ const past = new Date(Date.now() - 10_000);
86
+ const sid = seedScheduleDue(pid, past);
87
+
88
+ registerChatStream("x");
89
+
90
+ await tickScheduler();
91
+
92
+ // No task should have been created
93
+ const taskCount = db.select().from(tasks).all().length;
94
+ expect(taskCount).toBe(0);
95
+
96
+ // The schedule's next_fire_at should have been pushed forward ≥25s
97
+ const row = db.select().from(schedules).where(eq(schedules.id, sid)).get();
98
+ expect(row?.nextFireAt?.getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
99
+
100
+ unregisterChatStream("x");
101
+ });
102
+ });
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { tasks, schedules, projects, settings, scheduleFiringMetrics } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { randomUUID } from "crypto";
6
+ import { tickScheduler, recordFiringMetrics } from "../scheduler";
7
+
8
+ vi.mock("@/lib/agents/runtime", () => ({
9
+ executeTaskWithRuntime: vi.fn().mockResolvedValue(undefined),
10
+ }));
11
+
12
+ describe("per-schedule turn budget propagation", () => {
13
+ beforeEach(() => {
14
+ db.delete(scheduleFiringMetrics).run();
15
+ db.delete(tasks).run();
16
+ db.delete(schedules).run();
17
+ db.delete(projects).run();
18
+ db.delete(settings).where(eq(settings.key, "schedule.maxConcurrent")).run();
19
+ db.insert(settings)
20
+ .values({ key: "schedule.maxConcurrent", value: "10", updatedAt: new Date() })
21
+ .run();
22
+ });
23
+
24
+ it("copies schedules.max_turns into tasks.max_turns at firing time", async () => {
25
+ const pid = randomUUID();
26
+ const sid = randomUUID();
27
+ const now = new Date();
28
+ db.insert(projects)
29
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
30
+ .run();
31
+ db.insert(schedules)
32
+ .values({
33
+ id: sid,
34
+ projectId: pid,
35
+ name: "bounded",
36
+ prompt: "test",
37
+ cronExpression: "* * * * *",
38
+ status: "active",
39
+ type: "scheduled",
40
+ firingCount: 0,
41
+ suppressionCount: 0,
42
+ heartbeatSpentToday: 0,
43
+ failureStreak: 0,
44
+ turnBudgetBreachStreak: 0,
45
+ nextFireAt: new Date(now.getTime() - 10_000),
46
+ maxTurns: 42,
47
+ createdAt: now,
48
+ updatedAt: now,
49
+ })
50
+ .run();
51
+
52
+ await tickScheduler();
53
+
54
+ const [task] = db.select().from(tasks).where(eq(tasks.scheduleId, sid)).all();
55
+ expect(task?.maxTurns).toBe(42);
56
+ });
57
+
58
+ it("leaves tasks.max_turns null when schedules.max_turns is null", async () => {
59
+ const pid = randomUUID();
60
+ const sid = randomUUID();
61
+ const now = new Date();
62
+ db.insert(projects)
63
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
64
+ .run();
65
+ db.insert(schedules)
66
+ .values({
67
+ id: sid,
68
+ projectId: pid,
69
+ name: "unbounded",
70
+ prompt: "test",
71
+ cronExpression: "* * * * *",
72
+ status: "active",
73
+ type: "scheduled",
74
+ firingCount: 0,
75
+ suppressionCount: 0,
76
+ heartbeatSpentToday: 0,
77
+ failureStreak: 0,
78
+ turnBudgetBreachStreak: 0,
79
+ nextFireAt: new Date(now.getTime() - 10_000),
80
+ createdAt: now,
81
+ updatedAt: now,
82
+ })
83
+ .run();
84
+
85
+ await tickScheduler();
86
+
87
+ const [task] = db.select().from(tasks).where(eq(tasks.scheduleId, sid)).all();
88
+ expect(task?.maxTurns).toBeNull();
89
+ });
90
+ });
91
+
92
+ async function seedBreachedTask(scheduleId: string): Promise<string> {
93
+ const id = randomUUID();
94
+ const now = new Date();
95
+ db.insert(tasks)
96
+ .values({
97
+ id,
98
+ scheduleId,
99
+ title: "firing",
100
+ status: "failed",
101
+ result: "Agent exhausted its turn limit (42 turns used)",
102
+ priority: 2,
103
+ sourceType: "scheduled",
104
+ resumeCount: 0,
105
+ failureReason: "turn_limit_exceeded",
106
+ createdAt: now,
107
+ updatedAt: now,
108
+ })
109
+ .run();
110
+ return id;
111
+ }
112
+
113
+ describe("turn_budget_breach_streak", () => {
114
+ beforeEach(() => {
115
+ db.delete(scheduleFiringMetrics).run();
116
+ db.delete(tasks).run();
117
+ db.delete(schedules).run();
118
+ db.delete(projects).run();
119
+ });
120
+
121
+ it("does NOT increment generic failureStreak on turn-budget breach", async () => {
122
+ const pid = randomUUID();
123
+ const sid = randomUUID();
124
+ const now = new Date();
125
+ db.insert(projects)
126
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
127
+ .run();
128
+ db.insert(schedules)
129
+ .values({
130
+ id: sid,
131
+ projectId: pid,
132
+ name: "bounded",
133
+ prompt: "test",
134
+ cronExpression: "* * * * *",
135
+ status: "active",
136
+ type: "scheduled",
137
+ firingCount: 1,
138
+ suppressionCount: 0,
139
+ heartbeatSpentToday: 0,
140
+ failureStreak: 0,
141
+ turnBudgetBreachStreak: 0,
142
+ maxTurns: 20,
143
+ maxTurnsSetAt: new Date(now.getTime() - 86400_000), // yesterday
144
+ createdAt: now,
145
+ updatedAt: now,
146
+ })
147
+ .run();
148
+
149
+ const tid = await seedBreachedTask(sid);
150
+ await recordFiringMetrics(sid, tid);
151
+
152
+ const row = db.select().from(schedules).where(eq(schedules.id, sid)).get();
153
+ expect(row?.failureStreak).toBe(0);
154
+ expect(row?.turnBudgetBreachStreak).toBe(1);
155
+ });
156
+
157
+ it("applies first-breach grace when maxTurns was set recently", async () => {
158
+ const pid = randomUUID();
159
+ const sid = randomUUID();
160
+ const now = new Date();
161
+ db.insert(projects)
162
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
163
+ .run();
164
+ db.insert(schedules)
165
+ .values({
166
+ id: sid,
167
+ projectId: pid,
168
+ name: "bounded",
169
+ prompt: "test",
170
+ cronExpression: "0 * * * *", // hourly
171
+ status: "active",
172
+ type: "scheduled",
173
+ firingCount: 1,
174
+ suppressionCount: 0,
175
+ heartbeatSpentToday: 0,
176
+ failureStreak: 0,
177
+ turnBudgetBreachStreak: 0,
178
+ maxTurns: 20,
179
+ // maxTurnsSetAt 30 min ago → first firing after edit → grace applies
180
+ maxTurnsSetAt: new Date(now.getTime() - 30 * 60 * 1000),
181
+ createdAt: now,
182
+ updatedAt: now,
183
+ })
184
+ .run();
185
+
186
+ const tid = await seedBreachedTask(sid);
187
+ await recordFiringMetrics(sid, tid);
188
+
189
+ const row = db.select().from(schedules).where(eq(schedules.id, sid)).get();
190
+ expect(row?.turnBudgetBreachStreak).toBe(0); // grace applied
191
+ });
192
+
193
+ it("auto-pauses at turn_budget_breach_streak >= 5", async () => {
194
+ const pid = randomUUID();
195
+ const sid = randomUUID();
196
+ const now = new Date();
197
+ db.insert(projects)
198
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
199
+ .run();
200
+ db.insert(schedules)
201
+ .values({
202
+ id: sid,
203
+ projectId: pid,
204
+ name: "bounded",
205
+ prompt: "test",
206
+ cronExpression: "* * * * *",
207
+ status: "active",
208
+ type: "scheduled",
209
+ firingCount: 5,
210
+ suppressionCount: 0,
211
+ heartbeatSpentToday: 0,
212
+ failureStreak: 0,
213
+ turnBudgetBreachStreak: 4, // next breach trips the threshold
214
+ maxTurns: 20,
215
+ maxTurnsSetAt: new Date(now.getTime() - 86400_000),
216
+ createdAt: now,
217
+ updatedAt: now,
218
+ })
219
+ .run();
220
+
221
+ const tid = await seedBreachedTask(sid);
222
+ await recordFiringMetrics(sid, tid);
223
+
224
+ const row = db.select().from(schedules).where(eq(schedules.id, sid)).get();
225
+ expect(row?.status).toBe("paused");
226
+ expect(row?.turnBudgetBreachStreak).toBe(5);
227
+ });
228
+ });
@@ -0,0 +1,105 @@
1
+ import { db } from "@/lib/db";
2
+ import { schedules } from "@/lib/db/schema";
3
+ import { and, eq, ne } from "drizzle-orm";
4
+ import { expandCronMinutes } from "./interval-parser";
5
+
6
+ const BUCKET_SIZE_MIN = 5;
7
+ const COLLISION_THRESHOLD_TURNS = 3000;
8
+
9
+ export interface CronCollisionWarning {
10
+ type: "cron_collision";
11
+ overlappingSchedules: string[];
12
+ overlappingMinutes: number[];
13
+ estimatedConcurrentSteps: number;
14
+ }
15
+
16
+ /**
17
+ * Check if a candidate cron collides with existing active schedules in the
18
+ * same project inside a 5-minute bucket, weighted by the sum of their
19
+ * avgTurnsPerFiring. Warns only when combined weight exceeds 3000 steps.
20
+ *
21
+ * Passing an excludeScheduleId skips that schedule (for PATCH/PUT flows where a
22
+ * schedule should not collide with its own prior state).
23
+ *
24
+ * Deterministic — runs against nominal cron expansion, not chat-pressure
25
+ * adjusted times.
26
+ */
27
+ export function checkCollision(
28
+ candidateCron: string,
29
+ candidateAvgTurns: number,
30
+ projectId: string | null,
31
+ excludeScheduleId: string | null,
32
+ ): CronCollisionWarning[] {
33
+ let candidateMinutes: number[];
34
+ try {
35
+ candidateMinutes = expandCronMinutes(candidateCron);
36
+ } catch {
37
+ return [];
38
+ }
39
+
40
+ const candidateBuckets = new Set(
41
+ candidateMinutes.map((m) => Math.floor(m / BUCKET_SIZE_MIN)),
42
+ );
43
+
44
+ const conditions = [eq(schedules.status, "active")];
45
+ if (projectId !== null) {
46
+ conditions.push(eq(schedules.projectId, projectId));
47
+ }
48
+ if (excludeScheduleId !== null) {
49
+ conditions.push(ne(schedules.id, excludeScheduleId));
50
+ }
51
+
52
+ const others = db
53
+ .select({
54
+ id: schedules.id,
55
+ name: schedules.name,
56
+ cronExpression: schedules.cronExpression,
57
+ avgTurnsPerFiring: schedules.avgTurnsPerFiring,
58
+ })
59
+ .from(schedules)
60
+ .where(and(...conditions))
61
+ .all();
62
+
63
+ const overlappingNames: string[] = [];
64
+ const overlappingMinutesSet = new Set<number>();
65
+ let totalOtherTurns = 0;
66
+
67
+ for (const other of others) {
68
+ let otherMinutes: number[];
69
+ try {
70
+ otherMinutes = expandCronMinutes(other.cronExpression);
71
+ } catch {
72
+ continue;
73
+ }
74
+ const otherBuckets = new Set(
75
+ otherMinutes.map((m) => Math.floor(m / BUCKET_SIZE_MIN)),
76
+ );
77
+ const sharedBuckets = [...otherBuckets].filter((b) =>
78
+ candidateBuckets.has(b),
79
+ );
80
+ if (sharedBuckets.length > 0) {
81
+ overlappingNames.push(other.name);
82
+ totalOtherTurns += other.avgTurnsPerFiring ?? 0;
83
+ for (const b of sharedBuckets) {
84
+ overlappingMinutesSet.add(b * BUCKET_SIZE_MIN);
85
+ }
86
+ }
87
+ }
88
+
89
+ const combinedTurns = candidateAvgTurns + totalOtherTurns;
90
+ if (
91
+ overlappingNames.length === 0 ||
92
+ combinedTurns < COLLISION_THRESHOLD_TURNS
93
+ ) {
94
+ return [];
95
+ }
96
+
97
+ return [
98
+ {
99
+ type: "cron_collision",
100
+ overlappingSchedules: overlappingNames,
101
+ overlappingMinutes: [...overlappingMinutesSet].sort((a, b) => a - b),
102
+ estimatedConcurrentSteps: combinedTurns,
103
+ },
104
+ ];
105
+ }