dsh-taskboard 0.1.2 → 0.2.1

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.
@@ -10,10 +10,20 @@
10
10
  *
11
11
  * @module dsh-taskboard/host/execution
12
12
  */
13
- import { effectivePrompt, newExecutionId, type ExecutionRecord, type TaskRecord } from '../shared/protocol.ts'
13
+ import {
14
+ effectivePrompt,
15
+ newCommentId,
16
+ newExecutionId,
17
+ normalizeBody,
18
+ type ExecutionRecord,
19
+ type TaskRecord,
20
+ } from '../shared/protocol.ts'
14
21
  import { MessageId } from './sdk.ts'
15
22
  import type { TaskStore } from './store.ts'
16
23
 
24
+ /** Default cap on concurrently running executions (env-overridable). */
25
+ export const DEFAULT_MAX_CONCURRENT = 3
26
+
17
27
  /** Narrow agents face (the registry's create, structurally). */
18
28
  export interface AgentsFace {
19
29
  create(options: {
@@ -24,6 +34,7 @@ export interface AgentsFace {
24
34
  agent: {
25
35
  id: string
26
36
  followup(message: unknown): void
37
+ inject(message: unknown): void
27
38
  whenIdle(): Promise<void>
28
39
  }
29
40
  dispose(): Promise<void>
@@ -56,6 +67,8 @@ export interface ExecutionDeps {
56
67
  mintMessageId?: () => string
57
68
  /** Best-effort session rename (pins the session list title to the task title). */
58
69
  renameSession?: (sessionId: string, title: string) => void
70
+ /** Max concurrently running executions across all tasks (default 3). */
71
+ maxConcurrent?: number
59
72
  }
60
73
 
61
74
  /** Outcome of a run request (immediate; the run settles asynchronously). */
@@ -63,6 +76,11 @@ export type RunRequestResult =
63
76
  | { ok: true; executionId: string; sessionId: string }
64
77
  | { ok: false; error: string }
65
78
 
79
+ /** Outcome of a cancel request. */
80
+ export type CancelRequestResult =
81
+ | { ok: true; executionId: string }
82
+ | { ok: false; error: string }
83
+
66
84
  /** Whether a turn/end payload closed with an error reason. */
67
85
  function isErrorTurnEnd(data: unknown): { message: string } | undefined {
68
86
  if (typeof data !== 'object' || data === null) return undefined
@@ -78,12 +96,19 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
78
96
  return { message }
79
97
  }
80
98
 
99
+ /** One live execution tracked for settlement and cancellation. */
100
+ interface RunEntry {
101
+ sessionId: string
102
+ settle: () => void
103
+ dispose: () => Promise<void>
104
+ }
105
+
81
106
  /**
82
107
  * The execution service.
83
108
  */
84
109
  export class ExecutionService {
85
- /** Execution ids currently settling. */
86
- private readonly settling = new Map<string, () => void>()
110
+ /** Live executions by execution id (settles and cancels remove entries). */
111
+ private readonly runs = new Map<string, RunEntry>()
87
112
 
88
113
  /** @param deps - store + agents + workspaces + events + clock. */
89
114
  constructor(private readonly deps: ExecutionDeps) {
@@ -94,7 +119,7 @@ export class ExecutionService {
94
119
  })
95
120
  }
96
121
 
97
- /** Record a turn failure against the running execution of that session. */
122
+ /** Record a turn failure against the running execution of that session and give the task back. */
98
123
  private noteFailure(sessionId: string, message: string): void {
99
124
  void this.deps.store.mutate('execution-recorded', (ledger) => {
100
125
  for (const task of ledger.tasks) {
@@ -103,6 +128,21 @@ export class ExecutionService {
103
128
  execution.outcome = 'failed'
104
129
  execution.error = message.slice(0, 500)
105
130
  execution.endedAt = this.deps.now()
131
+ // The failed session will not finish the work: hand the task back
132
+ // instead of leaving it stuck in in_progress forever — and leave a
133
+ // system comment so the GUI shows why.
134
+ if (task.status === 'in_progress' && task.claimedBy === sessionId) {
135
+ task.status = 'todo'
136
+ task.updatedAt = this.deps.now()
137
+ delete task.claimedBy
138
+ delete task.claimedAt
139
+ task.comments.push({
140
+ id: newCommentId(),
141
+ body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
142
+ version: 1,
143
+ createdAt: this.deps.now(),
144
+ })
145
+ }
106
146
  return [task]
107
147
  }
108
148
  }
@@ -127,18 +167,24 @@ export class ExecutionService {
127
167
 
128
168
  /**
129
169
  * Run one task now (manual button or scheduler tick).
170
+ *
171
+ * The in-progress gate and the execution-open write happen inside ONE
172
+ * serial-queue mutation, so two overlapping run() calls (double click,
173
+ * overlapping scheduler ticks) can never both pass — exactly one session
174
+ * is opened per task.
130
175
  * @param taskId - the task to run.
131
176
  * @param trigger - what started it.
132
177
  * @returns the immediate result; settlement lands in the ledger.
133
178
  */
134
179
  async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {
180
+ const max = this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT
181
+ if (this.runs.size >= max) {
182
+ return { ok: false, error: `execution concurrency limit reached (${this.runs.size}/${max} running)` }
183
+ }
135
184
  const task = this.deps.store.get(taskId)
136
185
  if (task === undefined || task.trashedAt !== undefined) {
137
186
  return { ok: false, error: `no task ${taskId}` }
138
187
  }
139
- if (task.status === 'in_progress') {
140
- return { ok: false, error: 'task is already in progress' }
141
- }
142
188
  const workspace = this.deps.workspaces.get(task.workspaceId)
143
189
  if (workspace === undefined) {
144
190
  return { ok: false, error: `unknown workspace ${task.workspaceId}` }
@@ -147,10 +193,19 @@ export class ExecutionService {
147
193
  const executionId = newExecutionId()
148
194
  const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
149
195
 
150
- // 1. Open the execution record and move the card to in_progress in one write.
196
+ // 1. Open the execution record, flip the card to in_progress, and record
197
+ // the executing session as the claim holder — atomically.
198
+ let gate: string | undefined
151
199
  await this.deps.store.mutate('execution-recorded', (ledger) => {
152
200
  const target = ledger.tasks.find(t => t.id === taskId)
153
- if (target === undefined) return undefined
201
+ if (target === undefined || target.trashedAt !== undefined) {
202
+ gate = `no task ${taskId}`
203
+ return undefined
204
+ }
205
+ if (target.status === 'in_progress') {
206
+ gate = 'task is already in progress'
207
+ return undefined
208
+ }
154
209
  target.executions.push({
155
210
  id: executionId,
156
211
  trigger,
@@ -160,8 +215,11 @@ export class ExecutionService {
160
215
  target.status = 'in_progress'
161
216
  target.updatedAt = this.deps.now()
162
217
  target.updatedBy = { kind: 'user' }
218
+ target.claimedBy = sessionId
219
+ target.claimedAt = this.deps.now()
163
220
  return [target]
164
221
  })
222
+ if (gate !== undefined) return { ok: false, error: gate }
165
223
 
166
224
  // 2. Create the fresh agent+session inside the task's project, carrying
167
225
  // the pinned model — or the deployment default when unpinned (the
@@ -194,34 +252,65 @@ export class ExecutionService {
194
252
  // 4. Record the session id (execution is really started now).
195
253
  await this.patchExecution(executionId, { sessionId })
196
254
 
197
- // 5. Submit the effective prompt as an ordinary user message and settle
198
- // on quiescence (turn/end errors were already folded by the listener).
199
- // Source `user` (not `plugin`) so the opening message renders as a
200
- // normal user bubble in the conversation, exactly like a typed prompt.
201
- const message = {
255
+ // 5. Submit the opening pair and settle on quiescence (turn/end errors
256
+ // were already folded by the listener). Two messages, ONE turn:
257
+ // - inject() queues the plugin framing line (next-step, no wake); it
258
+ // renders as a plugin context row in the conversation.
259
+ // - followup() queues the card body as a normal user message
260
+ // (next-turn, wakes the driver). At claim time the loop drains ALL
261
+ // next-step messages plus the one next-turn message into a single
262
+ // turn — framing first, then the user bubble.
263
+ handle.agent.inject({
264
+ id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
265
+ role: 'user' as const,
266
+ content: [{ type: 'text' as const, text: this.pluginFraming(task) }],
267
+ source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },
268
+ })
269
+ handle.agent.followup({
202
270
  id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
203
271
  role: 'user' as const,
204
- content: [{ type: 'text' as const, text: this.executionPrompt(task) }],
272
+ content: [{ type: 'text' as const, text: this.userBody(task) }],
205
273
  source: { kind: 'user' as const },
206
- }
207
- handle.agent.followup(message)
274
+ })
208
275
 
209
- // 6. Settlement watcher.
276
+ // 6. Settlement watcher: mark succeeded, release the executing session's
277
+ // hold, and — when the session did NOT follow the handoff protocol —
278
+ // auto-move the card to in_review with a system comment (otherwise a
279
+ // disobedient session would leave it hanging in in_progress forever).
210
280
  const settle = (): void => {
211
- this.settling.delete(executionId)
281
+ this.runs.delete(executionId)
212
282
  void this.deps.store.mutate('execution-recorded', (ledger) => {
213
283
  for (const t of ledger.tasks) {
214
284
  const execution = t.executions.find(e => e.id === executionId)
215
285
  if (execution !== undefined && execution.outcome === 'running') {
286
+ const now = this.deps.now()
216
287
  execution.outcome = 'succeeded'
217
- execution.endedAt = this.deps.now()
288
+ execution.endedAt = now
289
+ if (t.status === 'in_progress' && t.claimedBy === sessionId) {
290
+ delete t.claimedBy
291
+ delete t.claimedAt
292
+ }
293
+ if (t.status === 'in_progress') {
294
+ const commented = t.comments.some(c => c.threadId === sessionId)
295
+ t.comments.push({
296
+ id: newCommentId(),
297
+ body: normalizeBody(commented
298
+ ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
299
+ : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
300
+ version: 1,
301
+ createdAt: now,
302
+ })
303
+ t.status = 'in_review'
304
+ t.updatedAt = now
305
+ t.updatedBy = { kind: 'user' }
306
+ }
218
307
  return [t]
219
308
  }
220
309
  }
221
310
  return undefined
222
311
  })
223
312
  }
224
- this.settling.set(executionId, settle)
313
+ this.runs.set(executionId, { sessionId, settle, dispose: () => handle.dispose() })
225
314
  void handle.agent.whenIdle().then(settle, () => {
226
315
  this.noteFailure(sessionId, 'agent did not reach quiescence')
227
316
  settle()
@@ -230,22 +319,127 @@ export class ExecutionService {
230
319
  return { ok: true, executionId, sessionId }
231
320
  }
232
321
 
233
- /** The prompt text one execution submits (task context + instructions). */
234
- private executionPrompt(task: TaskRecord): string {
235
- const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'
236
- const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`
237
- + `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`
238
- + `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`
239
- return `【任务】${task.title}(任务 ID: ${task.id})\n\n${state}\n\n${effectivePrompt(task)}\n\n${tail}`
322
+ /** How many executions are currently running (for the concurrency cap). */
323
+ inFlight(): number {
324
+ return this.runs.size
325
+ }
326
+
327
+ /**
328
+ * Cancel the running execution of a task (user action): stop the agent
329
+ * session, mark the execution cancelled, and hand the task back to todo.
330
+ * @param taskId - the task whose execution should be stopped.
331
+ * @returns the immediate result.
332
+ */
333
+ async cancel(taskId: string): Promise<CancelRequestResult> {
334
+ const task = this.deps.store.get(taskId)
335
+ if (task === undefined) return { ok: false, error: `no task ${taskId}` }
336
+ const running = [...task.executions].reverse().find(e => e.outcome === 'running')
337
+ if (running === undefined) return { ok: false, error: 'no running execution' }
338
+
339
+ const entry = this.runs.get(running.id)
340
+ this.runs.delete(running.id)
341
+ // Stop the agent first (best effort): dispose stops the loop, unregisters
342
+ // the agent, and removes its session. A late whenIdle settlement no-ops —
343
+ // the record is no longer 'running'.
344
+ try {
345
+ await entry?.dispose()
346
+ } catch { /* already gone */ }
347
+
348
+ await this.deps.store.mutate('execution-recorded', (ledger) => {
349
+ const target = ledger.tasks.find(t => t.id === taskId)
350
+ if (target === undefined) return undefined
351
+ const execution = target.executions.find(e => e.id === running.id)
352
+ if (execution === undefined || execution.outcome !== 'running') return undefined
353
+ execution.outcome = 'cancelled'
354
+ execution.endedAt = this.deps.now()
355
+ if (target.status === 'in_progress') {
356
+ target.status = 'todo'
357
+ target.updatedAt = this.deps.now()
358
+ delete target.claimedBy
359
+ delete target.claimedAt
360
+ }
361
+ return [target]
362
+ })
363
+ return { ok: true, executionId: running.id }
364
+ }
365
+
366
+ /**
367
+ * Startup reconciliation after a host restart: executions left `running`
368
+ * by the previous process can never settle here (their settlement watchers
369
+ * died with it), so mark them failed and hand their tasks back to todo.
370
+ */
371
+ async reconcile(): Promise<void> {
372
+ await this.deps.store.mutate('execution-recorded', (ledger) => {
373
+ const now = this.deps.now()
374
+ const touched: TaskRecord[] = []
375
+ for (const task of ledger.tasks) {
376
+ let dirty = false
377
+ for (const execution of task.executions) {
378
+ if (execution.outcome === 'running') {
379
+ execution.outcome = 'failed'
380
+ execution.error = 'interrupted by host restart'
381
+ execution.endedAt = now
382
+ dirty = true
383
+ }
384
+ }
385
+ if (!dirty) continue
386
+ if (task.status === 'in_progress') {
387
+ task.status = 'todo'
388
+ task.updatedAt = now
389
+ delete task.claimedBy
390
+ delete task.claimedAt
391
+ }
392
+ touched.push(task)
393
+ }
394
+ return touched.length > 0 ? touched : undefined
395
+ })
396
+ }
397
+
398
+ /**
399
+ * The plugin framing line (rendered as a plugin context row): task head,
400
+ * already-claimed state, and the handoff protocol — everything the session
401
+ * must know about the board. The task id appears exactly once (here); the
402
+ * protocol steps below refer to it as 本任务.
403
+ */
404
+ private pluginFraming(task: TaskRecord): string {
405
+ return `【任务看板】${task.title}(ID: ${task.id})\n`
406
+ + `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n`
407
+ + `完成后按序交接:\n`
408
+ + `1. taskboard_get 读取本任务,取得最新 version\n`
409
+ + `2. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\n`
410
+ + `3. taskboard_move 将本任务移至待验收 in_review(带 ifVersion)\n`
411
+ + `若无法完成:留评论说明原因,将任务移回待办 todo。`
412
+ }
413
+
414
+ /**
415
+ * The card body as a normal user bubble: the effective prompt (explicit
416
+ * prompt, else title+description) with template variables resolved from
417
+ * the task's own history at submit time (valuable for recurring patrols):
418
+ * `{{lastExecution}}` → the previous execution's trigger/outcome/error;
419
+ * `{{lastComments}}` → the last three comments (who + body).
420
+ */
421
+ private userBody(task: TaskRecord): string {
422
+ const lastExec = [...task.executions].reverse().find(e => e.outcome !== 'running')
423
+ const lastExecText = lastExec === undefined
424
+ ? '(无)'
425
+ : `${lastExec.trigger} · ${lastExec.outcome}${lastExec.error !== undefined ? ` · ${lastExec.error.slice(0, 200)}` : ''} · ${new Date(lastExec.startedAt ?? 0).toISOString()}`
426
+ const lastCommentsText = task.comments.slice(-3)
427
+ .map(c => `[${c.threadId !== undefined ? 'agent' : 'user'}] ${c.body}`)
428
+ .join('\n') || '(无)'
429
+ return effectivePrompt(task)
430
+ .replace(/\{\{lastExecution\}\}/g, lastExecText)
431
+ .replace(/\{\{lastComments\}\}/g, lastCommentsText)
240
432
  }
241
433
 
242
- /** Move a task back out of in_progress after a failed start. */
434
+ /** Move a task back out of in_progress (and release its hold) after a failed start. */
243
435
  private async revertProgress(taskId: string): Promise<void> {
244
436
  await this.deps.store.mutate('execution-recorded', (ledger) => {
245
437
  const target = ledger.tasks.find(t => t.id === taskId)
246
438
  if (target !== undefined && target.status === 'in_progress') {
247
439
  target.status = 'todo'
248
440
  target.updatedAt = this.deps.now()
441
+ delete target.claimedBy
442
+ delete target.claimedAt
249
443
  return [target]
250
444
  }
251
445
  return undefined
@@ -21,9 +21,12 @@ import {
21
21
  newTaskId,
22
22
  normalizeBody,
23
23
  normalizeExecution,
24
+ normalizeModel,
24
25
  normalizePrompt,
25
26
  normalizeTitle,
26
27
  summarize,
28
+ syncClaim,
29
+ type TaskModel,
27
30
  type TaskRecord,
28
31
  } from '../shared/protocol.ts'
29
32
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
@@ -43,6 +46,23 @@ export interface TaskboardRoutesOptions {
43
46
  now: () => number
44
47
  /** Manual-run hook (the execution service); absent → 501. */
45
48
  run?: (taskId: string) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>
49
+ /** Cancel hook (the execution service); absent → 501. */
50
+ cancel?: (taskId: string) => Promise<{ ok: true; executionId: string } | { ok: false; error: string }>
51
+ /**
52
+ * Registered model provider routes (from the host llm runtime), for
53
+ * advisory validation of pinned models; undefined = runtime unavailable.
54
+ */
55
+ modelProviders?: () => string[] | undefined
56
+ }
57
+
58
+ /** Validate a pinned model: structural check always, provider route when known. */
59
+ function checkModel(raw: unknown, modelProviders?: () => string[] | undefined): TaskModel {
60
+ const model = normalizeModel(raw)
61
+ const providers = modelProviders?.()
62
+ if (providers !== undefined && !providers.includes(model.provider)) {
63
+ throw new Error(`Error: invalid_input: model provider "${model.provider}" has no registered route (available: ${providers.join(', ')})`)
64
+ }
65
+ return model
46
66
  }
47
67
 
48
68
  /** JSON-envelope writer. */
@@ -117,14 +137,6 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
117
137
  }
118
138
  store.subscribe(broadcast)
119
139
 
120
- const taskPath = (id: string, action?: string): RegExp | null => {
121
- const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
122
- const pattern = action === undefined
123
- ? `^${ROUTE_PREFIX}/tasks/${escaped}$`
124
- : `^${ROUTE_PREFIX}/tasks/${escaped}/${action}$`
125
- return new RegExp(pattern)
126
- }
127
-
128
140
  const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
129
141
  try {
130
142
  const url = new URL(req.url ?? '/', 'http://x')
@@ -181,7 +193,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
181
193
  const urgency = asUrgency(str(body, 'urgency') ?? '')
182
194
  const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)
183
195
  const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
184
- const model = body.model as { provider: string; model: string } | undefined
196
+ const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
185
197
  const now = options.now()
186
198
  const task: TaskRecord = {
187
199
  id: newTaskId(),
@@ -245,7 +257,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
245
257
  // The GUI (task owner surface) may edit model/execution; null clears the model.
246
258
  if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
247
259
  if (body.model === null) next.model = undefined
248
- else if (body.model !== undefined) next.model = body.model as { provider: string; model: string }
260
+ else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
249
261
  next.version = task.version + 1
250
262
  next.updatedAt = options.now()
251
263
  next.updatedBy = { kind: 'user' }
@@ -270,6 +282,8 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
270
282
  next.updatedAt = options.now()
271
283
  next.updatedBy = { kind: 'user' }
272
284
  if (task.status === 'todo' && to === 'in_progress') next.blocked = false
285
+ // A user move records no holder; leaving in_progress releases any hold.
286
+ syncClaim(next, to, options.now())
273
287
  await store.mutate('task-moved', ledger => {
274
288
  const i = ledger.tasks.findIndex(t => t.id === id)
275
289
  ledger.tasks[i] = next
@@ -332,6 +346,20 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
332
346
  }
333
347
  return
334
348
  }
349
+ if (action === 'cancel') {
350
+ if (options.cancel === undefined) {
351
+ const f = fail('invalid_input', 'execution service unavailable')
352
+ json(res, f.res, 501)
353
+ return
354
+ }
355
+ const result = await options.cancel(id)
356
+ if (result.ok) json(res, { ok: true, value: { cancelled: true, executionId: result.executionId } }, 202)
357
+ else {
358
+ const f = fail('invalid_input', result.error)
359
+ json(res, f.res, f.status)
360
+ }
361
+ return
362
+ }
335
363
  const f = fail('not_found', `unknown action ${action}`)
336
364
  json(res, f.res, f.status)
337
365
  } catch (error) {
@@ -341,7 +369,6 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
341
369
  return
342
370
  }
343
371
 
344
- void taskPath
345
372
  res.writeHead(404)
346
373
  res.end()
347
374
  } catch (error) {
@@ -9,7 +9,7 @@
9
9
  * @module dsh-taskboard/host/scheduler
10
10
  */
11
11
  import { nextCronTime, parseCron, type TaskLedger } from '../shared/protocol.ts'
12
- import type { ExecutionService } from './execution.ts'
12
+ import { DEFAULT_MAX_CONCURRENT, type ExecutionService } from './execution.ts'
13
13
  import type { TaskStore } from './store.ts'
14
14
 
15
15
  /** Tick cadence. */
@@ -21,8 +21,10 @@ const SKIP_AFTER_MS = 5 * 60_000
21
21
  /** Everything the scheduler needs. */
22
22
  export interface SchedulerDeps {
23
23
  store: TaskStore
24
- execution: Pick<ExecutionService, 'run'>
24
+ execution: Pick<ExecutionService, 'run' | 'inFlight'>
25
25
  now: () => number
26
+ /** Max concurrently running executions (default 3; must match the execution service). */
27
+ maxConcurrent?: number
26
28
  /** Timer face (injectable for tests). */
27
29
  timers?: {
28
30
  setInterval(fn: () => void, ms: number): unknown
@@ -35,6 +37,7 @@ export interface SchedulerDeps {
35
37
  */
36
38
  export class SchedulerService {
37
39
  private handle: unknown
40
+ private catchup: ReturnType<typeof setTimeout> | undefined
38
41
 
39
42
  /** @param deps - store + execution + clock. */
40
43
  constructor(private readonly deps: SchedulerDeps) {}
@@ -46,12 +49,17 @@ export class SchedulerService {
46
49
  clearInterval: (handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>),
47
50
  }
48
51
  this.handle = timers.setInterval(() => { void this.tick() }, TICK_MS)
49
- // Catch up promptly on host restart: run one tick soon after start.
50
- setTimeout(() => { void this.tick() }, 3_000)
52
+ // Catch up promptly on host restart: run one tick soon after start. The
53
+ // handle is cleared on dispose so a torn-down scheduler never fires.
54
+ this.catchup = setTimeout(() => { void this.tick() }, 3_000)
51
55
  }
52
56
 
53
57
  /** Stop ticking. */
54
58
  dispose(): void {
59
+ if (this.catchup !== undefined) {
60
+ clearTimeout(this.catchup)
61
+ this.catchup = undefined
62
+ }
55
63
  if (this.handle === undefined) return
56
64
  const timers = this.deps.timers ?? { clearInterval: (h: unknown) => clearInterval(h as ReturnType<typeof setInterval>) }
57
65
  timers.clearInterval(this.handle)
@@ -60,13 +68,21 @@ export class SchedulerService {
60
68
 
61
69
  /** One scheduler pass (exported for tests). */
62
70
  async tick(): Promise<void> {
71
+ // Load once before reading: snapshot() does not trigger a load, and the
72
+ // scheduler may be the first consumer after a host restart (otherwise it
73
+ // would tick over an empty ledger until something else loads it).
74
+ await this.deps.store.load()
63
75
  const now = this.deps.now()
64
76
  const ledger: TaskLedger = this.deps.store.snapshot()
77
+ const atCapacity = this.deps.execution.inFlight() >= (this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT)
65
78
  for (const task of ledger.tasks) {
66
79
  if (task.execution.mode !== 'scheduled' || task.execution.cron === undefined) continue
67
80
  if (task.execution.nextRunAt === undefined) continue
68
81
  if (task.status === 'in_progress' || task.trashedAt !== undefined) continue
69
82
  if (task.execution.nextRunAt > now) continue
83
+ // At the concurrency cap: leave nextRunAt in the past and retry next
84
+ // tick — advancing here would silently burn this window.
85
+ if (atCapacity) continue
70
86
  const missed = now - task.execution.nextRunAt > SKIP_AFTER_MS
71
87
 
72
88
  // Advance the schedule FIRST (idempotent under re-ticks), then run
package/src/host/store.ts CHANGED
@@ -11,6 +11,7 @@ import { dirname, join } from 'node:path'
11
11
  import {
12
12
  LEDGER_SCHEMA_VERSION,
13
13
  emptyLedger,
14
+ pruneExecutions,
14
15
  type TaskLedger,
15
16
  type TaskRecord,
16
17
  } from '../shared/protocol.ts'
@@ -55,7 +56,18 @@ export class TaskStore {
55
56
  const raw = await readFile(this.file, 'utf8')
56
57
  const parsed = JSON.parse(raw) as TaskLedger
57
58
  if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {
58
- this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks: parsed.tasks }
59
+ const tasks = parsed.tasks as TaskRecord[]
60
+ // Migration from pre-claim-field ledgers: an agent-held in_progress
61
+ // task carried its holder in updatedBy — backfill the explicit claim
62
+ // fields so the hold survives user edits (updatedBy is audit-only).
63
+ for (const task of tasks) {
64
+ if (task.status === 'in_progress' && task.claimedBy === undefined
65
+ && task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {
66
+ task.claimedBy = task.updatedBy.sessionId
67
+ task.claimedAt = task.updatedAt
68
+ }
69
+ }
70
+ this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks }
59
71
  }
60
72
  } catch (error) {
61
73
  const code = (error as NodeJS.ErrnoException).code
@@ -70,14 +82,19 @@ export class TaskStore {
70
82
  this.loaded = true
71
83
  }
72
84
 
73
- /** The current immutable snapshot. */
85
+ /**
86
+ * The current snapshot — a deep-frozen clone. Mutating the returned value
87
+ * throws (strict mode) instead of silently bypassing the revision/persist
88
+ * path; internal state is never handed out.
89
+ */
74
90
  snapshot(): TaskLedger {
75
- return this.ledger
91
+ return deepFreeze(structuredClone(this.ledger))
76
92
  }
77
93
 
78
- /** Find a task by id. */
94
+ /** Find a task by id (frozen clone; internal state is never handed out). */
79
95
  get(id: string): TaskRecord | undefined {
80
- return this.ledger.tasks.find(t => t.id === id)
96
+ const task = this.ledger.tasks.find(t => t.id === id)
97
+ return task === undefined ? undefined : deepFreeze(structuredClone(task))
81
98
  }
82
99
 
83
100
  /** Subscribe to committed changes; returns the unsubscribe. */
@@ -103,6 +120,9 @@ export class TaskStore {
103
120
  if (changed === undefined) {
104
121
  return { ledger: this.ledger, changed: [] }
105
122
  }
123
+ // Retention cap: every committed mutation re-checks the touched tasks,
124
+ // so execution history can never grow unbounded (SSE state payload).
125
+ for (const task of changed) pruneExecutions(task)
106
126
  draft.revision += 1
107
127
  const json = JSON.stringify(draft)
108
128
  await persistAtomic(this.file, json)
@@ -118,16 +138,17 @@ export class TaskStore {
118
138
  const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>
119
139
  return result
120
140
  }
141
+ }
121
142
 
122
- /** Persist the current ledger now (used after external reconciliation). */
123
- async flush(kind: LedgerChange['kind'], changed: readonly TaskRecord[]): Promise<void> {
124
- await this.mutate(kind, (ledger) => {
125
- // replace tasks wholesale from the live snapshot objects
126
- const byId = new Map(this.ledger.tasks.map(t => [t.id, t]))
127
- ledger.tasks = ledger.tasks.map(t => byId.get(t.id) ?? t)
128
- return [...changed]
129
- })
143
+ /** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */
144
+ function deepFreeze<T>(value: T): T {
145
+ if (value !== null && typeof value === 'object') {
146
+ if (!Object.isFrozen(value)) Object.freeze(value)
147
+ for (const key of Object.keys(value as Record<string, unknown>)) {
148
+ deepFreeze((value as Record<string, unknown>)[key])
149
+ }
130
150
  }
151
+ return value
131
152
  }
132
153
 
133
154
  /** Atomic file persist: write temp, then rename over the target. */