dsh-taskboard 0.1.2 → 0.2.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.
@@ -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: {
@@ -56,6 +66,8 @@ export interface ExecutionDeps {
56
66
  mintMessageId?: () => string
57
67
  /** Best-effort session rename (pins the session list title to the task title). */
58
68
  renameSession?: (sessionId: string, title: string) => void
69
+ /** Max concurrently running executions across all tasks (default 3). */
70
+ maxConcurrent?: number
59
71
  }
60
72
 
61
73
  /** Outcome of a run request (immediate; the run settles asynchronously). */
@@ -63,6 +75,11 @@ export type RunRequestResult =
63
75
  | { ok: true; executionId: string; sessionId: string }
64
76
  | { ok: false; error: string }
65
77
 
78
+ /** Outcome of a cancel request. */
79
+ export type CancelRequestResult =
80
+ | { ok: true; executionId: string }
81
+ | { ok: false; error: string }
82
+
66
83
  /** Whether a turn/end payload closed with an error reason. */
67
84
  function isErrorTurnEnd(data: unknown): { message: string } | undefined {
68
85
  if (typeof data !== 'object' || data === null) return undefined
@@ -78,12 +95,19 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
78
95
  return { message }
79
96
  }
80
97
 
98
+ /** One live execution tracked for settlement and cancellation. */
99
+ interface RunEntry {
100
+ sessionId: string
101
+ settle: () => void
102
+ dispose: () => Promise<void>
103
+ }
104
+
81
105
  /**
82
106
  * The execution service.
83
107
  */
84
108
  export class ExecutionService {
85
- /** Execution ids currently settling. */
86
- private readonly settling = new Map<string, () => void>()
109
+ /** Live executions by execution id (settles and cancels remove entries). */
110
+ private readonly runs = new Map<string, RunEntry>()
87
111
 
88
112
  /** @param deps - store + agents + workspaces + events + clock. */
89
113
  constructor(private readonly deps: ExecutionDeps) {
@@ -94,7 +118,7 @@ export class ExecutionService {
94
118
  })
95
119
  }
96
120
 
97
- /** Record a turn failure against the running execution of that session. */
121
+ /** Record a turn failure against the running execution of that session and give the task back. */
98
122
  private noteFailure(sessionId: string, message: string): void {
99
123
  void this.deps.store.mutate('execution-recorded', (ledger) => {
100
124
  for (const task of ledger.tasks) {
@@ -103,6 +127,21 @@ export class ExecutionService {
103
127
  execution.outcome = 'failed'
104
128
  execution.error = message.slice(0, 500)
105
129
  execution.endedAt = this.deps.now()
130
+ // The failed session will not finish the work: hand the task back
131
+ // instead of leaving it stuck in in_progress forever — and leave a
132
+ // system comment so the GUI shows why.
133
+ if (task.status === 'in_progress' && task.claimedBy === sessionId) {
134
+ task.status = 'todo'
135
+ task.updatedAt = this.deps.now()
136
+ delete task.claimedBy
137
+ delete task.claimedAt
138
+ task.comments.push({
139
+ id: newCommentId(),
140
+ body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
141
+ version: 1,
142
+ createdAt: this.deps.now(),
143
+ })
144
+ }
106
145
  return [task]
107
146
  }
108
147
  }
@@ -127,18 +166,24 @@ export class ExecutionService {
127
166
 
128
167
  /**
129
168
  * Run one task now (manual button or scheduler tick).
169
+ *
170
+ * The in-progress gate and the execution-open write happen inside ONE
171
+ * serial-queue mutation, so two overlapping run() calls (double click,
172
+ * overlapping scheduler ticks) can never both pass — exactly one session
173
+ * is opened per task.
130
174
  * @param taskId - the task to run.
131
175
  * @param trigger - what started it.
132
176
  * @returns the immediate result; settlement lands in the ledger.
133
177
  */
134
178
  async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {
179
+ const max = this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT
180
+ if (this.runs.size >= max) {
181
+ return { ok: false, error: `execution concurrency limit reached (${this.runs.size}/${max} running)` }
182
+ }
135
183
  const task = this.deps.store.get(taskId)
136
184
  if (task === undefined || task.trashedAt !== undefined) {
137
185
  return { ok: false, error: `no task ${taskId}` }
138
186
  }
139
- if (task.status === 'in_progress') {
140
- return { ok: false, error: 'task is already in progress' }
141
- }
142
187
  const workspace = this.deps.workspaces.get(task.workspaceId)
143
188
  if (workspace === undefined) {
144
189
  return { ok: false, error: `unknown workspace ${task.workspaceId}` }
@@ -147,10 +192,19 @@ export class ExecutionService {
147
192
  const executionId = newExecutionId()
148
193
  const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
149
194
 
150
- // 1. Open the execution record and move the card to in_progress in one write.
195
+ // 1. Open the execution record, flip the card to in_progress, and record
196
+ // the executing session as the claim holder — atomically.
197
+ let gate: string | undefined
151
198
  await this.deps.store.mutate('execution-recorded', (ledger) => {
152
199
  const target = ledger.tasks.find(t => t.id === taskId)
153
- if (target === undefined) return undefined
200
+ if (target === undefined || target.trashedAt !== undefined) {
201
+ gate = `no task ${taskId}`
202
+ return undefined
203
+ }
204
+ if (target.status === 'in_progress') {
205
+ gate = 'task is already in progress'
206
+ return undefined
207
+ }
154
208
  target.executions.push({
155
209
  id: executionId,
156
210
  trigger,
@@ -160,8 +214,11 @@ export class ExecutionService {
160
214
  target.status = 'in_progress'
161
215
  target.updatedAt = this.deps.now()
162
216
  target.updatedBy = { kind: 'user' }
217
+ target.claimedBy = sessionId
218
+ target.claimedAt = this.deps.now()
163
219
  return [target]
164
220
  })
221
+ if (gate !== undefined) return { ok: false, error: gate }
165
222
 
166
223
  // 2. Create the fresh agent+session inside the task's project, carrying
167
224
  // the pinned model — or the deployment default when unpinned (the
@@ -206,22 +263,44 @@ export class ExecutionService {
206
263
  }
207
264
  handle.agent.followup(message)
208
265
 
209
- // 6. Settlement watcher.
266
+ // 6. Settlement watcher: mark succeeded, release the executing session's
267
+ // hold, and — when the session did NOT follow the handoff protocol —
268
+ // auto-move the card to in_review with a system comment (otherwise a
269
+ // disobedient session would leave it hanging in in_progress forever).
210
270
  const settle = (): void => {
211
- this.settling.delete(executionId)
271
+ this.runs.delete(executionId)
212
272
  void this.deps.store.mutate('execution-recorded', (ledger) => {
213
273
  for (const t of ledger.tasks) {
214
274
  const execution = t.executions.find(e => e.id === executionId)
215
275
  if (execution !== undefined && execution.outcome === 'running') {
276
+ const now = this.deps.now()
216
277
  execution.outcome = 'succeeded'
217
- execution.endedAt = this.deps.now()
278
+ execution.endedAt = now
279
+ if (t.status === 'in_progress' && t.claimedBy === sessionId) {
280
+ delete t.claimedBy
281
+ delete t.claimedAt
282
+ }
283
+ if (t.status === 'in_progress') {
284
+ const commented = t.comments.some(c => c.threadId === sessionId)
285
+ t.comments.push({
286
+ id: newCommentId(),
287
+ body: normalizeBody(commented
288
+ ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
289
+ : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
290
+ version: 1,
291
+ createdAt: now,
292
+ })
293
+ t.status = 'in_review'
294
+ t.updatedAt = now
295
+ t.updatedBy = { kind: 'user' }
296
+ }
218
297
  return [t]
219
298
  }
220
299
  }
221
300
  return undefined
222
301
  })
223
302
  }
224
- this.settling.set(executionId, settle)
303
+ this.runs.set(executionId, { sessionId, settle, dispose: () => handle.dispose() })
225
304
  void handle.agent.whenIdle().then(settle, () => {
226
305
  this.noteFailure(sessionId, 'agent did not reach quiescence')
227
306
  settle()
@@ -230,22 +309,117 @@ export class ExecutionService {
230
309
  return { ok: true, executionId, sessionId }
231
310
  }
232
311
 
233
- /** The prompt text one execution submits (task context + instructions). */
312
+ /** How many executions are currently running (for the concurrency cap). */
313
+ inFlight(): number {
314
+ return this.runs.size
315
+ }
316
+
317
+ /**
318
+ * Cancel the running execution of a task (user action): stop the agent
319
+ * session, mark the execution cancelled, and hand the task back to todo.
320
+ * @param taskId - the task whose execution should be stopped.
321
+ * @returns the immediate result.
322
+ */
323
+ async cancel(taskId: string): Promise<CancelRequestResult> {
324
+ const task = this.deps.store.get(taskId)
325
+ if (task === undefined) return { ok: false, error: `no task ${taskId}` }
326
+ const running = [...task.executions].reverse().find(e => e.outcome === 'running')
327
+ if (running === undefined) return { ok: false, error: 'no running execution' }
328
+
329
+ const entry = this.runs.get(running.id)
330
+ this.runs.delete(running.id)
331
+ // Stop the agent first (best effort): dispose stops the loop, unregisters
332
+ // the agent, and removes its session. A late whenIdle settlement no-ops —
333
+ // the record is no longer 'running'.
334
+ try {
335
+ await entry?.dispose()
336
+ } catch { /* already gone */ }
337
+
338
+ await this.deps.store.mutate('execution-recorded', (ledger) => {
339
+ const target = ledger.tasks.find(t => t.id === taskId)
340
+ if (target === undefined) return undefined
341
+ const execution = target.executions.find(e => e.id === running.id)
342
+ if (execution === undefined || execution.outcome !== 'running') return undefined
343
+ execution.outcome = 'cancelled'
344
+ execution.endedAt = this.deps.now()
345
+ if (target.status === 'in_progress') {
346
+ target.status = 'todo'
347
+ target.updatedAt = this.deps.now()
348
+ delete target.claimedBy
349
+ delete target.claimedAt
350
+ }
351
+ return [target]
352
+ })
353
+ return { ok: true, executionId: running.id }
354
+ }
355
+
356
+ /**
357
+ * Startup reconciliation after a host restart: executions left `running`
358
+ * by the previous process can never settle here (their settlement watchers
359
+ * died with it), so mark them failed and hand their tasks back to todo.
360
+ */
361
+ async reconcile(): Promise<void> {
362
+ await this.deps.store.mutate('execution-recorded', (ledger) => {
363
+ const now = this.deps.now()
364
+ const touched: TaskRecord[] = []
365
+ for (const task of ledger.tasks) {
366
+ let dirty = false
367
+ for (const execution of task.executions) {
368
+ if (execution.outcome === 'running') {
369
+ execution.outcome = 'failed'
370
+ execution.error = 'interrupted by host restart'
371
+ execution.endedAt = now
372
+ dirty = true
373
+ }
374
+ }
375
+ if (!dirty) continue
376
+ if (task.status === 'in_progress') {
377
+ task.status = 'todo'
378
+ task.updatedAt = now
379
+ delete task.claimedBy
380
+ delete task.claimedAt
381
+ }
382
+ touched.push(task)
383
+ }
384
+ return touched.length > 0 ? touched : undefined
385
+ })
386
+ }
387
+
388
+ /**
389
+ * The prompt text one execution submits (task context + instructions).
390
+ * The effective prompt supports two template variables, rendered from the
391
+ * task's own history at submit time (valuable for recurring patrols):
392
+ * `{{lastExecution}}` → the previous execution's trigger/outcome/error;
393
+ * `{{lastComments}}` → the last three comments (who + body).
394
+ */
234
395
  private executionPrompt(task: TaskRecord): string {
235
396
  const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'
236
397
  const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`
237
398
  + `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`
238
399
  + `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}`
400
+ const base = effectivePrompt(task)
401
+ const lastExec = [...task.executions].reverse().find(e => e.outcome !== 'running')
402
+ const lastExecText = lastExec === undefined
403
+ ? '(无)'
404
+ : `${lastExec.trigger} · ${lastExec.outcome}${lastExec.error !== undefined ? ` · ${lastExec.error.slice(0, 200)}` : ''} · ${new Date(lastExec.startedAt ?? 0).toISOString()}`
405
+ const lastCommentsText = task.comments.slice(-3)
406
+ .map(c => `[${c.threadId !== undefined ? 'agent' : 'user'}] ${c.body}`)
407
+ .join('\n') || '(无)'
408
+ const body = base
409
+ .replace(/\{\{lastExecution\}\}/g, lastExecText)
410
+ .replace(/\{\{lastComments\}\}/g, lastCommentsText)
411
+ return `【任务】${task.title}(任务 ID: ${task.id})\n\n${state}\n\n${body}\n\n${tail}`
240
412
  }
241
413
 
242
- /** Move a task back out of in_progress after a failed start. */
414
+ /** Move a task back out of in_progress (and release its hold) after a failed start. */
243
415
  private async revertProgress(taskId: string): Promise<void> {
244
416
  await this.deps.store.mutate('execution-recorded', (ledger) => {
245
417
  const target = ledger.tasks.find(t => t.id === taskId)
246
418
  if (target !== undefined && target.status === 'in_progress') {
247
419
  target.status = 'todo'
248
420
  target.updatedAt = this.deps.now()
421
+ delete target.claimedBy
422
+ delete target.claimedAt
249
423
  return [target]
250
424
  }
251
425
  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. */